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