Skip to main content

powerio/
write.rs

1//! The module emission dispatcher. One dynamic module emits a named target
2//! format; the concrete value routes to the family emitter that owns it.
3//!
4//! Same format emission keeps the family writers' echo tier: an unchanged
5//! parsed module emits its retained source bytes exactly. Cross format
6//! emission serializes the typed value and reports what the target cannot
7//! represent through the returned diagnostics.
8
9use 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
35/// True when `format` names the PyPSA CSV folder target.
36fn 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
48/// True when `format` names the GridFM directory target.
49fn is_gridfm_dir(format: &str) -> bool {
50    crate::resolve_format(format).is_some_and(|info| info.token == "gridfm")
51}
52
53/// True when two names identify the same supported directory format. The
54/// retained source carries the format selected by the parser; this comparison
55/// admits the public aliases at emission without treating an arbitrary
56/// declared directory token as an echoable format.
57fn 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
71/// The exact artifact inventory of an unchanged directory source when the
72/// requested format is that source's format. Entry names come from the
73/// source's bounded portable directory walk, and every byte is acquired
74/// through the source's confined, no-symlink path rather than by reopening a
75/// caller-controlled path here.
76fn 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
101/// A typed sibling module over `value` carrying every common record and the
102/// retained source from the dynamic module. Source descriptors are added
103/// first because source map and diagnostic spans validate against them.
104fn 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
127/// The module's retained source's exact original bytes, when that source's
128/// content is already `format`: the byte exact echo tier every kind should
129/// get, not just [`PioValue::BalancedNetwork`] and
130/// [`PioValue::MulticonductorNetwork`] (whose family emitters already carry
131/// it through `powerio_tx::emit` and `powerio_dist::emit`).
132/// `None` when there is no retained source, or its content is not `format`.
133///
134/// A source declared with an explicit format token (the C ABI and bindings
135/// route this way) is trusted directly; a source routed here by content (a
136/// bare `.json` file with no declared format, the common case for the CLI
137/// and a plain `Source::open`) is reclassified the same way `powerio::parse`'s
138/// own routing already did once to land on this kind in the first place.
139fn 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    // A one file representation can echo its primary buffer. A source set
148    // cannot: its primary file is only one input to the typed value (for
149    // example, the problem file beside a GO Challenge 3 solution).
150    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    // The descriptor still resolves the token in a build without the GridFM
197    // emitter, so the refusal names the missing feature rather than the name.
198    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/// Emit one module as `format` into `output`. The concrete value routes to its
213/// grid exchange format implementation. PowerIO IR uses [`crate::serialize`]
214/// instead.
215///
216/// `output` names the file or directory to write, or is a
217/// [`powerio_core::Destination`], which is how a memory destination and its
218/// artifact root name are given.
219///
220/// # Errors
221/// `REQUEST.EMIT.UNKNOWN_FORMAT` for a format name nothing recognizes,
222/// `REQUEST.EMIT.UNSUPPORTED_VALUE_TYPE` for a value the
223/// named format cannot state, and the family writer's own failure otherwise.
224///
225/// # Panics
226/// Never on external input: the fixed artifact names are valid by
227/// construction, and the solution columns a family writer reads are length
228/// checked by the solution constructors.
229pub fn emit<T>(
230    module: &PioModule<T>,
231    format: &str,
232    output: impl powerio_core::IntoDestination,
233) -> Result<EmitResult, Error>
234where
235    T: Clone + Into<PioValue>,
236{
237    let module = module.clone().map_value(Into::into);
238    emit_dynamic(&module, format, output.into_destination()?)
239}
240
241fn calculation_data_omitted(value_type: &str, format: &str) -> Diagnostic {
242    Diagnostic::of(
243        &codes::EMIT_CALCULATION_DATA_OMITTED,
244        format!(
245            "{format} represents a grid case, not the complete {value_type}; emitted its electrical network"
246        ),
247    )
248}
249
250fn solution_data_omitted(value_type: &str, format: &str) -> Diagnostic {
251    Diagnostic::of(
252        &codes::EMIT_SOLUTION_DATA_OMITTED,
253        format!(
254            "{format} cannot represent every {value_type} result; emitted the network values it supports, while PowerIO IR retains the complete solution"
255        ),
256    )
257}
258
259fn relaxation_data_omitted(format: &str) -> Diagnostic {
260    Diagnostic::of(
261        &codes::EMIT_RELAXATION_DATA_OMITTED,
262        format!(
263            "{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"
264        ),
265    )
266}
267
268fn operating_point_data_omitted(format: &str) -> Diagnostic {
269    Diagnostic::of(
270        &codes::EMIT_OPERATING_POINT_DATA_OMITTED,
271        format!(
272            "{format} has no source-neutral field for net bus injection columns; emitted the other operating point quantities"
273        ),
274    )
275}
276
277pub(crate) fn network_with_balanced_operating_point(
278    point: &OperatingPoint<BalancedNetwork>,
279    format: &str,
280) -> (BalancedNetwork, Vec<Diagnostic>) {
281    let mut network = point.network().clone();
282
283    if let Some(values) = point.values(BalancedOperatingPointQuantity::BusVoltageMagnitude) {
284        for (bus, (_, value)) in network.buses_mut().iter_mut().zip(values) {
285            bus.vm = value;
286        }
287    }
288    if let Some(values) = point.values(BalancedOperatingPointQuantity::BusVoltageAngle) {
289        for (bus, (_, value)) in network.buses_mut().iter_mut().zip(values) {
290            bus.va = value.to_degrees();
291        }
292    }
293    if let Some(values) = point.values(BalancedOperatingPointQuantity::GeneratorActivePower) {
294        for (generator, (_, value)) in network.generators_mut().iter_mut().zip(values) {
295            generator.pg = value;
296        }
297    }
298    if let Some(values) = point.values(BalancedOperatingPointQuantity::GeneratorReactivePower) {
299        for (generator, (_, value)) in network.generators_mut().iter_mut().zip(values) {
300            generator.qg = value;
301        }
302    }
303    if let Some(values) = point.values(BalancedOperatingPointQuantity::GeneratorVoltageSetpoint) {
304        for (generator, (_, value)) in network.generators_mut().iter_mut().zip(values) {
305            generator.vg = value;
306        }
307    }
308    if let Some(flags) = point.flags(BalancedOperatingPointFlag::GeneratorInService) {
309        for (generator, (_, value)) in network.generators_mut().iter_mut().zip(flags) {
310            generator.in_service = value;
311        }
312    }
313    if let Some(values) = point.values(BalancedOperatingPointQuantity::LoadActivePower) {
314        for (load, (_, value)) in network.loads_mut().iter_mut().zip(values) {
315            load.p = value;
316        }
317    }
318    if let Some(values) = point.values(BalancedOperatingPointQuantity::LoadReactivePower) {
319        for (load, (_, value)) in network.loads_mut().iter_mut().zip(values) {
320            load.q = value;
321        }
322    }
323    if let Some(flags) = point.flags(BalancedOperatingPointFlag::BranchInService) {
324        for (branch, (_, value)) in network.branches_mut().iter_mut().zip(flags) {
325            branch.in_service = value;
326        }
327    }
328    if let Some(values) = point.values(BalancedOperatingPointQuantity::BranchTapRatio) {
329        for (branch, (_, value)) in network.branches_mut().iter_mut().zip(values) {
330            branch.tap = value;
331        }
332    }
333    if let Some(values) = point.values(BalancedOperatingPointQuantity::BranchPhaseShift) {
334        for (branch, (_, value)) in network.branches_mut().iter_mut().zip(values) {
335            branch.shift = value;
336        }
337    }
338    if let Some(flags) = point.flags(BalancedOperatingPointFlag::SwitchClosed) {
339        for (switch, (_, value)) in network.switches_mut().iter_mut().zip(flags) {
340            switch.closed = value;
341        }
342    }
343
344    let has_unrepresented_injections = point
345        .values(BalancedOperatingPointQuantity::BusActiveInjection)
346        .is_some()
347        || point
348            .values(BalancedOperatingPointQuantity::BusReactiveInjection)
349            .is_some();
350    let diagnostics = has_unrepresented_injections
351        .then(|| operating_point_data_omitted(format))
352        .into_iter()
353        .collect();
354    (network, diagnostics)
355}
356
357fn network_with_multiconductor_operating_point(
358    point: &OperatingPoint<powerio_dist::MulticonductorNetwork>,
359    format: &str,
360) -> (powerio_dist::MulticonductorNetwork, Vec<Diagnostic>) {
361    let mut network = point.network().clone();
362
363    if let Some(values) = point.values(MulticonductorOperatingPointQuantity::LoadActivePower) {
364        let mut values = values.map(|(_, value)| value);
365        for load in network.loads_mut() {
366            for value in &mut load.p_nom {
367                *value = values
368                    .next()
369                    .expect("an operating point has one value per load terminal");
370            }
371        }
372        debug_assert!(values.next().is_none());
373    }
374    if let Some(values) = point.values(MulticonductorOperatingPointQuantity::LoadReactivePower) {
375        let mut values = values.map(|(_, value)| value);
376        for load in network.loads_mut() {
377            for value in &mut load.q_nom {
378                *value = values
379                    .next()
380                    .expect("an operating point has one value per load terminal");
381            }
382        }
383        debug_assert!(values.next().is_none());
384    }
385    if let Some(values) = point.values(MulticonductorOperatingPointQuantity::GeneratorActivePower) {
386        let mut values = values.map(|(_, value)| value);
387        for generator in network.generators_mut() {
388            for value in &mut generator.p_nom {
389                *value = values
390                    .next()
391                    .expect("an operating point has one value per generator terminal");
392            }
393        }
394        debug_assert!(values.next().is_none());
395    }
396    if let Some(values) = point.values(MulticonductorOperatingPointQuantity::GeneratorReactivePower)
397    {
398        let mut values = values.map(|(_, value)| value);
399        for generator in network.generators_mut() {
400            for value in &mut generator.q_nom {
401                *value = values
402                    .next()
403                    .expect("an operating point has one value per generator terminal");
404            }
405        }
406        debug_assert!(values.next().is_none());
407    }
408    if let Some(flags) = point.flags(MulticonductorOperatingPointFlag::SwitchClosed) {
409        for (switch, (_, closed)) in network.switches_mut().iter_mut().zip(flags) {
410            switch.open = !closed;
411        }
412    }
413
414    let mut omitted = Vec::new();
415    if point
416        .values(MulticonductorOperatingPointQuantity::TerminalVoltageMagnitude)
417        .is_some()
418    {
419        omitted.push("terminal voltage magnitude");
420    }
421    if point
422        .values(MulticonductorOperatingPointQuantity::TerminalVoltageAngle)
423        .is_some()
424    {
425        omitted.push("terminal voltage angle");
426    }
427    if point
428        .values(MulticonductorOperatingPointQuantity::TransformerTap)
429        .is_some()
430    {
431        omitted.push("transformer tap");
432    }
433    if point
434        .values(MulticonductorOperatingPointQuantity::CapacitorSteps)
435        .is_some()
436    {
437        omitted.push("capacitor steps");
438    }
439    let diagnostics = if omitted.is_empty() {
440        Vec::new()
441    } else {
442        vec![Diagnostic::of(
443            &codes::EMIT_OPERATING_POINT_DATA_OMITTED,
444            format!(
445                "{format} cannot represent these operating point quantities in the source-neutral network model: {}; emitted the other quantities",
446                omitted.join(", ")
447            ),
448        )]
449    };
450    (network, diagnostics)
451}
452
453fn network_with_dc_pf_solution(solution: &powerio_prob::DcPfSolution) -> BalancedNetwork {
454    let mut network = solution.network().clone();
455    for bus in network.buses_mut() {
456        bus.va = solution
457            .bus_voltage_angle(bus.id)
458            .expect("a solution contains one angle per network bus");
459    }
460    if let Some(dispatch) = solution.generator_dispatch() {
461        for (generator, active_power) in network.generators_mut().iter_mut().zip(&dispatch.p_mw) {
462            generator.pg = *active_power;
463        }
464        if !dispatch.q_mvar.is_empty() {
465            for (generator, reactive_power) in
466                network.generators_mut().iter_mut().zip(&dispatch.q_mvar)
467            {
468                generator.qg = *reactive_power;
469            }
470        }
471    }
472    network
473}
474
475fn network_with_ac_pf_solution(solution: &powerio_prob::AcPfSolution) -> BalancedNetwork {
476    let mut network = solution.network().clone();
477    for bus in network.buses_mut() {
478        bus.vm = solution
479            .bus_voltage_magnitude(bus.id)
480            .expect("a solution contains one magnitude per network bus");
481        bus.va = solution
482            .bus_voltage_angle(bus.id)
483            .expect("a solution contains one angle per network bus");
484    }
485    for (branch, identity) in network
486        .branches_mut()
487        .iter_mut()
488        .zip(solution.branch_order())
489    {
490        branch.solution = Some(BranchSolution::new(
491            solution
492                .branch_from_active_flow(&identity)
493                .expect("a solution contains one from active flow per branch"),
494            solution
495                .branch_from_reactive_flow(&identity)
496                .expect("a solution contains one from reactive flow per branch"),
497            solution
498                .branch_to_active_flow(&identity)
499                .expect("a solution contains one to active flow per branch"),
500            solution
501                .branch_to_reactive_flow(&identity)
502                .expect("a solution contains one to reactive flow per branch"),
503        ));
504    }
505    if let Some(dispatch) = solution.generator_dispatch() {
506        for (generator, active_power) in network.generators_mut().iter_mut().zip(&dispatch.p_mw) {
507            generator.pg = *active_power;
508        }
509        if !dispatch.q_mvar.is_empty() {
510            for (generator, reactive_power) in
511                network.generators_mut().iter_mut().zip(&dispatch.q_mvar)
512            {
513                generator.qg = *reactive_power;
514            }
515        }
516    }
517    network
518}
519
520fn network_with_dc_opf_solution(solution: &powerio_prob::DcOpfSolution) -> BalancedNetwork {
521    let mut network = solution.network().clone();
522    for bus in network.buses_mut() {
523        bus.va = solution
524            .bus_voltage_angle(bus.id)
525            .expect("a solution contains one angle per network bus");
526    }
527    for (generator, identity) in network
528        .generators_mut()
529        .iter_mut()
530        .zip(solution.generator_order())
531    {
532        generator.pg = solution
533            .generator_active_power(&identity)
534            .expect("a solution contains one active power per generator");
535    }
536    network
537}
538
539fn network_with_ac_opf_solution(solution: &powerio_prob::AcOpfSolution) -> BalancedNetwork {
540    let mut network = solution.network().clone();
541    for bus in network.buses_mut() {
542        bus.vm = solution
543            .bus_voltage_magnitude(bus.id)
544            .expect("a solution contains one magnitude per network bus");
545        bus.va = solution
546            .bus_voltage_angle(bus.id)
547            .expect("a solution contains one angle per network bus");
548    }
549    for (branch, identity) in network
550        .branches_mut()
551        .iter_mut()
552        .zip(solution.branch_order())
553    {
554        branch.solution = Some(BranchSolution::new(
555            solution
556                .branch_from_active_flow(&identity)
557                .expect("a solution contains one from active flow per branch"),
558            solution
559                .branch_from_reactive_flow(&identity)
560                .expect("a solution contains one from reactive flow per branch"),
561            solution
562                .branch_to_active_flow(&identity)
563                .expect("a solution contains one to active flow per branch"),
564            solution
565                .branch_to_reactive_flow(&identity)
566                .expect("a solution contains one to reactive flow per branch"),
567        ));
568    }
569    for (generator, identity) in network
570        .generators_mut()
571        .iter_mut()
572        .zip(solution.generator_order())
573    {
574        generator.pg = solution
575            .generator_active_power(&identity)
576            .expect("a solution contains one active power per generator");
577        generator.qg = solution
578            .generator_reactive_power(&identity)
579            .expect("a solution contains one reactive power per generator");
580    }
581    network
582}
583
584fn emit_balanced_network(
585    module: &PioModule<PioValue>,
586    network: &BalancedNetwork,
587    format: &str,
588    destination: Destination,
589    preserve_retained_source: bool,
590    diagnostics: Vec<Diagnostic>,
591) -> Result<EmitResult, Error> {
592    let typed = typed_sibling(module, network.clone())?;
593    let typed = if preserve_retained_source && retained_source_matches_case_format(module, format) {
594        typed
595    } else {
596        typed.sever_source()
597    };
598    let result = if is_pypsa_dir(format) {
599        powerio_tx::__emit_pypsa_csv(&typed, destination)
600    } else {
601        #[cfg(feature = "gridfm")]
602        if is_gridfm_dir(format) {
603            let mut diagnostics = diagnostics;
604            let dataset = powerio_matrix::build_gridfm_dataset(
605                network,
606                0,
607                &powerio_matrix::GridfmOptions::default(),
608            )
609            .map_err(|error| Error::new(error.code(), error.to_string()).with_cause(error))?;
610            diagnostics.extend(dataset.diagnostics);
611            return destination.__commit_artifacts(
612                true,
613                powerio_core::Fidelity::Canonical,
614                dataset.artifacts,
615                diagnostics,
616            );
617        }
618        let Some(target) = powerio_tx::format::parse_target_format(format) else {
619            return Err(unknown_format(format));
620        };
621        powerio_tx::emit(&typed, target, destination)
622    }?;
623    Ok(result.__with_diagnostics(diagnostics))
624}
625
626fn emit_multiconductor_network(
627    module: &PioModule<PioValue>,
628    network: powerio_dist::MulticonductorNetwork,
629    format: &str,
630    destination: Destination,
631    preserve_retained_source: bool,
632    diagnostics: Vec<Diagnostic>,
633) -> Result<EmitResult, Error> {
634    let Some(target) = powerio_dist::parse_dist_target_format(format) else {
635        return Err(unknown_format(format));
636    };
637    let typed = typed_sibling(module, network)?;
638    let typed = if preserve_retained_source && retained_source_matches_case_format(module, format) {
639        typed
640    } else {
641        typed.sever_source()
642    };
643    powerio_dist::emit(&typed, target, destination)
644        .map(|result| result.__with_diagnostics(diagnostics))
645}
646
647fn emit_goc3_solution(
648    solution: &powerio_prob::AcScucSolution,
649    destination: Destination,
650) -> Result<EmitResult, Error> {
651    let text = powerio_prob::__internal::__emit_goc3_output(solution)?;
652    let artifact = powerio_core::MemoryArtifact::new(
653        powerio_core::ArtifactPath::new("solution.json")
654            .expect("static name is a valid artifact path"),
655        text.into_bytes(),
656    );
657    destination.__commit_artifacts(
658        false,
659        powerio_core::Fidelity::Canonical,
660        vec![artifact],
661        Vec::new(),
662    )
663}
664
665/// Write one geographic layer as the canonical `.geo.json` document. No grid
666/// exchange format states a standalone layer, so any other target is refused
667/// by value type.
668fn emit_geo_layer(
669    layer: &powerio_tx::GeoLayer,
670    format: &str,
671    destination: Destination,
672) -> Result<EmitResult, Error> {
673    if !crate::is_geo_layer_token(format) {
674        // A `.pwd` display reads into a layer but has no writer, so naming it
675        // as the target is a different mistake from naming a grid case.
676        return Err(if crate::is_pwd_display_token(format) {
677            Error::new(
678                &codes::REQUEST_EMIT_UNSUPPORTED_VALUE_TYPE,
679                format!(
680                    "{format} names the PowerWorld display file, which has no writer; write the layer as `geo-json`"
681                ),
682            )
683        } else if known_format_name(format) {
684            Error::new(
685                &codes::REQUEST_EMIT_UNSUPPORTED_VALUE_TYPE,
686                format!(
687                    "{format} states a grid case, not powerio.GeoLayer; write a layer as `geo-json` or place it onto a case with apply_geo_layer"
688                ),
689            )
690        } else {
691            unknown_format(format)
692        });
693    }
694    let text = layer
695        .to_geojson_checked()
696        .map_err(|error| Error::new(error.code(), error.to_string()).with_cause(error))?;
697    let artifact = powerio_core::MemoryArtifact::new(
698        powerio_core::ArtifactPath::new(powerio_tx::geo::GEO_LAYER_EXTENSION)
699            .expect("static name is a valid artifact path"),
700        text.into_bytes(),
701    );
702    destination.__commit_artifacts(
703        false,
704        powerio_core::Fidelity::Canonical,
705        vec![artifact],
706        Vec::new(),
707    )
708}
709
710/// The three PSS/E contingency analysis files that a module value can be, or
711/// `None` for every other value.
712fn contingency_file_of_value(value: &PioValue) -> Option<crate::ContingencyFile> {
713    match value {
714        PioValue::ContingencySet(_) => Some(crate::ContingencyFile::Con),
715        PioValue::SubsystemSet(_) => Some(crate::ContingencyFile::Sub),
716        PioValue::MonitoredSet(_) => Some(crate::ContingencyFile::Mon),
717        _ => None,
718    }
719}
720
721/// The module's retained source bytes when the value is a PSS/E contingency
722/// analysis file and `format` names that same file. The three tokens are not
723/// grid case names, so `retained_source_matches_case_format` never claims
724/// them; this is the same rule stated over the contingency tokens.
725fn retained_contingency_source(module: &PioModule<PioValue>, format: &str) -> Option<Vec<u8>> {
726    let kind = contingency_file_of_value(module.value())?;
727    if crate::contingency_file_of_token(format)? != kind {
728        return None;
729    }
730    let source = module.source()?;
731    if source.acquired_buffers().len() != 1 {
732        return None;
733    }
734    if crate::contingency_file_of_token(source.format()?.as_str())? != kind {
735        return None;
736    }
737    Some(source.primary_buffer().ok()?.bytes().to_vec())
738}
739
740/// Write one PSS/E contingency analysis file as canonical text. Each of the
741/// three files is its own target: a `.con` written as `psse-sub` would state
742/// a different grammar, so only the token naming this value's own file is
743/// accepted.
744fn emit_contingency_file(
745    value: &PioValue,
746    kind: crate::ContingencyFile,
747    format: &str,
748    destination: Destination,
749) -> Result<EmitResult, Error> {
750    if crate::contingency_file_of_token(format) != Some(kind) {
751        return Err(if crate::contingency_file_of_token(format).is_some() {
752            Error::new(
753                &codes::REQUEST_EMIT_UNSUPPORTED_VALUE_TYPE,
754                format!(
755                    "{format} names another PSS/E contingency analysis file, not {}; write this one as `{}`",
756                    kind.type_name(),
757                    kind.token()
758                ),
759            )
760        } else if known_format_name(format) {
761            Error::new(
762                &codes::REQUEST_EMIT_UNSUPPORTED_VALUE_TYPE,
763                format!(
764                    "{format} states a grid case, not {}; write this file as `{}`",
765                    kind.type_name(),
766                    kind.token()
767                ),
768            )
769        } else {
770            unknown_format(format)
771        });
772    }
773    let text = match value {
774        PioValue::ContingencySet(set) => set.to_con(),
775        PioValue::SubsystemSet(set) => set.to_sub(),
776        PioValue::MonitoredSet(set) => set.to_mon(),
777        _ => unreachable!("the value variant selected the file kind"),
778    };
779    let artifact = powerio_core::MemoryArtifact::new(
780        powerio_core::ArtifactPath::new(kind.artifact_name())
781            .expect("static name is a valid artifact path"),
782        text.into_bytes(),
783    );
784    destination.__commit_artifacts(
785        false,
786        powerio_core::Fidelity::Canonical,
787        vec![artifact],
788        Vec::new(),
789    )
790}
791
792fn balanced_calculation_network(value: &PioValue) -> Option<&BalancedNetwork> {
793    match value {
794        PioValue::DcPfInstance(instance) => Some(instance.network()),
795        PioValue::AcPfInstance(instance) => Some(instance.network()),
796        PioValue::DcOpfInstance(instance) => Some(instance.network()),
797        PioValue::AcOpfInstance(instance) => Some(instance.network()),
798        PioValue::AcScucInstance(instance) => Some(instance.network()),
799        _ => None,
800    }
801}
802
803fn multiconductor_calculation_network(
804    value: &PioValue,
805) -> Option<&powerio_dist::MulticonductorNetwork> {
806    match value {
807        PioValue::McAcPfInstance(instance) => Some(instance.network()),
808        PioValue::McAcOpfInstance(instance) => Some(instance.network()),
809        PioValue::LinDist3FlowOpfInstance(instance) => Some(instance.network()),
810        _ => None,
811    }
812}
813
814fn emit_network_or_calculation(
815    module: &PioModule<PioValue>,
816    format: &str,
817    destination: Destination,
818) -> Result<EmitResult, Error> {
819    if matches!(module.value(), PioValue::AcScucInstance(_)) && is_goc3(format) {
820        return Err(unsupported_type(module, format));
821    }
822    if let Some(network) = balanced_calculation_network(module.value()) {
823        return emit_balanced_network(
824            module,
825            network,
826            format,
827            destination,
828            false,
829            vec![calculation_data_omitted(module.value().type_name(), format)],
830        );
831    }
832    if let Some(network) = multiconductor_calculation_network(module.value()) {
833        return emit_multiconductor_network(
834            module,
835            network.clone(),
836            format,
837            destination,
838            false,
839            vec![calculation_data_omitted(module.value().type_name(), format)],
840        );
841    }
842    match &module.value() {
843        PioValue::BalancedNetwork(network) => {
844            emit_balanced_network(module, network, format, destination, true, Vec::new())
845        }
846        PioValue::MulticonductorNetwork(network) => emit_multiconductor_network(
847            module,
848            network.clone(),
849            format,
850            destination,
851            true,
852            Vec::new(),
853        ),
854        PioValue::BalancedOperatingPoint(point) => {
855            let (network, diagnostics) = network_with_balanced_operating_point(point, format);
856            emit_balanced_network(module, &network, format, destination, false, diagnostics)
857        }
858        PioValue::MulticonductorOperatingPoint(point) => {
859            let (network, diagnostics) = network_with_multiconductor_operating_point(point, format);
860            emit_multiconductor_network(module, network, format, destination, false, diagnostics)
861        }
862        _ => unreachable!("caller selected a value that is not a network or calculation"),
863    }
864}
865
866fn emit_balanced_solution_network(
867    module: &PioModule<PioValue>,
868    network: &BalancedNetwork,
869    format: &str,
870    destination: Destination,
871) -> Result<EmitResult, Error> {
872    emit_balanced_network(
873        module,
874        network,
875        format,
876        destination,
877        false,
878        vec![solution_data_omitted(module.value().type_name(), format)],
879    )
880}
881
882fn emit_multiconductor_solution_network(
883    module: &PioModule<PioValue>,
884    network: &powerio_dist::MulticonductorNetwork,
885    format: &str,
886    destination: Destination,
887) -> Result<EmitResult, Error> {
888    emit_multiconductor_network(
889        module,
890        network.clone(),
891        format,
892        destination,
893        false,
894        vec![solution_data_omitted(module.value().type_name(), format)],
895    )
896}
897
898fn emit_solution(
899    module: &PioModule<PioValue>,
900    format: &str,
901    destination: Destination,
902) -> Result<EmitResult, Error> {
903    match &module.value() {
904        PioValue::DcPfSolution(solution) => emit_balanced_solution_network(
905            module,
906            &network_with_dc_pf_solution(solution),
907            format,
908            destination,
909        ),
910        PioValue::AcPfSolution(solution) => emit_balanced_solution_network(
911            module,
912            &network_with_ac_pf_solution(solution),
913            format,
914            destination,
915        ),
916        PioValue::DcOpfSolution(solution) => emit_balanced_solution_network(
917            module,
918            &network_with_dc_opf_solution(solution),
919            format,
920            destination,
921        ),
922        PioValue::AcOpfSolution(solution) => emit_balanced_solution_network(
923            module,
924            &network_with_ac_opf_solution(solution),
925            format,
926            destination,
927        ),
928        PioValue::SocwrOpfSolution(solution) => emit_balanced_network(
929            module,
930            solution.network(),
931            format,
932            destination,
933            false,
934            vec![relaxation_data_omitted(format)],
935        ),
936        PioValue::McAcPfSolution(solution) => {
937            emit_multiconductor_solution_network(module, solution.network(), format, destination)
938        }
939        PioValue::McAcOpfSolution(solution) => {
940            emit_multiconductor_solution_network(module, solution.network(), format, destination)
941        }
942        PioValue::LinDist3FlowOpfSolution(solution) => {
943            emit_multiconductor_solution_network(module, solution.network(), format, destination)
944        }
945        PioValue::AcScucSolution(solution) if is_goc3(format) => {
946            emit_goc3_solution(solution, destination)
947        }
948        PioValue::AcScucSolution(solution) => emit_balanced_solution_network(
949            module,
950            solution.instance().network(),
951            format,
952            destination,
953        ),
954        _ => unreachable!("caller selected a value that is not a solution"),
955    }
956}
957
958fn emit_versioned_bmopf(
959    module: &PioModule<PioValue>,
960    version: &str,
961    destination: Destination,
962) -> Result<EmitResult, Error> {
963    let format = format!("bmopf-json@{version}");
964    let profile = match version {
965        "0.1.0" => powerio_dist::BmopfSchemaVersion::Bmopf010,
966        "0.2.0" => powerio_dist::BmopfSchemaVersion::Bmopf020,
967        _ => return Err(unknown_format(&format)),
968    };
969    let (network, diagnostics) = match module.value() {
970        PioValue::MulticonductorNetwork(network) => (network.clone(), Vec::new()),
971        PioValue::McAcPfInstance(instance) => (
972            instance.network().clone(),
973            vec![calculation_data_omitted(
974                module.value().type_name(),
975                &format,
976            )],
977        ),
978        PioValue::McAcOpfInstance(instance) => (
979            instance.network().clone(),
980            vec![calculation_data_omitted(
981                module.value().type_name(),
982                &format,
983            )],
984        ),
985        PioValue::LinDist3FlowOpfInstance(instance) => (
986            instance.network().clone(),
987            vec![calculation_data_omitted(
988                module.value().type_name(),
989                &format,
990            )],
991        ),
992        PioValue::MulticonductorOperatingPoint(point) => {
993            network_with_multiconductor_operating_point(point, &format)
994        }
995        PioValue::McAcPfSolution(solution) => (
996            solution.instance().network().clone(),
997            vec![solution_data_omitted(module.value().type_name(), &format)],
998        ),
999        PioValue::McAcOpfSolution(solution) => (
1000            solution.instance().network().clone(),
1001            vec![solution_data_omitted(module.value().type_name(), &format)],
1002        ),
1003        PioValue::LinDist3FlowOpfSolution(solution) => (
1004            solution.instance().network().clone(),
1005            vec![solution_data_omitted(module.value().type_name(), &format)],
1006        ),
1007        _ => return Err(unsupported_type(module, &format)),
1008    };
1009    let typed = typed_sibling(module, network)?.sever_source();
1010    let mut options = powerio_dist::EmitOptions::default();
1011    options.bmopf.schema_version = profile;
1012    powerio_dist::emit_with_options(
1013        &typed,
1014        powerio_dist::DistTargetFormat::BmopfJson,
1015        &options,
1016        destination,
1017    )
1018    .map(|result| result.__with_diagnostics(diagnostics))
1019}
1020
1021fn emit_dynamic(
1022    module: &PioModule<PioValue>,
1023    format: &str,
1024    destination: Destination,
1025) -> Result<EmitResult, Error> {
1026    if let Some(version) = format.strip_prefix("bmopf-json@") {
1027        return emit_versioned_bmopf(module, version, destination);
1028    }
1029    if let Some(artifacts) = echo_retained_directory(module, format)? {
1030        return destination.__commit_artifacts(
1031            true,
1032            powerio_core::Fidelity::ExactSameFormat,
1033            artifacts,
1034            Vec::new(),
1035        );
1036    }
1037
1038    // A parse-only calculation format can still reproduce an unchanged
1039    // retained source exactly. A derived module has no retained source, so a
1040    // solved result never falls through to stale input bytes here.
1041    if !matches!(
1042        &module.value(),
1043        PioValue::BalancedNetwork(_) | PioValue::MulticonductorNetwork(_)
1044    ) && let Some(bytes) = echo_retained_source(module, format)
1045    {
1046        let artifact = powerio_core::MemoryArtifact::new(
1047            powerio_core::ArtifactPath::new("case").expect("static name is a valid artifact path"),
1048            bytes,
1049        );
1050        return destination.__commit_artifacts(
1051            false,
1052            powerio_core::Fidelity::ExactSameFormat,
1053            vec![artifact],
1054            Vec::new(),
1055        );
1056    }
1057
1058    if let PioValue::GeoLayer(layer) = &module.value() {
1059        return emit_geo_layer(layer, format, destination);
1060    }
1061
1062    if let Some(kind) = contingency_file_of_value(module.value()) {
1063        if let Some(bytes) = retained_contingency_source(module, format) {
1064            let artifact = powerio_core::MemoryArtifact::new(
1065                powerio_core::ArtifactPath::new(kind.artifact_name())
1066                    .expect("static name is a valid artifact path"),
1067                bytes,
1068            );
1069            return destination.__commit_artifacts(
1070                false,
1071                powerio_core::Fidelity::ExactSameFormat,
1072                vec![artifact],
1073                Vec::new(),
1074            );
1075        }
1076        return emit_contingency_file(module.value(), kind, format, destination);
1077    }
1078
1079    match &module.value() {
1080        PioValue::BalancedNetwork(_)
1081        | PioValue::MulticonductorNetwork(_)
1082        | PioValue::BalancedOperatingPoint(_)
1083        | PioValue::MulticonductorOperatingPoint(_)
1084        | PioValue::DcPfInstance(_)
1085        | PioValue::AcPfInstance(_)
1086        | PioValue::DcOpfInstance(_)
1087        | PioValue::AcOpfInstance(_)
1088        | PioValue::McAcPfInstance(_)
1089        | PioValue::McAcOpfInstance(_)
1090        | PioValue::LinDist3FlowOpfInstance(_)
1091        | PioValue::AcScucInstance(_) => emit_network_or_calculation(module, format, destination),
1092        PioValue::DcPfSolution(_)
1093        | PioValue::AcPfSolution(_)
1094        | PioValue::DcOpfSolution(_)
1095        | PioValue::AcOpfSolution(_)
1096        | PioValue::SocwrOpfSolution(_)
1097        | PioValue::McAcPfSolution(_)
1098        | PioValue::McAcOpfSolution(_)
1099        | PioValue::LinDist3FlowOpfSolution(_)
1100        | PioValue::AcScucSolution(_) => emit_solution(module, format, destination),
1101        _ => {
1102            if known_format_name(format) {
1103                Err(unsupported_type(module, format))
1104            } else {
1105                Err(unknown_format(format))
1106            }
1107        }
1108    }
1109}
1110
1111/// True when `format` is a name some family recognizes, used to tell "wrong
1112/// kind for this format" apart from "no such format".
1113fn known_format_name(format: &str) -> bool {
1114    crate::resolve_format(format).is_some()
1115}
1116
1117#[cfg(test)]
1118mod tests {
1119    use super::*;
1120    use powerio_core::{
1121        DiagnosticCode, DiagnosticSeverity, HistoryEntry, HistoryId, HistoryKind, Producer,
1122        SourceDescriptor, SourceId, SourceMapEntry, SourceRelation, SourceSpan,
1123    };
1124    use powerio_tx::{BalancedNetwork, Bus, BusId, BusType};
1125
1126    #[test]
1127    fn a_typed_writer_sibling_preserves_every_common_record() {
1128        let network = BalancedNetwork::in_memory(
1129            "records",
1130            100.0,
1131            vec![Bus::new(BusId(1), BusType::Ref, 230.0)],
1132            vec![],
1133        );
1134        let source = powerio_core::Source::from_memory("case.m", b"case bytes".to_vec())
1135            .unwrap()
1136            .with_format(powerio_core::FormatId::new("matpower").unwrap());
1137        let source_id = SourceId::new("source-1").unwrap();
1138        let mut module = PioModule::new(PioValue::BalancedNetwork(network.clone()))
1139            .with_producer(Producer::new("records-test", "1").unwrap())
1140            .with_source(source);
1141        module
1142            .add_source_descriptor(SourceDescriptor::new(source_id.clone(), "case.m", 10).unwrap())
1143            .unwrap();
1144        module
1145            .add_source_map_entry(
1146                SourceMapEntry::new(
1147                    "/buses/0",
1148                    SourceRelation::Exact,
1149                    vec![SourceSpan::new(source_id, 0, 4).unwrap()],
1150                )
1151                .unwrap(),
1152            )
1153            .unwrap();
1154        module
1155            .add_diagnostic(powerio_core::Diagnostic::new(
1156                DiagnosticCode::new("READ.TEST.RECORD").unwrap(),
1157                DiagnosticSeverity::Remark,
1158                "record carried to the family writer",
1159            ))
1160            .unwrap();
1161        module
1162            .add_history_entry(
1163                HistoryEntry::new(
1164                    HistoryId::new("history-1").unwrap(),
1165                    HistoryKind::Parse,
1166                    "parse",
1167                )
1168                .unwrap(),
1169            )
1170            .unwrap();
1171        module
1172            .insert_extension("test.writer", serde_json::json!({"kept": true}))
1173            .unwrap();
1174
1175        let sibling = typed_sibling(&module, network).unwrap();
1176        assert_eq!(sibling.producer(), module.producer());
1177        assert_eq!(sibling.sources(), module.sources());
1178        assert_eq!(sibling.source_map(), module.source_map());
1179        assert_eq!(sibling.diagnostics(), module.diagnostics());
1180        assert_eq!(sibling.history(), module.history());
1181        assert_eq!(sibling.extensions(), module.extensions());
1182        let sibling_source = sibling.source().unwrap();
1183        assert_eq!(sibling_source.name(), "case.m");
1184        assert_eq!(
1185            sibling_source.primary_buffer().unwrap().bytes(),
1186            b"case bytes"
1187        );
1188    }
1189}