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