Skip to main content

powerio_dist/bmopf/
read.rs

1//! BMOPF JSON into the canonical [`DistNetwork`].
2//!
3//! The format is fully explicit, so the reader materializes nothing and
4//! `defaulted` stays empty. Reading is liberal where writing is strict:
5//! fields outside the schema land in the element's `extras` with a warning
6//! instead of failing the parse. Transformer subtypes become windings; the
7//! subtype rides in the transformer's extras (`bmopf_subtype`) so writing
8//! back reproduces the same grouping for shapes the windings alone do not
9//! pin down (center tap reads as two windings).
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, n_winding_impedance_base,
21    n_winding_phase_count, pair_keys,
22};
23
24pub fn parse_bmopf_file(path: impl AsRef<Path>) -> Result<DistNetwork> {
25    let path = path.as_ref();
26    let text = std::fs::read_to_string(path).map_err(|source| Error::Io {
27        path: path.display().to_string(),
28        source,
29    })?;
30    parse_bmopf_str(&text)
31}
32
33pub fn parse_bmopf_str(text: &str) -> Result<DistNetwork> {
34    let doc: Value = serde_json::from_str(text).map_err(|e| Error::Json {
35        format: "BMOPF",
36        message: e.to_string(),
37    })?;
38    let Value::Object(doc) = doc else {
39        return Err(Error::Json {
40            format: "BMOPF",
41            message: "top level is not an object".into(),
42        });
43    };
44    let mut net = DistNetwork {
45        source: Some(Arc::new(text.to_string())),
46        source_format: Some(DistSourceFormat::BmopfJson),
47        base_frequency: 60.0,
48        ..DistNetwork::default()
49    };
50    let mut rd = Reader { net: &mut net };
51    rd.document(&doc);
52    Ok(net)
53}
54
55struct Reader<'a> {
56    net: &'a mut DistNetwork,
57}
58
59fn f(v: &Value) -> f64 {
60    v.as_f64().unwrap_or(f64::NAN)
61}
62
63fn floats(v: Option<&Value>) -> Option<Vec<f64>> {
64    v?.as_array().map(|a| a.iter().map(f).collect())
65}
66
67fn first_float(v: Option<&Value>) -> Option<f64> {
68    match v? {
69        Value::Array(a) => a.first().map(f),
70        v => Some(f(v)),
71    }
72}
73
74/// Like [`first_float`], but the field is per-phase-terminal in the schema
75/// while powerio's model holds one value; warn when collapsing loses a
76/// genuine per-phase difference instead of dropping it silently.
77fn first_float_collapsed(v: Option<&Value>, what: &str, warnings: &mut Vec<String>) -> Option<f64> {
78    match v? {
79        Value::Array(a) => {
80            let vals: Vec<f64> = a.iter().map(f).collect();
81            if vals.windows(2).any(|w| w[0].to_bits() != w[1].to_bits()) {
82                warnings.push(format!(
83                    "{what}: per-phase-terminal bound is non-uniform; collapsed to the first entry"
84                ));
85            }
86            vals.first().copied()
87        }
88        v => Some(f(v)),
89    }
90}
91
92fn value_alias<'a>(o: &'a Map<String, Value>, primary: &str, legacy: &str) -> Option<&'a Value> {
93    o.get(primary).or_else(|| o.get(legacy))
94}
95
96fn strings(v: Option<&Value>) -> Vec<String> {
97    v.and_then(Value::as_array)
98        .map(|a| {
99            a.iter()
100                .map(|s| s.as_str().unwrap_or_default().to_string())
101                .collect()
102        })
103        .unwrap_or_default()
104}
105
106fn string(v: Option<&Value>) -> String {
107    v.and_then(Value::as_str).unwrap_or_default().to_string()
108}
109
110/// Case insensitive on the recognized values (the dss reader's tolerance);
111/// a present but unrecognized string warns and reads as WYE.
112fn config(v: Option<&Value>, what: &str, warnings: &mut Vec<String>) -> Configuration {
113    let Some(s) = v.and_then(Value::as_str) else {
114        return Configuration::Wye;
115    };
116    match s.to_ascii_uppercase().as_str() {
117        "WYE" => Configuration::Wye,
118        "DELTA" => Configuration::Delta,
119        "SINGLE_PHASE" => Configuration::SinglePhase,
120        _ => {
121            warnings.push(format!(
122                "{what}: configuration `{s}` is not WYE, DELTA, or SINGLE_PHASE; read as WYE"
123            ));
124            Configuration::Wye
125        }
126    }
127}
128
129/// Parses the `_i_j` tail of a `prefix_i_j` matrix key (1 based). None
130/// when the key is not a well formed entry for this prefix.
131fn matrix_indices(key: &str, prefix: &str) -> Option<(usize, usize)> {
132    let rest = key.strip_prefix(prefix)?.strip_prefix('_')?;
133    let (i, j) = rest.split_once('_')?;
134    let (i, j) = (i.parse::<usize>().ok()?, j.parse::<usize>().ok()?);
135    (i >= 1 && j >= 1).then_some((i, j))
136}
137
138/// Collects `prefix_i_j` keys into a square matrix; `n` is the largest
139/// index seen. Returns None when no key carries the prefix.
140fn flat_matrix(o: &Map<String, Value>, prefix: &str) -> Option<Mat> {
141    let mut entries: Vec<(usize, usize, f64)> = Vec::new();
142    let mut n = 0;
143    for (k, v) in o {
144        let Some((i, j)) = matrix_indices(k, prefix) else {
145            continue;
146        };
147        entries.push((i - 1, j - 1, f(v)));
148        n = n.max(i).max(j);
149    }
150    if n == 0 {
151        return None;
152    }
153    let mut m = vec![vec![0.0; n]; n];
154    for (i, j, v) in entries {
155        m[i][j] = v;
156    }
157    Some(m)
158}
159
160/// Grows `m` to `n` by `n`, preserving the existing entries.
161fn pad_to(m: Mat, n: usize) -> Mat {
162    if m.len() >= n {
163        return m;
164    }
165    let mut out = vec![vec![0.0; n]; n];
166    for (i, row) in m.into_iter().enumerate() {
167        for (j, v) in row.into_iter().enumerate() {
168            out[i][j] = v;
169        }
170    }
171    out
172}
173
174/// Element fields outside `known` go to extras with a warning.
175fn take_extras(
176    o: &Map<String, Value>,
177    known: &[&str],
178    what: &str,
179    warnings: &mut Vec<String>,
180    matrix_prefixes: &[&str],
181) -> Extras {
182    let mut extras = Extras::new();
183    for (k, v) in o {
184        if known.contains(&k.as_str()) {
185            continue;
186        }
187        if matrix_prefixes
188            .iter()
189            .any(|p| matrix_indices(k, p).is_some())
190        {
191            continue;
192        }
193        warnings.push(format!(
194            "{what}: `{k}` is outside the schema; kept in extras"
195        ));
196        extras.insert(k.clone(), v.clone());
197    }
198    extras
199}
200
201impl Reader<'_> {
202    fn document(&mut self, doc: &Map<String, Value>) {
203        if let Some(name) = doc.get("name").and_then(Value::as_str) {
204            self.net.name = Some(name.to_string());
205        }
206        if let Some(frequency) =
207            first_float(doc.get("base_frequency")).or_else(|| first_float(doc.get("frequency")))
208            && frequency.is_finite()
209            && frequency > 0.0
210        {
211            self.net.base_frequency = frequency;
212        }
213        for (key, value) in doc {
214            let Value::Object(items) = value else {
215                continue;
216            };
217            match key.as_str() {
218                "bus" => self.buses(items),
219                "linecode" => self.linecodes(items),
220                "line" => self.lines(items),
221                "switch" => self.switches(items),
222                "load" => self.loads(items),
223                "generator" => self.generators(items),
224                "shunt" => self.shunts(items),
225                "voltage_source" => self.sources(items),
226                "transformer" => self.transformers(items),
227                // `meta` is provenance, not network data; the writer regenerates it.
228                "name" | "meta" => {}
229                other => {
230                    self.net.warnings.push(format!(
231                        "top level `{other}` is outside the schema; kept untyped"
232                    ));
233                    for (name, v) in items {
234                        self.net.untyped.push(UntypedObject {
235                            class: other.to_string(),
236                            name: name.clone(),
237                            props: vec![(None, v.to_string())],
238                        });
239                    }
240                }
241            }
242        }
243    }
244
245    fn buses(&mut self, items: &Map<String, Value>) {
246        for (id, v) in items {
247            let Value::Object(o) = v else { continue };
248            let known = [
249                "terminal_names",
250                "perfectly_grounded_terminals",
251                "v_min",
252                "v_max",
253                "vpn_min",
254                "vpn_max",
255                "vpp_min",
256                "vpp_max",
257                "vsym_min",
258                "vsym_max",
259            ];
260            self.net.buses.push(DistBus {
261                id: id.clone(),
262                terminals: strings(o.get("terminal_names")),
263                grounded: strings(o.get("perfectly_grounded_terminals")),
264                v_min: first_float_collapsed(
265                    o.get("v_min"),
266                    &format!("bus {id} v_min"),
267                    &mut self.net.warnings,
268                ),
269                v_max: first_float_collapsed(
270                    o.get("v_max"),
271                    &format!("bus {id} v_max"),
272                    &mut self.net.warnings,
273                ),
274                vpn_min: floats(o.get("vpn_min")),
275                vpn_max: floats(o.get("vpn_max")),
276                vpp_min: floats(o.get("vpp_min")),
277                vpp_max: floats(o.get("vpp_max")),
278                vsym_min: floats(o.get("vsym_min")),
279                vsym_max: floats(o.get("vsym_max")),
280                extras: take_extras(o, &known, &format!("bus {id}"), &mut self.net.warnings, &[]),
281            });
282        }
283    }
284
285    fn linecodes(&mut self, items: &Map<String, Value>) {
286        for (name, v) in items {
287            let Value::Object(o) = v else { continue };
288            let mats = [
289                flat_matrix(o, "R_series"),
290                flat_matrix(o, "X_series"),
291                flat_matrix(o, "G_from"),
292                flat_matrix(o, "B_from"),
293                flat_matrix(o, "G_to"),
294                flat_matrix(o, "B_to"),
295            ];
296            // Conductor count is the widest matrix present; absent matrices
297            // read as zero, smaller ones pad without losing entries.
298            let n = mats.iter().flatten().map(Vec::len).max().unwrap_or(0);
299            if mats.iter().flatten().any(|m| m.len() < n) {
300                self.net.warnings.push(format!(
301                    "linecode {name}: matrix sizes disagree; smaller ones padded \
302                     with zeros to {n}x{n}"
303                ));
304            }
305            let [r, x, gf, bf, gt, bt] = mats.map(|m| pad_to(m.unwrap_or_default(), n));
306            let code = DistLineCode {
307                name: name.clone(),
308                n_conductors: n,
309                r_series: r,
310                x_series: x,
311                g_from: gf,
312                b_from: bf,
313                g_to: gt,
314                b_to: bt,
315                i_max: floats(o.get("i_max")),
316                s_max: floats(o.get("s_max")),
317                extras: take_extras(
318                    o,
319                    &["i_max", "s_max"],
320                    &format!("linecode {name}"),
321                    &mut self.net.warnings,
322                    &["R_series", "X_series", "G_from", "G_to", "B_from", "B_to"],
323                ),
324            };
325            self.net.linecodes.push(code);
326        }
327    }
328
329    fn lines(&mut self, items: &Map<String, Value>) {
330        for (name, v) in items {
331            let Value::Object(o) = v else { continue };
332            let known = [
333                "length",
334                "linecode",
335                "bus_from",
336                "bus_to",
337                "terminal_map_from",
338                "terminal_map_to",
339            ];
340            self.net.lines.push(DistLine {
341                name: name.clone(),
342                bus_from: string(o.get("bus_from")),
343                bus_to: string(o.get("bus_to")),
344                terminal_map_from: strings(o.get("terminal_map_from")),
345                terminal_map_to: strings(o.get("terminal_map_to")),
346                linecode: string(o.get("linecode")),
347                length: o.get("length").map_or(f64::NAN, f),
348                extras: take_extras(
349                    o,
350                    &known,
351                    &format!("line {name}"),
352                    &mut self.net.warnings,
353                    &[],
354                ),
355            });
356        }
357    }
358
359    fn switches(&mut self, items: &Map<String, Value>) {
360        for (name, v) in items {
361            let Value::Object(o) = v else { continue };
362            let known = [
363                "bus_from",
364                "bus_to",
365                "terminal_map_from",
366                "terminal_map_to",
367                "open_switch",
368                "i_max",
369            ];
370            self.net.switches.push(DistSwitch {
371                name: name.clone(),
372                bus_from: string(o.get("bus_from")),
373                bus_to: string(o.get("bus_to")),
374                terminal_map_from: strings(o.get("terminal_map_from")),
375                terminal_map_to: strings(o.get("terminal_map_to")),
376                open: o
377                    .get("open_switch")
378                    .and_then(Value::as_bool)
379                    .unwrap_or(false),
380                i_max: floats(o.get("i_max")),
381                extras: take_extras(
382                    o,
383                    &known,
384                    &format!("switch {name}"),
385                    &mut self.net.warnings,
386                    &[],
387                ),
388            });
389        }
390    }
391
392    fn loads(&mut self, items: &Map<String, Value>) {
393        for (name, v) in items {
394            let Value::Object(o) = v else { continue };
395            let known = [
396                "p_nom",
397                "q_nom",
398                "bus",
399                "configuration",
400                "terminal_map",
401                "model",
402                "v_nom",
403                "alpha_z",
404                "alpha_i",
405                "alpha_p",
406                "beta_z",
407                "beta_i",
408                "beta_p",
409                "gamma_p",
410                "gamma_q",
411            ];
412            let v_nom = floats(o.get("v_nom")).unwrap_or_default();
413            let has_zip = [
414                "alpha_z", "alpha_i", "alpha_p", "beta_z", "beta_i", "beta_p",
415            ]
416            .iter()
417            .any(|key| o.get(*key).is_some());
418            let has_exp = o.get("gamma_p").is_some() || o.get("gamma_q").is_some();
419            let model = o
420                .get("model")
421                .and_then(Value::as_str)
422                .unwrap_or("POWER")
423                .to_ascii_uppercase();
424            let voltage_model = if has_exp {
425                DistLoadVoltageModel::Exponential {
426                    v_nom,
427                    gamma_p: floats(o.get("gamma_p")).unwrap_or_default(),
428                    gamma_q: floats(o.get("gamma_q")).unwrap_or_default(),
429                }
430            } else if has_zip {
431                DistLoadVoltageModel::Zip {
432                    v_nom,
433                    alpha_z: floats(o.get("alpha_z")).unwrap_or_default(),
434                    alpha_i: floats(o.get("alpha_i")).unwrap_or_default(),
435                    alpha_p: floats(o.get("alpha_p")).unwrap_or_default(),
436                    beta_z: floats(o.get("beta_z")).unwrap_or_default(),
437                    beta_i: floats(o.get("beta_i")).unwrap_or_default(),
438                    beta_p: floats(o.get("beta_p")).unwrap_or_default(),
439                }
440            } else if model.contains("IMPEDANCE") {
441                DistLoadVoltageModel::ConstantImpedance { v_nom }
442            } else if model.contains("CURRENT") {
443                DistLoadVoltageModel::ConstantCurrent { v_nom }
444            } else {
445                DistLoadVoltageModel::ConstantPower { v_nom }
446            };
447            self.net.loads.push(DistLoad {
448                name: name.clone(),
449                bus: string(o.get("bus")),
450                terminal_map: strings(o.get("terminal_map")),
451                configuration: config(
452                    o.get("configuration"),
453                    &format!("load {name}"),
454                    &mut self.net.warnings,
455                ),
456                p_nom: floats(o.get("p_nom")).unwrap_or_default(),
457                q_nom: floats(o.get("q_nom")).unwrap_or_default(),
458                voltage_model,
459                extras: take_extras(
460                    o,
461                    &known,
462                    &format!("load {name}"),
463                    &mut self.net.warnings,
464                    &[],
465                ),
466            });
467        }
468    }
469
470    fn generators(&mut self, items: &Map<String, Value>) {
471        for (name, v) in items {
472            let Value::Object(o) = v else { continue };
473            let known = [
474                "p_min",
475                "p_max",
476                "q_min",
477                "q_max",
478                "cost",
479                "bus",
480                "configuration",
481                "terminal_map",
482            ];
483            let p_min = floats(o.get("p_min"));
484            let p_max = floats(o.get("p_max"));
485            let q_min = floats(o.get("q_min"));
486            let q_max = floats(o.get("q_max"));
487            // Pinned bounds are a fixed dispatch; surface them as the
488            // setpoint too so a power flow oriented target has one.
489            let pinned = |lo: &Option<Vec<f64>>, hi: &Option<Vec<f64>>| match (lo, hi) {
490                (Some(a), Some(b)) if a == b => a.clone(),
491                _ => Vec::new(),
492            };
493            // Cost is a per-phase array in the schema; powerio's model holds one
494            // value, so take the first entry (warning if the phases disagree). A
495            // bare scalar is still accepted for documents written before v0.0.1.
496            let cost = match o.get("cost") {
497                Some(Value::Array(a)) => {
498                    let vals: Vec<f64> = a.iter().map(f).collect();
499                    // Bit comparison: detect any per-phase difference exactly
500                    // (broadcast entries are bit-identical), without a float_cmp.
501                    if vals.windows(2).any(|w| w[0].to_bits() != w[1].to_bits()) {
502                        self.net.warnings.push(format!(
503                            "generator {name}: per-phase cost is non-uniform; \
504                             collapsed to the first entry"
505                        ));
506                    }
507                    vals.first().copied()
508                }
509                Some(v) => Some(f(v)),
510                None => None,
511            };
512            self.net.generators.push(DistGenerator {
513                name: name.clone(),
514                bus: string(o.get("bus")),
515                terminal_map: strings(o.get("terminal_map")),
516                configuration: config(
517                    o.get("configuration"),
518                    &format!("generator {name}"),
519                    &mut self.net.warnings,
520                ),
521                p_nom: pinned(&p_min, &p_max),
522                q_nom: pinned(&q_min, &q_max),
523                p_min,
524                p_max,
525                q_min,
526                q_max,
527                cost,
528                extras: take_extras(
529                    o,
530                    &known,
531                    &format!("generator {name}"),
532                    &mut self.net.warnings,
533                    &[],
534                ),
535            });
536        }
537    }
538
539    fn shunts(&mut self, items: &Map<String, Value>) {
540        for (name, v) in items {
541            let Value::Object(o) = v else { continue };
542            let g = flat_matrix(o, "G").unwrap_or_default();
543            let b = flat_matrix(o, "B").unwrap_or_default();
544            let n = g.len().max(b.len());
545            if g.len() != b.len() {
546                self.net.warnings.push(format!(
547                    "shunt {name}: G is {gx}x{gx} but B is {bx}x{bx}; the smaller \
548                     padded with zeros to {n}x{n}",
549                    gx = g.len(),
550                    bx = b.len(),
551                ));
552            }
553            self.net.shunts.push(DistShunt {
554                name: name.clone(),
555                bus: string(o.get("bus")),
556                terminal_map: strings(o.get("terminal_map")),
557                g: pad_to(g, n),
558                b: pad_to(b, n),
559                extras: take_extras(
560                    o,
561                    &["bus", "terminal_map"],
562                    &format!("shunt {name}"),
563                    &mut self.net.warnings,
564                    &["G", "B"],
565                ),
566            });
567        }
568    }
569
570    fn sources(&mut self, items: &Map<String, Value>) {
571        for (name, v) in items {
572            let Value::Object(o) = v else { continue };
573            let known = ["v_magnitude", "v_angle", "bus", "terminal_map"];
574            self.net.sources.push(VoltageSource {
575                name: name.clone(),
576                bus: string(o.get("bus")),
577                terminal_map: strings(o.get("terminal_map")),
578                v_magnitude: floats(o.get("v_magnitude")).unwrap_or_default(),
579                v_angle: floats(o.get("v_angle")).unwrap_or_default(),
580                extras: take_extras(
581                    o,
582                    &known,
583                    &format!("voltage source {name}"),
584                    &mut self.net.warnings,
585                    &[],
586                ),
587            });
588        }
589    }
590
591    fn transformers(&mut self, subtypes: &Map<String, Value>) {
592        for (subtype, group) in subtypes {
593            let Value::Object(items) = group else {
594                continue;
595            };
596            for (name, v) in items {
597                let Value::Object(o) = v else { continue };
598                match subtype.as_str() {
599                    "n_winding" => {
600                        let t = self.n_winding_transformer(name, o);
601                        self.net.transformers.push(t);
602                    }
603                    "single_phase_autotransformer" | "open_delta_regulator" => {
604                        self.net.warnings.push(format!(
605                            "transformer {name}: subtype `{subtype}` is not typed yet; kept untyped"
606                        ));
607                        self.net.untyped.push(UntypedObject {
608                            class: format!("transformer.{subtype}"),
609                            name: name.clone(),
610                            props: vec![(None, v.to_string())],
611                        });
612                    }
613                    _ => {
614                        let t = self.transformer(subtype, name, o);
615                        self.net.transformers.push(t);
616                    }
617                }
618            }
619        }
620    }
621
622    #[allow(clippy::too_many_lines)] // one BMOPF transformer record maps many optional schema aliases
623    fn transformer(
624        &mut self,
625        subtype: &str,
626        name: &str,
627        o: &Map<String, Value>,
628    ) -> DistTransformer {
629        let known = [
630            "bus_from",
631            "bus_to",
632            "terminal_map_from",
633            "terminal_map_to",
634            "s_rating",
635            "v_nom_from",
636            "v_nom_to",
637            "v_ref_from",
638            "v_ref_to",
639            "g_no_load",
640            "b_no_load",
641            "r_series",
642            "x_series",
643            "r_series_from",
644            "r_series_to",
645            "x_series_from",
646            "x_series_to",
647            "tap",
648            "tap_min",
649            "tap_max",
650        ];
651        if !matches!(
652            subtype,
653            "single_phase" | "center_tap" | "wye_delta" | "delta_wye"
654        ) {
655            self.net.warnings.push(format!(
656                "transformer {name}: subtype `{subtype}` is outside the schema; \
657                 read as a single phase pair"
658            ));
659        }
660        let s = o.get("s_rating").map_or(f64::NAN, f);
661        let v_from = value_alias(o, "v_nom_from", "v_ref_from").map_or(f64::NAN, f);
662        let v_to = value_alias(o, "v_nom_to", "v_ref_to").map_or(f64::NAN, f);
663        let positive = |v: f64| v.is_finite() && v > 0.0;
664        if !positive(s) || !positive(v_from) || !positive(v_to) {
665            self.net.warnings.push(format!(
666                "transformer {name}: s_rating or v_nom missing or nonpositive; \
667                 impedances read as zero"
668            ));
669        }
670        let three_phase = matches!(subtype, "wye_delta" | "delta_wye");
671        let phases = if three_phase { 3 } else { 1 };
672
673        let pct = |x_ohm: f64, v: f64| {
674            if s > 0.0 && v > 0.0 {
675                x_ohm / (v * v / s) * 100.0
676            } else {
677                0.0
678            }
679        };
680        let (r_from_pct, r_to_pct, xsc) = if three_phase {
681            let wye_v = if subtype == "wye_delta" { v_from } else { v_to };
682            // The schema puts one series impedance on the wye side; the
683            // model splits resistance evenly across the windings.
684            let r = pct(o.get("r_series").map_or(0.0, f), wye_v);
685            let x = pct(o.get("x_series").map_or(0.0, f), wye_v);
686            (r / 2.0, r / 2.0, x)
687        } else {
688            let r_from = pct(o.get("r_series_from").map_or(0.0, f), v_from);
689            let r_to = pct(o.get("r_series_to").map_or(0.0, f), v_to);
690            let x = pct(o.get("x_series_from").map_or(0.0, f), v_from)
691                + pct(o.get("x_series_to").map_or(0.0, f), v_to);
692            (r_from, r_to, x)
693        };
694
695        let conn = |delta: bool| {
696            if delta {
697                WindingConn::Delta
698            } else {
699                WindingConn::Wye
700            }
701        };
702        let mut windings = vec![
703            Winding {
704                bus: string(o.get("bus_from")),
705                terminal_map: strings(o.get("terminal_map_from")),
706                conn: conn(subtype == "delta_wye"),
707                v_ref: v_from,
708                s_rating: s,
709                r_pct: r_from_pct,
710                tap: first_float(o.get("tap")).unwrap_or(1.0),
711            },
712            Winding {
713                bus: string(o.get("bus_to")),
714                terminal_map: strings(o.get("terminal_map_to")),
715                conn: conn(subtype == "wye_delta"),
716                v_ref: v_to,
717                s_rating: s,
718                r_pct: r_to_pct,
719                tap: 1.0,
720            },
721        ];
722        expand_center_tap_windings(subtype, &mut windings);
723        let mut extras = take_extras(
724            o,
725            &known,
726            &format!("transformer {name}"),
727            &mut self.net.warnings,
728            &[],
729        );
730        for key in ["tap_min", "tap_max"] {
731            if let Some(v) = o.get(key) {
732                extras.insert(key.into(), v.clone());
733            }
734        }
735        for key in ["g_no_load", "b_no_load"] {
736            if let Some(v) = o.get(key) {
737                extras.insert(key.into(), v.clone());
738            }
739        }
740        // Windings alone cannot tell single_phase from center_tap back
741        // apart; record the subtype for the writer.
742        extras.insert("bmopf_subtype".into(), subtype.into());
743        DistTransformer {
744            name: name.to_string(),
745            windings,
746            xsc_pct: vec![xsc],
747            phases,
748            extras,
749        }
750    }
751
752    fn n_winding_transformer(&mut self, name: &str, o: &Map<String, Value>) -> DistTransformer {
753        let known = ["windings", "x_sc", "s_rating", "g_no_load", "b_no_load"];
754        let s = o.get("s_rating").map_or(f64::NAN, f);
755        let mut windings = Vec::new();
756        if let Some(items) = o.get("windings").and_then(Value::as_array) {
757            for (idx, item) in items.iter().enumerate() {
758                let Some(w) = item.as_object() else {
759                    self.net.warnings.push(format!(
760                        "transformer {name}: winding {} is not an object; skipped",
761                        idx + 1
762                    ));
763                    continue;
764                };
765                let terminal_map = strings(w.get("terminal_map"));
766                let bmopf_v_nom = value_alias(w, "v_nom", "v_ref").map_or(f64::NAN, f);
767                let r_winding = w.get("r_winding").map_or(0.0, f);
768                let connection = w
769                    .get("configuration")
770                    .or_else(|| w.get("connection"))
771                    .and_then(Value::as_str)
772                    .unwrap_or("WYE")
773                    .to_ascii_uppercase();
774                if !matches!(connection.as_str(), "WYE" | "DELTA") {
775                    self.net.warnings.push(format!(
776                        "transformer {name}: winding {} connection `{connection}` is not WYE or DELTA; read as WYE",
777                        idx + 1
778                    ));
779                }
780                let conn = if connection == "DELTA" {
781                    WindingConn::Delta
782                } else {
783                    WindingConn::Wye
784                };
785                let r_pct = if let Some(base_z) =
786                    n_winding_base_from_bmopf(conn, &terminal_map, bmopf_v_nom, s)
787                {
788                    r_winding / base_z * 100.0
789                } else {
790                    0.0
791                };
792                windings.push(Winding {
793                    bus: string(w.get("bus")),
794                    terminal_map: terminal_map.clone(),
795                    conn,
796                    v_ref: n_winding_internal_v_ref(conn, &terminal_map, bmopf_v_nom),
797                    s_rating: s,
798                    r_pct,
799                    tap: 1.0,
800                });
801            }
802        }
803        let base_z = windings
804            .first()
805            .and_then(|w| n_winding_base_from_internal(w, s))
806            .unwrap_or(f64::NAN);
807        let mut xsc_pct = Vec::new();
808        let x_sc = o.get("x_sc").and_then(Value::as_object);
809        for (i, j) in pair_keys(windings.len()) {
810            let key = format!("{}_{}", i + 1, j + 1);
811            let x = x_sc.and_then(|m| m.get(&key)).map_or(0.0, f);
812            xsc_pct.push(if base_z.is_finite() && base_z > 0.0 {
813                x / base_z * 100.0
814            } else {
815                0.0
816            });
817        }
818        let mut extras = take_extras(
819            o,
820            &known,
821            &format!("transformer {name}"),
822            &mut self.net.warnings,
823            &[],
824        );
825        extras.insert("bmopf_subtype".into(), "n_winding".into());
826        for key in ["g_no_load", "b_no_load"] {
827            if let Some(v) = o.get(key) {
828                extras.insert(key.into(), v.clone());
829            }
830        }
831        DistTransformer {
832            name: name.to_string(),
833            phases: windings
834                .iter()
835                .map(|w| n_winding_phase_count(w.conn, &w.terminal_map))
836                .max()
837                .unwrap_or(1)
838                .max(1),
839            windings,
840            xsc_pct,
841            extras,
842        }
843    }
844}
845
846fn expand_center_tap_windings(subtype: &str, windings: &mut Vec<Winding>) {
847    if subtype != "center_tap" || windings[1].terminal_map.len() < 3 {
848        return;
849    }
850    let to = windings.pop().expect("secondary winding exists");
851    let common = to.terminal_map.last().cloned().unwrap_or_default();
852    let hot_a = to.terminal_map[0].clone();
853    let hot_b = to.terminal_map[1].clone();
854    let half = Winding {
855        bus: to.bus.clone(),
856        terminal_map: vec![hot_a, common.clone()],
857        conn: WindingConn::Wye,
858        v_ref: to.v_ref / 2.0,
859        s_rating: to.s_rating,
860        r_pct: to.r_pct * 2.0,
861        tap: to.tap,
862    };
863    let other_half = Winding {
864        bus: to.bus,
865        terminal_map: vec![common, hot_b],
866        conn: WindingConn::Wye,
867        v_ref: to.v_ref / 2.0,
868        s_rating: to.s_rating,
869        r_pct: to.r_pct * 2.0,
870        tap: to.tap,
871    };
872    windings.push(half);
873    windings.push(other_half);
874}
875
876fn n_winding_internal_v_ref(conn: WindingConn, terminal_map: &[String], bmopf_v_nom: f64) -> f64 {
877    if conn == WindingConn::Wye && n_winding_phase_count(conn, terminal_map) >= 2 {
878        bmopf_v_nom * 3f64.sqrt()
879    } else {
880        bmopf_v_nom
881    }
882}
883
884fn n_winding_bmopf_v_nom_from_internal(w: &Winding) -> f64 {
885    if w.conn == WindingConn::Wye && n_winding_phase_count(w.conn, &w.terminal_map) >= 2 {
886        w.v_ref / 3f64.sqrt()
887    } else {
888        w.v_ref
889    }
890}
891
892fn n_winding_base_from_bmopf(
893    conn: WindingConn,
894    terminal_map: &[String],
895    bmopf_v_nom: f64,
896    s: f64,
897) -> Option<f64> {
898    n_winding_impedance_base(n_winding_phase_count(conn, terminal_map), bmopf_v_nom, s)
899}
900
901fn n_winding_base_from_internal(w: &Winding, s: f64) -> Option<f64> {
902    n_winding_base_from_bmopf(
903        w.conn,
904        &w.terminal_map,
905        n_winding_bmopf_v_nom_from_internal(w),
906        s,
907    )
908}