1use std::collections::{BTreeMap, BTreeSet};
9
10use powerio_core::{Diagnostic, DiagnosticInfo, DiagnosticSeverity, Error};
11use powerio_dist::{Configuration, DistLoadVoltageModel, MulticonductorNetwork};
12use serde::{Deserialize, Serialize};
13
14use super::McAcOpfInstance;
15use crate::diagnostics::codes;
16use crate::{MulticonductorOperatingPointQuantity, ObjectiveTerm};
17
18#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
20#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
21#[serde(rename_all = "snake_case")]
22#[non_exhaustive]
23pub enum LinDist3FlowReferencePolicy {
24 #[default]
27 Auto,
28 Explicit,
30 SourcePropagated,
32}
33
34#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
36#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
37#[serde(rename_all = "snake_case")]
38#[non_exhaustive]
39pub enum LinDist3FlowUnsupported {
40 #[default]
41 Reject,
42 Lower,
43 Approximate,
44 Permissive,
45}
46
47#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
49#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
50#[non_exhaustive]
51pub struct LinDist3FlowBuildOptions {
52 pub reference_policy: LinDist3FlowReferencePolicy,
53 pub unsupported: LinDist3FlowUnsupported,
54 pub require_neutral_provenance: bool,
57}
58
59impl LinDist3FlowBuildOptions {
60 #[must_use]
61 pub const fn with_reference_policy(mut self, policy: LinDist3FlowReferencePolicy) -> Self {
62 self.reference_policy = policy;
63 self
64 }
65
66 #[must_use]
67 pub const fn with_unsupported(mut self, policy: LinDist3FlowUnsupported) -> Self {
68 self.unsupported = policy;
69 self
70 }
71
72 #[must_use]
73 pub const fn with_required_neutral_provenance(mut self, required: bool) -> Self {
74 self.require_neutral_provenance = required;
75 self
76 }
77}
78
79#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
81#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
82#[non_exhaustive]
83pub struct LinDist3FlowNode {
84 pub bus: String,
85 pub terminal: String,
86}
87
88#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
90#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
91#[non_exhaustive]
92pub struct LinDist3FlowOrientedConductor {
93 pub line: String,
94 pub source_line_row: usize,
95 pub conductor_position: usize,
96 pub parent: LinDist3FlowNode,
97 pub child: LinDist3FlowNode,
98 pub reversed: bool,
100}
101
102#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
104#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
105#[non_exhaustive]
106pub struct LinDist3FlowTopology {
107 pub nodes: Vec<LinDist3FlowNode>,
108 pub conductors: Vec<LinDist3FlowOrientedConductor>,
109 pub roots: Vec<LinDist3FlowNode>,
110 pub islands: Vec<Vec<LinDist3FlowNode>>,
111}
112
113#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
115#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
116#[serde(rename_all = "snake_case")]
117#[non_exhaustive]
118pub enum LinDist3FlowApplicabilityStatus {
119 Applicable,
120 Inapplicable,
121}
122
123#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
125#[non_exhaustive]
126pub struct LinDist3FlowApplicability {
127 pub status: LinDist3FlowApplicabilityStatus,
128 pub diagnostics: Vec<Diagnostic>,
129 pub roots: Vec<LinDist3FlowNode>,
130 pub islands: Vec<Vec<LinDist3FlowNode>>,
131 pub reference_provenance: Option<LinDist3FlowReferenceProvenance>,
132 pub kron_reduced: bool,
133 pub lowered: bool,
134}
135
136impl LinDist3FlowApplicability {
137 #[must_use]
138 pub const fn is_applicable(&self) -> bool {
139 matches!(self.status, LinDist3FlowApplicabilityStatus::Applicable)
140 }
141}
142
143#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
145#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
146#[serde(rename_all = "snake_case")]
147#[non_exhaustive]
148pub enum LinDist3FlowReferenceProvenance {
149 InitialPoint,
150 SourcePropagated,
151}
152
153#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
155#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
156#[non_exhaustive]
157pub struct LinDist3FlowReferenceVoltage {
158 pub node: LinDist3FlowNode,
159 pub magnitude: f64,
161 pub angle: f64,
163}
164
165#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
167#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
168#[non_exhaustive]
169pub struct LinDist3FlowReferenceState {
170 pub provenance: LinDist3FlowReferenceProvenance,
171 pub voltages: Vec<LinDist3FlowReferenceVoltage>,
172}
173
174impl LinDist3FlowReferenceState {
175 #[must_use]
176 pub fn voltage(&self, bus: &str, terminal: &str) -> Option<&LinDist3FlowReferenceVoltage> {
177 self.voltages.iter().find(|voltage| {
178 voltage.node.bus.eq_ignore_ascii_case(bus) && voltage.node.terminal == terminal
179 })
180 }
181}
182
183#[derive(Clone, Debug)]
185pub struct LinDist3FlowOpfInstance {
186 base: McAcOpfInstance,
187 topology: LinDist3FlowTopology,
188 reference: LinDist3FlowReferenceState,
189 applicability: LinDist3FlowApplicability,
190 options: LinDist3FlowBuildOptions,
191}
192
193impl LinDist3FlowOpfInstance {
194 pub fn from_network(
199 network: MulticonductorNetwork,
200 options: LinDist3FlowBuildOptions,
201 ) -> Result<Self, Error> {
202 Self::from_mc_ac(McAcOpfInstance::from_network(network)?, options)
203 }
204
205 pub fn from_mc_ac(
211 base: McAcOpfInstance,
212 options: LinDist3FlowBuildOptions,
213 ) -> Result<Self, Error> {
214 let (applicability, topology, reference) = assess(&base, options);
215 if let Some(diagnostic) = applicability
216 .diagnostics
217 .iter()
218 .find(|finding| finding.severity() == DiagnosticSeverity::Error)
219 {
220 return Err(error_from_diagnostic(diagnostic));
221 }
222 let topology = topology.ok_or_else(|| {
223 Error::new(
224 &codes::BUILD_LINDIST3FLOW_TOPOLOGY_INVALID,
225 "the applicable assessment did not produce a conductor topology",
226 )
227 })?;
228 let reference = reference.ok_or_else(|| {
229 Error::new(
230 &codes::BUILD_LINDIST3FLOW_REFERENCE_INVALID,
231 "the applicable assessment did not produce a coefficient reference",
232 )
233 })?;
234 Ok(Self {
235 base,
236 topology,
237 reference,
238 applicability,
239 options,
240 })
241 }
242
243 #[must_use]
244 pub fn base_instance(&self) -> &McAcOpfInstance {
245 &self.base
246 }
247
248 #[must_use]
249 pub fn network(&self) -> &MulticonductorNetwork {
250 self.base.network()
251 }
252
253 #[must_use]
254 pub const fn topology(&self) -> &LinDist3FlowTopology {
255 &self.topology
256 }
257
258 #[must_use]
259 pub const fn reference(&self) -> &LinDist3FlowReferenceState {
260 &self.reference
261 }
262
263 #[must_use]
264 pub const fn applicability(&self) -> &LinDist3FlowApplicability {
265 &self.applicability
266 }
267
268 #[must_use]
269 pub const fn options(&self) -> LinDist3FlowBuildOptions {
270 self.options
271 }
272}
273
274fn finding(
275 info: &'static DiagnosticInfo,
276 message: impl Into<String>,
277 target: Option<String>,
278) -> Diagnostic {
279 let mut diagnostic = Diagnostic::of(info, message);
280 if let Some(target) = target {
281 let _ = diagnostic.set_target(target);
282 }
283 diagnostic
284}
285
286fn error_from_diagnostic(diagnostic: &Diagnostic) -> Error {
287 Error::new(
288 diagnostic
289 .registered_info()
290 .expect("LinDist3Flow findings use registered codes"),
291 diagnostic.message(),
292 )
293}
294
295fn matrix_has_nonzero(matrix: &[Vec<f64>]) -> bool {
296 matrix.iter().flatten().any(|value| *value != 0.0)
297}
298
299fn check_objective(instance: &McAcOpfInstance, diagnostics: &mut Vec<Diagnostic>) {
300 let network = instance.network();
301 let dispatch_cost = match instance.objective().terms() {
302 [] => false,
303 [ObjectiveTerm::ActivePowerDispatchCost] => true,
304 terms => {
305 diagnostics.push(finding(
306 &codes::BUILD_LINDIST3FLOW_OBJECTIVE_UNSUPPORTED,
307 format!(
308 "the strict LinDist3Flow slice accepts feasibility or exactly one active-power dispatch-cost term, but received {} term(s)",
309 terms.len()
310 ),
311 None,
312 ));
313 false
314 }
315 };
316 if !dispatch_cost {
317 return;
318 }
319 for (row, generator) in network.generators().iter().enumerate() {
320 if generator.cost.is_none() {
321 diagnostics.push(finding(
322 &codes::BUILD_LINDIST3FLOW_COST_MISSING,
323 format!(
324 "generator `{}` has no active-power dispatch cost; its coefficient is zero",
325 generator.name
326 ),
327 Some(format!("/generators/{row}/cost")),
328 ));
329 }
330 }
331 for (row, source) in network.sources().iter().enumerate() {
332 if source.energy_cost_rate.is_none() {
333 diagnostics.push(finding(
334 &codes::BUILD_LINDIST3FLOW_COST_MISSING,
335 format!(
336 "voltage source `{}` has no energy cost rate; its coefficient is zero",
337 source.name
338 ),
339 Some(format!("/sources/{row}/energy_cost_rate")),
340 ));
341 }
342 }
343}
344
345fn supported_connection(configuration: Configuration, terminals: usize, channels: usize) -> bool {
346 match configuration {
347 Configuration::Wye => terminals == channels,
348 Configuration::SinglePhase => terminals == channels || (terminals == 2 && channels == 1),
349 Configuration::Delta => {
350 (terminals == 2 && channels == 1) || (terminals == 3 && channels == 3)
351 }
352 _ => false,
353 }
354}
355
356fn scalar_or_channels(values: &[f64], channels: usize) -> bool {
357 matches!(values.len(), 1) || values.len() == channels
358}
359
360fn finite_scalar_or_channels(values: &[f64], channels: usize) -> bool {
361 scalar_or_channels(values, channels) && values.iter().all(|value| value.is_finite())
362}
363
364fn positive_scalar_or_channels(values: &[f64], channels: usize) -> bool {
365 scalar_or_channels(values, channels)
366 && values.iter().all(|value| value.is_finite() && *value > 0.0)
367}
368
369fn valid_bounds(lower: Option<&[f64]>, upper: Option<&[f64]>, channels: usize) -> bool {
370 match (lower, upper) {
371 (None, None) => true,
372 (Some(lower), Some(upper)) => {
373 lower.len() == channels
374 && upper.len() == channels
375 && lower
376 .iter()
377 .zip(upper)
378 .all(|(lower, upper)| lower.is_finite() && upper.is_finite() && lower <= upper)
379 }
380 _ => false,
381 }
382}
383
384fn square_finite(matrix: &[Vec<f64>], dimension: usize) -> bool {
385 matrix.len() == dimension
386 && matrix
387 .iter()
388 .all(|row| row.len() == dimension && row.iter().all(|value| value.is_finite()))
389}
390
391fn device_invalid(diagnostics: &mut Vec<Diagnostic>, message: impl Into<String>, target: String) {
392 diagnostics.push(finding(
393 &codes::BUILD_LINDIST3FLOW_DEVICE_INVALID,
394 message,
395 Some(target),
396 ));
397}
398
399#[allow(clippy::too_many_lines)]
400fn check_device_shapes(instance: &McAcOpfInstance, diagnostics: &mut Vec<Diagnostic>) {
401 let network = instance.network();
402 for (row, load) in network.loads().iter().enumerate() {
403 let channels = load.p_nom.len();
404 let mut valid = channels != 0
405 && load.q_nom.len() == channels
406 && load.p_nom.iter().all(|value| value.is_finite())
407 && load.q_nom.iter().all(|value| value.is_finite())
408 && supported_connection(load.configuration, load.terminal_map.len(), channels);
409 valid &= match &load.voltage_model {
410 DistLoadVoltageModel::ConstantPower { .. }
411 | DistLoadVoltageModel::ConstantCurrent { .. }
412 | DistLoadVoltageModel::Exponential { .. } => true,
413 DistLoadVoltageModel::ConstantImpedance { v_nom } => {
414 positive_scalar_or_channels(v_nom, channels)
415 }
416 DistLoadVoltageModel::Zip {
417 v_nom,
418 alpha_z,
419 alpha_i,
420 alpha_p,
421 beta_z,
422 beta_i,
423 beta_p,
424 } => {
425 positive_scalar_or_channels(v_nom, channels)
426 && [alpha_z, alpha_i, alpha_p, beta_z, beta_i, beta_p]
427 .into_iter()
428 .all(|values| finite_scalar_or_channels(values, channels))
429 }
430 _ => false,
431 };
432 if !valid {
433 device_invalid(
434 diagnostics,
435 format!(
436 "load `{}` has invalid channel, connection, nominal-power, or voltage-model dimensions",
437 load.name
438 ),
439 format!("/loads/{row}"),
440 );
441 }
442 }
443
444 for (row, generator) in network.generators().iter().enumerate() {
445 let channels = generator.p_nom.len();
446 let limits_valid = [generator.s_max.as_deref(), generator.i_max.as_deref()]
447 .into_iter()
448 .flatten()
449 .all(|values| {
450 values.len() == channels
451 && values.iter().all(|value| value.is_finite() && *value > 0.0)
452 });
453 let cost_valid = generator
454 .cost
455 .as_deref()
456 .is_none_or(|values| finite_scalar_or_channels(values, channels));
457 let valid = channels != 0
458 && generator.q_nom.len() == channels
459 && generator.p_nom.iter().all(|value| value.is_finite())
460 && generator.q_nom.iter().all(|value| value.is_finite())
461 && supported_connection(
462 generator.configuration,
463 generator.terminal_map.len(),
464 channels,
465 )
466 && valid_bounds(
467 generator.p_min.as_deref(),
468 generator.p_max.as_deref(),
469 channels,
470 )
471 && valid_bounds(
472 generator.q_min.as_deref(),
473 generator.q_max.as_deref(),
474 channels,
475 )
476 && limits_valid
477 && cost_valid;
478 if !valid {
479 device_invalid(
480 diagnostics,
481 format!(
482 "generator `{}` has invalid channel, connection, paired-bound, rating, or cost dimensions",
483 generator.name
484 ),
485 format!("/generators/{row}"),
486 );
487 }
488 }
489
490 for (row, shunt) in network.shunts().iter().enumerate() {
491 let terminals = shunt.terminal_map.len();
492 if terminals == 0
493 || !square_finite(&shunt.g, terminals)
494 || !square_finite(&shunt.b, terminals)
495 {
496 device_invalid(
497 diagnostics,
498 format!(
499 "shunt `{}` admittance matrices are not finite {terminals}x{terminals} arrays",
500 shunt.name
501 ),
502 format!("/shunts/{row}"),
503 );
504 }
505 }
506
507 for (row, source) in network.sources().iter().enumerate() {
508 let channels = source.terminal_map.len();
509 let valid = channels != 0
510 && source.v_magnitude.len() == channels
511 && source.v_angle.len() == channels
512 && source
513 .v_magnitude
514 .iter()
515 .all(|value| value.is_finite() && *value > 0.0)
516 && source.v_angle.iter().all(|value| value.is_finite())
517 && source
518 .energy_cost_rate
519 .as_deref()
520 .is_none_or(|values| finite_scalar_or_channels(values, channels));
521 if !valid {
522 device_invalid(
523 diagnostics,
524 format!(
525 "voltage source `{}` has invalid terminal, phasor, or cost dimensions",
526 source.name
527 ),
528 format!("/sources/{row}"),
529 );
530 }
531 }
532}
533
534fn check_supported_slice(
535 instance: &McAcOpfInstance,
536 options: LinDist3FlowBuildOptions,
537 diagnostics: &mut Vec<Diagnostic>,
538) {
539 let network = instance.network();
540 check_objective(instance, diagnostics);
541 check_device_shapes(instance, diagnostics);
542 if options.unsupported != LinDist3FlowUnsupported::Reject {
543 diagnostics.push(finding(
544 &codes::BUILD_LINDIST3FLOW_POLICY_UNAVAILABLE,
545 format!(
546 "the {:?} unsupported-data policy is reserved but not implemented yet",
547 options.unsupported
548 ),
549 None,
550 ));
551 }
552
553 let conventions = network.extras().get("bmopf_terminal_conventions");
554 for (row, bus) in network.buses().iter().enumerate() {
555 if bus.phase_indices(conventions).len() != bus.terminals.len() {
556 diagnostics.push(finding(
557 &codes::BUILD_LINDIST3FLOW_EXPLICIT_NEUTRAL,
558 format!(
559 "bus `{}` still declares an explicit neutral conductor; apply neutral_kron_reduce first",
560 bus.id
561 ),
562 Some(format!("/buses/{row}/terminals")),
563 ));
564 }
565 }
566 if options.require_neutral_provenance && !network.extras().contains_key("powerio_neutral_kron")
567 {
568 diagnostics.push(finding(
569 &codes::BUILD_LINDIST3FLOW_EXPLICIT_NEUTRAL,
570 "the instance requires neutral-Kron provenance but the network carries none",
571 None,
572 ));
573 }
574
575 for (row, line) in network.lines().iter().enumerate() {
576 let Some(code) = network.linecode(&line.linecode) else {
577 continue;
578 };
579 if matrix_has_nonzero(&code.g_from)
580 || matrix_has_nonzero(&code.b_from)
581 || matrix_has_nonzero(&code.g_to)
582 || matrix_has_nonzero(&code.b_to)
583 {
584 diagnostics.push(finding(
585 &codes::BUILD_LINDIST3FLOW_UNSUPPORTED_COMPONENT,
586 format!(
587 "line `{}` has pi shunt admittance; endpoint-shunt lowering is not implemented yet",
588 line.name
589 ),
590 Some(format!("/lines/{row}/linecode")),
591 ));
592 }
593 }
594 for (row, load) in network.loads().iter().enumerate() {
595 let supported = match &load.voltage_model {
596 DistLoadVoltageModel::ConstantPower { .. }
597 | DistLoadVoltageModel::ConstantImpedance { .. } => true,
598 DistLoadVoltageModel::Zip {
599 alpha_i, beta_i, ..
600 } => {
601 alpha_i.iter().all(|value| value.abs() <= f64::EPSILON)
602 && beta_i.iter().all(|value| value.abs() <= f64::EPSILON)
603 }
604 DistLoadVoltageModel::ConstantCurrent { .. }
605 | DistLoadVoltageModel::Exponential { .. }
606 | _ => false,
607 };
608 if !supported {
609 diagnostics.push(finding(
610 &codes::BUILD_LINDIST3FLOW_UNSUPPORTED_COMPONENT,
611 format!(
612 "load `{}` needs a current/exponential approximation outside the strict model",
613 load.name
614 ),
615 Some(format!("/loads/{row}/voltage_model")),
616 ));
617 }
618 }
619
620 for (family, count) in [
621 ("switch", network.switches().len()),
622 ("transformer", network.transformers().len()),
623 ("capacitor", network.capacitors().len()),
624 ("IBR", network.ibrs().len()),
625 ("untyped object", network.untyped_objects().len()),
626 ] {
627 if count != 0 {
628 diagnostics.push(finding(
629 &codes::BUILD_LINDIST3FLOW_UNSUPPORTED_COMPONENT,
630 format!(
631 "the initial strict LinDist3Flow slice does not yet compile {count} {family} record(s)"
632 ),
633 None,
634 ));
635 }
636 }
637}
638
639#[must_use]
643pub fn check_lindist3flow_applicability(
644 instance: &McAcOpfInstance,
645 options: LinDist3FlowBuildOptions,
646) -> LinDist3FlowApplicability {
647 assess(instance, options).0
648}
649
650fn assess(
651 instance: &McAcOpfInstance,
652 options: LinDist3FlowBuildOptions,
653) -> (
654 LinDist3FlowApplicability,
655 Option<LinDist3FlowTopology>,
656 Option<LinDist3FlowReferenceState>,
657) {
658 let mut diagnostics = Vec::new();
659 check_supported_slice(instance, options, &mut diagnostics);
660 let topology = match build_topology(instance.network()) {
661 Ok(topology) => Some(topology),
662 Err(message) => {
663 diagnostics.push(finding(
664 &codes::BUILD_LINDIST3FLOW_TOPOLOGY_INVALID,
665 message,
666 None,
667 ));
668 None
669 }
670 };
671 let reference = topology.as_ref().and_then(|topology| {
672 match build_reference(instance, topology, options.reference_policy) {
673 Ok(reference) => Some(reference),
674 Err(error) => {
675 diagnostics.extend(error.into_diagnostics());
676 None
677 }
678 }
679 });
680 let status = if diagnostics
681 .iter()
682 .any(|finding| finding.severity() == DiagnosticSeverity::Error)
683 {
684 LinDist3FlowApplicabilityStatus::Inapplicable
685 } else {
686 LinDist3FlowApplicabilityStatus::Applicable
687 };
688 let roots = topology
689 .as_ref()
690 .map_or_else(Vec::new, |topology| topology.roots.clone());
691 let islands = topology
692 .as_ref()
693 .map_or_else(Vec::new, |topology| topology.islands.clone());
694 (
695 LinDist3FlowApplicability {
696 status,
697 diagnostics,
698 roots,
699 islands,
700 reference_provenance: reference.as_ref().map(|reference| reference.provenance),
701 kron_reduced: instance
702 .network()
703 .extras()
704 .contains_key("powerio_neutral_kron"),
705 lowered: false,
706 },
707 topology,
708 reference,
709 )
710}
711
712#[derive(Clone, Copy)]
713struct Edge {
714 from: usize,
715 to: usize,
716 line: usize,
717 conductor: usize,
718}
719
720struct UnionFind {
721 parent: Vec<usize>,
722}
723
724impl UnionFind {
725 fn new(n: usize) -> Self {
726 Self {
727 parent: (0..n).collect(),
728 }
729 }
730
731 fn find(&mut self, node: usize) -> usize {
732 let mut root = node;
733 while self.parent[root] != root {
734 root = self.parent[root];
735 }
736 let mut cursor = node;
737 while self.parent[cursor] != root {
738 let next = self.parent[cursor];
739 self.parent[cursor] = root;
740 cursor = next;
741 }
742 root
743 }
744
745 fn join(&mut self, left: usize, right: usize) -> bool {
746 let left = self.find(left);
747 let right = self.find(right);
748 if left == right {
749 return false;
750 }
751 self.parent[left.max(right)] = left.min(right);
752 true
753 }
754}
755
756fn node_key(bus: &str, terminal: &str) -> (String, String) {
757 (bus.to_ascii_lowercase(), terminal.to_owned())
758}
759
760#[allow(clippy::too_many_lines)]
761fn build_topology(network: &MulticonductorNetwork) -> Result<LinDist3FlowTopology, String> {
762 let mut nodes = Vec::new();
763 let mut positions = BTreeMap::new();
764 let mut bus_ids = BTreeSet::new();
765 let mut bus_positions = BTreeMap::new();
766 for bus in network.buses() {
767 if !bus_ids.insert(bus.id.to_ascii_lowercase()) {
768 return Err(format!(
769 "bus identity `{}` is duplicated case-insensitively",
770 bus.id
771 ));
772 }
773 bus_positions.insert(bus.id.to_ascii_lowercase(), bus_positions.len());
774 let mut terminals = BTreeSet::new();
775 for terminal in &bus.terminals {
776 if !terminals.insert(terminal.clone()) {
777 return Err(format!("bus `{}` repeats terminal `{terminal}`", bus.id));
778 }
779 let node = LinDist3FlowNode {
780 bus: bus.id.clone(),
781 terminal: terminal.clone(),
782 };
783 positions.insert(node_key(&node.bus, &node.terminal), nodes.len());
784 nodes.push(node);
785 }
786 }
787 if nodes.is_empty() {
788 return Err("the network has no retained bus terminals".to_owned());
789 }
790
791 let mut forest = UnionFind::new(nodes.len());
792 let mut bus_forest = UnionFind::new(bus_positions.len());
793 let mut edges = Vec::new();
794 for (line_row, line) in network.lines().iter().enumerate() {
795 if line.terminal_map_from.len() != line.terminal_map_to.len()
796 || line.terminal_map_from.is_empty()
797 {
798 return Err(format!(
799 "line `{}` has empty or unequal terminal maps",
800 line.name
801 ));
802 }
803 let from_bus = *bus_positions
804 .get(&line.bus_from.to_ascii_lowercase())
805 .ok_or_else(|| {
806 format!(
807 "line `{}` names unknown from bus `{}`",
808 line.name, line.bus_from
809 )
810 })?;
811 let to_bus = *bus_positions
812 .get(&line.bus_to.to_ascii_lowercase())
813 .ok_or_else(|| {
814 format!(
815 "line `{}` names unknown to bus `{}`",
816 line.name, line.bus_to
817 )
818 })?;
819 bus_forest.join(from_bus, to_bus);
820 for (conductor, (from_terminal, to_terminal)) in line
821 .terminal_map_from
822 .iter()
823 .zip(&line.terminal_map_to)
824 .enumerate()
825 {
826 let from = *positions
827 .get(&node_key(&line.bus_from, from_terminal))
828 .ok_or_else(|| {
829 format!(
830 "line `{}` names undeclared from terminal `{}/{from_terminal}`",
831 line.name, line.bus_from
832 )
833 })?;
834 let to = *positions
835 .get(&node_key(&line.bus_to, to_terminal))
836 .ok_or_else(|| {
837 format!(
838 "line `{}` names undeclared to terminal `{}/{to_terminal}`",
839 line.name, line.bus_to
840 )
841 })?;
842 if !forest.join(from, to) {
843 return Err(format!(
844 "line `{}` conductor {} closes a cycle in the conductor-resolved graph",
845 line.name,
846 conductor + 1
847 ));
848 }
849 edges.push(Edge {
850 from,
851 to,
852 line: line_row,
853 conductor,
854 });
855 }
856 }
857
858 let mut island_indices: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
859 for node in 0..nodes.len() {
860 island_indices
861 .entry(forest.find(node))
862 .or_default()
863 .push(node);
864 }
865 let mut source_nodes: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
866 let mut island_sources: BTreeMap<usize, Vec<&str>> = BTreeMap::new();
867 for source in network.sources() {
868 let bus = *bus_positions
869 .get(&source.bus.to_ascii_lowercase())
870 .ok_or_else(|| {
871 format!(
872 "voltage source `{}` names unknown bus `{}`",
873 source.name, source.bus
874 )
875 })?;
876 island_sources
877 .entry(bus_forest.find(bus))
878 .or_default()
879 .push(&source.name);
880 for terminal in &source.terminal_map {
881 let node = *positions
882 .get(&node_key(&source.bus, terminal))
883 .ok_or_else(|| {
884 format!(
885 "voltage source `{}` names undeclared terminal `{}/{terminal}`",
886 source.name, source.bus
887 )
888 })?;
889 source_nodes
890 .entry(forest.find(node))
891 .or_default()
892 .push(node);
893 }
894 }
895 let mut physical_islands: BTreeMap<usize, Vec<&str>> = BTreeMap::new();
896 for bus in network.buses() {
897 let position = bus_positions[&bus.id.to_ascii_lowercase()];
898 physical_islands
899 .entry(bus_forest.find(position))
900 .or_default()
901 .push(&bus.id);
902 }
903 for (component, buses) in physical_islands {
904 let sources: &[&str] = island_sources.get(&component).map_or(&[], Vec::as_slice);
905 if sources.len() != 1 {
906 return Err(format!(
907 "physical island containing bus `{}` has {} voltage source records; exactly one is required",
908 buses[0],
909 sources.len()
910 ));
911 }
912 }
913
914 let mut ordered_islands = island_indices.into_values().collect::<Vec<_>>();
915 ordered_islands.sort_by_key(|island| island[0]);
916 let mut root_indices = Vec::with_capacity(ordered_islands.len());
917 for island in &ordered_islands {
918 let component = forest.find(island[0]);
919 let roots: &[usize] = source_nodes.get(&component).map_or(&[], Vec::as_slice);
920 if roots.len() != 1 {
921 return Err(format!(
922 "conductor island rooted at `{}/{}` contains {} fixed-voltage source terminals; exactly one is required",
923 nodes[island[0]].bus,
924 nodes[island[0]].terminal,
925 roots.len()
926 ));
927 }
928 root_indices.push(roots[0]);
929 }
930
931 let mut adjacency = vec![Vec::<(usize, usize)>::new(); nodes.len()];
932 for (edge_index, edge) in edges.iter().enumerate() {
933 adjacency[edge.from].push((edge_index, edge.to));
934 adjacency[edge.to].push((edge_index, edge.from));
935 }
936 let mut directions = vec![None; edges.len()];
937 for &root in &root_indices {
938 let mut stack = vec![(root, usize::MAX)];
939 while let Some((parent, incoming)) = stack.pop() {
940 for &(edge_index, child) in &adjacency[parent] {
941 if edge_index == incoming {
942 continue;
943 }
944 directions[edge_index] = Some((parent, child));
945 stack.push((child, edge_index));
946 }
947 }
948 }
949 let mut line_directions = BTreeMap::new();
950 for (edge, direction) in edges.iter().zip(&directions) {
951 let (parent, _) = direction.expect("each source-rooted forest edge is visited");
952 let reversed = parent != edge.from;
953 if line_directions
954 .insert(edge.line, reversed)
955 .is_some_and(|previous| previous != reversed)
956 {
957 return Err(format!(
958 "line `{}` is reached in conflicting directions across its coupled conductors",
959 network.lines()[edge.line].name
960 ));
961 }
962 }
963 let conductors = edges
964 .iter()
965 .zip(directions)
966 .map(|(edge, direction)| {
967 let (parent, child) = direction.expect("each source-rooted forest edge is visited");
968 LinDist3FlowOrientedConductor {
969 line: network.lines()[edge.line].name.clone(),
970 source_line_row: edge.line,
971 conductor_position: edge.conductor,
972 parent: nodes[parent].clone(),
973 child: nodes[child].clone(),
974 reversed: parent != edge.from,
975 }
976 })
977 .collect();
978 Ok(LinDist3FlowTopology {
979 roots: root_indices
980 .iter()
981 .map(|&root| nodes[root].clone())
982 .collect(),
983 islands: ordered_islands
984 .iter()
985 .map(|island| island.iter().map(|&node| nodes[node].clone()).collect())
986 .collect(),
987 nodes,
988 conductors,
989 })
990}
991
992fn valid_phasor(magnitude: f64, angle: f64) -> bool {
993 magnitude.is_finite() && magnitude > 0.0 && angle.is_finite()
994}
995
996fn invalid_reference(message: impl Into<String>) -> Error {
997 Error::new(&codes::BUILD_LINDIST3FLOW_REFERENCE_INVALID, message)
998}
999
1000fn explicit_reference(
1001 instance: &McAcOpfInstance,
1002 topology: &LinDist3FlowTopology,
1003) -> Result<LinDist3FlowReferenceState, Error> {
1004 let point = instance.initial_point().ok_or_else(|| {
1005 invalid_reference("the explicit reference policy requires a voltage initial point")
1006 })?;
1007 if point
1008 .values(MulticonductorOperatingPointQuantity::TerminalVoltageMagnitude)
1009 .is_none()
1010 || point
1011 .values(MulticonductorOperatingPointQuantity::TerminalVoltageAngle)
1012 .is_none()
1013 {
1014 return Err(invalid_reference(
1015 "the initial point must contain both terminal voltage magnitude and angle columns",
1016 ));
1017 }
1018 let mut voltages = Vec::with_capacity(topology.nodes.len());
1019 for node in &topology.nodes {
1020 let magnitude = point
1021 .terminal_voltage_magnitude(&node.bus, &node.terminal)
1022 .ok_or_else(|| {
1023 invalid_reference(format!(
1024 "the initial point has no voltage magnitude for `{}/{}`",
1025 node.bus, node.terminal
1026 ))
1027 })?;
1028 let angle = point
1029 .terminal_voltage_angle(&node.bus, &node.terminal)
1030 .ok_or_else(|| {
1031 invalid_reference(format!(
1032 "the initial point has no voltage angle for `{}/{}`",
1033 node.bus, node.terminal
1034 ))
1035 })?;
1036 if !valid_phasor(magnitude, angle) {
1037 return Err(invalid_reference(format!(
1038 "the initial point voltage for `{}/{}` is zero or non-finite",
1039 node.bus, node.terminal
1040 )));
1041 }
1042 voltages.push(LinDist3FlowReferenceVoltage {
1043 node: node.clone(),
1044 magnitude,
1045 angle,
1046 });
1047 }
1048 Ok(LinDist3FlowReferenceState {
1049 provenance: LinDist3FlowReferenceProvenance::InitialPoint,
1050 voltages,
1051 })
1052}
1053
1054#[allow(clippy::too_many_lines)]
1055fn propagated_reference(
1056 instance: &McAcOpfInstance,
1057 topology: &LinDist3FlowTopology,
1058) -> Result<LinDist3FlowReferenceState, Error> {
1059 let mut positions = BTreeMap::new();
1060 for (position, node) in topology.nodes.iter().enumerate() {
1061 positions.insert(node_key(&node.bus, &node.terminal), position);
1062 }
1063 let mut values = vec![None; topology.nodes.len()];
1064 for source in instance.network().sources() {
1065 if source.v_magnitude.len() != source.terminal_map.len()
1066 || source.v_angle.len() != source.terminal_map.len()
1067 {
1068 return Err(invalid_reference(format!(
1069 "voltage source `{}` values do not align with its terminal map",
1070 source.name
1071 )));
1072 }
1073 for (terminal_position, terminal) in source.terminal_map.iter().enumerate() {
1074 let node = *positions
1075 .get(&node_key(&source.bus, terminal))
1076 .ok_or_else(|| {
1077 invalid_reference(format!(
1078 "voltage source `{}` names unknown terminal `{}/{terminal}`",
1079 source.name, source.bus
1080 ))
1081 })?;
1082 let magnitude = source.v_magnitude[terminal_position];
1083 let angle = source.v_angle[terminal_position];
1084 if !valid_phasor(magnitude, angle) {
1085 return Err(invalid_reference(format!(
1086 "voltage source `{}` terminal `{terminal}` is zero or non-finite",
1087 source.name
1088 )));
1089 }
1090 values[node] = Some((magnitude, angle));
1091 }
1092 }
1093
1094 let mut children = vec![Vec::new(); topology.nodes.len()];
1095 for conductor in &topology.conductors {
1096 let parent = positions[&node_key(&conductor.parent.bus, &conductor.parent.terminal)];
1097 let child = positions[&node_key(&conductor.child.bus, &conductor.child.terminal)];
1098 children[parent].push(child);
1099 }
1100 for root in &topology.roots {
1101 let root = positions[&node_key(&root.bus, &root.terminal)];
1102 let mut stack = vec![root];
1103 while let Some(parent) = stack.pop() {
1104 let value = values[parent].ok_or_else(|| {
1105 invalid_reference(format!(
1106 "source root `{}/{}` has no phasor",
1107 topology.nodes[parent].bus, topology.nodes[parent].terminal
1108 ))
1109 })?;
1110 for &child in &children[parent] {
1111 values[child] = Some(value);
1112 stack.push(child);
1113 }
1114 }
1115 }
1116 let voltages = topology
1117 .nodes
1118 .iter()
1119 .cloned()
1120 .zip(values)
1121 .map(|(node, value)| {
1122 let (magnitude, angle) = value.ok_or_else(|| {
1123 invalid_reference(format!(
1124 "node `{}/{}` was not reached from a source",
1125 node.bus, node.terminal
1126 ))
1127 })?;
1128 Ok(LinDist3FlowReferenceVoltage {
1129 node,
1130 magnitude,
1131 angle,
1132 })
1133 })
1134 .collect::<Result<Vec<_>, Error>>()?;
1135 Ok(LinDist3FlowReferenceState {
1136 provenance: LinDist3FlowReferenceProvenance::SourcePropagated,
1137 voltages,
1138 })
1139}
1140
1141fn build_reference(
1142 instance: &McAcOpfInstance,
1143 topology: &LinDist3FlowTopology,
1144 policy: LinDist3FlowReferencePolicy,
1145) -> Result<LinDist3FlowReferenceState, Error> {
1146 match policy {
1147 LinDist3FlowReferencePolicy::Auto if instance.initial_point().is_some() => {
1148 explicit_reference(instance, topology)
1149 }
1150 LinDist3FlowReferencePolicy::Explicit => explicit_reference(instance, topology),
1151 LinDist3FlowReferencePolicy::Auto | LinDist3FlowReferencePolicy::SourcePropagated => {
1152 propagated_reference(instance, topology)
1153 }
1154 }
1155}