Skip to main content

powerio_pkg/
lowering.rs

1//! Lowering records and preflight checks.
2//!
3//! Lowering is where PowerIO is a compiler rather than a parser: every pass that
4//! transforms one model into another (normalization, multiconductor to balanced,
5//! emission to a target format) appends a [`LoweringRecord`] to the package's
6//! `lowering_history`, so the transformation is auditable. The most consequential
7//! case, multiconductor to balanced, must be an explicit pass with diagnostics,
8//! never a silent positive sequence projection.
9
10use std::collections::{BTreeMap, BTreeSet};
11use std::f64::consts::PI;
12
13use num_complex::Complex64;
14use serde::{Deserialize, Serialize};
15
16use powerio::{
17    BalancedNetwork, Branch, BranchCharging, Bus, BusId, BusType, Extras as BalancedExtras,
18    Generator, Load, Network, Shunt, SourceFormat,
19};
20use powerio_dist::{DistBus, DistLineCode, DistLoadVoltageModel, Mat, MulticonductorNetwork};
21
22use crate::diagnostics::{DiagnosticSeverity, DiagnosticStage, StructuredDiagnostic};
23use crate::model::ModelKind;
24use crate::validation::ValidationStatus;
25
26/// One lowering/normalization/emission pass and what it changed.
27#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
28#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
29pub struct LoweringRecord {
30    /// A stable pass name, e.g. `normalize-balanced` or `multiconductor-to-balanced`.
31    pub pass: String,
32    pub input_kind: ModelKind,
33    pub output_kind: ModelKind,
34    #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
35    pub options: serde_json::Map<String, serde_json::Value>,
36    /// Modeling assumptions the pass relied on (e.g. "balanced four-wire feeder").
37    #[serde(default, skip_serializing_if = "Vec::is_empty")]
38    pub assumptions: Vec<String>,
39    /// Approximations the pass introduced (e.g. "Kron reduction of neutral").
40    #[serde(default, skip_serializing_if = "Vec::is_empty")]
41    pub approximations: Vec<String>,
42    /// Fields/constraints dropped because the output family cannot carry them.
43    #[serde(default, skip_serializing_if = "Vec::is_empty")]
44    pub dropped_fields: Vec<String>,
45    #[serde(default, skip_serializing_if = "Vec::is_empty")]
46    pub diagnostics: Vec<StructuredDiagnostic>,
47    pub validation_status: ValidationStatus,
48}
49
50impl LoweringRecord {
51    pub fn new(pass: impl Into<String>, input_kind: ModelKind, output_kind: ModelKind) -> Self {
52        Self {
53            pass: pass.into(),
54            input_kind,
55            output_kind,
56            options: serde_json::Map::new(),
57            assumptions: Vec::new(),
58            approximations: Vec::new(),
59            dropped_fields: Vec::new(),
60            diagnostics: Vec::new(),
61            validation_status: ValidationStatus::Ok,
62        }
63    }
64}
65
66/// Sequence transform used by the multiconductor to balanced lowering.
67#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
68#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
69#[serde(rename_all = "snake_case")]
70pub enum SequenceTransformConvention {
71    FortescuePowerInvariant,
72}
73
74impl std::fmt::Display for SequenceTransformConvention {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        match self {
77            Self::FortescuePowerInvariant => f.write_str("FortescuePowerInvariant"),
78        }
79    }
80}
81
82const DEFAULT_LOWERING_BASE_MVA: f64 = 100.0;
83const SQRT_3: f64 = 1.732_050_807_568_877_2;
84const COUPLING_TOLERANCE: f64 = 1.0e-9;
85
86fn default_lowering_base_mva() -> f64 {
87    DEFAULT_LOWERING_BASE_MVA
88}
89
90/// Options for the multiconductor to balanced lowering preflight and pass.
91#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
92#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
93pub struct MulticonductorToBalancedOptions {
94    pub convention: SequenceTransformConvention,
95    /// Three phase system power base used for the balanced per-unit projection.
96    #[serde(default = "default_lowering_base_mva")]
97    pub base_mva: f64,
98}
99
100impl Default for MulticonductorToBalancedOptions {
101    fn default() -> Self {
102        Self {
103            convention: SequenceTransformConvention::FortescuePowerInvariant,
104            base_mva: DEFAULT_LOWERING_BASE_MVA,
105        }
106    }
107}
108
109/// Readiness report for the multiconductor to balanced lowering pass.
110#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
111#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
112pub struct MulticonductorToBalancedReadiness {
113    pub convention: SequenceTransformConvention,
114    pub base_mva: f64,
115    pub status: ValidationStatus,
116    #[serde(default, skip_serializing_if = "Vec::is_empty")]
117    pub assumptions: Vec<String>,
118    #[serde(default, skip_serializing_if = "Vec::is_empty")]
119    pub approximations: Vec<String>,
120    #[serde(default, skip_serializing_if = "Vec::is_empty")]
121    pub diagnostics: Vec<StructuredDiagnostic>,
122}
123
124impl MulticonductorToBalancedReadiness {
125    #[must_use]
126    pub fn is_ready(&self) -> bool {
127        self.status <= ValidationStatus::Info
128    }
129}
130
131/// A successful raw multiconductor to balanced lowering result.
132#[derive(Clone, Debug, Serialize, Deserialize)]
133#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
134pub struct MulticonductorToBalancedLowering {
135    pub network: BalancedNetwork,
136    pub record: LoweringRecord,
137}
138
139/// Structured failure from the raw multiconductor to balanced lowering pass.
140#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
141#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
142pub struct MulticonductorToBalancedError {
143    pub options: MulticonductorToBalancedOptions,
144    pub status: ValidationStatus,
145    #[serde(default, skip_serializing_if = "Vec::is_empty")]
146    pub diagnostics: Vec<StructuredDiagnostic>,
147}
148
149impl MulticonductorToBalancedError {
150    pub fn new(
151        options: MulticonductorToBalancedOptions,
152        diagnostics: Vec<StructuredDiagnostic>,
153    ) -> Self {
154        Self {
155            options,
156            status: status_from_diagnostics(&diagnostics),
157            diagnostics,
158        }
159    }
160}
161
162impl std::fmt::Display for MulticonductorToBalancedError {
163    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164        match self.diagnostics.first() {
165            Some(diagnostic) => write!(f, "{}", diagnostic.message),
166            None => f.write_str("multiconductor to balanced lowering failed"),
167        }
168    }
169}
170
171impl std::error::Error for MulticonductorToBalancedError {}
172
173/// Check whether a multiconductor package is ready for the lowering pass.
174///
175/// This is a preflight only: it reports the assumptions and blockers that the
176/// lowering would need to account for, but it does not produce a balanced model
177/// and does not append to `lowering_history`.
178#[must_use]
179pub fn check_multiconductor_to_balanced_lowering(
180    net: &MulticonductorNetwork,
181    options: MulticonductorToBalancedOptions,
182) -> MulticonductorToBalancedReadiness {
183    let mut report = MulticonductorToBalancedReadiness {
184        convention: options.convention,
185        base_mva: options.base_mva,
186        status: ValidationStatus::Ok,
187        assumptions: vec![format!(
188            "sequence transform convention: {}",
189            options.convention
190        )],
191        approximations: Vec::new(),
192        diagnostics: Vec::new(),
193    };
194
195    check_options(options, &mut report);
196    check_bus_conductor_sets(net, &mut report);
197    check_phase_reference(net, &mut report);
198    check_line_terminal_maps(net, &mut report);
199    check_linecodes(net, &mut report);
200    check_switches(net, &mut report);
201    check_transformers(net, &mut report);
202    check_untyped_objects(net, &mut report);
203
204    report.status = status_from_diagnostics(&report.diagnostics);
205    report
206}
207
208/// Lower a transparent three phase multiconductor network to a balanced model.
209///
210/// The pass is explicit. It does not run from readers, writers, matrix builders,
211/// bindings, or package deserialization. Unsupported inputs return structured
212/// `LOWER.MULTI_TO_BALANCED.*` diagnostics in [`MulticonductorToBalancedError`].
213pub fn lower_multiconductor_to_balanced(
214    net: &MulticonductorNetwork,
215    options: MulticonductorToBalancedOptions,
216) -> Result<MulticonductorToBalancedLowering, MulticonductorToBalancedError> {
217    let readiness = check_multiconductor_to_balanced_lowering(net, options);
218    if !readiness.is_ready() {
219        return Err(MulticonductorToBalancedError::new(
220            options,
221            readiness.diagnostics,
222        ));
223    }
224
225    let mut state = LoweringState::new(net, options, readiness);
226    state.lower()
227}
228
229struct LoweringState<'a> {
230    net: &'a MulticonductorNetwork,
231    options: MulticonductorToBalancedOptions,
232    neutral_terminals: BTreeSet<String>,
233    bus_ids: BTreeMap<String, BusId>,
234    record: LoweringRecord,
235}
236
237impl<'a> LoweringState<'a> {
238    fn new(
239        net: &'a MulticonductorNetwork,
240        options: MulticonductorToBalancedOptions,
241        readiness: MulticonductorToBalancedReadiness,
242    ) -> Self {
243        let mut record = LoweringRecord::new(
244            "multiconductor-to-balanced",
245            ModelKind::Multiconductor,
246            ModelKind::Balanced,
247        );
248        record.options = options_map(options);
249        record.assumptions = readiness.assumptions;
250        record.approximations = readiness.approximations;
251        record.diagnostics = readiness.diagnostics;
252        record
253            .assumptions
254            .push(format!("balanced power base: {} MVA", options.base_mva));
255        record
256            .assumptions
257            .push("balanced bus ids are synthesized from multiconductor bus order".to_owned());
258        record.approximations.push(
259            "wire-coordinate branch and shunt matrices are projected to positive sequence"
260                .to_owned(),
261        );
262        record.approximations.push(
263            "phase injection records are aggregated into scalar balanced injections".to_owned(),
264        );
265        record.approximations.push(
266            "units are converted from W/var/V/ohm/siemens/radians to MW/MVAr/per-unit/degrees"
267                .to_owned(),
268        );
269        if net.switches.iter().any(|sw| sw.open) {
270            record
271                .dropped_fields
272                .push("open switches dropped from balanced model".to_owned());
273        }
274
275        let bus_ids = net
276            .buses
277            .iter()
278            .enumerate()
279            .map(|(idx, bus)| (bus.id.to_ascii_lowercase(), BusId(idx + 1)))
280            .collect();
281
282        Self {
283            net,
284            options,
285            neutral_terminals: global_neutral_terminals(net),
286            bus_ids,
287            record,
288        }
289    }
290
291    #[allow(clippy::too_many_lines)]
292    fn lower(&mut self) -> Result<MulticonductorToBalancedLowering, MulticonductorToBalancedError> {
293        let Some(base) = self.voltage_base()? else {
294            return Err(MulticonductorToBalancedError::new(
295                self.options,
296                self.record.diagnostics.clone(),
297            ));
298        };
299
300        let buses = self.lower_buses(base);
301        let branches = self.lower_lines(base)?;
302        let loads = self.lower_loads();
303        let shunts = self.lower_shunts(base)?;
304        let generators = self.lower_generators(&buses);
305        self.err_if_errors()?;
306
307        let mut network = Network::new(
308            self.net
309                .name
310                .clone()
311                .unwrap_or_else(|| "lowered-multiconductor".to_owned()),
312            self.options.base_mva,
313        );
314        network.base_frequency = self.net.base_frequency;
315        network.buses = buses;
316        network.loads = loads;
317        network.shunts = shunts;
318        network.branches = branches;
319        network.generators = generators;
320        network.source_format = SourceFormat::InMemory;
321
322        if let Err(err) = network.validate() {
323            self.record.diagnostics.push(StructuredDiagnostic::new(
324                "LOWER.MULTI_TO_BALANCED.INVALID_BALANCED_OUTPUT",
325                DiagnosticSeverity::Error,
326                DiagnosticStage::Lower,
327                format!("lowered balanced network failed structural validation: {err}"),
328            ));
329            return Err(MulticonductorToBalancedError::new(
330                self.options,
331                self.record.diagnostics.clone(),
332            ));
333        }
334        for finding in network.validate_values() {
335            self.record.diagnostics.push(
336                StructuredDiagnostic::new(
337                    "LOWER.MULTI_TO_BALANCED.BALANCED_VALUE_DOMAIN",
338                    DiagnosticSeverity::Warning,
339                    DiagnosticStage::Lower,
340                    format!(
341                        "{} field `{}` is outside its value domain after lowering",
342                        finding.element, finding.field
343                    ),
344                )
345                .with_suggested_action(
346                    "Inspect the multiconductor source values before using the lowered model.",
347                ),
348            );
349        }
350
351        self.record.validation_status = status_from_diagnostics(&self.record.diagnostics);
352        Ok(MulticonductorToBalancedLowering {
353            network,
354            record: self.record.clone(),
355        })
356    }
357
358    fn voltage_base(&mut self) -> Result<Option<VoltageBase>, MulticonductorToBalancedError> {
359        for (idx, source) in self.net.sources.iter().enumerate() {
360            let Some(bus) = self.net.bus(&source.bus) else {
361                self.record.diagnostics.push(
362                    StructuredDiagnostic::new(
363                        "LOWER.MULTI_TO_BALANCED.UNKNOWN_SOURCE_BUS",
364                        DiagnosticSeverity::Error,
365                        DiagnosticStage::Lower,
366                        format!(
367                            "voltage source {} references unknown bus {}",
368                            source.name, source.bus
369                        ),
370                    )
371                    .with_element_path(format!("/model/multiconductor_network/sources/{idx}/bus")),
372                );
373                continue;
374            };
375            let positions =
376                active_positions(&source.terminal_map, Some(bus), &self.neutral_terminals);
377            if positions.len() != 3 {
378                continue;
379            }
380            let Some(v1) = positive_sequence_voltage(source, &positions) else {
381                self.record.diagnostics.push(
382                    StructuredDiagnostic::new(
383                        "LOWER.MULTI_TO_BALANCED.INVALID_PHASE_REFERENCE",
384                        DiagnosticSeverity::Error,
385                        DiagnosticStage::Lower,
386                        format!(
387                            "voltage source {} does not carry finite three phase voltage magnitudes and angles",
388                            source.name
389                        ),
390                    )
391                    .with_element_path(format!("/model/multiconductor_network/sources/{idx}")),
392                );
393                continue;
394            };
395            let line_to_line_volts = v1.norm();
396            if !line_to_line_volts.is_finite() || line_to_line_volts <= 0.0 {
397                self.record.diagnostics.push(
398                    StructuredDiagnostic::new(
399                        "LOWER.MULTI_TO_BALANCED.INVALID_PHASE_REFERENCE",
400                        DiagnosticSeverity::Error,
401                        DiagnosticStage::Lower,
402                        format!(
403                            "voltage source {} produced a non-positive positive-sequence voltage base",
404                            source.name
405                        ),
406                    )
407                    .with_element_path(format!("/model/multiconductor_network/sources/{idx}")),
408                );
409                continue;
410            }
411            self.record.assumptions.push(format!(
412                "voltage base synthesized from source {} positive-sequence voltage: {} kV line-to-line",
413                source.name,
414                line_to_line_volts / 1000.0
415            ));
416            return Ok(Some(VoltageBase { line_to_line_volts }));
417        }
418
419        if self
420            .record
421            .diagnostics
422            .iter()
423            .any(|d| d.severity >= DiagnosticSeverity::Error)
424        {
425            return Err(MulticonductorToBalancedError::new(
426                self.options,
427                self.record.diagnostics.clone(),
428            ));
429        }
430        self.record.diagnostics.push(StructuredDiagnostic::new(
431            "LOWER.MULTI_TO_BALANCED.MISSING_PHASE_REFERENCE",
432            DiagnosticSeverity::Error,
433            DiagnosticStage::Lower,
434            "multiconductor to balanced lowering requires a finite three phase voltage source reference",
435        ));
436        Ok(None)
437    }
438
439    fn lower_buses(&mut self, base: VoltageBase) -> Vec<Bus> {
440        self.net
441            .buses
442            .iter()
443            .enumerate()
444            .map(|(idx, bus)| {
445                let source = self
446                    .net
447                    .sources
448                    .iter()
449                    .find(|source| source.bus.eq_ignore_ascii_case(&bus.id));
450                let (vm, va) = source
451                    .and_then(|source| {
452                        let positions = active_positions(
453                            &source.terminal_map,
454                            Some(bus),
455                            &self.neutral_terminals,
456                        );
457                        positive_sequence_voltage(source, &positions)
458                    })
459                    .map_or((1.0, 0.0), |v| {
460                        (
461                            v.norm() / base.line_to_line_volts,
462                            radians_to_degrees(v.arg()),
463                        )
464                    });
465                if source.is_none() {
466                    self.record.dropped_fields.push(format!(
467                        "bus {} voltage magnitude and angle defaulted to 1.0 p.u. and 0 degrees",
468                        bus.id
469                    ));
470                }
471                let (vmin, vmax) = match (bus.v_min, bus.v_max) {
472                    (Some(vmin), Some(vmax)) if vmin.is_finite() && vmax.is_finite() => (
473                        vmin / base.line_to_line_volts,
474                        vmax / base.line_to_line_volts,
475                    ),
476                    _ => {
477                        self.record.dropped_fields.push(format!(
478                            "bus {} voltage bounds defaulted to 0.9/1.1 p.u.",
479                            bus.id
480                        ));
481                        (0.9, 1.1)
482                    }
483                };
484                self.record_bus_bound_drops(bus);
485                let mut balanced = Bus::new(
486                    BusId(idx + 1),
487                    self.bus_kind(&bus.id),
488                    base.line_to_line_volts / 1000.0,
489                );
490                balanced.vm = vm;
491                balanced.va = va;
492                balanced.vmax = vmax;
493                balanced.vmin = vmin;
494                balanced.name = Some(bus.id.clone());
495                balanced.extras = source_extra("multiconductor_bus_id", &bus.id);
496                balanced
497            })
498            .collect()
499    }
500
501    fn record_bus_bound_drops(&mut self, bus: &DistBus) {
502        if bus.vpn_min.is_some()
503            || bus.vpn_max.is_some()
504            || bus.vpp_min.is_some()
505            || bus.vpp_max.is_some()
506            || bus.vsym_min.is_some()
507            || bus.vsym_max.is_some()
508        {
509            self.record.dropped_fields.push(format!(
510                "bus {} conductor voltage bound families dropped",
511                bus.id
512            ));
513        }
514    }
515
516    fn bus_kind(&self, bus_id: &str) -> BusType {
517        if self
518            .net
519            .sources
520            .iter()
521            .any(|source| source.bus.eq_ignore_ascii_case(bus_id))
522        {
523            BusType::Ref
524        } else if self
525            .net
526            .generators
527            .iter()
528            .any(|generator| generator.bus.eq_ignore_ascii_case(bus_id))
529        {
530            BusType::Pv
531        } else {
532            BusType::Pq
533        }
534    }
535
536    #[allow(clippy::too_many_lines)]
537    fn lower_lines(
538        &mut self,
539        base: VoltageBase,
540    ) -> Result<Vec<Branch>, MulticonductorToBalancedError> {
541        let mut branches = Vec::with_capacity(self.net.lines.len());
542        for (idx, line) in self.net.lines.iter().enumerate() {
543            let Some(code) = self.net.linecode(&line.linecode) else {
544                self.record.diagnostics.push(
545                    StructuredDiagnostic::new(
546                        "LOWER.MULTI_TO_BALANCED.UNKNOWN_LINECODE",
547                        DiagnosticSeverity::Error,
548                        DiagnosticStage::Lower,
549                        format!(
550                            "line {} references unknown linecode `{}`",
551                            line.name, line.linecode
552                        ),
553                    )
554                    .with_element_path(format!(
555                        "/model/multiconductor_network/lines/{idx}/linecode"
556                    )),
557                );
558                continue;
559            };
560            if !same_active_phase_order(
561                self.net.bus(&line.bus_from),
562                &line.terminal_map_from,
563                self.net.bus(&line.bus_to),
564                &line.terminal_map_to,
565                &self.neutral_terminals,
566            ) {
567                self.record.diagnostics.push(
568                    StructuredDiagnostic::new(
569                        "LOWER.MULTI_TO_BALANCED.PHASE_MAP_MISMATCH",
570                        DiagnosticSeverity::Error,
571                        DiagnosticStage::Lower,
572                        format!(
573                            "line {} connects different active terminal orders and cannot be lowered transparently",
574                            line.name
575                        ),
576                    )
577                    .with_element_path(format!("/model/multiconductor_network/lines/{idx}")),
578                );
579                continue;
580            }
581            let Some(from) = self.bus_id(&line.bus_from) else {
582                self.unknown_bus_diag("line", &line.name, &line.bus_from, idx, "bus_from");
583                continue;
584            };
585            let Some(to) = self.bus_id(&line.bus_to) else {
586                self.unknown_bus_diag("line", &line.name, &line.bus_to, idx, "bus_to");
587                continue;
588            };
589            let from_bus = self.net.bus(&line.bus_from);
590            let active =
591                active_positions(&line.terminal_map_from, from_bus, &self.neutral_terminals);
592            let neutral =
593                neutral_positions(&line.terminal_map_from, from_bus, &self.neutral_terminals);
594            let z_ohm =
595                self.line_positive_sequence_impedance(idx, code, &active, &neutral, line.length)?;
596            let y_from = self.line_positive_sequence_admittance(
597                idx,
598                code,
599                &active,
600                &neutral,
601                line.length,
602                ShuntSide::From,
603            )?;
604            let y_to = self.line_positive_sequence_admittance(
605                idx,
606                code,
607                &active,
608                &neutral,
609                line.length,
610                ShuntSide::To,
611            )?;
612            let z_base = base.z_base_ohm(self.options.base_mva);
613            let y_scale = z_base;
614            let charging = BranchCharging::new(
615                y_from.re * y_scale,
616                y_from.im * y_scale,
617                y_to.re * y_scale,
618                y_to.im * y_scale,
619            );
620            let rate = line_rate_mva(code, &active, base.line_to_line_volts).unwrap_or_else(|| {
621                self.record.dropped_fields.push(format!(
622                    "line {} thermal rating defaulted to 0 MVA",
623                    line.name
624                ));
625                0.0
626            });
627            let mut branch = Branch::new(from, to, z_ohm.re / z_base, z_ohm.im / z_base);
628            branch.b = charging.total_b();
629            branch.charging = Some(charging);
630            branch.rate_a = rate;
631            branch.rate_b = rate;
632            branch.rate_c = rate;
633            branch.extras = source_extra("multiconductor_line", &line.name);
634            branches.push(branch);
635        }
636        self.err_if_errors()?;
637        Ok(branches)
638    }
639
640    fn line_positive_sequence_impedance(
641        &mut self,
642        line_idx: usize,
643        code: &DistLineCode,
644        active: &[usize],
645        neutral: &[usize],
646        length: f64,
647    ) -> Result<Complex64, MulticonductorToBalancedError> {
648        let matrix = complex_matrix(&code.r_series, &code.x_series, length);
649        let reduced = kron_or_select(&matrix, active, neutral).map_err(|message| {
650            self.matrix_error(line_idx, &code.name, "series impedance", &message)
651        })?;
652        Ok(self.positive_sequence_from_matrix(line_idx, &code.name, "series impedance", &reduced))
653    }
654
655    fn line_positive_sequence_admittance(
656        &mut self,
657        line_idx: usize,
658        code: &DistLineCode,
659        active: &[usize],
660        neutral: &[usize],
661        length: f64,
662        side: ShuntSide,
663    ) -> Result<Complex64, MulticonductorToBalancedError> {
664        let (g, b, label) = match side {
665            ShuntSide::From => (&code.g_from, &code.b_from, "from shunt admittance"),
666            ShuntSide::To => (&code.g_to, &code.b_to, "to shunt admittance"),
667        };
668        let matrix = complex_matrix(g, b, length);
669        let reduced = kron_or_select(&matrix, active, neutral)
670            .map_err(|message| self.matrix_error(line_idx, &code.name, label, &message))?;
671        Ok(self.positive_sequence_from_matrix(line_idx, &code.name, label, &reduced))
672    }
673
674    fn positive_sequence_from_matrix(
675        &mut self,
676        line_idx: usize,
677        code_name: &str,
678        label: &str,
679        matrix: &[Vec<Complex64>],
680    ) -> Complex64 {
681        let seq = sequence_matrix(matrix);
682        let coupling = sequence_coupling_norm(&seq);
683        if coupling > COUPLING_TOLERANCE {
684            self.record.approximations.push(format!(
685                "linecode {code_name} {label} has sequence coupling norm {coupling}; positive-sequence diagonal retained"
686            ));
687            let mut diagnostic = StructuredDiagnostic::new(
688                "LOWER.MULTI_TO_BALANCED.SEQUENCE_COUPLING_DROPPED",
689                DiagnosticSeverity::Info,
690                DiagnosticStage::Lower,
691                format!(
692                    "linecode {code_name} {label} has nonzero sequence coupling; the balanced model keeps the positive-sequence diagonal"
693                ),
694            )
695            .with_element_path(format!("/model/multiconductor_network/lines/{line_idx}/linecode"));
696            diagnostic.details.insert(
697                "sequence_coupling_norm".to_owned(),
698                serde_json::json!(coupling),
699            );
700            self.record.diagnostics.push(diagnostic);
701        }
702        seq[1][1]
703    }
704
705    fn matrix_error(
706        &self,
707        line_idx: usize,
708        code_name: &str,
709        label: &str,
710        message: &str,
711    ) -> MulticonductorToBalancedError {
712        let mut diagnostics = self.record.diagnostics.clone();
713        diagnostics.push(
714            StructuredDiagnostic::new(
715                "LOWER.MULTI_TO_BALANCED.INVALID_LINECODE_MATRIX",
716                DiagnosticSeverity::Error,
717                DiagnosticStage::Lower,
718                format!("linecode {code_name} {label} cannot be lowered: {message}"),
719            )
720            .with_element_path(format!(
721                "/model/multiconductor_network/lines/{line_idx}/linecode"
722            )),
723        );
724        MulticonductorToBalancedError::new(self.options, diagnostics)
725    }
726
727    fn lower_loads(&mut self) -> Vec<Load> {
728        self.net
729            .loads
730            .iter()
731            .enumerate()
732            .filter_map(|(idx, load)| {
733                let Some(bus) = self.bus_id(&load.bus) else {
734                    self.unknown_bus_diag("load", &load.name, &load.bus, idx, "bus");
735                    return None;
736                };
737                if !matches!(
738                    load.voltage_model,
739                    DistLoadVoltageModel::ConstantPower { .. }
740                ) {
741                    self.record.dropped_fields.push(format!(
742                        "load {} voltage model dropped; balanced load is constant power",
743                        load.name
744                    ));
745                    self.record.diagnostics.push(
746                        StructuredDiagnostic::new(
747                            "LOWER.MULTI_TO_BALANCED.DROPPED_LOAD_VOLTAGE_MODEL",
748                            DiagnosticSeverity::Warning,
749                            DiagnosticStage::Lower,
750                            format!(
751                                "load {} voltage model cannot be represented by the conservative balanced lowering",
752                                load.name
753                            ),
754                        )
755                        .with_element_path(format!("/model/multiconductor_network/loads/{idx}/voltage_model")),
756                    );
757                }
758                let mut balanced = Load::new(
759                    bus,
760                    si_power_to_mega(load.p_nom.iter().sum()),
761                    si_power_to_mega(load.q_nom.iter().sum()),
762                );
763                balanced.extras = source_extra("multiconductor_load", &load.name);
764                Some(balanced)
765            })
766            .collect()
767    }
768
769    fn lower_shunts(
770        &mut self,
771        base: VoltageBase,
772    ) -> Result<Vec<Shunt>, MulticonductorToBalancedError> {
773        let mut shunts = Vec::with_capacity(self.net.shunts.len());
774        for (idx, shunt) in self.net.shunts.iter().enumerate() {
775            let Some(bus) = self.bus_id(&shunt.bus) else {
776                self.unknown_bus_diag("shunt", &shunt.name, &shunt.bus, idx, "bus");
777                continue;
778            };
779            let dist_bus = self.net.bus(&shunt.bus);
780            let active = active_positions(&shunt.terminal_map, dist_bus, &self.neutral_terminals);
781            let neutral = neutral_positions(&shunt.terminal_map, dist_bus, &self.neutral_terminals);
782            let y = if active.len() == 3 {
783                let matrix = complex_matrix(&shunt.g, &shunt.b, 1.0);
784                let reduced = kron_or_select(&matrix, &active, &neutral)
785                    .map_err(|message| self.shunt_matrix_error(idx, &shunt.name, &message))?;
786                let seq = sequence_matrix(&reduced);
787                seq[1][1]
788            } else {
789                self.record.approximations.push(format!(
790                    "shunt {} has {} active terminal(s); diagonal admittance projected with missing phases as zero",
791                    shunt.name,
792                    active.len()
793                ));
794                partial_phase_admittance(&shunt.g, &shunt.b, &active)
795            };
796            let scale = base.line_to_line_volts * base.line_to_line_volts / 1_000_000.0;
797            let mut balanced = Shunt::new(bus, y.re * scale, y.im * scale);
798            balanced.extras = source_extra("multiconductor_shunt", &shunt.name);
799            shunts.push(balanced);
800        }
801        self.err_if_errors()?;
802        Ok(shunts)
803    }
804
805    fn shunt_matrix_error(
806        &self,
807        shunt_idx: usize,
808        name: &str,
809        message: &str,
810    ) -> MulticonductorToBalancedError {
811        let mut diagnostics = self.record.diagnostics.clone();
812        diagnostics.push(
813            StructuredDiagnostic::new(
814                "LOWER.MULTI_TO_BALANCED.INVALID_SHUNT_MATRIX",
815                DiagnosticSeverity::Error,
816                DiagnosticStage::Lower,
817                format!("shunt {name} cannot be lowered: {message}"),
818            )
819            .with_element_path(format!("/model/multiconductor_network/shunts/{shunt_idx}")),
820        );
821        MulticonductorToBalancedError::new(self.options, diagnostics)
822    }
823
824    fn lower_generators(&mut self, buses: &[Bus]) -> Vec<Generator> {
825        self.net
826            .generators
827            .iter()
828            .enumerate()
829            .filter_map(|(idx, generator)| {
830                let Some(bus) = self.bus_id(&generator.bus) else {
831                    self.unknown_bus_diag("generator", &generator.name, &generator.bus, idx, "bus");
832                    return None;
833                };
834                let pg = si_power_to_mega(generator.p_nom.iter().sum());
835                let qg = si_power_to_mega(generator.q_nom.iter().sum());
836                let pmin = option_vec_sum_mw(generator.p_min.as_deref()).unwrap_or_else(|| {
837                    self.record.dropped_fields.push(format!(
838                        "generator {} p_min defaulted to pg",
839                        generator.name
840                    ));
841                    pg
842                });
843                let pmax = option_vec_sum_mw(generator.p_max.as_deref()).unwrap_or_else(|| {
844                    self.record.dropped_fields.push(format!(
845                        "generator {} p_max defaulted to pg",
846                        generator.name
847                    ));
848                    pg
849                });
850                let qmin = option_vec_sum_mw(generator.q_min.as_deref()).unwrap_or_else(|| {
851                    self.record.dropped_fields.push(format!(
852                        "generator {} q_min defaulted to qg",
853                        generator.name
854                    ));
855                    qg
856                });
857                let qmax = option_vec_sum_mw(generator.q_max.as_deref()).unwrap_or_else(|| {
858                    self.record.dropped_fields.push(format!(
859                        "generator {} q_max defaulted to qg",
860                        generator.name
861                    ));
862                    qg
863                });
864                if generator.cost.is_some() {
865                    self.record.dropped_fields.push(format!(
866                        "generator {} scalar distribution cost dropped",
867                        generator.name
868                    ));
869                }
870                let vg = buses
871                    .iter()
872                    .find(|balanced_bus| balanced_bus.id == bus)
873                    .map_or(1.0, |balanced_bus| balanced_bus.vm);
874                let mut balanced = Generator::new(bus);
875                balanced.pg = pg;
876                balanced.qg = qg;
877                balanced.pmax = pmax;
878                balanced.pmin = pmin;
879                balanced.qmax = qmax;
880                balanced.qmin = qmin;
881                balanced.vg = vg;
882                balanced.mbase = self.options.base_mva;
883                Some(balanced)
884            })
885            .collect()
886    }
887
888    fn bus_id(&self, bus: &str) -> Option<BusId> {
889        self.bus_ids.get(&bus.to_ascii_lowercase()).copied()
890    }
891
892    fn unknown_bus_diag(&mut self, element: &str, name: &str, bus: &str, idx: usize, field: &str) {
893        self.record.diagnostics.push(
894            StructuredDiagnostic::new(
895                "LOWER.MULTI_TO_BALANCED.UNKNOWN_BUS",
896                DiagnosticSeverity::Error,
897                DiagnosticStage::Lower,
898                format!("{element} {name} references unknown bus {bus}"),
899            )
900            .with_element_path(format!(
901                "/model/multiconductor_network/{element}s/{idx}/{field}"
902            )),
903        );
904    }
905
906    fn err_if_errors(&self) -> Result<(), MulticonductorToBalancedError> {
907        if self
908            .record
909            .diagnostics
910            .iter()
911            .any(|d| d.severity >= DiagnosticSeverity::Error)
912        {
913            Err(MulticonductorToBalancedError::new(
914                self.options,
915                self.record.diagnostics.clone(),
916            ))
917        } else {
918            Ok(())
919        }
920    }
921}
922
923#[derive(Clone, Copy)]
924struct VoltageBase {
925    line_to_line_volts: f64,
926}
927
928impl VoltageBase {
929    fn z_base_ohm(self, base_mva: f64) -> f64 {
930        self.line_to_line_volts * self.line_to_line_volts / (base_mva * 1_000_000.0)
931    }
932}
933
934#[derive(Clone, Copy)]
935enum ShuntSide {
936    From,
937    To,
938}
939
940fn options_map(
941    options: MulticonductorToBalancedOptions,
942) -> serde_json::Map<String, serde_json::Value> {
943    serde_json::to_value(options)
944        .ok()
945        .and_then(|value| value.as_object().cloned())
946        .unwrap_or_default()
947}
948
949fn source_extra(key: &str, value: &str) -> BalancedExtras {
950    let mut extras = BalancedExtras::new();
951    extras.insert(key.to_owned(), serde_json::Value::String(value.to_owned()));
952    extras
953}
954
955fn active_positions(
956    terminals: &[String],
957    bus: Option<&DistBus>,
958    neutral_terminals: &BTreeSet<String>,
959) -> Vec<usize> {
960    terminals
961        .iter()
962        .enumerate()
963        .filter_map(|(idx, terminal)| {
964            (!is_neutral_terminal(terminal, bus, neutral_terminals)).then_some(idx)
965        })
966        .collect()
967}
968
969fn neutral_positions(
970    terminals: &[String],
971    bus: Option<&DistBus>,
972    neutral_terminals: &BTreeSet<String>,
973) -> Vec<usize> {
974    terminals
975        .iter()
976        .enumerate()
977        .filter_map(|(idx, terminal)| {
978            is_neutral_terminal(terminal, bus, neutral_terminals).then_some(idx)
979        })
980        .collect()
981}
982
983fn same_active_phase_order(
984    from_bus: Option<&DistBus>,
985    from_terminals: &[String],
986    to_bus: Option<&DistBus>,
987    to_terminals: &[String],
988    neutral_terminals: &BTreeSet<String>,
989) -> bool {
990    let from: Vec<_> = from_terminals
991        .iter()
992        .filter(|terminal| !is_neutral_terminal(terminal, from_bus, neutral_terminals))
993        .map(|terminal| terminal.to_ascii_lowercase())
994        .collect();
995    let to: Vec<_> = to_terminals
996        .iter()
997        .filter(|terminal| !is_neutral_terminal(terminal, to_bus, neutral_terminals))
998        .map(|terminal| terminal.to_ascii_lowercase())
999        .collect();
1000    from == to
1001}
1002
1003fn positive_sequence_voltage(
1004    source: &powerio_dist::VoltageSource,
1005    positions: &[usize],
1006) -> Option<Complex64> {
1007    if positions.len() != 3 {
1008        return None;
1009    }
1010    let mut phase = [Complex64::new(0.0, 0.0); 3];
1011    for (out, &idx) in phase.iter_mut().zip(positions.iter()) {
1012        let magnitude = *source.v_magnitude.get(idx)?;
1013        let angle = *source.v_angle.get(idx)?;
1014        if !magnitude.is_finite() || !angle.is_finite() {
1015            return None;
1016        }
1017        *out = Complex64::from_polar(magnitude, angle);
1018    }
1019    let basis = sequence_basis();
1020    let mut seq = [Complex64::new(0.0, 0.0); 3];
1021    for (sequence_idx, out) in seq.iter_mut().enumerate() {
1022        for phase_idx in 0..3 {
1023            *out += basis[phase_idx][sequence_idx].conj() * phase[phase_idx];
1024        }
1025    }
1026    Some(seq[1])
1027}
1028
1029fn complex_matrix(g_or_r: &Mat, b_or_x: &Mat, scale: f64) -> Vec<Vec<Complex64>> {
1030    g_or_r
1031        .iter()
1032        .zip(b_or_x.iter())
1033        .map(|(g_row, b_row)| {
1034            g_row
1035                .iter()
1036                .zip(b_row.iter())
1037                .map(|(&g, &b)| Complex64::new(g * scale, b * scale))
1038                .collect()
1039        })
1040        .collect()
1041}
1042
1043fn kron_or_select(
1044    matrix: &[Vec<Complex64>],
1045    active: &[usize],
1046    neutral: &[usize],
1047) -> Result<Vec<Vec<Complex64>>, String> {
1048    if active.len() != 3 {
1049        return Err(format!(
1050            "expected three active conductors, got {}",
1051            active.len()
1052        ));
1053    }
1054    validate_indices(matrix, active)?;
1055    validate_indices(matrix, neutral)?;
1056    if neutral.is_empty() {
1057        return Ok(submatrix(matrix, active, active));
1058    }
1059
1060    let m_pp = submatrix(matrix, active, active);
1061    let m_pn = submatrix(matrix, active, neutral);
1062    let m_np = submatrix(matrix, neutral, active);
1063    let m_nn = submatrix(matrix, neutral, neutral);
1064    if matrix_is_near_zero(&m_pn) && matrix_is_near_zero(&m_np) && matrix_is_near_zero(&m_nn) {
1065        return Ok(m_pp);
1066    }
1067    let inv_nn = invert_complex_matrix(&m_nn)?;
1068    let correction = matmul(&matmul(&m_pn, &inv_nn), &m_np);
1069    Ok(matrix_sub(&m_pp, &correction))
1070}
1071
1072fn matrix_is_near_zero(matrix: &[Vec<Complex64>]) -> bool {
1073    matrix
1074        .iter()
1075        .flatten()
1076        .all(|value| value.norm() <= f64::EPSILON)
1077}
1078
1079fn validate_indices(matrix: &[Vec<Complex64>], indices: &[usize]) -> Result<(), String> {
1080    let n = matrix.len();
1081    if matrix.iter().any(|row| row.len() != n) {
1082        return Err("matrix is not square".to_owned());
1083    }
1084    if indices.iter().any(|&idx| idx >= n) {
1085        return Err("terminal map references a conductor outside the matrix".to_owned());
1086    }
1087    Ok(())
1088}
1089
1090fn submatrix(matrix: &[Vec<Complex64>], rows: &[usize], cols: &[usize]) -> Vec<Vec<Complex64>> {
1091    rows.iter()
1092        .map(|&row| cols.iter().map(|&col| matrix[row][col]).collect())
1093        .collect()
1094}
1095
1096#[allow(clippy::needless_range_loop)]
1097fn invert_complex_matrix(matrix: &[Vec<Complex64>]) -> Result<Vec<Vec<Complex64>>, String> {
1098    let n = matrix.len();
1099    if n == 0 || matrix.iter().any(|row| row.len() != n) {
1100        return Err("neutral block is not square".to_owned());
1101    }
1102    let mut aug = vec![vec![Complex64::new(0.0, 0.0); 2 * n]; n];
1103    for i in 0..n {
1104        for j in 0..n {
1105            aug[i][j] = matrix[i][j];
1106        }
1107        aug[i][n + i] = Complex64::new(1.0, 0.0);
1108    }
1109
1110    for col in 0..n {
1111        let pivot = (col..n)
1112            .max_by(|&a, &b| aug[a][col].norm_sqr().total_cmp(&aug[b][col].norm_sqr()))
1113            .ok_or_else(|| "neutral block is singular".to_owned())?;
1114        if aug[pivot][col].norm() <= f64::EPSILON {
1115            return Err("neutral block is singular".to_owned());
1116        }
1117        if pivot != col {
1118            aug.swap(pivot, col);
1119        }
1120        let pivot_value = aug[col][col];
1121        for j in 0..(2 * n) {
1122            aug[col][j] /= pivot_value;
1123        }
1124        for row in 0..n {
1125            if row == col {
1126                continue;
1127            }
1128            let factor = aug[row][col];
1129            if factor.norm() <= f64::EPSILON {
1130                continue;
1131            }
1132            for j in 0..(2 * n) {
1133                let pivot_entry = aug[col][j];
1134                aug[row][j] -= factor * pivot_entry;
1135            }
1136        }
1137    }
1138
1139    Ok(aug
1140        .into_iter()
1141        .map(|row| row.into_iter().skip(n).collect())
1142        .collect())
1143}
1144
1145fn matmul(a: &[Vec<Complex64>], b: &[Vec<Complex64>]) -> Vec<Vec<Complex64>> {
1146    if a.is_empty() || b.is_empty() {
1147        return Vec::new();
1148    }
1149    let rows = a.len();
1150    let cols = b[0].len();
1151    let inner = b.len();
1152    let mut out = vec![vec![Complex64::new(0.0, 0.0); cols]; rows];
1153    for i in 0..rows {
1154        for k in 0..inner {
1155            for j in 0..cols {
1156                out[i][j] += a[i][k] * b[k][j];
1157            }
1158        }
1159    }
1160    out
1161}
1162
1163fn matrix_sub(a: &[Vec<Complex64>], b: &[Vec<Complex64>]) -> Vec<Vec<Complex64>> {
1164    a.iter()
1165        .zip(b.iter())
1166        .map(|(a_row, b_row)| {
1167            a_row
1168                .iter()
1169                .zip(b_row.iter())
1170                .map(|(&a_value, &b_value)| a_value - b_value)
1171                .collect()
1172        })
1173        .collect()
1174}
1175
1176#[allow(clippy::many_single_char_names)]
1177fn sequence_basis() -> [[Complex64; 3]; 3] {
1178    let scale = 1.0 / SQRT_3;
1179    let a = Complex64::from_polar(1.0, 2.0 * PI / 3.0);
1180    let a2 = a * a;
1181    [
1182        [
1183            Complex64::new(scale, 0.0),
1184            Complex64::new(scale, 0.0),
1185            Complex64::new(scale, 0.0),
1186        ],
1187        [Complex64::new(scale, 0.0), a2 * scale, a * scale],
1188        [Complex64::new(scale, 0.0), a * scale, a2 * scale],
1189    ]
1190}
1191
1192fn sequence_matrix(matrix: &[Vec<Complex64>]) -> [[Complex64; 3]; 3] {
1193    let basis = sequence_basis();
1194    let mut seq = [[Complex64::new(0.0, 0.0); 3]; 3];
1195    for p in 0..3 {
1196        for q in 0..3 {
1197            for i in 0..3 {
1198                for j in 0..3 {
1199                    seq[p][q] += basis[i][p].conj() * matrix[i][j] * basis[j][q];
1200                }
1201            }
1202        }
1203    }
1204    seq
1205}
1206
1207fn sequence_coupling_norm(seq: &[[Complex64; 3]; 3]) -> f64 {
1208    let mut sum = 0.0;
1209    for (i, row) in seq.iter().enumerate() {
1210        for (j, value) in row.iter().enumerate() {
1211            if i != j {
1212                sum += value.norm_sqr();
1213            }
1214        }
1215    }
1216    sum.sqrt()
1217}
1218
1219fn line_rate_mva(code: &DistLineCode, active: &[usize], line_to_line_volts: f64) -> Option<f64> {
1220    if let Some(s_max) = &code.s_max {
1221        let values: Vec<_> = active
1222            .iter()
1223            .filter_map(|&idx| s_max.get(idx).copied())
1224            .collect();
1225        if !values.is_empty() && values.iter().all(|value| value.is_finite()) {
1226            return Some(values.iter().sum::<f64>() / 1_000_000.0);
1227        }
1228    }
1229    let i_max = code.i_max.as_ref()?;
1230    let amps: Vec<_> = active
1231        .iter()
1232        .filter_map(|&idx| i_max.get(idx).copied())
1233        .filter(|value| value.is_finite() && *value >= 0.0)
1234        .collect();
1235    let amps = amps.into_iter().reduce(f64::min)?;
1236    Some(SQRT_3 * line_to_line_volts * amps / 1_000_000.0)
1237}
1238
1239fn partial_phase_admittance(g: &Mat, b: &Mat, active: &[usize]) -> Complex64 {
1240    let mut total = Complex64::new(0.0, 0.0);
1241    for &idx in active {
1242        let Some(g_row) = g.get(idx) else {
1243            continue;
1244        };
1245        let Some(b_row) = b.get(idx) else {
1246            continue;
1247        };
1248        let Some(&g_value) = g_row.get(idx) else {
1249            continue;
1250        };
1251        let Some(&b_value) = b_row.get(idx) else {
1252            continue;
1253        };
1254        total += Complex64::new(g_value, b_value);
1255    }
1256    total / 3.0
1257}
1258
1259fn si_power_to_mega(value: f64) -> f64 {
1260    value / 1_000_000.0
1261}
1262
1263fn option_vec_sum_mw(values: Option<&[f64]>) -> Option<f64> {
1264    values.map(|v| si_power_to_mega(v.iter().sum()))
1265}
1266
1267fn radians_to_degrees(value: f64) -> f64 {
1268    value * 180.0 / PI
1269}
1270
1271fn status_from_diagnostics(diagnostics: &[StructuredDiagnostic]) -> ValidationStatus {
1272    diagnostics
1273        .iter()
1274        .map(|d| match d.severity {
1275            DiagnosticSeverity::Debug => ValidationStatus::Ok,
1276            DiagnosticSeverity::Info => ValidationStatus::Info,
1277            DiagnosticSeverity::Warning => ValidationStatus::Warning,
1278            DiagnosticSeverity::Error => ValidationStatus::Error,
1279            DiagnosticSeverity::Fatal => ValidationStatus::Fatal,
1280        })
1281        .max()
1282        .unwrap_or(ValidationStatus::Ok)
1283}
1284
1285fn check_options(
1286    options: MulticonductorToBalancedOptions,
1287    report: &mut MulticonductorToBalancedReadiness,
1288) {
1289    if !options.base_mva.is_finite() || options.base_mva <= 0.0 {
1290        report.diagnostics.push(StructuredDiagnostic::new(
1291            "LOWER.MULTI_TO_BALANCED.INVALID_BASE_MVA",
1292            DiagnosticSeverity::Error,
1293            DiagnosticStage::Lower,
1294            format!(
1295                "base_mva must be positive and finite for multiconductor to balanced lowering; got {}",
1296                options.base_mva
1297            ),
1298        ));
1299    }
1300}
1301
1302fn check_bus_conductor_sets(
1303    net: &MulticonductorNetwork,
1304    report: &mut MulticonductorToBalancedReadiness,
1305) {
1306    let neutral_terminals = global_neutral_terminals(net);
1307    let mut saw_neutral = false;
1308    for (i, bus) in net.buses.iter().enumerate() {
1309        let active_count = active_terminal_count(&bus.terminals, Some(bus), &neutral_terminals);
1310        if active_count < bus.terminals.len() {
1311            saw_neutral = true;
1312        }
1313
1314        match active_count {
1315            3 => {}
1316            2 => report.diagnostics.push(
1317                StructuredDiagnostic::new(
1318                    "LOWER.MULTI_TO_BALANCED.AMBIGUOUS_TERMINAL_MAP",
1319                    DiagnosticSeverity::Error,
1320                    DiagnosticStage::Lower,
1321                    format!(
1322                        "bus {} has two active terminals; no unique positive sequence projection is defined",
1323                        bus.id
1324                    ),
1325                )
1326                .with_element_path(format!("/model/multiconductor_network/buses/{i}/terminals")),
1327            ),
1328            0 | 1 => report.diagnostics.push(
1329                StructuredDiagnostic::new(
1330                    "LOWER.MULTI_TO_BALANCED.UNSUPPORTED_CONDUCTOR_SET",
1331                    DiagnosticSeverity::Error,
1332                    DiagnosticStage::Lower,
1333                    format!(
1334                        "bus {} has {active_count} active terminal; multiconductor to balanced lowering starts with three phase input",
1335                        bus.id
1336                    ),
1337                )
1338                .with_element_path(format!("/model/multiconductor_network/buses/{i}/terminals")),
1339            ),
1340            _ => report.diagnostics.push(
1341                StructuredDiagnostic::new(
1342                    "LOWER.MULTI_TO_BALANCED.UNSUPPORTED_CONDUCTOR_SET",
1343                    DiagnosticSeverity::Error,
1344                    DiagnosticStage::Lower,
1345                    format!(
1346                        "bus {} has {active_count} active terminals; multiconductor to balanced lowering starts with three phase input",
1347                        bus.id
1348                    ),
1349                )
1350                .with_element_path(format!("/model/multiconductor_network/buses/{i}/terminals")),
1351            ),
1352        }
1353    }
1354
1355    if saw_neutral {
1356        report
1357            .approximations
1358            .push("Kron reduction of neutral conductor before sequence transform".to_owned());
1359        report.diagnostics.push(StructuredDiagnostic::new(
1360            "LOWER.MULTI_TO_BALANCED.KRON_REDUCTION_REQUIRED",
1361            DiagnosticSeverity::Info,
1362            DiagnosticStage::Lower,
1363            "neutral conductors require Kron reduction before the sequence transform",
1364        ));
1365    }
1366}
1367
1368fn check_line_terminal_maps(
1369    net: &MulticonductorNetwork,
1370    report: &mut MulticonductorToBalancedReadiness,
1371) {
1372    let neutral_terminals = global_neutral_terminals(net);
1373    for (i, line) in net.lines.iter().enumerate() {
1374        for (field, bus_id, terminal_map) in [
1375            (
1376                "terminal_map_from",
1377                line.bus_from.as_str(),
1378                line.terminal_map_from.as_slice(),
1379            ),
1380            (
1381                "terminal_map_to",
1382                line.bus_to.as_str(),
1383                line.terminal_map_to.as_slice(),
1384            ),
1385        ] {
1386            let bus = net.bus(bus_id);
1387            let active_count = active_terminal_count(terminal_map, bus, &neutral_terminals);
1388            if active_count != 3 {
1389                report.diagnostics.push(
1390                    StructuredDiagnostic::new(
1391                        "LOWER.MULTI_TO_BALANCED.UNSUPPORTED_CONDUCTOR_SET",
1392                        DiagnosticSeverity::Error,
1393                        DiagnosticStage::Lower,
1394                        format!(
1395                            "line {} {field} has {active_count} active terminal(s); balanced branch lowering requires three active phase conductors",
1396                            line.name
1397                        ),
1398                    )
1399                    .with_element_path(format!("/model/multiconductor_network/lines/{i}/{field}")),
1400                );
1401            }
1402        }
1403    }
1404}
1405
1406fn check_linecodes(net: &MulticonductorNetwork, report: &mut MulticonductorToBalancedReadiness) {
1407    for (i, line) in net.lines.iter().enumerate() {
1408        let Some(code) = net.linecode(&line.linecode) else {
1409            report.diagnostics.push(
1410                StructuredDiagnostic::new(
1411                    "LOWER.MULTI_TO_BALANCED.UNKNOWN_LINECODE",
1412                    DiagnosticSeverity::Error,
1413                    DiagnosticStage::Lower,
1414                    format!(
1415                        "line {} references unknown linecode `{}`",
1416                        line.name, line.linecode
1417                    ),
1418                )
1419                .with_element_path(format!("/model/multiconductor_network/lines/{i}/linecode")),
1420            );
1421            continue;
1422        };
1423        if code.n_conductors != line.terminal_map_from.len()
1424            || code.n_conductors != line.terminal_map_to.len()
1425        {
1426            report.diagnostics.push(
1427                StructuredDiagnostic::new(
1428                    "LOWER.MULTI_TO_BALANCED.LINECODE_TERMINAL_MISMATCH",
1429                    DiagnosticSeverity::Error,
1430                    DiagnosticStage::Lower,
1431                    format!(
1432                        "line {} uses linecode {} with {} conductor(s), but its terminal maps have {} and {} terminal(s)",
1433                        line.name,
1434                        code.name,
1435                        code.n_conductors,
1436                        line.terminal_map_from.len(),
1437                        line.terminal_map_to.len()
1438                    ),
1439                )
1440                .with_element_path(format!("/model/multiconductor_network/lines/{i}/linecode")),
1441            );
1442        }
1443        if !square_matrix_shape(&code.r_series, code.n_conductors)
1444            || !square_matrix_shape(&code.x_series, code.n_conductors)
1445            || !square_matrix_shape(&code.g_from, code.n_conductors)
1446            || !square_matrix_shape(&code.b_from, code.n_conductors)
1447            || !square_matrix_shape(&code.g_to, code.n_conductors)
1448            || !square_matrix_shape(&code.b_to, code.n_conductors)
1449        {
1450            report.diagnostics.push(
1451                StructuredDiagnostic::new(
1452                    "LOWER.MULTI_TO_BALANCED.INVALID_LINECODE_MATRIX",
1453                    DiagnosticSeverity::Error,
1454                    DiagnosticStage::Lower,
1455                    format!(
1456                        "linecode {} does not carry square {} conductor matrices",
1457                        code.name, code.n_conductors
1458                    ),
1459                )
1460                .with_element_path(format!(
1461                    "/model/multiconductor_network/linecodes/{}",
1462                    code.name
1463                )),
1464            );
1465        }
1466    }
1467}
1468
1469fn square_matrix_shape(matrix: &Mat, n: usize) -> bool {
1470    matrix.len() == n && matrix.iter().all(|row| row.len() == n)
1471}
1472
1473fn check_switches(net: &MulticonductorNetwork, report: &mut MulticonductorToBalancedReadiness) {
1474    for (i, sw) in net.switches.iter().enumerate() {
1475        if sw.open {
1476            report.diagnostics.push(
1477                StructuredDiagnostic::new(
1478                    "LOWER.MULTI_TO_BALANCED.DROPPED_OPEN_SWITCH",
1479                    DiagnosticSeverity::Info,
1480                    DiagnosticStage::Lower,
1481                    format!(
1482                        "open switch {} is dropped by multiconductor to balanced lowering",
1483                        sw.name
1484                    ),
1485                )
1486                .with_element_path(format!("/model/multiconductor_network/switches/{i}")),
1487            );
1488        } else {
1489            report.diagnostics.push(
1490                StructuredDiagnostic::new(
1491                    "LOWER.MULTI_TO_BALANCED.UNSUPPORTED_CLOSED_SWITCH",
1492                    DiagnosticSeverity::Error,
1493                    DiagnosticStage::Lower,
1494                    format!(
1495                        "closed switch {} is not lowered into a zero impedance balanced branch",
1496                        sw.name
1497                    ),
1498                )
1499                .with_element_path(format!("/model/multiconductor_network/switches/{i}")),
1500            );
1501        }
1502    }
1503}
1504
1505fn global_neutral_terminals(net: &MulticonductorNetwork) -> BTreeSet<String> {
1506    net.buses
1507        .iter()
1508        .flat_map(|bus| bus.grounded.iter().cloned())
1509        .collect()
1510}
1511
1512fn active_terminal_count(
1513    terminals: &[String],
1514    bus: Option<&DistBus>,
1515    neutral_terminals: &BTreeSet<String>,
1516) -> usize {
1517    terminals
1518        .iter()
1519        .filter(|terminal| !is_neutral_terminal(terminal, bus, neutral_terminals))
1520        .count()
1521}
1522
1523fn is_neutral_terminal(
1524    terminal: &str,
1525    bus: Option<&DistBus>,
1526    neutral_terminals: &BTreeSet<String>,
1527) -> bool {
1528    terminal == "0"
1529        || terminal.eq_ignore_ascii_case("n")
1530        || bus.is_some_and(|b| b.grounded.iter().any(|g| g == terminal))
1531        || neutral_terminals.contains(terminal)
1532}
1533
1534fn check_phase_reference(
1535    net: &MulticonductorNetwork,
1536    report: &mut MulticonductorToBalancedReadiness,
1537) {
1538    let neutral_terminals = global_neutral_terminals(net);
1539    let has_three_phase_source = net.sources.iter().any(|source| {
1540        let bus = net.bus(&source.bus);
1541        active_terminal_count(&source.terminal_map, bus, &neutral_terminals) == 3
1542    });
1543
1544    if !has_three_phase_source {
1545        report.diagnostics.push(StructuredDiagnostic::new(
1546            "LOWER.MULTI_TO_BALANCED.MISSING_PHASE_REFERENCE",
1547            DiagnosticSeverity::Error,
1548            DiagnosticStage::Lower,
1549            "multiconductor to balanced lowering requires a three phase voltage source reference",
1550        ));
1551    }
1552}
1553
1554fn check_transformers(net: &MulticonductorNetwork, report: &mut MulticonductorToBalancedReadiness) {
1555    for (i, transformer) in net.transformers.iter().enumerate() {
1556        report.diagnostics.push(
1557            StructuredDiagnostic::new(
1558                "LOWER.MULTI_TO_BALANCED.UNSUPPORTED_TRANSFORMER",
1559                DiagnosticSeverity::Error,
1560                DiagnosticStage::Lower,
1561                format!(
1562                    "transformer {} is not supported by the multiconductor to balanced preflight",
1563                    transformer.name
1564                ),
1565            )
1566            .with_element_path(format!("/model/multiconductor_network/transformers/{i}")),
1567        );
1568    }
1569}
1570
1571fn check_untyped_objects(
1572    net: &MulticonductorNetwork,
1573    report: &mut MulticonductorToBalancedReadiness,
1574) {
1575    for (i, obj) in net.untyped.iter().enumerate() {
1576        report.diagnostics.push(
1577            StructuredDiagnostic::new(
1578                "LOWER.MULTI_TO_BALANCED.UNSUPPORTED_OBJECT",
1579                DiagnosticSeverity::Error,
1580                DiagnosticStage::Lower,
1581                format!(
1582                    "{} {} is preserved as an untyped object and cannot be lowered",
1583                    obj.class, obj.name
1584                ),
1585            )
1586            .with_element_path(format!("/model/multiconductor_network/untyped/{i}")),
1587        );
1588    }
1589}