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