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