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