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