1use std::path::Path;
12use std::sync::Arc;
13
14use serde::de::DeserializeOwned;
15use serde_json::{Map, Value};
16
17use crate::error::{Error, Result};
18use crate::geo::{CoordinateSpace, CoordsKind, GeoMeta, Location};
19use crate::model::{
20 ActivePowerReference, ActivePowerUnit, Configuration, ControlVoltageReference, DistBus,
21 DistCapacitor, DistControlProfile, DistGenerator, DistIbr, DistLine, DistLineCode, DistLoad,
22 DistLoadVoltageModel, DistShunt, DistSourceFormat, DistSwitch, DistTransformer, Extras,
23 IbrPrimeMover, IbrTopology, IbrVoltageAggregation, Mat, MulticonductorNetwork,
24 PowerFactorControl, ReactivePowerReference, ReactivePowerUnit, UntypedObject, VoltVarControl,
25 VoltWattControl, VoltageSource, Winding, WindingConn, n_winding_impedance_base,
26 n_winding_phase_count, pair_keys,
27};
28
29pub fn parse_bmopf_file(path: impl AsRef<Path>) -> Result<MulticonductorNetwork> {
30 let path = path.as_ref();
31 let text = std::fs::read_to_string(path).map_err(|source| Error::Io {
32 path: path.display().to_string(),
33 source,
34 })?;
35 parse_bmopf_str(&text)
36}
37
38pub fn parse_bmopf_str(text: &str) -> Result<MulticonductorNetwork> {
39 let doc: Value = serde_json::from_str(text).map_err(|e| Error::Json {
40 format: "BMOPF",
41 message: e.to_string(),
42 })?;
43 let Value::Object(doc) = doc else {
44 return Err(Error::Json {
45 format: "BMOPF",
46 message: "top level is not an object".into(),
47 });
48 };
49 let mut net = MulticonductorNetwork {
50 source: Some(Arc::new(text.to_string())),
51 source_format: Some(DistSourceFormat::BmopfJson),
52 base_frequency: 60.0,
53 ..MulticonductorNetwork::default()
54 };
55 report_non_numeric_fields(&doc, &mut net);
56 let mut rd = Reader {
57 net: &mut net,
58 frequency_stated: false,
59 };
60 rd.document(&doc);
61 crate::model::warn_unresolved_references(&mut net);
62 Ok(net)
63}
64
65const NUMERIC_FIELDS: &[&str] = &[
69 "alpha_i",
70 "alpha_p",
71 "alpha_z",
72 "beta_i",
73 "beta_p",
74 "beta_z",
75 "cost",
76 "frequency",
77 "gamma_p",
78 "gamma_q",
79 "i_max",
80 "i_max_from",
81 "i_max_to",
82 "length",
83 "p_max",
84 "p_min",
85 "p_nom",
86 "q_max",
87 "q_min",
88 "q_nom",
89 "q_rated",
90 "r_series",
91 "r_series_from",
92 "r_series_to",
93 "s_max",
94 "s_rating",
95 "v_angle",
96 "v_magnitude",
97 "v_max",
98 "v_min",
99 "v_nom",
100 "v_nom_from",
101 "v_nom_to",
102 "vn_max",
103 "vneg_max",
104 "vpn_max",
105 "vpn_min",
106 "vpos_max",
107 "vpos_min",
108 "vpp_max",
109 "vpp_min",
110 "vzero_max",
111 "x_series",
112 "x_series_from",
113 "x_series_to",
114];
115
116const NUMERIC_MATRIX_PREFIXES: &[&str] = &[
120 "B_from", "B_to", "B", "G_from", "G_to", "G", "R_series", "X_series",
121];
122
123fn is_numeric_matrix_field(key: &str) -> bool {
127 NUMERIC_MATRIX_PREFIXES
128 .iter()
129 .any(|p| matrix_indices(key, p).is_some())
130}
131
132fn is_numeric_field(key: &str) -> bool {
135 NUMERIC_FIELDS.binary_search(&key).is_ok() || is_numeric_matrix_field(key)
136}
137
138fn report_non_numeric_fields(doc: &Map<String, Value>, net: &mut MulticonductorNetwork) {
156 fn not_numeric(v: &Value) -> Option<&'static str> {
158 match v {
159 Value::Number(_) => None,
160 Value::Array(a) if a.iter().all(Value::is_number) => None,
161 Value::Null => Some("null"),
162 Value::Bool(_) => Some("a boolean"),
163 Value::String(_) => Some("a string"),
164 Value::Array(_) => Some("an array holding a value that is not a number"),
165 Value::Object(_) => Some("an object"),
166 }
167 }
168 fn report(net: &mut MulticonductorNetwork, pointer: String, what: &str) {
169 let message = format!(
170 "{pointer}: the schema types this field as a number and it holds {what}; \
171 it reads as NaN and anything derived from it is undefined"
172 );
173 net.warnings.push(message.clone());
174 net.parse_diagnostics.push(
175 crate::diagnostics::StructuredDiagnostic::new(
176 crate::diagnostics::READ_BMOPF_FIELD_NOT_A_NUMBER,
177 crate::diagnostics::DiagnosticSeverity::Error,
178 crate::diagnostics::DiagnosticStage::Read,
179 message,
180 )
181 .with_element_path(pointer)
182 .with_suggested_action("state a number, or omit the field"),
183 );
184 }
185 fn walk(obj: &Map<String, Value>, path: &str, net: &mut MulticonductorNetwork) {
188 for (key, value) in obj {
189 if key == "extras" {
190 continue;
191 }
192 if is_numeric_field(key) {
193 if let Some(what) = not_numeric(value) {
194 report(net, format!("{path}/{key}"), what);
195 }
196 continue;
197 }
198 match value {
199 Value::Object(o) => walk(o, &format!("{path}/{key}"), net),
200 Value::Array(a) => {
201 for (i, e) in a.iter().enumerate() {
202 if let Value::Object(o) = e {
203 walk(o, &format!("{path}/{key}/{i}"), net);
204 }
205 }
206 }
207 _ => {}
208 }
209 }
210 }
211 walk(doc, "", net);
212}
213
214struct Reader<'a> {
215 net: &'a mut MulticonductorNetwork,
216 frequency_stated: bool,
217}
218
219const BMOPF_DELTA_ROLLS_EXTRA: &str = "bmopf_delta_rolls";
220
221fn f(v: &Value) -> f64 {
222 v.as_f64().unwrap_or(f64::NAN)
223}
224
225fn floats(v: Option<&Value>) -> Option<Vec<f64>> {
226 v?.as_array().map(|a| a.iter().map(f).collect())
227}
228
229fn first_float(v: Option<&Value>) -> Option<f64> {
230 match v? {
231 Value::Array(a) => a.first().map(f),
232 v => Some(f(v)),
233 }
234}
235
236fn delta_roll_value(v: Option<&Value>) -> Option<i64> {
237 v.and_then(Value::as_i64)
238 .filter(|roll| matches!(*roll, -1 | 1))
239}
240
241fn first_float_collapsed(v: Option<&Value>, what: &str, warnings: &mut Vec<String>) -> Option<f64> {
245 match v? {
246 Value::Array(a) => {
247 let vals: Vec<f64> = a.iter().map(f).collect();
248 if vals.windows(2).any(|w| w[0].to_bits() != w[1].to_bits()) {
249 warnings.push(format!(
250 "{what}: per-phase-terminal bound is non-uniform; collapsed to the first entry"
251 ));
252 }
253 vals.first().copied()
254 }
255 v => Some(f(v)),
256 }
257}
258
259fn value_alias<'a>(o: &'a Map<String, Value>, primary: &str, legacy: &str) -> Option<&'a Value> {
260 o.get(primary).or_else(|| o.get(legacy))
261}
262
263fn merge_transformer_overlay(
267 subtypes: &Map<String, Value>,
268 overlay: &Map<String, Value>,
269) -> Map<String, Value> {
270 let mut merged = subtypes.clone();
271 for (subtype, names) in overlay {
272 let Value::Object(names) = names else {
273 continue;
274 };
275 let Some(Value::Object(table)) = merged.get_mut(subtype) else {
276 continue;
277 };
278 for (name, fields) in names {
279 let (Value::Object(fields), Some(Value::Object(target))) =
280 (fields, table.get_mut(name))
281 else {
282 continue;
283 };
284 for (key, value) in fields {
285 target.entry(key.clone()).or_insert_with(|| value.clone());
286 }
287 }
288 }
289 merged
290}
291
292fn strings(v: Option<&Value>) -> Vec<String> {
293 v.and_then(Value::as_array)
294 .map(|a| {
295 a.iter()
296 .map(|s| s.as_str().unwrap_or_default().to_string())
297 .collect()
298 })
299 .unwrap_or_default()
300}
301
302fn string(v: Option<&Value>) -> String {
303 v.and_then(Value::as_str).unwrap_or_default().to_string()
304}
305
306fn enum_field<T: DeserializeOwned>(
307 v: Option<&Value>,
308 what: &str,
309 warnings: &mut Vec<String>,
310) -> Option<T> {
311 let value = v?;
312 match serde_json::from_value(value.clone()) {
313 Ok(parsed) => Some(parsed),
314 Err(err) => {
315 warnings.push(format!("{what}: {err}; field ignored"));
316 None
317 }
318 }
319}
320
321fn config(v: Option<&Value>, what: &str, warnings: &mut Vec<String>) -> Configuration {
324 let Some(s) = v.and_then(Value::as_str) else {
325 return Configuration::Wye;
326 };
327 match s.to_ascii_uppercase().as_str() {
328 "WYE" => Configuration::Wye,
329 "DELTA" => Configuration::Delta,
330 "SINGLE_PHASE" => Configuration::SinglePhase,
331 _ => {
332 warnings.push(format!(
333 "{what}: configuration `{s}` is not WYE, DELTA, or SINGLE_PHASE; read as WYE"
334 ));
335 Configuration::Wye
336 }
337 }
338}
339
340const MAX_MATRIX_INDEX: usize = 64;
346
347fn matrix_indices(key: &str, prefix: &str) -> Option<(usize, usize)> {
350 let rest = key.strip_prefix(prefix)?.strip_prefix('_')?;
351 let (i, j) = rest.split_once('_')?;
352 let (i, j) = (i.parse::<usize>().ok()?, j.parse::<usize>().ok()?);
353 (i >= 1 && j >= 1 && i <= MAX_MATRIX_INDEX && j <= MAX_MATRIX_INDEX).then_some((i, j))
354}
355
356fn flat_matrix(o: &Map<String, Value>, prefix: &str) -> Option<Mat> {
362 let mut entries: Vec<(usize, usize, f64)> = Vec::new();
363 let mut n = 0;
364 for (k, v) in o {
365 let Some((i, j)) = matrix_indices(k, prefix) else {
366 continue;
367 };
368 entries.push((i - 1, j - 1, f(v)));
369 n = n.max(i).max(j);
370 }
371 if n == 0 {
372 return None;
373 }
374 let mut m = vec![vec![0.0; n]; n];
375 let mut spelled = vec![vec![false; n]; n];
376 for (i, j, v) in entries {
377 m[i][j] = v;
378 spelled[i][j] = true;
379 }
380 for i in 0..n {
381 for j in 0..n {
382 if spelled[i][j] && !spelled[j][i] {
383 m[j][i] = m[i][j];
384 }
385 }
386 }
387 Some(m)
388}
389
390fn linecode_matrices(o: &Map<String, Value>) -> ([Mat; 6], usize, bool) {
393 let mats = [
394 flat_matrix(o, "R_series"),
395 flat_matrix(o, "X_series"),
396 flat_matrix(o, "G_from"),
397 flat_matrix(o, "B_from"),
398 flat_matrix(o, "G_to"),
399 flat_matrix(o, "B_to"),
400 ];
401 let n = mats.iter().flatten().map(Vec::len).max().unwrap_or(0);
402 let ragged = mats.iter().flatten().any(|m| m.len() < n);
403 (mats.map(|m| pad_to(m.unwrap_or_default(), n)), n, ragged)
404}
405
406fn pad_to(m: Mat, n: usize) -> Mat {
408 if m.len() >= n {
409 return m;
410 }
411 let mut out = vec![vec![0.0; n]; n];
412 for (i, row) in m.into_iter().enumerate() {
413 for (j, v) in row.into_iter().enumerate() {
414 out[i][j] = v;
415 }
416 }
417 out
418}
419
420fn take_extras(
422 o: &Map<String, Value>,
423 known: &[&str],
424 what: &str,
425 warnings: &mut Vec<String>,
426 matrix_prefixes: &[&str],
427) -> Extras {
428 let mut extras = Extras::new();
429 for (k, v) in o {
430 if known.contains(&k.as_str()) {
431 continue;
432 }
433 if matrix_prefixes
434 .iter()
435 .any(|p| matrix_indices(k, p).is_some())
436 {
437 continue;
438 }
439 warnings.push(format!(
440 "{what}: `{k}` is outside the schema; kept in extras"
441 ));
442 extras.insert(k.clone(), v.clone());
443 }
444 extras
445}
446
447impl Reader<'_> {
448 fn document(&mut self, doc: &Map<String, Value>) {
449 if let Some(name) = doc.get("name").and_then(Value::as_str) {
450 self.net.name = Some(name.to_string());
451 }
452 if let Some(frequency) = first_float(
455 doc.get("meta")
456 .and_then(Value::as_object)
457 .and_then(|m| m.get("frequency")),
458 )
459 .or_else(|| first_float(doc.get("base_frequency")))
460 .or_else(|| first_float(doc.get("frequency")))
461 && frequency.is_finite()
462 && frequency > 0.0
463 {
464 self.net.base_frequency = frequency;
465 self.frequency_stated = true;
466 }
467 if let Some(Value::Object(items)) = doc.get("linecode") {
473 self.linecodes(items);
474 }
475 for (key, read) in [
483 ("ibr", Self::ibrs as fn(&mut Self, &Map<String, Value>)),
484 ("control_profile", Self::control_profiles),
485 ] {
486 if let Some(Value::Object(items)) = doc.get(key) {
487 read(self, items);
488 }
489 }
490 for (key, value) in doc {
491 let Value::Object(items) = value else {
492 continue;
493 };
494 match key.as_str() {
495 "bus" => self.buses(items),
496 "linecode" | "name" | "ibr" | "control_profile" => {}
501 "line" => self.lines(items),
502 "switch" => self.switches(items),
503 "load" => self.loads(items),
504 "generator" => self.generators(items),
505 "capacitor" => self.capacitors(items),
506 "shunt" => self.shunts(items),
507 "voltage_source" => self.sources(items),
508 "transformer" => {
509 let overlay = doc
514 .get("extras")
515 .and_then(Value::as_object)
516 .and_then(|e| e.get("transformer"))
517 .and_then(Value::as_object)
518 .filter(|o| !o.is_empty());
519 match overlay {
520 Some(overlay) => {
521 let merged = merge_transformer_overlay(items, overlay);
522 self.transformers(&merged);
523 }
524 None => self.transformers(items),
525 }
526 }
527 "extras" => self.extras_block(items),
528 "terminal_conventions" => {
531 self.net.extras.insert(
532 "bmopf_terminal_conventions".into(),
533 Value::Object(items.clone()),
534 );
535 }
536 "meta" => {
542 self.net
543 .extras
544 .insert("bmopf_meta".into(), Value::Object(items.clone()));
545 }
546 other => {
547 self.net.warnings.push(format!(
548 "top level `{other}` is outside the schema; kept untyped"
549 ));
550 for (name, v) in items {
551 self.net.untyped.push(UntypedObject {
552 class: other.to_string(),
553 name: name.clone(),
554 props: vec![(None, v.to_string())],
555 });
556 }
557 }
558 }
559 }
560 self.warn_orphan_transformer_overlay(doc);
561 if !self.frequency_stated {
562 crate::model::warn_defaulted_frequency(self.net, "frequency");
563 }
564 }
565
566 fn warn_orphan_transformer_overlay(&mut self, doc: &Map<String, Value>) {
572 let overlay = doc
573 .get("extras")
574 .and_then(Value::as_object)
575 .and_then(|e| e.get("transformer"))
576 .and_then(Value::as_object)
577 .filter(|o| !o.is_empty());
578 if overlay.is_some() && self.net.transformers.is_empty() {
579 self.net.warnings.push(
580 "`extras.transformer` carries fields for transformers the document does \
581 not declare; the fields are dropped"
582 .to_string(),
583 );
584 }
585 }
586
587 fn extras_block(&mut self, items: &Map<String, Value>) {
592 let mut stash = Map::new();
593 for (key, value) in items {
594 match (key.as_str(), value) {
595 ("ibr", Value::Object(table)) => self.ibrs(table),
596 ("control_profile", Value::Object(table)) => self.control_profiles(table),
597 ("transformer", Value::Object(_)) => {}
598 _ => {
599 stash.insert(key.clone(), value.clone());
600 }
601 }
602 }
603 if !stash.is_empty() {
604 self.net
605 .extras
606 .insert("bmopf_extras".into(), Value::Object(stash));
607 }
608 }
609
610 fn capacitors(&mut self, items: &Map<String, Value>) {
611 for (name, v) in items {
612 let Value::Object(o) = v else { continue };
613 let known = ["bus", "terminal_map", "configuration", "q_rated", "v_nom"];
614 for (field, value) in [("q_rated", o.get("q_rated")), ("v_nom", o.get("v_nom"))] {
615 if value.is_none() {
616 self.net
617 .warnings
618 .push(format!("capacitor {name}: `{field}` missing; read as NaN"));
619 }
620 }
621 self.net.capacitors.push(DistCapacitor {
622 name: name.clone(),
623 bus: string(o.get("bus")),
624 terminal_map: strings(o.get("terminal_map")),
625 configuration: config(
626 o.get("configuration"),
627 &format!("capacitor {name}"),
628 &mut self.net.warnings,
629 ),
630 q_rated: o.get("q_rated").map_or(f64::NAN, f),
631 v_nom: o.get("v_nom").map_or(f64::NAN, f),
632 extras: take_extras(
633 o,
634 &known,
635 &format!("capacitor {name}"),
636 &mut self.net.warnings,
637 &[],
638 ),
639 });
640 }
641 }
642
643 fn ibrs(&mut self, items: &Map<String, Value>) {
644 const TYPED: &[&str] = &[
645 "bus",
646 "terminal_map",
647 "topology",
648 "prime_mover",
649 "s_max",
650 "i_max",
651 "p_avail",
652 "p_min",
653 "p_max",
654 "q_min",
655 "q_max",
656 "control_profile",
657 "voltage_aggregation",
658 ];
659 for (name, v) in items {
660 let Value::Object(o) = v else { continue };
661 if self.reject_duplicate("ibr", name, |net| {
662 net.ibrs.iter().any(|x| x.name.eq_ignore_ascii_case(name))
663 }) {
664 continue;
665 }
666 let topology = enum_field::<IbrTopology>(
667 o.get("topology"),
668 &format!("ibr {name} topology"),
669 &mut self.net.warnings,
670 )
671 .unwrap_or(IbrTopology::SinglePhase);
672 let prime_mover = enum_field::<IbrPrimeMover>(
673 o.get("prime_mover"),
674 &format!("ibr {name} prime_mover"),
675 &mut self.net.warnings,
676 )
677 .unwrap_or(IbrPrimeMover::Generic);
678 let mut extras = Extras::new();
679 for (key, value) in o {
680 if !TYPED.contains(&key.as_str()) {
681 extras.insert(key.clone(), value.clone());
682 }
683 }
684 self.net.ibrs.push(DistIbr {
685 name: name.clone(),
686 bus: string(o.get("bus")),
687 terminal_map: strings(o.get("terminal_map")),
688 topology,
689 prime_mover,
690 s_max: floats(o.get("s_max")).unwrap_or_default(),
691 i_max: floats(o.get("i_max")),
692 p_avail: first_float(o.get("p_avail")),
693 p_min: floats(o.get("p_min")),
694 p_max: floats(o.get("p_max")),
695 q_min: floats(o.get("q_min")),
696 q_max: floats(o.get("q_max")),
697 control_profile: o
698 .get("control_profile")
699 .and_then(Value::as_str)
700 .map(str::to_string),
701 voltage_aggregation: enum_field::<IbrVoltageAggregation>(
702 o.get("voltage_aggregation"),
703 &format!("ibr {name} voltage_aggregation"),
704 &mut self.net.warnings,
705 ),
706 extras,
707 });
708 }
709 }
710
711 fn reject_duplicate(
718 &mut self,
719 class: &str,
720 name: &str,
721 seen: impl Fn(&MulticonductorNetwork) -> bool,
722 ) -> bool {
723 if !seen(self.net) {
724 return false;
725 }
726 self.net.warnings.push(format!(
727 "{class} {name}: the document declares this name at the top level and under \
728 `extras`; the second copy is dropped"
729 ));
730 true
731 }
732
733 fn control_profiles(&mut self, items: &Map<String, Value>) {
734 for (name, v) in items {
735 let Value::Object(o) = v else { continue };
736 if self.reject_duplicate("control_profile", name, |net| {
737 net.control_profiles
738 .iter()
739 .any(|x| x.name.eq_ignore_ascii_case(name))
740 }) {
741 continue;
742 }
743 let mut profile = DistControlProfile::new(name.clone());
744 if let Some(Value::Object(pf)) = o.get("power_factor") {
745 profile.power_factor =
746 first_float(pf.get("pf")).map(|pf| PowerFactorControl { pf });
747 }
748 if let Some(Value::Object(vv)) = o.get("volt_var") {
749 profile.volt_var = Some(VoltVarControl {
750 voltage_reference: enum_field::<ControlVoltageReference>(
751 vv.get("voltage_reference"),
752 &format!("control_profile {name} volt_var voltage_reference"),
753 &mut self.net.warnings,
754 ),
755 breakpoints: floats(vv.get("breakpoints")).unwrap_or_default(),
756 q_limits: floats(vv.get("q_limits")).unwrap_or_default(),
757 q_unit: enum_field::<ReactivePowerUnit>(
758 vv.get("q_unit"),
759 &format!("control_profile {name} volt_var q_unit"),
760 &mut self.net.warnings,
761 ),
762 q_ref: enum_field::<ReactivePowerReference>(
763 vv.get("q_ref"),
764 &format!("control_profile {name} volt_var q_ref"),
765 &mut self.net.warnings,
766 ),
767 p_min_for_q: first_float(vv.get("p_min_for_q")),
768 p_min_for_q_max: first_float(vv.get("p_min_for_q_max")),
769 });
770 }
771 if let Some(Value::Object(vw)) = o.get("volt_watt") {
772 profile.volt_watt = Some(VoltWattControl {
773 voltage_reference: enum_field::<ControlVoltageReference>(
774 vw.get("voltage_reference"),
775 &format!("control_profile {name} volt_watt voltage_reference"),
776 &mut self.net.warnings,
777 ),
778 breakpoints: floats(vw.get("breakpoints")).unwrap_or_default(),
779 p_limits: floats(vw.get("p_limits")).unwrap_or_default(),
780 p_unit: enum_field::<ActivePowerUnit>(
781 vw.get("p_unit"),
782 &format!("control_profile {name} volt_watt p_unit"),
783 &mut self.net.warnings,
784 ),
785 p_ref: enum_field::<ActivePowerReference>(
786 vw.get("p_ref"),
787 &format!("control_profile {name} volt_watt p_ref"),
788 &mut self.net.warnings,
789 ),
790 });
791 }
792 for (key, value) in o {
793 if !matches!(key.as_str(), "power_factor" | "volt_var" | "volt_watt") {
794 profile.extras.insert(key.clone(), value.clone());
795 }
796 }
797 self.net.control_profiles.push(profile);
798 }
799 }
800
801 fn buses(&mut self, items: &Map<String, Value>) {
802 for (id, v) in items {
803 let Value::Object(o) = v else { continue };
804 let known = [
805 "terminal_names",
806 "perfectly_grounded_terminals",
807 "longitude",
808 "latitude",
809 "v_min",
810 "v_max",
811 "vpn_min",
812 "vpn_max",
813 "vpp_min",
814 "vpp_max",
815 "vpos_min",
816 "vpos_max",
817 "vneg_max",
818 "vzero_max",
819 "vn_max",
820 "vsym_min",
821 "vsym_max",
822 ];
823 let lon = first_float(o.get("longitude")).filter(|v| v.is_finite());
824 let lat = first_float(o.get("latitude")).filter(|v| v.is_finite());
825 let has_lon = o.contains_key("longitude");
826 let has_lat = o.contains_key("latitude");
827 let location = match (lon, lat) {
828 (Some(x), Some(y)) => {
829 self.net.geo = Some(GeoMeta {
830 space: CoordinateSpace::Geographic { crs: None },
831 kind: Some(CoordsKind::Source),
832 });
833 Some(Location { x, y, kind: None })
834 }
835 _ if has_lon || has_lat => {
836 self.net.warnings.push(format!(
837 "bus {id}: longitude/latitude sideload is incomplete or nonfinite; kept in extras"
838 ));
839 None
840 }
841 _ => None,
842 };
843 let mut extras =
844 take_extras(o, &known, &format!("bus {id}"), &mut self.net.warnings, &[]);
845 if location.is_none() {
846 if let Some(value) = o.get("longitude") {
847 extras.insert("longitude".into(), value.clone());
848 }
849 if let Some(value) = o.get("latitude") {
850 extras.insert("latitude".into(), value.clone());
851 }
852 }
853 let legacy_min = floats(o.get("vsym_min"));
859 let legacy_max = floats(o.get("vsym_max"));
860 if legacy_min.is_some() || legacy_max.is_some() {
861 self.net.warnings.push(format!(
862 "bus {id}: legacy vsym_min/vsym_max arrays mapped to the per-sequence \
863 scalars assuming zero/positive/negative order"
864 ));
865 }
866 let legacy_vpos_min = legacy_min.as_ref().and_then(|v| v.get(1).copied());
867 let legacy_vpos_max = legacy_max.as_ref().and_then(|v| v.get(1).copied());
868 let legacy_vzero_max = legacy_max.as_ref().and_then(|v| v.first().copied());
869 let legacy_vneg_max = legacy_max.as_ref().and_then(|v| v.get(2).copied());
870 if legacy_min
871 .as_ref()
872 .is_some_and(|v| [v.first(), v.get(2)].iter().flatten().any(|&&m| m != 0.0))
873 {
874 self.net.warnings.push(format!(
875 "bus {id}: legacy vsym_min zero/negative sequence lower bounds have no \
876 slot in schema 0.1.0 (fixed at 0); dropped"
877 ));
878 }
879 self.net.buses.push(DistBus {
880 id: id.clone(),
881 terminals: strings(o.get("terminal_names")),
882 grounded: strings(o.get("perfectly_grounded_terminals")),
883 v_min: first_float_collapsed(
884 o.get("v_min"),
885 &format!("bus {id} v_min"),
886 &mut self.net.warnings,
887 ),
888 v_max: first_float_collapsed(
889 o.get("v_max"),
890 &format!("bus {id} v_max"),
891 &mut self.net.warnings,
892 ),
893 vpn_min: floats(o.get("vpn_min")),
894 vpn_max: floats(o.get("vpn_max")),
895 vpp_min: floats(o.get("vpp_min")),
896 vpp_max: floats(o.get("vpp_max")),
897 vpos_min: first_float(o.get("vpos_min")).or(legacy_vpos_min),
898 vpos_max: first_float(o.get("vpos_max")).or(legacy_vpos_max),
899 vneg_max: first_float(o.get("vneg_max")).or(legacy_vneg_max),
900 vzero_max: first_float(o.get("vzero_max")).or(legacy_vzero_max),
901 vn_max: first_float(o.get("vn_max")),
902 location,
903 extras,
904 });
905 }
906 }
907
908 fn linecodes(&mut self, items: &Map<String, Value>) {
909 for (name, v) in items {
910 let Value::Object(o) = v else { continue };
911 let ([r, x, gf, bf, gt, bt], n, ragged) = linecode_matrices(o);
914 if ragged {
915 self.net.warnings.push(format!(
916 "linecode {name}: matrix sizes disagree; smaller ones padded \
917 with zeros to {n}x{n}"
918 ));
919 }
920 let code = DistLineCode {
921 name: name.clone(),
922 n_conductors: n,
923 r_series: r,
924 x_series: x,
925 g_from: gf,
926 b_from: bf,
927 g_to: gt,
928 b_to: bt,
929 i_max: floats(o.get("i_max")),
930 s_max: floats(o.get("s_max")),
931 source: o.get("source").and_then(Value::as_str).map(String::from),
932 extras: take_extras(
933 o,
934 &["i_max", "s_max", "source"],
935 &format!("linecode {name}"),
936 &mut self.net.warnings,
937 &["R_series", "X_series", "G_from", "G_to", "B_from", "B_to"],
938 ),
939 };
940 self.net.linecodes.push(code);
941 }
942 }
943
944 fn lines(&mut self, items: &Map<String, Value>) {
945 let mut taken: std::collections::BTreeSet<String> = self
949 .net
950 .linecodes
951 .iter()
952 .map(|c| c.name.to_ascii_lowercase())
953 .collect();
954 for (name, v) in items {
955 let Value::Object(o) = v else { continue };
956 let known = [
957 "length",
958 "linecode",
959 "bus_from",
960 "bus_to",
961 "terminal_map_from",
962 "terminal_map_to",
963 "i_max",
964 "s_max",
965 ];
966 let mut linecode = string(o.get("linecode"));
970 let mut length = o.get("length").map_or(f64::NAN, f);
971 let inline = linecode.is_empty() && o.contains_key("R_series_1_1");
973 if inline {
974 linecode = self.synthesized_linecode(name, o, &mut taken);
975 if length.is_finite() && (length - 1.0).abs() > f64::EPSILON {
980 self.net.warnings.push(format!(
981 "line {name}: inline impedance matrices are absolute, so the \
982 descriptive `length` {length} does not scale them; the model \
983 keeps the line at unit length"
984 ));
985 }
986 length = 1.0;
987 } else if !length.is_finite() {
988 self.net.warnings.push(format!(
994 "line {name}: `length` missing or non-finite; impedances derived from \
995 this line are undefined"
996 ));
997 }
998 self.net.lines.push(DistLine {
999 name: name.clone(),
1000 bus_from: string(o.get("bus_from")),
1001 bus_to: string(o.get("bus_to")),
1002 terminal_map_from: strings(o.get("terminal_map_from")),
1003 terminal_map_to: strings(o.get("terminal_map_to")),
1004 linecode,
1005 length,
1006 route: None,
1007 i_max: floats(o.get("i_max")),
1008 s_max: floats(o.get("s_max")),
1009 extras: take_extras(
1010 o,
1011 &known,
1012 &format!("line {name}"),
1013 &mut self.net.warnings,
1014 if inline {
1015 &["R_series", "X_series", "G_from", "G_to", "B_from", "B_to"]
1016 } else {
1017 &[]
1018 },
1019 ),
1020 });
1021 }
1022 }
1023
1024 fn synthesized_linecode(
1029 &mut self,
1030 line: &str,
1031 o: &Map<String, Value>,
1032 taken: &mut std::collections::BTreeSet<String>,
1033 ) -> String {
1034 let mut name = line.to_string();
1035 while taken.contains(&name.to_ascii_lowercase()) {
1036 name.push('_');
1037 }
1038 taken.insert(name.to_ascii_lowercase());
1039 self.net.warnings.push(format!(
1040 "line {line}: inline impedance matrices read into synthesized linecode `{name}`"
1041 ));
1042 let ([r, x, gf, bf, gt, bt], n, ragged) = linecode_matrices(o);
1043 if ragged {
1044 self.net.warnings.push(format!(
1049 "line {line}: inline matrix sizes disagree; smaller ones padded \
1050 with zeros to {n}x{n}"
1051 ));
1052 }
1053 self.net.linecodes.push(DistLineCode {
1054 name: name.clone(),
1055 n_conductors: n,
1056 r_series: r,
1057 x_series: x,
1058 g_from: gf,
1059 b_from: bf,
1060 g_to: gt,
1061 b_to: bt,
1062 i_max: None,
1063 s_max: None,
1064 source: None,
1065 extras: Extras::new(),
1066 });
1067 name
1068 }
1069
1070 fn switches(&mut self, items: &Map<String, Value>) {
1071 for (name, v) in items {
1072 let Value::Object(o) = v else { continue };
1073 let known = [
1074 "bus_from",
1075 "bus_to",
1076 "terminal_map_from",
1077 "terminal_map_to",
1078 "open_switch",
1079 "i_max",
1080 ];
1081 self.net.switches.push(DistSwitch {
1082 name: name.clone(),
1083 bus_from: string(o.get("bus_from")),
1084 bus_to: string(o.get("bus_to")),
1085 terminal_map_from: strings(o.get("terminal_map_from")),
1086 terminal_map_to: strings(o.get("terminal_map_to")),
1087 open: o
1088 .get("open_switch")
1089 .and_then(Value::as_bool)
1090 .unwrap_or(false),
1091 i_max: floats(o.get("i_max")),
1092 extras: take_extras(
1093 o,
1094 &known,
1095 &format!("switch {name}"),
1096 &mut self.net.warnings,
1097 &[],
1098 ),
1099 });
1100 }
1101 }
1102
1103 fn loads(&mut self, items: &Map<String, Value>) {
1104 for (name, v) in items {
1105 let Value::Object(o) = v else { continue };
1106 let known = [
1107 "p_nom",
1108 "q_nom",
1109 "bus",
1110 "configuration",
1111 "terminal_map",
1112 "model",
1113 "v_nom",
1114 "alpha_z",
1115 "alpha_i",
1116 "alpha_p",
1117 "beta_z",
1118 "beta_i",
1119 "beta_p",
1120 "gamma_p",
1121 "gamma_q",
1122 ];
1123 let v_nom = floats(o.get("v_nom")).unwrap_or_default();
1124 let has_zip = [
1125 "alpha_z", "alpha_i", "alpha_p", "beta_z", "beta_i", "beta_p",
1126 ]
1127 .iter()
1128 .any(|key| o.get(*key).is_some());
1129 let has_exp = o.get("gamma_p").is_some() || o.get("gamma_q").is_some();
1130 let model = o
1131 .get("model")
1132 .and_then(Value::as_str)
1133 .unwrap_or("POWER")
1134 .to_ascii_uppercase();
1135 let voltage_model = if has_exp {
1136 DistLoadVoltageModel::Exponential {
1137 v_nom,
1138 gamma_p: floats(o.get("gamma_p")).unwrap_or_default(),
1139 gamma_q: floats(o.get("gamma_q")).unwrap_or_default(),
1140 }
1141 } else if has_zip {
1142 DistLoadVoltageModel::Zip {
1143 v_nom,
1144 alpha_z: floats(o.get("alpha_z")).unwrap_or_default(),
1145 alpha_i: floats(o.get("alpha_i")).unwrap_or_default(),
1146 alpha_p: floats(o.get("alpha_p")).unwrap_or_default(),
1147 beta_z: floats(o.get("beta_z")).unwrap_or_default(),
1148 beta_i: floats(o.get("beta_i")).unwrap_or_default(),
1149 beta_p: floats(o.get("beta_p")).unwrap_or_default(),
1150 }
1151 } else if model.contains("IMPEDANCE") {
1152 DistLoadVoltageModel::ConstantImpedance { v_nom }
1153 } else if model.contains("CURRENT") {
1154 DistLoadVoltageModel::ConstantCurrent { v_nom }
1155 } else {
1156 DistLoadVoltageModel::ConstantPower { v_nom }
1157 };
1158 self.net.loads.push(DistLoad {
1159 name: name.clone(),
1160 bus: string(o.get("bus")),
1161 terminal_map: strings(o.get("terminal_map")),
1162 configuration: config(
1163 o.get("configuration"),
1164 &format!("load {name}"),
1165 &mut self.net.warnings,
1166 ),
1167 p_nom: floats(o.get("p_nom")).unwrap_or_default(),
1168 q_nom: floats(o.get("q_nom")).unwrap_or_default(),
1169 voltage_model,
1170 extras: take_extras(
1171 o,
1172 &known,
1173 &format!("load {name}"),
1174 &mut self.net.warnings,
1175 &[],
1176 ),
1177 });
1178 }
1179 }
1180
1181 fn generators(&mut self, items: &Map<String, Value>) {
1182 for (name, v) in items {
1183 let Value::Object(o) = v else { continue };
1184 let known = [
1185 "p_min",
1186 "p_max",
1187 "q_min",
1188 "q_max",
1189 "s_max",
1190 "i_max",
1191 "cost",
1192 "bus",
1193 "configuration",
1194 "terminal_map",
1195 ];
1196 let p_min = floats(o.get("p_min"));
1197 let p_max = floats(o.get("p_max"));
1198 let q_min = floats(o.get("q_min"));
1199 let q_max = floats(o.get("q_max"));
1200 let pinned = |lo: &Option<Vec<f64>>, hi: &Option<Vec<f64>>| match (lo, hi) {
1203 (Some(a), Some(b)) if a == b => a.clone(),
1204 _ => Vec::new(),
1205 };
1206 let cost = match o.get("cost") {
1210 Some(Value::Array(a)) => {
1211 let vals: Vec<f64> = a.iter().map(f).collect();
1212 if vals.windows(2).any(|w| w[0].to_bits() != w[1].to_bits()) {
1215 self.net.warnings.push(format!(
1216 "generator {name}: per-phase cost is non-uniform; \
1217 collapsed to the first entry"
1218 ));
1219 }
1220 vals.first().copied()
1221 }
1222 Some(v) => Some(f(v)),
1223 None => None,
1224 };
1225 self.net.generators.push(DistGenerator {
1226 name: name.clone(),
1227 bus: string(o.get("bus")),
1228 terminal_map: strings(o.get("terminal_map")),
1229 configuration: config(
1230 o.get("configuration"),
1231 &format!("generator {name}"),
1232 &mut self.net.warnings,
1233 ),
1234 p_nom: pinned(&p_min, &p_max),
1235 q_nom: pinned(&q_min, &q_max),
1236 p_min,
1237 p_max,
1238 q_min,
1239 q_max,
1240 cost,
1241 s_max: floats(o.get("s_max")),
1242 i_max: floats(o.get("i_max")),
1243 extras: take_extras(
1244 o,
1245 &known,
1246 &format!("generator {name}"),
1247 &mut self.net.warnings,
1248 &[],
1249 ),
1250 });
1251 }
1252 }
1253
1254 fn shunts(&mut self, items: &Map<String, Value>) {
1255 for (name, v) in items {
1256 let Value::Object(o) = v else { continue };
1257 let g = flat_matrix(o, "G").unwrap_or_default();
1258 let b = flat_matrix(o, "B").unwrap_or_default();
1259 let n = g.len().max(b.len());
1260 if g.len() != b.len() {
1261 self.net.warnings.push(format!(
1262 "shunt {name}: G is {gx}x{gx} but B is {bx}x{bx}; the smaller \
1263 padded with zeros to {n}x{n}",
1264 gx = g.len(),
1265 bx = b.len(),
1266 ));
1267 }
1268 self.net.shunts.push(DistShunt {
1269 name: name.clone(),
1270 bus: string(o.get("bus")),
1271 terminal_map: strings(o.get("terminal_map")),
1272 g: pad_to(g, n),
1273 b: pad_to(b, n),
1274 extras: take_extras(
1275 o,
1276 &["bus", "terminal_map"],
1277 &format!("shunt {name}"),
1278 &mut self.net.warnings,
1279 &["G", "B"],
1280 ),
1281 });
1282 }
1283 }
1284
1285 fn sources(&mut self, items: &Map<String, Value>) {
1286 for (name, v) in items {
1287 let Value::Object(o) = v else { continue };
1288 let known = ["v_magnitude", "v_angle", "bus", "terminal_map"];
1289 self.net.sources.push(VoltageSource {
1290 name: name.clone(),
1291 bus: string(o.get("bus")),
1292 terminal_map: strings(o.get("terminal_map")),
1293 v_magnitude: floats(o.get("v_magnitude")).unwrap_or_default(),
1294 v_angle: floats(o.get("v_angle")).unwrap_or_default(),
1295 extras: take_extras(
1296 o,
1297 &known,
1298 &format!("voltage source {name}"),
1299 &mut self.net.warnings,
1300 &[],
1301 ),
1302 });
1303 }
1304 }
1305
1306 fn transformers(&mut self, subtypes: &Map<String, Value>) {
1307 for (subtype, group) in subtypes {
1308 let Value::Object(items) = group else {
1309 continue;
1310 };
1311 for (name, v) in items {
1312 let Value::Object(o) = v else { continue };
1313 match subtype.as_str() {
1314 "n_winding" => {
1315 let t = self.n_winding_transformer(name, o);
1316 self.net.transformers.push(t);
1317 }
1318 "single_phase_autotransformer" | "open_delta_regulator" => {
1319 self.net.warnings.push(format!(
1320 "transformer {name}: subtype `{subtype}` is not typed yet; kept untyped"
1321 ));
1322 self.net.untyped.push(UntypedObject {
1323 class: format!("transformer.{subtype}"),
1324 name: name.clone(),
1325 props: vec![(None, v.to_string())],
1326 });
1327 }
1328 _ => {
1329 let t = self.transformer(subtype, name, o);
1330 self.net.transformers.push(t);
1331 }
1332 }
1333 }
1334 }
1335 }
1336
1337 #[allow(clippy::too_many_lines)] fn transformer(
1339 &mut self,
1340 subtype: &str,
1341 name: &str,
1342 o: &Map<String, Value>,
1343 ) -> DistTransformer {
1344 let known = [
1345 "bus_from",
1346 "bus_to",
1347 "terminal_map_from",
1348 "terminal_map_to",
1349 "s_rating",
1350 "v_nom_from",
1351 "v_nom_to",
1352 "v_ref_from",
1353 "v_ref_to",
1354 "g_no_load",
1355 "b_no_load",
1356 "r_series",
1357 "x_series",
1358 "r_series_from",
1359 "r_series_to",
1360 "x_series_from",
1361 "x_series_to",
1362 "r_neutral_from",
1363 "x_neutral_from",
1364 "r_neutral_to",
1365 "x_neutral_to",
1366 "tap",
1367 "tap_min",
1368 "tap_max",
1369 ];
1370 if !matches!(
1371 subtype,
1372 "single_phase" | "center_tap" | "wye_delta" | "delta_wye"
1373 ) {
1374 self.net.warnings.push(format!(
1375 "transformer {name}: subtype `{subtype}` is outside the schema; \
1376 read as a single phase pair"
1377 ));
1378 }
1379 let s = o.get("s_rating").map_or(f64::NAN, f);
1380 let v_from = value_alias(o, "v_nom_from", "v_ref_from").map_or(f64::NAN, f);
1381 let v_to = value_alias(o, "v_nom_to", "v_ref_to").map_or(f64::NAN, f);
1382 let positive = |v: f64| v.is_finite() && v > 0.0;
1383 if !positive(s) || !positive(v_from) || !positive(v_to) {
1384 self.net.warnings.push(format!(
1385 "transformer {name}: s_rating or v_nom missing or nonpositive; \
1386 impedances read as zero"
1387 ));
1388 }
1389 let three_phase = matches!(subtype, "wye_delta" | "delta_wye");
1390 let phases = if three_phase { 3 } else { 1 };
1391
1392 let pct = |x_ohm: f64, v: f64| {
1393 if s > 0.0 && v > 0.0 {
1394 x_ohm / (v * v / s) * 100.0
1395 } else {
1396 0.0
1397 }
1398 };
1399 let has_split_three_phase_fields = [
1400 "r_series_from",
1401 "r_series_to",
1402 "x_series_from",
1403 "x_series_to",
1404 ]
1405 .iter()
1406 .any(|k| o.contains_key(*k));
1407 let (r_from_pct, r_to_pct, xsc_pct) = if three_phase && has_split_three_phase_fields {
1408 let r_from = pct(o.get("r_series_from").map_or(0.0, f), v_from);
1409 let r_to = pct(o.get("r_series_to").map_or(0.0, f), v_to);
1410 let x_from = pct(o.get("x_series_from").map_or(0.0, f), v_from);
1411 let x_to = pct(o.get("x_series_to").map_or(0.0, f), v_to);
1412 (r_from, r_to, vec![x_from + x_to])
1413 } else if three_phase {
1414 let wye_v = if subtype == "wye_delta" { v_from } else { v_to };
1415 let r = pct(o.get("r_series").map_or(0.0, f), wye_v);
1418 let x = pct(o.get("x_series").map_or(0.0, f), wye_v);
1419 (r / 2.0, r / 2.0, vec![x])
1420 } else {
1421 let r_from = pct(o.get("r_series_from").map_or(0.0, f), v_from);
1422 let r_to = pct(o.get("r_series_to").map_or(0.0, f), v_to);
1423 let x_from = pct(o.get("x_series_from").map_or(0.0, f), v_from);
1424 let x_to = pct(o.get("x_series_to").map_or(0.0, f), v_to);
1425 let xsc = if subtype == "center_tap" {
1426 vec![x_from + x_to, x_from + x_to, 2.0 * x_to]
1427 } else {
1428 vec![x_from + x_to]
1429 };
1430 (r_from, r_to, xsc)
1431 };
1432
1433 let conn = |delta: bool| {
1434 if delta {
1435 WindingConn::Delta
1436 } else {
1437 WindingConn::Wye
1438 }
1439 };
1440 let mut windings = vec![
1441 Winding {
1442 bus: string(o.get("bus_from")),
1443 terminal_map: strings(o.get("terminal_map_from")),
1444 conn: conn(subtype == "delta_wye"),
1445 v_ref: v_from,
1446 s_rating: s,
1447 r_pct: r_from_pct,
1448 tap: first_float(o.get("tap")).unwrap_or(1.0),
1449 r_neutral: first_float(o.get("r_neutral_from")),
1450 x_neutral: first_float(o.get("x_neutral_from")),
1451 },
1452 Winding {
1453 bus: string(o.get("bus_to")),
1454 terminal_map: strings(o.get("terminal_map_to")),
1455 conn: conn(subtype == "wye_delta"),
1456 v_ref: v_to,
1457 s_rating: s,
1458 r_pct: r_to_pct,
1459 tap: 1.0,
1460 r_neutral: first_float(o.get("r_neutral_to")),
1461 x_neutral: first_float(o.get("x_neutral_to")),
1462 },
1463 ];
1464 expand_center_tap_windings(subtype, &mut windings, &self.net.buses);
1465 if subtype == "center_tap"
1466 && let Some(w) = windings.get(1)
1467 && let Some(band) = phase_to_neutral_midpoint(w, &self.net.buses)
1468 && (w.v_ref - band).abs() > (w.v_ref / 2.0 - band).abs() * 4.0
1469 {
1470 self.net.warnings.push(format!(
1471 "transformer {name}: v_nom_to {} is about twice the {band} V the \
1472 secondary bus states phase to neutral, the value a full span \
1473 reading gives; the convention is the per leg voltage",
1474 w.v_ref
1475 ));
1476 }
1477 let mut extras = take_extras(
1478 o,
1479 &known,
1480 &format!("transformer {name}"),
1481 &mut self.net.warnings,
1482 &[],
1483 );
1484 for key in ["tap_min", "tap_max"] {
1485 if let Some(v) = o.get(key) {
1486 extras.insert(key.into(), v.clone());
1487 }
1488 }
1489 for key in ["g_no_load", "b_no_load"] {
1490 if let Some(v) = o.get(key) {
1491 extras.insert(key.into(), v.clone());
1492 }
1493 }
1494 extras.insert("bmopf_subtype".into(), subtype.into());
1497 DistTransformer {
1498 name: name.to_string(),
1499 windings,
1500 xsc_pct,
1501 phases,
1502 extras,
1503 }
1504 }
1505
1506 fn bounded_windings<'a>(&mut self, name: &str, items: &'a [Value]) -> &'a [Value] {
1511 const MAX_WINDINGS: usize = 64;
1512 if items.len() > MAX_WINDINGS {
1513 self.net.warnings.push(format!(
1514 "transformer {name}: {} windings exceed the supported maximum of \
1515 {MAX_WINDINGS}; the rest are ignored",
1516 items.len()
1517 ));
1518 }
1519 &items[..items.len().min(MAX_WINDINGS)]
1520 }
1521
1522 fn n_winding_transformer(&mut self, name: &str, o: &Map<String, Value>) -> DistTransformer {
1523 let known = ["windings", "x_sc", "s_rating", "g_no_load", "b_no_load"];
1524 let s = o.get("s_rating").map_or(f64::NAN, f);
1525 let mut windings = Vec::new();
1526 let mut delta_rolls = Map::new();
1527 if let Some(items) = o.get("windings").and_then(Value::as_array) {
1528 for (idx, item) in self.bounded_windings(name, items).iter().enumerate() {
1529 let Some(w) = item.as_object() else {
1530 self.net.warnings.push(format!(
1531 "transformer {name}: winding {} is not an object; skipped",
1532 idx + 1
1533 ));
1534 continue;
1535 };
1536 let terminal_map = strings(w.get("terminal_map"));
1537 let bmopf_v_nom = value_alias(w, "v_nom", "v_ref").map_or(f64::NAN, f);
1538 let r_winding = w.get("r_winding").map_or(0.0, f);
1539 let connection = w
1540 .get("configuration")
1541 .or_else(|| w.get("connection"))
1542 .and_then(Value::as_str)
1543 .unwrap_or("WYE")
1544 .to_ascii_uppercase();
1545 if !matches!(connection.as_str(), "WYE" | "DELTA") {
1546 self.net.warnings.push(format!(
1547 "transformer {name}: winding {} connection `{connection}` is not WYE or DELTA; read as WYE",
1548 idx + 1
1549 ));
1550 }
1551 let conn = if connection == "DELTA" {
1552 WindingConn::Delta
1553 } else {
1554 WindingConn::Wye
1555 };
1556 if let Some(delta_roll) = delta_roll_value(w.get("delta_roll")) {
1557 delta_rolls.insert((idx + 1).to_string(), Value::from(delta_roll));
1558 }
1559 let r_pct = if let Some(base_z) =
1560 n_winding_base_from_bmopf(conn, &terminal_map, bmopf_v_nom, s)
1561 {
1562 r_winding / base_z * 100.0
1563 } else {
1564 0.0
1565 };
1566 windings.push(Winding {
1567 bus: string(w.get("bus")),
1568 terminal_map: terminal_map.clone(),
1569 conn,
1570 v_ref: n_winding_internal_v_ref(conn, &terminal_map, bmopf_v_nom),
1571 s_rating: s,
1572 r_pct,
1573 tap: 1.0,
1574 r_neutral: None,
1575 x_neutral: None,
1576 });
1577 }
1578 }
1579 let base_z = windings
1580 .first()
1581 .and_then(|w| n_winding_base_from_internal(w, s))
1582 .unwrap_or(f64::NAN);
1583 let mut xsc_pct = Vec::new();
1584 let x_sc = o.get("x_sc").and_then(Value::as_object);
1585 for (i, j) in pair_keys(windings.len()) {
1586 let key = format!("{}_{}", i + 1, j + 1);
1587 let x = x_sc.and_then(|m| m.get(&key)).map_or(0.0, f);
1588 xsc_pct.push(if base_z.is_finite() && base_z > 0.0 {
1589 x / base_z * 100.0
1590 } else {
1591 0.0
1592 });
1593 }
1594 let mut extras = take_extras(
1595 o,
1596 &known,
1597 &format!("transformer {name}"),
1598 &mut self.net.warnings,
1599 &[],
1600 );
1601 extras.insert("bmopf_subtype".into(), "n_winding".into());
1602 if !delta_rolls.is_empty() {
1603 extras.insert(BMOPF_DELTA_ROLLS_EXTRA.into(), Value::Object(delta_rolls));
1604 }
1605 for key in ["g_no_load", "b_no_load"] {
1606 if let Some(v) = o.get(key) {
1607 extras.insert(key.into(), v.clone());
1608 }
1609 }
1610 DistTransformer {
1611 name: name.to_string(),
1612 phases: windings
1613 .iter()
1614 .map(|w| n_winding_phase_count(w.conn, &w.terminal_map))
1615 .max()
1616 .unwrap_or(1)
1617 .max(1),
1618 windings,
1619 xsc_pct,
1620 extras,
1621 }
1622 }
1623}
1624
1625fn phase_to_neutral_midpoint(w: &Winding, buses: &[DistBus]) -> Option<f64> {
1630 let bus = buses.iter().find(|b| b.id == w.bus)?;
1631 let lo = bus.vpn_min.as_ref()?.first()?;
1632 let hi = bus.vpn_max.as_ref()?.first()?;
1633 let mid = (lo + hi) / 2.0;
1634 (mid.is_finite() && mid > 0.0 && w.v_ref.is_finite()).then_some(mid)
1635}
1636
1637fn expand_center_tap_windings(subtype: &str, windings: &mut Vec<Winding>, buses: &[DistBus]) {
1638 if subtype != "center_tap" || windings[1].terminal_map.len() < 3 {
1639 return;
1640 }
1641 let to = windings.pop().expect("secondary winding exists");
1642 let neutral_idx = center_tap_neutral_index(&to, buses);
1643 let canonical = neutral_idx == 1;
1644 let common = to
1645 .terminal_map
1646 .get(neutral_idx)
1647 .cloned()
1648 .unwrap_or_default();
1649 let hots: Vec<String> = to
1650 .terminal_map
1651 .iter()
1652 .enumerate()
1653 .filter_map(|(idx, term)| (idx != neutral_idx).then_some(term.clone()))
1654 .collect();
1655 let hot_a = hots.first().cloned().unwrap_or_default();
1656 let hot_b = hots.get(1).cloned().unwrap_or_default();
1657 let v_ref = if canonical { to.v_ref } else { to.v_ref / 2.0 };
1658 let r_pct = if canonical { to.r_pct } else { to.r_pct * 2.0 };
1659 let half = Winding {
1660 bus: to.bus.clone(),
1661 terminal_map: vec![hot_a, common.clone()],
1662 conn: WindingConn::Wye,
1663 v_ref,
1664 s_rating: to.s_rating,
1665 r_pct,
1666 tap: to.tap,
1667 r_neutral: to.r_neutral,
1668 x_neutral: to.x_neutral,
1669 };
1670 let other_half = Winding {
1671 bus: to.bus,
1672 terminal_map: vec![common, hot_b],
1673 conn: WindingConn::Wye,
1674 v_ref,
1675 s_rating: to.s_rating,
1676 r_pct,
1677 tap: to.tap,
1678 r_neutral: None,
1679 x_neutral: None,
1680 };
1681 windings.push(half);
1682 windings.push(other_half);
1683}
1684
1685fn center_tap_neutral_index(to: &Winding, buses: &[DistBus]) -> usize {
1686 if let Some(bus) = buses.iter().find(|bus| bus.id == to.bus)
1687 && let Some((idx, _)) = to
1688 .terminal_map
1689 .iter()
1690 .enumerate()
1691 .find(|(_, term)| bus.grounded.iter().any(|ground| ground == *term))
1692 {
1693 return idx;
1694 }
1695 to.terminal_map
1696 .iter()
1697 .position(|term| term.eq_ignore_ascii_case("n") || term == "4")
1698 .unwrap_or_else(|| to.terminal_map.len() - 1)
1699}
1700
1701fn n_winding_internal_v_ref(conn: WindingConn, terminal_map: &[String], bmopf_v_nom: f64) -> f64 {
1702 if conn == WindingConn::Wye && n_winding_phase_count(conn, terminal_map) >= 2 {
1703 bmopf_v_nom * 3f64.sqrt()
1704 } else {
1705 bmopf_v_nom
1706 }
1707}
1708
1709fn n_winding_bmopf_v_nom_from_internal(w: &Winding) -> f64 {
1710 if w.conn == WindingConn::Wye && n_winding_phase_count(w.conn, &w.terminal_map) >= 2 {
1711 w.v_ref / 3f64.sqrt()
1712 } else {
1713 w.v_ref
1714 }
1715}
1716
1717fn n_winding_base_from_bmopf(
1718 conn: WindingConn,
1719 terminal_map: &[String],
1720 bmopf_v_nom: f64,
1721 s: f64,
1722) -> Option<f64> {
1723 n_winding_impedance_base(n_winding_phase_count(conn, terminal_map), bmopf_v_nom, s)
1724}
1725
1726fn n_winding_base_from_internal(w: &Winding, s: f64) -> Option<f64> {
1727 n_winding_base_from_bmopf(
1728 w.conn,
1729 &w.terminal_map,
1730 n_winding_bmopf_v_nom_from_internal(w),
1731 s,
1732 )
1733}
1734
1735#[cfg(test)]
1736mod tests {
1737 use super::{MAX_MATRIX_INDEX, is_numeric_field, matrix_indices};
1738 use serde_json::Value;
1739
1740 #[test]
1745 fn the_numeric_check_claims_exactly_the_matrix_keys_the_reader_assembles() {
1746 let over = MAX_MATRIX_INDEX + 1;
1747 for key in [
1748 "R_series_1_1",
1749 "X_series_2_3",
1750 &format!("B_from_{MAX_MATRIX_INDEX}_{MAX_MATRIX_INDEX}"),
1751 "G_to_1_2",
1752 "B_1_1",
1753 ] {
1754 assert!(is_numeric_field(key), "{key} should be numeric");
1755 }
1756 for key in [
1757 "R_series_0_1",
1758 "R_series_1_0",
1759 &format!("R_series_{over}_1"),
1760 &format!("R_series_1_{over}"),
1761 "R_series_1",
1762 "R_series_a_b",
1763 "R_series",
1764 ] {
1765 assert!(!is_numeric_field(key), "{key} should not be numeric");
1766 assert!(
1767 super::NUMERIC_MATRIX_PREFIXES
1768 .iter()
1769 .all(|p| matrix_indices(key, p).is_none()),
1770 "{key} disagrees with the reader"
1771 );
1772 }
1773 }
1774
1775 #[test]
1779 fn numeric_field_names_match_the_vendored_schema() {
1780 fn number(v: &Value) -> bool {
1781 v.get("type").and_then(Value::as_str) == Some("number")
1782 || v.get("$ref")
1783 .and_then(Value::as_str)
1784 .is_some_and(|r| r.ends_with("nonnegative_number"))
1785 }
1786 fn is_numeric(v: &Value) -> bool {
1787 number(v)
1788 || (v.get("type").and_then(Value::as_str) == Some("array")
1789 && v.get("items").is_some_and(number))
1790 }
1791 fn walk(node: &Value, names: &mut Vec<String>, patterns: &mut Vec<String>) {
1792 match node {
1793 Value::Object(o) => {
1794 for (key, target) in
1795 [("properties", &mut *names), ("patternProperties", patterns)]
1796 {
1797 if let Some(Value::Object(props)) = o.get(key) {
1798 target.extend(
1799 props
1800 .iter()
1801 .filter(|(_, v)| is_numeric(v))
1802 .map(|(k, _)| k.clone()),
1803 );
1804 }
1805 }
1806 o.values().for_each(|v| walk(v, names, patterns));
1807 }
1808 Value::Array(a) => a.iter().for_each(|v| walk(v, names, patterns)),
1809 _ => {}
1810 }
1811 }
1812
1813 let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1814 .join("../tests/data/dist/bmopf/draft_bmopf_schema.json");
1815 let schema: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
1816 let (mut names, mut patterns) = (Vec::new(), Vec::new());
1817 walk(&schema, &mut names, &mut patterns);
1818 names.sort_unstable();
1819 names.dedup();
1820 patterns.sort_unstable();
1821 patterns.dedup();
1822 assert!(!names.is_empty() && !patterns.is_empty());
1823
1824 for name in &names {
1825 assert!(is_numeric_field(name), "the reader does not know `{name}`");
1826 }
1827 for pattern in &patterns {
1829 let sample = pattern
1830 .trim_start_matches('^')
1831 .trim_end_matches('$')
1832 .replace("\\d+", "7");
1833 assert!(
1834 is_numeric_field(&sample),
1835 "the reader does not know `{sample}` (from `{pattern}`)"
1836 );
1837 }
1838 for name in super::NUMERIC_FIELDS {
1840 assert!(
1841 names.iter().any(|n| n == name),
1842 "`{name}` is not a schema number"
1843 );
1844 }
1845 assert!(
1847 super::NUMERIC_FIELDS.is_sorted(),
1848 "NUMERIC_FIELDS must stay sorted"
1849 );
1850 }
1851}