1use 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#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
22#[non_exhaustive]
23pub struct NeutralKronOptions {
24 pub neutral_terminals: BTreeMap<String, String>,
27 pub allow_forced_ideal_ground: bool,
31}
32
33impl NeutralKronOptions {
34 #[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 #[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#[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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
64#[non_exhaustive]
65pub struct NeutralKronBus {
66 pub bus: String,
67 pub neutral_terminal: String,
68 pub source_position: usize,
70 pub grounding: NeutralKronGrounding,
71}
72
73#[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#[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#[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#[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
947pub 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}