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