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 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::{
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/// Selection of the fixed phasors used to form LinDist3Flow coefficients.
26#[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    /// Use the instance initial point when present, otherwise propagate source
32    /// phasors through the no-load line topology.
33    #[default]
34    Auto,
35    /// Require the instance to carry a complete voltage initial point.
36    Explicit,
37    /// Ignore any initial point and propagate source phasors without line drop.
38    SourcePropagated,
39}
40
41pub use powerio_dist::LinDist3FlowPreparationPolicy as LinDist3FlowUnsupported;
42
43/// Semantic choices used while creating a LinDist3Flow instance.
44#[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    /// Require typed neutral-Kron provenance even when no explicit neutral
51    /// remains in the network.
52    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/// One retained voltage node, identified independently of dense row order.
76#[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/// One line conductor in a deterministic source-rooted orientation.
85#[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    /// Whether this direction is opposite the input line's from/to order.
95    pub reversed: bool,
96}
97
98/// The source-covered conductor graph used by the formulation.
99#[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    /// Whether at least one retained conductor closes a cycle or parallels an
108    /// existing conductor connection.
109    pub meshed: bool,
110}
111
112/// Whether applicability checks permit instance construction.
113#[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/// Structured findings and topology evidence from the formulation front end.
123#[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/// Origin of the fixed voltage reference.
143#[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/// One reference voltage phasor in polar SI coordinates.
153#[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    /// Volts.
159    pub magnitude: f64,
160    /// Radians.
161    pub angle: f64,
162}
163
164/// Complete immutable phasor reference in topology node order.
165#[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/// Matrix-free LinDist3Flow OPF instance.
183#[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    /// Build a LinDist3Flow instance directly from a multiconductor network.
196    ///
197    /// # Errors
198    /// As [`McAcOpfInstance::from_network`] and [`Self::from_mc_ac`].
199    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    /// Compile formulation semantics from a multiconductor AC OPF instance.
207    ///
208    /// # Errors
209    /// The applicability report contains an error, or the selected voltage
210    /// reference is absent, incomplete, non-finite, or zero magnitude.
211    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    /// The unmodified multiconductor OPF instance supplied by the caller.
249    #[must_use]
250    pub fn source_instance(&self) -> &McAcOpfInstance {
251        &self.source_base
252    }
253
254    /// The unmodified distribution network supplied by the caller.
255    #[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    /// Typed provenance for every component transformation and omission.
291    #[must_use]
292    pub const fn preparation(&self) -> &LinDist3FlowPreparationReport {
293        &self.preparation
294    }
295}
296
297/// Fixed-dispatch LinDist3Flow instance.
298///
299/// Generator/IBR active and reactive powers are fixed by nominal values or
300/// equal lower/upper bounds. Line thermal limits are monitored after a
301/// converged solve instead of entering the conic feasibility problem. Voltage
302/// and generator-capability selections remain active.
303#[derive(Clone, Debug)]
304pub struct LinDist3FlowPfInstance {
305    formulation: LinDist3FlowOpfInstance,
306}
307
308impl LinDist3FlowPfInstance {
309    /// Build a fixed-dispatch instance from a multiconductor network.
310    ///
311    /// # Errors
312    /// As [`LinDist3FlowOpfInstance::from_network`], or a generator/IBR states
313    /// a dispatch range rather than fixed active and reactive power.
314    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    /// Build from a multiconductor OPF container while discarding its
322    /// objective and monitoring, rather than enforcing, conductor limits.
323    ///
324    /// # Errors
325    /// As [`LinDist3FlowOpfInstance::from_mc_ac`], or dispatch is not fixed.
326    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    /// Restore a fixed-dispatch instance from its canonical formulation.
342    ///
343    /// # Errors
344    /// The formulation carries an objective, enforces conductor limits, or
345    /// contains dispatch ranges rather than fixed active/reactive powers.
346    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    /// The internal zero-objective formulation used by matrix builders.
370    #[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)] // Equal bounds are the explicit fixed-dispatch representation.
412fn 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/// Check the current strict LinDist3Flow boundary without constructing an
847/// instance. Findings are stable diagnostics; callers can display all of them
848/// before deciding whether to transform the network.
849#[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-level loops whose lines carry disjoint conductors are still a
1083        // conductor forest; only conductor joins below decide `meshed`.
1084        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}