1use std::collections::{BTreeMap, BTreeSet};
9
10use powerio_core::{Diagnostic, DiagnosticInfo, DiagnosticSeverity, Error};
11use powerio_dist::{
12 Configuration, DistLoadVoltageModel, LinDist3FlowPreparationAction,
13 LinDist3FlowPreparationActionKind, LinDist3FlowPreparationReport, MulticonductorNetwork,
14 prepare_lindist3flow_network,
15};
16use serde::{Deserialize, Serialize};
17
18use super::McAcOpfInstance;
19use crate::diagnostics::codes;
20use crate::{
21 ConstraintSelection, MulticonductorActiveConstraints, MulticonductorOperatingPointQuantity,
22 Objective, ObjectiveTerm,
23};
24
25#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
27#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
28#[serde(rename_all = "snake_case")]
29#[non_exhaustive]
30pub enum LinDist3FlowReferencePolicy {
31 #[default]
34 Auto,
35 Explicit,
37 SourcePropagated,
39}
40
41pub use powerio_dist::LinDist3FlowPreparationPolicy as LinDist3FlowUnsupported;
42
43#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
45#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
46#[non_exhaustive]
47pub struct LinDist3FlowBuildOptions {
48 pub reference_policy: LinDist3FlowReferencePolicy,
49 pub unsupported: LinDist3FlowUnsupported,
50 pub require_neutral_provenance: bool,
53}
54
55impl LinDist3FlowBuildOptions {
56 #[must_use]
57 pub const fn with_reference_policy(mut self, policy: LinDist3FlowReferencePolicy) -> Self {
58 self.reference_policy = policy;
59 self
60 }
61
62 #[must_use]
63 pub const fn with_unsupported(mut self, policy: LinDist3FlowUnsupported) -> Self {
64 self.unsupported = policy;
65 self
66 }
67
68 #[must_use]
69 pub const fn with_required_neutral_provenance(mut self, required: bool) -> Self {
70 self.require_neutral_provenance = required;
71 self
72 }
73}
74
75#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
77#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
78#[non_exhaustive]
79pub struct LinDist3FlowNode {
80 pub bus: String,
81 pub terminal: String,
82}
83
84#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
86#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
87#[non_exhaustive]
88pub struct LinDist3FlowOrientedConductor {
89 pub line: String,
90 pub source_line_row: usize,
91 pub conductor_position: usize,
92 pub parent: LinDist3FlowNode,
93 pub child: LinDist3FlowNode,
94 pub reversed: bool,
96}
97
98#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
100#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
101#[non_exhaustive]
102pub struct LinDist3FlowTopology {
103 pub nodes: Vec<LinDist3FlowNode>,
104 pub conductors: Vec<LinDist3FlowOrientedConductor>,
105 pub roots: Vec<LinDist3FlowNode>,
106 pub islands: Vec<Vec<LinDist3FlowNode>>,
107 pub meshed: bool,
110}
111
112#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
114#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
115#[serde(rename_all = "snake_case")]
116#[non_exhaustive]
117pub enum LinDist3FlowApplicabilityStatus {
118 Applicable,
119 Inapplicable,
120}
121
122#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
124#[non_exhaustive]
125pub struct LinDist3FlowApplicability {
126 pub status: LinDist3FlowApplicabilityStatus,
127 pub diagnostics: Vec<Diagnostic>,
128 pub roots: Vec<LinDist3FlowNode>,
129 pub islands: Vec<Vec<LinDist3FlowNode>>,
130 pub reference_provenance: Option<LinDist3FlowReferenceProvenance>,
131 pub kron_reduced: bool,
132 pub lowered: bool,
133}
134
135impl LinDist3FlowApplicability {
136 #[must_use]
137 pub const fn is_applicable(&self) -> bool {
138 matches!(self.status, LinDist3FlowApplicabilityStatus::Applicable)
139 }
140}
141
142#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
144#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
145#[serde(rename_all = "snake_case")]
146#[non_exhaustive]
147pub enum LinDist3FlowReferenceProvenance {
148 InitialPoint,
149 SourcePropagated,
150}
151
152#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
154#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
155#[non_exhaustive]
156pub struct LinDist3FlowReferenceVoltage {
157 pub node: LinDist3FlowNode,
158 pub magnitude: f64,
160 pub angle: f64,
162}
163
164#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
166#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
167#[non_exhaustive]
168pub struct LinDist3FlowReferenceState {
169 pub provenance: LinDist3FlowReferenceProvenance,
170 pub voltages: Vec<LinDist3FlowReferenceVoltage>,
171}
172
173impl LinDist3FlowReferenceState {
174 #[must_use]
175 pub fn voltage(&self, bus: &str, terminal: &str) -> Option<&LinDist3FlowReferenceVoltage> {
176 self.voltages.iter().find(|voltage| {
177 voltage.node.bus.eq_ignore_ascii_case(bus) && voltage.node.terminal == terminal
178 })
179 }
180}
181
182#[derive(Clone, Debug)]
184pub struct LinDist3FlowOpfInstance {
185 source_base: McAcOpfInstance,
186 base: McAcOpfInstance,
187 topology: LinDist3FlowTopology,
188 reference: LinDist3FlowReferenceState,
189 applicability: LinDist3FlowApplicability,
190 options: LinDist3FlowBuildOptions,
191 preparation: LinDist3FlowPreparationReport,
192}
193
194impl LinDist3FlowOpfInstance {
195 pub fn from_network(
200 network: MulticonductorNetwork,
201 options: LinDist3FlowBuildOptions,
202 ) -> Result<Self, Error> {
203 Self::from_mc_ac(McAcOpfInstance::from_network(network)?, options)
204 }
205
206 pub fn from_mc_ac(
212 base: McAcOpfInstance,
213 options: LinDist3FlowBuildOptions,
214 ) -> Result<Self, Error> {
215 let source_base = base;
216 let (base, preparation) = prepare_base(&source_base, options)?;
217 let (applicability, topology, reference) = assess(&base, options, &preparation);
218 if let Some(diagnostic) = applicability
219 .diagnostics
220 .iter()
221 .find(|finding| finding.severity() == DiagnosticSeverity::Error)
222 {
223 return Err(error_from_diagnostic(diagnostic));
224 }
225 let topology = topology.ok_or_else(|| {
226 Error::new(
227 &codes::BUILD_LINDIST3FLOW_TOPOLOGY_INVALID,
228 "the applicable assessment did not produce a conductor topology",
229 )
230 })?;
231 let reference = reference.ok_or_else(|| {
232 Error::new(
233 &codes::BUILD_LINDIST3FLOW_REFERENCE_INVALID,
234 "the applicable assessment did not produce a coefficient reference",
235 )
236 })?;
237 Ok(Self {
238 source_base,
239 base,
240 topology,
241 reference,
242 applicability,
243 options,
244 preparation,
245 })
246 }
247
248 #[must_use]
250 pub fn source_instance(&self) -> &McAcOpfInstance {
251 &self.source_base
252 }
253
254 #[must_use]
256 pub fn source_network(&self) -> &MulticonductorNetwork {
257 self.source_base.network()
258 }
259
260 #[must_use]
261 pub fn base_instance(&self) -> &McAcOpfInstance {
262 &self.base
263 }
264
265 #[must_use]
266 pub fn network(&self) -> &MulticonductorNetwork {
267 self.base.network()
268 }
269
270 #[must_use]
271 pub const fn topology(&self) -> &LinDist3FlowTopology {
272 &self.topology
273 }
274
275 #[must_use]
276 pub const fn reference(&self) -> &LinDist3FlowReferenceState {
277 &self.reference
278 }
279
280 #[must_use]
281 pub const fn applicability(&self) -> &LinDist3FlowApplicability {
282 &self.applicability
283 }
284
285 #[must_use]
286 pub const fn options(&self) -> LinDist3FlowBuildOptions {
287 self.options
288 }
289
290 #[must_use]
292 pub const fn preparation(&self) -> &LinDist3FlowPreparationReport {
293 &self.preparation
294 }
295}
296
297#[derive(Clone, Debug)]
304pub struct LinDist3FlowPfInstance {
305 formulation: LinDist3FlowOpfInstance,
306}
307
308impl LinDist3FlowPfInstance {
309 pub fn from_network(
315 network: MulticonductorNetwork,
316 options: LinDist3FlowBuildOptions,
317 ) -> Result<Self, Error> {
318 Self::from_mc_ac(McAcOpfInstance::from_network(network)?, options)
319 }
320
321 pub fn from_mc_ac(
327 base: McAcOpfInstance,
328 options: LinDist3FlowBuildOptions,
329 ) -> Result<Self, Error> {
330 let mut constraints: MulticonductorActiveConstraints = base.constraints().clone();
331 constraints.conductor_limits = ConstraintSelection::None;
332 constraints.generator_capability = ConstraintSelection::All;
333 let base = base
334 .with_objective(Objective::default())
335 .with_constraints(constraints);
336 let formulation = LinDist3FlowOpfInstance::from_mc_ac(base, options)?;
337 require_fixed_dispatch(&formulation)?;
338 Ok(Self { formulation })
339 }
340
341 pub fn from_formulation(formulation: LinDist3FlowOpfInstance) -> Result<Self, Error> {
347 if !formulation.base_instance().objective().terms().is_empty()
348 || !matches!(
349 formulation.base_instance().constraints().conductor_limits,
350 ConstraintSelection::None
351 )
352 || !matches!(
353 formulation
354 .base_instance()
355 .constraints()
356 .generator_capability,
357 ConstraintSelection::All
358 )
359 {
360 return Err(Error::new(
361 &codes::BUILD_LINDIST3FLOW_FIXED_DISPATCH_REQUIRED,
362 "a fixed-dispatch formulation must have no objective, enforce every generator's fixed capability bounds, and monitor rather than enforce conductor limits",
363 ));
364 }
365 require_fixed_dispatch(&formulation)?;
366 Ok(Self { formulation })
367 }
368
369 #[must_use]
371 pub const fn formulation(&self) -> &LinDist3FlowOpfInstance {
372 &self.formulation
373 }
374
375 #[must_use]
376 pub fn network(&self) -> &MulticonductorNetwork {
377 self.formulation.network()
378 }
379
380 #[must_use]
381 pub fn source_network(&self) -> &MulticonductorNetwork {
382 self.formulation.source_network()
383 }
384
385 #[must_use]
386 pub const fn topology(&self) -> &LinDist3FlowTopology {
387 self.formulation.topology()
388 }
389
390 #[must_use]
391 pub const fn reference(&self) -> &LinDist3FlowReferenceState {
392 self.formulation.reference()
393 }
394
395 #[must_use]
396 pub const fn applicability(&self) -> &LinDist3FlowApplicability {
397 self.formulation.applicability()
398 }
399
400 #[must_use]
401 pub const fn options(&self) -> LinDist3FlowBuildOptions {
402 self.formulation.options()
403 }
404
405 #[must_use]
406 pub const fn preparation(&self) -> &LinDist3FlowPreparationReport {
407 self.formulation.preparation()
408 }
409}
410
411#[allow(clippy::float_cmp)] fn fixed_bounds(lower: Option<&[f64]>, upper: Option<&[f64]>, channels: usize) -> bool {
413 match (lower, upper) {
414 (None, None) => true,
415 (Some(lower), Some(upper)) if lower.len() == channels && upper.len() == channels => lower
416 .iter()
417 .zip(upper)
418 .all(|(lower, upper)| lower.is_finite() && lower == upper),
419 _ => false,
420 }
421}
422
423fn require_fixed_dispatch(instance: &LinDist3FlowOpfInstance) -> Result<(), Error> {
424 for generator in instance.network().generators() {
425 let channels = generator.p_nom.len();
426 let nominal = channels != 0
427 && generator.q_nom.len() == channels
428 && generator.p_nom.iter().all(|value| value.is_finite())
429 && generator.q_nom.iter().all(|value| value.is_finite());
430 if !nominal
431 || !fixed_bounds(
432 generator.p_min.as_deref(),
433 generator.p_max.as_deref(),
434 channels,
435 )
436 || !fixed_bounds(
437 generator.q_min.as_deref(),
438 generator.q_max.as_deref(),
439 channels,
440 )
441 {
442 return Err(Error::new(
443 &codes::BUILD_LINDIST3FLOW_FIXED_DISPATCH_REQUIRED,
444 format!(
445 "generator `{}` must state finite nominal P/Q or equal per-channel lower and upper bounds",
446 generator.name
447 ),
448 ));
449 }
450 }
451 Ok(())
452}
453
454fn prepare_base(
455 source: &McAcOpfInstance,
456 options: LinDist3FlowBuildOptions,
457) -> Result<(McAcOpfInstance, LinDist3FlowPreparationReport), Error> {
458 let prepared =
459 prepare_lindist3flow_network(source.network(), options.unsupported).map_err(|error| {
460 Error::new(
461 &codes::BUILD_LINDIST3FLOW_PREPARATION_FAILED,
462 error.to_string(),
463 )
464 })?;
465 let (network, mut report) = prepared.into_parts();
466 if report.actions.is_empty() {
467 return Ok((source.clone(), report));
468 }
469 match source.clone().with_network(network.clone()) {
470 Ok(base) => Ok((base, report)),
471 Err(error) if source.initial_point().is_some() => {
472 let base = McAcOpfInstance::from_network(network)?
473 .with_objective(source.objective().clone())
474 .with_constraints(source.constraints().clone());
475 let mut action = LinDist3FlowPreparationAction::new(
476 LinDist3FlowPreparationActionKind::InitialPointOmitted,
477 "initial_point",
478 None,
479 );
480 action.details.insert(
481 "reason".to_owned(),
482 serde_json::Value::String(error.to_string()),
483 );
484 report.actions.push(action);
485 Ok((base, report))
486 }
487 Err(error) => Err(error),
488 }
489}
490
491fn finding(
492 info: &'static DiagnosticInfo,
493 message: impl Into<String>,
494 target: Option<String>,
495) -> Diagnostic {
496 let mut diagnostic = Diagnostic::of(info, message);
497 if let Some(target) = target {
498 let _ = diagnostic.set_target(target);
499 }
500 diagnostic
501}
502
503fn error_from_diagnostic(diagnostic: &Diagnostic) -> Error {
504 Error::new(
505 diagnostic
506 .registered_info()
507 .expect("LinDist3Flow findings use registered codes"),
508 diagnostic.message(),
509 )
510}
511
512fn matrix_has_nonzero(matrix: &[Vec<f64>]) -> bool {
513 matrix.iter().flatten().any(|value| *value != 0.0)
514}
515
516fn check_objective(instance: &McAcOpfInstance, diagnostics: &mut Vec<Diagnostic>) {
517 let network = instance.network();
518 let dispatch_cost = match instance.objective().terms() {
519 [] => false,
520 [ObjectiveTerm::ActivePowerDispatchCost] => true,
521 terms => {
522 diagnostics.push(finding(
523 &codes::BUILD_LINDIST3FLOW_OBJECTIVE_UNSUPPORTED,
524 format!(
525 "the strict LinDist3Flow slice accepts feasibility or exactly one active-power dispatch-cost term, but received {} term(s)",
526 terms.len()
527 ),
528 None,
529 ));
530 false
531 }
532 };
533 if !dispatch_cost {
534 return;
535 }
536 for (row, generator) in network.generators().iter().enumerate() {
537 if generator.cost.is_none() {
538 diagnostics.push(finding(
539 &codes::BUILD_LINDIST3FLOW_COST_MISSING,
540 format!(
541 "generator `{}` has no active-power dispatch cost; its coefficient is zero",
542 generator.name
543 ),
544 Some(format!("/generators/{row}/cost")),
545 ));
546 }
547 }
548 for (row, source) in network.sources().iter().enumerate() {
549 if source.energy_cost_rate.is_none() {
550 diagnostics.push(finding(
551 &codes::BUILD_LINDIST3FLOW_COST_MISSING,
552 format!(
553 "voltage source `{}` has no energy cost rate; its coefficient is zero",
554 source.name
555 ),
556 Some(format!("/sources/{row}/energy_cost_rate")),
557 ));
558 }
559 }
560}
561
562fn supported_connection(configuration: Configuration, terminals: usize, channels: usize) -> bool {
563 match configuration {
564 Configuration::Wye => terminals == channels,
565 Configuration::SinglePhase => terminals == channels || (terminals == 2 && channels == 1),
566 Configuration::Delta => {
567 (terminals == 2 && channels == 1) || (terminals == 3 && channels == 3)
568 }
569 _ => false,
570 }
571}
572
573fn scalar_or_channels(values: &[f64], channels: usize) -> bool {
574 matches!(values.len(), 1) || values.len() == channels
575}
576
577fn finite_scalar_or_channels(values: &[f64], channels: usize) -> bool {
578 scalar_or_channels(values, channels) && values.iter().all(|value| value.is_finite())
579}
580
581fn positive_scalar_or_channels(values: &[f64], channels: usize) -> bool {
582 scalar_or_channels(values, channels)
583 && values.iter().all(|value| value.is_finite() && *value > 0.0)
584}
585
586fn valid_bounds(lower: Option<&[f64]>, upper: Option<&[f64]>, channels: usize) -> bool {
587 match (lower, upper) {
588 (None, None) => true,
589 (Some(lower), Some(upper)) => {
590 lower.len() == channels
591 && upper.len() == channels
592 && lower
593 .iter()
594 .zip(upper)
595 .all(|(lower, upper)| lower.is_finite() && upper.is_finite() && lower <= upper)
596 }
597 _ => false,
598 }
599}
600
601fn square_finite(matrix: &[Vec<f64>], dimension: usize) -> bool {
602 matrix.len() == dimension
603 && matrix
604 .iter()
605 .all(|row| row.len() == dimension && row.iter().all(|value| value.is_finite()))
606}
607
608fn device_invalid(diagnostics: &mut Vec<Diagnostic>, message: impl Into<String>, target: String) {
609 diagnostics.push(finding(
610 &codes::BUILD_LINDIST3FLOW_DEVICE_INVALID,
611 message,
612 Some(target),
613 ));
614}
615
616#[allow(clippy::too_many_lines)]
617fn check_device_shapes(instance: &McAcOpfInstance, diagnostics: &mut Vec<Diagnostic>) {
618 let network = instance.network();
619 for (row, load) in network.loads().iter().enumerate() {
620 let channels = load.p_nom.len();
621 let mut valid = channels != 0
622 && load.q_nom.len() == channels
623 && load.p_nom.iter().all(|value| value.is_finite())
624 && load.q_nom.iter().all(|value| value.is_finite())
625 && supported_connection(load.configuration, load.terminal_map.len(), channels);
626 valid &= match &load.voltage_model {
627 DistLoadVoltageModel::ConstantPower { .. }
628 | DistLoadVoltageModel::ConstantCurrent { .. }
629 | DistLoadVoltageModel::Exponential { .. } => true,
630 DistLoadVoltageModel::ConstantImpedance { v_nom } => {
631 positive_scalar_or_channels(v_nom, channels)
632 }
633 DistLoadVoltageModel::Zip {
634 v_nom,
635 alpha_z,
636 alpha_i,
637 alpha_p,
638 beta_z,
639 beta_i,
640 beta_p,
641 } => {
642 positive_scalar_or_channels(v_nom, channels)
643 && [alpha_z, alpha_i, alpha_p, beta_z, beta_i, beta_p]
644 .into_iter()
645 .all(|values| finite_scalar_or_channels(values, channels))
646 }
647 _ => false,
648 };
649 if !valid {
650 device_invalid(
651 diagnostics,
652 format!(
653 "load `{}` has invalid channel, connection, nominal-power, or voltage-model dimensions",
654 load.name
655 ),
656 format!("/loads/{row}"),
657 );
658 }
659 }
660
661 for (row, generator) in network.generators().iter().enumerate() {
662 let channels = generator.p_nom.len();
663 let limits_valid = [generator.s_max.as_deref(), generator.i_max.as_deref()]
664 .into_iter()
665 .flatten()
666 .all(|values| {
667 values.len() == channels
668 && values.iter().all(|value| value.is_finite() && *value > 0.0)
669 });
670 let cost_valid = generator
671 .cost
672 .as_deref()
673 .is_none_or(|values| finite_scalar_or_channels(values, channels));
674 let valid = channels != 0
675 && generator.q_nom.len() == channels
676 && generator.p_nom.iter().all(|value| value.is_finite())
677 && generator.q_nom.iter().all(|value| value.is_finite())
678 && supported_connection(
679 generator.configuration,
680 generator.terminal_map.len(),
681 channels,
682 )
683 && valid_bounds(
684 generator.p_min.as_deref(),
685 generator.p_max.as_deref(),
686 channels,
687 )
688 && valid_bounds(
689 generator.q_min.as_deref(),
690 generator.q_max.as_deref(),
691 channels,
692 )
693 && limits_valid
694 && cost_valid;
695 if !valid {
696 device_invalid(
697 diagnostics,
698 format!(
699 "generator `{}` has invalid channel, connection, paired-bound, rating, or cost dimensions",
700 generator.name
701 ),
702 format!("/generators/{row}"),
703 );
704 }
705 }
706
707 for (row, shunt) in network.shunts().iter().enumerate() {
708 let terminals = shunt.terminal_map.len();
709 if terminals == 0
710 || !square_finite(&shunt.g, terminals)
711 || !square_finite(&shunt.b, terminals)
712 {
713 device_invalid(
714 diagnostics,
715 format!(
716 "shunt `{}` admittance matrices are not finite {terminals}x{terminals} arrays",
717 shunt.name
718 ),
719 format!("/shunts/{row}"),
720 );
721 }
722 }
723
724 for (row, source) in network.sources().iter().enumerate() {
725 let channels = source.terminal_map.len();
726 let valid = source.reference_terminal.is_none()
727 && channels != 0
728 && source.v_magnitude.len() == channels
729 && source.v_angle.len() == channels
730 && source
731 .v_magnitude
732 .iter()
733 .all(|value| value.is_finite() && *value > 0.0)
734 && source.v_angle.iter().all(|value| value.is_finite())
735 && source
736 .energy_cost_rate
737 .as_deref()
738 .is_none_or(|values| finite_scalar_or_channels(values, channels));
739 if !valid {
740 device_invalid(
741 diagnostics,
742 format!(
743 "voltage source `{}` has invalid terminal, phasor, or cost dimensions",
744 source.name
745 ),
746 format!("/sources/{row}"),
747 );
748 }
749 }
750}
751
752fn check_supported_slice(
753 instance: &McAcOpfInstance,
754 options: LinDist3FlowBuildOptions,
755 diagnostics: &mut Vec<Diagnostic>,
756) {
757 let network = instance.network();
758 check_objective(instance, diagnostics);
759 check_device_shapes(instance, diagnostics);
760 let conventions = network.extras().get("bmopf_terminal_conventions");
761 for (row, bus) in network.buses().iter().enumerate() {
762 if bus.phase_indices(conventions).len() != bus.terminals.len() {
763 diagnostics.push(finding(
764 &codes::BUILD_LINDIST3FLOW_EXPLICIT_NEUTRAL,
765 format!(
766 "bus `{}` still declares an explicit neutral conductor; apply neutral_kron_reduce first",
767 bus.id
768 ),
769 Some(format!("/buses/{row}/terminals")),
770 ));
771 }
772 }
773 if options.require_neutral_provenance && !network.extras().contains_key("powerio_neutral_kron")
774 {
775 diagnostics.push(finding(
776 &codes::BUILD_LINDIST3FLOW_EXPLICIT_NEUTRAL,
777 "the instance requires neutral-Kron provenance but the network carries none",
778 None,
779 ));
780 }
781
782 for (row, line) in network.lines().iter().enumerate() {
783 let Some(code) = network.linecode(&line.linecode) else {
784 continue;
785 };
786 if matrix_has_nonzero(&code.g_from)
787 || matrix_has_nonzero(&code.b_from)
788 || matrix_has_nonzero(&code.g_to)
789 || matrix_has_nonzero(&code.b_to)
790 {
791 diagnostics.push(finding(
792 &codes::BUILD_LINDIST3FLOW_UNSUPPORTED_COMPONENT,
793 format!(
794 "line `{}` has pi shunt admittance; endpoint-shunt lowering is not implemented yet",
795 line.name
796 ),
797 Some(format!("/lines/{row}/linecode")),
798 ));
799 }
800 }
801 for (row, load) in network.loads().iter().enumerate() {
802 let supported = match &load.voltage_model {
803 DistLoadVoltageModel::ConstantPower { .. }
804 | DistLoadVoltageModel::ConstantImpedance { .. } => true,
805 DistLoadVoltageModel::Zip {
806 alpha_i, beta_i, ..
807 } => {
808 alpha_i.iter().all(|value| value.abs() <= f64::EPSILON)
809 && beta_i.iter().all(|value| value.abs() <= f64::EPSILON)
810 }
811 DistLoadVoltageModel::ConstantCurrent { .. }
812 | DistLoadVoltageModel::Exponential { .. }
813 | _ => false,
814 };
815 if !supported {
816 diagnostics.push(finding(
817 &codes::BUILD_LINDIST3FLOW_UNSUPPORTED_COMPONENT,
818 format!(
819 "load `{}` needs a current/exponential approximation outside the strict model",
820 load.name
821 ),
822 Some(format!("/loads/{row}/voltage_model")),
823 ));
824 }
825 }
826
827 for (family, count) in [
828 ("switch", network.switches().len()),
829 ("transformer", network.transformers().len()),
830 ("capacitor", network.capacitors().len()),
831 ("IBR", network.ibrs().len()),
832 ("untyped object", network.untyped_objects().len()),
833 ] {
834 if count != 0 {
835 diagnostics.push(finding(
836 &codes::BUILD_LINDIST3FLOW_UNSUPPORTED_COMPONENT,
837 format!(
838 "the initial strict LinDist3Flow slice does not yet compile {count} {family} record(s)"
839 ),
840 None,
841 ));
842 }
843 }
844}
845
846#[must_use]
850pub fn check_lindist3flow_applicability(
851 instance: &McAcOpfInstance,
852 options: LinDist3FlowBuildOptions,
853) -> LinDist3FlowApplicability {
854 match prepare_base(instance, options) {
855 Ok((prepared, report)) => assess(&prepared, options, &report).0,
856 Err(error) => LinDist3FlowApplicability {
857 status: LinDist3FlowApplicabilityStatus::Inapplicable,
858 diagnostics: error.into_diagnostics(),
859 roots: Vec::new(),
860 islands: Vec::new(),
861 reference_provenance: None,
862 kron_reduced: false,
863 lowered: false,
864 },
865 }
866}
867
868fn preparation_findings(report: &LinDist3FlowPreparationReport) -> Vec<Diagnostic> {
869 report
870 .actions
871 .iter()
872 .map(|action| {
873 let info = match action.kind {
874 LinDist3FlowPreparationActionKind::LoadApproximated
875 | LinDist3FlowPreparationActionKind::IbrApproximated
876 | LinDist3FlowPreparationActionKind::StaticControlFrozen => {
877 &codes::BUILD_LINDIST3FLOW_COMPONENT_APPROXIMATED
878 }
879 LinDist3FlowPreparationActionKind::UntypedObjectOmitted
880 | LinDist3FlowPreparationActionKind::InitialPointOmitted => {
881 &codes::BUILD_LINDIST3FLOW_COMPONENT_OMITTED
882 }
883 _ => &codes::BUILD_LINDIST3FLOW_COMPONENT_LOWERED,
884 };
885 finding(
886 info,
887 format!(
888 "`{}` was prepared by {:?}{}",
889 action.source,
890 action.kind,
891 action
892 .target
893 .as_ref()
894 .map_or_else(String::new, |target| format!(" as `{target}`"))
895 ),
896 Some(action.source.clone()),
897 )
898 })
899 .collect()
900}
901
902fn assess(
903 instance: &McAcOpfInstance,
904 options: LinDist3FlowBuildOptions,
905 preparation: &LinDist3FlowPreparationReport,
906) -> (
907 LinDist3FlowApplicability,
908 Option<LinDist3FlowTopology>,
909 Option<LinDist3FlowReferenceState>,
910) {
911 let mut diagnostics = preparation_findings(preparation);
912 check_supported_slice(instance, options, &mut diagnostics);
913 let topology = match build_topology(instance.network()) {
914 Ok(topology) => {
915 if topology.meshed {
916 diagnostics.push(finding(
917 &codes::BUILD_LINDIST3FLOW_MESH_APPROXIMATION,
918 "the retained conductor graph contains a cycle or parallel connection; the model keeps every line but adds no angle, loop-consistency, circulating-flow, or radialisation constraint",
919 None,
920 ));
921 }
922 Some(topology)
923 }
924 Err(message) => {
925 diagnostics.push(finding(
926 &codes::BUILD_LINDIST3FLOW_TOPOLOGY_INVALID,
927 message,
928 None,
929 ));
930 None
931 }
932 };
933 let reference = topology.as_ref().and_then(|topology| {
934 match build_reference(instance, topology, options.reference_policy) {
935 Ok(reference) => Some(reference),
936 Err(error) => {
937 diagnostics.extend(error.into_diagnostics());
938 None
939 }
940 }
941 });
942 let status = if diagnostics
943 .iter()
944 .any(|finding| finding.severity() == DiagnosticSeverity::Error)
945 {
946 LinDist3FlowApplicabilityStatus::Inapplicable
947 } else {
948 LinDist3FlowApplicabilityStatus::Applicable
949 };
950 let roots = topology
951 .as_ref()
952 .map_or_else(Vec::new, |topology| topology.roots.clone());
953 let islands = topology
954 .as_ref()
955 .map_or_else(Vec::new, |topology| topology.islands.clone());
956 (
957 LinDist3FlowApplicability {
958 status,
959 diagnostics,
960 roots,
961 islands,
962 reference_provenance: reference.as_ref().map(|reference| reference.provenance),
963 kron_reduced: instance
964 .network()
965 .extras()
966 .contains_key("powerio_neutral_kron"),
967 lowered: !preparation.actions.is_empty(),
968 },
969 topology,
970 reference,
971 )
972}
973
974#[derive(Clone, Copy)]
975struct Edge {
976 from: usize,
977 to: usize,
978 line: usize,
979 conductor: usize,
980}
981
982struct UnionFind {
983 parent: Vec<usize>,
984}
985
986impl UnionFind {
987 fn new(n: usize) -> Self {
988 Self {
989 parent: (0..n).collect(),
990 }
991 }
992
993 fn find(&mut self, node: usize) -> usize {
994 let mut root = node;
995 while self.parent[root] != root {
996 root = self.parent[root];
997 }
998 let mut cursor = node;
999 while self.parent[cursor] != root {
1000 let next = self.parent[cursor];
1001 self.parent[cursor] = root;
1002 cursor = next;
1003 }
1004 root
1005 }
1006
1007 fn join(&mut self, left: usize, right: usize) -> bool {
1008 let left = self.find(left);
1009 let right = self.find(right);
1010 if left == right {
1011 return false;
1012 }
1013 self.parent[left.max(right)] = left.min(right);
1014 true
1015 }
1016}
1017
1018fn node_key(bus: &str, terminal: &str) -> (String, String) {
1019 (bus.to_ascii_lowercase(), terminal.to_owned())
1020}
1021
1022#[allow(clippy::too_many_lines)]
1023fn build_topology(network: &MulticonductorNetwork) -> Result<LinDist3FlowTopology, String> {
1024 let mut nodes = Vec::new();
1025 let mut positions = BTreeMap::new();
1026 let mut bus_ids = BTreeSet::new();
1027 let mut bus_positions = BTreeMap::new();
1028 for bus in network.buses() {
1029 if !bus_ids.insert(bus.id.to_ascii_lowercase()) {
1030 return Err(format!(
1031 "bus identity `{}` is duplicated case-insensitively",
1032 bus.id
1033 ));
1034 }
1035 bus_positions.insert(bus.id.to_ascii_lowercase(), bus_positions.len());
1036 let mut terminals = BTreeSet::new();
1037 for terminal in &bus.terminals {
1038 if !terminals.insert(terminal.clone()) {
1039 return Err(format!("bus `{}` repeats terminal `{terminal}`", bus.id));
1040 }
1041 let node = LinDist3FlowNode {
1042 bus: bus.id.clone(),
1043 terminal: terminal.clone(),
1044 };
1045 positions.insert(node_key(&node.bus, &node.terminal), nodes.len());
1046 nodes.push(node);
1047 }
1048 }
1049 if nodes.is_empty() {
1050 return Err("the network has no retained bus terminals".to_owned());
1051 }
1052
1053 let mut components = UnionFind::new(nodes.len());
1054 let mut bus_components = UnionFind::new(bus_positions.len());
1055 let mut edges = Vec::new();
1056 let mut meshed = false;
1057 for (line_row, line) in network.lines().iter().enumerate() {
1058 if line.terminal_map_from.len() != line.terminal_map_to.len()
1059 || line.terminal_map_from.is_empty()
1060 {
1061 return Err(format!(
1062 "line `{}` has empty or unequal terminal maps",
1063 line.name
1064 ));
1065 }
1066 let from_bus = *bus_positions
1067 .get(&line.bus_from.to_ascii_lowercase())
1068 .ok_or_else(|| {
1069 format!(
1070 "line `{}` names unknown from bus `{}`",
1071 line.name, line.bus_from
1072 )
1073 })?;
1074 let to_bus = *bus_positions
1075 .get(&line.bus_to.to_ascii_lowercase())
1076 .ok_or_else(|| {
1077 format!(
1078 "line `{}` names unknown to bus `{}`",
1079 line.name, line.bus_to
1080 )
1081 })?;
1082 bus_components.join(from_bus, to_bus);
1085 for (conductor, (from_terminal, to_terminal)) in line
1086 .terminal_map_from
1087 .iter()
1088 .zip(&line.terminal_map_to)
1089 .enumerate()
1090 {
1091 let from = *positions
1092 .get(&node_key(&line.bus_from, from_terminal))
1093 .ok_or_else(|| {
1094 format!(
1095 "line `{}` names undeclared from terminal `{}/{from_terminal}`",
1096 line.name, line.bus_from
1097 )
1098 })?;
1099 let to = *positions
1100 .get(&node_key(&line.bus_to, to_terminal))
1101 .ok_or_else(|| {
1102 format!(
1103 "line `{}` names undeclared to terminal `{}/{to_terminal}`",
1104 line.name, line.bus_to
1105 )
1106 })?;
1107 meshed |= !components.join(from, to);
1108 edges.push(Edge {
1109 from,
1110 to,
1111 line: line_row,
1112 conductor,
1113 });
1114 }
1115 }
1116
1117 let mut island_indices: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
1118 for node in 0..nodes.len() {
1119 island_indices
1120 .entry(components.find(node))
1121 .or_default()
1122 .push(node);
1123 }
1124 let mut source_nodes: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
1125 let mut island_sources: BTreeMap<usize, Vec<&str>> = BTreeMap::new();
1126 let mut source_bus_roots = Vec::new();
1127 for source in network.sources() {
1128 let bus = *bus_positions
1129 .get(&source.bus.to_ascii_lowercase())
1130 .ok_or_else(|| {
1131 format!(
1132 "voltage source `{}` names unknown bus `{}`",
1133 source.name, source.bus
1134 )
1135 })?;
1136 island_sources
1137 .entry(bus_components.find(bus))
1138 .or_default()
1139 .push(&source.name);
1140 source_bus_roots.push(bus);
1141 for terminal in &source.terminal_map {
1142 let node = *positions
1143 .get(&node_key(&source.bus, terminal))
1144 .ok_or_else(|| {
1145 format!(
1146 "voltage source `{}` names undeclared terminal `{}/{terminal}`",
1147 source.name, source.bus
1148 )
1149 })?;
1150 source_nodes
1151 .entry(components.find(node))
1152 .or_default()
1153 .push(node);
1154 }
1155 }
1156 let mut physical_islands: BTreeMap<usize, Vec<&str>> = BTreeMap::new();
1157 for bus in network.buses() {
1158 let position = bus_positions[&bus.id.to_ascii_lowercase()];
1159 physical_islands
1160 .entry(bus_components.find(position))
1161 .or_default()
1162 .push(&bus.id);
1163 }
1164 for (component, buses) in physical_islands {
1165 let sources: &[&str] = island_sources.get(&component).map_or(&[], Vec::as_slice);
1166 if sources.len() != 1 {
1167 return Err(format!(
1168 "physical island containing bus `{}` has {} voltage source records; exactly one is required",
1169 buses[0],
1170 sources.len()
1171 ));
1172 }
1173 }
1174
1175 let mut ordered_islands = island_indices.into_values().collect::<Vec<_>>();
1176 ordered_islands.sort_by_key(|island| island[0]);
1177 let mut root_indices = Vec::with_capacity(ordered_islands.len());
1178 for island in &ordered_islands {
1179 let component = components.find(island[0]);
1180 let roots: &[usize] = source_nodes.get(&component).map_or(&[], Vec::as_slice);
1181 if roots.len() != 1 {
1182 return Err(format!(
1183 "conductor island rooted at `{}/{}` contains {} fixed-voltage source terminals; exactly one is required",
1184 nodes[island[0]].bus,
1185 nodes[island[0]].terminal,
1186 roots.len()
1187 ));
1188 }
1189 root_indices.push(roots[0]);
1190 }
1191
1192 let mut bus_adjacency = vec![Vec::new(); bus_positions.len()];
1193 for line in network.lines() {
1194 let from = bus_positions[&line.bus_from.to_ascii_lowercase()];
1195 let to = bus_positions[&line.bus_to.to_ascii_lowercase()];
1196 bus_adjacency[from].push(to);
1197 bus_adjacency[to].push(from);
1198 }
1199 let mut bus_distances = vec![usize::MAX; bus_positions.len()];
1200 let mut frontier = std::collections::VecDeque::new();
1201 for root in source_bus_roots {
1202 bus_distances[root] = 0;
1203 frontier.push_back(root);
1204 }
1205 while let Some(parent) = frontier.pop_front() {
1206 for &child in &bus_adjacency[parent] {
1207 if bus_distances[child] == usize::MAX {
1208 bus_distances[child] = bus_distances[parent] + 1;
1209 frontier.push_back(child);
1210 }
1211 }
1212 }
1213 if let Some(bus) = bus_distances
1214 .iter()
1215 .position(|distance| *distance == usize::MAX)
1216 {
1217 let id = network
1218 .buses()
1219 .iter()
1220 .find(|candidate| bus_positions[&candidate.id.to_ascii_lowercase()] == bus)
1221 .map_or("<unknown>", |candidate| candidate.id.as_str());
1222 return Err(format!(
1223 "bus `{id}` was not reached from its physical-island source"
1224 ));
1225 }
1226 let directions = edges
1227 .iter()
1228 .map(|edge| {
1229 let line = &network.lines()[edge.line];
1230 let from_bus = bus_positions[&line.bus_from.to_ascii_lowercase()];
1231 let to_bus = bus_positions[&line.bus_to.to_ascii_lowercase()];
1232 match bus_distances[from_bus].cmp(&bus_distances[to_bus]) {
1233 std::cmp::Ordering::Less => (edge.from, edge.to),
1234 std::cmp::Ordering::Greater => (edge.to, edge.from),
1235 std::cmp::Ordering::Equal => {
1236 let from = line.bus_from.to_ascii_lowercase();
1237 let to = line.bus_to.to_ascii_lowercase();
1238 if from <= to {
1239 (edge.from, edge.to)
1240 } else {
1241 (edge.to, edge.from)
1242 }
1243 }
1244 }
1245 })
1246 .collect::<Vec<_>>();
1247 let conductors = edges
1248 .iter()
1249 .zip(directions)
1250 .map(|(edge, (parent, child))| LinDist3FlowOrientedConductor {
1251 line: network.lines()[edge.line].name.clone(),
1252 source_line_row: edge.line,
1253 conductor_position: edge.conductor,
1254 parent: nodes[parent].clone(),
1255 child: nodes[child].clone(),
1256 reversed: parent != edge.from,
1257 })
1258 .collect();
1259 Ok(LinDist3FlowTopology {
1260 roots: root_indices
1261 .iter()
1262 .map(|&root| nodes[root].clone())
1263 .collect(),
1264 islands: ordered_islands
1265 .iter()
1266 .map(|island| island.iter().map(|&node| nodes[node].clone()).collect())
1267 .collect(),
1268 nodes,
1269 conductors,
1270 meshed,
1271 })
1272}
1273
1274fn valid_phasor(magnitude: f64, angle: f64) -> bool {
1275 magnitude.is_finite() && magnitude > 0.0 && angle.is_finite()
1276}
1277
1278fn invalid_reference(message: impl Into<String>) -> Error {
1279 Error::new(&codes::BUILD_LINDIST3FLOW_REFERENCE_INVALID, message)
1280}
1281
1282fn explicit_reference(
1283 instance: &McAcOpfInstance,
1284 topology: &LinDist3FlowTopology,
1285) -> Result<LinDist3FlowReferenceState, Error> {
1286 let point = instance.initial_point().ok_or_else(|| {
1287 invalid_reference("the explicit reference policy requires a voltage initial point")
1288 })?;
1289 if point
1290 .values(MulticonductorOperatingPointQuantity::TerminalVoltageMagnitude)
1291 .is_none()
1292 || point
1293 .values(MulticonductorOperatingPointQuantity::TerminalVoltageAngle)
1294 .is_none()
1295 {
1296 return Err(invalid_reference(
1297 "the initial point must contain both terminal voltage magnitude and angle columns",
1298 ));
1299 }
1300 let mut voltages = Vec::with_capacity(topology.nodes.len());
1301 for node in &topology.nodes {
1302 let magnitude = point
1303 .terminal_voltage_magnitude(&node.bus, &node.terminal)
1304 .ok_or_else(|| {
1305 invalid_reference(format!(
1306 "the initial point has no voltage magnitude for `{}/{}`",
1307 node.bus, node.terminal
1308 ))
1309 })?;
1310 let angle = point
1311 .terminal_voltage_angle(&node.bus, &node.terminal)
1312 .ok_or_else(|| {
1313 invalid_reference(format!(
1314 "the initial point has no voltage angle for `{}/{}`",
1315 node.bus, node.terminal
1316 ))
1317 })?;
1318 if !valid_phasor(magnitude, angle) {
1319 return Err(invalid_reference(format!(
1320 "the initial point voltage for `{}/{}` is zero or non-finite",
1321 node.bus, node.terminal
1322 )));
1323 }
1324 voltages.push(LinDist3FlowReferenceVoltage {
1325 node: node.clone(),
1326 magnitude,
1327 angle,
1328 });
1329 }
1330 Ok(LinDist3FlowReferenceState {
1331 provenance: LinDist3FlowReferenceProvenance::InitialPoint,
1332 voltages,
1333 })
1334}
1335
1336#[allow(clippy::too_many_lines)]
1337fn propagated_reference(
1338 instance: &McAcOpfInstance,
1339 topology: &LinDist3FlowTopology,
1340) -> Result<LinDist3FlowReferenceState, Error> {
1341 let mut positions = BTreeMap::new();
1342 for (position, node) in topology.nodes.iter().enumerate() {
1343 positions.insert(node_key(&node.bus, &node.terminal), position);
1344 }
1345 let mut values = vec![None; topology.nodes.len()];
1346 for source in instance.network().sources() {
1347 if source.v_magnitude.len() != source.terminal_map.len()
1348 || source.v_angle.len() != source.terminal_map.len()
1349 {
1350 return Err(invalid_reference(format!(
1351 "voltage source `{}` values do not align with its terminal map",
1352 source.name
1353 )));
1354 }
1355 for (terminal_position, terminal) in source.terminal_map.iter().enumerate() {
1356 let node = *positions
1357 .get(&node_key(&source.bus, terminal))
1358 .ok_or_else(|| {
1359 invalid_reference(format!(
1360 "voltage source `{}` names unknown terminal `{}/{terminal}`",
1361 source.name, source.bus
1362 ))
1363 })?;
1364 let magnitude = source.v_magnitude[terminal_position];
1365 let angle = source.v_angle[terminal_position];
1366 if !valid_phasor(magnitude, angle) {
1367 return Err(invalid_reference(format!(
1368 "voltage source `{}` terminal `{terminal}` is zero or non-finite",
1369 source.name
1370 )));
1371 }
1372 values[node] = Some((magnitude, angle));
1373 }
1374 }
1375
1376 let mut adjacency = vec![Vec::new(); topology.nodes.len()];
1377 for conductor in &topology.conductors {
1378 let parent = positions[&node_key(&conductor.parent.bus, &conductor.parent.terminal)];
1379 let child = positions[&node_key(&conductor.child.bus, &conductor.child.terminal)];
1380 adjacency[parent].push(child);
1381 adjacency[child].push(parent);
1382 }
1383 for root in &topology.roots {
1384 let root = positions[&node_key(&root.bus, &root.terminal)];
1385 let mut stack = vec![root];
1386 let mut visited = vec![false; topology.nodes.len()];
1387 visited[root] = true;
1388 while let Some(node) = stack.pop() {
1389 let value = values[node].ok_or_else(|| {
1390 invalid_reference(format!(
1391 "source root `{}/{}` has no phasor",
1392 topology.nodes[root].bus, topology.nodes[root].terminal
1393 ))
1394 })?;
1395 for &neighbor in &adjacency[node] {
1396 if !visited[neighbor] {
1397 visited[neighbor] = true;
1398 values[neighbor] = Some(value);
1399 stack.push(neighbor);
1400 }
1401 }
1402 }
1403 }
1404 let voltages = topology
1405 .nodes
1406 .iter()
1407 .cloned()
1408 .zip(values)
1409 .map(|(node, value)| {
1410 let (magnitude, angle) = value.ok_or_else(|| {
1411 invalid_reference(format!(
1412 "node `{}/{}` was not reached from a source",
1413 node.bus, node.terminal
1414 ))
1415 })?;
1416 Ok(LinDist3FlowReferenceVoltage {
1417 node,
1418 magnitude,
1419 angle,
1420 })
1421 })
1422 .collect::<Result<Vec<_>, Error>>()?;
1423 Ok(LinDist3FlowReferenceState {
1424 provenance: LinDist3FlowReferenceProvenance::SourcePropagated,
1425 voltages,
1426 })
1427}
1428
1429fn build_reference(
1430 instance: &McAcOpfInstance,
1431 topology: &LinDist3FlowTopology,
1432 policy: LinDist3FlowReferencePolicy,
1433) -> Result<LinDist3FlowReferenceState, Error> {
1434 match policy {
1435 LinDist3FlowReferencePolicy::Auto if instance.initial_point().is_some() => {
1436 explicit_reference(instance, topology)
1437 }
1438 LinDist3FlowReferencePolicy::Explicit => explicit_reference(instance, topology),
1439 LinDist3FlowReferencePolicy::Auto | LinDist3FlowReferencePolicy::SourcePropagated => {
1440 propagated_reference(instance, topology)
1441 }
1442 }
1443}