Skip to main content

powerio_dist/bmopf/
read.rs

1//! BMOPF JSON into the canonical [`MulticonductorNetwork`].
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::de::DeserializeOwned;
15use serde_json::{Map, Value};
16
17use crate::error::{Error, Result};
18use crate::geo::{CoordinateSpace, CoordsKind, GeoMeta, Location};
19use crate::model::{
20    ActivePowerReference, ActivePowerUnit, Configuration, ControlVoltageReference, DistBus,
21    DistCapacitor, DistControlProfile, DistGenerator, DistIbr, DistLine, DistLineCode, DistLoad,
22    DistLoadVoltageModel, DistShunt, DistSourceFormat, DistSwitch, DistTransformer, Extras,
23    IbrPrimeMover, IbrTopology, IbrVoltageAggregation, Mat, MulticonductorNetwork,
24    PowerFactorControl, ReactivePowerReference, ReactivePowerUnit, UntypedObject, VoltVarControl,
25    VoltWattControl, VoltageSource, Winding, WindingConn, n_winding_impedance_base,
26    n_winding_phase_count, pair_keys,
27};
28
29pub fn parse_bmopf_file(path: impl AsRef<Path>) -> Result<MulticonductorNetwork> {
30    let path = path.as_ref();
31    let text = std::fs::read_to_string(path).map_err(|source| Error::Io {
32        path: path.display().to_string(),
33        source,
34    })?;
35    parse_bmopf_str(&text)
36}
37
38pub fn parse_bmopf_str(text: &str) -> Result<MulticonductorNetwork> {
39    let doc: Value = serde_json::from_str(text).map_err(|e| Error::Json {
40        format: "BMOPF",
41        message: e.to_string(),
42    })?;
43    let Value::Object(doc) = doc else {
44        return Err(Error::Json {
45            format: "BMOPF",
46            message: "top level is not an object".into(),
47        });
48    };
49    let mut net = MulticonductorNetwork {
50        source: Some(Arc::new(text.to_string())),
51        source_format: Some(DistSourceFormat::BmopfJson),
52        base_frequency: 60.0,
53        ..MulticonductorNetwork::default()
54    };
55    report_non_numeric_fields(&doc, &mut net);
56    let mut rd = Reader {
57        net: &mut net,
58        frequency_stated: false,
59    };
60    rd.document(&doc);
61    crate::model::warn_unresolved_references(&mut net);
62    Ok(net)
63}
64
65/// Schema 0.1.0 field names typed `number` or an array of them. Derived from
66/// the vendored schema; `schema_numeric_fields_match_the_vendored_schema`
67/// holds the two together.
68const NUMERIC_FIELDS: &[&str] = &[
69    "alpha_i",
70    "alpha_p",
71    "alpha_z",
72    "beta_i",
73    "beta_p",
74    "beta_z",
75    "cost",
76    "frequency",
77    "gamma_p",
78    "gamma_q",
79    "i_max",
80    "i_max_from",
81    "i_max_to",
82    "length",
83    "p_max",
84    "p_min",
85    "p_nom",
86    "q_max",
87    "q_min",
88    "q_nom",
89    "q_rated",
90    "r_series",
91    "r_series_from",
92    "r_series_to",
93    "s_max",
94    "s_rating",
95    "v_angle",
96    "v_magnitude",
97    "v_max",
98    "v_min",
99    "v_nom",
100    "v_nom_from",
101    "v_nom_to",
102    "vn_max",
103    "vneg_max",
104    "vpn_max",
105    "vpn_min",
106    "vpos_max",
107    "vpos_min",
108    "vpp_max",
109    "vpp_min",
110    "vzero_max",
111    "x_series",
112    "x_series_from",
113    "x_series_to",
114];
115
116/// Prefixes of the schema's matrix element patterns, e.g. `R_series_1_2`.
117/// The same spelling the readers pass to [`matrix_indices`] and
118/// [`flat_matrix`].
119const NUMERIC_MATRIX_PREFIXES: &[&str] = &[
120    "B_from", "B_to", "B", "G_from", "G_to", "G", "R_series", "X_series",
121];
122
123/// A `<prefix>_<row>_<col>` matrix element name, by the rule that decides
124/// whether the reader assembles the cell. An index the reader rejects is
125/// outside the schema, so this must not claim it either.
126fn is_numeric_matrix_field(key: &str) -> bool {
127    NUMERIC_MATRIX_PREFIXES
128        .iter()
129        .any(|p| matrix_indices(key, p).is_some())
130}
131
132/// Runs for every key in the document, so the exact set is a binary search
133/// rather than a scan. `NUMERIC_FIELDS` is sorted, which a test holds.
134fn is_numeric_field(key: &str) -> bool {
135    NUMERIC_FIELDS.binary_search(&key).is_ok() || is_numeric_matrix_field(key)
136}
137
138/// Report every schema-numeric field holding something that is not a number.
139///
140/// Schema 0.1.0 spells no `null` anywhere and types the bounds and ratings as
141/// `nonnegative_number`, so every value this reaches is already invalid. Each
142/// reads as `NaN`, which serializes on as `null` and restores as an unbounded
143/// limit — an explicit "no limit" the source never stated. The `Error`
144/// severity carries that to the CLI exit code.
145///
146/// The field stays in the document. Removing it would read as absent, and
147/// absent is a third meaning the reader spells per field: `0.0` for an
148/// optional impedance, `NaN` for a rating, and for `R_series_1_1` the
149/// presence check that picks the inline branch of the line `oneOf` — so a
150/// removal there loses every other matrix entry on that line. Distinguishing
151/// absent from invalid per field needs the typed readers of #293.
152///
153/// `extras` is skipped, since it round trips arbitrary consumer JSON where
154/// these names carry no schema meaning.
155fn report_non_numeric_fields(doc: &Map<String, Value>, net: &mut MulticonductorNetwork) {
156    /// What the value holds, or `None` when the schema allows it.
157    fn not_numeric(v: &Value) -> Option<&'static str> {
158        match v {
159            Value::Number(_) => None,
160            Value::Array(a) if a.iter().all(Value::is_number) => None,
161            Value::Null => Some("null"),
162            Value::Bool(_) => Some("a boolean"),
163            Value::String(_) => Some("a string"),
164            Value::Array(_) => Some("an array holding a value that is not a number"),
165            Value::Object(_) => Some("an object"),
166        }
167    }
168    fn report(net: &mut MulticonductorNetwork, pointer: String, what: &str) {
169        let message = format!(
170            "{pointer}: the schema types this field as a number and it holds {what}; \
171             it reads as NaN and anything derived from it is undefined"
172        );
173        net.warnings.push(message.clone());
174        net.parse_diagnostics.push(
175            crate::diagnostics::StructuredDiagnostic::new(
176                crate::diagnostics::READ_BMOPF_FIELD_NOT_A_NUMBER,
177                crate::diagnostics::DiagnosticSeverity::Error,
178                crate::diagnostics::DiagnosticStage::Read,
179                message,
180            )
181            .with_element_path(pointer)
182            .with_suggested_action("state a number, or omit the field"),
183        );
184    }
185    // A pointer costs an allocation, so it is built for a container the walk
186    // descends into and for a field it reports, never for a sound leaf.
187    fn walk(obj: &Map<String, Value>, path: &str, net: &mut MulticonductorNetwork) {
188        for (key, value) in obj {
189            if key == "extras" {
190                continue;
191            }
192            if is_numeric_field(key) {
193                if let Some(what) = not_numeric(value) {
194                    report(net, format!("{path}/{key}"), what);
195                }
196                continue;
197            }
198            match value {
199                Value::Object(o) => walk(o, &format!("{path}/{key}"), net),
200                Value::Array(a) => {
201                    for (i, e) in a.iter().enumerate() {
202                        if let Value::Object(o) = e {
203                            walk(o, &format!("{path}/{key}/{i}"), net);
204                        }
205                    }
206                }
207                _ => {}
208            }
209        }
210    }
211    walk(doc, "", net);
212}
213
214struct Reader<'a> {
215    net: &'a mut MulticonductorNetwork,
216    frequency_stated: bool,
217}
218
219const BMOPF_DELTA_ROLLS_EXTRA: &str = "bmopf_delta_rolls";
220
221fn f(v: &Value) -> f64 {
222    v.as_f64().unwrap_or(f64::NAN)
223}
224
225fn floats(v: Option<&Value>) -> Option<Vec<f64>> {
226    v?.as_array().map(|a| a.iter().map(f).collect())
227}
228
229fn first_float(v: Option<&Value>) -> Option<f64> {
230    match v? {
231        Value::Array(a) => a.first().map(f),
232        v => Some(f(v)),
233    }
234}
235
236fn delta_roll_value(v: Option<&Value>) -> Option<i64> {
237    v.and_then(Value::as_i64)
238        .filter(|roll| matches!(*roll, -1 | 1))
239}
240
241/// Like [`first_float`], but the field is per-phase-terminal in the schema
242/// while powerio's model holds one value; warn when collapsing loses a
243/// genuine per-phase difference instead of dropping it silently.
244fn first_float_collapsed(v: Option<&Value>, what: &str, warnings: &mut Vec<String>) -> Option<f64> {
245    match v? {
246        Value::Array(a) => {
247            let vals: Vec<f64> = a.iter().map(f).collect();
248            if vals.windows(2).any(|w| w[0].to_bits() != w[1].to_bits()) {
249                warnings.push(format!(
250                    "{what}: per-phase-terminal bound is non-uniform; collapsed to the first entry"
251                ));
252            }
253            vals.first().copied()
254        }
255        v => Some(f(v)),
256    }
257}
258
259fn value_alias<'a>(o: &'a Map<String, Value>, primary: &str, legacy: &str) -> Option<&'a Value> {
260    o.get(primary).or_else(|| o.get(legacy))
261}
262
263/// Folds `extras.transformer.<subtype>.<name>` fields back onto the raw
264/// transformer objects (the reverse of the writer's overflow split). The
265/// in-place field wins over the overlay on a key collision.
266fn merge_transformer_overlay(
267    subtypes: &Map<String, Value>,
268    overlay: &Map<String, Value>,
269) -> Map<String, Value> {
270    let mut merged = subtypes.clone();
271    for (subtype, names) in overlay {
272        let Value::Object(names) = names else {
273            continue;
274        };
275        let Some(Value::Object(table)) = merged.get_mut(subtype) else {
276            continue;
277        };
278        for (name, fields) in names {
279            let (Value::Object(fields), Some(Value::Object(target))) =
280                (fields, table.get_mut(name))
281            else {
282                continue;
283            };
284            for (key, value) in fields {
285                target.entry(key.clone()).or_insert_with(|| value.clone());
286            }
287        }
288    }
289    merged
290}
291
292fn strings(v: Option<&Value>) -> Vec<String> {
293    v.and_then(Value::as_array)
294        .map(|a| {
295            a.iter()
296                .map(|s| s.as_str().unwrap_or_default().to_string())
297                .collect()
298        })
299        .unwrap_or_default()
300}
301
302fn string(v: Option<&Value>) -> String {
303    v.and_then(Value::as_str).unwrap_or_default().to_string()
304}
305
306fn enum_field<T: DeserializeOwned>(
307    v: Option<&Value>,
308    what: &str,
309    warnings: &mut Vec<String>,
310) -> Option<T> {
311    let value = v?;
312    match serde_json::from_value(value.clone()) {
313        Ok(parsed) => Some(parsed),
314        Err(err) => {
315            warnings.push(format!("{what}: {err}; field ignored"));
316            None
317        }
318    }
319}
320
321/// Case insensitive on the recognized values (the dss reader's tolerance);
322/// a present but unrecognized string warns and reads as WYE.
323fn config(v: Option<&Value>, what: &str, warnings: &mut Vec<String>) -> Configuration {
324    let Some(s) = v.and_then(Value::as_str) else {
325        return Configuration::Wye;
326    };
327    match s.to_ascii_uppercase().as_str() {
328        "WYE" => Configuration::Wye,
329        "DELTA" => Configuration::Delta,
330        "SINGLE_PHASE" => Configuration::SinglePhase,
331        _ => {
332            warnings.push(format!(
333                "{what}: configuration `{s}` is not WYE, DELTA, or SINGLE_PHASE; read as WYE"
334            ));
335            Configuration::Wye
336        }
337    }
338}
339
340/// The largest conductor index a `prefix_i_j` matrix key may carry. The
341/// largest index seen sizes a dense n×n allocation in [`flat_matrix`], so an
342/// unbounded key (a few bytes of JSON) could demand gigabytes; no physical
343/// conductor bundle comes near this bound. An oversized index makes the key
344/// unrecognized, so it lands in extras with the malformed-key warning.
345const MAX_MATRIX_INDEX: usize = 64;
346
347/// Parses the `_i_j` tail of a `prefix_i_j` matrix key (1 based). None
348/// when the key is not a well formed entry for this prefix.
349fn matrix_indices(key: &str, prefix: &str) -> Option<(usize, usize)> {
350    let rest = key.strip_prefix(prefix)?.strip_prefix('_')?;
351    let (i, j) = rest.split_once('_')?;
352    let (i, j) = (i.parse::<usize>().ok()?, j.parse::<usize>().ok()?);
353    (i >= 1 && j >= 1 && i <= MAX_MATRIX_INDEX && j <= MAX_MATRIX_INDEX).then_some((i, j))
354}
355
356/// Collects `prefix_i_j` keys into a square matrix; `n` is the largest
357/// index seen. Returns None when no key carries the prefix. A cell whose
358/// transpose is not spelled is mirrored: these matrices are symmetric, and
359/// BMOPFTools' reader accepts the same one-triangle shorthand (a spelled
360/// cell always wins; both writers emit full matrices in practice).
361fn flat_matrix(o: &Map<String, Value>, prefix: &str) -> Option<Mat> {
362    let mut entries: Vec<(usize, usize, f64)> = Vec::new();
363    let mut n = 0;
364    for (k, v) in o {
365        let Some((i, j)) = matrix_indices(k, prefix) else {
366            continue;
367        };
368        entries.push((i - 1, j - 1, f(v)));
369        n = n.max(i).max(j);
370    }
371    if n == 0 {
372        return None;
373    }
374    let mut m = vec![vec![0.0; n]; n];
375    let mut spelled = vec![vec![false; n]; n];
376    for (i, j, v) in entries {
377        m[i][j] = v;
378        spelled[i][j] = true;
379    }
380    for i in 0..n {
381        for j in 0..n {
382            if spelled[i][j] && !spelled[j][i] {
383                m[j][i] = m[i][j];
384            }
385        }
386    }
387    Some(m)
388}
389
390/// The six linecode-shaped matrices of `o`, padded square to the widest one
391/// present; `ragged` reports a genuine size disagreement between them.
392fn linecode_matrices(o: &Map<String, Value>) -> ([Mat; 6], usize, bool) {
393    let mats = [
394        flat_matrix(o, "R_series"),
395        flat_matrix(o, "X_series"),
396        flat_matrix(o, "G_from"),
397        flat_matrix(o, "B_from"),
398        flat_matrix(o, "G_to"),
399        flat_matrix(o, "B_to"),
400    ];
401    let n = mats.iter().flatten().map(Vec::len).max().unwrap_or(0);
402    let ragged = mats.iter().flatten().any(|m| m.len() < n);
403    (mats.map(|m| pad_to(m.unwrap_or_default(), n)), n, ragged)
404}
405
406/// Grows `m` to `n` by `n`, preserving the existing entries.
407fn pad_to(m: Mat, n: usize) -> Mat {
408    if m.len() >= n {
409        return m;
410    }
411    let mut out = vec![vec![0.0; n]; n];
412    for (i, row) in m.into_iter().enumerate() {
413        for (j, v) in row.into_iter().enumerate() {
414            out[i][j] = v;
415        }
416    }
417    out
418}
419
420/// Element fields outside `known` go to extras with a warning.
421fn take_extras(
422    o: &Map<String, Value>,
423    known: &[&str],
424    what: &str,
425    warnings: &mut Vec<String>,
426    matrix_prefixes: &[&str],
427) -> Extras {
428    let mut extras = Extras::new();
429    for (k, v) in o {
430        if known.contains(&k.as_str()) {
431            continue;
432        }
433        if matrix_prefixes
434            .iter()
435            .any(|p| matrix_indices(k, p).is_some())
436        {
437            continue;
438        }
439        warnings.push(format!(
440            "{what}: `{k}` is outside the schema; kept in extras"
441        ));
442        extras.insert(k.clone(), v.clone());
443    }
444    extras
445}
446
447impl Reader<'_> {
448    fn document(&mut self, doc: &Map<String, Value>) {
449        if let Some(name) = doc.get("name").and_then(Value::as_str) {
450            self.net.name = Some(name.to_string());
451        }
452        // Schema 0.1.0 carries the frequency in `meta`; the top-level
453        // spellings are the pre-0.1.0 vintage.
454        if let Some(frequency) = first_float(
455            doc.get("meta")
456                .and_then(Value::as_object)
457                .and_then(|m| m.get("frequency")),
458        )
459        .or_else(|| first_float(doc.get("base_frequency")))
460        .or_else(|| first_float(doc.get("frequency")))
461            && frequency.is_finite()
462            && frequency > 0.0
463        {
464            self.net.base_frequency = frequency;
465            self.frequency_stated = true;
466        }
467        // `serde_json::Map` iterates in key order, so the loop below reaches
468        // `line` before `linecode`. A line with inline impedance synthesizes a
469        // linecode named after the line, and that name must not collide with a
470        // linecode the document declares. Read the declared table first, then
471        // skip it in the loop.
472        if let Some(Value::Object(items)) = doc.get("linecode") {
473            self.linecodes(items);
474        }
475        // `ibr` and `control_profile` can also arrive under `extras`, and the
476        // reader keeps whichever copy it reads first. Key order alone decides
477        // that: `control_profile` sorts before `extras` and `ibr` sorts after,
478        // so the same document would resolve the two classes in opposite
479        // directions. Read both canonical tables here, ahead of `extras`, so
480        // the top-level copy always wins and the rule holds whatever the key
481        // order is.
482        for (key, read) in [
483            ("ibr", Self::ibrs as fn(&mut Self, &Map<String, Value>)),
484            ("control_profile", Self::control_profiles),
485        ] {
486            if let Some(Value::Object(items)) = doc.get(key) {
487                read(self, items);
488            }
489        }
490        for (key, value) in doc {
491            let Value::Object(items) = value else {
492                continue;
493            };
494            match key.as_str() {
495                "bus" => self.buses(items),
496                // All read before the loop: `linecode` ahead of any line that
497                // could synthesize a linecode name, `ibr` and
498                // `control_profile` ahead of `extras`, `name` with the rest of
499                // the document header.
500                "linecode" | "name" | "ibr" | "control_profile" => {}
501                "line" => self.lines(items),
502                "switch" => self.switches(items),
503                "load" => self.loads(items),
504                "generator" => self.generators(items),
505                "capacitor" => self.capacitors(items),
506                "shunt" => self.shunts(items),
507                "voltage_source" => self.sources(items),
508                "transformer" => {
509                    // The writer relocates schema-less transformer fields
510                    // (taps, neutral impedance, no load admittance) to
511                    // `extras.transformer.<subtype>.<name>`; fold them back
512                    // onto the raw objects before parsing.
513                    let overlay = doc
514                        .get("extras")
515                        .and_then(Value::as_object)
516                        .and_then(|e| e.get("transformer"))
517                        .and_then(Value::as_object)
518                        .filter(|o| !o.is_empty());
519                    match overlay {
520                        Some(overlay) => {
521                            let merged = merge_transformer_overlay(items, overlay);
522                            self.transformers(&merged);
523                        }
524                        None => self.transformers(items),
525                    }
526                }
527                "extras" => self.extras_block(items),
528                // The phase/neutral label conventions block: no typed slot,
529                // stashed whole so a round trip keeps it (the meta pattern).
530                "terminal_conventions" => {
531                    self.net.extras.insert(
532                        "bmopf_terminal_conventions".into(),
533                        Value::Object(items.clone()),
534                    );
535                }
536                // `meta` is provenance (license, authors, generator tool),
537                // with no typed slot in the model. Stash it whole, the way the
538                // PMD reader stashes `pmd_settings`; the BMOPF writer owns
539                // `$schema`/`frequency`/`case_study_generator` and folds the
540                // other schema fields back from this stash.
541                "meta" => {
542                    self.net
543                        .extras
544                        .insert("bmopf_meta".into(), Value::Object(items.clone()));
545                }
546                other => {
547                    self.net.warnings.push(format!(
548                        "top level `{other}` is outside the schema; kept untyped"
549                    ));
550                    for (name, v) in items {
551                        self.net.untyped.push(UntypedObject {
552                            class: other.to_string(),
553                            name: name.clone(),
554                            props: vec![(None, v.to_string())],
555                        });
556                    }
557                }
558            }
559        }
560        self.warn_orphan_transformer_overlay(doc);
561        if !self.frequency_stated {
562            crate::model::warn_defaulted_frequency(self.net, "frequency");
563        }
564    }
565
566    /// The `extras.transformer` overlay carries the transformer fields that
567    /// schema 0.1.0 has no slot for. The transformer arm folds it back onto
568    /// the raw objects, so an overlay with no transformer table to attach to
569    /// has no reader at all. Name the loss; `extras_block` skips the overlay
570    /// deliberately, so nothing else reports it.
571    fn warn_orphan_transformer_overlay(&mut self, doc: &Map<String, Value>) {
572        let overlay = doc
573            .get("extras")
574            .and_then(Value::as_object)
575            .and_then(|e| e.get("transformer"))
576            .and_then(Value::as_object)
577            .filter(|o| !o.is_empty());
578        if overlay.is_some() && self.net.transformers.is_empty() {
579            self.net.warnings.push(
580                "`extras.transformer` carries fields for transformers the document does \
581                 not declare; the fields are dropped"
582                    .to_string(),
583            );
584        }
585    }
586
587    /// The top-level `extras` escape hatch (schema 0.1.0). The IBR and
588    /// control profile tables that lost their top-level slots read typed from
589    /// here, the transformer overflow is consumed by the transformer merge,
590    /// and everything else is stashed verbatim for the writer to re-emit.
591    fn extras_block(&mut self, items: &Map<String, Value>) {
592        let mut stash = Map::new();
593        for (key, value) in items {
594            match (key.as_str(), value) {
595                ("ibr", Value::Object(table)) => self.ibrs(table),
596                ("control_profile", Value::Object(table)) => self.control_profiles(table),
597                ("transformer", Value::Object(_)) => {}
598                _ => {
599                    stash.insert(key.clone(), value.clone());
600                }
601            }
602        }
603        if !stash.is_empty() {
604            self.net
605                .extras
606                .insert("bmopf_extras".into(), Value::Object(stash));
607        }
608    }
609
610    fn capacitors(&mut self, items: &Map<String, Value>) {
611        for (name, v) in items {
612            let Value::Object(o) = v else { continue };
613            let known = ["bus", "terminal_map", "configuration", "q_rated", "v_nom"];
614            for (field, value) in [("q_rated", o.get("q_rated")), ("v_nom", o.get("v_nom"))] {
615                if value.is_none() {
616                    self.net
617                        .warnings
618                        .push(format!("capacitor {name}: `{field}` missing; read as NaN"));
619                }
620            }
621            self.net.capacitors.push(DistCapacitor {
622                name: name.clone(),
623                bus: string(o.get("bus")),
624                terminal_map: strings(o.get("terminal_map")),
625                configuration: config(
626                    o.get("configuration"),
627                    &format!("capacitor {name}"),
628                    &mut self.net.warnings,
629                ),
630                q_rated: o.get("q_rated").map_or(f64::NAN, f),
631                v_nom: o.get("v_nom").map_or(f64::NAN, f),
632                extras: take_extras(
633                    o,
634                    &known,
635                    &format!("capacitor {name}"),
636                    &mut self.net.warnings,
637                    &[],
638                ),
639            });
640        }
641    }
642
643    fn ibrs(&mut self, items: &Map<String, Value>) {
644        const TYPED: &[&str] = &[
645            "bus",
646            "terminal_map",
647            "topology",
648            "prime_mover",
649            "s_max",
650            "i_max",
651            "p_avail",
652            "p_min",
653            "p_max",
654            "q_min",
655            "q_max",
656            "control_profile",
657            "voltage_aggregation",
658        ];
659        for (name, v) in items {
660            let Value::Object(o) = v else { continue };
661            if self.reject_duplicate("ibr", name, |net| {
662                net.ibrs.iter().any(|x| x.name.eq_ignore_ascii_case(name))
663            }) {
664                continue;
665            }
666            let topology = enum_field::<IbrTopology>(
667                o.get("topology"),
668                &format!("ibr {name} topology"),
669                &mut self.net.warnings,
670            )
671            .unwrap_or(IbrTopology::SinglePhase);
672            let prime_mover = enum_field::<IbrPrimeMover>(
673                o.get("prime_mover"),
674                &format!("ibr {name} prime_mover"),
675                &mut self.net.warnings,
676            )
677            .unwrap_or(IbrPrimeMover::Generic);
678            let mut extras = Extras::new();
679            for (key, value) in o {
680                if !TYPED.contains(&key.as_str()) {
681                    extras.insert(key.clone(), value.clone());
682                }
683            }
684            self.net.ibrs.push(DistIbr {
685                name: name.clone(),
686                bus: string(o.get("bus")),
687                terminal_map: strings(o.get("terminal_map")),
688                topology,
689                prime_mover,
690                s_max: floats(o.get("s_max")).unwrap_or_default(),
691                i_max: floats(o.get("i_max")),
692                p_avail: first_float(o.get("p_avail")),
693                p_min: floats(o.get("p_min")),
694                p_max: floats(o.get("p_max")),
695                q_min: floats(o.get("q_min")),
696                q_max: floats(o.get("q_max")),
697                control_profile: o
698                    .get("control_profile")
699                    .and_then(Value::as_str)
700                    .map(str::to_string),
701                voltage_aggregation: enum_field::<IbrVoltageAggregation>(
702                    o.get("voltage_aggregation"),
703                    &format!("ibr {name} voltage_aggregation"),
704                    &mut self.net.warnings,
705                ),
706                extras,
707            });
708        }
709    }
710
711    /// True when an element of this class and name is already read. Schema
712    /// 0.1.0 moved the IBR and control profile tables under `extras`, and the
713    /// reader accepts both places, so a part-migrated document can declare
714    /// the same name twice. Two elements of one name double every count and
715    /// let the writer silently keep only the last, so the second reads as a
716    /// duplicate and drops with a warning.
717    fn reject_duplicate(
718        &mut self,
719        class: &str,
720        name: &str,
721        seen: impl Fn(&MulticonductorNetwork) -> bool,
722    ) -> bool {
723        if !seen(self.net) {
724            return false;
725        }
726        self.net.warnings.push(format!(
727            "{class} {name}: the document declares this name at the top level and under \
728             `extras`; the second copy is dropped"
729        ));
730        true
731    }
732
733    fn control_profiles(&mut self, items: &Map<String, Value>) {
734        for (name, v) in items {
735            let Value::Object(o) = v else { continue };
736            if self.reject_duplicate("control_profile", name, |net| {
737                net.control_profiles
738                    .iter()
739                    .any(|x| x.name.eq_ignore_ascii_case(name))
740            }) {
741                continue;
742            }
743            let mut profile = DistControlProfile::new(name.clone());
744            if let Some(Value::Object(pf)) = o.get("power_factor") {
745                profile.power_factor =
746                    first_float(pf.get("pf")).map(|pf| PowerFactorControl { pf });
747            }
748            if let Some(Value::Object(vv)) = o.get("volt_var") {
749                profile.volt_var = Some(VoltVarControl {
750                    voltage_reference: enum_field::<ControlVoltageReference>(
751                        vv.get("voltage_reference"),
752                        &format!("control_profile {name} volt_var voltage_reference"),
753                        &mut self.net.warnings,
754                    ),
755                    breakpoints: floats(vv.get("breakpoints")).unwrap_or_default(),
756                    q_limits: floats(vv.get("q_limits")).unwrap_or_default(),
757                    q_unit: enum_field::<ReactivePowerUnit>(
758                        vv.get("q_unit"),
759                        &format!("control_profile {name} volt_var q_unit"),
760                        &mut self.net.warnings,
761                    ),
762                    q_ref: enum_field::<ReactivePowerReference>(
763                        vv.get("q_ref"),
764                        &format!("control_profile {name} volt_var q_ref"),
765                        &mut self.net.warnings,
766                    ),
767                    p_min_for_q: first_float(vv.get("p_min_for_q")),
768                    p_min_for_q_max: first_float(vv.get("p_min_for_q_max")),
769                });
770            }
771            if let Some(Value::Object(vw)) = o.get("volt_watt") {
772                profile.volt_watt = Some(VoltWattControl {
773                    voltage_reference: enum_field::<ControlVoltageReference>(
774                        vw.get("voltage_reference"),
775                        &format!("control_profile {name} volt_watt voltage_reference"),
776                        &mut self.net.warnings,
777                    ),
778                    breakpoints: floats(vw.get("breakpoints")).unwrap_or_default(),
779                    p_limits: floats(vw.get("p_limits")).unwrap_or_default(),
780                    p_unit: enum_field::<ActivePowerUnit>(
781                        vw.get("p_unit"),
782                        &format!("control_profile {name} volt_watt p_unit"),
783                        &mut self.net.warnings,
784                    ),
785                    p_ref: enum_field::<ActivePowerReference>(
786                        vw.get("p_ref"),
787                        &format!("control_profile {name} volt_watt p_ref"),
788                        &mut self.net.warnings,
789                    ),
790                });
791            }
792            for (key, value) in o {
793                if !matches!(key.as_str(), "power_factor" | "volt_var" | "volt_watt") {
794                    profile.extras.insert(key.clone(), value.clone());
795                }
796            }
797            self.net.control_profiles.push(profile);
798        }
799    }
800
801    fn buses(&mut self, items: &Map<String, Value>) {
802        for (id, v) in items {
803            let Value::Object(o) = v else { continue };
804            let known = [
805                "terminal_names",
806                "perfectly_grounded_terminals",
807                "longitude",
808                "latitude",
809                "v_min",
810                "v_max",
811                "vpn_min",
812                "vpn_max",
813                "vpp_min",
814                "vpp_max",
815                "vpos_min",
816                "vpos_max",
817                "vneg_max",
818                "vzero_max",
819                "vn_max",
820                "vsym_min",
821                "vsym_max",
822            ];
823            let lon = first_float(o.get("longitude")).filter(|v| v.is_finite());
824            let lat = first_float(o.get("latitude")).filter(|v| v.is_finite());
825            let has_lon = o.contains_key("longitude");
826            let has_lat = o.contains_key("latitude");
827            let location = match (lon, lat) {
828                (Some(x), Some(y)) => {
829                    self.net.geo = Some(GeoMeta {
830                        space: CoordinateSpace::Geographic { crs: None },
831                        kind: Some(CoordsKind::Source),
832                    });
833                    Some(Location { x, y, kind: None })
834                }
835                _ if has_lon || has_lat => {
836                    self.net.warnings.push(format!(
837                        "bus {id}: longitude/latitude sideload is incomplete or nonfinite; kept in extras"
838                    ));
839                    None
840                }
841                _ => None,
842            };
843            let mut extras =
844                take_extras(o, &known, &format!("bus {id}"), &mut self.net.warnings, &[]);
845            if location.is_none() {
846                if let Some(value) = o.get("longitude") {
847                    extras.insert("longitude".into(), value.clone());
848                }
849                if let Some(value) = o.get("latitude") {
850                    extras.insert("latitude".into(), value.clone());
851                }
852            }
853            // Legacy (pre-0.1.0) `vsym_min`/`vsym_max` arrays carried the
854            // symmetrical component bounds in zero/positive/negative order.
855            // Schema 0.1.0 replaced them with named per-sequence scalars;
856            // map what has a slot and warn about what does not (the negative
857            // and zero sequence lower bounds are fixed at 0 in 0.1.0).
858            let legacy_min = floats(o.get("vsym_min"));
859            let legacy_max = floats(o.get("vsym_max"));
860            if legacy_min.is_some() || legacy_max.is_some() {
861                self.net.warnings.push(format!(
862                    "bus {id}: legacy vsym_min/vsym_max arrays mapped to the per-sequence \
863                     scalars assuming zero/positive/negative order"
864                ));
865            }
866            let legacy_vpos_min = legacy_min.as_ref().and_then(|v| v.get(1).copied());
867            let legacy_vpos_max = legacy_max.as_ref().and_then(|v| v.get(1).copied());
868            let legacy_vzero_max = legacy_max.as_ref().and_then(|v| v.first().copied());
869            let legacy_vneg_max = legacy_max.as_ref().and_then(|v| v.get(2).copied());
870            if legacy_min
871                .as_ref()
872                .is_some_and(|v| [v.first(), v.get(2)].iter().flatten().any(|&&m| m != 0.0))
873            {
874                self.net.warnings.push(format!(
875                    "bus {id}: legacy vsym_min zero/negative sequence lower bounds have no \
876                     slot in schema 0.1.0 (fixed at 0); dropped"
877                ));
878            }
879            self.net.buses.push(DistBus {
880                id: id.clone(),
881                terminals: strings(o.get("terminal_names")),
882                grounded: strings(o.get("perfectly_grounded_terminals")),
883                v_min: first_float_collapsed(
884                    o.get("v_min"),
885                    &format!("bus {id} v_min"),
886                    &mut self.net.warnings,
887                ),
888                v_max: first_float_collapsed(
889                    o.get("v_max"),
890                    &format!("bus {id} v_max"),
891                    &mut self.net.warnings,
892                ),
893                vpn_min: floats(o.get("vpn_min")),
894                vpn_max: floats(o.get("vpn_max")),
895                vpp_min: floats(o.get("vpp_min")),
896                vpp_max: floats(o.get("vpp_max")),
897                vpos_min: first_float(o.get("vpos_min")).or(legacy_vpos_min),
898                vpos_max: first_float(o.get("vpos_max")).or(legacy_vpos_max),
899                vneg_max: first_float(o.get("vneg_max")).or(legacy_vneg_max),
900                vzero_max: first_float(o.get("vzero_max")).or(legacy_vzero_max),
901                vn_max: first_float(o.get("vn_max")),
902                location,
903                extras,
904            });
905        }
906    }
907
908    fn linecodes(&mut self, items: &Map<String, Value>) {
909        for (name, v) in items {
910            let Value::Object(o) = v else { continue };
911            // Conductor count is the widest matrix present; absent matrices
912            // read as zero, smaller ones pad without losing entries.
913            let ([r, x, gf, bf, gt, bt], n, ragged) = linecode_matrices(o);
914            if ragged {
915                self.net.warnings.push(format!(
916                    "linecode {name}: matrix sizes disagree; smaller ones padded \
917                     with zeros to {n}x{n}"
918                ));
919            }
920            let code = DistLineCode {
921                name: name.clone(),
922                n_conductors: n,
923                r_series: r,
924                x_series: x,
925                g_from: gf,
926                b_from: bf,
927                g_to: gt,
928                b_to: bt,
929                i_max: floats(o.get("i_max")),
930                s_max: floats(o.get("s_max")),
931                source: o.get("source").and_then(Value::as_str).map(String::from),
932                extras: take_extras(
933                    o,
934                    &["i_max", "s_max", "source"],
935                    &format!("linecode {name}"),
936                    &mut self.net.warnings,
937                    &["R_series", "X_series", "G_from", "G_to", "B_from", "B_to"],
938                ),
939            };
940            self.net.linecodes.push(code);
941        }
942    }
943
944    fn lines(&mut self, items: &Map<String, Value>) {
945        // `MulticonductorNetwork::linecode` resolves a name case-insensitively, so the
946        // taken set holds lowercase keys and a synthesized name avoids a
947        // declared linecode that differs only in case.
948        let mut taken: std::collections::BTreeSet<String> = self
949            .net
950            .linecodes
951            .iter()
952            .map(|c| c.name.to_ascii_lowercase())
953            .collect();
954        for (name, v) in items {
955            let Value::Object(o) = v else { continue };
956            let known = [
957                "length",
958                "linecode",
959                "bus_from",
960                "bus_to",
961                "terminal_map_from",
962                "terminal_map_to",
963                "i_max",
964                "s_max",
965            ];
966            // Schema 0.1.0 lines carry either a linecode + length or inline
967            // impedance matrices (the linecode oneOf). Inline matrices read
968            // into a synthesized single-use linecode named after the line.
969            let mut linecode = string(o.get("linecode"));
970            let mut length = o.get("length").map_or(f64::NAN, f);
971            // The inline branch of the schema's oneOf requires R_series_1_1.
972            let inline = linecode.is_empty() && o.contains_key("R_series_1_1");
973            if inline {
974                linecode = self.synthesized_linecode(name, o, &mut taken);
975                // Inline matrices are the whole impedance of the line, in
976                // ohms. The schema calls `length` "purely descriptive" on
977                // this branch, so it must not scale them. The synthesized
978                // linecode holds those ohms per meter at unit length.
979                if length.is_finite() && (length - 1.0).abs() > f64::EPSILON {
980                    self.net.warnings.push(format!(
981                        "line {name}: inline impedance matrices are absolute, so the \
982                         descriptive `length` {length} does not scale them; the model \
983                         keeps the line at unit length"
984                    ));
985                }
986                length = 1.0;
987            } else if !length.is_finite() {
988                // The schema requires `length` alongside a linecode; a missing
989                // one becomes NaN in the model, which every impedance
990                // computation downstream inherits, so name the gap the way the
991                // transformer reader names a missing s_rating instead of
992                // letting the NaN travel silently.
993                self.net.warnings.push(format!(
994                    "line {name}: `length` missing or non-finite; impedances derived from \
995                     this line are undefined"
996                ));
997            }
998            self.net.lines.push(DistLine {
999                name: name.clone(),
1000                bus_from: string(o.get("bus_from")),
1001                bus_to: string(o.get("bus_to")),
1002                terminal_map_from: strings(o.get("terminal_map_from")),
1003                terminal_map_to: strings(o.get("terminal_map_to")),
1004                linecode,
1005                length,
1006                route: None,
1007                i_max: floats(o.get("i_max")),
1008                s_max: floats(o.get("s_max")),
1009                extras: take_extras(
1010                    o,
1011                    &known,
1012                    &format!("line {name}"),
1013                    &mut self.net.warnings,
1014                    if inline {
1015                        &["R_series", "X_series", "G_from", "G_to", "B_from", "B_to"]
1016                    } else {
1017                        &[]
1018                    },
1019                ),
1020            });
1021        }
1022    }
1023
1024    /// Reads a line's inline impedance matrices into a linecode named after
1025    /// the line (suffixed if taken), returning the linecode name. `taken`
1026    /// holds lowercase keys: the declared linecodes plus the names already
1027    /// synthesized on this pass.
1028    fn synthesized_linecode(
1029        &mut self,
1030        line: &str,
1031        o: &Map<String, Value>,
1032        taken: &mut std::collections::BTreeSet<String>,
1033    ) -> String {
1034        let mut name = line.to_string();
1035        while taken.contains(&name.to_ascii_lowercase()) {
1036            name.push('_');
1037        }
1038        taken.insert(name.to_ascii_lowercase());
1039        self.net.warnings.push(format!(
1040            "line {line}: inline impedance matrices read into synthesized linecode `{name}`"
1041        ));
1042        let ([r, x, gf, bf, gt, bt], n, ragged) = linecode_matrices(o);
1043        if ragged {
1044            // The declared-linecode path reports this. An inline matrix set
1045            // that disagrees in size is padded the same way, and a conductor
1046            // that gains a zero where the document held nothing is a change
1047            // of physics, so it needs the same warning.
1048            self.net.warnings.push(format!(
1049                "line {line}: inline matrix sizes disagree; smaller ones padded \
1050                 with zeros to {n}x{n}"
1051            ));
1052        }
1053        self.net.linecodes.push(DistLineCode {
1054            name: name.clone(),
1055            n_conductors: n,
1056            r_series: r,
1057            x_series: x,
1058            g_from: gf,
1059            b_from: bf,
1060            g_to: gt,
1061            b_to: bt,
1062            i_max: None,
1063            s_max: None,
1064            source: None,
1065            extras: Extras::new(),
1066        });
1067        name
1068    }
1069
1070    fn switches(&mut self, items: &Map<String, Value>) {
1071        for (name, v) in items {
1072            let Value::Object(o) = v else { continue };
1073            let known = [
1074                "bus_from",
1075                "bus_to",
1076                "terminal_map_from",
1077                "terminal_map_to",
1078                "open_switch",
1079                "i_max",
1080            ];
1081            self.net.switches.push(DistSwitch {
1082                name: name.clone(),
1083                bus_from: string(o.get("bus_from")),
1084                bus_to: string(o.get("bus_to")),
1085                terminal_map_from: strings(o.get("terminal_map_from")),
1086                terminal_map_to: strings(o.get("terminal_map_to")),
1087                open: o
1088                    .get("open_switch")
1089                    .and_then(Value::as_bool)
1090                    .unwrap_or(false),
1091                i_max: floats(o.get("i_max")),
1092                extras: take_extras(
1093                    o,
1094                    &known,
1095                    &format!("switch {name}"),
1096                    &mut self.net.warnings,
1097                    &[],
1098                ),
1099            });
1100        }
1101    }
1102
1103    fn loads(&mut self, items: &Map<String, Value>) {
1104        for (name, v) in items {
1105            let Value::Object(o) = v else { continue };
1106            let known = [
1107                "p_nom",
1108                "q_nom",
1109                "bus",
1110                "configuration",
1111                "terminal_map",
1112                "model",
1113                "v_nom",
1114                "alpha_z",
1115                "alpha_i",
1116                "alpha_p",
1117                "beta_z",
1118                "beta_i",
1119                "beta_p",
1120                "gamma_p",
1121                "gamma_q",
1122            ];
1123            let v_nom = floats(o.get("v_nom")).unwrap_or_default();
1124            let has_zip = [
1125                "alpha_z", "alpha_i", "alpha_p", "beta_z", "beta_i", "beta_p",
1126            ]
1127            .iter()
1128            .any(|key| o.get(*key).is_some());
1129            let has_exp = o.get("gamma_p").is_some() || o.get("gamma_q").is_some();
1130            let model = o
1131                .get("model")
1132                .and_then(Value::as_str)
1133                .unwrap_or("POWER")
1134                .to_ascii_uppercase();
1135            let voltage_model = if has_exp {
1136                DistLoadVoltageModel::Exponential {
1137                    v_nom,
1138                    gamma_p: floats(o.get("gamma_p")).unwrap_or_default(),
1139                    gamma_q: floats(o.get("gamma_q")).unwrap_or_default(),
1140                }
1141            } else if has_zip {
1142                DistLoadVoltageModel::Zip {
1143                    v_nom,
1144                    alpha_z: floats(o.get("alpha_z")).unwrap_or_default(),
1145                    alpha_i: floats(o.get("alpha_i")).unwrap_or_default(),
1146                    alpha_p: floats(o.get("alpha_p")).unwrap_or_default(),
1147                    beta_z: floats(o.get("beta_z")).unwrap_or_default(),
1148                    beta_i: floats(o.get("beta_i")).unwrap_or_default(),
1149                    beta_p: floats(o.get("beta_p")).unwrap_or_default(),
1150                }
1151            } else if model.contains("IMPEDANCE") {
1152                DistLoadVoltageModel::ConstantImpedance { v_nom }
1153            } else if model.contains("CURRENT") {
1154                DistLoadVoltageModel::ConstantCurrent { v_nom }
1155            } else {
1156                DistLoadVoltageModel::ConstantPower { v_nom }
1157            };
1158            self.net.loads.push(DistLoad {
1159                name: name.clone(),
1160                bus: string(o.get("bus")),
1161                terminal_map: strings(o.get("terminal_map")),
1162                configuration: config(
1163                    o.get("configuration"),
1164                    &format!("load {name}"),
1165                    &mut self.net.warnings,
1166                ),
1167                p_nom: floats(o.get("p_nom")).unwrap_or_default(),
1168                q_nom: floats(o.get("q_nom")).unwrap_or_default(),
1169                voltage_model,
1170                extras: take_extras(
1171                    o,
1172                    &known,
1173                    &format!("load {name}"),
1174                    &mut self.net.warnings,
1175                    &[],
1176                ),
1177            });
1178        }
1179    }
1180
1181    fn generators(&mut self, items: &Map<String, Value>) {
1182        for (name, v) in items {
1183            let Value::Object(o) = v else { continue };
1184            let known = [
1185                "p_min",
1186                "p_max",
1187                "q_min",
1188                "q_max",
1189                "s_max",
1190                "i_max",
1191                "cost",
1192                "bus",
1193                "configuration",
1194                "terminal_map",
1195            ];
1196            let p_min = floats(o.get("p_min"));
1197            let p_max = floats(o.get("p_max"));
1198            let q_min = floats(o.get("q_min"));
1199            let q_max = floats(o.get("q_max"));
1200            // Pinned bounds are a fixed dispatch; surface them as the
1201            // setpoint too so a power flow oriented target has one.
1202            let pinned = |lo: &Option<Vec<f64>>, hi: &Option<Vec<f64>>| match (lo, hi) {
1203                (Some(a), Some(b)) if a == b => a.clone(),
1204                _ => Vec::new(),
1205            };
1206            // Cost is a per-phase array in the schema; powerio's model holds one
1207            // value, so take the first entry (warning if the phases disagree). A
1208            // bare scalar is still accepted for documents written before v0.0.1.
1209            let cost = match o.get("cost") {
1210                Some(Value::Array(a)) => {
1211                    let vals: Vec<f64> = a.iter().map(f).collect();
1212                    // Bit comparison: detect any per-phase difference exactly
1213                    // (broadcast entries are bit-identical), without a float_cmp.
1214                    if vals.windows(2).any(|w| w[0].to_bits() != w[1].to_bits()) {
1215                        self.net.warnings.push(format!(
1216                            "generator {name}: per-phase cost is non-uniform; \
1217                             collapsed to the first entry"
1218                        ));
1219                    }
1220                    vals.first().copied()
1221                }
1222                Some(v) => Some(f(v)),
1223                None => None,
1224            };
1225            self.net.generators.push(DistGenerator {
1226                name: name.clone(),
1227                bus: string(o.get("bus")),
1228                terminal_map: strings(o.get("terminal_map")),
1229                configuration: config(
1230                    o.get("configuration"),
1231                    &format!("generator {name}"),
1232                    &mut self.net.warnings,
1233                ),
1234                p_nom: pinned(&p_min, &p_max),
1235                q_nom: pinned(&q_min, &q_max),
1236                p_min,
1237                p_max,
1238                q_min,
1239                q_max,
1240                cost,
1241                s_max: floats(o.get("s_max")),
1242                i_max: floats(o.get("i_max")),
1243                extras: take_extras(
1244                    o,
1245                    &known,
1246                    &format!("generator {name}"),
1247                    &mut self.net.warnings,
1248                    &[],
1249                ),
1250            });
1251        }
1252    }
1253
1254    fn shunts(&mut self, items: &Map<String, Value>) {
1255        for (name, v) in items {
1256            let Value::Object(o) = v else { continue };
1257            let g = flat_matrix(o, "G").unwrap_or_default();
1258            let b = flat_matrix(o, "B").unwrap_or_default();
1259            let n = g.len().max(b.len());
1260            if g.len() != b.len() {
1261                self.net.warnings.push(format!(
1262                    "shunt {name}: G is {gx}x{gx} but B is {bx}x{bx}; the smaller \
1263                     padded with zeros to {n}x{n}",
1264                    gx = g.len(),
1265                    bx = b.len(),
1266                ));
1267            }
1268            self.net.shunts.push(DistShunt {
1269                name: name.clone(),
1270                bus: string(o.get("bus")),
1271                terminal_map: strings(o.get("terminal_map")),
1272                g: pad_to(g, n),
1273                b: pad_to(b, n),
1274                extras: take_extras(
1275                    o,
1276                    &["bus", "terminal_map"],
1277                    &format!("shunt {name}"),
1278                    &mut self.net.warnings,
1279                    &["G", "B"],
1280                ),
1281            });
1282        }
1283    }
1284
1285    fn sources(&mut self, items: &Map<String, Value>) {
1286        for (name, v) in items {
1287            let Value::Object(o) = v else { continue };
1288            let known = ["v_magnitude", "v_angle", "bus", "terminal_map"];
1289            self.net.sources.push(VoltageSource {
1290                name: name.clone(),
1291                bus: string(o.get("bus")),
1292                terminal_map: strings(o.get("terminal_map")),
1293                v_magnitude: floats(o.get("v_magnitude")).unwrap_or_default(),
1294                v_angle: floats(o.get("v_angle")).unwrap_or_default(),
1295                extras: take_extras(
1296                    o,
1297                    &known,
1298                    &format!("voltage source {name}"),
1299                    &mut self.net.warnings,
1300                    &[],
1301                ),
1302            });
1303        }
1304    }
1305
1306    fn transformers(&mut self, subtypes: &Map<String, Value>) {
1307        for (subtype, group) in subtypes {
1308            let Value::Object(items) = group else {
1309                continue;
1310            };
1311            for (name, v) in items {
1312                let Value::Object(o) = v else { continue };
1313                match subtype.as_str() {
1314                    "n_winding" => {
1315                        let t = self.n_winding_transformer(name, o);
1316                        self.net.transformers.push(t);
1317                    }
1318                    "single_phase_autotransformer" | "open_delta_regulator" => {
1319                        self.net.warnings.push(format!(
1320                            "transformer {name}: subtype `{subtype}` is not typed yet; kept untyped"
1321                        ));
1322                        self.net.untyped.push(UntypedObject {
1323                            class: format!("transformer.{subtype}"),
1324                            name: name.clone(),
1325                            props: vec![(None, v.to_string())],
1326                        });
1327                    }
1328                    _ => {
1329                        let t = self.transformer(subtype, name, o);
1330                        self.net.transformers.push(t);
1331                    }
1332                }
1333            }
1334        }
1335    }
1336
1337    #[allow(clippy::too_many_lines)] // one BMOPF transformer record maps many optional schema aliases
1338    fn transformer(
1339        &mut self,
1340        subtype: &str,
1341        name: &str,
1342        o: &Map<String, Value>,
1343    ) -> DistTransformer {
1344        let known = [
1345            "bus_from",
1346            "bus_to",
1347            "terminal_map_from",
1348            "terminal_map_to",
1349            "s_rating",
1350            "v_nom_from",
1351            "v_nom_to",
1352            "v_ref_from",
1353            "v_ref_to",
1354            "g_no_load",
1355            "b_no_load",
1356            "r_series",
1357            "x_series",
1358            "r_series_from",
1359            "r_series_to",
1360            "x_series_from",
1361            "x_series_to",
1362            "r_neutral_from",
1363            "x_neutral_from",
1364            "r_neutral_to",
1365            "x_neutral_to",
1366            "tap",
1367            "tap_min",
1368            "tap_max",
1369        ];
1370        if !matches!(
1371            subtype,
1372            "single_phase" | "center_tap" | "wye_delta" | "delta_wye"
1373        ) {
1374            self.net.warnings.push(format!(
1375                "transformer {name}: subtype `{subtype}` is outside the schema; \
1376                 read as a single phase pair"
1377            ));
1378        }
1379        let s = o.get("s_rating").map_or(f64::NAN, f);
1380        let v_from = value_alias(o, "v_nom_from", "v_ref_from").map_or(f64::NAN, f);
1381        let v_to = value_alias(o, "v_nom_to", "v_ref_to").map_or(f64::NAN, f);
1382        let positive = |v: f64| v.is_finite() && v > 0.0;
1383        if !positive(s) || !positive(v_from) || !positive(v_to) {
1384            self.net.warnings.push(format!(
1385                "transformer {name}: s_rating or v_nom missing or nonpositive; \
1386                 impedances read as zero"
1387            ));
1388        }
1389        let three_phase = matches!(subtype, "wye_delta" | "delta_wye");
1390        let phases = if three_phase { 3 } else { 1 };
1391
1392        let pct = |x_ohm: f64, v: f64| {
1393            if s > 0.0 && v > 0.0 {
1394                x_ohm / (v * v / s) * 100.0
1395            } else {
1396                0.0
1397            }
1398        };
1399        let has_split_three_phase_fields = [
1400            "r_series_from",
1401            "r_series_to",
1402            "x_series_from",
1403            "x_series_to",
1404        ]
1405        .iter()
1406        .any(|k| o.contains_key(*k));
1407        let (r_from_pct, r_to_pct, xsc_pct) = if three_phase && has_split_three_phase_fields {
1408            let r_from = pct(o.get("r_series_from").map_or(0.0, f), v_from);
1409            let r_to = pct(o.get("r_series_to").map_or(0.0, f), v_to);
1410            let x_from = pct(o.get("x_series_from").map_or(0.0, f), v_from);
1411            let x_to = pct(o.get("x_series_to").map_or(0.0, f), v_to);
1412            (r_from, r_to, vec![x_from + x_to])
1413        } else if three_phase {
1414            let wye_v = if subtype == "wye_delta" { v_from } else { v_to };
1415            // The schema puts one series impedance on the wye side; the
1416            // model splits resistance evenly across the windings.
1417            let r = pct(o.get("r_series").map_or(0.0, f), wye_v);
1418            let x = pct(o.get("x_series").map_or(0.0, f), wye_v);
1419            (r / 2.0, r / 2.0, vec![x])
1420        } else {
1421            let r_from = pct(o.get("r_series_from").map_or(0.0, f), v_from);
1422            let r_to = pct(o.get("r_series_to").map_or(0.0, f), v_to);
1423            let x_from = pct(o.get("x_series_from").map_or(0.0, f), v_from);
1424            let x_to = pct(o.get("x_series_to").map_or(0.0, f), v_to);
1425            let xsc = if subtype == "center_tap" {
1426                vec![x_from + x_to, x_from + x_to, 2.0 * x_to]
1427            } else {
1428                vec![x_from + x_to]
1429            };
1430            (r_from, r_to, xsc)
1431        };
1432
1433        let conn = |delta: bool| {
1434            if delta {
1435                WindingConn::Delta
1436            } else {
1437                WindingConn::Wye
1438            }
1439        };
1440        let mut windings = vec![
1441            Winding {
1442                bus: string(o.get("bus_from")),
1443                terminal_map: strings(o.get("terminal_map_from")),
1444                conn: conn(subtype == "delta_wye"),
1445                v_ref: v_from,
1446                s_rating: s,
1447                r_pct: r_from_pct,
1448                tap: first_float(o.get("tap")).unwrap_or(1.0),
1449                r_neutral: first_float(o.get("r_neutral_from")),
1450                x_neutral: first_float(o.get("x_neutral_from")),
1451            },
1452            Winding {
1453                bus: string(o.get("bus_to")),
1454                terminal_map: strings(o.get("terminal_map_to")),
1455                conn: conn(subtype == "wye_delta"),
1456                v_ref: v_to,
1457                s_rating: s,
1458                r_pct: r_to_pct,
1459                tap: 1.0,
1460                r_neutral: first_float(o.get("r_neutral_to")),
1461                x_neutral: first_float(o.get("x_neutral_to")),
1462            },
1463        ];
1464        expand_center_tap_windings(subtype, &mut windings, &self.net.buses);
1465        if subtype == "center_tap"
1466            && let Some(w) = windings.get(1)
1467            && let Some(band) = phase_to_neutral_midpoint(w, &self.net.buses)
1468            && (w.v_ref - band).abs() > (w.v_ref / 2.0 - band).abs() * 4.0
1469        {
1470            self.net.warnings.push(format!(
1471                "transformer {name}: v_nom_to {} is about twice the {band} V the \
1472                 secondary bus states phase to neutral, the value a full span \
1473                 reading gives; the convention is the per leg voltage",
1474                w.v_ref
1475            ));
1476        }
1477        let mut extras = take_extras(
1478            o,
1479            &known,
1480            &format!("transformer {name}"),
1481            &mut self.net.warnings,
1482            &[],
1483        );
1484        for key in ["tap_min", "tap_max"] {
1485            if let Some(v) = o.get(key) {
1486                extras.insert(key.into(), v.clone());
1487            }
1488        }
1489        for key in ["g_no_load", "b_no_load"] {
1490            if let Some(v) = o.get(key) {
1491                extras.insert(key.into(), v.clone());
1492            }
1493        }
1494        // Windings alone cannot tell single_phase from center_tap back
1495        // apart; record the subtype for the writer.
1496        extras.insert("bmopf_subtype".into(), subtype.into());
1497        DistTransformer {
1498            name: name.to_string(),
1499            windings,
1500            xsc_pct,
1501            phases,
1502            extras,
1503        }
1504    }
1505
1506    /// Cap the winding list a document supplies. The short circuit pair
1507    /// enumeration is quadratic in the winding count, so an unbounded count
1508    /// would be a quadratic blowup from a linear document; no physical
1509    /// transformer comes near the cap.
1510    fn bounded_windings<'a>(&mut self, name: &str, items: &'a [Value]) -> &'a [Value] {
1511        const MAX_WINDINGS: usize = 64;
1512        if items.len() > MAX_WINDINGS {
1513            self.net.warnings.push(format!(
1514                "transformer {name}: {} windings exceed the supported maximum of \
1515                 {MAX_WINDINGS}; the rest are ignored",
1516                items.len()
1517            ));
1518        }
1519        &items[..items.len().min(MAX_WINDINGS)]
1520    }
1521
1522    fn n_winding_transformer(&mut self, name: &str, o: &Map<String, Value>) -> DistTransformer {
1523        let known = ["windings", "x_sc", "s_rating", "g_no_load", "b_no_load"];
1524        let s = o.get("s_rating").map_or(f64::NAN, f);
1525        let mut windings = Vec::new();
1526        let mut delta_rolls = Map::new();
1527        if let Some(items) = o.get("windings").and_then(Value::as_array) {
1528            for (idx, item) in self.bounded_windings(name, items).iter().enumerate() {
1529                let Some(w) = item.as_object() else {
1530                    self.net.warnings.push(format!(
1531                        "transformer {name}: winding {} is not an object; skipped",
1532                        idx + 1
1533                    ));
1534                    continue;
1535                };
1536                let terminal_map = strings(w.get("terminal_map"));
1537                let bmopf_v_nom = value_alias(w, "v_nom", "v_ref").map_or(f64::NAN, f);
1538                let r_winding = w.get("r_winding").map_or(0.0, f);
1539                let connection = w
1540                    .get("configuration")
1541                    .or_else(|| w.get("connection"))
1542                    .and_then(Value::as_str)
1543                    .unwrap_or("WYE")
1544                    .to_ascii_uppercase();
1545                if !matches!(connection.as_str(), "WYE" | "DELTA") {
1546                    self.net.warnings.push(format!(
1547                        "transformer {name}: winding {} connection `{connection}` is not WYE or DELTA; read as WYE",
1548                        idx + 1
1549                    ));
1550                }
1551                let conn = if connection == "DELTA" {
1552                    WindingConn::Delta
1553                } else {
1554                    WindingConn::Wye
1555                };
1556                if let Some(delta_roll) = delta_roll_value(w.get("delta_roll")) {
1557                    delta_rolls.insert((idx + 1).to_string(), Value::from(delta_roll));
1558                }
1559                let r_pct = if let Some(base_z) =
1560                    n_winding_base_from_bmopf(conn, &terminal_map, bmopf_v_nom, s)
1561                {
1562                    r_winding / base_z * 100.0
1563                } else {
1564                    0.0
1565                };
1566                windings.push(Winding {
1567                    bus: string(w.get("bus")),
1568                    terminal_map: terminal_map.clone(),
1569                    conn,
1570                    v_ref: n_winding_internal_v_ref(conn, &terminal_map, bmopf_v_nom),
1571                    s_rating: s,
1572                    r_pct,
1573                    tap: 1.0,
1574                    r_neutral: None,
1575                    x_neutral: None,
1576                });
1577            }
1578        }
1579        let base_z = windings
1580            .first()
1581            .and_then(|w| n_winding_base_from_internal(w, s))
1582            .unwrap_or(f64::NAN);
1583        let mut xsc_pct = Vec::new();
1584        let x_sc = o.get("x_sc").and_then(Value::as_object);
1585        for (i, j) in pair_keys(windings.len()) {
1586            let key = format!("{}_{}", i + 1, j + 1);
1587            let x = x_sc.and_then(|m| m.get(&key)).map_or(0.0, f);
1588            xsc_pct.push(if base_z.is_finite() && base_z > 0.0 {
1589                x / base_z * 100.0
1590            } else {
1591                0.0
1592            });
1593        }
1594        let mut extras = take_extras(
1595            o,
1596            &known,
1597            &format!("transformer {name}"),
1598            &mut self.net.warnings,
1599            &[],
1600        );
1601        extras.insert("bmopf_subtype".into(), "n_winding".into());
1602        if !delta_rolls.is_empty() {
1603            extras.insert(BMOPF_DELTA_ROLLS_EXTRA.into(), Value::Object(delta_rolls));
1604        }
1605        for key in ["g_no_load", "b_no_load"] {
1606            if let Some(v) = o.get(key) {
1607                extras.insert(key.into(), v.clone());
1608            }
1609        }
1610        DistTransformer {
1611            name: name.to_string(),
1612            phases: windings
1613                .iter()
1614                .map(|w| n_winding_phase_count(w.conn, &w.terminal_map))
1615                .max()
1616                .unwrap_or(1)
1617                .max(1),
1618            windings,
1619            xsc_pct,
1620            extras,
1621        }
1622    }
1623}
1624
1625/// The middle of the secondary bus's phase to neutral band, when it states
1626/// one. A center tapped winding's `v_nom_to` is the per leg voltage, so the
1627/// two agree; a source that states the full span across both legs lands at
1628/// twice this.
1629fn phase_to_neutral_midpoint(w: &Winding, buses: &[DistBus]) -> Option<f64> {
1630    let bus = buses.iter().find(|b| b.id == w.bus)?;
1631    let lo = bus.vpn_min.as_ref()?.first()?;
1632    let hi = bus.vpn_max.as_ref()?.first()?;
1633    let mid = (lo + hi) / 2.0;
1634    (mid.is_finite() && mid > 0.0 && w.v_ref.is_finite()).then_some(mid)
1635}
1636
1637fn expand_center_tap_windings(subtype: &str, windings: &mut Vec<Winding>, buses: &[DistBus]) {
1638    if subtype != "center_tap" || windings[1].terminal_map.len() < 3 {
1639        return;
1640    }
1641    let to = windings.pop().expect("secondary winding exists");
1642    let neutral_idx = center_tap_neutral_index(&to, buses);
1643    let canonical = neutral_idx == 1;
1644    let common = to
1645        .terminal_map
1646        .get(neutral_idx)
1647        .cloned()
1648        .unwrap_or_default();
1649    let hots: Vec<String> = to
1650        .terminal_map
1651        .iter()
1652        .enumerate()
1653        .filter_map(|(idx, term)| (idx != neutral_idx).then_some(term.clone()))
1654        .collect();
1655    let hot_a = hots.first().cloned().unwrap_or_default();
1656    let hot_b = hots.get(1).cloned().unwrap_or_default();
1657    let v_ref = if canonical { to.v_ref } else { to.v_ref / 2.0 };
1658    let r_pct = if canonical { to.r_pct } else { to.r_pct * 2.0 };
1659    let half = Winding {
1660        bus: to.bus.clone(),
1661        terminal_map: vec![hot_a, common.clone()],
1662        conn: WindingConn::Wye,
1663        v_ref,
1664        s_rating: to.s_rating,
1665        r_pct,
1666        tap: to.tap,
1667        r_neutral: to.r_neutral,
1668        x_neutral: to.x_neutral,
1669    };
1670    let other_half = Winding {
1671        bus: to.bus,
1672        terminal_map: vec![common, hot_b],
1673        conn: WindingConn::Wye,
1674        v_ref,
1675        s_rating: to.s_rating,
1676        r_pct,
1677        tap: to.tap,
1678        r_neutral: None,
1679        x_neutral: None,
1680    };
1681    windings.push(half);
1682    windings.push(other_half);
1683}
1684
1685fn center_tap_neutral_index(to: &Winding, buses: &[DistBus]) -> usize {
1686    if let Some(bus) = buses.iter().find(|bus| bus.id == to.bus)
1687        && let Some((idx, _)) = to
1688            .terminal_map
1689            .iter()
1690            .enumerate()
1691            .find(|(_, term)| bus.grounded.iter().any(|ground| ground == *term))
1692    {
1693        return idx;
1694    }
1695    to.terminal_map
1696        .iter()
1697        .position(|term| term.eq_ignore_ascii_case("n") || term == "4")
1698        .unwrap_or_else(|| to.terminal_map.len() - 1)
1699}
1700
1701fn n_winding_internal_v_ref(conn: WindingConn, terminal_map: &[String], bmopf_v_nom: f64) -> f64 {
1702    if conn == WindingConn::Wye && n_winding_phase_count(conn, terminal_map) >= 2 {
1703        bmopf_v_nom * 3f64.sqrt()
1704    } else {
1705        bmopf_v_nom
1706    }
1707}
1708
1709fn n_winding_bmopf_v_nom_from_internal(w: &Winding) -> f64 {
1710    if w.conn == WindingConn::Wye && n_winding_phase_count(w.conn, &w.terminal_map) >= 2 {
1711        w.v_ref / 3f64.sqrt()
1712    } else {
1713        w.v_ref
1714    }
1715}
1716
1717fn n_winding_base_from_bmopf(
1718    conn: WindingConn,
1719    terminal_map: &[String],
1720    bmopf_v_nom: f64,
1721    s: f64,
1722) -> Option<f64> {
1723    n_winding_impedance_base(n_winding_phase_count(conn, terminal_map), bmopf_v_nom, s)
1724}
1725
1726fn n_winding_base_from_internal(w: &Winding, s: f64) -> Option<f64> {
1727    n_winding_base_from_bmopf(
1728        w.conn,
1729        &w.terminal_map,
1730        n_winding_bmopf_v_nom_from_internal(w),
1731        s,
1732    )
1733}
1734
1735#[cfg(test)]
1736mod tests {
1737    use super::{MAX_MATRIX_INDEX, is_numeric_field, matrix_indices};
1738    use serde_json::Value;
1739
1740    /// A matrix key the reader will not assemble is outside the schema, and
1741    /// the numeric check must not claim it. The two ran on separate rules
1742    /// once, so an index past the bound was reported as a schema field while
1743    /// the reader put it in extras.
1744    #[test]
1745    fn the_numeric_check_claims_exactly_the_matrix_keys_the_reader_assembles() {
1746        let over = MAX_MATRIX_INDEX + 1;
1747        for key in [
1748            "R_series_1_1",
1749            "X_series_2_3",
1750            &format!("B_from_{MAX_MATRIX_INDEX}_{MAX_MATRIX_INDEX}"),
1751            "G_to_1_2",
1752            "B_1_1",
1753        ] {
1754            assert!(is_numeric_field(key), "{key} should be numeric");
1755        }
1756        for key in [
1757            "R_series_0_1",
1758            "R_series_1_0",
1759            &format!("R_series_{over}_1"),
1760            &format!("R_series_1_{over}"),
1761            "R_series_1",
1762            "R_series_a_b",
1763            "R_series",
1764        ] {
1765            assert!(!is_numeric_field(key), "{key} should not be numeric");
1766            assert!(
1767                super::NUMERIC_MATRIX_PREFIXES
1768                    .iter()
1769                    .all(|p| matrix_indices(key, p).is_none()),
1770                "{key} disagrees with the reader"
1771            );
1772        }
1773    }
1774
1775    /// [`super::NUMERIC_FIELDS`] and [`super::NUMERIC_MATRIX_PREFIXES`] copy
1776    /// what the schema types as a number. Derive the set again from the
1777    /// vendored schema so the copy cannot drift when upstream moves.
1778    #[test]
1779    fn numeric_field_names_match_the_vendored_schema() {
1780        fn number(v: &Value) -> bool {
1781            v.get("type").and_then(Value::as_str) == Some("number")
1782                || v.get("$ref")
1783                    .and_then(Value::as_str)
1784                    .is_some_and(|r| r.ends_with("nonnegative_number"))
1785        }
1786        fn is_numeric(v: &Value) -> bool {
1787            number(v)
1788                || (v.get("type").and_then(Value::as_str) == Some("array")
1789                    && v.get("items").is_some_and(number))
1790        }
1791        fn walk(node: &Value, names: &mut Vec<String>, patterns: &mut Vec<String>) {
1792            match node {
1793                Value::Object(o) => {
1794                    for (key, target) in
1795                        [("properties", &mut *names), ("patternProperties", patterns)]
1796                    {
1797                        if let Some(Value::Object(props)) = o.get(key) {
1798                            target.extend(
1799                                props
1800                                    .iter()
1801                                    .filter(|(_, v)| is_numeric(v))
1802                                    .map(|(k, _)| k.clone()),
1803                            );
1804                        }
1805                    }
1806                    o.values().for_each(|v| walk(v, names, patterns));
1807                }
1808                Value::Array(a) => a.iter().for_each(|v| walk(v, names, patterns)),
1809                _ => {}
1810            }
1811        }
1812
1813        let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1814            .join("../tests/data/dist/bmopf/draft_bmopf_schema.json");
1815        let schema: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
1816        let (mut names, mut patterns) = (Vec::new(), Vec::new());
1817        walk(&schema, &mut names, &mut patterns);
1818        names.sort_unstable();
1819        names.dedup();
1820        patterns.sort_unstable();
1821        patterns.dedup();
1822        assert!(!names.is_empty() && !patterns.is_empty());
1823
1824        for name in &names {
1825            assert!(is_numeric_field(name), "the reader does not know `{name}`");
1826        }
1827        // Each matrix pattern resolves through a `<prefix>_<row>_<col>` sample.
1828        for pattern in &patterns {
1829            let sample = pattern
1830                .trim_start_matches('^')
1831                .trim_end_matches('$')
1832                .replace("\\d+", "7");
1833            assert!(
1834                is_numeric_field(&sample),
1835                "the reader does not know `{sample}` (from `{pattern}`)"
1836            );
1837        }
1838        // And nothing the reader claims is numeric is absent from the schema.
1839        for name in super::NUMERIC_FIELDS {
1840            assert!(
1841                names.iter().any(|n| n == name),
1842                "`{name}` is not a schema number"
1843            );
1844        }
1845        // `is_numeric_field` binary searches this list.
1846        assert!(
1847            super::NUMERIC_FIELDS.is_sorted(),
1848            "NUMERIC_FIELDS must stay sorted"
1849        );
1850    }
1851}