Skip to main content

powerio_dist/pmd/
read.rs

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