Skip to main content

powerio/
transform.rs

1//! Transformations of facade values, preserving their module records.
2use crate::codes;
3use crate::{BalancedNetwork, GeoApplyReport, GeoLayer};
4use powerio_core::{Diagnostic, HistoryEntry, HistoryId, HistoryKind};
5use powerio_dist::{MulticonductorNetwork, NeutralKronOptions, NeutralKronReport};
6#[cfg(test)]
7use powerio_tnd::transform::capped_history_notes;
8#[doc(inline)]
9pub use powerio_tnd::transform::{
10    MulticonductorToBalancedError, MulticonductorToBalancedOptions, MulticonductorToBalancedReport,
11    MulticonductorToBalancedTransformation, SequenceTransformConvention, to_balanced_network,
12    to_balanced_network_report,
13};
14use std::collections::BTreeMap;
15/// Readiness of one module's value for the balanced lowering: the #398
16/// inspect operation. The value must be a multiconductor network.
17///
18/// # Errors
19/// A value of any other kind, named.
20pub fn to_balanced_report(
21    module: &powerio_core::PioModule<crate::PioValue>,
22    options: MulticonductorToBalancedOptions,
23) -> Result<MulticonductorToBalancedReport, powerio_core::Error> {
24    let crate::PioValue::MulticonductorNetwork(net) = &module.value() else {
25        return Err(wrong_kind_error(module.value()));
26    };
27    Ok(to_balanced_network_report(net, options))
28}
29
30/// Lower a multiconductor module to a balanced module: the #398 transform
31/// operation. The module's common records carry over, the retained source is
32/// severed because its bytes describe the input value, and the pass appends
33/// its structured findings as module diagnostics and one Transform history
34/// entry stating the chosen base power, every assumption and approximation,
35/// the dropped fields, and the removed bus and switch identities.
36///
37/// # Errors
38/// A value of any other kind (the module comes back untouched), or the
39/// lowering's structured refusal.
40///
41/// # Panics
42/// Only on a broken internal invariant: the pass's diagnostics carry no
43/// identity and no span, the note lists are capped, and the history id is
44/// minted unused, so every record append succeeds.
45#[allow(clippy::result_large_err)]
46pub fn to_balanced(
47    module: powerio_core::PioModule<crate::PioValue>,
48    options: MulticonductorToBalancedOptions,
49) -> Result<
50    powerio_core::PioModule<crate::PioValue>,
51    (
52        powerio_core::PioModule<crate::PioValue>,
53        Box<MulticonductorToBalancedError>,
54    ),
55> {
56    let crate::PioValue::MulticonductorNetwork(net) = &module.value() else {
57        let error = MulticonductorToBalancedError::new(
58            options,
59            &[Diagnostic::of(
60                &codes::TRANSFORM_MULTI_TO_BALANCED_WRONG_MODEL_KIND,
61                format!(
62                    "the module carries a {} value; the balanced lowering takes a \
63                     multiconductor network",
64                    module.value().type_name()
65                ),
66            )],
67        );
68        return Err((module, Box::new(error)));
69    };
70    let lowering = match to_balanced_network(net, options) {
71        Ok(lowering) => lowering,
72        Err(error) => return Err((module, Box::new(error))),
73    };
74    let MulticonductorToBalancedTransformation {
75        network,
76        diagnostics,
77        history,
78        ..
79    } = lowering;
80    // Room for the pass's own records is checked against the module maxima
81    // before the value is consumed, so the additions below hold by
82    // construction and a cap-edge input is refused with its module intact.
83    let diagnostics_room =
84        powerio_core::limits::MAX_MODULE_DIAGNOSTICS.saturating_sub(module.diagnostics().len());
85    let history_room =
86        powerio_core::limits::MAX_MODULE_HISTORY_ENTRIES.saturating_sub(module.history().len());
87    if diagnostics.len() > diagnostics_room || history_room == 0 {
88        let error = MulticonductorToBalancedError::new(
89            options,
90            &[Diagnostic::of(
91                &codes::TRANSFORM_MULTI_TO_BALANCED_RECORD_CAP,
92                "the module cannot hold the lowering's findings and history entry; export a \
93                 fresh module before lowering"
94                    .to_string(),
95            )],
96        );
97        return Err((module, Box::new(error)));
98    }
99    let mut module = module
100        .map_value(|_| crate::PioValue::BalancedNetwork(network))
101        .sever_source();
102    // The value's kind changed, so no RFC 6901 target survives the
103    // transform: pre-existing diagnostic targets and the source map pointed
104    // into the consumed multiconductor value and are severed here; the
105    // pass's own findings are emitted with no target for the same reason.
106    module.sever_value_targets();
107    for diagnostic in &diagnostics {
108        let mut diagnostic = diagnostic.clone();
109        diagnostic.clear_target();
110        module
111            .add_diagnostic(diagnostic)
112            .expect("room was checked; pass diagnostics carry no identity and no span");
113    }
114    let entry = copy_history_with_id(
115        &history,
116        unused_history_id(&module, "multiconductor-to-balanced"),
117    );
118    module
119        .add_history_entry(entry)
120        .expect("room was checked and the history id is unique by construction");
121    Ok(module)
122}
123
124fn derive_balanced_calculation<I>(
125    module: &powerio_core::PioModule<crate::PioValue>,
126    operation: &'static str,
127    output_type: &'static str,
128    build: impl FnOnce(BalancedNetwork) -> Result<I, powerio_core::Error>,
129) -> Result<powerio_core::PioModule<I>, powerio_core::Error> {
130    if !matches!(module.value(), crate::PioValue::BalancedNetwork(_)) {
131        return Err(powerio_core::Error::new(
132            &codes::REQUEST_MODULE_WRONG_MODEL_KIND,
133            format!(
134                "{operation} requires powerio.BalancedNetwork; the module contains {}",
135                module.value().type_name()
136            ),
137        ));
138    }
139    let history = HistoryEntry::new(
140        unused_history_id(module, operation),
141        HistoryKind::Transform,
142        operation,
143    )?
144    .with_input_type("powerio.BalancedNetwork")?
145    .with_output_type(output_type)?;
146    let producer = powerio_core::Producer::new("powerio", crate::VERSION)?;
147    module.clone().try_derive_value(producer, history, |value| {
148        let crate::PioValue::BalancedNetwork(network) = value else {
149            unreachable!("the value type was checked before derivation")
150        };
151        build(network)
152    })
153}
154
155/// Derive a calculation value from any module: the module's records carry
156/// over, and one Transform history entry records the module's value type as
157/// the input.
158fn derive_calculation<I>(
159    module: &powerio_core::PioModule<crate::PioValue>,
160    operation: &'static str,
161    output_type: &'static str,
162    value: Result<I, powerio_core::Error>,
163) -> Result<powerio_core::PioModule<I>, powerio_core::Error> {
164    let value = value?;
165    let history = HistoryEntry::new(
166        unused_history_id(module, operation),
167        HistoryKind::Transform,
168        operation,
169    )?
170    .with_input_type(module.value().type_name())?
171    .with_output_type(output_type)?;
172    let producer = powerio_core::Producer::new("powerio", crate::VERSION)?;
173    module
174        .clone()
175        .try_derive_value(producer, history, |_| Ok(value))
176}
177
178fn derive_multiconductor_calculation<I>(
179    module: &powerio_core::PioModule<crate::PioValue>,
180    operation: &'static str,
181    output_type: &'static str,
182    build: impl FnOnce(MulticonductorNetwork) -> Result<I, powerio_core::Error>,
183) -> Result<powerio_core::PioModule<I>, powerio_core::Error> {
184    if !matches!(module.value(), crate::PioValue::MulticonductorNetwork(_)) {
185        return Err(powerio_core::Error::new(
186            &codes::REQUEST_MODULE_WRONG_MODEL_KIND,
187            format!(
188                "{operation} requires powerio.MulticonductorNetwork; the module contains {}",
189                module.value().type_name()
190            ),
191        ));
192    }
193    let history = HistoryEntry::new(
194        unused_history_id(module, operation),
195        HistoryKind::Transform,
196        operation,
197    )?
198    .with_input_type("powerio.MulticonductorNetwork")?
199    .with_output_type(output_type)?;
200    let producer = powerio_core::Producer::new("powerio", crate::VERSION)?;
201    module.clone().try_derive_value(producer, history, |value| {
202        let crate::PioValue::MulticonductorNetwork(network) = value else {
203            unreachable!("the value type was checked before derivation")
204        };
205        build(network)
206    })
207}
208
209/// Apply one geographic layer to a network module.
210///
211/// Balanced bus points and branch routes use
212/// [`BalancedNetwork::apply_geo_layer`]. Multiconductor coordinates use the
213/// same shared matching rules through [`crate::dist_geo::apply_dist_geo_layer`].
214/// The source module is unchanged. The returned module clears retained bytes
215/// and source mappings, preserves its other records, and appends one
216/// `apply_geo_layer` history entry.
217///
218/// # Errors
219/// The module does not contain a balanced or multiconductor network, or its
220/// records cannot accept the new history entry.
221pub fn apply_geo_layer(
222    module: &powerio_core::PioModule<crate::PioValue>,
223    layer: &GeoLayer,
224) -> Result<(powerio_core::PioModule<crate::PioValue>, GeoApplyReport), powerio_core::Error> {
225    let type_name = match &module.value() {
226        crate::PioValue::BalancedNetwork(_) => "powerio.BalancedNetwork",
227        crate::PioValue::MulticonductorNetwork(_) => "powerio.MulticonductorNetwork",
228        value => {
229            return Err(powerio_core::Error::new(
230                &codes::REQUEST_MODULE_WRONG_MODEL_KIND,
231                format!(
232                    "apply_geo_layer requires powerio.BalancedNetwork or \
233                     powerio.MulticonductorNetwork; the module contains {}",
234                    value.type_name()
235                ),
236            ));
237        }
238    };
239    let history = HistoryEntry::new(
240        unused_history_id(module, "apply-geo-layer"),
241        HistoryKind::Transform,
242        "apply_geo_layer",
243    )?
244    .with_input_type(type_name)?
245    .with_output_type(type_name)?;
246    let producer = powerio_core::Producer::new("powerio", crate::VERSION)?;
247    let mut report = None;
248    let derived = module
249        .clone()
250        .try_derive_value(producer, history, |mut value| {
251            let applied = match &mut value {
252                crate::PioValue::BalancedNetwork(network) => network.apply_geo_layer(layer),
253                crate::PioValue::MulticonductorNetwork(network) => {
254                    crate::dist_geo::apply_dist_geo_layer(network, layer)
255                }
256                value => {
257                    return Err(powerio_core::Error::new(
258                        &codes::REQUEST_MODULE_WRONG_MODEL_KIND,
259                        format!(
260                            "apply_geo_layer requires powerio.BalancedNetwork or \
261                             powerio.MulticonductorNetwork; the module contains {}",
262                            value.type_name()
263                        ),
264                    ));
265                }
266            };
267            report = Some(applied);
268            Ok(value)
269        })?;
270    let report = report.ok_or_else(|| {
271        powerio_core::Error::new(
272            &codes::REQUEST_MODULE_WRONG_MODEL_KIND,
273            "apply_geo_layer did not receive a network value",
274        )
275    })?;
276    Ok((derived, report))
277}
278
279/// Eliminate explicitly grounded neutral conductors with the default Kron
280/// projection options.
281///
282/// The source module is left unchanged. The returned module retains its
283/// records, clears locators into the pre-projection value, appends the
284/// projection findings, and records one same-family transform history entry.
285pub fn neutral_kron(
286    module: &powerio_core::PioModule<crate::PioValue>,
287) -> Result<(powerio_core::PioModule<crate::PioValue>, NeutralKronReport), powerio_core::Error> {
288    neutral_kron_with_options(module, &NeutralKronOptions::default())
289}
290
291/// Eliminate explicitly grounded neutral conductors with explicit projection
292/// options.
293///
294/// The returned report keeps its input-network targets. Copies appended to the
295/// output module have those targets cleared because terminal arrays changed.
296///
297/// # Errors
298/// The module does not contain a multiconductor network, the neutral
299/// projection cannot preserve its semantics, or the derived module cannot
300/// accept the new history and diagnostic records.
301pub fn neutral_kron_with_options(
302    module: &powerio_core::PioModule<crate::PioValue>,
303    options: &NeutralKronOptions,
304) -> Result<(powerio_core::PioModule<crate::PioValue>, NeutralKronReport), powerio_core::Error> {
305    let crate::PioValue::MulticonductorNetwork(network) = module.value() else {
306        return Err(powerio_core::Error::new(
307            &codes::REQUEST_MODULE_WRONG_MODEL_KIND,
308            format!(
309                "neutral_kron requires powerio.MulticonductorNetwork; the module contains {}",
310                module.value().type_name()
311            ),
312        ));
313    };
314    let reduction = powerio_dist::neutral_kron_reduce(network, options).map_err(|cause| {
315        powerio_core::Error::new(cause.code(), cause.to_string()).with_cause(cause)
316    })?;
317    let (network, report) = reduction.into_parts();
318    let neutral_terminals = options
319        .neutral_terminals
320        .iter()
321        .map(|(bus, terminal)| (bus.clone(), serde_json::Value::String(terminal.clone())))
322        .collect();
323    let parameters = BTreeMap::from([
324        (
325            "allow_forced_ideal_ground".to_owned(),
326            serde_json::Value::Bool(options.allow_forced_ideal_ground),
327        ),
328        (
329            "neutral_terminals".to_owned(),
330            serde_json::Value::Object(neutral_terminals),
331        ),
332    ]);
333    let history = HistoryEntry::new(
334        unused_history_id(module, "neutral_kron"),
335        HistoryKind::Transform,
336        "neutral_kron",
337    )?
338    .with_input_type("powerio.MulticonductorNetwork")?
339    .with_output_type("powerio.MulticonductorNetwork")?
340    .with_parameters(parameters)?;
341    let producer = powerio_core::Producer::new("powerio", crate::VERSION)?;
342    let mut derived = module.clone().try_derive_value(
343        producer,
344        history,
345        move |value| match value {
346            crate::PioValue::MulticonductorNetwork(_) => {
347                Ok(crate::PioValue::MulticonductorNetwork(network))
348            }
349            value => Err(powerio_core::Error::new(
350                &codes::REQUEST_MODULE_WRONG_MODEL_KIND,
351                format!(
352                    "neutral_kron requires powerio.MulticonductorNetwork; the module contains {}",
353                    value.type_name()
354                ),
355            )),
356        },
357    )?;
358    for diagnostic in &report.diagnostics {
359        let mut diagnostic = diagnostic.clone();
360        diagnostic.clear_target();
361        derived.add_diagnostic(diagnostic)?;
362    }
363    Ok((derived, report))
364}
365
366/// The balanced network an operating point states: the point's network with
367/// the point's bus voltages, generator dispatch and setpoints, load powers,
368/// service flags, taps, phase shifts, and switch positions applied. The
369/// collection's shared network is not changed. When the point supplies electrical
370/// columns, inherited branch solutions are cleared: new voltages or dispatch do
371/// not establish solved branch flows. Net bus injection columns have
372/// no network field and are not applied; `emit` reports that omission.
373#[must_use]
374pub fn network_with_operating_point(
375    point: &powerio_prob::OperatingPoint<BalancedNetwork>,
376) -> BalancedNetwork {
377    crate::write::network_with_balanced_operating_point(point, "network").0
378}
379
380/// Construct a DC power flow calculation from a balanced network module.
381/// Module diagnostics, source descriptions, provenance, and prior history are
382/// preserved. Retained source bytes and value locators are cleared because
383/// they describe the network rather than the calculation instance.
384pub fn to_dc_pf_instance(
385    module: &powerio_core::PioModule<crate::PioValue>,
386) -> Result<powerio_core::PioModule<powerio_prob::DcPfInstance>, powerio_core::Error> {
387    if matches!(module.value(), crate::PioValue::DcPfInstance(_)) {
388        return Ok(module.clone().map_value(|value| match value {
389            crate::PioValue::DcPfInstance(instance) => instance,
390            _ => unreachable!("the value type was checked before extraction"),
391        }));
392    }
393    derive_balanced_calculation(
394        module,
395        "to_dc_pf_instance",
396        "powerio.DcPfInstance",
397        powerio_prob::DcPfInstance::from_network,
398    )
399}
400
401/// Construct an AC power flow calculation from a balanced network module.
402pub fn to_ac_pf_instance(
403    module: &powerio_core::PioModule<crate::PioValue>,
404) -> Result<powerio_core::PioModule<powerio_prob::AcPfInstance>, powerio_core::Error> {
405    if matches!(module.value(), crate::PioValue::AcPfInstance(_)) {
406        return Ok(module.clone().map_value(|value| match value {
407            crate::PioValue::AcPfInstance(instance) => instance,
408            _ => unreachable!("the value type was checked before extraction"),
409        }));
410    }
411    derive_balanced_calculation(
412        module,
413        "to_ac_pf_instance",
414        "powerio.AcPfInstance",
415        powerio_prob::AcPfInstance::from_network,
416    )
417}
418
419/// Construct a DC optimal power flow calculation from a balanced network
420/// module.
421pub fn to_dc_opf_instance(
422    module: &powerio_core::PioModule<crate::PioValue>,
423) -> Result<powerio_core::PioModule<powerio_prob::DcOpfInstance>, powerio_core::Error> {
424    if matches!(module.value(), crate::PioValue::DcOpfInstance(_)) {
425        return Ok(module.clone().map_value(|value| match value {
426            crate::PioValue::DcOpfInstance(instance) => instance,
427            _ => unreachable!("the value type was checked before extraction"),
428        }));
429    }
430    derive_balanced_calculation(
431        module,
432        "to_dc_opf_instance",
433        "powerio.DcOpfInstance",
434        powerio_prob::DcOpfInstance::from_network,
435    )
436}
437
438/// Construct an AC optimal power flow calculation from a balanced network
439/// module.
440pub fn to_ac_opf_instance(
441    module: &powerio_core::PioModule<crate::PioValue>,
442) -> Result<powerio_core::PioModule<powerio_prob::AcOpfInstance>, powerio_core::Error> {
443    if matches!(module.value(), crate::PioValue::AcOpfInstance(_)) {
444        return Ok(module.clone().map_value(|value| match value {
445            crate::PioValue::AcOpfInstance(instance) => instance,
446            _ => unreachable!("the value type was checked before extraction"),
447        }));
448    }
449    derive_balanced_calculation(
450        module,
451        "to_ac_opf_instance",
452        "powerio.AcOpfInstance",
453        powerio_prob::AcOpfInstance::from_network,
454    )
455}
456
457/// Construct an AC optimal transmission switching calculation with the
458/// default switching options: every initially in-service branch may switch,
459/// at zero cost, with no budget, no islands, and no current limits, and the
460/// objective gains the switching cost term. The module holds a balanced
461/// network (the AC optimal power flow base is
462/// [`AcOpfInstance::from_network`](powerio_prob::AcOpfInstance::from_network))
463/// or an AC optimal power flow calculation. An AC optimal transmission
464/// switching module comes back unchanged.
465///
466/// # Errors
467/// A value of another kind, or a base the defaults cannot build on.
468pub fn to_ac_ots_instance(
469    module: &powerio_core::PioModule<crate::PioValue>,
470) -> Result<powerio_core::PioModule<powerio_prob::AcOtsInstance>, powerio_core::Error> {
471    match module.value() {
472        crate::PioValue::AcOtsInstance(_) => Ok(module.clone().map_value(|value| match value {
473            crate::PioValue::AcOtsInstance(instance) => instance,
474            _ => unreachable!("the value type was checked before extraction"),
475        })),
476        crate::PioValue::AcOpfInstance(base) => derive_calculation(
477            module,
478            "to_ac_ots_instance",
479            "powerio.AcOtsInstance",
480            powerio_prob::AcOtsInstance::from_ac_opf(base.clone()),
481        ),
482        _ => derive_balanced_calculation(
483            module,
484            "to_ac_ots_instance",
485            "powerio.AcOtsInstance",
486            powerio_prob::AcOtsInstance::from_network,
487        ),
488    }
489}
490
491/// Construct, or reconfigure, an AC optimal transmission switching
492/// calculation with explicit switching options. The module holds a balanced
493/// network, an AC optimal power flow calculation, or an AC optimal
494/// transmission switching calculation, whose base problem and objective are
495/// kept and whose options are replaced. The objective gains the switching
496/// cost term once.
497///
498/// # Errors
499/// A value of another kind, or options
500/// [`AcOtsInstance::new`](powerio_prob::AcOtsInstance::new) refuses.
501pub fn to_ac_ots_instance_with_options(
502    module: &powerio_core::PioModule<crate::PioValue>,
503    options: powerio_prob::AcOtsOptions,
504) -> Result<powerio_core::PioModule<powerio_prob::AcOtsInstance>, powerio_core::Error> {
505    let configure = |instance: Result<powerio_prob::AcOtsInstance, powerio_core::Error>| {
506        instance.map(powerio_prob::AcOtsInstance::with_switching_objective)
507    };
508    match module.value() {
509        crate::PioValue::AcOtsInstance(instance) => derive_calculation(
510            module,
511            "to_ac_ots_instance",
512            "powerio.AcOtsInstance",
513            configure(instance.clone().with_options(options)),
514        ),
515        crate::PioValue::AcOpfInstance(base) => derive_calculation(
516            module,
517            "to_ac_ots_instance",
518            "powerio.AcOtsInstance",
519            configure(powerio_prob::AcOtsInstance::new(base.clone(), options)),
520        ),
521        _ => derive_balanced_calculation(
522            module,
523            "to_ac_ots_instance",
524            "powerio.AcOtsInstance",
525            |network| {
526                configure(powerio_prob::AcOtsInstance::new(
527                    powerio_prob::AcOpfInstance::from_network(network)?,
528                    options,
529                ))
530            },
531        ),
532    }
533}
534
535/// Combine an AC optimal power flow base with explicit switching options,
536/// keeping the base objective exactly. `module` is the module `base` belongs
537/// to, directly or as a solution's instance; its records carry over to the
538/// result.
539///
540/// # Errors
541/// Options [`AcOtsInstance::new`](powerio_prob::AcOtsInstance::new) refuses.
542pub fn ac_ots_instance_from_base(
543    module: &powerio_core::PioModule<crate::PioValue>,
544    base: &powerio_prob::AcOpfInstance,
545    options: powerio_prob::AcOtsOptions,
546) -> Result<powerio_core::PioModule<powerio_prob::AcOtsInstance>, powerio_core::Error> {
547    derive_calculation(
548        module,
549        "ac_ots_instance",
550        "powerio.AcOtsInstance",
551        powerio_prob::AcOtsInstance::new(base.clone(), options),
552    )
553}
554
555/// The AC optimal power flow over a final topology of `instance`
556/// ([`AcOtsInstance::fix_topology`](powerio_prob::AcOtsInstance::fix_topology)),
557/// with its discards recorded as module diagnostics. `module` is the module
558/// `instance` belongs to, directly or as a solution's instance.
559///
560/// # Errors
561/// A topology the instance refuses.
562pub fn fix_ac_ots_topology(
563    module: &powerio_core::PioModule<crate::PioValue>,
564    instance: &powerio_prob::AcOtsInstance,
565    branch_closed: &[bool],
566) -> Result<powerio_core::PioModule<powerio_prob::AcOpfInstance>, powerio_core::Error> {
567    let (fixed, diagnostics) = instance.fix_topology(branch_closed)?;
568    let mut derived = derive_calculation(
569        module,
570        "fix_ac_ots_topology",
571        "powerio.AcOpfInstance",
572        Ok(fixed),
573    )?;
574    for mut diagnostic in diagnostics {
575        diagnostic.clear_target();
576        derived.add_diagnostic(diagnostic)?;
577    }
578    Ok(derived)
579}
580
581/// Attach the AC optimal power flow solution of `instance`'s fixed-topology
582/// problem ([`AcOtsSolution::new`](powerio_prob::AcOtsSolution::new)).
583/// `module` is the module `instance` belongs to; its records carry over to
584/// the result.
585///
586/// # Errors
587/// As [`AcOtsSolution::new`](powerio_prob::AcOtsSolution::new).
588pub fn ac_ots_solution(
589    module: &powerio_core::PioModule<crate::PioValue>,
590    instance: &powerio_prob::AcOtsInstance,
591    branch_closed: Vec<bool>,
592    ac_solution: powerio_prob::AcOpfSolution,
593) -> Result<powerio_core::PioModule<powerio_prob::AcOtsSolution>, powerio_core::Error> {
594    derive_calculation(
595        module,
596        "ac_ots_solution",
597        "powerio.AcOtsSolution",
598        powerio_prob::AcOtsSolution::new(
599            std::sync::Arc::new(instance.clone()),
600            branch_closed,
601            ac_solution,
602        ),
603    )
604}
605
606/// The network an AC optimal transmission switching solution states: the
607/// final branch statuses, with the AC solution's bus voltages, generator
608/// dispatch, and branch flows applied. Emit this network to write the
609/// solved case; the solution's own network keeps the initial statuses.
610/// `module` is the module `solution` belongs to.
611///
612/// # Errors
613/// A record the derived module cannot carry.
614pub fn materialize_ac_ots_solution(
615    module: &powerio_core::PioModule<crate::PioValue>,
616    solution: &powerio_prob::AcOtsSolution,
617) -> Result<powerio_core::PioModule<BalancedNetwork>, powerio_core::Error> {
618    let mut network = crate::write::network_with_ac_opf_solution(solution.ac_solution());
619    network.assign_missing_component_ids();
620    derive_calculation(
621        module,
622        "materialize_ac_ots_solution",
623        "powerio.BalancedNetwork",
624        Ok(network),
625    )
626}
627
628/// Construct a multiconductor AC power flow calculation from a
629/// multiconductor network module.
630pub fn to_mc_ac_pf_instance(
631    module: &powerio_core::PioModule<crate::PioValue>,
632) -> Result<powerio_core::PioModule<powerio_prob::McAcPfInstance>, powerio_core::Error> {
633    if matches!(module.value(), crate::PioValue::McAcPfInstance(_)) {
634        return Ok(module.clone().map_value(|value| match value {
635            crate::PioValue::McAcPfInstance(instance) => instance,
636            _ => unreachable!("the value type was checked before extraction"),
637        }));
638    }
639    derive_multiconductor_calculation(
640        module,
641        "to_mc_ac_pf_instance",
642        "powerio.McAcPfInstance",
643        powerio_prob::McAcPfInstance::from_network,
644    )
645}
646
647/// Construct a multiconductor AC optimal power flow calculation from a
648/// multiconductor network module.
649pub fn to_mc_ac_opf_instance(
650    module: &powerio_core::PioModule<crate::PioValue>,
651) -> Result<powerio_core::PioModule<powerio_prob::McAcOpfInstance>, powerio_core::Error> {
652    if matches!(module.value(), crate::PioValue::McAcOpfInstance(_)) {
653        return Ok(module.clone().map_value(|value| match value {
654            crate::PioValue::McAcOpfInstance(instance) => instance,
655            _ => unreachable!("the value type was checked before extraction"),
656        }));
657    }
658    derive_multiconductor_calculation(
659        module,
660        "to_mc_ac_opf_instance",
661        "powerio.McAcOpfInstance",
662        powerio_prob::McAcOpfInstance::from_network,
663    )
664}
665
666/// Construct a LinDist3Flow optimal power flow calculation from a
667/// multiconductor network module using the default formulation options.
668///
669/// An already typed LinDist3Flow instance is extracted without reconstruction
670/// or another history entry.
671pub fn to_lindist3flow_opf_instance(
672    module: &powerio_core::PioModule<crate::PioValue>,
673) -> Result<powerio_core::PioModule<powerio_prob::LinDist3FlowOpfInstance>, powerio_core::Error> {
674    to_lindist3flow_opf_instance_with_options(
675        module,
676        powerio_prob::LinDist3FlowBuildOptions::default(),
677    )
678}
679
680/// Construct a LinDist3Flow optimal power flow calculation with explicit
681/// formulation options.
682///
683/// The options are recorded on the transform history entry. An already typed
684/// LinDist3Flow instance is extracted as-is; its own stored options remain
685/// authoritative.
686pub fn to_lindist3flow_opf_instance_with_options(
687    module: &powerio_core::PioModule<crate::PioValue>,
688    options: powerio_prob::LinDist3FlowBuildOptions,
689) -> Result<powerio_core::PioModule<powerio_prob::LinDist3FlowOpfInstance>, powerio_core::Error> {
690    if matches!(module.value(), crate::PioValue::LinDist3FlowOpfInstance(_)) {
691        return Ok(module.clone().map_value(|value| match value {
692            crate::PioValue::LinDist3FlowOpfInstance(instance) => instance,
693            _ => unreachable!("the value type was checked before extraction"),
694        }));
695    }
696    if !matches!(module.value(), crate::PioValue::MulticonductorNetwork(_)) {
697        return Err(powerio_core::Error::new(
698            &codes::REQUEST_MODULE_WRONG_MODEL_KIND,
699            format!(
700                "to_lindist3flow_opf_instance requires powerio.MulticonductorNetwork; the module contains {}",
701                module.value().type_name()
702            ),
703        ));
704    }
705    let options_value = serde_json::to_value(options).map_err(|cause| {
706        powerio_core::Error::new(
707            &codes::TRANSFORM_LINDIST3FLOW_OPTIONS_SERIALIZE_FAILED,
708            "could not record LinDist3Flow build options in transform history",
709        )
710        .with_cause(cause)
711    })?;
712    let serde_json::Value::Object(parameters) = options_value else {
713        return Err(powerio_core::Error::new(
714            &codes::TRANSFORM_LINDIST3FLOW_OPTIONS_SERIALIZE_FAILED,
715            "LinDist3Flow build options did not serialize as an object",
716        ));
717    };
718    let history = HistoryEntry::new(
719        unused_history_id(module, "to_lindist3flow_opf_instance"),
720        HistoryKind::Transform,
721        "to_lindist3flow_opf_instance",
722    )?
723    .with_input_type("powerio.MulticonductorNetwork")?
724    .with_output_type("powerio.LinDist3FlowOpfInstance")?
725    .with_parameters(parameters.into_iter().collect())?;
726    let producer = powerio_core::Producer::new("powerio", crate::VERSION)?;
727    module.clone().try_derive_value(producer, history, |value| {
728        let crate::PioValue::MulticonductorNetwork(network) = value else {
729            unreachable!("the value type was checked before derivation")
730        };
731        powerio_prob::LinDist3FlowOpfInstance::from_network(network, options)
732    })
733}
734
735/// Construct a fixed-dispatch LinDist3Flow power-flow calculation from a
736/// multiconductor network using the default formulation options.
737pub fn to_lindist3flow_pf_instance(
738    module: &powerio_core::PioModule<crate::PioValue>,
739) -> Result<powerio_core::PioModule<powerio_prob::LinDist3FlowPfInstance>, powerio_core::Error> {
740    to_lindist3flow_pf_instance_with_options(
741        module,
742        powerio_prob::LinDist3FlowBuildOptions::default(),
743    )
744}
745
746/// Construct a fixed-dispatch LinDist3Flow power-flow calculation with
747/// explicit formulation options.
748pub fn to_lindist3flow_pf_instance_with_options(
749    module: &powerio_core::PioModule<crate::PioValue>,
750    options: powerio_prob::LinDist3FlowBuildOptions,
751) -> Result<powerio_core::PioModule<powerio_prob::LinDist3FlowPfInstance>, powerio_core::Error> {
752    if matches!(module.value(), crate::PioValue::LinDist3FlowPfInstance(_)) {
753        return Ok(module.clone().map_value(|value| match value {
754            crate::PioValue::LinDist3FlowPfInstance(instance) => instance,
755            _ => unreachable!("the value type was checked before extraction"),
756        }));
757    }
758    if !matches!(module.value(), crate::PioValue::MulticonductorNetwork(_)) {
759        return Err(powerio_core::Error::new(
760            &codes::REQUEST_MODULE_WRONG_MODEL_KIND,
761            format!(
762                "to_lindist3flow_pf_instance requires powerio.MulticonductorNetwork; the module contains {}",
763                module.value().type_name()
764            ),
765        ));
766    }
767    let options_value = serde_json::to_value(options).map_err(|cause| {
768        powerio_core::Error::new(
769            &codes::TRANSFORM_LINDIST3FLOW_OPTIONS_SERIALIZE_FAILED,
770            "could not record LinDist3Flow build options in transform history",
771        )
772        .with_cause(cause)
773    })?;
774    let serde_json::Value::Object(parameters) = options_value else {
775        return Err(powerio_core::Error::new(
776            &codes::TRANSFORM_LINDIST3FLOW_OPTIONS_SERIALIZE_FAILED,
777            "LinDist3Flow build options did not serialize as an object",
778        ));
779    };
780    let history = HistoryEntry::new(
781        unused_history_id(module, "to_lindist3flow_pf_instance"),
782        HistoryKind::Transform,
783        "to_lindist3flow_pf_instance",
784    )?
785    .with_input_type("powerio.MulticonductorNetwork")?
786    .with_output_type("powerio.LinDist3FlowPfInstance")?
787    .with_parameters(parameters.into_iter().collect())?;
788    let producer = powerio_core::Producer::new("powerio", crate::VERSION)?;
789    module.clone().try_derive_value(producer, history, |value| {
790        let crate::PioValue::MulticonductorNetwork(network) = value else {
791            unreachable!("the value type was checked before derivation")
792        };
793        powerio_prob::LinDist3FlowPfInstance::from_network(network, options)
794    })
795}
796
797/// Cap a history note list at the record limit, replacing the overflow with
798/// one note stating how many entries were elided, and normalize every kept
799/// note to the record layer's requirements: NUL replaced, never empty, and
800/// truncated at a character boundary within the identifier bound with a
801/// visible marker. Truncation and elision are always visible, never silent.
802fn copy_history_with_id(history: &HistoryEntry, id: HistoryId) -> HistoryEntry {
803    let mut copied = HistoryEntry::new(id, history.kind(), history.name())
804        .expect("the existing history name is valid")
805        .with_parameters(history.parameters().clone())
806        .expect("the existing parameter set is valid");
807    if let Some(type_name) = history.input_type() {
808        copied = copied
809            .with_input_type(type_name)
810            .expect("the existing input type is valid");
811    }
812    if let Some(type_name) = history.output_type() {
813        copied = copied
814            .with_output_type(type_name)
815            .expect("the existing output type is valid");
816    }
817    for assumption in history.assumptions() {
818        copied = copied
819            .with_assumption(assumption.clone())
820            .expect("the existing assumption list is valid");
821    }
822    for loss in history.losses() {
823        copied = copied
824            .with_loss(loss.clone())
825            .expect("the existing loss list is valid");
826    }
827    copied
828}
829
830/// One history note made valid for the record layer, whatever the source
831/// element names carried: nonempty, free of NUL, within the identifier
832/// bound.
833fn unused_history_id(
834    module: &powerio_core::PioModule<crate::PioValue>,
835    base: &str,
836) -> powerio_core::HistoryId {
837    use powerio_core::HistoryId;
838    let taken: std::collections::BTreeSet<&str> = module
839        .history()
840        .iter()
841        .map(|entry| entry.id().as_str())
842        .collect();
843    if !taken.contains(base) {
844        return HistoryId::new(base).expect("static id is valid");
845    }
846    let mut counter = 2usize;
847    loop {
848        let candidate = format!("{base}-{counter}");
849        if !taken.contains(candidate.as_str()) {
850            return HistoryId::new(candidate).expect("numbered id is valid");
851        }
852        counter += 1;
853    }
854}
855
856fn wrong_kind_error(value: &crate::PioValue) -> powerio_core::Error {
857    powerio_core::Error::new(
858        &codes::TRANSFORM_MULTI_TO_BALANCED_WRONG_MODEL_KIND,
859        format!(
860            "the module carries a {} value; the balanced lowering takes a multiconductor \
861             network",
862            value.type_name()
863        ),
864    )
865}
866
867#[cfg(test)]
868mod history_record_tests {
869    use super::{capped_history_notes, unused_history_id};
870
871    #[test]
872    fn note_overflow_is_stated_within_the_cap() {
873        let cap = powerio_core::limits::MAX_HISTORY_NOTES;
874        let notes: Vec<String> = (0..cap + 40).map(|index| format!("note {index}")).collect();
875        let kept = capped_history_notes(notes, "assumptions");
876        assert_eq!(kept.len(), cap);
877        assert_eq!(kept.last().unwrap(), "41 more assumptions elided");
878
879        let short: Vec<String> = (0..3).map(|index| format!("note {index}")).collect();
880        assert_eq!(capped_history_notes(short.clone(), "assumptions"), short);
881    }
882
883    #[test]
884    fn the_history_id_is_minted_unused() {
885        use powerio_core::{HistoryEntry, HistoryId, HistoryKind, PioModule};
886        let mut module = PioModule::new(crate::PioValue::BalancedNetwork(
887            crate::BalancedNetwork::in_memory("t", 100.0, Vec::new(), Vec::new()),
888        ));
889        assert_eq!(
890            unused_history_id(&module, "multiconductor-to-balanced").as_str(),
891            "multiconductor-to-balanced"
892        );
893        for id in ["multiconductor-to-balanced", "multiconductor-to-balanced-2"] {
894            module
895                .add_history_entry(
896                    HistoryEntry::new(
897                        HistoryId::new(id).unwrap(),
898                        HistoryKind::Transform,
899                        "to_balanced",
900                    )
901                    .unwrap(),
902                )
903                .unwrap();
904        }
905        assert_eq!(
906            unused_history_id(&module, "multiconductor-to-balanced").as_str(),
907            "multiconductor-to-balanced-3"
908        );
909    }
910}