1use std::collections::{BTreeMap, BTreeSet};
15
16use crate::diagnostics::codes as C;
17
18use super::defaults as dd;
19use super::lex::{BusSpec, Value, VarMap};
20use super::raw::{RawDss, RawObject};
21use crate::error::Result;
22use crate::geo::{CoordinateSpace, DistCoordsKind, DistGeoMeta, DistLocation};
23use crate::model::{
24 ActivePowerReference, ActivePowerUnit, ConductorMatrix, Configuration, ControlVoltageReference,
25 DistBus, DistControlProfile, DistGenerator, DistIbr, DistLine, DistLineCode, DistLoad,
26 DistLoadVoltageModel, DistShunt, DistSourceFormat, DistSwitch, DistTransformer, DistWinding,
27 DistWindingConn, Extras, IbrPrimeMover, IbrTopology, MulticonductorNetwork,
28 MulticonductorNetworkTables, PowerFactorControl, ReactivePowerReference, ReactivePowerUnit,
29 UntypedObject, VoltVarControl, VoltWattControl, VoltageSource, open_delta_connection,
30 open_delta_pairable, pair_keys, square_from_rows, winding_phase_pair,
31};
32
33const MAX_COUNT: usize = 64;
40
41const CALCULATION_DSS_CLASSES: [&str; 9] = [
45 "loadshape",
46 "tshape",
47 "priceshape",
48 "growthshape",
49 "monitor",
50 "energymeter",
51 "sensor",
52 "relay",
53 "recloser",
54];
55
56const TYPED_DSS_CLASSES: &[&str] = &[
57 "linecode",
58 "vsource",
59 "line",
60 "transformer",
61 "load",
62 "capacitor",
63 "reactor",
64 "generator",
65 "pvsystem",
66 "xycurve",
67 "invcontrol",
68 "swtcontrol",
69 "regcontrol",
70];
71
72pub(crate) fn parse_dss_collecting(
77 source: &powerio_core::Source,
78 diags: &mut crate::collect::Diagnostics,
79) -> Result<MulticonductorNetwork> {
80 let raw = super::raw::parse_raw_from_source(source)?;
81 let (net, found) = to_network_from_raw(&raw);
82 diags.absorb(found);
83 Ok(net)
84}
85
86#[allow(clippy::too_many_lines)]
91pub fn to_network_from_raw(
92 raw: &RawDss,
93) -> (MulticonductorNetwork, Vec<crate::diagnostics::Diagnostic>) {
94 let mut diags = crate::collect::Diagnostics::new();
95 diags.absorb(raw.diagnostics.iter().cloned());
96 let mut rd = Reader {
97 net: MulticonductorNetwork::from_tables(MulticonductorNetworkTables {
98 name: raw.circuit_name.clone(),
99 base_frequency: dd::BASE_FREQUENCY,
100 source_format: Some(DistSourceFormat::Dss),
101 ..MulticonductorNetworkTables::default()
102 }),
103 diags,
104 buses: BTreeMap::new(),
105 bus_order: Vec::new(),
106 linecode_units: BTreeMap::new(),
107 linecode_nconds: BTreeMap::new(),
108 xycurves: BTreeMap::new(),
109 regulated: BTreeSet::new(),
110 vars: &raw.vars,
111 };
112
113 for (name, value) in &raw.options {
114 if name.len() >= "defaultb".len()
120 && "defaultbasefrequency".starts_with(name.as_str())
121 && let Ok(f) = value.to_f64(Some(rd.vars))
122 {
123 *rd.net.base_frequency_mut() = f;
124 }
125 rd.net
126 .options_mut()
127 .push((name.clone(), value.text.clone()));
128 }
129 for cmd in &raw.commands {
130 rd.net
131 .commands_mut()
132 .push((cmd.verb.clone(), cmd.args.clone()));
133 }
134
135 for obj in raw.of_class("linecode") {
138 let lc = rd.linecode(obj);
139 rd.net.line_codes_mut().push(lc);
140 }
141 for obj in raw.of_class("vsource") {
142 let vs = rd.vsource(obj);
143 rd.net.sources_mut().push(vs);
144 }
145 for obj in raw.of_class("line") {
146 rd.line(obj);
147 }
148 for obj in raw.of_class("transformer") {
149 let t = rd.transformer(obj);
150 rd.net.transformers_mut().push(t);
151 }
152 for obj in raw.of_class("load") {
153 let l = rd.load(obj);
154 rd.net.loads_mut().push(l);
155 }
156 for obj in raw.of_class("capacitor") {
157 rd.capacitor(obj);
158 }
159 for obj in raw.of_class("reactor") {
160 rd.reactor(obj);
161 }
162 for obj in raw.of_class("generator") {
163 let g = rd.generator(obj);
164 rd.net.generators_mut().push(g);
165 }
166 read_ibr_objects(&mut rd, raw);
167 for obj in raw.of_class("swtcontrol") {
168 rd.swtcontrol(obj);
169 }
170 for obj in raw.of_class("regcontrol") {
171 rd.regcontrol(obj);
172 }
173 rd.classify_regulators();
174 let mut outside_profile: std::collections::BTreeMap<&str, usize> =
175 std::collections::BTreeMap::new();
176 for obj in &raw.objects {
177 if !TYPED_DSS_CLASSES.contains(&obj.class.as_str()) {
178 if let Some(class) = CALCULATION_DSS_CLASSES
179 .iter()
180 .find(|class| **class == obj.class)
181 {
182 *outside_profile.entry(class).or_default() += 1;
183 }
184 rd.net.untyped_objects_mut().push(UntypedObject::from(obj));
185 }
186 }
187 for (class, count) in outside_profile {
193 rd.diags.push(
194 &C::READ_DSS_RETAINED_SOURCE_ONLY,
195 format!(
196 "{count} `{class}` object(s) are outside the static circuit profile \
197 (schedules, calculation instructions, and output requests are not \
198 interpreted); retained for exact same format writing"
199 ),
200 );
201 }
202
203 let known: std::collections::BTreeSet<String> = rd
209 .net
210 .line_codes()
211 .iter()
212 .map(|c| c.name.to_ascii_lowercase())
213 .collect();
214 let missing: Vec<String> = rd
215 .net
216 .lines()
217 .iter()
218 .filter(|l| !known.contains(&l.linecode.to_ascii_lowercase()))
219 .map(|l| {
220 format!(
221 "line {} references unknown linecode `{}`",
222 l.name, l.linecode
223 )
224 })
225 .collect();
226 for message in missing {
227 rd.diags.push(
228 &crate::diagnostics::codes::READ_DSS_LINECODE_UNKNOWN,
229 message,
230 );
231 }
232
233 finish_buses(rd, raw)
234}
235
236fn finish_buses(
244 mut rd: Reader,
245 raw: &RawDss,
246) -> (MulticonductorNetwork, Vec<crate::diagnostics::Diagnostic>) {
247 let mut coords: BTreeMap<String, (f64, f64)> = BTreeMap::new();
248 for c in &raw.buscoords {
249 coords.insert(c.bus.to_ascii_lowercase(), (c.x, c.y));
250 }
251 let buses = std::mem::take(&mut rd.bus_order);
252 let states = std::mem::take(&mut rd.buses);
253 let mut net = rd.net;
254 let mut diags = rd.diags;
255 let mut neutral_names: BTreeMap<String, String> = BTreeMap::new();
256 for id in buses {
257 let st = &states[&id];
258 let mut terminals: Vec<i32> = st.nodes.iter().copied().filter(|&n| n != 0).collect();
259 terminals.sort_unstable();
260 let mut bus = DistBus {
261 id: st.display.clone(),
262 terminals: terminals.iter().map(ToString::to_string).collect(),
263 ..DistBus::default()
264 };
265 if st.nodes.contains(&0) {
266 let neutral = terminals.last().map_or(4, |&n| n.max(3) + 1);
267 bus.terminals.push(neutral.to_string());
268 bus.grounded.push(neutral.to_string());
269 neutral_names.insert(id.clone(), neutral.to_string());
270 }
271 if let Some((x, y)) = coords.get(&id) {
272 bus.location = Some(DistLocation {
273 x: *x,
274 y: *y,
275 kind: None,
276 });
277 }
278 net.buses_mut().push(bus);
279 }
280 if !coords.is_empty() {
281 *net.geo_mut() = Some(DistGeoMeta {
282 space: CoordinateSpace::Unknown,
283 kind: Some(DistCoordsKind::Source),
284 });
285 if coords
286 .values()
287 .all(|(x, y)| (-180.0..=180.0).contains(x) && (-90.0..=90.0).contains(y))
288 {
289 diags.push(
290 &crate::diagnostics::codes::READ_DSS_COORDINATE_SPACE_UNKNOWN,
291 "OpenDSS buscoords fit longitude/latitude ranges; coordinate space remains unknown because Buscoords does not declare a CRS",
292 );
293 }
294 }
295
296 let rewrite = |bus: &str, map: &mut [String]| {
297 if let Some(neutral) = neutral_names.get(&bus.to_ascii_lowercase()) {
298 for t in map.iter_mut().filter(|t| *t == "0") {
299 t.clone_from(neutral);
300 }
301 }
302 };
303 for l in net.lines_mut() {
304 rewrite(&l.bus_from, &mut l.terminal_map_from);
305 rewrite(&l.bus_to, &mut l.terminal_map_to);
306 }
307 for s in net.switches_mut() {
308 rewrite(&s.bus_from, &mut s.terminal_map_from);
309 rewrite(&s.bus_to, &mut s.terminal_map_to);
310 }
311 for l in net.loads_mut() {
312 rewrite(&l.bus, &mut l.terminal_map);
313 }
314 for g in net.generators_mut() {
315 rewrite(&g.bus, &mut g.terminal_map);
316 }
317 for s in net.shunts_mut() {
318 rewrite(&s.bus, &mut s.terminal_map);
319 }
320 for v in net.sources_mut() {
321 rewrite(&v.bus, &mut v.terminal_map);
322 }
323 for t in net.transformers_mut() {
324 for w in &mut t.windings {
325 rewrite(&w.bus, &mut w.terminal_map);
326 }
327 }
328 (net, diags.into_records())
329}
330
331fn read_ibr_objects(rd: &mut Reader<'_>, raw: &RawDss) {
332 for obj in raw.of_class("pvsystem") {
333 rd.pvsystem(obj);
334 }
335 for obj in raw.of_class("xycurve") {
336 rd.xycurve(obj);
337 }
338 for obj in raw.of_class("invcontrol") {
339 rd.invcontrol(obj);
340 }
341}
342
343impl From<&RawObject> for UntypedObject {
344 fn from(obj: &RawObject) -> Self {
345 UntypedObject {
346 class: obj.class.clone(),
347 name: obj.name.clone(),
348 props: obj
349 .props
350 .iter()
351 .map(|p| (p.name.clone(), p.value.text.clone()))
352 .collect(),
353 }
354 }
355}
356
357struct BusState {
358 display: String,
359 nodes: std::collections::BTreeSet<i32>,
360}
361
362struct Reader<'a> {
363 net: MulticonductorNetwork,
364 diags: crate::collect::Diagnostics,
365 buses: BTreeMap<String, BusState>,
366 bus_order: Vec<String>,
367 linecode_units: BTreeMap<String, Option<f64>>,
371 linecode_nconds: BTreeMap<String, usize>,
375 xycurves: BTreeMap<String, XyCurveRaw>,
376 regulated: BTreeSet<String>,
379 vars: &'a VarMap,
380}
381
382struct Props<'a> {
385 by_name: BTreeMap<&'a str, &'a Value>,
386 consumed: std::cell::RefCell<Vec<&'a str>>,
387}
388
389impl<'a> Props<'a> {
390 fn new(obj: &'a RawObject) -> Self {
391 let mut by_name = BTreeMap::new();
392 for p in &obj.props {
393 if let Some(n) = &p.name {
394 by_name.insert(n.as_str(), &p.value);
395 }
396 }
397 Props {
398 by_name,
399 consumed: std::cell::RefCell::new(Vec::new()),
400 }
401 }
402
403 fn get(&self, name: &'a str) -> Option<&'a Value> {
404 self.consumed.borrow_mut().push(name);
405 self.by_name.get(name).copied()
406 }
407
408 fn leftovers(&self) -> Vec<(&str, &Value)> {
410 let consumed = self.consumed.borrow();
411 self.by_name
412 .iter()
413 .filter(|(k, _)| !consumed.contains(*k) && **k != "like")
414 .map(|(k, v)| (*k, *v))
415 .collect()
416 }
417}
418
419const REACTOR_IMPEDANCE_FORMS: &[&str] = &[
426 "rmatrix", "xmatrix", "r", "x", "z1", "z2", "z0", "z", "rcurve", "lcurve", "lmh",
427];
428
429#[derive(Clone, Copy)]
430struct KvarShuntSpec {
431 class: &'static str,
432 series_name: &'static str,
433 default_phases: usize,
434 default_kvar: f64,
435 default_kv: f64,
436 b_sign: f64,
437}
438
439const CAPACITOR_KVAR_SHUNT: KvarShuntSpec = KvarShuntSpec {
440 class: "capacitor",
441 series_name: "capacitors",
442 default_phases: dd::capacitor::PHASES,
443 default_kvar: dd::capacitor::KVAR,
444 default_kv: dd::capacitor::KV,
445 b_sign: 1.0,
446};
447
448const REACTOR_KVAR_SHUNT: KvarShuntSpec = KvarShuntSpec {
449 class: "reactor",
450 series_name: "reactors",
451 default_phases: dd::reactor::PHASES,
452 default_kvar: dd::reactor::KVAR,
453 default_kv: dd::reactor::KV,
454 b_sign: -1.0,
455};
456
457#[derive(Clone, Debug)]
458struct XyCurveRaw {
459 x: Vec<f64>,
460 y: Vec<f64>,
461}
462
463impl Reader<'_> {
464 fn warn(&mut self, info: &'static crate::diagnostics::DiagnosticInfo, msg: impl Into<String>) {
465 self.diags.push(info, msg);
466 }
467
468 fn defaulted(&mut self, class: &str, name: &str, field: &'static str) {
469 let fields = self
470 .net
471 .defaulted_mut()
472 .entry(format!("{class}.{name}"))
473 .or_default();
474 if !fields.contains(&field) {
475 fields.push(field);
476 }
477 }
478
479 fn f64_prop(&mut self, p: Option<&Value>) -> Option<f64> {
480 p.and_then(|v| v.to_f64(Some(self.vars)).ok())
481 }
482
483 fn usize_prop(&mut self, p: Option<&Value>) -> Option<usize> {
484 p.and_then(|v| v.to_i64(Some(self.vars)).ok()).map(|i| {
485 let n = usize::try_from(i).unwrap_or(0);
486 if n > MAX_COUNT {
487 self.diags.push(
488 &crate::diagnostics::codes::READ_DSS_VALUE_CLAMPED,
489 format!(
490 "count property {n} exceeds the supported maximum of {MAX_COUNT}; clamped"
491 ),
492 );
493 MAX_COUNT
494 } else {
495 n
496 }
497 })
498 }
499
500 fn units_code(&mut self, units: Option<&str>, class: &str, name: &str) -> Option<f64> {
505 let u = units?;
506 if let Some(f) = dd::calc_meters_per_unit(u) {
507 return Some(f);
508 }
509 if !u.to_ascii_lowercase().starts_with("no") {
510 self.diags.push(
511 &crate::diagnostics::codes::READ_DSS_VALUE_UNSUPPORTED,
512 format!("{class} {name}: unknown units `{u}`; treated as none"),
513 );
514 }
515 None
516 }
517
518 fn stash_numeric(&self, v: &Value) -> serde_json::Value {
523 if v.text.parse::<f64>().is_ok() {
524 v.text.clone().into()
525 } else {
526 match v.to_f64(Some(self.vars)) {
527 Ok(n) => n.into(),
528 Err(_) => v.text.clone().into(),
529 }
530 }
531 }
532
533 fn stash_kv_and_phases(&self, props: &Props, extras: &mut Extras, kv: f64, phases: usize) {
536 let kv_value = match props.by_name.get("kv") {
537 Some(written) => self.stash_numeric(written),
538 None => kv.into(),
539 };
540 extras.insert("kv".into(), kv_value);
541 let phases_value = match props.by_name.get("phases") {
542 Some(written) => self.stash_numeric(written),
543 None => (phases as u64).into(),
544 };
545 extras.insert("phases".into(), phases_value);
546 if let Some(written) = props.by_name.get("conn") {
550 extras.insert("conn".into(), written.text.clone().into());
551 }
552 }
553
554 fn f64_or(
556 &mut self,
557 props: &Props,
558 key: &'static str,
559 class: &str,
560 name: &str,
561 default: f64,
562 ) -> f64 {
563 if let Some(v) = self.f64_prop(props.get(key)) {
564 v
565 } else {
566 self.defaulted(class, name, key);
567 default
568 }
569 }
570
571 fn usize_or(
572 &mut self,
573 props: &Props,
574 key: &'static str,
575 class: &str,
576 name: &str,
577 default: usize,
578 ) -> usize {
579 if let Some(v) = self.usize_prop(props.get(key)) {
580 v
581 } else {
582 self.defaulted(class, name, key);
583 default
584 }
585 }
586
587 fn terminals(
592 &mut self,
593 spec: &BusSpec,
594 phases: usize,
595 nconds: usize,
596 keep: usize,
597 ) -> Vec<String> {
598 let mut nodes: Vec<i32> = (1..=i32::try_from(nconds).unwrap_or(i32::MAX)).collect();
599 for n in nodes.iter_mut().skip(phases) {
600 *n = 0;
601 }
602 for (i, &n) in spec.nodes.iter().enumerate().take(nconds) {
603 nodes[i] = n.max(0); }
605 let key = spec.name.to_ascii_lowercase();
606 let state = self.buses.entry(key.clone()).or_insert_with(|| {
607 self.bus_order.push(key.clone());
608 BusState {
609 display: spec.name.clone(),
610 nodes: std::collections::BTreeSet::new(),
611 }
612 });
613 for &n in nodes.iter().take(keep) {
614 state.nodes.insert(n);
615 }
616 nodes.truncate(keep);
617 nodes.iter().map(ToString::to_string).collect()
618 }
619
620 fn linecode(&mut self, obj: &RawObject) -> DistLineCode {
623 let props = Props::new(obj);
624 let n = self.usize_or(
625 &props,
626 "nphases",
627 "linecode",
628 &obj.name,
629 dd::linecode::NPHASES,
630 );
631 let units = props.get("units").map(|v| v.text.clone());
632 let units_m = self.units_code(units.as_deref(), "linecode", &obj.name);
633 let per_meter = units_m.unwrap_or(1.0);
634 self.linecode_units
635 .insert(obj.name.to_ascii_lowercase(), units_m);
636 self.linecode_nconds
637 .insert(obj.name.to_ascii_lowercase(), n);
638
639 let freq = self
640 .f64_prop(props.get("basefreq"))
641 .unwrap_or(self.net.base_frequency());
642
643 let z = self.impedance_matrices(
644 &props,
645 n,
646 "linecode",
647 &obj.name,
648 dd::line::R1,
649 dd::line::X1,
650 dd::line::R0,
651 dd::line::X0,
652 dd::line::C1_NF,
653 dd::line::C0_NF,
654 );
655 if z.all_default {
656 self.defaulted("linecode", &obj.name, "rmatrix");
657 }
658
659 let b_half = scale_mat(
662 &z.c_nf,
663 std::f64::consts::TAU * freq * 1e-9 / per_meter / 2.0,
664 );
665 let zero = vec![vec![0.0; n]; n];
666
667 let amps = self.f64_or(
670 &props,
671 "emergamps",
672 "linecode",
673 &obj.name,
674 dd::line::EMERGAMPS,
675 );
676 let i_max = Some(vec![amps; n]);
677
678 let mut extras = extras_from_leftovers(&props);
679 if let Some(u) = units {
680 extras.insert("units".into(), u.into());
681 }
682 for (key, text) in z.malformed {
683 extras.insert(key.to_string(), text.into());
684 }
685 DistLineCode {
686 name: obj.name.clone(),
687 n_conductors: n,
688 r_series: scale_mat(&z.r, 1.0 / per_meter),
689 x_series: scale_mat(&z.x, 1.0 / per_meter),
690 g_from: zero.clone(),
691 b_from: b_half.clone(),
692 g_to: zero,
693 b_to: b_half,
694 i_max,
695 s_max: None,
696 source: None,
697 extras,
698 }
699 }
700
701 #[allow(clippy::too_many_arguments)]
704 fn impedance_matrices(
705 &mut self,
706 props: &Props,
707 n: usize,
708 class: &str,
709 name: &str,
710 r1d: f64,
711 x1d: f64,
712 r0d: f64,
713 x0d: f64,
714 c1d: f64,
715 c0d: f64,
716 ) -> SeriesImpedance {
717 let mut malformed: Vec<(&'static str, String)> = Vec::new();
718 let mut rows = |key: &'static str| -> Option<ConductorMatrix> {
719 let v = props.get(key)?;
720 let parsed = v
721 .to_rows(Some(self.vars))
722 .ok()
723 .and_then(|rows| square_from_rows(&rows, n));
724 if parsed.is_none() {
725 malformed.push((key, v.text.clone()));
726 }
727 parsed
728 };
729 let rm = rows("rmatrix");
730 let xm = rows("xmatrix");
731 let cm = rows("cmatrix");
732 for (key, _) in &malformed {
736 self.warn(
737 &C::READ_DSS_RETAINED_SOURCE_ONLY,
738 format!(
739 "{class} {name}: `{key}` does not parse as a {n}x{n} matrix; \
740 sequence values apply and the text is kept in extras"
741 ),
742 );
743 }
744 let any_written = [
745 "rmatrix", "xmatrix", "cmatrix", "r1", "x1", "r0", "x0", "c1", "c0", "b1", "b0",
746 ]
747 .iter()
748 .any(|k| props.by_name.contains_key(*k));
749
750 let seq = |props: &Props, k1: &'static str, k0: &'static str, d1: f64, d0: f64| {
751 let v1 = props
752 .get(k1)
753 .and_then(|v| v.to_f64(Some(self.vars)).ok())
754 .unwrap_or(d1);
755 let v0 = props
756 .get(k0)
757 .and_then(|v| v.to_f64(Some(self.vars)).ok())
758 .unwrap_or(d0);
759 if n == 1 {
760 return vec![vec![v1]];
761 }
762 let s = (2.0 * v1 + v0) / 3.0;
765 let m = (v0 - v1) / 3.0;
766 let mut mat = vec![vec![m; n]; n];
767 for (i, row) in mat.iter_mut().enumerate() {
768 row[i] = s;
769 }
770 mat
771 };
772
773 SeriesImpedance {
774 r: rm.unwrap_or_else(|| seq(props, "r1", "r0", r1d, r0d)),
775 x: xm.unwrap_or_else(|| seq(props, "x1", "x0", x1d, x0d)),
776 c_nf: cm.unwrap_or_else(|| seq(props, "c1", "c0", c1d, c0d)),
777 all_default: !any_written,
778 malformed,
779 }
780 }
781
782 fn vsource(&mut self, obj: &RawObject) -> VoltageSource {
785 let props = Props::new(obj);
786 let phases = self.usize_or(&props, "phases", "vsource", &obj.name, dd::vsource::PHASES);
787 let basekv = self.f64_or(&props, "basekv", "vsource", &obj.name, dd::vsource::BASEKV);
788 let pu = self.f64_or(&props, "pu", "vsource", &obj.name, dd::vsource::PU);
789 let angle_deg = self.f64_or(
790 &props,
791 "angle",
792 "vsource",
793 &obj.name,
794 dd::vsource::ANGLE_DEG,
795 );
796 let spec = if let Some(v) = props.get("bus1") {
797 v.to_bus_spec()
798 } else {
799 self.defaulted("vsource", &obj.name, "bus1");
800 Value::new(dd::vsource::BUS1).to_bus_spec()
801 };
802 let map = self.terminals(&spec, phases, phases + 1, phases + 1);
803
804 let v_ln = if phases == 1 {
810 basekv * 1e3 * pu
811 } else {
812 basekv * 1e3 * pu / (2.0 * (std::f64::consts::PI / phases as f64).sin())
813 };
814 let mut v_magnitude = vec![v_ln; phases];
815 let mut v_angle: Vec<f64> = (0..phases)
816 .map(|k| {
817 let deg = angle_deg - 360.0 / phases as f64 * k as f64;
818 let a = deg.to_radians();
819 let shifted = (a + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU);
822 if shifted <= 0.0 {
823 std::f64::consts::PI
824 } else {
825 shifted - std::f64::consts::PI
826 }
827 })
828 .collect();
829 v_magnitude.push(0.0);
831 v_angle.push(0.0);
832
833 let mut extras = extras_from_leftovers(&props);
836 extras.insert("basekv".into(), basekv.into());
837 extras.insert("angle".into(), angle_deg.into());
838 if (pu - 1.0).abs() > 0.0 {
839 extras.insert("pu".into(), pu.into());
840 }
841 VoltageSource {
842 reference_terminal: None,
843 name: obj.name.clone(),
844 bus: spec.name,
845 terminal_map: map,
846 v_magnitude,
847 v_angle,
848 energy_cost_rate: None,
849 extras,
850 }
851 }
852
853 #[expect(clippy::too_many_lines)]
858 fn line(&mut self, obj: &RawObject) {
859 let props = Props::new(obj);
860 if crate::readiness::DEFERRED_GEOMETRY_KEYS
861 .iter()
862 .any(|key| props.by_name.contains_key(*key))
863 {
864 self.warn(&C::READ_DSS_GEOMETRY_UNRESOLVED, format!(
865 "line {}: geometry-derived conductor impedances are unresolved; the complete source object is retained without a synthetic linecode or terminal map",
866 obj.name
867 ));
868 self.net
869 .untyped_objects_mut()
870 .push(UntypedObject::from(obj));
871 return;
872 }
873 let explicit = self.usize_prop(props.get("phases"));
880 let from_code = props.get("linecode").and_then(|c| {
881 self.linecode_nconds
882 .get(&c.text.to_ascii_lowercase())
883 .copied()
884 });
885 let linecode_last = obj
886 .props
887 .iter()
888 .rev()
889 .find_map(|p| match p.name.as_deref() {
890 Some("phases") => Some(false),
891 Some("linecode") => Some(true),
892 _ => None,
893 })
894 .unwrap_or(false);
895 let phases = match (explicit, from_code) {
896 (Some(_), Some(n)) if linecode_last => n,
897 (Some(p), _) => p,
898 (None, Some(n)) => n,
899 (None, None) => dd::line::PHASES,
900 };
901 let spec1 = bus_spec(props.get("bus1"), "");
902 let spec2 = bus_spec(props.get("bus2"), "");
903 let map_from = self.terminals(&spec1, phases, phases, phases);
905 let map_to = self.terminals(&spec2, phases, phases, phases);
906
907 let is_switch = props.get("switch").is_some_and(super::lex::Value::to_bool);
908 if is_switch {
909 let amps = self.f64_or(&props, "emergamps", "line", &obj.name, dd::line::EMERGAMPS);
910 let i_max = Some(vec![amps; phases]);
911 let mut extras = extras_from_leftovers(&props);
912 for k in ["linecode", "length", "r1", "x1", "rmatrix", "xmatrix"] {
915 if let Some(v) = props.by_name.get(k) {
916 extras.insert(k.to_string(), v.text.clone().into());
917 self.warn(
918 &C::READ_DSS_RETAINED_SOURCE_ONLY,
919 format!(
920 "line {}: `{k}` is ignored by OpenDSS on switch=yes; kept in extras",
921 obj.name
922 ),
923 );
924 }
925 }
926 self.net.switches_mut().push(DistSwitch {
927 name: obj.name.clone(),
928 bus_from: spec1.name,
929 bus_to: spec2.name,
930 terminal_map_from: map_from,
931 terminal_map_to: map_to,
932 open: false,
933 i_max,
934 extras,
935 });
936 return;
937 }
938
939 let length_units = props.get("units").map(|v| v.text.clone());
940 let line_units_m = self.units_code(length_units.as_deref(), "line", &obj.name);
941 let length = self.f64_or(&props, "length", "line", &obj.name, dd::line::LENGTH);
942
943 let mut malformed: Vec<(&'static str, String)> = Vec::new();
950 let (linecode, length_factor, synthesized) = if let Some(code) = props.get("linecode") {
951 let lc_units_m = self
952 .linecode_units
953 .get(&code.text.to_ascii_lowercase())
954 .copied()
955 .flatten();
956 let factor = match (lc_units_m, line_units_m) {
957 (Some(_), Some(lf)) => lf,
958 (Some(lcf), None) => lcf,
959 (None, _) => 1.0,
960 };
961 (code.text.clone(), factor, false)
962 } else {
963 let factor = line_units_m.unwrap_or(1.0);
964 let (code, bad) = self.synthesize_linecode(&props, phases, factor, &obj.name);
965 malformed = bad;
966 (code, factor, true)
967 };
968
969 let (i_max, raw_emergamps) = self.line_rating(&props, phases, synthesized);
970 let mut extras = extras_from_leftovers(&props);
971 if let Some(u) = length_units {
972 extras.insert("units".into(), u.into());
973 }
974 if let Some(text) = raw_emergamps {
975 extras.insert("emergamps".into(), text.into());
976 }
977 for (key, text) in malformed {
978 extras.insert(key.to_string(), text.into());
979 }
980 self.net.lines_mut().push(DistLine {
981 name: obj.name.clone(),
982 bus_from: spec1.name,
983 bus_to: spec2.name,
984 terminal_map_from: map_from,
985 terminal_map_to: map_to,
986 linecode,
987 length: length * length_factor,
988 route: None,
989 i_max,
990 s_max: None,
991 extras,
992 });
993 }
994
995 fn line_rating(
1003 &self,
1004 props: &Props,
1005 phases: usize,
1006 synthesized_linecode: bool,
1007 ) -> (Option<Vec<f64>>, Option<String>) {
1008 if synthesized_linecode {
1013 return (None, None);
1014 }
1015 let Some(v) = props.get("emergamps") else {
1016 return (None, None);
1017 };
1018 match v.to_f64(Some(self.vars)) {
1019 Ok(amps) => (Some(vec![amps; phases]), None),
1020 Err(_) => (None, Some(v.text.clone())),
1021 }
1022 }
1023
1024 fn synthesize_linecode(
1028 &mut self,
1029 props: &Props,
1030 phases: usize,
1031 length_factor: f64,
1032 line_name: &str,
1033 ) -> (String, Vec<(&'static str, String)>) {
1034 let z = self.impedance_matrices(
1035 props,
1036 phases,
1037 "line",
1038 line_name,
1039 dd::line::R1,
1040 dd::line::X1,
1041 dd::line::R0,
1042 dd::line::X0,
1043 dd::line::C1_NF,
1044 dd::line::C0_NF,
1045 );
1046 if z.all_default {
1047 self.defaulted("line", line_name, "r1");
1048 self.defaulted("line", line_name, "x1");
1049 }
1050 let b_half = scale_mat(
1051 &z.c_nf,
1052 std::f64::consts::TAU * self.net.base_frequency() * 1e-9 / length_factor / 2.0,
1053 );
1054 let zero = vec![vec![0.0; phases]; phases];
1055 let amps = self.f64_or(props, "emergamps", "line", line_name, dd::line::EMERGAMPS);
1056 let i_max = Some(vec![amps; phases]);
1057 let name = format!("_line_{line_name}");
1058 self.net.line_codes_mut().push(DistLineCode {
1059 name: name.clone(),
1060 n_conductors: phases,
1061 r_series: scale_mat(&z.r, 1.0 / length_factor),
1062 x_series: scale_mat(&z.x, 1.0 / length_factor),
1063 g_from: zero.clone(),
1064 b_from: b_half.clone(),
1065 g_to: zero,
1066 b_to: b_half,
1067 i_max,
1068 s_max: None,
1069 source: None,
1070 extras: Extras::new(),
1071 });
1072 (name, z.malformed)
1073 }
1074
1075 fn load_power(&mut self, obj: &RawObject) -> LoadPower {
1089 let mut s = LoadPower {
1090 kw: dd::load::KW,
1091 kvar: 0.0,
1095 pf: dd::load::PF,
1096 spec_kvar: false, kw_written: false,
1098 pf_written: false,
1099 };
1100 let mut start = 0;
1101 for end in obj.edit_bounds() {
1102 for p in &obj.props[start..end] {
1103 let Some(key @ ("kw" | "kvar" | "pf")) = p.name.as_deref() else {
1104 continue;
1105 };
1106 let Some(v) = self.f64_prop(Some(&p.value)) else {
1107 continue;
1108 };
1109 match key {
1110 "kw" => {
1111 s.kw = v;
1112 s.spec_kvar = false;
1113 s.kw_written = true;
1114 }
1115 "kvar" => {
1116 s.kvar = v;
1117 s.spec_kvar = true;
1118 }
1119 _ => {
1120 s.pf = v;
1121 s.pf_written = true;
1122 }
1123 }
1124 }
1125 start = end;
1126 if s.spec_kvar {
1128 let kva = s.kw.hypot(s.kvar);
1129 if kva > 0.0 {
1130 s.pf = s.kw / kva;
1131 if s.kw * s.kvar < 0.0 {
1133 s.pf = -s.pf;
1134 }
1135 }
1136 } else {
1137 s.kvar = s.kw * (1.0 / (s.pf * s.pf) - 1.0).sqrt();
1138 if s.pf < 0.0 {
1139 s.kvar = -s.kvar;
1140 }
1141 }
1142 }
1143 s
1144 }
1145
1146 fn load(&mut self, obj: &RawObject) -> DistLoad {
1147 let props = Props::new(obj);
1148 let phases = self.usize_or(&props, "phases", "load", &obj.name, dd::load::PHASES);
1149 let conn_delta = props.get("conn").is_some_and(|v| {
1150 v.text.to_ascii_lowercase().starts_with('d') || v.text.eq_ignore_ascii_case("ll")
1151 });
1152 let kv = self.f64_or(&props, "kv", "load", &obj.name, dd::load::KV);
1153 let LoadPower {
1154 kw,
1155 kvar: q_total,
1156 pf,
1157 spec_kvar,
1158 kw_written,
1159 pf_written,
1160 } = self.load_power(obj);
1161 if !kw_written {
1162 self.defaulted("load", &obj.name, "kw");
1163 }
1164 let _ = (props.get("kw"), props.get("kvar"), props.get("pf"));
1166 let mut pf_source: Option<f64> = None;
1172 if !spec_kvar {
1173 if !pf_written {
1174 self.defaulted("load", &obj.name, "pf");
1175 }
1176 pf_source = Some(pf);
1177 }
1178 let model = self
1179 .usize_prop(props.get("model"))
1180 .map_or(dd::load::MODEL, |m| i64::try_from(m).unwrap_or(i64::MAX));
1181
1182 let spec = bus_spec(props.get("bus1"), "");
1183 let nconds = if conn_delta && phases == 3 {
1184 phases
1185 } else {
1186 phases + 1
1187 };
1188 let map = self.terminals(&spec, phases, nconds, nconds);
1189
1190 let configuration = if phases == 1 {
1191 Configuration::SinglePhase
1192 } else if conn_delta {
1193 Configuration::Delta
1194 } else {
1195 Configuration::Wye
1196 };
1197
1198 let mut extras = extras_from_leftovers(&props);
1205 self.stash_kv_and_phases(&props, &mut extras, kv, phases);
1206 if let Some(pf) = pf_source {
1207 extras.insert("pf".into(), pf.into());
1208 }
1209 if model != 1 {
1210 extras.insert("model".into(), model.into());
1211 }
1212 let v_phase = if phases >= 2 && configuration == Configuration::Wye {
1213 kv * 1e3 / 3f64.sqrt()
1214 } else {
1215 kv * 1e3
1216 };
1217 let v_nom = vec![v_phase; phases];
1218 let zipv = props
1219 .get("zipv")
1220 .and_then(|v| v.to_vector(Some(self.vars)).ok())
1221 .unwrap_or_default();
1222 let voltage_model = match model {
1223 2 => DistLoadVoltageModel::ConstantImpedance { v_nom },
1224 5 => DistLoadVoltageModel::ConstantCurrent { v_nom },
1225 8 if zipv.len() >= 6 => DistLoadVoltageModel::Zip {
1226 v_nom,
1227 alpha_z: vec![zipv[0]; phases],
1228 alpha_i: vec![zipv[1]; phases],
1229 alpha_p: vec![zipv[2]; phases],
1230 beta_z: vec![zipv[3]; phases],
1231 beta_i: vec![zipv[4]; phases],
1232 beta_p: vec![zipv[5]; phases],
1233 },
1234 8 => DistLoadVoltageModel::Zip {
1235 v_nom,
1236 alpha_z: Vec::new(),
1237 alpha_i: Vec::new(),
1238 alpha_p: Vec::new(),
1239 beta_z: Vec::new(),
1240 beta_i: Vec::new(),
1241 beta_p: Vec::new(),
1242 },
1243 _ => DistLoadVoltageModel::ConstantPower { v_nom },
1244 };
1245 DistLoad {
1246 name: obj.name.clone(),
1247 bus: spec.name,
1248 terminal_map: map,
1249 configuration,
1250 p_nom: vec![kw * 1e3 / phases as f64; phases],
1251 q_nom: vec![q_total * 1e3 / phases as f64; phases],
1252 voltage_model,
1253 extras,
1254 }
1255 }
1256
1257 #[allow(clippy::too_many_lines)] fn transformer(&mut self, obj: &RawObject) -> DistTransformer {
1261 let mut phases = dd::transformer::PHASES;
1264 let mut n_windings = dd::transformer::WINDINGS;
1265 let mut windings = vec![WindingRaw::default(); n_windings];
1266 let mut active = 0usize;
1267 let mut xhl = dd::transformer::XHL;
1268 let mut xht = dd::transformer::XHT;
1269 let mut xlt = dd::transformer::XLT;
1270 let mut xhl_specified = false;
1271 let mut xsc_pct = vec![dd::transformer::XHL];
1272 let mut extras = Extras::new();
1273 let conn_is_delta =
1274 |t: &str| t.to_ascii_lowercase().starts_with('d') || t.eq_ignore_ascii_case("ll");
1275 let mut begin = 0;
1276 for end in obj.edit_bounds() {
1277 let mut scalar_reactance_changed = false;
1278 for p in &obj.props[begin..end] {
1279 let Some(name) = &p.name else { continue };
1280 let v = &p.value;
1281 match name.as_str() {
1282 "phases" => {
1283 phases = self.usize_prop(Some(v)).unwrap_or(phases);
1284 }
1285 "windings" => {
1286 n_windings = self.usize_prop(Some(v)).unwrap_or(n_windings).max(1);
1287 windings = vec![WindingRaw::default(); n_windings];
1288 active = 0;
1289 }
1290 "wdg" => {
1291 let k = self.usize_prop(Some(v)).unwrap_or(1).max(1);
1292 grow(
1293 &mut windings,
1294 k,
1295 &mut n_windings,
1296 &obj.name,
1297 &mut self.diags,
1298 );
1299 active = k - 1;
1300 }
1301 "bus" => windings[active].bus = Some(v.to_bus_spec()),
1302 "conn" => windings[active].conn_delta = conn_is_delta(&v.text),
1303 "kv" | "kva" | "tap" | "%r" | "rneut" | "xneut" => {
1304 let parsed = self.f64_prop(Some(v));
1305 let w = &mut windings[active];
1306 match name.as_str() {
1307 "kv" => {
1308 w.kv = parsed.unwrap_or(w.kv);
1309 w.kv_specified = true;
1310 }
1311 "kva" => {
1312 w.kva = parsed.unwrap_or(w.kva);
1313 w.kva_specified = true;
1314 }
1315 "tap" => w.tap = parsed.unwrap_or(w.tap),
1316 "%r" => w.r_pct = parsed.unwrap_or(w.r_pct),
1317 "rneut" => w.r_neutral = parsed,
1318 "xneut" => w.x_neutral = parsed,
1319 _ => unreachable!("matched transformer scalar property"),
1320 }
1321 }
1322 "buses" | "conns" => {
1323 let items = v.to_string_list(Some(self.vars));
1324 grow(
1325 &mut windings,
1326 items.len(),
1327 &mut n_windings,
1328 &obj.name,
1329 &mut self.diags,
1330 );
1331 apply_winding_strings(&mut windings, name, &items);
1332 }
1333 "kvs" | "kvas" | "taps" | "%rs" => match v.to_vector(Some(self.vars)) {
1334 Ok(items) => {
1335 grow(
1336 &mut windings,
1337 items.len(),
1338 &mut n_windings,
1339 &obj.name,
1340 &mut self.diags,
1341 );
1342 apply_winding_numbers(&mut windings, name, &items);
1343 }
1344 Err(e) => self.warn(
1345 &C::READ_DSS_VALUE_DEFAULTED,
1346 format!("transformer {}: {name}: {e}", obj.name),
1347 ),
1348 },
1349 "%loadloss" => {
1350 if let Some(ll) = self.f64_prop(Some(v)) {
1355 for w in windings.iter_mut().take(2) {
1356 w.r_pct = ll / 2.0;
1357 }
1358 }
1359 extras.insert("%loadloss".to_string(), v.text.clone().into());
1360 }
1361 "xhl" | "x12" => {
1362 xhl = self.f64_prop(Some(v)).unwrap_or(xhl);
1363 xhl_specified = true;
1364 scalar_reactance_changed = true;
1365 }
1366 "xht" | "x13" => {
1367 xht = self.f64_prop(Some(v)).unwrap_or(xht);
1368 scalar_reactance_changed = true;
1369 }
1370 "xlt" | "x23" => {
1371 xlt = self.f64_prop(Some(v)).unwrap_or(xlt);
1372 scalar_reactance_changed = true;
1373 }
1374 "xscarray" => match v.to_vector(Some(self.vars)) {
1375 Ok(items) if items.len() == n_windings * (n_windings - 1) / 2 => {
1376 xsc_pct = items;
1377 xhl_specified = true;
1378 }
1379 Ok(items) => self.warn(
1380 &C::READ_DSS_VALUE_DEFAULTED,
1381 format!(
1382 "transformer {}: xscarray has {} values; expected {} for {n_windings} windings; retained reactances apply",
1383 obj.name, items.len(), n_windings * (n_windings - 1) / 2,
1384 ),
1385 ),
1386 Err(e) => self.warn(
1387 &C::READ_DSS_VALUE_DEFAULTED,
1388 format!("transformer {}: xscarray: {e}", obj.name),
1389 ),
1390 },
1391 other if x_pair_key(other).is_some() => {
1392 if let Some((i, j)) = x_pair_key(other) {
1393 let x = self.f64_prop(Some(v)).unwrap_or(0.0);
1394 if let Some(index) = pair_keys(n_windings).iter().position(|pair| *pair == (i, j)) {
1395 xsc_pct[index] = x;
1396 }
1397 }
1398 }
1399 other => {
1400 extras.insert(other.to_string(), v.text.clone().into());
1401 }
1402 }
1403 xsc_pct.resize(n_windings * (n_windings - 1) / 2, dd::transformer::XLT);
1404 }
1405 if scalar_reactance_changed && (2..=3).contains(&n_windings) {
1407 xsc_pct[0] = xhl;
1408 if n_windings == 3 {
1409 xsc_pct[1] = xht;
1410 xsc_pct[2] = xlt;
1411 }
1412 }
1413 begin = end;
1414 }
1415
1416 if !xhl_specified {
1417 self.defaulted("transformer", &obj.name, "xhl");
1418 }
1419 let out = self.finish_windings(&windings, phases, &obj.name);
1420
1421 DistTransformer {
1422 name: obj.name.clone(),
1423 windings: out,
1424 xsc_pct,
1425 phases,
1426 extras,
1427 }
1428 }
1429
1430 fn finish_windings(
1433 &mut self,
1434 windings: &[WindingRaw],
1435 phases: usize,
1436 name: &str,
1437 ) -> Vec<DistWinding> {
1438 let mut out = Vec::with_capacity(windings.len());
1439 for (i, w) in windings.iter().enumerate() {
1440 if !w.kv_specified {
1441 self.defaulted("transformer", name, "kv");
1442 }
1443 if !w.kva_specified {
1444 self.defaulted("transformer", name, "kva");
1445 }
1446 let spec = w
1447 .bus
1448 .clone()
1449 .unwrap_or_else(|| Value::new(format!("{name}_w{}", i + 1)).to_bus_spec());
1450 let keep = if w.conn_delta {
1456 phases.max(2)
1457 } else {
1458 phases + 1
1459 };
1460 let map = self.terminals(&spec, phases, phases + 1, keep);
1461 out.push(DistWinding {
1462 bus: spec.name,
1463 terminal_map: map,
1464 conn: if w.conn_delta {
1465 DistWindingConn::Delta
1466 } else {
1467 DistWindingConn::Wye
1468 },
1469 v_ref: w.kv * 1e3,
1470 s_rating: w.kva * 1e3,
1471 r_pct: w.r_pct,
1472 tap: w.tap,
1473 r_neutral: w.r_neutral,
1474 x_neutral: w.x_neutral,
1475 });
1476 }
1477 out
1478 }
1479
1480 fn capacitor(&mut self, obj: &RawObject) {
1483 self.kvar_shunt(obj, CAPACITOR_KVAR_SHUNT);
1484 }
1485
1486 fn reactor(&mut self, obj: &RawObject) {
1494 let props = Props::new(obj);
1495 let phases = self.usize_or(&props, "phases", "reactor", &obj.name, dd::reactor::PHASES);
1496 if phases == 0 {
1497 self.warn(
1498 &C::READ_DSS_OBJECT_UNTYPED,
1499 format!(
1500 "reactor {}: nonpositive `phases` value is not a typed shunt; kept untyped",
1501 obj.name
1502 ),
1503 );
1504 self.net
1505 .untyped_objects_mut()
1506 .push(UntypedObject::from(obj));
1507 return;
1508 }
1509 let bus = bus_spec(props.get("bus1"), "");
1510 let bus2 = props.get("bus2").map(super::lex::Value::to_bus_spec);
1511 let explicit_single_grounding = bus2
1512 .as_ref()
1513 .is_some_and(|return_bus| explicit_single_terminal_ground_return(&bus, return_bus));
1514 let grounding_return = explicit_single_grounding
1515 || bus2
1516 .as_ref()
1517 .is_some_and(|return_bus| same_bus_ground_return(&bus, return_bus, phases));
1518
1519 if bus2.is_some() && !grounding_return {
1520 self.warn(
1521 &C::READ_DSS_OBJECT_UNTYPED,
1522 format!(
1523 "reactor {}: series reactors (bus2) are not typed yet; kept untyped",
1524 obj.name
1525 ),
1526 );
1527 self.net
1528 .untyped_objects_mut()
1529 .push(UntypedObject::from(obj));
1530 return;
1531 }
1532
1533 if let Some(form) = REACTOR_IMPEDANCE_FORMS
1534 .iter()
1535 .find(|k| !matches!(**k, "r" | "x") && props.by_name.contains_key(**k))
1536 {
1537 self.warn(
1538 &C::READ_DSS_OBJECT_UNTYPED,
1539 format!(
1540 "reactor {}: impedance form (`{form}`) is not typed yet; kept untyped",
1541 obj.name
1542 ),
1543 );
1544 self.net
1545 .untyped_objects_mut()
1546 .push(UntypedObject::from(obj));
1547 return;
1548 }
1549 let has_rx = props.by_name.contains_key("r") || props.by_name.contains_key("x");
1550 if has_rx {
1551 if grounding_return {
1552 let grounding_phases = if explicit_single_grounding { 1 } else { phases };
1553 self.grounding_impedance_reactor(obj, &props, &bus, grounding_phases);
1554 } else {
1555 let form = if props.by_name.contains_key("r") {
1556 "r"
1557 } else {
1558 "x"
1559 };
1560 self.warn(
1561 &C::READ_DSS_OBJECT_UNTYPED,
1562 format!(
1563 "reactor {}: impedance form (`{form}`) is not typed yet; kept untyped",
1564 obj.name
1565 ),
1566 );
1567 self.net
1568 .untyped_objects_mut()
1569 .push(UntypedObject::from(obj));
1570 }
1571 return;
1572 }
1573
1574 self.kvar_shunt_with_props(obj, &props, REACTOR_KVAR_SHUNT);
1575 }
1576
1577 fn grounding_impedance_reactor(
1578 &mut self,
1579 obj: &RawObject,
1580 props: &Props<'_>,
1581 bus: &BusSpec,
1582 phases: usize,
1583 ) {
1584 let term = |v: Option<&Value>| v.map_or(Ok(0.0), |val| val.to_f64(Some(self.vars)));
1589 let (Ok(resistance), Ok(reactance)) = (term(props.get("r")), term(props.get("x"))) else {
1590 self.warn(
1591 &C::READ_DSS_OBJECT_UNTYPED,
1592 format!(
1593 "reactor {}: `r`/`x` does not evaluate to a number; kept untyped",
1594 obj.name
1595 ),
1596 );
1597 self.net
1598 .untyped_objects_mut()
1599 .push(UntypedObject::from(obj));
1600 return;
1601 };
1602 let denom = resistance * resistance + reactance * reactance;
1603 if !denom.is_finite() || denom <= 0.0 {
1604 self.warn(&C::READ_DSS_OBJECT_UNTYPED, format!(
1605 "reactor {}: zero impedance grounding reactor is not a typed shunt; kept untyped",
1606 obj.name
1607 ));
1608 self.net
1609 .untyped_objects_mut()
1610 .push(UntypedObject::from(obj));
1611 return;
1612 }
1613 let map = self.terminals(bus, phases, phases + 1, phases);
1614 let dim = map.len();
1615 let mut conductance = vec![vec![0.0; dim]; dim];
1616 let mut susceptance = vec![vec![0.0; dim]; dim];
1617 let y_g = resistance / denom;
1618 let y_b = -reactance / denom;
1619 for idx in 0..dim {
1620 conductance[idx][idx] = y_g;
1621 susceptance[idx][idx] = y_b;
1622 }
1623 self.net.shunts_mut().push(DistShunt {
1624 name: obj.name.clone(),
1625 bus: bus.name.clone(),
1626 terminal_map: map,
1627 g: conductance,
1628 b: susceptance,
1629 extras: extras_from_leftovers(props),
1630 });
1631 }
1632
1633 fn kvar_shunt(&mut self, obj: &RawObject, spec: KvarShuntSpec) {
1634 let props = Props::new(obj);
1635 self.kvar_shunt_with_props(obj, &props, spec);
1636 }
1637
1638 #[expect(clippy::too_many_lines)]
1641 fn kvar_shunt_with_props(&mut self, obj: &RawObject, props: &Props<'_>, spec: KvarShuntSpec) {
1642 let phases = self.usize_or(props, "phases", spec.class, &obj.name, spec.default_phases);
1643 if phases == 0 {
1644 self.warn(
1645 &C::READ_DSS_OBJECT_UNTYPED,
1646 format!(
1647 "{} {}: nonpositive `phases` value is not a typed shunt; kept untyped",
1648 spec.class, obj.name
1649 ),
1650 );
1651 self.net
1652 .untyped_objects_mut()
1653 .push(UntypedObject::from(obj));
1654 return;
1655 }
1656 let conn_delta = props.get("conn").is_some_and(|v| {
1660 v.text.to_ascii_lowercase().starts_with('d') || v.text.eq_ignore_ascii_case("ll")
1661 });
1662 let bus = bus_spec(props.get("bus1"), "");
1663 if let Some(return_bus) = props.get("bus2").map(super::lex::Value::to_bus_spec)
1664 && !same_bus_ground_return(&bus, &return_bus, phases)
1665 {
1666 self.warn(
1667 &C::READ_DSS_OBJECT_UNTYPED,
1668 format!(
1669 "{} {}: series {} (bus2) are not typed yet; kept untyped",
1670 spec.class, obj.name, spec.series_name
1671 ),
1672 );
1673 self.net
1674 .untyped_objects_mut()
1675 .push(UntypedObject::from(obj));
1676 return;
1677 }
1678
1679 if conn_delta && phases == 1 && bus.nodes.len() < 2 {
1680 self.warn(
1681 &C::READ_DSS_OBJECT_UNTYPED,
1682 format!(
1683 "{} {}: single phase delta shunt needs two bus nodes; kept untyped",
1684 spec.class, obj.name
1685 ),
1686 );
1687 self.net
1688 .untyped_objects_mut()
1689 .push(UntypedObject::from(obj));
1690 return;
1691 }
1692 let kvar = props
1695 .get("kvar")
1696 .and_then(|v| v.to_vector(Some(self.vars)).ok())
1697 .and_then(|v| v.first().copied())
1698 .unwrap_or_else(|| {
1699 self.defaulted(spec.class, &obj.name, "kvar");
1700 spec.default_kvar
1701 });
1702 let kv = self.f64_or(props, "kv", spec.class, &obj.name, spec.default_kv);
1703 let v_ref = if conn_delta {
1706 kv * 1e3
1707 } else if phases == 2 || phases == 3 {
1708 kv * 1e3 / 3f64.sqrt()
1709 } else {
1710 kv * 1e3
1711 };
1712 let v_sq = v_ref * v_ref;
1716 if !v_ref.is_finite() || v_ref <= 0.0 || !v_sq.is_finite() || v_sq == 0.0 {
1717 self.warn(
1718 &C::READ_DSS_OBJECT_UNTYPED,
1719 format!(
1720 "{} {}: invalid `kv` value is not a typed shunt; kept untyped",
1721 spec.class, obj.name
1722 ),
1723 );
1724 self.net
1725 .untyped_objects_mut()
1726 .push(UntypedObject::from(obj));
1727 return;
1728 }
1729
1730 let (nconds, keep) = if conn_delta {
1731 let keep = match phases {
1732 1 => 2,
1733 2 => 3,
1734 _ => phases,
1735 };
1736 (keep, keep)
1737 } else {
1738 (phases + 1, phases)
1742 };
1743 let map = self.terminals(&bus, phases, nconds, keep);
1744 let Some(susceptance) =
1745 kvar_shunt_matrix(&map, phases, conn_delta, kvar, v_ref, spec.b_sign)
1746 else {
1747 self.warn(
1748 &C::READ_DSS_OBJECT_UNTYPED,
1749 format!(
1750 "{} {}: delta shunt terminal map is not typed; kept untyped",
1751 spec.class, obj.name
1752 ),
1753 );
1754 self.net
1755 .untyped_objects_mut()
1756 .push(UntypedObject::from(obj));
1757 return;
1758 };
1759 let mut extras = extras_from_leftovers(props);
1760 self.stash_kv_and_phases(props, &mut extras, kv, phases);
1761 extras.insert("kvar".into(), kvar.into());
1762 if conn_delta {
1763 extras.insert("conn".into(), "delta".into());
1764 }
1765 self.net.shunts_mut().push(DistShunt {
1766 name: obj.name.clone(),
1767 bus: bus.name,
1768 terminal_map: map,
1769 g: vec![vec![0.0; susceptance.len()]; susceptance.len()],
1770 b: susceptance,
1771 extras,
1772 });
1773 }
1774
1775 fn generator(&mut self, obj: &RawObject) -> DistGenerator {
1778 let props = Props::new(obj);
1779 let phases = self.usize_or(
1780 &props,
1781 "phases",
1782 "generator",
1783 &obj.name,
1784 dd::generator::PHASES,
1785 );
1786 let conn_delta = props.get("conn").is_some_and(|v| {
1788 v.text.to_ascii_lowercase().starts_with('d') || v.text.eq_ignore_ascii_case("ll")
1789 });
1790 let mut kw = dd::generator::KW;
1799 let mut kvar = dd::generator::KVAR;
1800 let mut pf = dd::generator::PF;
1801 let (mut kw_written, mut q_written) = (false, false);
1802 for p in &obj.props {
1803 let Some(key @ ("kw" | "kvar" | "pf")) = p.name.as_deref() else {
1804 continue;
1805 };
1806 let Some(v) = self.f64_prop(Some(&p.value)) else {
1807 continue;
1808 };
1809 match key {
1810 "kw" | "pf" => {
1811 if key == "kw" {
1812 kw = v;
1813 kw_written = true;
1814 } else {
1815 pf = v;
1816 q_written = true;
1817 }
1818 if pf != 0.0 {
1819 kvar = kw * (pf.acos().tan()).copysign(pf);
1820 }
1821 }
1822 _ => {
1823 kvar = v;
1824 q_written = true;
1825 let kva = kw.hypot(kvar);
1826 pf = if kva == 0.0 { 1.0 } else { kw / kva };
1827 if kw * kvar < 0.0 {
1828 pf = -pf;
1829 }
1830 }
1831 }
1832 }
1833 if !kw_written {
1834 self.defaulted("generator", &obj.name, "kw");
1835 }
1836 if !q_written {
1837 self.defaulted("generator", &obj.name, "kvar");
1838 }
1839 let _ = (props.get("kw"), props.get("kvar"), props.get("pf"));
1841 let kv = self.f64_or(&props, "kv", "generator", &obj.name, dd::generator::KV);
1842 let maxkvar = self.f64_prop(props.get("maxkvar"));
1843 let minkvar = self.f64_prop(props.get("minkvar"));
1844
1845 let spec = bus_spec(props.get("bus1"), "");
1846 let nconds = if conn_delta && phases == 3 {
1847 phases
1848 } else {
1849 phases + 1
1850 };
1851 let map = self.terminals(&spec, phases, nconds, nconds);
1852
1853 let per_phase = |total_kw: f64| vec![total_kw * 1e3 / phases as f64; phases];
1854 let mut extras = extras_from_leftovers(&props);
1855 self.stash_kv_and_phases(&props, &mut extras, kv, phases);
1856 DistGenerator {
1857 name: obj.name.clone(),
1858 bus: spec.name,
1859 terminal_map: map,
1860 configuration: if phases == 1 {
1861 Configuration::SinglePhase
1862 } else if conn_delta {
1863 Configuration::Delta
1864 } else {
1865 Configuration::Wye
1866 },
1867 p_nom: per_phase(kw),
1868 q_nom: per_phase(kvar),
1869 p_min: None,
1870 p_max: None,
1871 q_min: minkvar.map(per_phase),
1872 q_max: maxkvar.map(per_phase),
1873 cost: None,
1874 s_max: None,
1875 i_max: None,
1876 extras,
1877 }
1878 }
1879
1880 fn pvsystem(&mut self, obj: &RawObject) {
1883 let props = Props::new(obj);
1884 let phases = self.usize_or(
1885 &props,
1886 "phases",
1887 "pvsystem",
1888 &obj.name,
1889 dd::pvsystem::PHASES,
1890 );
1891 if phases == 0 {
1892 self.warn(
1893 &C::READ_DSS_OBJECT_UNTYPED,
1894 format!(
1895 "pvsystem {}: nonpositive `phases` value is not typed; kept untyped",
1896 obj.name
1897 ),
1898 );
1899 self.net
1900 .untyped_objects_mut()
1901 .push(UntypedObject::from(obj));
1902 return;
1903 }
1904 let conn_delta = props.get("conn").is_some_and(|v| {
1905 v.text.to_ascii_lowercase().starts_with('d') || v.text.eq_ignore_ascii_case("ll")
1906 });
1907 let spec = bus_spec(props.get("bus1"), "");
1908 let nconds = if conn_delta && phases == 3 {
1909 phases
1910 } else {
1911 phases + 1
1912 };
1913 let map = self.terminals(&spec, phases, nconds, nconds);
1914 let kv = self.f64_or(&props, "kv", "pvsystem", &obj.name, dd::pvsystem::KV);
1915 let irradiance = self.f64_or(
1916 &props,
1917 "irradiance",
1918 "pvsystem",
1919 &obj.name,
1920 dd::pvsystem::IRRADIANCE,
1921 );
1922 let pmpp = self.f64_or(&props, "pmpp", "pvsystem", &obj.name, dd::pvsystem::PMPP);
1923 let pct_pmpp = self
1924 .f64_prop(props.get("%pmpp"))
1925 .or_else(|| self.f64_prop(props.get("pctpmpp")))
1926 .unwrap_or(dd::pvsystem::PCT_PMPP);
1927 let kva = self
1928 .f64_prop(props.get("kva"))
1929 .unwrap_or(pmpp.max(f64::EPSILON));
1930 let per_phase = |total_kw: f64| vec![total_kw * 1e3 / phases as f64; phases];
1931 let p_avail = pmpp * irradiance * pct_pmpp / 100.0 * 1e3;
1932 let q_max = self
1933 .f64_prop(props.get("kvarmax"))
1934 .map(per_phase)
1935 .or_else(|| Some(vec![kva * 1e3 / phases as f64; phases]));
1936 let q_min = self
1937 .f64_prop(props.get("kvarmaxabs"))
1938 .map(|v| vec![-v * 1e3 / phases as f64; phases])
1939 .or_else(|| q_max.as_ref().map(|v| v.iter().map(|x| -*x).collect()));
1940 let topology = if phases == 1 {
1941 IbrTopology::SinglePhase
1942 } else if conn_delta {
1943 IbrTopology::ThreeLeg
1944 } else {
1945 IbrTopology::FourLeg
1946 };
1947 let pf = self.f64_prop(props.get("pf"));
1948 let mut extras = extras_from_leftovers(&props);
1949 self.stash_kv_and_phases(&props, &mut extras, kv, phases);
1950 extras.remove("conn");
1951 let mut ibr = DistIbr {
1952 name: obj.name.clone(),
1953 bus: spec.name,
1954 terminal_map: map,
1955 topology,
1956 prime_mover: IbrPrimeMover::Pv,
1957 s_max: vec![kva * 1e3 / phases as f64; phases],
1958 i_max: None,
1959 p_avail: Some(p_avail),
1960 p_min: Some(vec![0.0; phases]),
1961 p_max: Some(per_phase(pmpp * pct_pmpp / 100.0)),
1962 q_min,
1963 q_max,
1964 control_profile: None,
1965 voltage_aggregation: None,
1966 extras,
1967 };
1968 if let Some(pf) = pf {
1969 let profile = format!("{}_pf", obj.name);
1970 ibr.control_profile = Some(profile.clone());
1971 self.net.control_profiles_mut().push(DistControlProfile {
1972 name: profile,
1973 power_factor: Some(PowerFactorControl { pf }),
1974 volt_var: None,
1975 volt_watt: None,
1976 extras: Extras::new(),
1977 });
1978 }
1979 self.net.ibrs_mut().push(ibr);
1980 }
1981
1982 fn xycurve(&mut self, obj: &RawObject) {
1983 let props = Props::new(obj);
1984 let x = props
1985 .get("xarray")
1986 .and_then(|v| v.to_vector(Some(self.vars)).ok())
1987 .unwrap_or_default();
1988 let y = props
1989 .get("yarray")
1990 .and_then(|v| v.to_vector(Some(self.vars)).ok())
1991 .unwrap_or_default();
1992 if x.is_empty() || y.is_empty() {
1993 self.warn(
1994 &C::READ_DSS_OBJECT_UNTYPED,
1995 format!(
1996 "xycurve {}: xarray/yarray are incomplete; kept untyped",
1997 obj.name
1998 ),
1999 );
2000 self.net
2001 .untyped_objects_mut()
2002 .push(UntypedObject::from(obj));
2003 return;
2004 }
2005 self.xycurves
2006 .insert(obj.name.to_ascii_lowercase(), XyCurveRaw { x, y });
2007 }
2008
2009 fn invcontrol(&mut self, obj: &RawObject) {
2010 let props = Props::new(obj);
2011 let derlist = props
2012 .get("derlist")
2013 .map(|v| dss_name_list(&v.text))
2014 .unwrap_or_default();
2015 let mode = props
2016 .get("mode")
2017 .map(|v| v.text.to_ascii_lowercase())
2018 .unwrap_or_default();
2019 let combimode = props
2020 .get("combimode")
2021 .map(|v| v.text.to_ascii_lowercase())
2022 .unwrap_or_default();
2023 let mon = props
2024 .get("monvoltagecalc")
2025 .map(|v| v.text.to_ascii_lowercase())
2026 .unwrap_or_default();
2027 let voltage_reference = if mon.contains("avg") {
2028 ControlVoltageReference::PgAveraged
2029 } else {
2030 ControlVoltageReference::PgPerPhase
2031 };
2032 let mut profile = DistControlProfile::new(obj.name.clone());
2033 profile.volt_var =
2034 self.invcontrol_volt_var(obj, &props, &derlist, voltage_reference, &mode, &combimode);
2035 profile.volt_watt =
2036 self.invcontrol_volt_watt(&props, &derlist, voltage_reference, &mode, &combimode);
2037 if profile.power_factor.is_none()
2038 && profile.volt_var.is_none()
2039 && profile.volt_watt.is_none()
2040 {
2041 self.warn(
2042 &C::READ_DSS_OBJECT_UNTYPED,
2043 format!(
2044 "invcontrol {}: control mode is not typed; kept untyped",
2045 obj.name
2046 ),
2047 );
2048 self.net
2049 .untyped_objects_mut()
2050 .push(UntypedObject::from(obj));
2051 return;
2052 }
2053 for der in derlist {
2054 let name = der.rsplit_once('.').map_or(der.as_str(), |(_, name)| name);
2055 if let Some(ibr) = self
2056 .net
2057 .ibrs_mut()
2058 .iter_mut()
2059 .find(|ibr| ibr.name.eq_ignore_ascii_case(name))
2060 {
2061 ibr.control_profile = Some(profile.name.clone());
2062 if profile.volt_var.is_some() {
2063 ibr.extras.remove("%pminnovars");
2064 ibr.extras.remove("%pminkvarmax");
2065 }
2066 } else {
2067 self.warn(
2068 &C::READ_DSS_REFERENCE_DROPPED,
2069 format!(
2070 "invcontrol {}: DER `{der}` does not match a typed PVSystem",
2071 obj.name
2072 ),
2073 );
2074 }
2075 }
2076 self.net.control_profiles_mut().push(profile);
2077 }
2078
2079 fn invcontrol_volt_var(
2080 &mut self,
2081 obj: &RawObject,
2082 props: &Props<'_>,
2083 derlist: &[String],
2084 voltage_reference: ControlVoltageReference,
2085 mode: &str,
2086 combimode: &str,
2087 ) -> Option<VoltVarControl> {
2088 if !(mode.contains("voltvar") || combimode.contains("vv")) {
2089 return None;
2090 }
2091 let curve_name = props.get("vvc_curve1").map(|v| v.text.clone())?;
2092 let curve = self
2093 .xycurves
2094 .get(&curve_name.to_ascii_lowercase())
2095 .cloned()?;
2096 let base_v = self.control_base_voltage(derlist).unwrap_or_else(|| {
2097 self.warn(
2098 &C::READ_DSS_VALUE_DEFAULTED,
2099 format!(
2100 "invcontrol {}: no rated voltage found for vvc_curve1; using 1 pu as 1 V",
2101 obj.name
2102 ),
2103 );
2104 1.0
2105 });
2106 let q_ref = props
2107 .get("refreactivepower")
2108 .map(|v| v.text.to_ascii_uppercase())
2109 .filter(|s| s.contains("VARAVAL"))
2110 .map_or(ReactivePowerReference::VarMax, |_| {
2111 ReactivePowerReference::VarAvailable
2112 });
2113 let p_min_for_q = self
2114 .ibr_extra_f64(derlist, "%pminnovars")
2115 .or_else(|| self.f64_prop(props.get("%pminnovars")));
2116 let p_min_for_q_max = self
2117 .ibr_extra_f64(derlist, "%pminkvarmax")
2118 .or_else(|| self.f64_prop(props.get("%pminkvarmax")));
2119 Some(VoltVarControl {
2120 voltage_reference: Some(voltage_reference),
2121 breakpoints: curve.x.iter().map(|x| x * base_v).collect(),
2122 q_limits: if curve.y.len() >= 4 {
2123 vec![curve.y[3], curve.y[0]]
2124 } else {
2125 curve.y
2126 },
2127 q_unit: Some(ReactivePowerUnit::VaFraction),
2128 q_ref: Some(q_ref),
2129 p_min_for_q,
2130 p_min_for_q_max,
2131 })
2132 }
2133
2134 fn invcontrol_volt_watt(
2135 &mut self,
2136 props: &Props<'_>,
2137 derlist: &[String],
2138 voltage_reference: ControlVoltageReference,
2139 mode: &str,
2140 combimode: &str,
2141 ) -> Option<VoltWattControl> {
2142 if !(mode.contains("voltwatt") || combimode.contains("vw")) {
2143 return None;
2144 }
2145 let curve_name = props
2146 .get("voltwatt_curve")
2147 .or_else(|| props.get("volt_watt_curve"))
2148 .map(|v| v.text.clone())?;
2149 let curve = self
2150 .xycurves
2151 .get(&curve_name.to_ascii_lowercase())
2152 .cloned()?;
2153 let base_v = self.control_base_voltage(derlist).unwrap_or(1.0);
2154 let p_ref = props
2155 .get("voltwattyaxis")
2156 .map(|v| v.text.to_ascii_uppercase())
2157 .map_or(ActivePowerReference::SMax, |s| {
2158 if s.contains("PAVAILABLE") {
2159 ActivePowerReference::PAvailable
2160 } else if s.contains("PMPP") {
2161 ActivePowerReference::PMax
2162 } else {
2163 ActivePowerReference::SMax
2164 }
2165 });
2166 Some(VoltWattControl {
2167 voltage_reference: Some(voltage_reference),
2168 breakpoints: curve.x.iter().map(|x| x * base_v).collect(),
2169 p_limits: if curve.y.len() >= 2 {
2170 vec![curve.y[1], curve.y[0]]
2171 } else {
2172 curve.y
2173 },
2174 p_unit: Some(ActivePowerUnit::VaFraction),
2175 p_ref: Some(p_ref),
2176 })
2177 }
2178
2179 fn ibr_extra_f64(&self, derlist: &[String], key: &str) -> Option<f64> {
2180 derlist.iter().find_map(|der| {
2181 let name = der.rsplit_once('.').map_or(der.as_str(), |(_, name)| name);
2182 self.net
2183 .ibrs()
2184 .iter()
2185 .find(|ibr| ibr.name.eq_ignore_ascii_case(name))
2186 .and_then(|ibr| ibr.extras.get(key))
2187 .and_then(json_value_f64)
2188 })
2189 }
2190
2191 fn control_base_voltage(&self, derlist: &[String]) -> Option<f64> {
2192 let der = derlist.first()?;
2193 let name = der.rsplit_once('.').map_or(der.as_str(), |(_, name)| name);
2194 let ibr = self
2195 .net
2196 .ibrs()
2197 .iter()
2198 .find(|ibr| ibr.name.eq_ignore_ascii_case(name))?;
2199 let kv = ibr
2200 .extras
2201 .get("kv")
2202 .and_then(|v| {
2203 v.as_f64()
2204 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
2205 })
2206 .unwrap_or(dd::pvsystem::KV);
2207 Some(match ibr.topology {
2208 IbrTopology::FourLeg => kv * 1e3 / 3f64.sqrt(),
2209 IbrTopology::SinglePhase | IbrTopology::ThreeLeg => kv * 1e3,
2210 })
2211 }
2212
2213 fn swtcontrol(&mut self, obj: &RawObject) {
2216 let props = Props::new(obj);
2217 let Some(target) = props.get("switchedobj").map(|v| v.text.clone()) else {
2218 self.warn(
2219 &C::READ_DSS_REFERENCE_DROPPED,
2220 format!("swtcontrol {}: no SwitchedObj; ignored", obj.name),
2221 );
2222 return;
2223 };
2224 let line_name = match target.split_once('.') {
2227 Some((class, rest)) if class.eq_ignore_ascii_case("line") => rest,
2228 _ => target.as_str(),
2229 };
2230 let mut open = None;
2233 for p in &obj.props {
2234 match p.name.as_deref() {
2235 Some("action" | "state") => {
2236 open = Some(p.value.text.to_ascii_lowercase().starts_with('o'));
2237 }
2238 Some("normal") if open.is_none() => {
2239 open = Some(p.value.text.to_ascii_lowercase().starts_with('o'));
2240 }
2241 _ => {}
2242 }
2243 }
2244 let open = open.unwrap_or(false);
2245 match self
2246 .net
2247 .switches_mut()
2248 .iter_mut()
2249 .find(|s| s.name.eq_ignore_ascii_case(line_name))
2250 {
2251 Some(sw) => sw.open = open,
2252 None => self.warn(
2253 &C::READ_DSS_REFERENCE_DROPPED,
2254 format!(
2255 "swtcontrol {}: switched object `{target}` is not a switch line",
2256 obj.name
2257 ),
2258 ),
2259 }
2260 }
2261
2262 fn regcontrol(&mut self, obj: &RawObject) {
2263 let props = Props::new(obj);
2264 let target = props
2265 .get("transformer")
2266 .map_or_else(String::new, |v| v.text.clone());
2267 self.warn(&C::READ_DSS_VALUE_UNSUPPORTED, format!(
2268 "regcontrol {}: voltage regulation is ignored; transformer `{target}` keeps its written taps",
2269 obj.name
2270 ));
2271 if !target.is_empty() {
2272 self.regulated.insert(target.to_ascii_lowercase());
2273 }
2274 self.net
2275 .untyped_objects_mut()
2276 .push(UntypedObject::from(obj));
2277 }
2278
2279 fn classify_regulators(&mut self) {
2286 let regulated = std::mem::take(&mut self.regulated);
2287 if regulated.is_empty() {
2288 return;
2289 }
2290 let mut legs: Vec<usize> = Vec::new();
2291 for (idx, t) in self.net.transformers_mut().iter_mut().enumerate() {
2292 if !regulated.contains(&t.name.to_ascii_lowercase()) || !is_series_regulator(t) {
2293 continue;
2294 }
2295 t.extras.insert(
2296 "bmopf_subtype".into(),
2297 "single_phase_autotransformer".into(),
2298 );
2299 if winding_phase_pair(&t.windings[0]).is_some() {
2300 legs.push(idx);
2301 }
2302 }
2303 let mut groups: BTreeMap<(String, String), Vec<usize>> = BTreeMap::new();
2304 for &idx in &legs {
2305 let t = &self.net.transformers()[idx];
2306 let key = (
2307 t.windings[0].bus.to_ascii_lowercase(),
2308 t.windings[1].bus.to_ascii_lowercase(),
2309 );
2310 groups.entry(key).or_default().push(idx);
2311 }
2312 for idxs in groups.into_values() {
2313 let [a, b] = idxs[..] else { continue };
2314 let (ta, tb) = (&self.net.transformers()[a], &self.net.transformers()[b]);
2315 let pair = |t: &DistTransformer| winding_phase_pair(&t.windings[0]);
2316 let (Some(pa), Some(pb)) = (pair(ta), pair(tb)) else {
2317 continue;
2318 };
2319 if open_delta_connection(pa, pb).is_none() || !open_delta_pairable(ta, tb) {
2320 continue;
2321 }
2322 for idx in [a, b] {
2323 self.net.transformers_mut()[idx]
2324 .extras
2325 .insert("bmopf_subtype".into(), "open_delta_regulator".into());
2326 }
2327 }
2328 }
2329}
2330
2331fn is_series_regulator(t: &DistTransformer) -> bool {
2335 let [a, b] = t.windings.as_slice() else {
2336 return false;
2337 };
2338 let positive = |v: f64| v.is_finite() && v > 0.0;
2339 let phase_terms = |w: &DistWinding| -> Vec<String> {
2340 w.terminal_map
2341 .iter()
2342 .filter(|t| *t != "0")
2343 .cloned()
2344 .collect()
2345 };
2346 let phases = phase_terms(a);
2347 t.phases == 1
2348 && a.conn == b.conn
2349 && a.v_ref.to_bits() == b.v_ref.to_bits()
2350 && positive(a.v_ref)
2351 && a.s_rating.to_bits() == b.s_rating.to_bits()
2352 && positive(a.s_rating)
2353 && !a.bus.eq_ignore_ascii_case(&b.bus)
2354 && phases == phase_terms(b)
2355 && matches!(phases.len(), 1 | 2)
2356 && (phases.len() < 2 || phases[0] != phases[1])
2357}
2358
2359fn scale_mat(m: &ConductorMatrix, k: f64) -> ConductorMatrix {
2361 m.iter()
2362 .map(|row| row.iter().map(|v| v * k).collect())
2363 .collect()
2364}
2365
2366fn filled_phase_nodes(spec: &BusSpec, phases: usize) -> Vec<i32> {
2367 let mut nodes: Vec<i32> = (1..=i32::try_from(phases).unwrap_or(i32::MAX)).collect();
2368 for (idx, &node) in spec.nodes.iter().enumerate().take(phases) {
2369 nodes[idx] = node.max(0);
2370 }
2371 nodes
2372}
2373
2374fn same_bus_ground_return(bus: &BusSpec, return_bus: &BusSpec, phases: usize) -> bool {
2375 bus.name.eq_ignore_ascii_case(&return_bus.name)
2376 && !return_bus.nodes.is_empty()
2377 && filled_phase_nodes(return_bus, phases)
2378 .iter()
2379 .all(|&n| n <= 0)
2380}
2381
2382fn explicit_single_terminal_ground_return(bus: &BusSpec, return_bus: &BusSpec) -> bool {
2383 bus.name.eq_ignore_ascii_case(&return_bus.name)
2384 && bus.nodes.len() == 1
2385 && return_bus.nodes.len() == 1
2386 && return_bus.nodes[0] <= 0
2387}
2388
2389fn dss_name_list(text: &str) -> Vec<String> {
2390 text.trim_matches(|c: char| matches!(c, '[' | ']' | '(' | ')'))
2391 .split(|c: char| c == ',' || c.is_whitespace())
2392 .filter(|s| !s.is_empty())
2393 .map(str::to_string)
2394 .collect()
2395}
2396
2397fn json_value_f64(value: &serde_json::Value) -> Option<f64> {
2398 value
2399 .as_f64()
2400 .or_else(|| value.as_str().and_then(|s| s.parse().ok()))
2401}
2402
2403pub(super) fn delta_edges(n: usize, phases: usize) -> Vec<(usize, usize)> {
2407 if n < 2 {
2408 Vec::new()
2409 } else if phases >= 3 && n >= 3 {
2410 (0..n).map(|i| (i, (i + 1) % n)).collect()
2411 } else {
2412 let branches = phases.max(1).min(n - 1);
2413 (0..branches).map(|i| (i, i + 1)).collect()
2414 }
2415}
2416
2417fn kvar_shunt_matrix(
2418 map: &[String],
2419 phases: usize,
2420 conn_delta: bool,
2421 kvar: f64,
2422 v_ref: f64,
2423 b_sign: f64,
2424) -> Option<ConductorMatrix> {
2425 let dim = map.len();
2426 let mut susceptance = vec![vec![0.0; dim]; dim];
2427 if conn_delta {
2428 let edges = delta_edges(dim, phases);
2429 if edges.is_empty() || map.iter().any(|t| t == "0") {
2430 return None;
2431 }
2432 let b_branch = b_sign * kvar * 1e3 / edges.len() as f64 / (v_ref * v_ref);
2433 for (from, to) in edges {
2434 susceptance[from][from] += b_branch;
2435 susceptance[to][to] += b_branch;
2436 susceptance[from][to] -= b_branch;
2437 susceptance[to][from] -= b_branch;
2438 }
2439 } else {
2440 let b_phase = b_sign * kvar * 1e3 / phases as f64 / (v_ref * v_ref);
2441 for (idx, row) in susceptance.iter_mut().enumerate().take(phases) {
2442 row[idx] = b_phase;
2443 }
2444 }
2445 Some(susceptance)
2446}
2447
2448fn bus_spec(v: Option<&Value>, fallback: &str) -> BusSpec {
2449 v.map_or_else(
2450 || Value::new(fallback).to_bus_spec(),
2451 super::lex::Value::to_bus_spec,
2452 )
2453}
2454
2455fn extras_from_leftovers(props: &Props) -> Extras {
2456 let mut extras = Extras::new();
2457 for (k, v) in props.leftovers() {
2458 extras.insert(k.to_string(), v.text.clone().into());
2459 }
2460 extras
2461}
2462
2463fn apply_winding_strings(windings: &mut [WindingRaw], name: &str, items: &[String]) {
2465 let conn_is_delta =
2466 |t: &str| t.to_ascii_lowercase().starts_with('d') || t.eq_ignore_ascii_case("ll");
2467 for (w, item) in windings.iter_mut().zip(items) {
2470 if name == "buses" {
2471 w.bus = Some(Value::new(item.clone()).to_bus_spec());
2472 } else {
2473 w.conn_delta = conn_is_delta(item);
2474 }
2475 }
2476}
2477
2478fn apply_winding_numbers(windings: &mut [WindingRaw], name: &str, items: &[f64]) {
2481 for (w, &item) in windings.iter_mut().zip(items) {
2482 match name {
2483 "kvs" => {
2484 w.kv = item;
2485 w.kv_specified = true;
2486 }
2487 "kvas" => {
2488 w.kva = item;
2489 w.kva_specified = true;
2490 }
2491 "taps" => w.tap = item,
2492 _ => w.r_pct = item,
2493 }
2494 }
2495}
2496
2497fn x_pair_key(name: &str) -> Option<(usize, usize)> {
2498 let rest = name.strip_prefix('x')?;
2499 if rest.len() != 2 || !rest.chars().all(|c| c.is_ascii_digit()) {
2500 return None;
2501 }
2502 let mut chars = rest.chars();
2503 let i = chars.next()?.to_digit(10)? as usize;
2504 let j = chars.next()?.to_digit(10)? as usize;
2505 if i == 0 || j == 0 || i == j {
2506 return None;
2507 }
2508 Some((i.min(j) - 1, i.max(j) - 1))
2509}
2510
2511struct LoadPower {
2515 kw: f64,
2516 kvar: f64,
2517 pf: f64,
2518 spec_kvar: bool,
2520 kw_written: bool,
2521 pf_written: bool,
2522}
2523
2524struct SeriesImpedance {
2526 r: ConductorMatrix,
2527 x: ConductorMatrix,
2528 c_nf: ConductorMatrix,
2529 all_default: bool,
2531 malformed: Vec<(&'static str, String)>,
2535}
2536
2537#[derive(Clone)]
2538struct WindingRaw {
2539 bus: Option<BusSpec>,
2540 conn_delta: bool,
2541 kv: f64,
2542 kva: f64,
2543 tap: f64,
2544 r_pct: f64,
2545 r_neutral: Option<f64>,
2546 x_neutral: Option<f64>,
2547 kv_specified: bool,
2548 kva_specified: bool,
2549}
2550
2551impl Default for WindingRaw {
2552 fn default() -> Self {
2553 WindingRaw {
2554 bus: None,
2555 conn_delta: false,
2556 kv: dd::transformer::KV,
2557 kva: dd::transformer::KVA,
2558 tap: dd::transformer::TAP,
2559 r_pct: dd::transformer::PCT_R,
2560 r_neutral: None,
2561 x_neutral: None,
2562 kv_specified: false,
2563 kva_specified: false,
2564 }
2565 }
2566}
2567
2568fn grow(
2570 windings: &mut Vec<WindingRaw>,
2571 requested: usize,
2572 count: &mut usize,
2573 name: &str,
2574 diags: &mut crate::collect::Diagnostics,
2575) {
2576 let n = if requested > MAX_COUNT {
2581 diags.push(
2582 &C::READ_DSS_VALUE_CLAMPED,
2583 format!(
2584 "transformer {name}: winding count {requested} exceeds the supported maximum \
2585 of {MAX_COUNT}; clamped"
2586 ),
2587 );
2588 MAX_COUNT
2589 } else {
2590 requested
2591 };
2592 if n > windings.len() {
2593 windings.resize(n, WindingRaw::default());
2594 *count = n;
2595 }
2596}
2597
2598#[cfg(test)]
2599mod tests {
2600 use super::*;
2601 use crate::testkit::{Parsed, parse_dss_str};
2602
2603 fn has_warning(net: &Parsed, needle: &str) -> bool {
2604 net.warnings.iter().any(|w| w.contains(needle))
2605 }
2606
2607 #[test]
2608 fn vsource_magnitude_is_the_polygon_chord() {
2609 let net = parse_dss_str(
2612 "New Circuit.c basekv=12.47 pu=1.05 phases=2 bus1=src.1.2\n\
2613 New Vsource.aux basekv=12.47 phases=4 bus1=b2\n\
2614 New Vsource.solo basekv=2.4 phases=1 bus1=b3.1",
2615 );
2616 let two = &net.sources()[0];
2617 assert!((two.v_magnitude[0] - 12.47e3 * 1.05 / 2.0).abs() < 1e-9);
2618 assert!((two.v_angle[1] - std::f64::consts::PI).abs() < 1e-12);
2621 let four = &net.sources()[1];
2622 let chord = 2.0 * (std::f64::consts::PI / 4.0).sin();
2623 assert!((four.v_magnitude[0] - 12.47e3 / chord).abs() < 1e-9);
2624 let solo = &net.sources()[2];
2625 assert!((solo.v_magnitude[0] - 2.4e3).abs() < 1e-9);
2626 }
2627
2628 #[test]
2629 fn vsource_defaults_are_recorded() {
2630 let net = parse_dss_str("New Circuit.c1");
2631 let fields = net.defaulted().get("vsource.source").expect("entry");
2632 for key in ["phases", "pu", "angle", "basekv", "bus1"] {
2633 assert!(fields.contains(&key), "missing {key}");
2634 }
2635 }
2636
2637 fn r_and_length(lc_tail: &str, line_tail: &str) -> (f64, f64) {
2639 let net = parse_dss_str(&format!(
2640 "New Circuit.c\n\
2641 New Linecode.lc nphases=1 rmatrix=(0.5){lc_tail}\n\
2642 New Line.l1 bus1=a.1 bus2=b.1 phases=1 linecode=lc{line_tail}"
2643 ));
2644 let line = net.lines().iter().find(|l| l.name == "l1").unwrap();
2645 let code = net.linecode(&line.linecode).unwrap();
2646 (code.r_series[0][0], line.length)
2647 }
2648
2649 #[test]
2650 fn unitless_line_length_is_in_linecode_units() {
2651 let (r, len) = r_and_length(" units=km", " length=2");
2655 assert!((len - 2000.0).abs() < 1e-9);
2656 assert!((r * len - 1.0).abs() < 1e-12);
2657 }
2658
2659 #[test]
2660 fn unitless_linecode_is_per_line_unit() {
2661 let (r, len) = r_and_length("", " length=2 units=km");
2664 assert!((len - 2.0).abs() < 1e-12);
2665 assert!((r * len - 1.0).abs() < 1e-12);
2666 }
2667
2668 #[test]
2669 fn written_units_on_both_sides_convert() {
2670 let (r, len) = r_and_length(" units=km", " length=500 units=m");
2672 assert!((len - 500.0).abs() < 1e-9);
2673 assert!((r * len - 0.25).abs() < 1e-12);
2674 }
2675
2676 #[test]
2677 fn one_phase_inline_sequence_values_stay_positive_sequence() {
2678 let net = parse_dss_str(
2679 "New Circuit.c\n\
2680 New Line.l1 bus1=a.1 bus2=b.1 phases=1 length=0.5 units=km r1=0.5 x1=0.2 c1=3",
2681 );
2682 let line = net.lines().iter().find(|l| l.name == "l1").unwrap();
2683 let code = net.linecode(&line.linecode).unwrap();
2684 assert!((line.length - 500.0).abs() < 1e-9);
2685 assert!((code.r_series[0][0] * line.length - 0.25).abs() < 1e-12);
2686 assert!((code.x_series[0][0] * line.length - 0.1).abs() < 1e-12);
2687 }
2688
2689 #[test]
2690 fn no_units_anywhere_takes_the_raw_product() {
2691 let (r, len) = r_and_length("", " length=2");
2692 assert!((len - 2.0).abs() < 1e-12);
2693 assert!((r * len - 1.0).abs() < 1e-12);
2694 }
2695
2696 #[test]
2697 fn two_phase_wye_capacitor_kv_is_line_to_line() {
2698 let net = parse_dss_str(
2701 "New Circuit.c\n\
2702 New Capacitor.c2 bus1=b.1.2 phases=2 kv=12.47 kvar=600\n\
2703 New Capacitor.c1 bus1=b.3 phases=1 kv=7.2 kvar=300",
2704 );
2705 let c2 = net.shunts().iter().find(|s| s.name == "c2").unwrap();
2706 let v2 = 12.47e3 / 3f64.sqrt();
2707 assert!((c2.b[0][0] * v2 * v2 / 300e3 - 1.0).abs() < 1e-12);
2708 let c1 = net.shunts().iter().find(|s| s.name == "c1").unwrap();
2709 let v1 = 7.2e3;
2710 assert!((c1.b[0][0] * v1 * v1 / 300e3 - 1.0).abs() < 1e-12);
2711 }
2712
2713 #[test]
2714 fn capacitor_and_reactor_kvar_shunts_share_magnitude_with_opposite_sign() {
2715 let net = parse_dss_str(
2716 "New Circuit.c\n\
2717 New Capacitor.cap bus1=b.1 phases=1 kv=7.2 kvar=300\n\
2718 New Reactor.rea bus1=b.2 phases=1 kv=7.2 kvar=300",
2719 );
2720 let cap = net.shunts().iter().find(|s| s.name == "cap").unwrap();
2721 let rea = net.shunts().iter().find(|s| s.name == "rea").unwrap();
2722 assert!(cap.b[0][0] > 0.0);
2723 assert!(rea.b[0][0] < 0.0);
2724 assert!((cap.b[0][0] + rea.b[0][0]).abs() < 1e-18);
2725 }
2726
2727 #[test]
2728 fn kvar_shunts_with_nonpositive_phases_stay_untyped() {
2729 let net = parse_dss_str(
2730 "New Circuit.c\n\
2731 New Capacitor.cap bus1=b.1 phases=0 kv=7.2 kvar=300\n\
2732 New Reactor.rea bus1=b.2 phases=0 kv=7.2 kvar=300",
2733 );
2734 assert_eq!(net.shunts().as_slice(), []);
2735 assert!(
2736 net.untyped_objects()
2737 .iter()
2738 .any(|u| u.class.eq_ignore_ascii_case("capacitor") && u.name == "cap")
2739 );
2740 assert!(
2741 net.untyped_objects()
2742 .iter()
2743 .any(|u| u.class.eq_ignore_ascii_case("reactor") && u.name == "rea")
2744 );
2745 assert!(
2746 net.warnings
2747 .iter()
2748 .any(|w| w.contains("capacitor cap: nonpositive `phases`"))
2749 );
2750 assert!(
2751 net.warnings
2752 .iter()
2753 .any(|w| w.contains("reactor rea: nonpositive `phases`"))
2754 );
2755 }
2756
2757 #[test]
2758 fn ll_connection_means_delta() {
2759 let net = parse_dss_str(
2761 "New Circuit.c\n\
2762 New Generator.g bus1=b.1.2.3 phases=3 conn=ll kw=90 kvar=30 kv=4.16\n\
2763 New Capacitor.cap bus1=b.1.2.3 phases=3 conn=ll kvar=600 kv=4.16",
2764 );
2765 assert_eq!(net.generators()[0].configuration, Configuration::Delta);
2766 assert_eq!(net.shunts().len(), 1);
2768 let sh = &net.shunts()[0];
2769 assert!(sh.b[0][1] < 0.0, "{:?}", sh.b);
2770 assert_eq!(sh.terminal_map, vec!["1", "2", "3"]);
2771 assert!(
2772 net.untyped_objects()
2773 .iter()
2774 .all(|u| !(u.class.eq_ignore_ascii_case("capacitor") && u.name == "cap"))
2775 );
2776 }
2777
2778 #[test]
2779 fn load_kw_after_kvar_reverts_to_pf() {
2780 let net =
2783 parse_dss_str("New Circuit.c\nNew Load.l bus1=b.1 phases=1 kv=2.4 kvar=20 kw=100");
2784 let l = &net.loads()[0];
2785 let q: f64 = l.q_nom.iter().sum();
2786 assert!((q - 100e3 * 0.88f64.acos().tan()).abs() < 1e-6);
2787 assert_eq!(
2788 l.extras.get("pf").and_then(serde_json::Value::as_f64),
2789 Some(0.88)
2790 );
2791 assert!(
2792 net.defaulted()
2793 .get("load.l")
2794 .is_some_and(|f| f.contains(&"pf"))
2795 );
2796 }
2797
2798 #[test]
2799 fn load_like_replays_the_sources_recalced_pf() {
2800 let net = parse_dss_str(
2807 "New Circuit.c\n\
2808 New Load.a bus1=b.1 phases=1 kv=2.4 kvar=20\n\
2809 New Load.b like=a kw=100",
2810 );
2811 let b = net.loads().iter().find(|l| l.name == "b").unwrap();
2812 let q: f64 = b.q_nom.iter().sum();
2813 assert!((q - 200e3).abs() < 1e-6);
2814 let pf = b.extras.get("pf").and_then(serde_json::Value::as_f64);
2816 assert!((pf.unwrap() - 0.447_213_595_499_957_9).abs() < 1e-12);
2817 let a = net.loads().iter().find(|l| l.name == "a").unwrap();
2819 let qa: f64 = a.q_nom.iter().sum();
2820 assert!((qa - 20e3).abs() < 1e-9);
2821 }
2822
2823 #[test]
2824 fn load_tilde_continuation_recalcs_at_each_edit() {
2825 let net = parse_dss_str(
2829 "New Circuit.c\n\
2830 New Load.l bus1=b.1 phases=1 kv=2.4 kvar=20\n\
2831 ~ kw=100",
2832 );
2833 let q: f64 = net.loads()[0].q_nom.iter().sum();
2834 assert!((q - 200e3).abs() < 1e-6);
2835 }
2836
2837 #[test]
2838 fn load_pf_between_kvar_and_kw_applies() {
2839 let net = parse_dss_str(
2844 "New Circuit.c\nNew Load.l bus1=b.1 phases=1 kv=2.4 kvar=20 pf=0.95 kw=100",
2845 );
2846 let l = &net.loads()[0];
2847 let q: f64 = l.q_nom.iter().sum();
2848 assert!((q - 100e3 * 0.95f64.acos().tan()).abs() < 1e-6);
2849 assert_eq!(
2850 l.extras.get("pf").and_then(serde_json::Value::as_f64),
2851 Some(0.95)
2852 );
2853 assert!(
2854 !net.defaulted()
2855 .get("load.l")
2856 .is_some_and(|f| f.contains(&"pf"))
2857 );
2858 }
2859
2860 #[test]
2861 fn load_kvar_after_kw_stays() {
2862 let net =
2863 parse_dss_str("New Circuit.c\nNew Load.l bus1=b.1 phases=1 kv=2.4 kw=100 kvar=20");
2864 let l = &net.loads()[0];
2865 let q: f64 = l.q_nom.iter().sum();
2866 assert!((q - 20e3).abs() < 1e-9);
2867 assert!(!l.extras.contains_key("pf"));
2869 }
2870
2871 #[test]
2872 fn generator_kw_after_kvar_resyncs_q() {
2873 let net =
2877 parse_dss_str("New Circuit.c\nNew Generator.g bus1=b.1 phases=1 kv=2.4 kvar=20 kw=100");
2878 let q: f64 = net.generators()[0].q_nom.iter().sum();
2879 assert!((q - 2e3).abs() < 1e-6);
2880 }
2881
2882 #[test]
2883 fn generator_kvar_after_kw_stays() {
2884 let net =
2885 parse_dss_str("New Circuit.c\nNew Generator.g bus1=b.1 phases=1 kv=2.4 kw=100 kvar=20");
2886 let q: f64 = net.generators()[0].q_nom.iter().sum();
2887 assert!((q - 20e3).abs() < 1e-9);
2888 }
2889
2890 #[test]
2891 fn generator_pf_after_kvar_wins() {
2892 let net = parse_dss_str(
2895 "New Circuit.c\nNew Generator.g bus1=b.1.2.3 phases=3 kv=4.16 kvar=20 pf=0.9",
2896 );
2897 let q: f64 = net.generators()[0].q_nom.iter().sum();
2898 assert!((q - 1000e3 * 0.9f64.acos().tan()).abs() < 1e-3);
2899 }
2900
2901 #[test]
2902 fn malformed_matrix_warns_and_keeps_text() {
2903 let net = parse_dss_str(
2907 "New Circuit.c\n\
2908 New Linecode.bad nphases=2 rmatrix=(1 2 3) units=m\n\
2909 New Line.l2 bus1=a.1.2 bus2=b.1.2 phases=2 rmatrix=(bogus) length=10",
2910 );
2911 assert!(has_warning(&net, "linecode bad") && has_warning(&net, "rmatrix"));
2912 assert!(
2913 !net.defaulted()
2914 .get("linecode.bad")
2915 .is_some_and(|f| f.contains(&"rmatrix"))
2916 );
2917 let code = net.linecode("bad").unwrap();
2918 assert!(
2919 code.extras
2920 .get("rmatrix")
2921 .and_then(serde_json::Value::as_str)
2922 .is_some_and(|s| s.contains("1 2 3"))
2923 );
2924 let diag = (2.0 * dd::line::R1 + dd::line::R0) / 3.0;
2926 assert!((code.r_series[0][0] - diag).abs() < 1e-12);
2927 assert!(has_warning(&net, "line l2"));
2929 let l2 = net.lines().iter().find(|l| l.name == "l2").unwrap();
2930 assert!(
2931 l2.extras
2932 .get("rmatrix")
2933 .and_then(serde_json::Value::as_str)
2934 .is_some_and(|s| s.contains("bogus"))
2935 );
2936 }
2937
2938 #[test]
2939 fn switchedobj_class_prefix_is_case_insensitive() {
2940 let net = parse_dss_str(
2941 "New Circuit.c\n\
2942 New Line.sw1 bus1=a.1 bus2=b.1 phases=1 switch=y\n\
2943 New SwtControl.s1 SwitchedObj=LINE.sw1 Action=open",
2944 );
2945 assert!(net.switches()[0].open);
2946 }
2947
2948 #[test]
2949 fn phases_token_rides_in_extras() {
2950 let net = parse_dss_str(
2953 "New Circuit.c\n\
2954 New Load.l bus1=b.1.2 phases=2 conn=delta kw=50 kvar=10 kv=4.8\n\
2955 New Generator.g bus1=b.1.2.3 kw=10 kvar=2 kv=4.16\n\
2956 New Capacitor.cap bus1=b.1.2.3 phases=3 kvar=600 kv=4.16",
2957 );
2958 let l = &net.loads()[0];
2959 assert_eq!(l.terminal_map.len(), 3);
2960 assert_eq!(
2961 l.extras.get("phases").and_then(serde_json::Value::as_str),
2962 Some("2")
2963 );
2964 assert_eq!(
2966 net.generators()[0]
2967 .extras
2968 .get("phases")
2969 .and_then(serde_json::Value::as_u64),
2970 Some(3)
2971 );
2972 assert_eq!(
2973 net.shunts()[0]
2974 .extras
2975 .get("phases")
2976 .and_then(serde_json::Value::as_str),
2977 Some("3")
2978 );
2979 }
2980
2981 #[test]
2982 fn rpn_kv_token_stashes_the_evaluated_value() {
2983 let net = parse_dss_str("New Circuit.c\nNew Load.l bus1=b.1 phases=1 kw=10 kv={4.8 2 /}");
2985 assert_eq!(
2986 net.loads()[0]
2987 .extras
2988 .get("kv")
2989 .and_then(serde_json::Value::as_f64),
2990 Some(2.4)
2991 );
2992 }
2993
2994 #[test]
2995 fn string_input_reads_no_filesystem_includes() {
2996 let net = parse_dss_str(
3000 "New Circuit.c basekv=12.47\nRedirect /etc/passwd\nBuscoords /etc/hosts\n\
3001 Redirect extra.dss\n",
3002 );
3003 assert_eq!(
3004 net.warnings
3005 .iter()
3006 .filter(|w| w.contains("READ.DSS.INCLUDE_REFUSED"))
3007 .count(),
3008 2,
3009 "absolute includes are refused as escapes: {:?}",
3010 net.warnings
3011 );
3012 assert_eq!(
3013 net.warnings
3014 .iter()
3015 .filter(|w| w.contains("READ.DSS.INCLUDE_LOAD_FAILED"))
3016 .count(),
3017 1,
3018 "a relative include finds no named buffer: {:?}",
3019 net.warnings
3020 );
3021 }
3022
3023 #[test]
3024 fn oversized_count_properties_are_clamped() {
3025 let net = parse_dss_str(
3029 "New Circuit.c basekv=12.47\nNew Transformer.t phases=1000000 windings=999999\n",
3030 );
3031 assert!(
3032 net.warnings
3033 .iter()
3034 .any(|w| w.contains("exceeds the supported maximum")),
3035 "warnings: {:?}",
3036 net.warnings
3037 );
3038 }
3039
3040 #[test]
3041 fn oversized_winding_list_is_clamped() {
3042 let buses = (0..5000)
3046 .map(|i| format!("b{i}"))
3047 .collect::<Vec<_>>()
3048 .join(",");
3049 let net = parse_dss_str(&format!(
3050 "New Circuit.c basekv=12.47\nNew Transformer.t buses=({buses})\n"
3051 ));
3052 assert!(
3053 net.warnings
3054 .iter()
3055 .any(|w| w.contains("winding count") && w.contains("clamped")),
3056 "warnings: {:?}",
3057 net.warnings
3058 );
3059 }
3060}