Skip to main content

powerio_dist/bmopf/
write.rs

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