1use std::collections::{BTreeMap, BTreeSet};
11use std::f64::consts::PI;
12
13use num_complex::Complex64;
14use serde::{Deserialize, Serialize};
15
16use powerio::{
17 BalancedNetwork, Branch, BranchCharging, Bus, BusId, BusType, Extras as BalancedExtras,
18 Generator, Load, Network, Shunt, SourceFormat,
19};
20use powerio_dist::{DistBus, DistLineCode, DistLoadVoltageModel, Mat, MulticonductorNetwork};
21
22use crate::diagnostics::{DiagnosticSeverity, DiagnosticStage, StructuredDiagnostic};
23use crate::model::ModelKind;
24use crate::validation::ValidationStatus;
25
26#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
28pub struct LoweringRecord {
29 pub pass: String,
31 pub input_kind: ModelKind,
32 pub output_kind: ModelKind,
33 #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
34 pub options: serde_json::Map<String, serde_json::Value>,
35 #[serde(default, skip_serializing_if = "Vec::is_empty")]
37 pub assumptions: Vec<String>,
38 #[serde(default, skip_serializing_if = "Vec::is_empty")]
40 pub approximations: Vec<String>,
41 #[serde(default, skip_serializing_if = "Vec::is_empty")]
43 pub dropped_fields: Vec<String>,
44 #[serde(default, skip_serializing_if = "Vec::is_empty")]
45 pub diagnostics: Vec<StructuredDiagnostic>,
46 pub validation_status: ValidationStatus,
47}
48
49impl LoweringRecord {
50 pub fn new(pass: impl Into<String>, input_kind: ModelKind, output_kind: ModelKind) -> Self {
51 Self {
52 pass: pass.into(),
53 input_kind,
54 output_kind,
55 options: serde_json::Map::new(),
56 assumptions: Vec::new(),
57 approximations: Vec::new(),
58 dropped_fields: Vec::new(),
59 diagnostics: Vec::new(),
60 validation_status: ValidationStatus::Ok,
61 }
62 }
63}
64
65#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(rename_all = "snake_case")]
68pub enum SequenceTransformConvention {
69 FortescuePowerInvariant,
70}
71
72impl std::fmt::Display for SequenceTransformConvention {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 match self {
75 Self::FortescuePowerInvariant => f.write_str("FortescuePowerInvariant"),
76 }
77 }
78}
79
80const DEFAULT_LOWERING_BASE_MVA: f64 = 100.0;
81const SQRT_3: f64 = 1.732_050_807_568_877_2;
82const COUPLING_TOLERANCE: f64 = 1.0e-9;
83
84fn default_lowering_base_mva() -> f64 {
85 DEFAULT_LOWERING_BASE_MVA
86}
87
88#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
90pub struct MulticonductorToBalancedOptions {
91 pub convention: SequenceTransformConvention,
92 #[serde(default = "default_lowering_base_mva")]
94 pub base_mva: f64,
95}
96
97impl Default for MulticonductorToBalancedOptions {
98 fn default() -> Self {
99 Self {
100 convention: SequenceTransformConvention::FortescuePowerInvariant,
101 base_mva: DEFAULT_LOWERING_BASE_MVA,
102 }
103 }
104}
105
106#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
108pub struct MulticonductorToBalancedReadiness {
109 pub convention: SequenceTransformConvention,
110 pub base_mva: f64,
111 pub status: ValidationStatus,
112 #[serde(default, skip_serializing_if = "Vec::is_empty")]
113 pub assumptions: Vec<String>,
114 #[serde(default, skip_serializing_if = "Vec::is_empty")]
115 pub approximations: Vec<String>,
116 #[serde(default, skip_serializing_if = "Vec::is_empty")]
117 pub diagnostics: Vec<StructuredDiagnostic>,
118}
119
120impl MulticonductorToBalancedReadiness {
121 #[must_use]
122 pub fn is_ready(&self) -> bool {
123 self.status <= ValidationStatus::Info
124 }
125}
126
127#[derive(Clone, Debug, Serialize, Deserialize)]
129pub struct MulticonductorToBalancedLowering {
130 pub network: BalancedNetwork,
131 pub record: LoweringRecord,
132}
133
134#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
136pub struct MulticonductorToBalancedError {
137 pub options: MulticonductorToBalancedOptions,
138 pub status: ValidationStatus,
139 #[serde(default, skip_serializing_if = "Vec::is_empty")]
140 pub diagnostics: Vec<StructuredDiagnostic>,
141}
142
143impl MulticonductorToBalancedError {
144 pub fn new(
145 options: MulticonductorToBalancedOptions,
146 diagnostics: Vec<StructuredDiagnostic>,
147 ) -> Self {
148 Self {
149 options,
150 status: status_from_diagnostics(&diagnostics),
151 diagnostics,
152 }
153 }
154}
155
156impl std::fmt::Display for MulticonductorToBalancedError {
157 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158 match self.diagnostics.first() {
159 Some(diagnostic) => write!(f, "{}", diagnostic.message),
160 None => f.write_str("multiconductor to balanced lowering failed"),
161 }
162 }
163}
164
165impl std::error::Error for MulticonductorToBalancedError {}
166
167#[must_use]
173pub fn check_multiconductor_to_balanced_lowering(
174 net: &MulticonductorNetwork,
175 options: MulticonductorToBalancedOptions,
176) -> MulticonductorToBalancedReadiness {
177 let mut report = MulticonductorToBalancedReadiness {
178 convention: options.convention,
179 base_mva: options.base_mva,
180 status: ValidationStatus::Ok,
181 assumptions: vec![format!(
182 "sequence transform convention: {}",
183 options.convention
184 )],
185 approximations: Vec::new(),
186 diagnostics: Vec::new(),
187 };
188
189 check_options(options, &mut report);
190 check_bus_conductor_sets(net, &mut report);
191 check_phase_reference(net, &mut report);
192 check_line_terminal_maps(net, &mut report);
193 check_linecodes(net, &mut report);
194 check_switches(net, &mut report);
195 check_transformers(net, &mut report);
196 check_untyped_objects(net, &mut report);
197
198 report.status = status_from_diagnostics(&report.diagnostics);
199 report
200}
201
202pub fn lower_multiconductor_to_balanced(
208 net: &MulticonductorNetwork,
209 options: MulticonductorToBalancedOptions,
210) -> Result<MulticonductorToBalancedLowering, MulticonductorToBalancedError> {
211 let readiness = check_multiconductor_to_balanced_lowering(net, options);
212 if !readiness.is_ready() {
213 return Err(MulticonductorToBalancedError::new(
214 options,
215 readiness.diagnostics,
216 ));
217 }
218
219 let mut state = LoweringState::new(net, options, readiness);
220 state.lower()
221}
222
223struct LoweringState<'a> {
224 net: &'a MulticonductorNetwork,
225 options: MulticonductorToBalancedOptions,
226 neutral_terminals: BTreeSet<String>,
227 bus_ids: BTreeMap<String, BusId>,
228 record: LoweringRecord,
229}
230
231impl<'a> LoweringState<'a> {
232 fn new(
233 net: &'a MulticonductorNetwork,
234 options: MulticonductorToBalancedOptions,
235 readiness: MulticonductorToBalancedReadiness,
236 ) -> Self {
237 let mut record = LoweringRecord::new(
238 "multiconductor-to-balanced",
239 ModelKind::Multiconductor,
240 ModelKind::Balanced,
241 );
242 record.options = options_map(options);
243 record.assumptions = readiness.assumptions;
244 record.approximations = readiness.approximations;
245 record.diagnostics = readiness.diagnostics;
246 record
247 .assumptions
248 .push(format!("balanced power base: {} MVA", options.base_mva));
249 record
250 .assumptions
251 .push("balanced bus ids are synthesized from multiconductor bus order".to_owned());
252 record.approximations.push(
253 "wire-coordinate branch and shunt matrices are projected to positive sequence"
254 .to_owned(),
255 );
256 record.approximations.push(
257 "phase injection records are aggregated into scalar balanced injections".to_owned(),
258 );
259 record.approximations.push(
260 "units are converted from W/var/V/ohm/siemens/radians to MW/MVAr/per-unit/degrees"
261 .to_owned(),
262 );
263 if net.switches.iter().any(|sw| sw.open) {
264 record
265 .dropped_fields
266 .push("open switches dropped from balanced model".to_owned());
267 }
268
269 let bus_ids = net
270 .buses
271 .iter()
272 .enumerate()
273 .map(|(idx, bus)| (bus.id.to_ascii_lowercase(), BusId(idx + 1)))
274 .collect();
275
276 Self {
277 net,
278 options,
279 neutral_terminals: global_neutral_terminals(net),
280 bus_ids,
281 record,
282 }
283 }
284
285 #[allow(clippy::too_many_lines)]
286 fn lower(&mut self) -> Result<MulticonductorToBalancedLowering, MulticonductorToBalancedError> {
287 let Some(base) = self.voltage_base()? else {
288 return Err(MulticonductorToBalancedError::new(
289 self.options,
290 self.record.diagnostics.clone(),
291 ));
292 };
293
294 let buses = self.lower_buses(base);
295 let branches = self.lower_lines(base)?;
296 let loads = self.lower_loads();
297 let shunts = self.lower_shunts(base)?;
298 let generators = self.lower_generators(&buses);
299 self.err_if_errors()?;
300
301 let mut network = Network::new(
302 self.net
303 .name
304 .clone()
305 .unwrap_or_else(|| "lowered-multiconductor".to_owned()),
306 self.options.base_mva,
307 );
308 network.base_frequency = self.net.base_frequency;
309 network.buses = buses;
310 network.loads = loads;
311 network.shunts = shunts;
312 network.branches = branches;
313 network.generators = generators;
314 network.source_format = SourceFormat::InMemory;
315
316 if let Err(err) = network.validate() {
317 self.record.diagnostics.push(StructuredDiagnostic::new(
318 "LOWER.MULTI_TO_BALANCED.INVALID_BALANCED_OUTPUT",
319 DiagnosticSeverity::Error,
320 DiagnosticStage::Lower,
321 format!("lowered balanced network failed structural validation: {err}"),
322 ));
323 return Err(MulticonductorToBalancedError::new(
324 self.options,
325 self.record.diagnostics.clone(),
326 ));
327 }
328 for finding in network.validate_values() {
329 self.record.diagnostics.push(
330 StructuredDiagnostic::new(
331 "LOWER.MULTI_TO_BALANCED.BALANCED_VALUE_DOMAIN",
332 DiagnosticSeverity::Warning,
333 DiagnosticStage::Lower,
334 format!(
335 "{} field `{}` is outside its value domain after lowering",
336 finding.element, finding.field
337 ),
338 )
339 .with_suggested_action(
340 "Inspect the multiconductor source values before using the lowered model.",
341 ),
342 );
343 }
344
345 self.record.validation_status = status_from_diagnostics(&self.record.diagnostics);
346 Ok(MulticonductorToBalancedLowering {
347 network,
348 record: self.record.clone(),
349 })
350 }
351
352 fn voltage_base(&mut self) -> Result<Option<VoltageBase>, MulticonductorToBalancedError> {
353 for (idx, source) in self.net.sources.iter().enumerate() {
354 let Some(bus) = self.net.bus(&source.bus) else {
355 self.record.diagnostics.push(
356 StructuredDiagnostic::new(
357 "LOWER.MULTI_TO_BALANCED.UNKNOWN_SOURCE_BUS",
358 DiagnosticSeverity::Error,
359 DiagnosticStage::Lower,
360 format!(
361 "voltage source {} references unknown bus {}",
362 source.name, source.bus
363 ),
364 )
365 .with_element_path(format!("/model/multiconductor_network/sources/{idx}/bus")),
366 );
367 continue;
368 };
369 let positions =
370 active_positions(&source.terminal_map, Some(bus), &self.neutral_terminals);
371 if positions.len() != 3 {
372 continue;
373 }
374 let Some(v1) = positive_sequence_voltage(source, &positions) else {
375 self.record.diagnostics.push(
376 StructuredDiagnostic::new(
377 "LOWER.MULTI_TO_BALANCED.INVALID_PHASE_REFERENCE",
378 DiagnosticSeverity::Error,
379 DiagnosticStage::Lower,
380 format!(
381 "voltage source {} does not carry finite three phase voltage magnitudes and angles",
382 source.name
383 ),
384 )
385 .with_element_path(format!("/model/multiconductor_network/sources/{idx}")),
386 );
387 continue;
388 };
389 let line_to_line_volts = v1.norm();
390 if !line_to_line_volts.is_finite() || line_to_line_volts <= 0.0 {
391 self.record.diagnostics.push(
392 StructuredDiagnostic::new(
393 "LOWER.MULTI_TO_BALANCED.INVALID_PHASE_REFERENCE",
394 DiagnosticSeverity::Error,
395 DiagnosticStage::Lower,
396 format!(
397 "voltage source {} produced a non-positive positive-sequence voltage base",
398 source.name
399 ),
400 )
401 .with_element_path(format!("/model/multiconductor_network/sources/{idx}")),
402 );
403 continue;
404 }
405 self.record.assumptions.push(format!(
406 "voltage base synthesized from source {} positive-sequence voltage: {} kV line-to-line",
407 source.name,
408 line_to_line_volts / 1000.0
409 ));
410 return Ok(Some(VoltageBase { line_to_line_volts }));
411 }
412
413 if self
414 .record
415 .diagnostics
416 .iter()
417 .any(|d| d.severity >= DiagnosticSeverity::Error)
418 {
419 return Err(MulticonductorToBalancedError::new(
420 self.options,
421 self.record.diagnostics.clone(),
422 ));
423 }
424 self.record.diagnostics.push(StructuredDiagnostic::new(
425 "LOWER.MULTI_TO_BALANCED.MISSING_PHASE_REFERENCE",
426 DiagnosticSeverity::Error,
427 DiagnosticStage::Lower,
428 "multiconductor to balanced lowering requires a finite three phase voltage source reference",
429 ));
430 Ok(None)
431 }
432
433 fn lower_buses(&mut self, base: VoltageBase) -> Vec<Bus> {
434 self.net
435 .buses
436 .iter()
437 .enumerate()
438 .map(|(idx, bus)| {
439 let source = self
440 .net
441 .sources
442 .iter()
443 .find(|source| source.bus.eq_ignore_ascii_case(&bus.id));
444 let (vm, va) = source
445 .and_then(|source| {
446 let positions = active_positions(
447 &source.terminal_map,
448 Some(bus),
449 &self.neutral_terminals,
450 );
451 positive_sequence_voltage(source, &positions)
452 })
453 .map_or((1.0, 0.0), |v| {
454 (
455 v.norm() / base.line_to_line_volts,
456 radians_to_degrees(v.arg()),
457 )
458 });
459 if source.is_none() {
460 self.record.dropped_fields.push(format!(
461 "bus {} voltage magnitude and angle defaulted to 1.0 p.u. and 0 degrees",
462 bus.id
463 ));
464 }
465 let (vmin, vmax) = match (bus.v_min, bus.v_max) {
466 (Some(vmin), Some(vmax)) if vmin.is_finite() && vmax.is_finite() => (
467 vmin / base.line_to_line_volts,
468 vmax / base.line_to_line_volts,
469 ),
470 _ => {
471 self.record.dropped_fields.push(format!(
472 "bus {} voltage bounds defaulted to 0.9/1.1 p.u.",
473 bus.id
474 ));
475 (0.9, 1.1)
476 }
477 };
478 self.record_bus_bound_drops(bus);
479 let mut balanced = Bus::new(
480 BusId(idx + 1),
481 self.bus_kind(&bus.id),
482 base.line_to_line_volts / 1000.0,
483 );
484 balanced.vm = vm;
485 balanced.va = va;
486 balanced.vmax = vmax;
487 balanced.vmin = vmin;
488 balanced.name = Some(bus.id.clone());
489 balanced.extras = source_extra("multiconductor_bus_id", &bus.id);
490 balanced
491 })
492 .collect()
493 }
494
495 fn record_bus_bound_drops(&mut self, bus: &DistBus) {
496 if bus.vpn_min.is_some()
497 || bus.vpn_max.is_some()
498 || bus.vpp_min.is_some()
499 || bus.vpp_max.is_some()
500 || bus.vsym_min.is_some()
501 || bus.vsym_max.is_some()
502 {
503 self.record.dropped_fields.push(format!(
504 "bus {} conductor voltage bound families dropped",
505 bus.id
506 ));
507 }
508 }
509
510 fn bus_kind(&self, bus_id: &str) -> BusType {
511 if self
512 .net
513 .sources
514 .iter()
515 .any(|source| source.bus.eq_ignore_ascii_case(bus_id))
516 {
517 BusType::Ref
518 } else if self
519 .net
520 .generators
521 .iter()
522 .any(|generator| generator.bus.eq_ignore_ascii_case(bus_id))
523 {
524 BusType::Pv
525 } else {
526 BusType::Pq
527 }
528 }
529
530 #[allow(clippy::too_many_lines)]
531 fn lower_lines(
532 &mut self,
533 base: VoltageBase,
534 ) -> Result<Vec<Branch>, MulticonductorToBalancedError> {
535 let mut branches = Vec::with_capacity(self.net.lines.len());
536 for (idx, line) in self.net.lines.iter().enumerate() {
537 let Some(code) = self.net.linecode(&line.linecode) else {
538 self.record.diagnostics.push(
539 StructuredDiagnostic::new(
540 "LOWER.MULTI_TO_BALANCED.UNKNOWN_LINECODE",
541 DiagnosticSeverity::Error,
542 DiagnosticStage::Lower,
543 format!(
544 "line {} references unknown linecode `{}`",
545 line.name, line.linecode
546 ),
547 )
548 .with_element_path(format!(
549 "/model/multiconductor_network/lines/{idx}/linecode"
550 )),
551 );
552 continue;
553 };
554 if !same_active_phase_order(
555 self.net.bus(&line.bus_from),
556 &line.terminal_map_from,
557 self.net.bus(&line.bus_to),
558 &line.terminal_map_to,
559 &self.neutral_terminals,
560 ) {
561 self.record.diagnostics.push(
562 StructuredDiagnostic::new(
563 "LOWER.MULTI_TO_BALANCED.PHASE_MAP_MISMATCH",
564 DiagnosticSeverity::Error,
565 DiagnosticStage::Lower,
566 format!(
567 "line {} connects different active terminal orders and cannot be lowered transparently",
568 line.name
569 ),
570 )
571 .with_element_path(format!("/model/multiconductor_network/lines/{idx}")),
572 );
573 continue;
574 }
575 let Some(from) = self.bus_id(&line.bus_from) else {
576 self.unknown_bus_diag("line", &line.name, &line.bus_from, idx, "bus_from");
577 continue;
578 };
579 let Some(to) = self.bus_id(&line.bus_to) else {
580 self.unknown_bus_diag("line", &line.name, &line.bus_to, idx, "bus_to");
581 continue;
582 };
583 let from_bus = self.net.bus(&line.bus_from);
584 let active =
585 active_positions(&line.terminal_map_from, from_bus, &self.neutral_terminals);
586 let neutral =
587 neutral_positions(&line.terminal_map_from, from_bus, &self.neutral_terminals);
588 let z_ohm =
589 self.line_positive_sequence_impedance(idx, code, &active, &neutral, line.length)?;
590 let y_from = self.line_positive_sequence_admittance(
591 idx,
592 code,
593 &active,
594 &neutral,
595 line.length,
596 ShuntSide::From,
597 )?;
598 let y_to = self.line_positive_sequence_admittance(
599 idx,
600 code,
601 &active,
602 &neutral,
603 line.length,
604 ShuntSide::To,
605 )?;
606 let z_base = base.z_base_ohm(self.options.base_mva);
607 let y_scale = z_base;
608 let charging = BranchCharging::new(
609 y_from.re * y_scale,
610 y_from.im * y_scale,
611 y_to.re * y_scale,
612 y_to.im * y_scale,
613 );
614 let rate = line_rate_mva(code, &active, base.line_to_line_volts).unwrap_or_else(|| {
615 self.record.dropped_fields.push(format!(
616 "line {} thermal rating defaulted to 0 MVA",
617 line.name
618 ));
619 0.0
620 });
621 let mut branch = Branch::new(from, to, z_ohm.re / z_base, z_ohm.im / z_base);
622 branch.b = charging.total_b();
623 branch.charging = Some(charging);
624 branch.rate_a = rate;
625 branch.rate_b = rate;
626 branch.rate_c = rate;
627 branch.extras = source_extra("multiconductor_line", &line.name);
628 branches.push(branch);
629 }
630 self.err_if_errors()?;
631 Ok(branches)
632 }
633
634 fn line_positive_sequence_impedance(
635 &mut self,
636 line_idx: usize,
637 code: &DistLineCode,
638 active: &[usize],
639 neutral: &[usize],
640 length: f64,
641 ) -> Result<Complex64, MulticonductorToBalancedError> {
642 let matrix = complex_matrix(&code.r_series, &code.x_series, length);
643 let reduced = kron_or_select(&matrix, active, neutral).map_err(|message| {
644 self.matrix_error(line_idx, &code.name, "series impedance", &message)
645 })?;
646 Ok(self.positive_sequence_from_matrix(line_idx, &code.name, "series impedance", &reduced))
647 }
648
649 fn line_positive_sequence_admittance(
650 &mut self,
651 line_idx: usize,
652 code: &DistLineCode,
653 active: &[usize],
654 neutral: &[usize],
655 length: f64,
656 side: ShuntSide,
657 ) -> Result<Complex64, MulticonductorToBalancedError> {
658 let (g, b, label) = match side {
659 ShuntSide::From => (&code.g_from, &code.b_from, "from shunt admittance"),
660 ShuntSide::To => (&code.g_to, &code.b_to, "to shunt admittance"),
661 };
662 let matrix = complex_matrix(g, b, length);
663 let reduced = kron_or_select(&matrix, active, neutral)
664 .map_err(|message| self.matrix_error(line_idx, &code.name, label, &message))?;
665 Ok(self.positive_sequence_from_matrix(line_idx, &code.name, label, &reduced))
666 }
667
668 fn positive_sequence_from_matrix(
669 &mut self,
670 line_idx: usize,
671 code_name: &str,
672 label: &str,
673 matrix: &[Vec<Complex64>],
674 ) -> Complex64 {
675 let seq = sequence_matrix(matrix);
676 let coupling = sequence_coupling_norm(&seq);
677 if coupling > COUPLING_TOLERANCE {
678 self.record.approximations.push(format!(
679 "linecode {code_name} {label} has sequence coupling norm {coupling}; positive-sequence diagonal retained"
680 ));
681 let mut diagnostic = StructuredDiagnostic::new(
682 "LOWER.MULTI_TO_BALANCED.SEQUENCE_COUPLING_DROPPED",
683 DiagnosticSeverity::Info,
684 DiagnosticStage::Lower,
685 format!(
686 "linecode {code_name} {label} has nonzero sequence coupling; the balanced model keeps the positive-sequence diagonal"
687 ),
688 )
689 .with_element_path(format!("/model/multiconductor_network/lines/{line_idx}/linecode"));
690 diagnostic.details.insert(
691 "sequence_coupling_norm".to_owned(),
692 serde_json::json!(coupling),
693 );
694 self.record.diagnostics.push(diagnostic);
695 }
696 seq[1][1]
697 }
698
699 fn matrix_error(
700 &self,
701 line_idx: usize,
702 code_name: &str,
703 label: &str,
704 message: &str,
705 ) -> MulticonductorToBalancedError {
706 let mut diagnostics = self.record.diagnostics.clone();
707 diagnostics.push(
708 StructuredDiagnostic::new(
709 "LOWER.MULTI_TO_BALANCED.INVALID_LINECODE_MATRIX",
710 DiagnosticSeverity::Error,
711 DiagnosticStage::Lower,
712 format!("linecode {code_name} {label} cannot be lowered: {message}"),
713 )
714 .with_element_path(format!(
715 "/model/multiconductor_network/lines/{line_idx}/linecode"
716 )),
717 );
718 MulticonductorToBalancedError::new(self.options, diagnostics)
719 }
720
721 fn lower_loads(&mut self) -> Vec<Load> {
722 self.net
723 .loads
724 .iter()
725 .enumerate()
726 .filter_map(|(idx, load)| {
727 let Some(bus) = self.bus_id(&load.bus) else {
728 self.unknown_bus_diag("load", &load.name, &load.bus, idx, "bus");
729 return None;
730 };
731 if !matches!(
732 load.voltage_model,
733 DistLoadVoltageModel::ConstantPower { .. }
734 ) {
735 self.record.dropped_fields.push(format!(
736 "load {} voltage model dropped; balanced load is constant power",
737 load.name
738 ));
739 self.record.diagnostics.push(
740 StructuredDiagnostic::new(
741 "LOWER.MULTI_TO_BALANCED.DROPPED_LOAD_VOLTAGE_MODEL",
742 DiagnosticSeverity::Warning,
743 DiagnosticStage::Lower,
744 format!(
745 "load {} voltage model cannot be represented by the conservative balanced lowering",
746 load.name
747 ),
748 )
749 .with_element_path(format!("/model/multiconductor_network/loads/{idx}/voltage_model")),
750 );
751 }
752 let mut balanced = Load::new(
753 bus,
754 si_power_to_mega(load.p_nom.iter().sum()),
755 si_power_to_mega(load.q_nom.iter().sum()),
756 );
757 balanced.extras = source_extra("multiconductor_load", &load.name);
758 Some(balanced)
759 })
760 .collect()
761 }
762
763 fn lower_shunts(
764 &mut self,
765 base: VoltageBase,
766 ) -> Result<Vec<Shunt>, MulticonductorToBalancedError> {
767 let mut shunts = Vec::with_capacity(self.net.shunts.len());
768 for (idx, shunt) in self.net.shunts.iter().enumerate() {
769 let Some(bus) = self.bus_id(&shunt.bus) else {
770 self.unknown_bus_diag("shunt", &shunt.name, &shunt.bus, idx, "bus");
771 continue;
772 };
773 let dist_bus = self.net.bus(&shunt.bus);
774 let active = active_positions(&shunt.terminal_map, dist_bus, &self.neutral_terminals);
775 let neutral = neutral_positions(&shunt.terminal_map, dist_bus, &self.neutral_terminals);
776 let y = if active.len() == 3 {
777 let matrix = complex_matrix(&shunt.g, &shunt.b, 1.0);
778 let reduced = kron_or_select(&matrix, &active, &neutral)
779 .map_err(|message| self.shunt_matrix_error(idx, &shunt.name, &message))?;
780 let seq = sequence_matrix(&reduced);
781 seq[1][1]
782 } else {
783 self.record.approximations.push(format!(
784 "shunt {} has {} active terminal(s); diagonal admittance projected with missing phases as zero",
785 shunt.name,
786 active.len()
787 ));
788 partial_phase_admittance(&shunt.g, &shunt.b, &active)
789 };
790 let scale = base.line_to_line_volts * base.line_to_line_volts / 1_000_000.0;
791 let mut balanced = Shunt::new(bus, y.re * scale, y.im * scale);
792 balanced.extras = source_extra("multiconductor_shunt", &shunt.name);
793 shunts.push(balanced);
794 }
795 self.err_if_errors()?;
796 Ok(shunts)
797 }
798
799 fn shunt_matrix_error(
800 &self,
801 shunt_idx: usize,
802 name: &str,
803 message: &str,
804 ) -> MulticonductorToBalancedError {
805 let mut diagnostics = self.record.diagnostics.clone();
806 diagnostics.push(
807 StructuredDiagnostic::new(
808 "LOWER.MULTI_TO_BALANCED.INVALID_SHUNT_MATRIX",
809 DiagnosticSeverity::Error,
810 DiagnosticStage::Lower,
811 format!("shunt {name} cannot be lowered: {message}"),
812 )
813 .with_element_path(format!("/model/multiconductor_network/shunts/{shunt_idx}")),
814 );
815 MulticonductorToBalancedError::new(self.options, diagnostics)
816 }
817
818 fn lower_generators(&mut self, buses: &[Bus]) -> Vec<Generator> {
819 self.net
820 .generators
821 .iter()
822 .enumerate()
823 .filter_map(|(idx, generator)| {
824 let Some(bus) = self.bus_id(&generator.bus) else {
825 self.unknown_bus_diag("generator", &generator.name, &generator.bus, idx, "bus");
826 return None;
827 };
828 let pg = si_power_to_mega(generator.p_nom.iter().sum());
829 let qg = si_power_to_mega(generator.q_nom.iter().sum());
830 let pmin = option_vec_sum_mw(generator.p_min.as_deref()).unwrap_or_else(|| {
831 self.record.dropped_fields.push(format!(
832 "generator {} p_min defaulted to pg",
833 generator.name
834 ));
835 pg
836 });
837 let pmax = option_vec_sum_mw(generator.p_max.as_deref()).unwrap_or_else(|| {
838 self.record.dropped_fields.push(format!(
839 "generator {} p_max defaulted to pg",
840 generator.name
841 ));
842 pg
843 });
844 let qmin = option_vec_sum_mw(generator.q_min.as_deref()).unwrap_or_else(|| {
845 self.record.dropped_fields.push(format!(
846 "generator {} q_min defaulted to qg",
847 generator.name
848 ));
849 qg
850 });
851 let qmax = option_vec_sum_mw(generator.q_max.as_deref()).unwrap_or_else(|| {
852 self.record.dropped_fields.push(format!(
853 "generator {} q_max defaulted to qg",
854 generator.name
855 ));
856 qg
857 });
858 if generator.cost.is_some() {
859 self.record.dropped_fields.push(format!(
860 "generator {} scalar distribution cost dropped",
861 generator.name
862 ));
863 }
864 let vg = buses
865 .iter()
866 .find(|balanced_bus| balanced_bus.id == bus)
867 .map_or(1.0, |balanced_bus| balanced_bus.vm);
868 let mut balanced = Generator::new(bus);
869 balanced.pg = pg;
870 balanced.qg = qg;
871 balanced.pmax = pmax;
872 balanced.pmin = pmin;
873 balanced.qmax = qmax;
874 balanced.qmin = qmin;
875 balanced.vg = vg;
876 balanced.mbase = self.options.base_mva;
877 Some(balanced)
878 })
879 .collect()
880 }
881
882 fn bus_id(&self, bus: &str) -> Option<BusId> {
883 self.bus_ids.get(&bus.to_ascii_lowercase()).copied()
884 }
885
886 fn unknown_bus_diag(&mut self, element: &str, name: &str, bus: &str, idx: usize, field: &str) {
887 self.record.diagnostics.push(
888 StructuredDiagnostic::new(
889 "LOWER.MULTI_TO_BALANCED.UNKNOWN_BUS",
890 DiagnosticSeverity::Error,
891 DiagnosticStage::Lower,
892 format!("{element} {name} references unknown bus {bus}"),
893 )
894 .with_element_path(format!(
895 "/model/multiconductor_network/{element}s/{idx}/{field}"
896 )),
897 );
898 }
899
900 fn err_if_errors(&self) -> Result<(), MulticonductorToBalancedError> {
901 if self
902 .record
903 .diagnostics
904 .iter()
905 .any(|d| d.severity >= DiagnosticSeverity::Error)
906 {
907 Err(MulticonductorToBalancedError::new(
908 self.options,
909 self.record.diagnostics.clone(),
910 ))
911 } else {
912 Ok(())
913 }
914 }
915}
916
917#[derive(Clone, Copy)]
918struct VoltageBase {
919 line_to_line_volts: f64,
920}
921
922impl VoltageBase {
923 fn z_base_ohm(self, base_mva: f64) -> f64 {
924 self.line_to_line_volts * self.line_to_line_volts / (base_mva * 1_000_000.0)
925 }
926}
927
928#[derive(Clone, Copy)]
929enum ShuntSide {
930 From,
931 To,
932}
933
934fn options_map(
935 options: MulticonductorToBalancedOptions,
936) -> serde_json::Map<String, serde_json::Value> {
937 serde_json::to_value(options)
938 .ok()
939 .and_then(|value| value.as_object().cloned())
940 .unwrap_or_default()
941}
942
943fn source_extra(key: &str, value: &str) -> BalancedExtras {
944 let mut extras = BalancedExtras::new();
945 extras.insert(key.to_owned(), serde_json::Value::String(value.to_owned()));
946 extras
947}
948
949fn active_positions(
950 terminals: &[String],
951 bus: Option<&DistBus>,
952 neutral_terminals: &BTreeSet<String>,
953) -> Vec<usize> {
954 terminals
955 .iter()
956 .enumerate()
957 .filter_map(|(idx, terminal)| {
958 (!is_neutral_terminal(terminal, bus, neutral_terminals)).then_some(idx)
959 })
960 .collect()
961}
962
963fn neutral_positions(
964 terminals: &[String],
965 bus: Option<&DistBus>,
966 neutral_terminals: &BTreeSet<String>,
967) -> Vec<usize> {
968 terminals
969 .iter()
970 .enumerate()
971 .filter_map(|(idx, terminal)| {
972 is_neutral_terminal(terminal, bus, neutral_terminals).then_some(idx)
973 })
974 .collect()
975}
976
977fn same_active_phase_order(
978 from_bus: Option<&DistBus>,
979 from_terminals: &[String],
980 to_bus: Option<&DistBus>,
981 to_terminals: &[String],
982 neutral_terminals: &BTreeSet<String>,
983) -> bool {
984 let from: Vec<_> = from_terminals
985 .iter()
986 .filter(|terminal| !is_neutral_terminal(terminal, from_bus, neutral_terminals))
987 .map(|terminal| terminal.to_ascii_lowercase())
988 .collect();
989 let to: Vec<_> = to_terminals
990 .iter()
991 .filter(|terminal| !is_neutral_terminal(terminal, to_bus, neutral_terminals))
992 .map(|terminal| terminal.to_ascii_lowercase())
993 .collect();
994 from == to
995}
996
997fn positive_sequence_voltage(
998 source: &powerio_dist::VoltageSource,
999 positions: &[usize],
1000) -> Option<Complex64> {
1001 if positions.len() != 3 {
1002 return None;
1003 }
1004 let mut phase = [Complex64::new(0.0, 0.0); 3];
1005 for (out, &idx) in phase.iter_mut().zip(positions.iter()) {
1006 let magnitude = *source.v_magnitude.get(idx)?;
1007 let angle = *source.v_angle.get(idx)?;
1008 if !magnitude.is_finite() || !angle.is_finite() {
1009 return None;
1010 }
1011 *out = Complex64::from_polar(magnitude, angle);
1012 }
1013 let basis = sequence_basis();
1014 let mut seq = [Complex64::new(0.0, 0.0); 3];
1015 for (sequence_idx, out) in seq.iter_mut().enumerate() {
1016 for phase_idx in 0..3 {
1017 *out += basis[phase_idx][sequence_idx].conj() * phase[phase_idx];
1018 }
1019 }
1020 Some(seq[1])
1021}
1022
1023fn complex_matrix(g_or_r: &Mat, b_or_x: &Mat, scale: f64) -> Vec<Vec<Complex64>> {
1024 g_or_r
1025 .iter()
1026 .zip(b_or_x.iter())
1027 .map(|(g_row, b_row)| {
1028 g_row
1029 .iter()
1030 .zip(b_row.iter())
1031 .map(|(&g, &b)| Complex64::new(g * scale, b * scale))
1032 .collect()
1033 })
1034 .collect()
1035}
1036
1037fn kron_or_select(
1038 matrix: &[Vec<Complex64>],
1039 active: &[usize],
1040 neutral: &[usize],
1041) -> Result<Vec<Vec<Complex64>>, String> {
1042 if active.len() != 3 {
1043 return Err(format!(
1044 "expected three active conductors, got {}",
1045 active.len()
1046 ));
1047 }
1048 validate_indices(matrix, active)?;
1049 validate_indices(matrix, neutral)?;
1050 if neutral.is_empty() {
1051 return Ok(submatrix(matrix, active, active));
1052 }
1053
1054 let m_pp = submatrix(matrix, active, active);
1055 let m_pn = submatrix(matrix, active, neutral);
1056 let m_np = submatrix(matrix, neutral, active);
1057 let m_nn = submatrix(matrix, neutral, neutral);
1058 if matrix_is_near_zero(&m_pn) && matrix_is_near_zero(&m_np) && matrix_is_near_zero(&m_nn) {
1059 return Ok(m_pp);
1060 }
1061 let inv_nn = invert_complex_matrix(&m_nn)?;
1062 let correction = matmul(&matmul(&m_pn, &inv_nn), &m_np);
1063 Ok(matrix_sub(&m_pp, &correction))
1064}
1065
1066fn matrix_is_near_zero(matrix: &[Vec<Complex64>]) -> bool {
1067 matrix
1068 .iter()
1069 .flatten()
1070 .all(|value| value.norm() <= f64::EPSILON)
1071}
1072
1073fn validate_indices(matrix: &[Vec<Complex64>], indices: &[usize]) -> Result<(), String> {
1074 let n = matrix.len();
1075 if matrix.iter().any(|row| row.len() != n) {
1076 return Err("matrix is not square".to_owned());
1077 }
1078 if indices.iter().any(|&idx| idx >= n) {
1079 return Err("terminal map references a conductor outside the matrix".to_owned());
1080 }
1081 Ok(())
1082}
1083
1084fn submatrix(matrix: &[Vec<Complex64>], rows: &[usize], cols: &[usize]) -> Vec<Vec<Complex64>> {
1085 rows.iter()
1086 .map(|&row| cols.iter().map(|&col| matrix[row][col]).collect())
1087 .collect()
1088}
1089
1090#[allow(clippy::needless_range_loop)]
1091fn invert_complex_matrix(matrix: &[Vec<Complex64>]) -> Result<Vec<Vec<Complex64>>, String> {
1092 let n = matrix.len();
1093 if n == 0 || matrix.iter().any(|row| row.len() != n) {
1094 return Err("neutral block is not square".to_owned());
1095 }
1096 let mut aug = vec![vec![Complex64::new(0.0, 0.0); 2 * n]; n];
1097 for i in 0..n {
1098 for j in 0..n {
1099 aug[i][j] = matrix[i][j];
1100 }
1101 aug[i][n + i] = Complex64::new(1.0, 0.0);
1102 }
1103
1104 for col in 0..n {
1105 let pivot = (col..n)
1106 .max_by(|&a, &b| aug[a][col].norm_sqr().total_cmp(&aug[b][col].norm_sqr()))
1107 .ok_or_else(|| "neutral block is singular".to_owned())?;
1108 if aug[pivot][col].norm() <= f64::EPSILON {
1109 return Err("neutral block is singular".to_owned());
1110 }
1111 if pivot != col {
1112 aug.swap(pivot, col);
1113 }
1114 let pivot_value = aug[col][col];
1115 for j in 0..(2 * n) {
1116 aug[col][j] /= pivot_value;
1117 }
1118 for row in 0..n {
1119 if row == col {
1120 continue;
1121 }
1122 let factor = aug[row][col];
1123 if factor.norm() <= f64::EPSILON {
1124 continue;
1125 }
1126 for j in 0..(2 * n) {
1127 let pivot_entry = aug[col][j];
1128 aug[row][j] -= factor * pivot_entry;
1129 }
1130 }
1131 }
1132
1133 Ok(aug
1134 .into_iter()
1135 .map(|row| row.into_iter().skip(n).collect())
1136 .collect())
1137}
1138
1139fn matmul(a: &[Vec<Complex64>], b: &[Vec<Complex64>]) -> Vec<Vec<Complex64>> {
1140 if a.is_empty() || b.is_empty() {
1141 return Vec::new();
1142 }
1143 let rows = a.len();
1144 let cols = b[0].len();
1145 let inner = b.len();
1146 let mut out = vec![vec![Complex64::new(0.0, 0.0); cols]; rows];
1147 for i in 0..rows {
1148 for k in 0..inner {
1149 for j in 0..cols {
1150 out[i][j] += a[i][k] * b[k][j];
1151 }
1152 }
1153 }
1154 out
1155}
1156
1157fn matrix_sub(a: &[Vec<Complex64>], b: &[Vec<Complex64>]) -> Vec<Vec<Complex64>> {
1158 a.iter()
1159 .zip(b.iter())
1160 .map(|(a_row, b_row)| {
1161 a_row
1162 .iter()
1163 .zip(b_row.iter())
1164 .map(|(&a_value, &b_value)| a_value - b_value)
1165 .collect()
1166 })
1167 .collect()
1168}
1169
1170#[allow(clippy::many_single_char_names)]
1171fn sequence_basis() -> [[Complex64; 3]; 3] {
1172 let scale = 1.0 / SQRT_3;
1173 let a = Complex64::from_polar(1.0, 2.0 * PI / 3.0);
1174 let a2 = a * a;
1175 [
1176 [
1177 Complex64::new(scale, 0.0),
1178 Complex64::new(scale, 0.0),
1179 Complex64::new(scale, 0.0),
1180 ],
1181 [Complex64::new(scale, 0.0), a2 * scale, a * scale],
1182 [Complex64::new(scale, 0.0), a * scale, a2 * scale],
1183 ]
1184}
1185
1186fn sequence_matrix(matrix: &[Vec<Complex64>]) -> [[Complex64; 3]; 3] {
1187 let basis = sequence_basis();
1188 let mut seq = [[Complex64::new(0.0, 0.0); 3]; 3];
1189 for p in 0..3 {
1190 for q in 0..3 {
1191 for i in 0..3 {
1192 for j in 0..3 {
1193 seq[p][q] += basis[i][p].conj() * matrix[i][j] * basis[j][q];
1194 }
1195 }
1196 }
1197 }
1198 seq
1199}
1200
1201fn sequence_coupling_norm(seq: &[[Complex64; 3]; 3]) -> f64 {
1202 let mut sum = 0.0;
1203 for (i, row) in seq.iter().enumerate() {
1204 for (j, value) in row.iter().enumerate() {
1205 if i != j {
1206 sum += value.norm_sqr();
1207 }
1208 }
1209 }
1210 sum.sqrt()
1211}
1212
1213fn line_rate_mva(code: &DistLineCode, active: &[usize], line_to_line_volts: f64) -> Option<f64> {
1214 if let Some(s_max) = &code.s_max {
1215 let values: Vec<_> = active
1216 .iter()
1217 .filter_map(|&idx| s_max.get(idx).copied())
1218 .collect();
1219 if !values.is_empty() && values.iter().all(|value| value.is_finite()) {
1220 return Some(values.iter().sum::<f64>() / 1_000_000.0);
1221 }
1222 }
1223 let i_max = code.i_max.as_ref()?;
1224 let amps: Vec<_> = active
1225 .iter()
1226 .filter_map(|&idx| i_max.get(idx).copied())
1227 .filter(|value| value.is_finite() && *value >= 0.0)
1228 .collect();
1229 let amps = amps.into_iter().reduce(f64::min)?;
1230 Some(SQRT_3 * line_to_line_volts * amps / 1_000_000.0)
1231}
1232
1233fn partial_phase_admittance(g: &Mat, b: &Mat, active: &[usize]) -> Complex64 {
1234 let mut total = Complex64::new(0.0, 0.0);
1235 for &idx in active {
1236 let Some(g_row) = g.get(idx) else {
1237 continue;
1238 };
1239 let Some(b_row) = b.get(idx) else {
1240 continue;
1241 };
1242 let Some(&g_value) = g_row.get(idx) else {
1243 continue;
1244 };
1245 let Some(&b_value) = b_row.get(idx) else {
1246 continue;
1247 };
1248 total += Complex64::new(g_value, b_value);
1249 }
1250 total / 3.0
1251}
1252
1253fn si_power_to_mega(value: f64) -> f64 {
1254 value / 1_000_000.0
1255}
1256
1257fn option_vec_sum_mw(values: Option<&[f64]>) -> Option<f64> {
1258 values.map(|v| si_power_to_mega(v.iter().sum()))
1259}
1260
1261fn radians_to_degrees(value: f64) -> f64 {
1262 value * 180.0 / PI
1263}
1264
1265fn status_from_diagnostics(diagnostics: &[StructuredDiagnostic]) -> ValidationStatus {
1266 diagnostics
1267 .iter()
1268 .map(|d| match d.severity {
1269 DiagnosticSeverity::Debug => ValidationStatus::Ok,
1270 DiagnosticSeverity::Info => ValidationStatus::Info,
1271 DiagnosticSeverity::Warning => ValidationStatus::Warning,
1272 DiagnosticSeverity::Error => ValidationStatus::Error,
1273 DiagnosticSeverity::Fatal => ValidationStatus::Fatal,
1274 })
1275 .max()
1276 .unwrap_or(ValidationStatus::Ok)
1277}
1278
1279fn check_options(
1280 options: MulticonductorToBalancedOptions,
1281 report: &mut MulticonductorToBalancedReadiness,
1282) {
1283 if !options.base_mva.is_finite() || options.base_mva <= 0.0 {
1284 report.diagnostics.push(StructuredDiagnostic::new(
1285 "LOWER.MULTI_TO_BALANCED.INVALID_BASE_MVA",
1286 DiagnosticSeverity::Error,
1287 DiagnosticStage::Lower,
1288 format!(
1289 "base_mva must be positive and finite for multiconductor to balanced lowering; got {}",
1290 options.base_mva
1291 ),
1292 ));
1293 }
1294}
1295
1296fn check_bus_conductor_sets(
1297 net: &MulticonductorNetwork,
1298 report: &mut MulticonductorToBalancedReadiness,
1299) {
1300 let neutral_terminals = global_neutral_terminals(net);
1301 let mut saw_neutral = false;
1302 for (i, bus) in net.buses.iter().enumerate() {
1303 let active_count = active_terminal_count(&bus.terminals, Some(bus), &neutral_terminals);
1304 if active_count < bus.terminals.len() {
1305 saw_neutral = true;
1306 }
1307
1308 match active_count {
1309 3 => {}
1310 2 => report.diagnostics.push(
1311 StructuredDiagnostic::new(
1312 "LOWER.MULTI_TO_BALANCED.AMBIGUOUS_TERMINAL_MAP",
1313 DiagnosticSeverity::Error,
1314 DiagnosticStage::Lower,
1315 format!(
1316 "bus {} has two active terminals; no unique positive sequence projection is defined",
1317 bus.id
1318 ),
1319 )
1320 .with_element_path(format!("/model/multiconductor_network/buses/{i}/terminals")),
1321 ),
1322 0 | 1 => report.diagnostics.push(
1323 StructuredDiagnostic::new(
1324 "LOWER.MULTI_TO_BALANCED.UNSUPPORTED_CONDUCTOR_SET",
1325 DiagnosticSeverity::Error,
1326 DiagnosticStage::Lower,
1327 format!(
1328 "bus {} has {active_count} active terminal; multiconductor to balanced lowering starts with three phase input",
1329 bus.id
1330 ),
1331 )
1332 .with_element_path(format!("/model/multiconductor_network/buses/{i}/terminals")),
1333 ),
1334 _ => report.diagnostics.push(
1335 StructuredDiagnostic::new(
1336 "LOWER.MULTI_TO_BALANCED.UNSUPPORTED_CONDUCTOR_SET",
1337 DiagnosticSeverity::Error,
1338 DiagnosticStage::Lower,
1339 format!(
1340 "bus {} has {active_count} active terminals; multiconductor to balanced lowering starts with three phase input",
1341 bus.id
1342 ),
1343 )
1344 .with_element_path(format!("/model/multiconductor_network/buses/{i}/terminals")),
1345 ),
1346 }
1347 }
1348
1349 if saw_neutral {
1350 report
1351 .approximations
1352 .push("Kron reduction of neutral conductor before sequence transform".to_owned());
1353 report.diagnostics.push(StructuredDiagnostic::new(
1354 "LOWER.MULTI_TO_BALANCED.KRON_REDUCTION_REQUIRED",
1355 DiagnosticSeverity::Info,
1356 DiagnosticStage::Lower,
1357 "neutral conductors require Kron reduction before the sequence transform",
1358 ));
1359 }
1360}
1361
1362fn check_line_terminal_maps(
1363 net: &MulticonductorNetwork,
1364 report: &mut MulticonductorToBalancedReadiness,
1365) {
1366 let neutral_terminals = global_neutral_terminals(net);
1367 for (i, line) in net.lines.iter().enumerate() {
1368 for (field, bus_id, terminal_map) in [
1369 (
1370 "terminal_map_from",
1371 line.bus_from.as_str(),
1372 line.terminal_map_from.as_slice(),
1373 ),
1374 (
1375 "terminal_map_to",
1376 line.bus_to.as_str(),
1377 line.terminal_map_to.as_slice(),
1378 ),
1379 ] {
1380 let bus = net.bus(bus_id);
1381 let active_count = active_terminal_count(terminal_map, bus, &neutral_terminals);
1382 if active_count != 3 {
1383 report.diagnostics.push(
1384 StructuredDiagnostic::new(
1385 "LOWER.MULTI_TO_BALANCED.UNSUPPORTED_CONDUCTOR_SET",
1386 DiagnosticSeverity::Error,
1387 DiagnosticStage::Lower,
1388 format!(
1389 "line {} {field} has {active_count} active terminal(s); balanced branch lowering requires three active phase conductors",
1390 line.name
1391 ),
1392 )
1393 .with_element_path(format!("/model/multiconductor_network/lines/{i}/{field}")),
1394 );
1395 }
1396 }
1397 }
1398}
1399
1400fn check_linecodes(net: &MulticonductorNetwork, report: &mut MulticonductorToBalancedReadiness) {
1401 for (i, line) in net.lines.iter().enumerate() {
1402 let Some(code) = net.linecode(&line.linecode) else {
1403 report.diagnostics.push(
1404 StructuredDiagnostic::new(
1405 "LOWER.MULTI_TO_BALANCED.UNKNOWN_LINECODE",
1406 DiagnosticSeverity::Error,
1407 DiagnosticStage::Lower,
1408 format!(
1409 "line {} references unknown linecode `{}`",
1410 line.name, line.linecode
1411 ),
1412 )
1413 .with_element_path(format!("/model/multiconductor_network/lines/{i}/linecode")),
1414 );
1415 continue;
1416 };
1417 if code.n_conductors != line.terminal_map_from.len()
1418 || code.n_conductors != line.terminal_map_to.len()
1419 {
1420 report.diagnostics.push(
1421 StructuredDiagnostic::new(
1422 "LOWER.MULTI_TO_BALANCED.LINECODE_TERMINAL_MISMATCH",
1423 DiagnosticSeverity::Error,
1424 DiagnosticStage::Lower,
1425 format!(
1426 "line {} uses linecode {} with {} conductor(s), but its terminal maps have {} and {} terminal(s)",
1427 line.name,
1428 code.name,
1429 code.n_conductors,
1430 line.terminal_map_from.len(),
1431 line.terminal_map_to.len()
1432 ),
1433 )
1434 .with_element_path(format!("/model/multiconductor_network/lines/{i}/linecode")),
1435 );
1436 }
1437 if !square_matrix_shape(&code.r_series, code.n_conductors)
1438 || !square_matrix_shape(&code.x_series, code.n_conductors)
1439 || !square_matrix_shape(&code.g_from, code.n_conductors)
1440 || !square_matrix_shape(&code.b_from, code.n_conductors)
1441 || !square_matrix_shape(&code.g_to, code.n_conductors)
1442 || !square_matrix_shape(&code.b_to, code.n_conductors)
1443 {
1444 report.diagnostics.push(
1445 StructuredDiagnostic::new(
1446 "LOWER.MULTI_TO_BALANCED.INVALID_LINECODE_MATRIX",
1447 DiagnosticSeverity::Error,
1448 DiagnosticStage::Lower,
1449 format!(
1450 "linecode {} does not carry square {} conductor matrices",
1451 code.name, code.n_conductors
1452 ),
1453 )
1454 .with_element_path(format!(
1455 "/model/multiconductor_network/linecodes/{}",
1456 code.name
1457 )),
1458 );
1459 }
1460 }
1461}
1462
1463fn square_matrix_shape(matrix: &Mat, n: usize) -> bool {
1464 matrix.len() == n && matrix.iter().all(|row| row.len() == n)
1465}
1466
1467fn check_switches(net: &MulticonductorNetwork, report: &mut MulticonductorToBalancedReadiness) {
1468 for (i, sw) in net.switches.iter().enumerate() {
1469 if sw.open {
1470 report.diagnostics.push(
1471 StructuredDiagnostic::new(
1472 "LOWER.MULTI_TO_BALANCED.DROPPED_OPEN_SWITCH",
1473 DiagnosticSeverity::Info,
1474 DiagnosticStage::Lower,
1475 format!(
1476 "open switch {} is dropped by multiconductor to balanced lowering",
1477 sw.name
1478 ),
1479 )
1480 .with_element_path(format!("/model/multiconductor_network/switches/{i}")),
1481 );
1482 } else {
1483 report.diagnostics.push(
1484 StructuredDiagnostic::new(
1485 "LOWER.MULTI_TO_BALANCED.UNSUPPORTED_CLOSED_SWITCH",
1486 DiagnosticSeverity::Error,
1487 DiagnosticStage::Lower,
1488 format!(
1489 "closed switch {} is not lowered into a zero impedance balanced branch",
1490 sw.name
1491 ),
1492 )
1493 .with_element_path(format!("/model/multiconductor_network/switches/{i}")),
1494 );
1495 }
1496 }
1497}
1498
1499fn global_neutral_terminals(net: &MulticonductorNetwork) -> BTreeSet<String> {
1500 net.buses
1501 .iter()
1502 .flat_map(|bus| bus.grounded.iter().cloned())
1503 .collect()
1504}
1505
1506fn active_terminal_count(
1507 terminals: &[String],
1508 bus: Option<&DistBus>,
1509 neutral_terminals: &BTreeSet<String>,
1510) -> usize {
1511 terminals
1512 .iter()
1513 .filter(|terminal| !is_neutral_terminal(terminal, bus, neutral_terminals))
1514 .count()
1515}
1516
1517fn is_neutral_terminal(
1518 terminal: &str,
1519 bus: Option<&DistBus>,
1520 neutral_terminals: &BTreeSet<String>,
1521) -> bool {
1522 terminal == "0"
1523 || terminal.eq_ignore_ascii_case("n")
1524 || bus.is_some_and(|b| b.grounded.iter().any(|g| g == terminal))
1525 || neutral_terminals.contains(terminal)
1526}
1527
1528fn check_phase_reference(
1529 net: &MulticonductorNetwork,
1530 report: &mut MulticonductorToBalancedReadiness,
1531) {
1532 let neutral_terminals = global_neutral_terminals(net);
1533 let has_three_phase_source = net.sources.iter().any(|source| {
1534 let bus = net.bus(&source.bus);
1535 active_terminal_count(&source.terminal_map, bus, &neutral_terminals) == 3
1536 });
1537
1538 if !has_three_phase_source {
1539 report.diagnostics.push(StructuredDiagnostic::new(
1540 "LOWER.MULTI_TO_BALANCED.MISSING_PHASE_REFERENCE",
1541 DiagnosticSeverity::Error,
1542 DiagnosticStage::Lower,
1543 "multiconductor to balanced lowering requires a three phase voltage source reference",
1544 ));
1545 }
1546}
1547
1548fn check_transformers(net: &MulticonductorNetwork, report: &mut MulticonductorToBalancedReadiness) {
1549 for (i, transformer) in net.transformers.iter().enumerate() {
1550 report.diagnostics.push(
1551 StructuredDiagnostic::new(
1552 "LOWER.MULTI_TO_BALANCED.UNSUPPORTED_TRANSFORMER",
1553 DiagnosticSeverity::Error,
1554 DiagnosticStage::Lower,
1555 format!(
1556 "transformer {} is not supported by the multiconductor to balanced preflight",
1557 transformer.name
1558 ),
1559 )
1560 .with_element_path(format!("/model/multiconductor_network/transformers/{i}")),
1561 );
1562 }
1563}
1564
1565fn check_untyped_objects(
1566 net: &MulticonductorNetwork,
1567 report: &mut MulticonductorToBalancedReadiness,
1568) {
1569 for (i, obj) in net.untyped.iter().enumerate() {
1570 report.diagnostics.push(
1571 StructuredDiagnostic::new(
1572 "LOWER.MULTI_TO_BALANCED.UNSUPPORTED_OBJECT",
1573 DiagnosticSeverity::Error,
1574 DiagnosticStage::Lower,
1575 format!(
1576 "{} {} is preserved as an untyped object and cannot be lowered",
1577 obj.class, obj.name
1578 ),
1579 )
1580 .with_element_path(format!("/model/multiconductor_network/untyped/{i}")),
1581 );
1582 }
1583}