Skip to main content

powerio_dist/
kron.rs

1//! Explicit-neutral Kron projection for [`MulticonductorNetwork`].
2//!
3//! This is a same-family network transformation, not an OPF formulation. It
4//! removes one identified neutral terminal per bus, applies the exact Schur
5//! complement to each referenced primitive series-impedance matrix, and
6//! rewrites terminal maps and conductor-indexed data onto the retained
7//! conductors. The input handle is never mutated.
8
9use std::collections::{BTreeMap, BTreeSet};
10
11use num_complex::Complex64;
12use serde::{Deserialize, Serialize};
13use serde_json::{Value, json};
14
15use crate::DistLocationTarget;
16use crate::diagnostics::codes;
17use crate::geo::ComponentLocationEdits;
18use crate::{ConductorMatrix, Diagnostic, Error, MulticonductorNetwork, Result};
19
20const SINGULAR_TOLERANCE: f64 = 1e-12;
21
22/// Options controlling explicit-neutral identification and grounding.
23#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
24#[non_exhaustive]
25pub struct NeutralKronOptions {
26    /// Per-bus neutral terminal overrides. Bus identity is case insensitive;
27    /// terminal identity remains case sensitive.
28    pub neutral_terminals: BTreeMap<String, String>,
29    /// Permit an identified neutral that is not listed in the bus's explicit
30    /// grounded set to be treated as ideal ground. Off by default because this
31    /// changes the physical network.
32    pub allow_forced_ideal_ground: bool,
33}
34
35impl NeutralKronOptions {
36    /// Override the neutral label for one bus.
37    #[must_use]
38    pub fn with_neutral_terminal(
39        mut self,
40        bus: impl Into<String>,
41        terminal: impl Into<String>,
42    ) -> Self {
43        self.neutral_terminals.insert(bus.into(), terminal.into());
44        self
45    }
46
47    /// Opt into treating an identified floating neutral as ideal ground.
48    #[must_use]
49    pub const fn with_forced_ideal_ground(mut self, allow: bool) -> Self {
50        self.allow_forced_ideal_ground = allow;
51        self
52    }
53}
54
55/// How the eliminated neutral was grounded in the source network.
56#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "snake_case")]
58#[non_exhaustive]
59pub enum NeutralKronGrounding {
60    Perfect,
61    ForcedIdeal,
62}
63
64/// One eliminated bus terminal.
65#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
66#[non_exhaustive]
67pub struct NeutralKronBus {
68    pub bus: String,
69    pub neutral_terminal: String,
70    /// Position in the bus's terminal order before reduction.
71    pub source_position: usize,
72    pub grounding: NeutralKronGrounding,
73}
74
75/// Neutral-current recovery for one reduced linecode.
76///
77/// Zero neutral voltage drop gives `i_n = K i_p` for retained conductor
78/// currents `i_p`, where `K = -Z_nn^-1 Z_np`. Both neutral endpoints remain
79/// at ideal ground; this matrix does not recover a neutral voltage.
80#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
81#[non_exhaustive]
82pub struct NeutralKronRecovery {
83    pub linecode: String,
84    pub source_linecode: String,
85    pub eliminated_position: usize,
86    pub retained_positions: Vec<usize>,
87    pub k_re: Vec<f64>,
88    pub k_im: Vec<f64>,
89}
90
91/// One auditable non-matrix rewrite performed by the projection.
92#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
93#[non_exhaustive]
94pub struct NeutralKronAction {
95    pub component: String,
96    pub action: String,
97    pub details: BTreeMap<String, Value>,
98}
99
100/// Complete audit of one neutral-Kron projection.
101#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
102#[non_exhaustive]
103pub struct NeutralKronReport {
104    pub buses: Vec<NeutralKronBus>,
105    pub recoveries: Vec<NeutralKronRecovery>,
106    pub actions: Vec<NeutralKronAction>,
107    pub diagnostics: Vec<Diagnostic>,
108}
109
110/// The independently owned reduced network and its projection audit.
111#[derive(Clone, Debug)]
112pub struct NeutralKronReduction {
113    network: MulticonductorNetwork,
114    report: NeutralKronReport,
115}
116
117impl NeutralKronReduction {
118    #[must_use]
119    pub fn network(&self) -> &MulticonductorNetwork {
120        &self.network
121    }
122
123    #[must_use]
124    pub const fn report(&self) -> &NeutralKronReport {
125        &self.report
126    }
127
128    #[must_use]
129    pub fn into_parts(self) -> (MulticonductorNetwork, NeutralKronReport) {
130        (self.network, self.report)
131    }
132}
133
134fn fail(message: impl Into<String>) -> Error {
135    Error::KronReduction {
136        message: message.into(),
137    }
138}
139
140fn action(component: impl Into<String>, action: impl Into<String>) -> NeutralKronAction {
141    NeutralKronAction {
142        component: component.into(),
143        action: action.into(),
144        details: BTreeMap::new(),
145    }
146}
147
148fn conventions(network: &MulticonductorNetwork) -> Option<&Value> {
149    network.extras().get("bmopf_terminal_conventions")
150}
151
152fn override_for_bus<'a>(options: &'a NeutralKronOptions, bus: &str) -> Option<&'a str> {
153    options
154        .neutral_terminals
155        .iter()
156        .find(|(id, _)| id.eq_ignore_ascii_case(bus))
157        .map(|(_, terminal)| terminal.as_str())
158}
159
160fn identify_neutrals(
161    network: &MulticonductorNetwork,
162    options: &NeutralKronOptions,
163) -> Result<Vec<NeutralKronBus>> {
164    let roles = conventions(network);
165    let mut buses = Vec::new();
166    let mut matched_overrides = BTreeSet::new();
167    for bus in network.buses() {
168        let explicit = override_for_bus(options, &bus.id);
169        if explicit.is_some() {
170            matched_overrides.insert(bus.id.to_ascii_lowercase());
171        }
172        let candidates = if let Some(terminal) = explicit {
173            vec![terminal.to_owned()]
174        } else {
175            let phase_positions: BTreeSet<usize> = bus.phase_indices(roles).into_iter().collect();
176            bus.terminals
177                .iter()
178                .enumerate()
179                .filter_map(|(position, terminal)| {
180                    (!phase_positions.contains(&position)).then_some(terminal.clone())
181                })
182                .collect::<Vec<_>>()
183        };
184        if candidates.is_empty() {
185            continue;
186        }
187        if candidates.len() != 1 {
188            return Err(fail(format!(
189                "bus `{}` has multiple candidate neutral terminals {:?}",
190                bus.id, candidates
191            )));
192        }
193        let terminal = &candidates[0];
194        let positions = bus
195            .terminals
196            .iter()
197            .enumerate()
198            .filter_map(|(position, value)| (value == terminal).then_some(position))
199            .collect::<Vec<_>>();
200        if positions.len() != 1 {
201            return Err(fail(format!(
202                "bus `{}` neutral override `{terminal}` does not name exactly one declared terminal",
203                bus.id
204            )));
205        }
206        let grounded = bus.grounded.iter().any(|value| value == terminal);
207        let grounding = if grounded {
208            NeutralKronGrounding::Perfect
209        } else if options.allow_forced_ideal_ground {
210            NeutralKronGrounding::ForcedIdeal
211        } else {
212            return Err(fail(format!(
213                "bus `{}` neutral terminal `{terminal}` is not perfectly grounded; set allow_forced_ideal_ground only when idealizing it is intended",
214                bus.id
215            )));
216        };
217        buses.push(NeutralKronBus {
218            bus: bus.id.clone(),
219            neutral_terminal: terminal.clone(),
220            source_position: positions[0],
221            grounding,
222        });
223    }
224
225    for key in options.neutral_terminals.keys() {
226        if !matched_overrides.contains(&key.to_ascii_lowercase()) {
227            return Err(fail(format!("neutral override names unknown bus `{key}`")));
228        }
229    }
230    Ok(buses)
231}
232
233fn neutral_for_bus<'a>(buses: &'a [NeutralKronBus], bus: &str) -> Option<&'a NeutralKronBus> {
234    buses
235        .iter()
236        .find(|entry| entry.bus.eq_ignore_ascii_case(bus))
237}
238
239fn terminal_position(map: &[String], terminal: &str, label: &str) -> Result<Option<usize>> {
240    let positions = map
241        .iter()
242        .enumerate()
243        .filter_map(|(position, value)| (value == terminal).then_some(position))
244        .collect::<Vec<_>>();
245    if positions.len() > 1 {
246        return Err(fail(format!(
247            "{label} repeats neutral terminal `{terminal}`"
248        )));
249    }
250    Ok(positions.first().copied())
251}
252
253fn validate_square(matrix: &ConductorMatrix, n: usize, label: &str) -> Result<()> {
254    if matrix.len() != n || matrix.iter().any(|row| row.len() != n) {
255        return Err(fail(format!(
256            "{label} is not a {n} by {n} conductor matrix"
257        )));
258    }
259    if matrix.iter().flatten().any(|value| !value.is_finite()) {
260        return Err(fail(format!("{label} contains a non-finite value")));
261    }
262    Ok(())
263}
264
265fn retained_positions(n: usize, neutral: usize) -> Vec<usize> {
266    (0..n).filter(|position| *position != neutral).collect()
267}
268
269fn submatrix(matrix: &ConductorMatrix, keep: &[usize]) -> ConductorMatrix {
270    keep.iter()
271        .map(|&row| keep.iter().map(|&column| matrix[row][column]).collect())
272        .collect()
273}
274
275fn reduce_series(
276    r: &ConductorMatrix,
277    x: &ConductorMatrix,
278    neutral: usize,
279    label: &str,
280) -> Result<(ConductorMatrix, ConductorMatrix, Vec<usize>, Vec<Complex64>)> {
281    let n = r.len();
282    validate_square(r, n, &format!("{label} resistance"))?;
283    validate_square(x, n, &format!("{label} reactance"))?;
284    if neutral >= n {
285        return Err(fail(format!(
286            "{label} neutral position {neutral} lies outside its {n} conductors"
287        )));
288    }
289    let z = |row: usize, column: usize| Complex64::new(r[row][column], x[row][column]);
290    let scale = r
291        .iter()
292        .flatten()
293        .zip(x.iter().flatten())
294        .map(|(&re, &im)| Complex64::new(re, im).norm())
295        .fold(1.0_f64, f64::max);
296    let z_nn = z(neutral, neutral);
297    if !z_nn.norm().is_finite() || z_nn.norm() <= SINGULAR_TOLERANCE * scale {
298        return Err(fail(format!(
299            "{label} neutral self impedance is singular or near-singular"
300        )));
301    }
302    let keep = retained_positions(n, neutral);
303    if keep.is_empty() {
304        return Err(fail(format!(
305            "{label} has no retained conductor after eliminating its neutral"
306        )));
307    }
308    let recovery = keep
309        .iter()
310        .map(|&column| -z(neutral, column) / z_nn)
311        .collect::<Vec<_>>();
312    let mut r_reduced = vec![vec![0.0; keep.len()]; keep.len()];
313    let mut x_reduced = vec![vec![0.0; keep.len()]; keep.len()];
314    for (out_row, &row) in keep.iter().enumerate() {
315        for (out_column, &column) in keep.iter().enumerate() {
316            let value = z(row, column) - z(row, neutral) * z(neutral, column) / z_nn;
317            if !value.re.is_finite() || !value.im.is_finite() {
318                return Err(fail(format!(
319                    "{label} produced a non-finite reduced impedance"
320                )));
321            }
322            r_reduced[out_row][out_column] = value.re;
323            x_reduced[out_row][out_column] = value.im;
324        }
325    }
326    Ok((r_reduced, x_reduced, keep, recovery))
327}
328
329fn slice_if_conductor_aligned(values: &mut Option<Vec<f64>>, old_n: usize, neutral: usize) {
330    if values.as_ref().is_some_and(|values| values.len() == old_n) {
331        values.as_mut().expect("checked Some").remove(neutral);
332    }
333}
334
335fn reduce_current_limit(
336    values: &mut Option<Vec<f64>>,
337    old_n: usize,
338    neutral: usize,
339    label: &str,
340) -> Result<()> {
341    if values
342        .as_ref()
343        .is_some_and(|values| values.len() == old_n && values[neutral] != f64::INFINITY)
344    {
345        return Err(fail(format!(
346            "{label} has a neutral current limit that the reduced network cannot represent"
347        )));
348    }
349    slice_if_conductor_aligned(values, old_n, neutral);
350    Ok(())
351}
352
353fn reduce_phase_bound(
354    values: &mut Option<Vec<f64>>,
355    terminal_count: usize,
356    neutral: usize,
357    label: &str,
358) -> Result<()> {
359    let Some(values) = values else {
360        return Ok(());
361    };
362    if values.len() == terminal_count {
363        values.remove(neutral);
364    } else if values.len() != terminal_count.saturating_sub(1) {
365        return Err(fail(format!(
366            "{label} has {} entries; expected {} phase entries or {terminal_count} terminal entries",
367            values.len(),
368            terminal_count.saturating_sub(1)
369        )));
370    }
371    Ok(())
372}
373
374fn strip_map(map: &mut Vec<String>, neutral: &NeutralKronBus, label: &str) -> Result<bool> {
375    let Some(position) = terminal_position(map, &neutral.neutral_terminal, label)? else {
376        return Ok(false);
377    };
378    map.remove(position);
379    if map.is_empty() {
380        return Err(fail(format!(
381            "{label} has no retained terminal after neutral elimination"
382        )));
383    }
384    Ok(true)
385}
386
387fn merge_phase_neutral_bounds(bus: &mut crate::DistBus) -> Result<()> {
388    let phase_count = bus.terminals.len().saturating_sub(1);
389    if let Some(vpn_min) = bus.vpn_min.take() {
390        if vpn_min.len() != phase_count {
391            return Err(fail(format!(
392                "bus `{}` vpn_min has {} entries; expected {phase_count}",
393                bus.id,
394                vpn_min.len()
395            )));
396        }
397        let existing = bus
398            .v_min_phase
399            .take()
400            .unwrap_or_else(|| vec![bus.v_min.unwrap_or(f64::NEG_INFINITY); phase_count]);
401        if existing.len() != phase_count {
402            return Err(fail(format!(
403                "bus `{}` phase minimum bound has {} entries; expected {phase_count}",
404                bus.id,
405                existing.len()
406            )));
407        }
408        bus.v_min = None;
409        bus.v_min_phase = Some(
410            existing
411                .into_iter()
412                .zip(vpn_min)
413                .map(|(ground, neutral)| ground.max(neutral))
414                .collect(),
415        );
416    }
417    if let Some(vpn_max) = bus.vpn_max.take() {
418        if vpn_max.len() != phase_count {
419            return Err(fail(format!(
420                "bus `{}` vpn_max has {} entries; expected {phase_count}",
421                bus.id,
422                vpn_max.len()
423            )));
424        }
425        let existing = bus
426            .v_max_phase
427            .take()
428            .unwrap_or_else(|| vec![bus.v_max.unwrap_or(f64::INFINITY); phase_count]);
429        if existing.len() != phase_count {
430            return Err(fail(format!(
431                "bus `{}` phase maximum bound has {} entries; expected {phase_count}",
432                bus.id,
433                existing.len()
434            )));
435        }
436        bus.v_max = None;
437        bus.v_max_phase = Some(
438            existing
439                .into_iter()
440                .zip(vpn_max)
441                .map(|(ground, neutral)| ground.min(neutral))
442                .collect(),
443        );
444    }
445    if let (Some(minimum), Some(maximum)) = (&bus.v_min_phase, &bus.v_max_phase)
446        && minimum
447            .iter()
448            .zip(maximum)
449            .any(|(minimum, maximum)| minimum > maximum)
450    {
451        return Err(fail(format!(
452            "bus `{}` has contradictory voltage bounds after neutral elimination",
453            bus.id
454        )));
455    }
456    bus.vn_max = None;
457    Ok(())
458}
459
460fn unique_linecode_name(network: &MulticonductorNetwork, base: &str) -> String {
461    let mut candidate = base.to_owned();
462    let mut suffix = 2usize;
463    while network
464        .line_codes()
465        .iter()
466        .any(|code| code.name.eq_ignore_ascii_case(&candidate))
467    {
468        candidate = format!("{base}_{suffix}");
469        suffix += 1;
470    }
471    candidate
472}
473
474#[derive(Clone, Copy)]
475struct LineUse {
476    line: usize,
477    code: usize,
478    neutral: Option<usize>,
479}
480
481fn collect_line_uses(
482    network: &MulticonductorNetwork,
483    buses: &[NeutralKronBus],
484) -> Result<Vec<LineUse>> {
485    let mut uses = Vec::with_capacity(network.lines().len());
486    for (line_index, line) in network.lines().iter().enumerate() {
487        let code = network
488            .line_codes()
489            .iter()
490            .position(|code| code.name.eq_ignore_ascii_case(&line.linecode))
491            .ok_or_else(|| {
492                fail(format!(
493                    "line `{}` references unknown linecode `{}`",
494                    line.name, line.linecode
495                ))
496            })?;
497        let from = neutral_for_bus(buses, &line.bus_from)
498            .map(|entry| {
499                terminal_position(
500                    &line.terminal_map_from,
501                    &entry.neutral_terminal,
502                    &format!("line `{}` from map", line.name),
503                )
504            })
505            .transpose()?
506            .flatten();
507        let to = neutral_for_bus(buses, &line.bus_to)
508            .map(|entry| {
509                terminal_position(
510                    &line.terminal_map_to,
511                    &entry.neutral_terminal,
512                    &format!("line `{}` to map", line.name),
513                )
514            })
515            .transpose()?
516            .flatten();
517        let neutral = match (from, to) {
518            (None, None) => None,
519            (Some(from), Some(to)) if from == to => Some(from),
520            (Some(from), Some(to)) => {
521                return Err(fail(format!(
522                    "line `{}` maps its neutral at different conductor positions {from} and {to}",
523                    line.name
524                )));
525            }
526            _ => {
527                return Err(fail(format!(
528                    "line `{}` carries a neutral at only one endpoint",
529                    line.name
530                )));
531            }
532        };
533        uses.push(LineUse {
534            line: line_index,
535            code,
536            neutral,
537        });
538    }
539    Ok(uses)
540}
541
542fn reduce_linecodes(
543    network: &mut MulticonductorNetwork,
544    uses: &[LineUse],
545    report: &mut NeutralKronReport,
546) -> Result<()> {
547    let mut by_code: BTreeMap<usize, BTreeMap<Option<usize>, Vec<usize>>> = BTreeMap::new();
548    for usage in uses {
549        by_code
550            .entry(usage.code)
551            .or_default()
552            .entry(usage.neutral)
553            .or_default()
554            .push(usage.line);
555    }
556
557    for (code_index, groups) in by_code {
558        let source = network.line_codes()[code_index].clone();
559        let reduced_groups = groups
560            .iter()
561            .filter_map(|(position, lines)| position.map(|position| (position, lines)))
562            .collect::<Vec<_>>();
563        if reduced_groups.is_empty() {
564            continue;
565        }
566        let simple = reduced_groups.len() == 1 && !groups.contains_key(&None);
567        for (position, lines) in reduced_groups {
568            if position >= source.n_conductors {
569                return Err(fail(format!(
570                    "linecode `{}` has {} conductors but a line maps its neutral at position {position}",
571                    source.name, source.n_conductors
572                )));
573            }
574            let target_name = if simple {
575                source.name.clone()
576            } else {
577                unique_linecode_name(network, &format!("{}__kron_{}", source.name, position + 1))
578            };
579            let (r_series, x_series, keep, recovery) = reduce_series(
580                &source.r_series,
581                &source.x_series,
582                position,
583                &format!("linecode `{}`", source.name),
584            )?;
585            for (matrix, label) in [
586                (&source.g_from, "from conductance"),
587                (&source.b_from, "from susceptance"),
588                (&source.g_to, "to conductance"),
589                (&source.b_to, "to susceptance"),
590            ] {
591                validate_square(
592                    matrix,
593                    source.n_conductors,
594                    &format!("linecode `{}` {label}", source.name),
595                )?;
596            }
597            let mut reduced = source.clone();
598            reduced.name.clone_from(&target_name);
599            reduced.n_conductors = keep.len();
600            reduced.r_series = r_series;
601            reduced.x_series = x_series;
602            reduced.g_from = submatrix(&source.g_from, &keep);
603            reduced.b_from = submatrix(&source.b_from, &keep);
604            reduced.g_to = submatrix(&source.g_to, &keep);
605            reduced.b_to = submatrix(&source.b_to, &keep);
606            reduce_current_limit(
607                &mut reduced.i_max,
608                source.n_conductors,
609                position,
610                &format!("linecode `{}`", source.name),
611            )?;
612            slice_if_conductor_aligned(&mut reduced.s_max, source.n_conductors, position);
613            reduced.source = Some("kron_reduction".to_owned());
614
615            if simple {
616                network.line_codes_mut()[code_index] = reduced;
617            } else {
618                network.line_codes_mut().push(reduced);
619                for &line in lines {
620                    network.lines_mut()[line].linecode.clone_from(&target_name);
621                }
622            }
623            report.recoveries.push(NeutralKronRecovery {
624                linecode: target_name,
625                source_linecode: source.name.clone(),
626                eliminated_position: position,
627                retained_positions: keep,
628                k_re: recovery.iter().map(|value| value.re).collect(),
629                k_im: recovery.iter().map(|value| value.im).collect(),
630            });
631        }
632    }
633    Ok(())
634}
635
636fn reduced_switch_shunts<'a>(
637    switch: &crate::DistSwitch,
638    code: Option<&crate::DistLineCode>,
639    n: usize,
640    keep: &[usize],
641    frequency: f64,
642) -> Result<Vec<(&'a str, ConductorMatrix)>> {
643    let read = |key: &str| -> Result<Option<ConductorMatrix>> {
644        switch
645            .extras
646            .get(key)
647            .map(|value| {
648                let matrix: Option<ConductorMatrix> = value.as_array().and_then(|rows| {
649                    rows.iter()
650                        .map(|row| row.as_array()?.iter().map(Value::as_f64).collect())
651                        .collect()
652                });
653                let matrix = matrix
654                    .ok_or_else(|| fail(format!("switch {}: malformed {key}", switch.name)))?;
655                validate_square(&matrix, n, &format!("switch {} {key}", switch.name))?;
656                Ok(matrix)
657            })
658            .transpose()
659    };
660    let mut shunts = Vec::new();
661    for (key, fallback) in [
662        ("pmd_g_fr", code.map(|c| &c.g_from)),
663        ("pmd_g_to", code.map(|c| &c.g_to)),
664        ("pmd_b_fr", code.map(|c| &c.b_from)),
665        ("pmd_b_to", code.map(|c| &c.b_to)),
666    ] {
667        let matrix = read(key)?.or_else(|| {
668            fallback.map(|m| {
669                m.iter()
670                    .map(|row| {
671                        row.iter()
672                            .map(|v| v * 1e9 / (2.0 * std::f64::consts::PI * frequency))
673                            .collect()
674                    })
675                    .collect()
676            })
677        });
678        if let Some(m) = matrix {
679            validate_square(&m, n, &format!("switch {} {key}", switch.name))?;
680            shunts.push((key, submatrix(&m, keep)));
681        }
682    }
683    Ok(shunts)
684}
685
686/// Capture each switch's effective primitive before shared linecodes change.
687fn reduce_switch_impedances(
688    network: &mut MulticonductorNetwork,
689    original: &MulticonductorNetwork,
690    report: &mut NeutralKronReport,
691) -> Result<()> {
692    for switch in network.switches_mut() {
693        let position = |bus: &str, map: &[String]| -> Result<Option<usize>> {
694            neutral_for_bus(&report.buses, bus)
695                .map(|n| {
696                    terminal_position(map, &n.neutral_terminal, &format!("switch {}", switch.name))
697                })
698                .transpose()
699                .map(Option::flatten)
700        };
701        let (Some(from), Some(to)) = (
702            position(&switch.bus_from, &switch.terminal_map_from)?,
703            position(&switch.bus_to, &switch.terminal_map_to)?,
704        ) else {
705            continue;
706        };
707        if from != to || switch.terminal_map_from.len() != switch.terminal_map_to.len() {
708            return Err(fail(format!(
709                "switch {} has inconsistent neutral conductor positions",
710                switch.name
711            )));
712        }
713        let n = switch.terminal_map_from.len();
714        if n == 1 {
715            continue;
716        }
717        let code = switch
718            .extras
719            .get("linecode")
720            .and_then(Value::as_str)
721            .and_then(|name| original.linecode(name));
722        let read = |key: &str| -> Result<Option<ConductorMatrix>> {
723            let Some(value) = switch.extras.get(key) else {
724                return Ok(None);
725            };
726            let matrix: Option<ConductorMatrix> = value.as_array().and_then(|rows| {
727                rows.iter()
728                    .map(|row| row.as_array()?.iter().map(Value::as_f64).collect())
729                    .collect()
730            });
731            let matrix =
732                matrix.ok_or_else(|| fail(format!("switch {}: malformed {key}", switch.name)))?;
733            validate_square(&matrix, n, &format!("switch {} {key}", switch.name))?;
734            Ok(Some(matrix))
735        };
736        let r = read("pmd_rs")?.or_else(|| code.map(|c| c.r_series.clone()));
737        let x = read("pmd_xs")?.or_else(|| code.map(|c| c.x_series.clone()));
738        if r.is_none() && x.is_none() {
739            continue;
740        }
741        for m in [&r, &x].into_iter().flatten() {
742            validate_square(m, n, &format!("switch {} impedance", switch.name))?;
743        }
744        let r = r.unwrap_or_else(|| vec![vec![0.0; n]; n]);
745        let x = x.unwrap_or_else(|| vec![vec![0.0; n]; n]);
746        let keep = retained_positions(n, from);
747        let uncoupled = (0..n).all(|k| {
748            r[from][k] == 0.0 && x[from][k] == 0.0 && r[k][from] == 0.0 && x[k][from] == 0.0
749        });
750        let (r, x, keep, recovery) = if uncoupled {
751            (
752                submatrix(&r, &keep),
753                submatrix(&x, &keep),
754                keep.clone(),
755                vec![Complex64::default(); keep.len()],
756            )
757        } else {
758            reduce_series(&r, &x, from, &format!("switch {}", switch.name))?
759        };
760        let shunts = reduced_switch_shunts(switch, code, n, &keep, original.base_frequency())?;
761        switch.extras.insert("pmd_rs".into(), json!(r));
762        switch.extras.insert("pmd_xs".into(), json!(x));
763        for (key, m) in shunts {
764            switch.extras.insert(key.into(), json!(m));
765        }
766        if switch.i_max.is_none() {
767            switch.i_max = code.and_then(|c| c.i_max.clone());
768        }
769        let mut record = action(
770            format!("switch/{}", switch.name),
771            "reduced_switch_impedance",
772        );
773        record
774            .details
775            .insert("eliminated_position".into(), json!(from));
776        record
777            .details
778            .insert("retained_positions".into(), json!(keep));
779        record.details.insert(
780            "k_re".into(),
781            json!(recovery.iter().map(|v| v.re).collect::<Vec<_>>()),
782        );
783        record.details.insert(
784            "k_im".into(),
785            json!(recovery.iter().map(|v| v.im).collect::<Vec<_>>()),
786        );
787        report.actions.push(record);
788    }
789    Ok(())
790}
791
792fn reduce_buses(network: &mut MulticonductorNetwork, report: &mut NeutralKronReport) -> Result<()> {
793    for bus_reduction in &report.buses {
794        let bus = network
795            .buses_mut()
796            .iter_mut()
797            .find(|bus| bus.id.eq_ignore_ascii_case(&bus_reduction.bus))
798            .expect("identified bus remains present");
799        for key in ["pmd_vm_pair_lb", "pmd_vm_pair_ub"] {
800            if bus
801                .extras
802                .get(key)
803                .and_then(Value::as_array)
804                .is_some_and(|rows| {
805                    rows.iter().any(|row| {
806                        row.as_array().is_some_and(|r| {
807                            r.iter().take(2).any(|id| {
808                                id.as_i64().is_some_and(|i| {
809                                    i.to_string() == bus_reduction.neutral_terminal
810                                })
811                            })
812                        })
813                    })
814                })
815            {
816                return Err(fail(format!(
817                    "bus {}: retained pairwise voltage bounds reference the neutral being eliminated",
818                    bus.id
819                )));
820            }
821        }
822        let terminal_count = bus.terminals.len();
823        reduce_phase_bound(
824            &mut bus.v_min_phase,
825            terminal_count,
826            bus_reduction.source_position,
827            &format!("bus `{}` phase minimum bound", bus.id),
828        )?;
829        reduce_phase_bound(
830            &mut bus.v_max_phase,
831            terminal_count,
832            bus_reduction.source_position,
833            &format!("bus `{}` phase maximum bound", bus.id),
834        )?;
835        merge_phase_neutral_bounds(bus)?;
836        bus.terminals.remove(bus_reduction.source_position);
837        bus.grounded
838            .retain(|terminal| terminal != &bus_reduction.neutral_terminal);
839        let mut record = action(format!("bus/{}", bus.id), "removed_neutral_terminal");
840        record
841            .details
842            .insert("terminal".to_owned(), json!(bus_reduction.neutral_terminal));
843        report.actions.push(record);
844    }
845    Ok(())
846}
847
848#[allow(clippy::too_many_lines)]
849fn reduce_terminal_maps(
850    network: &mut MulticonductorNetwork,
851    report: &mut NeutralKronReport,
852    locations: &mut ComponentLocationEdits,
853) -> Result<()> {
854    let buses = report.buses.clone();
855    for line in network.lines_mut() {
856        if let Some(neutral) = neutral_for_bus(&buses, &line.bus_from) {
857            let old_n = line.terminal_map_from.len();
858            if let Some(position) = terminal_position(
859                &line.terminal_map_from,
860                &neutral.neutral_terminal,
861                &format!("line `{}` from map", line.name),
862            )? {
863                line.terminal_map_from.remove(position);
864                reduce_current_limit(
865                    &mut line.i_max,
866                    old_n,
867                    position,
868                    &format!("line `{}`", line.name),
869                )?;
870                slice_if_conductor_aligned(&mut line.s_max, old_n, position);
871            }
872        }
873        if let Some(neutral) = neutral_for_bus(&buses, &line.bus_to) {
874            let position = terminal_position(
875                &line.terminal_map_to,
876                &neutral.neutral_terminal,
877                &format!("line `{}` to map", line.name),
878            )?;
879            if let Some(position) = position {
880                line.terminal_map_to.remove(position);
881            }
882        }
883        if line.terminal_map_from.is_empty() || line.terminal_map_to.is_empty() {
884            return Err(fail(format!(
885                "line `{}` has no retained conductor after neutral elimination",
886                line.name
887            )));
888        }
889    }
890
891    for load in network.loads_mut() {
892        if let Some(neutral) = neutral_for_bus(&buses, &load.bus) {
893            strip_map(
894                &mut load.terminal_map,
895                neutral,
896                &format!("load `{}` terminal map", load.name),
897            )?;
898        }
899    }
900    for generator in network.generators_mut() {
901        if let Some(neutral) = neutral_for_bus(&buses, &generator.bus) {
902            let old_n = generator.terminal_map.len();
903            if let Some(position) = terminal_position(
904                &generator.terminal_map,
905                &neutral.neutral_terminal,
906                &format!("generator `{}` terminal map", generator.name),
907            )? {
908                generator.terminal_map.remove(position);
909                reduce_current_limit(
910                    &mut generator.i_max,
911                    old_n,
912                    position,
913                    &format!("generator `{}`", generator.name),
914                )?;
915                slice_if_conductor_aligned(&mut generator.s_max, old_n, position);
916            }
917            if generator.terminal_map.is_empty() {
918                return Err(fail(format!(
919                    "generator `{}` has no retained terminal after neutral elimination",
920                    generator.name
921                )));
922            }
923        }
924    }
925    for source in network.sources_mut() {
926        if let Some(neutral) = neutral_for_bus(&buses, &source.bus) {
927            let old_n = source.terminal_map.len();
928            if let Some(position) = terminal_position(
929                &source.terminal_map,
930                &neutral.neutral_terminal,
931                &format!("voltage source `{}` terminal map", source.name),
932            )? {
933                source.terminal_map.remove(position);
934                if source.v_magnitude.len() == old_n {
935                    source.v_magnitude.remove(position);
936                }
937                if source.v_angle.len() == old_n {
938                    source.v_angle.remove(position);
939                }
940                if source
941                    .energy_cost_rate
942                    .as_ref()
943                    .is_some_and(|values| values.len() == old_n)
944                {
945                    source
946                        .energy_cost_rate
947                        .as_mut()
948                        .expect("checked Some")
949                        .remove(position);
950                }
951            }
952            if source.terminal_map.is_empty() {
953                return Err(fail(format!(
954                    "voltage source `{}` has no retained terminal after neutral elimination",
955                    source.name
956                )));
957            }
958        }
959    }
960
961    for ibr in network.ibrs() {
962        if neutral_for_bus(&buses, &ibr.bus).is_some_and(|neutral| {
963            ibr.terminal_map
964                .iter()
965                .any(|terminal| terminal == &neutral.neutral_terminal)
966        }) {
967            return Err(fail(format!(
968                "IBR `{}` contains an active neutral leg; this projection cannot preserve neutral-current physics",
969                ibr.name
970            )));
971        }
972    }
973    for capacitor in network.capacitors() {
974        if neutral_for_bus(&buses, &capacitor.bus).is_some_and(|neutral| {
975            capacitor
976                .terminal_map
977                .iter()
978                .any(|terminal| terminal == &neutral.neutral_terminal)
979        }) {
980            return Err(fail(format!(
981                "capacitor `{}` contains an explicit neutral; convert it to a typed shunt before neutral reduction",
982                capacitor.name
983            )));
984        }
985    }
986
987    let mut retained_shunts = Vec::with_capacity(network.shunts().len());
988    for mut shunt in std::mem::take(network.shunts_mut()) {
989        if let Some(neutral) = neutral_for_bus(&buses, &shunt.bus)
990            && let Some(position) = terminal_position(
991                &shunt.terminal_map,
992                &neutral.neutral_terminal,
993                &format!("shunt `{}` terminal map", shunt.name),
994            )?
995        {
996            if shunt.terminal_map.len() == 1 {
997                let mut record = action(
998                    format!("shunt/{}", shunt.name),
999                    "removed_neutral_only_shunt",
1000                );
1001                locations.rewrite(
1002                    DistLocationTarget::Shunt,
1003                    &shunt.name,
1004                    None,
1005                    &mut record.details,
1006                );
1007                report.actions.push(record);
1008                continue;
1009            }
1010            let old_n = shunt.terminal_map.len();
1011            validate_square(
1012                &shunt.g,
1013                old_n,
1014                &format!("shunt `{}` conductance", shunt.name),
1015            )?;
1016            validate_square(
1017                &shunt.b,
1018                old_n,
1019                &format!("shunt `{}` susceptance", shunt.name),
1020            )?;
1021            let keep = retained_positions(old_n, position);
1022            shunt.terminal_map.remove(position);
1023            shunt.g = submatrix(&shunt.g, &keep);
1024            shunt.b = submatrix(&shunt.b, &keep);
1025        }
1026        retained_shunts.push(shunt);
1027    }
1028    *network.shunts_mut() = retained_shunts;
1029
1030    let mut retained_switches = Vec::with_capacity(network.switches().len());
1031    for mut switch in std::mem::take(network.switches_mut()) {
1032        let from = neutral_for_bus(&buses, &switch.bus_from)
1033            .map(|neutral| {
1034                terminal_position(
1035                    &switch.terminal_map_from,
1036                    &neutral.neutral_terminal,
1037                    &format!("switch `{}` from map", switch.name),
1038                )
1039            })
1040            .transpose()?
1041            .flatten();
1042        let to = neutral_for_bus(&buses, &switch.bus_to)
1043            .map(|neutral| {
1044                terminal_position(
1045                    &switch.terminal_map_to,
1046                    &neutral.neutral_terminal,
1047                    &format!("switch `{}` to map", switch.name),
1048                )
1049            })
1050            .transpose()?
1051            .flatten();
1052        match (from, to) {
1053            (Some(from), Some(to)) => {
1054                if switch.terminal_map_from.len() == 1 && switch.terminal_map_to.len() == 1 {
1055                    let mut record = action(
1056                        format!("switch/{}", switch.name),
1057                        "removed_neutral_only_switch",
1058                    );
1059                    locations.rewrite(
1060                        DistLocationTarget::Switch,
1061                        &switch.name,
1062                        None,
1063                        &mut record.details,
1064                    );
1065                    report.actions.push(record);
1066                    continue;
1067                }
1068                let old_n = switch.terminal_map_from.len();
1069                switch.terminal_map_from.remove(from);
1070                switch.terminal_map_to.remove(to);
1071                reduce_current_limit(
1072                    &mut switch.i_max,
1073                    old_n,
1074                    from,
1075                    &format!("switch `{}`", switch.name),
1076                )?;
1077            }
1078            (None, None) => {}
1079            _ => {
1080                return Err(fail(format!(
1081                    "switch `{}` carries a neutral at only one endpoint",
1082                    switch.name
1083                )));
1084            }
1085        }
1086        retained_switches.push(switch);
1087    }
1088    *network.switches_mut() = retained_switches;
1089
1090    for transformer in network.transformers_mut() {
1091        for (winding_index, winding) in transformer.windings.iter_mut().enumerate() {
1092            let removed = if let Some(neutral) = neutral_for_bus(&buses, &winding.bus) {
1093                strip_map(
1094                    &mut winding.terminal_map,
1095                    neutral,
1096                    &format!(
1097                        "transformer `{}` winding {} terminal map",
1098                        transformer.name,
1099                        winding_index + 1
1100                    ),
1101                )?
1102            } else {
1103                false
1104            };
1105            if removed {
1106                let had_r = winding.r_neutral.take().is_some();
1107                let had_x = winding.x_neutral.take().is_some();
1108                if !(had_r || had_x) {
1109                    continue;
1110                }
1111                report.actions.push(action(
1112                    format!(
1113                        "transformer/{}/winding/{}",
1114                        transformer.name,
1115                        winding_index + 1
1116                    ),
1117                    "removed_neutral_grounding_impedance",
1118                ));
1119            }
1120        }
1121    }
1122    Ok(())
1123}
1124
1125fn update_terminal_conventions(network: &mut MulticonductorNetwork, buses: &[NeutralKronBus]) {
1126    let removed = buses
1127        .iter()
1128        .map(|entry| entry.neutral_terminal.as_str())
1129        .collect::<BTreeSet<_>>();
1130    let Some(Value::Object(roles)) = network.extras_mut().get_mut("bmopf_terminal_conventions")
1131    else {
1132        return;
1133    };
1134    if let Some(Value::Array(neutrals)) = roles.get_mut("neutral") {
1135        neutrals.retain(|value| {
1136            value
1137                .as_str()
1138                .is_none_or(|terminal| !removed.contains(terminal))
1139        });
1140    }
1141}
1142
1143/// Produce a neutral-reduced multiconductor network without mutating the
1144/// input. Neutral roles come from `options.neutral_terminals`, retained BMOPF
1145/// terminal conventions, or the model's conservative `n` / four-wire `4`
1146/// convention. Only perfectly grounded neutrals are accepted unless the
1147/// explicit forced-ground option is enabled.
1148/// Component points remain attached to retained components and are copied to
1149/// derived line configurations. Removed devices' points remain in the audit
1150/// action's `details.source_location`.
1151///
1152/// # Errors
1153/// Ambiguous or floating neutral roles, inconsistent conductor maps,
1154/// singular primitive impedance, or typed neutral-current physics the
1155/// projection cannot preserve.
1156pub fn neutral_kron_reduce(
1157    network: &MulticonductorNetwork,
1158    options: &NeutralKronOptions,
1159) -> Result<NeutralKronReduction> {
1160    network
1161        .validate_component_locations()
1162        .map_err(|error| fail(error.to_string()))?;
1163    crate::readiness::require_earth_referenced_sources(network)
1164        .map_err(|error| fail(error.to_string()))?;
1165    let buses = identify_neutrals(network, options)?;
1166    let mut report = NeutralKronReport {
1167        buses,
1168        ..NeutralKronReport::default()
1169    };
1170    if report.buses.is_empty() {
1171        return Ok(NeutralKronReduction {
1172            network: network.clone(),
1173            report,
1174        });
1175    }
1176
1177    for (index, bus) in report.buses.iter().enumerate() {
1178        let code = match bus.grounding {
1179            NeutralKronGrounding::Perfect => &codes::TRANSFORM_DIST_NEUTRAL_KRON_REDUCED,
1180            NeutralKronGrounding::ForcedIdeal => &codes::TRANSFORM_DIST_NEUTRAL_KRON_FORCED_GROUND,
1181        };
1182        let mut diagnostic = Diagnostic::of(
1183            code,
1184            format!(
1185                "bus `{}` neutral terminal `{}` was eliminated",
1186                bus.bus, bus.neutral_terminal
1187            ),
1188        );
1189        crate::diagnostics::attach_target(&mut diagnostic, format!("/buses/{index}/terminals"));
1190        let _ = diagnostic.insert_detail("neutral_terminal", json!(bus.neutral_terminal));
1191        report.diagnostics.push(diagnostic);
1192    }
1193    if !network.untyped_objects().is_empty() {
1194        report.diagnostics.push(Diagnostic::of(
1195            &codes::TRANSFORM_DIST_KRON_UNTYPED_RETAINED,
1196            format!(
1197                "{} untyped source object(s) were retained unchanged",
1198                network.untyped_objects().len()
1199            ),
1200        ));
1201    }
1202
1203    let uses = collect_line_uses(network, &report.buses)?;
1204    let mut reduced = network.clone();
1205    let mut locations = ComponentLocationEdits::new(network);
1206    reduce_switch_impedances(&mut reduced, network, &mut report)?;
1207    reduce_linecodes(&mut reduced, &uses, &mut report)?;
1208    copy_linecode_locations(&mut locations, &mut report)?;
1209    reduce_terminal_maps(&mut reduced, &mut report, &mut locations)?;
1210    reduce_buses(&mut reduced, &mut report)?;
1211    locations.apply(&mut reduced);
1212    reduced
1213        .validate_component_locations()
1214        .map_err(|error| fail(error.to_string()))?;
1215    update_terminal_conventions(&mut reduced, &report.buses);
1216    crate::transform_audit::store(
1217        &mut reduced,
1218        "powerio_neutral_kron",
1219        json!({
1220            "method": "explicit_neutral_schur_complement",
1221            "buses": &report.buses,
1222            "recoveries": &report.recoveries,
1223            "actions": &report.actions,
1224        }),
1225    )
1226    .map_err(fail)?;
1227    Ok(NeutralKronReduction {
1228        network: reduced,
1229        report,
1230    })
1231}
1232
1233fn copy_linecode_locations(
1234    locations: &mut ComponentLocationEdits,
1235    report: &mut NeutralKronReport,
1236) -> Result<()> {
1237    for recovery in &report.recoveries {
1238        if recovery.linecode == recovery.source_linecode {
1239            continue;
1240        }
1241        let mut record = action(
1242            format!("linecode/{}", recovery.source_linecode),
1243            "copied_linecode_location",
1244        );
1245        if locations
1246            .copy_linecode(
1247                &recovery.source_linecode,
1248                &recovery.linecode,
1249                &mut record.details,
1250            )
1251            .map_err(fail)?
1252        {
1253            record.details.insert(
1254                "target".to_owned(),
1255                json!(format!("linecode/{}", recovery.linecode)),
1256            );
1257            report.actions.push(record);
1258        }
1259    }
1260    Ok(())
1261}