Skip to main content

powerio_dist/
model.rs

1//! The canonical multiconductor network model.
2//!
3//! Wire coordinates with BMOPF semantics: string bus ids, ordered string
4//! terminal names per bus, explicit grounding on buses, terminal maps on
5//! every element, SI units (V, W, var, ohm, S, meters) and radians. Terminal
6//! names are the OpenDSS node numbers as strings; implicit ground
7//! connections become an explicit perfectly grounded neutral
8//! terminal on the bus (named 4 on a three phase bus), the convention
9//! PowerModelsDistribution and the public BMOPF examples share.
10//!
11//! Transformer impedances stay in the per unit form the source formats use
12//! (`r_pct`, `xsc_pct` as percent of the winding base); the BMOPF writer
13//! converts to ohms on the wye side at emission. Everything an element
14//! carries beyond the typed fields lives in its `extras` map.
15
16use std::collections::BTreeMap;
17
18use serde::{Deserialize, Serialize};
19
20use crate::geo::{DistGeoMeta, DistLocation};
21
22mod located_network;
23
24pub type Extras = BTreeMap<String, serde_json::Value>;
25
26/// A square matrix in conductor order, row major.
27pub type ConductorMatrix = Vec<Vec<f64>>;
28
29/// Where the network came from; fixes the echo tier target.
30#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
31#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
32#[serde(rename_all = "kebab-case")]
33#[non_exhaustive]
34pub enum DistSourceFormat {
35    Dss,
36    BmopfJson,
37    PmdJson,
38    CimDist,
39    Sincal,
40}
41
42impl DistSourceFormat {
43    /// The canonical source format name. A read-only format need not have a
44    /// corresponding [`crate::DistTargetFormat`].
45    pub fn name(self) -> &'static str {
46        match self {
47            DistSourceFormat::Sincal => "sincal",
48            DistSourceFormat::Dss => "dss",
49            DistSourceFormat::PmdJson => "pmd-json",
50            DistSourceFormat::BmopfJson => "bmopf-json",
51            DistSourceFormat::CimDist => "cim-dist",
52        }
53    }
54}
55
56#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
57#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
58#[non_exhaustive]
59pub struct DistBus {
60    pub id: String,
61    /// Ordered terminal names; OpenDSS node numbers as strings.
62    pub terminals: Vec<String>,
63    /// Terminals tied to ground with zero impedance.
64    pub grounded: Vec<String>,
65    /// Voltage magnitude bounds, volts: the scalar pair plus the phase to
66    /// neutral and phase to phase families, and the per-sequence scalars
67    /// (BMOPF schema 0.1.0: positive sequence has both bounds, negative and
68    /// zero sequence and neutral to ground are magnitude caps whose lower
69    /// bound is always 0).
70    pub v_min: Option<f64>,
71    pub v_max: Option<f64>,
72    /// Nonuniform phase-to-ground bounds in phase-terminal order, volts.
73    /// A scalar bound, when present, takes precedence over this vector.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub v_min_phase: Option<Vec<f64>>,
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub v_max_phase: Option<Vec<f64>>,
78    pub vpn_min: Option<Vec<f64>>,
79    pub vpn_max: Option<Vec<f64>>,
80    pub vpp_min: Option<Vec<f64>>,
81    pub vpp_max: Option<Vec<f64>>,
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub vpos_min: Option<f64>,
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub vpos_max: Option<f64>,
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub vneg_max: Option<f64>,
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub vzero_max: Option<f64>,
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub vn_max: Option<f64>,
92    /// Optional bus coordinates in the network coordinate space.
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub location: Option<DistLocation>,
95    pub extras: Extras,
96}
97
98impl DistBus {
99    /// Phase positions in terminal order, using explicit BMOPF roles when supplied.
100    #[must_use]
101    pub fn phase_indices(&self, conventions: Option<&serde_json::Value>) -> Vec<usize> {
102        let numeric_four = self.terminals.len() == 4
103            && ["1", "2", "3", "4"]
104                .iter()
105                .all(|t| self.terminals.iter().any(|v| v == t));
106        self.terminals
107            .iter()
108            .enumerate()
109            .filter_map(|(index, terminal)| {
110                let nonphase = if let Some(roles) = conventions {
111                    ["neutral", "earth"].iter().any(|role| {
112                        roles
113                            .get(role)
114                            .and_then(serde_json::Value::as_array)
115                            .is_some_and(|names| {
116                                names
117                                    .iter()
118                                    .any(|name| name.as_str() == Some(terminal.as_str()))
119                            })
120                    })
121                } else {
122                    terminal.eq_ignore_ascii_case("n") || (numeric_four && terminal == "4")
123                };
124                (!nonphase).then_some(index)
125            })
126            .collect()
127    }
128
129    #[must_use]
130    pub fn new(id: impl Into<String>, terminals: Vec<String>) -> Self {
131        Self {
132            id: id.into(),
133            terminals,
134            grounded: Vec::new(),
135            v_min: None,
136            v_max: None,
137            v_min_phase: None,
138            v_max_phase: None,
139            vpn_min: None,
140            vpn_max: None,
141            vpp_min: None,
142            vpp_max: None,
143            vpos_min: None,
144            vpos_max: None,
145            vneg_max: None,
146            vzero_max: None,
147            vn_max: None,
148            location: None,
149            extras: Extras::new(),
150        }
151    }
152}
153
154#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
155#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
156#[non_exhaustive]
157pub struct DistLineCode {
158    pub name: String,
159    pub n_conductors: usize,
160    /// Series impedance, ohm per meter.
161    pub r_series: ConductorMatrix,
162    pub x_series: ConductorMatrix,
163    /// Shunt admittance halves at each end, S per meter.
164    pub g_from: ConductorMatrix,
165    pub b_from: ConductorMatrix,
166    pub g_to: ConductorMatrix,
167    pub b_to: ConductorMatrix,
168    /// Ampacity per conductor.
169    #[serde(default)]
170    pub i_max: Option<Vec<f64>>,
171    #[serde(default)]
172    pub s_max: Option<Vec<f64>>,
173    /// Origin of the impedance matrices (BMOPF `source`, e.g. "fem",
174    /// "datasheet", "import").
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub source: Option<String>,
177    pub extras: Extras,
178}
179
180impl DistLineCode {
181    #[must_use]
182    pub fn new(
183        name: impl Into<String>,
184        r_series: ConductorMatrix,
185        x_series: ConductorMatrix,
186    ) -> Self {
187        let n_conductors = matrix_extent(&r_series).max(matrix_extent(&x_series));
188        Self {
189            name: name.into(),
190            n_conductors,
191            r_series,
192            x_series,
193            g_from: zero_mat(n_conductors),
194            b_from: zero_mat(n_conductors),
195            g_to: zero_mat(n_conductors),
196            b_to: zero_mat(n_conductors),
197            i_max: None,
198            s_max: None,
199            source: None,
200            extras: Extras::new(),
201        }
202    }
203}
204
205#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
206#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
207#[non_exhaustive]
208pub struct DistLine {
209    pub name: String,
210    pub bus_from: String,
211    pub bus_to: String,
212    pub terminal_map_from: Vec<String>,
213    pub terminal_map_to: Vec<String>,
214    pub linecode: String,
215    /// Meters.
216    pub length: f64,
217    /// Polyline route in the network's coordinate space (`MulticonductorNetwork.geo`),
218    /// present only when a source provides intermediate geometry.
219    /// `#[serde(default)]` so JSON written before the field existed still
220    /// deserializes.
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub route: Option<Vec<DistLocation>>,
223    /// Per-conductor ampacity and apparent power limits, amps and VA
224    /// (BMOPF schema 0.1.0 line fields, alongside the linecode's own).
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub i_max: Option<Vec<f64>>,
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub s_max: Option<Vec<f64>>,
229    pub extras: Extras,
230}
231
232impl DistLine {
233    #[must_use]
234    pub fn new(
235        name: impl Into<String>,
236        bus_from: impl Into<String>,
237        bus_to: impl Into<String>,
238        terminal_map_from: Vec<String>,
239        terminal_map_to: Vec<String>,
240        linecode: impl Into<String>,
241        length: f64,
242    ) -> Self {
243        Self {
244            name: name.into(),
245            bus_from: bus_from.into(),
246            bus_to: bus_to.into(),
247            terminal_map_from,
248            terminal_map_to,
249            linecode: linecode.into(),
250            length,
251            route: None,
252            i_max: None,
253            s_max: None,
254            extras: Extras::new(),
255        }
256    }
257}
258
259/// A rated capacitor bank (BMOPF schema 0.1.0 `capacitor`): `q_rated` vars
260/// delivered at `v_nom` volts across the element terminals, distinct from the
261/// raw admittance [`DistShunt`]. The DSS converter still lowers OpenDSS
262/// capacitors to shunt B matrices; this element carries capacitors that arrive
263/// as BMOPF input.
264#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
265#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
266#[non_exhaustive]
267pub struct DistCapacitor {
268    pub name: String,
269    pub bus: String,
270    pub terminal_map: Vec<String>,
271    pub configuration: Configuration,
272    /// Nameplate rated reactive power of the whole bank, vars.
273    pub q_rated: f64,
274    /// Nameplate nominal voltage, volts: line to line for the three phase
275    /// configurations, across the terminals for SINGLE_PHASE.
276    pub v_nom: f64,
277    pub extras: Extras,
278}
279
280impl DistCapacitor {
281    #[must_use]
282    pub fn new(
283        name: impl Into<String>,
284        bus: impl Into<String>,
285        terminal_map: Vec<String>,
286        configuration: Configuration,
287        q_rated: f64,
288        v_nom: f64,
289    ) -> Self {
290        Self {
291            name: name.into(),
292            bus: bus.into(),
293            terminal_map,
294            configuration,
295            q_rated,
296            v_nom,
297            extras: Extras::new(),
298        }
299    }
300}
301
302#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
303#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
304#[non_exhaustive]
305pub struct DistSwitch {
306    pub name: String,
307    pub bus_from: String,
308    pub bus_to: String,
309    pub terminal_map_from: Vec<String>,
310    pub terminal_map_to: Vec<String>,
311    pub open: bool,
312    /// Ampacity per conductor.
313    #[serde(default)]
314    pub i_max: Option<Vec<f64>>,
315    pub extras: Extras,
316}
317
318impl DistSwitch {
319    #[must_use]
320    pub fn new(
321        name: impl Into<String>,
322        bus_from: impl Into<String>,
323        bus_to: impl Into<String>,
324        terminal_map_from: Vec<String>,
325        terminal_map_to: Vec<String>,
326        open: bool,
327    ) -> Self {
328        Self {
329            name: name.into(),
330            bus_from: bus_from.into(),
331            bus_to: bus_to.into(),
332            terminal_map_from,
333            terminal_map_to,
334            open,
335            i_max: None,
336            extras: Extras::new(),
337        }
338    }
339}
340
341#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
342#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
343#[serde(rename_all = "snake_case")]
344#[non_exhaustive]
345pub enum Configuration {
346    Wye,
347    Delta,
348    SinglePhase,
349}
350
351#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
352#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
353#[non_exhaustive]
354pub struct DistLoad {
355    pub name: String,
356    pub bus: String,
357    pub terminal_map: Vec<String>,
358    pub configuration: Configuration,
359    /// Watts per phase.
360    pub p_nom: Vec<f64>,
361    /// Vars per phase.
362    pub q_nom: Vec<f64>,
363    pub voltage_model: DistLoadVoltageModel,
364    pub extras: Extras,
365}
366
367impl DistLoad {
368    #[must_use]
369    pub fn new(
370        name: impl Into<String>,
371        bus: impl Into<String>,
372        terminal_map: Vec<String>,
373        configuration: Configuration,
374        p_nom: Vec<f64>,
375        q_nom: Vec<f64>,
376    ) -> Self {
377        Self {
378            name: name.into(),
379            bus: bus.into(),
380            terminal_map,
381            configuration,
382            p_nom,
383            q_nom,
384            voltage_model: DistLoadVoltageModel::default(),
385            extras: Extras::new(),
386        }
387    }
388}
389
390#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
391#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
392#[serde(tag = "model", rename_all = "snake_case")]
393#[non_exhaustive]
394pub enum DistLoadVoltageModel {
395    /// Constant power load. `v_nom` is volts per active phase when the source
396    /// states it.
397    ConstantPower { v_nom: Vec<f64> },
398    /// Constant current load. `v_nom` is volts per active phase.
399    ConstantCurrent { v_nom: Vec<f64> },
400    /// Constant impedance load. `v_nom` is volts per active phase.
401    ConstantImpedance { v_nom: Vec<f64> },
402    /// ZIP load coefficients by active phase. `v_nom` is volts per active
403    /// phase; alpha terms apply to active power and beta terms to reactive
404    /// power.
405    Zip {
406        v_nom: Vec<f64>,
407        alpha_z: Vec<f64>,
408        alpha_i: Vec<f64>,
409        alpha_p: Vec<f64>,
410        beta_z: Vec<f64>,
411        beta_i: Vec<f64>,
412        beta_p: Vec<f64>,
413    },
414    /// Exponential voltage model by active phase. `v_nom` is volts per active
415    /// phase.
416    Exponential {
417        v_nom: Vec<f64>,
418        gamma_p: Vec<f64>,
419        gamma_q: Vec<f64>,
420    },
421    /// ZIP law with low-voltage current interpolation and high-voltage
422    /// impedance continuation. See [`Self::power_scale`] for the equations.
423    /// Below `v_low_pu` the entire nominal load is constant impedance;
424    /// the dropout curve applies only above that threshold.
425    BoundedZip {
426        v_nom: Vec<f64>,
427        alpha_z: Vec<f64>,
428        alpha_i: Vec<f64>,
429        alpha_p: Vec<f64>,
430        beta_z: Vec<f64>,
431        beta_i: Vec<f64>,
432        beta_p: Vec<f64>,
433        v_low_pu: f64,
434        v_min_pu: f64,
435        v_max_pu: f64,
436        cutoff_pu: f64,
437    },
438    /// Exponential law inside a voltage band, with nominal-impedance
439    /// continuation below `v_low_pu`, current interpolation up to `v_min_pu`,
440    /// and impedance continuation above `v_max_pu`. The continuations apply
441    /// equally to P and Q, independently of their middle-band exponents.
442    /// See [`Self::power_scale`] for the equations. Arrays follow active branches.
443    BoundedExponential {
444        v_nom: Vec<f64>,
445        gamma_p: Vec<f64>,
446        gamma_q: Vec<f64>,
447        v_low_pu: f64,
448        v_min_pu: f64,
449        v_max_pu: f64,
450    },
451    /// Constant power between positive voltage bounds, with impedance
452    /// continuation outside them and an optional current-magnitude limit.
453    /// `v_nom` is volts per active branch. `current_limit_pu` is a multiple
454    /// of that branch's nominal apparent power divided by nominal voltage.
455    /// P and Q share one scale, preserving power factor. See [`Self::power_scale`].
456    BoundedConstantPower {
457        v_nom: Vec<f64>,
458        v_min_pu: f64,
459        v_max_pu: f64,
460        current_limit_pu: Option<f64>,
461    },
462}
463
464impl Default for DistLoadVoltageModel {
465    fn default() -> Self {
466        Self::ConstantPower { v_nom: Vec::new() }
467    }
468}
469
470impl DistLoadVoltageModel {
471    #[must_use]
472    pub fn v_nom(&self) -> &[f64] {
473        match self {
474            Self::ConstantPower { v_nom }
475            | Self::ConstantCurrent { v_nom }
476            | Self::ConstantImpedance { v_nom }
477            | Self::Zip { v_nom, .. }
478            | Self::Exponential { v_nom, .. }
479            | Self::BoundedZip { v_nom, .. }
480            | Self::BoundedConstantPower { v_nom, .. }
481            | Self::BoundedExponential { v_nom, .. } => v_nom,
482        }
483    }
484}
485
486#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
487#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
488#[non_exhaustive]
489pub struct DistGenerator {
490    pub name: String,
491    pub bus: String,
492    pub terminal_map: Vec<String>,
493    pub configuration: Configuration,
494    /// Setpoint, watts per phase.
495    pub p_nom: Vec<f64>,
496    pub q_nom: Vec<f64>,
497    /// Bounds per phase.
498    #[serde(default)]
499    pub p_min: Option<Vec<f64>>,
500    #[serde(default)]
501    pub p_max: Option<Vec<f64>>,
502    #[serde(default)]
503    pub q_min: Option<Vec<f64>>,
504    #[serde(default)]
505    pub q_max: Option<Vec<f64>>,
506    /// Generation cost, $/kWh per phase conductor, exactly as the source
507    /// states it: one entry per phase, or a single entry for a scalar
508    /// statement. No OpenDSS equivalent, so it is None until a format
509    /// supplies it.
510    pub cost: Option<Vec<f64>>,
511    /// Per-conductor apparent power and current limits, VA and amps (BMOPF
512    /// generator fields, alongside the p/q bounds).
513    #[serde(default, skip_serializing_if = "Option::is_none")]
514    pub s_max: Option<Vec<f64>>,
515    #[serde(default, skip_serializing_if = "Option::is_none")]
516    pub i_max: Option<Vec<f64>>,
517    pub extras: Extras,
518}
519
520impl DistGenerator {
521    #[must_use]
522    pub fn new(
523        name: impl Into<String>,
524        bus: impl Into<String>,
525        terminal_map: Vec<String>,
526        configuration: Configuration,
527        p_nom: Vec<f64>,
528        q_nom: Vec<f64>,
529    ) -> Self {
530        Self {
531            name: name.into(),
532            bus: bus.into(),
533            terminal_map,
534            configuration,
535            p_nom,
536            q_nom,
537            p_min: None,
538            p_max: None,
539            q_min: None,
540            q_max: None,
541            cost: None,
542            s_max: None,
543            i_max: None,
544            extras: Extras::new(),
545        }
546    }
547}
548
549#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
550#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
551#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
552#[non_exhaustive]
553pub enum IbrTopology {
554    SinglePhase,
555    ThreeLeg,
556    FourLeg,
557}
558
559#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
560#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
561#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
562#[non_exhaustive]
563pub enum IbrPrimeMover {
564    Pv,
565    Battery,
566    Generic,
567    Statcom,
568    Dstatcom,
569}
570
571#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
572#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
573#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
574#[non_exhaustive]
575pub enum IbrVoltageAggregation {
576    PerPhase,
577    Average,
578}
579
580#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
581#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
582#[non_exhaustive]
583pub struct DistIbr {
584    pub name: String,
585    pub bus: String,
586    pub terminal_map: Vec<String>,
587    pub topology: IbrTopology,
588    pub prime_mover: IbrPrimeMover,
589    /// Per phase apparent power nameplate ratings, volt amperes.
590    pub s_max: Vec<f64>,
591    /// Per conductor current limits, amperes.
592    #[serde(default)]
593    pub i_max: Option<Vec<f64>>,
594    /// Available active power, watts.
595    pub p_avail: Option<f64>,
596    #[serde(default)]
597    pub p_min: Option<Vec<f64>>,
598    #[serde(default)]
599    pub p_max: Option<Vec<f64>>,
600    #[serde(default)]
601    pub q_min: Option<Vec<f64>>,
602    #[serde(default)]
603    pub q_max: Option<Vec<f64>>,
604    pub control_profile: Option<String>,
605    pub voltage_aggregation: Option<IbrVoltageAggregation>,
606    pub extras: Extras,
607}
608
609impl DistIbr {
610    #[must_use]
611    pub fn new(
612        name: impl Into<String>,
613        bus: impl Into<String>,
614        terminal_map: Vec<String>,
615        topology: IbrTopology,
616        prime_mover: IbrPrimeMover,
617        s_max: Vec<f64>,
618    ) -> Self {
619        Self {
620            name: name.into(),
621            bus: bus.into(),
622            terminal_map,
623            topology,
624            prime_mover,
625            s_max,
626            i_max: None,
627            p_avail: None,
628            p_min: None,
629            p_max: None,
630            q_min: None,
631            q_max: None,
632            control_profile: None,
633            voltage_aggregation: None,
634            extras: Extras::new(),
635        }
636    }
637}
638
639#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
640#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
641#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
642#[non_exhaustive]
643pub enum ControlVoltageReference {
644    PnPerPhase,
645    PpPerPhase,
646    PpAveraged,
647    PgAveraged,
648    PnAveraged,
649    PgPerPhase,
650}
651
652#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
653#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
654#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
655#[non_exhaustive]
656pub enum ReactivePowerUnit {
657    VaFraction,
658    Var,
659}
660
661#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
662#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
663#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
664#[non_exhaustive]
665pub enum ActivePowerUnit {
666    VaFraction,
667    W,
668}
669
670#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
671#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
672#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
673#[non_exhaustive]
674pub enum ReactivePowerReference {
675    VarMax,
676    VarAvailable,
677}
678
679#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
680#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
681#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
682#[non_exhaustive]
683pub enum ActivePowerReference {
684    PAvailable,
685    PMax,
686    SMax,
687}
688
689#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
690#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
691#[non_exhaustive]
692pub struct PowerFactorControl {
693    pub pf: f64,
694}
695
696#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
697#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
698#[non_exhaustive]
699pub struct VoltVarControl {
700    pub voltage_reference: Option<ControlVoltageReference>,
701    pub breakpoints: Vec<f64>,
702    pub q_limits: Vec<f64>,
703    pub q_unit: Option<ReactivePowerUnit>,
704    pub q_ref: Option<ReactivePowerReference>,
705    pub p_min_for_q: Option<f64>,
706    pub p_min_for_q_max: Option<f64>,
707}
708
709#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
710#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
711#[non_exhaustive]
712pub struct VoltWattControl {
713    pub voltage_reference: Option<ControlVoltageReference>,
714    pub breakpoints: Vec<f64>,
715    pub p_limits: Vec<f64>,
716    pub p_unit: Option<ActivePowerUnit>,
717    pub p_ref: Option<ActivePowerReference>,
718}
719
720#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
721#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
722#[non_exhaustive]
723pub struct DistControlProfile {
724    pub name: String,
725    pub power_factor: Option<PowerFactorControl>,
726    pub volt_var: Option<VoltVarControl>,
727    pub volt_watt: Option<VoltWattControl>,
728    pub extras: Extras,
729}
730
731impl DistControlProfile {
732    #[must_use]
733    pub fn new(name: impl Into<String>) -> Self {
734        Self {
735            name: name.into(),
736            power_factor: None,
737            volt_var: None,
738            volt_watt: None,
739            extras: Extras::new(),
740        }
741    }
742}
743
744#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
745#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
746#[non_exhaustive]
747pub struct DistShunt {
748    pub name: String,
749    pub bus: String,
750    pub terminal_map: Vec<String>,
751    /// Total siemens in conductor order.
752    pub g: ConductorMatrix,
753    pub b: ConductorMatrix,
754    pub extras: Extras,
755}
756
757impl DistShunt {
758    #[must_use]
759    pub fn new(
760        name: impl Into<String>,
761        bus: impl Into<String>,
762        terminal_map: Vec<String>,
763        g: ConductorMatrix,
764        b: ConductorMatrix,
765    ) -> Self {
766        Self {
767            name: name.into(),
768            bus: bus.into(),
769            terminal_map,
770            g,
771            b,
772            extras: Extras::new(),
773        }
774    }
775}
776
777#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
778#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
779#[serde(rename_all = "snake_case")]
780#[non_exhaustive]
781pub enum DistWindingConn {
782    Wye,
783    Delta,
784}
785
786#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
787#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
788#[non_exhaustive]
789pub struct DistWinding {
790    pub bus: String,
791    pub terminal_map: Vec<String>,
792    pub conn: DistWindingConn,
793    /// Rated winding voltage, volts (line to line for 2 and 3 phase).
794    pub v_ref: f64,
795    /// Volt amperes.
796    pub s_rating: f64,
797    /// Winding resistance, percent of the winding base.
798    pub r_pct: f64,
799    pub tap: f64,
800    #[serde(default, skip_serializing_if = "Option::is_none")]
801    pub r_neutral: Option<f64>,
802    #[serde(default, skip_serializing_if = "Option::is_none")]
803    pub x_neutral: Option<f64>,
804}
805
806impl DistWinding {
807    #[must_use]
808    pub fn new(
809        bus: impl Into<String>,
810        terminal_map: Vec<String>,
811        conn: DistWindingConn,
812        v_ref: f64,
813        s_rating: f64,
814    ) -> Self {
815        Self {
816            bus: bus.into(),
817            terminal_map,
818            conn,
819            v_ref,
820            s_rating,
821            r_pct: 0.0,
822            tap: 1.0,
823            r_neutral: None,
824            x_neutral: None,
825        }
826    }
827}
828
829#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
830#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
831#[non_exhaustive]
832pub struct DistTransformer {
833    pub name: String,
834    pub windings: Vec<DistWinding>,
835    /// Short circuit reactances between winding pairs, percent:
836    /// Upper-triangular order `12, 13, ..., 1n, 23, ..., (n-1)n`,
837    /// on winding 1's power base, matching OpenDSS `Xscarray`.
838    pub xsc_pct: Vec<f64>,
839    pub phases: usize,
840    pub extras: Extras,
841}
842
843impl DistTransformer {
844    #[must_use]
845    pub fn new(
846        name: impl Into<String>,
847        windings: Vec<DistWinding>,
848        xsc_pct: Vec<f64>,
849        phases: usize,
850    ) -> Self {
851        Self {
852            name: name.into(),
853            windings,
854            xsc_pct,
855            phases,
856            extras: Extras::new(),
857        }
858    }
859}
860
861mod voltage_source;
862pub use voltage_source::VoltageSource;
863
864/// An object the reader recognized but does not type: preserved by class,
865/// name, and raw property text so conversions can warn precisely.
866#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
867#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
868#[non_exhaustive]
869pub struct UntypedObject {
870    pub class: String,
871    pub name: String,
872    pub props: Vec<(Option<String>, String)>,
873}
874
875impl UntypedObject {
876    #[must_use]
877    pub fn new(
878        class: impl Into<String>,
879        name: impl Into<String>,
880        props: Vec<(Option<String>, String)>,
881    ) -> Self {
882        Self {
883            class: class.into(),
884            name: name.into(),
885            props,
886        }
887    }
888}
889
890/// A multiconductor distribution network: an immutable cheap to clone owning
891/// handle over private shared tables.
892///
893/// Cloning the handle bumps one reference count and clones no table
894/// allocation. Reads go through the per field accessors; the `*_mut`
895/// accessors copy the shared tables once on first write to a shared handle
896/// (copy on write), so no other handle ever observes a mutation. Per element
897/// (`"class.name"` key), the tables also record which fields the reader
898/// materialized from a format default rather than the source text. The
899/// original source text for the byte exact echo tier is not carried here; it
900/// lives on the [`PioModule`](powerio_core::PioModule) a parse returns,
901/// alongside this network.
902#[derive(Debug, Clone)]
903pub struct MulticonductorNetwork {
904    tables: std::sync::Arc<MulticonductorNetworkTables>,
905}
906
907impl MulticonductorNetwork {
908    pub(crate) fn from_tables(tables: MulticonductorNetworkTables) -> Self {
909        Self {
910            tables: std::sync::Arc::new(tables),
911        }
912    }
913
914    /// The one mutation door: copies the shared tables on first write to a
915    /// shared handle, so no other handle observes the change.
916    pub(crate) fn tables_mut(&mut self) -> &mut MulticonductorNetworkTables {
917        std::sync::Arc::make_mut(&mut self.tables)
918    }
919}
920
921/// A multiconductor distribution network.
922///
923/// `source` retains the original text for the byte exact echo tier;
924/// `defaulted` records, per element (`"class.name"` key), the fields the
925/// reader materialized from format defaults rather than the source text.
926// The one owned table store behind the `MulticonductorNetwork` handle.
927#[derive(Clone, Debug, Serialize, Deserialize)]
928#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
929#[cfg_attr(feature = "schema", schemars(rename = "MulticonductorNetwork"))]
930#[serde(remote = "Self")]
931pub(crate) struct MulticonductorNetworkTables {
932    pub name: Option<String>,
933    /// Hz.
934    pub base_frequency: f64,
935    #[serde(default, skip_serializing_if = "Option::is_none")]
936    pub geo: Option<DistGeoMeta>,
937    /// Component geography uses a distinct stored network wrapper. The legacy
938    /// network body and its schema remain unchanged when this table is empty.
939    #[serde(skip)]
940    pub component_locations: Vec<crate::DistComponentLocation>,
941    pub buses: Vec<DistBus>,
942    #[serde(rename = "linecodes")]
943    pub line_codes: Vec<DistLineCode>,
944    pub lines: Vec<DistLine>,
945    pub switches: Vec<DistSwitch>,
946    pub transformers: Vec<DistTransformer>,
947    pub loads: Vec<DistLoad>,
948    pub generators: Vec<DistGenerator>,
949    #[serde(default, skip_serializing_if = "Vec::is_empty")]
950    pub ibrs: Vec<DistIbr>,
951    #[serde(default, skip_serializing_if = "Vec::is_empty")]
952    pub control_profiles: Vec<DistControlProfile>,
953    pub shunts: Vec<DistShunt>,
954    #[serde(default, skip_serializing_if = "Vec::is_empty")]
955    pub capacitors: Vec<DistCapacitor>,
956    /// BMOPF allows exactly one; the model allows any number and the BMOPF
957    /// writer warns beyond the first.
958    pub sources: Vec<VoltageSource>,
959    #[serde(rename = "untyped")]
960    pub untyped_objects: Vec<UntypedObject>,
961    /// Source commands and options the typed model does not interpret
962    /// (`solve`, `set mode=...`), in order, as (verb, args).
963    pub commands: Vec<(String, String)>,
964    pub options: Vec<(String, String)>,
965    /// Per-element record of which fields were materialized from a format
966    /// default. Skipped in the `.pio.json` payload: the field holds
967    /// `&'static str` (no `Deserialize`), and these defaulted fields belong in
968    /// the module source map with `mapping_kind = defaulted`, not in the
969    /// network value. See
970    /// <https://eigenergy.github.io/powerio/guide/pio-json-schema.html>.
971    #[serde(skip)]
972    pub defaulted: BTreeMap<String, Vec<&'static str>>,
973    pub source_format: Option<DistSourceFormat>,
974    pub extras: Extras,
975}
976
977// `remote = "Self"` turns the derived serde impls into inherent functions;
978// routing them through `powerio_core::nonfinite` spells a nonfinite float as
979// `"Infinity"`/`"-Infinity"`/`"NaN"` on every JSON route, the same convention
980// as the balanced model.
981impl serde::Serialize for MulticonductorNetwork {
982    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
983        located_network::serialize(self, serializer)
984    }
985}
986
987impl<'de> serde::Deserialize<'de> for MulticonductorNetwork {
988    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
989        located_network::deserialize(deserializer)
990    }
991}
992
993#[cfg(feature = "schema")]
994impl schemars::JsonSchema for MulticonductorNetwork {
995    fn schema_name() -> std::borrow::Cow<'static, str> {
996        <MulticonductorNetworkTables as schemars::JsonSchema>::schema_name()
997    }
998
999    fn schema_id() -> std::borrow::Cow<'static, str> {
1000        <MulticonductorNetworkTables as schemars::JsonSchema>::schema_id()
1001    }
1002
1003    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
1004        located_network::json_schema(generator)
1005    }
1006}
1007
1008macro_rules! dist_table_accessors {
1009    ($($field:ident, $field_mut:ident: $ty:ty;)+) => {
1010        impl MulticonductorNetwork {
1011            $(
1012                #[must_use]
1013                pub fn $field(&self) -> &$ty {
1014                    &self.tables.$field
1015                }
1016
1017                /// Mutable access to the same table; a shared handle copies
1018                /// its tables once here, so no other handle observes the
1019                /// change.
1020                #[must_use]
1021                pub fn $field_mut(&mut self) -> &mut $ty {
1022                    &mut self.tables_mut().$field
1023                }
1024            )+
1025        }
1026    };
1027}
1028
1029dist_table_accessors! {
1030    name, name_mut: Option<String>;
1031    geo, geo_mut: Option<DistGeoMeta>;
1032    component_locations, component_locations_mut: Vec<crate::DistComponentLocation>;
1033    buses, buses_mut: Vec<DistBus>;
1034    line_codes, line_codes_mut: Vec<DistLineCode>;
1035    lines, lines_mut: Vec<DistLine>;
1036    switches, switches_mut: Vec<DistSwitch>;
1037    transformers, transformers_mut: Vec<DistTransformer>;
1038    loads, loads_mut: Vec<DistLoad>;
1039    generators, generators_mut: Vec<DistGenerator>;
1040    ibrs, ibrs_mut: Vec<DistIbr>;
1041    control_profiles, control_profiles_mut: Vec<DistControlProfile>;
1042    shunts, shunts_mut: Vec<DistShunt>;
1043    capacitors, capacitors_mut: Vec<DistCapacitor>;
1044    sources, sources_mut: Vec<VoltageSource>;
1045    untyped_objects, untyped_objects_mut: Vec<UntypedObject>;
1046    commands, commands_mut: Vec<(String, String)>;
1047    options, options_mut: Vec<(String, String)>;
1048    defaulted, defaulted_mut: BTreeMap<String, Vec<&'static str>>;
1049    source_format, source_format_mut: Option<DistSourceFormat>;
1050    extras, extras_mut: Extras;
1051}
1052
1053impl MulticonductorNetwork {
1054    /// Hz.
1055    #[must_use]
1056    pub fn base_frequency(&self) -> f64 {
1057        self.tables.base_frequency
1058    }
1059
1060    #[must_use]
1061    pub fn base_frequency_mut(&mut self) -> &mut f64 {
1062        &mut self.tables_mut().base_frequency
1063    }
1064}
1065
1066impl Default for MulticonductorNetwork {
1067    fn default() -> Self {
1068        MulticonductorNetwork::from_tables(MulticonductorNetworkTables::default())
1069    }
1070}
1071
1072impl Default for MulticonductorNetworkTables {
1073    /// An empty network at the OpenDSS default frequency. A derived 0 Hz
1074    /// default would put NaN into every capacitance the dss writer converts
1075    /// through omega.
1076    fn default() -> Self {
1077        MulticonductorNetworkTables {
1078            name: None,
1079            base_frequency: crate::dss::defaults::BASE_FREQUENCY,
1080            geo: None,
1081            component_locations: Vec::new(),
1082            buses: Vec::new(),
1083            line_codes: Vec::new(),
1084            lines: Vec::new(),
1085            switches: Vec::new(),
1086            transformers: Vec::new(),
1087            loads: Vec::new(),
1088            generators: Vec::new(),
1089            ibrs: Vec::new(),
1090            control_profiles: Vec::new(),
1091            shunts: Vec::new(),
1092            capacitors: Vec::new(),
1093            sources: Vec::new(),
1094            untyped_objects: Vec::new(),
1095            commands: Vec::new(),
1096            options: Vec::new(),
1097            defaulted: BTreeMap::new(),
1098            source_format: None,
1099            extras: Extras::new(),
1100        }
1101    }
1102}
1103
1104impl MulticonductorNetwork {
1105    #[must_use]
1106    pub fn new() -> Self {
1107        Self::default()
1108    }
1109
1110    #[must_use]
1111    pub fn named(name: impl Into<String>) -> Self {
1112        MulticonductorNetwork::from_tables(MulticonductorNetworkTables {
1113            name: Some(name.into()),
1114            ..MulticonductorNetworkTables::default()
1115        })
1116    }
1117
1118    /// Case insensitive, matching the source formats' name semantics.
1119    pub fn bus(&self, id: &str) -> Option<&DistBus> {
1120        self.buses().iter().find(|b| b.id.eq_ignore_ascii_case(id))
1121    }
1122
1123    /// Case insensitive, matching the source formats' name semantics.
1124    pub fn linecode(&self, name: &str) -> Option<&DistLineCode> {
1125        self.line_codes()
1126            .iter()
1127            .find(|c| c.name.eq_ignore_ascii_case(name))
1128    }
1129}
1130
1131/// Susceptance converts to a dss `cmatrix` at the network frequency, so a
1132/// 50 Hz feeder read at the 60 Hz default loses a fifth of its line charging,
1133/// and a stated 60 Hz is indistinguishable from a defaulted one downstream.
1134/// Only a network carrying susceptance can lose anything, so a document that
1135/// states no frequency and no charging stays quiet.
1136pub(crate) fn warn_defaulted_frequency(
1137    net: &MulticonductorNetwork,
1138    field: &str,
1139    diags: &mut crate::collect::Diagnostics,
1140) {
1141    let charging = net.line_codes().iter().any(|c| {
1142        [&c.b_from, &c.b_to]
1143            .iter()
1144            .flat_map(|m| m.iter())
1145            .flatten()
1146            .any(|v| v.is_finite() && v.abs() > 0.0)
1147    });
1148    if charging {
1149        diags.push(
1150            &crate::diagnostics::codes::READ_MULTICONDUCTOR_VALUE_DEFAULTED,
1151            format!(
1152                "document states no {field} and carries line susceptance; read at {} Hz",
1153                net.base_frequency()
1154            ),
1155        );
1156    }
1157}
1158
1159/// Push a warning for every dangling or empty cross-reference. Bus and
1160/// linecode references are bare strings, a reader leaves an empty string
1161/// where the field is missing, and the graph projection synthesizes a
1162/// phantom bus for any unresolved id (the empty string included) — so a
1163/// typo or an absent field would otherwise parse cleanly into a
1164/// topologically wrong network. Comparison is ASCII case insensitive,
1165/// matching [`MulticonductorNetwork::bus`] and [`MulticonductorNetwork::linecode`].
1166/// The unresolved bus and linecode references of a network, as one message
1167/// per finding: the same walk the reader's warning pass runs, exposed so a
1168/// decoder can refuse a document whose network fails it.
1169#[must_use]
1170pub fn find_unresolved_references(net: &MulticonductorNetwork) -> Vec<String> {
1171    let mut diags = crate::collect::Diagnostics::new();
1172    warn_unresolved_references(net, &mut diags);
1173    diags
1174        .into_records()
1175        .into_iter()
1176        .map(|record| record.message().to_owned())
1177        .collect()
1178}
1179
1180pub(crate) fn warn_unresolved_references(
1181    net: &MulticonductorNetwork,
1182    diags: &mut crate::collect::Diagnostics,
1183) {
1184    use std::collections::BTreeSet;
1185    let buses: BTreeSet<String> = net
1186        .buses()
1187        .iter()
1188        .map(|b| b.id.to_ascii_lowercase())
1189        .collect();
1190    let linecodes: BTreeSet<String> = net
1191        .line_codes()
1192        .iter()
1193        .map(|c| c.name.to_ascii_lowercase())
1194        .collect();
1195    let mut warnings = Vec::new();
1196    {
1197        let mut bus = |what: &str, field: &str, id: &str| {
1198            if id.is_empty() {
1199                warnings.push(format!("{what}: `{field}` reference is empty or missing"));
1200            } else if !buses.contains(&id.to_ascii_lowercase()) {
1201                warnings.push(format!("{what}: references undefined bus `{id}`"));
1202            }
1203        };
1204        for l in net.lines() {
1205            let what = format!("line {}", l.name);
1206            bus(&what, "bus_from", &l.bus_from);
1207            bus(&what, "bus_to", &l.bus_to);
1208        }
1209        for sw in net.switches() {
1210            let what = format!("switch {}", sw.name);
1211            bus(&what, "bus_from", &sw.bus_from);
1212            bus(&what, "bus_to", &sw.bus_to);
1213        }
1214        for t in net.transformers() {
1215            let what = format!("transformer {}", t.name);
1216            for w in &t.windings {
1217                bus(&what, "bus", &w.bus);
1218            }
1219        }
1220        for (what, id) in std::iter::empty()
1221            .chain(
1222                net.loads()
1223                    .iter()
1224                    .map(|x| (format!("load {}", x.name), &x.bus)),
1225            )
1226            .chain(
1227                net.generators()
1228                    .iter()
1229                    .map(|x| (format!("generator {}", x.name), &x.bus)),
1230            )
1231            .chain(
1232                net.shunts()
1233                    .iter()
1234                    .map(|x| (format!("shunt {}", x.name), &x.bus)),
1235            )
1236            .chain(
1237                net.capacitors()
1238                    .iter()
1239                    .map(|x| (format!("capacitor {}", x.name), &x.bus)),
1240            )
1241            .chain(
1242                net.ibrs()
1243                    .iter()
1244                    .map(|x| (format!("ibr {}", x.name), &x.bus)),
1245            )
1246            .chain(
1247                net.sources()
1248                    .iter()
1249                    .map(|x| (format!("voltage_source {}", x.name), &x.bus)),
1250            )
1251        {
1252            bus(&what, "bus", id);
1253        }
1254    }
1255    for l in net.lines() {
1256        if l.linecode.is_empty() {
1257            warnings.push(format!(
1258                "line {}: `linecode` reference is empty or missing",
1259                l.name
1260            ));
1261        } else if !linecodes.contains(&l.linecode.to_ascii_lowercase()) {
1262            warnings.push(format!(
1263                "line {}: references undefined linecode `{}`",
1264                l.name, l.linecode
1265            ));
1266        }
1267    }
1268    for message in warnings {
1269        diags.push(
1270            &crate::diagnostics::codes::VALIDATE_MULTICONDUCTOR_REFERENCE_UNDEFINED,
1271            message,
1272        );
1273    }
1274}
1275
1276fn zero_mat(n: usize) -> ConductorMatrix {
1277    vec![vec![0.0; n]; n]
1278}
1279
1280fn matrix_extent(m: &ConductorMatrix) -> usize {
1281    m.iter().map(Vec::len).fold(m.len(), usize::max)
1282}
1283
1284/// Windings per phase for an n-winding transformer terminal map: WYE counts
1285/// the hot terminals (excluding the shared neutral), DELTA counts terminals
1286/// directly except the phase to phase two terminal case.
1287pub(crate) fn n_winding_phase_count(conn: DistWindingConn, terminal_map: &[String]) -> usize {
1288    match conn {
1289        DistWindingConn::Wye => terminal_map.len().saturating_sub(1).max(1),
1290        DistWindingConn::Delta => {
1291            if terminal_map.len() == 2 {
1292                1
1293            } else {
1294                terminal_map.len().max(1)
1295            }
1296        }
1297    }
1298}
1299
1300/// `phases * v_nom^2 / s`, the impedance base for an n-winding transformer
1301/// winding, or `None` if any input isn't a positive finite number.
1302pub(crate) fn n_winding_impedance_base(phases: usize, v_nom: f64, s: f64) -> Option<f64> {
1303    let phases = phases as f64;
1304    (phases > 0.0 && v_nom.is_finite() && v_nom > 0.0 && s.is_finite() && s > 0.0)
1305        .then_some(phases * v_nom * v_nom / s)
1306}
1307
1308/// The two phase conductors of a line to line regulating winding: exactly
1309/// two terminals, both named 1..=3, distinct, in terminal map order.
1310pub(crate) fn winding_phase_pair(winding: &DistWinding) -> Option<(u8, u8)> {
1311    let [first, second] = winding.terminal_map.as_slice() else {
1312        return None;
1313    };
1314    let phase = |term: &String| term.parse::<u8>().ok().filter(|n| (1..=3).contains(n));
1315    match (phase(first), phase(second)) {
1316        (Some(lead), Some(lag)) if lead != lag => Some((lead, lag)),
1317        _ => None,
1318    }
1319}
1320
1321/// The GridLAB-D open delta connection two ordered line to line phase pairs
1322/// spell (the naming the BMOPFTools schema extension uses), and whether the
1323/// legs arrived in the swapped order. Any other orientation is `None`.
1324pub(crate) fn open_delta_connection(a: (u8, u8), b: (u8, u8)) -> Option<(&'static str, bool)> {
1325    match (a, b) {
1326        ((1, 2), (2, 3)) => Some(("ABBC", false)),
1327        ((2, 3), (1, 2)) => Some(("ABBC", true)),
1328        ((2, 3), (1, 3)) => Some(("BCAC", false)),
1329        ((1, 3), (2, 3)) => Some(("BCAC", true)),
1330        ((3, 1), (2, 1)) => Some(("CABA", false)),
1331        ((2, 1), (3, 1)) => Some(("CABA", true)),
1332        _ => None,
1333    }
1334}
1335
1336/// Whether two single phase regulator legs are electrically identical, so
1337/// one BMOPF `open_delta_regulator` object (which states one per regulator
1338/// impedance and rating) can carry both without collapsing anything.
1339pub(crate) fn open_delta_pairable(a: &DistTransformer, b: &DistTransformer) -> bool {
1340    let ([a0, a1], [b0, b1]) = (a.windings.as_slice(), b.windings.as_slice()) else {
1341        return false;
1342    };
1343    let eq = |x: f64, y: f64| x.to_bits() == y.to_bits();
1344    eq(a0.v_ref, b0.v_ref)
1345        && eq(a1.v_ref, b1.v_ref)
1346        && eq(a0.s_rating, b0.s_rating)
1347        && eq(a1.s_rating, b1.s_rating)
1348        && eq(a0.r_pct, b0.r_pct)
1349        && eq(a1.r_pct, b1.r_pct)
1350        && a.xsc_pct.len() == b.xsc_pct.len()
1351        && a.xsc_pct.iter().zip(&b.xsc_pct).all(|(x, y)| eq(*x, *y))
1352        && ["%noloadloss", "%imag", "g_no_load", "b_no_load"]
1353            .iter()
1354            .all(|k| a.extras.get(*k) == b.extras.get(*k))
1355}
1356
1357/// Upper triangular `(i, j)` winding index pairs for `n` windings, the order
1358/// short circuit test pairs (`x_sc`/`xsc_pct`) are keyed by.
1359pub(crate) fn pair_keys(n: usize) -> Vec<(usize, usize)> {
1360    let mut pairs = Vec::new();
1361    for i in 0..n {
1362        for j in i + 1..n {
1363            pairs.push((i, j));
1364        }
1365    }
1366    pairs
1367}
1368
1369/// Actual coil voltage base, including the fixed winding tap.
1370pub(crate) fn winding_coil_voltage(winding: &DistWinding, phases: usize) -> f64 {
1371    let divisor = if winding.conn == DistWindingConn::Wye && matches!(phases, 2 | 3) {
1372        3f64.sqrt()
1373    } else {
1374        1.0
1375    };
1376    winding.v_ref * winding.tap / divisor
1377}
1378
1379/// OpenDSS no-load percentages for an explicit winding-2 terminal-coil shunt.
1380/// Other winding locations cannot use the OpenDSS exciting-branch parameters.
1381pub(crate) fn transformer_no_load_percentages(t: &DistTransformer) -> Option<(f64, f64)> {
1382    let shunt = t.extras.get("no_load_shunt")?;
1383    if shunt.get("winding")?.as_u64()? != 2 {
1384        return None;
1385    }
1386    let g = shunt.get("g")?.as_f64()?;
1387    let b = shunt.get("b")?.as_f64()?;
1388    let voltage = winding_coil_voltage(t.windings.get(1)?, t.phases);
1389    let rating = t.windings.first()?.s_rating;
1390    if !voltage.is_finite()
1391        || voltage <= 0.0
1392        || !rating.is_finite()
1393        || rating <= 0.0
1394        || g < 0.0
1395        || b > 0.0
1396    {
1397        return None;
1398    }
1399    let scale = 100.0 * t.phases.max(1) as f64 * voltage.powi(2) / rating;
1400    Some((g * scale, -b * scale))
1401}
1402
1403#[cfg(test)]
1404mod tests {
1405    use super::*;
1406
1407    #[test]
1408    fn multiconductor_json_uses_only_final_nonfinite_spellings() {
1409        let mut network = MulticonductorNetwork::named("strict-ir");
1410        let mut code = DistLineCode::new("lc", vec![vec![0.1]], vec![vec![0.2]]);
1411        code.i_max = Some(vec![f64::INFINITY]);
1412        network.line_codes_mut().push(code);
1413
1414        let text = serde_json::to_string(&network).unwrap();
1415        assert!(text.contains(r#""i_max":["Infinity"]"#), "{text}");
1416
1417        let invalid = text.replace(r#""i_max":["Infinity"]"#, r#""i_max":[null]"#);
1418        let error = serde_json::from_str::<MulticonductorNetwork>(&invalid).unwrap_err();
1419        assert!(error.to_string().contains("cannot be null"), "{error}");
1420    }
1421}