Skip to main content

powerio_prob/instance/
lindist3flow.rs

1//! Solver-neutral LinDist3Flow OPF instance front end.
2//!
3//! This module owns formulation semantics that precede numerical coefficient
4//! assembly: applicability, conductor-resolved radial topology, and the fixed
5//! voltage phasor reference. Sparse matrices and affine/SOC preparation remain
6//! in `powerio-matrix`.
7
8use 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/// Selection of the fixed phasors used to form LinDist3Flow coefficients.
19#[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    /// Use the instance initial point when present, otherwise propagate source
25    /// phasors through the no-load line topology.
26    #[default]
27    Auto,
28    /// Require the instance to carry a complete voltage initial point.
29    Explicit,
30    /// Ignore any initial point and propagate source phasors without line drop.
31    SourcePropagated,
32}
33
34/// Policy for network features outside the canonical affine model.
35#[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/// Semantic choices used while creating a LinDist3Flow instance.
48#[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    /// Require typed neutral-Kron provenance even when no explicit neutral
55    /// remains in the network.
56    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/// One retained voltage node, identified independently of dense row order.
80#[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/// One line conductor oriented away from its conductor island's source.
89#[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    /// Whether this direction is opposite the input line's from/to order.
99    pub reversed: bool,
100}
101
102/// The source-rooted conductor forest used by the formulation.
103#[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/// Whether applicability checks permit instance construction.
114#[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/// Structured findings and topology evidence from the formulation front end.
124#[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/// Origin of the fixed voltage reference.
144#[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/// One reference voltage phasor in polar SI coordinates.
154#[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    /// Volts.
160    pub magnitude: f64,
161    /// Radians.
162    pub angle: f64,
163}
164
165/// Complete immutable phasor reference in topology node order.
166#[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/// Matrix-free LinDist3Flow OPF instance.
184#[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    /// Build a LinDist3Flow instance directly from a multiconductor network.
195    ///
196    /// # Errors
197    /// As [`McAcOpfInstance::from_network`] and [`Self::from_mc_ac`].
198    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    /// Compile formulation semantics from a multiconductor AC OPF instance.
206    ///
207    /// # Errors
208    /// The applicability report contains an error, or the selected voltage
209    /// reference is absent, incomplete, non-finite, or zero magnitude.
210    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/// Check the current strict LinDist3Flow boundary without constructing an
640/// instance. Findings are stable diagnostics; callers can display all of them
641/// before deciding whether to transform the network.
642#[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}