1use std::path::Path;
12use std::sync::Arc;
13
14use serde_json::{Map, Value};
15
16use crate::error::{Error, Result};
17use crate::model::{
18 Configuration, DistBus, DistGenerator, DistLine, DistLineCode, DistLoad, DistLoadVoltageModel,
19 DistNetwork, DistShunt, DistSourceFormat, DistSwitch, DistTransformer, Extras, Mat,
20 UntypedObject, VoltageSource, Winding, WindingConn, n_winding_impedance_base,
21 n_winding_phase_count, pair_keys,
22};
23
24pub fn parse_bmopf_file(path: impl AsRef<Path>) -> Result<DistNetwork> {
25 let path = path.as_ref();
26 let text = std::fs::read_to_string(path).map_err(|source| Error::Io {
27 path: path.display().to_string(),
28 source,
29 })?;
30 parse_bmopf_str(&text)
31}
32
33pub fn parse_bmopf_str(text: &str) -> Result<DistNetwork> {
34 let doc: Value = serde_json::from_str(text).map_err(|e| Error::Json {
35 format: "BMOPF",
36 message: e.to_string(),
37 })?;
38 let Value::Object(doc) = doc else {
39 return Err(Error::Json {
40 format: "BMOPF",
41 message: "top level is not an object".into(),
42 });
43 };
44 let mut net = DistNetwork {
45 source: Some(Arc::new(text.to_string())),
46 source_format: Some(DistSourceFormat::BmopfJson),
47 base_frequency: 60.0,
48 ..DistNetwork::default()
49 };
50 let mut rd = Reader { net: &mut net };
51 rd.document(&doc);
52 Ok(net)
53}
54
55struct Reader<'a> {
56 net: &'a mut DistNetwork,
57}
58
59fn f(v: &Value) -> f64 {
60 v.as_f64().unwrap_or(f64::NAN)
61}
62
63fn floats(v: Option<&Value>) -> Option<Vec<f64>> {
64 v?.as_array().map(|a| a.iter().map(f).collect())
65}
66
67fn first_float(v: Option<&Value>) -> Option<f64> {
68 match v? {
69 Value::Array(a) => a.first().map(f),
70 v => Some(f(v)),
71 }
72}
73
74fn first_float_collapsed(v: Option<&Value>, what: &str, warnings: &mut Vec<String>) -> Option<f64> {
78 match v? {
79 Value::Array(a) => {
80 let vals: Vec<f64> = a.iter().map(f).collect();
81 if vals.windows(2).any(|w| w[0].to_bits() != w[1].to_bits()) {
82 warnings.push(format!(
83 "{what}: per-phase-terminal bound is non-uniform; collapsed to the first entry"
84 ));
85 }
86 vals.first().copied()
87 }
88 v => Some(f(v)),
89 }
90}
91
92fn value_alias<'a>(o: &'a Map<String, Value>, primary: &str, legacy: &str) -> Option<&'a Value> {
93 o.get(primary).or_else(|| o.get(legacy))
94}
95
96fn strings(v: Option<&Value>) -> Vec<String> {
97 v.and_then(Value::as_array)
98 .map(|a| {
99 a.iter()
100 .map(|s| s.as_str().unwrap_or_default().to_string())
101 .collect()
102 })
103 .unwrap_or_default()
104}
105
106fn string(v: Option<&Value>) -> String {
107 v.and_then(Value::as_str).unwrap_or_default().to_string()
108}
109
110fn config(v: Option<&Value>, what: &str, warnings: &mut Vec<String>) -> Configuration {
113 let Some(s) = v.and_then(Value::as_str) else {
114 return Configuration::Wye;
115 };
116 match s.to_ascii_uppercase().as_str() {
117 "WYE" => Configuration::Wye,
118 "DELTA" => Configuration::Delta,
119 "SINGLE_PHASE" => Configuration::SinglePhase,
120 _ => {
121 warnings.push(format!(
122 "{what}: configuration `{s}` is not WYE, DELTA, or SINGLE_PHASE; read as WYE"
123 ));
124 Configuration::Wye
125 }
126 }
127}
128
129fn matrix_indices(key: &str, prefix: &str) -> Option<(usize, usize)> {
132 let rest = key.strip_prefix(prefix)?.strip_prefix('_')?;
133 let (i, j) = rest.split_once('_')?;
134 let (i, j) = (i.parse::<usize>().ok()?, j.parse::<usize>().ok()?);
135 (i >= 1 && j >= 1).then_some((i, j))
136}
137
138fn flat_matrix(o: &Map<String, Value>, prefix: &str) -> Option<Mat> {
141 let mut entries: Vec<(usize, usize, f64)> = Vec::new();
142 let mut n = 0;
143 for (k, v) in o {
144 let Some((i, j)) = matrix_indices(k, prefix) else {
145 continue;
146 };
147 entries.push((i - 1, j - 1, f(v)));
148 n = n.max(i).max(j);
149 }
150 if n == 0 {
151 return None;
152 }
153 let mut m = vec![vec![0.0; n]; n];
154 for (i, j, v) in entries {
155 m[i][j] = v;
156 }
157 Some(m)
158}
159
160fn pad_to(m: Mat, n: usize) -> Mat {
162 if m.len() >= n {
163 return m;
164 }
165 let mut out = vec![vec![0.0; n]; n];
166 for (i, row) in m.into_iter().enumerate() {
167 for (j, v) in row.into_iter().enumerate() {
168 out[i][j] = v;
169 }
170 }
171 out
172}
173
174fn take_extras(
176 o: &Map<String, Value>,
177 known: &[&str],
178 what: &str,
179 warnings: &mut Vec<String>,
180 matrix_prefixes: &[&str],
181) -> Extras {
182 let mut extras = Extras::new();
183 for (k, v) in o {
184 if known.contains(&k.as_str()) {
185 continue;
186 }
187 if matrix_prefixes
188 .iter()
189 .any(|p| matrix_indices(k, p).is_some())
190 {
191 continue;
192 }
193 warnings.push(format!(
194 "{what}: `{k}` is outside the schema; kept in extras"
195 ));
196 extras.insert(k.clone(), v.clone());
197 }
198 extras
199}
200
201impl Reader<'_> {
202 fn document(&mut self, doc: &Map<String, Value>) {
203 if let Some(name) = doc.get("name").and_then(Value::as_str) {
204 self.net.name = Some(name.to_string());
205 }
206 if let Some(frequency) =
207 first_float(doc.get("base_frequency")).or_else(|| first_float(doc.get("frequency")))
208 && frequency.is_finite()
209 && frequency > 0.0
210 {
211 self.net.base_frequency = frequency;
212 }
213 for (key, value) in doc {
214 let Value::Object(items) = value else {
215 continue;
216 };
217 match key.as_str() {
218 "bus" => self.buses(items),
219 "linecode" => self.linecodes(items),
220 "line" => self.lines(items),
221 "switch" => self.switches(items),
222 "load" => self.loads(items),
223 "generator" => self.generators(items),
224 "shunt" => self.shunts(items),
225 "voltage_source" => self.sources(items),
226 "transformer" => self.transformers(items),
227 "name" | "meta" => {}
229 other => {
230 self.net.warnings.push(format!(
231 "top level `{other}` is outside the schema; kept untyped"
232 ));
233 for (name, v) in items {
234 self.net.untyped.push(UntypedObject {
235 class: other.to_string(),
236 name: name.clone(),
237 props: vec![(None, v.to_string())],
238 });
239 }
240 }
241 }
242 }
243 }
244
245 fn buses(&mut self, items: &Map<String, Value>) {
246 for (id, v) in items {
247 let Value::Object(o) = v else { continue };
248 let known = [
249 "terminal_names",
250 "perfectly_grounded_terminals",
251 "v_min",
252 "v_max",
253 "vpn_min",
254 "vpn_max",
255 "vpp_min",
256 "vpp_max",
257 "vsym_min",
258 "vsym_max",
259 ];
260 self.net.buses.push(DistBus {
261 id: id.clone(),
262 terminals: strings(o.get("terminal_names")),
263 grounded: strings(o.get("perfectly_grounded_terminals")),
264 v_min: first_float_collapsed(
265 o.get("v_min"),
266 &format!("bus {id} v_min"),
267 &mut self.net.warnings,
268 ),
269 v_max: first_float_collapsed(
270 o.get("v_max"),
271 &format!("bus {id} v_max"),
272 &mut self.net.warnings,
273 ),
274 vpn_min: floats(o.get("vpn_min")),
275 vpn_max: floats(o.get("vpn_max")),
276 vpp_min: floats(o.get("vpp_min")),
277 vpp_max: floats(o.get("vpp_max")),
278 vsym_min: floats(o.get("vsym_min")),
279 vsym_max: floats(o.get("vsym_max")),
280 extras: take_extras(o, &known, &format!("bus {id}"), &mut self.net.warnings, &[]),
281 });
282 }
283 }
284
285 fn linecodes(&mut self, items: &Map<String, Value>) {
286 for (name, v) in items {
287 let Value::Object(o) = v else { continue };
288 let mats = [
289 flat_matrix(o, "R_series"),
290 flat_matrix(o, "X_series"),
291 flat_matrix(o, "G_from"),
292 flat_matrix(o, "B_from"),
293 flat_matrix(o, "G_to"),
294 flat_matrix(o, "B_to"),
295 ];
296 let n = mats.iter().flatten().map(Vec::len).max().unwrap_or(0);
299 if mats.iter().flatten().any(|m| m.len() < n) {
300 self.net.warnings.push(format!(
301 "linecode {name}: matrix sizes disagree; smaller ones padded \
302 with zeros to {n}x{n}"
303 ));
304 }
305 let [r, x, gf, bf, gt, bt] = mats.map(|m| pad_to(m.unwrap_or_default(), n));
306 let code = DistLineCode {
307 name: name.clone(),
308 n_conductors: n,
309 r_series: r,
310 x_series: x,
311 g_from: gf,
312 b_from: bf,
313 g_to: gt,
314 b_to: bt,
315 i_max: floats(o.get("i_max")),
316 s_max: floats(o.get("s_max")),
317 extras: take_extras(
318 o,
319 &["i_max", "s_max"],
320 &format!("linecode {name}"),
321 &mut self.net.warnings,
322 &["R_series", "X_series", "G_from", "G_to", "B_from", "B_to"],
323 ),
324 };
325 self.net.linecodes.push(code);
326 }
327 }
328
329 fn lines(&mut self, items: &Map<String, Value>) {
330 for (name, v) in items {
331 let Value::Object(o) = v else { continue };
332 let known = [
333 "length",
334 "linecode",
335 "bus_from",
336 "bus_to",
337 "terminal_map_from",
338 "terminal_map_to",
339 ];
340 self.net.lines.push(DistLine {
341 name: name.clone(),
342 bus_from: string(o.get("bus_from")),
343 bus_to: string(o.get("bus_to")),
344 terminal_map_from: strings(o.get("terminal_map_from")),
345 terminal_map_to: strings(o.get("terminal_map_to")),
346 linecode: string(o.get("linecode")),
347 length: o.get("length").map_or(f64::NAN, f),
348 extras: take_extras(
349 o,
350 &known,
351 &format!("line {name}"),
352 &mut self.net.warnings,
353 &[],
354 ),
355 });
356 }
357 }
358
359 fn switches(&mut self, items: &Map<String, Value>) {
360 for (name, v) in items {
361 let Value::Object(o) = v else { continue };
362 let known = [
363 "bus_from",
364 "bus_to",
365 "terminal_map_from",
366 "terminal_map_to",
367 "open_switch",
368 "i_max",
369 ];
370 self.net.switches.push(DistSwitch {
371 name: name.clone(),
372 bus_from: string(o.get("bus_from")),
373 bus_to: string(o.get("bus_to")),
374 terminal_map_from: strings(o.get("terminal_map_from")),
375 terminal_map_to: strings(o.get("terminal_map_to")),
376 open: o
377 .get("open_switch")
378 .and_then(Value::as_bool)
379 .unwrap_or(false),
380 i_max: floats(o.get("i_max")),
381 extras: take_extras(
382 o,
383 &known,
384 &format!("switch {name}"),
385 &mut self.net.warnings,
386 &[],
387 ),
388 });
389 }
390 }
391
392 fn loads(&mut self, items: &Map<String, Value>) {
393 for (name, v) in items {
394 let Value::Object(o) = v else { continue };
395 let known = [
396 "p_nom",
397 "q_nom",
398 "bus",
399 "configuration",
400 "terminal_map",
401 "model",
402 "v_nom",
403 "alpha_z",
404 "alpha_i",
405 "alpha_p",
406 "beta_z",
407 "beta_i",
408 "beta_p",
409 "gamma_p",
410 "gamma_q",
411 ];
412 let v_nom = floats(o.get("v_nom")).unwrap_or_default();
413 let has_zip = [
414 "alpha_z", "alpha_i", "alpha_p", "beta_z", "beta_i", "beta_p",
415 ]
416 .iter()
417 .any(|key| o.get(*key).is_some());
418 let has_exp = o.get("gamma_p").is_some() || o.get("gamma_q").is_some();
419 let model = o
420 .get("model")
421 .and_then(Value::as_str)
422 .unwrap_or("POWER")
423 .to_ascii_uppercase();
424 let voltage_model = if has_exp {
425 DistLoadVoltageModel::Exponential {
426 v_nom,
427 gamma_p: floats(o.get("gamma_p")).unwrap_or_default(),
428 gamma_q: floats(o.get("gamma_q")).unwrap_or_default(),
429 }
430 } else if has_zip {
431 DistLoadVoltageModel::Zip {
432 v_nom,
433 alpha_z: floats(o.get("alpha_z")).unwrap_or_default(),
434 alpha_i: floats(o.get("alpha_i")).unwrap_or_default(),
435 alpha_p: floats(o.get("alpha_p")).unwrap_or_default(),
436 beta_z: floats(o.get("beta_z")).unwrap_or_default(),
437 beta_i: floats(o.get("beta_i")).unwrap_or_default(),
438 beta_p: floats(o.get("beta_p")).unwrap_or_default(),
439 }
440 } else if model.contains("IMPEDANCE") {
441 DistLoadVoltageModel::ConstantImpedance { v_nom }
442 } else if model.contains("CURRENT") {
443 DistLoadVoltageModel::ConstantCurrent { v_nom }
444 } else {
445 DistLoadVoltageModel::ConstantPower { v_nom }
446 };
447 self.net.loads.push(DistLoad {
448 name: name.clone(),
449 bus: string(o.get("bus")),
450 terminal_map: strings(o.get("terminal_map")),
451 configuration: config(
452 o.get("configuration"),
453 &format!("load {name}"),
454 &mut self.net.warnings,
455 ),
456 p_nom: floats(o.get("p_nom")).unwrap_or_default(),
457 q_nom: floats(o.get("q_nom")).unwrap_or_default(),
458 voltage_model,
459 extras: take_extras(
460 o,
461 &known,
462 &format!("load {name}"),
463 &mut self.net.warnings,
464 &[],
465 ),
466 });
467 }
468 }
469
470 fn generators(&mut self, items: &Map<String, Value>) {
471 for (name, v) in items {
472 let Value::Object(o) = v else { continue };
473 let known = [
474 "p_min",
475 "p_max",
476 "q_min",
477 "q_max",
478 "cost",
479 "bus",
480 "configuration",
481 "terminal_map",
482 ];
483 let p_min = floats(o.get("p_min"));
484 let p_max = floats(o.get("p_max"));
485 let q_min = floats(o.get("q_min"));
486 let q_max = floats(o.get("q_max"));
487 let pinned = |lo: &Option<Vec<f64>>, hi: &Option<Vec<f64>>| match (lo, hi) {
490 (Some(a), Some(b)) if a == b => a.clone(),
491 _ => Vec::new(),
492 };
493 let cost = match o.get("cost") {
497 Some(Value::Array(a)) => {
498 let vals: Vec<f64> = a.iter().map(f).collect();
499 if vals.windows(2).any(|w| w[0].to_bits() != w[1].to_bits()) {
502 self.net.warnings.push(format!(
503 "generator {name}: per-phase cost is non-uniform; \
504 collapsed to the first entry"
505 ));
506 }
507 vals.first().copied()
508 }
509 Some(v) => Some(f(v)),
510 None => None,
511 };
512 self.net.generators.push(DistGenerator {
513 name: name.clone(),
514 bus: string(o.get("bus")),
515 terminal_map: strings(o.get("terminal_map")),
516 configuration: config(
517 o.get("configuration"),
518 &format!("generator {name}"),
519 &mut self.net.warnings,
520 ),
521 p_nom: pinned(&p_min, &p_max),
522 q_nom: pinned(&q_min, &q_max),
523 p_min,
524 p_max,
525 q_min,
526 q_max,
527 cost,
528 extras: take_extras(
529 o,
530 &known,
531 &format!("generator {name}"),
532 &mut self.net.warnings,
533 &[],
534 ),
535 });
536 }
537 }
538
539 fn shunts(&mut self, items: &Map<String, Value>) {
540 for (name, v) in items {
541 let Value::Object(o) = v else { continue };
542 let g = flat_matrix(o, "G").unwrap_or_default();
543 let b = flat_matrix(o, "B").unwrap_or_default();
544 let n = g.len().max(b.len());
545 if g.len() != b.len() {
546 self.net.warnings.push(format!(
547 "shunt {name}: G is {gx}x{gx} but B is {bx}x{bx}; the smaller \
548 padded with zeros to {n}x{n}",
549 gx = g.len(),
550 bx = b.len(),
551 ));
552 }
553 self.net.shunts.push(DistShunt {
554 name: name.clone(),
555 bus: string(o.get("bus")),
556 terminal_map: strings(o.get("terminal_map")),
557 g: pad_to(g, n),
558 b: pad_to(b, n),
559 extras: take_extras(
560 o,
561 &["bus", "terminal_map"],
562 &format!("shunt {name}"),
563 &mut self.net.warnings,
564 &["G", "B"],
565 ),
566 });
567 }
568 }
569
570 fn sources(&mut self, items: &Map<String, Value>) {
571 for (name, v) in items {
572 let Value::Object(o) = v else { continue };
573 let known = ["v_magnitude", "v_angle", "bus", "terminal_map"];
574 self.net.sources.push(VoltageSource {
575 name: name.clone(),
576 bus: string(o.get("bus")),
577 terminal_map: strings(o.get("terminal_map")),
578 v_magnitude: floats(o.get("v_magnitude")).unwrap_or_default(),
579 v_angle: floats(o.get("v_angle")).unwrap_or_default(),
580 extras: take_extras(
581 o,
582 &known,
583 &format!("voltage source {name}"),
584 &mut self.net.warnings,
585 &[],
586 ),
587 });
588 }
589 }
590
591 fn transformers(&mut self, subtypes: &Map<String, Value>) {
592 for (subtype, group) in subtypes {
593 let Value::Object(items) = group else {
594 continue;
595 };
596 for (name, v) in items {
597 let Value::Object(o) = v else { continue };
598 match subtype.as_str() {
599 "n_winding" => {
600 let t = self.n_winding_transformer(name, o);
601 self.net.transformers.push(t);
602 }
603 "single_phase_autotransformer" | "open_delta_regulator" => {
604 self.net.warnings.push(format!(
605 "transformer {name}: subtype `{subtype}` is not typed yet; kept untyped"
606 ));
607 self.net.untyped.push(UntypedObject {
608 class: format!("transformer.{subtype}"),
609 name: name.clone(),
610 props: vec![(None, v.to_string())],
611 });
612 }
613 _ => {
614 let t = self.transformer(subtype, name, o);
615 self.net.transformers.push(t);
616 }
617 }
618 }
619 }
620 }
621
622 #[allow(clippy::too_many_lines)] fn transformer(
624 &mut self,
625 subtype: &str,
626 name: &str,
627 o: &Map<String, Value>,
628 ) -> DistTransformer {
629 let known = [
630 "bus_from",
631 "bus_to",
632 "terminal_map_from",
633 "terminal_map_to",
634 "s_rating",
635 "v_nom_from",
636 "v_nom_to",
637 "v_ref_from",
638 "v_ref_to",
639 "g_no_load",
640 "b_no_load",
641 "r_series",
642 "x_series",
643 "r_series_from",
644 "r_series_to",
645 "x_series_from",
646 "x_series_to",
647 "tap",
648 "tap_min",
649 "tap_max",
650 ];
651 if !matches!(
652 subtype,
653 "single_phase" | "center_tap" | "wye_delta" | "delta_wye"
654 ) {
655 self.net.warnings.push(format!(
656 "transformer {name}: subtype `{subtype}` is outside the schema; \
657 read as a single phase pair"
658 ));
659 }
660 let s = o.get("s_rating").map_or(f64::NAN, f);
661 let v_from = value_alias(o, "v_nom_from", "v_ref_from").map_or(f64::NAN, f);
662 let v_to = value_alias(o, "v_nom_to", "v_ref_to").map_or(f64::NAN, f);
663 let positive = |v: f64| v.is_finite() && v > 0.0;
664 if !positive(s) || !positive(v_from) || !positive(v_to) {
665 self.net.warnings.push(format!(
666 "transformer {name}: s_rating or v_nom missing or nonpositive; \
667 impedances read as zero"
668 ));
669 }
670 let three_phase = matches!(subtype, "wye_delta" | "delta_wye");
671 let phases = if three_phase { 3 } else { 1 };
672
673 let pct = |x_ohm: f64, v: f64| {
674 if s > 0.0 && v > 0.0 {
675 x_ohm / (v * v / s) * 100.0
676 } else {
677 0.0
678 }
679 };
680 let (r_from_pct, r_to_pct, xsc) = if three_phase {
681 let wye_v = if subtype == "wye_delta" { v_from } else { v_to };
682 let r = pct(o.get("r_series").map_or(0.0, f), wye_v);
685 let x = pct(o.get("x_series").map_or(0.0, f), wye_v);
686 (r / 2.0, r / 2.0, x)
687 } else {
688 let r_from = pct(o.get("r_series_from").map_or(0.0, f), v_from);
689 let r_to = pct(o.get("r_series_to").map_or(0.0, f), v_to);
690 let x = pct(o.get("x_series_from").map_or(0.0, f), v_from)
691 + pct(o.get("x_series_to").map_or(0.0, f), v_to);
692 (r_from, r_to, x)
693 };
694
695 let conn = |delta: bool| {
696 if delta {
697 WindingConn::Delta
698 } else {
699 WindingConn::Wye
700 }
701 };
702 let mut windings = vec![
703 Winding {
704 bus: string(o.get("bus_from")),
705 terminal_map: strings(o.get("terminal_map_from")),
706 conn: conn(subtype == "delta_wye"),
707 v_ref: v_from,
708 s_rating: s,
709 r_pct: r_from_pct,
710 tap: first_float(o.get("tap")).unwrap_or(1.0),
711 },
712 Winding {
713 bus: string(o.get("bus_to")),
714 terminal_map: strings(o.get("terminal_map_to")),
715 conn: conn(subtype == "wye_delta"),
716 v_ref: v_to,
717 s_rating: s,
718 r_pct: r_to_pct,
719 tap: 1.0,
720 },
721 ];
722 expand_center_tap_windings(subtype, &mut windings);
723 let mut extras = take_extras(
724 o,
725 &known,
726 &format!("transformer {name}"),
727 &mut self.net.warnings,
728 &[],
729 );
730 for key in ["tap_min", "tap_max"] {
731 if let Some(v) = o.get(key) {
732 extras.insert(key.into(), v.clone());
733 }
734 }
735 for key in ["g_no_load", "b_no_load"] {
736 if let Some(v) = o.get(key) {
737 extras.insert(key.into(), v.clone());
738 }
739 }
740 extras.insert("bmopf_subtype".into(), subtype.into());
743 DistTransformer {
744 name: name.to_string(),
745 windings,
746 xsc_pct: vec![xsc],
747 phases,
748 extras,
749 }
750 }
751
752 fn n_winding_transformer(&mut self, name: &str, o: &Map<String, Value>) -> DistTransformer {
753 let known = ["windings", "x_sc", "s_rating", "g_no_load", "b_no_load"];
754 let s = o.get("s_rating").map_or(f64::NAN, f);
755 let mut windings = Vec::new();
756 if let Some(items) = o.get("windings").and_then(Value::as_array) {
757 for (idx, item) in items.iter().enumerate() {
758 let Some(w) = item.as_object() else {
759 self.net.warnings.push(format!(
760 "transformer {name}: winding {} is not an object; skipped",
761 idx + 1
762 ));
763 continue;
764 };
765 let terminal_map = strings(w.get("terminal_map"));
766 let bmopf_v_nom = value_alias(w, "v_nom", "v_ref").map_or(f64::NAN, f);
767 let r_winding = w.get("r_winding").map_or(0.0, f);
768 let connection = w
769 .get("configuration")
770 .or_else(|| w.get("connection"))
771 .and_then(Value::as_str)
772 .unwrap_or("WYE")
773 .to_ascii_uppercase();
774 if !matches!(connection.as_str(), "WYE" | "DELTA") {
775 self.net.warnings.push(format!(
776 "transformer {name}: winding {} connection `{connection}` is not WYE or DELTA; read as WYE",
777 idx + 1
778 ));
779 }
780 let conn = if connection == "DELTA" {
781 WindingConn::Delta
782 } else {
783 WindingConn::Wye
784 };
785 let r_pct = if let Some(base_z) =
786 n_winding_base_from_bmopf(conn, &terminal_map, bmopf_v_nom, s)
787 {
788 r_winding / base_z * 100.0
789 } else {
790 0.0
791 };
792 windings.push(Winding {
793 bus: string(w.get("bus")),
794 terminal_map: terminal_map.clone(),
795 conn,
796 v_ref: n_winding_internal_v_ref(conn, &terminal_map, bmopf_v_nom),
797 s_rating: s,
798 r_pct,
799 tap: 1.0,
800 });
801 }
802 }
803 let base_z = windings
804 .first()
805 .and_then(|w| n_winding_base_from_internal(w, s))
806 .unwrap_or(f64::NAN);
807 let mut xsc_pct = Vec::new();
808 let x_sc = o.get("x_sc").and_then(Value::as_object);
809 for (i, j) in pair_keys(windings.len()) {
810 let key = format!("{}_{}", i + 1, j + 1);
811 let x = x_sc.and_then(|m| m.get(&key)).map_or(0.0, f);
812 xsc_pct.push(if base_z.is_finite() && base_z > 0.0 {
813 x / base_z * 100.0
814 } else {
815 0.0
816 });
817 }
818 let mut extras = take_extras(
819 o,
820 &known,
821 &format!("transformer {name}"),
822 &mut self.net.warnings,
823 &[],
824 );
825 extras.insert("bmopf_subtype".into(), "n_winding".into());
826 for key in ["g_no_load", "b_no_load"] {
827 if let Some(v) = o.get(key) {
828 extras.insert(key.into(), v.clone());
829 }
830 }
831 DistTransformer {
832 name: name.to_string(),
833 phases: windings
834 .iter()
835 .map(|w| n_winding_phase_count(w.conn, &w.terminal_map))
836 .max()
837 .unwrap_or(1)
838 .max(1),
839 windings,
840 xsc_pct,
841 extras,
842 }
843 }
844}
845
846fn expand_center_tap_windings(subtype: &str, windings: &mut Vec<Winding>) {
847 if subtype != "center_tap" || windings[1].terminal_map.len() < 3 {
848 return;
849 }
850 let to = windings.pop().expect("secondary winding exists");
851 let common = to.terminal_map.last().cloned().unwrap_or_default();
852 let hot_a = to.terminal_map[0].clone();
853 let hot_b = to.terminal_map[1].clone();
854 let half = Winding {
855 bus: to.bus.clone(),
856 terminal_map: vec![hot_a, common.clone()],
857 conn: WindingConn::Wye,
858 v_ref: to.v_ref / 2.0,
859 s_rating: to.s_rating,
860 r_pct: to.r_pct * 2.0,
861 tap: to.tap,
862 };
863 let other_half = Winding {
864 bus: to.bus,
865 terminal_map: vec![common, hot_b],
866 conn: WindingConn::Wye,
867 v_ref: to.v_ref / 2.0,
868 s_rating: to.s_rating,
869 r_pct: to.r_pct * 2.0,
870 tap: to.tap,
871 };
872 windings.push(half);
873 windings.push(other_half);
874}
875
876fn n_winding_internal_v_ref(conn: WindingConn, terminal_map: &[String], bmopf_v_nom: f64) -> f64 {
877 if conn == WindingConn::Wye && n_winding_phase_count(conn, terminal_map) >= 2 {
878 bmopf_v_nom * 3f64.sqrt()
879 } else {
880 bmopf_v_nom
881 }
882}
883
884fn n_winding_bmopf_v_nom_from_internal(w: &Winding) -> f64 {
885 if w.conn == WindingConn::Wye && n_winding_phase_count(w.conn, &w.terminal_map) >= 2 {
886 w.v_ref / 3f64.sqrt()
887 } else {
888 w.v_ref
889 }
890}
891
892fn n_winding_base_from_bmopf(
893 conn: WindingConn,
894 terminal_map: &[String],
895 bmopf_v_nom: f64,
896 s: f64,
897) -> Option<f64> {
898 n_winding_impedance_base(n_winding_phase_count(conn, terminal_map), bmopf_v_nom, s)
899}
900
901fn n_winding_base_from_internal(w: &Winding, s: f64) -> Option<f64> {
902 n_winding_base_from_bmopf(
903 w.conn,
904 &w.terminal_map,
905 n_winding_bmopf_v_nom_from_internal(w),
906 s,
907 )
908}