Skip to main content

powerio_dist/bmopf/
write.rs

1//! [`DistNetwork`] into strict BMOPF JSON.
2//!
3//! Output is schema valid wherever the schema permits the data.
4//!
5//! Numbers serialize through serde_json (shortest round trip form).
6//! Nonfinite values cannot appear in JSON; they emit as 0 with a warning
7//! naming the element and field.
8
9use std::collections::{BTreeMap, BTreeSet};
10use std::f64::consts::{FRAC_PI_2, PI, TAU};
11
12use serde_json::{Map, Value, json};
13
14use crate::convert::Conversion;
15use crate::diagnostics::{DiagnosticSeverity, DiagnosticStage, StructuredDiagnostic};
16use crate::geo::CoordinateSpace;
17use crate::model::{
18    ActivePowerReference, ActivePowerUnit, Configuration, ControlVoltageReference,
19    DistControlProfile, DistGenerator, DistIbr, DistLoadVoltageModel, DistNetwork, DistTransformer,
20    Extras, Mat, ReactivePowerReference, ReactivePowerUnit, VoltVarControl, VoltWattControl,
21    VoltageSource, Winding, WindingConn, n_winding_impedance_base, pair_keys,
22};
23
24/// The `$id` of the BMOPF schema this writer targets, and the value it
25/// stamps into `meta.$schema`. The upstream `$id` is not version pinned,
26/// so use it together with [`BMOPF_SCHEMA_VERSION`].
27pub const BMOPF_SCHEMA_ID: &str = "https://raw.githubusercontent.com/frederikgeth/bmopf-report/main/draft_schema_and_networks/draft_bmopf_schema.json";
28
29/// The `version` field of the vendored BMOPF schema
30/// (`tests/data/dist/bmopf/draft_bmopf_schema.json`). Upstream can change
31/// the schema without a version bump, so use it together with
32/// [`BMOPF_SCHEMA_ID`].
33pub const BMOPF_SCHEMA_VERSION: &str = "0.1.0";
34
35/// Untyped classes that belong to the BMOPF ecosystem. Schema 0.1.0 dropped
36/// their top-level tables (`additionalProperties: false` + the `extras`
37/// escape hatch), so they re-emit under `extras` instead of the top level.
38const RAW_BMOPF_EXTRAS_TABLES: &[&str] = &[
39    "ibr",
40    "control_profile",
41    "dc_bus",
42    "dc_line",
43    "dc_load",
44    "dc_source",
45    "time_series",
46    // An OpenDSS capacitor the dss reader could not type (a nonpositive
47    // phase count) stays an untyped object. The typed `capacitor` table of
48    // schema 0.1.0 is strict, so the raw properties cannot go there; they
49    // re-emit under `extras`, which the schema leaves free-form.
50    "capacitor",
51];
52
53/// The reader's verbatim stash of a source document's own `extras` object.
54const BMOPF_EXTRAS_STASH: &str = "bmopf_extras";
55
56/// The reader's verbatim stash of a source document's `meta` object.
57const BMOPF_META_STASH: &str = "bmopf_meta";
58
59/// The reader's verbatim stash of a source document's `terminal_conventions`.
60const BMOPF_TERMINAL_CONVENTIONS_STASH: &str = "bmopf_terminal_conventions";
61
62const IBR_EXTRA_FIELDS: &[&str] = &[
63    "dc_link_coupled",
64    "p_dc_min",
65    "p_dc_max",
66    "dc_bus",
67    "dc_terminal_map",
68    "dc_control",
69    "dc_v_set",
70    "dc_p_ref",
71    "dc_droop",
72    "dc_deadband",
73    "r_filter",
74    "x_filter",
75    "b_filter_shunt",
76    "grid_forming",
77    "v_ref_internal",
78    "cost",
79    "time_series",
80];
81
82const BMOPF_DELTA_ROLLS_EXTRA: &str = "bmopf_delta_rolls";
83
84/// Upper bound on the model-driven dimensions this writer expands
85/// quadratically: the winding count feeding the `x_sc` pair table and the
86/// conductor count an absent matrix is materialized at as zeros. The BMOPF
87/// and DSS readers cap the same quantities at 64 on their way in, but a
88/// `DistNetwork` can also arrive without those caps (the model JSON C entry
89/// point deserializes one unchecked), and a linear-size model could otherwise
90/// demand O(n²) memory here. No physical element comes near this bound.
91const MAX_DIM: usize = 64;
92
93const TRANSFORMER_NO_LOAD_ALLOWED_EXTRAS: [&str; 5] = [
94    "g_no_load",
95    "b_no_load",
96    "%noloadloss",
97    "%imag",
98    BMOPF_DELTA_ROLLS_EXTRA,
99];
100const TRANSFORMER_TWO_WINDING_ALLOWED_EXTRAS: [&str; 18] = [
101    "tap_min",
102    "tap_max",
103    "mintap",
104    "maxtap",
105    "numtaps",
106    "pmd_tm_set",
107    "pmd_tm_lb",
108    "pmd_tm_ub",
109    "pmd_tm_fix",
110    "pmd_tm_step",
111    "g_no_load",
112    "b_no_load",
113    "r_neutral_from",
114    "x_neutral_from",
115    "r_neutral_to",
116    "x_neutral_to",
117    "%noloadloss",
118    "%imag",
119];
120
121/// Options for BMOPF JSON output.
122#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
123#[non_exhaustive]
124pub struct BmopfWriteOptions {
125    /// Emit the BMOPFTools coordinate sideload fields on buses.
126    ///
127    /// The default stays schema strict because the BMOPF schema rejects these
128    /// fields with `additionalProperties: false`.
129    pub sideload_coordinates: bool,
130}
131
132/// Writes the strict BMOPF document. Every field the schema cannot carry
133/// is reported in the warnings.
134///
135/// # Panics
136///
137/// Never in practice: the document is maps, strings, and finite numbers,
138/// which always serialize.
139pub fn write_bmopf_json(net: &DistNetwork) -> Conversion {
140    write_bmopf_json_with_options(net, &BmopfWriteOptions::default())
141}
142
143/// Writes BMOPF JSON with explicit options.
144///
145/// # Panics
146///
147/// Never in practice: the document is maps, strings, and finite numbers,
148/// which always serialize.
149pub fn write_bmopf_json_with_options(net: &DistNetwork, options: &BmopfWriteOptions) -> Conversion {
150    let mut w = Writer {
151        options: *options,
152        warnings: Vec::new(),
153        diagnostics: Vec::new(),
154        grounded: net
155            .buses
156            .iter()
157            .map(|b| (b.id.to_ascii_lowercase(), b.grounded.clone()))
158            .collect(),
159        transformer_overflow: Map::new(),
160    };
161    let doc = w.document(net);
162    Conversion {
163        text: serde_json::to_string_pretty(&doc).expect("maps and finite numbers") + "\n",
164        sidecars: Vec::new(),
165        warnings: w.warnings,
166        diagnostics: w.diagnostics,
167    }
168}
169
170struct Writer {
171    options: BmopfWriteOptions,
172    warnings: Vec<String>,
173    diagnostics: Vec<StructuredDiagnostic>,
174    grounded: BTreeMap<String, Vec<String>>,
175    /// Transformer fields with no slot in the schema 0.1.0 subtype defs
176    /// (taps, neutral impedance, no load admittance), relocated to
177    /// `extras.transformer.<subtype>.<name>` instead of dropped.
178    transformer_overflow: Map<String, Value>,
179}
180
181impl Writer {
182    fn warn(&mut self, msg: impl Into<String>) {
183        self.warnings.push(msg.into());
184    }
185
186    fn diagnostic(
187        &mut self,
188        code: &'static str,
189        element_path: impl Into<String>,
190        message: impl Into<String>,
191        details: Map<String, Value>,
192    ) {
193        let message = message.into();
194        self.warnings.push(format!("{message} [{code}]"));
195        self.diagnostics.push(
196            StructuredDiagnostic::new(
197                code,
198                DiagnosticSeverity::Warning,
199                DiagnosticStage::Emit,
200                message,
201            )
202            .with_element_path(element_path)
203            .with_details(details),
204        );
205    }
206
207    fn transformer_diagnostic(
208        &mut self,
209        t: &DistTransformer,
210        code: &'static str,
211        message: impl Into<String>,
212        mut details: Map<String, Value>,
213    ) {
214        details.insert("transformer".into(), json!(&t.name));
215        self.diagnostic(code, format!("transformer {}", t.name), message, details);
216    }
217
218    /// Finite number guard (the jnum pattern): JSON has no Inf/NaN.
219    fn num(&mut self, v: f64, what: &str) -> Value {
220        if v.is_finite() {
221            json!(v)
222        } else {
223            self.warn(format!("{what}: nonfinite value emitted as 0"));
224            json!(0.0)
225        }
226    }
227
228    fn nums(&mut self, vs: &[f64], what: &str) -> Value {
229        Value::Array(vs.iter().map(|&v| self.num(v, what)).collect())
230    }
231
232    /// A rating/bound array. PMD spells an unbounded phase as JSON null,
233    /// which restores as ±Inf; BMOPF has no unbounded spelling, and the
234    /// `num` zero fallback would turn "no limit" into a zero limit. Drop
235    /// the whole field with a warning instead.
236    fn bounds(&mut self, vs: &[f64], what: &str) -> Option<Value> {
237        if vs.iter().all(|v| v.is_finite()) {
238            Some(json!(vs))
239        } else {
240            self.warn(format!(
241                "{what}: nonfinite entries (an unbounded phase) have no BMOPF spelling; \
242                 field dropped"
243            ));
244            None
245        }
246    }
247
248    fn extras_dropped(&mut self, extras: &crate::model::Extras, what: &str) {
249        for key in extras.keys() {
250            // `bmopf_subtype` is reader bookkeeping; `conn` marks a delta shunt
251            // whose geometry already lives in the off diagonal B matrix, so it
252            // is preserved, not dropped.
253            if key == "bmopf_subtype" || key == "conn" {
254                continue;
255            }
256            self.warn(format!(
257                "{what}: `{key}` has no place in the BMOPF schema; dropped from the output"
258            ));
259        }
260    }
261
262    /// Provenance + schema-vintage self-identification (the BMOPF `meta` object):
263    /// "generated by powerio vX, targeting BMOPF schema vintage Y." The writer
264    /// owns `$schema`, `frequency`, and `case_study_generator`; every other
265    /// schema `meta` field of a BMOPF source (title, authors, license, ...)
266    /// folds back from the reader's stash, so a round trip keeps the case
267    /// provenance. Deterministic and round-trip stable — no generated
268    /// timestamp, and nothing that depends on the immediate source format
269    /// (which a round trip would change) — so canonical output is idempotent.
270    /// The vintage lives in `$schema` (the canonical bmopf-report `$id`).
271    fn meta(&mut self, net: &DistNetwork) -> Value {
272        let mut m = Map::new();
273        m.insert("$schema".into(), json!(BMOPF_SCHEMA_ID));
274        m.insert(
275            "frequency".into(),
276            self.num(net.base_frequency, "meta frequency"),
277        );
278        m.insert(
279            "case_study_generator".into(),
280            json!({"tool": "powerio", "version": env!("CARGO_PKG_VERSION")}),
281        );
282        if let Some(Value::Object(stash)) = net.extras.get(BMOPF_META_STASH) {
283            for (key, value) in stash {
284                match key.as_str() {
285                    // Writer-owned: this document is powerio's emission, at
286                    // the model's frequency, against the vintage above.
287                    "$schema" | "frequency" | "case_study_generator" => {}
288                    "title" | "description" | "license" | "authors" | "data_sources"
289                    | "created" | "modified" | "provenance" | "version" => {
290                        m.insert(key.clone(), value.clone());
291                    }
292                    other => self.warn(format!(
293                        "meta `{other}` has no slot in the BMOPF schema; dropped"
294                    )),
295                }
296            }
297        }
298        Value::Object(m)
299    }
300
301    fn document(&mut self, net: &DistNetwork) -> Value {
302        let mut doc = Map::new();
303        if let Some(name) = &net.name {
304            doc.insert("name".into(), json!(name));
305        }
306        let meta = self.meta(net);
307        doc.insert("meta".into(), meta);
308        if let Some(Value::Object(tc)) = net.extras.get(BMOPF_TERMINAL_CONVENTIONS_STASH) {
309            doc.insert("terminal_conventions".into(), Value::Object(tc.clone()));
310        } else if let Some(tc) = authored_terminal_conventions(net) {
311            doc.insert("terminal_conventions".into(), tc);
312        }
313        self.buses(net, &mut doc);
314        self.linecodes(net, &mut doc);
315
316        self.branches(net, &mut doc);
317        self.injections(net, &mut doc);
318        self.capacitors(net, &mut doc);
319
320        let transformers = self.transformers(net);
321        if !transformers.is_empty() {
322            doc.insert("transformer".into(), Value::Object(transformers));
323        }
324
325        // Schema 0.1.0 dropped the IBR, control profile, DC, and time series
326        // tables from the top level; `extras` is their sanctioned home.
327        let mut extras = Map::new();
328        if let Some(Value::Object(stash)) = net.extras.get(BMOPF_EXTRAS_STASH) {
329            extras.extend(stash.clone());
330        }
331        self.control_profiles(net, &mut extras);
332        self.ibrs(net, &mut extras);
333        self.untyped_bmopf_tables(net, &mut doc, &mut extras);
334        if !self.transformer_overflow.is_empty() {
335            let overflow = std::mem::take(&mut self.transformer_overflow);
336            extras.insert("transformer".into(), Value::Object(overflow));
337        }
338        if !extras.is_empty() {
339            doc.insert("extras".into(), Value::Object(extras));
340        }
341        self.warn_unemitted_untyped(net);
342        self.prune_unreferenced_buses(&mut doc);
343        Value::Object(doc)
344    }
345
346    fn buses(&mut self, net: &DistNetwork, doc: &mut Map<String, Value>) {
347        let mut buses = Map::new();
348        for b in &net.buses {
349            let mut o = Map::new();
350            o.insert("terminal_names".into(), json!(b.terminals));
351            if !b.grounded.is_empty() {
352                o.insert("perfectly_grounded_terminals".into(), json!(b.grounded));
353            }
354            if let Some(v) = b.v_min {
355                o.insert("v_min".into(), Value::Array(vec![self.num(v, "bus v_min")]));
356            }
357            if let Some(v) = b.v_max {
358                o.insert("v_max".into(), Value::Array(vec![self.num(v, "bus v_max")]));
359            }
360            for (key, bound) in [
361                ("vpn_min", &b.vpn_min),
362                ("vpn_max", &b.vpn_max),
363                ("vpp_min", &b.vpp_min),
364                ("vpp_max", &b.vpp_max),
365            ] {
366                if let Some(v) = bound {
367                    o.insert(key.into(), self.nums(v, &format!("bus {key}")));
368                }
369            }
370            for (key, bound) in [
371                ("vpos_min", b.vpos_min),
372                ("vpos_max", b.vpos_max),
373                ("vneg_max", b.vneg_max),
374                ("vzero_max", b.vzero_max),
375                ("vn_max", b.vn_max),
376            ] {
377                if let Some(v) = bound {
378                    o.insert(key.into(), self.num(v, &format!("bus {key}")));
379                }
380            }
381            self.bus_location(&mut o, b, net);
382            // Other extras have no bus fields in the schema.
383            self.extras_dropped(&b.extras, &format!("bus {}", b.id));
384            buses.insert(b.id.clone(), Value::Object(o));
385        }
386        doc.insert("bus".into(), Value::Object(buses));
387    }
388
389    fn linecodes(&mut self, net: &DistNetwork, doc: &mut Map<String, Value>) {
390        if !net.linecodes.is_empty() {
391            let mut codes = Map::new();
392            for c in &net.linecodes {
393                let mut o = Map::new();
394                // The schema requires R_series_1_1 and X_series_1_1; an
395                // empty matrix would drop them and invalidate the output.
396                let dim = c.r_series.len().max(c.x_series.len()).max(1);
397                if c.r_series.is_empty() && c.x_series.is_empty() {
398                    self.warn(format!(
399                        "linecode {}: no series matrix; emitted as 1 conductor \
400                         zero impedance",
401                        c.name
402                    ));
403                } else if c.r_series.is_empty() || c.x_series.is_empty() {
404                    self.warn(format!(
405                        "linecode {}: R_series and X_series sizes disagree; the \
406                         empty one emitted as zeros",
407                        c.name
408                    ));
409                }
410                self.required_matrix(&mut o, "R_series", &c.r_series, dim, &c.name);
411                self.required_matrix(&mut o, "X_series", &c.x_series, dim, &c.name);
412                self.flat_matrix(&mut o, "G_from", &c.g_from, &c.name);
413                self.flat_matrix(&mut o, "G_to", &c.g_to, &c.name);
414                self.flat_matrix(&mut o, "B_from", &c.b_from, &c.name);
415                self.flat_matrix(&mut o, "B_to", &c.b_to, &c.name);
416                if let Some(i_max) = &c.i_max
417                    && let Some(v) = self.bounds(i_max, &format!("linecode {} i_max", c.name))
418                {
419                    o.insert("i_max".into(), v);
420                }
421                if let Some(s_max) = &c.s_max
422                    && let Some(v) = self.bounds(s_max, &format!("linecode {} s_max", c.name))
423                {
424                    o.insert("s_max".into(), v);
425                }
426                if let Some(source) = &c.source {
427                    o.insert("source".into(), json!(source));
428                }
429                self.extras_dropped(&c.extras, &format!("linecode {}", c.name));
430                codes.insert(c.name.clone(), Value::Object(o));
431            }
432            doc.insert("linecode".into(), Value::Object(codes));
433        }
434    }
435
436    fn bus_location(
437        &mut self,
438        o: &mut Map<String, Value>,
439        b: &crate::model::DistBus,
440        net: &DistNetwork,
441    ) {
442        let Some(location) = b.location else {
443            return;
444        };
445        if !self.options.sideload_coordinates {
446            self.diagnostic(
447                "EMIT.BMOPF.BUS_LOCATION_DROPPED",
448                format!("bus {}", b.id),
449                format!(
450                    "bus {}: location has no place in the BMOPF schema; dropped from the output",
451                    b.id
452                ),
453                json!({
454                    "bus": b.id,
455                    "x": location.x,
456                    "y": location.y,
457                })
458                .as_object()
459                .expect("object literal")
460                .clone(),
461            );
462            return;
463        }
464        if !matches!(
465            net.geo.as_ref().map(|geo| &geo.space),
466            Some(CoordinateSpace::Geographic { .. })
467        ) {
468            self.diagnostic(
469                "EMIT.BMOPF.BUS_LOCATION_DROPPED",
470                format!("bus {}", b.id),
471                format!(
472                    "bus {}: non-geographic or undeclared location cannot be emitted as BMOPF longitude/latitude",
473                    b.id
474                ),
475                json!({
476                    "bus": b.id,
477                    "x": location.x,
478                    "y": location.y,
479                })
480                .as_object()
481                .expect("object literal")
482                .clone(),
483            );
484            return;
485        }
486        if !location.x.is_finite() || !location.y.is_finite() {
487            self.diagnostic(
488                "EMIT.BMOPF.BUS_LOCATION_DROPPED",
489                format!("bus {}", b.id),
490                format!(
491                    "bus {}: nonfinite location cannot be emitted as BMOPF longitude/latitude",
492                    b.id
493                ),
494                json!({
495                    "bus": b.id,
496                    "x": location.x,
497                    "y": location.y,
498                })
499                .as_object()
500                .expect("object literal")
501                .clone(),
502            );
503            return;
504        }
505        o.insert("longitude".into(), self.num(location.x, "bus longitude"));
506        o.insert("latitude".into(), self.num(location.y, "bus latitude"));
507    }
508
509    fn warn_unemitted_untyped(&mut self, net: &DistNetwork) {
510        for u in &net.untyped {
511            if Self::is_emitted_untyped(u) {
512                continue;
513            }
514            let message = format!(
515                "{} {}: class is not represented in BMOPF; dropped from the output",
516                u.class, u.name
517            );
518            if u.class == "regcontrol" || u.class == "autotrans" {
519                let mut details = Map::new();
520                details.insert("class".into(), json!(&u.class));
521                details.insert("name".into(), json!(&u.name));
522                let code = if u.class == "regcontrol" {
523                    "EMIT.BMOPF.REGCONTROL_DROPPED"
524                } else {
525                    "EMIT.BMOPF.AUTOTRANSFORMER_DROPPED"
526                };
527                self.diagnostic(code, format!("{} {}", u.class, u.name), message, details);
528            } else {
529                self.warn(message);
530            }
531        }
532    }
533
534    fn is_emitted_untyped(u: &crate::model::UntypedObject) -> bool {
535        RAW_BMOPF_EXTRAS_TABLES.contains(&u.class.as_str()) || u.class.starts_with("transformer.")
536    }
537
538    /// Untyped BMOPF ecosystem objects, one pass: the tables that lost
539    /// their top-level slots in schema 0.1.0 re-emit under `extras`, while
540    /// untyped transformer subtypes keep their place under `transformer`.
541    fn untyped_bmopf_tables(
542        &mut self,
543        net: &DistNetwork,
544        doc: &mut Map<String, Value>,
545        extras: &mut Map<String, Value>,
546    ) {
547        self.clear_non_table_extras_slots(net, extras);
548        for u in &net.untyped {
549            let subtype = u.class.strip_prefix("transformer.");
550            if subtype.is_none() && !RAW_BMOPF_EXTRAS_TABLES.contains(&u.class.as_str()) {
551                continue;
552            }
553            let mut unplaced = Vec::new();
554            let Some(value) = raw_bmopf_value(u, &mut unplaced) else {
555                self.warn(format!(
556                    "{} {}: the untyped BMOPF object carries no field this writer can \
557                     place; dropped from the output",
558                    u.class, u.name
559                ));
560                continue;
561            };
562            for text in unplaced {
563                self.warn(format!(
564                    "{} {}: the value `{text}` has no field name; dropped from the \
565                     output, the named fields beside it are kept",
566                    u.class, u.name
567                ));
568            }
569            // An untyped transformer subtype lands in the top-level
570            // `transformer` table, not under `extras`. Name the slot the
571            // object really went to, so a warning points at the part of the
572            // document a reader must look at.
573            let slot_path = match subtype {
574                Some(sub) => format!("transformer.{sub}"),
575                None => format!("extras.{}", u.class),
576            };
577            let slot = match subtype {
578                Some(sub) => doc
579                    .entry("transformer")
580                    .or_insert_with(|| Value::Object(Map::new()))
581                    .as_object_mut()
582                    .expect("the writer builds the transformer table as an object")
583                    .entry(sub.to_string())
584                    .or_insert_with(|| Value::Object(Map::new())),
585                None => extras
586                    .entry(u.class.clone())
587                    .or_insert_with(|| Value::Object(Map::new())),
588            };
589            // `clear_non_table_extras_slots` makes this hold, and every slot
590            // the writer creates is a table. The path still runs on untrusted
591            // input, so a surprise drops the object instead of the process.
592            let Some(table) = slot.as_object_mut() else {
593                self.warn(format!(
594                    "{} {}: the `{slot_path}` slot is not a table; dropped from the output",
595                    u.class, u.name
596                ));
597                continue;
598            };
599            if table.insert(u.name.clone(), value).is_some() {
600                self.warn(format!(
601                    "{} {}: the source `{slot_path}` carried an entry of the same name; \
602                     the top-level object replaced it",
603                    u.class, u.name
604                ));
605            }
606        }
607    }
608
609    /// `extras` is seeded from the source document's own `extras` object, so
610    /// a value under one of the relocated table names is input, not something
611    /// this writer built. A value that is not a table has no slot for a named
612    /// entry; warn once per name and replace it with an empty table, which
613    /// the untyped objects of that class then fill.
614    fn clear_non_table_extras_slots(&mut self, net: &DistNetwork, extras: &mut Map<String, Value>) {
615        let classes: BTreeSet<&str> = net
616            .untyped
617            .iter()
618            .map(|u| u.class.as_str())
619            .filter(|class| RAW_BMOPF_EXTRAS_TABLES.contains(class))
620            .collect();
621        for class in classes {
622            if extras.get(class).is_some_and(|v| !v.is_object()) {
623                self.warn(format!(
624                    "extras `{class}`: the source value is not a table; replaced by the \
625                     top-level `{class}` objects"
626                ));
627                extras.insert(class.to_string(), Value::Object(Map::new()));
628            }
629        }
630    }
631
632    fn prune_unreferenced_buses(&mut self, doc: &mut Map<String, Value>) {
633        let mut refs = BTreeMap::new();
634        for (key, value) in doc.iter() {
635            if key != "bus" {
636                collect_bus_usage(value, &mut refs);
637            }
638        }
639        let Some(buses) = doc.get_mut("bus").and_then(Value::as_object_mut) else {
640            return;
641        };
642        let ids: Vec<String> = buses.keys().cloned().collect();
643        for id in ids {
644            let Some(used) = refs.get(&id) else {
645                buses.remove(&id);
646                self.warn(format!(
647                    "bus {id}: no emitted BMOPF element references this bus; dropped from the output"
648                ));
649                continue;
650            };
651            let Some(bus) = buses.get_mut(&id).and_then(Value::as_object_mut) else {
652                continue;
653            };
654            // A perfectly grounded terminal is referenced by ground itself:
655            // pruning it would silently lose the grounding, and a dss round
656            // trip would come back with different bus connectivity. Collect
657            // those few names beside `used` rather than cloning the whole
658            // referenced set once per bus.
659            let grounded: BTreeSet<String> = match bus.get("perfectly_grounded_terminals") {
660                Some(Value::Array(terms)) => terms
661                    .iter()
662                    .filter_map(Value::as_str)
663                    .map(str::to_string)
664                    .collect(),
665                _ => BTreeSet::new(),
666            };
667            prune_string_array(
668                bus,
669                "terminal_names",
670                used,
671                &grounded,
672                &mut self.warnings,
673                &format!("bus {id}"),
674            );
675            prune_string_array(
676                bus,
677                "perfectly_grounded_terminals",
678                used,
679                &grounded,
680                &mut self.warnings,
681                &format!("bus {id}"),
682            );
683            if matches!(
684                bus.get("perfectly_grounded_terminals"),
685                Some(Value::Array(terms)) if terms.is_empty()
686            ) {
687                bus.remove("perfectly_grounded_terminals");
688            }
689        }
690    }
691
692    /// Lines and switches.
693    fn branches(&mut self, net: &DistNetwork, doc: &mut Map<String, Value>) {
694        if !net.lines.is_empty() {
695            let mut lines = Map::new();
696            for l in &net.lines {
697                let mut o = Map::new();
698                o.insert("length".into(), self.num(l.length, "line length"));
699                o.insert("linecode".into(), json!(l.linecode));
700                o.insert("bus_from".into(), json!(l.bus_from));
701                o.insert("bus_to".into(), json!(l.bus_to));
702                o.insert("terminal_map_from".into(), json!(l.terminal_map_from));
703                o.insert("terminal_map_to".into(), json!(l.terminal_map_to));
704                let what = format!("line {}", l.name);
705                if let Some(i_max) = &l.i_max
706                    && let Some(v) = self.bounds(i_max, &format!("{what} i_max"))
707                {
708                    o.insert("i_max".into(), v);
709                }
710                if let Some(s_max) = &l.s_max
711                    && let Some(v) = self.bounds(s_max, &format!("{what} s_max"))
712                {
713                    o.insert("s_max".into(), v);
714                }
715                self.extras_dropped(&l.extras, &what);
716                lines.insert(l.name.clone(), Value::Object(o));
717            }
718            doc.insert("line".into(), Value::Object(lines));
719        }
720        if !net.switches.is_empty() {
721            let mut switches = Map::new();
722            for s in &net.switches {
723                let mut o = Map::new();
724                o.insert("bus_from".into(), json!(s.bus_from));
725                o.insert("bus_to".into(), json!(s.bus_to));
726                o.insert("terminal_map_from".into(), json!(s.terminal_map_from));
727                o.insert("terminal_map_to".into(), json!(s.terminal_map_to));
728                o.insert("open_switch".into(), json!(s.open));
729                if let Some(i_max) = &s.i_max
730                    && let Some(v) = self.bounds(i_max, &format!("switch {} i_max", s.name))
731                {
732                    o.insert("i_max".into(), v);
733                }
734                self.extras_dropped(&s.extras, &format!("switch {}", s.name));
735                switches.insert(s.name.clone(), Value::Object(o));
736            }
737            doc.insert("switch".into(), Value::Object(switches));
738        }
739    }
740
741    /// Rated capacitor banks (schema 0.1.0 `capacitor`), distinct from the
742    /// raw admittance `shunt` table.
743    fn capacitors(&mut self, net: &DistNetwork, doc: &mut Map<String, Value>) {
744        if net.capacitors.is_empty() {
745            return;
746        }
747        let mut caps = Map::new();
748        for c in &net.capacitors {
749            let mut o = Map::new();
750            o.insert("bus".into(), json!(c.bus));
751            o.insert("terminal_map".into(), json!(c.terminal_map));
752            o.insert("configuration".into(), json!(config_str(c.configuration)));
753            o.insert("q_rated".into(), self.num(c.q_rated, "capacitor q_rated"));
754            o.insert("v_nom".into(), self.num(c.v_nom, "capacitor v_nom"));
755            self.extras_dropped(&c.extras, &format!("capacitor {}", c.name));
756            caps.insert(c.name.clone(), Value::Object(o));
757        }
758        doc.insert("capacitor".into(), Value::Object(caps));
759    }
760
761    /// Loads, generators, shunts, and the voltage sources.
762    fn injections(&mut self, net: &DistNetwork, doc: &mut Map<String, Value>) {
763        let mut loads = Map::new();
764        for l in &net.loads {
765            let mut o = Map::new();
766            o.insert("configuration".into(), json!(config_str(l.configuration)));
767            o.insert("p_nom".into(), self.nums(&l.p_nom, "load p_nom"));
768            o.insert("q_nom".into(), self.nums(&l.q_nom, "load q_nom"));
769            o.insert("bus".into(), json!(l.bus));
770            o.insert("terminal_map".into(), json!(l.terminal_map));
771            self.load_voltage_model(&mut o, &l.voltage_model, &format!("load {}", l.name));
772            self.extras_dropped(&l.extras, &format!("load {}", l.name));
773            loads.insert(l.name.clone(), Value::Object(o));
774        }
775        let mut gens = Map::new();
776        for g in &net.generators {
777            gens.insert(g.name.clone(), self.generator(g));
778        }
779        if !loads.is_empty() {
780            doc.insert("load".into(), Value::Object(loads));
781        }
782        if !gens.is_empty() {
783            doc.insert("generator".into(), Value::Object(gens));
784        }
785        if !net.shunts.is_empty() {
786            let mut shunts = Map::new();
787            for s in &net.shunts {
788                let mut o = Map::new();
789                o.insert("bus".into(), json!(s.bus));
790                o.insert("terminal_map".into(), json!(s.terminal_map));
791                // The schema requires G_1_1 and B_1_1.
792                let dim = s.g.len().max(s.b.len()).max(1);
793                if s.g.is_empty() && s.b.is_empty() {
794                    self.warn(format!(
795                        "shunt {}: no admittance matrix; emitted as 1 conductor \
796                         zero admittance",
797                        s.name
798                    ));
799                } else if s.g.is_empty() || s.b.is_empty() {
800                    self.warn(format!(
801                        "shunt {}: G and B sizes disagree; the empty one emitted \
802                         as zeros",
803                        s.name
804                    ));
805                }
806                self.required_matrix(&mut o, "G", &s.g, dim, &s.name);
807                self.required_matrix(&mut o, "B", &s.b, dim, &s.name);
808                self.extras_dropped(&s.extras, &format!("shunt {}", s.name));
809                shunts.insert(s.name.clone(), Value::Object(o));
810            }
811            doc.insert("shunt".into(), Value::Object(shunts));
812        }
813        let emitted_sources = bmopf_voltage_sources(net);
814        let mut sources = Map::new();
815        if emitted_sources.is_empty() {
816            self.warn("network has no voltage source; BMOPF requires exactly one");
817        }
818        for (i, vs) in emitted_sources.iter().enumerate() {
819            if i > 0 {
820                self.warn(format!(
821                    "voltage source {}: the BMOPF formulation expects exactly one source; \
822                     this network has {}",
823                    vs.name,
824                    emitted_sources.len()
825                ));
826            }
827            let mut o = Map::new();
828            o.insert(
829                "v_magnitude".into(),
830                self.nums(&vs.v_magnitude, "voltage_source v_magnitude"),
831            );
832            o.insert(
833                "v_angle".into(),
834                self.nums(&vs.v_angle, "voltage_source v_angle"),
835            );
836            o.insert("bus".into(), json!(&vs.bus));
837            o.insert("terminal_map".into(), json!(&vs.terminal_map));
838            let mut extras = vs.extras.clone();
839            if let Some(cost) = extras.remove("cost") {
840                o.insert("cost".into(), cost);
841            }
842            self.extras_dropped(&extras, &format!("voltage source {}", vs.name));
843            for (name, extras) in &vs.dropped_extras {
844                self.extras_dropped(extras, &format!("voltage source {name}"));
845            }
846            sources.insert(vs.name.clone(), Value::Object(o));
847        }
848        doc.insert("voltage_source".into(), Value::Object(sources));
849    }
850
851    fn control_profiles(&mut self, net: &DistNetwork, doc: &mut Map<String, Value>) {
852        if net.control_profiles.is_empty() {
853            return;
854        }
855        let mut profiles = Map::new();
856        for profile in &net.control_profiles {
857            profiles.insert(profile.name.clone(), self.control_profile(profile));
858        }
859        doc.insert("control_profile".into(), Value::Object(profiles));
860    }
861
862    fn control_profile(&mut self, profile: &DistControlProfile) -> Value {
863        let mut o = Map::new();
864        if let Some(pf) = &profile.power_factor {
865            o.insert(
866                "power_factor".into(),
867                json!({ "pf": self.num(pf.pf, "power factor") }),
868            );
869        }
870        if let Some(vv) = &profile.volt_var {
871            o.insert("volt_var".into(), self.volt_var(vv));
872        }
873        if let Some(vw) = &profile.volt_watt {
874            o.insert("volt_watt".into(), self.volt_watt(vw));
875        }
876        for (key, value) in &profile.extras {
877            if value.is_object() {
878                o.insert(key.clone(), value.clone());
879            } else {
880                self.warn(format!(
881                    "control_profile {}: extra `{key}` is not an object; dropped from the output",
882                    profile.name
883                ));
884            }
885        }
886        Value::Object(o)
887    }
888
889    fn volt_var(&mut self, vv: &VoltVarControl) -> Value {
890        let mut o = Map::new();
891        if let Some(v) = vv.voltage_reference {
892            o.insert("voltage_reference".into(), json_enum(v));
893        }
894        o.insert(
895            "breakpoints".into(),
896            self.nums(&vv.breakpoints, "volt_var breakpoints"),
897        );
898        o.insert(
899            "q_limits".into(),
900            self.nums(&vv.q_limits, "volt_var q_limits"),
901        );
902        if let Some(v) = vv.q_unit {
903            o.insert("q_unit".into(), json_enum::<ReactivePowerUnit>(v));
904        }
905        if let Some(v) = vv.q_ref {
906            o.insert("q_ref".into(), json_enum::<ReactivePowerReference>(v));
907        }
908        if let Some(v) = vv.p_min_for_q {
909            o.insert("p_min_for_q".into(), self.num(v, "volt_var p_min_for_q"));
910        }
911        if let Some(v) = vv.p_min_for_q_max {
912            o.insert(
913                "p_min_for_q_max".into(),
914                self.num(v, "volt_var p_min_for_q_max"),
915            );
916        }
917        Value::Object(o)
918    }
919
920    fn volt_watt(&mut self, vw: &VoltWattControl) -> Value {
921        let mut o = Map::new();
922        if let Some(v) = vw.voltage_reference {
923            o.insert(
924                "voltage_reference".into(),
925                json_enum::<ControlVoltageReference>(v),
926            );
927        }
928        o.insert(
929            "breakpoints".into(),
930            self.nums(&vw.breakpoints, "volt_watt breakpoints"),
931        );
932        o.insert(
933            "p_limits".into(),
934            self.nums(&vw.p_limits, "volt_watt p_limits"),
935        );
936        if let Some(v) = vw.p_unit {
937            o.insert("p_unit".into(), json_enum::<ActivePowerUnit>(v));
938        }
939        if let Some(v) = vw.p_ref {
940            o.insert("p_ref".into(), json_enum::<ActivePowerReference>(v));
941        }
942        Value::Object(o)
943    }
944
945    fn ibrs(&mut self, net: &DistNetwork, doc: &mut Map<String, Value>) {
946        if net.ibrs.is_empty() {
947            return;
948        }
949        let mut ibrs = Map::new();
950        for ibr in &net.ibrs {
951            ibrs.insert(ibr.name.clone(), self.ibr(ibr));
952        }
953        doc.insert("ibr".into(), Value::Object(ibrs));
954    }
955
956    fn ibr(&mut self, ibr: &DistIbr) -> Value {
957        let mut o = Map::new();
958        o.insert("bus".into(), json!(ibr.bus));
959        o.insert("terminal_map".into(), json!(ibr.terminal_map));
960        o.insert("topology".into(), json_enum(ibr.topology));
961        o.insert("prime_mover".into(), json_enum(ibr.prime_mover));
962        o.insert("s_max".into(), self.nums(&ibr.s_max, "ibr s_max"));
963        if let Some(v) = &ibr.i_max {
964            o.insert("i_max".into(), self.nums(v, "ibr i_max"));
965        }
966        if let Some(v) = ibr.p_avail {
967            o.insert("p_avail".into(), self.num(v, "ibr p_avail"));
968        }
969        if let Some(v) = &ibr.p_min {
970            o.insert("p_min".into(), self.nums(v, "ibr p_min"));
971        }
972        if let Some(v) = &ibr.p_max {
973            o.insert("p_max".into(), self.nums(v, "ibr p_max"));
974        }
975        if let Some(v) = &ibr.q_min {
976            o.insert("q_min".into(), self.nums(v, "ibr q_min"));
977        }
978        if let Some(v) = &ibr.q_max {
979            o.insert("q_max".into(), self.nums(v, "ibr q_max"));
980        }
981        if let Some(v) = &ibr.control_profile {
982            o.insert("control_profile".into(), json!(v));
983        }
984        if let Some(v) = ibr.voltage_aggregation {
985            o.insert("voltage_aggregation".into(), json_enum(v));
986        }
987        for (key, value) in &ibr.extras {
988            if IBR_EXTRA_FIELDS.contains(&key.as_str()) {
989                o.insert(key.clone(), value.clone());
990            } else {
991                self.warn(format!(
992                    "ibr {}: extra `{key}` has no place in the BMOPF schema; dropped from the output",
993                    ibr.name
994                ));
995            }
996        }
997        Value::Object(o)
998    }
999
1000    fn load_voltage_model(
1001        &mut self,
1002        o: &mut Map<String, Value>,
1003        model: &DistLoadVoltageModel,
1004        what: &str,
1005    ) {
1006        match model {
1007            DistLoadVoltageModel::ConstantPower { v_nom } => {
1008                o.insert("model".into(), json!("CONSTANT_POWER"));
1009                if !v_nom.is_empty() {
1010                    o.insert("v_nom".into(), self.nums(v_nom, &format!("{what} v_nom")));
1011                }
1012            }
1013            DistLoadVoltageModel::ConstantCurrent { v_nom } => {
1014                o.insert("model".into(), json!("CONSTANT_CURRENT"));
1015                o.insert("v_nom".into(), self.nums(v_nom, &format!("{what} v_nom")));
1016            }
1017            DistLoadVoltageModel::ConstantImpedance { v_nom } => {
1018                o.insert("model".into(), json!("CONSTANT_IMPEDANCE"));
1019                o.insert("v_nom".into(), self.nums(v_nom, &format!("{what} v_nom")));
1020            }
1021            DistLoadVoltageModel::Zip {
1022                v_nom,
1023                alpha_z,
1024                alpha_i,
1025                alpha_p,
1026                beta_z,
1027                beta_i,
1028                beta_p,
1029            } => {
1030                o.insert("model".into(), json!("ZIP"));
1031                o.insert("v_nom".into(), self.nums(v_nom, &format!("{what} v_nom")));
1032                o.insert(
1033                    "alpha_z".into(),
1034                    self.nums(alpha_z, &format!("{what} alpha_z")),
1035                );
1036                o.insert(
1037                    "alpha_i".into(),
1038                    self.nums(alpha_i, &format!("{what} alpha_i")),
1039                );
1040                o.insert(
1041                    "alpha_p".into(),
1042                    self.nums(alpha_p, &format!("{what} alpha_p")),
1043                );
1044                o.insert(
1045                    "beta_z".into(),
1046                    self.nums(beta_z, &format!("{what} beta_z")),
1047                );
1048                o.insert(
1049                    "beta_i".into(),
1050                    self.nums(beta_i, &format!("{what} beta_i")),
1051                );
1052                o.insert(
1053                    "beta_p".into(),
1054                    self.nums(beta_p, &format!("{what} beta_p")),
1055                );
1056            }
1057            DistLoadVoltageModel::Exponential {
1058                v_nom,
1059                gamma_p,
1060                gamma_q,
1061            } => {
1062                o.insert("model".into(), json!("EXPONENTIAL"));
1063                o.insert("v_nom".into(), self.nums(v_nom, &format!("{what} v_nom")));
1064                o.insert(
1065                    "gamma_p".into(),
1066                    self.nums(gamma_p, &format!("{what} gamma_p")),
1067                );
1068                o.insert(
1069                    "gamma_q".into(),
1070                    self.nums(gamma_q, &format!("{what} gamma_q")),
1071                );
1072            }
1073        }
1074    }
1075
1076    fn generator(&mut self, g: &DistGenerator) -> Value {
1077        let mut o = Map::new();
1078        // BMOPF generators carry bounds and cost, no dispatch setpoint: a
1079        // fixed injection becomes pinned bounds. Explicit source bounds win
1080        // over the setpoint, which then has nowhere to go.
1081        let what = format!("generator {}", g.name);
1082        for (key_lo, key_hi, lo, hi, nom) in [
1083            ("p_min", "p_max", &g.p_min, &g.p_max, &g.p_nom),
1084            ("q_min", "q_max", &g.q_min, &g.q_max, &g.q_nom),
1085        ] {
1086            if lo.is_some() || hi.is_some() {
1087                // Pinned bounds ARE the setpoint; only a setpoint that
1088                // differs from the bounds has nowhere to go.
1089                let pinned = lo.as_deref() == Some(nom) && hi.as_deref() == Some(nom);
1090                if !nom.is_empty() && !nom.iter().all(|&v| v == 0.0) && !pinned {
1091                    self.warn(format!(
1092                        "{what}: explicit {key_lo}/{key_hi} bounds win over the setpoint, \
1093                         which has no BMOPF field"
1094                    ));
1095                }
1096                if let Some(v) = lo
1097                    && let Some(v) = self.bounds(v, &format!("{what} {key_lo}"))
1098                {
1099                    o.insert(key_lo.into(), v);
1100                }
1101                if let Some(v) = hi
1102                    && let Some(v) = self.bounds(v, &format!("{what} {key_hi}"))
1103                {
1104                    o.insert(key_hi.into(), v);
1105                }
1106            } else if !nom.is_empty() {
1107                // A fixed injection becomes pinned bounds.
1108                o.insert(key_lo.into(), self.nums(nom, key_lo));
1109                o.insert(key_hi.into(), self.nums(nom, key_hi));
1110            }
1111        }
1112        // BMOPF generation cost is per phase conductor; powerio carries a single
1113        // value, so broadcast the scalar to one entry per phase.
1114        let n_phase = if g.p_nom.is_empty() {
1115            g.terminal_map.len().max(1)
1116        } else {
1117            g.p_nom.len()
1118        };
1119        let cost = g.cost.unwrap_or_else(|| {
1120            self.warnings.push(format!(
1121                "{what}: no generation cost in the source; emitted cost 0"
1122            ));
1123            0.0
1124        });
1125        o.insert(
1126            "cost".into(),
1127            self.nums(&vec![cost; n_phase], "generator cost"),
1128        );
1129        if let Some(s_max) = &g.s_max
1130            && let Some(v) = self.bounds(s_max, &format!("{what} s_max"))
1131        {
1132            o.insert("s_max".into(), v);
1133        }
1134        if let Some(i_max) = &g.i_max
1135            && let Some(v) = self.bounds(i_max, &format!("{what} i_max"))
1136        {
1137            o.insert("i_max".into(), v);
1138        }
1139        o.insert("bus".into(), json!(g.bus));
1140        o.insert("configuration".into(), json!(config_str(g.configuration)));
1141        o.insert("terminal_map".into(), json!(g.terminal_map));
1142        if g.configuration == Configuration::Delta {
1143            self.warn(format!(
1144                "{what}: the BMOPF formulation covers WYE generators; DELTA emitted as written"
1145            ));
1146        }
1147        self.extras_dropped(&g.extras, &what);
1148        Value::Object(o)
1149    }
1150
1151    /// Transformers keyed by subtype; wye-wye three phase units decompose
1152    /// into one single_phase entry per phase, the convention the public
1153    /// example networks use.
1154    fn transformers(&mut self, net: &DistNetwork) -> Map<String, Value> {
1155        let mut by_subtype: Map<String, Value> = Map::new();
1156        let insert = |sub: &str, name: String, v: Value, map: &mut Map<String, Value>| {
1157            map.entry(sub.to_string())
1158                .or_insert_with(|| Value::Object(Map::new()))
1159                .as_object_mut()
1160                .expect("subtype maps are objects")
1161                .insert(name, v);
1162        };
1163        for t in &net.transformers {
1164            self.warn_nonuniform_per_phase_taps(t);
1165            match classify(t) {
1166                Kind::SinglePhase => {
1167                    if t.windings.iter().any(|w| w.conn == WindingConn::Delta) {
1168                        // An open wye / open delta leg. The single_phase shape
1169                        // carries the terminals and impedance faithfully, but
1170                        // has no field for the wye/delta connection, so a
1171                        // consumer that models the subtype literally reads it
1172                        // as a wye-wye unit. Flag it; the line to line topology
1173                        // survives in the terminal map.
1174                        let connection = match (t.windings[0].conn, t.windings[1].conn) {
1175                            (WindingConn::Wye, WindingConn::Delta) => "wye/delta",
1176                            (WindingConn::Delta, WindingConn::Wye) => "delta/wye",
1177                            _ => "delta",
1178                        };
1179                        let mut details = Map::new();
1180                        details.insert("connection".into(), json!(connection));
1181                        details.insert("emitted_subtype".into(), json!("single_phase"));
1182                        self.transformer_diagnostic(
1183                            t,
1184                            "EMIT.BMOPF.TRANSFORMER_CONNECTION_LOSSY",
1185                            format!(
1186                                "transformer {}: single phase wye/delta emitted as single_phase; \
1187                                 the wye/delta connection is not encoded in the subtype, only the \
1188                                 line to line terminal map",
1189                                t.name
1190                            ),
1191                            details,
1192                        );
1193                    }
1194                    let v = self.two_winding(t, &t.windings[0], &t.windings[1], 1.0, true, true);
1195                    insert("single_phase", t.name.clone(), v, &mut by_subtype);
1196                }
1197                Kind::SinglePhaseShape(sub) => {
1198                    let v = self.two_winding(t, &t.windings[0], &t.windings[1], 1.0, true, true);
1199                    insert(sub, t.name.clone(), v, &mut by_subtype);
1200                }
1201                Kind::CenterTap => {
1202                    let v = self.center_tap(t);
1203                    insert("center_tap", t.name.clone(), v, &mut by_subtype);
1204                }
1205                Kind::WyeDelta => {
1206                    let v = self.three_phase(t, 0);
1207                    insert("wye_delta", t.name.clone(), v, &mut by_subtype);
1208                }
1209                Kind::DeltaWye => {
1210                    let v = self.three_phase(t, 1);
1211                    insert("delta_wye", t.name.clone(), v, &mut by_subtype);
1212                }
1213                Kind::WyeWye3 => {
1214                    for (k, v) in self.decompose_wye_wye(t) {
1215                        insert("single_phase", k, v, &mut by_subtype);
1216                    }
1217                }
1218                Kind::NWinding => {
1219                    let v = self.n_winding(t);
1220                    insert("n_winding", t.name.clone(), v, &mut by_subtype);
1221                }
1222                Kind::Unsupported(why) => {
1223                    let mut details = Map::new();
1224                    details.insert("reason".into(), json!(&why));
1225                    details.insert("phases".into(), json!(t.phases));
1226                    details.insert("windings".into(), json!(t.windings.len()));
1227                    self.transformer_diagnostic(
1228                        t,
1229                        "EMIT.BMOPF.TRANSFORMER_UNSUPPORTED",
1230                        format!(
1231                            "transformer {}: {why}; not representable in the four BMOPF \
1232                             subtypes, dropped from the output",
1233                            t.name
1234                        ),
1235                        details,
1236                    );
1237                }
1238            }
1239        }
1240        self.split_transformer_overflow(&mut by_subtype);
1241        by_subtype
1242    }
1243
1244    /// Moves transformer fields with no slot in the schema 0.1.0 subtype
1245    /// defs (taps, neutral impedance, no load admittance) out of the
1246    /// `additionalProperties: false` subtype objects and into
1247    /// `extras.transformer.<subtype>.<name>`, warning per transformer.
1248    /// Subtypes the schema leaves undefined (`n_winding`, untyped
1249    /// passthrough) are untouched.
1250    fn split_transformer_overflow(&mut self, by_subtype: &mut Map<String, Value>) {
1251        // The transformer fields the emitters still produce that lost their
1252        // subtype slots in schema 0.1.0. Listing the moved set (rather than
1253        // an allow-list of the schema shape) keeps the failure mode loud: a
1254        // future emitted field lands in the subtype object, where the schema
1255        // validation tests reject it if it has no slot.
1256        const MOVED_FIELDS: &[&str] = &[
1257            "tap",
1258            "tap_min",
1259            "tap_max",
1260            "r_neutral_from",
1261            "x_neutral_from",
1262            "r_neutral_to",
1263            "x_neutral_to",
1264            "g_no_load",
1265            "b_no_load",
1266        ];
1267        for subtype in ["single_phase", "center_tap", "wye_delta", "delta_wye"] {
1268            let Some(Value::Object(table)) = by_subtype.get_mut(subtype) else {
1269                continue;
1270            };
1271            for (name, entry) in table.iter_mut() {
1272                let Value::Object(o) = entry else { continue };
1273                let moved: Vec<String> = MOVED_FIELDS
1274                    .iter()
1275                    .filter(|k| o.contains_key(**k))
1276                    .map(|k| (*k).to_string())
1277                    .collect();
1278                if moved.is_empty() {
1279                    continue;
1280                }
1281                let mut overflow = Map::new();
1282                for key in &moved {
1283                    if let Some(v) = o.remove(key) {
1284                        overflow.insert(key.clone(), v);
1285                    }
1286                }
1287                self.warnings.push(format!(
1288                    "transformer {name}: {} have no {subtype} slot in BMOPF schema 0.1.0; \
1289                     kept under extras.transformer",
1290                    moved.join(", ")
1291                ));
1292                self.transformer_overflow
1293                    .entry(subtype.to_string())
1294                    .or_insert_with(|| Value::Object(Map::new()))
1295                    .as_object_mut()
1296                    .expect("overflow subtype tables are objects")
1297                    .insert(name.clone(), Value::Object(overflow));
1298            }
1299        }
1300    }
1301
1302    /// Shared single_phase / center_tap shape. `to_scale` rescales the to
1303    /// side ratings (used by the wye-wye decomposition).
1304    fn two_winding(
1305        &mut self,
1306        t: &DistTransformer,
1307        from: &Winding,
1308        to: &Winding,
1309        s_scale: f64,
1310        emit_no_load: bool,
1311        warn_extras: bool,
1312    ) -> Value {
1313        let s = from.s_rating * s_scale;
1314        let zb_from = base_impedance(from.v_ref, s);
1315        let zb_to = base_impedance(to.v_ref, s);
1316        let mut o = Map::new();
1317        o.insert("bus_from".into(), json!(from.bus));
1318        o.insert("bus_to".into(), json!(to.bus));
1319        o.insert("s_rating".into(), self.num(s, "transformer s_rating"));
1320        o.insert(
1321            "v_nom_from".into(),
1322            self.num(from.v_ref, "transformer v_nom_from"),
1323        );
1324        o.insert(
1325            "v_nom_to".into(),
1326            self.num(to.v_ref, "transformer v_nom_to"),
1327        );
1328        self.referred_ohms(&mut o, "r_series_from", from.r_pct, zb_from, t, "from");
1329        self.referred_ohms(&mut o, "r_series_to", to.r_pct, zb_to, t, "to");
1330        // The whole leakage reactance rides on the from side, the
1331        // convention the public example uses.
1332        if t.xsc_pct.is_empty() {
1333            self.transformer_diagnostic(
1334                t,
1335                "EMIT.BMOPF.TRANSFORMER_MISSING_XSC",
1336                format!(
1337                    "transformer {}: xsc_pct is empty; emitted x_series_from=0",
1338                    t.name
1339                ),
1340                Map::new(),
1341            );
1342        }
1343        let xhl = t.xsc_pct.first().copied().unwrap_or(0.0);
1344        self.referred_ohms(&mut o, "x_series_from", xhl, zb_from, t, "from");
1345        o.insert("x_series_to".into(), json!(0.0));
1346        o.insert("terminal_map_from".into(), json!(from.terminal_map));
1347        o.insert("terminal_map_to".into(), json!(to.terminal_map));
1348        self.transformer_neutral_fields(&mut o, t, from, to);
1349        self.transformer_tap_fields(&mut o, t, from, to);
1350        if emit_no_load {
1351            self.transformer_no_load_fields(&mut o, t, from, s);
1352        }
1353        if warn_extras {
1354            self.transformer_extras_dropped(t, &TRANSFORMER_TWO_WINDING_ALLOWED_EXTRAS);
1355        }
1356        o.into()
1357    }
1358
1359    fn center_tap(&mut self, t: &DistTransformer) -> Value {
1360        let from = &t.windings[0];
1361        let (w2, w3) = (&t.windings[1], &t.windings[2]);
1362        let common = center_tap_common_terminal(w2, w3);
1363        let r_neutral = self.center_tap_neutral(t, "r_neutral", w2.r_neutral, w3.r_neutral);
1364        let x_neutral = self.center_tap_neutral(t, "x_neutral", w2.x_neutral, w3.x_neutral);
1365        if (w2.tap - w3.tap).abs() > 1e-9 {
1366            let mut details = Map::new();
1367            details.insert("from_tap".into(), json!(from.tap));
1368            details.insert("secondary_taps".into(), json!([w2.tap, w3.tap]));
1369            details.insert("emitted_secondary_tap".into(), json!(w2.tap));
1370            self.transformer_diagnostic(
1371                t,
1372                "EMIT.BMOPF.TRANSFORMER_CENTER_TAP_TAP_COLLAPSED",
1373                format!(
1374                    "transformer {}: center tap secondary half winding taps ({}, {}) differ; emitted the first half tap",
1375                    t.name, w2.tap, w3.tap
1376                ),
1377                details,
1378            );
1379        }
1380        let to = center_tap_to_winding(w2, w3, &common, from.s_rating, r_neutral, x_neutral);
1381        if w2.s_rating.to_bits() != from.s_rating.to_bits()
1382            || w3.s_rating.to_bits() != from.s_rating.to_bits()
1383        {
1384            let mut details = Map::new();
1385            details.insert("from_s_rating".into(), json!(from.s_rating));
1386            details.insert("half_s_ratings".into(), json!([w2.s_rating, w3.s_rating]));
1387            self.transformer_diagnostic(
1388                t,
1389                "EMIT.BMOPF.TRANSFORMER_CENTER_TAP_RATING_COLLAPSED",
1390                format!(
1391                    "transformer {}: center tap half winding s_ratings ({}, {}) differ \
1392                     from the primary's {}; BMOPF carries one transformer rating, and \
1393                     the first secondary half rating is used for the to-side impedance base",
1394                    t.name, w2.s_rating, w3.s_rating, from.s_rating
1395                ),
1396                details,
1397            );
1398        }
1399        let s = from.s_rating;
1400        let zb_from = winding_base(from);
1401        let zb_to = winding_base(w2);
1402        if t.xsc_pct.is_empty() {
1403            self.transformer_diagnostic(
1404                t,
1405                "EMIT.BMOPF.TRANSFORMER_MISSING_XSC",
1406                format!(
1407                    "transformer {}: xsc_pct is empty; emitted x_series_from=0",
1408                    t.name
1409                ),
1410                Map::new(),
1411            );
1412        }
1413        let (x_from_pct, x_to_pct) = self.center_tap_leakage_percentages(t);
1414
1415        let mut o = Map::new();
1416        o.insert("bus_from".into(), json!(from.bus));
1417        o.insert("bus_to".into(), json!(to.bus));
1418        o.insert("s_rating".into(), self.num(s, "transformer s_rating"));
1419        o.insert(
1420            "v_nom_from".into(),
1421            self.num(from.v_ref, "transformer v_nom_from"),
1422        );
1423        o.insert(
1424            "v_nom_to".into(),
1425            self.num(to.v_ref, "transformer v_nom_to"),
1426        );
1427        self.referred_ohms(&mut o, "r_series_from", from.r_pct, zb_from, t, "from");
1428        self.referred_ohms(&mut o, "r_series_to", w2.r_pct, zb_to, t, "to");
1429        self.referred_ohms(&mut o, "x_series_from", x_from_pct, zb_from, t, "from");
1430        self.referred_ohms(&mut o, "x_series_to", x_to_pct, zb_to, t, "to");
1431        o.insert("terminal_map_from".into(), json!(from.terminal_map));
1432        o.insert("terminal_map_to".into(), json!(to.terminal_map));
1433        self.transformer_neutral_fields(&mut o, t, from, &to);
1434        self.transformer_tap_fields(&mut o, t, from, &to);
1435        self.transformer_no_load_fields(&mut o, t, from, s);
1436        self.transformer_extras_dropped(t, &TRANSFORMER_TWO_WINDING_ALLOWED_EXTRAS);
1437        o.into()
1438    }
1439
1440    /// The lumped series resistance on the wye base, in ohms. Each winding
1441    /// holds its percent resistance on its own rating base, so each term is
1442    /// `r_pct / 100 * v_wye^2 / s_rating`. A rating that is not positive
1443    /// makes its term undefined; that term drops with a warning, so the
1444    /// output keeps the resistance of the other winding instead of an
1445    /// infinity that `num` would then emit as a lossless zero.
1446    fn referred_resistance(
1447        &mut self,
1448        t: &DistTransformer,
1449        from: &Winding,
1450        to: &Winding,
1451        v_wye2: f64,
1452    ) -> f64 {
1453        let mut total = 0.0;
1454        for (side, w) in [("from", from), ("to", to)] {
1455            if w.s_rating > 0.0 && w.s_rating.is_finite() {
1456                total += w.r_pct / w.s_rating;
1457            } else if w.r_pct != 0.0 {
1458                self.warn(format!(
1459                    "transformer {}: the `{side}` winding rating is not positive, so its \
1460                     resistance has no base to refer to; the term is dropped from r_series",
1461                    t.name
1462                ));
1463            }
1464        }
1465        total / 100.0 * v_wye2
1466    }
1467
1468    /// Emit one series impedance field from a percent on `base`, or drop the
1469    /// field with a warning when the rating leaves that base undefined.
1470    fn referred_ohms(
1471        &mut self,
1472        o: &mut Map<String, Value>,
1473        key: &str,
1474        pct: f64,
1475        base: Option<f64>,
1476        t: &DistTransformer,
1477        side: &str,
1478    ) {
1479        match base {
1480            Some(zb) => {
1481                let value = self.num(pct / 100.0 * zb, key);
1482                o.insert(key.into(), value);
1483            }
1484            None => self.warn(format!(
1485                "transformer {}: the `{side}` winding rating is not positive, so its \
1486                 percent impedance has no base to refer to; `{key}` is dropped from \
1487                 the output",
1488                t.name
1489            )),
1490        }
1491    }
1492
1493    fn center_tap_leakage_percentages(&mut self, t: &DistTransformer) -> (f64, f64) {
1494        let (x_from_pct, x_to_pct) = center_tap_star_percentages(&t.xsc_pct);
1495        if x_from_pct.is_finite()
1496            && x_to_pct.is_finite()
1497            && x_from_pct >= -1e-12
1498            && x_to_pct >= -1e-12
1499        {
1500            return (x_from_pct.max(0.0), x_to_pct.max(0.0));
1501        }
1502        let xhl = t.xsc_pct.first().copied().unwrap_or(0.0);
1503        let emitted_from = if xhl.is_finite() { xhl.max(0.0) } else { 0.0 };
1504        let mut details = Map::new();
1505        details.insert("xsc_pct".into(), json!(&t.xsc_pct));
1506        details.insert("star_percentages".into(), json!([x_from_pct, x_to_pct]));
1507        details.insert("emitted_percentages".into(), json!([emitted_from, 0.0]));
1508        self.transformer_diagnostic(
1509            t,
1510            "EMIT.BMOPF.TRANSFORMER_CENTER_TAP_LEAKAGE_UNREPRESENTABLE",
1511            format!(
1512                "transformer {}: center tap leakage star arms ({x_from_pct}, {x_to_pct}) \
1513                 are not representable as nonnegative BMOPF fields; emitted xhl on the \
1514                 from side and zero on the to side",
1515                t.name
1516            ),
1517            details,
1518        );
1519        (emitted_from, 0.0)
1520    }
1521
1522    /// Both three phase subtypes use the schema 0.1.0 lumped form: one
1523    /// `r_series`/`x_series` pair referred to the wye winding's base (the
1524    /// split `_from`/`_to` fields lost their slots in 0.1.0).
1525    fn three_phase(&mut self, t: &DistTransformer, wye_idx: usize) -> Value {
1526        let from = &t.windings[0];
1527        let to = &t.windings[1];
1528        let s = from.s_rating;
1529        let mut o = Map::new();
1530        o.insert("bus_from".into(), json!(from.bus));
1531        o.insert("bus_to".into(), json!(to.bus));
1532        o.insert("s_rating".into(), self.num(s, "transformer s_rating"));
1533        o.insert(
1534            "v_nom_from".into(),
1535            self.num(from.v_ref, "transformer v_nom_from"),
1536        );
1537        o.insert(
1538            "v_nom_to".into(),
1539            self.num(to.v_ref, "transformer v_nom_to"),
1540        );
1541        if t.xsc_pct.is_empty() {
1542            self.transformer_diagnostic(
1543                t,
1544                "EMIT.BMOPF.TRANSFORMER_MISSING_XSC",
1545                format!(
1546                    "transformer {}: xsc_pct is empty; emitted x_series=0",
1547                    t.name,
1548                ),
1549                Map::new(),
1550            );
1551        }
1552        let xhl = t.xsc_pct.first().copied().unwrap_or(0.0);
1553        let wye = &t.windings[wye_idx];
1554        let v_wye2 = wye.v_ref * wye.v_ref;
1555        // Each winding's percent resistance is on its own rating base; refer
1556        // both to the wye side before lumping (identical to the plain sum
1557        // when the ratings match). XHL is on the first winding's base.
1558        let r_series = self.referred_resistance(t, from, to, v_wye2);
1559        o.insert(
1560            "r_series".into(),
1561            self.num(r_series, "transformer r_series"),
1562        );
1563        // XHL is a percent on the first winding's rating base. That base is
1564        // the same one `referred_resistance` guards, so guard it here too: an
1565        // unusable rating must not reach the output as a zero reactance.
1566        let x_base = base_impedance(wye.v_ref, s);
1567        self.referred_ohms(&mut o, "x_series", xhl, x_base, t, "from");
1568        o.insert("terminal_map_from".into(), json!(from.terminal_map));
1569        o.insert("terminal_map_to".into(), json!(to.terminal_map));
1570        self.transformer_neutral_fields(&mut o, t, from, to);
1571        self.transformer_tap_fields(&mut o, t, from, to);
1572        self.transformer_no_load_fields(&mut o, t, from, s);
1573        self.transformer_extras_dropped(t, &TRANSFORMER_TWO_WINDING_ALLOWED_EXTRAS);
1574        o.into()
1575    }
1576
1577    fn n_winding(&mut self, t: &DistTransformer) -> Value {
1578        let s = t.windings.first().map_or(f64::NAN, |w| w.s_rating);
1579        if t.windings
1580            .iter()
1581            .any(|w| w.s_rating.to_bits() != s.to_bits())
1582        {
1583            let mut details = Map::new();
1584            details.insert(
1585                "s_ratings".into(),
1586                json!(t.windings.iter().map(|w| w.s_rating).collect::<Vec<_>>()),
1587            );
1588            self.transformer_diagnostic(
1589                t,
1590                "EMIT.BMOPF.TRANSFORMER_N_WINDING_RATING_COLLAPSED",
1591                format!(
1592                    "transformer {}: n_winding BMOPF carries one s_rating; emitted the first winding rating",
1593                    t.name
1594                ),
1595                details,
1596            );
1597        }
1598        let mut o = Map::new();
1599        o.insert("s_rating".into(), self.num(s, "transformer s_rating"));
1600        let windings: Vec<Value> = t
1601            .windings
1602            .iter()
1603            .enumerate()
1604            .map(|(idx, w)| {
1605                let mut wj = Map::new();
1606                wj.insert("bus".into(), json!(w.bus));
1607                wj.insert("terminal_map".into(), json!(w.terminal_map));
1608                wj.insert(
1609                    "v_nom".into(),
1610                    self.num(n_winding_bmopf_v_nom(w), "transformer winding v_nom"),
1611                );
1612                wj.insert(
1613                    "configuration".into(),
1614                    json!(match w.conn {
1615                        WindingConn::Wye => "WYE",
1616                        WindingConn::Delta => "DELTA",
1617                    }),
1618                );
1619                let zbase = n_winding_base(w, s).unwrap_or(f64::NAN);
1620                wj.insert(
1621                    "r_winding".into(),
1622                    self.num(w.r_pct / 100.0 * zbase, "transformer winding r_winding"),
1623                );
1624                if let Some(delta_roll) = bmopf_delta_roll(t, idx, w) {
1625                    wj.insert("delta_roll".into(), json!(delta_roll));
1626                }
1627                Value::Object(wj)
1628            })
1629            .collect();
1630        o.insert("windings".into(), Value::Array(windings));
1631        let base_z = t
1632            .windings
1633            .first()
1634            .and_then(|w| n_winding_base(w, s))
1635            .unwrap_or(f64::NAN);
1636        let x_sc = self.n_winding_x_sc(t, base_z);
1637        o.insert("x_sc".into(), Value::Object(x_sc));
1638        if let Some(first) = t.windings.first() {
1639            self.transformer_no_load_fields(&mut o, t, first, s);
1640        }
1641        self.warn_unrepresented_neutral_fields(t, "n_winding BMOPF");
1642        self.taps_dropped(t);
1643        self.transformer_extras_dropped(t, &TRANSFORMER_NO_LOAD_ALLOWED_EXTRAS);
1644        o.into()
1645    }
1646
1647    /// The `x_sc` pair table for an n_winding transformer. The winding count
1648    /// is model input, so the quadratic pair expansion is capped at
1649    /// [`MAX_DIM`] with a diagnostic.
1650    fn n_winding_x_sc(&mut self, t: &DistTransformer, base_z: f64) -> Map<String, Value> {
1651        let mut x_sc = Map::new();
1652        let n_windings = t.windings.len();
1653        if n_windings > MAX_DIM {
1654            let mut details = Map::new();
1655            details.insert("windings".into(), json!(n_windings));
1656            self.transformer_diagnostic(
1657                t,
1658                "EMIT.BMOPF.TRANSFORMER_WINDINGS_CLAMPED",
1659                format!(
1660                    "transformer {}: {n_windings} windings exceed the supported \
1661                     maximum of {MAX_DIM}; x_sc pairs beyond it are dropped",
1662                    t.name
1663                ),
1664                details,
1665            );
1666        }
1667        for (idx, (i, j)) in pair_keys(n_windings.min(MAX_DIM)).into_iter().enumerate() {
1668            let x_pct = t.xsc_pct.get(idx).copied().unwrap_or_else(|| {
1669                let mut details = Map::new();
1670                details.insert("winding_pair".into(), json!(format!("{}_{}", i + 1, j + 1)));
1671                self.transformer_diagnostic(
1672                    t,
1673                    "EMIT.BMOPF.TRANSFORMER_MISSING_XSC",
1674                    format!(
1675                        "transformer {}: missing x_sc for winding pair {}_{}; emitted 0",
1676                        t.name,
1677                        i + 1,
1678                        j + 1
1679                    ),
1680                    details,
1681                );
1682                0.0
1683            });
1684            x_sc.insert(
1685                format!("{}_{}", i + 1, j + 1),
1686                self.num(x_pct / 100.0 * base_z, "transformer x_sc"),
1687            );
1688        }
1689        x_sc
1690    }
1691
1692    /// A three phase wye-wye unit becomes one single_phase entry per phase
1693    /// (`name_1`..), each at line to neutral voltage and a third of the
1694    /// rating. That keeps the impedance base v^2/s, so the percent values
1695    /// carry over unchanged. The public IEEE13 example records the line to
1696    /// line voltage on its decomposed units instead; both are self
1697    /// consistent, they differ in the v_ref convention.
1698    fn decompose_wye_wye(&mut self, t: &DistTransformer) -> Vec<(String, Value)> {
1699        let mut out = Vec::new();
1700        let (from, to) = (&t.windings[0], &t.windings[1]);
1701        let sqrt3 = 3f64.sqrt();
1702        for k in 0..t.phases {
1703            let per = |w: &Winding| {
1704                let neutral = w.terminal_map.last().cloned().unwrap_or_default();
1705                Winding {
1706                    bus: w.bus.clone(),
1707                    terminal_map: vec![w.terminal_map[k].clone(), neutral],
1708                    conn: WindingConn::Wye,
1709                    v_ref: w.v_ref / sqrt3,
1710                    s_rating: w.s_rating / 3.0,
1711                    r_pct: w.r_pct,
1712                    tap: w.tap,
1713                    r_neutral: if k == 0 { w.r_neutral } else { None },
1714                    x_neutral: if k == 0 { w.x_neutral } else { None },
1715                }
1716            };
1717            let f = per(from);
1718            let to_1 = per(to);
1719            let mut t1 = t.clone();
1720            t1.windings = vec![f.clone(), to_1.clone()];
1721            split_no_load_extras(&mut t1, t.phases);
1722            let v = self.two_winding(&t1, &f, &to_1, 1.0, true, false);
1723            out.push((format!("{}_{}", t.name, k + 1), v));
1724        }
1725        let mut details = Map::new();
1726        details.insert("emitted_subtype".into(), json!("single_phase"));
1727        details.insert("units".into(), json!(t.phases));
1728        self.transformer_diagnostic(
1729            t,
1730            "EMIT.BMOPF.TRANSFORMER_WYE_WYE_DECOMPOSED",
1731            format!(
1732                "transformer {}: three phase wye-wye decomposed into {} single_phase units",
1733                t.name, t.phases
1734            ),
1735            details,
1736        );
1737        self.transformer_extras_dropped(t, &TRANSFORMER_TWO_WINDING_ALLOWED_EXTRAS);
1738        out
1739    }
1740
1741    fn taps_dropped(&mut self, t: &DistTransformer) {
1742        for w in &t.windings {
1743            if (w.tap - 1.0).abs() > 1e-12 {
1744                let mut details = Map::new();
1745                details.insert("tap".into(), json!(w.tap));
1746                self.transformer_diagnostic(
1747                    t,
1748                    "EMIT.BMOPF.TRANSFORMER_TAP_DROPPED",
1749                    format!(
1750                        "transformer {}: off nominal tap {} has no BMOPF field; dropped",
1751                        t.name, w.tap
1752                    ),
1753                    details,
1754                );
1755            }
1756        }
1757    }
1758
1759    fn transformer_tap_fields(
1760        &mut self,
1761        o: &mut Map<String, Value>,
1762        t: &DistTransformer,
1763        from: &Winding,
1764        to: &Winding,
1765    ) {
1766        if to.tap.abs() <= 1e-12 {
1767            if (from.tap - 1.0).abs() > 1e-12 || (to.tap - 1.0).abs() > 1e-12 {
1768                let mut details = Map::new();
1769                details.insert("from_tap".into(), json!(from.tap));
1770                details.insert("to_tap".into(), json!(to.tap));
1771                self.transformer_diagnostic(
1772                    t,
1773                    "EMIT.BMOPF.TRANSFORMER_TAP_DROPPED",
1774                    format!(
1775                        "transformer {}: to-side tap {} cannot form a finite BMOPF ratio; dropped",
1776                        t.name, to.tap
1777                    ),
1778                    details,
1779                );
1780            }
1781        } else {
1782            let tap = from.tap / to.tap;
1783            if (tap - 1.0).abs() > 1e-12 || t.extras.contains_key("tap") {
1784                o.insert("tap".into(), self.num(tap, "transformer tap"));
1785            }
1786        }
1787        for key in ["tap_min", "tap_max"] {
1788            if let Some(v) = extras_number(&t.extras, key) {
1789                o.insert(key.into(), self.num(v, &format!("transformer {key}")));
1790            }
1791        }
1792    }
1793
1794    fn warn_nonuniform_per_phase_taps(&mut self, t: &DistTransformer) {
1795        let Some(tm_set) = t.extras.get("pmd_tm_set").and_then(Value::as_array) else {
1796            return;
1797        };
1798        for (idx, raw) in tm_set.iter().enumerate() {
1799            let Some(taps) = tap_values(raw) else {
1800                continue;
1801            };
1802            let Some(first) = taps.first().copied() else {
1803                continue;
1804            };
1805            if taps.iter().any(|tap| (tap - first).abs() > 1e-9) {
1806                let mut details = Map::new();
1807                details.insert("winding".into(), json!(idx + 1));
1808                details.insert("source_taps".into(), json!(taps));
1809                details.insert("emitted_winding_tap".into(), json!(first));
1810                self.transformer_diagnostic(
1811                    t,
1812                    "EMIT.BMOPF.TRANSFORMER_PER_PHASE_TAP_COLLAPSED",
1813                    format!(
1814                        "transformer {}: winding {} has non-uniform per phase taps; emitted the first phase tap",
1815                        t.name,
1816                        idx + 1
1817                    ),
1818                    details,
1819                );
1820            }
1821        }
1822    }
1823
1824    fn transformer_neutral_fields(
1825        &mut self,
1826        o: &mut Map<String, Value>,
1827        t: &DistTransformer,
1828        from: &Winding,
1829        to: &Winding,
1830    ) {
1831        self.transformer_neutral_field(o, t, "r_neutral_from", from.r_neutral);
1832        self.transformer_neutral_field(o, t, "x_neutral_from", from.x_neutral);
1833        self.transformer_neutral_field(o, t, "r_neutral_to", to.r_neutral);
1834        self.transformer_neutral_field(o, t, "x_neutral_to", to.x_neutral);
1835    }
1836
1837    fn transformer_neutral_field(
1838        &mut self,
1839        o: &mut Map<String, Value>,
1840        t: &DistTransformer,
1841        key: &str,
1842        value: Option<f64>,
1843    ) {
1844        let Some(v) = value else {
1845            return;
1846        };
1847        if v.is_finite() && v >= 0.0 {
1848            o.insert(key.into(), json!(v));
1849        } else {
1850            let mut details = Map::new();
1851            details.insert("field".into(), json!(key));
1852            details.insert("value".into(), json!(v));
1853            self.transformer_diagnostic(
1854                t,
1855                "EMIT.BMOPF.TRANSFORMER_NEUTRAL_DROPPED",
1856                format!(
1857                    "transformer {}: {key}={v} is not a nonnegative finite BMOPF neutral impedance; dropped",
1858                    t.name
1859                ),
1860                details,
1861            );
1862        }
1863    }
1864
1865    fn center_tap_neutral(
1866        &mut self,
1867        t: &DistTransformer,
1868        field: &str,
1869        a: Option<f64>,
1870        b: Option<f64>,
1871    ) -> Option<f64> {
1872        if let (Some(a), Some(b)) = (a, b) {
1873            let mut details = Map::new();
1874            details.insert("field".into(), json!(field));
1875            details.insert("values".into(), json!([a, b]));
1876            self.transformer_diagnostic(
1877                t,
1878                "EMIT.BMOPF.TRANSFORMER_CENTER_TAP_NEUTRAL_COLLAPSED",
1879                format!(
1880                    "transformer {}: center tap secondary has two {field} values ({a}, {b}); emitted the first",
1881                    t.name
1882                ),
1883                details,
1884            );
1885        }
1886        a.or(b)
1887    }
1888
1889    fn warn_unrepresented_neutral_fields(&mut self, t: &DistTransformer, target: &str) {
1890        for (idx, w) in t.windings.iter().enumerate() {
1891            if w.r_neutral.is_some() || w.x_neutral.is_some() {
1892                let mut details = Map::new();
1893                details.insert("target".into(), json!(target));
1894                details.insert("winding".into(), json!(idx + 1));
1895                self.transformer_diagnostic(
1896                    t,
1897                    "EMIT.BMOPF.TRANSFORMER_NEUTRAL_DROPPED",
1898                    format!(
1899                        "transformer {} winding {}: neutral impedance has no {target} field; dropped",
1900                        t.name,
1901                        idx + 1
1902                    ),
1903                    details,
1904                );
1905            }
1906        }
1907    }
1908
1909    fn transformer_no_load_fields(
1910        &mut self,
1911        o: &mut Map<String, Value>,
1912        t: &DistTransformer,
1913        from: &Winding,
1914        s: f64,
1915    ) {
1916        if let Some(v) = t.extras.get("g_no_load") {
1917            o.insert("g_no_load".into(), v.clone());
1918        } else if let Some(loss_pct) = extras_number(&t.extras, "%noloadloss") {
1919            if self.is_phase_to_phase_single_phase(from) {
1920                let mut details = Map::new();
1921                details.insert("field".into(), json!("%noloadloss"));
1922                details.insert("reason".into(), json!("phase_to_phase_single_phase"));
1923                self.transformer_diagnostic(
1924                    t,
1925                    "EMIT.BMOPF.TRANSFORMER_NO_LOAD_SHUNT_DROPPED",
1926                    format!(
1927                        "transformer {}: phase-to-phase %noloadloss cannot be represented as a BMOPF no-load shunt; dropped",
1928                        t.name
1929                    ),
1930                    details,
1931                );
1932            } else {
1933                let v_stamp = no_load_voltage_base(from);
1934                if s.is_finite() && s > 0.0 && v_stamp.is_finite() && v_stamp > 0.0 {
1935                    let y_base = s / (v_stamp * v_stamp);
1936                    o.insert(
1937                        "g_no_load".into(),
1938                        self.num(loss_pct / 100.0 * y_base, "transformer g_no_load"),
1939                    );
1940                } else {
1941                    let mut details = Map::new();
1942                    details.insert("field".into(), json!("%noloadloss"));
1943                    details.insert("s_rating".into(), json!(s));
1944                    details.insert("v_nom_from".into(), json!(v_stamp));
1945                    self.transformer_diagnostic(
1946                        t,
1947                        "EMIT.BMOPF.TRANSFORMER_NO_LOAD_SHUNT_UNCONVERTIBLE",
1948                        format!(
1949                            "transformer {}: %noloadloss cannot be converted without a positive s_rating and v_nom_from",
1950                            t.name
1951                        ),
1952                        details,
1953                    );
1954                }
1955            }
1956        }
1957
1958        if let Some(v) = t.extras.get("b_no_load") {
1959            o.insert("b_no_load".into(), v.clone());
1960        } else if let Some(imag_pct) = extras_number(&t.extras, "%imag") {
1961            if self.is_phase_to_phase_single_phase(from) {
1962                let mut details = Map::new();
1963                details.insert("field".into(), json!("%imag"));
1964                details.insert("reason".into(), json!("phase_to_phase_single_phase"));
1965                self.transformer_diagnostic(
1966                    t,
1967                    "EMIT.BMOPF.TRANSFORMER_NO_LOAD_SHUNT_DROPPED",
1968                    format!(
1969                        "transformer {}: phase-to-phase %imag cannot be represented as a BMOPF no-load shunt; dropped",
1970                        t.name
1971                    ),
1972                    details,
1973                );
1974            } else {
1975                let v_stamp = no_load_voltage_base(from);
1976                if s.is_finite() && s > 0.0 && v_stamp.is_finite() && v_stamp > 0.0 {
1977                    let y_base = s / (v_stamp * v_stamp);
1978                    o.insert(
1979                        "b_no_load".into(),
1980                        self.num(imag_pct / 100.0 * y_base, "transformer b_no_load"),
1981                    );
1982                } else {
1983                    let mut details = Map::new();
1984                    details.insert("field".into(), json!("%imag"));
1985                    details.insert("s_rating".into(), json!(s));
1986                    details.insert("v_nom_from".into(), json!(v_stamp));
1987                    self.transformer_diagnostic(
1988                        t,
1989                        "EMIT.BMOPF.TRANSFORMER_NO_LOAD_SHUNT_UNCONVERTIBLE",
1990                        format!(
1991                            "transformer {}: %imag cannot be converted without a positive s_rating and v_nom_from",
1992                            t.name
1993                        ),
1994                        details,
1995                    );
1996                }
1997            }
1998        } else if !self.is_phase_to_phase_single_phase(from)
1999            && extras_number(&t.extras, "%noloadloss").is_some()
2000        {
2001            o.insert("b_no_load".into(), json!(0.0));
2002        }
2003    }
2004
2005    fn is_phase_to_phase_single_phase(&self, winding: &Winding) -> bool {
2006        n_winding_phase_count(winding) == 1
2007            && !self
2008                .grounded
2009                .get(&winding.bus.to_ascii_lowercase())
2010                .is_some_and(|g| winding.terminal_map.iter().any(|t| g.contains(t)))
2011    }
2012
2013    fn transformer_extras_dropped(&mut self, t: &DistTransformer, allowed: &[&str]) {
2014        for key in t.extras.keys() {
2015            if key == "bmopf_subtype" || key == "tap" || allowed.contains(&key.as_str()) {
2016                continue;
2017            }
2018            let mut details = Map::new();
2019            details.insert("field".into(), json!(key));
2020            self.transformer_diagnostic(
2021                t,
2022                "EMIT.BMOPF.TRANSFORMER_EXTRA_DROPPED",
2023                format!(
2024                    "transformer {}: `{key}` has no place in the BMOPF schema; dropped from the output",
2025                    t.name
2026                ),
2027                details,
2028            );
2029        }
2030    }
2031
2032    /// Emits a matrix whose `_1_1` entry the schema requires; an empty one
2033    /// becomes `dim` by `dim` zeros so the required key exists. `dim` derives
2034    /// from a sibling matrix's row count, which model input controls, so the
2035    /// dense zero fill is capped at [`MAX_DIM`].
2036    fn required_matrix(
2037        &mut self,
2038        o: &mut Map<String, Value>,
2039        prefix: &str,
2040        m: &Mat,
2041        dim: usize,
2042        name: &str,
2043    ) {
2044        if m.is_empty() {
2045            let dim = if dim > MAX_DIM {
2046                self.warn(format!(
2047                    "{name}: {prefix} dimension {dim} exceeds the supported \
2048                     maximum of {MAX_DIM}; zero matrix clamped"
2049                ));
2050                MAX_DIM
2051            } else {
2052                dim
2053            };
2054            self.flat_matrix(o, prefix, &vec![vec![0.0; dim]; dim], name);
2055        } else {
2056            self.flat_matrix(o, prefix, m, name);
2057        }
2058    }
2059
2060    fn flat_matrix(&mut self, o: &mut Map<String, Value>, prefix: &str, m: &Mat, name: &str) {
2061        for (i, row) in m.iter().enumerate() {
2062            for (j, &v) in row.iter().enumerate() {
2063                o.insert(
2064                    format!("{prefix}_{}_{}", i + 1, j + 1),
2065                    self.num(v, &format!("{name} {prefix}")),
2066                );
2067            }
2068        }
2069    }
2070}
2071
2072fn collect_bus_usage(value: &Value, refs: &mut BTreeMap<String, BTreeSet<String>>) {
2073    match value {
2074        Value::Object(o) => {
2075            add_bus_usage(o, refs, "bus", "terminal_map");
2076            add_bus_usage(o, refs, "bus_from", "terminal_map_from");
2077            add_bus_usage(o, refs, "bus_to", "terminal_map_to");
2078            for value in o.values() {
2079                collect_bus_usage(value, refs);
2080            }
2081        }
2082        Value::Array(values) => {
2083            for value in values {
2084                collect_bus_usage(value, refs);
2085            }
2086        }
2087        _ => {}
2088    }
2089}
2090
2091fn add_bus_usage(
2092    o: &Map<String, Value>,
2093    refs: &mut BTreeMap<String, BTreeSet<String>>,
2094    bus_key: &str,
2095    map_key: &str,
2096) {
2097    let Some(id) = o.get(bus_key).and_then(Value::as_str) else {
2098        return;
2099    };
2100    let entry = refs.entry(id.to_string()).or_default();
2101    if let Some(terms) = o.get(map_key).and_then(Value::as_array) {
2102        entry.extend(terms.iter().filter_map(Value::as_str).map(str::to_string));
2103    }
2104}
2105
2106/// Drop the entries of a string array that no emitted element names. A name in
2107/// `used` or in `also` is kept; `also` carries the few names this bus keeps for
2108/// a reason of its own, so the caller does not have to merge them into `used`.
2109fn prune_string_array(
2110    o: &mut Map<String, Value>,
2111    key: &str,
2112    used: &BTreeSet<String>,
2113    also: &BTreeSet<String>,
2114    warnings: &mut Vec<String>,
2115    what: &str,
2116) {
2117    let Some(Value::Array(values)) = o.get_mut(key) else {
2118        return;
2119    };
2120    let old = std::mem::take(values);
2121    let mut kept = Vec::new();
2122    let mut dropped = Vec::new();
2123    for value in old {
2124        if value
2125            .as_str()
2126            .is_some_and(|s| used.contains(s) || also.contains(s))
2127        {
2128            kept.push(value);
2129        } else {
2130            dropped.push(value);
2131        }
2132    }
2133    if !dropped.is_empty() {
2134        let names: Vec<String> = dropped
2135            .iter()
2136            .filter_map(Value::as_str)
2137            .map(str::to_string)
2138            .collect();
2139        warnings.push(format!(
2140            "{what}: `{key}` entries {names:?} are not referenced by emitted BMOPF elements; dropped from the output"
2141        ));
2142    }
2143    *values = kept;
2144}
2145
2146#[derive(Clone)]
2147struct SourceEmit {
2148    name: String,
2149    bus: String,
2150    terminal_map: Vec<String>,
2151    v_magnitude: Vec<f64>,
2152    v_angle: Vec<f64>,
2153    extras: Extras,
2154    dropped_extras: Vec<(String, Extras)>,
2155}
2156
2157impl From<&VoltageSource> for SourceEmit {
2158    fn from(source: &VoltageSource) -> Self {
2159        Self {
2160            name: source.name.clone(),
2161            bus: source.bus.clone(),
2162            terminal_map: source.terminal_map.clone(),
2163            v_magnitude: source.v_magnitude.clone(),
2164            v_angle: source.v_angle.clone(),
2165            extras: source.extras.clone(),
2166            dropped_extras: Vec::new(),
2167        }
2168    }
2169}
2170
2171#[derive(Clone)]
2172struct PhaseSource {
2173    label: String,
2174    magnitude: f64,
2175    angle: f64,
2176    neutral: Option<String>,
2177}
2178
2179#[derive(Clone, Copy, PartialEq, Eq)]
2180enum PhaseArrangement {
2181    Zero,
2182    Quadrature,
2183    Positive,
2184    Negative,
2185    AntiPhase,
2186    Incoherent,
2187}
2188
2189fn bmopf_voltage_sources(net: &DistNetwork) -> Vec<SourceEmit> {
2190    let emitted: Vec<SourceEmit> = net.sources.iter().map(SourceEmit::from).collect();
2191    let bus_ids: BTreeMap<String, String> = net
2192        .buses
2193        .iter()
2194        .map(|bus| (bus.id.to_ascii_lowercase(), bus.id.clone()))
2195        .collect();
2196    let mut by_bus: BTreeMap<String, Vec<usize>> = BTreeMap::new();
2197    for (i, source) in emitted.iter().enumerate() {
2198        by_bus
2199            .entry(source.bus.to_ascii_lowercase())
2200            .or_default()
2201            .push(i);
2202    }
2203
2204    let mut replacements = BTreeMap::new();
2205    let mut removed = BTreeSet::new();
2206    for (bus_key, indices) in by_bus.iter().filter(|(_, indices)| indices.len() > 1) {
2207        if let Some((keep, merged)) =
2208            merge_voltage_source_group(&emitted, indices, bus_ids.get(bus_key))
2209        {
2210            replacements.insert(keep, merged);
2211            removed.extend(indices.iter().copied().filter(|i| *i != keep));
2212        }
2213    }
2214
2215    emitted
2216        .into_iter()
2217        .enumerate()
2218        .filter_map(|(i, source)| {
2219            if removed.contains(&i) {
2220                None
2221            } else {
2222                Some(replacements.remove(&i).unwrap_or(source))
2223            }
2224        })
2225        .collect()
2226}
2227
2228fn merge_voltage_source_group(
2229    sources: &[SourceEmit],
2230    indices: &[usize],
2231    bus_id: Option<&String>,
2232) -> Option<(usize, SourceEmit)> {
2233    let mut sorted = indices.to_vec();
2234    sorted.sort_by(|a, b| sources[*a].name.cmp(&sources[*b].name));
2235
2236    let mut phases = Vec::new();
2237    let mut seen = BTreeSet::new();
2238    for index in &sorted {
2239        let source = &sources[*index];
2240        if source_has_bounds_or_cost(&source.extras) {
2241            return None;
2242        }
2243        let (rank, phase) = single_phase_source(source)?;
2244        if !seen.insert(rank) {
2245            return None;
2246        }
2247        phases.push((rank, phase));
2248    }
2249
2250    if phases.len() < 2 {
2251        return None;
2252    }
2253    phases.sort_by_key(|(rank, _)| *rank);
2254
2255    let neutral = phases.first()?.1.neutral.clone();
2256    if phases.iter().any(|(_, phase)| phase.neutral != neutral) {
2257        return None;
2258    }
2259
2260    match phase_arrangement(phases.iter().map(|(_, phase)| phase.angle)) {
2261        PhaseArrangement::Zero | PhaseArrangement::Positive | PhaseArrangement::Negative => {}
2262        PhaseArrangement::Quadrature
2263        | PhaseArrangement::AntiPhase
2264        | PhaseArrangement::Incoherent => {
2265            return None;
2266        }
2267    }
2268
2269    let keep = sorted
2270        .iter()
2271        .copied()
2272        .find(|i| sources[*i].name == "source")
2273        .unwrap_or(sorted[0]);
2274    let mut merged = sources[keep].clone();
2275    if let Some(bus_id) = bus_id {
2276        merged.bus.clone_from(bus_id);
2277    }
2278    merged.terminal_map = phases
2279        .iter()
2280        .map(|(_, phase)| phase.label.clone())
2281        .collect();
2282    merged.v_magnitude = phases.iter().map(|(_, phase)| phase.magnitude).collect();
2283    merged.v_angle = phases.iter().map(|(_, phase)| phase.angle).collect();
2284    if let Some(neutral) = neutral {
2285        merged.terminal_map.push(neutral);
2286        merged.v_magnitude.push(0.0);
2287        merged.v_angle.push(0.0);
2288    }
2289    merged.dropped_extras = sorted
2290        .iter()
2291        .copied()
2292        .filter(|i| *i != keep)
2293        .map(|i| (sources[i].name.clone(), sources[i].extras.clone()))
2294        .collect();
2295    Some((keep, merged))
2296}
2297
2298fn source_has_bounds_or_cost(extras: &Extras) -> bool {
2299    ["p_min", "p_max", "q_min", "q_max", "cost"]
2300        .iter()
2301        .any(|key| extras.contains_key(*key))
2302}
2303
2304fn single_phase_source(source: &SourceEmit) -> Option<(usize, PhaseSource)> {
2305    let mut phase = None;
2306    let mut neutral = None;
2307    for (i, label) in source.terminal_map.iter().enumerate() {
2308        if let Some(rank) = phase_rank(label) {
2309            if phase.replace((rank, label.clone(), i)).is_some() {
2310                return None;
2311            }
2312        } else if neutral.replace(label.clone()).is_some() {
2313            return None;
2314        }
2315    }
2316    let (rank, label, index) = phase?;
2317    Some((
2318        rank,
2319        PhaseSource {
2320            label,
2321            magnitude: *source.v_magnitude.get(index)?,
2322            angle: *source.v_angle.get(index)?,
2323            neutral,
2324        },
2325    ))
2326}
2327
2328fn phase_rank(label: &str) -> Option<usize> {
2329    match label {
2330        "1" | "a" | "A" => Some(0),
2331        "2" | "b" | "B" => Some(1),
2332        "3" | "c" | "C" => Some(2),
2333        _ => None,
2334    }
2335}
2336
2337fn phase_arrangement(angles: impl IntoIterator<Item = f64>) -> PhaseArrangement {
2338    let angles: Vec<f64> = angles.into_iter().collect();
2339    if angles.len() < 2 {
2340        return PhaseArrangement::Incoherent;
2341    }
2342    if angles.len() == 2 {
2343        return separation_of_diff(angles[0] - angles[1]);
2344    }
2345    let mut arrangement = None;
2346    for i in 0..angles.len() {
2347        let next = (i + 1) % angles.len();
2348        let current = separation_of_diff(angles[i] - angles[next]);
2349        if current == PhaseArrangement::Incoherent {
2350            return PhaseArrangement::Incoherent;
2351        }
2352        if let Some(previous) = arrangement {
2353            if previous != current {
2354                return PhaseArrangement::Incoherent;
2355            }
2356        } else {
2357            arrangement = Some(current);
2358        }
2359    }
2360    arrangement.unwrap_or(PhaseArrangement::Incoherent)
2361}
2362
2363fn separation_of_diff(diff: f64) -> PhaseArrangement {
2364    let diff = wrap_pi(diff);
2365    let adiff = diff.abs();
2366    if adiff <= PI / 6.0 {
2367        PhaseArrangement::Zero
2368    } else if (adiff - FRAC_PI_2).abs() <= PI / 12.0 {
2369        PhaseArrangement::Quadrature
2370    } else if (adiff - TAU / 3.0).abs() <= PI / 6.0 {
2371        if diff > 0.0 {
2372            PhaseArrangement::Positive
2373        } else {
2374            PhaseArrangement::Negative
2375        }
2376    } else if (adiff - PI).abs() <= PI / 12.0 {
2377        PhaseArrangement::AntiPhase
2378    } else {
2379        PhaseArrangement::Incoherent
2380    }
2381}
2382
2383fn wrap_pi(angle: f64) -> f64 {
2384    let wrapped = (angle + PI).rem_euclid(TAU) - PI;
2385    if (wrapped + PI).abs() < f64::EPSILON {
2386        PI
2387    } else {
2388        wrapped
2389    }
2390}
2391
2392enum Kind {
2393    SinglePhase,
2394    /// Two windings already in the shared single_phase/center_tap shape,
2395    /// emitted under the named subtype.
2396    SinglePhaseShape(&'static str),
2397    CenterTap,
2398    WyeDelta,
2399    DeltaWye,
2400    WyeWye3,
2401    NWinding,
2402    Unsupported(String),
2403}
2404
2405fn classify(t: &DistTransformer) -> Kind {
2406    // A network read from BMOPF records its subtype; trust it so writing
2407    // back reproduces the grouping (center tap reads as two windings).
2408    // An unknown or shape mismatched subtype falls through to the shape
2409    // based classification below.
2410    if let Some(sub) = t.extras.get("bmopf_subtype").and_then(|v| v.as_str()) {
2411        if t.windings.len() == 2 {
2412            match sub {
2413                "single_phase" => return Kind::SinglePhase,
2414                "center_tap" => return Kind::SinglePhaseShape("center_tap"),
2415                "wye_delta" => return Kind::WyeDelta,
2416                "delta_wye" => return Kind::DeltaWye,
2417                _ => {}
2418            }
2419        }
2420        if sub == "n_winding" && t.windings.len() >= 2 {
2421            return Kind::NWinding;
2422        }
2423    }
2424    let conns: Vec<WindingConn> = t.windings.iter().map(|w| w.conn).collect();
2425    match (t.phases, conns.as_slice()) {
2426        // single_phase covers the plain 1-phase wye-wye unit and both open
2427        // wye / open delta leg orientations (one delta winding wired line to
2428        // line). The single_phase shape holds the delta side: it carries two
2429        // phase terminals, no conn discriminator, and its line to line v_ref
2430        // makes the per winding impedance base v^2/s already right. The
2431        // pattern reads as the three pairs wye-wye, delta-wye, wye-delta.
2432        (
2433            1,
2434            [WindingConn::Wye | WindingConn::Delta, WindingConn::Wye]
2435            | [WindingConn::Wye, WindingConn::Delta],
2436        ) => Kind::SinglePhase,
2437        (1, [WindingConn::Wye, WindingConn::Wye, WindingConn::Wye]) => Kind::CenterTap,
2438        (3, [WindingConn::Wye, WindingConn::Delta]) => Kind::WyeDelta,
2439        (3, [WindingConn::Delta, WindingConn::Wye]) => Kind::DeltaWye,
2440        // The decomposition indexes terminal_map[phase] and takes the last
2441        // entry as the neutral; anything else is not safely decomposable.
2442        (3, [WindingConn::Wye, WindingConn::Wye])
2443            if t.windings
2444                .iter()
2445                .all(|w| w.terminal_map.len() == t.phases + 1) =>
2446        {
2447            Kind::WyeWye3
2448        }
2449        (3, [WindingConn::Wye, WindingConn::Wye]) => Kind::Unsupported(
2450            "three phase wye-wye whose terminal maps do not list each phase plus a neutral".into(),
2451        ),
2452        (_, _) if t.windings.len() >= 3 => Kind::NWinding,
2453        _ => Kind::Unsupported(format!(
2454            "{} phase with {} windings ({:?})",
2455            t.phases,
2456            t.windings.len(),
2457            conns
2458        )),
2459    }
2460}
2461
2462/// The re-emitted form of an untyped object.
2463///
2464/// A BMOPF sourced object rides as one unkeyed blob, which is the JSON the
2465/// document declared. A dss sourced object rides as key/value property
2466/// pairs, which rebuild into an object; without that arm every dss sourced
2467/// untyped object failed the JSON parse and dropped.
2468/// Rebuild the value of an untyped object. One unnamed property alone is the
2469/// whole object as JSON text. Otherwise each named property is one field.
2470///
2471/// An unnamed property beside named ones has no field name, so this writer
2472/// cannot place it. It goes to `unplaced` for the caller to report, and the
2473/// named fields still reach the output; dropping the whole object over one
2474/// positional token would lose every field beside it.
2475fn raw_bmopf_value(u: &crate::model::UntypedObject, unplaced: &mut Vec<String>) -> Option<Value> {
2476    if let [(None, text)] = u.props.as_slice() {
2477        return serde_json::from_str(text).ok();
2478    }
2479    let mut o = Map::new();
2480    for (key, text) in &u.props {
2481        let Some(key) = key.as_ref() else {
2482            unplaced.push(text.clone());
2483            continue;
2484        };
2485        let value = serde_json::from_str(text).unwrap_or_else(|_| Value::String(text.clone()));
2486        o.insert(key.clone(), value);
2487    }
2488    (!o.is_empty()).then_some(Value::Object(o))
2489}
2490
2491fn extras_number(extras: &crate::model::Extras, key: &str) -> Option<f64> {
2492    let v = extras.get(key)?;
2493    v.as_f64()
2494        .or_else(|| v.as_i64().map(|v| v as f64))
2495        .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
2496        .filter(|v| v.is_finite())
2497}
2498
2499fn tap_values(v: &Value) -> Option<Vec<f64>> {
2500    if let Some(items) = v.as_array() {
2501        let out: Vec<f64> = items.iter().filter_map(value_number).collect();
2502        Some(out)
2503    } else {
2504        value_number(v).map(|tap| vec![tap])
2505    }
2506}
2507
2508fn value_number(v: &Value) -> Option<f64> {
2509    v.as_f64()
2510        .or_else(|| v.as_i64().map(|v| v as f64))
2511        .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
2512        .filter(|v| v.is_finite())
2513}
2514
2515fn split_no_load_extras(t: &mut DistTransformer, phases: usize) {
2516    let phases = phases.max(1) as f64;
2517    for key in ["g_no_load", "b_no_load"] {
2518        if let Some(v) = extras_number(&t.extras, key) {
2519            t.extras.insert(key.into(), json!(v / phases));
2520        }
2521    }
2522}
2523
2524fn center_tap_common_terminal(w2: &Winding, w3: &Winding) -> String {
2525    w2.terminal_map
2526        .iter()
2527        .find(|term| w3.terminal_map.contains(term))
2528        .cloned()
2529        .unwrap_or_default()
2530}
2531
2532fn center_tap_to_winding(
2533    w2: &Winding,
2534    w3: &Winding,
2535    common: &str,
2536    s_rating: f64,
2537    r_neutral: Option<f64>,
2538    x_neutral: Option<f64>,
2539) -> Winding {
2540    let terminal_map = center_tap_terminal_map(w2, w3, common);
2541    Winding {
2542        bus: w2.bus.clone(),
2543        terminal_map,
2544        conn: WindingConn::Wye,
2545        v_ref: w2.v_ref,
2546        s_rating,
2547        r_pct: w2.r_pct,
2548        tap: w2.tap,
2549        r_neutral,
2550        x_neutral,
2551    }
2552}
2553
2554fn center_tap_terminal_map(w2: &Winding, w3: &Winding, common: &str) -> Vec<String> {
2555    let mut hots: Vec<String> = Vec::new();
2556    for term in w2.terminal_map.iter().chain(&w3.terminal_map) {
2557        if term != common && !hots.contains(term) {
2558            hots.push(term.clone());
2559        }
2560    }
2561    let first = hots.first().cloned().unwrap_or_default();
2562    let second = hots.get(1).cloned().unwrap_or_default();
2563    vec![first, common.to_string(), second]
2564}
2565
2566fn center_tap_star_percentages(xsc_pct: &[f64]) -> (f64, f64) {
2567    let xhl = xsc_pct.first().copied().unwrap_or(0.0);
2568    let xht = xsc_pct.get(1).copied().unwrap_or(xhl);
2569    let xlt = xsc_pct.get(2).copied().unwrap_or(0.0);
2570    ((xhl + xht - xlt) / 2.0, (xhl + xlt - xht) / 2.0)
2571}
2572
2573fn winding_base(w: &Winding) -> Option<f64> {
2574    base_impedance(w.v_ref, w.s_rating)
2575}
2576
2577/// Base impedance `v^2 / s` in ohms, or None when the rating gives the
2578/// percent quantities no base. Dividing by a rating that is not positive
2579/// yields an infinity, and `num` then emits that infinity as a zero: a
2580/// zero-resistance and, worse, a zero-reactance transformer reads as a short
2581/// circuit. The schema leaves every series impedance field optional, so the
2582/// caller drops the field instead, and an absent field reads as unknown.
2583fn base_impedance(v_ref: f64, s: f64) -> Option<f64> {
2584    (s > 0.0 && s.is_finite()).then(|| v_ref * v_ref / s)
2585}
2586
2587fn n_winding_phase_count(w: &Winding) -> usize {
2588    crate::model::n_winding_phase_count(w.conn, &w.terminal_map)
2589}
2590
2591fn n_winding_bmopf_v_nom(w: &Winding) -> f64 {
2592    if w.conn == WindingConn::Wye && n_winding_phase_count(w) >= 2 {
2593        w.v_ref / 3f64.sqrt()
2594    } else {
2595        w.v_ref
2596    }
2597}
2598
2599fn n_winding_base(w: &Winding, s: f64) -> Option<f64> {
2600    n_winding_impedance_base(n_winding_phase_count(w), n_winding_bmopf_v_nom(w), s)
2601}
2602
2603fn bmopf_delta_roll(t: &DistTransformer, idx: usize, w: &Winding) -> Option<i64> {
2604    if w.conn != WindingConn::Delta {
2605        return None;
2606    }
2607    t.extras
2608        .get(BMOPF_DELTA_ROLLS_EXTRA)
2609        .and_then(Value::as_object)
2610        .and_then(|rolls| rolls.get(&(idx + 1).to_string()))
2611        .and_then(Value::as_i64)
2612        .filter(|roll| *roll == 1 || *roll == -1)
2613        .or(Some(-1))
2614}
2615
2616/// The phase and neutral label lists, from the bus terminal names. The rule
2617/// is the one the schema prescribes for an absent `terminal_conventions`
2618/// block: an `n` or `N` label is neutral, every other label is a phase.
2619/// Labels keep first-seen order. A network with no bus terminal gives `None`.
2620fn authored_terminal_conventions(net: &DistNetwork) -> Option<Value> {
2621    let mut phase: Vec<&String> = Vec::new();
2622    let mut neutral: Vec<&String> = Vec::new();
2623    for b in &net.buses {
2624        for term in &b.terminals {
2625            let labels = if term.eq_ignore_ascii_case("n") {
2626                &mut neutral
2627            } else {
2628                &mut phase
2629            };
2630            // Bucketing is case insensitive, so the dedup has to be too:
2631            // `N` and `n` are one label, and emitting both would tell a
2632            // consumer the network has two neutrals.
2633            if !labels.iter().any(|l| l.eq_ignore_ascii_case(term)) {
2634                labels.push(term);
2635            }
2636        }
2637    }
2638    (!phase.is_empty() || !neutral.is_empty()).then(|| json!({"phase": phase, "neutral": neutral}))
2639}
2640
2641fn no_load_voltage_base(from: &Winding) -> f64 {
2642    let phases = match from.conn {
2643        WindingConn::Wye => from.terminal_map.len().saturating_sub(1),
2644        WindingConn::Delta => from.terminal_map.len(),
2645    };
2646    if phases >= 3 {
2647        from.v_ref / 3f64.sqrt()
2648    } else {
2649        from.v_ref
2650    }
2651}
2652
2653fn config_str(c: Configuration) -> &'static str {
2654    match c {
2655        Configuration::Wye => "WYE",
2656        Configuration::Delta => "DELTA",
2657        Configuration::SinglePhase => "SINGLE_PHASE",
2658    }
2659}
2660
2661fn json_enum<T: serde::Serialize>(value: T) -> Value {
2662    serde_json::to_value(value).expect("enum serializes to a string")
2663}
2664
2665#[cfg(test)]
2666mod tests {
2667    use super::*;
2668    use crate::bmopf::parse_bmopf_str;
2669    use crate::model::DistLoadVoltageModel;
2670
2671    #[test]
2672    fn load_voltage_models_round_trip_through_bmopf() {
2673        let text = r#"{
2674            "bus": {
2675                "b1": {"terminal_names": ["1", "2", "3", "4"], "perfectly_grounded_terminals": ["4"]}
2676            },
2677            "voltage_source": {
2678                "source": {
2679                    "bus": "b1", "terminal_map": ["1", "2", "3", "4"],
2680                    "v_magnitude": [7200.0, 7200.0, 7200.0, 0.0],
2681                    "v_angle": [0.0, -120.0, 120.0, 0.0]
2682                }
2683            },
2684            "load": {
2685                "zip": {
2686                    "bus": "b1", "terminal_map": ["1", "2", "3", "4"],
2687                    "configuration": "WYE", "p_nom": [1.0, 2.0, 3.0], "q_nom": [0.1, 0.2, 0.3],
2688                    "model": "zip", "v_nom": [7200.0, 7200.0, 7200.0],
2689                    "alpha_z": [0.2, 0.2, 0.2], "alpha_i": [0.3, 0.3, 0.3], "alpha_p": [0.5, 0.5, 0.5],
2690                    "beta_z": [0.1, 0.1, 0.1], "beta_i": [0.4, 0.4, 0.4], "beta_p": [0.5, 0.5, 0.5]
2691                },
2692                "exp": {
2693                    "bus": "b1", "terminal_map": ["1", "2", "3", "4"],
2694                    "configuration": "WYE", "p_nom": [1.0, 1.0, 1.0], "q_nom": [0.0, 0.0, 0.0],
2695                    "model": "exponential", "v_nom": [7200.0, 7200.0, 7200.0],
2696                    "gamma_p": [1.2, 1.2, 1.2], "gamma_q": [2.1, 2.1, 2.1]
2697                }
2698            }
2699        }"#;
2700        let net = parse_bmopf_str(text).unwrap();
2701        let zip = net.loads.iter().find(|l| l.name == "zip").unwrap();
2702        let exp = net.loads.iter().find(|l| l.name == "exp").unwrap();
2703        assert!(matches!(
2704            &zip.voltage_model,
2705            DistLoadVoltageModel::Zip { alpha_z, .. } if alpha_z == &vec![0.2, 0.2, 0.2]
2706        ));
2707        assert!(matches!(
2708            &exp.voltage_model,
2709            DistLoadVoltageModel::Exponential { gamma_q, .. } if gamma_q == &vec![2.1, 2.1, 2.1]
2710        ));
2711
2712        let out = write_bmopf_json(&net);
2713        assert!(out.warnings.is_empty(), "{:?}", out.warnings);
2714        let v: Value = serde_json::from_str(&out.text).unwrap();
2715        assert_eq!(
2716            v["load"]["zip"]["alpha_i"],
2717            serde_json::json!([0.3, 0.3, 0.3])
2718        );
2719        assert_eq!(
2720            v["load"]["exp"]["gamma_p"],
2721            serde_json::json!([1.2, 1.2, 1.2])
2722        );
2723    }
2724
2725    /// A model built without the reader caps (the model JSON C entry point
2726    /// deserializes one unchecked) must not force quadratic allocation out of
2727    /// linear-size input.
2728    #[test]
2729    fn oversized_model_dimensions_are_clamped_not_expanded() {
2730        use crate::model::{DistLineCode, DistShunt, DistTransformer, Winding, WindingConn};
2731
2732        let mut net = crate::model::DistNetwork::default();
2733        // A linecode whose R rows imply a huge dimension while X is empty
2734        // would materialize dim x dim zeros for X.
2735        let mut lc = DistLineCode::new("big", Vec::new(), Vec::new());
2736        lc.r_series = vec![Vec::new(); 100_000];
2737        net.linecodes.push(lc);
2738        // Same shape for a shunt's G/B pair.
2739        net.shunts.push(DistShunt::new(
2740            "big",
2741            "b",
2742            Vec::new(),
2743            vec![Vec::new(); 100_000],
2744            Vec::new(),
2745        ));
2746        // A winding count beyond the cap would expand to ~n²/2 x_sc pairs.
2747        let winding = Winding::new("b", Vec::new(), WindingConn::Wye, 1.0, 1.0);
2748        net.transformers.push(DistTransformer::new(
2749            "many",
2750            vec![winding; MAX_DIM + 6],
2751            Vec::new(),
2752            3,
2753        ));
2754
2755        let out = write_bmopf_json(&net);
2756        let v: Value = serde_json::from_str(&out.text).unwrap();
2757        let count_keys = |m: &Value, prefix: &str| {
2758            m.as_object()
2759                .unwrap()
2760                .keys()
2761                .filter(|k| k.starts_with(prefix))
2762                .count()
2763        };
2764        assert_eq!(
2765            count_keys(&v["linecode"]["big"], "X_series_"),
2766            MAX_DIM * MAX_DIM
2767        );
2768        assert_eq!(count_keys(&v["shunt"]["big"], "B_"), MAX_DIM * MAX_DIM);
2769        assert_eq!(
2770            v["transformer"]["n_winding"]["many"]["x_sc"]
2771                .as_object()
2772                .unwrap()
2773                .len(),
2774            MAX_DIM * (MAX_DIM - 1) / 2
2775        );
2776        assert!(
2777            out.warnings
2778                .iter()
2779                .any(|w| w.contains("exceeds the supported maximum")),
2780            "{:?}",
2781            out.warnings
2782        );
2783    }
2784}