Skip to main content

powerio_tnd/
transform.rs

1//! Explicit transformations and their preflight checks.
2//!
3//! A pass that changes one model into another carries the module records
4//! forward and appends transformation history, so the result is auditable.
5//! Emission borrows the module and returns emission diagnostics separately. The
6//! most consequential transformation, multiconductor to balanced, is explicit
7//! and diagnosed, never a silent positive sequence projection.
8
9use std::collections::{BTreeMap, BTreeSet};
10use std::f64::consts::PI;
11
12use num_complex::Complex64;
13use serde::{Deserialize, Serialize};
14
15use powerio_core::{Diagnostic, DiagnosticSeverity, HistoryEntry, HistoryId, HistoryKind};
16use powerio_dist::{
17    ConductorMatrix, DistBus, DistLine, DistLineCode, DistLoadVoltageModel, MulticonductorNetwork,
18};
19use powerio_tx::{
20    BalancedNetwork, Branch, BranchCharging, Bus, BusId, BusType, Extras as BalancedExtras,
21    Generator, Load, Shunt, SourceFormat,
22};
23
24use crate::diagnostics as codes;
25
26trait DiagnosticTargetExt {
27    fn with_value_target(self, target: String) -> Self;
28}
29
30impl DiagnosticTargetExt for Diagnostic {
31    fn with_value_target(self, target: String) -> Self {
32        self.with_target(target)
33            .expect("transform targets are bounded RFC 6901 pointers")
34    }
35}
36
37/// Records accumulated while the transformation is being built. The public
38/// result exposes the current diagnostic and history records instead of this
39/// implementation detail.
40#[derive(Clone, Debug)]
41struct TransformRecords {
42    options: serde_json::Map<String, serde_json::Value>,
43    assumptions: Vec<String>,
44    approximations: Vec<String>,
45    dropped_fields: Vec<String>,
46    diagnostics: Vec<Diagnostic>,
47}
48
49impl TransformRecords {
50    fn new(options: MulticonductorToBalancedOptions) -> Self {
51        Self {
52            options: options_map(options),
53            assumptions: Vec::new(),
54            approximations: Vec::new(),
55            dropped_fields: Vec::new(),
56            diagnostics: Vec::new(),
57        }
58    }
59}
60
61/// Sequence transform used by the multiconductor to balanced lowering.
62#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
63#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
64#[serde(rename_all = "snake_case")]
65pub enum SequenceTransformConvention {
66    FortescuePowerInvariant,
67}
68
69impl std::fmt::Display for SequenceTransformConvention {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        match self {
72            Self::FortescuePowerInvariant => f.write_str("FortescuePowerInvariant"),
73        }
74    }
75}
76
77const DEFAULT_LOWERING_BASE_MVA: f64 = 100.0;
78const SQRT_3: f64 = 1.732_050_807_568_877_2;
79const COUPLING_TOLERANCE: f64 = 1.0e-9;
80
81fn default_lowering_base_mva() -> f64 {
82    DEFAULT_LOWERING_BASE_MVA
83}
84
85/// Options for the multiconductor to balanced lowering preflight and pass.
86#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
87#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
88pub struct MulticonductorToBalancedOptions {
89    pub convention: SequenceTransformConvention,
90    /// Three phase system power base used for the balanced per-unit projection.
91    #[serde(default = "default_lowering_base_mva")]
92    pub base_mva: f64,
93}
94
95impl Default for MulticonductorToBalancedOptions {
96    fn default() -> Self {
97        Self {
98            convention: SequenceTransformConvention::FortescuePowerInvariant,
99            base_mva: DEFAULT_LOWERING_BASE_MVA,
100        }
101    }
102}
103
104/// Report for the multiconductor to balanced transformation.
105#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
106pub struct MulticonductorToBalancedReport {
107    pub convention: SequenceTransformConvention,
108    pub base_mva: f64,
109    #[serde(default, skip_serializing_if = "Vec::is_empty")]
110    pub assumptions: Vec<String>,
111    #[serde(default, skip_serializing_if = "Vec::is_empty")]
112    pub approximations: Vec<String>,
113    #[serde(default, skip_serializing_if = "Vec::is_empty")]
114    pub diagnostics: Vec<Diagnostic>,
115}
116
117impl MulticonductorToBalancedReport {
118    #[must_use]
119    pub fn is_ready(&self) -> bool {
120        self.diagnostics
121            .iter()
122            .all(|diagnostic| diagnostic.severity() < DiagnosticSeverity::Error)
123    }
124
125    /// The greatest severity among this report's findings, or `None` for a
126    /// clean report.
127    #[must_use]
128    pub fn dominant_severity(&self) -> Option<DiagnosticSeverity> {
129        self.diagnostics
130            .iter()
131            .map(powerio_core::Diagnostic::severity)
132            .max()
133    }
134}
135
136/// A successful raw multiconductor to balanced lowering result.
137#[derive(Clone, Debug)]
138pub struct MulticonductorToBalancedTransformation {
139    pub network: BalancedNetwork,
140    /// Findings produced by the transformation itself.
141    pub diagnostics: Vec<Diagnostic>,
142    /// The current history record for this transformation. A module lowering
143    /// remints its ID when the default ID is already present.
144    pub history: HistoryEntry,
145    /// Buses removed by closed switch merges: removed bus ID to the kept
146    /// bus ID, in the source's spelling.
147    pub merged_buses: BTreeMap<String, String>,
148    /// Closed switches whose merge removed them from the balanced model.
149    pub removed_switches: Vec<String>,
150}
151
152/// Structured failure from the raw multiconductor to balanced lowering pass.
153///
154/// `diagnostics` are current module records. Their targets use the
155/// multiconductor value's pointer grammar (for example `/sources/0/bus`)
156/// because a refusal leaves that value unchanged.
157#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
158pub struct MulticonductorToBalancedError {
159    pub options: MulticonductorToBalancedOptions,
160    #[serde(default, skip_serializing_if = "Vec::is_empty")]
161    pub diagnostics: Vec<Diagnostic>,
162}
163
164impl MulticonductorToBalancedError {
165    pub fn new(options: MulticonductorToBalancedOptions, diagnostics: &[Diagnostic]) -> Self {
166        Self {
167            options,
168            diagnostics: diagnostics.to_vec(),
169        }
170    }
171}
172
173impl std::fmt::Display for MulticonductorToBalancedError {
174    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175        match self.diagnostics.first() {
176            Some(diagnostic) => write!(f, "{}", diagnostic.message()),
177            None => f.write_str("multiconductor to balanced transformation failed"),
178        }
179    }
180}
181
182impl std::error::Error for MulticonductorToBalancedError {}
183
184/// Check whether a multiconductor network is ready for the lowering pass.
185///
186/// This is a preflight only: it reports the assumptions and blockers that the
187/// lowering would need to account for, but it does not produce a balanced model
188/// and does not append to `history`.
189#[must_use]
190pub fn to_balanced_network_report(
191    net: &MulticonductorNetwork,
192    options: MulticonductorToBalancedOptions,
193) -> MulticonductorToBalancedReport {
194    let mut report = MulticonductorToBalancedReport {
195        convention: options.convention,
196        base_mva: options.base_mva,
197        assumptions: vec![format!(
198            "sequence transform convention: {}",
199            options.convention
200        )],
201        approximations: Vec::new(),
202        diagnostics: Vec::new(),
203    };
204
205    check_options(options, &mut report);
206    check_bus_conductor_sets(net, &mut report);
207    check_phase_reference(net, &mut report);
208    check_line_terminal_maps(net, &mut report);
209    check_linecodes(net, &mut report);
210    check_switches(net, &mut report);
211    check_transformers(net, &mut report);
212    check_untyped_objects(net, &mut report);
213    if !net.component_locations().is_empty() {
214        report.diagnostics.push(Diagnostic::of(
215            &codes::TRANSFORM_MULTI_TO_BALANCED_COMPONENT_COORDINATES_DROPPED,
216            format!("{} component geographic point(s) are omitted by this balanced projection; the original network retains them", net.component_locations().len()),
217        ));
218    }
219    report
220}
221
222/// Lower a transparent three phase multiconductor network to a balanced model.
223///
224/// The pass is explicit. It does not run from parsers, emitters, matrix builders,
225/// bindings, or PowerIO IR deserialization. Unsupported inputs return structured
226/// `TRANSFORM.MULTI_TO_BALANCED.*` diagnostics in [`MulticonductorToBalancedError`].
227pub fn to_balanced_network(
228    net: &MulticonductorNetwork,
229    options: MulticonductorToBalancedOptions,
230) -> Result<MulticonductorToBalancedTransformation, MulticonductorToBalancedError> {
231    let readiness = to_balanced_network_report(net, options);
232    if !readiness.is_ready() {
233        return Err(MulticonductorToBalancedError::new(
234            options,
235            &readiness.diagnostics,
236        ));
237    }
238
239    let mut state = LoweringState::new(net, options, readiness);
240    state.lower()
241}
242
243#[doc(hidden)]
244pub fn capped_history_notes(notes: Vec<String>, what: &str) -> Vec<String> {
245    let cap = powerio_core::limits::MAX_HISTORY_NOTES;
246    if notes.len() <= cap {
247        return notes.into_iter().map(normalized_note).collect();
248    }
249    let elided = notes.len() - (cap - 1);
250    let mut kept: Vec<String> = notes
251        .into_iter()
252        .take(cap - 1)
253        .map(normalized_note)
254        .collect();
255    kept.push(format!("{elided} more {what} elided"));
256    kept
257}
258
259fn transform_history(
260    id: HistoryId,
261    records: &TransformRecords,
262    merged_buses: &BTreeMap<String, String>,
263    removed_switches: &[String],
264) -> HistoryEntry {
265    let parameters: BTreeMap<String, serde_json::Value> =
266        records.options.clone().into_iter().collect();
267    let mut entry = HistoryEntry::new(id, HistoryKind::Transform, "to_balanced")
268        .expect("the static history name is valid")
269        .with_input_type("powerio.MulticonductorNetwork")
270        .expect("the registered input type is valid")
271        .with_output_type("powerio.BalancedNetwork")
272        .expect("the registered output type is valid")
273        .with_parameters(parameters)
274        .expect("the transformation has a bounded parameter set");
275
276    let mut assumptions = records.assumptions.clone();
277    assumptions.extend(
278        records
279            .approximations
280            .iter()
281            .map(|note| format!("approximation: {note}")),
282    );
283    assumptions.extend(
284        merged_buses
285            .iter()
286            .map(|(removed, kept)| format!("bus {removed} merged into bus {kept}")),
287    );
288    assumptions.extend(
289        removed_switches
290            .iter()
291            .map(|switch| format!("switch {switch} removed by its bus merge")),
292    );
293    for assumption in capped_history_notes(assumptions, "assumptions") {
294        entry = entry
295            .with_assumption(assumption)
296            .expect("the note list is under the history cap by construction");
297    }
298    for loss in capped_history_notes(records.dropped_fields.clone(), "losses") {
299        entry = entry
300            .with_loss(loss)
301            .expect("the loss list is under the history cap by construction");
302    }
303    entry
304}
305
306fn normalized_note(note: String) -> String {
307    let mut note = if note.contains('\0') {
308        note.replace('\0', "\u{fffd}")
309    } else {
310        note
311    };
312    let bound = powerio_core::limits::MAX_IDENTIFIER_BYTES;
313    if note.len() > bound {
314        let marker = " [truncated]";
315        let mut end = bound - marker.len();
316        while !note.is_char_boundary(end) {
317            end -= 1;
318        }
319        note.truncate(end);
320        note.push_str(marker);
321    }
322    if note.is_empty() {
323        note.push_str("(an empty note was elided)");
324    }
325    note
326}
327
328/// A history id unused by the module: the stable name, then a numbered
329/// spelling when a prior lowering already recorded one.
330struct LoweringState<'a> {
331    net: &'a MulticonductorNetwork,
332    options: MulticonductorToBalancedOptions,
333    neutral_terminals: BTreeSet<String>,
334    /// Every multiconductor bus (lowercase) to its balanced bus: merged
335    /// members map to their canonical bus's ID.
336    bus_ids: BTreeMap<String, BusId>,
337    /// Lowercase bus id to its canonical member's row index.
338    canonical_rows: BTreeMap<String, usize>,
339    /// Removed bus ID to kept bus ID, source spelling.
340    merged_buses: BTreeMap<String, String>,
341    removed_switches: Vec<String>,
342    /// Per bus (lowercase) line to line voltage base in volts.
343    bus_base: BTreeMap<String, f64>,
344    records: TransformRecords,
345}
346
347impl<'a> LoweringState<'a> {
348    fn new(
349        net: &'a MulticonductorNetwork,
350        options: MulticonductorToBalancedOptions,
351        readiness: MulticonductorToBalancedReport,
352    ) -> Self {
353        let mut records = TransformRecords::new(options);
354        records.assumptions = readiness.assumptions;
355        records.approximations = readiness.approximations;
356        records.diagnostics = readiness.diagnostics;
357        records
358            .assumptions
359            .push(format!("balanced power base: {} MVA", options.base_mva));
360        records
361            .assumptions
362            .push("balanced bus ids are synthesized from multiconductor bus order".to_owned());
363        records.approximations.push(
364            "wire-coordinate branch and shunt matrices are projected to positive sequence"
365                .to_owned(),
366        );
367        records.approximations.push(
368            "phase injection records are aggregated into scalar balanced injections".to_owned(),
369        );
370        records.approximations.push(
371            "units are converted from W/var/V/ohm/siemens/radians to MW/MVAr/per-unit/degrees"
372                .to_owned(),
373        );
374        if net.switches().iter().any(|sw| sw.open) {
375            records
376                .dropped_fields
377                .push("open switches dropped from balanced model".to_owned());
378        }
379
380        // Union closed switch endpoints: preflight already refused every
381        // blocked merge, so a closed switch here merges its buses. The
382        // canonical member is the earliest bus row; merged rows disappear
383        // from the balanced model and the mapping is recorded.
384        let row_of: BTreeMap<String, usize> = net
385            .buses()
386            .iter()
387            .enumerate()
388            .map(|(row, bus)| (bus.id.to_ascii_lowercase(), row))
389            .collect();
390        let mut union = UnionFind::new(net.buses().len());
391        let mut removed_switches = Vec::new();
392        for sw in net.switches().iter().filter(|sw| !sw.open) {
393            let (Some(&from), Some(&to)) = (
394                row_of.get(&sw.bus_from.to_ascii_lowercase()),
395                row_of.get(&sw.bus_to.to_ascii_lowercase()),
396            ) else {
397                continue;
398            };
399            union.join(from, to);
400            removed_switches.push(sw.name.clone());
401            records.assumptions.push(format!(
402                "closed switch {} merged bus {} into bus {} and was removed; no impedance \
403                 was invented for it",
404                sw.name, sw.bus_to, sw.bus_from
405            ));
406        }
407        let mut canonical_rows = BTreeMap::new();
408        let mut merged_buses = BTreeMap::new();
409        let mut number = BTreeMap::new();
410        for (row, bus) in net.buses().iter().enumerate() {
411            let root = union.root(row);
412            if root == row {
413                let id = BusId(number.len() + 1);
414                number.insert(row, id);
415            } else {
416                merged_buses.insert(bus.id.clone(), net.buses()[root].id.clone());
417            }
418            canonical_rows.insert(bus.id.to_ascii_lowercase(), root);
419        }
420        let bus_ids = net
421            .buses()
422            .iter()
423            .map(|bus| {
424                let key = bus.id.to_ascii_lowercase();
425                let root = union.root(row_of[&key]);
426                (key, number[&root])
427            })
428            .collect();
429
430        Self {
431            net,
432            options,
433            neutral_terminals: global_neutral_terminals(net),
434            bus_ids,
435            canonical_rows,
436            merged_buses,
437            removed_switches,
438            bus_base: BTreeMap::new(),
439            records,
440        }
441    }
442
443    #[allow(clippy::too_many_lines)]
444    fn lower(
445        &mut self,
446    ) -> Result<MulticonductorToBalancedTransformation, MulticonductorToBalancedError> {
447        let Some(base) = self.voltage_base()? else {
448            return Err(MulticonductorToBalancedError::new(
449                self.options,
450                &self.records.diagnostics,
451            ));
452        };
453
454        self.assign_bus_bases(base);
455        let buses = self.lower_buses(base);
456        let mut branches = self.lower_lines()?;
457        branches.extend(self.lower_transformers());
458        let loads = self.lower_loads();
459        let shunts = self.lower_shunts()?;
460        let generators = self.lower_generators(&buses);
461        self.record_capacitor_drops();
462        self.err_if_errors()?;
463
464        let mut network = BalancedNetwork::new(
465            self.net
466                .name()
467                .clone()
468                .unwrap_or_else(|| "lowered-multiconductor".to_owned()),
469            self.options.base_mva,
470        );
471        *network.base_frequency_mut() = self.net.base_frequency();
472        *network.buses_mut() = buses;
473        *network.loads_mut() = loads;
474        *network.shunts_mut() = shunts;
475        *network.branches_mut() = branches;
476        *network.generators_mut() = generators;
477        *network.source_format_mut() = SourceFormat::InMemory;
478        if let Err(err) = network.validate() {
479            self.records.diagnostics.push(Diagnostic::of(
480                &codes::TRANSFORM_MULTI_TO_BALANCED_INVALID_BALANCED_OUTPUT,
481                format!("lowered balanced network failed structural validation: {err}"),
482            ));
483            return Err(MulticonductorToBalancedError::new(
484                self.options,
485                &self.records.diagnostics,
486            ));
487        }
488        for finding in network.validate_values() {
489            let details = finding.details();
490            self.records.diagnostics.push(
491                Diagnostic::of(
492                    &codes::TRANSFORM_MULTI_TO_BALANCED_BALANCED_VALUE_DOMAIN,
493                    format!(
494                        "{} field `{}` is outside its value domain after lowering",
495                        details["element"].as_str().unwrap_or_default(),
496                        details["field"].as_str().unwrap_or_default()
497                    ),
498                )
499                .with_suggested_action(
500                    "Inspect the multiconductor source values before using the lowered model.",
501                ),
502            );
503        }
504
505        let history = transform_history(
506            HistoryId::new("multiconductor-to-balanced").expect("static history id is valid"),
507            &self.records,
508            &self.merged_buses,
509            &self.removed_switches,
510        );
511        Ok(MulticonductorToBalancedTransformation {
512            network,
513            diagnostics: self.records.diagnostics.clone(),
514            history,
515            merged_buses: self.merged_buses.clone(),
516            removed_switches: self.removed_switches.clone(),
517        })
518    }
519
520    /// Voltage bases by zone: buses joined by lines or merged switches share
521    /// one base; a zone with a source takes the source's positive sequence
522    /// magnitude; a supported transformer bases the zone across it with the
523    /// far winding's rated voltage; anything still unbased defaults to the
524    /// reference base with a note.
525    fn assign_bus_bases(&mut self, reference: VoltageBase) {
526        let row_of: BTreeMap<String, usize> = self
527            .net
528            .buses()
529            .iter()
530            .enumerate()
531            .map(|(row, bus)| (bus.id.to_ascii_lowercase(), row))
532            .collect();
533        let mut zones = UnionFind::new(self.net.buses().len());
534        for line in self.net.lines() {
535            if let (Some(&from), Some(&to)) = (
536                row_of.get(&line.bus_from.to_ascii_lowercase()),
537                row_of.get(&line.bus_to.to_ascii_lowercase()),
538            ) {
539                zones.join(from, to);
540            }
541        }
542        for sw in self.net.switches().iter().filter(|sw| !sw.open) {
543            if let (Some(&from), Some(&to)) = (
544                row_of.get(&sw.bus_from.to_ascii_lowercase()),
545                row_of.get(&sw.bus_to.to_ascii_lowercase()),
546            ) {
547                zones.join(from, to);
548            }
549        }
550
551        let mut zone_base: BTreeMap<usize, f64> = BTreeMap::new();
552        for source in self.net.sources() {
553            let Some(&row) = row_of.get(&source.bus.to_ascii_lowercase()) else {
554                continue;
555            };
556            let bus = self.net.bus(&source.bus);
557            let positions = active_positions(&source.terminal_map, bus, &self.neutral_terminals);
558            if positions.len() != 3 {
559                continue;
560            }
561            let Some(v1) = positive_sequence_voltage(source, &positions) else {
562                continue;
563            };
564            if v1.norm().is_finite() && v1.norm() > 0.0 {
565                zone_base.entry(zones.root(row)).or_insert(v1.norm());
566            }
567        }
568
569        let supported: Vec<(usize, usize, f64, f64)> = self
570            .net
571            .transformers()
572            .iter()
573            .filter_map(|transformer| {
574                let [high, low] =
575                    classify_transformer(self.net, transformer, &self.neutral_terminals).ok()?;
576                let high_row = *row_of.get(&high.bus.to_ascii_lowercase())?;
577                let low_row = *row_of.get(&low.bus.to_ascii_lowercase())?;
578                Some((high_row, low_row, high.v_ref, low.v_ref))
579            })
580            .collect();
581        loop {
582            let mut changed = false;
583            for &(high_row, low_row, high_v, low_v) in &supported {
584                let (high_zone, low_zone) = (zones.root(high_row), zones.root(low_row));
585                match (
586                    zone_base.contains_key(&high_zone),
587                    zone_base.contains_key(&low_zone),
588                ) {
589                    (true, false) => {
590                        zone_base.insert(low_zone, low_v);
591                        changed = true;
592                    }
593                    (false, true) => {
594                        zone_base.insert(high_zone, high_v);
595                        changed = true;
596                    }
597                    _ => {}
598                }
599            }
600            if !changed {
601                break;
602            }
603        }
604
605        for (row, bus) in self.net.buses().iter().enumerate() {
606            let zone = zones.root(row);
607            let base = zone_base.get(&zone).copied().unwrap_or_else(|| {
608                self.records.dropped_fields.push(format!(
609                    "bus {} voltage base defaulted to the reference base",
610                    bus.id
611                ));
612                reference.line_to_line_volts
613            });
614            self.bus_base.insert(bus.id.to_ascii_lowercase(), base);
615        }
616    }
617
618    /// The line to line voltage base of one bus, in volts.
619    fn base_volts(&self, bus: &str) -> f64 {
620        self.bus_base
621            .get(&bus.to_ascii_lowercase())
622            .copied()
623            .expect("every declared bus was based")
624    }
625
626    fn voltage_base(&mut self) -> Result<Option<VoltageBase>, MulticonductorToBalancedError> {
627        for (idx, source) in self.net.sources().iter().enumerate() {
628            let Some(bus) = self.net.bus(&source.bus) else {
629                self.records.diagnostics.push(
630                    Diagnostic::of(
631                        &codes::TRANSFORM_MULTI_TO_BALANCED_UNKNOWN_SOURCE_BUS,
632                        format!(
633                            "voltage source {} references unknown bus {}",
634                            source.name, source.bus
635                        ),
636                    )
637                    .with_value_target(format!("/sources/{idx}/bus")),
638                );
639                continue;
640            };
641            let positions =
642                active_positions(&source.terminal_map, Some(bus), &self.neutral_terminals);
643            if positions.len() != 3 {
644                continue;
645            }
646            let Some(v1) = positive_sequence_voltage(source, &positions) else {
647                self.records.diagnostics.push(
648                    Diagnostic::of(
649    &codes::TRANSFORM_MULTI_TO_BALANCED_INVALID_PHASE_REFERENCE,
650format!(
651                            "voltage source {} does not carry finite three phase voltage magnitudes and angles",
652                            source.name
653                        ),
654                    )
655                    .with_value_target(format!("/sources/{idx}")),
656                );
657                continue;
658            };
659            let line_to_line_volts = v1.norm();
660            if !line_to_line_volts.is_finite() || line_to_line_volts <= 0.0 {
661                self.records.diagnostics.push(
662                    Diagnostic::of(
663    &codes::TRANSFORM_MULTI_TO_BALANCED_INVALID_PHASE_REFERENCE,
664format!(
665                            "voltage source {} produced a non-positive positive-sequence voltage base",
666                            source.name
667                        ),
668                    )
669                    .with_value_target(format!("/sources/{idx}")),
670                );
671                continue;
672            }
673            self.records.assumptions.push(format!(
674                "voltage base synthesized from source {} positive-sequence voltage: {} kV line-to-line",
675                source.name,
676                line_to_line_volts / 1000.0
677            ));
678            return Ok(Some(VoltageBase { line_to_line_volts }));
679        }
680
681        if self
682            .records
683            .diagnostics
684            .iter()
685            .any(|d| d.severity() >= DiagnosticSeverity::Error)
686        {
687            return Err(MulticonductorToBalancedError::new(
688                self.options,
689                &self.records.diagnostics,
690            ));
691        }
692        self.records.diagnostics.push(Diagnostic::of(
693    &codes::TRANSFORM_MULTI_TO_BALANCED_MISSING_PHASE_REFERENCE,
694"multiconductor to balanced lowering requires a finite three phase voltage source reference",
695        ));
696        Ok(None)
697    }
698
699    fn lower_buses(&mut self, _reference: VoltageBase) -> Vec<Bus> {
700        // Canonical buses only: a merged member's data folds into its
701        // canonical bus, and its identity is recorded in `merged_buses`.
702        let mut members: BTreeMap<usize, Vec<&DistBus>> = BTreeMap::new();
703        for bus in self.net.buses() {
704            let root = self.canonical_rows[&bus.id.to_ascii_lowercase()];
705            members.entry(root).or_default().push(bus);
706        }
707        let mut balanced_buses = Vec::with_capacity(members.len());
708        for (&root, group) in &members {
709            let canonical = &self.net.buses()[root];
710            let base_volts = self.base_volts(&canonical.id);
711            let sourced = group.iter().find_map(|bus| {
712                self.net
713                    .sources()
714                    .iter()
715                    .find(|source| source.bus.eq_ignore_ascii_case(&bus.id))
716                    .map(|source| (*bus, source))
717            });
718            let (vm, va) = sourced
719                .and_then(|(bus, source)| {
720                    let positions =
721                        active_positions(&source.terminal_map, Some(bus), &self.neutral_terminals);
722                    positive_sequence_voltage(source, &positions)
723                })
724                .map_or((1.0, 0.0), |v| {
725                    (v.norm() / base_volts, radians_to_degrees(v.arg()))
726                });
727            if sourced.is_none() {
728                self.records.dropped_fields.push(format!(
729                    "bus {} voltage magnitude and angle defaulted to 1.0 p.u. and 0 degrees",
730                    canonical.id
731                ));
732            }
733            // Preflight refused conflicting stated bounds across a merge, so
734            // the first member stating both carries the group's bounds.
735            let stated = group.iter().find_map(|bus| match (bus.v_min, bus.v_max) {
736                (Some(vmin), Some(vmax)) if vmin.is_finite() && vmax.is_finite() => {
737                    Some((vmin / base_volts, vmax / base_volts))
738                }
739                _ => None,
740            });
741            let (vmin, vmax) = stated.unwrap_or_else(|| {
742                self.records.dropped_fields.push(format!(
743                    "bus {} voltage bounds defaulted to 0.9/1.1 p.u.",
744                    canonical.id
745                ));
746                (0.9, 1.1)
747            });
748            for bus in group {
749                self.record_bus_bound_drops(bus);
750            }
751            let kind = group
752                .iter()
753                .map(|bus| self.bus_kind(&bus.id))
754                .min_by_key(|kind| match kind {
755                    BusType::Ref => 0,
756                    BusType::Pv => 1,
757                    _ => 2,
758                })
759                .unwrap_or(BusType::Pq);
760            let mut balanced = Bus::new(
761                self.bus_ids[&canonical.id.to_ascii_lowercase()],
762                kind,
763                base_volts / 1000.0,
764            );
765            balanced.vm = vm;
766            balanced.va = va;
767            balanced.vmax = vmax;
768            balanced.vmin = vmin;
769            balanced.name = Some(canonical.id.clone());
770            balanced.extras = source_extra("multiconductor_bus_id", &canonical.id);
771            balanced_buses.push(balanced);
772        }
773        balanced_buses
774    }
775
776    /// A rated capacitor bank (BMOPF schema 0.1.0 `capacitor`) has no
777    /// balanced equivalent yet: `q_rated` at `v_nom` is a nameplate rating,
778    /// not the admittance a balanced `Shunt` carries. The bank therefore
779    /// drops, and the record names it, because a silent drop removes
780    /// reactive support the case depends on.
781    fn record_capacitor_drops(&mut self) {
782        for capacitor in self.net.capacitors() {
783            self.records.dropped_fields.push(format!(
784                "capacitor {} dropped: a rated bank has no balanced shunt equivalent",
785                capacitor.name
786            ));
787        }
788    }
789
790    fn record_bus_bound_drops(&mut self, bus: &DistBus) {
791        if bus.vpn_min.is_some()
792            || bus.vpn_max.is_some()
793            || bus.vpp_min.is_some()
794            || bus.vpp_max.is_some()
795            || bus.vpos_min.is_some()
796            || bus.vpos_max.is_some()
797            || bus.vneg_max.is_some()
798            || bus.vzero_max.is_some()
799            || bus.vn_max.is_some()
800        {
801            self.records.dropped_fields.push(format!(
802                "bus {} conductor voltage bound families dropped",
803                bus.id
804            ));
805        }
806    }
807
808    fn bus_kind(&self, bus_id: &str) -> BusType {
809        if self
810            .net
811            .sources()
812            .iter()
813            .any(|source| source.bus.eq_ignore_ascii_case(bus_id))
814        {
815            BusType::Ref
816        } else if self
817            .net
818            .generators()
819            .iter()
820            .any(|generator| generator.bus.eq_ignore_ascii_case(bus_id))
821        {
822            BusType::Pv
823        } else {
824            BusType::Pq
825        }
826    }
827
828    #[allow(clippy::too_many_lines)]
829    fn lower_lines(&mut self) -> Result<Vec<Branch>, MulticonductorToBalancedError> {
830        let mut branches = Vec::with_capacity(self.net.lines().len());
831        for (idx, line) in self.net.lines().iter().enumerate() {
832            let Some(code) = self.net.linecode(&line.linecode) else {
833                self.records.diagnostics.push(
834                    Diagnostic::of(
835                        &codes::TRANSFORM_MULTI_TO_BALANCED_UNKNOWN_LINECODE,
836                        format!(
837                            "line {} references unknown linecode `{}`",
838                            line.name, line.linecode
839                        ),
840                    )
841                    .with_value_target(format!("/lines/{idx}/linecode")),
842                );
843                continue;
844            };
845            if !same_active_phase_order(
846                self.net.bus(&line.bus_from),
847                &line.terminal_map_from,
848                self.net.bus(&line.bus_to),
849                &line.terminal_map_to,
850                &self.neutral_terminals,
851            ) {
852                self.records.diagnostics.push(
853                    Diagnostic::of(
854    &codes::TRANSFORM_MULTI_TO_BALANCED_PHASE_MAP_MISMATCH,
855format!(
856                            "line {} connects different active terminal orders and cannot be lowered transparently",
857                            line.name
858                        ),
859                    )
860                    .with_value_target(format!("/lines/{idx}")),
861                );
862                continue;
863            }
864            let Some(from) = self.bus_id(&line.bus_from) else {
865                self.unknown_bus_diag("line", &line.name, &line.bus_from, idx, "bus_from");
866                continue;
867            };
868            let Some(to) = self.bus_id(&line.bus_to) else {
869                self.unknown_bus_diag("line", &line.name, &line.bus_to, idx, "bus_to");
870                continue;
871            };
872            let from_bus = self.net.bus(&line.bus_from);
873            let active =
874                active_positions(&line.terminal_map_from, from_bus, &self.neutral_terminals);
875            let neutral =
876                neutral_positions(&line.terminal_map_from, from_bus, &self.neutral_terminals);
877            let z_ohm =
878                self.line_positive_sequence_impedance(idx, code, &active, &neutral, line.length)?;
879            let y_from = self.line_positive_sequence_admittance(
880                idx,
881                code,
882                &active,
883                &neutral,
884                line.length,
885                ShuntSide::From,
886            )?;
887            let y_to = self.line_positive_sequence_admittance(
888                idx,
889                code,
890                &active,
891                &neutral,
892                line.length,
893                ShuntSide::To,
894            )?;
895            let base_volts = self.base_volts(&line.bus_from);
896            let z_base = z_base_ohm_of(base_volts, self.options.base_mva);
897            let y_scale = z_base;
898            let charging = BranchCharging::new(
899                y_from.re * y_scale,
900                y_from.im * y_scale,
901                y_to.re * y_scale,
902                y_to.im * y_scale,
903            );
904            let rate = line_rate_mva(line, code, &active, base_volts).unwrap_or_else(|| {
905                self.records.dropped_fields.push(format!(
906                    "line {} thermal rating defaulted to 0 MVA",
907                    line.name
908                ));
909                0.0
910            });
911            let mut branch = Branch::new(from, to, z_ohm.re / z_base, z_ohm.im / z_base);
912            branch.b = charging.calc_total_b();
913            branch.charging = Some(charging);
914            branch.rate_a = rate;
915            branch.rate_b = rate;
916            branch.rate_c = rate;
917            branch.extras = source_extra("multiconductor_line", &line.name);
918            branches.push(branch);
919        }
920        self.err_if_errors()?;
921        Ok(branches)
922    }
923
924    fn line_positive_sequence_impedance(
925        &mut self,
926        line_idx: usize,
927        code: &DistLineCode,
928        active: &[usize],
929        neutral: &[usize],
930        length: f64,
931    ) -> Result<Complex64, MulticonductorToBalancedError> {
932        self.check_finite_length(line_idx, length)?;
933        let matrix = complex_matrix(&code.r_series, &code.x_series, length);
934        let reduced = kron_or_select(&matrix, active, neutral).map_err(|message| {
935            self.matrix_error(line_idx, &code.name, "series impedance", &message)
936        })?;
937        Ok(self.positive_sequence_from_matrix(line_idx, &code.name, "series impedance", &reduced))
938    }
939
940    fn line_positive_sequence_admittance(
941        &mut self,
942        line_idx: usize,
943        code: &DistLineCode,
944        active: &[usize],
945        neutral: &[usize],
946        length: f64,
947        side: ShuntSide,
948    ) -> Result<Complex64, MulticonductorToBalancedError> {
949        let (g, b, label) = match side {
950            ShuntSide::From => (&code.g_from, &code.b_from, "from shunt admittance"),
951            ShuntSide::To => (&code.g_to, &code.b_to, "to shunt admittance"),
952        };
953        let matrix = complex_matrix(g, b, length);
954        let reduced = kron_or_select(&matrix, active, neutral)
955            .map_err(|message| self.matrix_error(line_idx, &code.name, label, &message))?;
956        Ok(self.positive_sequence_from_matrix(line_idx, &code.name, label, &reduced))
957    }
958
959    fn positive_sequence_from_matrix(
960        &mut self,
961        line_idx: usize,
962        code_name: &str,
963        label: &str,
964        matrix: &[Vec<Complex64>],
965    ) -> Complex64 {
966        let seq = sequence_matrix(matrix);
967        let coupling = sequence_coupling_norm(&seq);
968        if coupling > COUPLING_TOLERANCE {
969            self.records.approximations.push(format!(
970                "linecode {code_name} {label} has sequence coupling norm {coupling}; positive-sequence diagonal retained"
971            ));
972            let mut diagnostic = Diagnostic::of(
973    &codes::TRANSFORM_MULTI_TO_BALANCED_SEQUENCE_COUPLING_DROPPED,
974format!(
975                    "linecode {code_name} {label} has nonzero sequence coupling; the balanced model keeps the positive-sequence diagonal"
976                ),
977            )
978            .with_value_target(format!("/lines/{line_idx}/linecode"));
979            diagnostic
980                .insert_detail("sequence_coupling_norm", serde_json::json!(coupling))
981                .expect("the static detail key is valid");
982            self.records.diagnostics.push(diagnostic);
983        }
984        seq[1][1]
985    }
986
987    /// Refuse a line whose length is not a finite number. A BMOPF line without
988    /// a length reads back as `NaN` (the `null` spelling), and every impedance
989    /// and admittance below scales by it, so an unchecked value would reach the
990    /// solver as a `NaN` branch with nothing said about it.
991    fn check_finite_length(
992        &self,
993        line_idx: usize,
994        length: f64,
995    ) -> Result<(), MulticonductorToBalancedError> {
996        if length.is_finite() {
997            return Ok(());
998        }
999        let mut diagnostics = self.records.diagnostics.clone();
1000        diagnostics.push(
1001            Diagnostic::of(
1002    &codes::TRANSFORM_MULTI_TO_BALANCED_NONFINITE_LINE_LENGTH,
1003format!("line {line_idx} has no finite length ({length}), so its impedance cannot be scaled"),
1004            )
1005            .with_value_target(format!("/lines/{line_idx}/length"))
1006            .with_suggested_action("give the line a length in meters, or drop it from the network"),
1007        );
1008        Err(MulticonductorToBalancedError::new(
1009            self.options,
1010            &diagnostics,
1011        ))
1012    }
1013
1014    fn matrix_error(
1015        &self,
1016        line_idx: usize,
1017        code_name: &str,
1018        label: &str,
1019        message: &str,
1020    ) -> MulticonductorToBalancedError {
1021        let mut diagnostics = self.records.diagnostics.clone();
1022        diagnostics.push(
1023            Diagnostic::of(
1024                &codes::TRANSFORM_MULTI_TO_BALANCED_INVALID_LINECODE_MATRIX,
1025                format!("linecode {code_name} {label} cannot be lowered: {message}"),
1026            )
1027            .with_value_target(format!("/lines/{line_idx}/linecode")),
1028        );
1029        MulticonductorToBalancedError::new(self.options, &diagnostics)
1030    }
1031
1032    /// Supported transformers lower into balanced branches: series impedance
1033    /// from the winding resistances and the first short circuit reactance on
1034    /// the transformer's own base converted to the system base, tap from the
1035    /// rated winding voltages against the zone voltage bases, and the
1036    /// representable ANSI thirty degree connection shift with the high
1037    /// voltage side leading.
1038    fn lower_transformers(&mut self) -> Vec<Branch> {
1039        let mut branches = Vec::new();
1040        for transformer in self.net.transformers() {
1041            let Ok([high, low]) =
1042                classify_transformer(self.net, transformer, &self.neutral_terminals)
1043            else {
1044                // Preflight refused the pass for unsupported transformers.
1045                continue;
1046            };
1047            let (Some(from), Some(to)) = (self.bus_id(&high.bus), self.bus_id(&low.bus)) else {
1048                continue;
1049            };
1050            let base_from = self.base_volts(&high.bus);
1051            let base_to = self.base_volts(&low.bus);
1052            let tap = (high.v_ref * high.tap / base_from) / (low.v_ref * low.tap / base_to);
1053            let z_scale = (self.options.base_mva * 1_000_000.0 / high.s_rating)
1054                * (high.v_ref / base_from).powi(2);
1055            // Each winding states %R on its own power rating; the low winding
1056            // figure converts onto the high winding base before the sum.
1057            let low_rating_scale = high.s_rating / low.s_rating;
1058            let r = ((high.r_pct + low.r_pct * low_rating_scale) / 100.0) * z_scale;
1059            let x = (transformer.xsc_pct[0] / 100.0) * z_scale;
1060            let shift = if high.v_ref >= low.v_ref { 30.0 } else { -30.0 };
1061            let rate = high.s_rating / 1_000_000.0;
1062            self.records.assumptions.push(format!(
1063                "transformer {} lowered as a balanced branch with tap {tap:.6} and the ANSI \
1064                 {shift} degree connection shift (high voltage side leads)",
1065                transformer.name
1066            ));
1067            if (low_rating_scale - 1.0).abs() > 1e-9 {
1068                self.records.assumptions.push(format!(
1069                    "transformer {}: the low winding resistance was converted from its own \
1070                     {:.3} kVA base onto the high winding {:.3} kVA base",
1071                    transformer.name,
1072                    low.s_rating / 1_000.0,
1073                    high.s_rating / 1_000.0
1074                ));
1075            }
1076            if high.r_neutral.is_some()
1077                || high.x_neutral.is_some()
1078                || low.r_neutral.is_some()
1079                || low.x_neutral.is_some()
1080            {
1081                self.records.dropped_fields.push(format!(
1082                    "transformer {} neutral grounding impedance dropped",
1083                    transformer.name
1084                ));
1085            }
1086            if transformer.xsc_pct.len() > 1 {
1087                self.records.dropped_fields.push(format!(
1088                    "transformer {} extra short circuit reactances dropped",
1089                    transformer.name
1090                ));
1091            }
1092            let mut branch = Branch::new(from, to, r, x);
1093            branch.tap = tap;
1094            branch.shift = shift;
1095            branch.rate_a = rate;
1096            branch.rate_b = rate;
1097            branch.rate_c = rate;
1098            branch.extras = source_extra("multiconductor_transformer", &transformer.name);
1099            branches.push(branch);
1100        }
1101        branches
1102    }
1103
1104    fn lower_loads(&mut self) -> Vec<Load> {
1105        self.net
1106            .loads()
1107            .iter()
1108            .enumerate()
1109            .filter_map(|(idx, load)| {
1110                let Some(bus) = self.bus_id(&load.bus) else {
1111                    self.unknown_bus_diag("load", &load.name, &load.bus, idx, "bus");
1112                    return None;
1113                };
1114                if !matches!(
1115                    load.voltage_model,
1116                    DistLoadVoltageModel::ConstantPower { .. }
1117                ) {
1118                    self.records.dropped_fields.push(format!(
1119                        "load {} voltage model dropped; balanced load is constant power",
1120                        load.name
1121                    ));
1122                    self.records.diagnostics.push(
1123                        Diagnostic::of(
1124    &codes::TRANSFORM_MULTI_TO_BALANCED_DROPPED_LOAD_VOLTAGE_MODEL,
1125format!(
1126                                "load {} voltage model cannot be represented by the conservative balanced lowering",
1127                                load.name
1128                            ),
1129                        )
1130                        .with_value_target(format!("/loads/{idx}/voltage_model")),
1131                    );
1132                }
1133                let mut balanced = Load::new(
1134                    bus,
1135                    si_power_to_mega(load.p_nom.iter().sum()),
1136                    si_power_to_mega(load.q_nom.iter().sum()),
1137                );
1138                balanced.extras = source_extra("multiconductor_load", &load.name);
1139                Some(balanced)
1140            })
1141            .collect()
1142    }
1143
1144    fn lower_shunts(&mut self) -> Result<Vec<Shunt>, MulticonductorToBalancedError> {
1145        let mut shunts = Vec::with_capacity(self.net.shunts().len());
1146        for (idx, shunt) in self.net.shunts().iter().enumerate() {
1147            let Some(bus) = self.bus_id(&shunt.bus) else {
1148                self.unknown_bus_diag("shunt", &shunt.name, &shunt.bus, idx, "bus");
1149                continue;
1150            };
1151            let dist_bus = self.net.bus(&shunt.bus);
1152            let active = active_positions(&shunt.terminal_map, dist_bus, &self.neutral_terminals);
1153            let neutral = neutral_positions(&shunt.terminal_map, dist_bus, &self.neutral_terminals);
1154            let y = if active.len() == 3 {
1155                let matrix = complex_matrix(&shunt.g, &shunt.b, 1.0);
1156                let reduced = kron_or_select(&matrix, &active, &neutral)
1157                    .map_err(|message| self.shunt_matrix_error(idx, &shunt.name, &message))?;
1158                let seq = sequence_matrix(&reduced);
1159                seq[1][1]
1160            } else {
1161                self.records.approximations.push(format!(
1162                    "shunt {} has {} active terminal(s); diagonal admittance projected with missing phases as zero",
1163                    shunt.name,
1164                    active.len()
1165                ));
1166                partial_phase_admittance(&shunt.g, &shunt.b, &active)
1167            };
1168            let base_volts = self.base_volts(&shunt.bus);
1169            let scale = base_volts * base_volts / 1_000_000.0;
1170            let mut balanced = Shunt::new(bus, y.re * scale, y.im * scale);
1171            balanced.extras = source_extra("multiconductor_shunt", &shunt.name);
1172            shunts.push(balanced);
1173        }
1174        self.err_if_errors()?;
1175        Ok(shunts)
1176    }
1177
1178    fn shunt_matrix_error(
1179        &self,
1180        shunt_idx: usize,
1181        name: &str,
1182        message: &str,
1183    ) -> MulticonductorToBalancedError {
1184        let mut diagnostics = self.records.diagnostics.clone();
1185        diagnostics.push(
1186            Diagnostic::of(
1187                &codes::TRANSFORM_MULTI_TO_BALANCED_INVALID_SHUNT_MATRIX,
1188                format!("shunt {name} cannot be lowered: {message}"),
1189            )
1190            .with_value_target(format!("/shunts/{shunt_idx}")),
1191        );
1192        MulticonductorToBalancedError::new(self.options, &diagnostics)
1193    }
1194
1195    fn lower_generators(&mut self, buses: &[Bus]) -> Vec<Generator> {
1196        self.net
1197            .generators()
1198            .iter()
1199            .enumerate()
1200            .filter_map(|(idx, generator)| {
1201                let Some(bus) = self.bus_id(&generator.bus) else {
1202                    self.unknown_bus_diag("generator", &generator.name, &generator.bus, idx, "bus");
1203                    return None;
1204                };
1205                let pg = si_power_to_mega(generator.p_nom.iter().sum());
1206                let qg = si_power_to_mega(generator.q_nom.iter().sum());
1207                let pmin = option_vec_sum_mw(generator.p_min.as_deref()).unwrap_or_else(|| {
1208                    self.records.dropped_fields.push(format!(
1209                        "generator {} p_min defaulted to pg",
1210                        generator.name
1211                    ));
1212                    pg
1213                });
1214                let pmax = option_vec_sum_mw(generator.p_max.as_deref()).unwrap_or_else(|| {
1215                    self.records.dropped_fields.push(format!(
1216                        "generator {} p_max defaulted to pg",
1217                        generator.name
1218                    ));
1219                    pg
1220                });
1221                let qmin = option_vec_sum_mw(generator.q_min.as_deref()).unwrap_or_else(|| {
1222                    self.records.dropped_fields.push(format!(
1223                        "generator {} q_min defaulted to qg",
1224                        generator.name
1225                    ));
1226                    qg
1227                });
1228                let qmax = option_vec_sum_mw(generator.q_max.as_deref()).unwrap_or_else(|| {
1229                    self.records.dropped_fields.push(format!(
1230                        "generator {} q_max defaulted to qg",
1231                        generator.name
1232                    ));
1233                    qg
1234                });
1235                if generator.cost.is_some() {
1236                    self.records.dropped_fields.push(format!(
1237                        "generator {} scalar distribution cost dropped",
1238                        generator.name
1239                    ));
1240                }
1241                if generator.s_max.is_some() || generator.i_max.is_some() {
1242                    self.records.dropped_fields.push(format!(
1243                        "generator {} per-conductor rating fields dropped",
1244                        generator.name
1245                    ));
1246                }
1247                let vg = buses
1248                    .iter()
1249                    .find(|balanced_bus| balanced_bus.id == bus)
1250                    .map_or(1.0, |balanced_bus| balanced_bus.vm);
1251                let mut balanced = Generator::new(bus);
1252                balanced.pg = pg;
1253                balanced.qg = qg;
1254                balanced.pmax = pmax;
1255                balanced.pmin = pmin;
1256                balanced.qmax = qmax;
1257                balanced.qmin = qmin;
1258                balanced.vg = vg;
1259                balanced.mbase = self.options.base_mva;
1260                Some(balanced)
1261            })
1262            .collect()
1263    }
1264
1265    fn bus_id(&self, bus: &str) -> Option<BusId> {
1266        self.bus_ids.get(&bus.to_ascii_lowercase()).copied()
1267    }
1268
1269    fn unknown_bus_diag(&mut self, element: &str, name: &str, bus: &str, idx: usize, field: &str) {
1270        self.records.diagnostics.push(
1271            Diagnostic::of(
1272                &codes::TRANSFORM_MULTI_TO_BALANCED_UNKNOWN_BUS,
1273                format!("{element} {name} references unknown bus {bus}"),
1274            )
1275            .with_value_target(format!("/{element}s/{idx}/{field}")),
1276        );
1277    }
1278
1279    fn err_if_errors(&self) -> Result<(), MulticonductorToBalancedError> {
1280        if self
1281            .records
1282            .diagnostics
1283            .iter()
1284            .any(|d| d.severity() >= DiagnosticSeverity::Error)
1285        {
1286            Err(MulticonductorToBalancedError::new(
1287                self.options,
1288                &self.records.diagnostics,
1289            ))
1290        } else {
1291            Ok(())
1292        }
1293    }
1294}
1295
1296#[derive(Clone, Copy)]
1297struct VoltageBase {
1298    line_to_line_volts: f64,
1299}
1300
1301fn z_base_ohm_of(line_to_line_volts: f64, base_mva: f64) -> f64 {
1302    line_to_line_volts * line_to_line_volts / (base_mva * 1_000_000.0)
1303}
1304
1305pub(crate) struct UnionFind {
1306    parent: Vec<usize>,
1307}
1308
1309impl UnionFind {
1310    pub(crate) fn new(len: usize) -> Self {
1311        Self {
1312            parent: (0..len).collect(),
1313        }
1314    }
1315
1316    pub(crate) fn root(&mut self, mut node: usize) -> usize {
1317        while self.parent[node] != node {
1318            self.parent[node] = self.parent[self.parent[node]];
1319            node = self.parent[node];
1320        }
1321        node
1322    }
1323
1324    pub(crate) fn join(&mut self, a: usize, b: usize) {
1325        let (a, b) = (self.root(a), self.root(b));
1326        // The smaller row stays the root, so the canonical member is stable.
1327        let (keep, fold) = if a <= b { (a, b) } else { (b, a) };
1328        self.parent[fold] = keep;
1329    }
1330}
1331
1332#[derive(Clone, Copy)]
1333enum ShuntSide {
1334    From,
1335    To,
1336}
1337
1338fn options_map(
1339    options: MulticonductorToBalancedOptions,
1340) -> serde_json::Map<String, serde_json::Value> {
1341    serde_json::to_value(options)
1342        .ok()
1343        .and_then(|value| value.as_object().cloned())
1344        .unwrap_or_default()
1345}
1346
1347fn source_extra(key: &str, value: &str) -> BalancedExtras {
1348    let mut extras = BalancedExtras::new();
1349    extras.insert(key.to_owned(), serde_json::Value::String(value.to_owned()));
1350    extras
1351}
1352
1353fn active_positions(
1354    terminals: &[String],
1355    bus: Option<&DistBus>,
1356    neutral_terminals: &BTreeSet<String>,
1357) -> Vec<usize> {
1358    terminals
1359        .iter()
1360        .enumerate()
1361        .filter_map(|(idx, terminal)| {
1362            (!is_neutral_terminal(terminal, bus, neutral_terminals)).then_some(idx)
1363        })
1364        .collect()
1365}
1366
1367fn neutral_positions(
1368    terminals: &[String],
1369    bus: Option<&DistBus>,
1370    neutral_terminals: &BTreeSet<String>,
1371) -> Vec<usize> {
1372    terminals
1373        .iter()
1374        .enumerate()
1375        .filter_map(|(idx, terminal)| {
1376            is_neutral_terminal(terminal, bus, neutral_terminals).then_some(idx)
1377        })
1378        .collect()
1379}
1380
1381fn same_active_phase_order(
1382    from_bus: Option<&DistBus>,
1383    from_terminals: &[String],
1384    to_bus: Option<&DistBus>,
1385    to_terminals: &[String],
1386    neutral_terminals: &BTreeSet<String>,
1387) -> bool {
1388    let from: Vec<_> = from_terminals
1389        .iter()
1390        .filter(|terminal| !is_neutral_terminal(terminal, from_bus, neutral_terminals))
1391        .map(|terminal| terminal.to_ascii_lowercase())
1392        .collect();
1393    let to: Vec<_> = to_terminals
1394        .iter()
1395        .filter(|terminal| !is_neutral_terminal(terminal, to_bus, neutral_terminals))
1396        .map(|terminal| terminal.to_ascii_lowercase())
1397        .collect();
1398    from == to
1399}
1400
1401fn positive_sequence_voltage(
1402    source: &powerio_dist::VoltageSource,
1403    positions: &[usize],
1404) -> Option<Complex64> {
1405    if positions.len() != 3 {
1406        return None;
1407    }
1408    let mut phase = [Complex64::new(0.0, 0.0); 3];
1409    for (out, &idx) in phase.iter_mut().zip(positions.iter()) {
1410        let magnitude = *source.v_magnitude.get(idx)?;
1411        let angle = *source.v_angle.get(idx)?;
1412        if !magnitude.is_finite() || !angle.is_finite() {
1413            return None;
1414        }
1415        *out = Complex64::from_polar(magnitude, angle);
1416    }
1417    let basis = sequence_basis();
1418    let mut seq = [Complex64::new(0.0, 0.0); 3];
1419    for (sequence_idx, out) in seq.iter_mut().enumerate() {
1420        for phase_idx in 0..3 {
1421            *out += basis[phase_idx][sequence_idx].conj() * phase[phase_idx];
1422        }
1423    }
1424    Some(seq[1])
1425}
1426
1427fn complex_matrix(
1428    g_or_r: &ConductorMatrix,
1429    b_or_x: &ConductorMatrix,
1430    scale: f64,
1431) -> Vec<Vec<Complex64>> {
1432    g_or_r
1433        .iter()
1434        .zip(b_or_x.iter())
1435        .map(|(g_row, b_row)| {
1436            g_row
1437                .iter()
1438                .zip(b_row.iter())
1439                .map(|(&g, &b)| Complex64::new(g * scale, b * scale))
1440                .collect()
1441        })
1442        .collect()
1443}
1444
1445fn kron_or_select(
1446    matrix: &[Vec<Complex64>],
1447    active: &[usize],
1448    neutral: &[usize],
1449) -> Result<Vec<Vec<Complex64>>, String> {
1450    if active.len() != 3 {
1451        return Err(format!(
1452            "expected three active conductors, got {}",
1453            active.len()
1454        ));
1455    }
1456    validate_indices(matrix, active)?;
1457    validate_indices(matrix, neutral)?;
1458    if neutral.is_empty() {
1459        return Ok(submatrix(matrix, active, active));
1460    }
1461
1462    let m_pp = submatrix(matrix, active, active);
1463    let m_pn = submatrix(matrix, active, neutral);
1464    let m_np = submatrix(matrix, neutral, active);
1465    let m_nn = submatrix(matrix, neutral, neutral);
1466    if matrix_is_near_zero(&m_pn) && matrix_is_near_zero(&m_np) && matrix_is_near_zero(&m_nn) {
1467        return Ok(m_pp);
1468    }
1469    let inv_nn = invert_complex_matrix(&m_nn)?;
1470    let correction = matmul(&matmul(&m_pn, &inv_nn), &m_np);
1471    Ok(matrix_sub(&m_pp, &correction))
1472}
1473
1474fn matrix_is_near_zero(matrix: &[Vec<Complex64>]) -> bool {
1475    matrix
1476        .iter()
1477        .flatten()
1478        .all(|value| value.norm() <= f64::EPSILON)
1479}
1480
1481fn validate_indices(matrix: &[Vec<Complex64>], indices: &[usize]) -> Result<(), String> {
1482    let n = matrix.len();
1483    if matrix.iter().any(|row| row.len() != n) {
1484        return Err("matrix is not square".to_owned());
1485    }
1486    if indices.iter().any(|&idx| idx >= n) {
1487        return Err("terminal map references a conductor outside the matrix".to_owned());
1488    }
1489    Ok(())
1490}
1491
1492fn submatrix(matrix: &[Vec<Complex64>], rows: &[usize], cols: &[usize]) -> Vec<Vec<Complex64>> {
1493    rows.iter()
1494        .map(|&row| cols.iter().map(|&col| matrix[row][col]).collect())
1495        .collect()
1496}
1497
1498#[allow(clippy::needless_range_loop)]
1499fn invert_complex_matrix(matrix: &[Vec<Complex64>]) -> Result<Vec<Vec<Complex64>>, String> {
1500    let n = matrix.len();
1501    if n == 0 || matrix.iter().any(|row| row.len() != n) {
1502        return Err("neutral block is not square".to_owned());
1503    }
1504    let mut aug = vec![vec![Complex64::new(0.0, 0.0); 2 * n]; n];
1505    for i in 0..n {
1506        for j in 0..n {
1507            aug[i][j] = matrix[i][j];
1508        }
1509        aug[i][n + i] = Complex64::new(1.0, 0.0);
1510    }
1511
1512    for col in 0..n {
1513        let pivot = (col..n)
1514            .max_by(|&a, &b| aug[a][col].norm_sqr().total_cmp(&aug[b][col].norm_sqr()))
1515            .ok_or_else(|| "neutral block is singular".to_owned())?;
1516        if aug[pivot][col].norm() <= f64::EPSILON {
1517            return Err("neutral block is singular".to_owned());
1518        }
1519        if pivot != col {
1520            aug.swap(pivot, col);
1521        }
1522        let pivot_value = aug[col][col];
1523        for j in 0..(2 * n) {
1524            aug[col][j] /= pivot_value;
1525        }
1526        for row in 0..n {
1527            if row == col {
1528                continue;
1529            }
1530            let factor = aug[row][col];
1531            if factor.norm() <= f64::EPSILON {
1532                continue;
1533            }
1534            for j in 0..(2 * n) {
1535                let pivot_entry = aug[col][j];
1536                aug[row][j] -= factor * pivot_entry;
1537            }
1538        }
1539    }
1540
1541    Ok(aug
1542        .into_iter()
1543        .map(|row| row.into_iter().skip(n).collect())
1544        .collect())
1545}
1546
1547fn matmul(a: &[Vec<Complex64>], b: &[Vec<Complex64>]) -> Vec<Vec<Complex64>> {
1548    if a.is_empty() || b.is_empty() {
1549        return Vec::new();
1550    }
1551    let rows = a.len();
1552    let cols = b[0].len();
1553    let inner = b.len();
1554    let mut out = vec![vec![Complex64::new(0.0, 0.0); cols]; rows];
1555    for i in 0..rows {
1556        for k in 0..inner {
1557            for j in 0..cols {
1558                out[i][j] += a[i][k] * b[k][j];
1559            }
1560        }
1561    }
1562    out
1563}
1564
1565fn matrix_sub(a: &[Vec<Complex64>], b: &[Vec<Complex64>]) -> Vec<Vec<Complex64>> {
1566    a.iter()
1567        .zip(b.iter())
1568        .map(|(a_row, b_row)| {
1569            a_row
1570                .iter()
1571                .zip(b_row.iter())
1572                .map(|(&a_value, &b_value)| a_value - b_value)
1573                .collect()
1574        })
1575        .collect()
1576}
1577
1578#[allow(clippy::many_single_char_names)]
1579fn sequence_basis() -> [[Complex64; 3]; 3] {
1580    let scale = 1.0 / SQRT_3;
1581    let a = Complex64::from_polar(1.0, 2.0 * PI / 3.0);
1582    let a2 = a * a;
1583    [
1584        [
1585            Complex64::new(scale, 0.0),
1586            Complex64::new(scale, 0.0),
1587            Complex64::new(scale, 0.0),
1588        ],
1589        [Complex64::new(scale, 0.0), a2 * scale, a * scale],
1590        [Complex64::new(scale, 0.0), a * scale, a2 * scale],
1591    ]
1592}
1593
1594fn sequence_matrix(matrix: &[Vec<Complex64>]) -> [[Complex64; 3]; 3] {
1595    let basis = sequence_basis();
1596    let mut seq = [[Complex64::new(0.0, 0.0); 3]; 3];
1597    for p in 0..3 {
1598        for q in 0..3 {
1599            for i in 0..3 {
1600                for j in 0..3 {
1601                    seq[p][q] += basis[i][p].conj() * matrix[i][j] * basis[j][q];
1602                }
1603            }
1604        }
1605    }
1606    seq
1607}
1608
1609fn sequence_coupling_norm(seq: &[[Complex64; 3]; 3]) -> f64 {
1610    let mut sum = 0.0;
1611    for (i, row) in seq.iter().enumerate() {
1612        for (j, value) in row.iter().enumerate() {
1613            if i != j {
1614                sum += value.norm_sqr();
1615            }
1616        }
1617    }
1618    sum.sqrt()
1619}
1620
1621/// The branch rating, in MVA. BMOPF schema 0.1.0 gives a line its own
1622/// `i_max`/`s_max`, which "overrides the linecode's i_max for this line", so
1623/// both line fields are tried before either linecode field. Within one owner
1624/// `s_max` comes first, because an apparent power limit needs no voltage.
1625///
1626/// A field the active conductors leave unusable falls through to the next
1627/// candidate rather than ending the search: a line whose `s_max` is all
1628/// infinities must not hide a linecode that carries a real rating.
1629fn line_rate_mva(
1630    line: &DistLine,
1631    code: &DistLineCode,
1632    active: &[usize],
1633    line_to_line_volts: f64,
1634) -> Option<f64> {
1635    for (s_max, i_max) in [
1636        (line.s_max.as_ref(), line.i_max.as_ref()),
1637        (code.s_max.as_ref(), code.i_max.as_ref()),
1638    ] {
1639        if let Some(mva) = s_max.and_then(|values| apparent_power_mva(values, active)) {
1640            return Some(mva);
1641        }
1642        if let Some(amps) = i_max.and_then(|values| limiting_amps(values, active)) {
1643            return Some(SQRT_3 * line_to_line_volts * amps / 1_000_000.0);
1644        }
1645    }
1646    None
1647}
1648
1649/// The summed apparent power limit of the active conductors, in MVA, or None
1650/// when any of them has no finite limit.
1651fn apparent_power_mva(s_max: &[f64], active: &[usize]) -> Option<f64> {
1652    let values: Vec<_> = active
1653        .iter()
1654        .filter_map(|&idx| s_max.get(idx).copied())
1655        .collect();
1656    (!values.is_empty() && values.iter().all(|value| value.is_finite()))
1657        .then(|| values.iter().sum::<f64>() / 1_000_000.0)
1658}
1659
1660/// The smallest usable current limit over the active conductors, in amps.
1661fn limiting_amps(i_max: &[f64], active: &[usize]) -> Option<f64> {
1662    active
1663        .iter()
1664        .filter_map(|&idx| i_max.get(idx).copied())
1665        .filter(|value| value.is_finite() && *value >= 0.0)
1666        .reduce(f64::min)
1667}
1668
1669fn partial_phase_admittance(
1670    g: &ConductorMatrix,
1671    b: &ConductorMatrix,
1672    active: &[usize],
1673) -> Complex64 {
1674    let mut total = Complex64::new(0.0, 0.0);
1675    for &idx in active {
1676        let Some(g_row) = g.get(idx) else {
1677            continue;
1678        };
1679        let Some(b_row) = b.get(idx) else {
1680            continue;
1681        };
1682        let Some(&g_value) = g_row.get(idx) else {
1683            continue;
1684        };
1685        let Some(&b_value) = b_row.get(idx) else {
1686            continue;
1687        };
1688        total += Complex64::new(g_value, b_value);
1689    }
1690    total / 3.0
1691}
1692
1693fn si_power_to_mega(value: f64) -> f64 {
1694    value / 1_000_000.0
1695}
1696
1697fn option_vec_sum_mw(values: Option<&[f64]>) -> Option<f64> {
1698    values.map(|v| si_power_to_mega(v.iter().sum()))
1699}
1700
1701fn radians_to_degrees(value: f64) -> f64 {
1702    value * 180.0 / PI
1703}
1704
1705fn check_options(
1706    options: MulticonductorToBalancedOptions,
1707    report: &mut MulticonductorToBalancedReport,
1708) {
1709    if !options.base_mva.is_finite() || options.base_mva <= 0.0 {
1710        report.diagnostics.push(Diagnostic::of(
1711    &codes::TRANSFORM_MULTI_TO_BALANCED_INVALID_BASE_MVA,
1712format!(
1713                "base_mva must be positive and finite for multiconductor to balanced lowering; got {}",
1714                options.base_mva
1715            ),
1716        ));
1717    }
1718}
1719
1720fn check_bus_conductor_sets(
1721    net: &MulticonductorNetwork,
1722    report: &mut MulticonductorToBalancedReport,
1723) {
1724    let neutral_terminals = global_neutral_terminals(net);
1725    let mut saw_neutral = false;
1726    for (i, bus) in net.buses().iter().enumerate() {
1727        if (bus.v_min.is_none() && bus.v_min_phase.is_some())
1728            || (bus.v_max.is_none() && bus.v_max_phase.is_some())
1729        {
1730            report.diagnostics.push(Diagnostic::of(
1731                &codes::TRANSFORM_MULTI_TO_BALANCED_UNSUPPORTED_OBJECT,
1732                format!("bus {} has nonuniform phase voltage bounds that a balanced bus cannot represent", bus.id),
1733            ).with_value_target(format!("/buses/{i}")));
1734        }
1735        let active_count = active_terminal_count(&bus.terminals, Some(bus), &neutral_terminals);
1736        if active_count < bus.terminals.len() {
1737            saw_neutral = true;
1738        }
1739
1740        match active_count {
1741            3 => {}
1742            2 => report.diagnostics.push(
1743                Diagnostic::of(
1744    &codes::TRANSFORM_MULTI_TO_BALANCED_AMBIGUOUS_TERMINAL_MAP,
1745format!(
1746                        "bus {} has two active terminals; no unique positive sequence projection is defined",
1747                        bus.id
1748                    ),
1749                )
1750                .with_value_target(format!("/buses/{i}/terminals")),
1751            ),
1752            0 | 1 => report.diagnostics.push(
1753                Diagnostic::of(
1754    &codes::TRANSFORM_MULTI_TO_BALANCED_UNSUPPORTED_CONDUCTOR_SET,
1755format!(
1756                        "bus {} has {active_count} active terminal; multiconductor to balanced lowering starts with three phase input",
1757                        bus.id
1758                    ),
1759                )
1760                .with_value_target(format!("/buses/{i}/terminals")),
1761            ),
1762            _ => report.diagnostics.push(
1763                Diagnostic::of(
1764    &codes::TRANSFORM_MULTI_TO_BALANCED_UNSUPPORTED_CONDUCTOR_SET,
1765format!(
1766                        "bus {} has {active_count} active terminals; multiconductor to balanced lowering starts with three phase input",
1767                        bus.id
1768                    ),
1769                )
1770                .with_value_target(format!("/buses/{i}/terminals")),
1771            ),
1772        }
1773    }
1774
1775    if saw_neutral {
1776        report
1777            .approximations
1778            .push("Kron reduction of neutral conductor before sequence transform".to_owned());
1779        report.diagnostics.push(Diagnostic::of(
1780            &codes::TRANSFORM_MULTI_TO_BALANCED_KRON_REDUCTION_REQUIRED,
1781            "neutral conductors require Kron reduction before the sequence transform",
1782        ));
1783    }
1784}
1785
1786fn check_line_terminal_maps(
1787    net: &MulticonductorNetwork,
1788    report: &mut MulticonductorToBalancedReport,
1789) {
1790    let neutral_terminals = global_neutral_terminals(net);
1791    for (i, line) in net.lines().iter().enumerate() {
1792        for (field, bus_id, terminal_map) in [
1793            (
1794                "terminal_map_from",
1795                line.bus_from.as_str(),
1796                line.terminal_map_from.as_slice(),
1797            ),
1798            (
1799                "terminal_map_to",
1800                line.bus_to.as_str(),
1801                line.terminal_map_to.as_slice(),
1802            ),
1803        ] {
1804            let bus = net.bus(bus_id);
1805            let active_count = active_terminal_count(terminal_map, bus, &neutral_terminals);
1806            if active_count != 3 {
1807                report.diagnostics.push(
1808                    Diagnostic::of(
1809    &codes::TRANSFORM_MULTI_TO_BALANCED_UNSUPPORTED_CONDUCTOR_SET,
1810format!(
1811                            "line {} {field} has {active_count} active terminal(s); balanced branch lowering requires three active phase conductors",
1812                            line.name
1813                        ),
1814                    )
1815                    .with_value_target(format!("/lines/{i}/{field}")),
1816                );
1817            }
1818        }
1819    }
1820}
1821
1822fn check_linecodes(net: &MulticonductorNetwork, report: &mut MulticonductorToBalancedReport) {
1823    for (i, line) in net.lines().iter().enumerate() {
1824        let Some(code) = net.linecode(&line.linecode) else {
1825            report.diagnostics.push(
1826                Diagnostic::of(
1827                    &codes::TRANSFORM_MULTI_TO_BALANCED_UNKNOWN_LINECODE,
1828                    format!(
1829                        "line {} references unknown linecode `{}`",
1830                        line.name, line.linecode
1831                    ),
1832                )
1833                .with_value_target(format!("/lines/{i}/linecode")),
1834            );
1835            continue;
1836        };
1837        if code.n_conductors != line.terminal_map_from.len()
1838            || code.n_conductors != line.terminal_map_to.len()
1839        {
1840            report.diagnostics.push(
1841                Diagnostic::of(
1842    &codes::TRANSFORM_MULTI_TO_BALANCED_LINECODE_TERMINAL_MISMATCH,
1843format!(
1844                        "line {} uses linecode {} with {} conductor(s), but its terminal maps have {} and {} terminal(s)",
1845                        line.name,
1846                        code.name,
1847                        code.n_conductors,
1848                        line.terminal_map_from.len(),
1849                        line.terminal_map_to.len()
1850                    ),
1851                )
1852                .with_value_target(format!("/lines/{i}/linecode")),
1853            );
1854        }
1855        if !square_matrix_shape(&code.r_series, code.n_conductors)
1856            || !square_matrix_shape(&code.x_series, code.n_conductors)
1857            || !square_matrix_shape(&code.g_from, code.n_conductors)
1858            || !square_matrix_shape(&code.b_from, code.n_conductors)
1859            || !square_matrix_shape(&code.g_to, code.n_conductors)
1860            || !square_matrix_shape(&code.b_to, code.n_conductors)
1861        {
1862            report.diagnostics.push(
1863                Diagnostic::of(
1864                    &codes::TRANSFORM_MULTI_TO_BALANCED_INVALID_LINECODE_MATRIX,
1865                    format!(
1866                        "linecode {} does not carry square {} conductor matrices",
1867                        code.name, code.n_conductors
1868                    ),
1869                )
1870                .with_value_target(format!("/lines/{i}/linecode")),
1871            );
1872        }
1873    }
1874}
1875
1876fn square_matrix_shape(matrix: &ConductorMatrix, n: usize) -> bool {
1877    matrix.len() == n && matrix.iter().all(|row| row.len() == n)
1878}
1879
1880fn check_switches(net: &MulticonductorNetwork, report: &mut MulticonductorToBalancedReport) {
1881    let neutral_terminals = global_neutral_terminals(net);
1882    for (i, sw) in net.switches().iter().enumerate() {
1883        if sw.open {
1884            report.diagnostics.push(
1885                Diagnostic::of(
1886                    &codes::TRANSFORM_MULTI_TO_BALANCED_DROPPED_OPEN_SWITCH,
1887                    format!(
1888                        "open switch {} is dropped by multiconductor to balanced lowering",
1889                        sw.name
1890                    ),
1891                )
1892                .with_value_target(format!("/switches/{i}")),
1893            );
1894        } else {
1895            report
1896                .diagnostics
1897                .extend(switch_merge_blockers(net, i, sw, &neutral_terminals));
1898        }
1899    }
1900}
1901
1902/// Everything that stops a closed switch from merging its buses. An empty
1903/// list means the switch merges: its endpoints collapse to one balanced bus
1904/// and the switch identity is removed with the mapping recorded. Merging
1905/// never invents an impedance.
1906fn switch_merge_blockers(
1907    net: &MulticonductorNetwork,
1908    index: usize,
1909    sw: &powerio_dist::DistSwitch,
1910    neutral_terminals: &BTreeSet<String>,
1911) -> Vec<Diagnostic> {
1912    let path = format!("/switches/{index}");
1913    let mut blockers = Vec::new();
1914    if sw
1915        .i_max
1916        .as_ref()
1917        .is_some_and(|limits| limits.iter().any(|limit| limit.is_finite()))
1918    {
1919        blockers.push(
1920            Diagnostic::of(
1921                &codes::TRANSFORM_MULTI_TO_BALANCED_RATED_CLOSED_SWITCH,
1922                format!(
1923                    "closed switch {} carries a finite ampacity; merging its buses would \
1924                     remove the branch flow the limit constrains",
1925                    sw.name
1926                ),
1927            )
1928            .with_value_target(path.clone()),
1929        );
1930    }
1931    let from_bus = net.bus(&sw.bus_from);
1932    let to_bus = net.bus(&sw.bus_to);
1933    if from_bus.is_none() || to_bus.is_none() {
1934        let missing = if from_bus.is_none() {
1935            &sw.bus_from
1936        } else {
1937            &sw.bus_to
1938        };
1939        blockers.push(
1940            Diagnostic::of(
1941                &codes::TRANSFORM_MULTI_TO_BALANCED_UNKNOWN_BUS,
1942                format!("switch {} references unknown bus {missing}", sw.name),
1943            )
1944            .with_value_target(path.clone()),
1945        );
1946        return blockers;
1947    }
1948    if !same_active_phase_order(
1949        from_bus,
1950        &sw.terminal_map_from,
1951        to_bus,
1952        &sw.terminal_map_to,
1953        neutral_terminals,
1954    ) {
1955        blockers.push(
1956            Diagnostic::of(
1957                &codes::TRANSFORM_MULTI_TO_BALANCED_SWITCH_TERMINAL_MISMATCH,
1958                format!(
1959                    "closed switch {} does not map identical conductors on both ends, so its \
1960                     buses are not electrically identical",
1961                    sw.name
1962                ),
1963            )
1964            .with_value_target(path.clone()),
1965        );
1966    }
1967    if !sw.bus_from.eq_ignore_ascii_case(&sw.bus_to) {
1968        let sourced = |bus: &str| {
1969            net.sources()
1970                .iter()
1971                .any(|source| source.bus.eq_ignore_ascii_case(bus))
1972        };
1973        if sourced(&sw.bus_from) && sourced(&sw.bus_to) {
1974            blockers.push(
1975                Diagnostic::of(
1976                    &codes::TRANSFORM_MULTI_TO_BALANCED_SWITCH_MERGE_CONFLICT,
1977                    format!(
1978                        "closed switch {} joins two buses that both carry voltage source \
1979                         references",
1980                        sw.name
1981                    ),
1982                )
1983                .with_value_target(path.clone()),
1984            );
1985        }
1986        let (from_bus, to_bus) = (from_bus.expect("checked"), to_bus.expect("checked"));
1987        for (label, a, b) in [
1988            ("v_min", from_bus.v_min, to_bus.v_min),
1989            ("v_max", from_bus.v_max, to_bus.v_max),
1990        ] {
1991            if let (Some(a), Some(b)) = (a, b)
1992                && (a - b).abs() > f64::EPSILON * a.abs().max(b.abs()).max(1.0)
1993            {
1994                blockers.push(
1995                    Diagnostic::of(
1996                        &codes::TRANSFORM_MULTI_TO_BALANCED_SWITCH_MERGE_CONFLICT,
1997                        format!(
1998                            "closed switch {} joins buses stating different {label} bounds \
1999                             ({a} and {b})",
2000                            sw.name
2001                        ),
2002                    )
2003                    .with_value_target(path.clone()),
2004                );
2005            }
2006        }
2007    }
2008    blockers
2009}
2010
2011fn global_neutral_terminals(net: &MulticonductorNetwork) -> BTreeSet<String> {
2012    net.buses()
2013        .iter()
2014        .flat_map(|bus| bus.grounded.iter().cloned())
2015        .collect()
2016}
2017
2018fn active_terminal_count(
2019    terminals: &[String],
2020    bus: Option<&DistBus>,
2021    neutral_terminals: &BTreeSet<String>,
2022) -> usize {
2023    terminals
2024        .iter()
2025        .filter(|terminal| !is_neutral_terminal(terminal, bus, neutral_terminals))
2026        .count()
2027}
2028
2029fn is_neutral_terminal(
2030    terminal: &str,
2031    bus: Option<&DistBus>,
2032    neutral_terminals: &BTreeSet<String>,
2033) -> bool {
2034    terminal == "0"
2035        || terminal.eq_ignore_ascii_case("n")
2036        || bus.is_some_and(|b| b.grounded.iter().any(|g| g == terminal))
2037        || neutral_terminals.contains(terminal)
2038}
2039
2040fn check_phase_reference(net: &MulticonductorNetwork, report: &mut MulticonductorToBalancedReport) {
2041    for (index, source) in net.sources().iter().enumerate() {
2042        if source.reference_terminal.is_some() {
2043            report.diagnostics.push(
2044                Diagnostic::of(
2045                    &codes::TRANSFORM_MULTI_TO_BALANCED_UNSUPPORTED_OBJECT,
2046                    format!(
2047                        "voltage source {} requires reference-terminal-aware balanced projection",
2048                        source.name
2049                    ),
2050                )
2051                .with_value_target(format!("/sources/{index}/reference_terminal")),
2052            );
2053        }
2054    }
2055    let neutral_terminals = global_neutral_terminals(net);
2056    let has_three_phase_source = net.sources().iter().any(|source| {
2057        let bus = net.bus(&source.bus);
2058        active_terminal_count(&source.terminal_map, bus, &neutral_terminals) == 3
2059    });
2060
2061    if !has_three_phase_source {
2062        report.diagnostics.push(Diagnostic::of(
2063            &codes::TRANSFORM_MULTI_TO_BALANCED_MISSING_PHASE_REFERENCE,
2064            "multiconductor to balanced lowering requires a three phase voltage source reference",
2065        ));
2066    }
2067}
2068
2069fn check_transformers(net: &MulticonductorNetwork, report: &mut MulticonductorToBalancedReport) {
2070    let neutral_terminals = global_neutral_terminals(net);
2071    for (i, transformer) in net.transformers().iter().enumerate() {
2072        if let Err(reason) = classify_transformer(net, transformer, &neutral_terminals) {
2073            report.diagnostics.push(
2074                Diagnostic::of(
2075                    &codes::TRANSFORM_MULTI_TO_BALANCED_UNSUPPORTED_TRANSFORMER,
2076                    format!("transformer {} {reason}", transformer.name),
2077                )
2078                .with_value_target(format!("/transformers/{i}")),
2079            );
2080        }
2081    }
2082}
2083
2084/// Why one transformer lowers or refuses. The supported shape is a three
2085/// phase two winding `wye_delta` or `delta_wye` transformer with finite
2086/// positive ratings and full three phase terminal maps.
2087fn classify_transformer<'net>(
2088    net: &'net MulticonductorNetwork,
2089    transformer: &'net powerio_dist::DistTransformer,
2090    neutral_terminals: &BTreeSet<String>,
2091) -> Result<[&'net powerio_dist::DistWinding; 2], String> {
2092    use powerio_dist::DistWindingConn;
2093    if transformer.phases != 3 {
2094        return Err(format!(
2095            "has {} phases; only a three phase transformer lowers",
2096            transformer.phases
2097        ));
2098    }
2099    let [high, low] = transformer.windings.as_slice() else {
2100        return Err(format!(
2101            "has {} windings; only a two winding transformer lowers",
2102            transformer.windings.len()
2103        ));
2104    };
2105    if high.conn == low.conn {
2106        return Err(format!(
2107            "states a {:?}-{:?} connection; only wye_delta and delta_wye lower, with their \
2108             representable thirty degree shift",
2109            high.conn, low.conn
2110        ));
2111    }
2112    debug_assert!(matches!(
2113        (high.conn, low.conn),
2114        (DistWindingConn::Wye, DistWindingConn::Delta)
2115            | (DistWindingConn::Delta, DistWindingConn::Wye)
2116    ));
2117    for winding in [high, low] {
2118        let Some(bus) = net.bus(&winding.bus) else {
2119            return Err(format!("references unknown bus {}", winding.bus));
2120        };
2121        let active = active_terminal_count(&winding.terminal_map, Some(bus), neutral_terminals);
2122        if active != 3 {
2123            return Err(format!(
2124                "winding on bus {} maps {active} active conductors; a full three phase map \
2125                 is required",
2126                winding.bus
2127            ));
2128        }
2129        if !(winding.v_ref.is_finite() && winding.v_ref > 0.0) {
2130            return Err(format!(
2131                "winding on bus {} has no finite positive voltage rating",
2132                winding.bus
2133            ));
2134        }
2135        if !winding.r_pct.is_finite() || winding.r_pct < 0.0 {
2136            return Err(format!(
2137                "winding on bus {} has no finite nonnegative resistance",
2138                winding.bus
2139            ));
2140        }
2141        if !winding.tap.is_finite() || winding.tap <= 0.0 {
2142            return Err(format!(
2143                "winding on bus {} has no finite positive tap",
2144                winding.bus
2145            ));
2146        }
2147    }
2148    if !(high.s_rating.is_finite() && high.s_rating > 0.0) {
2149        return Err("has no finite positive power rating".to_owned());
2150    }
2151    if !(low.s_rating.is_finite() && low.s_rating > 0.0) {
2152        return Err("has no finite positive low winding power rating".to_owned());
2153    }
2154    match transformer.xsc_pct.first() {
2155        Some(x) if x.is_finite() && *x >= 0.0 => {}
2156        _ => return Err("states no finite short circuit reactance".to_owned()),
2157    }
2158    Ok([high, low])
2159}
2160
2161fn check_untyped_objects(net: &MulticonductorNetwork, report: &mut MulticonductorToBalancedReport) {
2162    for (i, obj) in net.untyped_objects().iter().enumerate() {
2163        if obj.class.eq_ignore_ascii_case("linecode") {
2164            report.diagnostics.push(Diagnostic::of(
2165                &codes::TRANSFORM_MULTI_TO_BALANCED_UNSUPPORTED_OBJECT,
2166                format!("linecode {} source definition is omitted; typed line matrices supply the electrical data", obj.name),
2167            ).with_severity(DiagnosticSeverity::Remark).with_value_target(format!("/untyped/{i}")));
2168            continue;
2169        }
2170        report.diagnostics.push(
2171            Diagnostic::of(
2172                &codes::TRANSFORM_MULTI_TO_BALANCED_UNSUPPORTED_OBJECT,
2173                format!(
2174                    "{} {} is preserved as an untyped object and cannot be lowered",
2175                    obj.class, obj.name
2176                ),
2177            )
2178            .with_value_target(format!("/untyped/{i}")),
2179        );
2180    }
2181}