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