Skip to main content

powerio/
transform.rs

1//! Explicit transformations and their preflight checks.
2//!
3//! A pass that changes one model into another carries the module records
4//! forward and appends transformation history, so the result is auditable.
5//! Emission borrows the module and returns emission diagnostics separately. The
6//! most consequential transformation, multiconductor to balanced, is explicit
7//! and diagnosed, never a silent positive sequence projection.
8
9use std::collections::{BTreeMap, BTreeSet};
10use std::f64::consts::PI;
11
12use num_complex::Complex64;
13use serde::{Deserialize, Serialize};
14
15use crate::{
16    BalancedNetwork, Branch, BranchCharging, Bus, BusId, BusType, Extras as BalancedExtras,
17    Generator, GeoApplyReport, GeoLayer, Load, Shunt, SourceFormat,
18};
19use powerio_core::{Diagnostic, DiagnosticSeverity, HistoryEntry, HistoryId, HistoryKind};
20use powerio_dist::{
21    ConductorMatrix, DistBus, DistLine, DistLineCode, DistLoadVoltageModel, MulticonductorNetwork,
22    NeutralKronOptions, NeutralKronReport,
23};
24
25use crate::codes;
26
27trait DiagnosticTargetExt {
28    fn with_value_target(self, target: String) -> Self;
29}
30
31impl DiagnosticTargetExt for Diagnostic {
32    fn with_value_target(self, target: String) -> Self {
33        self.with_target(target)
34            .expect("transform targets are bounded RFC 6901 pointers")
35    }
36}
37
38/// Records accumulated while the transformation is being built. The public
39/// result exposes the current diagnostic and history records instead of this
40/// implementation detail.
41#[derive(Clone, Debug)]
42struct TransformRecords {
43    options: serde_json::Map<String, serde_json::Value>,
44    assumptions: Vec<String>,
45    approximations: Vec<String>,
46    dropped_fields: Vec<String>,
47    diagnostics: Vec<Diagnostic>,
48}
49
50impl TransformRecords {
51    fn new(options: MulticonductorToBalancedOptions) -> Self {
52        Self {
53            options: options_map(options),
54            assumptions: Vec::new(),
55            approximations: Vec::new(),
56            dropped_fields: Vec::new(),
57            diagnostics: Vec::new(),
58        }
59    }
60}
61
62/// Sequence transform used by the multiconductor to balanced lowering.
63#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
64#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
65#[serde(rename_all = "snake_case")]
66pub enum SequenceTransformConvention {
67    FortescuePowerInvariant,
68}
69
70impl std::fmt::Display for SequenceTransformConvention {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        match self {
73            Self::FortescuePowerInvariant => f.write_str("FortescuePowerInvariant"),
74        }
75    }
76}
77
78const DEFAULT_LOWERING_BASE_MVA: f64 = 100.0;
79const SQRT_3: f64 = 1.732_050_807_568_877_2;
80const COUPLING_TOLERANCE: f64 = 1.0e-9;
81
82fn default_lowering_base_mva() -> f64 {
83    DEFAULT_LOWERING_BASE_MVA
84}
85
86/// Options for the multiconductor to balanced lowering preflight and pass.
87#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
88#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
89pub struct MulticonductorToBalancedOptions {
90    pub convention: SequenceTransformConvention,
91    /// Three phase system power base used for the balanced per-unit projection.
92    #[serde(default = "default_lowering_base_mva")]
93    pub base_mva: f64,
94}
95
96impl Default for MulticonductorToBalancedOptions {
97    fn default() -> Self {
98        Self {
99            convention: SequenceTransformConvention::FortescuePowerInvariant,
100            base_mva: DEFAULT_LOWERING_BASE_MVA,
101        }
102    }
103}
104
105/// Report for the multiconductor to balanced transformation.
106#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
107pub struct MulticonductorToBalancedReport {
108    pub convention: SequenceTransformConvention,
109    pub base_mva: f64,
110    #[serde(default, skip_serializing_if = "Vec::is_empty")]
111    pub assumptions: Vec<String>,
112    #[serde(default, skip_serializing_if = "Vec::is_empty")]
113    pub approximations: Vec<String>,
114    #[serde(default, skip_serializing_if = "Vec::is_empty")]
115    pub diagnostics: Vec<Diagnostic>,
116}
117
118impl MulticonductorToBalancedReport {
119    #[must_use]
120    pub fn is_ready(&self) -> bool {
121        self.diagnostics
122            .iter()
123            .all(|diagnostic| diagnostic.severity() < DiagnosticSeverity::Error)
124    }
125
126    /// The greatest severity among this report's findings, or `None` for a
127    /// clean report.
128    #[must_use]
129    pub fn dominant_severity(&self) -> Option<DiagnosticSeverity> {
130        self.diagnostics
131            .iter()
132            .map(powerio_core::Diagnostic::severity)
133            .max()
134    }
135}
136
137/// A successful raw multiconductor to balanced lowering result.
138#[derive(Clone, Debug)]
139pub struct MulticonductorToBalancedTransformation {
140    pub network: BalancedNetwork,
141    /// Findings produced by the transformation itself.
142    pub diagnostics: Vec<Diagnostic>,
143    /// The current history record for this transformation. A module lowering
144    /// remints its ID when the default ID is already present.
145    pub history: HistoryEntry,
146    /// Buses removed by closed switch merges: removed bus ID to the kept
147    /// bus ID, in the source's spelling.
148    pub merged_buses: BTreeMap<String, String>,
149    /// Closed switches whose merge removed them from the balanced model.
150    pub removed_switches: Vec<String>,
151}
152
153/// Structured failure from the raw multiconductor to balanced lowering pass.
154///
155/// `diagnostics` are current module records. Their targets use the
156/// multiconductor value's pointer grammar (for example `/sources/0/bus`)
157/// because a refusal leaves that value unchanged.
158#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
159pub struct MulticonductorToBalancedError {
160    pub options: MulticonductorToBalancedOptions,
161    #[serde(default, skip_serializing_if = "Vec::is_empty")]
162    pub diagnostics: Vec<Diagnostic>,
163}
164
165impl MulticonductorToBalancedError {
166    pub fn new(options: MulticonductorToBalancedOptions, diagnostics: &[Diagnostic]) -> Self {
167        Self {
168            options,
169            diagnostics: diagnostics.to_vec(),
170        }
171    }
172}
173
174impl std::fmt::Display for MulticonductorToBalancedError {
175    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
176        match self.diagnostics.first() {
177            Some(diagnostic) => write!(f, "{}", diagnostic.message()),
178            None => f.write_str("multiconductor to balanced transformation failed"),
179        }
180    }
181}
182
183impl std::error::Error for MulticonductorToBalancedError {}
184
185/// Check whether a multiconductor network is ready for the lowering pass.
186///
187/// This is a preflight only: it reports the assumptions and blockers that the
188/// lowering would need to account for, but it does not produce a balanced model
189/// and does not append to `history`.
190#[must_use]
191pub fn to_balanced_network_report(
192    net: &MulticonductorNetwork,
193    options: MulticonductorToBalancedOptions,
194) -> MulticonductorToBalancedReport {
195    let mut report = MulticonductorToBalancedReport {
196        convention: options.convention,
197        base_mva: options.base_mva,
198        assumptions: vec![format!(
199            "sequence transform convention: {}",
200            options.convention
201        )],
202        approximations: Vec::new(),
203        diagnostics: Vec::new(),
204    };
205
206    check_options(options, &mut report);
207    check_bus_conductor_sets(net, &mut report);
208    check_phase_reference(net, &mut report);
209    check_line_terminal_maps(net, &mut report);
210    check_linecodes(net, &mut report);
211    check_switches(net, &mut report);
212    check_transformers(net, &mut report);
213    check_untyped_objects(net, &mut report);
214    report
215}
216
217/// Lower a transparent three phase multiconductor network to a balanced model.
218///
219/// The pass is explicit. It does not run from parsers, emitters, matrix builders,
220/// bindings, or PowerIO IR deserialization. Unsupported inputs return structured
221/// `TRANSFORM.MULTI_TO_BALANCED.*` diagnostics in [`MulticonductorToBalancedError`].
222pub fn to_balanced_network(
223    net: &MulticonductorNetwork,
224    options: MulticonductorToBalancedOptions,
225) -> Result<MulticonductorToBalancedTransformation, MulticonductorToBalancedError> {
226    let readiness = to_balanced_network_report(net, options);
227    if !readiness.is_ready() {
228        return Err(MulticonductorToBalancedError::new(
229            options,
230            &readiness.diagnostics,
231        ));
232    }
233
234    let mut state = LoweringState::new(net, options, readiness);
235    state.lower()
236}
237
238/// Readiness of one module's value for the balanced lowering: the #398
239/// inspect operation. The value must be a multiconductor network.
240///
241/// # Errors
242/// A value of any other kind, named.
243pub fn to_balanced_report(
244    module: &powerio_core::PioModule<crate::PioValue>,
245    options: MulticonductorToBalancedOptions,
246) -> Result<MulticonductorToBalancedReport, powerio_core::Error> {
247    let crate::PioValue::MulticonductorNetwork(net) = &module.value() else {
248        return Err(wrong_kind_error(module.value()));
249    };
250    Ok(to_balanced_network_report(net, options))
251}
252
253/// Lower a multiconductor module to a balanced module: the #398 transform
254/// operation. The module's common records carry over, the retained source is
255/// severed because its bytes describe the input value, and the pass appends
256/// its structured findings as module diagnostics and one Transform history
257/// entry stating the chosen base power, every assumption and approximation,
258/// the dropped fields, and the removed bus and switch identities.
259///
260/// # Errors
261/// A value of any other kind (the module comes back untouched), or the
262/// lowering's structured refusal.
263///
264/// # Panics
265/// Only on a broken internal invariant: the pass's diagnostics carry no
266/// identity and no span, the note lists are capped, and the history id is
267/// minted unused, so every record append succeeds.
268#[allow(clippy::result_large_err)]
269pub fn to_balanced(
270    module: powerio_core::PioModule<crate::PioValue>,
271    options: MulticonductorToBalancedOptions,
272) -> Result<
273    powerio_core::PioModule<crate::PioValue>,
274    (
275        powerio_core::PioModule<crate::PioValue>,
276        Box<MulticonductorToBalancedError>,
277    ),
278> {
279    let crate::PioValue::MulticonductorNetwork(net) = &module.value() else {
280        let error = MulticonductorToBalancedError::new(
281            options,
282            &[Diagnostic::of(
283                &codes::TRANSFORM_MULTI_TO_BALANCED_WRONG_MODEL_KIND,
284                format!(
285                    "the module carries a {} value; the balanced lowering takes a \
286                     multiconductor network",
287                    module.value().type_name()
288                ),
289            )],
290        );
291        return Err((module, Box::new(error)));
292    };
293    let lowering = match to_balanced_network(net, options) {
294        Ok(lowering) => lowering,
295        Err(error) => return Err((module, Box::new(error))),
296    };
297    let MulticonductorToBalancedTransformation {
298        network,
299        diagnostics,
300        history,
301        ..
302    } = lowering;
303    // Room for the pass's own records is checked against the module maxima
304    // before the value is consumed, so the additions below hold by
305    // construction and a cap-edge input is refused with its module intact.
306    let diagnostics_room =
307        powerio_core::limits::MAX_MODULE_DIAGNOSTICS.saturating_sub(module.diagnostics().len());
308    let history_room =
309        powerio_core::limits::MAX_MODULE_HISTORY_ENTRIES.saturating_sub(module.history().len());
310    if diagnostics.len() > diagnostics_room || history_room == 0 {
311        let error = MulticonductorToBalancedError::new(
312            options,
313            &[Diagnostic::of(
314                &codes::TRANSFORM_MULTI_TO_BALANCED_RECORD_CAP,
315                "the module cannot hold the lowering's findings and history entry; export a \
316                 fresh module before lowering"
317                    .to_string(),
318            )],
319        );
320        return Err((module, Box::new(error)));
321    }
322    let mut module = module
323        .map_value(|_| crate::PioValue::BalancedNetwork(network))
324        .sever_source();
325    // The value's kind changed, so no RFC 6901 target survives the
326    // transform: pre-existing diagnostic targets and the source map pointed
327    // into the consumed multiconductor value and are severed here; the
328    // pass's own findings are emitted with no target for the same reason.
329    module.sever_value_targets();
330    for diagnostic in &diagnostics {
331        let mut diagnostic = diagnostic.clone();
332        diagnostic.clear_target();
333        module
334            .add_diagnostic(diagnostic)
335            .expect("room was checked; pass diagnostics carry no identity and no span");
336    }
337    let entry = copy_history_with_id(
338        &history,
339        unused_history_id(&module, "multiconductor-to-balanced"),
340    );
341    module
342        .add_history_entry(entry)
343        .expect("room was checked and the history id is unique by construction");
344    Ok(module)
345}
346
347fn derive_balanced_calculation<I>(
348    module: &powerio_core::PioModule<crate::PioValue>,
349    operation: &'static str,
350    output_type: &'static str,
351    build: impl FnOnce(BalancedNetwork) -> Result<I, powerio_core::Error>,
352) -> Result<powerio_core::PioModule<I>, powerio_core::Error> {
353    if !matches!(module.value(), crate::PioValue::BalancedNetwork(_)) {
354        return Err(powerio_core::Error::new(
355            &codes::REQUEST_MODULE_WRONG_MODEL_KIND,
356            format!(
357                "{operation} requires powerio.BalancedNetwork; the module contains {}",
358                module.value().type_name()
359            ),
360        ));
361    }
362    let history = HistoryEntry::new(
363        unused_history_id(module, operation),
364        HistoryKind::Transform,
365        operation,
366    )?
367    .with_input_type("powerio.BalancedNetwork")?
368    .with_output_type(output_type)?;
369    let producer = powerio_core::Producer::new("powerio", crate::VERSION)?;
370    module.clone().try_derive_value(producer, history, |value| {
371        let crate::PioValue::BalancedNetwork(network) = value else {
372            unreachable!("the value type was checked before derivation")
373        };
374        build(network)
375    })
376}
377
378fn derive_multiconductor_calculation<I>(
379    module: &powerio_core::PioModule<crate::PioValue>,
380    operation: &'static str,
381    output_type: &'static str,
382    build: impl FnOnce(MulticonductorNetwork) -> Result<I, powerio_core::Error>,
383) -> Result<powerio_core::PioModule<I>, powerio_core::Error> {
384    if !matches!(module.value(), crate::PioValue::MulticonductorNetwork(_)) {
385        return Err(powerio_core::Error::new(
386            &codes::REQUEST_MODULE_WRONG_MODEL_KIND,
387            format!(
388                "{operation} requires powerio.MulticonductorNetwork; the module contains {}",
389                module.value().type_name()
390            ),
391        ));
392    }
393    let history = HistoryEntry::new(
394        unused_history_id(module, operation),
395        HistoryKind::Transform,
396        operation,
397    )?
398    .with_input_type("powerio.MulticonductorNetwork")?
399    .with_output_type(output_type)?;
400    let producer = powerio_core::Producer::new("powerio", crate::VERSION)?;
401    module.clone().try_derive_value(producer, history, |value| {
402        let crate::PioValue::MulticonductorNetwork(network) = value else {
403            unreachable!("the value type was checked before derivation")
404        };
405        build(network)
406    })
407}
408
409/// Apply one geographic layer to a network module.
410///
411/// Balanced bus points and branch routes use
412/// [`BalancedNetwork::apply_geo_layer`]. Multiconductor coordinates use the
413/// same shared matching rules through [`crate::dist_geo::apply_dist_geo_layer`].
414/// The source module is unchanged. The returned module clears retained bytes
415/// and source mappings, preserves its other records, and appends one
416/// `apply_geo_layer` history entry.
417///
418/// # Errors
419/// The module does not contain a balanced or multiconductor network, or its
420/// records cannot accept the new history entry.
421pub fn apply_geo_layer(
422    module: &powerio_core::PioModule<crate::PioValue>,
423    layer: &GeoLayer,
424) -> Result<(powerio_core::PioModule<crate::PioValue>, GeoApplyReport), powerio_core::Error> {
425    let type_name = match &module.value() {
426        crate::PioValue::BalancedNetwork(_) => "powerio.BalancedNetwork",
427        crate::PioValue::MulticonductorNetwork(_) => "powerio.MulticonductorNetwork",
428        value => {
429            return Err(powerio_core::Error::new(
430                &codes::REQUEST_MODULE_WRONG_MODEL_KIND,
431                format!(
432                    "apply_geo_layer requires powerio.BalancedNetwork or \
433                     powerio.MulticonductorNetwork; the module contains {}",
434                    value.type_name()
435                ),
436            ));
437        }
438    };
439    let history = HistoryEntry::new(
440        unused_history_id(module, "apply-geo-layer"),
441        HistoryKind::Transform,
442        "apply_geo_layer",
443    )?
444    .with_input_type(type_name)?
445    .with_output_type(type_name)?;
446    let producer = powerio_core::Producer::new("powerio", crate::VERSION)?;
447    let mut report = None;
448    let derived = module
449        .clone()
450        .try_derive_value(producer, history, |mut value| {
451            let applied = match &mut value {
452                crate::PioValue::BalancedNetwork(network) => network.apply_geo_layer(layer),
453                crate::PioValue::MulticonductorNetwork(network) => {
454                    crate::dist_geo::apply_dist_geo_layer(network, layer)
455                }
456                value => {
457                    return Err(powerio_core::Error::new(
458                        &codes::REQUEST_MODULE_WRONG_MODEL_KIND,
459                        format!(
460                            "apply_geo_layer requires powerio.BalancedNetwork or \
461                             powerio.MulticonductorNetwork; the module contains {}",
462                            value.type_name()
463                        ),
464                    ));
465                }
466            };
467            report = Some(applied);
468            Ok(value)
469        })?;
470    let report = report.ok_or_else(|| {
471        powerio_core::Error::new(
472            &codes::REQUEST_MODULE_WRONG_MODEL_KIND,
473            "apply_geo_layer did not receive a network value",
474        )
475    })?;
476    Ok((derived, report))
477}
478
479/// Eliminate explicitly grounded neutral conductors with the default Kron
480/// projection options.
481///
482/// The source module is left unchanged. The returned module retains its
483/// records, clears locators into the pre-projection value, appends the
484/// projection findings, and records one same-family transform history entry.
485pub fn neutral_kron(
486    module: &powerio_core::PioModule<crate::PioValue>,
487) -> Result<(powerio_core::PioModule<crate::PioValue>, NeutralKronReport), powerio_core::Error> {
488    neutral_kron_with_options(module, &NeutralKronOptions::default())
489}
490
491/// Eliminate explicitly grounded neutral conductors with explicit projection
492/// options.
493///
494/// The returned report keeps its input-network targets. Copies appended to the
495/// output module have those targets cleared because terminal arrays changed.
496///
497/// # Errors
498/// The module does not contain a multiconductor network, the neutral
499/// projection cannot preserve its semantics, or the derived module cannot
500/// accept the new history and diagnostic records.
501pub fn neutral_kron_with_options(
502    module: &powerio_core::PioModule<crate::PioValue>,
503    options: &NeutralKronOptions,
504) -> Result<(powerio_core::PioModule<crate::PioValue>, NeutralKronReport), powerio_core::Error> {
505    let crate::PioValue::MulticonductorNetwork(network) = module.value() else {
506        return Err(powerio_core::Error::new(
507            &codes::REQUEST_MODULE_WRONG_MODEL_KIND,
508            format!(
509                "neutral_kron requires powerio.MulticonductorNetwork; the module contains {}",
510                module.value().type_name()
511            ),
512        ));
513    };
514    let reduction = powerio_dist::neutral_kron_reduce(network, options).map_err(|cause| {
515        powerio_core::Error::new(cause.code(), cause.to_string()).with_cause(cause)
516    })?;
517    let (network, report) = reduction.into_parts();
518    let neutral_terminals = options
519        .neutral_terminals
520        .iter()
521        .map(|(bus, terminal)| (bus.clone(), serde_json::Value::String(terminal.clone())))
522        .collect();
523    let parameters = BTreeMap::from([
524        (
525            "allow_forced_ideal_ground".to_owned(),
526            serde_json::Value::Bool(options.allow_forced_ideal_ground),
527        ),
528        (
529            "neutral_terminals".to_owned(),
530            serde_json::Value::Object(neutral_terminals),
531        ),
532    ]);
533    let history = HistoryEntry::new(
534        unused_history_id(module, "neutral_kron"),
535        HistoryKind::Transform,
536        "neutral_kron",
537    )?
538    .with_input_type("powerio.MulticonductorNetwork")?
539    .with_output_type("powerio.MulticonductorNetwork")?
540    .with_parameters(parameters)?;
541    let producer = powerio_core::Producer::new("powerio", crate::VERSION)?;
542    let mut derived = module.clone().try_derive_value(
543        producer,
544        history,
545        move |value| match value {
546            crate::PioValue::MulticonductorNetwork(_) => {
547                Ok(crate::PioValue::MulticonductorNetwork(network))
548            }
549            value => Err(powerio_core::Error::new(
550                &codes::REQUEST_MODULE_WRONG_MODEL_KIND,
551                format!(
552                    "neutral_kron requires powerio.MulticonductorNetwork; the module contains {}",
553                    value.type_name()
554                ),
555            )),
556        },
557    )?;
558    for diagnostic in &report.diagnostics {
559        let mut diagnostic = diagnostic.clone();
560        diagnostic.clear_target();
561        derived.add_diagnostic(diagnostic)?;
562    }
563    Ok((derived, report))
564}
565
566/// The balanced network an operating point states: the point's network with
567/// the point's bus voltages, generator dispatch and setpoints, load powers,
568/// service flags, taps, phase shifts, and switch positions applied. The
569/// collection's shared network is not changed. Net bus injection columns have
570/// no network field and are not applied; `emit` reports that omission.
571#[must_use]
572pub fn network_with_operating_point(
573    point: &powerio_prob::OperatingPoint<BalancedNetwork>,
574) -> BalancedNetwork {
575    crate::write::network_with_balanced_operating_point(point, "network").0
576}
577
578/// Construct a DC power flow calculation from a balanced network module.
579/// Module diagnostics, source descriptions, provenance, and prior history are
580/// preserved. Retained source bytes and value locators are cleared because
581/// they describe the network rather than the calculation instance.
582pub fn to_dc_pf_instance(
583    module: &powerio_core::PioModule<crate::PioValue>,
584) -> Result<powerio_core::PioModule<powerio_prob::DcPfInstance>, powerio_core::Error> {
585    if matches!(module.value(), crate::PioValue::DcPfInstance(_)) {
586        return Ok(module.clone().map_value(|value| match value {
587            crate::PioValue::DcPfInstance(instance) => instance,
588            _ => unreachable!("the value type was checked before extraction"),
589        }));
590    }
591    derive_balanced_calculation(
592        module,
593        "to_dc_pf_instance",
594        "powerio.DcPfInstance",
595        powerio_prob::DcPfInstance::from_network,
596    )
597}
598
599/// Construct an AC power flow calculation from a balanced network module.
600pub fn to_ac_pf_instance(
601    module: &powerio_core::PioModule<crate::PioValue>,
602) -> Result<powerio_core::PioModule<powerio_prob::AcPfInstance>, powerio_core::Error> {
603    if matches!(module.value(), crate::PioValue::AcPfInstance(_)) {
604        return Ok(module.clone().map_value(|value| match value {
605            crate::PioValue::AcPfInstance(instance) => instance,
606            _ => unreachable!("the value type was checked before extraction"),
607        }));
608    }
609    derive_balanced_calculation(
610        module,
611        "to_ac_pf_instance",
612        "powerio.AcPfInstance",
613        powerio_prob::AcPfInstance::from_network,
614    )
615}
616
617/// Construct a DC optimal power flow calculation from a balanced network
618/// module.
619pub fn to_dc_opf_instance(
620    module: &powerio_core::PioModule<crate::PioValue>,
621) -> Result<powerio_core::PioModule<powerio_prob::DcOpfInstance>, powerio_core::Error> {
622    if matches!(module.value(), crate::PioValue::DcOpfInstance(_)) {
623        return Ok(module.clone().map_value(|value| match value {
624            crate::PioValue::DcOpfInstance(instance) => instance,
625            _ => unreachable!("the value type was checked before extraction"),
626        }));
627    }
628    derive_balanced_calculation(
629        module,
630        "to_dc_opf_instance",
631        "powerio.DcOpfInstance",
632        powerio_prob::DcOpfInstance::from_network,
633    )
634}
635
636/// Construct an AC optimal power flow calculation from a balanced network
637/// module.
638pub fn to_ac_opf_instance(
639    module: &powerio_core::PioModule<crate::PioValue>,
640) -> Result<powerio_core::PioModule<powerio_prob::AcOpfInstance>, powerio_core::Error> {
641    if matches!(module.value(), crate::PioValue::AcOpfInstance(_)) {
642        return Ok(module.clone().map_value(|value| match value {
643            crate::PioValue::AcOpfInstance(instance) => instance,
644            _ => unreachable!("the value type was checked before extraction"),
645        }));
646    }
647    derive_balanced_calculation(
648        module,
649        "to_ac_opf_instance",
650        "powerio.AcOpfInstance",
651        powerio_prob::AcOpfInstance::from_network,
652    )
653}
654
655/// Construct a multiconductor AC power flow calculation from a
656/// multiconductor network module.
657pub fn to_mc_ac_pf_instance(
658    module: &powerio_core::PioModule<crate::PioValue>,
659) -> Result<powerio_core::PioModule<powerio_prob::McAcPfInstance>, powerio_core::Error> {
660    if matches!(module.value(), crate::PioValue::McAcPfInstance(_)) {
661        return Ok(module.clone().map_value(|value| match value {
662            crate::PioValue::McAcPfInstance(instance) => instance,
663            _ => unreachable!("the value type was checked before extraction"),
664        }));
665    }
666    derive_multiconductor_calculation(
667        module,
668        "to_mc_ac_pf_instance",
669        "powerio.McAcPfInstance",
670        powerio_prob::McAcPfInstance::from_network,
671    )
672}
673
674/// Construct a multiconductor AC optimal power flow calculation from a
675/// multiconductor network module.
676pub fn to_mc_ac_opf_instance(
677    module: &powerio_core::PioModule<crate::PioValue>,
678) -> Result<powerio_core::PioModule<powerio_prob::McAcOpfInstance>, powerio_core::Error> {
679    if matches!(module.value(), crate::PioValue::McAcOpfInstance(_)) {
680        return Ok(module.clone().map_value(|value| match value {
681            crate::PioValue::McAcOpfInstance(instance) => instance,
682            _ => unreachable!("the value type was checked before extraction"),
683        }));
684    }
685    derive_multiconductor_calculation(
686        module,
687        "to_mc_ac_opf_instance",
688        "powerio.McAcOpfInstance",
689        powerio_prob::McAcOpfInstance::from_network,
690    )
691}
692
693/// Construct a LinDist3Flow optimal power flow calculation from a
694/// multiconductor network module using the default formulation options.
695///
696/// An already typed LinDist3Flow instance is extracted without reconstruction
697/// or another history entry.
698pub fn to_lindist3flow_opf_instance(
699    module: &powerio_core::PioModule<crate::PioValue>,
700) -> Result<powerio_core::PioModule<powerio_prob::LinDist3FlowOpfInstance>, powerio_core::Error> {
701    to_lindist3flow_opf_instance_with_options(
702        module,
703        powerio_prob::LinDist3FlowBuildOptions::default(),
704    )
705}
706
707/// Construct a LinDist3Flow optimal power flow calculation with explicit
708/// formulation options.
709///
710/// The options are recorded on the transform history entry. An already typed
711/// LinDist3Flow instance is extracted as-is; its own stored options remain
712/// authoritative.
713pub fn to_lindist3flow_opf_instance_with_options(
714    module: &powerio_core::PioModule<crate::PioValue>,
715    options: powerio_prob::LinDist3FlowBuildOptions,
716) -> Result<powerio_core::PioModule<powerio_prob::LinDist3FlowOpfInstance>, powerio_core::Error> {
717    if matches!(module.value(), crate::PioValue::LinDist3FlowOpfInstance(_)) {
718        return Ok(module.clone().map_value(|value| match value {
719            crate::PioValue::LinDist3FlowOpfInstance(instance) => instance,
720            _ => unreachable!("the value type was checked before extraction"),
721        }));
722    }
723    if !matches!(module.value(), crate::PioValue::MulticonductorNetwork(_)) {
724        return Err(powerio_core::Error::new(
725            &codes::REQUEST_MODULE_WRONG_MODEL_KIND,
726            format!(
727                "to_lindist3flow_opf_instance requires powerio.MulticonductorNetwork; the module contains {}",
728                module.value().type_name()
729            ),
730        ));
731    }
732    let options_value = serde_json::to_value(options).map_err(|cause| {
733        powerio_core::Error::new(
734            &codes::TRANSFORM_LINDIST3FLOW_OPTIONS_SERIALIZE_FAILED,
735            "could not record LinDist3Flow build options in transform history",
736        )
737        .with_cause(cause)
738    })?;
739    let serde_json::Value::Object(parameters) = options_value else {
740        return Err(powerio_core::Error::new(
741            &codes::TRANSFORM_LINDIST3FLOW_OPTIONS_SERIALIZE_FAILED,
742            "LinDist3Flow build options did not serialize as an object",
743        ));
744    };
745    let history = HistoryEntry::new(
746        unused_history_id(module, "to_lindist3flow_opf_instance"),
747        HistoryKind::Transform,
748        "to_lindist3flow_opf_instance",
749    )?
750    .with_input_type("powerio.MulticonductorNetwork")?
751    .with_output_type("powerio.LinDist3FlowOpfInstance")?
752    .with_parameters(parameters.into_iter().collect())?;
753    let producer = powerio_core::Producer::new("powerio", crate::VERSION)?;
754    module.clone().try_derive_value(producer, history, |value| {
755        let crate::PioValue::MulticonductorNetwork(network) = value else {
756            unreachable!("the value type was checked before derivation")
757        };
758        powerio_prob::LinDist3FlowOpfInstance::from_network(network, options)
759    })
760}
761
762/// Cap a history note list at the record limit, replacing the overflow with
763/// one note stating how many entries were elided, and normalize every kept
764/// note to the record layer's requirements: NUL replaced, never empty, and
765/// truncated at a character boundary within the identifier bound with a
766/// visible marker. Truncation and elision are always visible, never silent.
767fn capped_history_notes(notes: Vec<String>, what: &str) -> Vec<String> {
768    let cap = powerio_core::limits::MAX_HISTORY_NOTES;
769    if notes.len() <= cap {
770        return notes.into_iter().map(normalized_note).collect();
771    }
772    let elided = notes.len() - (cap - 1);
773    let mut kept: Vec<String> = notes
774        .into_iter()
775        .take(cap - 1)
776        .map(normalized_note)
777        .collect();
778    kept.push(format!("{elided} more {what} elided"));
779    kept
780}
781
782fn transform_history(
783    id: HistoryId,
784    records: &TransformRecords,
785    merged_buses: &BTreeMap<String, String>,
786    removed_switches: &[String],
787) -> HistoryEntry {
788    let parameters: BTreeMap<String, serde_json::Value> =
789        records.options.clone().into_iter().collect();
790    let mut entry = HistoryEntry::new(id, HistoryKind::Transform, "to_balanced")
791        .expect("the static history name is valid")
792        .with_input_type("powerio.MulticonductorNetwork")
793        .expect("the registered input type is valid")
794        .with_output_type("powerio.BalancedNetwork")
795        .expect("the registered output type is valid")
796        .with_parameters(parameters)
797        .expect("the transformation has a bounded parameter set");
798
799    let mut assumptions = records.assumptions.clone();
800    assumptions.extend(
801        records
802            .approximations
803            .iter()
804            .map(|note| format!("approximation: {note}")),
805    );
806    assumptions.extend(
807        merged_buses
808            .iter()
809            .map(|(removed, kept)| format!("bus {removed} merged into bus {kept}")),
810    );
811    assumptions.extend(
812        removed_switches
813            .iter()
814            .map(|switch| format!("switch {switch} removed by its bus merge")),
815    );
816    for assumption in capped_history_notes(assumptions, "assumptions") {
817        entry = entry
818            .with_assumption(assumption)
819            .expect("the note list is under the history cap by construction");
820    }
821    for loss in capped_history_notes(records.dropped_fields.clone(), "losses") {
822        entry = entry
823            .with_loss(loss)
824            .expect("the loss list is under the history cap by construction");
825    }
826    entry
827}
828
829fn copy_history_with_id(history: &HistoryEntry, id: HistoryId) -> HistoryEntry {
830    let mut copied = HistoryEntry::new(id, history.kind(), history.name())
831        .expect("the existing history name is valid")
832        .with_parameters(history.parameters().clone())
833        .expect("the existing parameter set is valid");
834    if let Some(type_name) = history.input_type() {
835        copied = copied
836            .with_input_type(type_name)
837            .expect("the existing input type is valid");
838    }
839    if let Some(type_name) = history.output_type() {
840        copied = copied
841            .with_output_type(type_name)
842            .expect("the existing output type is valid");
843    }
844    for assumption in history.assumptions() {
845        copied = copied
846            .with_assumption(assumption.clone())
847            .expect("the existing assumption list is valid");
848    }
849    for loss in history.losses() {
850        copied = copied
851            .with_loss(loss.clone())
852            .expect("the existing loss list is valid");
853    }
854    copied
855}
856
857/// One history note made valid for the record layer, whatever the source
858/// element names carried: nonempty, free of NUL, within the identifier
859/// bound.
860fn normalized_note(note: String) -> String {
861    let mut note = if note.contains('\0') {
862        note.replace('\0', "\u{fffd}")
863    } else {
864        note
865    };
866    let bound = powerio_core::limits::MAX_IDENTIFIER_BYTES;
867    if note.len() > bound {
868        let marker = " [truncated]";
869        let mut end = bound - marker.len();
870        while !note.is_char_boundary(end) {
871            end -= 1;
872        }
873        note.truncate(end);
874        note.push_str(marker);
875    }
876    if note.is_empty() {
877        note.push_str("(an empty note was elided)");
878    }
879    note
880}
881
882/// A history id unused by the module: the stable name, then a numbered
883/// spelling when a prior lowering already recorded one.
884fn unused_history_id(
885    module: &powerio_core::PioModule<crate::PioValue>,
886    base: &str,
887) -> powerio_core::HistoryId {
888    use powerio_core::HistoryId;
889    let taken: std::collections::BTreeSet<&str> = module
890        .history()
891        .iter()
892        .map(|entry| entry.id().as_str())
893        .collect();
894    if !taken.contains(base) {
895        return HistoryId::new(base).expect("static id is valid");
896    }
897    let mut counter = 2usize;
898    loop {
899        let candidate = format!("{base}-{counter}");
900        if !taken.contains(candidate.as_str()) {
901            return HistoryId::new(candidate).expect("numbered id is valid");
902        }
903        counter += 1;
904    }
905}
906
907fn wrong_kind_error(value: &crate::PioValue) -> powerio_core::Error {
908    powerio_core::Error::new(
909        &codes::TRANSFORM_MULTI_TO_BALANCED_WRONG_MODEL_KIND,
910        format!(
911            "the module carries a {} value; the balanced lowering takes a multiconductor \
912             network",
913            value.type_name()
914        ),
915    )
916}
917
918struct LoweringState<'a> {
919    net: &'a MulticonductorNetwork,
920    options: MulticonductorToBalancedOptions,
921    neutral_terminals: BTreeSet<String>,
922    /// Every multiconductor bus (lowercase) to its balanced bus: merged
923    /// members map to their canonical bus's ID.
924    bus_ids: BTreeMap<String, BusId>,
925    /// Lowercase bus id to its canonical member's row index.
926    canonical_rows: BTreeMap<String, usize>,
927    /// Removed bus ID to kept bus ID, source spelling.
928    merged_buses: BTreeMap<String, String>,
929    removed_switches: Vec<String>,
930    /// Per bus (lowercase) line to line voltage base in volts.
931    bus_base: BTreeMap<String, f64>,
932    records: TransformRecords,
933}
934
935impl<'a> LoweringState<'a> {
936    fn new(
937        net: &'a MulticonductorNetwork,
938        options: MulticonductorToBalancedOptions,
939        readiness: MulticonductorToBalancedReport,
940    ) -> Self {
941        let mut records = TransformRecords::new(options);
942        records.assumptions = readiness.assumptions;
943        records.approximations = readiness.approximations;
944        records.diagnostics = readiness.diagnostics;
945        records
946            .assumptions
947            .push(format!("balanced power base: {} MVA", options.base_mva));
948        records
949            .assumptions
950            .push("balanced bus ids are synthesized from multiconductor bus order".to_owned());
951        records.approximations.push(
952            "wire-coordinate branch and shunt matrices are projected to positive sequence"
953                .to_owned(),
954        );
955        records.approximations.push(
956            "phase injection records are aggregated into scalar balanced injections".to_owned(),
957        );
958        records.approximations.push(
959            "units are converted from W/var/V/ohm/siemens/radians to MW/MVAr/per-unit/degrees"
960                .to_owned(),
961        );
962        if net.switches().iter().any(|sw| sw.open) {
963            records
964                .dropped_fields
965                .push("open switches dropped from balanced model".to_owned());
966        }
967
968        // Union closed switch endpoints: preflight already refused every
969        // blocked merge, so a closed switch here merges its buses. The
970        // canonical member is the earliest bus row; merged rows disappear
971        // from the balanced model and the mapping is recorded.
972        let row_of: BTreeMap<String, usize> = net
973            .buses()
974            .iter()
975            .enumerate()
976            .map(|(row, bus)| (bus.id.to_ascii_lowercase(), row))
977            .collect();
978        let mut union = UnionFind::new(net.buses().len());
979        let mut removed_switches = Vec::new();
980        for sw in net.switches().iter().filter(|sw| !sw.open) {
981            let (Some(&from), Some(&to)) = (
982                row_of.get(&sw.bus_from.to_ascii_lowercase()),
983                row_of.get(&sw.bus_to.to_ascii_lowercase()),
984            ) else {
985                continue;
986            };
987            union.join(from, to);
988            removed_switches.push(sw.name.clone());
989            records.assumptions.push(format!(
990                "closed switch {} merged bus {} into bus {} and was removed; no impedance \
991                 was invented for it",
992                sw.name, sw.bus_to, sw.bus_from
993            ));
994        }
995        let mut canonical_rows = BTreeMap::new();
996        let mut merged_buses = BTreeMap::new();
997        let mut number = BTreeMap::new();
998        for (row, bus) in net.buses().iter().enumerate() {
999            let root = union.root(row);
1000            if root == row {
1001                let id = BusId(number.len() + 1);
1002                number.insert(row, id);
1003            } else {
1004                merged_buses.insert(bus.id.clone(), net.buses()[root].id.clone());
1005            }
1006            canonical_rows.insert(bus.id.to_ascii_lowercase(), root);
1007        }
1008        let bus_ids = net
1009            .buses()
1010            .iter()
1011            .map(|bus| {
1012                let key = bus.id.to_ascii_lowercase();
1013                let root = union.root(row_of[&key]);
1014                (key, number[&root])
1015            })
1016            .collect();
1017
1018        Self {
1019            net,
1020            options,
1021            neutral_terminals: global_neutral_terminals(net),
1022            bus_ids,
1023            canonical_rows,
1024            merged_buses,
1025            removed_switches,
1026            bus_base: BTreeMap::new(),
1027            records,
1028        }
1029    }
1030
1031    #[allow(clippy::too_many_lines)]
1032    fn lower(
1033        &mut self,
1034    ) -> Result<MulticonductorToBalancedTransformation, MulticonductorToBalancedError> {
1035        let Some(base) = self.voltage_base()? else {
1036            return Err(MulticonductorToBalancedError::new(
1037                self.options,
1038                &self.records.diagnostics,
1039            ));
1040        };
1041
1042        self.assign_bus_bases(base);
1043        let buses = self.lower_buses(base);
1044        let mut branches = self.lower_lines()?;
1045        branches.extend(self.lower_transformers());
1046        let loads = self.lower_loads();
1047        let shunts = self.lower_shunts()?;
1048        let generators = self.lower_generators(&buses);
1049        self.record_capacitor_drops();
1050        self.err_if_errors()?;
1051
1052        let mut network = BalancedNetwork::new(
1053            self.net
1054                .name()
1055                .clone()
1056                .unwrap_or_else(|| "lowered-multiconductor".to_owned()),
1057            self.options.base_mva,
1058        );
1059        *network.base_frequency_mut() = self.net.base_frequency();
1060        *network.buses_mut() = buses;
1061        *network.loads_mut() = loads;
1062        *network.shunts_mut() = shunts;
1063        *network.branches_mut() = branches;
1064        *network.generators_mut() = generators;
1065        *network.source_format_mut() = SourceFormat::InMemory;
1066        if let Err(err) = network.validate() {
1067            self.records.diagnostics.push(Diagnostic::of(
1068                &codes::TRANSFORM_MULTI_TO_BALANCED_INVALID_BALANCED_OUTPUT,
1069                format!("lowered balanced network failed structural validation: {err}"),
1070            ));
1071            return Err(MulticonductorToBalancedError::new(
1072                self.options,
1073                &self.records.diagnostics,
1074            ));
1075        }
1076        for finding in network.validate_values() {
1077            let details = finding.details();
1078            self.records.diagnostics.push(
1079                Diagnostic::of(
1080                    &codes::TRANSFORM_MULTI_TO_BALANCED_BALANCED_VALUE_DOMAIN,
1081                    format!(
1082                        "{} field `{}` is outside its value domain after lowering",
1083                        details["element"].as_str().unwrap_or_default(),
1084                        details["field"].as_str().unwrap_or_default()
1085                    ),
1086                )
1087                .with_suggested_action(
1088                    "Inspect the multiconductor source values before using the lowered model.",
1089                ),
1090            );
1091        }
1092
1093        let history = transform_history(
1094            HistoryId::new("multiconductor-to-balanced").expect("static history id is valid"),
1095            &self.records,
1096            &self.merged_buses,
1097            &self.removed_switches,
1098        );
1099        Ok(MulticonductorToBalancedTransformation {
1100            network,
1101            diagnostics: self.records.diagnostics.clone(),
1102            history,
1103            merged_buses: self.merged_buses.clone(),
1104            removed_switches: self.removed_switches.clone(),
1105        })
1106    }
1107
1108    /// Voltage bases by zone: buses joined by lines or merged switches share
1109    /// one base; a zone with a source takes the source's positive sequence
1110    /// magnitude; a supported transformer bases the zone across it with the
1111    /// far winding's rated voltage; anything still unbased defaults to the
1112    /// reference base with a note.
1113    fn assign_bus_bases(&mut self, reference: VoltageBase) {
1114        let row_of: BTreeMap<String, usize> = self
1115            .net
1116            .buses()
1117            .iter()
1118            .enumerate()
1119            .map(|(row, bus)| (bus.id.to_ascii_lowercase(), row))
1120            .collect();
1121        let mut zones = UnionFind::new(self.net.buses().len());
1122        for line in self.net.lines() {
1123            if let (Some(&from), Some(&to)) = (
1124                row_of.get(&line.bus_from.to_ascii_lowercase()),
1125                row_of.get(&line.bus_to.to_ascii_lowercase()),
1126            ) {
1127                zones.join(from, to);
1128            }
1129        }
1130        for sw in self.net.switches().iter().filter(|sw| !sw.open) {
1131            if let (Some(&from), Some(&to)) = (
1132                row_of.get(&sw.bus_from.to_ascii_lowercase()),
1133                row_of.get(&sw.bus_to.to_ascii_lowercase()),
1134            ) {
1135                zones.join(from, to);
1136            }
1137        }
1138
1139        let mut zone_base: BTreeMap<usize, f64> = BTreeMap::new();
1140        for source in self.net.sources() {
1141            let Some(&row) = row_of.get(&source.bus.to_ascii_lowercase()) else {
1142                continue;
1143            };
1144            let bus = self.net.bus(&source.bus);
1145            let positions = active_positions(&source.terminal_map, bus, &self.neutral_terminals);
1146            if positions.len() != 3 {
1147                continue;
1148            }
1149            let Some(v1) = positive_sequence_voltage(source, &positions) else {
1150                continue;
1151            };
1152            if v1.norm().is_finite() && v1.norm() > 0.0 {
1153                zone_base.entry(zones.root(row)).or_insert(v1.norm());
1154            }
1155        }
1156
1157        let supported: Vec<(usize, usize, f64, f64)> = self
1158            .net
1159            .transformers()
1160            .iter()
1161            .filter_map(|transformer| {
1162                let [high, low] =
1163                    classify_transformer(self.net, transformer, &self.neutral_terminals).ok()?;
1164                let high_row = *row_of.get(&high.bus.to_ascii_lowercase())?;
1165                let low_row = *row_of.get(&low.bus.to_ascii_lowercase())?;
1166                Some((high_row, low_row, high.v_ref, low.v_ref))
1167            })
1168            .collect();
1169        loop {
1170            let mut changed = false;
1171            for &(high_row, low_row, high_v, low_v) in &supported {
1172                let (high_zone, low_zone) = (zones.root(high_row), zones.root(low_row));
1173                match (
1174                    zone_base.contains_key(&high_zone),
1175                    zone_base.contains_key(&low_zone),
1176                ) {
1177                    (true, false) => {
1178                        zone_base.insert(low_zone, low_v);
1179                        changed = true;
1180                    }
1181                    (false, true) => {
1182                        zone_base.insert(high_zone, high_v);
1183                        changed = true;
1184                    }
1185                    _ => {}
1186                }
1187            }
1188            if !changed {
1189                break;
1190            }
1191        }
1192
1193        for (row, bus) in self.net.buses().iter().enumerate() {
1194            let zone = zones.root(row);
1195            let base = zone_base.get(&zone).copied().unwrap_or_else(|| {
1196                self.records.dropped_fields.push(format!(
1197                    "bus {} voltage base defaulted to the reference base",
1198                    bus.id
1199                ));
1200                reference.line_to_line_volts
1201            });
1202            self.bus_base.insert(bus.id.to_ascii_lowercase(), base);
1203        }
1204    }
1205
1206    /// The line to line voltage base of one bus, in volts.
1207    fn base_volts(&self, bus: &str) -> f64 {
1208        self.bus_base
1209            .get(&bus.to_ascii_lowercase())
1210            .copied()
1211            .expect("every declared bus was based")
1212    }
1213
1214    fn voltage_base(&mut self) -> Result<Option<VoltageBase>, MulticonductorToBalancedError> {
1215        for (idx, source) in self.net.sources().iter().enumerate() {
1216            let Some(bus) = self.net.bus(&source.bus) else {
1217                self.records.diagnostics.push(
1218                    Diagnostic::of(
1219                        &codes::TRANSFORM_MULTI_TO_BALANCED_UNKNOWN_SOURCE_BUS,
1220                        format!(
1221                            "voltage source {} references unknown bus {}",
1222                            source.name, source.bus
1223                        ),
1224                    )
1225                    .with_value_target(format!("/sources/{idx}/bus")),
1226                );
1227                continue;
1228            };
1229            let positions =
1230                active_positions(&source.terminal_map, Some(bus), &self.neutral_terminals);
1231            if positions.len() != 3 {
1232                continue;
1233            }
1234            let Some(v1) = positive_sequence_voltage(source, &positions) else {
1235                self.records.diagnostics.push(
1236                    Diagnostic::of(
1237    &codes::TRANSFORM_MULTI_TO_BALANCED_INVALID_PHASE_REFERENCE,
1238format!(
1239                            "voltage source {} does not carry finite three phase voltage magnitudes and angles",
1240                            source.name
1241                        ),
1242                    )
1243                    .with_value_target(format!("/sources/{idx}")),
1244                );
1245                continue;
1246            };
1247            let line_to_line_volts = v1.norm();
1248            if !line_to_line_volts.is_finite() || line_to_line_volts <= 0.0 {
1249                self.records.diagnostics.push(
1250                    Diagnostic::of(
1251    &codes::TRANSFORM_MULTI_TO_BALANCED_INVALID_PHASE_REFERENCE,
1252format!(
1253                            "voltage source {} produced a non-positive positive-sequence voltage base",
1254                            source.name
1255                        ),
1256                    )
1257                    .with_value_target(format!("/sources/{idx}")),
1258                );
1259                continue;
1260            }
1261            self.records.assumptions.push(format!(
1262                "voltage base synthesized from source {} positive-sequence voltage: {} kV line-to-line",
1263                source.name,
1264                line_to_line_volts / 1000.0
1265            ));
1266            return Ok(Some(VoltageBase { line_to_line_volts }));
1267        }
1268
1269        if self
1270            .records
1271            .diagnostics
1272            .iter()
1273            .any(|d| d.severity() >= DiagnosticSeverity::Error)
1274        {
1275            return Err(MulticonductorToBalancedError::new(
1276                self.options,
1277                &self.records.diagnostics,
1278            ));
1279        }
1280        self.records.diagnostics.push(Diagnostic::of(
1281    &codes::TRANSFORM_MULTI_TO_BALANCED_MISSING_PHASE_REFERENCE,
1282"multiconductor to balanced lowering requires a finite three phase voltage source reference",
1283        ));
1284        Ok(None)
1285    }
1286
1287    fn lower_buses(&mut self, _reference: VoltageBase) -> Vec<Bus> {
1288        // Canonical buses only: a merged member's data folds into its
1289        // canonical bus, and its identity is recorded in `merged_buses`.
1290        let mut members: BTreeMap<usize, Vec<&DistBus>> = BTreeMap::new();
1291        for bus in self.net.buses() {
1292            let root = self.canonical_rows[&bus.id.to_ascii_lowercase()];
1293            members.entry(root).or_default().push(bus);
1294        }
1295        let mut balanced_buses = Vec::with_capacity(members.len());
1296        for (&root, group) in &members {
1297            let canonical = &self.net.buses()[root];
1298            let base_volts = self.base_volts(&canonical.id);
1299            let sourced = group.iter().find_map(|bus| {
1300                self.net
1301                    .sources()
1302                    .iter()
1303                    .find(|source| source.bus.eq_ignore_ascii_case(&bus.id))
1304                    .map(|source| (*bus, source))
1305            });
1306            let (vm, va) = sourced
1307                .and_then(|(bus, source)| {
1308                    let positions =
1309                        active_positions(&source.terminal_map, Some(bus), &self.neutral_terminals);
1310                    positive_sequence_voltage(source, &positions)
1311                })
1312                .map_or((1.0, 0.0), |v| {
1313                    (v.norm() / base_volts, radians_to_degrees(v.arg()))
1314                });
1315            if sourced.is_none() {
1316                self.records.dropped_fields.push(format!(
1317                    "bus {} voltage magnitude and angle defaulted to 1.0 p.u. and 0 degrees",
1318                    canonical.id
1319                ));
1320            }
1321            // Preflight refused conflicting stated bounds across a merge, so
1322            // the first member stating both carries the group's bounds.
1323            let stated = group.iter().find_map(|bus| match (bus.v_min, bus.v_max) {
1324                (Some(vmin), Some(vmax)) if vmin.is_finite() && vmax.is_finite() => {
1325                    Some((vmin / base_volts, vmax / base_volts))
1326                }
1327                _ => None,
1328            });
1329            let (vmin, vmax) = stated.unwrap_or_else(|| {
1330                self.records.dropped_fields.push(format!(
1331                    "bus {} voltage bounds defaulted to 0.9/1.1 p.u.",
1332                    canonical.id
1333                ));
1334                (0.9, 1.1)
1335            });
1336            for bus in group {
1337                self.record_bus_bound_drops(bus);
1338            }
1339            let kind = group
1340                .iter()
1341                .map(|bus| self.bus_kind(&bus.id))
1342                .min_by_key(|kind| match kind {
1343                    BusType::Ref => 0,
1344                    BusType::Pv => 1,
1345                    _ => 2,
1346                })
1347                .unwrap_or(BusType::Pq);
1348            let mut balanced = Bus::new(
1349                self.bus_ids[&canonical.id.to_ascii_lowercase()],
1350                kind,
1351                base_volts / 1000.0,
1352            );
1353            balanced.vm = vm;
1354            balanced.va = va;
1355            balanced.vmax = vmax;
1356            balanced.vmin = vmin;
1357            balanced.name = Some(canonical.id.clone());
1358            balanced.extras = source_extra("multiconductor_bus_id", &canonical.id);
1359            balanced_buses.push(balanced);
1360        }
1361        balanced_buses
1362    }
1363
1364    /// A rated capacitor bank (BMOPF schema 0.1.0 `capacitor`) has no
1365    /// balanced equivalent yet: `q_rated` at `v_nom` is a nameplate rating,
1366    /// not the admittance a balanced `Shunt` carries. The bank therefore
1367    /// drops, and the record names it, because a silent drop removes
1368    /// reactive support the case depends on.
1369    fn record_capacitor_drops(&mut self) {
1370        for capacitor in self.net.capacitors() {
1371            self.records.dropped_fields.push(format!(
1372                "capacitor {} dropped: a rated bank has no balanced shunt equivalent",
1373                capacitor.name
1374            ));
1375        }
1376    }
1377
1378    fn record_bus_bound_drops(&mut self, bus: &DistBus) {
1379        if bus.vpn_min.is_some()
1380            || bus.vpn_max.is_some()
1381            || bus.vpp_min.is_some()
1382            || bus.vpp_max.is_some()
1383            || bus.vpos_min.is_some()
1384            || bus.vpos_max.is_some()
1385            || bus.vneg_max.is_some()
1386            || bus.vzero_max.is_some()
1387            || bus.vn_max.is_some()
1388        {
1389            self.records.dropped_fields.push(format!(
1390                "bus {} conductor voltage bound families dropped",
1391                bus.id
1392            ));
1393        }
1394    }
1395
1396    fn bus_kind(&self, bus_id: &str) -> BusType {
1397        if self
1398            .net
1399            .sources()
1400            .iter()
1401            .any(|source| source.bus.eq_ignore_ascii_case(bus_id))
1402        {
1403            BusType::Ref
1404        } else if self
1405            .net
1406            .generators()
1407            .iter()
1408            .any(|generator| generator.bus.eq_ignore_ascii_case(bus_id))
1409        {
1410            BusType::Pv
1411        } else {
1412            BusType::Pq
1413        }
1414    }
1415
1416    #[allow(clippy::too_many_lines)]
1417    fn lower_lines(&mut self) -> Result<Vec<Branch>, MulticonductorToBalancedError> {
1418        let mut branches = Vec::with_capacity(self.net.lines().len());
1419        for (idx, line) in self.net.lines().iter().enumerate() {
1420            let Some(code) = self.net.linecode(&line.linecode) else {
1421                self.records.diagnostics.push(
1422                    Diagnostic::of(
1423                        &codes::TRANSFORM_MULTI_TO_BALANCED_UNKNOWN_LINECODE,
1424                        format!(
1425                            "line {} references unknown linecode `{}`",
1426                            line.name, line.linecode
1427                        ),
1428                    )
1429                    .with_value_target(format!("/lines/{idx}/linecode")),
1430                );
1431                continue;
1432            };
1433            if !same_active_phase_order(
1434                self.net.bus(&line.bus_from),
1435                &line.terminal_map_from,
1436                self.net.bus(&line.bus_to),
1437                &line.terminal_map_to,
1438                &self.neutral_terminals,
1439            ) {
1440                self.records.diagnostics.push(
1441                    Diagnostic::of(
1442    &codes::TRANSFORM_MULTI_TO_BALANCED_PHASE_MAP_MISMATCH,
1443format!(
1444                            "line {} connects different active terminal orders and cannot be lowered transparently",
1445                            line.name
1446                        ),
1447                    )
1448                    .with_value_target(format!("/lines/{idx}")),
1449                );
1450                continue;
1451            }
1452            let Some(from) = self.bus_id(&line.bus_from) else {
1453                self.unknown_bus_diag("line", &line.name, &line.bus_from, idx, "bus_from");
1454                continue;
1455            };
1456            let Some(to) = self.bus_id(&line.bus_to) else {
1457                self.unknown_bus_diag("line", &line.name, &line.bus_to, idx, "bus_to");
1458                continue;
1459            };
1460            let from_bus = self.net.bus(&line.bus_from);
1461            let active =
1462                active_positions(&line.terminal_map_from, from_bus, &self.neutral_terminals);
1463            let neutral =
1464                neutral_positions(&line.terminal_map_from, from_bus, &self.neutral_terminals);
1465            let z_ohm =
1466                self.line_positive_sequence_impedance(idx, code, &active, &neutral, line.length)?;
1467            let y_from = self.line_positive_sequence_admittance(
1468                idx,
1469                code,
1470                &active,
1471                &neutral,
1472                line.length,
1473                ShuntSide::From,
1474            )?;
1475            let y_to = self.line_positive_sequence_admittance(
1476                idx,
1477                code,
1478                &active,
1479                &neutral,
1480                line.length,
1481                ShuntSide::To,
1482            )?;
1483            let base_volts = self.base_volts(&line.bus_from);
1484            let z_base = z_base_ohm_of(base_volts, self.options.base_mva);
1485            let y_scale = z_base;
1486            let charging = BranchCharging::new(
1487                y_from.re * y_scale,
1488                y_from.im * y_scale,
1489                y_to.re * y_scale,
1490                y_to.im * y_scale,
1491            );
1492            let rate = line_rate_mva(line, code, &active, base_volts).unwrap_or_else(|| {
1493                self.records.dropped_fields.push(format!(
1494                    "line {} thermal rating defaulted to 0 MVA",
1495                    line.name
1496                ));
1497                0.0
1498            });
1499            let mut branch = Branch::new(from, to, z_ohm.re / z_base, z_ohm.im / z_base);
1500            branch.b = charging.calc_total_b();
1501            branch.charging = Some(charging);
1502            branch.rate_a = rate;
1503            branch.rate_b = rate;
1504            branch.rate_c = rate;
1505            branch.extras = source_extra("multiconductor_line", &line.name);
1506            branches.push(branch);
1507        }
1508        self.err_if_errors()?;
1509        Ok(branches)
1510    }
1511
1512    fn line_positive_sequence_impedance(
1513        &mut self,
1514        line_idx: usize,
1515        code: &DistLineCode,
1516        active: &[usize],
1517        neutral: &[usize],
1518        length: f64,
1519    ) -> Result<Complex64, MulticonductorToBalancedError> {
1520        self.check_finite_length(line_idx, length)?;
1521        let matrix = complex_matrix(&code.r_series, &code.x_series, length);
1522        let reduced = kron_or_select(&matrix, active, neutral).map_err(|message| {
1523            self.matrix_error(line_idx, &code.name, "series impedance", &message)
1524        })?;
1525        Ok(self.positive_sequence_from_matrix(line_idx, &code.name, "series impedance", &reduced))
1526    }
1527
1528    fn line_positive_sequence_admittance(
1529        &mut self,
1530        line_idx: usize,
1531        code: &DistLineCode,
1532        active: &[usize],
1533        neutral: &[usize],
1534        length: f64,
1535        side: ShuntSide,
1536    ) -> Result<Complex64, MulticonductorToBalancedError> {
1537        let (g, b, label) = match side {
1538            ShuntSide::From => (&code.g_from, &code.b_from, "from shunt admittance"),
1539            ShuntSide::To => (&code.g_to, &code.b_to, "to shunt admittance"),
1540        };
1541        let matrix = complex_matrix(g, b, length);
1542        let reduced = kron_or_select(&matrix, active, neutral)
1543            .map_err(|message| self.matrix_error(line_idx, &code.name, label, &message))?;
1544        Ok(self.positive_sequence_from_matrix(line_idx, &code.name, label, &reduced))
1545    }
1546
1547    fn positive_sequence_from_matrix(
1548        &mut self,
1549        line_idx: usize,
1550        code_name: &str,
1551        label: &str,
1552        matrix: &[Vec<Complex64>],
1553    ) -> Complex64 {
1554        let seq = sequence_matrix(matrix);
1555        let coupling = sequence_coupling_norm(&seq);
1556        if coupling > COUPLING_TOLERANCE {
1557            self.records.approximations.push(format!(
1558                "linecode {code_name} {label} has sequence coupling norm {coupling}; positive-sequence diagonal retained"
1559            ));
1560            let mut diagnostic = Diagnostic::of(
1561    &codes::TRANSFORM_MULTI_TO_BALANCED_SEQUENCE_COUPLING_DROPPED,
1562format!(
1563                    "linecode {code_name} {label} has nonzero sequence coupling; the balanced model keeps the positive-sequence diagonal"
1564                ),
1565            )
1566            .with_value_target(format!("/lines/{line_idx}/linecode"));
1567            diagnostic
1568                .insert_detail("sequence_coupling_norm", serde_json::json!(coupling))
1569                .expect("the static detail key is valid");
1570            self.records.diagnostics.push(diagnostic);
1571        }
1572        seq[1][1]
1573    }
1574
1575    /// Refuse a line whose length is not a finite number. A BMOPF line without
1576    /// a length reads back as `NaN` (the `null` spelling), and every impedance
1577    /// and admittance below scales by it, so an unchecked value would reach the
1578    /// solver as a `NaN` branch with nothing said about it.
1579    fn check_finite_length(
1580        &self,
1581        line_idx: usize,
1582        length: f64,
1583    ) -> Result<(), MulticonductorToBalancedError> {
1584        if length.is_finite() {
1585            return Ok(());
1586        }
1587        let mut diagnostics = self.records.diagnostics.clone();
1588        diagnostics.push(
1589            Diagnostic::of(
1590    &codes::TRANSFORM_MULTI_TO_BALANCED_NONFINITE_LINE_LENGTH,
1591format!("line {line_idx} has no finite length ({length}), so its impedance cannot be scaled"),
1592            )
1593            .with_value_target(format!("/lines/{line_idx}/length"))
1594            .with_suggested_action("give the line a length in meters, or drop it from the network"),
1595        );
1596        Err(MulticonductorToBalancedError::new(
1597            self.options,
1598            &diagnostics,
1599        ))
1600    }
1601
1602    fn matrix_error(
1603        &self,
1604        line_idx: usize,
1605        code_name: &str,
1606        label: &str,
1607        message: &str,
1608    ) -> MulticonductorToBalancedError {
1609        let mut diagnostics = self.records.diagnostics.clone();
1610        diagnostics.push(
1611            Diagnostic::of(
1612                &codes::TRANSFORM_MULTI_TO_BALANCED_INVALID_LINECODE_MATRIX,
1613                format!("linecode {code_name} {label} cannot be lowered: {message}"),
1614            )
1615            .with_value_target(format!("/lines/{line_idx}/linecode")),
1616        );
1617        MulticonductorToBalancedError::new(self.options, &diagnostics)
1618    }
1619
1620    /// Supported transformers lower into balanced branches: series impedance
1621    /// from the winding resistances and the first short circuit reactance on
1622    /// the transformer's own base converted to the system base, tap from the
1623    /// rated winding voltages against the zone voltage bases, and the
1624    /// representable ANSI thirty degree connection shift with the high
1625    /// voltage side leading.
1626    fn lower_transformers(&mut self) -> Vec<Branch> {
1627        let mut branches = Vec::new();
1628        for transformer in self.net.transformers() {
1629            let Ok([high, low]) =
1630                classify_transformer(self.net, transformer, &self.neutral_terminals)
1631            else {
1632                // Preflight refused the pass for unsupported transformers.
1633                continue;
1634            };
1635            let (Some(from), Some(to)) = (self.bus_id(&high.bus), self.bus_id(&low.bus)) else {
1636                continue;
1637            };
1638            let base_from = self.base_volts(&high.bus);
1639            let base_to = self.base_volts(&low.bus);
1640            let tap = (high.v_ref * high.tap / base_from) / (low.v_ref * low.tap / base_to);
1641            let z_scale = (self.options.base_mva * 1_000_000.0 / high.s_rating)
1642                * (high.v_ref / base_from).powi(2);
1643            // Each winding states %R on its own power rating; the low winding
1644            // figure converts onto the high winding base before the sum.
1645            let low_rating_scale = high.s_rating / low.s_rating;
1646            let r = ((high.r_pct + low.r_pct * low_rating_scale) / 100.0) * z_scale;
1647            let x = (transformer.xsc_pct[0] / 100.0) * z_scale;
1648            let shift = if high.v_ref >= low.v_ref { 30.0 } else { -30.0 };
1649            let rate = high.s_rating / 1_000_000.0;
1650            self.records.assumptions.push(format!(
1651                "transformer {} lowered as a balanced branch with tap {tap:.6} and the ANSI \
1652                 {shift} degree connection shift (high voltage side leads)",
1653                transformer.name
1654            ));
1655            if (low_rating_scale - 1.0).abs() > 1e-9 {
1656                self.records.assumptions.push(format!(
1657                    "transformer {}: the low winding resistance was converted from its own \
1658                     {:.3} kVA base onto the high winding {:.3} kVA base",
1659                    transformer.name,
1660                    low.s_rating / 1_000.0,
1661                    high.s_rating / 1_000.0
1662                ));
1663            }
1664            if high.r_neutral.is_some()
1665                || high.x_neutral.is_some()
1666                || low.r_neutral.is_some()
1667                || low.x_neutral.is_some()
1668            {
1669                self.records.dropped_fields.push(format!(
1670                    "transformer {} neutral grounding impedance dropped",
1671                    transformer.name
1672                ));
1673            }
1674            if transformer.xsc_pct.len() > 1 {
1675                self.records.dropped_fields.push(format!(
1676                    "transformer {} extra short circuit reactances dropped",
1677                    transformer.name
1678                ));
1679            }
1680            let mut branch = Branch::new(from, to, r, x);
1681            branch.tap = tap;
1682            branch.shift = shift;
1683            branch.rate_a = rate;
1684            branch.rate_b = rate;
1685            branch.rate_c = rate;
1686            branch.extras = source_extra("multiconductor_transformer", &transformer.name);
1687            branches.push(branch);
1688        }
1689        branches
1690    }
1691
1692    fn lower_loads(&mut self) -> Vec<Load> {
1693        self.net
1694            .loads()
1695            .iter()
1696            .enumerate()
1697            .filter_map(|(idx, load)| {
1698                let Some(bus) = self.bus_id(&load.bus) else {
1699                    self.unknown_bus_diag("load", &load.name, &load.bus, idx, "bus");
1700                    return None;
1701                };
1702                if !matches!(
1703                    load.voltage_model,
1704                    DistLoadVoltageModel::ConstantPower { .. }
1705                ) {
1706                    self.records.dropped_fields.push(format!(
1707                        "load {} voltage model dropped; balanced load is constant power",
1708                        load.name
1709                    ));
1710                    self.records.diagnostics.push(
1711                        Diagnostic::of(
1712    &codes::TRANSFORM_MULTI_TO_BALANCED_DROPPED_LOAD_VOLTAGE_MODEL,
1713format!(
1714                                "load {} voltage model cannot be represented by the conservative balanced lowering",
1715                                load.name
1716                            ),
1717                        )
1718                        .with_value_target(format!("/loads/{idx}/voltage_model")),
1719                    );
1720                }
1721                let mut balanced = Load::new(
1722                    bus,
1723                    si_power_to_mega(load.p_nom.iter().sum()),
1724                    si_power_to_mega(load.q_nom.iter().sum()),
1725                );
1726                balanced.extras = source_extra("multiconductor_load", &load.name);
1727                Some(balanced)
1728            })
1729            .collect()
1730    }
1731
1732    fn lower_shunts(&mut self) -> Result<Vec<Shunt>, MulticonductorToBalancedError> {
1733        let mut shunts = Vec::with_capacity(self.net.shunts().len());
1734        for (idx, shunt) in self.net.shunts().iter().enumerate() {
1735            let Some(bus) = self.bus_id(&shunt.bus) else {
1736                self.unknown_bus_diag("shunt", &shunt.name, &shunt.bus, idx, "bus");
1737                continue;
1738            };
1739            let dist_bus = self.net.bus(&shunt.bus);
1740            let active = active_positions(&shunt.terminal_map, dist_bus, &self.neutral_terminals);
1741            let neutral = neutral_positions(&shunt.terminal_map, dist_bus, &self.neutral_terminals);
1742            let y = if active.len() == 3 {
1743                let matrix = complex_matrix(&shunt.g, &shunt.b, 1.0);
1744                let reduced = kron_or_select(&matrix, &active, &neutral)
1745                    .map_err(|message| self.shunt_matrix_error(idx, &shunt.name, &message))?;
1746                let seq = sequence_matrix(&reduced);
1747                seq[1][1]
1748            } else {
1749                self.records.approximations.push(format!(
1750                    "shunt {} has {} active terminal(s); diagonal admittance projected with missing phases as zero",
1751                    shunt.name,
1752                    active.len()
1753                ));
1754                partial_phase_admittance(&shunt.g, &shunt.b, &active)
1755            };
1756            let base_volts = self.base_volts(&shunt.bus);
1757            let scale = base_volts * base_volts / 1_000_000.0;
1758            let mut balanced = Shunt::new(bus, y.re * scale, y.im * scale);
1759            balanced.extras = source_extra("multiconductor_shunt", &shunt.name);
1760            shunts.push(balanced);
1761        }
1762        self.err_if_errors()?;
1763        Ok(shunts)
1764    }
1765
1766    fn shunt_matrix_error(
1767        &self,
1768        shunt_idx: usize,
1769        name: &str,
1770        message: &str,
1771    ) -> MulticonductorToBalancedError {
1772        let mut diagnostics = self.records.diagnostics.clone();
1773        diagnostics.push(
1774            Diagnostic::of(
1775                &codes::TRANSFORM_MULTI_TO_BALANCED_INVALID_SHUNT_MATRIX,
1776                format!("shunt {name} cannot be lowered: {message}"),
1777            )
1778            .with_value_target(format!("/shunts/{shunt_idx}")),
1779        );
1780        MulticonductorToBalancedError::new(self.options, &diagnostics)
1781    }
1782
1783    fn lower_generators(&mut self, buses: &[Bus]) -> Vec<Generator> {
1784        self.net
1785            .generators()
1786            .iter()
1787            .enumerate()
1788            .filter_map(|(idx, generator)| {
1789                let Some(bus) = self.bus_id(&generator.bus) else {
1790                    self.unknown_bus_diag("generator", &generator.name, &generator.bus, idx, "bus");
1791                    return None;
1792                };
1793                let pg = si_power_to_mega(generator.p_nom.iter().sum());
1794                let qg = si_power_to_mega(generator.q_nom.iter().sum());
1795                let pmin = option_vec_sum_mw(generator.p_min.as_deref()).unwrap_or_else(|| {
1796                    self.records.dropped_fields.push(format!(
1797                        "generator {} p_min defaulted to pg",
1798                        generator.name
1799                    ));
1800                    pg
1801                });
1802                let pmax = option_vec_sum_mw(generator.p_max.as_deref()).unwrap_or_else(|| {
1803                    self.records.dropped_fields.push(format!(
1804                        "generator {} p_max defaulted to pg",
1805                        generator.name
1806                    ));
1807                    pg
1808                });
1809                let qmin = option_vec_sum_mw(generator.q_min.as_deref()).unwrap_or_else(|| {
1810                    self.records.dropped_fields.push(format!(
1811                        "generator {} q_min defaulted to qg",
1812                        generator.name
1813                    ));
1814                    qg
1815                });
1816                let qmax = option_vec_sum_mw(generator.q_max.as_deref()).unwrap_or_else(|| {
1817                    self.records.dropped_fields.push(format!(
1818                        "generator {} q_max defaulted to qg",
1819                        generator.name
1820                    ));
1821                    qg
1822                });
1823                if generator.cost.is_some() {
1824                    self.records.dropped_fields.push(format!(
1825                        "generator {} scalar distribution cost dropped",
1826                        generator.name
1827                    ));
1828                }
1829                if generator.s_max.is_some() || generator.i_max.is_some() {
1830                    self.records.dropped_fields.push(format!(
1831                        "generator {} per-conductor rating fields dropped",
1832                        generator.name
1833                    ));
1834                }
1835                let vg = buses
1836                    .iter()
1837                    .find(|balanced_bus| balanced_bus.id == bus)
1838                    .map_or(1.0, |balanced_bus| balanced_bus.vm);
1839                let mut balanced = Generator::new(bus);
1840                balanced.pg = pg;
1841                balanced.qg = qg;
1842                balanced.pmax = pmax;
1843                balanced.pmin = pmin;
1844                balanced.qmax = qmax;
1845                balanced.qmin = qmin;
1846                balanced.vg = vg;
1847                balanced.mbase = self.options.base_mva;
1848                Some(balanced)
1849            })
1850            .collect()
1851    }
1852
1853    fn bus_id(&self, bus: &str) -> Option<BusId> {
1854        self.bus_ids.get(&bus.to_ascii_lowercase()).copied()
1855    }
1856
1857    fn unknown_bus_diag(&mut self, element: &str, name: &str, bus: &str, idx: usize, field: &str) {
1858        self.records.diagnostics.push(
1859            Diagnostic::of(
1860                &codes::TRANSFORM_MULTI_TO_BALANCED_UNKNOWN_BUS,
1861                format!("{element} {name} references unknown bus {bus}"),
1862            )
1863            .with_value_target(format!("/{element}s/{idx}/{field}")),
1864        );
1865    }
1866
1867    fn err_if_errors(&self) -> Result<(), MulticonductorToBalancedError> {
1868        if self
1869            .records
1870            .diagnostics
1871            .iter()
1872            .any(|d| d.severity() >= DiagnosticSeverity::Error)
1873        {
1874            Err(MulticonductorToBalancedError::new(
1875                self.options,
1876                &self.records.diagnostics,
1877            ))
1878        } else {
1879            Ok(())
1880        }
1881    }
1882}
1883
1884#[derive(Clone, Copy)]
1885struct VoltageBase {
1886    line_to_line_volts: f64,
1887}
1888
1889fn z_base_ohm_of(line_to_line_volts: f64, base_mva: f64) -> f64 {
1890    line_to_line_volts * line_to_line_volts / (base_mva * 1_000_000.0)
1891}
1892
1893struct UnionFind {
1894    parent: Vec<usize>,
1895}
1896
1897impl UnionFind {
1898    fn new(len: usize) -> Self {
1899        Self {
1900            parent: (0..len).collect(),
1901        }
1902    }
1903
1904    fn root(&mut self, mut node: usize) -> usize {
1905        while self.parent[node] != node {
1906            self.parent[node] = self.parent[self.parent[node]];
1907            node = self.parent[node];
1908        }
1909        node
1910    }
1911
1912    fn join(&mut self, a: usize, b: usize) {
1913        let (a, b) = (self.root(a), self.root(b));
1914        // The smaller row stays the root, so the canonical member is stable.
1915        let (keep, fold) = if a <= b { (a, b) } else { (b, a) };
1916        self.parent[fold] = keep;
1917    }
1918}
1919
1920#[derive(Clone, Copy)]
1921enum ShuntSide {
1922    From,
1923    To,
1924}
1925
1926fn options_map(
1927    options: MulticonductorToBalancedOptions,
1928) -> serde_json::Map<String, serde_json::Value> {
1929    serde_json::to_value(options)
1930        .ok()
1931        .and_then(|value| value.as_object().cloned())
1932        .unwrap_or_default()
1933}
1934
1935fn source_extra(key: &str, value: &str) -> BalancedExtras {
1936    let mut extras = BalancedExtras::new();
1937    extras.insert(key.to_owned(), serde_json::Value::String(value.to_owned()));
1938    extras
1939}
1940
1941fn active_positions(
1942    terminals: &[String],
1943    bus: Option<&DistBus>,
1944    neutral_terminals: &BTreeSet<String>,
1945) -> Vec<usize> {
1946    terminals
1947        .iter()
1948        .enumerate()
1949        .filter_map(|(idx, terminal)| {
1950            (!is_neutral_terminal(terminal, bus, neutral_terminals)).then_some(idx)
1951        })
1952        .collect()
1953}
1954
1955fn neutral_positions(
1956    terminals: &[String],
1957    bus: Option<&DistBus>,
1958    neutral_terminals: &BTreeSet<String>,
1959) -> Vec<usize> {
1960    terminals
1961        .iter()
1962        .enumerate()
1963        .filter_map(|(idx, terminal)| {
1964            is_neutral_terminal(terminal, bus, neutral_terminals).then_some(idx)
1965        })
1966        .collect()
1967}
1968
1969fn same_active_phase_order(
1970    from_bus: Option<&DistBus>,
1971    from_terminals: &[String],
1972    to_bus: Option<&DistBus>,
1973    to_terminals: &[String],
1974    neutral_terminals: &BTreeSet<String>,
1975) -> bool {
1976    let from: Vec<_> = from_terminals
1977        .iter()
1978        .filter(|terminal| !is_neutral_terminal(terminal, from_bus, neutral_terminals))
1979        .map(|terminal| terminal.to_ascii_lowercase())
1980        .collect();
1981    let to: Vec<_> = to_terminals
1982        .iter()
1983        .filter(|terminal| !is_neutral_terminal(terminal, to_bus, neutral_terminals))
1984        .map(|terminal| terminal.to_ascii_lowercase())
1985        .collect();
1986    from == to
1987}
1988
1989fn positive_sequence_voltage(
1990    source: &powerio_dist::VoltageSource,
1991    positions: &[usize],
1992) -> Option<Complex64> {
1993    if positions.len() != 3 {
1994        return None;
1995    }
1996    let mut phase = [Complex64::new(0.0, 0.0); 3];
1997    for (out, &idx) in phase.iter_mut().zip(positions.iter()) {
1998        let magnitude = *source.v_magnitude.get(idx)?;
1999        let angle = *source.v_angle.get(idx)?;
2000        if !magnitude.is_finite() || !angle.is_finite() {
2001            return None;
2002        }
2003        *out = Complex64::from_polar(magnitude, angle);
2004    }
2005    let basis = sequence_basis();
2006    let mut seq = [Complex64::new(0.0, 0.0); 3];
2007    for (sequence_idx, out) in seq.iter_mut().enumerate() {
2008        for phase_idx in 0..3 {
2009            *out += basis[phase_idx][sequence_idx].conj() * phase[phase_idx];
2010        }
2011    }
2012    Some(seq[1])
2013}
2014
2015fn complex_matrix(
2016    g_or_r: &ConductorMatrix,
2017    b_or_x: &ConductorMatrix,
2018    scale: f64,
2019) -> Vec<Vec<Complex64>> {
2020    g_or_r
2021        .iter()
2022        .zip(b_or_x.iter())
2023        .map(|(g_row, b_row)| {
2024            g_row
2025                .iter()
2026                .zip(b_row.iter())
2027                .map(|(&g, &b)| Complex64::new(g * scale, b * scale))
2028                .collect()
2029        })
2030        .collect()
2031}
2032
2033fn kron_or_select(
2034    matrix: &[Vec<Complex64>],
2035    active: &[usize],
2036    neutral: &[usize],
2037) -> Result<Vec<Vec<Complex64>>, String> {
2038    if active.len() != 3 {
2039        return Err(format!(
2040            "expected three active conductors, got {}",
2041            active.len()
2042        ));
2043    }
2044    validate_indices(matrix, active)?;
2045    validate_indices(matrix, neutral)?;
2046    if neutral.is_empty() {
2047        return Ok(submatrix(matrix, active, active));
2048    }
2049
2050    let m_pp = submatrix(matrix, active, active);
2051    let m_pn = submatrix(matrix, active, neutral);
2052    let m_np = submatrix(matrix, neutral, active);
2053    let m_nn = submatrix(matrix, neutral, neutral);
2054    if matrix_is_near_zero(&m_pn) && matrix_is_near_zero(&m_np) && matrix_is_near_zero(&m_nn) {
2055        return Ok(m_pp);
2056    }
2057    let inv_nn = invert_complex_matrix(&m_nn)?;
2058    let correction = matmul(&matmul(&m_pn, &inv_nn), &m_np);
2059    Ok(matrix_sub(&m_pp, &correction))
2060}
2061
2062fn matrix_is_near_zero(matrix: &[Vec<Complex64>]) -> bool {
2063    matrix
2064        .iter()
2065        .flatten()
2066        .all(|value| value.norm() <= f64::EPSILON)
2067}
2068
2069fn validate_indices(matrix: &[Vec<Complex64>], indices: &[usize]) -> Result<(), String> {
2070    let n = matrix.len();
2071    if matrix.iter().any(|row| row.len() != n) {
2072        return Err("matrix is not square".to_owned());
2073    }
2074    if indices.iter().any(|&idx| idx >= n) {
2075        return Err("terminal map references a conductor outside the matrix".to_owned());
2076    }
2077    Ok(())
2078}
2079
2080fn submatrix(matrix: &[Vec<Complex64>], rows: &[usize], cols: &[usize]) -> Vec<Vec<Complex64>> {
2081    rows.iter()
2082        .map(|&row| cols.iter().map(|&col| matrix[row][col]).collect())
2083        .collect()
2084}
2085
2086#[allow(clippy::needless_range_loop)]
2087fn invert_complex_matrix(matrix: &[Vec<Complex64>]) -> Result<Vec<Vec<Complex64>>, String> {
2088    let n = matrix.len();
2089    if n == 0 || matrix.iter().any(|row| row.len() != n) {
2090        return Err("neutral block is not square".to_owned());
2091    }
2092    let mut aug = vec![vec![Complex64::new(0.0, 0.0); 2 * n]; n];
2093    for i in 0..n {
2094        for j in 0..n {
2095            aug[i][j] = matrix[i][j];
2096        }
2097        aug[i][n + i] = Complex64::new(1.0, 0.0);
2098    }
2099
2100    for col in 0..n {
2101        let pivot = (col..n)
2102            .max_by(|&a, &b| aug[a][col].norm_sqr().total_cmp(&aug[b][col].norm_sqr()))
2103            .ok_or_else(|| "neutral block is singular".to_owned())?;
2104        if aug[pivot][col].norm() <= f64::EPSILON {
2105            return Err("neutral block is singular".to_owned());
2106        }
2107        if pivot != col {
2108            aug.swap(pivot, col);
2109        }
2110        let pivot_value = aug[col][col];
2111        for j in 0..(2 * n) {
2112            aug[col][j] /= pivot_value;
2113        }
2114        for row in 0..n {
2115            if row == col {
2116                continue;
2117            }
2118            let factor = aug[row][col];
2119            if factor.norm() <= f64::EPSILON {
2120                continue;
2121            }
2122            for j in 0..(2 * n) {
2123                let pivot_entry = aug[col][j];
2124                aug[row][j] -= factor * pivot_entry;
2125            }
2126        }
2127    }
2128
2129    Ok(aug
2130        .into_iter()
2131        .map(|row| row.into_iter().skip(n).collect())
2132        .collect())
2133}
2134
2135fn matmul(a: &[Vec<Complex64>], b: &[Vec<Complex64>]) -> Vec<Vec<Complex64>> {
2136    if a.is_empty() || b.is_empty() {
2137        return Vec::new();
2138    }
2139    let rows = a.len();
2140    let cols = b[0].len();
2141    let inner = b.len();
2142    let mut out = vec![vec![Complex64::new(0.0, 0.0); cols]; rows];
2143    for i in 0..rows {
2144        for k in 0..inner {
2145            for j in 0..cols {
2146                out[i][j] += a[i][k] * b[k][j];
2147            }
2148        }
2149    }
2150    out
2151}
2152
2153fn matrix_sub(a: &[Vec<Complex64>], b: &[Vec<Complex64>]) -> Vec<Vec<Complex64>> {
2154    a.iter()
2155        .zip(b.iter())
2156        .map(|(a_row, b_row)| {
2157            a_row
2158                .iter()
2159                .zip(b_row.iter())
2160                .map(|(&a_value, &b_value)| a_value - b_value)
2161                .collect()
2162        })
2163        .collect()
2164}
2165
2166#[allow(clippy::many_single_char_names)]
2167fn sequence_basis() -> [[Complex64; 3]; 3] {
2168    let scale = 1.0 / SQRT_3;
2169    let a = Complex64::from_polar(1.0, 2.0 * PI / 3.0);
2170    let a2 = a * a;
2171    [
2172        [
2173            Complex64::new(scale, 0.0),
2174            Complex64::new(scale, 0.0),
2175            Complex64::new(scale, 0.0),
2176        ],
2177        [Complex64::new(scale, 0.0), a2 * scale, a * scale],
2178        [Complex64::new(scale, 0.0), a * scale, a2 * scale],
2179    ]
2180}
2181
2182fn sequence_matrix(matrix: &[Vec<Complex64>]) -> [[Complex64; 3]; 3] {
2183    let basis = sequence_basis();
2184    let mut seq = [[Complex64::new(0.0, 0.0); 3]; 3];
2185    for p in 0..3 {
2186        for q in 0..3 {
2187            for i in 0..3 {
2188                for j in 0..3 {
2189                    seq[p][q] += basis[i][p].conj() * matrix[i][j] * basis[j][q];
2190                }
2191            }
2192        }
2193    }
2194    seq
2195}
2196
2197fn sequence_coupling_norm(seq: &[[Complex64; 3]; 3]) -> f64 {
2198    let mut sum = 0.0;
2199    for (i, row) in seq.iter().enumerate() {
2200        for (j, value) in row.iter().enumerate() {
2201            if i != j {
2202                sum += value.norm_sqr();
2203            }
2204        }
2205    }
2206    sum.sqrt()
2207}
2208
2209/// The branch rating, in MVA. BMOPF schema 0.1.0 gives a line its own
2210/// `i_max`/`s_max`, which "overrides the linecode's i_max for this line", so
2211/// both line fields are tried before either linecode field. Within one owner
2212/// `s_max` comes first, because an apparent power limit needs no voltage.
2213///
2214/// A field the active conductors leave unusable falls through to the next
2215/// candidate rather than ending the search: a line whose `s_max` is all
2216/// infinities must not hide a linecode that carries a real rating.
2217fn line_rate_mva(
2218    line: &DistLine,
2219    code: &DistLineCode,
2220    active: &[usize],
2221    line_to_line_volts: f64,
2222) -> Option<f64> {
2223    for (s_max, i_max) in [
2224        (line.s_max.as_ref(), line.i_max.as_ref()),
2225        (code.s_max.as_ref(), code.i_max.as_ref()),
2226    ] {
2227        if let Some(mva) = s_max.and_then(|values| apparent_power_mva(values, active)) {
2228            return Some(mva);
2229        }
2230        if let Some(amps) = i_max.and_then(|values| limiting_amps(values, active)) {
2231            return Some(SQRT_3 * line_to_line_volts * amps / 1_000_000.0);
2232        }
2233    }
2234    None
2235}
2236
2237/// The summed apparent power limit of the active conductors, in MVA, or None
2238/// when any of them has no finite limit.
2239fn apparent_power_mva(s_max: &[f64], active: &[usize]) -> Option<f64> {
2240    let values: Vec<_> = active
2241        .iter()
2242        .filter_map(|&idx| s_max.get(idx).copied())
2243        .collect();
2244    (!values.is_empty() && values.iter().all(|value| value.is_finite()))
2245        .then(|| values.iter().sum::<f64>() / 1_000_000.0)
2246}
2247
2248/// The smallest usable current limit over the active conductors, in amps.
2249fn limiting_amps(i_max: &[f64], active: &[usize]) -> Option<f64> {
2250    active
2251        .iter()
2252        .filter_map(|&idx| i_max.get(idx).copied())
2253        .filter(|value| value.is_finite() && *value >= 0.0)
2254        .reduce(f64::min)
2255}
2256
2257fn partial_phase_admittance(
2258    g: &ConductorMatrix,
2259    b: &ConductorMatrix,
2260    active: &[usize],
2261) -> Complex64 {
2262    let mut total = Complex64::new(0.0, 0.0);
2263    for &idx in active {
2264        let Some(g_row) = g.get(idx) else {
2265            continue;
2266        };
2267        let Some(b_row) = b.get(idx) else {
2268            continue;
2269        };
2270        let Some(&g_value) = g_row.get(idx) else {
2271            continue;
2272        };
2273        let Some(&b_value) = b_row.get(idx) else {
2274            continue;
2275        };
2276        total += Complex64::new(g_value, b_value);
2277    }
2278    total / 3.0
2279}
2280
2281fn si_power_to_mega(value: f64) -> f64 {
2282    value / 1_000_000.0
2283}
2284
2285fn option_vec_sum_mw(values: Option<&[f64]>) -> Option<f64> {
2286    values.map(|v| si_power_to_mega(v.iter().sum()))
2287}
2288
2289fn radians_to_degrees(value: f64) -> f64 {
2290    value * 180.0 / PI
2291}
2292
2293fn check_options(
2294    options: MulticonductorToBalancedOptions,
2295    report: &mut MulticonductorToBalancedReport,
2296) {
2297    if !options.base_mva.is_finite() || options.base_mva <= 0.0 {
2298        report.diagnostics.push(Diagnostic::of(
2299    &codes::TRANSFORM_MULTI_TO_BALANCED_INVALID_BASE_MVA,
2300format!(
2301                "base_mva must be positive and finite for multiconductor to balanced lowering; got {}",
2302                options.base_mva
2303            ),
2304        ));
2305    }
2306}
2307
2308fn check_bus_conductor_sets(
2309    net: &MulticonductorNetwork,
2310    report: &mut MulticonductorToBalancedReport,
2311) {
2312    let neutral_terminals = global_neutral_terminals(net);
2313    let mut saw_neutral = false;
2314    for (i, bus) in net.buses().iter().enumerate() {
2315        if (bus.v_min.is_none() && bus.v_min_phase.is_some())
2316            || (bus.v_max.is_none() && bus.v_max_phase.is_some())
2317        {
2318            report.diagnostics.push(Diagnostic::of(
2319                &codes::TRANSFORM_MULTI_TO_BALANCED_UNSUPPORTED_OBJECT,
2320                format!("bus {} has nonuniform phase voltage bounds that a balanced bus cannot represent", bus.id),
2321            ).with_value_target(format!("/buses/{i}")));
2322        }
2323        let active_count = active_terminal_count(&bus.terminals, Some(bus), &neutral_terminals);
2324        if active_count < bus.terminals.len() {
2325            saw_neutral = true;
2326        }
2327
2328        match active_count {
2329            3 => {}
2330            2 => report.diagnostics.push(
2331                Diagnostic::of(
2332    &codes::TRANSFORM_MULTI_TO_BALANCED_AMBIGUOUS_TERMINAL_MAP,
2333format!(
2334                        "bus {} has two active terminals; no unique positive sequence projection is defined",
2335                        bus.id
2336                    ),
2337                )
2338                .with_value_target(format!("/buses/{i}/terminals")),
2339            ),
2340            0 | 1 => report.diagnostics.push(
2341                Diagnostic::of(
2342    &codes::TRANSFORM_MULTI_TO_BALANCED_UNSUPPORTED_CONDUCTOR_SET,
2343format!(
2344                        "bus {} has {active_count} active terminal; multiconductor to balanced lowering starts with three phase input",
2345                        bus.id
2346                    ),
2347                )
2348                .with_value_target(format!("/buses/{i}/terminals")),
2349            ),
2350            _ => report.diagnostics.push(
2351                Diagnostic::of(
2352    &codes::TRANSFORM_MULTI_TO_BALANCED_UNSUPPORTED_CONDUCTOR_SET,
2353format!(
2354                        "bus {} has {active_count} active terminals; multiconductor to balanced lowering starts with three phase input",
2355                        bus.id
2356                    ),
2357                )
2358                .with_value_target(format!("/buses/{i}/terminals")),
2359            ),
2360        }
2361    }
2362
2363    if saw_neutral {
2364        report
2365            .approximations
2366            .push("Kron reduction of neutral conductor before sequence transform".to_owned());
2367        report.diagnostics.push(Diagnostic::of(
2368            &codes::TRANSFORM_MULTI_TO_BALANCED_KRON_REDUCTION_REQUIRED,
2369            "neutral conductors require Kron reduction before the sequence transform",
2370        ));
2371    }
2372}
2373
2374fn check_line_terminal_maps(
2375    net: &MulticonductorNetwork,
2376    report: &mut MulticonductorToBalancedReport,
2377) {
2378    let neutral_terminals = global_neutral_terminals(net);
2379    for (i, line) in net.lines().iter().enumerate() {
2380        for (field, bus_id, terminal_map) in [
2381            (
2382                "terminal_map_from",
2383                line.bus_from.as_str(),
2384                line.terminal_map_from.as_slice(),
2385            ),
2386            (
2387                "terminal_map_to",
2388                line.bus_to.as_str(),
2389                line.terminal_map_to.as_slice(),
2390            ),
2391        ] {
2392            let bus = net.bus(bus_id);
2393            let active_count = active_terminal_count(terminal_map, bus, &neutral_terminals);
2394            if active_count != 3 {
2395                report.diagnostics.push(
2396                    Diagnostic::of(
2397    &codes::TRANSFORM_MULTI_TO_BALANCED_UNSUPPORTED_CONDUCTOR_SET,
2398format!(
2399                            "line {} {field} has {active_count} active terminal(s); balanced branch lowering requires three active phase conductors",
2400                            line.name
2401                        ),
2402                    )
2403                    .with_value_target(format!("/lines/{i}/{field}")),
2404                );
2405            }
2406        }
2407    }
2408}
2409
2410fn check_linecodes(net: &MulticonductorNetwork, report: &mut MulticonductorToBalancedReport) {
2411    for (i, line) in net.lines().iter().enumerate() {
2412        let Some(code) = net.linecode(&line.linecode) else {
2413            report.diagnostics.push(
2414                Diagnostic::of(
2415                    &codes::TRANSFORM_MULTI_TO_BALANCED_UNKNOWN_LINECODE,
2416                    format!(
2417                        "line {} references unknown linecode `{}`",
2418                        line.name, line.linecode
2419                    ),
2420                )
2421                .with_value_target(format!("/lines/{i}/linecode")),
2422            );
2423            continue;
2424        };
2425        if code.n_conductors != line.terminal_map_from.len()
2426            || code.n_conductors != line.terminal_map_to.len()
2427        {
2428            report.diagnostics.push(
2429                Diagnostic::of(
2430    &codes::TRANSFORM_MULTI_TO_BALANCED_LINECODE_TERMINAL_MISMATCH,
2431format!(
2432                        "line {} uses linecode {} with {} conductor(s), but its terminal maps have {} and {} terminal(s)",
2433                        line.name,
2434                        code.name,
2435                        code.n_conductors,
2436                        line.terminal_map_from.len(),
2437                        line.terminal_map_to.len()
2438                    ),
2439                )
2440                .with_value_target(format!("/lines/{i}/linecode")),
2441            );
2442        }
2443        if !square_matrix_shape(&code.r_series, code.n_conductors)
2444            || !square_matrix_shape(&code.x_series, code.n_conductors)
2445            || !square_matrix_shape(&code.g_from, code.n_conductors)
2446            || !square_matrix_shape(&code.b_from, code.n_conductors)
2447            || !square_matrix_shape(&code.g_to, code.n_conductors)
2448            || !square_matrix_shape(&code.b_to, code.n_conductors)
2449        {
2450            report.diagnostics.push(
2451                Diagnostic::of(
2452                    &codes::TRANSFORM_MULTI_TO_BALANCED_INVALID_LINECODE_MATRIX,
2453                    format!(
2454                        "linecode {} does not carry square {} conductor matrices",
2455                        code.name, code.n_conductors
2456                    ),
2457                )
2458                .with_value_target(format!("/lines/{i}/linecode")),
2459            );
2460        }
2461    }
2462}
2463
2464fn square_matrix_shape(matrix: &ConductorMatrix, n: usize) -> bool {
2465    matrix.len() == n && matrix.iter().all(|row| row.len() == n)
2466}
2467
2468fn check_switches(net: &MulticonductorNetwork, report: &mut MulticonductorToBalancedReport) {
2469    let neutral_terminals = global_neutral_terminals(net);
2470    for (i, sw) in net.switches().iter().enumerate() {
2471        if sw.open {
2472            report.diagnostics.push(
2473                Diagnostic::of(
2474                    &codes::TRANSFORM_MULTI_TO_BALANCED_DROPPED_OPEN_SWITCH,
2475                    format!(
2476                        "open switch {} is dropped by multiconductor to balanced lowering",
2477                        sw.name
2478                    ),
2479                )
2480                .with_value_target(format!("/switches/{i}")),
2481            );
2482        } else {
2483            report
2484                .diagnostics
2485                .extend(switch_merge_blockers(net, i, sw, &neutral_terminals));
2486        }
2487    }
2488}
2489
2490/// Everything that stops a closed switch from merging its buses. An empty
2491/// list means the switch merges: its endpoints collapse to one balanced bus
2492/// and the switch identity is removed with the mapping recorded. Merging
2493/// never invents an impedance.
2494fn switch_merge_blockers(
2495    net: &MulticonductorNetwork,
2496    index: usize,
2497    sw: &powerio_dist::DistSwitch,
2498    neutral_terminals: &BTreeSet<String>,
2499) -> Vec<Diagnostic> {
2500    let path = format!("/switches/{index}");
2501    let mut blockers = Vec::new();
2502    if sw
2503        .i_max
2504        .as_ref()
2505        .is_some_and(|limits| limits.iter().any(|limit| limit.is_finite()))
2506    {
2507        blockers.push(
2508            Diagnostic::of(
2509                &codes::TRANSFORM_MULTI_TO_BALANCED_RATED_CLOSED_SWITCH,
2510                format!(
2511                    "closed switch {} carries a finite ampacity; merging its buses would \
2512                     remove the branch flow the limit constrains",
2513                    sw.name
2514                ),
2515            )
2516            .with_value_target(path.clone()),
2517        );
2518    }
2519    let from_bus = net.bus(&sw.bus_from);
2520    let to_bus = net.bus(&sw.bus_to);
2521    if from_bus.is_none() || to_bus.is_none() {
2522        let missing = if from_bus.is_none() {
2523            &sw.bus_from
2524        } else {
2525            &sw.bus_to
2526        };
2527        blockers.push(
2528            Diagnostic::of(
2529                &codes::TRANSFORM_MULTI_TO_BALANCED_UNKNOWN_BUS,
2530                format!("switch {} references unknown bus {missing}", sw.name),
2531            )
2532            .with_value_target(path.clone()),
2533        );
2534        return blockers;
2535    }
2536    if !same_active_phase_order(
2537        from_bus,
2538        &sw.terminal_map_from,
2539        to_bus,
2540        &sw.terminal_map_to,
2541        neutral_terminals,
2542    ) {
2543        blockers.push(
2544            Diagnostic::of(
2545                &codes::TRANSFORM_MULTI_TO_BALANCED_SWITCH_TERMINAL_MISMATCH,
2546                format!(
2547                    "closed switch {} does not map identical conductors on both ends, so its \
2548                     buses are not electrically identical",
2549                    sw.name
2550                ),
2551            )
2552            .with_value_target(path.clone()),
2553        );
2554    }
2555    if !sw.bus_from.eq_ignore_ascii_case(&sw.bus_to) {
2556        let sourced = |bus: &str| {
2557            net.sources()
2558                .iter()
2559                .any(|source| source.bus.eq_ignore_ascii_case(bus))
2560        };
2561        if sourced(&sw.bus_from) && sourced(&sw.bus_to) {
2562            blockers.push(
2563                Diagnostic::of(
2564                    &codes::TRANSFORM_MULTI_TO_BALANCED_SWITCH_MERGE_CONFLICT,
2565                    format!(
2566                        "closed switch {} joins two buses that both carry voltage source \
2567                         references",
2568                        sw.name
2569                    ),
2570                )
2571                .with_value_target(path.clone()),
2572            );
2573        }
2574        let (from_bus, to_bus) = (from_bus.expect("checked"), to_bus.expect("checked"));
2575        for (label, a, b) in [
2576            ("v_min", from_bus.v_min, to_bus.v_min),
2577            ("v_max", from_bus.v_max, to_bus.v_max),
2578        ] {
2579            if let (Some(a), Some(b)) = (a, b)
2580                && (a - b).abs() > f64::EPSILON * a.abs().max(b.abs()).max(1.0)
2581            {
2582                blockers.push(
2583                    Diagnostic::of(
2584                        &codes::TRANSFORM_MULTI_TO_BALANCED_SWITCH_MERGE_CONFLICT,
2585                        format!(
2586                            "closed switch {} joins buses stating different {label} bounds \
2587                             ({a} and {b})",
2588                            sw.name
2589                        ),
2590                    )
2591                    .with_value_target(path.clone()),
2592                );
2593            }
2594        }
2595    }
2596    blockers
2597}
2598
2599fn global_neutral_terminals(net: &MulticonductorNetwork) -> BTreeSet<String> {
2600    net.buses()
2601        .iter()
2602        .flat_map(|bus| bus.grounded.iter().cloned())
2603        .collect()
2604}
2605
2606fn active_terminal_count(
2607    terminals: &[String],
2608    bus: Option<&DistBus>,
2609    neutral_terminals: &BTreeSet<String>,
2610) -> usize {
2611    terminals
2612        .iter()
2613        .filter(|terminal| !is_neutral_terminal(terminal, bus, neutral_terminals))
2614        .count()
2615}
2616
2617fn is_neutral_terminal(
2618    terminal: &str,
2619    bus: Option<&DistBus>,
2620    neutral_terminals: &BTreeSet<String>,
2621) -> bool {
2622    terminal == "0"
2623        || terminal.eq_ignore_ascii_case("n")
2624        || bus.is_some_and(|b| b.grounded.iter().any(|g| g == terminal))
2625        || neutral_terminals.contains(terminal)
2626}
2627
2628fn check_phase_reference(net: &MulticonductorNetwork, report: &mut MulticonductorToBalancedReport) {
2629    let neutral_terminals = global_neutral_terminals(net);
2630    let has_three_phase_source = net.sources().iter().any(|source| {
2631        let bus = net.bus(&source.bus);
2632        active_terminal_count(&source.terminal_map, bus, &neutral_terminals) == 3
2633    });
2634
2635    if !has_three_phase_source {
2636        report.diagnostics.push(Diagnostic::of(
2637            &codes::TRANSFORM_MULTI_TO_BALANCED_MISSING_PHASE_REFERENCE,
2638            "multiconductor to balanced lowering requires a three phase voltage source reference",
2639        ));
2640    }
2641}
2642
2643fn check_transformers(net: &MulticonductorNetwork, report: &mut MulticonductorToBalancedReport) {
2644    let neutral_terminals = global_neutral_terminals(net);
2645    for (i, transformer) in net.transformers().iter().enumerate() {
2646        if let Err(reason) = classify_transformer(net, transformer, &neutral_terminals) {
2647            report.diagnostics.push(
2648                Diagnostic::of(
2649                    &codes::TRANSFORM_MULTI_TO_BALANCED_UNSUPPORTED_TRANSFORMER,
2650                    format!("transformer {} {reason}", transformer.name),
2651                )
2652                .with_value_target(format!("/transformers/{i}")),
2653            );
2654        }
2655    }
2656}
2657
2658/// Why one transformer lowers or refuses. The supported shape is a three
2659/// phase two winding `wye_delta` or `delta_wye` transformer with finite
2660/// positive ratings and full three phase terminal maps.
2661fn classify_transformer<'net>(
2662    net: &'net MulticonductorNetwork,
2663    transformer: &'net powerio_dist::DistTransformer,
2664    neutral_terminals: &BTreeSet<String>,
2665) -> Result<[&'net powerio_dist::DistWinding; 2], String> {
2666    use powerio_dist::DistWindingConn;
2667    if transformer.phases != 3 {
2668        return Err(format!(
2669            "has {} phases; only a three phase transformer lowers",
2670            transformer.phases
2671        ));
2672    }
2673    let [high, low] = transformer.windings.as_slice() else {
2674        return Err(format!(
2675            "has {} windings; only a two winding transformer lowers",
2676            transformer.windings.len()
2677        ));
2678    };
2679    if high.conn == low.conn {
2680        return Err(format!(
2681            "states a {:?}-{:?} connection; only wye_delta and delta_wye lower, with their \
2682             representable thirty degree shift",
2683            high.conn, low.conn
2684        ));
2685    }
2686    debug_assert!(matches!(
2687        (high.conn, low.conn),
2688        (DistWindingConn::Wye, DistWindingConn::Delta)
2689            | (DistWindingConn::Delta, DistWindingConn::Wye)
2690    ));
2691    for winding in [high, low] {
2692        let Some(bus) = net.bus(&winding.bus) else {
2693            return Err(format!("references unknown bus {}", winding.bus));
2694        };
2695        let active = active_terminal_count(&winding.terminal_map, Some(bus), neutral_terminals);
2696        if active != 3 {
2697            return Err(format!(
2698                "winding on bus {} maps {active} active conductors; a full three phase map \
2699                 is required",
2700                winding.bus
2701            ));
2702        }
2703        if !(winding.v_ref.is_finite() && winding.v_ref > 0.0) {
2704            return Err(format!(
2705                "winding on bus {} has no finite positive voltage rating",
2706                winding.bus
2707            ));
2708        }
2709        if !winding.r_pct.is_finite() || winding.r_pct < 0.0 {
2710            return Err(format!(
2711                "winding on bus {} has no finite nonnegative resistance",
2712                winding.bus
2713            ));
2714        }
2715        if !winding.tap.is_finite() || winding.tap <= 0.0 {
2716            return Err(format!(
2717                "winding on bus {} has no finite positive tap",
2718                winding.bus
2719            ));
2720        }
2721    }
2722    if !(high.s_rating.is_finite() && high.s_rating > 0.0) {
2723        return Err("has no finite positive power rating".to_owned());
2724    }
2725    if !(low.s_rating.is_finite() && low.s_rating > 0.0) {
2726        return Err("has no finite positive low winding power rating".to_owned());
2727    }
2728    match transformer.xsc_pct.first() {
2729        Some(x) if x.is_finite() && *x >= 0.0 => {}
2730        _ => return Err("states no finite short circuit reactance".to_owned()),
2731    }
2732    Ok([high, low])
2733}
2734
2735fn check_untyped_objects(net: &MulticonductorNetwork, report: &mut MulticonductorToBalancedReport) {
2736    for (i, obj) in net.untyped_objects().iter().enumerate() {
2737        report.diagnostics.push(
2738            Diagnostic::of(
2739                &codes::TRANSFORM_MULTI_TO_BALANCED_UNSUPPORTED_OBJECT,
2740                format!(
2741                    "{} {} is preserved as an untyped object and cannot be lowered",
2742                    obj.class, obj.name
2743                ),
2744            )
2745            .with_value_target(format!("/untyped/{i}")),
2746        );
2747    }
2748}
2749
2750#[cfg(test)]
2751mod history_record_tests {
2752    use super::{capped_history_notes, unused_history_id};
2753
2754    #[test]
2755    fn note_overflow_is_stated_within_the_cap() {
2756        let cap = powerio_core::limits::MAX_HISTORY_NOTES;
2757        let notes: Vec<String> = (0..cap + 40).map(|index| format!("note {index}")).collect();
2758        let kept = capped_history_notes(notes, "assumptions");
2759        assert_eq!(kept.len(), cap);
2760        assert_eq!(kept.last().unwrap(), "41 more assumptions elided");
2761
2762        let short: Vec<String> = (0..3).map(|index| format!("note {index}")).collect();
2763        assert_eq!(capped_history_notes(short.clone(), "assumptions"), short);
2764    }
2765
2766    #[test]
2767    fn the_history_id_is_minted_unused() {
2768        use powerio_core::{HistoryEntry, HistoryId, HistoryKind, PioModule};
2769        let mut module = PioModule::new(crate::PioValue::BalancedNetwork(
2770            crate::BalancedNetwork::in_memory("t", 100.0, Vec::new(), Vec::new()),
2771        ));
2772        assert_eq!(
2773            unused_history_id(&module, "multiconductor-to-balanced").as_str(),
2774            "multiconductor-to-balanced"
2775        );
2776        for id in ["multiconductor-to-balanced", "multiconductor-to-balanced-2"] {
2777            module
2778                .add_history_entry(
2779                    HistoryEntry::new(
2780                        HistoryId::new(id).unwrap(),
2781                        HistoryKind::Transform,
2782                        "to_balanced",
2783                    )
2784                    .unwrap(),
2785                )
2786                .unwrap();
2787        }
2788        assert_eq!(
2789            unused_history_id(&module, "multiconductor-to-balanced").as_str(),
2790            "multiconductor-to-balanced-3"
2791        );
2792    }
2793}