1use 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
19const 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
45pub 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 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 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 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 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 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 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 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 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 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 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 o.insert(key_lo.into(), self.nums(nom, key_lo));
523 o.insert(key_hi.into(), self.nums(nom, key_hi));
524 }
525 }
526 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 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 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 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 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 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 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 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 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 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 SinglePhaseShape(&'static str),
1118 CenterTap,
1119 WyeDelta,
1120 DeltaWye,
1121 WyeWye3,
1122 NWinding,
1123 Unsupported(String),
1124}
1125
1126fn classify(t: &DistTransformer) -> Kind {
1127 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 (
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 (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}