1use powerio_core::{Destination, Diagnostic, EmitResult, Error, PioModule};
10use powerio_prob::{
11 BalancedOperatingPointFlag, BalancedOperatingPointQuantity, MulticonductorOperatingPointFlag,
12 MulticonductorOperatingPointQuantity, OperatingPoint,
13};
14use powerio_tx::{BalancedNetwork, BranchSolution};
15
16use crate::PioValue;
17
18pub mod codes {
19 powerio_core::diagnostic_codes! {
20 REQUEST_EMIT_UNKNOWN_FORMAT = "REQUEST.EMIT.UNKNOWN_FORMAT", Error,
21 "the requested target format name is not recognized", category = Request;
22 REQUEST_EMIT_UNSUPPORTED_VALUE_TYPE = "REQUEST.EMIT.UNSUPPORTED_VALUE_TYPE", Error,
23 "the module's value type cannot be emitted in the requested format", category = Request;
24 EMIT_CALCULATION_DATA_OMITTED = "EMIT.CALCULATION.DATA_OMITTED", Warning,
25 "the target grid exchange format does not represent the complete calculation definition", category = Output;
26 EMIT_SOLUTION_DATA_OMITTED = "EMIT.SOLUTION.DATA_OMITTED", Warning,
27 "the target grid exchange format does not represent every solution result", category = Output;
28 EMIT_RELAXATION_DATA_OMITTED = "EMIT.RELAXATION.DATA_OMITTED", Warning,
29 "the target grid exchange format does not represent an SOCWR relaxation result", category = Output;
30 EMIT_OPERATING_POINT_DATA_OMITTED = "EMIT.OPERATING_POINT.DATA_OMITTED", Warning,
31 "the target grid exchange format does not represent every operating point quantity", category = Output;
32 }
33}
34
35fn is_pypsa_dir(format: &str) -> bool {
37 crate::resolve_format(format).is_some_and(|info| info.token == "pypsa-csv")
38}
39
40fn is_cgmes_dir(format: &str) -> bool {
41 crate::resolve_format(format).is_some_and(|info| info.token == "cgmes")
42}
43
44fn is_goc3(format: &str) -> bool {
45 crate::resolve_format(format).is_some_and(|info| info.token == "goc3-json")
46}
47
48fn is_gridfm_dir(format: &str) -> bool {
50 crate::resolve_format(format).is_some_and(|info| info.token == "gridfm")
51}
52
53fn same_directory_format(source: &str, requested: &str) -> bool {
58 if is_pypsa_dir(source) && is_pypsa_dir(requested) {
59 return true;
60 }
61 if is_cgmes_dir(source) && is_cgmes_dir(requested) {
62 return true;
63 }
64 #[cfg(feature = "gridfm")]
65 if source.eq_ignore_ascii_case("gridfm") && requested.eq_ignore_ascii_case("gridfm") {
66 return true;
67 }
68 false
69}
70
71fn echo_retained_directory(
77 module: &PioModule<PioValue>,
78 format: &str,
79) -> Result<Option<Vec<powerio_core::MemoryArtifact>>, Error> {
80 let Some(source) = module.source().filter(|source| source.is_directory()) else {
81 return Ok(None);
82 };
83 let Some(source_format) = source.format() else {
84 return Ok(None);
85 };
86 if !same_directory_format(source_format.as_str(), format) {
87 return Ok(None);
88 }
89
90 let mut artifacts = Vec::new();
91 for name in source.entry_names()? {
92 let buffer = source.buffer(&name)?;
93 artifacts.push(powerio_core::MemoryArtifact::new(
94 name,
95 buffer.bytes().to_vec(),
96 ));
97 }
98 Ok(Some(artifacts))
99}
100
101fn typed_sibling<T>(module: &PioModule<PioValue>, value: T) -> Result<PioModule<T>, Error> {
105 let mut out = PioModule::new(value).with_producer(module.producer().clone());
106 for descriptor in module.sources() {
107 out.add_source_descriptor(descriptor.clone())?;
108 }
109 for entry in module.source_map() {
110 out.add_source_map_entry(entry.clone())?;
111 }
112 for diagnostic in module.diagnostics() {
113 out.add_diagnostic(diagnostic.clone())?;
114 }
115 for entry in module.history() {
116 out.add_history_entry(entry.clone())?;
117 }
118 for (namespace, value) in module.extensions() {
119 out.insert_extension(namespace.clone(), value.clone())?;
120 }
121 Ok(match module.source() {
122 Some(source) => out.with_source(source.clone()),
123 None => out,
124 })
125}
126
127fn retained_source_matches_case_format(module: &PioModule<PioValue>, format: &str) -> bool {
140 use powerio_tx::format::routing::{
141 Detection, JsonClass, classify_format_name, classify_json_text,
142 };
143
144 let Some(source) = module.source() else {
145 return false;
146 };
147 if source.acquired_buffers().len() != 1 {
151 return false;
152 }
153 let Some(requested) = classify_format_name(format).known() else {
154 return false;
155 };
156 let actual = if let Some(declared) = source.format() {
157 let Some(actual) = classify_format_name(declared.as_str()).known() else {
158 return false;
159 };
160 actual
161 } else {
162 let Ok(buffer) = source.primary_buffer() else {
163 return false;
164 };
165 let Ok(text) = std::str::from_utf8(buffer.content_bytes()) else {
166 return false;
167 };
168 match classify_json_text(text) {
169 JsonClass::Case(Detection::Known(found)) => found,
170 _ => return false,
171 }
172 };
173 requested == actual
174}
175
176fn echo_retained_source(module: &PioModule<PioValue>, format: &str) -> Option<Vec<u8>> {
177 if !retained_source_matches_case_format(module, format) {
178 return None;
179 }
180 let source = module.source()?;
181 let buffer = source.primary_buffer().ok()?;
182 Some(buffer.bytes().to_vec())
183}
184
185fn unsupported_type(module: &PioModule<PioValue>, format: &str) -> Error {
186 Error::new(
187 &codes::REQUEST_EMIT_UNSUPPORTED_VALUE_TYPE,
188 format!(
189 "a {} module cannot be emitted as {format}; serialize writes PowerIO IR",
190 module.value().type_name()
191 ),
192 )
193}
194
195fn unknown_format(format: &str) -> Error {
196 if cfg!(not(feature = "gridfm")) && is_gridfm_dir(format) {
199 return Error::new(
200 &codes::REQUEST_EMIT_UNKNOWN_FORMAT,
201 format!(
202 "{format} names the GridFM Parquet directory format, which this build compiled without the `gridfm` feature"
203 ),
204 );
205 }
206 Error::new(
207 &codes::REQUEST_EMIT_UNKNOWN_FORMAT,
208 format!("{format} is not a recognized target format name"),
209 )
210}
211
212#[derive(Clone, Debug, Default)]
215#[non_exhaustive]
216pub struct EmitOptions {
217 #[cfg(feature = "sincal")]
220 pub sincal_experimental: Option<crate::SincalExperimentalOptions>,
221}
222
223pub fn emit<T>(
241 module: &PioModule<T>,
242 format: &str,
243 output: impl powerio_core::IntoDestination,
244) -> Result<EmitResult, Error>
245where
246 T: Clone + Into<PioValue>,
247{
248 emit_with_options(module, format, &EmitOptions::default(), output)
249}
250
251pub fn emit_with_options<T>(
259 module: &PioModule<T>,
260 format: &str,
261 options: &EmitOptions,
262 output: impl powerio_core::IntoDestination,
263) -> Result<EmitResult, Error>
264where
265 T: Clone + Into<PioValue>,
266{
267 let module = module.clone().map_value(Into::into);
268 let destination = output.into_destination()?;
269 #[cfg(feature = "sincal")]
270 if let Some(options) = &options.sincal_experimental {
271 return crate::sincal_write::emit(&module, format, options, destination);
272 }
273 #[cfg(not(feature = "sincal"))]
275 let _: &EmitOptions = options;
276 let result = emit_dynamic(&module, format, destination)?;
277 let from_sincal = module.sources().iter().any(|source| {
278 source
279 .format()
280 .is_some_and(|f| is_sincal_format(f.as_str()))
281 });
282 if from_sincal
283 && !is_sincal_format(format)
284 && !result.diagnostics().iter().any(|d| {
285 matches!(
286 d.code(),
287 "EMIT.SINCAL.RETAINED_SOURCE_OMITTED" | "EMIT.DIST.SINCAL_RETAINED_SOURCE_OMITTED"
288 )
289 })
290 {
291 Ok(result.__with_diagnostics(vec![Diagnostic::of(
292 &powerio_tx::diagnostics::codes::EMIT_SINCAL_RETAINED_SOURCE_OMITTED,
293 "Cross-format emission includes the typed electrical profile; fault, dynamic, protection, diagram, result and other native source-only data are omitted.",
294 )]))
295 } else {
296 Ok(result)
297 }
298}
299
300fn calculation_data_omitted(value_type: &str, format: &str) -> Diagnostic {
301 Diagnostic::of(
302 &codes::EMIT_CALCULATION_DATA_OMITTED,
303 format!(
304 "{format} represents a grid case, not the complete {value_type}; emitted its electrical network"
305 ),
306 )
307}
308
309fn solution_data_omitted(value_type: &str, format: &str) -> Diagnostic {
310 Diagnostic::of(
311 &codes::EMIT_SOLUTION_DATA_OMITTED,
312 format!(
313 "{format} cannot represent every {value_type} result; emitted the network values it supports, while PowerIO IR retains the complete solution"
314 ),
315 )
316}
317
318fn relaxation_data_omitted(format: &str) -> Diagnostic {
319 Diagnostic::of(
320 &codes::EMIT_RELAXATION_DATA_OMITTED,
321 format!(
322 "{format} cannot represent SOCWR W-space values or the objective lower bound; emitted the instance network without treating the relaxation as an AC power flow solution"
323 ),
324 )
325}
326
327fn operating_point_data_omitted(format: &str) -> Diagnostic {
328 Diagnostic::of(
329 &codes::EMIT_OPERATING_POINT_DATA_OMITTED,
330 format!(
331 "{format} has no source-neutral field for net bus injection columns; emitted the other operating point quantities"
332 ),
333 )
334}
335
336pub(crate) fn network_with_balanced_operating_point(
337 point: &OperatingPoint<BalancedNetwork>,
338 format: &str,
339) -> (BalancedNetwork, Vec<Diagnostic>) {
340 let mut network = point.network().clone();
341 let mut overrides_electrical_state = false;
342
343 if let Some(values) = point.values(BalancedOperatingPointQuantity::BusVoltageMagnitude) {
344 overrides_electrical_state = true;
345 for (bus, (_, value)) in network.buses_mut().iter_mut().zip(values) {
346 bus.vm = value;
347 }
348 }
349 if let Some(values) = point.values(BalancedOperatingPointQuantity::BusVoltageAngle) {
350 overrides_electrical_state = true;
351 for (bus, (_, value)) in network.buses_mut().iter_mut().zip(values) {
352 bus.va = value.to_degrees();
353 }
354 }
355 if let Some(values) = point.values(BalancedOperatingPointQuantity::GeneratorActivePower) {
356 overrides_electrical_state = true;
357 for (generator, (_, value)) in network.generators_mut().iter_mut().zip(values) {
358 generator.pg = value;
359 }
360 }
361 if let Some(values) = point.values(BalancedOperatingPointQuantity::GeneratorReactivePower) {
362 overrides_electrical_state = true;
363 for (generator, (_, value)) in network.generators_mut().iter_mut().zip(values) {
364 generator.qg = value;
365 }
366 }
367 if let Some(values) = point.values(BalancedOperatingPointQuantity::GeneratorVoltageSetpoint) {
368 overrides_electrical_state = true;
369 for (generator, (_, value)) in network.generators_mut().iter_mut().zip(values) {
370 generator.vg = value;
371 }
372 }
373 if let Some(flags) = point.flags(BalancedOperatingPointFlag::GeneratorInService) {
374 overrides_electrical_state = true;
375 for (generator, (_, value)) in network.generators_mut().iter_mut().zip(flags) {
376 generator.in_service = value;
377 }
378 }
379 if let Some(values) = point.values(BalancedOperatingPointQuantity::LoadActivePower) {
380 overrides_electrical_state = true;
381 for (load, (_, value)) in network.loads_mut().iter_mut().zip(values) {
382 load.p = value;
383 }
384 }
385 if let Some(values) = point.values(BalancedOperatingPointQuantity::LoadReactivePower) {
386 overrides_electrical_state = true;
387 for (load, (_, value)) in network.loads_mut().iter_mut().zip(values) {
388 load.q = value;
389 }
390 }
391 if let Some(flags) = point.flags(BalancedOperatingPointFlag::BranchInService) {
392 overrides_electrical_state = true;
393 for (branch, (_, value)) in network.branches_mut().iter_mut().zip(flags) {
394 branch.in_service = value;
395 }
396 }
397 if let Some(values) = point.values(BalancedOperatingPointQuantity::BranchTapRatio) {
398 overrides_electrical_state = true;
399 for (branch, (_, value)) in network.branches_mut().iter_mut().zip(values) {
400 branch.tap = value;
401 }
402 }
403 if let Some(values) = point.values(BalancedOperatingPointQuantity::BranchPhaseShift) {
404 overrides_electrical_state = true;
405 for (branch, (_, value)) in network.branches_mut().iter_mut().zip(values) {
406 branch.shift = value;
407 }
408 }
409 if let Some(flags) = point.flags(BalancedOperatingPointFlag::SwitchClosed) {
410 overrides_electrical_state = true;
411 for (switch, (_, value)) in network.switches_mut().iter_mut().zip(flags) {
412 switch.closed = value;
413 }
414 }
415
416 let has_unrepresented_injections = point
417 .values(BalancedOperatingPointQuantity::BusActiveInjection)
418 .is_some()
419 || point
420 .values(BalancedOperatingPointQuantity::BusReactiveInjection)
421 .is_some();
422 let mut diagnostics: Vec<_> = has_unrepresented_injections
423 .then(|| operating_point_data_omitted(format))
424 .into_iter()
425 .collect();
426 if overrides_electrical_state || has_unrepresented_injections {
430 let mut cleared = false;
431 for branch in network.branches_mut() {
432 cleared |= branch.solution.take().is_some();
433 for key in ["pf", "qf", "pt", "qt"] {
434 cleared |= branch.extras.remove(key).is_some();
435 }
436 }
437 if cleared {
438 diagnostics.push(Diagnostic::of(
439 &codes::EMIT_OPERATING_POINT_DATA_OMITTED,
440 format!("{format}: base-network branch results were cleared because operating-point columns supply a new electrical state without branch results"),
441 ));
442 }
443 }
444 (network, diagnostics)
445}
446
447fn network_with_multiconductor_operating_point(
448 point: &OperatingPoint<powerio_dist::MulticonductorNetwork>,
449 format: &str,
450) -> (powerio_dist::MulticonductorNetwork, Vec<Diagnostic>) {
451 let mut network = point.network().clone();
452
453 if let Some(values) = point.values(MulticonductorOperatingPointQuantity::LoadActivePower) {
454 let mut values = values.map(|(_, value)| value);
455 for load in network.loads_mut() {
456 for value in &mut load.p_nom {
457 *value = values
458 .next()
459 .expect("an operating point has one value per load terminal");
460 }
461 }
462 debug_assert!(values.next().is_none());
463 }
464 if let Some(values) = point.values(MulticonductorOperatingPointQuantity::LoadReactivePower) {
465 let mut values = values.map(|(_, value)| value);
466 for load in network.loads_mut() {
467 for value in &mut load.q_nom {
468 *value = values
469 .next()
470 .expect("an operating point has one value per load terminal");
471 }
472 }
473 debug_assert!(values.next().is_none());
474 }
475 if let Some(values) = point.values(MulticonductorOperatingPointQuantity::GeneratorActivePower) {
476 let mut values = values.map(|(_, value)| value);
477 for generator in network.generators_mut() {
478 for value in &mut generator.p_nom {
479 *value = values
480 .next()
481 .expect("an operating point has one value per generator terminal");
482 }
483 }
484 debug_assert!(values.next().is_none());
485 }
486 if let Some(values) = point.values(MulticonductorOperatingPointQuantity::GeneratorReactivePower)
487 {
488 let mut values = values.map(|(_, value)| value);
489 for generator in network.generators_mut() {
490 for value in &mut generator.q_nom {
491 *value = values
492 .next()
493 .expect("an operating point has one value per generator terminal");
494 }
495 }
496 debug_assert!(values.next().is_none());
497 }
498 if let Some(flags) = point.flags(MulticonductorOperatingPointFlag::SwitchClosed) {
499 for (switch, (_, closed)) in network.switches_mut().iter_mut().zip(flags) {
500 switch.open = !closed;
501 }
502 }
503
504 let mut omitted = Vec::new();
505 if point
506 .values(MulticonductorOperatingPointQuantity::TerminalVoltageMagnitude)
507 .is_some()
508 {
509 omitted.push("terminal voltage magnitude");
510 }
511 if point
512 .values(MulticonductorOperatingPointQuantity::TerminalVoltageAngle)
513 .is_some()
514 {
515 omitted.push("terminal voltage angle");
516 }
517 if point
518 .values(MulticonductorOperatingPointQuantity::TransformerTap)
519 .is_some()
520 {
521 omitted.push("transformer tap");
522 }
523 if point
524 .values(MulticonductorOperatingPointQuantity::CapacitorSteps)
525 .is_some()
526 {
527 omitted.push("capacitor steps");
528 }
529 let diagnostics = if omitted.is_empty() {
530 Vec::new()
531 } else {
532 vec![Diagnostic::of(
533 &codes::EMIT_OPERATING_POINT_DATA_OMITTED,
534 format!(
535 "{format} cannot represent these operating point quantities in the source-neutral network model: {}; emitted the other quantities",
536 omitted.join(", ")
537 ),
538 )]
539 };
540 (network, diagnostics)
541}
542
543fn network_with_dc_pf_solution(solution: &powerio_prob::DcPfSolution) -> BalancedNetwork {
544 let mut network = solution.network().clone();
545 for bus in network.buses_mut() {
546 bus.va = solution
547 .bus_voltage_angle(bus.id)
548 .expect("a solution contains one angle per network bus");
549 }
550 if let Some(dispatch) = solution.generator_dispatch() {
551 for (generator, active_power) in network.generators_mut().iter_mut().zip(&dispatch.p_mw) {
552 generator.pg = *active_power;
553 }
554 if !dispatch.q_mvar.is_empty() {
555 for (generator, reactive_power) in
556 network.generators_mut().iter_mut().zip(&dispatch.q_mvar)
557 {
558 generator.qg = *reactive_power;
559 }
560 }
561 }
562 network
563}
564
565fn network_with_ac_pf_solution(solution: &powerio_prob::AcPfSolution) -> BalancedNetwork {
566 let mut network = solution.network().clone();
567 for bus in network.buses_mut() {
568 bus.vm = solution
569 .bus_voltage_magnitude(bus.id)
570 .expect("a solution contains one magnitude per network bus");
571 bus.va = solution
572 .bus_voltage_angle(bus.id)
573 .expect("a solution contains one angle per network bus");
574 }
575 for (branch, identity) in network
576 .branches_mut()
577 .iter_mut()
578 .zip(solution.branch_order())
579 {
580 branch.solution = Some(BranchSolution::new(
581 solution
582 .branch_from_active_flow(&identity)
583 .expect("a solution contains one from active flow per branch"),
584 solution
585 .branch_from_reactive_flow(&identity)
586 .expect("a solution contains one from reactive flow per branch"),
587 solution
588 .branch_to_active_flow(&identity)
589 .expect("a solution contains one to active flow per branch"),
590 solution
591 .branch_to_reactive_flow(&identity)
592 .expect("a solution contains one to reactive flow per branch"),
593 ));
594 }
595 if let Some(dispatch) = solution.generator_dispatch() {
596 for (generator, active_power) in network.generators_mut().iter_mut().zip(&dispatch.p_mw) {
597 generator.pg = *active_power;
598 }
599 if !dispatch.q_mvar.is_empty() {
600 for (generator, reactive_power) in
601 network.generators_mut().iter_mut().zip(&dispatch.q_mvar)
602 {
603 generator.qg = *reactive_power;
604 }
605 }
606 }
607 network
608}
609
610fn network_with_dc_opf_solution(solution: &powerio_prob::DcOpfSolution) -> BalancedNetwork {
611 let mut network = solution.network().clone();
612 for bus in network.buses_mut() {
613 bus.va = solution
614 .bus_voltage_angle(bus.id)
615 .expect("a solution contains one angle per network bus");
616 }
617 for (generator, identity) in network
618 .generators_mut()
619 .iter_mut()
620 .zip(solution.generator_order())
621 {
622 generator.pg = solution
623 .generator_active_power(&identity)
624 .expect("a solution contains one active power per generator");
625 }
626 network
627}
628
629pub(crate) fn network_with_ac_opf_solution(
630 solution: &powerio_prob::AcOpfSolution,
631) -> BalancedNetwork {
632 let mut network = solution.network().clone();
633 for bus in network.buses_mut() {
634 bus.vm = solution
635 .bus_voltage_magnitude(bus.id)
636 .expect("a solution contains one magnitude per network bus");
637 bus.va = solution
638 .bus_voltage_angle(bus.id)
639 .expect("a solution contains one angle per network bus");
640 }
641 for (branch, identity) in network
642 .branches_mut()
643 .iter_mut()
644 .zip(solution.branch_order())
645 {
646 branch.solution = Some(BranchSolution::new(
647 solution
648 .branch_from_active_flow(&identity)
649 .expect("a solution contains one from active flow per branch"),
650 solution
651 .branch_from_reactive_flow(&identity)
652 .expect("a solution contains one from reactive flow per branch"),
653 solution
654 .branch_to_active_flow(&identity)
655 .expect("a solution contains one to active flow per branch"),
656 solution
657 .branch_to_reactive_flow(&identity)
658 .expect("a solution contains one to reactive flow per branch"),
659 ));
660 }
661 for (generator, identity) in network
662 .generators_mut()
663 .iter_mut()
664 .zip(solution.generator_order())
665 {
666 generator.pg = solution
667 .generator_active_power(&identity)
668 .expect("a solution contains one active power per generator");
669 generator.qg = solution
670 .generator_reactive_power(&identity)
671 .expect("a solution contains one reactive power per generator");
672 }
673 network
674}
675
676fn emit_balanced_network(
677 module: &PioModule<PioValue>,
678 network: &BalancedNetwork,
679 format: &str,
680 destination: Destination,
681 preserve_retained_source: bool,
682 diagnostics: Vec<Diagnostic>,
683) -> Result<EmitResult, Error> {
684 let typed = typed_sibling(module, network.clone())?;
685 let typed = if preserve_retained_source && retained_source_matches_case_format(module, format) {
686 typed
687 } else {
688 typed.sever_source()
689 };
690 let result = if is_pypsa_dir(format) {
691 powerio_tx::__emit_pypsa_csv(&typed, destination)
692 } else {
693 #[cfg(feature = "gridfm")]
694 if is_gridfm_dir(format) {
695 let mut diagnostics = diagnostics;
696 let dataset = powerio_matrix::build_gridfm_dataset(
697 network,
698 0,
699 &powerio_matrix::GridfmOptions::default(),
700 )
701 .map_err(|error| Error::new(error.code(), error.to_string()).with_cause(error))?;
702 diagnostics.extend(dataset.diagnostics);
703 return destination.__commit_artifacts(
704 true,
705 powerio_core::Fidelity::Canonical,
706 dataset.artifacts,
707 diagnostics,
708 );
709 }
710 let Some(target) = powerio_tx::format::parse_target_format(format) else {
711 return Err(unknown_format(format));
712 };
713 powerio_tx::emit(&typed, target, destination)
714 }?;
715 Ok(result.__with_diagnostics(diagnostics))
716}
717
718fn emit_multiconductor_network(
719 module: &PioModule<PioValue>,
720 network: powerio_dist::MulticonductorNetwork,
721 format: &str,
722 destination: Destination,
723 preserve_retained_source: bool,
724 diagnostics: Vec<Diagnostic>,
725) -> Result<EmitResult, Error> {
726 let Some(target) = powerio_dist::parse_dist_target_format(format) else {
727 return Err(unknown_format(format));
728 };
729 let typed = typed_sibling(module, network)?;
730 let retained_project = matches!(
731 target,
732 powerio_dist::DistTargetFormat::Dss
733 | powerio_dist::DistTargetFormat::Gridlabd
734 | powerio_dist::DistTargetFormat::GridlabdArras
735 ) && module
736 .source()
737 .and_then(powerio_core::Source::format)
738 .and_then(|id| powerio_dist::parse_dist_target_format(id.as_str()))
739 == Some(target);
740 let typed = if preserve_retained_source
741 && (retained_project || retained_source_matches_case_format(module, format))
742 {
743 typed
744 } else {
745 typed.sever_source()
746 };
747 powerio_dist::emit(&typed, target, destination)
748 .map(|result| result.__with_diagnostics(diagnostics))
749}
750
751fn emit_goc3_solution(
752 solution: &powerio_prob::AcScucSolution,
753 destination: Destination,
754) -> Result<EmitResult, Error> {
755 let text = powerio_prob::__internal::__emit_goc3_output(solution)?;
756 let artifact = powerio_core::MemoryArtifact::new(
757 powerio_core::ArtifactPath::new("solution.json")
758 .expect("static name is a valid artifact path"),
759 text.into_bytes(),
760 );
761 destination.__commit_artifacts(
762 false,
763 powerio_core::Fidelity::Canonical,
764 vec![artifact],
765 Vec::new(),
766 )
767}
768
769fn emit_geo_layer(
773 layer: &powerio_tx::GeoLayer,
774 format: &str,
775 destination: Destination,
776) -> Result<EmitResult, Error> {
777 if !crate::is_geo_layer_token(format) {
778 return Err(if crate::is_pwd_display_token(format) {
781 Error::new(
782 &codes::REQUEST_EMIT_UNSUPPORTED_VALUE_TYPE,
783 format!(
784 "{format} names the PowerWorld display file, which has no writer; write the layer as `geo-json`"
785 ),
786 )
787 } else if known_format_name(format) {
788 Error::new(
789 &codes::REQUEST_EMIT_UNSUPPORTED_VALUE_TYPE,
790 format!(
791 "{format} states a grid case, not powerio.GeoLayer; write a layer as `geo-json` or place it onto a case with apply_geo_layer"
792 ),
793 )
794 } else {
795 unknown_format(format)
796 });
797 }
798 let text = layer
799 .to_geojson_checked()
800 .map_err(|error| Error::new(error.code(), error.to_string()).with_cause(error))?;
801 let artifact = powerio_core::MemoryArtifact::new(
802 powerio_core::ArtifactPath::new(powerio_tx::geo::GEO_LAYER_EXTENSION)
803 .expect("static name is a valid artifact path"),
804 text.into_bytes(),
805 );
806 destination.__commit_artifacts(
807 false,
808 powerio_core::Fidelity::Canonical,
809 vec![artifact],
810 Vec::new(),
811 )
812}
813
814fn contingency_file_of_value(value: &PioValue) -> Option<crate::ContingencyFile> {
817 match value {
818 PioValue::ContingencySet(_) => Some(crate::ContingencyFile::Con),
819 PioValue::SubsystemSet(_) => Some(crate::ContingencyFile::Sub),
820 PioValue::MonitoredSet(_) => Some(crate::ContingencyFile::Mon),
821 _ => None,
822 }
823}
824
825fn retained_contingency_source(module: &PioModule<PioValue>, format: &str) -> Option<Vec<u8>> {
830 let kind = contingency_file_of_value(module.value())?;
831 if crate::contingency_file_of_token(format)? != kind {
832 return None;
833 }
834 let source = module.source()?;
835 if source.acquired_buffers().len() != 1 {
836 return None;
837 }
838 if crate::contingency_file_of_token(source.format()?.as_str())? != kind {
839 return None;
840 }
841 Some(source.primary_buffer().ok()?.bytes().to_vec())
842}
843
844fn emit_contingency_file(
849 value: &PioValue,
850 kind: crate::ContingencyFile,
851 format: &str,
852 destination: Destination,
853) -> Result<EmitResult, Error> {
854 if crate::contingency_file_of_token(format) != Some(kind) {
855 return Err(if crate::contingency_file_of_token(format).is_some() {
856 Error::new(
857 &codes::REQUEST_EMIT_UNSUPPORTED_VALUE_TYPE,
858 format!(
859 "{format} names another PSS/E contingency analysis file, not {}; write this one as `{}`",
860 kind.type_name(),
861 kind.token()
862 ),
863 )
864 } else if known_format_name(format) {
865 Error::new(
866 &codes::REQUEST_EMIT_UNSUPPORTED_VALUE_TYPE,
867 format!(
868 "{format} states a grid case, not {}; write this file as `{}`",
869 kind.type_name(),
870 kind.token()
871 ),
872 )
873 } else {
874 unknown_format(format)
875 });
876 }
877 let text = match value {
878 PioValue::ContingencySet(set) => set.to_con(),
879 PioValue::SubsystemSet(set) => set.to_sub(),
880 PioValue::MonitoredSet(set) => set.to_mon(),
881 _ => unreachable!("the value variant selected the file kind"),
882 };
883 let artifact = powerio_core::MemoryArtifact::new(
884 powerio_core::ArtifactPath::new(kind.artifact_name())
885 .expect("static name is a valid artifact path"),
886 text.into_bytes(),
887 );
888 destination.__commit_artifacts(
889 false,
890 powerio_core::Fidelity::Canonical,
891 vec![artifact],
892 Vec::new(),
893 )
894}
895
896fn balanced_calculation_network(value: &PioValue) -> Option<&BalancedNetwork> {
897 match value {
898 PioValue::DcPfInstance(instance) => Some(instance.network()),
899 PioValue::AcPfInstance(instance) => Some(instance.network()),
900 PioValue::DcOpfInstance(instance) => Some(instance.network()),
901 PioValue::AcOpfInstance(instance) => Some(instance.network()),
902 PioValue::AcScucInstance(instance) => Some(instance.network()),
903 _ => None,
904 }
905}
906
907fn multiconductor_calculation_network(
908 value: &PioValue,
909) -> Option<&powerio_dist::MulticonductorNetwork> {
910 match value {
911 PioValue::McAcPfInstance(instance) => Some(instance.network()),
912 PioValue::McAcOpfInstance(instance) => Some(instance.network()),
913 PioValue::LinDist3FlowPfInstance(instance) => Some(instance.source_network()),
916 PioValue::LinDist3FlowOpfInstance(instance) => Some(instance.source_network()),
917 _ => None,
918 }
919}
920
921fn emit_network_or_calculation(
922 module: &PioModule<PioValue>,
923 format: &str,
924 destination: Destination,
925) -> Result<EmitResult, Error> {
926 if matches!(module.value(), PioValue::AcScucInstance(_)) && is_goc3(format) {
927 return Err(unsupported_type(module, format));
928 }
929 if let Some(network) = balanced_calculation_network(module.value()) {
930 return emit_balanced_network(
931 module,
932 network,
933 format,
934 destination,
935 false,
936 vec![calculation_data_omitted(module.value().type_name(), format)],
937 );
938 }
939 if let Some(network) = multiconductor_calculation_network(module.value()) {
940 return emit_multiconductor_network(
941 module,
942 network.clone(),
943 format,
944 destination,
945 false,
946 vec![calculation_data_omitted(module.value().type_name(), format)],
947 );
948 }
949 match &module.value() {
950 PioValue::BalancedNetwork(network) => {
951 emit_balanced_network(module, network, format, destination, true, Vec::new())
952 }
953 PioValue::MulticonductorNetwork(network) => emit_multiconductor_network(
954 module,
955 network.clone(),
956 format,
957 destination,
958 true,
959 Vec::new(),
960 ),
961 PioValue::BalancedOperatingPoint(point) => {
962 let (network, diagnostics) = network_with_balanced_operating_point(point, format);
963 emit_balanced_network(module, &network, format, destination, false, diagnostics)
964 }
965 PioValue::MulticonductorOperatingPoint(point) => {
966 let (network, diagnostics) = network_with_multiconductor_operating_point(point, format);
967 emit_multiconductor_network(module, network, format, destination, false, diagnostics)
968 }
969 _ => unreachable!("caller selected a value that is not a network or calculation"),
970 }
971}
972
973fn emit_balanced_solution_network(
974 module: &PioModule<PioValue>,
975 network: &BalancedNetwork,
976 format: &str,
977 destination: Destination,
978) -> Result<EmitResult, Error> {
979 emit_balanced_network(
980 module,
981 network,
982 format,
983 destination,
984 false,
985 vec![solution_data_omitted(module.value().type_name(), format)],
986 )
987}
988
989fn emit_multiconductor_solution_network(
990 module: &PioModule<PioValue>,
991 network: &powerio_dist::MulticonductorNetwork,
992 format: &str,
993 destination: Destination,
994) -> Result<EmitResult, Error> {
995 emit_multiconductor_network(
996 module,
997 network.clone(),
998 format,
999 destination,
1000 false,
1001 vec![solution_data_omitted(module.value().type_name(), format)],
1002 )
1003}
1004
1005fn emit_solution(
1006 module: &PioModule<PioValue>,
1007 format: &str,
1008 destination: Destination,
1009) -> Result<EmitResult, Error> {
1010 match &module.value() {
1011 PioValue::DcPfSolution(solution) => emit_balanced_solution_network(
1012 module,
1013 &network_with_dc_pf_solution(solution),
1014 format,
1015 destination,
1016 ),
1017 PioValue::AcPfSolution(solution) => emit_balanced_solution_network(
1018 module,
1019 &network_with_ac_pf_solution(solution),
1020 format,
1021 destination,
1022 ),
1023 PioValue::DcOpfSolution(solution) => emit_balanced_solution_network(
1024 module,
1025 &network_with_dc_opf_solution(solution),
1026 format,
1027 destination,
1028 ),
1029 PioValue::AcOpfSolution(solution) => emit_balanced_solution_network(
1030 module,
1031 &network_with_ac_opf_solution(solution),
1032 format,
1033 destination,
1034 ),
1035 PioValue::SocwrOpfSolution(solution) => emit_balanced_network(
1036 module,
1037 solution.network(),
1038 format,
1039 destination,
1040 false,
1041 vec![relaxation_data_omitted(format)],
1042 ),
1043 PioValue::McAcPfSolution(solution) => {
1044 emit_multiconductor_solution_network(module, solution.network(), format, destination)
1045 }
1046 PioValue::McAcOpfSolution(solution) => {
1047 emit_multiconductor_solution_network(module, solution.network(), format, destination)
1048 }
1049 PioValue::LinDist3FlowPfSolution(solution) => emit_multiconductor_solution_network(
1050 module,
1051 solution.instance().source_network(),
1052 format,
1053 destination,
1054 ),
1055 PioValue::LinDist3FlowOpfSolution(solution) => emit_multiconductor_solution_network(
1056 module,
1057 solution.instance().source_network(),
1058 format,
1059 destination,
1060 ),
1061 PioValue::AcScucSolution(solution) if is_goc3(format) => {
1062 emit_goc3_solution(solution, destination)
1063 }
1064 PioValue::AcScucSolution(solution) => emit_balanced_solution_network(
1065 module,
1066 solution.instance().network(),
1067 format,
1068 destination,
1069 ),
1070 _ => unreachable!("caller selected a value that is not a solution"),
1071 }
1072}
1073
1074fn emit_versioned_bmopf(
1075 module: &PioModule<PioValue>,
1076 version: &str,
1077 destination: Destination,
1078) -> Result<EmitResult, Error> {
1079 let format = format!("bmopf-json@{version}");
1080 let profile = match version {
1081 "0.1.0" => powerio_dist::BmopfSchemaVersion::Bmopf010,
1082 "0.2.0" => powerio_dist::BmopfSchemaVersion::Bmopf020,
1083 _ => return Err(unknown_format(&format)),
1084 };
1085 let (network, diagnostics) = match module.value() {
1086 PioValue::MulticonductorNetwork(network) => (network.clone(), Vec::new()),
1087 PioValue::McAcPfInstance(instance) => (
1088 instance.network().clone(),
1089 vec![calculation_data_omitted(
1090 module.value().type_name(),
1091 &format,
1092 )],
1093 ),
1094 PioValue::McAcOpfInstance(instance) => (
1095 instance.network().clone(),
1096 vec![calculation_data_omitted(
1097 module.value().type_name(),
1098 &format,
1099 )],
1100 ),
1101 PioValue::LinDist3FlowPfInstance(instance) => (
1102 instance.source_network().clone(),
1103 vec![calculation_data_omitted(
1104 module.value().type_name(),
1105 &format,
1106 )],
1107 ),
1108 PioValue::LinDist3FlowOpfInstance(instance) => (
1109 instance.source_network().clone(),
1110 vec![calculation_data_omitted(
1111 module.value().type_name(),
1112 &format,
1113 )],
1114 ),
1115 PioValue::MulticonductorOperatingPoint(point) => {
1116 network_with_multiconductor_operating_point(point, &format)
1117 }
1118 PioValue::McAcPfSolution(solution) => (
1119 solution.instance().network().clone(),
1120 vec![solution_data_omitted(module.value().type_name(), &format)],
1121 ),
1122 PioValue::McAcOpfSolution(solution) => (
1123 solution.instance().network().clone(),
1124 vec![solution_data_omitted(module.value().type_name(), &format)],
1125 ),
1126 PioValue::LinDist3FlowPfSolution(solution) => (
1127 solution.instance().source_network().clone(),
1128 vec![solution_data_omitted(module.value().type_name(), &format)],
1129 ),
1130 PioValue::LinDist3FlowOpfSolution(solution) => (
1131 solution.instance().source_network().clone(),
1132 vec![solution_data_omitted(module.value().type_name(), &format)],
1133 ),
1134 _ => return Err(unsupported_type(module, &format)),
1135 };
1136 let typed = typed_sibling(module, network)?.sever_source();
1137 let mut options = powerio_dist::EmitOptions::default();
1138 options.bmopf.schema_version = profile;
1139 powerio_dist::emit_with_options(
1140 &typed,
1141 powerio_dist::DistTargetFormat::BmopfJson,
1142 &options,
1143 destination,
1144 )
1145 .map(|result| result.__with_diagnostics(diagnostics))
1146}
1147
1148pub(crate) fn is_sincal_format(format: &str) -> bool {
1149 crate::resolve_format(format).is_some_and(|f| {
1150 matches!(
1151 f.token,
1152 "sincal" | "sincal-balanced" | "sincal-multiconductor"
1153 )
1154 })
1155}
1156
1157#[cfg(feature = "sincal")]
1159fn echo_sincal(
1160 module: &PioModule<PioValue>,
1161 format: &str,
1162 destination: Destination,
1163) -> Result<EmitResult, Error> {
1164 if !matches!(
1165 module.value(),
1166 PioValue::BalancedNetwork(_) | PioValue::MulticonductorNetwork(_)
1167 ) {
1168 return Err(unsupported_type(module, "sincal"));
1169 }
1170 let target = crate::resolve_format(format).map(|f| f.token);
1171 if matches!(
1172 (target, module.value()),
1173 (Some("sincal-balanced"), PioValue::MulticonductorNetwork(_))
1174 | (Some("sincal-multiconductor"), PioValue::BalancedNetwork(_))
1175 ) {
1176 return Err(unsupported_type(module, format));
1177 }
1178 let source = module.source().filter(|source| source.format().is_some_and(|f| is_sincal_format(f.as_str())))
1179 .ok_or_else(|| Error::new(&powerio_tx::diagnostics::codes::EMIT_SINCAL_FRESH_UNSUPPORTED,
1180 "SINCAL currently supports unchanged retained-source echo only. Edited, constructed, or IR-restored networks require the separate experimental fresh writer."))?;
1181 let primary = source.primary_buffer()?;
1182 if !primary.bytes().starts_with(b"SQLite format 3\0")
1186 && !primary.bytes().starts_with(b"PK\x03\x04")
1187 && !primary.bytes().starts_with(b"\0\x01\0\0Standard Jet DB\0")
1188 {
1189 return Err(Error::new(
1190 &powerio_tx::diagnostics::codes::EMIT_SINCAL_FRESH_UNSUPPORTED,
1191 "The retained bytes are not a native SINCAL database or archive; source metadata alone cannot enable native echo.",
1192 ));
1193 }
1194 destination.__commit_artifacts(
1195 false,
1196 powerio_core::Fidelity::ExactSameFormat,
1197 vec![powerio_core::MemoryArtifact::new(
1198 powerio_core::ArtifactPath::new("case.sinx")?,
1199 primary.bytes().to_vec(),
1200 )],
1201 Vec::new(),
1202 )
1203}
1204
1205fn emit_egret_series(
1206 module: &PioModule<PioValue>,
1207 series: &crate::PioTimeSeries,
1208 format: &str,
1209 destination: Destination,
1210) -> Result<EmitResult, Error> {
1211 let mut diagnostics = Vec::new();
1212 let networks = series
1213 .iter()
1214 .map(|(_, value)| match value {
1215 PioValue::BalancedNetwork(network) => Ok(network.clone()),
1216 PioValue::BalancedOperatingPoint(point) => {
1217 let (network, findings) = network_with_balanced_operating_point(point, format);
1218 for finding in findings {
1219 if !diagnostics.contains(&finding) {
1220 diagnostics.push(finding);
1221 }
1222 }
1223 Ok(network)
1224 }
1225 _ => Err(unsupported_type(module, format)),
1226 })
1227 .collect::<Result<Vec<_>, Error>>()?;
1228 let typed = powerio_core::TimeSeries::new(series.time_points().to_vec(), networks)?;
1229 powerio_tx::format::__emit_egret_time_series(&typed, destination)
1230 .map(|result| result.__with_diagnostics(diagnostics))
1231}
1232
1233fn emit_dynamic(
1234 module: &PioModule<PioValue>,
1235 format: &str,
1236 destination: Destination,
1237) -> Result<EmitResult, Error> {
1238 if is_sincal_format(format) {
1239 #[cfg(feature = "sincal")]
1240 return echo_sincal(module, format, destination);
1241 #[cfg(not(feature = "sincal"))]
1242 return Err(crate::sincal_feature_disabled());
1243 }
1244 if let Some(version) = format.strip_prefix("bmopf-json@") {
1245 return emit_versioned_bmopf(module, version, destination);
1246 }
1247 if let Some(artifacts) = echo_retained_directory(module, format)? {
1248 return destination.__commit_artifacts(
1249 true,
1250 powerio_core::Fidelity::ExactSameFormat,
1251 artifacts,
1252 Vec::new(),
1253 );
1254 }
1255
1256 if !matches!(
1260 &module.value(),
1261 PioValue::BalancedNetwork(_) | PioValue::MulticonductorNetwork(_)
1262 ) && let Some(bytes) = echo_retained_source(module, format)
1263 {
1264 let artifact = powerio_core::MemoryArtifact::new(
1265 powerio_core::ArtifactPath::new("case").expect("static name is a valid artifact path"),
1266 bytes,
1267 );
1268 return destination.__commit_artifacts(
1269 false,
1270 powerio_core::Fidelity::ExactSameFormat,
1271 vec![artifact],
1272 Vec::new(),
1273 );
1274 }
1275
1276 if let PioValue::GeoLayer(layer) = &module.value() {
1277 return emit_geo_layer(layer, format, destination);
1278 }
1279
1280 if let Some(kind) = contingency_file_of_value(module.value()) {
1281 if let Some(bytes) = retained_contingency_source(module, format) {
1282 let artifact = powerio_core::MemoryArtifact::new(
1283 powerio_core::ArtifactPath::new(kind.artifact_name())
1284 .expect("static name is a valid artifact path"),
1285 bytes,
1286 );
1287 return destination.__commit_artifacts(
1288 false,
1289 powerio_core::Fidelity::ExactSameFormat,
1290 vec![artifact],
1291 Vec::new(),
1292 );
1293 }
1294 return emit_contingency_file(module.value(), kind, format, destination);
1295 }
1296
1297 match &module.value() {
1298 PioValue::TimeSeries(series)
1299 if powerio_tx::format::parse_target_format(format)
1300 == Some(powerio_tx::TargetFormat::EgretJson) =>
1301 {
1302 emit_egret_series(module, series, format, destination)
1303 }
1304 PioValue::BalancedNetwork(_)
1305 | PioValue::MulticonductorNetwork(_)
1306 | PioValue::BalancedOperatingPoint(_)
1307 | PioValue::MulticonductorOperatingPoint(_)
1308 | PioValue::DcPfInstance(_)
1309 | PioValue::AcPfInstance(_)
1310 | PioValue::DcOpfInstance(_)
1311 | PioValue::AcOpfInstance(_)
1312 | PioValue::McAcPfInstance(_)
1313 | PioValue::McAcOpfInstance(_)
1314 | PioValue::LinDist3FlowPfInstance(_)
1315 | PioValue::LinDist3FlowOpfInstance(_)
1316 | PioValue::AcScucInstance(_) => emit_network_or_calculation(module, format, destination),
1317 PioValue::DcPfSolution(_)
1318 | PioValue::AcPfSolution(_)
1319 | PioValue::DcOpfSolution(_)
1320 | PioValue::AcOpfSolution(_)
1321 | PioValue::SocwrOpfSolution(_)
1322 | PioValue::McAcPfSolution(_)
1323 | PioValue::McAcOpfSolution(_)
1324 | PioValue::LinDist3FlowPfSolution(_)
1325 | PioValue::LinDist3FlowOpfSolution(_)
1326 | PioValue::AcScucSolution(_) => emit_solution(module, format, destination),
1327 _ => {
1328 if known_format_name(format) {
1329 Err(unsupported_type(module, format))
1330 } else {
1331 Err(unknown_format(format))
1332 }
1333 }
1334 }
1335}
1336
1337fn known_format_name(format: &str) -> bool {
1340 crate::resolve_format(format).is_some()
1341}
1342
1343#[cfg(test)]
1344mod tests {
1345 use super::*;
1346 use powerio_core::{
1347 DiagnosticCode, DiagnosticSeverity, HistoryEntry, HistoryId, HistoryKind, Producer,
1348 SourceDescriptor, SourceId, SourceMapEntry, SourceRelation, SourceSpan,
1349 };
1350 use powerio_tx::{BalancedNetwork, Bus, BusId, BusType};
1351
1352 #[test]
1353 fn a_typed_writer_sibling_preserves_every_common_record() {
1354 let network = BalancedNetwork::in_memory(
1355 "records",
1356 100.0,
1357 vec![Bus::new(BusId(1), BusType::Ref, 230.0)],
1358 vec![],
1359 );
1360 let source = powerio_core::Source::from_memory("case.m", b"case bytes".to_vec())
1361 .unwrap()
1362 .with_format(powerio_core::FormatId::new("matpower").unwrap());
1363 let source_id = SourceId::new("source-1").unwrap();
1364 let mut module = PioModule::new(PioValue::BalancedNetwork(network.clone()))
1365 .with_producer(Producer::new("records-test", "1").unwrap())
1366 .with_source(source);
1367 module
1368 .add_source_descriptor(SourceDescriptor::new(source_id.clone(), "case.m", 10).unwrap())
1369 .unwrap();
1370 module
1371 .add_source_map_entry(
1372 SourceMapEntry::new(
1373 "/buses/0",
1374 SourceRelation::Exact,
1375 vec![SourceSpan::new(source_id, 0, 4).unwrap()],
1376 )
1377 .unwrap(),
1378 )
1379 .unwrap();
1380 module
1381 .add_diagnostic(powerio_core::Diagnostic::new(
1382 DiagnosticCode::new("READ.TEST.RECORD").unwrap(),
1383 DiagnosticSeverity::Remark,
1384 "record carried to the family writer",
1385 ))
1386 .unwrap();
1387 module
1388 .add_history_entry(
1389 HistoryEntry::new(
1390 HistoryId::new("history-1").unwrap(),
1391 HistoryKind::Parse,
1392 "parse",
1393 )
1394 .unwrap(),
1395 )
1396 .unwrap();
1397 module
1398 .insert_extension("test.writer", serde_json::json!({"kept": true}))
1399 .unwrap();
1400
1401 let sibling = typed_sibling(&module, network).unwrap();
1402 assert_eq!(sibling.producer(), module.producer());
1403 assert_eq!(sibling.sources(), module.sources());
1404 assert_eq!(sibling.source_map(), module.source_map());
1405 assert_eq!(sibling.diagnostics(), module.diagnostics());
1406 assert_eq!(sibling.history(), module.history());
1407 assert_eq!(sibling.extensions(), module.extensions());
1408 let sibling_source = sibling.source().unwrap();
1409 assert_eq!(sibling_source.name(), "case.m");
1410 assert_eq!(
1411 sibling_source.primary_buffer().unwrap().bytes(),
1412 b"case bytes"
1413 );
1414 }
1415}