Skip to main content

powerio_dist/bmopf/
write.rs

1//! [`DistNetwork`] into strict BMOPF JSON.
2//!
3//! Output is schema valid wherever the schema permits the data.
4//!
5//! Numbers serialize through serde_json (shortest round trip form).
6//! Nonfinite values cannot appear in JSON; they emit as 0 with a warning
7//! naming the element and field.
8
9use std::collections::{BTreeMap, BTreeSet};
10
11use serde_json::{Map, Value, json};
12
13use crate::convert::Conversion;
14use crate::model::{
15    Configuration, DistGenerator, DistLoadVoltageModel, DistNetwork, DistTransformer, Mat, Winding,
16    WindingConn, n_winding_impedance_base, pair_keys,
17};
18
19/// The `$schema` stamped into every document's `meta`: the current BMOPF
20/// schema URI used by BMOPFTools.
21const BMOPF_SCHEMA_ID: &str =
22    "https://raw.githubusercontent.com/frederikgeth/bmopf-report/main/schema/bmopf.json";
23
24const RAW_BMOPF_TOP_LEVEL: &[&str] = &[
25    "capacitor",
26    "ibr",
27    "control_profile",
28    "dc_bus",
29    "dc_line",
30    "dc_load",
31    "dc_source",
32];
33
34const TRANSFORMER_NO_LOAD_ALLOWED_EXTRAS: [&str; 4] =
35    ["g_no_load", "b_no_load", "%noloadloss", "%imag"];
36const TRANSFORMER_TWO_WINDING_ALLOWED_EXTRAS: [&str; 6] = [
37    "tap_min",
38    "tap_max",
39    "g_no_load",
40    "b_no_load",
41    "%noloadloss",
42    "%imag",
43];
44
45/// Writes the strict BMOPF document. Every field the schema cannot carry
46/// is reported in the warnings.
47///
48/// # Panics
49///
50/// Never in practice: the document is maps, strings, and finite numbers,
51/// which always serialize.
52pub fn write_bmopf_json(net: &DistNetwork) -> Conversion {
53    let mut w = Writer {
54        warnings: Vec::new(),
55        grounded: net
56            .buses
57            .iter()
58            .map(|b| (b.id.to_ascii_lowercase(), b.grounded.clone()))
59            .collect(),
60    };
61    let doc = w.document(net);
62    Conversion {
63        text: serde_json::to_string_pretty(&doc).expect("maps and finite numbers") + "\n",
64        warnings: w.warnings,
65    }
66}
67
68struct Writer {
69    warnings: Vec<String>,
70    grounded: BTreeMap<String, Vec<String>>,
71}
72
73impl Writer {
74    fn warn(&mut self, msg: impl Into<String>) {
75        self.warnings.push(msg.into());
76    }
77
78    /// Finite number guard (the jnum pattern): JSON has no Inf/NaN.
79    fn num(&mut self, v: f64, what: &str) -> Value {
80        if v.is_finite() {
81            json!(v)
82        } else {
83            self.warn(format!("{what}: nonfinite value emitted as 0"));
84            json!(0.0)
85        }
86    }
87
88    fn nums(&mut self, vs: &[f64], what: &str) -> Value {
89        Value::Array(vs.iter().map(|&v| self.num(v, what)).collect())
90    }
91
92    fn extras_dropped(&mut self, extras: &crate::model::Extras, what: &str) {
93        for key in extras.keys() {
94            // `bmopf_subtype` is reader bookkeeping; `conn` marks a delta shunt
95            // whose geometry already lives in the off diagonal B matrix, so it
96            // is preserved, not dropped.
97            if key == "bmopf_subtype" || key == "conn" {
98                continue;
99            }
100            self.warn(format!(
101                "{what}: `{key}` has no place in the BMOPF schema; dropped from the output"
102            ));
103        }
104    }
105
106    /// Provenance + schema-vintage self-identification (the BMOPF `meta` object):
107    /// "generated by powerio vX, targeting BMOPF schema vintage Y." Deterministic
108    /// and round-trip stable — no timestamp, and nothing that depends on the
109    /// immediate source format (which a round trip would change) — so canonical
110    /// output is idempotent. The vintage lives in `$schema` (the canonical
111    /// bmopf-report `$id`).
112    fn meta() -> Value {
113        json!({
114            "$schema": BMOPF_SCHEMA_ID,
115            "generator": {"tool": "powerio", "version": env!("CARGO_PKG_VERSION")},
116        })
117    }
118
119    fn document(&mut self, net: &DistNetwork) -> Value {
120        let mut doc = Map::new();
121        if let Some(name) = &net.name {
122            doc.insert("name".into(), json!(name));
123        }
124        doc.insert("meta".into(), Self::meta());
125
126        let mut buses = Map::new();
127        for b in &net.buses {
128            let mut o = Map::new();
129            o.insert("terminal_names".into(), json!(b.terminals));
130            if !b.grounded.is_empty() {
131                o.insert("perfectly_grounded_terminals".into(), json!(b.grounded));
132            }
133            if let Some(v) = b.v_min {
134                o.insert("v_min".into(), Value::Array(vec![self.num(v, "bus v_min")]));
135            }
136            if let Some(v) = b.v_max {
137                o.insert("v_max".into(), Value::Array(vec![self.num(v, "bus v_max")]));
138            }
139            for (key, bound) in [
140                ("vpn_min", &b.vpn_min),
141                ("vpn_max", &b.vpn_max),
142                ("vpp_min", &b.vpp_min),
143                ("vpp_max", &b.vpp_max),
144                ("vsym_min", &b.vsym_min),
145                ("vsym_max", &b.vsym_max),
146            ] {
147                if let Some(v) = bound {
148                    o.insert(key.into(), self.nums(v, &format!("bus {key}")));
149                }
150            }
151            // Coordinates and other extras have no bus fields in the schema.
152            self.extras_dropped(&b.extras, &format!("bus {}", b.id));
153            buses.insert(b.id.clone(), Value::Object(o));
154        }
155        doc.insert("bus".into(), Value::Object(buses));
156
157        if !net.linecodes.is_empty() {
158            let mut codes = Map::new();
159            for c in &net.linecodes {
160                let mut o = Map::new();
161                // The schema requires R_series_1_1 and X_series_1_1; an
162                // empty matrix would drop them and invalidate the output.
163                let dim = c.r_series.len().max(c.x_series.len()).max(1);
164                if c.r_series.is_empty() && c.x_series.is_empty() {
165                    self.warn(format!(
166                        "linecode {}: no series matrix; emitted as 1 conductor \
167                         zero impedance",
168                        c.name
169                    ));
170                } else if c.r_series.is_empty() || c.x_series.is_empty() {
171                    self.warn(format!(
172                        "linecode {}: R_series and X_series sizes disagree; the \
173                         empty one emitted as zeros",
174                        c.name
175                    ));
176                }
177                self.required_matrix(&mut o, "R_series", &c.r_series, dim, &c.name);
178                self.required_matrix(&mut o, "X_series", &c.x_series, dim, &c.name);
179                self.flat_matrix(&mut o, "G_from", &c.g_from, &c.name);
180                self.flat_matrix(&mut o, "G_to", &c.g_to, &c.name);
181                self.flat_matrix(&mut o, "B_from", &c.b_from, &c.name);
182                self.flat_matrix(&mut o, "B_to", &c.b_to, &c.name);
183                if let Some(i_max) = &c.i_max {
184                    o.insert("i_max".into(), self.nums(i_max, "linecode i_max"));
185                }
186                if let Some(s_max) = &c.s_max {
187                    o.insert("s_max".into(), self.nums(s_max, "linecode s_max"));
188                }
189                self.extras_dropped(&c.extras, &format!("linecode {}", c.name));
190                codes.insert(c.name.clone(), Value::Object(o));
191            }
192            doc.insert("linecode".into(), Value::Object(codes));
193        }
194
195        self.branches(net, &mut doc);
196        self.injections(net, &mut doc);
197
198        let transformers = self.transformers(net);
199        if !transformers.is_empty() {
200            doc.insert("transformer".into(), Value::Object(transformers));
201        }
202
203        self.untyped_bmopf_tables(net, &mut doc);
204
205        for u in &net.untyped {
206            if Self::is_emitted_untyped(u) {
207                continue;
208            }
209            self.warn(format!(
210                "{} {}: class is not represented in BMOPF; dropped from the output",
211                u.class, u.name
212            ));
213        }
214        self.prune_unreferenced_buses(&mut doc);
215        Value::Object(doc)
216    }
217
218    fn is_emitted_untyped(u: &crate::model::UntypedObject) -> bool {
219        RAW_BMOPF_TOP_LEVEL.contains(&u.class.as_str()) || u.class.starts_with("transformer.")
220    }
221
222    fn untyped_bmopf_tables(&mut self, net: &DistNetwork, doc: &mut Map<String, Value>) {
223        for u in &net.untyped {
224            let Some(value) = raw_bmopf_value(u) else {
225                self.warn(format!(
226                    "{} {}: untyped BMOPF object could not be parsed as JSON; dropped from the output",
227                    u.class, u.name
228                ));
229                continue;
230            };
231            if RAW_BMOPF_TOP_LEVEL.contains(&u.class.as_str()) {
232                doc.entry(u.class.clone())
233                    .or_insert_with(|| Value::Object(Map::new()))
234                    .as_object_mut()
235                    .expect("BMOPF tables are objects")
236                    .insert(u.name.clone(), value);
237            } else if let Some(subtype) = u.class.strip_prefix("transformer.") {
238                doc.entry("transformer")
239                    .or_insert_with(|| Value::Object(Map::new()))
240                    .as_object_mut()
241                    .expect("transformer table is an object")
242                    .entry(subtype.to_string())
243                    .or_insert_with(|| Value::Object(Map::new()))
244                    .as_object_mut()
245                    .expect("transformer subtype table is an object")
246                    .insert(u.name.clone(), value);
247            }
248        }
249    }
250
251    fn prune_unreferenced_buses(&mut self, doc: &mut Map<String, Value>) {
252        let mut refs = BTreeMap::new();
253        for (key, value) in doc.iter() {
254            if key != "bus" {
255                collect_bus_usage(value, &mut refs);
256            }
257        }
258        let Some(buses) = doc.get_mut("bus").and_then(Value::as_object_mut) else {
259            return;
260        };
261        let ids: Vec<String> = buses.keys().cloned().collect();
262        for id in ids {
263            let Some(used) = refs.get(&id) else {
264                buses.remove(&id);
265                self.warn(format!(
266                    "bus {id}: no emitted BMOPF element references this bus; dropped from the output"
267                ));
268                continue;
269            };
270            let Some(bus) = buses.get_mut(&id).and_then(Value::as_object_mut) else {
271                continue;
272            };
273            prune_string_array(
274                bus,
275                "terminal_names",
276                used,
277                &mut self.warnings,
278                &format!("bus {id}"),
279            );
280            prune_string_array(
281                bus,
282                "perfectly_grounded_terminals",
283                used,
284                &mut self.warnings,
285                &format!("bus {id}"),
286            );
287            if matches!(
288                bus.get("perfectly_grounded_terminals"),
289                Some(Value::Array(terms)) if terms.is_empty()
290            ) {
291                bus.remove("perfectly_grounded_terminals");
292            }
293        }
294    }
295
296    /// Lines and switches.
297    fn branches(&mut self, net: &DistNetwork, doc: &mut Map<String, Value>) {
298        if !net.lines.is_empty() {
299            let mut lines = Map::new();
300            for l in &net.lines {
301                let mut o = Map::new();
302                o.insert("length".into(), self.num(l.length, "line length"));
303                o.insert("linecode".into(), json!(l.linecode));
304                o.insert("bus_from".into(), json!(l.bus_from));
305                o.insert("bus_to".into(), json!(l.bus_to));
306                o.insert("terminal_map_from".into(), json!(l.terminal_map_from));
307                o.insert("terminal_map_to".into(), json!(l.terminal_map_to));
308                self.extras_dropped(&l.extras, &format!("line {}", l.name));
309                lines.insert(l.name.clone(), Value::Object(o));
310            }
311            doc.insert("line".into(), Value::Object(lines));
312        }
313        if !net.switches.is_empty() {
314            let mut switches = Map::new();
315            for s in &net.switches {
316                let mut o = Map::new();
317                o.insert("bus_from".into(), json!(s.bus_from));
318                o.insert("bus_to".into(), json!(s.bus_to));
319                o.insert("terminal_map_from".into(), json!(s.terminal_map_from));
320                o.insert("terminal_map_to".into(), json!(s.terminal_map_to));
321                o.insert("open_switch".into(), json!(s.open));
322                if let Some(i_max) = &s.i_max {
323                    o.insert("i_max".into(), self.nums(i_max, "switch i_max"));
324                }
325                self.extras_dropped(&s.extras, &format!("switch {}", s.name));
326                switches.insert(s.name.clone(), Value::Object(o));
327            }
328            doc.insert("switch".into(), Value::Object(switches));
329        }
330    }
331
332    /// Loads, generators, shunts, and the voltage sources.
333    fn injections(&mut self, net: &DistNetwork, doc: &mut Map<String, Value>) {
334        let mut loads = Map::new();
335        for l in &net.loads {
336            let mut o = Map::new();
337            o.insert("configuration".into(), json!(config_str(l.configuration)));
338            o.insert("p_nom".into(), self.nums(&l.p_nom, "load p_nom"));
339            o.insert("q_nom".into(), self.nums(&l.q_nom, "load q_nom"));
340            o.insert("bus".into(), json!(l.bus));
341            o.insert("terminal_map".into(), json!(l.terminal_map));
342            self.load_voltage_model(&mut o, &l.voltage_model, &format!("load {}", l.name));
343            self.extras_dropped(&l.extras, &format!("load {}", l.name));
344            loads.insert(l.name.clone(), Value::Object(o));
345        }
346        let mut gens = Map::new();
347        for g in &net.generators {
348            gens.insert(g.name.clone(), self.generator(g));
349        }
350        if !loads.is_empty() {
351            doc.insert("load".into(), Value::Object(loads));
352        }
353        if !gens.is_empty() {
354            doc.insert("generator".into(), Value::Object(gens));
355        }
356        if !net.shunts.is_empty() {
357            let mut shunts = Map::new();
358            for s in &net.shunts {
359                let mut o = Map::new();
360                o.insert("bus".into(), json!(s.bus));
361                o.insert("terminal_map".into(), json!(s.terminal_map));
362                // The schema requires G_1_1 and B_1_1.
363                let dim = s.g.len().max(s.b.len()).max(1);
364                if s.g.is_empty() && s.b.is_empty() {
365                    self.warn(format!(
366                        "shunt {}: no admittance matrix; emitted as 1 conductor \
367                         zero admittance",
368                        s.name
369                    ));
370                } else if s.g.is_empty() || s.b.is_empty() {
371                    self.warn(format!(
372                        "shunt {}: G and B sizes disagree; the empty one emitted \
373                         as zeros",
374                        s.name
375                    ));
376                }
377                self.required_matrix(&mut o, "G", &s.g, dim, &s.name);
378                self.required_matrix(&mut o, "B", &s.b, dim, &s.name);
379                self.extras_dropped(&s.extras, &format!("shunt {}", s.name));
380                shunts.insert(s.name.clone(), Value::Object(o));
381            }
382            doc.insert("shunt".into(), Value::Object(shunts));
383        }
384        let mut sources = Map::new();
385        if net.sources.is_empty() {
386            self.warn("network has no voltage source; BMOPF requires exactly one");
387        }
388        for (i, vs) in net.sources.iter().enumerate() {
389            if i > 0 {
390                self.warn(format!(
391                    "voltage source {}: the BMOPF formulation expects exactly one source; \
392                     this network has {}",
393                    vs.name,
394                    net.sources.len()
395                ));
396            }
397            let mut o = Map::new();
398            o.insert(
399                "v_magnitude".into(),
400                self.nums(&vs.v_magnitude, "voltage_source v_magnitude"),
401            );
402            o.insert(
403                "v_angle".into(),
404                self.nums(&vs.v_angle, "voltage_source v_angle"),
405            );
406            o.insert("bus".into(), json!(vs.bus));
407            o.insert("terminal_map".into(), json!(vs.terminal_map));
408            let mut extras = vs.extras.clone();
409            if let Some(cost) = extras.remove("cost") {
410                o.insert("cost".into(), cost);
411            }
412            self.extras_dropped(&extras, &format!("voltage source {}", vs.name));
413            sources.insert(vs.name.clone(), Value::Object(o));
414        }
415        doc.insert("voltage_source".into(), Value::Object(sources));
416    }
417
418    fn load_voltage_model(
419        &mut self,
420        o: &mut Map<String, Value>,
421        model: &DistLoadVoltageModel,
422        what: &str,
423    ) {
424        match model {
425            DistLoadVoltageModel::ConstantPower { v_nom } => {
426                o.insert("model".into(), json!("constant_power"));
427                if !v_nom.is_empty() {
428                    o.insert("v_nom".into(), self.nums(v_nom, &format!("{what} v_nom")));
429                }
430            }
431            DistLoadVoltageModel::ConstantCurrent { v_nom } => {
432                o.insert("model".into(), json!("constant_current"));
433                o.insert("v_nom".into(), self.nums(v_nom, &format!("{what} v_nom")));
434            }
435            DistLoadVoltageModel::ConstantImpedance { v_nom } => {
436                o.insert("model".into(), json!("constant_impedance"));
437                o.insert("v_nom".into(), self.nums(v_nom, &format!("{what} v_nom")));
438            }
439            DistLoadVoltageModel::Zip {
440                v_nom,
441                alpha_z,
442                alpha_i,
443                alpha_p,
444                beta_z,
445                beta_i,
446                beta_p,
447            } => {
448                o.insert("model".into(), json!("zip"));
449                o.insert("v_nom".into(), self.nums(v_nom, &format!("{what} v_nom")));
450                o.insert(
451                    "alpha_z".into(),
452                    self.nums(alpha_z, &format!("{what} alpha_z")),
453                );
454                o.insert(
455                    "alpha_i".into(),
456                    self.nums(alpha_i, &format!("{what} alpha_i")),
457                );
458                o.insert(
459                    "alpha_p".into(),
460                    self.nums(alpha_p, &format!("{what} alpha_p")),
461                );
462                o.insert(
463                    "beta_z".into(),
464                    self.nums(beta_z, &format!("{what} beta_z")),
465                );
466                o.insert(
467                    "beta_i".into(),
468                    self.nums(beta_i, &format!("{what} beta_i")),
469                );
470                o.insert(
471                    "beta_p".into(),
472                    self.nums(beta_p, &format!("{what} beta_p")),
473                );
474            }
475            DistLoadVoltageModel::Exponential {
476                v_nom,
477                gamma_p,
478                gamma_q,
479            } => {
480                o.insert("model".into(), json!("exponential"));
481                o.insert("v_nom".into(), self.nums(v_nom, &format!("{what} v_nom")));
482                o.insert(
483                    "gamma_p".into(),
484                    self.nums(gamma_p, &format!("{what} gamma_p")),
485                );
486                o.insert(
487                    "gamma_q".into(),
488                    self.nums(gamma_q, &format!("{what} gamma_q")),
489                );
490            }
491        }
492    }
493
494    fn generator(&mut self, g: &DistGenerator) -> Value {
495        let mut o = Map::new();
496        // BMOPF generators carry bounds and cost, no dispatch setpoint: a
497        // fixed injection becomes pinned bounds. Explicit source bounds win
498        // over the setpoint, which then has nowhere to go.
499        let what = format!("generator {}", g.name);
500        for (key_lo, key_hi, lo, hi, nom) in [
501            ("p_min", "p_max", &g.p_min, &g.p_max, &g.p_nom),
502            ("q_min", "q_max", &g.q_min, &g.q_max, &g.q_nom),
503        ] {
504            if lo.is_some() || hi.is_some() {
505                // Pinned bounds ARE the setpoint; only a setpoint that
506                // differs from the bounds has nowhere to go.
507                let pinned = lo.as_deref() == Some(nom) && hi.as_deref() == Some(nom);
508                if !nom.is_empty() && !nom.iter().all(|&v| v == 0.0) && !pinned {
509                    self.warn(format!(
510                        "{what}: explicit {key_lo}/{key_hi} bounds win over the setpoint, \
511                         which has no BMOPF field"
512                    ));
513                }
514                if let Some(v) = lo {
515                    o.insert(key_lo.into(), self.nums(v, key_lo));
516                }
517                if let Some(v) = hi {
518                    o.insert(key_hi.into(), self.nums(v, key_hi));
519                }
520            } else if !nom.is_empty() {
521                // A fixed injection becomes pinned bounds.
522                o.insert(key_lo.into(), self.nums(nom, key_lo));
523                o.insert(key_hi.into(), self.nums(nom, key_hi));
524            }
525        }
526        // BMOPF generation cost is per phase conductor; powerio carries a single
527        // value, so broadcast the scalar to one entry per phase.
528        let n_phase = if g.p_nom.is_empty() {
529            g.terminal_map.len().max(1)
530        } else {
531            g.p_nom.len()
532        };
533        let cost = g.cost.unwrap_or_else(|| {
534            self.warnings.push(format!(
535                "{what}: no generation cost in the source; emitted cost 0"
536            ));
537            0.0
538        });
539        o.insert(
540            "cost".into(),
541            self.nums(&vec![cost; n_phase], "generator cost"),
542        );
543        o.insert("bus".into(), json!(g.bus));
544        o.insert("configuration".into(), json!(config_str(g.configuration)));
545        o.insert("terminal_map".into(), json!(g.terminal_map));
546        if g.configuration == Configuration::Delta {
547            self.warn(format!(
548                "{what}: the BMOPF formulation covers WYE generators; DELTA emitted as written"
549            ));
550        }
551        self.extras_dropped(&g.extras, &what);
552        Value::Object(o)
553    }
554
555    /// Transformers keyed by subtype; wye-wye three phase units decompose
556    /// into one single_phase entry per phase, the convention the public
557    /// example networks use.
558    fn transformers(&mut self, net: &DistNetwork) -> Map<String, Value> {
559        let mut by_subtype: Map<String, Value> = Map::new();
560        let insert = |sub: &str, name: String, v: Value, map: &mut Map<String, Value>| {
561            map.entry(sub.to_string())
562                .or_insert_with(|| Value::Object(Map::new()))
563                .as_object_mut()
564                .expect("subtype maps are objects")
565                .insert(name, v);
566        };
567        for t in &net.transformers {
568            match classify(t) {
569                Kind::SinglePhase => {
570                    if t.windings.iter().any(|w| w.conn == WindingConn::Delta) {
571                        // An open wye / open delta leg. The single_phase shape
572                        // carries the terminals and impedance faithfully, but
573                        // has no field for the wye/delta connection, so a
574                        // consumer that models the subtype literally reads it
575                        // as a wye-wye unit. Flag it; the line to line topology
576                        // survives in the terminal map.
577                        self.warn(format!(
578                            "transformer {}: single phase wye/delta emitted as single_phase; \
579                             the wye/delta connection is not encoded in the subtype, only the \
580                             line to line terminal map",
581                            t.name
582                        ));
583                    }
584                    let v = self.two_winding(t, &t.windings[0], &t.windings[1], 1.0, true, true);
585                    insert("single_phase", t.name.clone(), v, &mut by_subtype);
586                }
587                Kind::SinglePhaseShape(sub) => {
588                    let v = self.two_winding(t, &t.windings[0], &t.windings[1], 1.0, true, true);
589                    insert(sub, t.name.clone(), v, &mut by_subtype);
590                }
591                Kind::CenterTap => {
592                    let v = self.center_tap(t);
593                    insert("center_tap", t.name.clone(), v, &mut by_subtype);
594                }
595                Kind::WyeDelta => {
596                    let v = self.three_phase(t, 0);
597                    insert("wye_delta", t.name.clone(), v, &mut by_subtype);
598                }
599                Kind::DeltaWye => {
600                    let v = self.three_phase(t, 1);
601                    insert("delta_wye", t.name.clone(), v, &mut by_subtype);
602                }
603                Kind::WyeWye3 => {
604                    for (k, v) in self.decompose_wye_wye(t) {
605                        insert("single_phase", k, v, &mut by_subtype);
606                    }
607                }
608                Kind::NWinding => {
609                    let v = self.n_winding(t);
610                    insert("n_winding", t.name.clone(), v, &mut by_subtype);
611                }
612                Kind::Unsupported(why) => {
613                    self.warn(format!(
614                        "transformer {}: {why}; not representable in the four BMOPF \
615                         subtypes, dropped from the output",
616                        t.name
617                    ));
618                }
619            }
620        }
621        by_subtype
622    }
623
624    /// Shared single_phase / center_tap shape. `to_scale` rescales the to
625    /// side ratings (used by the wye-wye decomposition).
626    fn two_winding(
627        &mut self,
628        t: &DistTransformer,
629        from: &Winding,
630        to: &Winding,
631        s_scale: f64,
632        emit_no_load: bool,
633        warn_extras: bool,
634    ) -> Value {
635        let s = from.s_rating * s_scale;
636        let zb_from = from.v_ref * from.v_ref / s;
637        let zb_to = to.v_ref * to.v_ref / s;
638        let mut o = Map::new();
639        o.insert("bus_from".into(), json!(from.bus));
640        o.insert("bus_to".into(), json!(to.bus));
641        o.insert("s_rating".into(), self.num(s, "transformer s_rating"));
642        o.insert(
643            "v_nom_from".into(),
644            self.num(from.v_ref, "transformer v_nom_from"),
645        );
646        o.insert(
647            "v_nom_to".into(),
648            self.num(to.v_ref, "transformer v_nom_to"),
649        );
650        o.insert(
651            "r_series_from".into(),
652            self.num(from.r_pct / 100.0 * zb_from, "transformer r_series_from"),
653        );
654        o.insert(
655            "r_series_to".into(),
656            self.num(to.r_pct / 100.0 * zb_to, "transformer r_series_to"),
657        );
658        // The whole leakage reactance rides on the from side, the
659        // convention the public example uses.
660        if t.xsc_pct.is_empty() {
661            self.warn(format!(
662                "transformer {}: xsc_pct is empty; emitted x_series_from=0",
663                t.name
664            ));
665        }
666        let xhl = t.xsc_pct.first().copied().unwrap_or(0.0);
667        o.insert(
668            "x_series_from".into(),
669            self.num(xhl / 100.0 * zb_from, "transformer x_series_from"),
670        );
671        o.insert("x_series_to".into(), json!(0.0));
672        o.insert("terminal_map_from".into(), json!(from.terminal_map));
673        o.insert("terminal_map_to".into(), json!(to.terminal_map));
674        self.transformer_tap_fields(&mut o, t, from);
675        if emit_no_load {
676            self.transformer_no_load_fields(&mut o, t, from, s);
677        }
678        if warn_extras {
679            self.transformer_extras_dropped(t, &TRANSFORMER_TWO_WINDING_ALLOWED_EXTRAS);
680        }
681        o.into()
682    }
683
684    fn center_tap(&mut self, t: &DistTransformer) -> Value {
685        // The split secondary collapses to one to side winding: voltage is
686        // the full 240 V across the outer terminals, the center tap is the
687        // shared terminal, listed last.
688        let from = &t.windings[0];
689        let (w2, w3) = (&t.windings[1], &t.windings[2]);
690        let common = w2
691            .terminal_map
692            .iter()
693            .find(|term| w3.terminal_map.contains(term))
694            .cloned()
695            .unwrap_or_default();
696        let mut hots: Vec<String> = Vec::new();
697        for term in w2.terminal_map.iter().chain(&w3.terminal_map) {
698            if *term != common && !hots.contains(term) {
699                hots.push(term.clone());
700            }
701        }
702        // Percent resistance does not transfer to the doubled voltage:
703        // each winding's impedance base is its own v^2/s (PMD eng2math
704        // builds zbase per winding from vnom^2/snom). Convert each half to
705        // ohms on its own base, sum the series path, and express the total
706        // on the base two_winding gives the combined winding,
707        // v_new^2/from.s_rating. Equal ratings make the s factors exactly
708        // 1, leaving the plain v^2 weighting. The leakage reactance needs
709        // no such move: two_winding applies xsc_pct at the from side,
710        // whose base the collapse does not touch.
711        let v_new = w2.v_ref + w3.v_ref;
712        let r_pct_new = (w2.r_pct * w2.v_ref * w2.v_ref * (from.s_rating / w2.s_rating)
713            + w3.r_pct * w3.v_ref * w3.v_ref * (from.s_rating / w3.s_rating))
714            / (v_new * v_new);
715        let to = Winding {
716            bus: w2.bus.clone(),
717            terminal_map: {
718                let mut m = hots;
719                m.push(common);
720                m
721            },
722            conn: WindingConn::Wye,
723            v_ref: v_new,
724            s_rating: from.s_rating,
725            r_pct: r_pct_new,
726            tap: 1.0,
727        };
728        self.warn(format!(
729            "transformer {}: center tap secondary collapsed to one winding; the \
730             xht/xlt impedance split is not representable and was dropped",
731            t.name
732        ));
733        if w2.s_rating.to_bits() != from.s_rating.to_bits()
734            || w3.s_rating.to_bits() != from.s_rating.to_bits()
735        {
736            self.warn(format!(
737                "transformer {}: center tap half winding s_ratings ({}, {}) differ \
738                 from the primary's {}; the collapsed winding keeps the primary \
739                 rating, the half ratings only survive in the resistance conversion",
740                t.name, w2.s_rating, w3.s_rating, from.s_rating
741            ));
742        }
743        self.two_winding(t, from, &to, 1.0, true, true)
744    }
745
746    /// `wye_delta` / `delta_wye`: one series impedance in ohms on the wye
747    /// side. `wye_idx` names which winding is the wye one.
748    fn three_phase(&mut self, t: &DistTransformer, wye_idx: usize) -> Value {
749        let from = &t.windings[0];
750        let to = &t.windings[1];
751        let wye = &t.windings[wye_idx];
752        let s = from.s_rating;
753        let zb_wye = wye.v_ref * wye.v_ref / s;
754        let mut o = Map::new();
755        o.insert("bus_from".into(), json!(from.bus));
756        o.insert("bus_to".into(), json!(to.bus));
757        o.insert("s_rating".into(), self.num(s, "transformer s_rating"));
758        o.insert(
759            "v_nom_from".into(),
760            self.num(from.v_ref, "transformer v_nom_from"),
761        );
762        o.insert(
763            "v_nom_to".into(),
764            self.num(to.v_ref, "transformer v_nom_to"),
765        );
766        o.insert(
767            "r_series".into(),
768            self.num(
769                (from.r_pct + to.r_pct) / 100.0 * zb_wye,
770                "transformer r_series",
771            ),
772        );
773        if t.xsc_pct.is_empty() {
774            self.warn(format!(
775                "transformer {}: xsc_pct is empty; emitted x_series=0",
776                t.name
777            ));
778        }
779        let xhl = t.xsc_pct.first().copied().unwrap_or(0.0);
780        o.insert(
781            "x_series".into(),
782            self.num(xhl / 100.0 * zb_wye, "transformer x_series"),
783        );
784        o.insert("terminal_map_from".into(), json!(from.terminal_map));
785        o.insert("terminal_map_to".into(), json!(to.terminal_map));
786        self.transformer_tap_fields(&mut o, t, from);
787        self.transformer_no_load_fields(&mut o, t, from, s);
788        self.transformer_extras_dropped(t, &TRANSFORMER_TWO_WINDING_ALLOWED_EXTRAS);
789        o.into()
790    }
791
792    fn n_winding(&mut self, t: &DistTransformer) -> Value {
793        let s = t.windings.first().map_or(f64::NAN, |w| w.s_rating);
794        if t.windings
795            .iter()
796            .any(|w| w.s_rating.to_bits() != s.to_bits())
797        {
798            self.warn(format!(
799                "transformer {}: n_winding BMOPF carries one s_rating; emitted the first winding rating",
800                t.name
801            ));
802        }
803        let mut o = Map::new();
804        o.insert("s_rating".into(), self.num(s, "transformer s_rating"));
805        let windings: Vec<Value> = t
806            .windings
807            .iter()
808            .map(|w| {
809                let mut wj = Map::new();
810                wj.insert("bus".into(), json!(w.bus));
811                wj.insert("terminal_map".into(), json!(w.terminal_map));
812                wj.insert(
813                    "v_nom".into(),
814                    self.num(n_winding_bmopf_v_nom(w), "transformer winding v_nom"),
815                );
816                wj.insert(
817                    "configuration".into(),
818                    json!(match w.conn {
819                        WindingConn::Wye => "WYE",
820                        WindingConn::Delta => "DELTA",
821                    }),
822                );
823                let zbase = n_winding_base(w, s).unwrap_or(f64::NAN);
824                wj.insert(
825                    "r_winding".into(),
826                    self.num(w.r_pct / 100.0 * zbase, "transformer winding r_winding"),
827                );
828                Value::Object(wj)
829            })
830            .collect();
831        o.insert("windings".into(), Value::Array(windings));
832        let base_z = t
833            .windings
834            .first()
835            .and_then(|w| n_winding_base(w, s))
836            .unwrap_or(f64::NAN);
837        let mut x_sc = Map::new();
838        for (idx, (i, j)) in pair_keys(t.windings.len()).into_iter().enumerate() {
839            let x_pct = t.xsc_pct.get(idx).copied().unwrap_or_else(|| {
840                self.warn(format!(
841                    "transformer {}: missing x_sc for winding pair {}_{}; emitted 0",
842                    t.name,
843                    i + 1,
844                    j + 1
845                ));
846                0.0
847            });
848            x_sc.insert(
849                format!("{}_{}", i + 1, j + 1),
850                self.num(x_pct / 100.0 * base_z, "transformer x_sc"),
851            );
852        }
853        o.insert("x_sc".into(), Value::Object(x_sc));
854        if let Some(first) = t.windings.first() {
855            self.transformer_no_load_fields(&mut o, t, first, s);
856        }
857        self.taps_dropped(t);
858        self.transformer_extras_dropped(t, &TRANSFORMER_NO_LOAD_ALLOWED_EXTRAS);
859        o.into()
860    }
861
862    /// A three phase wye-wye unit becomes one single_phase entry per phase
863    /// (`name_1`..), each at line to neutral voltage and a third of the
864    /// rating. That keeps the impedance base v^2/s, so the percent values
865    /// carry over unchanged. The public IEEE13 example records the line to
866    /// line voltage on its decomposed units instead; both are self
867    /// consistent, they differ in the v_ref convention.
868    fn decompose_wye_wye(&mut self, t: &DistTransformer) -> Vec<(String, Value)> {
869        let mut out = Vec::new();
870        let (from, to) = (&t.windings[0], &t.windings[1]);
871        let sqrt3 = 3f64.sqrt();
872        for k in 0..t.phases {
873            let per = |w: &Winding| {
874                let neutral = w.terminal_map.last().cloned().unwrap_or_default();
875                Winding {
876                    bus: w.bus.clone(),
877                    terminal_map: vec![w.terminal_map[k].clone(), neutral],
878                    conn: WindingConn::Wye,
879                    v_ref: w.v_ref / sqrt3,
880                    s_rating: w.s_rating / 3.0,
881                    r_pct: w.r_pct,
882                    tap: w.tap,
883                }
884            };
885            let f = per(from);
886            let to_1 = per(to);
887            let mut t1 = t.clone();
888            t1.windings = vec![f.clone(), to_1.clone()];
889            let v = self.two_winding(&t1, &f, &to_1, 1.0, false, false);
890            out.push((format!("{}_{}", t.name, k + 1), v));
891        }
892        self.warn(format!(
893            "transformer {}: three phase wye-wye decomposed into {} single_phase units",
894            t.name, t.phases
895        ));
896        self.transformer_extras_dropped(t, &TRANSFORMER_TWO_WINDING_ALLOWED_EXTRAS);
897        out
898    }
899
900    fn taps_dropped(&mut self, t: &DistTransformer) {
901        for w in &t.windings {
902            if (w.tap - 1.0).abs() > 1e-12 {
903                self.warn(format!(
904                    "transformer {}: off nominal tap {} has no BMOPF field; dropped",
905                    t.name, w.tap
906                ));
907            }
908        }
909    }
910
911    fn transformer_tap_fields(
912        &mut self,
913        o: &mut Map<String, Value>,
914        t: &DistTransformer,
915        from: &Winding,
916    ) {
917        if (from.tap - 1.0).abs() > 1e-12 || t.extras.contains_key("tap") {
918            o.insert("tap".into(), self.num(from.tap, "transformer tap"));
919        }
920        for key in ["tap_min", "tap_max"] {
921            if let Some(v) = extras_number(&t.extras, key) {
922                o.insert(key.into(), self.num(v, &format!("transformer {key}")));
923            }
924        }
925        for w in t.windings.iter().skip(1) {
926            if (w.tap - 1.0).abs() > 1e-12 {
927                self.warn(format!(
928                    "transformer {}: non-from-side tap {} has no BMOPF field; dropped",
929                    t.name, w.tap
930                ));
931            }
932        }
933    }
934
935    fn transformer_no_load_fields(
936        &mut self,
937        o: &mut Map<String, Value>,
938        t: &DistTransformer,
939        from: &Winding,
940        s: f64,
941    ) {
942        if let Some(v) = t.extras.get("g_no_load") {
943            o.insert("g_no_load".into(), v.clone());
944        } else if let Some(loss_pct) = extras_number(&t.extras, "%noloadloss") {
945            if self.is_phase_to_phase_single_phase(from) {
946                self.warn(format!(
947                    "transformer {}: phase-to-phase %noloadloss cannot be represented as a BMOPF no-load shunt; dropped",
948                    t.name
949                ));
950            } else {
951                let v_stamp = no_load_voltage_base(from);
952                if s.is_finite() && s > 0.0 && v_stamp.is_finite() && v_stamp > 0.0 {
953                    let y_base = s / (v_stamp * v_stamp);
954                    o.insert(
955                        "g_no_load".into(),
956                        self.num(loss_pct / 100.0 * y_base, "transformer g_no_load"),
957                    );
958                } else {
959                    self.warn(format!(
960                        "transformer {}: %noloadloss cannot be converted without a positive s_rating and v_nom_from",
961                        t.name
962                    ));
963                }
964            }
965        }
966
967        if let Some(v) = t.extras.get("b_no_load") {
968            o.insert("b_no_load".into(), v.clone());
969        } else if let Some(imag_pct) = extras_number(&t.extras, "%imag") {
970            if self.is_phase_to_phase_single_phase(from) {
971                self.warn(format!(
972                    "transformer {}: phase-to-phase %imag cannot be represented as a BMOPF no-load shunt; dropped",
973                    t.name
974                ));
975            } else {
976                let v_stamp = no_load_voltage_base(from);
977                if s.is_finite() && s > 0.0 && v_stamp.is_finite() && v_stamp > 0.0 {
978                    let y_base = s / (v_stamp * v_stamp);
979                    o.insert(
980                        "b_no_load".into(),
981                        self.num(imag_pct / 100.0 * y_base, "transformer b_no_load"),
982                    );
983                } else {
984                    self.warn(format!(
985                        "transformer {}: %imag cannot be converted without a positive s_rating and v_nom_from",
986                        t.name
987                    ));
988                }
989            }
990        } else if !self.is_phase_to_phase_single_phase(from)
991            && extras_number(&t.extras, "%noloadloss").is_some()
992        {
993            o.insert("b_no_load".into(), json!(0.0));
994        }
995    }
996
997    fn is_phase_to_phase_single_phase(&self, winding: &Winding) -> bool {
998        n_winding_phase_count(winding) == 1
999            && !self
1000                .grounded
1001                .get(&winding.bus.to_ascii_lowercase())
1002                .is_some_and(|g| winding.terminal_map.iter().any(|t| g.contains(t)))
1003    }
1004
1005    fn transformer_extras_dropped(&mut self, t: &DistTransformer, allowed: &[&str]) {
1006        for key in t.extras.keys() {
1007            if key == "bmopf_subtype" || key == "tap" || allowed.contains(&key.as_str()) {
1008                continue;
1009            }
1010            self.warn(format!(
1011                "transformer {}: `{key}` has no place in the BMOPF schema; dropped from the output",
1012                t.name
1013            ));
1014        }
1015    }
1016
1017    /// Emits a matrix whose `_1_1` entry the schema requires; an empty one
1018    /// becomes `dim` by `dim` zeros so the required key exists.
1019    fn required_matrix(
1020        &mut self,
1021        o: &mut Map<String, Value>,
1022        prefix: &str,
1023        m: &Mat,
1024        dim: usize,
1025        name: &str,
1026    ) {
1027        if m.is_empty() {
1028            self.flat_matrix(o, prefix, &vec![vec![0.0; dim]; dim], name);
1029        } else {
1030            self.flat_matrix(o, prefix, m, name);
1031        }
1032    }
1033
1034    fn flat_matrix(&mut self, o: &mut Map<String, Value>, prefix: &str, m: &Mat, name: &str) {
1035        for (i, row) in m.iter().enumerate() {
1036            for (j, &v) in row.iter().enumerate() {
1037                o.insert(
1038                    format!("{prefix}_{}_{}", i + 1, j + 1),
1039                    self.num(v, &format!("{name} {prefix}")),
1040                );
1041            }
1042        }
1043    }
1044}
1045
1046fn collect_bus_usage(value: &Value, refs: &mut BTreeMap<String, BTreeSet<String>>) {
1047    match value {
1048        Value::Object(o) => {
1049            add_bus_usage(o, refs, "bus", "terminal_map");
1050            add_bus_usage(o, refs, "bus_from", "terminal_map_from");
1051            add_bus_usage(o, refs, "bus_to", "terminal_map_to");
1052            for value in o.values() {
1053                collect_bus_usage(value, refs);
1054            }
1055        }
1056        Value::Array(values) => {
1057            for value in values {
1058                collect_bus_usage(value, refs);
1059            }
1060        }
1061        _ => {}
1062    }
1063}
1064
1065fn add_bus_usage(
1066    o: &Map<String, Value>,
1067    refs: &mut BTreeMap<String, BTreeSet<String>>,
1068    bus_key: &str,
1069    map_key: &str,
1070) {
1071    let Some(id) = o.get(bus_key).and_then(Value::as_str) else {
1072        return;
1073    };
1074    let entry = refs.entry(id.to_string()).or_default();
1075    if let Some(terms) = o.get(map_key).and_then(Value::as_array) {
1076        entry.extend(terms.iter().filter_map(Value::as_str).map(str::to_string));
1077    }
1078}
1079
1080fn prune_string_array(
1081    o: &mut Map<String, Value>,
1082    key: &str,
1083    used: &BTreeSet<String>,
1084    warnings: &mut Vec<String>,
1085    what: &str,
1086) {
1087    let Some(Value::Array(values)) = o.get_mut(key) else {
1088        return;
1089    };
1090    let old = std::mem::take(values);
1091    let mut kept = Vec::new();
1092    let mut dropped = Vec::new();
1093    for value in old {
1094        if value.as_str().is_some_and(|s| used.contains(s)) {
1095            kept.push(value);
1096        } else {
1097            dropped.push(value);
1098        }
1099    }
1100    if !dropped.is_empty() {
1101        let names: Vec<String> = dropped
1102            .iter()
1103            .filter_map(Value::as_str)
1104            .map(str::to_string)
1105            .collect();
1106        warnings.push(format!(
1107            "{what}: `{key}` entries {names:?} are not referenced by emitted BMOPF elements; dropped from the output"
1108        ));
1109    }
1110    *values = kept;
1111}
1112
1113enum Kind {
1114    SinglePhase,
1115    /// Two windings already in the shared single_phase/center_tap shape,
1116    /// emitted under the named subtype.
1117    SinglePhaseShape(&'static str),
1118    CenterTap,
1119    WyeDelta,
1120    DeltaWye,
1121    WyeWye3,
1122    NWinding,
1123    Unsupported(String),
1124}
1125
1126fn classify(t: &DistTransformer) -> Kind {
1127    // A network read from BMOPF records its subtype; trust it so writing
1128    // back reproduces the grouping (center tap reads as two windings).
1129    // An unknown or shape mismatched subtype falls through to the shape
1130    // based classification below.
1131    if let Some(sub) = t.extras.get("bmopf_subtype").and_then(|v| v.as_str()) {
1132        if t.windings.len() == 2 {
1133            match sub {
1134                "single_phase" => return Kind::SinglePhase,
1135                "center_tap" => return Kind::SinglePhaseShape("center_tap"),
1136                "wye_delta" => return Kind::WyeDelta,
1137                "delta_wye" => return Kind::DeltaWye,
1138                _ => {}
1139            }
1140        }
1141        if sub == "n_winding" && t.windings.len() >= 2 {
1142            return Kind::NWinding;
1143        }
1144    }
1145    let conns: Vec<WindingConn> = t.windings.iter().map(|w| w.conn).collect();
1146    match (t.phases, conns.as_slice()) {
1147        // single_phase covers the plain 1-phase wye-wye unit and both open
1148        // wye / open delta leg orientations (one delta winding wired line to
1149        // line). The single_phase shape holds the delta side: it carries two
1150        // phase terminals, no conn discriminator, and its line to line v_ref
1151        // makes the per winding impedance base v^2/s already right. The
1152        // pattern reads as the three pairs wye-wye, delta-wye, wye-delta.
1153        (
1154            1,
1155            [WindingConn::Wye | WindingConn::Delta, WindingConn::Wye]
1156            | [WindingConn::Wye, WindingConn::Delta],
1157        ) => Kind::SinglePhase,
1158        (1, [WindingConn::Wye, WindingConn::Wye, WindingConn::Wye]) => Kind::CenterTap,
1159        (3, [WindingConn::Wye, WindingConn::Delta]) => Kind::WyeDelta,
1160        (3, [WindingConn::Delta, WindingConn::Wye]) => Kind::DeltaWye,
1161        // The decomposition indexes terminal_map[phase] and takes the last
1162        // entry as the neutral; anything else is not safely decomposable.
1163        (3, [WindingConn::Wye, WindingConn::Wye])
1164            if t.windings
1165                .iter()
1166                .all(|w| w.terminal_map.len() == t.phases + 1) =>
1167        {
1168            Kind::WyeWye3
1169        }
1170        (3, [WindingConn::Wye, WindingConn::Wye]) => Kind::Unsupported(
1171            "three phase wye-wye whose terminal maps do not list each phase plus a neutral".into(),
1172        ),
1173        (_, _) if t.windings.len() >= 3 => Kind::NWinding,
1174        _ => Kind::Unsupported(format!(
1175            "{} phase with {} windings ({:?})",
1176            t.phases,
1177            t.windings.len(),
1178            conns
1179        )),
1180    }
1181}
1182
1183fn raw_bmopf_value(u: &crate::model::UntypedObject) -> Option<Value> {
1184    let (_, text) = u.props.first()?;
1185    serde_json::from_str(text).ok()
1186}
1187
1188fn extras_number(extras: &crate::model::Extras, key: &str) -> Option<f64> {
1189    let v = extras.get(key)?;
1190    v.as_f64()
1191        .or_else(|| v.as_i64().map(|v| v as f64))
1192        .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
1193        .filter(|v| v.is_finite())
1194}
1195
1196fn n_winding_phase_count(w: &Winding) -> usize {
1197    crate::model::n_winding_phase_count(w.conn, &w.terminal_map)
1198}
1199
1200fn n_winding_bmopf_v_nom(w: &Winding) -> f64 {
1201    if w.conn == WindingConn::Wye && n_winding_phase_count(w) >= 2 {
1202        w.v_ref / 3f64.sqrt()
1203    } else {
1204        w.v_ref
1205    }
1206}
1207
1208fn n_winding_base(w: &Winding, s: f64) -> Option<f64> {
1209    n_winding_impedance_base(n_winding_phase_count(w), n_winding_bmopf_v_nom(w), s)
1210}
1211
1212fn no_load_voltage_base(from: &Winding) -> f64 {
1213    let phases = match from.conn {
1214        WindingConn::Wye => from.terminal_map.len().saturating_sub(1),
1215        WindingConn::Delta => from.terminal_map.len(),
1216    };
1217    if phases >= 3 {
1218        from.v_ref / 3f64.sqrt()
1219    } else {
1220        from.v_ref
1221    }
1222}
1223
1224fn config_str(c: Configuration) -> &'static str {
1225    match c {
1226        Configuration::Wye => "WYE",
1227        Configuration::Delta => "DELTA",
1228        Configuration::SinglePhase => "SINGLE_PHASE",
1229    }
1230}
1231
1232#[cfg(test)]
1233mod tests {
1234    use super::*;
1235    use crate::bmopf::parse_bmopf_str;
1236    use crate::model::DistLoadVoltageModel;
1237
1238    #[test]
1239    fn load_voltage_models_round_trip_through_bmopf() {
1240        let text = r#"{
1241            "bus": {
1242                "b1": {"terminal_names": ["1", "2", "3", "4"], "perfectly_grounded_terminals": ["4"]}
1243            },
1244            "voltage_source": {
1245                "source": {
1246                    "bus": "b1", "terminal_map": ["1", "2", "3", "4"],
1247                    "v_magnitude": [7200.0, 7200.0, 7200.0, 0.0],
1248                    "v_angle": [0.0, -120.0, 120.0, 0.0]
1249                }
1250            },
1251            "load": {
1252                "zip": {
1253                    "bus": "b1", "terminal_map": ["1", "2", "3", "4"],
1254                    "configuration": "WYE", "p_nom": [1.0, 2.0, 3.0], "q_nom": [0.1, 0.2, 0.3],
1255                    "model": "zip", "v_nom": [7200.0, 7200.0, 7200.0],
1256                    "alpha_z": [0.2, 0.2, 0.2], "alpha_i": [0.3, 0.3, 0.3], "alpha_p": [0.5, 0.5, 0.5],
1257                    "beta_z": [0.1, 0.1, 0.1], "beta_i": [0.4, 0.4, 0.4], "beta_p": [0.5, 0.5, 0.5]
1258                },
1259                "exp": {
1260                    "bus": "b1", "terminal_map": ["1", "2", "3", "4"],
1261                    "configuration": "WYE", "p_nom": [1.0, 1.0, 1.0], "q_nom": [0.0, 0.0, 0.0],
1262                    "model": "exponential", "v_nom": [7200.0, 7200.0, 7200.0],
1263                    "gamma_p": [1.2, 1.2, 1.2], "gamma_q": [2.1, 2.1, 2.1]
1264                }
1265            }
1266        }"#;
1267        let net = parse_bmopf_str(text).unwrap();
1268        let zip = net.loads.iter().find(|l| l.name == "zip").unwrap();
1269        let exp = net.loads.iter().find(|l| l.name == "exp").unwrap();
1270        assert!(matches!(
1271            &zip.voltage_model,
1272            DistLoadVoltageModel::Zip { alpha_z, .. } if alpha_z == &vec![0.2, 0.2, 0.2]
1273        ));
1274        assert!(matches!(
1275            &exp.voltage_model,
1276            DistLoadVoltageModel::Exponential { gamma_q, .. } if gamma_q == &vec![2.1, 2.1, 2.1]
1277        ));
1278
1279        let out = write_bmopf_json(&net);
1280        assert!(out.warnings.is_empty(), "{:?}", out.warnings);
1281        let v: Value = serde_json::from_str(&out.text).unwrap();
1282        assert_eq!(
1283            v["load"]["zip"]["alpha_i"],
1284            serde_json::json!([0.3, 0.3, 0.3])
1285        );
1286        assert_eq!(
1287            v["load"]["exp"]["gamma_p"],
1288            serde_json::json!([1.2, 1.2, 1.2])
1289        );
1290    }
1291}