1use std::collections::{BTreeMap, BTreeSet};
10use std::f64::consts::PI;
11
12use num_complex::Complex64;
13use serde::{Deserialize, Serialize};
14
15use crate::{
16 BalancedNetwork, Branch, BranchCharging, Bus, BusId, BusType, Extras as BalancedExtras,
17 Generator, GeoApplyReport, GeoLayer, Load, Shunt, SourceFormat,
18};
19use powerio_core::{Diagnostic, DiagnosticSeverity, HistoryEntry, HistoryId, HistoryKind};
20use powerio_dist::{
21 ConductorMatrix, DistBus, DistLine, DistLineCode, DistLoadVoltageModel, MulticonductorNetwork,
22 NeutralKronOptions, NeutralKronReport,
23};
24
25use crate::codes;
26
27trait DiagnosticTargetExt {
28 fn with_value_target(self, target: String) -> Self;
29}
30
31impl DiagnosticTargetExt for Diagnostic {
32 fn with_value_target(self, target: String) -> Self {
33 self.with_target(target)
34 .expect("transform targets are bounded RFC 6901 pointers")
35 }
36}
37
38#[derive(Clone, Debug)]
42struct TransformRecords {
43 options: serde_json::Map<String, serde_json::Value>,
44 assumptions: Vec<String>,
45 approximations: Vec<String>,
46 dropped_fields: Vec<String>,
47 diagnostics: Vec<Diagnostic>,
48}
49
50impl TransformRecords {
51 fn new(options: MulticonductorToBalancedOptions) -> Self {
52 Self {
53 options: options_map(options),
54 assumptions: Vec::new(),
55 approximations: Vec::new(),
56 dropped_fields: Vec::new(),
57 diagnostics: Vec::new(),
58 }
59 }
60}
61
62#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
64#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
65#[serde(rename_all = "snake_case")]
66pub enum SequenceTransformConvention {
67 FortescuePowerInvariant,
68}
69
70impl std::fmt::Display for SequenceTransformConvention {
71 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72 match self {
73 Self::FortescuePowerInvariant => f.write_str("FortescuePowerInvariant"),
74 }
75 }
76}
77
78const DEFAULT_LOWERING_BASE_MVA: f64 = 100.0;
79const SQRT_3: f64 = 1.732_050_807_568_877_2;
80const COUPLING_TOLERANCE: f64 = 1.0e-9;
81
82fn default_lowering_base_mva() -> f64 {
83 DEFAULT_LOWERING_BASE_MVA
84}
85
86#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
88#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
89pub struct MulticonductorToBalancedOptions {
90 pub convention: SequenceTransformConvention,
91 #[serde(default = "default_lowering_base_mva")]
93 pub base_mva: f64,
94}
95
96impl Default for MulticonductorToBalancedOptions {
97 fn default() -> Self {
98 Self {
99 convention: SequenceTransformConvention::FortescuePowerInvariant,
100 base_mva: DEFAULT_LOWERING_BASE_MVA,
101 }
102 }
103}
104
105#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
107pub struct MulticonductorToBalancedReport {
108 pub convention: SequenceTransformConvention,
109 pub base_mva: f64,
110 #[serde(default, skip_serializing_if = "Vec::is_empty")]
111 pub assumptions: Vec<String>,
112 #[serde(default, skip_serializing_if = "Vec::is_empty")]
113 pub approximations: Vec<String>,
114 #[serde(default, skip_serializing_if = "Vec::is_empty")]
115 pub diagnostics: Vec<Diagnostic>,
116}
117
118impl MulticonductorToBalancedReport {
119 #[must_use]
120 pub fn is_ready(&self) -> bool {
121 self.diagnostics
122 .iter()
123 .all(|diagnostic| diagnostic.severity() < DiagnosticSeverity::Error)
124 }
125
126 #[must_use]
129 pub fn dominant_severity(&self) -> Option<DiagnosticSeverity> {
130 self.diagnostics
131 .iter()
132 .map(powerio_core::Diagnostic::severity)
133 .max()
134 }
135}
136
137#[derive(Clone, Debug)]
139pub struct MulticonductorToBalancedTransformation {
140 pub network: BalancedNetwork,
141 pub diagnostics: Vec<Diagnostic>,
143 pub history: HistoryEntry,
146 pub merged_buses: BTreeMap<String, String>,
149 pub removed_switches: Vec<String>,
151}
152
153#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
159pub struct MulticonductorToBalancedError {
160 pub options: MulticonductorToBalancedOptions,
161 #[serde(default, skip_serializing_if = "Vec::is_empty")]
162 pub diagnostics: Vec<Diagnostic>,
163}
164
165impl MulticonductorToBalancedError {
166 pub fn new(options: MulticonductorToBalancedOptions, diagnostics: &[Diagnostic]) -> Self {
167 Self {
168 options,
169 diagnostics: diagnostics.to_vec(),
170 }
171 }
172}
173
174impl std::fmt::Display for MulticonductorToBalancedError {
175 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
176 match self.diagnostics.first() {
177 Some(diagnostic) => write!(f, "{}", diagnostic.message()),
178 None => f.write_str("multiconductor to balanced transformation failed"),
179 }
180 }
181}
182
183impl std::error::Error for MulticonductorToBalancedError {}
184
185#[must_use]
191pub fn to_balanced_network_report(
192 net: &MulticonductorNetwork,
193 options: MulticonductorToBalancedOptions,
194) -> MulticonductorToBalancedReport {
195 let mut report = MulticonductorToBalancedReport {
196 convention: options.convention,
197 base_mva: options.base_mva,
198 assumptions: vec![format!(
199 "sequence transform convention: {}",
200 options.convention
201 )],
202 approximations: Vec::new(),
203 diagnostics: Vec::new(),
204 };
205
206 check_options(options, &mut report);
207 check_bus_conductor_sets(net, &mut report);
208 check_phase_reference(net, &mut report);
209 check_line_terminal_maps(net, &mut report);
210 check_linecodes(net, &mut report);
211 check_switches(net, &mut report);
212 check_transformers(net, &mut report);
213 check_untyped_objects(net, &mut report);
214 report
215}
216
217pub fn to_balanced_network(
223 net: &MulticonductorNetwork,
224 options: MulticonductorToBalancedOptions,
225) -> Result<MulticonductorToBalancedTransformation, MulticonductorToBalancedError> {
226 let readiness = to_balanced_network_report(net, options);
227 if !readiness.is_ready() {
228 return Err(MulticonductorToBalancedError::new(
229 options,
230 &readiness.diagnostics,
231 ));
232 }
233
234 let mut state = LoweringState::new(net, options, readiness);
235 state.lower()
236}
237
238pub fn to_balanced_report(
244 module: &powerio_core::PioModule<crate::PioValue>,
245 options: MulticonductorToBalancedOptions,
246) -> Result<MulticonductorToBalancedReport, powerio_core::Error> {
247 let crate::PioValue::MulticonductorNetwork(net) = &module.value() else {
248 return Err(wrong_kind_error(module.value()));
249 };
250 Ok(to_balanced_network_report(net, options))
251}
252
253#[allow(clippy::result_large_err)]
269pub fn to_balanced(
270 module: powerio_core::PioModule<crate::PioValue>,
271 options: MulticonductorToBalancedOptions,
272) -> Result<
273 powerio_core::PioModule<crate::PioValue>,
274 (
275 powerio_core::PioModule<crate::PioValue>,
276 Box<MulticonductorToBalancedError>,
277 ),
278> {
279 let crate::PioValue::MulticonductorNetwork(net) = &module.value() else {
280 let error = MulticonductorToBalancedError::new(
281 options,
282 &[Diagnostic::of(
283 &codes::TRANSFORM_MULTI_TO_BALANCED_WRONG_MODEL_KIND,
284 format!(
285 "the module carries a {} value; the balanced lowering takes a \
286 multiconductor network",
287 module.value().type_name()
288 ),
289 )],
290 );
291 return Err((module, Box::new(error)));
292 };
293 let lowering = match to_balanced_network(net, options) {
294 Ok(lowering) => lowering,
295 Err(error) => return Err((module, Box::new(error))),
296 };
297 let MulticonductorToBalancedTransformation {
298 network,
299 diagnostics,
300 history,
301 ..
302 } = lowering;
303 let diagnostics_room =
307 powerio_core::limits::MAX_MODULE_DIAGNOSTICS.saturating_sub(module.diagnostics().len());
308 let history_room =
309 powerio_core::limits::MAX_MODULE_HISTORY_ENTRIES.saturating_sub(module.history().len());
310 if diagnostics.len() > diagnostics_room || history_room == 0 {
311 let error = MulticonductorToBalancedError::new(
312 options,
313 &[Diagnostic::of(
314 &codes::TRANSFORM_MULTI_TO_BALANCED_RECORD_CAP,
315 "the module cannot hold the lowering's findings and history entry; export a \
316 fresh module before lowering"
317 .to_string(),
318 )],
319 );
320 return Err((module, Box::new(error)));
321 }
322 let mut module = module
323 .map_value(|_| crate::PioValue::BalancedNetwork(network))
324 .sever_source();
325 module.sever_value_targets();
330 for diagnostic in &diagnostics {
331 let mut diagnostic = diagnostic.clone();
332 diagnostic.clear_target();
333 module
334 .add_diagnostic(diagnostic)
335 .expect("room was checked; pass diagnostics carry no identity and no span");
336 }
337 let entry = copy_history_with_id(
338 &history,
339 unused_history_id(&module, "multiconductor-to-balanced"),
340 );
341 module
342 .add_history_entry(entry)
343 .expect("room was checked and the history id is unique by construction");
344 Ok(module)
345}
346
347fn derive_balanced_calculation<I>(
348 module: &powerio_core::PioModule<crate::PioValue>,
349 operation: &'static str,
350 output_type: &'static str,
351 build: impl FnOnce(BalancedNetwork) -> Result<I, powerio_core::Error>,
352) -> Result<powerio_core::PioModule<I>, powerio_core::Error> {
353 if !matches!(module.value(), crate::PioValue::BalancedNetwork(_)) {
354 return Err(powerio_core::Error::new(
355 &codes::REQUEST_MODULE_WRONG_MODEL_KIND,
356 format!(
357 "{operation} requires powerio.BalancedNetwork; the module contains {}",
358 module.value().type_name()
359 ),
360 ));
361 }
362 let history = HistoryEntry::new(
363 unused_history_id(module, operation),
364 HistoryKind::Transform,
365 operation,
366 )?
367 .with_input_type("powerio.BalancedNetwork")?
368 .with_output_type(output_type)?;
369 let producer = powerio_core::Producer::new("powerio", crate::VERSION)?;
370 module.clone().try_derive_value(producer, history, |value| {
371 let crate::PioValue::BalancedNetwork(network) = value else {
372 unreachable!("the value type was checked before derivation")
373 };
374 build(network)
375 })
376}
377
378fn derive_multiconductor_calculation<I>(
379 module: &powerio_core::PioModule<crate::PioValue>,
380 operation: &'static str,
381 output_type: &'static str,
382 build: impl FnOnce(MulticonductorNetwork) -> Result<I, powerio_core::Error>,
383) -> Result<powerio_core::PioModule<I>, powerio_core::Error> {
384 if !matches!(module.value(), crate::PioValue::MulticonductorNetwork(_)) {
385 return Err(powerio_core::Error::new(
386 &codes::REQUEST_MODULE_WRONG_MODEL_KIND,
387 format!(
388 "{operation} requires powerio.MulticonductorNetwork; the module contains {}",
389 module.value().type_name()
390 ),
391 ));
392 }
393 let history = HistoryEntry::new(
394 unused_history_id(module, operation),
395 HistoryKind::Transform,
396 operation,
397 )?
398 .with_input_type("powerio.MulticonductorNetwork")?
399 .with_output_type(output_type)?;
400 let producer = powerio_core::Producer::new("powerio", crate::VERSION)?;
401 module.clone().try_derive_value(producer, history, |value| {
402 let crate::PioValue::MulticonductorNetwork(network) = value else {
403 unreachable!("the value type was checked before derivation")
404 };
405 build(network)
406 })
407}
408
409pub fn apply_geo_layer(
422 module: &powerio_core::PioModule<crate::PioValue>,
423 layer: &GeoLayer,
424) -> Result<(powerio_core::PioModule<crate::PioValue>, GeoApplyReport), powerio_core::Error> {
425 let type_name = match &module.value() {
426 crate::PioValue::BalancedNetwork(_) => "powerio.BalancedNetwork",
427 crate::PioValue::MulticonductorNetwork(_) => "powerio.MulticonductorNetwork",
428 value => {
429 return Err(powerio_core::Error::new(
430 &codes::REQUEST_MODULE_WRONG_MODEL_KIND,
431 format!(
432 "apply_geo_layer requires powerio.BalancedNetwork or \
433 powerio.MulticonductorNetwork; the module contains {}",
434 value.type_name()
435 ),
436 ));
437 }
438 };
439 let history = HistoryEntry::new(
440 unused_history_id(module, "apply-geo-layer"),
441 HistoryKind::Transform,
442 "apply_geo_layer",
443 )?
444 .with_input_type(type_name)?
445 .with_output_type(type_name)?;
446 let producer = powerio_core::Producer::new("powerio", crate::VERSION)?;
447 let mut report = None;
448 let derived = module
449 .clone()
450 .try_derive_value(producer, history, |mut value| {
451 let applied = match &mut value {
452 crate::PioValue::BalancedNetwork(network) => network.apply_geo_layer(layer),
453 crate::PioValue::MulticonductorNetwork(network) => {
454 crate::dist_geo::apply_dist_geo_layer(network, layer)
455 }
456 value => {
457 return Err(powerio_core::Error::new(
458 &codes::REQUEST_MODULE_WRONG_MODEL_KIND,
459 format!(
460 "apply_geo_layer requires powerio.BalancedNetwork or \
461 powerio.MulticonductorNetwork; the module contains {}",
462 value.type_name()
463 ),
464 ));
465 }
466 };
467 report = Some(applied);
468 Ok(value)
469 })?;
470 let report = report.ok_or_else(|| {
471 powerio_core::Error::new(
472 &codes::REQUEST_MODULE_WRONG_MODEL_KIND,
473 "apply_geo_layer did not receive a network value",
474 )
475 })?;
476 Ok((derived, report))
477}
478
479pub fn neutral_kron(
486 module: &powerio_core::PioModule<crate::PioValue>,
487) -> Result<(powerio_core::PioModule<crate::PioValue>, NeutralKronReport), powerio_core::Error> {
488 neutral_kron_with_options(module, &NeutralKronOptions::default())
489}
490
491pub fn neutral_kron_with_options(
502 module: &powerio_core::PioModule<crate::PioValue>,
503 options: &NeutralKronOptions,
504) -> Result<(powerio_core::PioModule<crate::PioValue>, NeutralKronReport), powerio_core::Error> {
505 let crate::PioValue::MulticonductorNetwork(network) = module.value() else {
506 return Err(powerio_core::Error::new(
507 &codes::REQUEST_MODULE_WRONG_MODEL_KIND,
508 format!(
509 "neutral_kron requires powerio.MulticonductorNetwork; the module contains {}",
510 module.value().type_name()
511 ),
512 ));
513 };
514 let reduction = powerio_dist::neutral_kron_reduce(network, options).map_err(|cause| {
515 powerio_core::Error::new(cause.code(), cause.to_string()).with_cause(cause)
516 })?;
517 let (network, report) = reduction.into_parts();
518 let neutral_terminals = options
519 .neutral_terminals
520 .iter()
521 .map(|(bus, terminal)| (bus.clone(), serde_json::Value::String(terminal.clone())))
522 .collect();
523 let parameters = BTreeMap::from([
524 (
525 "allow_forced_ideal_ground".to_owned(),
526 serde_json::Value::Bool(options.allow_forced_ideal_ground),
527 ),
528 (
529 "neutral_terminals".to_owned(),
530 serde_json::Value::Object(neutral_terminals),
531 ),
532 ]);
533 let history = HistoryEntry::new(
534 unused_history_id(module, "neutral_kron"),
535 HistoryKind::Transform,
536 "neutral_kron",
537 )?
538 .with_input_type("powerio.MulticonductorNetwork")?
539 .with_output_type("powerio.MulticonductorNetwork")?
540 .with_parameters(parameters)?;
541 let producer = powerio_core::Producer::new("powerio", crate::VERSION)?;
542 let mut derived = module.clone().try_derive_value(
543 producer,
544 history,
545 move |value| match value {
546 crate::PioValue::MulticonductorNetwork(_) => {
547 Ok(crate::PioValue::MulticonductorNetwork(network))
548 }
549 value => Err(powerio_core::Error::new(
550 &codes::REQUEST_MODULE_WRONG_MODEL_KIND,
551 format!(
552 "neutral_kron requires powerio.MulticonductorNetwork; the module contains {}",
553 value.type_name()
554 ),
555 )),
556 },
557 )?;
558 for diagnostic in &report.diagnostics {
559 let mut diagnostic = diagnostic.clone();
560 diagnostic.clear_target();
561 derived.add_diagnostic(diagnostic)?;
562 }
563 Ok((derived, report))
564}
565
566#[must_use]
572pub fn network_with_operating_point(
573 point: &powerio_prob::OperatingPoint<BalancedNetwork>,
574) -> BalancedNetwork {
575 crate::write::network_with_balanced_operating_point(point, "network").0
576}
577
578pub fn to_dc_pf_instance(
583 module: &powerio_core::PioModule<crate::PioValue>,
584) -> Result<powerio_core::PioModule<powerio_prob::DcPfInstance>, powerio_core::Error> {
585 if matches!(module.value(), crate::PioValue::DcPfInstance(_)) {
586 return Ok(module.clone().map_value(|value| match value {
587 crate::PioValue::DcPfInstance(instance) => instance,
588 _ => unreachable!("the value type was checked before extraction"),
589 }));
590 }
591 derive_balanced_calculation(
592 module,
593 "to_dc_pf_instance",
594 "powerio.DcPfInstance",
595 powerio_prob::DcPfInstance::from_network,
596 )
597}
598
599pub fn to_ac_pf_instance(
601 module: &powerio_core::PioModule<crate::PioValue>,
602) -> Result<powerio_core::PioModule<powerio_prob::AcPfInstance>, powerio_core::Error> {
603 if matches!(module.value(), crate::PioValue::AcPfInstance(_)) {
604 return Ok(module.clone().map_value(|value| match value {
605 crate::PioValue::AcPfInstance(instance) => instance,
606 _ => unreachable!("the value type was checked before extraction"),
607 }));
608 }
609 derive_balanced_calculation(
610 module,
611 "to_ac_pf_instance",
612 "powerio.AcPfInstance",
613 powerio_prob::AcPfInstance::from_network,
614 )
615}
616
617pub fn to_dc_opf_instance(
620 module: &powerio_core::PioModule<crate::PioValue>,
621) -> Result<powerio_core::PioModule<powerio_prob::DcOpfInstance>, powerio_core::Error> {
622 if matches!(module.value(), crate::PioValue::DcOpfInstance(_)) {
623 return Ok(module.clone().map_value(|value| match value {
624 crate::PioValue::DcOpfInstance(instance) => instance,
625 _ => unreachable!("the value type was checked before extraction"),
626 }));
627 }
628 derive_balanced_calculation(
629 module,
630 "to_dc_opf_instance",
631 "powerio.DcOpfInstance",
632 powerio_prob::DcOpfInstance::from_network,
633 )
634}
635
636pub fn to_ac_opf_instance(
639 module: &powerio_core::PioModule<crate::PioValue>,
640) -> Result<powerio_core::PioModule<powerio_prob::AcOpfInstance>, powerio_core::Error> {
641 if matches!(module.value(), crate::PioValue::AcOpfInstance(_)) {
642 return Ok(module.clone().map_value(|value| match value {
643 crate::PioValue::AcOpfInstance(instance) => instance,
644 _ => unreachable!("the value type was checked before extraction"),
645 }));
646 }
647 derive_balanced_calculation(
648 module,
649 "to_ac_opf_instance",
650 "powerio.AcOpfInstance",
651 powerio_prob::AcOpfInstance::from_network,
652 )
653}
654
655pub fn to_mc_ac_pf_instance(
658 module: &powerio_core::PioModule<crate::PioValue>,
659) -> Result<powerio_core::PioModule<powerio_prob::McAcPfInstance>, powerio_core::Error> {
660 if matches!(module.value(), crate::PioValue::McAcPfInstance(_)) {
661 return Ok(module.clone().map_value(|value| match value {
662 crate::PioValue::McAcPfInstance(instance) => instance,
663 _ => unreachable!("the value type was checked before extraction"),
664 }));
665 }
666 derive_multiconductor_calculation(
667 module,
668 "to_mc_ac_pf_instance",
669 "powerio.McAcPfInstance",
670 powerio_prob::McAcPfInstance::from_network,
671 )
672}
673
674pub fn to_mc_ac_opf_instance(
677 module: &powerio_core::PioModule<crate::PioValue>,
678) -> Result<powerio_core::PioModule<powerio_prob::McAcOpfInstance>, powerio_core::Error> {
679 if matches!(module.value(), crate::PioValue::McAcOpfInstance(_)) {
680 return Ok(module.clone().map_value(|value| match value {
681 crate::PioValue::McAcOpfInstance(instance) => instance,
682 _ => unreachable!("the value type was checked before extraction"),
683 }));
684 }
685 derive_multiconductor_calculation(
686 module,
687 "to_mc_ac_opf_instance",
688 "powerio.McAcOpfInstance",
689 powerio_prob::McAcOpfInstance::from_network,
690 )
691}
692
693pub fn to_lindist3flow_opf_instance(
699 module: &powerio_core::PioModule<crate::PioValue>,
700) -> Result<powerio_core::PioModule<powerio_prob::LinDist3FlowOpfInstance>, powerio_core::Error> {
701 to_lindist3flow_opf_instance_with_options(
702 module,
703 powerio_prob::LinDist3FlowBuildOptions::default(),
704 )
705}
706
707pub fn to_lindist3flow_opf_instance_with_options(
714 module: &powerio_core::PioModule<crate::PioValue>,
715 options: powerio_prob::LinDist3FlowBuildOptions,
716) -> Result<powerio_core::PioModule<powerio_prob::LinDist3FlowOpfInstance>, powerio_core::Error> {
717 if matches!(module.value(), crate::PioValue::LinDist3FlowOpfInstance(_)) {
718 return Ok(module.clone().map_value(|value| match value {
719 crate::PioValue::LinDist3FlowOpfInstance(instance) => instance,
720 _ => unreachable!("the value type was checked before extraction"),
721 }));
722 }
723 if !matches!(module.value(), crate::PioValue::MulticonductorNetwork(_)) {
724 return Err(powerio_core::Error::new(
725 &codes::REQUEST_MODULE_WRONG_MODEL_KIND,
726 format!(
727 "to_lindist3flow_opf_instance requires powerio.MulticonductorNetwork; the module contains {}",
728 module.value().type_name()
729 ),
730 ));
731 }
732 let options_value = serde_json::to_value(options).map_err(|cause| {
733 powerio_core::Error::new(
734 &codes::TRANSFORM_LINDIST3FLOW_OPTIONS_SERIALIZE_FAILED,
735 "could not record LinDist3Flow build options in transform history",
736 )
737 .with_cause(cause)
738 })?;
739 let serde_json::Value::Object(parameters) = options_value else {
740 return Err(powerio_core::Error::new(
741 &codes::TRANSFORM_LINDIST3FLOW_OPTIONS_SERIALIZE_FAILED,
742 "LinDist3Flow build options did not serialize as an object",
743 ));
744 };
745 let history = HistoryEntry::new(
746 unused_history_id(module, "to_lindist3flow_opf_instance"),
747 HistoryKind::Transform,
748 "to_lindist3flow_opf_instance",
749 )?
750 .with_input_type("powerio.MulticonductorNetwork")?
751 .with_output_type("powerio.LinDist3FlowOpfInstance")?
752 .with_parameters(parameters.into_iter().collect())?;
753 let producer = powerio_core::Producer::new("powerio", crate::VERSION)?;
754 module.clone().try_derive_value(producer, history, |value| {
755 let crate::PioValue::MulticonductorNetwork(network) = value else {
756 unreachable!("the value type was checked before derivation")
757 };
758 powerio_prob::LinDist3FlowOpfInstance::from_network(network, options)
759 })
760}
761
762fn capped_history_notes(notes: Vec<String>, what: &str) -> Vec<String> {
768 let cap = powerio_core::limits::MAX_HISTORY_NOTES;
769 if notes.len() <= cap {
770 return notes.into_iter().map(normalized_note).collect();
771 }
772 let elided = notes.len() - (cap - 1);
773 let mut kept: Vec<String> = notes
774 .into_iter()
775 .take(cap - 1)
776 .map(normalized_note)
777 .collect();
778 kept.push(format!("{elided} more {what} elided"));
779 kept
780}
781
782fn transform_history(
783 id: HistoryId,
784 records: &TransformRecords,
785 merged_buses: &BTreeMap<String, String>,
786 removed_switches: &[String],
787) -> HistoryEntry {
788 let parameters: BTreeMap<String, serde_json::Value> =
789 records.options.clone().into_iter().collect();
790 let mut entry = HistoryEntry::new(id, HistoryKind::Transform, "to_balanced")
791 .expect("the static history name is valid")
792 .with_input_type("powerio.MulticonductorNetwork")
793 .expect("the registered input type is valid")
794 .with_output_type("powerio.BalancedNetwork")
795 .expect("the registered output type is valid")
796 .with_parameters(parameters)
797 .expect("the transformation has a bounded parameter set");
798
799 let mut assumptions = records.assumptions.clone();
800 assumptions.extend(
801 records
802 .approximations
803 .iter()
804 .map(|note| format!("approximation: {note}")),
805 );
806 assumptions.extend(
807 merged_buses
808 .iter()
809 .map(|(removed, kept)| format!("bus {removed} merged into bus {kept}")),
810 );
811 assumptions.extend(
812 removed_switches
813 .iter()
814 .map(|switch| format!("switch {switch} removed by its bus merge")),
815 );
816 for assumption in capped_history_notes(assumptions, "assumptions") {
817 entry = entry
818 .with_assumption(assumption)
819 .expect("the note list is under the history cap by construction");
820 }
821 for loss in capped_history_notes(records.dropped_fields.clone(), "losses") {
822 entry = entry
823 .with_loss(loss)
824 .expect("the loss list is under the history cap by construction");
825 }
826 entry
827}
828
829fn copy_history_with_id(history: &HistoryEntry, id: HistoryId) -> HistoryEntry {
830 let mut copied = HistoryEntry::new(id, history.kind(), history.name())
831 .expect("the existing history name is valid")
832 .with_parameters(history.parameters().clone())
833 .expect("the existing parameter set is valid");
834 if let Some(type_name) = history.input_type() {
835 copied = copied
836 .with_input_type(type_name)
837 .expect("the existing input type is valid");
838 }
839 if let Some(type_name) = history.output_type() {
840 copied = copied
841 .with_output_type(type_name)
842 .expect("the existing output type is valid");
843 }
844 for assumption in history.assumptions() {
845 copied = copied
846 .with_assumption(assumption.clone())
847 .expect("the existing assumption list is valid");
848 }
849 for loss in history.losses() {
850 copied = copied
851 .with_loss(loss.clone())
852 .expect("the existing loss list is valid");
853 }
854 copied
855}
856
857fn normalized_note(note: String) -> String {
861 let mut note = if note.contains('\0') {
862 note.replace('\0', "\u{fffd}")
863 } else {
864 note
865 };
866 let bound = powerio_core::limits::MAX_IDENTIFIER_BYTES;
867 if note.len() > bound {
868 let marker = " [truncated]";
869 let mut end = bound - marker.len();
870 while !note.is_char_boundary(end) {
871 end -= 1;
872 }
873 note.truncate(end);
874 note.push_str(marker);
875 }
876 if note.is_empty() {
877 note.push_str("(an empty note was elided)");
878 }
879 note
880}
881
882fn unused_history_id(
885 module: &powerio_core::PioModule<crate::PioValue>,
886 base: &str,
887) -> powerio_core::HistoryId {
888 use powerio_core::HistoryId;
889 let taken: std::collections::BTreeSet<&str> = module
890 .history()
891 .iter()
892 .map(|entry| entry.id().as_str())
893 .collect();
894 if !taken.contains(base) {
895 return HistoryId::new(base).expect("static id is valid");
896 }
897 let mut counter = 2usize;
898 loop {
899 let candidate = format!("{base}-{counter}");
900 if !taken.contains(candidate.as_str()) {
901 return HistoryId::new(candidate).expect("numbered id is valid");
902 }
903 counter += 1;
904 }
905}
906
907fn wrong_kind_error(value: &crate::PioValue) -> powerio_core::Error {
908 powerio_core::Error::new(
909 &codes::TRANSFORM_MULTI_TO_BALANCED_WRONG_MODEL_KIND,
910 format!(
911 "the module carries a {} value; the balanced lowering takes a multiconductor \
912 network",
913 value.type_name()
914 ),
915 )
916}
917
918struct LoweringState<'a> {
919 net: &'a MulticonductorNetwork,
920 options: MulticonductorToBalancedOptions,
921 neutral_terminals: BTreeSet<String>,
922 bus_ids: BTreeMap<String, BusId>,
925 canonical_rows: BTreeMap<String, usize>,
927 merged_buses: BTreeMap<String, String>,
929 removed_switches: Vec<String>,
930 bus_base: BTreeMap<String, f64>,
932 records: TransformRecords,
933}
934
935impl<'a> LoweringState<'a> {
936 fn new(
937 net: &'a MulticonductorNetwork,
938 options: MulticonductorToBalancedOptions,
939 readiness: MulticonductorToBalancedReport,
940 ) -> Self {
941 let mut records = TransformRecords::new(options);
942 records.assumptions = readiness.assumptions;
943 records.approximations = readiness.approximations;
944 records.diagnostics = readiness.diagnostics;
945 records
946 .assumptions
947 .push(format!("balanced power base: {} MVA", options.base_mva));
948 records
949 .assumptions
950 .push("balanced bus ids are synthesized from multiconductor bus order".to_owned());
951 records.approximations.push(
952 "wire-coordinate branch and shunt matrices are projected to positive sequence"
953 .to_owned(),
954 );
955 records.approximations.push(
956 "phase injection records are aggregated into scalar balanced injections".to_owned(),
957 );
958 records.approximations.push(
959 "units are converted from W/var/V/ohm/siemens/radians to MW/MVAr/per-unit/degrees"
960 .to_owned(),
961 );
962 if net.switches().iter().any(|sw| sw.open) {
963 records
964 .dropped_fields
965 .push("open switches dropped from balanced model".to_owned());
966 }
967
968 let row_of: BTreeMap<String, usize> = net
973 .buses()
974 .iter()
975 .enumerate()
976 .map(|(row, bus)| (bus.id.to_ascii_lowercase(), row))
977 .collect();
978 let mut union = UnionFind::new(net.buses().len());
979 let mut removed_switches = Vec::new();
980 for sw in net.switches().iter().filter(|sw| !sw.open) {
981 let (Some(&from), Some(&to)) = (
982 row_of.get(&sw.bus_from.to_ascii_lowercase()),
983 row_of.get(&sw.bus_to.to_ascii_lowercase()),
984 ) else {
985 continue;
986 };
987 union.join(from, to);
988 removed_switches.push(sw.name.clone());
989 records.assumptions.push(format!(
990 "closed switch {} merged bus {} into bus {} and was removed; no impedance \
991 was invented for it",
992 sw.name, sw.bus_to, sw.bus_from
993 ));
994 }
995 let mut canonical_rows = BTreeMap::new();
996 let mut merged_buses = BTreeMap::new();
997 let mut number = BTreeMap::new();
998 for (row, bus) in net.buses().iter().enumerate() {
999 let root = union.root(row);
1000 if root == row {
1001 let id = BusId(number.len() + 1);
1002 number.insert(row, id);
1003 } else {
1004 merged_buses.insert(bus.id.clone(), net.buses()[root].id.clone());
1005 }
1006 canonical_rows.insert(bus.id.to_ascii_lowercase(), root);
1007 }
1008 let bus_ids = net
1009 .buses()
1010 .iter()
1011 .map(|bus| {
1012 let key = bus.id.to_ascii_lowercase();
1013 let root = union.root(row_of[&key]);
1014 (key, number[&root])
1015 })
1016 .collect();
1017
1018 Self {
1019 net,
1020 options,
1021 neutral_terminals: global_neutral_terminals(net),
1022 bus_ids,
1023 canonical_rows,
1024 merged_buses,
1025 removed_switches,
1026 bus_base: BTreeMap::new(),
1027 records,
1028 }
1029 }
1030
1031 #[allow(clippy::too_many_lines)]
1032 fn lower(
1033 &mut self,
1034 ) -> Result<MulticonductorToBalancedTransformation, MulticonductorToBalancedError> {
1035 let Some(base) = self.voltage_base()? else {
1036 return Err(MulticonductorToBalancedError::new(
1037 self.options,
1038 &self.records.diagnostics,
1039 ));
1040 };
1041
1042 self.assign_bus_bases(base);
1043 let buses = self.lower_buses(base);
1044 let mut branches = self.lower_lines()?;
1045 branches.extend(self.lower_transformers());
1046 let loads = self.lower_loads();
1047 let shunts = self.lower_shunts()?;
1048 let generators = self.lower_generators(&buses);
1049 self.record_capacitor_drops();
1050 self.err_if_errors()?;
1051
1052 let mut network = BalancedNetwork::new(
1053 self.net
1054 .name()
1055 .clone()
1056 .unwrap_or_else(|| "lowered-multiconductor".to_owned()),
1057 self.options.base_mva,
1058 );
1059 *network.base_frequency_mut() = self.net.base_frequency();
1060 *network.buses_mut() = buses;
1061 *network.loads_mut() = loads;
1062 *network.shunts_mut() = shunts;
1063 *network.branches_mut() = branches;
1064 *network.generators_mut() = generators;
1065 *network.source_format_mut() = SourceFormat::InMemory;
1066 if let Err(err) = network.validate() {
1067 self.records.diagnostics.push(Diagnostic::of(
1068 &codes::TRANSFORM_MULTI_TO_BALANCED_INVALID_BALANCED_OUTPUT,
1069 format!("lowered balanced network failed structural validation: {err}"),
1070 ));
1071 return Err(MulticonductorToBalancedError::new(
1072 self.options,
1073 &self.records.diagnostics,
1074 ));
1075 }
1076 for finding in network.validate_values() {
1077 let details = finding.details();
1078 self.records.diagnostics.push(
1079 Diagnostic::of(
1080 &codes::TRANSFORM_MULTI_TO_BALANCED_BALANCED_VALUE_DOMAIN,
1081 format!(
1082 "{} field `{}` is outside its value domain after lowering",
1083 details["element"].as_str().unwrap_or_default(),
1084 details["field"].as_str().unwrap_or_default()
1085 ),
1086 )
1087 .with_suggested_action(
1088 "Inspect the multiconductor source values before using the lowered model.",
1089 ),
1090 );
1091 }
1092
1093 let history = transform_history(
1094 HistoryId::new("multiconductor-to-balanced").expect("static history id is valid"),
1095 &self.records,
1096 &self.merged_buses,
1097 &self.removed_switches,
1098 );
1099 Ok(MulticonductorToBalancedTransformation {
1100 network,
1101 diagnostics: self.records.diagnostics.clone(),
1102 history,
1103 merged_buses: self.merged_buses.clone(),
1104 removed_switches: self.removed_switches.clone(),
1105 })
1106 }
1107
1108 fn assign_bus_bases(&mut self, reference: VoltageBase) {
1114 let row_of: BTreeMap<String, usize> = self
1115 .net
1116 .buses()
1117 .iter()
1118 .enumerate()
1119 .map(|(row, bus)| (bus.id.to_ascii_lowercase(), row))
1120 .collect();
1121 let mut zones = UnionFind::new(self.net.buses().len());
1122 for line in self.net.lines() {
1123 if let (Some(&from), Some(&to)) = (
1124 row_of.get(&line.bus_from.to_ascii_lowercase()),
1125 row_of.get(&line.bus_to.to_ascii_lowercase()),
1126 ) {
1127 zones.join(from, to);
1128 }
1129 }
1130 for sw in self.net.switches().iter().filter(|sw| !sw.open) {
1131 if let (Some(&from), Some(&to)) = (
1132 row_of.get(&sw.bus_from.to_ascii_lowercase()),
1133 row_of.get(&sw.bus_to.to_ascii_lowercase()),
1134 ) {
1135 zones.join(from, to);
1136 }
1137 }
1138
1139 let mut zone_base: BTreeMap<usize, f64> = BTreeMap::new();
1140 for source in self.net.sources() {
1141 let Some(&row) = row_of.get(&source.bus.to_ascii_lowercase()) else {
1142 continue;
1143 };
1144 let bus = self.net.bus(&source.bus);
1145 let positions = active_positions(&source.terminal_map, bus, &self.neutral_terminals);
1146 if positions.len() != 3 {
1147 continue;
1148 }
1149 let Some(v1) = positive_sequence_voltage(source, &positions) else {
1150 continue;
1151 };
1152 if v1.norm().is_finite() && v1.norm() > 0.0 {
1153 zone_base.entry(zones.root(row)).or_insert(v1.norm());
1154 }
1155 }
1156
1157 let supported: Vec<(usize, usize, f64, f64)> = self
1158 .net
1159 .transformers()
1160 .iter()
1161 .filter_map(|transformer| {
1162 let [high, low] =
1163 classify_transformer(self.net, transformer, &self.neutral_terminals).ok()?;
1164 let high_row = *row_of.get(&high.bus.to_ascii_lowercase())?;
1165 let low_row = *row_of.get(&low.bus.to_ascii_lowercase())?;
1166 Some((high_row, low_row, high.v_ref, low.v_ref))
1167 })
1168 .collect();
1169 loop {
1170 let mut changed = false;
1171 for &(high_row, low_row, high_v, low_v) in &supported {
1172 let (high_zone, low_zone) = (zones.root(high_row), zones.root(low_row));
1173 match (
1174 zone_base.contains_key(&high_zone),
1175 zone_base.contains_key(&low_zone),
1176 ) {
1177 (true, false) => {
1178 zone_base.insert(low_zone, low_v);
1179 changed = true;
1180 }
1181 (false, true) => {
1182 zone_base.insert(high_zone, high_v);
1183 changed = true;
1184 }
1185 _ => {}
1186 }
1187 }
1188 if !changed {
1189 break;
1190 }
1191 }
1192
1193 for (row, bus) in self.net.buses().iter().enumerate() {
1194 let zone = zones.root(row);
1195 let base = zone_base.get(&zone).copied().unwrap_or_else(|| {
1196 self.records.dropped_fields.push(format!(
1197 "bus {} voltage base defaulted to the reference base",
1198 bus.id
1199 ));
1200 reference.line_to_line_volts
1201 });
1202 self.bus_base.insert(bus.id.to_ascii_lowercase(), base);
1203 }
1204 }
1205
1206 fn base_volts(&self, bus: &str) -> f64 {
1208 self.bus_base
1209 .get(&bus.to_ascii_lowercase())
1210 .copied()
1211 .expect("every declared bus was based")
1212 }
1213
1214 fn voltage_base(&mut self) -> Result<Option<VoltageBase>, MulticonductorToBalancedError> {
1215 for (idx, source) in self.net.sources().iter().enumerate() {
1216 let Some(bus) = self.net.bus(&source.bus) else {
1217 self.records.diagnostics.push(
1218 Diagnostic::of(
1219 &codes::TRANSFORM_MULTI_TO_BALANCED_UNKNOWN_SOURCE_BUS,
1220 format!(
1221 "voltage source {} references unknown bus {}",
1222 source.name, source.bus
1223 ),
1224 )
1225 .with_value_target(format!("/sources/{idx}/bus")),
1226 );
1227 continue;
1228 };
1229 let positions =
1230 active_positions(&source.terminal_map, Some(bus), &self.neutral_terminals);
1231 if positions.len() != 3 {
1232 continue;
1233 }
1234 let Some(v1) = positive_sequence_voltage(source, &positions) else {
1235 self.records.diagnostics.push(
1236 Diagnostic::of(
1237 &codes::TRANSFORM_MULTI_TO_BALANCED_INVALID_PHASE_REFERENCE,
1238format!(
1239 "voltage source {} does not carry finite three phase voltage magnitudes and angles",
1240 source.name
1241 ),
1242 )
1243 .with_value_target(format!("/sources/{idx}")),
1244 );
1245 continue;
1246 };
1247 let line_to_line_volts = v1.norm();
1248 if !line_to_line_volts.is_finite() || line_to_line_volts <= 0.0 {
1249 self.records.diagnostics.push(
1250 Diagnostic::of(
1251 &codes::TRANSFORM_MULTI_TO_BALANCED_INVALID_PHASE_REFERENCE,
1252format!(
1253 "voltage source {} produced a non-positive positive-sequence voltage base",
1254 source.name
1255 ),
1256 )
1257 .with_value_target(format!("/sources/{idx}")),
1258 );
1259 continue;
1260 }
1261 self.records.assumptions.push(format!(
1262 "voltage base synthesized from source {} positive-sequence voltage: {} kV line-to-line",
1263 source.name,
1264 line_to_line_volts / 1000.0
1265 ));
1266 return Ok(Some(VoltageBase { line_to_line_volts }));
1267 }
1268
1269 if self
1270 .records
1271 .diagnostics
1272 .iter()
1273 .any(|d| d.severity() >= DiagnosticSeverity::Error)
1274 {
1275 return Err(MulticonductorToBalancedError::new(
1276 self.options,
1277 &self.records.diagnostics,
1278 ));
1279 }
1280 self.records.diagnostics.push(Diagnostic::of(
1281 &codes::TRANSFORM_MULTI_TO_BALANCED_MISSING_PHASE_REFERENCE,
1282"multiconductor to balanced lowering requires a finite three phase voltage source reference",
1283 ));
1284 Ok(None)
1285 }
1286
1287 fn lower_buses(&mut self, _reference: VoltageBase) -> Vec<Bus> {
1288 let mut members: BTreeMap<usize, Vec<&DistBus>> = BTreeMap::new();
1291 for bus in self.net.buses() {
1292 let root = self.canonical_rows[&bus.id.to_ascii_lowercase()];
1293 members.entry(root).or_default().push(bus);
1294 }
1295 let mut balanced_buses = Vec::with_capacity(members.len());
1296 for (&root, group) in &members {
1297 let canonical = &self.net.buses()[root];
1298 let base_volts = self.base_volts(&canonical.id);
1299 let sourced = group.iter().find_map(|bus| {
1300 self.net
1301 .sources()
1302 .iter()
1303 .find(|source| source.bus.eq_ignore_ascii_case(&bus.id))
1304 .map(|source| (*bus, source))
1305 });
1306 let (vm, va) = sourced
1307 .and_then(|(bus, source)| {
1308 let positions =
1309 active_positions(&source.terminal_map, Some(bus), &self.neutral_terminals);
1310 positive_sequence_voltage(source, &positions)
1311 })
1312 .map_or((1.0, 0.0), |v| {
1313 (v.norm() / base_volts, radians_to_degrees(v.arg()))
1314 });
1315 if sourced.is_none() {
1316 self.records.dropped_fields.push(format!(
1317 "bus {} voltage magnitude and angle defaulted to 1.0 p.u. and 0 degrees",
1318 canonical.id
1319 ));
1320 }
1321 let stated = group.iter().find_map(|bus| match (bus.v_min, bus.v_max) {
1324 (Some(vmin), Some(vmax)) if vmin.is_finite() && vmax.is_finite() => {
1325 Some((vmin / base_volts, vmax / base_volts))
1326 }
1327 _ => None,
1328 });
1329 let (vmin, vmax) = stated.unwrap_or_else(|| {
1330 self.records.dropped_fields.push(format!(
1331 "bus {} voltage bounds defaulted to 0.9/1.1 p.u.",
1332 canonical.id
1333 ));
1334 (0.9, 1.1)
1335 });
1336 for bus in group {
1337 self.record_bus_bound_drops(bus);
1338 }
1339 let kind = group
1340 .iter()
1341 .map(|bus| self.bus_kind(&bus.id))
1342 .min_by_key(|kind| match kind {
1343 BusType::Ref => 0,
1344 BusType::Pv => 1,
1345 _ => 2,
1346 })
1347 .unwrap_or(BusType::Pq);
1348 let mut balanced = Bus::new(
1349 self.bus_ids[&canonical.id.to_ascii_lowercase()],
1350 kind,
1351 base_volts / 1000.0,
1352 );
1353 balanced.vm = vm;
1354 balanced.va = va;
1355 balanced.vmax = vmax;
1356 balanced.vmin = vmin;
1357 balanced.name = Some(canonical.id.clone());
1358 balanced.extras = source_extra("multiconductor_bus_id", &canonical.id);
1359 balanced_buses.push(balanced);
1360 }
1361 balanced_buses
1362 }
1363
1364 fn record_capacitor_drops(&mut self) {
1370 for capacitor in self.net.capacitors() {
1371 self.records.dropped_fields.push(format!(
1372 "capacitor {} dropped: a rated bank has no balanced shunt equivalent",
1373 capacitor.name
1374 ));
1375 }
1376 }
1377
1378 fn record_bus_bound_drops(&mut self, bus: &DistBus) {
1379 if bus.vpn_min.is_some()
1380 || bus.vpn_max.is_some()
1381 || bus.vpp_min.is_some()
1382 || bus.vpp_max.is_some()
1383 || bus.vpos_min.is_some()
1384 || bus.vpos_max.is_some()
1385 || bus.vneg_max.is_some()
1386 || bus.vzero_max.is_some()
1387 || bus.vn_max.is_some()
1388 {
1389 self.records.dropped_fields.push(format!(
1390 "bus {} conductor voltage bound families dropped",
1391 bus.id
1392 ));
1393 }
1394 }
1395
1396 fn bus_kind(&self, bus_id: &str) -> BusType {
1397 if self
1398 .net
1399 .sources()
1400 .iter()
1401 .any(|source| source.bus.eq_ignore_ascii_case(bus_id))
1402 {
1403 BusType::Ref
1404 } else if self
1405 .net
1406 .generators()
1407 .iter()
1408 .any(|generator| generator.bus.eq_ignore_ascii_case(bus_id))
1409 {
1410 BusType::Pv
1411 } else {
1412 BusType::Pq
1413 }
1414 }
1415
1416 #[allow(clippy::too_many_lines)]
1417 fn lower_lines(&mut self) -> Result<Vec<Branch>, MulticonductorToBalancedError> {
1418 let mut branches = Vec::with_capacity(self.net.lines().len());
1419 for (idx, line) in self.net.lines().iter().enumerate() {
1420 let Some(code) = self.net.linecode(&line.linecode) else {
1421 self.records.diagnostics.push(
1422 Diagnostic::of(
1423 &codes::TRANSFORM_MULTI_TO_BALANCED_UNKNOWN_LINECODE,
1424 format!(
1425 "line {} references unknown linecode `{}`",
1426 line.name, line.linecode
1427 ),
1428 )
1429 .with_value_target(format!("/lines/{idx}/linecode")),
1430 );
1431 continue;
1432 };
1433 if !same_active_phase_order(
1434 self.net.bus(&line.bus_from),
1435 &line.terminal_map_from,
1436 self.net.bus(&line.bus_to),
1437 &line.terminal_map_to,
1438 &self.neutral_terminals,
1439 ) {
1440 self.records.diagnostics.push(
1441 Diagnostic::of(
1442 &codes::TRANSFORM_MULTI_TO_BALANCED_PHASE_MAP_MISMATCH,
1443format!(
1444 "line {} connects different active terminal orders and cannot be lowered transparently",
1445 line.name
1446 ),
1447 )
1448 .with_value_target(format!("/lines/{idx}")),
1449 );
1450 continue;
1451 }
1452 let Some(from) = self.bus_id(&line.bus_from) else {
1453 self.unknown_bus_diag("line", &line.name, &line.bus_from, idx, "bus_from");
1454 continue;
1455 };
1456 let Some(to) = self.bus_id(&line.bus_to) else {
1457 self.unknown_bus_diag("line", &line.name, &line.bus_to, idx, "bus_to");
1458 continue;
1459 };
1460 let from_bus = self.net.bus(&line.bus_from);
1461 let active =
1462 active_positions(&line.terminal_map_from, from_bus, &self.neutral_terminals);
1463 let neutral =
1464 neutral_positions(&line.terminal_map_from, from_bus, &self.neutral_terminals);
1465 let z_ohm =
1466 self.line_positive_sequence_impedance(idx, code, &active, &neutral, line.length)?;
1467 let y_from = self.line_positive_sequence_admittance(
1468 idx,
1469 code,
1470 &active,
1471 &neutral,
1472 line.length,
1473 ShuntSide::From,
1474 )?;
1475 let y_to = self.line_positive_sequence_admittance(
1476 idx,
1477 code,
1478 &active,
1479 &neutral,
1480 line.length,
1481 ShuntSide::To,
1482 )?;
1483 let base_volts = self.base_volts(&line.bus_from);
1484 let z_base = z_base_ohm_of(base_volts, self.options.base_mva);
1485 let y_scale = z_base;
1486 let charging = BranchCharging::new(
1487 y_from.re * y_scale,
1488 y_from.im * y_scale,
1489 y_to.re * y_scale,
1490 y_to.im * y_scale,
1491 );
1492 let rate = line_rate_mva(line, code, &active, base_volts).unwrap_or_else(|| {
1493 self.records.dropped_fields.push(format!(
1494 "line {} thermal rating defaulted to 0 MVA",
1495 line.name
1496 ));
1497 0.0
1498 });
1499 let mut branch = Branch::new(from, to, z_ohm.re / z_base, z_ohm.im / z_base);
1500 branch.b = charging.calc_total_b();
1501 branch.charging = Some(charging);
1502 branch.rate_a = rate;
1503 branch.rate_b = rate;
1504 branch.rate_c = rate;
1505 branch.extras = source_extra("multiconductor_line", &line.name);
1506 branches.push(branch);
1507 }
1508 self.err_if_errors()?;
1509 Ok(branches)
1510 }
1511
1512 fn line_positive_sequence_impedance(
1513 &mut self,
1514 line_idx: usize,
1515 code: &DistLineCode,
1516 active: &[usize],
1517 neutral: &[usize],
1518 length: f64,
1519 ) -> Result<Complex64, MulticonductorToBalancedError> {
1520 self.check_finite_length(line_idx, length)?;
1521 let matrix = complex_matrix(&code.r_series, &code.x_series, length);
1522 let reduced = kron_or_select(&matrix, active, neutral).map_err(|message| {
1523 self.matrix_error(line_idx, &code.name, "series impedance", &message)
1524 })?;
1525 Ok(self.positive_sequence_from_matrix(line_idx, &code.name, "series impedance", &reduced))
1526 }
1527
1528 fn line_positive_sequence_admittance(
1529 &mut self,
1530 line_idx: usize,
1531 code: &DistLineCode,
1532 active: &[usize],
1533 neutral: &[usize],
1534 length: f64,
1535 side: ShuntSide,
1536 ) -> Result<Complex64, MulticonductorToBalancedError> {
1537 let (g, b, label) = match side {
1538 ShuntSide::From => (&code.g_from, &code.b_from, "from shunt admittance"),
1539 ShuntSide::To => (&code.g_to, &code.b_to, "to shunt admittance"),
1540 };
1541 let matrix = complex_matrix(g, b, length);
1542 let reduced = kron_or_select(&matrix, active, neutral)
1543 .map_err(|message| self.matrix_error(line_idx, &code.name, label, &message))?;
1544 Ok(self.positive_sequence_from_matrix(line_idx, &code.name, label, &reduced))
1545 }
1546
1547 fn positive_sequence_from_matrix(
1548 &mut self,
1549 line_idx: usize,
1550 code_name: &str,
1551 label: &str,
1552 matrix: &[Vec<Complex64>],
1553 ) -> Complex64 {
1554 let seq = sequence_matrix(matrix);
1555 let coupling = sequence_coupling_norm(&seq);
1556 if coupling > COUPLING_TOLERANCE {
1557 self.records.approximations.push(format!(
1558 "linecode {code_name} {label} has sequence coupling norm {coupling}; positive-sequence diagonal retained"
1559 ));
1560 let mut diagnostic = Diagnostic::of(
1561 &codes::TRANSFORM_MULTI_TO_BALANCED_SEQUENCE_COUPLING_DROPPED,
1562format!(
1563 "linecode {code_name} {label} has nonzero sequence coupling; the balanced model keeps the positive-sequence diagonal"
1564 ),
1565 )
1566 .with_value_target(format!("/lines/{line_idx}/linecode"));
1567 diagnostic
1568 .insert_detail("sequence_coupling_norm", serde_json::json!(coupling))
1569 .expect("the static detail key is valid");
1570 self.records.diagnostics.push(diagnostic);
1571 }
1572 seq[1][1]
1573 }
1574
1575 fn check_finite_length(
1580 &self,
1581 line_idx: usize,
1582 length: f64,
1583 ) -> Result<(), MulticonductorToBalancedError> {
1584 if length.is_finite() {
1585 return Ok(());
1586 }
1587 let mut diagnostics = self.records.diagnostics.clone();
1588 diagnostics.push(
1589 Diagnostic::of(
1590 &codes::TRANSFORM_MULTI_TO_BALANCED_NONFINITE_LINE_LENGTH,
1591format!("line {line_idx} has no finite length ({length}), so its impedance cannot be scaled"),
1592 )
1593 .with_value_target(format!("/lines/{line_idx}/length"))
1594 .with_suggested_action("give the line a length in meters, or drop it from the network"),
1595 );
1596 Err(MulticonductorToBalancedError::new(
1597 self.options,
1598 &diagnostics,
1599 ))
1600 }
1601
1602 fn matrix_error(
1603 &self,
1604 line_idx: usize,
1605 code_name: &str,
1606 label: &str,
1607 message: &str,
1608 ) -> MulticonductorToBalancedError {
1609 let mut diagnostics = self.records.diagnostics.clone();
1610 diagnostics.push(
1611 Diagnostic::of(
1612 &codes::TRANSFORM_MULTI_TO_BALANCED_INVALID_LINECODE_MATRIX,
1613 format!("linecode {code_name} {label} cannot be lowered: {message}"),
1614 )
1615 .with_value_target(format!("/lines/{line_idx}/linecode")),
1616 );
1617 MulticonductorToBalancedError::new(self.options, &diagnostics)
1618 }
1619
1620 fn lower_transformers(&mut self) -> Vec<Branch> {
1627 let mut branches = Vec::new();
1628 for transformer in self.net.transformers() {
1629 let Ok([high, low]) =
1630 classify_transformer(self.net, transformer, &self.neutral_terminals)
1631 else {
1632 continue;
1634 };
1635 let (Some(from), Some(to)) = (self.bus_id(&high.bus), self.bus_id(&low.bus)) else {
1636 continue;
1637 };
1638 let base_from = self.base_volts(&high.bus);
1639 let base_to = self.base_volts(&low.bus);
1640 let tap = (high.v_ref * high.tap / base_from) / (low.v_ref * low.tap / base_to);
1641 let z_scale = (self.options.base_mva * 1_000_000.0 / high.s_rating)
1642 * (high.v_ref / base_from).powi(2);
1643 let low_rating_scale = high.s_rating / low.s_rating;
1646 let r = ((high.r_pct + low.r_pct * low_rating_scale) / 100.0) * z_scale;
1647 let x = (transformer.xsc_pct[0] / 100.0) * z_scale;
1648 let shift = if high.v_ref >= low.v_ref { 30.0 } else { -30.0 };
1649 let rate = high.s_rating / 1_000_000.0;
1650 self.records.assumptions.push(format!(
1651 "transformer {} lowered as a balanced branch with tap {tap:.6} and the ANSI \
1652 {shift} degree connection shift (high voltage side leads)",
1653 transformer.name
1654 ));
1655 if (low_rating_scale - 1.0).abs() > 1e-9 {
1656 self.records.assumptions.push(format!(
1657 "transformer {}: the low winding resistance was converted from its own \
1658 {:.3} kVA base onto the high winding {:.3} kVA base",
1659 transformer.name,
1660 low.s_rating / 1_000.0,
1661 high.s_rating / 1_000.0
1662 ));
1663 }
1664 if high.r_neutral.is_some()
1665 || high.x_neutral.is_some()
1666 || low.r_neutral.is_some()
1667 || low.x_neutral.is_some()
1668 {
1669 self.records.dropped_fields.push(format!(
1670 "transformer {} neutral grounding impedance dropped",
1671 transformer.name
1672 ));
1673 }
1674 if transformer.xsc_pct.len() > 1 {
1675 self.records.dropped_fields.push(format!(
1676 "transformer {} extra short circuit reactances dropped",
1677 transformer.name
1678 ));
1679 }
1680 let mut branch = Branch::new(from, to, r, x);
1681 branch.tap = tap;
1682 branch.shift = shift;
1683 branch.rate_a = rate;
1684 branch.rate_b = rate;
1685 branch.rate_c = rate;
1686 branch.extras = source_extra("multiconductor_transformer", &transformer.name);
1687 branches.push(branch);
1688 }
1689 branches
1690 }
1691
1692 fn lower_loads(&mut self) -> Vec<Load> {
1693 self.net
1694 .loads()
1695 .iter()
1696 .enumerate()
1697 .filter_map(|(idx, load)| {
1698 let Some(bus) = self.bus_id(&load.bus) else {
1699 self.unknown_bus_diag("load", &load.name, &load.bus, idx, "bus");
1700 return None;
1701 };
1702 if !matches!(
1703 load.voltage_model,
1704 DistLoadVoltageModel::ConstantPower { .. }
1705 ) {
1706 self.records.dropped_fields.push(format!(
1707 "load {} voltage model dropped; balanced load is constant power",
1708 load.name
1709 ));
1710 self.records.diagnostics.push(
1711 Diagnostic::of(
1712 &codes::TRANSFORM_MULTI_TO_BALANCED_DROPPED_LOAD_VOLTAGE_MODEL,
1713format!(
1714 "load {} voltage model cannot be represented by the conservative balanced lowering",
1715 load.name
1716 ),
1717 )
1718 .with_value_target(format!("/loads/{idx}/voltage_model")),
1719 );
1720 }
1721 let mut balanced = Load::new(
1722 bus,
1723 si_power_to_mega(load.p_nom.iter().sum()),
1724 si_power_to_mega(load.q_nom.iter().sum()),
1725 );
1726 balanced.extras = source_extra("multiconductor_load", &load.name);
1727 Some(balanced)
1728 })
1729 .collect()
1730 }
1731
1732 fn lower_shunts(&mut self) -> Result<Vec<Shunt>, MulticonductorToBalancedError> {
1733 let mut shunts = Vec::with_capacity(self.net.shunts().len());
1734 for (idx, shunt) in self.net.shunts().iter().enumerate() {
1735 let Some(bus) = self.bus_id(&shunt.bus) else {
1736 self.unknown_bus_diag("shunt", &shunt.name, &shunt.bus, idx, "bus");
1737 continue;
1738 };
1739 let dist_bus = self.net.bus(&shunt.bus);
1740 let active = active_positions(&shunt.terminal_map, dist_bus, &self.neutral_terminals);
1741 let neutral = neutral_positions(&shunt.terminal_map, dist_bus, &self.neutral_terminals);
1742 let y = if active.len() == 3 {
1743 let matrix = complex_matrix(&shunt.g, &shunt.b, 1.0);
1744 let reduced = kron_or_select(&matrix, &active, &neutral)
1745 .map_err(|message| self.shunt_matrix_error(idx, &shunt.name, &message))?;
1746 let seq = sequence_matrix(&reduced);
1747 seq[1][1]
1748 } else {
1749 self.records.approximations.push(format!(
1750 "shunt {} has {} active terminal(s); diagonal admittance projected with missing phases as zero",
1751 shunt.name,
1752 active.len()
1753 ));
1754 partial_phase_admittance(&shunt.g, &shunt.b, &active)
1755 };
1756 let base_volts = self.base_volts(&shunt.bus);
1757 let scale = base_volts * base_volts / 1_000_000.0;
1758 let mut balanced = Shunt::new(bus, y.re * scale, y.im * scale);
1759 balanced.extras = source_extra("multiconductor_shunt", &shunt.name);
1760 shunts.push(balanced);
1761 }
1762 self.err_if_errors()?;
1763 Ok(shunts)
1764 }
1765
1766 fn shunt_matrix_error(
1767 &self,
1768 shunt_idx: usize,
1769 name: &str,
1770 message: &str,
1771 ) -> MulticonductorToBalancedError {
1772 let mut diagnostics = self.records.diagnostics.clone();
1773 diagnostics.push(
1774 Diagnostic::of(
1775 &codes::TRANSFORM_MULTI_TO_BALANCED_INVALID_SHUNT_MATRIX,
1776 format!("shunt {name} cannot be lowered: {message}"),
1777 )
1778 .with_value_target(format!("/shunts/{shunt_idx}")),
1779 );
1780 MulticonductorToBalancedError::new(self.options, &diagnostics)
1781 }
1782
1783 fn lower_generators(&mut self, buses: &[Bus]) -> Vec<Generator> {
1784 self.net
1785 .generators()
1786 .iter()
1787 .enumerate()
1788 .filter_map(|(idx, generator)| {
1789 let Some(bus) = self.bus_id(&generator.bus) else {
1790 self.unknown_bus_diag("generator", &generator.name, &generator.bus, idx, "bus");
1791 return None;
1792 };
1793 let pg = si_power_to_mega(generator.p_nom.iter().sum());
1794 let qg = si_power_to_mega(generator.q_nom.iter().sum());
1795 let pmin = option_vec_sum_mw(generator.p_min.as_deref()).unwrap_or_else(|| {
1796 self.records.dropped_fields.push(format!(
1797 "generator {} p_min defaulted to pg",
1798 generator.name
1799 ));
1800 pg
1801 });
1802 let pmax = option_vec_sum_mw(generator.p_max.as_deref()).unwrap_or_else(|| {
1803 self.records.dropped_fields.push(format!(
1804 "generator {} p_max defaulted to pg",
1805 generator.name
1806 ));
1807 pg
1808 });
1809 let qmin = option_vec_sum_mw(generator.q_min.as_deref()).unwrap_or_else(|| {
1810 self.records.dropped_fields.push(format!(
1811 "generator {} q_min defaulted to qg",
1812 generator.name
1813 ));
1814 qg
1815 });
1816 let qmax = option_vec_sum_mw(generator.q_max.as_deref()).unwrap_or_else(|| {
1817 self.records.dropped_fields.push(format!(
1818 "generator {} q_max defaulted to qg",
1819 generator.name
1820 ));
1821 qg
1822 });
1823 if generator.cost.is_some() {
1824 self.records.dropped_fields.push(format!(
1825 "generator {} scalar distribution cost dropped",
1826 generator.name
1827 ));
1828 }
1829 if generator.s_max.is_some() || generator.i_max.is_some() {
1830 self.records.dropped_fields.push(format!(
1831 "generator {} per-conductor rating fields dropped",
1832 generator.name
1833 ));
1834 }
1835 let vg = buses
1836 .iter()
1837 .find(|balanced_bus| balanced_bus.id == bus)
1838 .map_or(1.0, |balanced_bus| balanced_bus.vm);
1839 let mut balanced = Generator::new(bus);
1840 balanced.pg = pg;
1841 balanced.qg = qg;
1842 balanced.pmax = pmax;
1843 balanced.pmin = pmin;
1844 balanced.qmax = qmax;
1845 balanced.qmin = qmin;
1846 balanced.vg = vg;
1847 balanced.mbase = self.options.base_mva;
1848 Some(balanced)
1849 })
1850 .collect()
1851 }
1852
1853 fn bus_id(&self, bus: &str) -> Option<BusId> {
1854 self.bus_ids.get(&bus.to_ascii_lowercase()).copied()
1855 }
1856
1857 fn unknown_bus_diag(&mut self, element: &str, name: &str, bus: &str, idx: usize, field: &str) {
1858 self.records.diagnostics.push(
1859 Diagnostic::of(
1860 &codes::TRANSFORM_MULTI_TO_BALANCED_UNKNOWN_BUS,
1861 format!("{element} {name} references unknown bus {bus}"),
1862 )
1863 .with_value_target(format!("/{element}s/{idx}/{field}")),
1864 );
1865 }
1866
1867 fn err_if_errors(&self) -> Result<(), MulticonductorToBalancedError> {
1868 if self
1869 .records
1870 .diagnostics
1871 .iter()
1872 .any(|d| d.severity() >= DiagnosticSeverity::Error)
1873 {
1874 Err(MulticonductorToBalancedError::new(
1875 self.options,
1876 &self.records.diagnostics,
1877 ))
1878 } else {
1879 Ok(())
1880 }
1881 }
1882}
1883
1884#[derive(Clone, Copy)]
1885struct VoltageBase {
1886 line_to_line_volts: f64,
1887}
1888
1889fn z_base_ohm_of(line_to_line_volts: f64, base_mva: f64) -> f64 {
1890 line_to_line_volts * line_to_line_volts / (base_mva * 1_000_000.0)
1891}
1892
1893struct UnionFind {
1894 parent: Vec<usize>,
1895}
1896
1897impl UnionFind {
1898 fn new(len: usize) -> Self {
1899 Self {
1900 parent: (0..len).collect(),
1901 }
1902 }
1903
1904 fn root(&mut self, mut node: usize) -> usize {
1905 while self.parent[node] != node {
1906 self.parent[node] = self.parent[self.parent[node]];
1907 node = self.parent[node];
1908 }
1909 node
1910 }
1911
1912 fn join(&mut self, a: usize, b: usize) {
1913 let (a, b) = (self.root(a), self.root(b));
1914 let (keep, fold) = if a <= b { (a, b) } else { (b, a) };
1916 self.parent[fold] = keep;
1917 }
1918}
1919
1920#[derive(Clone, Copy)]
1921enum ShuntSide {
1922 From,
1923 To,
1924}
1925
1926fn options_map(
1927 options: MulticonductorToBalancedOptions,
1928) -> serde_json::Map<String, serde_json::Value> {
1929 serde_json::to_value(options)
1930 .ok()
1931 .and_then(|value| value.as_object().cloned())
1932 .unwrap_or_default()
1933}
1934
1935fn source_extra(key: &str, value: &str) -> BalancedExtras {
1936 let mut extras = BalancedExtras::new();
1937 extras.insert(key.to_owned(), serde_json::Value::String(value.to_owned()));
1938 extras
1939}
1940
1941fn active_positions(
1942 terminals: &[String],
1943 bus: Option<&DistBus>,
1944 neutral_terminals: &BTreeSet<String>,
1945) -> Vec<usize> {
1946 terminals
1947 .iter()
1948 .enumerate()
1949 .filter_map(|(idx, terminal)| {
1950 (!is_neutral_terminal(terminal, bus, neutral_terminals)).then_some(idx)
1951 })
1952 .collect()
1953}
1954
1955fn neutral_positions(
1956 terminals: &[String],
1957 bus: Option<&DistBus>,
1958 neutral_terminals: &BTreeSet<String>,
1959) -> Vec<usize> {
1960 terminals
1961 .iter()
1962 .enumerate()
1963 .filter_map(|(idx, terminal)| {
1964 is_neutral_terminal(terminal, bus, neutral_terminals).then_some(idx)
1965 })
1966 .collect()
1967}
1968
1969fn same_active_phase_order(
1970 from_bus: Option<&DistBus>,
1971 from_terminals: &[String],
1972 to_bus: Option<&DistBus>,
1973 to_terminals: &[String],
1974 neutral_terminals: &BTreeSet<String>,
1975) -> bool {
1976 let from: Vec<_> = from_terminals
1977 .iter()
1978 .filter(|terminal| !is_neutral_terminal(terminal, from_bus, neutral_terminals))
1979 .map(|terminal| terminal.to_ascii_lowercase())
1980 .collect();
1981 let to: Vec<_> = to_terminals
1982 .iter()
1983 .filter(|terminal| !is_neutral_terminal(terminal, to_bus, neutral_terminals))
1984 .map(|terminal| terminal.to_ascii_lowercase())
1985 .collect();
1986 from == to
1987}
1988
1989fn positive_sequence_voltage(
1990 source: &powerio_dist::VoltageSource,
1991 positions: &[usize],
1992) -> Option<Complex64> {
1993 if positions.len() != 3 {
1994 return None;
1995 }
1996 let mut phase = [Complex64::new(0.0, 0.0); 3];
1997 for (out, &idx) in phase.iter_mut().zip(positions.iter()) {
1998 let magnitude = *source.v_magnitude.get(idx)?;
1999 let angle = *source.v_angle.get(idx)?;
2000 if !magnitude.is_finite() || !angle.is_finite() {
2001 return None;
2002 }
2003 *out = Complex64::from_polar(magnitude, angle);
2004 }
2005 let basis = sequence_basis();
2006 let mut seq = [Complex64::new(0.0, 0.0); 3];
2007 for (sequence_idx, out) in seq.iter_mut().enumerate() {
2008 for phase_idx in 0..3 {
2009 *out += basis[phase_idx][sequence_idx].conj() * phase[phase_idx];
2010 }
2011 }
2012 Some(seq[1])
2013}
2014
2015fn complex_matrix(
2016 g_or_r: &ConductorMatrix,
2017 b_or_x: &ConductorMatrix,
2018 scale: f64,
2019) -> Vec<Vec<Complex64>> {
2020 g_or_r
2021 .iter()
2022 .zip(b_or_x.iter())
2023 .map(|(g_row, b_row)| {
2024 g_row
2025 .iter()
2026 .zip(b_row.iter())
2027 .map(|(&g, &b)| Complex64::new(g * scale, b * scale))
2028 .collect()
2029 })
2030 .collect()
2031}
2032
2033fn kron_or_select(
2034 matrix: &[Vec<Complex64>],
2035 active: &[usize],
2036 neutral: &[usize],
2037) -> Result<Vec<Vec<Complex64>>, String> {
2038 if active.len() != 3 {
2039 return Err(format!(
2040 "expected three active conductors, got {}",
2041 active.len()
2042 ));
2043 }
2044 validate_indices(matrix, active)?;
2045 validate_indices(matrix, neutral)?;
2046 if neutral.is_empty() {
2047 return Ok(submatrix(matrix, active, active));
2048 }
2049
2050 let m_pp = submatrix(matrix, active, active);
2051 let m_pn = submatrix(matrix, active, neutral);
2052 let m_np = submatrix(matrix, neutral, active);
2053 let m_nn = submatrix(matrix, neutral, neutral);
2054 if matrix_is_near_zero(&m_pn) && matrix_is_near_zero(&m_np) && matrix_is_near_zero(&m_nn) {
2055 return Ok(m_pp);
2056 }
2057 let inv_nn = invert_complex_matrix(&m_nn)?;
2058 let correction = matmul(&matmul(&m_pn, &inv_nn), &m_np);
2059 Ok(matrix_sub(&m_pp, &correction))
2060}
2061
2062fn matrix_is_near_zero(matrix: &[Vec<Complex64>]) -> bool {
2063 matrix
2064 .iter()
2065 .flatten()
2066 .all(|value| value.norm() <= f64::EPSILON)
2067}
2068
2069fn validate_indices(matrix: &[Vec<Complex64>], indices: &[usize]) -> Result<(), String> {
2070 let n = matrix.len();
2071 if matrix.iter().any(|row| row.len() != n) {
2072 return Err("matrix is not square".to_owned());
2073 }
2074 if indices.iter().any(|&idx| idx >= n) {
2075 return Err("terminal map references a conductor outside the matrix".to_owned());
2076 }
2077 Ok(())
2078}
2079
2080fn submatrix(matrix: &[Vec<Complex64>], rows: &[usize], cols: &[usize]) -> Vec<Vec<Complex64>> {
2081 rows.iter()
2082 .map(|&row| cols.iter().map(|&col| matrix[row][col]).collect())
2083 .collect()
2084}
2085
2086#[allow(clippy::needless_range_loop)]
2087fn invert_complex_matrix(matrix: &[Vec<Complex64>]) -> Result<Vec<Vec<Complex64>>, String> {
2088 let n = matrix.len();
2089 if n == 0 || matrix.iter().any(|row| row.len() != n) {
2090 return Err("neutral block is not square".to_owned());
2091 }
2092 let mut aug = vec![vec![Complex64::new(0.0, 0.0); 2 * n]; n];
2093 for i in 0..n {
2094 for j in 0..n {
2095 aug[i][j] = matrix[i][j];
2096 }
2097 aug[i][n + i] = Complex64::new(1.0, 0.0);
2098 }
2099
2100 for col in 0..n {
2101 let pivot = (col..n)
2102 .max_by(|&a, &b| aug[a][col].norm_sqr().total_cmp(&aug[b][col].norm_sqr()))
2103 .ok_or_else(|| "neutral block is singular".to_owned())?;
2104 if aug[pivot][col].norm() <= f64::EPSILON {
2105 return Err("neutral block is singular".to_owned());
2106 }
2107 if pivot != col {
2108 aug.swap(pivot, col);
2109 }
2110 let pivot_value = aug[col][col];
2111 for j in 0..(2 * n) {
2112 aug[col][j] /= pivot_value;
2113 }
2114 for row in 0..n {
2115 if row == col {
2116 continue;
2117 }
2118 let factor = aug[row][col];
2119 if factor.norm() <= f64::EPSILON {
2120 continue;
2121 }
2122 for j in 0..(2 * n) {
2123 let pivot_entry = aug[col][j];
2124 aug[row][j] -= factor * pivot_entry;
2125 }
2126 }
2127 }
2128
2129 Ok(aug
2130 .into_iter()
2131 .map(|row| row.into_iter().skip(n).collect())
2132 .collect())
2133}
2134
2135fn matmul(a: &[Vec<Complex64>], b: &[Vec<Complex64>]) -> Vec<Vec<Complex64>> {
2136 if a.is_empty() || b.is_empty() {
2137 return Vec::new();
2138 }
2139 let rows = a.len();
2140 let cols = b[0].len();
2141 let inner = b.len();
2142 let mut out = vec![vec![Complex64::new(0.0, 0.0); cols]; rows];
2143 for i in 0..rows {
2144 for k in 0..inner {
2145 for j in 0..cols {
2146 out[i][j] += a[i][k] * b[k][j];
2147 }
2148 }
2149 }
2150 out
2151}
2152
2153fn matrix_sub(a: &[Vec<Complex64>], b: &[Vec<Complex64>]) -> Vec<Vec<Complex64>> {
2154 a.iter()
2155 .zip(b.iter())
2156 .map(|(a_row, b_row)| {
2157 a_row
2158 .iter()
2159 .zip(b_row.iter())
2160 .map(|(&a_value, &b_value)| a_value - b_value)
2161 .collect()
2162 })
2163 .collect()
2164}
2165
2166#[allow(clippy::many_single_char_names)]
2167fn sequence_basis() -> [[Complex64; 3]; 3] {
2168 let scale = 1.0 / SQRT_3;
2169 let a = Complex64::from_polar(1.0, 2.0 * PI / 3.0);
2170 let a2 = a * a;
2171 [
2172 [
2173 Complex64::new(scale, 0.0),
2174 Complex64::new(scale, 0.0),
2175 Complex64::new(scale, 0.0),
2176 ],
2177 [Complex64::new(scale, 0.0), a2 * scale, a * scale],
2178 [Complex64::new(scale, 0.0), a * scale, a2 * scale],
2179 ]
2180}
2181
2182fn sequence_matrix(matrix: &[Vec<Complex64>]) -> [[Complex64; 3]; 3] {
2183 let basis = sequence_basis();
2184 let mut seq = [[Complex64::new(0.0, 0.0); 3]; 3];
2185 for p in 0..3 {
2186 for q in 0..3 {
2187 for i in 0..3 {
2188 for j in 0..3 {
2189 seq[p][q] += basis[i][p].conj() * matrix[i][j] * basis[j][q];
2190 }
2191 }
2192 }
2193 }
2194 seq
2195}
2196
2197fn sequence_coupling_norm(seq: &[[Complex64; 3]; 3]) -> f64 {
2198 let mut sum = 0.0;
2199 for (i, row) in seq.iter().enumerate() {
2200 for (j, value) in row.iter().enumerate() {
2201 if i != j {
2202 sum += value.norm_sqr();
2203 }
2204 }
2205 }
2206 sum.sqrt()
2207}
2208
2209fn line_rate_mva(
2218 line: &DistLine,
2219 code: &DistLineCode,
2220 active: &[usize],
2221 line_to_line_volts: f64,
2222) -> Option<f64> {
2223 for (s_max, i_max) in [
2224 (line.s_max.as_ref(), line.i_max.as_ref()),
2225 (code.s_max.as_ref(), code.i_max.as_ref()),
2226 ] {
2227 if let Some(mva) = s_max.and_then(|values| apparent_power_mva(values, active)) {
2228 return Some(mva);
2229 }
2230 if let Some(amps) = i_max.and_then(|values| limiting_amps(values, active)) {
2231 return Some(SQRT_3 * line_to_line_volts * amps / 1_000_000.0);
2232 }
2233 }
2234 None
2235}
2236
2237fn apparent_power_mva(s_max: &[f64], active: &[usize]) -> Option<f64> {
2240 let values: Vec<_> = active
2241 .iter()
2242 .filter_map(|&idx| s_max.get(idx).copied())
2243 .collect();
2244 (!values.is_empty() && values.iter().all(|value| value.is_finite()))
2245 .then(|| values.iter().sum::<f64>() / 1_000_000.0)
2246}
2247
2248fn limiting_amps(i_max: &[f64], active: &[usize]) -> Option<f64> {
2250 active
2251 .iter()
2252 .filter_map(|&idx| i_max.get(idx).copied())
2253 .filter(|value| value.is_finite() && *value >= 0.0)
2254 .reduce(f64::min)
2255}
2256
2257fn partial_phase_admittance(
2258 g: &ConductorMatrix,
2259 b: &ConductorMatrix,
2260 active: &[usize],
2261) -> Complex64 {
2262 let mut total = Complex64::new(0.0, 0.0);
2263 for &idx in active {
2264 let Some(g_row) = g.get(idx) else {
2265 continue;
2266 };
2267 let Some(b_row) = b.get(idx) else {
2268 continue;
2269 };
2270 let Some(&g_value) = g_row.get(idx) else {
2271 continue;
2272 };
2273 let Some(&b_value) = b_row.get(idx) else {
2274 continue;
2275 };
2276 total += Complex64::new(g_value, b_value);
2277 }
2278 total / 3.0
2279}
2280
2281fn si_power_to_mega(value: f64) -> f64 {
2282 value / 1_000_000.0
2283}
2284
2285fn option_vec_sum_mw(values: Option<&[f64]>) -> Option<f64> {
2286 values.map(|v| si_power_to_mega(v.iter().sum()))
2287}
2288
2289fn radians_to_degrees(value: f64) -> f64 {
2290 value * 180.0 / PI
2291}
2292
2293fn check_options(
2294 options: MulticonductorToBalancedOptions,
2295 report: &mut MulticonductorToBalancedReport,
2296) {
2297 if !options.base_mva.is_finite() || options.base_mva <= 0.0 {
2298 report.diagnostics.push(Diagnostic::of(
2299 &codes::TRANSFORM_MULTI_TO_BALANCED_INVALID_BASE_MVA,
2300format!(
2301 "base_mva must be positive and finite for multiconductor to balanced lowering; got {}",
2302 options.base_mva
2303 ),
2304 ));
2305 }
2306}
2307
2308fn check_bus_conductor_sets(
2309 net: &MulticonductorNetwork,
2310 report: &mut MulticonductorToBalancedReport,
2311) {
2312 let neutral_terminals = global_neutral_terminals(net);
2313 let mut saw_neutral = false;
2314 for (i, bus) in net.buses().iter().enumerate() {
2315 if (bus.v_min.is_none() && bus.v_min_phase.is_some())
2316 || (bus.v_max.is_none() && bus.v_max_phase.is_some())
2317 {
2318 report.diagnostics.push(Diagnostic::of(
2319 &codes::TRANSFORM_MULTI_TO_BALANCED_UNSUPPORTED_OBJECT,
2320 format!("bus {} has nonuniform phase voltage bounds that a balanced bus cannot represent", bus.id),
2321 ).with_value_target(format!("/buses/{i}")));
2322 }
2323 let active_count = active_terminal_count(&bus.terminals, Some(bus), &neutral_terminals);
2324 if active_count < bus.terminals.len() {
2325 saw_neutral = true;
2326 }
2327
2328 match active_count {
2329 3 => {}
2330 2 => report.diagnostics.push(
2331 Diagnostic::of(
2332 &codes::TRANSFORM_MULTI_TO_BALANCED_AMBIGUOUS_TERMINAL_MAP,
2333format!(
2334 "bus {} has two active terminals; no unique positive sequence projection is defined",
2335 bus.id
2336 ),
2337 )
2338 .with_value_target(format!("/buses/{i}/terminals")),
2339 ),
2340 0 | 1 => report.diagnostics.push(
2341 Diagnostic::of(
2342 &codes::TRANSFORM_MULTI_TO_BALANCED_UNSUPPORTED_CONDUCTOR_SET,
2343format!(
2344 "bus {} has {active_count} active terminal; multiconductor to balanced lowering starts with three phase input",
2345 bus.id
2346 ),
2347 )
2348 .with_value_target(format!("/buses/{i}/terminals")),
2349 ),
2350 _ => report.diagnostics.push(
2351 Diagnostic::of(
2352 &codes::TRANSFORM_MULTI_TO_BALANCED_UNSUPPORTED_CONDUCTOR_SET,
2353format!(
2354 "bus {} has {active_count} active terminals; multiconductor to balanced lowering starts with three phase input",
2355 bus.id
2356 ),
2357 )
2358 .with_value_target(format!("/buses/{i}/terminals")),
2359 ),
2360 }
2361 }
2362
2363 if saw_neutral {
2364 report
2365 .approximations
2366 .push("Kron reduction of neutral conductor before sequence transform".to_owned());
2367 report.diagnostics.push(Diagnostic::of(
2368 &codes::TRANSFORM_MULTI_TO_BALANCED_KRON_REDUCTION_REQUIRED,
2369 "neutral conductors require Kron reduction before the sequence transform",
2370 ));
2371 }
2372}
2373
2374fn check_line_terminal_maps(
2375 net: &MulticonductorNetwork,
2376 report: &mut MulticonductorToBalancedReport,
2377) {
2378 let neutral_terminals = global_neutral_terminals(net);
2379 for (i, line) in net.lines().iter().enumerate() {
2380 for (field, bus_id, terminal_map) in [
2381 (
2382 "terminal_map_from",
2383 line.bus_from.as_str(),
2384 line.terminal_map_from.as_slice(),
2385 ),
2386 (
2387 "terminal_map_to",
2388 line.bus_to.as_str(),
2389 line.terminal_map_to.as_slice(),
2390 ),
2391 ] {
2392 let bus = net.bus(bus_id);
2393 let active_count = active_terminal_count(terminal_map, bus, &neutral_terminals);
2394 if active_count != 3 {
2395 report.diagnostics.push(
2396 Diagnostic::of(
2397 &codes::TRANSFORM_MULTI_TO_BALANCED_UNSUPPORTED_CONDUCTOR_SET,
2398format!(
2399 "line {} {field} has {active_count} active terminal(s); balanced branch lowering requires three active phase conductors",
2400 line.name
2401 ),
2402 )
2403 .with_value_target(format!("/lines/{i}/{field}")),
2404 );
2405 }
2406 }
2407 }
2408}
2409
2410fn check_linecodes(net: &MulticonductorNetwork, report: &mut MulticonductorToBalancedReport) {
2411 for (i, line) in net.lines().iter().enumerate() {
2412 let Some(code) = net.linecode(&line.linecode) else {
2413 report.diagnostics.push(
2414 Diagnostic::of(
2415 &codes::TRANSFORM_MULTI_TO_BALANCED_UNKNOWN_LINECODE,
2416 format!(
2417 "line {} references unknown linecode `{}`",
2418 line.name, line.linecode
2419 ),
2420 )
2421 .with_value_target(format!("/lines/{i}/linecode")),
2422 );
2423 continue;
2424 };
2425 if code.n_conductors != line.terminal_map_from.len()
2426 || code.n_conductors != line.terminal_map_to.len()
2427 {
2428 report.diagnostics.push(
2429 Diagnostic::of(
2430 &codes::TRANSFORM_MULTI_TO_BALANCED_LINECODE_TERMINAL_MISMATCH,
2431format!(
2432 "line {} uses linecode {} with {} conductor(s), but its terminal maps have {} and {} terminal(s)",
2433 line.name,
2434 code.name,
2435 code.n_conductors,
2436 line.terminal_map_from.len(),
2437 line.terminal_map_to.len()
2438 ),
2439 )
2440 .with_value_target(format!("/lines/{i}/linecode")),
2441 );
2442 }
2443 if !square_matrix_shape(&code.r_series, code.n_conductors)
2444 || !square_matrix_shape(&code.x_series, code.n_conductors)
2445 || !square_matrix_shape(&code.g_from, code.n_conductors)
2446 || !square_matrix_shape(&code.b_from, code.n_conductors)
2447 || !square_matrix_shape(&code.g_to, code.n_conductors)
2448 || !square_matrix_shape(&code.b_to, code.n_conductors)
2449 {
2450 report.diagnostics.push(
2451 Diagnostic::of(
2452 &codes::TRANSFORM_MULTI_TO_BALANCED_INVALID_LINECODE_MATRIX,
2453 format!(
2454 "linecode {} does not carry square {} conductor matrices",
2455 code.name, code.n_conductors
2456 ),
2457 )
2458 .with_value_target(format!("/lines/{i}/linecode")),
2459 );
2460 }
2461 }
2462}
2463
2464fn square_matrix_shape(matrix: &ConductorMatrix, n: usize) -> bool {
2465 matrix.len() == n && matrix.iter().all(|row| row.len() == n)
2466}
2467
2468fn check_switches(net: &MulticonductorNetwork, report: &mut MulticonductorToBalancedReport) {
2469 let neutral_terminals = global_neutral_terminals(net);
2470 for (i, sw) in net.switches().iter().enumerate() {
2471 if sw.open {
2472 report.diagnostics.push(
2473 Diagnostic::of(
2474 &codes::TRANSFORM_MULTI_TO_BALANCED_DROPPED_OPEN_SWITCH,
2475 format!(
2476 "open switch {} is dropped by multiconductor to balanced lowering",
2477 sw.name
2478 ),
2479 )
2480 .with_value_target(format!("/switches/{i}")),
2481 );
2482 } else {
2483 report
2484 .diagnostics
2485 .extend(switch_merge_blockers(net, i, sw, &neutral_terminals));
2486 }
2487 }
2488}
2489
2490fn switch_merge_blockers(
2495 net: &MulticonductorNetwork,
2496 index: usize,
2497 sw: &powerio_dist::DistSwitch,
2498 neutral_terminals: &BTreeSet<String>,
2499) -> Vec<Diagnostic> {
2500 let path = format!("/switches/{index}");
2501 let mut blockers = Vec::new();
2502 if sw
2503 .i_max
2504 .as_ref()
2505 .is_some_and(|limits| limits.iter().any(|limit| limit.is_finite()))
2506 {
2507 blockers.push(
2508 Diagnostic::of(
2509 &codes::TRANSFORM_MULTI_TO_BALANCED_RATED_CLOSED_SWITCH,
2510 format!(
2511 "closed switch {} carries a finite ampacity; merging its buses would \
2512 remove the branch flow the limit constrains",
2513 sw.name
2514 ),
2515 )
2516 .with_value_target(path.clone()),
2517 );
2518 }
2519 let from_bus = net.bus(&sw.bus_from);
2520 let to_bus = net.bus(&sw.bus_to);
2521 if from_bus.is_none() || to_bus.is_none() {
2522 let missing = if from_bus.is_none() {
2523 &sw.bus_from
2524 } else {
2525 &sw.bus_to
2526 };
2527 blockers.push(
2528 Diagnostic::of(
2529 &codes::TRANSFORM_MULTI_TO_BALANCED_UNKNOWN_BUS,
2530 format!("switch {} references unknown bus {missing}", sw.name),
2531 )
2532 .with_value_target(path.clone()),
2533 );
2534 return blockers;
2535 }
2536 if !same_active_phase_order(
2537 from_bus,
2538 &sw.terminal_map_from,
2539 to_bus,
2540 &sw.terminal_map_to,
2541 neutral_terminals,
2542 ) {
2543 blockers.push(
2544 Diagnostic::of(
2545 &codes::TRANSFORM_MULTI_TO_BALANCED_SWITCH_TERMINAL_MISMATCH,
2546 format!(
2547 "closed switch {} does not map identical conductors on both ends, so its \
2548 buses are not electrically identical",
2549 sw.name
2550 ),
2551 )
2552 .with_value_target(path.clone()),
2553 );
2554 }
2555 if !sw.bus_from.eq_ignore_ascii_case(&sw.bus_to) {
2556 let sourced = |bus: &str| {
2557 net.sources()
2558 .iter()
2559 .any(|source| source.bus.eq_ignore_ascii_case(bus))
2560 };
2561 if sourced(&sw.bus_from) && sourced(&sw.bus_to) {
2562 blockers.push(
2563 Diagnostic::of(
2564 &codes::TRANSFORM_MULTI_TO_BALANCED_SWITCH_MERGE_CONFLICT,
2565 format!(
2566 "closed switch {} joins two buses that both carry voltage source \
2567 references",
2568 sw.name
2569 ),
2570 )
2571 .with_value_target(path.clone()),
2572 );
2573 }
2574 let (from_bus, to_bus) = (from_bus.expect("checked"), to_bus.expect("checked"));
2575 for (label, a, b) in [
2576 ("v_min", from_bus.v_min, to_bus.v_min),
2577 ("v_max", from_bus.v_max, to_bus.v_max),
2578 ] {
2579 if let (Some(a), Some(b)) = (a, b)
2580 && (a - b).abs() > f64::EPSILON * a.abs().max(b.abs()).max(1.0)
2581 {
2582 blockers.push(
2583 Diagnostic::of(
2584 &codes::TRANSFORM_MULTI_TO_BALANCED_SWITCH_MERGE_CONFLICT,
2585 format!(
2586 "closed switch {} joins buses stating different {label} bounds \
2587 ({a} and {b})",
2588 sw.name
2589 ),
2590 )
2591 .with_value_target(path.clone()),
2592 );
2593 }
2594 }
2595 }
2596 blockers
2597}
2598
2599fn global_neutral_terminals(net: &MulticonductorNetwork) -> BTreeSet<String> {
2600 net.buses()
2601 .iter()
2602 .flat_map(|bus| bus.grounded.iter().cloned())
2603 .collect()
2604}
2605
2606fn active_terminal_count(
2607 terminals: &[String],
2608 bus: Option<&DistBus>,
2609 neutral_terminals: &BTreeSet<String>,
2610) -> usize {
2611 terminals
2612 .iter()
2613 .filter(|terminal| !is_neutral_terminal(terminal, bus, neutral_terminals))
2614 .count()
2615}
2616
2617fn is_neutral_terminal(
2618 terminal: &str,
2619 bus: Option<&DistBus>,
2620 neutral_terminals: &BTreeSet<String>,
2621) -> bool {
2622 terminal == "0"
2623 || terminal.eq_ignore_ascii_case("n")
2624 || bus.is_some_and(|b| b.grounded.iter().any(|g| g == terminal))
2625 || neutral_terminals.contains(terminal)
2626}
2627
2628fn check_phase_reference(net: &MulticonductorNetwork, report: &mut MulticonductorToBalancedReport) {
2629 let neutral_terminals = global_neutral_terminals(net);
2630 let has_three_phase_source = net.sources().iter().any(|source| {
2631 let bus = net.bus(&source.bus);
2632 active_terminal_count(&source.terminal_map, bus, &neutral_terminals) == 3
2633 });
2634
2635 if !has_three_phase_source {
2636 report.diagnostics.push(Diagnostic::of(
2637 &codes::TRANSFORM_MULTI_TO_BALANCED_MISSING_PHASE_REFERENCE,
2638 "multiconductor to balanced lowering requires a three phase voltage source reference",
2639 ));
2640 }
2641}
2642
2643fn check_transformers(net: &MulticonductorNetwork, report: &mut MulticonductorToBalancedReport) {
2644 let neutral_terminals = global_neutral_terminals(net);
2645 for (i, transformer) in net.transformers().iter().enumerate() {
2646 if let Err(reason) = classify_transformer(net, transformer, &neutral_terminals) {
2647 report.diagnostics.push(
2648 Diagnostic::of(
2649 &codes::TRANSFORM_MULTI_TO_BALANCED_UNSUPPORTED_TRANSFORMER,
2650 format!("transformer {} {reason}", transformer.name),
2651 )
2652 .with_value_target(format!("/transformers/{i}")),
2653 );
2654 }
2655 }
2656}
2657
2658fn classify_transformer<'net>(
2662 net: &'net MulticonductorNetwork,
2663 transformer: &'net powerio_dist::DistTransformer,
2664 neutral_terminals: &BTreeSet<String>,
2665) -> Result<[&'net powerio_dist::DistWinding; 2], String> {
2666 use powerio_dist::DistWindingConn;
2667 if transformer.phases != 3 {
2668 return Err(format!(
2669 "has {} phases; only a three phase transformer lowers",
2670 transformer.phases
2671 ));
2672 }
2673 let [high, low] = transformer.windings.as_slice() else {
2674 return Err(format!(
2675 "has {} windings; only a two winding transformer lowers",
2676 transformer.windings.len()
2677 ));
2678 };
2679 if high.conn == low.conn {
2680 return Err(format!(
2681 "states a {:?}-{:?} connection; only wye_delta and delta_wye lower, with their \
2682 representable thirty degree shift",
2683 high.conn, low.conn
2684 ));
2685 }
2686 debug_assert!(matches!(
2687 (high.conn, low.conn),
2688 (DistWindingConn::Wye, DistWindingConn::Delta)
2689 | (DistWindingConn::Delta, DistWindingConn::Wye)
2690 ));
2691 for winding in [high, low] {
2692 let Some(bus) = net.bus(&winding.bus) else {
2693 return Err(format!("references unknown bus {}", winding.bus));
2694 };
2695 let active = active_terminal_count(&winding.terminal_map, Some(bus), neutral_terminals);
2696 if active != 3 {
2697 return Err(format!(
2698 "winding on bus {} maps {active} active conductors; a full three phase map \
2699 is required",
2700 winding.bus
2701 ));
2702 }
2703 if !(winding.v_ref.is_finite() && winding.v_ref > 0.0) {
2704 return Err(format!(
2705 "winding on bus {} has no finite positive voltage rating",
2706 winding.bus
2707 ));
2708 }
2709 if !winding.r_pct.is_finite() || winding.r_pct < 0.0 {
2710 return Err(format!(
2711 "winding on bus {} has no finite nonnegative resistance",
2712 winding.bus
2713 ));
2714 }
2715 if !winding.tap.is_finite() || winding.tap <= 0.0 {
2716 return Err(format!(
2717 "winding on bus {} has no finite positive tap",
2718 winding.bus
2719 ));
2720 }
2721 }
2722 if !(high.s_rating.is_finite() && high.s_rating > 0.0) {
2723 return Err("has no finite positive power rating".to_owned());
2724 }
2725 if !(low.s_rating.is_finite() && low.s_rating > 0.0) {
2726 return Err("has no finite positive low winding power rating".to_owned());
2727 }
2728 match transformer.xsc_pct.first() {
2729 Some(x) if x.is_finite() && *x >= 0.0 => {}
2730 _ => return Err("states no finite short circuit reactance".to_owned()),
2731 }
2732 Ok([high, low])
2733}
2734
2735fn check_untyped_objects(net: &MulticonductorNetwork, report: &mut MulticonductorToBalancedReport) {
2736 for (i, obj) in net.untyped_objects().iter().enumerate() {
2737 report.diagnostics.push(
2738 Diagnostic::of(
2739 &codes::TRANSFORM_MULTI_TO_BALANCED_UNSUPPORTED_OBJECT,
2740 format!(
2741 "{} {} is preserved as an untyped object and cannot be lowered",
2742 obj.class, obj.name
2743 ),
2744 )
2745 .with_value_target(format!("/untyped/{i}")),
2746 );
2747 }
2748}
2749
2750#[cfg(test)]
2751mod history_record_tests {
2752 use super::{capped_history_notes, unused_history_id};
2753
2754 #[test]
2755 fn note_overflow_is_stated_within_the_cap() {
2756 let cap = powerio_core::limits::MAX_HISTORY_NOTES;
2757 let notes: Vec<String> = (0..cap + 40).map(|index| format!("note {index}")).collect();
2758 let kept = capped_history_notes(notes, "assumptions");
2759 assert_eq!(kept.len(), cap);
2760 assert_eq!(kept.last().unwrap(), "41 more assumptions elided");
2761
2762 let short: Vec<String> = (0..3).map(|index| format!("note {index}")).collect();
2763 assert_eq!(capped_history_notes(short.clone(), "assumptions"), short);
2764 }
2765
2766 #[test]
2767 fn the_history_id_is_minted_unused() {
2768 use powerio_core::{HistoryEntry, HistoryId, HistoryKind, PioModule};
2769 let mut module = PioModule::new(crate::PioValue::BalancedNetwork(
2770 crate::BalancedNetwork::in_memory("t", 100.0, Vec::new(), Vec::new()),
2771 ));
2772 assert_eq!(
2773 unused_history_id(&module, "multiconductor-to-balanced").as_str(),
2774 "multiconductor-to-balanced"
2775 );
2776 for id in ["multiconductor-to-balanced", "multiconductor-to-balanced-2"] {
2777 module
2778 .add_history_entry(
2779 HistoryEntry::new(
2780 HistoryId::new(id).unwrap(),
2781 HistoryKind::Transform,
2782 "to_balanced",
2783 )
2784 .unwrap(),
2785 )
2786 .unwrap();
2787 }
2788 assert_eq!(
2789 unused_history_id(&module, "multiconductor-to-balanced").as_str(),
2790 "multiconductor-to-balanced-3"
2791 );
2792 }
2793}