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