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