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