1use 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#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
24#[non_exhaustive]
25pub struct NeutralKronOptions {
26 pub neutral_terminals: BTreeMap<String, String>,
29 pub allow_forced_ideal_ground: bool,
33}
34
35impl NeutralKronOptions {
36 #[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 #[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#[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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
66#[non_exhaustive]
67pub struct NeutralKronBus {
68 pub bus: String,
69 pub neutral_terminal: String,
70 pub source_position: usize,
72 pub grounding: NeutralKronGrounding,
73}
74
75#[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#[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#[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#[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
686fn 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
1143pub 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}