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
21pub type Extras = BTreeMap<String, serde_json::Value>;
22
23/// A square matrix in conductor order, row major.
24pub type Mat = Vec<Vec<f64>>;
25
26/// Where the network came from; fixes the echo tier target.
27#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "kebab-case")]
29#[non_exhaustive]
30pub enum DistSourceFormat {
31    Dss,
32    BmopfJson,
33    PmdJson,
34}
35
36impl DistSourceFormat {
37    /// The canonical format name (`dss`, `pmd-json`, `bmopf-json`), accepted
38    /// back by [`crate::dist_target_from_name`].
39    pub fn name(self) -> &'static str {
40        match self {
41            DistSourceFormat::Dss => "dss",
42            DistSourceFormat::PmdJson => "pmd-json",
43            DistSourceFormat::BmopfJson => "bmopf-json",
44        }
45    }
46}
47
48#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
49#[non_exhaustive]
50pub struct DistBus {
51    pub id: String,
52    /// Ordered terminal names; OpenDSS node numbers as strings.
53    pub terminals: Vec<String>,
54    /// Terminals tied to ground with zero impedance.
55    pub grounded: Vec<String>,
56    /// Voltage magnitude bounds, volts: the scalar pair plus the phase to
57    /// neutral, phase to phase, and symmetrical component families (the
58    /// four BMOPF bound families).
59    pub v_min: Option<f64>,
60    pub v_max: Option<f64>,
61    pub vpn_min: Option<Vec<f64>>,
62    pub vpn_max: Option<Vec<f64>>,
63    pub vpp_min: Option<Vec<f64>>,
64    pub vpp_max: Option<Vec<f64>>,
65    pub vsym_min: Option<Vec<f64>>,
66    pub vsym_max: Option<Vec<f64>>,
67    pub extras: Extras,
68}
69
70impl DistBus {
71    #[must_use]
72    pub fn new(id: impl Into<String>, terminals: Vec<String>) -> Self {
73        Self {
74            id: id.into(),
75            terminals,
76            grounded: Vec::new(),
77            v_min: None,
78            v_max: None,
79            vpn_min: None,
80            vpn_max: None,
81            vpp_min: None,
82            vpp_max: None,
83            vsym_min: None,
84            vsym_max: None,
85            extras: Extras::new(),
86        }
87    }
88}
89
90#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
91#[non_exhaustive]
92pub struct DistLineCode {
93    pub name: String,
94    pub n_conductors: usize,
95    /// Series impedance, ohm per meter.
96    pub r_series: Mat,
97    pub x_series: Mat,
98    /// Shunt admittance halves at each end, S per meter.
99    pub g_from: Mat,
100    pub b_from: Mat,
101    pub g_to: Mat,
102    pub b_to: Mat,
103    /// Ampacity per conductor.
104    pub i_max: Option<Vec<f64>>,
105    pub s_max: Option<Vec<f64>>,
106    pub extras: Extras,
107}
108
109impl DistLineCode {
110    #[must_use]
111    pub fn new(name: impl Into<String>, r_series: Mat, x_series: Mat) -> Self {
112        let n_conductors = matrix_extent(&r_series).max(matrix_extent(&x_series));
113        Self {
114            name: name.into(),
115            n_conductors,
116            r_series,
117            x_series,
118            g_from: zero_mat(n_conductors),
119            b_from: zero_mat(n_conductors),
120            g_to: zero_mat(n_conductors),
121            b_to: zero_mat(n_conductors),
122            i_max: None,
123            s_max: None,
124            extras: Extras::new(),
125        }
126    }
127}
128
129#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
130#[non_exhaustive]
131pub struct DistLine {
132    pub name: String,
133    pub bus_from: String,
134    pub bus_to: String,
135    pub terminal_map_from: Vec<String>,
136    pub terminal_map_to: Vec<String>,
137    pub linecode: String,
138    /// Meters.
139    pub length: f64,
140    pub extras: Extras,
141}
142
143impl DistLine {
144    #[must_use]
145    pub fn new(
146        name: impl Into<String>,
147        bus_from: impl Into<String>,
148        bus_to: impl Into<String>,
149        terminal_map_from: Vec<String>,
150        terminal_map_to: Vec<String>,
151        linecode: impl Into<String>,
152        length: f64,
153    ) -> Self {
154        Self {
155            name: name.into(),
156            bus_from: bus_from.into(),
157            bus_to: bus_to.into(),
158            terminal_map_from,
159            terminal_map_to,
160            linecode: linecode.into(),
161            length,
162            extras: Extras::new(),
163        }
164    }
165}
166
167#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
168#[non_exhaustive]
169pub struct DistSwitch {
170    pub name: String,
171    pub bus_from: String,
172    pub bus_to: String,
173    pub terminal_map_from: Vec<String>,
174    pub terminal_map_to: Vec<String>,
175    pub open: bool,
176    pub i_max: Option<Vec<f64>>,
177    pub extras: Extras,
178}
179
180impl DistSwitch {
181    #[must_use]
182    pub fn new(
183        name: impl Into<String>,
184        bus_from: impl Into<String>,
185        bus_to: impl Into<String>,
186        terminal_map_from: Vec<String>,
187        terminal_map_to: Vec<String>,
188        open: bool,
189    ) -> Self {
190        Self {
191            name: name.into(),
192            bus_from: bus_from.into(),
193            bus_to: bus_to.into(),
194            terminal_map_from,
195            terminal_map_to,
196            open,
197            i_max: None,
198            extras: Extras::new(),
199        }
200    }
201}
202
203#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
204#[serde(rename_all = "snake_case")]
205#[non_exhaustive]
206pub enum Configuration {
207    Wye,
208    Delta,
209    SinglePhase,
210}
211
212#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
213#[non_exhaustive]
214pub struct DistLoad {
215    pub name: String,
216    pub bus: String,
217    pub terminal_map: Vec<String>,
218    pub configuration: Configuration,
219    /// Watts per phase.
220    pub p_nom: Vec<f64>,
221    /// Vars per phase.
222    pub q_nom: Vec<f64>,
223    pub voltage_model: DistLoadVoltageModel,
224    pub extras: Extras,
225}
226
227impl DistLoad {
228    #[must_use]
229    pub fn new(
230        name: impl Into<String>,
231        bus: impl Into<String>,
232        terminal_map: Vec<String>,
233        configuration: Configuration,
234        p_nom: Vec<f64>,
235        q_nom: Vec<f64>,
236    ) -> Self {
237        Self {
238            name: name.into(),
239            bus: bus.into(),
240            terminal_map,
241            configuration,
242            p_nom,
243            q_nom,
244            voltage_model: DistLoadVoltageModel::default(),
245            extras: Extras::new(),
246        }
247    }
248}
249
250#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
251#[serde(tag = "model", rename_all = "snake_case")]
252#[non_exhaustive]
253pub enum DistLoadVoltageModel {
254    /// Constant power load. `v_nom` is volts per active phase when the source
255    /// states it.
256    ConstantPower { v_nom: Vec<f64> },
257    /// Constant current load. `v_nom` is volts per active phase.
258    ConstantCurrent { v_nom: Vec<f64> },
259    /// Constant impedance load. `v_nom` is volts per active phase.
260    ConstantImpedance { v_nom: Vec<f64> },
261    /// ZIP load coefficients by active phase. `v_nom` is volts per active
262    /// phase; alpha terms apply to active power and beta terms to reactive
263    /// power.
264    Zip {
265        v_nom: Vec<f64>,
266        alpha_z: Vec<f64>,
267        alpha_i: Vec<f64>,
268        alpha_p: Vec<f64>,
269        beta_z: Vec<f64>,
270        beta_i: Vec<f64>,
271        beta_p: Vec<f64>,
272    },
273    /// Exponential voltage model by active phase. `v_nom` is volts per active
274    /// phase.
275    Exponential {
276        v_nom: Vec<f64>,
277        gamma_p: Vec<f64>,
278        gamma_q: Vec<f64>,
279    },
280}
281
282impl Default for DistLoadVoltageModel {
283    fn default() -> Self {
284        Self::ConstantPower { v_nom: Vec::new() }
285    }
286}
287
288impl DistLoadVoltageModel {
289    #[must_use]
290    pub fn v_nom(&self) -> &[f64] {
291        match self {
292            Self::ConstantPower { v_nom }
293            | Self::ConstantCurrent { v_nom }
294            | Self::ConstantImpedance { v_nom }
295            | Self::Zip { v_nom, .. }
296            | Self::Exponential { v_nom, .. } => v_nom,
297        }
298    }
299}
300
301#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
302#[non_exhaustive]
303pub struct DistGenerator {
304    pub name: String,
305    pub bus: String,
306    pub terminal_map: Vec<String>,
307    pub configuration: Configuration,
308    /// Setpoint, watts per phase.
309    pub p_nom: Vec<f64>,
310    pub q_nom: Vec<f64>,
311    pub p_min: Option<Vec<f64>>,
312    pub p_max: Option<Vec<f64>>,
313    pub q_min: Option<Vec<f64>>,
314    pub q_max: Option<Vec<f64>>,
315    /// $/kWh; no OpenDSS equivalent, so it is None until a format supplies it.
316    pub cost: Option<f64>,
317    pub extras: Extras,
318}
319
320impl DistGenerator {
321    #[must_use]
322    pub fn new(
323        name: impl Into<String>,
324        bus: impl Into<String>,
325        terminal_map: Vec<String>,
326        configuration: Configuration,
327        p_nom: Vec<f64>,
328        q_nom: Vec<f64>,
329    ) -> Self {
330        Self {
331            name: name.into(),
332            bus: bus.into(),
333            terminal_map,
334            configuration,
335            p_nom,
336            q_nom,
337            p_min: None,
338            p_max: None,
339            q_min: None,
340            q_max: None,
341            cost: None,
342            extras: Extras::new(),
343        }
344    }
345}
346
347#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
348#[non_exhaustive]
349pub struct DistShunt {
350    pub name: String,
351    pub bus: String,
352    pub terminal_map: Vec<String>,
353    /// Total siemens in conductor order.
354    pub g: Mat,
355    pub b: Mat,
356    pub extras: Extras,
357}
358
359impl DistShunt {
360    #[must_use]
361    pub fn new(
362        name: impl Into<String>,
363        bus: impl Into<String>,
364        terminal_map: Vec<String>,
365        g: Mat,
366        b: Mat,
367    ) -> Self {
368        Self {
369            name: name.into(),
370            bus: bus.into(),
371            terminal_map,
372            g,
373            b,
374            extras: Extras::new(),
375        }
376    }
377}
378
379#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
380#[serde(rename_all = "snake_case")]
381#[non_exhaustive]
382pub enum WindingConn {
383    Wye,
384    Delta,
385}
386
387#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
388#[non_exhaustive]
389pub struct Winding {
390    pub bus: String,
391    pub terminal_map: Vec<String>,
392    pub conn: WindingConn,
393    /// Rated winding voltage, volts (line to line for 2 and 3 phase).
394    pub v_ref: f64,
395    /// Volt amperes.
396    pub s_rating: f64,
397    /// Winding resistance, percent of the winding base.
398    pub r_pct: f64,
399    pub tap: f64,
400}
401
402impl Winding {
403    #[must_use]
404    pub fn new(
405        bus: impl Into<String>,
406        terminal_map: Vec<String>,
407        conn: WindingConn,
408        v_ref: f64,
409        s_rating: f64,
410    ) -> Self {
411        Self {
412            bus: bus.into(),
413            terminal_map,
414            conn,
415            v_ref,
416            s_rating,
417            r_pct: 0.0,
418            tap: 1.0,
419        }
420    }
421}
422
423#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
424#[non_exhaustive]
425pub struct DistTransformer {
426    pub name: String,
427    pub windings: Vec<Winding>,
428    /// Short circuit reactances between winding pairs, percent:
429    /// `[xhl]` for two windings, `[xhl, xht, xlt]` for three.
430    pub xsc_pct: Vec<f64>,
431    pub phases: usize,
432    pub extras: Extras,
433}
434
435impl DistTransformer {
436    #[must_use]
437    pub fn new(
438        name: impl Into<String>,
439        windings: Vec<Winding>,
440        xsc_pct: Vec<f64>,
441        phases: usize,
442    ) -> Self {
443        Self {
444            name: name.into(),
445            windings,
446            xsc_pct,
447            phases,
448            extras: Extras::new(),
449        }
450    }
451}
452
453#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
454#[non_exhaustive]
455pub struct VoltageSource {
456    pub name: String,
457    pub bus: String,
458    pub terminal_map: Vec<String>,
459    /// Volts per terminal (0.0 on grounded terminals).
460    pub v_magnitude: Vec<f64>,
461    /// Radians per terminal.
462    pub v_angle: Vec<f64>,
463    pub extras: Extras,
464}
465
466impl VoltageSource {
467    #[must_use]
468    pub fn new(
469        name: impl Into<String>,
470        bus: impl Into<String>,
471        terminal_map: Vec<String>,
472        v_magnitude: Vec<f64>,
473        v_angle: Vec<f64>,
474    ) -> Self {
475        Self {
476            name: name.into(),
477            bus: bus.into(),
478            terminal_map,
479            v_magnitude,
480            v_angle,
481            extras: Extras::new(),
482        }
483    }
484}
485
486/// An object the reader recognized but does not type: preserved by class,
487/// name, and raw property text so conversions can warn precisely.
488#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
489#[non_exhaustive]
490pub struct UntypedObject {
491    pub class: String,
492    pub name: String,
493    pub props: Vec<(Option<String>, String)>,
494}
495
496impl UntypedObject {
497    #[must_use]
498    pub fn new(
499        class: impl Into<String>,
500        name: impl Into<String>,
501        props: Vec<(Option<String>, String)>,
502    ) -> Self {
503        Self {
504            class: class.into(),
505            name: name.into(),
506            props,
507        }
508    }
509}
510
511/// A multiconductor distribution network.
512///
513/// `source` retains the original text for the byte exact echo tier;
514/// `defaulted` records, per element (`"class.name"` key), the fields the
515/// reader materialized from format defaults rather than the source text.
516#[derive(Clone, Debug, Serialize, Deserialize)]
517#[non_exhaustive]
518pub struct DistNetwork {
519    pub name: Option<String>,
520    /// Hz.
521    pub base_frequency: f64,
522    pub buses: Vec<DistBus>,
523    pub linecodes: Vec<DistLineCode>,
524    pub lines: Vec<DistLine>,
525    pub switches: Vec<DistSwitch>,
526    pub transformers: Vec<DistTransformer>,
527    pub loads: Vec<DistLoad>,
528    pub generators: Vec<DistGenerator>,
529    pub shunts: Vec<DistShunt>,
530    /// BMOPF allows exactly one; the model allows any number and the BMOPF
531    /// writer warns beyond the first.
532    pub sources: Vec<VoltageSource>,
533    pub untyped: Vec<UntypedObject>,
534    /// Source commands and options the typed model does not interpret
535    /// (`solve`, `set mode=...`), in order, as (verb, args).
536    pub commands: Vec<(String, String)>,
537    pub options: Vec<(String, String)>,
538    /// Per-element record of which fields were materialized from a format
539    /// default. Skipped in the `.pio.json` payload: the field holds
540    /// `&'static str` (no `Deserialize`), and this provenance belongs in the
541    /// compiler package's `source_maps` as `mapping_kind = defaulted`, not in
542    /// the raw IR payload. See
543    /// <https://eigenergy.github.io/powerio/guide/pio-json-schema.html>.
544    #[serde(skip)]
545    pub defaulted: BTreeMap<String, Vec<&'static str>>,
546    pub warnings: Vec<String>,
547    /// Retained source text for the byte-exact echo tier. Skipped in the
548    /// `.pio.json` payload (mirrors `powerio::Network::source`): keeping it out
549    /// avoids serde's `rc` feature, and retained source is an envelope concern
550    /// surfaced through `Origin::File { retained_source, .. }`.
551    #[serde(skip)]
552    pub source: Option<Arc<String>>,
553    pub source_format: Option<DistSourceFormat>,
554    pub extras: Extras,
555}
556
557/// v1-facing name for the canonical multiconductor distribution model.
558pub type MulticonductorNetwork = DistNetwork;
559
560impl Default for DistNetwork {
561    /// An empty network at the OpenDSS default frequency. A derived 0 Hz
562    /// default would put NaN into every capacitance the dss writer converts
563    /// through omega.
564    fn default() -> Self {
565        DistNetwork {
566            name: None,
567            base_frequency: crate::dss::defaults::BASE_FREQUENCY,
568            buses: Vec::new(),
569            linecodes: Vec::new(),
570            lines: Vec::new(),
571            switches: Vec::new(),
572            transformers: Vec::new(),
573            loads: Vec::new(),
574            generators: Vec::new(),
575            shunts: Vec::new(),
576            sources: Vec::new(),
577            untyped: Vec::new(),
578            commands: Vec::new(),
579            options: Vec::new(),
580            defaulted: BTreeMap::new(),
581            warnings: Vec::new(),
582            source: None,
583            source_format: None,
584            extras: Extras::new(),
585        }
586    }
587}
588
589impl DistNetwork {
590    #[must_use]
591    pub fn new() -> Self {
592        Self::default()
593    }
594
595    #[must_use]
596    pub fn named(name: impl Into<String>) -> Self {
597        Self {
598            name: Some(name.into()),
599            ..Self::default()
600        }
601    }
602
603    /// Case insensitive, matching the source formats' name semantics.
604    pub fn bus(&self, id: &str) -> Option<&DistBus> {
605        self.buses.iter().find(|b| b.id.eq_ignore_ascii_case(id))
606    }
607
608    /// Case insensitive, matching the source formats' name semantics.
609    pub fn linecode(&self, name: &str) -> Option<&DistLineCode> {
610        self.linecodes
611            .iter()
612            .find(|c| c.name.eq_ignore_ascii_case(name))
613    }
614}
615
616fn zero_mat(n: usize) -> Mat {
617    vec![vec![0.0; n]; n]
618}
619
620fn matrix_extent(m: &Mat) -> usize {
621    m.iter().map(Vec::len).fold(m.len(), usize::max)
622}
623
624/// Windings per phase for an n-winding transformer terminal map: WYE counts
625/// the hot terminals (excluding the shared neutral), DELTA counts terminals
626/// directly except the phase to phase two terminal case.
627pub(crate) fn n_winding_phase_count(conn: WindingConn, terminal_map: &[String]) -> usize {
628    match conn {
629        WindingConn::Wye => terminal_map.len().saturating_sub(1).max(1),
630        WindingConn::Delta => {
631            if terminal_map.len() == 2 {
632                1
633            } else {
634                terminal_map.len().max(1)
635            }
636        }
637    }
638}
639
640/// `phases * v_nom^2 / s`, the impedance base for an n-winding transformer
641/// winding, or `None` if any input isn't a positive finite number.
642pub(crate) fn n_winding_impedance_base(phases: usize, v_nom: f64, s: f64) -> Option<f64> {
643    let phases = phases as f64;
644    (phases > 0.0 && v_nom.is_finite() && v_nom > 0.0 && s.is_finite() && s > 0.0)
645        .then_some(phases * v_nom * v_nom / s)
646}
647
648/// Upper triangular `(i, j)` winding index pairs for `n` windings, the order
649/// short circuit test pairs (`x_sc`/`xsc_pct`) are keyed by.
650pub(crate) fn pair_keys(n: usize) -> Vec<(usize, usize)> {
651    let mut pairs = Vec::new();
652    for i in 0..n {
653        for j in i + 1..n {
654            pairs.push((i, j));
655        }
656    }
657    pairs
658}
659
660/// Builds an `n`x`n` matrix from lower triangle rows (the OpenDSS matrix
661/// entry convention) or full rows; symmetric completion for the triangle.
662pub(crate) fn square_from_rows(rows: &[Vec<f64>], n: usize) -> Option<Mat> {
663    let mut m = vec![vec![0.0; n]; n];
664    if rows.len() != n {
665        return None;
666    }
667    let lower = rows.iter().enumerate().all(|(i, r)| r.len() == i + 1);
668    let full = rows.iter().all(|r| r.len() == n);
669    if lower {
670        for (i, row) in rows.iter().enumerate() {
671            for (j, &v) in row.iter().enumerate() {
672                m[i][j] = v;
673                m[j][i] = v;
674            }
675        }
676    } else if full {
677        for (i, row) in rows.iter().enumerate() {
678            m[i].clone_from_slice(&row[..n]);
679        }
680    } else {
681        return None;
682    }
683    Some(m)
684}
685
686#[cfg(test)]
687mod tests {
688    use super::*;
689
690    #[test]
691    #[allow(clippy::float_cmp)]
692    fn lower_triangle_completes_symmetrically() {
693        let rows = vec![vec![1.0], vec![0.5, 2.0], vec![0.3, 0.4, 3.0]];
694        let m = square_from_rows(&rows, 3).unwrap();
695        assert_eq!(m[0][1], 0.5);
696        assert_eq!(m[1][0], 0.5);
697        assert_eq!(m[2][2], 3.0);
698        assert_eq!(m[0][2], 0.3);
699    }
700
701    #[test]
702    #[allow(clippy::float_cmp)]
703    fn full_rows_pass_through() {
704        let rows = vec![vec![1.0, 9.0], vec![8.0, 2.0]];
705        let m = square_from_rows(&rows, 2).unwrap();
706        assert_eq!(m[0][1], 9.0);
707        assert_eq!(m[1][0], 8.0);
708    }
709
710    #[test]
711    fn wrong_shape_is_rejected() {
712        assert!(square_from_rows(&[vec![1.0], vec![2.0]], 2).is_none());
713        assert!(square_from_rows(&[vec![1.0, 2.0]], 2).is_none());
714    }
715}