Skip to main content

powerio_dist/pmd/
read.rs

1//! PMD ENGINEERING JSON into the canonical [`MulticonductorNetwork`].
2//!
3//! The reader applies PMD's own import corrections: `null` becomes +Inf
4//! under a `_ub`/`max` suffix, -Inf under `_lb`/`min`, NaN elsewhere, and
5//! arrays of arrays rebuild as matrices with the inner arrays as columns.
6//! Integer terminals become the model's string names; per unit transformer
7//! impedances become the model's percent fields; kV, kW, and degrees scale
8//! to volts, watts, and radians. Fields the model does not type ride in
9//! `extras` so the PMD writer can reproduce them.
10
11use std::path::Path;
12use std::sync::Arc;
13
14use serde_json::{Map, Value};
15
16use crate::error::{Error, Result};
17use crate::model::{
18    Configuration, DistBus, DistGenerator, DistLine, DistLineCode, DistLoad, DistLoadVoltageModel,
19    DistShunt, DistSourceFormat, DistSwitch, DistTransformer, Extras, Mat, MulticonductorNetwork,
20    UntypedObject, VoltageSource, Winding, WindingConn,
21};
22
23pub fn parse_pmd_file(path: impl AsRef<Path>) -> Result<MulticonductorNetwork> {
24    let path = path.as_ref();
25    let text = std::fs::read_to_string(path).map_err(|source| Error::Io {
26        path: path.display().to_string(),
27        source,
28    })?;
29    parse_pmd_str(&text)
30}
31
32pub fn parse_pmd_str(text: &str) -> Result<MulticonductorNetwork> {
33    let doc: Value = serde_json::from_str(text).map_err(|e| Error::Json {
34        format: "PMD",
35        message: e.to_string(),
36    })?;
37    let Value::Object(doc) = doc else {
38        return Err(Error::Json {
39            format: "PMD",
40            message: "top level is not an object".into(),
41        });
42    };
43    // This reader types the ENGINEERING model only. The MATHEMATICAL model
44    // shares the `data_model` marker but is index based and structurally
45    // unrelated; interpreting its sections as ENGINEERING would silently
46    // build a wrong network.
47    if let Some(dm) = doc.get("data_model").and_then(Value::as_str) {
48        if !dm.eq_ignore_ascii_case("ENGINEERING") {
49            return Err(Error::Json {
50                format: "PMD",
51                message: format!(
52                    "`data_model` is `{dm}`; this reader supports the ENGINEERING model \
53                     (convert MATHEMATICAL output back with \
54                     PowerModelsDistribution.transform_solution or export the ENGINEERING \
55                     model)"
56                ),
57            });
58        }
59    }
60    let mut net = MulticonductorNetwork {
61        source: Some(Arc::new(text.to_string())),
62        source_format: Some(DistSourceFormat::PmdJson),
63        base_frequency: 60.0,
64        ..MulticonductorNetwork::default()
65    };
66    let mut rd = Reader { net: &mut net };
67    rd.document(&doc);
68    crate::model::warn_unresolved_references(&mut net);
69    Ok(net)
70}
71
72struct Reader<'a> {
73    net: &'a mut MulticonductorNetwork,
74}
75
76/// PMD's null restoration: the field suffix picks the value.
77fn restore(key: &str, v: &Value) -> f64 {
78    if v.is_null() {
79        if key.ends_with("_ub") || key.ends_with("max") {
80            f64::INFINITY
81        } else if key.ends_with("_lb") || key.ends_with("min") {
82            f64::NEG_INFINITY
83        } else {
84            f64::NAN
85        }
86    } else {
87        v.as_f64().unwrap_or(f64::NAN)
88    }
89}
90
91fn floats(key: &str, v: Option<&Value>) -> Option<Vec<f64>> {
92    v?.as_array()
93        .map(|a| a.iter().map(|x| restore(key, x)).collect())
94}
95
96/// Largest conductor count a PMD impedance/admittance matrix may declare. `n`
97/// is the outer array length and sizes a dense n×n allocation, so an unbounded
98/// value from a small array of empty arrays could demand gigabytes. No physical
99/// conductor bundle comes near this bound; it matches the DSS reader's count
100/// cap. An oversized matrix is dropped with a warning.
101const MAX_MATRIX_DIM: usize = 64;
102
103/// Arrays of arrays rebuild with the inner arrays as columns (`hcat`). A
104/// column that is not an array warns and stays zero instead of dropping
105/// the whole matrix silently; a field that is not an array of columns at
106/// all warns and drops, because there is no shape to keep. An absent field
107/// is not a defect, so it stays quiet.
108fn matrix(key: &str, v: Option<&Value>, what: &str, warnings: &mut Vec<String>) -> Option<Mat> {
109    let v = v?;
110    let Some(cols) = v.as_array() else {
111        warnings.push(format!(
112            "{what}: `{key}` is not an array of columns; dropped"
113        ));
114        return None;
115    };
116    let n = cols.len();
117    if n > MAX_MATRIX_DIM {
118        warnings.push(format!(
119            "{key}: matrix dimension {n} exceeds the supported maximum of \
120             {MAX_MATRIX_DIM}; dropped"
121        ));
122        return None;
123    }
124    let mut m = vec![vec![0.0; n]; n];
125    for (j, col) in cols.iter().enumerate() {
126        let Some(col) = col.as_array() else {
127            warnings.push(format!(
128                "{what}: `{key}` column {j} is not an array; kept as zeros"
129            ));
130            continue;
131        };
132        for (i, x) in col.iter().enumerate().take(n) {
133            m[i][j] = restore(key, x);
134        }
135    }
136    Some(m)
137}
138
139fn ints_as_strings(v: Option<&Value>) -> Vec<String> {
140    v.and_then(Value::as_array)
141        .map(|a| {
142            a.iter()
143                .map(|x| {
144                    x.as_i64().map_or_else(
145                        || x.as_str().unwrap_or_default().to_string(),
146                        |i| i.to_string(),
147                    )
148                })
149                .collect()
150        })
151        .unwrap_or_default()
152}
153
154fn string(v: Option<&Value>) -> String {
155    v.and_then(Value::as_str).unwrap_or_default().to_string()
156}
157
158/// Grows `m` to `n` by `n`, preserving the existing entries.
159fn pad_to(m: Mat, n: usize) -> Mat {
160    if m.len() >= n {
161        return m;
162    }
163    let mut out = vec![vec![0.0; n]; n];
164    for (i, row) in m.into_iter().enumerate() {
165        for (j, v) in row.into_iter().enumerate() {
166            out[i][j] = v;
167        }
168    }
169    out
170}
171
172/// Keeps fields outside `known` in extras verbatim (no warning: the
173/// ENGINEERING model legitimately carries fields the hub does not type,
174/// and the PMD writer reproduces the typed ones).
175fn take_extras(o: &Map<String, Value>, known: &[&str]) -> Extras {
176    o.iter()
177        // The inner `name` duplicates the element's key.
178        .filter(|(k, _)| !known.contains(&k.as_str()) && k.as_str() != "name")
179        .map(|(k, v)| (k.clone(), v.clone()))
180        .collect()
181}
182
183/// The model has no status field; a non ENABLED status rides in extras so
184/// the PMD writer reproduces it instead of silently re-enabling.
185fn stash_status(
186    o: &Map<String, Value>,
187    extras: &mut Extras,
188    what: &str,
189    warnings: &mut Vec<String>,
190) {
191    if let Some(s) = o.get("status").and_then(Value::as_str)
192        && s != "ENABLED"
193    {
194        extras.insert("pmd_status".into(), Value::String(s.to_string()));
195        warnings.push(format!(
196            "{what}: status {s} kept in extras; other formats emit the element enabled"
197        ));
198    }
199}
200
201/// A linecode from an object carrying the linecode matrix fields: a
202/// `linecode` entry, or a line with inline impedance (the dss2eng output
203/// for rmatrix defined lines). Extras hold only the raw `b_fr`/`b_to`
204/// stash; the caller merges anything else.
205fn linecode_from(
206    name: &str,
207    o: &Map<String, Value>,
208    base_frequency: f64,
209    warnings: &mut Vec<String>,
210) -> DistLineCode {
211    let what = format!("linecode {name}");
212    let mut mat = |key: &str| matrix(key, o.get(key), &what, warnings);
213    let mats = [
214        mat("rs"),
215        mat("xs"),
216        mat("g_fr"),
217        mat("g_to"),
218        mat("b_fr"),
219        mat("b_to"),
220    ];
221    // Conductor count is the widest matrix present; absent matrices read
222    // as zero, smaller ones pad without losing entries.
223    let n = mats.iter().flatten().map(Vec::len).max().unwrap_or(0);
224    if mats.iter().flatten().any(|m| m.len() < n) {
225        warnings.push(format!(
226            "linecode {name}: matrix sizes disagree; smaller ones padded \
227             with zeros to {n}x{n}"
228        ));
229    }
230    let [r, x, gf, gt, bf, bt] = mats.map(|m| pad_to(m.unwrap_or_default(), n));
231    // b_fr/b_to numbers are cmatrix halves in nF per meter; the model
232    // holds siemens per meter.
233    let omega = std::f64::consts::TAU * base_frequency * 1e-9;
234    let to_b = |m: Mat| -> Mat {
235        m.into_iter()
236            .map(|row| row.into_iter().map(|v| v * omega).collect())
237            .collect()
238    };
239    DistLineCode {
240        name: name.to_string(),
241        n_conductors: n,
242        x_series: x,
243        g_from: gf,
244        g_to: gt,
245        b_from: to_b(bf),
246        b_to: to_b(bt),
247        r_series: r,
248        // A null entry restores as Inf (an unbounded conductor); keeping the
249        // mixed array preserves the finite bounds beside it. `None` means
250        // the field was absent.
251        i_max: floats("cm_ub", o.get("cm_ub")),
252        s_max: floats("sm_ub", o.get("sm_ub")),
253        source: None,
254        extras: {
255            // The raw arrays make writing back bit exact across the
256            // capacitance to susceptance basis change.
257            let mut extras = Extras::new();
258            if let Some(b) = o.get("b_fr") {
259                extras.insert("pmd_b_fr".into(), b.clone());
260            }
261            if let Some(b) = o.get("b_to") {
262                extras.insert("pmd_b_to".into(), b.clone());
263            }
264            extras
265        },
266    }
267}
268
269/// `Winding.tap` is a scalar; the first phase tap represents each winding
270/// (the raw per phase arrays ride in extras). The flag reports whether any
271/// winding's phases disagree, which exact comparison detects: a copied
272/// default differs by zero bits.
273#[allow(clippy::float_cmp)]
274fn representative_taps(tm_set: Option<&Value>) -> (Vec<f64>, bool) {
275    let mut firsts = Vec::new();
276    let mut differ = false;
277    for w in tm_set
278        .and_then(Value::as_array)
279        .map(Vec::as_slice)
280        .unwrap_or_default()
281    {
282        let taps: Vec<f64> = w
283            .as_array()
284            .map(|p| p.iter().map(|v| restore("tm_set", v)).collect())
285            .unwrap_or_default();
286        let first = taps.first().copied().unwrap_or(1.0);
287        differ |= taps.iter().any(|&t| t != first);
288        firsts.push(first);
289    }
290    (firsts, differ)
291}
292
293struct WindingNums<'a> {
294    rw: &'a [f64],
295    xsc: &'a [f64],
296    sm_nom: &'a [f64],
297    vm_nom: &'a [f64],
298    tm_set: &'a [f64],
299}
300
301/// Windings from the parallel per winding arrays; undoes the lag
302/// connection's barrel roll (`polarity` -1 on a wye winding under a delta
303/// primary) so the model holds the source case's order. The flag reports
304/// whether any winding was unrolled.
305fn build_windings(
306    buses: &[String],
307    configs: &[WindingConn],
308    polarity: &[i64],
309    o: &Map<String, Value>,
310    nums: &WindingNums,
311) -> (Vec<Winding>, usize, bool) {
312    let _ = nums.xsc;
313    let mut windings = Vec::with_capacity(buses.len());
314    let mut phases = 1;
315    let mut unrolled = false;
316    for (w, bus) in buses.iter().enumerate() {
317        let mut map = ints_as_strings(
318            o.get("connections")
319                .and_then(Value::as_array)
320                .and_then(|a| a.get(w)),
321        );
322        let conn = configs.get(w).copied().unwrap_or(WindingConn::Wye);
323        if polarity.get(w) == Some(&-1)
324            && conn == WindingConn::Wye
325            && configs.first() == Some(&WindingConn::Delta)
326            && map.len() > 1
327        {
328            let phases_part = map.len() - 1;
329            map[..phases_part].rotate_right(1);
330            unrolled = true;
331        }
332        if conn == WindingConn::Wye {
333            phases = phases.max(map.len().saturating_sub(1));
334        } else {
335            phases = phases.max(map.len());
336        }
337        windings.push(Winding {
338            bus: bus.clone(),
339            terminal_map: map,
340            conn,
341            v_ref: nums.vm_nom.get(w).copied().unwrap_or(f64::NAN) * 1e3,
342            s_rating: nums.sm_nom.get(w).copied().unwrap_or(f64::NAN) * 1e3,
343            r_pct: nums.rw.get(w).copied().unwrap_or(0.0) * 100.0,
344            tap: nums.tm_set.get(w).copied().unwrap_or(1.0),
345            r_neutral: None,
346            x_neutral: None,
347        });
348    }
349    (windings, phases, unrolled)
350}
351
352/// The known sections process in a fixed order, not the document's
353/// (serde_json maps iterate sorted, which puts "line" before "linecode"):
354/// `lines` consults the already materialized linecodes for the inline
355/// impedance `{name}_z` collision check, so "linecode" must come first.
356/// The other sections do not consult each other; unknown sections follow
357/// in document order.
358const SECTIONS: &[&str] = &[
359    "bus",
360    "linecode",
361    "line",
362    "switch",
363    "load",
364    "generator",
365    "shunt",
366    "voltage_source",
367    "transformer",
368];
369
370impl Reader<'_> {
371    fn document(&mut self, doc: &Map<String, Value>) {
372        if let Some(name) = doc.get("name").and_then(Value::as_str) {
373            self.net.name = Some(name.to_string());
374        }
375        let settings = doc.get("settings").and_then(Value::as_object);
376        let stated = settings
377            .and_then(|s| s.get("base_frequency"))
378            .and_then(Value::as_f64);
379        if let Some(f) = stated {
380            self.net.base_frequency = f;
381        }
382        if let Some(settings) = settings {
383            self.net
384                .extras
385                .insert("pmd_settings".into(), Value::Object(settings.clone()));
386        }
387        for key in ["data_model", "files", "conductor_ids", "per_unit"] {
388            if let Some(v) = doc.get(key) {
389                self.net.extras.insert(format!("pmd_{key}"), v.clone());
390            }
391        }
392
393        for &key in SECTIONS {
394            let Some(Value::Object(items)) = doc.get(key) else {
395                continue;
396            };
397            match key {
398                "bus" => self.buses(items),
399                "linecode" => self.linecodes(items),
400                "line" => self.lines(items),
401                "switch" => self.switches(items),
402                "load" => self.loads(items),
403                "generator" => self.generators(items),
404                "shunt" => self.shunts(items),
405                "voltage_source" => self.sources(items),
406                "transformer" => self.transformers(items),
407                _ => unreachable!(),
408            }
409        }
410        for (key, value) in doc {
411            if SECTIONS.contains(&key.as_str()) || key == "settings" || key == "name" {
412                continue;
413            }
414            let Value::Object(items) = value else {
415                continue;
416            };
417            self.net.warnings.push(format!(
418                "ENGINEERING `{key}` components are not typed; kept untyped"
419            ));
420            for (name, v) in items {
421                self.net.untyped.push(UntypedObject {
422                    class: key.clone(),
423                    name: name.clone(),
424                    props: vec![(None, v.to_string())],
425                });
426            }
427        }
428        if stated.is_none() {
429            crate::model::warn_defaulted_frequency(self.net, "settings.base_frequency");
430        }
431    }
432
433    fn buses(&mut self, items: &Map<String, Value>) {
434        for (id, v) in items {
435            let Value::Object(o) = v else { continue };
436            let mut extras = take_extras(
437                o,
438                &["terminals", "grounded", "rg", "xg", "status", "lat", "lon"],
439            );
440            if let Some(x) = o.get("lon") {
441                extras.insert("x".into(), x.clone());
442            }
443            if let Some(y) = o.get("lat") {
444                extras.insert("y".into(), y.clone());
445            }
446            let rg = floats("rg", o.get("rg")).unwrap_or_default();
447            let xg = floats("xg", o.get("xg")).unwrap_or_default();
448            if rg.iter().any(|&r| r != 0.0) || xg.iter().any(|&x| x != 0.0) {
449                self.net.warnings.push(format!(
450                    "bus {id}: nonzero grounding impedance is not typed; kept in extras"
451                ));
452                extras.insert("rg".into(), o.get("rg").cloned().unwrap_or(Value::Null));
453                extras.insert("xg".into(), o.get("xg").cloned().unwrap_or(Value::Null));
454            }
455            stash_status(o, &mut extras, &format!("bus {id}"), &mut self.net.warnings);
456            self.net.buses.push(DistBus {
457                id: id.clone(),
458                terminals: ints_as_strings(o.get("terminals")),
459                grounded: ints_as_strings(o.get("grounded")),
460                extras,
461                ..DistBus::default()
462            });
463        }
464    }
465
466    fn linecodes(&mut self, items: &Map<String, Value>) {
467        for (name, v) in items {
468            let Value::Object(o) = v else { continue };
469            let mut lc = linecode_from(name, o, self.net.base_frequency, &mut self.net.warnings);
470            let mut extras = take_extras(
471                o,
472                &["rs", "xs", "g_fr", "g_to", "b_fr", "b_to", "cm_ub", "sm_ub"],
473            );
474            extras.append(&mut lc.extras);
475            lc.extras = extras;
476            self.net.linecodes.push(lc);
477        }
478    }
479
480    fn lines(&mut self, items: &Map<String, Value>) {
481        for (name, v) in items {
482            let Value::Object(o) = v else { continue };
483            let mut known = vec![
484                "f_bus",
485                "t_bus",
486                "f_connections",
487                "t_connections",
488                "linecode",
489                "length",
490                "status",
491                "source_id",
492            ];
493            let mut linecode = string(o.get("linecode"));
494            let mut extras;
495            let mut i_max = None;
496            let mut s_max = None;
497            // Inline impedance (the dss2eng output for rmatrix defined
498            // lines): materialize a linecode so the matrices survive, and
499            // mark the line so the PMD writer re-inlines them. The inline
500            // ratings live on the synthesized linecode, not the line.
501            if linecode.is_empty() && o.get("rs").is_some() {
502                known.extend(["rs", "xs", "g_fr", "g_to", "b_fr", "b_to", "cm_ub", "sm_ub"]);
503                extras = take_extras(o, &known);
504                let mut lc_name = format!("{name}_z");
505                let mut k = 2;
506                while self.net.linecode(&lc_name).is_some() {
507                    lc_name = format!("{name}_z{k}");
508                    k += 1;
509                }
510                let lc =
511                    linecode_from(&lc_name, o, self.net.base_frequency, &mut self.net.warnings);
512                self.net.linecodes.push(lc);
513                self.net.warnings.push(format!(
514                    "line {name}: inline impedance materialized as linecode {lc_name}; the PMD writer re-inlines it"
515                ));
516                extras.insert("pmd_inline".into(), Value::Bool(true));
517                linecode = lc_name;
518            } else {
519                known.extend(["cm_ub", "sm_ub"]);
520                extras = take_extras(o, &known);
521                i_max = floats("cm_ub", o.get("cm_ub"));
522                s_max = floats("sm_ub", o.get("sm_ub"));
523            }
524            stash_status(
525                o,
526                &mut extras,
527                &format!("line {name}"),
528                &mut self.net.warnings,
529            );
530            self.net.lines.push(DistLine {
531                name: name.clone(),
532                bus_from: string(o.get("f_bus")),
533                bus_to: string(o.get("t_bus")),
534                terminal_map_from: ints_as_strings(o.get("f_connections")),
535                terminal_map_to: ints_as_strings(o.get("t_connections")),
536                linecode,
537                length: o.get("length").map_or(f64::NAN, |v| restore("length", v)),
538                route: None,
539                i_max,
540                s_max,
541                extras,
542            });
543        }
544    }
545
546    fn switches(&mut self, items: &Map<String, Value>) {
547        for (name, v) in items {
548            let Value::Object(o) = v else { continue };
549            let mut extras = take_extras(
550                o,
551                &[
552                    "f_bus",
553                    "t_bus",
554                    "f_connections",
555                    "t_connections",
556                    "state",
557                    "cm_ub",
558                    "status",
559                    "source_id",
560                    "dispatchable",
561                    "rs",
562                    "xs",
563                    "g_fr",
564                    "g_to",
565                    "b_fr",
566                    "b_to",
567                ],
568            );
569            // The series matrices ride along raw so a dss regeneration can
570            // override the engine's switch dummy impedance with the real
571            // one, and the PMD writer can reproduce them.
572            for key in ["rs", "xs"] {
573                if let Some(m) = o.get(key) {
574                    extras.insert(format!("pmd_{key}"), m.clone());
575                }
576            }
577            stash_status(
578                o,
579                &mut extras,
580                &format!("switch {name}"),
581                &mut self.net.warnings,
582            );
583            self.net.switches.push(DistSwitch {
584                name: name.clone(),
585                bus_from: string(o.get("f_bus")),
586                bus_to: string(o.get("t_bus")),
587                terminal_map_from: ints_as_strings(o.get("f_connections")),
588                terminal_map_to: ints_as_strings(o.get("t_connections")),
589                open: o.get("state").and_then(Value::as_str) == Some("OPEN"),
590                i_max: floats("cm_ub", o.get("cm_ub")),
591                extras,
592            });
593        }
594    }
595
596    fn loads(&mut self, items: &Map<String, Value>) {
597        for (name, v) in items {
598            let Value::Object(o) = v else { continue };
599            let connections = ints_as_strings(o.get("connections"));
600            let configuration = match o.get("configuration").and_then(Value::as_str) {
601                Some("DELTA") if connections.len() > 2 => Configuration::Delta,
602                _ if connections.len() <= 2 => Configuration::SinglePhase,
603                Some("DELTA") => Configuration::Delta,
604                _ => Configuration::Wye,
605            };
606            let scale = |key: &str| {
607                floats(key, o.get(key))
608                    .unwrap_or_default()
609                    .iter()
610                    .map(|v| v * 1e3)
611                    .collect::<Vec<_>>()
612            };
613            let mut extras = take_extras(
614                o,
615                &[
616                    "bus",
617                    "connections",
618                    "configuration",
619                    "pd_nom",
620                    "qd_nom",
621                    "status",
622                    "source_id",
623                    "dispatchable",
624                    "vm_nom",
625                    "model",
626                ],
627            );
628            if let Some(kv) = o.get("vm_nom") {
629                extras.insert("kv".into(), kv.clone());
630            }
631            if let Some(model) = o.get("model").and_then(Value::as_str) {
632                let dss_model = match model {
633                    "IMPEDANCE" => 2,
634                    "CURRENT" => 5,
635                    "ZIPV" => 8,
636                    _ => 1,
637                };
638                if dss_model != 1 {
639                    extras.insert("model".into(), dss_model.into());
640                }
641            }
642            let v_nom: Vec<f64> = floats("vm_nom", o.get("vm_nom"))
643                .or_else(|| o.get("vm_nom").map(|v| vec![restore("vm_nom", v)]))
644                .unwrap_or_default()
645                .iter()
646                .map(|v| v * 1e3)
647                .collect();
648            let voltage_model = match o.get("model").and_then(Value::as_str) {
649                Some("IMPEDANCE") => DistLoadVoltageModel::ConstantImpedance { v_nom },
650                Some("CURRENT") => DistLoadVoltageModel::ConstantCurrent { v_nom },
651                Some("ZIPV") => DistLoadVoltageModel::Zip {
652                    v_nom,
653                    alpha_z: Vec::new(),
654                    alpha_i: Vec::new(),
655                    alpha_p: Vec::new(),
656                    beta_z: Vec::new(),
657                    beta_i: Vec::new(),
658                    beta_p: Vec::new(),
659                },
660                _ => DistLoadVoltageModel::ConstantPower { v_nom },
661            };
662            stash_status(
663                o,
664                &mut extras,
665                &format!("load {name}"),
666                &mut self.net.warnings,
667            );
668            self.net.loads.push(DistLoad {
669                name: name.clone(),
670                bus: string(o.get("bus")),
671                terminal_map: connections,
672                configuration,
673                p_nom: scale("pd_nom"),
674                q_nom: scale("qd_nom"),
675                voltage_model,
676                extras,
677            });
678        }
679    }
680
681    fn generators(&mut self, items: &Map<String, Value>) {
682        for (name, v) in items {
683            let Value::Object(o) = v else { continue };
684            let scale = |key: &str| {
685                floats(key, o.get(key)).map(|v| v.iter().map(|x| x * 1e3).collect::<Vec<f64>>())
686            };
687            let mut extras = take_extras(
688                o,
689                &[
690                    "bus",
691                    "connections",
692                    "configuration",
693                    "pg",
694                    "qg",
695                    "pg_lb",
696                    "pg_ub",
697                    "qg_lb",
698                    "qg_ub",
699                    "status",
700                    "source_id",
701                ],
702            );
703            stash_status(
704                o,
705                &mut extras,
706                &format!("generator {name}"),
707                &mut self.net.warnings,
708            );
709            self.net.generators.push(DistGenerator {
710                name: name.clone(),
711                bus: string(o.get("bus")),
712                terminal_map: ints_as_strings(o.get("connections")),
713                configuration: match o.get("configuration").and_then(Value::as_str) {
714                    Some("DELTA") => Configuration::Delta,
715                    _ => Configuration::Wye,
716                },
717                p_nom: scale("pg").unwrap_or_default(),
718                q_nom: scale("qg").unwrap_or_default(),
719                // A null bound restores as +/-Inf (an unbounded phase);
720                // keeping the mixed array preserves the finite bounds
721                // beside it. `None` means the field was absent.
722                p_min: scale("pg_lb"),
723                p_max: scale("pg_ub"),
724                q_min: scale("qg_lb"),
725                q_max: scale("qg_ub"),
726                cost: None,
727                s_max: None,
728                i_max: None,
729                extras,
730            });
731        }
732    }
733
734    fn shunts(&mut self, items: &Map<String, Value>) {
735        for (name, v) in items {
736            let Value::Object(o) = v else { continue };
737            let what = format!("shunt {name}");
738            let g = matrix("gs", o.get("gs"), &what, &mut self.net.warnings).unwrap_or_default();
739            let b = matrix("bs", o.get("bs"), &what, &mut self.net.warnings).unwrap_or_default();
740            let mut extras = take_extras(
741                o,
742                &["bus", "connections", "gs", "bs", "status", "source_id"],
743            );
744            stash_status(
745                o,
746                &mut extras,
747                &format!("shunt {name}"),
748                &mut self.net.warnings,
749            );
750            self.net.shunts.push(DistShunt {
751                name: name.clone(),
752                bus: string(o.get("bus")),
753                terminal_map: ints_as_strings(o.get("connections")),
754                g,
755                b,
756                extras,
757            });
758        }
759    }
760
761    fn sources(&mut self, items: &Map<String, Value>) {
762        for (name, v) in items {
763            let Value::Object(o) = v else { continue };
764            let mut extras = take_extras(
765                o,
766                &["bus", "connections", "vm", "va", "status", "source_id"],
767            );
768            stash_status(
769                o,
770                &mut extras,
771                &format!("voltage source {name}"),
772                &mut self.net.warnings,
773            );
774            self.net.sources.push(VoltageSource {
775                name: name.clone(),
776                bus: string(o.get("bus")),
777                terminal_map: ints_as_strings(o.get("connections")),
778                v_magnitude: floats("vm", o.get("vm"))
779                    .unwrap_or_default()
780                    .iter()
781                    .map(|v| v * 1e3)
782                    .collect(),
783                v_angle: floats("va", o.get("va"))
784                    .unwrap_or_default()
785                    .iter()
786                    .map(|a| a.to_radians())
787                    .collect(),
788                extras,
789            });
790        }
791    }
792
793    fn transformers(&mut self, items: &Map<String, Value>) {
794        for (name, v) in items {
795            let Value::Object(o) = v else { continue };
796            let t = self.transformer(name, o);
797            self.net.transformers.push(t);
798        }
799    }
800
801    /// The writer recomputes polarity from the lag convention; when the
802    /// file disagrees (a euro/lead or reversed winding), the raw arrays
803    /// ride in extras and the writer emits them verbatim.
804    fn stash_polarity(
805        &mut self,
806        name: &str,
807        o: &Map<String, Value>,
808        windings: &[Winding],
809        polarity: &[i64],
810        unrolled: bool,
811        extras: &mut Extras,
812    ) {
813        let file_polarity: Vec<i64> = (0..windings.len())
814            .map(|w| polarity.get(w).copied().unwrap_or(1))
815            .collect();
816        if file_polarity == super::write::lag_polarity(windings) {
817            return;
818        }
819        extras.insert(
820            "pmd_polarity".into(),
821            o.get("polarity")
822                .cloned()
823                .unwrap_or_else(|| file_polarity.clone().into()),
824        );
825        if unrolled && let Some(c) = o.get("connections") {
826            extras.insert("pmd_connections".into(), c.clone());
827        }
828        self.net.warnings.push(format!(
829            "transformer {name}: polarity {file_polarity:?} is not the lag convention; kept in extras (other formats assume lag)"
830        ));
831    }
832
833    fn transformer(&mut self, name: &str, o: &Map<String, Value>) -> DistTransformer {
834        // The winding count is the `bus` array length and drives an O(n^2)
835        // pair enumeration in the graph builder, so it is capped like the
836        // conductor matrix dimension: a huge array cannot force quadratic work.
837        let mut buses = ints_as_strings(o.get("bus"));
838        if buses.len() > MAX_MATRIX_DIM {
839            self.net.warnings.push(format!(
840                "transformer {name}: winding count {} exceeds the supported maximum of \
841                 {MAX_MATRIX_DIM}; extra windings dropped",
842                buses.len()
843            ));
844            buses.truncate(MAX_MATRIX_DIM);
845        }
846        let configs: Vec<WindingConn> = o
847            .get("configuration")
848            .and_then(Value::as_array)
849            .map(|a| {
850                a.iter()
851                    .map(|c| {
852                        if c.as_str() == Some("DELTA") {
853                            WindingConn::Delta
854                        } else {
855                            WindingConn::Wye
856                        }
857                    })
858                    .collect()
859            })
860            .unwrap_or_default();
861        let polarity: Vec<i64> = o
862            .get("polarity")
863            .and_then(Value::as_array)
864            .map(|a| a.iter().map(|p| p.as_i64().unwrap_or(1)).collect())
865            .unwrap_or_default();
866        let rw = floats("rw", o.get("rw")).unwrap_or_default();
867        let xsc = floats("xsc", o.get("xsc")).unwrap_or_default();
868        let sm_nom = floats("sm_nom", o.get("sm_nom")).unwrap_or_default();
869        let vm_nom = floats("vm_nom", o.get("vm_nom")).unwrap_or_default();
870        let (tm_set, taps_differ) = representative_taps(o.get("tm_set"));
871        if taps_differ {
872            self.net.warnings.push(format!(
873                "transformer {name}: per phase taps differ; the winding tap keeps the first phase (full arrays in extras)"
874            ));
875        }
876
877        let (windings, phases, unrolled) = build_windings(
878            &buses,
879            &configs,
880            &polarity,
881            o,
882            &WindingNums {
883                rw: &rw,
884                xsc: &xsc,
885                sm_nom: &sm_nom,
886                vm_nom: &vm_nom,
887                tm_set: &tm_set,
888            },
889        );
890
891        if o.get("controls").is_some() {
892            self.net.warnings.push(format!(
893                "transformer {name}: regulator controls are not typed; kept in extras"
894            ));
895        }
896        let mut extras = take_extras(
897            o,
898            &[
899                "bus",
900                "connections",
901                "configuration",
902                "polarity",
903                "rw",
904                "xsc",
905                "sm_nom",
906                "vm_nom",
907                "tm_set",
908                "tm_fix",
909                "tm_lb",
910                "tm_ub",
911                "tm_step",
912                "status",
913                "source_id",
914                "noloadloss",
915                "cmag",
916                "sm_ub",
917            ],
918        );
919        for key in ["tm_set", "tm_lb", "tm_ub", "tm_fix", "tm_step"] {
920            if let Some(v) = o.get(key) {
921                extras.insert(format!("pmd_{key}"), v.clone());
922            }
923        }
924        self.stash_polarity(name, o, &windings, &polarity, unrolled, &mut extras);
925        stash_status(
926            o,
927            &mut extras,
928            &format!("transformer {name}"),
929            &mut self.net.warnings,
930        );
931        DistTransformer {
932            name: name.to_string(),
933            windings,
934            xsc_pct: xsc.iter().map(|x| x * 100.0).collect(),
935            phases,
936            extras,
937        }
938    }
939}