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