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