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 materialize as 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;
17use std::sync::Arc;
18
19use serde::{Deserialize, Serialize};
20
21use crate::geo::{GeoMeta, Location};
22
23pub type Extras = BTreeMap<String, serde_json::Value>;
24
25/// A square matrix in conductor order, row major.
26pub type Mat = Vec<Vec<f64>>;
27
28/// Where the network came from; fixes the echo tier target.
29#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
30#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
31#[serde(rename_all = "kebab-case")]
32#[non_exhaustive]
33pub enum DistSourceFormat {
34    Dss,
35    BmopfJson,
36    PmdJson,
37}
38
39impl DistSourceFormat {
40    /// The canonical format name (`dss`, `pmd-json`, `bmopf-json`), accepted
41    /// back by [`crate::dist_target_from_name`].
42    pub fn name(self) -> &'static str {
43        match self {
44            DistSourceFormat::Dss => "dss",
45            DistSourceFormat::PmdJson => "pmd-json",
46            DistSourceFormat::BmopfJson => "bmopf-json",
47        }
48    }
49}
50
51#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
52#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
53#[non_exhaustive]
54pub struct DistBus {
55    pub id: String,
56    /// Ordered terminal names; OpenDSS node numbers as strings.
57    pub terminals: Vec<String>,
58    /// Terminals tied to ground with zero impedance.
59    pub grounded: Vec<String>,
60    /// Voltage magnitude bounds, volts: the scalar pair plus the phase to
61    /// neutral and phase to phase families, and the per-sequence scalars
62    /// (BMOPF schema 0.1.0: positive sequence has both bounds, negative and
63    /// zero sequence and neutral to ground are magnitude caps whose lower
64    /// bound is always 0).
65    pub v_min: Option<f64>,
66    pub v_max: Option<f64>,
67    pub vpn_min: Option<Vec<f64>>,
68    pub vpn_max: Option<Vec<f64>>,
69    pub vpp_min: Option<Vec<f64>>,
70    pub vpp_max: Option<Vec<f64>>,
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub vpos_min: Option<f64>,
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub vpos_max: Option<f64>,
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub vneg_max: Option<f64>,
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub vzero_max: Option<f64>,
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub vn_max: Option<f64>,
81    /// Optional bus coordinates in the network coordinate space.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub location: Option<Location>,
84    pub extras: Extras,
85}
86
87impl DistBus {
88    #[must_use]
89    pub fn new(id: impl Into<String>, terminals: Vec<String>) -> Self {
90        Self {
91            id: id.into(),
92            terminals,
93            grounded: Vec::new(),
94            v_min: None,
95            v_max: None,
96            vpn_min: None,
97            vpn_max: None,
98            vpp_min: None,
99            vpp_max: None,
100            vpos_min: None,
101            vpos_max: None,
102            vneg_max: None,
103            vzero_max: None,
104            vn_max: None,
105            location: None,
106            extras: Extras::new(),
107        }
108    }
109}
110
111#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
112#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
113#[non_exhaustive]
114pub struct DistLineCode {
115    pub name: String,
116    pub n_conductors: usize,
117    /// Series impedance, ohm per meter.
118    pub r_series: Mat,
119    pub x_series: Mat,
120    /// Shunt admittance halves at each end, S per meter.
121    pub g_from: Mat,
122    pub b_from: Mat,
123    pub g_to: Mat,
124    pub b_to: Mat,
125    /// Ampacity per conductor. A `null` element reads as +Inf (#268).
126    #[serde(default, with = "crate::nonfinite::upper_bounds")]
127    #[cfg_attr(feature = "schema", schemars(with = "Option<Vec<Option<f64>>>"))]
128    pub i_max: Option<Vec<f64>>,
129    #[serde(default, with = "crate::nonfinite::upper_bounds")]
130    #[cfg_attr(feature = "schema", schemars(with = "Option<Vec<Option<f64>>>"))]
131    pub s_max: Option<Vec<f64>>,
132    /// Provenance of the impedance matrices (BMOPF `source`, e.g. "fem",
133    /// "datasheet", "import").
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub source: Option<String>,
136    pub extras: Extras,
137}
138
139impl DistLineCode {
140    #[must_use]
141    pub fn new(name: impl Into<String>, r_series: Mat, x_series: Mat) -> Self {
142        let n_conductors = matrix_extent(&r_series).max(matrix_extent(&x_series));
143        Self {
144            name: name.into(),
145            n_conductors,
146            r_series,
147            x_series,
148            g_from: zero_mat(n_conductors),
149            b_from: zero_mat(n_conductors),
150            g_to: zero_mat(n_conductors),
151            b_to: zero_mat(n_conductors),
152            i_max: None,
153            s_max: None,
154            source: None,
155            extras: Extras::new(),
156        }
157    }
158}
159
160#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
161#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
162#[non_exhaustive]
163pub struct DistLine {
164    pub name: String,
165    pub bus_from: String,
166    pub bus_to: String,
167    pub terminal_map_from: Vec<String>,
168    pub terminal_map_to: Vec<String>,
169    pub linecode: String,
170    /// Meters. A `null` reads as NaN: a BMOPF line without a length (#268).
171    #[serde(with = "crate::nonfinite::nan_scalar")]
172    #[cfg_attr(
173        feature = "schema",
174        schemars(schema_with = "crate::nonfinite::nullable_number")
175    )]
176    pub length: f64,
177    /// Polyline route in the network's coordinate space (`DistNetwork.geo`),
178    /// present only when a source provides intermediate geometry.
179    /// `#[serde(default)]` so JSON written before the field existed still
180    /// deserializes.
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub route: Option<Vec<Location>>,
183    /// Per-conductor ampacity and apparent power limits, amps and VA
184    /// (BMOPF schema 0.1.0 line fields, alongside the linecode's own).
185    /// A `null` element reads as +Inf (#268).
186    #[serde(
187        default,
188        skip_serializing_if = "Option::is_none",
189        with = "crate::nonfinite::upper_bounds"
190    )]
191    #[cfg_attr(feature = "schema", schemars(with = "Option<Vec<Option<f64>>>"))]
192    pub i_max: Option<Vec<f64>>,
193    #[serde(
194        default,
195        skip_serializing_if = "Option::is_none",
196        with = "crate::nonfinite::upper_bounds"
197    )]
198    #[cfg_attr(feature = "schema", schemars(with = "Option<Vec<Option<f64>>>"))]
199    pub s_max: Option<Vec<f64>>,
200    pub extras: Extras,
201}
202
203impl DistLine {
204    #[must_use]
205    pub fn new(
206        name: impl Into<String>,
207        bus_from: impl Into<String>,
208        bus_to: impl Into<String>,
209        terminal_map_from: Vec<String>,
210        terminal_map_to: Vec<String>,
211        linecode: impl Into<String>,
212        length: f64,
213    ) -> Self {
214        Self {
215            name: name.into(),
216            bus_from: bus_from.into(),
217            bus_to: bus_to.into(),
218            terminal_map_from,
219            terminal_map_to,
220            linecode: linecode.into(),
221            length,
222            route: None,
223            i_max: None,
224            s_max: None,
225            extras: Extras::new(),
226        }
227    }
228}
229
230/// A rated capacitor bank (BMOPF schema 0.1.0 `capacitor`): `q_rated` vars
231/// delivered at `v_nom` volts across the element terminals, distinct from the
232/// raw admittance [`DistShunt`]. The DSS converter still lowers OpenDSS
233/// capacitors to shunt B matrices; this element carries capacitors that arrive
234/// as BMOPF input.
235#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
236#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
237#[non_exhaustive]
238pub struct DistCapacitor {
239    pub name: String,
240    pub bus: String,
241    pub terminal_map: Vec<String>,
242    pub configuration: Configuration,
243    /// Nameplate rated reactive power of the whole bank, vars.
244    #[serde(with = "crate::nonfinite::nan_scalar")]
245    #[cfg_attr(
246        feature = "schema",
247        schemars(schema_with = "crate::nonfinite::nullable_number")
248    )]
249    pub q_rated: f64,
250    /// Nameplate nominal voltage, volts: line to line for the three phase
251    /// configurations, across the terminals for SINGLE_PHASE.
252    #[serde(with = "crate::nonfinite::nan_scalar")]
253    #[cfg_attr(
254        feature = "schema",
255        schemars(schema_with = "crate::nonfinite::nullable_number")
256    )]
257    pub v_nom: f64,
258    pub extras: Extras,
259}
260
261impl DistCapacitor {
262    #[must_use]
263    pub fn new(
264        name: impl Into<String>,
265        bus: impl Into<String>,
266        terminal_map: Vec<String>,
267        configuration: Configuration,
268        q_rated: f64,
269        v_nom: f64,
270    ) -> Self {
271        Self {
272            name: name.into(),
273            bus: bus.into(),
274            terminal_map,
275            configuration,
276            q_rated,
277            v_nom,
278            extras: Extras::new(),
279        }
280    }
281}
282
283#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
284#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
285#[non_exhaustive]
286pub struct DistSwitch {
287    pub name: String,
288    pub bus_from: String,
289    pub bus_to: String,
290    pub terminal_map_from: Vec<String>,
291    pub terminal_map_to: Vec<String>,
292    pub open: bool,
293    /// Ampacity per conductor. A `null` element reads as +Inf (#268).
294    #[serde(default, with = "crate::nonfinite::upper_bounds")]
295    #[cfg_attr(feature = "schema", schemars(with = "Option<Vec<Option<f64>>>"))]
296    pub i_max: Option<Vec<f64>>,
297    pub extras: Extras,
298}
299
300impl DistSwitch {
301    #[must_use]
302    pub fn new(
303        name: impl Into<String>,
304        bus_from: impl Into<String>,
305        bus_to: impl Into<String>,
306        terminal_map_from: Vec<String>,
307        terminal_map_to: Vec<String>,
308        open: bool,
309    ) -> Self {
310        Self {
311            name: name.into(),
312            bus_from: bus_from.into(),
313            bus_to: bus_to.into(),
314            terminal_map_from,
315            terminal_map_to,
316            open,
317            i_max: None,
318            extras: Extras::new(),
319        }
320    }
321}
322
323#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
324#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
325#[serde(rename_all = "snake_case")]
326#[non_exhaustive]
327pub enum Configuration {
328    Wye,
329    Delta,
330    SinglePhase,
331}
332
333#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
334#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
335#[non_exhaustive]
336pub struct DistLoad {
337    pub name: String,
338    pub bus: String,
339    pub terminal_map: Vec<String>,
340    pub configuration: Configuration,
341    /// Watts per phase.
342    pub p_nom: Vec<f64>,
343    /// Vars per phase.
344    pub q_nom: Vec<f64>,
345    pub voltage_model: DistLoadVoltageModel,
346    pub extras: Extras,
347}
348
349impl DistLoad {
350    #[must_use]
351    pub fn new(
352        name: impl Into<String>,
353        bus: impl Into<String>,
354        terminal_map: Vec<String>,
355        configuration: Configuration,
356        p_nom: Vec<f64>,
357        q_nom: Vec<f64>,
358    ) -> Self {
359        Self {
360            name: name.into(),
361            bus: bus.into(),
362            terminal_map,
363            configuration,
364            p_nom,
365            q_nom,
366            voltage_model: DistLoadVoltageModel::default(),
367            extras: Extras::new(),
368        }
369    }
370}
371
372#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
373#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
374#[serde(tag = "model", rename_all = "snake_case")]
375#[non_exhaustive]
376pub enum DistLoadVoltageModel {
377    /// Constant power load. `v_nom` is volts per active phase when the source
378    /// states it.
379    ConstantPower { v_nom: Vec<f64> },
380    /// Constant current load. `v_nom` is volts per active phase.
381    ConstantCurrent { v_nom: Vec<f64> },
382    /// Constant impedance load. `v_nom` is volts per active phase.
383    ConstantImpedance { v_nom: Vec<f64> },
384    /// ZIP load coefficients by active phase. `v_nom` is volts per active
385    /// phase; alpha terms apply to active power and beta terms to reactive
386    /// power.
387    Zip {
388        v_nom: Vec<f64>,
389        alpha_z: Vec<f64>,
390        alpha_i: Vec<f64>,
391        alpha_p: Vec<f64>,
392        beta_z: Vec<f64>,
393        beta_i: Vec<f64>,
394        beta_p: Vec<f64>,
395    },
396    /// Exponential voltage model by active phase. `v_nom` is volts per active
397    /// phase.
398    Exponential {
399        v_nom: Vec<f64>,
400        gamma_p: Vec<f64>,
401        gamma_q: Vec<f64>,
402    },
403}
404
405impl Default for DistLoadVoltageModel {
406    fn default() -> Self {
407        Self::ConstantPower { v_nom: Vec::new() }
408    }
409}
410
411impl DistLoadVoltageModel {
412    #[must_use]
413    pub fn v_nom(&self) -> &[f64] {
414        match self {
415            Self::ConstantPower { v_nom }
416            | Self::ConstantCurrent { v_nom }
417            | Self::ConstantImpedance { v_nom }
418            | Self::Zip { v_nom, .. }
419            | Self::Exponential { v_nom, .. } => v_nom,
420        }
421    }
422}
423
424#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
425#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
426#[non_exhaustive]
427pub struct DistGenerator {
428    pub name: String,
429    pub bus: String,
430    pub terminal_map: Vec<String>,
431    pub configuration: Configuration,
432    /// Setpoint, watts per phase.
433    pub p_nom: Vec<f64>,
434    pub q_nom: Vec<f64>,
435    /// Bounds per phase. A `null` element reads as -Inf in a lower bound
436    /// and +Inf in an upper bound: the PMD unbounded spelling (#268).
437    #[serde(default, with = "crate::nonfinite::lower_bounds")]
438    #[cfg_attr(feature = "schema", schemars(with = "Option<Vec<Option<f64>>>"))]
439    pub p_min: Option<Vec<f64>>,
440    #[serde(default, with = "crate::nonfinite::upper_bounds")]
441    #[cfg_attr(feature = "schema", schemars(with = "Option<Vec<Option<f64>>>"))]
442    pub p_max: Option<Vec<f64>>,
443    #[serde(default, with = "crate::nonfinite::lower_bounds")]
444    #[cfg_attr(feature = "schema", schemars(with = "Option<Vec<Option<f64>>>"))]
445    pub q_min: Option<Vec<f64>>,
446    #[serde(default, with = "crate::nonfinite::upper_bounds")]
447    #[cfg_attr(feature = "schema", schemars(with = "Option<Vec<Option<f64>>>"))]
448    pub q_max: Option<Vec<f64>>,
449    /// $/kWh; no OpenDSS equivalent, so it is None until a format supplies it.
450    pub cost: Option<f64>,
451    /// Per-conductor apparent power and current limits, VA and amps (BMOPF
452    /// generator fields, alongside the p/q bounds).
453    #[serde(
454        default,
455        skip_serializing_if = "Option::is_none",
456        with = "crate::nonfinite::upper_bounds"
457    )]
458    #[cfg_attr(feature = "schema", schemars(with = "Option<Vec<Option<f64>>>"))]
459    pub s_max: Option<Vec<f64>>,
460    #[serde(
461        default,
462        skip_serializing_if = "Option::is_none",
463        with = "crate::nonfinite::upper_bounds"
464    )]
465    #[cfg_attr(feature = "schema", schemars(with = "Option<Vec<Option<f64>>>"))]
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    #[serde(with = "crate::nonfinite::upper_limits")]
541    #[cfg_attr(feature = "schema", schemars(with = "Vec<Option<f64>>"))]
542    pub s_max: Vec<f64>,
543    /// Per conductor current limits, amperes.
544    #[serde(default, with = "crate::nonfinite::upper_bounds")]
545    #[cfg_attr(feature = "schema", schemars(with = "Option<Vec<Option<f64>>>"))]
546    pub i_max: Option<Vec<f64>>,
547    /// Available active power, watts.
548    pub p_avail: Option<f64>,
549    #[serde(default, with = "crate::nonfinite::lower_bounds")]
550    #[cfg_attr(feature = "schema", schemars(with = "Option<Vec<Option<f64>>>"))]
551    pub p_min: Option<Vec<f64>>,
552    #[serde(default, with = "crate::nonfinite::upper_bounds")]
553    #[cfg_attr(feature = "schema", schemars(with = "Option<Vec<Option<f64>>>"))]
554    pub p_max: Option<Vec<f64>>,
555    #[serde(default, with = "crate::nonfinite::lower_bounds")]
556    #[cfg_attr(feature = "schema", schemars(with = "Option<Vec<Option<f64>>>"))]
557    pub q_min: Option<Vec<f64>>,
558    #[serde(default, with = "crate::nonfinite::upper_bounds")]
559    #[cfg_attr(feature = "schema", schemars(with = "Option<Vec<Option<f64>>>"))]
560    pub q_max: Option<Vec<f64>>,
561    pub control_profile: Option<String>,
562    pub voltage_aggregation: Option<IbrVoltageAggregation>,
563    pub extras: Extras,
564}
565
566impl DistIbr {
567    #[must_use]
568    pub fn new(
569        name: impl Into<String>,
570        bus: impl Into<String>,
571        terminal_map: Vec<String>,
572        topology: IbrTopology,
573        prime_mover: IbrPrimeMover,
574        s_max: Vec<f64>,
575    ) -> Self {
576        Self {
577            name: name.into(),
578            bus: bus.into(),
579            terminal_map,
580            topology,
581            prime_mover,
582            s_max,
583            i_max: None,
584            p_avail: None,
585            p_min: None,
586            p_max: None,
587            q_min: None,
588            q_max: None,
589            control_profile: None,
590            voltage_aggregation: None,
591            extras: Extras::new(),
592        }
593    }
594}
595
596#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
597#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
598#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
599#[non_exhaustive]
600pub enum ControlVoltageReference {
601    PnPerPhase,
602    PpPerPhase,
603    PpAveraged,
604    PgAveraged,
605    PnAveraged,
606    PgPerPhase,
607}
608
609#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
610#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
611#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
612#[non_exhaustive]
613pub enum ReactivePowerUnit {
614    VaFraction,
615    Var,
616}
617
618#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
619#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
620#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
621#[non_exhaustive]
622pub enum ActivePowerUnit {
623    VaFraction,
624    W,
625}
626
627#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
628#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
629#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
630#[non_exhaustive]
631pub enum ReactivePowerReference {
632    VarMax,
633    VarAvailable,
634}
635
636#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
637#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
638#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
639#[non_exhaustive]
640pub enum ActivePowerReference {
641    PAvailable,
642    PMax,
643    SMax,
644}
645
646#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
647#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
648#[non_exhaustive]
649pub struct PowerFactorControl {
650    pub pf: f64,
651}
652
653#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
654#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
655#[non_exhaustive]
656pub struct VoltVarControl {
657    pub voltage_reference: Option<ControlVoltageReference>,
658    pub breakpoints: Vec<f64>,
659    pub q_limits: Vec<f64>,
660    pub q_unit: Option<ReactivePowerUnit>,
661    pub q_ref: Option<ReactivePowerReference>,
662    pub p_min_for_q: Option<f64>,
663    pub p_min_for_q_max: Option<f64>,
664}
665
666#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
667#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
668#[non_exhaustive]
669pub struct VoltWattControl {
670    pub voltage_reference: Option<ControlVoltageReference>,
671    pub breakpoints: Vec<f64>,
672    pub p_limits: Vec<f64>,
673    pub p_unit: Option<ActivePowerUnit>,
674    pub p_ref: Option<ActivePowerReference>,
675}
676
677#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
678#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
679#[non_exhaustive]
680pub struct DistControlProfile {
681    pub name: String,
682    pub power_factor: Option<PowerFactorControl>,
683    pub volt_var: Option<VoltVarControl>,
684    pub volt_watt: Option<VoltWattControl>,
685    pub extras: Extras,
686}
687
688impl DistControlProfile {
689    #[must_use]
690    pub fn new(name: impl Into<String>) -> Self {
691        Self {
692            name: name.into(),
693            power_factor: None,
694            volt_var: None,
695            volt_watt: None,
696            extras: Extras::new(),
697        }
698    }
699}
700
701#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
702#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
703#[non_exhaustive]
704pub struct DistShunt {
705    pub name: String,
706    pub bus: String,
707    pub terminal_map: Vec<String>,
708    /// Total siemens in conductor order.
709    pub g: Mat,
710    pub b: Mat,
711    pub extras: Extras,
712}
713
714impl DistShunt {
715    #[must_use]
716    pub fn new(
717        name: impl Into<String>,
718        bus: impl Into<String>,
719        terminal_map: Vec<String>,
720        g: Mat,
721        b: Mat,
722    ) -> Self {
723        Self {
724            name: name.into(),
725            bus: bus.into(),
726            terminal_map,
727            g,
728            b,
729            extras: Extras::new(),
730        }
731    }
732}
733
734#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
735#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
736#[serde(rename_all = "snake_case")]
737#[non_exhaustive]
738pub enum WindingConn {
739    Wye,
740    Delta,
741}
742
743#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
744#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
745#[non_exhaustive]
746pub struct Winding {
747    pub bus: String,
748    pub terminal_map: Vec<String>,
749    pub conn: WindingConn,
750    /// Rated winding voltage, volts (line to line for 2 and 3 phase).
751    #[serde(with = "crate::nonfinite::nan_scalar")]
752    #[cfg_attr(
753        feature = "schema",
754        schemars(schema_with = "crate::nonfinite::nullable_number")
755    )]
756    pub v_ref: f64,
757    /// Volt amperes.
758    #[serde(with = "crate::nonfinite::nan_scalar")]
759    #[cfg_attr(
760        feature = "schema",
761        schemars(schema_with = "crate::nonfinite::nullable_number")
762    )]
763    pub s_rating: f64,
764    /// Winding resistance, percent of the winding base.
765    #[serde(with = "crate::nonfinite::nan_scalar")]
766    #[cfg_attr(
767        feature = "schema",
768        schemars(schema_with = "crate::nonfinite::nullable_number")
769    )]
770    pub r_pct: f64,
771    pub tap: f64,
772    #[serde(default, skip_serializing_if = "Option::is_none")]
773    pub r_neutral: Option<f64>,
774    #[serde(default, skip_serializing_if = "Option::is_none")]
775    pub x_neutral: Option<f64>,
776}
777
778impl Winding {
779    #[must_use]
780    pub fn new(
781        bus: impl Into<String>,
782        terminal_map: Vec<String>,
783        conn: WindingConn,
784        v_ref: f64,
785        s_rating: f64,
786    ) -> Self {
787        Self {
788            bus: bus.into(),
789            terminal_map,
790            conn,
791            v_ref,
792            s_rating,
793            r_pct: 0.0,
794            tap: 1.0,
795            r_neutral: None,
796            x_neutral: None,
797        }
798    }
799}
800
801#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
802#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
803#[non_exhaustive]
804pub struct DistTransformer {
805    pub name: String,
806    pub windings: Vec<Winding>,
807    /// Short circuit reactances between winding pairs, percent:
808    /// `[xhl]` for two windings, `[xhl, xht, xlt]` for three.
809    pub xsc_pct: Vec<f64>,
810    pub phases: usize,
811    pub extras: Extras,
812}
813
814impl DistTransformer {
815    #[must_use]
816    pub fn new(
817        name: impl Into<String>,
818        windings: Vec<Winding>,
819        xsc_pct: Vec<f64>,
820        phases: usize,
821    ) -> Self {
822        Self {
823            name: name.into(),
824            windings,
825            xsc_pct,
826            phases,
827            extras: Extras::new(),
828        }
829    }
830}
831
832#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
833#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
834#[non_exhaustive]
835pub struct VoltageSource {
836    pub name: String,
837    pub bus: String,
838    pub terminal_map: Vec<String>,
839    /// Volts per terminal (0.0 on grounded terminals).
840    pub v_magnitude: Vec<f64>,
841    /// Radians per terminal.
842    pub v_angle: Vec<f64>,
843    pub extras: Extras,
844}
845
846impl VoltageSource {
847    #[must_use]
848    pub fn new(
849        name: impl Into<String>,
850        bus: impl Into<String>,
851        terminal_map: Vec<String>,
852        v_magnitude: Vec<f64>,
853        v_angle: Vec<f64>,
854    ) -> Self {
855        Self {
856            name: name.into(),
857            bus: bus.into(),
858            terminal_map,
859            v_magnitude,
860            v_angle,
861            extras: Extras::new(),
862        }
863    }
864}
865
866/// An object the reader recognized but does not type: preserved by class,
867/// name, and raw property text so conversions can warn precisely.
868#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
869#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
870#[non_exhaustive]
871pub struct UntypedObject {
872    pub class: String,
873    pub name: String,
874    pub props: Vec<(Option<String>, String)>,
875}
876
877impl UntypedObject {
878    #[must_use]
879    pub fn new(
880        class: impl Into<String>,
881        name: impl Into<String>,
882        props: Vec<(Option<String>, String)>,
883    ) -> Self {
884        Self {
885            class: class.into(),
886            name: name.into(),
887            props,
888        }
889    }
890}
891
892/// A multiconductor distribution network.
893///
894/// `source` retains the original text for the byte exact echo tier;
895/// `defaulted` records, per element (`"class.name"` key), the fields the
896/// reader materialized from format defaults rather than the source text.
897#[derive(Clone, Debug, Serialize, Deserialize)]
898#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
899#[non_exhaustive]
900pub struct DistNetwork {
901    pub name: Option<String>,
902    /// Hz.
903    pub base_frequency: f64,
904    #[serde(default, skip_serializing_if = "Option::is_none")]
905    pub geo: Option<GeoMeta>,
906    pub buses: Vec<DistBus>,
907    pub linecodes: Vec<DistLineCode>,
908    pub lines: Vec<DistLine>,
909    pub switches: Vec<DistSwitch>,
910    pub transformers: Vec<DistTransformer>,
911    pub loads: Vec<DistLoad>,
912    pub generators: Vec<DistGenerator>,
913    #[serde(default, skip_serializing_if = "Vec::is_empty")]
914    pub ibrs: Vec<DistIbr>,
915    #[serde(default, skip_serializing_if = "Vec::is_empty")]
916    pub control_profiles: Vec<DistControlProfile>,
917    pub shunts: Vec<DistShunt>,
918    #[serde(default, skip_serializing_if = "Vec::is_empty")]
919    pub capacitors: Vec<DistCapacitor>,
920    /// BMOPF allows exactly one; the model allows any number and the BMOPF
921    /// writer warns beyond the first.
922    pub sources: Vec<VoltageSource>,
923    pub untyped: Vec<UntypedObject>,
924    /// Source commands and options the typed model does not interpret
925    /// (`solve`, `set mode=...`), in order, as (verb, args).
926    pub commands: Vec<(String, String)>,
927    pub options: Vec<(String, String)>,
928    /// Per-element record of which fields were materialized from a format
929    /// default. Skipped in the `.pio.json` payload: the field holds
930    /// `&'static str` (no `Deserialize`), and this provenance belongs in the
931    /// compiler package's `source_maps` as `mapping_kind = defaulted`, not in
932    /// the raw IR payload. See
933    /// <https://eigenergy.github.io/powerio/guide/pio-json-schema.html>.
934    #[serde(skip)]
935    pub defaulted: BTreeMap<String, Vec<&'static str>>,
936    pub warnings: Vec<String>,
937    /// Structured findings from the parse session. An `Error` entry means
938    /// the network is incomplete (for example a refused include). Skipped
939    /// in the `.pio.json` payload: the wire spelling is a v0.9 register
940    /// decision, and package diagnostics live in the envelope.
941    #[serde(skip)]
942    pub parse_diagnostics: Vec<crate::diagnostics::StructuredDiagnostic>,
943    /// Retained source text for the byte-exact echo tier. Skipped in the
944    /// `.pio.json` payload (mirrors `powerio::Network::source`): keeping it out
945    /// avoids serde's `rc` feature, and retained source is an envelope concern
946    /// surfaced through `Origin::File { retained_source, .. }`.
947    #[serde(skip)]
948    pub source: Option<Arc<String>>,
949    pub source_format: Option<DistSourceFormat>,
950    pub extras: Extras,
951}
952
953/// v1-facing name for the canonical multiconductor distribution model.
954pub type MulticonductorNetwork = DistNetwork;
955
956impl Default for DistNetwork {
957    /// An empty network at the OpenDSS default frequency. A derived 0 Hz
958    /// default would put NaN into every capacitance the dss writer converts
959    /// through omega.
960    fn default() -> Self {
961        DistNetwork {
962            name: None,
963            base_frequency: crate::dss::defaults::BASE_FREQUENCY,
964            geo: None,
965            buses: Vec::new(),
966            linecodes: Vec::new(),
967            lines: Vec::new(),
968            switches: Vec::new(),
969            transformers: Vec::new(),
970            loads: Vec::new(),
971            generators: Vec::new(),
972            ibrs: Vec::new(),
973            control_profiles: Vec::new(),
974            shunts: Vec::new(),
975            capacitors: Vec::new(),
976            sources: Vec::new(),
977            untyped: Vec::new(),
978            commands: Vec::new(),
979            options: Vec::new(),
980            defaulted: BTreeMap::new(),
981            warnings: Vec::new(),
982            parse_diagnostics: Vec::new(),
983            source: None,
984            source_format: None,
985            extras: Extras::new(),
986        }
987    }
988}
989
990impl DistNetwork {
991    #[must_use]
992    pub fn new() -> Self {
993        Self::default()
994    }
995
996    #[must_use]
997    pub fn named(name: impl Into<String>) -> Self {
998        Self {
999            name: Some(name.into()),
1000            ..Self::default()
1001        }
1002    }
1003
1004    /// Case insensitive, matching the source formats' name semantics.
1005    pub fn bus(&self, id: &str) -> Option<&DistBus> {
1006        self.buses.iter().find(|b| b.id.eq_ignore_ascii_case(id))
1007    }
1008
1009    /// Case insensitive, matching the source formats' name semantics.
1010    pub fn linecode(&self, name: &str) -> Option<&DistLineCode> {
1011        self.linecodes
1012            .iter()
1013            .find(|c| c.name.eq_ignore_ascii_case(name))
1014    }
1015}
1016
1017/// Susceptance converts to a dss `cmatrix` at the network frequency, so a
1018/// 50 Hz feeder read at the 60 Hz default loses a fifth of its line charging,
1019/// and a stated 60 Hz is indistinguishable from a defaulted one downstream.
1020/// Only a network carrying susceptance can lose anything, so a document that
1021/// states no frequency and no charging stays quiet.
1022pub(crate) fn warn_defaulted_frequency(net: &mut DistNetwork, field: &str) {
1023    let charging = net.linecodes.iter().any(|c| {
1024        [&c.b_from, &c.b_to]
1025            .iter()
1026            .flat_map(|m| m.iter())
1027            .flatten()
1028            .any(|v| v.is_finite() && v.abs() > 0.0)
1029    });
1030    if charging {
1031        net.warnings.push(format!(
1032            "document states no {field} and carries line susceptance; read at {} Hz",
1033            net.base_frequency
1034        ));
1035    }
1036}
1037
1038/// Push a warning for every dangling or empty cross-reference. Bus and
1039/// linecode references are bare strings, a reader leaves an empty string
1040/// where the field is missing, and the graph projection synthesizes a
1041/// phantom bus for any unresolved id (the empty string included) — so a
1042/// typo or an absent field would otherwise parse cleanly into a
1043/// topologically wrong network. Comparison is ASCII case insensitive,
1044/// matching [`DistNetwork::bus`] and [`DistNetwork::linecode`].
1045pub(crate) fn warn_unresolved_references(net: &mut DistNetwork) {
1046    use std::collections::BTreeSet;
1047    let buses: BTreeSet<String> = net
1048        .buses
1049        .iter()
1050        .map(|b| b.id.to_ascii_lowercase())
1051        .collect();
1052    let linecodes: BTreeSet<String> = net
1053        .linecodes
1054        .iter()
1055        .map(|c| c.name.to_ascii_lowercase())
1056        .collect();
1057    let mut warnings = Vec::new();
1058    {
1059        let mut bus = |what: &str, field: &str, id: &str| {
1060            if id.is_empty() {
1061                warnings.push(format!("{what}: `{field}` reference is empty or missing"));
1062            } else if !buses.contains(&id.to_ascii_lowercase()) {
1063                warnings.push(format!("{what}: references undefined bus `{id}`"));
1064            }
1065        };
1066        for l in &net.lines {
1067            let what = format!("line {}", l.name);
1068            bus(&what, "bus_from", &l.bus_from);
1069            bus(&what, "bus_to", &l.bus_to);
1070        }
1071        for sw in &net.switches {
1072            let what = format!("switch {}", sw.name);
1073            bus(&what, "bus_from", &sw.bus_from);
1074            bus(&what, "bus_to", &sw.bus_to);
1075        }
1076        for t in &net.transformers {
1077            let what = format!("transformer {}", t.name);
1078            for w in &t.windings {
1079                bus(&what, "bus", &w.bus);
1080            }
1081        }
1082        for (what, id) in std::iter::empty()
1083            .chain(
1084                net.loads
1085                    .iter()
1086                    .map(|x| (format!("load {}", x.name), &x.bus)),
1087            )
1088            .chain(
1089                net.generators
1090                    .iter()
1091                    .map(|x| (format!("generator {}", x.name), &x.bus)),
1092            )
1093            .chain(
1094                net.shunts
1095                    .iter()
1096                    .map(|x| (format!("shunt {}", x.name), &x.bus)),
1097            )
1098            .chain(
1099                net.capacitors
1100                    .iter()
1101                    .map(|x| (format!("capacitor {}", x.name), &x.bus)),
1102            )
1103            .chain(net.ibrs.iter().map(|x| (format!("ibr {}", x.name), &x.bus)))
1104            .chain(
1105                net.sources
1106                    .iter()
1107                    .map(|x| (format!("voltage_source {}", x.name), &x.bus)),
1108            )
1109        {
1110            bus(&what, "bus", id);
1111        }
1112    }
1113    for l in &net.lines {
1114        if l.linecode.is_empty() {
1115            warnings.push(format!(
1116                "line {}: `linecode` reference is empty or missing",
1117                l.name
1118            ));
1119        } else if !linecodes.contains(&l.linecode.to_ascii_lowercase()) {
1120            warnings.push(format!(
1121                "line {}: references undefined linecode `{}`",
1122                l.name, l.linecode
1123            ));
1124        }
1125    }
1126    net.warnings.extend(warnings);
1127}
1128
1129fn zero_mat(n: usize) -> Mat {
1130    vec![vec![0.0; n]; n]
1131}
1132
1133fn matrix_extent(m: &Mat) -> usize {
1134    m.iter().map(Vec::len).fold(m.len(), usize::max)
1135}
1136
1137/// Windings per phase for an n-winding transformer terminal map: WYE counts
1138/// the hot terminals (excluding the shared neutral), DELTA counts terminals
1139/// directly except the phase to phase two terminal case.
1140pub(crate) fn n_winding_phase_count(conn: WindingConn, terminal_map: &[String]) -> usize {
1141    match conn {
1142        WindingConn::Wye => terminal_map.len().saturating_sub(1).max(1),
1143        WindingConn::Delta => {
1144            if terminal_map.len() == 2 {
1145                1
1146            } else {
1147                terminal_map.len().max(1)
1148            }
1149        }
1150    }
1151}
1152
1153/// `phases * v_nom^2 / s`, the impedance base for an n-winding transformer
1154/// winding, or `None` if any input isn't a positive finite number.
1155pub(crate) fn n_winding_impedance_base(phases: usize, v_nom: f64, s: f64) -> Option<f64> {
1156    let phases = phases as f64;
1157    (phases > 0.0 && v_nom.is_finite() && v_nom > 0.0 && s.is_finite() && s > 0.0)
1158        .then_some(phases * v_nom * v_nom / s)
1159}
1160
1161/// Upper triangular `(i, j)` winding index pairs for `n` windings, the order
1162/// short circuit test pairs (`x_sc`/`xsc_pct`) are keyed by.
1163pub(crate) fn pair_keys(n: usize) -> Vec<(usize, usize)> {
1164    let mut pairs = Vec::new();
1165    for i in 0..n {
1166        for j in i + 1..n {
1167            pairs.push((i, j));
1168        }
1169    }
1170    pairs
1171}
1172
1173/// Builds an `n`x`n` matrix from lower triangle rows (the OpenDSS matrix
1174/// entry convention) or full rows; symmetric completion for the triangle.
1175pub(crate) fn square_from_rows(rows: &[Vec<f64>], n: usize) -> Option<Mat> {
1176    let mut m = vec![vec![0.0; n]; n];
1177    if rows.len() != n {
1178        return None;
1179    }
1180    let lower = rows.iter().enumerate().all(|(i, r)| r.len() == i + 1);
1181    let full = rows.iter().all(|r| r.len() == n);
1182    if lower {
1183        for (i, row) in rows.iter().enumerate() {
1184            for (j, &v) in row.iter().enumerate() {
1185                m[i][j] = v;
1186                m[j][i] = v;
1187            }
1188        }
1189    } else if full {
1190        for (i, row) in rows.iter().enumerate() {
1191            m[i].clone_from_slice(&row[..n]);
1192        }
1193    } else {
1194        return None;
1195    }
1196    Some(m)
1197}
1198
1199#[cfg(test)]
1200mod tests {
1201    use super::*;
1202
1203    #[test]
1204    #[allow(clippy::float_cmp)]
1205    fn lower_triangle_completes_symmetrically() {
1206        let rows = vec![vec![1.0], vec![0.5, 2.0], vec![0.3, 0.4, 3.0]];
1207        let m = square_from_rows(&rows, 3).unwrap();
1208        assert_eq!(m[0][1], 0.5);
1209        assert_eq!(m[1][0], 0.5);
1210        assert_eq!(m[2][2], 3.0);
1211        assert_eq!(m[0][2], 0.3);
1212    }
1213
1214    #[test]
1215    #[allow(clippy::float_cmp)]
1216    fn full_rows_pass_through() {
1217        let rows = vec![vec![1.0, 9.0], vec![8.0, 2.0]];
1218        let m = square_from_rows(&rows, 2).unwrap();
1219        assert_eq!(m[0][1], 9.0);
1220        assert_eq!(m[1][0], 8.0);
1221    }
1222
1223    #[test]
1224    fn wrong_shape_is_rejected() {
1225        assert!(square_from_rows(&[vec![1.0], vec![2.0]], 2).is_none());
1226        assert!(square_from_rows(&[vec![1.0, 2.0]], 2).is_none());
1227    }
1228}