1use std::collections::BTreeMap;
15use std::path::{Path, PathBuf};
16use std::sync::Arc;
17
18use super::defaults as dd;
19use super::lex::{BusSpec, Value, VarMap};
20use super::raw::{
21 RawDss, RawObject, canonical_case_root, confined_fs_read, parse_raw_with,
22 parse_raw_with_confined,
23};
24use crate::error::{Error, Result};
25use crate::geo::{CoordinateSpace, CoordsKind, GeoMeta, Location};
26use crate::model::{
27 ActivePowerReference, ActivePowerUnit, Configuration, ControlVoltageReference, DistBus,
28 DistControlProfile, DistGenerator, DistIbr, DistLine, DistLineCode, DistLoad,
29 DistLoadVoltageModel, DistShunt, DistSourceFormat, DistSwitch, DistTransformer, Extras,
30 IbrPrimeMover, IbrTopology, Mat, MulticonductorNetwork, PowerFactorControl,
31 ReactivePowerReference, ReactivePowerUnit, UntypedObject, VoltVarControl, VoltWattControl,
32 VoltageSource, Winding, WindingConn, pair_keys, square_from_rows,
33};
34
35const MAX_COUNT: usize = 64;
42
43const TYPED_DSS_CLASSES: &[&str] = &[
44 "linecode",
45 "vsource",
46 "line",
47 "transformer",
48 "load",
49 "capacitor",
50 "reactor",
51 "generator",
52 "pvsystem",
53 "xycurve",
54 "invcontrol",
55 "swtcontrol",
56 "regcontrol",
57];
58
59fn strip_bom_owned(text: String) -> String {
62 match text.strip_prefix('\u{feff}') {
63 Some(stripped) => stripped.to_owned(),
64 None => text,
65 }
66}
67
68fn confined_bom_stripping_loader(
74 canonical_root: Option<PathBuf>,
75 stripped_paths: &mut Vec<String>,
76) -> impl FnMut(&Path) -> std::io::Result<String> + '_ {
77 move |p: &Path| {
78 confined_fs_read(canonical_root.as_deref(), p).map(|text| {
79 if text.starts_with('\u{feff}') {
80 stripped_paths.push(p.display().to_string());
81 }
82 strip_bom_owned(text)
83 })
84 }
85}
86
87fn warn_stripped_boms(
88 net: &mut MulticonductorNetwork,
89 root_had_bom: bool,
90 stripped_paths: Vec<String>,
91) {
92 if root_had_bom {
93 net.warnings.push(crate::convert::BOM_WARNING.to_owned());
94 }
95 for path in stripped_paths {
96 net.warnings
97 .push(format!("{path}: {}", crate::convert::BOM_WARNING));
98 }
99}
100
101pub fn parse_dss_file(path: impl AsRef<Path>) -> Result<MulticonductorNetwork> {
114 let path = path.as_ref();
115 let text = std::fs::read_to_string(path).map_err(|source| Error::Io {
116 path: path.display().to_string(),
117 source,
118 })?;
119 let had_bom = text.starts_with('\u{feff}');
120 let text = strip_bom_owned(text);
121 let mut stripped_paths = Vec::new();
122 let raw = parse_raw_with_confined(
123 &text,
124 &path.display().to_string(),
125 &mut confined_bom_stripping_loader(canonical_case_root(path), &mut stripped_paths),
126 );
127 let mut net = network_from_raw(&raw, Arc::new(text));
128 warn_stripped_boms(&mut net, had_bom, stripped_paths);
129 Ok(net)
130}
131
132pub fn parse_dss_str(text: &str) -> MulticonductorNetwork {
138 let stripped = text.trim_start_matches('\u{feff}');
139 let mut no_includes = |_path: &Path| -> std::io::Result<String> {
140 Err(std::io::Error::new(
141 std::io::ErrorKind::Unsupported,
142 "file includes are disabled when parsing from a string",
143 ))
144 };
145 let raw = parse_raw_with(stripped, "<string>", &mut no_includes);
146 let mut net = network_from_raw(&raw, Arc::new(stripped.to_string()));
147 warn_stripped_boms(&mut net, stripped.len() != text.len(), Vec::new());
148 net
149}
150
151pub fn network_from_raw(raw: &RawDss, source: Arc<String>) -> MulticonductorNetwork {
153 let mut rd = Reader {
154 net: MulticonductorNetwork {
155 name: raw.circuit_name.clone(),
156 base_frequency: dd::BASE_FREQUENCY,
157 source: Some(source),
158 source_format: Some(DistSourceFormat::Dss),
159 warnings: raw.warnings.clone(),
160 parse_diagnostics: raw.diagnostics.clone(),
161 ..MulticonductorNetwork::default()
162 },
163 buses: BTreeMap::new(),
164 bus_order: Vec::new(),
165 linecode_units: BTreeMap::new(),
166 linecode_nconds: BTreeMap::new(),
167 xycurves: BTreeMap::new(),
168 vars: &raw.vars,
169 };
170
171 for (name, value) in &raw.options {
172 if name.len() >= "defaultb".len() && "defaultbasefrequency".starts_with(name.as_str()) {
178 if let Ok(f) = value.to_f64(Some(rd.vars)) {
179 rd.net.base_frequency = f;
180 }
181 }
182 rd.net.options.push((name.clone(), value.text.clone()));
183 }
184 for cmd in &raw.commands {
185 rd.net.commands.push((cmd.verb.clone(), cmd.args.clone()));
186 }
187
188 for obj in raw.of_class("linecode") {
191 let lc = rd.linecode(obj);
192 rd.net.linecodes.push(lc);
193 }
194 for obj in raw.of_class("vsource") {
195 let vs = rd.vsource(obj);
196 rd.net.sources.push(vs);
197 }
198 for obj in raw.of_class("line") {
199 rd.line(obj);
200 }
201 for obj in raw.of_class("transformer") {
202 let t = rd.transformer(obj);
203 rd.net.transformers.push(t);
204 }
205 for obj in raw.of_class("load") {
206 let l = rd.load(obj);
207 rd.net.loads.push(l);
208 }
209 for obj in raw.of_class("capacitor") {
210 rd.capacitor(obj);
211 }
212 for obj in raw.of_class("reactor") {
213 rd.reactor(obj);
214 }
215 for obj in raw.of_class("generator") {
216 let g = rd.generator(obj);
217 rd.net.generators.push(g);
218 }
219 read_ibr_objects(&mut rd, raw);
220 for obj in raw.of_class("swtcontrol") {
221 rd.swtcontrol(obj);
222 }
223 for obj in raw.of_class("regcontrol") {
224 rd.regcontrol(obj);
225 }
226 for obj in &raw.objects {
227 if !TYPED_DSS_CLASSES.contains(&obj.class.as_str()) {
228 rd.net.untyped.push(UntypedObject::from(obj));
229 }
230 }
231
232 let known: std::collections::BTreeSet<String> = rd
238 .net
239 .linecodes
240 .iter()
241 .map(|c| c.name.to_ascii_lowercase())
242 .collect();
243 let missing: Vec<String> = rd
244 .net
245 .lines
246 .iter()
247 .filter(|l| !known.contains(&l.linecode.to_ascii_lowercase()))
248 .map(|l| {
249 format!(
250 "line {} references unknown linecode `{}`",
251 l.name, l.linecode
252 )
253 })
254 .collect();
255 rd.net.warnings.extend(missing);
256
257 finish_buses(rd, raw)
258}
259
260fn finish_buses(mut rd: Reader, raw: &RawDss) -> MulticonductorNetwork {
268 let mut coords: BTreeMap<String, (f64, f64)> = BTreeMap::new();
269 for c in &raw.buscoords {
270 coords.insert(c.bus.to_ascii_lowercase(), (c.x, c.y));
271 }
272 let buses = std::mem::take(&mut rd.bus_order);
273 let states = std::mem::take(&mut rd.buses);
274 let mut net = rd.net;
275 let mut neutral_names: BTreeMap<String, String> = BTreeMap::new();
276 for id in buses {
277 let st = &states[&id];
278 let mut terminals: Vec<i32> = st.nodes.iter().copied().filter(|&n| n != 0).collect();
279 terminals.sort_unstable();
280 let mut bus = DistBus {
281 id: st.display.clone(),
282 terminals: terminals.iter().map(ToString::to_string).collect(),
283 ..DistBus::default()
284 };
285 if st.nodes.contains(&0) {
286 let neutral = terminals.last().map_or(4, |&n| n.max(3) + 1);
287 bus.terminals.push(neutral.to_string());
288 bus.grounded.push(neutral.to_string());
289 neutral_names.insert(id.clone(), neutral.to_string());
290 }
291 if let Some((x, y)) = coords.get(&id) {
292 bus.location = Some(Location {
293 x: *x,
294 y: *y,
295 kind: None,
296 });
297 }
298 net.buses.push(bus);
299 }
300 if !coords.is_empty() {
301 net.geo = Some(GeoMeta {
302 space: CoordinateSpace::Unknown,
303 kind: Some(CoordsKind::Source),
304 });
305 if coords
306 .values()
307 .all(|(x, y)| (-180.0..=180.0).contains(x) && (-90.0..=90.0).contains(y))
308 {
309 net.warnings.push(
310 "OpenDSS buscoords fit longitude/latitude ranges; coordinate space remains unknown because Buscoords does not declare a CRS".to_owned(),
311 );
312 }
313 }
314
315 let rewrite = |bus: &str, map: &mut [String]| {
316 if let Some(neutral) = neutral_names.get(&bus.to_ascii_lowercase()) {
317 for t in map.iter_mut().filter(|t| *t == "0") {
318 t.clone_from(neutral);
319 }
320 }
321 };
322 for l in &mut net.lines {
323 rewrite(&l.bus_from, &mut l.terminal_map_from);
324 rewrite(&l.bus_to, &mut l.terminal_map_to);
325 }
326 for s in &mut net.switches {
327 rewrite(&s.bus_from, &mut s.terminal_map_from);
328 rewrite(&s.bus_to, &mut s.terminal_map_to);
329 }
330 for l in &mut net.loads {
331 rewrite(&l.bus, &mut l.terminal_map);
332 }
333 for g in &mut net.generators {
334 rewrite(&g.bus, &mut g.terminal_map);
335 }
336 for s in &mut net.shunts {
337 rewrite(&s.bus, &mut s.terminal_map);
338 }
339 for v in &mut net.sources {
340 rewrite(&v.bus, &mut v.terminal_map);
341 }
342 for t in &mut net.transformers {
343 for w in &mut t.windings {
344 rewrite(&w.bus, &mut w.terminal_map);
345 }
346 }
347 net
348}
349
350fn read_ibr_objects(rd: &mut Reader<'_>, raw: &RawDss) {
351 for obj in raw.of_class("pvsystem") {
352 rd.pvsystem(obj);
353 }
354 for obj in raw.of_class("xycurve") {
355 rd.xycurve(obj);
356 }
357 for obj in raw.of_class("invcontrol") {
358 rd.invcontrol(obj);
359 }
360}
361
362impl From<&RawObject> for UntypedObject {
363 fn from(obj: &RawObject) -> Self {
364 UntypedObject {
365 class: obj.class.clone(),
366 name: obj.name.clone(),
367 props: obj
368 .props
369 .iter()
370 .map(|p| (p.name.clone(), p.value.text.clone()))
371 .collect(),
372 }
373 }
374}
375
376struct BusState {
377 display: String,
378 nodes: std::collections::BTreeSet<i32>,
379}
380
381struct Reader<'a> {
382 net: MulticonductorNetwork,
383 buses: BTreeMap<String, BusState>,
384 bus_order: Vec<String>,
385 linecode_units: BTreeMap<String, Option<f64>>,
389 linecode_nconds: BTreeMap<String, usize>,
393 xycurves: BTreeMap<String, XyCurveRaw>,
394 vars: &'a VarMap,
395}
396
397struct Props<'a> {
400 by_name: BTreeMap<&'a str, &'a Value>,
401 consumed: std::cell::RefCell<Vec<&'a str>>,
402}
403
404impl<'a> Props<'a> {
405 fn new(obj: &'a RawObject) -> Self {
406 let mut by_name = BTreeMap::new();
407 for p in &obj.props {
408 if let Some(n) = &p.name {
409 by_name.insert(n.as_str(), &p.value);
410 }
411 }
412 Props {
413 by_name,
414 consumed: std::cell::RefCell::new(Vec::new()),
415 }
416 }
417
418 fn get(&self, name: &'a str) -> Option<&'a Value> {
419 self.consumed.borrow_mut().push(name);
420 self.by_name.get(name).copied()
421 }
422
423 fn leftovers(&self) -> Vec<(&str, &Value)> {
425 let consumed = self.consumed.borrow();
426 self.by_name
427 .iter()
428 .filter(|(k, _)| !consumed.contains(*k) && **k != "like")
429 .map(|(k, v)| (*k, *v))
430 .collect()
431 }
432}
433
434const REACTOR_IMPEDANCE_FORMS: &[&str] = &[
441 "rmatrix", "xmatrix", "r", "x", "z1", "z2", "z0", "z", "rcurve", "lcurve", "lmh",
442];
443
444#[derive(Clone, Copy)]
445struct KvarShuntSpec {
446 class: &'static str,
447 series_name: &'static str,
448 default_phases: usize,
449 default_kvar: f64,
450 default_kv: f64,
451 b_sign: f64,
452}
453
454const CAPACITOR_KVAR_SHUNT: KvarShuntSpec = KvarShuntSpec {
455 class: "capacitor",
456 series_name: "capacitors",
457 default_phases: dd::capacitor::PHASES,
458 default_kvar: dd::capacitor::KVAR,
459 default_kv: dd::capacitor::KV,
460 b_sign: 1.0,
461};
462
463const REACTOR_KVAR_SHUNT: KvarShuntSpec = KvarShuntSpec {
464 class: "reactor",
465 series_name: "reactors",
466 default_phases: dd::reactor::PHASES,
467 default_kvar: dd::reactor::KVAR,
468 default_kv: dd::reactor::KV,
469 b_sign: -1.0,
470};
471
472#[derive(Clone, Debug)]
473struct XyCurveRaw {
474 x: Vec<f64>,
475 y: Vec<f64>,
476}
477
478impl Reader<'_> {
479 fn warn(&mut self, msg: impl Into<String>) {
480 self.net.warnings.push(msg.into());
481 }
482
483 fn defaulted(&mut self, class: &str, name: &str, field: &'static str) {
484 let fields = self
485 .net
486 .defaulted
487 .entry(format!("{class}.{name}"))
488 .or_default();
489 if !fields.contains(&field) {
490 fields.push(field);
491 }
492 }
493
494 fn f64_prop(&mut self, p: Option<&Value>) -> Option<f64> {
495 p.and_then(|v| v.to_f64(Some(self.vars)).ok())
496 }
497
498 fn usize_prop(&mut self, p: Option<&Value>) -> Option<usize> {
499 p.and_then(|v| v.to_i64(Some(self.vars)).ok()).map(|i| {
500 let n = usize::try_from(i).unwrap_or(0);
501 if n > MAX_COUNT {
502 self.net.warnings.push(format!(
503 "count property {n} exceeds the supported maximum of {MAX_COUNT}; clamped"
504 ));
505 MAX_COUNT
506 } else {
507 n
508 }
509 })
510 }
511
512 fn units_code(&mut self, units: Option<&str>, class: &str, name: &str) -> Option<f64> {
517 let u = units?;
518 if let Some(f) = dd::unit_to_meters(u) {
519 return Some(f);
520 }
521 if !u.to_ascii_lowercase().starts_with("no") {
522 self.net.warnings.push(format!(
523 "{class} {name}: unknown units `{u}`; treated as none"
524 ));
525 }
526 None
527 }
528
529 fn stash_numeric(&self, v: &Value) -> serde_json::Value {
534 if v.text.parse::<f64>().is_ok() {
535 v.text.clone().into()
536 } else {
537 match v.to_f64(Some(self.vars)) {
538 Ok(n) => n.into(),
539 Err(_) => v.text.clone().into(),
540 }
541 }
542 }
543
544 fn stash_kv_and_phases(&self, props: &Props, extras: &mut Extras, kv: f64, phases: usize) {
547 let kv_value = match props.by_name.get("kv") {
548 Some(written) => self.stash_numeric(written),
549 None => kv.into(),
550 };
551 extras.insert("kv".into(), kv_value);
552 let phases_value = match props.by_name.get("phases") {
553 Some(written) => self.stash_numeric(written),
554 None => (phases as u64).into(),
555 };
556 extras.insert("phases".into(), phases_value);
557 if let Some(written) = props.by_name.get("conn") {
561 extras.insert("conn".into(), written.text.clone().into());
562 }
563 }
564
565 fn f64_or(
567 &mut self,
568 props: &Props,
569 key: &'static str,
570 class: &str,
571 name: &str,
572 default: f64,
573 ) -> f64 {
574 if let Some(v) = self.f64_prop(props.get(key)) {
575 v
576 } else {
577 self.defaulted(class, name, key);
578 default
579 }
580 }
581
582 fn usize_or(
583 &mut self,
584 props: &Props,
585 key: &'static str,
586 class: &str,
587 name: &str,
588 default: usize,
589 ) -> usize {
590 if let Some(v) = self.usize_prop(props.get(key)) {
591 v
592 } else {
593 self.defaulted(class, name, key);
594 default
595 }
596 }
597
598 fn terminals(
603 &mut self,
604 spec: &BusSpec,
605 phases: usize,
606 nconds: usize,
607 keep: usize,
608 ) -> Vec<String> {
609 let mut nodes: Vec<i32> = (1..=i32::try_from(nconds).unwrap_or(i32::MAX)).collect();
610 for n in nodes.iter_mut().skip(phases) {
611 *n = 0;
612 }
613 for (i, &n) in spec.nodes.iter().enumerate().take(nconds) {
614 nodes[i] = n.max(0); }
616 let key = spec.name.to_ascii_lowercase();
617 let state = self.buses.entry(key.clone()).or_insert_with(|| {
618 self.bus_order.push(key.clone());
619 BusState {
620 display: spec.name.clone(),
621 nodes: std::collections::BTreeSet::new(),
622 }
623 });
624 for &n in nodes.iter().take(keep) {
625 state.nodes.insert(n);
626 }
627 nodes.truncate(keep);
628 nodes.iter().map(ToString::to_string).collect()
629 }
630
631 fn linecode(&mut self, obj: &RawObject) -> DistLineCode {
634 let props = Props::new(obj);
635 let n = self.usize_or(
636 &props,
637 "nphases",
638 "linecode",
639 &obj.name,
640 dd::linecode::NPHASES,
641 );
642 let units = props.get("units").map(|v| v.text.clone());
643 let units_m = self.units_code(units.as_deref(), "linecode", &obj.name);
644 let per_meter = units_m.unwrap_or(1.0);
645 self.linecode_units
646 .insert(obj.name.to_ascii_lowercase(), units_m);
647 self.linecode_nconds
648 .insert(obj.name.to_ascii_lowercase(), n);
649
650 let freq = self
651 .f64_prop(props.get("basefreq"))
652 .unwrap_or(self.net.base_frequency);
653
654 let z = self.impedance_matrices(
655 &props,
656 n,
657 "linecode",
658 &obj.name,
659 dd::line::R1,
660 dd::line::X1,
661 dd::line::R0,
662 dd::line::X0,
663 dd::line::C1_NF,
664 dd::line::C0_NF,
665 );
666 if z.all_default {
667 self.defaulted("linecode", &obj.name, "rmatrix");
668 }
669
670 let b_half = scale_mat(
673 &z.c_nf,
674 std::f64::consts::TAU * freq * 1e-9 / per_meter / 2.0,
675 );
676 let zero = vec![vec![0.0; n]; n];
677
678 let amps = self.f64_or(
681 &props,
682 "emergamps",
683 "linecode",
684 &obj.name,
685 dd::line::EMERGAMPS,
686 );
687 let i_max = Some(vec![amps; n]);
688
689 let mut extras = extras_from_leftovers(&props);
690 if let Some(u) = units {
691 extras.insert("units".into(), u.into());
692 }
693 for (key, text) in z.malformed {
694 extras.insert(key.to_string(), text.into());
695 }
696 DistLineCode {
697 name: obj.name.clone(),
698 n_conductors: n,
699 r_series: scale_mat(&z.r, 1.0 / per_meter),
700 x_series: scale_mat(&z.x, 1.0 / per_meter),
701 g_from: zero.clone(),
702 b_from: b_half.clone(),
703 g_to: zero,
704 b_to: b_half,
705 i_max,
706 s_max: None,
707 source: None,
708 extras,
709 }
710 }
711
712 #[allow(clippy::too_many_arguments)]
715 fn impedance_matrices(
716 &mut self,
717 props: &Props,
718 n: usize,
719 class: &str,
720 name: &str,
721 r1d: f64,
722 x1d: f64,
723 r0d: f64,
724 x0d: f64,
725 c1d: f64,
726 c0d: f64,
727 ) -> SeriesImpedance {
728 let mut malformed: Vec<(&'static str, String)> = Vec::new();
729 let mut rows = |key: &'static str| -> Option<Mat> {
730 let v = props.get(key)?;
731 let parsed = v
732 .to_rows(Some(self.vars))
733 .ok()
734 .and_then(|rows| square_from_rows(&rows, n));
735 if parsed.is_none() {
736 malformed.push((key, v.text.clone()));
737 }
738 parsed
739 };
740 let rm = rows("rmatrix");
741 let xm = rows("xmatrix");
742 let cm = rows("cmatrix");
743 for (key, _) in &malformed {
747 self.warn(format!(
748 "{class} {name}: `{key}` does not parse as a {n}x{n} matrix; \
749 sequence values apply and the text is kept in extras"
750 ));
751 }
752 let any_written = [
753 "rmatrix", "xmatrix", "cmatrix", "r1", "x1", "r0", "x0", "c1", "c0", "b1", "b0",
754 ]
755 .iter()
756 .any(|k| props.by_name.contains_key(*k));
757
758 let seq = |props: &Props, k1: &'static str, k0: &'static str, d1: f64, d0: f64| {
759 let v1 = props
760 .get(k1)
761 .and_then(|v| v.to_f64(Some(self.vars)).ok())
762 .unwrap_or(d1);
763 let v0 = props
764 .get(k0)
765 .and_then(|v| v.to_f64(Some(self.vars)).ok())
766 .unwrap_or(d0);
767 if n == 1 {
768 return vec![vec![v1]];
769 }
770 let s = (2.0 * v1 + v0) / 3.0;
773 let m = (v0 - v1) / 3.0;
774 let mut mat = vec![vec![m; n]; n];
775 for (i, row) in mat.iter_mut().enumerate() {
776 row[i] = s;
777 }
778 mat
779 };
780
781 SeriesImpedance {
782 r: rm.unwrap_or_else(|| seq(props, "r1", "r0", r1d, r0d)),
783 x: xm.unwrap_or_else(|| seq(props, "x1", "x0", x1d, x0d)),
784 c_nf: cm.unwrap_or_else(|| seq(props, "c1", "c0", c1d, c0d)),
785 all_default: !any_written,
786 malformed,
787 }
788 }
789
790 fn vsource(&mut self, obj: &RawObject) -> VoltageSource {
793 let props = Props::new(obj);
794 let phases = self.usize_or(&props, "phases", "vsource", &obj.name, dd::vsource::PHASES);
795 let basekv = self.f64_or(&props, "basekv", "vsource", &obj.name, dd::vsource::BASEKV);
796 let pu = self.f64_or(&props, "pu", "vsource", &obj.name, dd::vsource::PU);
797 let angle_deg = self.f64_or(
798 &props,
799 "angle",
800 "vsource",
801 &obj.name,
802 dd::vsource::ANGLE_DEG,
803 );
804 let spec = if let Some(v) = props.get("bus1") {
805 v.to_bus_spec()
806 } else {
807 self.defaulted("vsource", &obj.name, "bus1");
808 Value::new(dd::vsource::BUS1).to_bus_spec()
809 };
810 let map = self.terminals(&spec, phases, phases + 1, phases + 1);
811
812 let v_ln = if phases == 1 {
818 basekv * 1e3 * pu
819 } else {
820 basekv * 1e3 * pu / (2.0 * (std::f64::consts::PI / phases as f64).sin())
821 };
822 let mut v_magnitude = vec![v_ln; phases];
823 let mut v_angle: Vec<f64> = (0..phases)
824 .map(|k| {
825 let deg = angle_deg - 360.0 / phases as f64 * k as f64;
826 let a = deg.to_radians();
827 let shifted = (a + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU);
830 if shifted <= 0.0 {
831 std::f64::consts::PI
832 } else {
833 shifted - std::f64::consts::PI
834 }
835 })
836 .collect();
837 v_magnitude.push(0.0);
839 v_angle.push(0.0);
840
841 let mut extras = extras_from_leftovers(&props);
844 extras.insert("basekv".into(), basekv.into());
845 extras.insert("angle".into(), angle_deg.into());
846 if (pu - 1.0).abs() > 0.0 {
847 extras.insert("pu".into(), pu.into());
848 }
849 VoltageSource {
850 name: obj.name.clone(),
851 bus: spec.name,
852 terminal_map: map,
853 v_magnitude,
854 v_angle,
855 extras,
856 }
857 }
858
859 fn line(&mut self, obj: &RawObject) {
862 let props = Props::new(obj);
863 let explicit = self.usize_prop(props.get("phases"));
870 let from_code = props.get("linecode").and_then(|c| {
871 self.linecode_nconds
872 .get(&c.text.to_ascii_lowercase())
873 .copied()
874 });
875 let linecode_last = obj
876 .props
877 .iter()
878 .rev()
879 .find_map(|p| match p.name.as_deref() {
880 Some("phases") => Some(false),
881 Some("linecode") => Some(true),
882 _ => None,
883 })
884 .unwrap_or(false);
885 let phases = match (explicit, from_code) {
886 (Some(_), Some(n)) if linecode_last => n,
887 (Some(p), _) => p,
888 (None, Some(n)) => n,
889 (None, None) => dd::line::PHASES,
890 };
891 let spec1 = bus_spec(props.get("bus1"), "");
892 let spec2 = bus_spec(props.get("bus2"), "");
893 let map_from = self.terminals(&spec1, phases, phases, phases);
895 let map_to = self.terminals(&spec2, phases, phases, phases);
896
897 let is_switch = props.get("switch").is_some_and(super::lex::Value::to_bool);
898 if is_switch {
899 let amps = self.f64_or(&props, "emergamps", "line", &obj.name, dd::line::EMERGAMPS);
900 let i_max = Some(vec![amps; phases]);
901 let mut extras = extras_from_leftovers(&props);
902 for k in ["linecode", "length", "r1", "x1", "rmatrix", "xmatrix"] {
905 if let Some(v) = props.by_name.get(k) {
906 extras.insert(k.to_string(), v.text.clone().into());
907 self.warn(format!(
908 "line {}: `{k}` is ignored by OpenDSS on switch=yes; kept in extras",
909 obj.name
910 ));
911 }
912 }
913 self.net.switches.push(DistSwitch {
914 name: obj.name.clone(),
915 bus_from: spec1.name,
916 bus_to: spec2.name,
917 terminal_map_from: map_from,
918 terminal_map_to: map_to,
919 open: false,
920 i_max,
921 extras,
922 });
923 return;
924 }
925
926 let length_units = props.get("units").map(|v| v.text.clone());
927 let line_units_m = self.units_code(length_units.as_deref(), "line", &obj.name);
928 let length = self.f64_or(&props, "length", "line", &obj.name, dd::line::LENGTH);
929
930 let mut malformed: Vec<(&'static str, String)> = Vec::new();
937 let (linecode, length_factor, synthesized) = if let Some(code) = props.get("linecode") {
938 let lc_units_m = self
939 .linecode_units
940 .get(&code.text.to_ascii_lowercase())
941 .copied()
942 .flatten();
943 let factor = match (lc_units_m, line_units_m) {
944 (Some(_), Some(lf)) => lf,
945 (Some(lcf), None) => lcf,
946 (None, _) => 1.0,
947 };
948 (code.text.clone(), factor, false)
949 } else {
950 let factor = line_units_m.unwrap_or(1.0);
951 let (code, bad) = self.synthesize_linecode(&props, phases, factor, &obj.name);
952 malformed = bad;
953 (code, factor, true)
954 };
955
956 let (i_max, raw_emergamps) = self.line_rating(&props, phases, synthesized);
957 let mut extras = extras_from_leftovers(&props);
958 if let Some(u) = length_units {
959 extras.insert("units".into(), u.into());
960 }
961 if let Some(text) = raw_emergamps {
962 extras.insert("emergamps".into(), text.into());
963 }
964 for (key, text) in malformed {
965 extras.insert(key.to_string(), text.into());
966 }
967 self.net.lines.push(DistLine {
968 name: obj.name.clone(),
969 bus_from: spec1.name,
970 bus_to: spec2.name,
971 terminal_map_from: map_from,
972 terminal_map_to: map_to,
973 linecode,
974 length: length * length_factor,
975 route: None,
976 i_max,
977 s_max: None,
978 extras,
979 });
980 }
981
982 fn line_rating(
990 &self,
991 props: &Props,
992 phases: usize,
993 synthesized_linecode: bool,
994 ) -> (Option<Vec<f64>>, Option<String>) {
995 if synthesized_linecode {
1000 return (None, None);
1001 }
1002 let Some(v) = props.get("emergamps") else {
1003 return (None, None);
1004 };
1005 match v.to_f64(Some(self.vars)) {
1006 Ok(amps) => (Some(vec![amps; phases]), None),
1007 Err(_) => (None, Some(v.text.clone())),
1008 }
1009 }
1010
1011 fn synthesize_linecode(
1015 &mut self,
1016 props: &Props,
1017 phases: usize,
1018 length_factor: f64,
1019 line_name: &str,
1020 ) -> (String, Vec<(&'static str, String)>) {
1021 let z = self.impedance_matrices(
1022 props,
1023 phases,
1024 "line",
1025 line_name,
1026 dd::line::R1,
1027 dd::line::X1,
1028 dd::line::R0,
1029 dd::line::X0,
1030 dd::line::C1_NF,
1031 dd::line::C0_NF,
1032 );
1033 if z.all_default {
1034 self.defaulted("line", line_name, "r1");
1035 self.defaulted("line", line_name, "x1");
1036 }
1037 let b_half = scale_mat(
1038 &z.c_nf,
1039 std::f64::consts::TAU * self.net.base_frequency * 1e-9 / length_factor / 2.0,
1040 );
1041 let zero = vec![vec![0.0; phases]; phases];
1042 let amps = self.f64_or(props, "emergamps", "line", line_name, dd::line::EMERGAMPS);
1043 let i_max = Some(vec![amps; phases]);
1044 let name = format!("_line_{line_name}");
1045 self.net.linecodes.push(DistLineCode {
1046 name: name.clone(),
1047 n_conductors: phases,
1048 r_series: scale_mat(&z.r, 1.0 / length_factor),
1049 x_series: scale_mat(&z.x, 1.0 / length_factor),
1050 g_from: zero.clone(),
1051 b_from: b_half.clone(),
1052 g_to: zero,
1053 b_to: b_half,
1054 i_max,
1055 s_max: None,
1056 source: None,
1057 extras: Extras::new(),
1058 });
1059 (name, z.malformed)
1060 }
1061
1062 fn load_power(&mut self, obj: &RawObject) -> LoadPower {
1076 let mut s = LoadPower {
1077 kw: dd::load::KW,
1078 kvar: 0.0,
1082 pf: dd::load::PF,
1083 spec_kvar: false, kw_written: false,
1085 pf_written: false,
1086 };
1087 let mut start = 0;
1088 for end in obj.edit_bounds() {
1089 for p in &obj.props[start..end] {
1090 let Some(key @ ("kw" | "kvar" | "pf")) = p.name.as_deref() else {
1091 continue;
1092 };
1093 let Some(v) = self.f64_prop(Some(&p.value)) else {
1094 continue;
1095 };
1096 match key {
1097 "kw" => {
1098 s.kw = v;
1099 s.spec_kvar = false;
1100 s.kw_written = true;
1101 }
1102 "kvar" => {
1103 s.kvar = v;
1104 s.spec_kvar = true;
1105 }
1106 _ => {
1107 s.pf = v;
1108 s.pf_written = true;
1109 }
1110 }
1111 }
1112 start = end;
1113 if s.spec_kvar {
1115 let kva = s.kw.hypot(s.kvar);
1116 if kva > 0.0 {
1117 s.pf = s.kw / kva;
1118 if s.kw * s.kvar < 0.0 {
1120 s.pf = -s.pf;
1121 }
1122 }
1123 } else {
1124 s.kvar = s.kw * (1.0 / (s.pf * s.pf) - 1.0).sqrt();
1125 if s.pf < 0.0 {
1126 s.kvar = -s.kvar;
1127 }
1128 }
1129 }
1130 s
1131 }
1132
1133 fn load(&mut self, obj: &RawObject) -> DistLoad {
1134 let props = Props::new(obj);
1135 let phases = self.usize_or(&props, "phases", "load", &obj.name, dd::load::PHASES);
1136 let conn_delta = props.get("conn").is_some_and(|v| {
1137 v.text.to_ascii_lowercase().starts_with('d') || v.text.eq_ignore_ascii_case("ll")
1138 });
1139 let kv = self.f64_or(&props, "kv", "load", &obj.name, dd::load::KV);
1140 let LoadPower {
1141 kw,
1142 kvar: q_total,
1143 pf,
1144 spec_kvar,
1145 kw_written,
1146 pf_written,
1147 } = self.load_power(obj);
1148 if !kw_written {
1149 self.defaulted("load", &obj.name, "kw");
1150 }
1151 let _ = (props.get("kw"), props.get("kvar"), props.get("pf"));
1153 let mut pf_source: Option<f64> = None;
1159 if !spec_kvar {
1160 if !pf_written {
1161 self.defaulted("load", &obj.name, "pf");
1162 }
1163 pf_source = Some(pf);
1164 }
1165 let model = self
1166 .usize_prop(props.get("model"))
1167 .map_or(dd::load::MODEL, |m| i64::try_from(m).unwrap_or(i64::MAX));
1168
1169 let spec = bus_spec(props.get("bus1"), "");
1170 let nconds = if conn_delta && phases == 3 {
1171 phases
1172 } else {
1173 phases + 1
1174 };
1175 let map = self.terminals(&spec, phases, nconds, nconds);
1176
1177 let configuration = if phases == 1 {
1178 Configuration::SinglePhase
1179 } else if conn_delta {
1180 Configuration::Delta
1181 } else {
1182 Configuration::Wye
1183 };
1184
1185 let mut extras = extras_from_leftovers(&props);
1192 self.stash_kv_and_phases(&props, &mut extras, kv, phases);
1193 if let Some(pf) = pf_source {
1194 extras.insert("pf".into(), pf.into());
1195 }
1196 if model != 1 {
1197 extras.insert("model".into(), model.into());
1198 }
1199 let v_phase = if phases >= 2 && configuration == Configuration::Wye {
1200 kv * 1e3 / 3f64.sqrt()
1201 } else {
1202 kv * 1e3
1203 };
1204 let v_nom = vec![v_phase; phases];
1205 let zipv = props
1206 .get("zipv")
1207 .and_then(|v| v.to_vector(Some(self.vars)).ok())
1208 .unwrap_or_default();
1209 let voltage_model = match model {
1210 2 => DistLoadVoltageModel::ConstantImpedance { v_nom },
1211 5 => DistLoadVoltageModel::ConstantCurrent { v_nom },
1212 8 if zipv.len() >= 6 => DistLoadVoltageModel::Zip {
1213 v_nom,
1214 alpha_z: vec![zipv[0]; phases],
1215 alpha_i: vec![zipv[1]; phases],
1216 alpha_p: vec![zipv[2]; phases],
1217 beta_z: vec![zipv[3]; phases],
1218 beta_i: vec![zipv[4]; phases],
1219 beta_p: vec![zipv[5]; phases],
1220 },
1221 8 => DistLoadVoltageModel::Zip {
1222 v_nom,
1223 alpha_z: Vec::new(),
1224 alpha_i: Vec::new(),
1225 alpha_p: Vec::new(),
1226 beta_z: Vec::new(),
1227 beta_i: Vec::new(),
1228 beta_p: Vec::new(),
1229 },
1230 _ => DistLoadVoltageModel::ConstantPower { v_nom },
1231 };
1232 DistLoad {
1233 name: obj.name.clone(),
1234 bus: spec.name,
1235 terminal_map: map,
1236 configuration,
1237 p_nom: vec![kw * 1e3 / phases as f64; phases],
1238 q_nom: vec![q_total * 1e3 / phases as f64; phases],
1239 voltage_model,
1240 extras,
1241 }
1242 }
1243
1244 #[allow(clippy::too_many_lines)] fn transformer(&mut self, obj: &RawObject) -> DistTransformer {
1248 let mut phases = dd::transformer::PHASES;
1251 let mut n_windings = dd::transformer::WINDINGS;
1252 let mut windings = vec![WindingRaw::default(); n_windings];
1253 let mut active = 0usize;
1254 let mut xhl = dd::transformer::XHL;
1255 let mut xht = dd::transformer::XHT;
1256 let mut xlt = dd::transformer::XLT;
1257 let mut xhl_specified = false;
1258 let mut x_pairs: BTreeMap<(usize, usize), f64> = BTreeMap::new();
1259 let mut extras = Extras::new();
1260 let conn_is_delta =
1261 |t: &str| t.to_ascii_lowercase().starts_with('d') || t.eq_ignore_ascii_case("ll");
1262 for p in &obj.props {
1263 let Some(name) = &p.name else { continue };
1264 let v = &p.value;
1265 match name.as_str() {
1266 "phases" => {
1267 phases = self.usize_prop(Some(v)).unwrap_or(phases);
1268 }
1269 "windings" => {
1270 n_windings = self.usize_prop(Some(v)).unwrap_or(n_windings).max(1);
1271 windings = vec![WindingRaw::default(); n_windings];
1272 active = 0;
1273 }
1274 "wdg" => {
1275 let k = self.usize_prop(Some(v)).unwrap_or(1).max(1);
1276 grow(
1277 &mut windings,
1278 k,
1279 &mut n_windings,
1280 &obj.name,
1281 &mut self.net.warnings,
1282 );
1283 active = k - 1;
1284 }
1285 "bus" => windings[active].bus = Some(v.to_bus_spec()),
1286 "conn" => windings[active].conn_delta = conn_is_delta(&v.text),
1287 "kv" | "kva" | "tap" | "%r" | "rneut" | "xneut" => {
1288 let parsed = self.f64_prop(Some(v));
1289 let w = &mut windings[active];
1290 match name.as_str() {
1291 "kv" => {
1292 w.kv = parsed.unwrap_or(w.kv);
1293 w.kv_specified = true;
1294 }
1295 "kva" => {
1296 w.kva = parsed.unwrap_or(w.kva);
1297 w.kva_specified = true;
1298 }
1299 "tap" => w.tap = parsed.unwrap_or(w.tap),
1300 "%r" => w.r_pct = parsed.unwrap_or(w.r_pct),
1301 "rneut" => w.r_neutral = parsed,
1302 "xneut" => w.x_neutral = parsed,
1303 _ => unreachable!("matched transformer scalar property"),
1304 }
1305 }
1306 "buses" | "conns" => {
1307 let items = v.to_string_list(Some(self.vars));
1308 grow(
1309 &mut windings,
1310 items.len(),
1311 &mut n_windings,
1312 &obj.name,
1313 &mut self.net.warnings,
1314 );
1315 apply_winding_strings(&mut windings, name, &items);
1316 }
1317 "kvs" | "kvas" | "taps" | "%rs" => match v.to_vector(Some(self.vars)) {
1318 Ok(items) => {
1319 grow(
1320 &mut windings,
1321 items.len(),
1322 &mut n_windings,
1323 &obj.name,
1324 &mut self.net.warnings,
1325 );
1326 apply_winding_numbers(&mut windings, name, &items);
1327 }
1328 Err(e) => self.warn(format!("transformer {}: {name}: {e}", obj.name)),
1329 },
1330 "%loadloss" => {
1331 if let Some(ll) = self.f64_prop(Some(v)) {
1336 for w in windings.iter_mut().take(2) {
1337 w.r_pct = ll / 2.0;
1338 }
1339 }
1340 extras.insert("%loadloss".to_string(), v.text.clone().into());
1341 }
1342 "xhl" | "x12" => {
1343 xhl = self.f64_prop(Some(v)).unwrap_or(xhl);
1344 xhl_specified = true;
1345 x_pairs.insert((0, 1), xhl);
1346 }
1347 "xht" | "x13" => {
1348 xht = self.f64_prop(Some(v)).unwrap_or(xht);
1349 x_pairs.insert((0, 2), xht);
1350 }
1351 "xlt" | "x23" => {
1352 xlt = self.f64_prop(Some(v)).unwrap_or(xlt);
1353 x_pairs.insert((1, 2), xlt);
1354 }
1355 other if x_pair_key(other).is_some() => {
1356 if let Some((i, j)) = x_pair_key(other) {
1357 let x = self.f64_prop(Some(v)).unwrap_or(0.0);
1358 x_pairs.insert((i, j), x);
1359 }
1360 }
1361 other => {
1362 extras.insert(other.to_string(), v.text.clone().into());
1363 }
1364 }
1365 }
1366
1367 if !xhl_specified {
1368 self.defaulted("transformer", &obj.name, "xhl");
1369 }
1370 let out = self.finish_windings(&windings, phases, &obj.name);
1371
1372 let xsc_pct = if n_windings >= 3 {
1373 pair_keys(n_windings)
1374 .into_iter()
1375 .map(|pair| {
1376 x_pairs.get(&pair).copied().unwrap_or(match pair {
1377 (0, 1) => xhl,
1378 (0, 2) => xht,
1379 (1, 2) => xlt,
1380 _ => 0.0,
1381 })
1382 })
1383 .collect()
1384 } else {
1385 vec![xhl]
1386 };
1387 DistTransformer {
1388 name: obj.name.clone(),
1389 windings: out,
1390 xsc_pct,
1391 phases,
1392 extras,
1393 }
1394 }
1395
1396 fn finish_windings(
1399 &mut self,
1400 windings: &[WindingRaw],
1401 phases: usize,
1402 name: &str,
1403 ) -> Vec<Winding> {
1404 let mut out = Vec::with_capacity(windings.len());
1405 for (i, w) in windings.iter().enumerate() {
1406 if !w.kv_specified {
1407 self.defaulted("transformer", name, "kv");
1408 }
1409 if !w.kva_specified {
1410 self.defaulted("transformer", name, "kva");
1411 }
1412 let spec = w
1413 .bus
1414 .clone()
1415 .unwrap_or_else(|| Value::new(format!("{name}_w{}", i + 1)).to_bus_spec());
1416 let keep = if w.conn_delta {
1422 phases.max(2)
1423 } else {
1424 phases + 1
1425 };
1426 let map = self.terminals(&spec, phases, phases + 1, keep);
1427 out.push(Winding {
1428 bus: spec.name,
1429 terminal_map: map,
1430 conn: if w.conn_delta {
1431 WindingConn::Delta
1432 } else {
1433 WindingConn::Wye
1434 },
1435 v_ref: w.kv * 1e3,
1436 s_rating: w.kva * 1e3,
1437 r_pct: w.r_pct,
1438 tap: w.tap,
1439 r_neutral: w.r_neutral,
1440 x_neutral: w.x_neutral,
1441 });
1442 }
1443 out
1444 }
1445
1446 fn capacitor(&mut self, obj: &RawObject) {
1449 self.kvar_shunt(obj, CAPACITOR_KVAR_SHUNT);
1450 }
1451
1452 fn reactor(&mut self, obj: &RawObject) {
1460 let props = Props::new(obj);
1461 let phases = self.usize_or(&props, "phases", "reactor", &obj.name, dd::reactor::PHASES);
1462 if phases == 0 {
1463 self.warn(format!(
1464 "reactor {}: nonpositive `phases` value is not a typed shunt; kept untyped",
1465 obj.name
1466 ));
1467 self.net.untyped.push(UntypedObject::from(obj));
1468 return;
1469 }
1470 let bus = bus_spec(props.get("bus1"), "");
1471 let bus2 = props.get("bus2").map(super::lex::Value::to_bus_spec);
1472 let explicit_single_grounding = bus2
1473 .as_ref()
1474 .is_some_and(|return_bus| explicit_single_terminal_ground_return(&bus, return_bus));
1475 let grounding_return = explicit_single_grounding
1476 || bus2
1477 .as_ref()
1478 .is_some_and(|return_bus| same_bus_ground_return(&bus, return_bus, phases));
1479
1480 if bus2.is_some() && !grounding_return {
1481 self.warn(format!(
1482 "reactor {}: series reactors (bus2) are not typed yet; kept untyped",
1483 obj.name
1484 ));
1485 self.net.untyped.push(UntypedObject::from(obj));
1486 return;
1487 }
1488
1489 if let Some(form) = REACTOR_IMPEDANCE_FORMS
1490 .iter()
1491 .find(|k| !matches!(**k, "r" | "x") && props.by_name.contains_key(**k))
1492 {
1493 self.warn(format!(
1494 "reactor {}: impedance form (`{form}`) is not typed yet; kept untyped",
1495 obj.name
1496 ));
1497 self.net.untyped.push(UntypedObject::from(obj));
1498 return;
1499 }
1500 let has_rx = props.by_name.contains_key("r") || props.by_name.contains_key("x");
1501 if has_rx {
1502 if grounding_return {
1503 let grounding_phases = if explicit_single_grounding { 1 } else { phases };
1504 self.grounding_impedance_reactor(obj, &props, &bus, grounding_phases);
1505 } else {
1506 let form = if props.by_name.contains_key("r") {
1507 "r"
1508 } else {
1509 "x"
1510 };
1511 self.warn(format!(
1512 "reactor {}: impedance form (`{form}`) is not typed yet; kept untyped",
1513 obj.name
1514 ));
1515 self.net.untyped.push(UntypedObject::from(obj));
1516 }
1517 return;
1518 }
1519
1520 self.kvar_shunt_with_props(obj, &props, REACTOR_KVAR_SHUNT);
1521 }
1522
1523 fn grounding_impedance_reactor(
1524 &mut self,
1525 obj: &RawObject,
1526 props: &Props<'_>,
1527 bus: &BusSpec,
1528 phases: usize,
1529 ) {
1530 let term = |v: Option<&Value>| v.map_or(Ok(0.0), |val| val.to_f64(Some(self.vars)));
1535 let (Ok(resistance), Ok(reactance)) = (term(props.get("r")), term(props.get("x"))) else {
1536 self.warn(format!(
1537 "reactor {}: `r`/`x` does not evaluate to a number; kept untyped",
1538 obj.name
1539 ));
1540 self.net.untyped.push(UntypedObject::from(obj));
1541 return;
1542 };
1543 let denom = resistance * resistance + reactance * reactance;
1544 if !denom.is_finite() || denom <= 0.0 {
1545 self.warn(format!(
1546 "reactor {}: zero impedance grounding reactor is not a typed shunt; kept untyped",
1547 obj.name
1548 ));
1549 self.net.untyped.push(UntypedObject::from(obj));
1550 return;
1551 }
1552 let map = self.terminals(bus, phases, phases + 1, phases);
1553 let dim = map.len();
1554 let mut conductance = vec![vec![0.0; dim]; dim];
1555 let mut susceptance = vec![vec![0.0; dim]; dim];
1556 let y_g = resistance / denom;
1557 let y_b = -reactance / denom;
1558 for idx in 0..dim {
1559 conductance[idx][idx] = y_g;
1560 susceptance[idx][idx] = y_b;
1561 }
1562 self.net.shunts.push(DistShunt {
1563 name: obj.name.clone(),
1564 bus: bus.name.clone(),
1565 terminal_map: map,
1566 g: conductance,
1567 b: susceptance,
1568 extras: extras_from_leftovers(props),
1569 });
1570 }
1571
1572 fn kvar_shunt(&mut self, obj: &RawObject, spec: KvarShuntSpec) {
1573 let props = Props::new(obj);
1574 self.kvar_shunt_with_props(obj, &props, spec);
1575 }
1576
1577 fn kvar_shunt_with_props(&mut self, obj: &RawObject, props: &Props<'_>, spec: KvarShuntSpec) {
1578 let phases = self.usize_or(props, "phases", spec.class, &obj.name, spec.default_phases);
1579 if phases == 0 {
1580 self.warn(format!(
1581 "{} {}: nonpositive `phases` value is not a typed shunt; kept untyped",
1582 spec.class, obj.name
1583 ));
1584 self.net.untyped.push(UntypedObject::from(obj));
1585 return;
1586 }
1587 let conn_delta = props.get("conn").is_some_and(|v| {
1591 v.text.to_ascii_lowercase().starts_with('d') || v.text.eq_ignore_ascii_case("ll")
1592 });
1593 let bus = bus_spec(props.get("bus1"), "");
1594 if let Some(return_bus) = props.get("bus2").map(super::lex::Value::to_bus_spec) {
1595 if !same_bus_ground_return(&bus, &return_bus, phases) {
1596 self.warn(format!(
1597 "{} {}: series {} (bus2) are not typed yet; kept untyped",
1598 spec.class, obj.name, spec.series_name
1599 ));
1600 self.net.untyped.push(UntypedObject::from(obj));
1601 return;
1602 }
1603 }
1604
1605 if conn_delta && phases == 1 && bus.nodes.len() < 2 {
1606 self.warn(format!(
1607 "{} {}: single phase delta shunt needs two bus nodes; kept untyped",
1608 spec.class, obj.name
1609 ));
1610 self.net.untyped.push(UntypedObject::from(obj));
1611 return;
1612 }
1613 let kvar = props
1616 .get("kvar")
1617 .and_then(|v| v.to_vector(Some(self.vars)).ok())
1618 .and_then(|v| v.first().copied())
1619 .unwrap_or_else(|| {
1620 self.defaulted(spec.class, &obj.name, "kvar");
1621 spec.default_kvar
1622 });
1623 let kv = self.f64_or(props, "kv", spec.class, &obj.name, spec.default_kv);
1624 let v_ref = if conn_delta {
1627 kv * 1e3
1628 } else if phases == 2 || phases == 3 {
1629 kv * 1e3 / 3f64.sqrt()
1630 } else {
1631 kv * 1e3
1632 };
1633 let v_sq = v_ref * v_ref;
1637 if !v_ref.is_finite() || v_ref <= 0.0 || !v_sq.is_finite() || v_sq == 0.0 {
1638 self.warn(format!(
1639 "{} {}: invalid `kv` value is not a typed shunt; kept untyped",
1640 spec.class, obj.name
1641 ));
1642 self.net.untyped.push(UntypedObject::from(obj));
1643 return;
1644 }
1645
1646 let (nconds, keep) = if conn_delta {
1647 let keep = match phases {
1648 1 => 2,
1649 2 => 3,
1650 _ => phases,
1651 };
1652 (keep, keep)
1653 } else {
1654 (phases + 1, phases)
1658 };
1659 let map = self.terminals(&bus, phases, nconds, keep);
1660 let Some(susceptance) =
1661 kvar_shunt_matrix(&map, phases, conn_delta, kvar, v_ref, spec.b_sign)
1662 else {
1663 self.warn(format!(
1664 "{} {}: delta shunt terminal map is not typed; kept untyped",
1665 spec.class, obj.name
1666 ));
1667 self.net.untyped.push(UntypedObject::from(obj));
1668 return;
1669 };
1670 let mut extras = extras_from_leftovers(props);
1671 self.stash_kv_and_phases(props, &mut extras, kv, phases);
1672 extras.insert("kvar".into(), kvar.into());
1673 if conn_delta {
1674 extras.insert("conn".into(), "delta".into());
1675 }
1676 self.net.shunts.push(DistShunt {
1677 name: obj.name.clone(),
1678 bus: bus.name,
1679 terminal_map: map,
1680 g: vec![vec![0.0; susceptance.len()]; susceptance.len()],
1681 b: susceptance,
1682 extras,
1683 });
1684 }
1685
1686 fn generator(&mut self, obj: &RawObject) -> DistGenerator {
1689 let props = Props::new(obj);
1690 let phases = self.usize_or(
1691 &props,
1692 "phases",
1693 "generator",
1694 &obj.name,
1695 dd::generator::PHASES,
1696 );
1697 let conn_delta = props.get("conn").is_some_and(|v| {
1699 v.text.to_ascii_lowercase().starts_with('d') || v.text.eq_ignore_ascii_case("ll")
1700 });
1701 let mut kw = dd::generator::KW;
1710 let mut kvar = dd::generator::KVAR;
1711 let mut pf = dd::generator::PF;
1712 let (mut kw_written, mut q_written) = (false, false);
1713 for p in &obj.props {
1714 let Some(key @ ("kw" | "kvar" | "pf")) = p.name.as_deref() else {
1715 continue;
1716 };
1717 let Some(v) = self.f64_prop(Some(&p.value)) else {
1718 continue;
1719 };
1720 match key {
1721 "kw" | "pf" => {
1722 if key == "kw" {
1723 kw = v;
1724 kw_written = true;
1725 } else {
1726 pf = v;
1727 q_written = true;
1728 }
1729 if pf != 0.0 {
1730 kvar = kw * (pf.acos().tan()).copysign(pf);
1731 }
1732 }
1733 _ => {
1734 kvar = v;
1735 q_written = true;
1736 let kva = kw.hypot(kvar);
1737 pf = if kva == 0.0 { 1.0 } else { kw / kva };
1738 if kw * kvar < 0.0 {
1739 pf = -pf;
1740 }
1741 }
1742 }
1743 }
1744 if !kw_written {
1745 self.defaulted("generator", &obj.name, "kw");
1746 }
1747 if !q_written {
1748 self.defaulted("generator", &obj.name, "kvar");
1749 }
1750 let _ = (props.get("kw"), props.get("kvar"), props.get("pf"));
1752 let kv = self.f64_or(&props, "kv", "generator", &obj.name, dd::generator::KV);
1753 let maxkvar = self.f64_prop(props.get("maxkvar"));
1754 let minkvar = self.f64_prop(props.get("minkvar"));
1755
1756 let spec = bus_spec(props.get("bus1"), "");
1757 let nconds = if conn_delta && phases == 3 {
1758 phases
1759 } else {
1760 phases + 1
1761 };
1762 let map = self.terminals(&spec, phases, nconds, nconds);
1763
1764 let per_phase = |total_kw: f64| vec![total_kw * 1e3 / phases as f64; phases];
1765 let mut extras = extras_from_leftovers(&props);
1766 self.stash_kv_and_phases(&props, &mut extras, kv, phases);
1767 DistGenerator {
1768 name: obj.name.clone(),
1769 bus: spec.name,
1770 terminal_map: map,
1771 configuration: if phases == 1 {
1772 Configuration::SinglePhase
1773 } else if conn_delta {
1774 Configuration::Delta
1775 } else {
1776 Configuration::Wye
1777 },
1778 p_nom: per_phase(kw),
1779 q_nom: per_phase(kvar),
1780 p_min: None,
1781 p_max: None,
1782 q_min: minkvar.map(per_phase),
1783 q_max: maxkvar.map(per_phase),
1784 cost: None,
1785 s_max: None,
1786 i_max: None,
1787 extras,
1788 }
1789 }
1790
1791 fn pvsystem(&mut self, obj: &RawObject) {
1794 let props = Props::new(obj);
1795 let phases = self.usize_or(
1796 &props,
1797 "phases",
1798 "pvsystem",
1799 &obj.name,
1800 dd::pvsystem::PHASES,
1801 );
1802 if phases == 0 {
1803 self.warn(format!(
1804 "pvsystem {}: nonpositive `phases` value is not typed; kept untyped",
1805 obj.name
1806 ));
1807 self.net.untyped.push(UntypedObject::from(obj));
1808 return;
1809 }
1810 let conn_delta = props.get("conn").is_some_and(|v| {
1811 v.text.to_ascii_lowercase().starts_with('d') || v.text.eq_ignore_ascii_case("ll")
1812 });
1813 let spec = bus_spec(props.get("bus1"), "");
1814 let nconds = if conn_delta && phases == 3 {
1815 phases
1816 } else {
1817 phases + 1
1818 };
1819 let map = self.terminals(&spec, phases, nconds, nconds);
1820 let kv = self.f64_or(&props, "kv", "pvsystem", &obj.name, dd::pvsystem::KV);
1821 let irradiance = self.f64_or(
1822 &props,
1823 "irradiance",
1824 "pvsystem",
1825 &obj.name,
1826 dd::pvsystem::IRRADIANCE,
1827 );
1828 let pmpp = self.f64_or(&props, "pmpp", "pvsystem", &obj.name, dd::pvsystem::PMPP);
1829 let pct_pmpp = self
1830 .f64_prop(props.get("%pmpp"))
1831 .or_else(|| self.f64_prop(props.get("pctpmpp")))
1832 .unwrap_or(dd::pvsystem::PCT_PMPP);
1833 let kva = self
1834 .f64_prop(props.get("kva"))
1835 .unwrap_or(pmpp.max(f64::EPSILON));
1836 let per_phase = |total_kw: f64| vec![total_kw * 1e3 / phases as f64; phases];
1837 let p_avail = pmpp * irradiance * pct_pmpp / 100.0 * 1e3;
1838 let q_max = self
1839 .f64_prop(props.get("kvarmax"))
1840 .map(per_phase)
1841 .or_else(|| Some(vec![kva * 1e3 / phases as f64; phases]));
1842 let q_min = self
1843 .f64_prop(props.get("kvarmaxabs"))
1844 .map(|v| vec![-v * 1e3 / phases as f64; phases])
1845 .or_else(|| q_max.as_ref().map(|v| v.iter().map(|x| -*x).collect()));
1846 let topology = if phases == 1 {
1847 IbrTopology::SinglePhase
1848 } else if conn_delta {
1849 IbrTopology::ThreeLeg
1850 } else {
1851 IbrTopology::FourLeg
1852 };
1853 let pf = self.f64_prop(props.get("pf"));
1854 let mut extras = extras_from_leftovers(&props);
1855 self.stash_kv_and_phases(&props, &mut extras, kv, phases);
1856 extras.remove("conn");
1857 let mut ibr = DistIbr {
1858 name: obj.name.clone(),
1859 bus: spec.name,
1860 terminal_map: map,
1861 topology,
1862 prime_mover: IbrPrimeMover::Pv,
1863 s_max: vec![kva * 1e3 / phases as f64; phases],
1864 i_max: None,
1865 p_avail: Some(p_avail),
1866 p_min: Some(vec![0.0; phases]),
1867 p_max: Some(per_phase(pmpp * pct_pmpp / 100.0)),
1868 q_min,
1869 q_max,
1870 control_profile: None,
1871 voltage_aggregation: None,
1872 extras,
1873 };
1874 if let Some(pf) = pf {
1875 let profile = format!("{}_pf", obj.name);
1876 ibr.control_profile = Some(profile.clone());
1877 self.net.control_profiles.push(DistControlProfile {
1878 name: profile,
1879 power_factor: Some(PowerFactorControl { pf }),
1880 volt_var: None,
1881 volt_watt: None,
1882 extras: Extras::new(),
1883 });
1884 }
1885 self.net.ibrs.push(ibr);
1886 }
1887
1888 fn xycurve(&mut self, obj: &RawObject) {
1889 let props = Props::new(obj);
1890 let x = props
1891 .get("xarray")
1892 .and_then(|v| v.to_vector(Some(self.vars)).ok())
1893 .unwrap_or_default();
1894 let y = props
1895 .get("yarray")
1896 .and_then(|v| v.to_vector(Some(self.vars)).ok())
1897 .unwrap_or_default();
1898 if x.is_empty() || y.is_empty() {
1899 self.warn(format!(
1900 "xycurve {}: xarray/yarray are incomplete; kept untyped",
1901 obj.name
1902 ));
1903 self.net.untyped.push(UntypedObject::from(obj));
1904 return;
1905 }
1906 self.xycurves
1907 .insert(obj.name.to_ascii_lowercase(), XyCurveRaw { x, y });
1908 }
1909
1910 fn invcontrol(&mut self, obj: &RawObject) {
1911 let props = Props::new(obj);
1912 let derlist = props
1913 .get("derlist")
1914 .map(|v| dss_name_list(&v.text))
1915 .unwrap_or_default();
1916 let mode = props
1917 .get("mode")
1918 .map(|v| v.text.to_ascii_lowercase())
1919 .unwrap_or_default();
1920 let combimode = props
1921 .get("combimode")
1922 .map(|v| v.text.to_ascii_lowercase())
1923 .unwrap_or_default();
1924 let mon = props
1925 .get("monvoltagecalc")
1926 .map(|v| v.text.to_ascii_lowercase())
1927 .unwrap_or_default();
1928 let voltage_reference = if mon.contains("avg") {
1929 ControlVoltageReference::PgAveraged
1930 } else {
1931 ControlVoltageReference::PgPerPhase
1932 };
1933 let mut profile = DistControlProfile::new(obj.name.clone());
1934 profile.volt_var =
1935 self.invcontrol_volt_var(obj, &props, &derlist, voltage_reference, &mode, &combimode);
1936 profile.volt_watt =
1937 self.invcontrol_volt_watt(&props, &derlist, voltage_reference, &mode, &combimode);
1938 if profile.power_factor.is_none()
1939 && profile.volt_var.is_none()
1940 && profile.volt_watt.is_none()
1941 {
1942 self.warn(format!(
1943 "invcontrol {}: control mode is not typed; kept untyped",
1944 obj.name
1945 ));
1946 self.net.untyped.push(UntypedObject::from(obj));
1947 return;
1948 }
1949 for der in derlist {
1950 let name = der.rsplit_once('.').map_or(der.as_str(), |(_, name)| name);
1951 if let Some(ibr) = self
1952 .net
1953 .ibrs
1954 .iter_mut()
1955 .find(|ibr| ibr.name.eq_ignore_ascii_case(name))
1956 {
1957 ibr.control_profile = Some(profile.name.clone());
1958 if profile.volt_var.is_some() {
1959 ibr.extras.remove("%pminnovars");
1960 ibr.extras.remove("%pminkvarmax");
1961 }
1962 } else {
1963 self.warn(format!(
1964 "invcontrol {}: DER `{der}` does not match a typed PVSystem",
1965 obj.name
1966 ));
1967 }
1968 }
1969 self.net.control_profiles.push(profile);
1970 }
1971
1972 fn invcontrol_volt_var(
1973 &mut self,
1974 obj: &RawObject,
1975 props: &Props<'_>,
1976 derlist: &[String],
1977 voltage_reference: ControlVoltageReference,
1978 mode: &str,
1979 combimode: &str,
1980 ) -> Option<VoltVarControl> {
1981 if !(mode.contains("voltvar") || combimode.contains("vv")) {
1982 return None;
1983 }
1984 let curve_name = props.get("vvc_curve1").map(|v| v.text.clone())?;
1985 let curve = self
1986 .xycurves
1987 .get(&curve_name.to_ascii_lowercase())
1988 .cloned()?;
1989 let base_v = self.control_base_voltage(derlist).unwrap_or_else(|| {
1990 self.warn(format!(
1991 "invcontrol {}: no rated voltage found for vvc_curve1; using 1 pu as 1 V",
1992 obj.name
1993 ));
1994 1.0
1995 });
1996 let q_ref = props
1997 .get("refreactivepower")
1998 .map(|v| v.text.to_ascii_uppercase())
1999 .filter(|s| s.contains("VARAVAL"))
2000 .map_or(ReactivePowerReference::VarMax, |_| {
2001 ReactivePowerReference::VarAvailable
2002 });
2003 let p_min_for_q = self
2004 .ibr_extra_f64(derlist, "%pminnovars")
2005 .or_else(|| self.f64_prop(props.get("%pminnovars")));
2006 let p_min_for_q_max = self
2007 .ibr_extra_f64(derlist, "%pminkvarmax")
2008 .or_else(|| self.f64_prop(props.get("%pminkvarmax")));
2009 Some(VoltVarControl {
2010 voltage_reference: Some(voltage_reference),
2011 breakpoints: curve.x.iter().map(|x| x * base_v).collect(),
2012 q_limits: if curve.y.len() >= 4 {
2013 vec![curve.y[3], curve.y[0]]
2014 } else {
2015 curve.y
2016 },
2017 q_unit: Some(ReactivePowerUnit::VaFraction),
2018 q_ref: Some(q_ref),
2019 p_min_for_q,
2020 p_min_for_q_max,
2021 })
2022 }
2023
2024 fn invcontrol_volt_watt(
2025 &mut self,
2026 props: &Props<'_>,
2027 derlist: &[String],
2028 voltage_reference: ControlVoltageReference,
2029 mode: &str,
2030 combimode: &str,
2031 ) -> Option<VoltWattControl> {
2032 if !(mode.contains("voltwatt") || combimode.contains("vw")) {
2033 return None;
2034 }
2035 let curve_name = props
2036 .get("voltwatt_curve")
2037 .or_else(|| props.get("volt_watt_curve"))
2038 .map(|v| v.text.clone())?;
2039 let curve = self
2040 .xycurves
2041 .get(&curve_name.to_ascii_lowercase())
2042 .cloned()?;
2043 let base_v = self.control_base_voltage(derlist).unwrap_or(1.0);
2044 let p_ref = props
2045 .get("voltwattyaxis")
2046 .map(|v| v.text.to_ascii_uppercase())
2047 .map_or(ActivePowerReference::SMax, |s| {
2048 if s.contains("PAVAILABLE") {
2049 ActivePowerReference::PAvailable
2050 } else if s.contains("PMPP") {
2051 ActivePowerReference::PMax
2052 } else {
2053 ActivePowerReference::SMax
2054 }
2055 });
2056 Some(VoltWattControl {
2057 voltage_reference: Some(voltage_reference),
2058 breakpoints: curve.x.iter().map(|x| x * base_v).collect(),
2059 p_limits: if curve.y.len() >= 2 {
2060 vec![curve.y[1], curve.y[0]]
2061 } else {
2062 curve.y
2063 },
2064 p_unit: Some(ActivePowerUnit::VaFraction),
2065 p_ref: Some(p_ref),
2066 })
2067 }
2068
2069 fn ibr_extra_f64(&self, derlist: &[String], key: &str) -> Option<f64> {
2070 derlist.iter().find_map(|der| {
2071 let name = der.rsplit_once('.').map_or(der.as_str(), |(_, name)| name);
2072 self.net
2073 .ibrs
2074 .iter()
2075 .find(|ibr| ibr.name.eq_ignore_ascii_case(name))
2076 .and_then(|ibr| ibr.extras.get(key))
2077 .and_then(json_value_f64)
2078 })
2079 }
2080
2081 fn control_base_voltage(&self, derlist: &[String]) -> Option<f64> {
2082 let der = derlist.first()?;
2083 let name = der.rsplit_once('.').map_or(der.as_str(), |(_, name)| name);
2084 let ibr = self
2085 .net
2086 .ibrs
2087 .iter()
2088 .find(|ibr| ibr.name.eq_ignore_ascii_case(name))?;
2089 let kv = ibr
2090 .extras
2091 .get("kv")
2092 .and_then(|v| {
2093 v.as_f64()
2094 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
2095 })
2096 .unwrap_or(dd::pvsystem::KV);
2097 Some(match ibr.topology {
2098 IbrTopology::FourLeg => kv * 1e3 / 3f64.sqrt(),
2099 IbrTopology::SinglePhase | IbrTopology::ThreeLeg => kv * 1e3,
2100 })
2101 }
2102
2103 fn swtcontrol(&mut self, obj: &RawObject) {
2106 let props = Props::new(obj);
2107 let Some(target) = props.get("switchedobj").map(|v| v.text.clone()) else {
2108 self.warn(format!("swtcontrol {}: no SwitchedObj; ignored", obj.name));
2109 return;
2110 };
2111 let line_name = match target.split_once('.') {
2114 Some((class, rest)) if class.eq_ignore_ascii_case("line") => rest,
2115 _ => target.as_str(),
2116 };
2117 let mut open = None;
2120 for p in &obj.props {
2121 match p.name.as_deref() {
2122 Some("action" | "state") => {
2123 open = Some(p.value.text.to_ascii_lowercase().starts_with('o'));
2124 }
2125 Some("normal") if open.is_none() => {
2126 open = Some(p.value.text.to_ascii_lowercase().starts_with('o'));
2127 }
2128 _ => {}
2129 }
2130 }
2131 let open = open.unwrap_or(false);
2132 match self
2133 .net
2134 .switches
2135 .iter_mut()
2136 .find(|s| s.name.eq_ignore_ascii_case(line_name))
2137 {
2138 Some(sw) => sw.open = open,
2139 None => self.warn(format!(
2140 "swtcontrol {}: switched object `{target}` is not a switch line",
2141 obj.name
2142 )),
2143 }
2144 }
2145
2146 fn regcontrol(&mut self, obj: &RawObject) {
2147 let props = Props::new(obj);
2148 let target = props
2149 .get("transformer")
2150 .map_or_else(String::new, |v| v.text.clone());
2151 self.warn(format!(
2152 "regcontrol {}: voltage regulation is ignored; transformer `{target}` keeps its written taps",
2153 obj.name
2154 ));
2155 self.net.untyped.push(UntypedObject::from(obj));
2156 }
2157}
2158
2159fn scale_mat(m: &Mat, k: f64) -> Mat {
2161 m.iter()
2162 .map(|row| row.iter().map(|v| v * k).collect())
2163 .collect()
2164}
2165
2166fn filled_phase_nodes(spec: &BusSpec, phases: usize) -> Vec<i32> {
2167 let mut nodes: Vec<i32> = (1..=i32::try_from(phases).unwrap_or(i32::MAX)).collect();
2168 for (idx, &node) in spec.nodes.iter().enumerate().take(phases) {
2169 nodes[idx] = node.max(0);
2170 }
2171 nodes
2172}
2173
2174fn same_bus_ground_return(bus: &BusSpec, return_bus: &BusSpec, phases: usize) -> bool {
2175 bus.name.eq_ignore_ascii_case(&return_bus.name)
2176 && !return_bus.nodes.is_empty()
2177 && filled_phase_nodes(return_bus, phases)
2178 .iter()
2179 .all(|&n| n <= 0)
2180}
2181
2182fn explicit_single_terminal_ground_return(bus: &BusSpec, return_bus: &BusSpec) -> bool {
2183 bus.name.eq_ignore_ascii_case(&return_bus.name)
2184 && bus.nodes.len() == 1
2185 && return_bus.nodes.len() == 1
2186 && return_bus.nodes[0] <= 0
2187}
2188
2189fn dss_name_list(text: &str) -> Vec<String> {
2190 text.trim_matches(|c: char| matches!(c, '[' | ']' | '(' | ')'))
2191 .split(|c: char| c == ',' || c.is_whitespace())
2192 .filter(|s| !s.is_empty())
2193 .map(str::to_string)
2194 .collect()
2195}
2196
2197fn json_value_f64(value: &serde_json::Value) -> Option<f64> {
2198 value
2199 .as_f64()
2200 .or_else(|| value.as_str().and_then(|s| s.parse().ok()))
2201}
2202
2203pub(super) fn delta_edges(n: usize, phases: usize) -> Vec<(usize, usize)> {
2207 if n < 2 {
2208 Vec::new()
2209 } else if phases >= 3 && n >= 3 {
2210 (0..n).map(|i| (i, (i + 1) % n)).collect()
2211 } else {
2212 let branches = phases.max(1).min(n - 1);
2213 (0..branches).map(|i| (i, i + 1)).collect()
2214 }
2215}
2216
2217fn kvar_shunt_matrix(
2218 map: &[String],
2219 phases: usize,
2220 conn_delta: bool,
2221 kvar: f64,
2222 v_ref: f64,
2223 b_sign: f64,
2224) -> Option<Mat> {
2225 let dim = map.len();
2226 let mut susceptance = vec![vec![0.0; dim]; dim];
2227 if conn_delta {
2228 let edges = delta_edges(dim, phases);
2229 if edges.is_empty() || map.iter().any(|t| t == "0") {
2230 return None;
2231 }
2232 let b_branch = b_sign * kvar * 1e3 / edges.len() as f64 / (v_ref * v_ref);
2233 for (from, to) in edges {
2234 susceptance[from][from] += b_branch;
2235 susceptance[to][to] += b_branch;
2236 susceptance[from][to] -= b_branch;
2237 susceptance[to][from] -= b_branch;
2238 }
2239 } else {
2240 let b_phase = b_sign * kvar * 1e3 / phases as f64 / (v_ref * v_ref);
2241 for (idx, row) in susceptance.iter_mut().enumerate().take(phases) {
2242 row[idx] = b_phase;
2243 }
2244 }
2245 Some(susceptance)
2246}
2247
2248fn bus_spec(v: Option<&Value>, fallback: &str) -> BusSpec {
2249 v.map_or_else(
2250 || Value::new(fallback).to_bus_spec(),
2251 super::lex::Value::to_bus_spec,
2252 )
2253}
2254
2255fn extras_from_leftovers(props: &Props) -> Extras {
2256 let mut extras = Extras::new();
2257 for (k, v) in props.leftovers() {
2258 extras.insert(k.to_string(), v.text.clone().into());
2259 }
2260 extras
2261}
2262
2263fn apply_winding_strings(windings: &mut [WindingRaw], name: &str, items: &[String]) {
2265 let conn_is_delta =
2266 |t: &str| t.to_ascii_lowercase().starts_with('d') || t.eq_ignore_ascii_case("ll");
2267 for (w, item) in windings.iter_mut().zip(items) {
2270 if name == "buses" {
2271 w.bus = Some(Value::new(item.clone()).to_bus_spec());
2272 } else {
2273 w.conn_delta = conn_is_delta(item);
2274 }
2275 }
2276}
2277
2278fn apply_winding_numbers(windings: &mut [WindingRaw], name: &str, items: &[f64]) {
2281 for (w, &item) in windings.iter_mut().zip(items) {
2282 match name {
2283 "kvs" => {
2284 w.kv = item;
2285 w.kv_specified = true;
2286 }
2287 "kvas" => {
2288 w.kva = item;
2289 w.kva_specified = true;
2290 }
2291 "taps" => w.tap = item,
2292 _ => w.r_pct = item,
2293 }
2294 }
2295}
2296
2297fn x_pair_key(name: &str) -> Option<(usize, usize)> {
2298 let rest = name.strip_prefix('x')?;
2299 if rest.len() != 2 || !rest.chars().all(|c| c.is_ascii_digit()) {
2300 return None;
2301 }
2302 let mut chars = rest.chars();
2303 let i = chars.next()?.to_digit(10)? as usize;
2304 let j = chars.next()?.to_digit(10)? as usize;
2305 if i == 0 || j == 0 || i == j {
2306 return None;
2307 }
2308 Some((i.min(j) - 1, i.max(j) - 1))
2309}
2310
2311struct LoadPower {
2315 kw: f64,
2316 kvar: f64,
2317 pf: f64,
2318 spec_kvar: bool,
2320 kw_written: bool,
2321 pf_written: bool,
2322}
2323
2324struct SeriesImpedance {
2326 r: Mat,
2327 x: Mat,
2328 c_nf: Mat,
2329 all_default: bool,
2331 malformed: Vec<(&'static str, String)>,
2335}
2336
2337#[derive(Clone)]
2338struct WindingRaw {
2339 bus: Option<BusSpec>,
2340 conn_delta: bool,
2341 kv: f64,
2342 kva: f64,
2343 tap: f64,
2344 r_pct: f64,
2345 r_neutral: Option<f64>,
2346 x_neutral: Option<f64>,
2347 kv_specified: bool,
2348 kva_specified: bool,
2349}
2350
2351impl Default for WindingRaw {
2352 fn default() -> Self {
2353 WindingRaw {
2354 bus: None,
2355 conn_delta: false,
2356 kv: dd::transformer::KV,
2357 kva: dd::transformer::KVA,
2358 tap: dd::transformer::TAP,
2359 r_pct: dd::transformer::PCT_R,
2360 r_neutral: None,
2361 x_neutral: None,
2362 kv_specified: false,
2363 kva_specified: false,
2364 }
2365 }
2366}
2367
2368fn grow(
2370 windings: &mut Vec<WindingRaw>,
2371 requested: usize,
2372 count: &mut usize,
2373 name: &str,
2374 warnings: &mut Vec<String>,
2375) {
2376 let n = if requested > MAX_COUNT {
2381 warnings.push(format!(
2382 "transformer {name}: winding count {requested} exceeds the supported maximum \
2383 of {MAX_COUNT}; clamped"
2384 ));
2385 MAX_COUNT
2386 } else {
2387 requested
2388 };
2389 if n > windings.len() {
2390 windings.resize(n, WindingRaw::default());
2391 *count = n;
2392 }
2393}
2394
2395#[cfg(test)]
2396mod tests {
2397 use super::*;
2398
2399 fn has_warning(net: &MulticonductorNetwork, needle: &str) -> bool {
2400 net.warnings.iter().any(|w| w.contains(needle))
2401 }
2402
2403 #[test]
2404 fn vsource_magnitude_is_the_polygon_chord() {
2405 let net = parse_dss_str(
2408 "New Circuit.c basekv=12.47 pu=1.05 phases=2 bus1=src.1.2\n\
2409 New Vsource.aux basekv=12.47 phases=4 bus1=b2\n\
2410 New Vsource.solo basekv=2.4 phases=1 bus1=b3.1",
2411 );
2412 let two = &net.sources[0];
2413 assert!((two.v_magnitude[0] - 12.47e3 * 1.05 / 2.0).abs() < 1e-9);
2414 assert!((two.v_angle[1] - std::f64::consts::PI).abs() < 1e-12);
2417 let four = &net.sources[1];
2418 let chord = 2.0 * (std::f64::consts::PI / 4.0).sin();
2419 assert!((four.v_magnitude[0] - 12.47e3 / chord).abs() < 1e-9);
2420 let solo = &net.sources[2];
2421 assert!((solo.v_magnitude[0] - 2.4e3).abs() < 1e-9);
2422 }
2423
2424 #[test]
2425 fn vsource_defaults_are_recorded() {
2426 let net = parse_dss_str("New Circuit.c1");
2427 let fields = net.defaulted.get("vsource.source").expect("entry");
2428 for key in ["phases", "pu", "angle", "basekv", "bus1"] {
2429 assert!(fields.contains(&key), "missing {key}");
2430 }
2431 }
2432
2433 fn r_and_length(lc_tail: &str, line_tail: &str) -> (f64, f64) {
2435 let net = parse_dss_str(&format!(
2436 "New Circuit.c\n\
2437 New Linecode.lc nphases=1 rmatrix=(0.5){lc_tail}\n\
2438 New Line.l1 bus1=a.1 bus2=b.1 phases=1 linecode=lc{line_tail}"
2439 ));
2440 let line = net.lines.iter().find(|l| l.name == "l1").unwrap();
2441 let code = net.linecode(&line.linecode).unwrap();
2442 (code.r_series[0][0], line.length)
2443 }
2444
2445 #[test]
2446 fn unitless_line_length_is_in_linecode_units() {
2447 let (r, len) = r_and_length(" units=km", " length=2");
2451 assert!((len - 2000.0).abs() < 1e-9);
2452 assert!((r * len - 1.0).abs() < 1e-12);
2453 }
2454
2455 #[test]
2456 fn unitless_linecode_is_per_line_unit() {
2457 let (r, len) = r_and_length("", " length=2 units=km");
2460 assert!((len - 2.0).abs() < 1e-12);
2461 assert!((r * len - 1.0).abs() < 1e-12);
2462 }
2463
2464 #[test]
2465 fn written_units_on_both_sides_convert() {
2466 let (r, len) = r_and_length(" units=km", " length=500 units=m");
2468 assert!((len - 500.0).abs() < 1e-9);
2469 assert!((r * len - 0.25).abs() < 1e-12);
2470 }
2471
2472 #[test]
2473 fn one_phase_inline_sequence_values_stay_positive_sequence() {
2474 let net = parse_dss_str(
2475 "New Circuit.c\n\
2476 New Line.l1 bus1=a.1 bus2=b.1 phases=1 length=0.5 units=km r1=0.5 x1=0.2 c1=3",
2477 );
2478 let line = net.lines.iter().find(|l| l.name == "l1").unwrap();
2479 let code = net.linecode(&line.linecode).unwrap();
2480 assert!((line.length - 500.0).abs() < 1e-9);
2481 assert!((code.r_series[0][0] * line.length - 0.25).abs() < 1e-12);
2482 assert!((code.x_series[0][0] * line.length - 0.1).abs() < 1e-12);
2483 }
2484
2485 #[test]
2486 fn no_units_anywhere_takes_the_raw_product() {
2487 let (r, len) = r_and_length("", " length=2");
2488 assert!((len - 2.0).abs() < 1e-12);
2489 assert!((r * len - 1.0).abs() < 1e-12);
2490 }
2491
2492 #[test]
2493 fn two_phase_wye_capacitor_kv_is_line_to_line() {
2494 let net = parse_dss_str(
2497 "New Circuit.c\n\
2498 New Capacitor.c2 bus1=b.1.2 phases=2 kv=12.47 kvar=600\n\
2499 New Capacitor.c1 bus1=b.3 phases=1 kv=7.2 kvar=300",
2500 );
2501 let c2 = net.shunts.iter().find(|s| s.name == "c2").unwrap();
2502 let v2 = 12.47e3 / 3f64.sqrt();
2503 assert!((c2.b[0][0] * v2 * v2 / 300e3 - 1.0).abs() < 1e-12);
2504 let c1 = net.shunts.iter().find(|s| s.name == "c1").unwrap();
2505 let v1 = 7.2e3;
2506 assert!((c1.b[0][0] * v1 * v1 / 300e3 - 1.0).abs() < 1e-12);
2507 }
2508
2509 #[test]
2510 fn capacitor_and_reactor_kvar_shunts_share_magnitude_with_opposite_sign() {
2511 let net = parse_dss_str(
2512 "New Circuit.c\n\
2513 New Capacitor.cap bus1=b.1 phases=1 kv=7.2 kvar=300\n\
2514 New Reactor.rea bus1=b.2 phases=1 kv=7.2 kvar=300",
2515 );
2516 let cap = net.shunts.iter().find(|s| s.name == "cap").unwrap();
2517 let rea = net.shunts.iter().find(|s| s.name == "rea").unwrap();
2518 assert!(cap.b[0][0] > 0.0);
2519 assert!(rea.b[0][0] < 0.0);
2520 assert!((cap.b[0][0] + rea.b[0][0]).abs() < 1e-18);
2521 }
2522
2523 #[test]
2524 fn kvar_shunts_with_nonpositive_phases_stay_untyped() {
2525 let net = parse_dss_str(
2526 "New Circuit.c\n\
2527 New Capacitor.cap bus1=b.1 phases=0 kv=7.2 kvar=300\n\
2528 New Reactor.rea bus1=b.2 phases=0 kv=7.2 kvar=300",
2529 );
2530 assert!(net.shunts.is_empty());
2531 assert!(
2532 net.untyped
2533 .iter()
2534 .any(|u| u.class.eq_ignore_ascii_case("capacitor") && u.name == "cap")
2535 );
2536 assert!(
2537 net.untyped
2538 .iter()
2539 .any(|u| u.class.eq_ignore_ascii_case("reactor") && u.name == "rea")
2540 );
2541 assert!(
2542 net.warnings
2543 .iter()
2544 .any(|w| w.contains("capacitor cap: nonpositive `phases`"))
2545 );
2546 assert!(
2547 net.warnings
2548 .iter()
2549 .any(|w| w.contains("reactor rea: nonpositive `phases`"))
2550 );
2551 }
2552
2553 #[test]
2554 fn ll_connection_means_delta() {
2555 let net = parse_dss_str(
2557 "New Circuit.c\n\
2558 New Generator.g bus1=b.1.2.3 phases=3 conn=ll kw=90 kvar=30 kv=4.16\n\
2559 New Capacitor.cap bus1=b.1.2.3 phases=3 conn=ll kvar=600 kv=4.16",
2560 );
2561 assert_eq!(net.generators[0].configuration, Configuration::Delta);
2562 assert_eq!(net.shunts.len(), 1);
2564 let sh = &net.shunts[0];
2565 assert!(sh.b[0][1] < 0.0, "{:?}", sh.b);
2566 assert_eq!(sh.terminal_map, vec!["1", "2", "3"]);
2567 assert!(
2568 net.untyped
2569 .iter()
2570 .all(|u| !(u.class.eq_ignore_ascii_case("capacitor") && u.name == "cap"))
2571 );
2572 }
2573
2574 #[test]
2575 fn load_kw_after_kvar_reverts_to_pf() {
2576 let net =
2579 parse_dss_str("New Circuit.c\nNew Load.l bus1=b.1 phases=1 kv=2.4 kvar=20 kw=100");
2580 let l = &net.loads[0];
2581 let q: f64 = l.q_nom.iter().sum();
2582 assert!((q - 100e3 * 0.88f64.acos().tan()).abs() < 1e-6);
2583 assert_eq!(
2584 l.extras.get("pf").and_then(serde_json::Value::as_f64),
2585 Some(0.88)
2586 );
2587 assert!(
2588 net.defaulted
2589 .get("load.l")
2590 .is_some_and(|f| f.contains(&"pf"))
2591 );
2592 }
2593
2594 #[test]
2595 fn load_like_replays_the_sources_recalced_pf() {
2596 let net = parse_dss_str(
2603 "New Circuit.c\n\
2604 New Load.a bus1=b.1 phases=1 kv=2.4 kvar=20\n\
2605 New Load.b like=a kw=100",
2606 );
2607 let b = net.loads.iter().find(|l| l.name == "b").unwrap();
2608 let q: f64 = b.q_nom.iter().sum();
2609 assert!((q - 200e3).abs() < 1e-6);
2610 let pf = b.extras.get("pf").and_then(serde_json::Value::as_f64);
2612 assert!((pf.unwrap() - 0.447_213_595_499_957_9).abs() < 1e-12);
2613 let a = net.loads.iter().find(|l| l.name == "a").unwrap();
2615 let qa: f64 = a.q_nom.iter().sum();
2616 assert!((qa - 20e3).abs() < 1e-9);
2617 }
2618
2619 #[test]
2620 fn load_tilde_continuation_recalcs_at_each_edit() {
2621 let net = parse_dss_str(
2625 "New Circuit.c\n\
2626 New Load.l bus1=b.1 phases=1 kv=2.4 kvar=20\n\
2627 ~ kw=100",
2628 );
2629 let q: f64 = net.loads[0].q_nom.iter().sum();
2630 assert!((q - 200e3).abs() < 1e-6);
2631 }
2632
2633 #[test]
2634 fn load_pf_between_kvar_and_kw_applies() {
2635 let net = parse_dss_str(
2640 "New Circuit.c\nNew Load.l bus1=b.1 phases=1 kv=2.4 kvar=20 pf=0.95 kw=100",
2641 );
2642 let l = &net.loads[0];
2643 let q: f64 = l.q_nom.iter().sum();
2644 assert!((q - 100e3 * 0.95f64.acos().tan()).abs() < 1e-6);
2645 assert_eq!(
2646 l.extras.get("pf").and_then(serde_json::Value::as_f64),
2647 Some(0.95)
2648 );
2649 assert!(
2650 !net.defaulted
2651 .get("load.l")
2652 .is_some_and(|f| f.contains(&"pf"))
2653 );
2654 }
2655
2656 #[test]
2657 fn load_kvar_after_kw_stays() {
2658 let net =
2659 parse_dss_str("New Circuit.c\nNew Load.l bus1=b.1 phases=1 kv=2.4 kw=100 kvar=20");
2660 let l = &net.loads[0];
2661 let q: f64 = l.q_nom.iter().sum();
2662 assert!((q - 20e3).abs() < 1e-9);
2663 assert!(!l.extras.contains_key("pf"));
2665 }
2666
2667 #[test]
2668 fn generator_kw_after_kvar_resyncs_q() {
2669 let net =
2673 parse_dss_str("New Circuit.c\nNew Generator.g bus1=b.1 phases=1 kv=2.4 kvar=20 kw=100");
2674 let q: f64 = net.generators[0].q_nom.iter().sum();
2675 assert!((q - 2e3).abs() < 1e-6);
2676 }
2677
2678 #[test]
2679 fn generator_kvar_after_kw_stays() {
2680 let net =
2681 parse_dss_str("New Circuit.c\nNew Generator.g bus1=b.1 phases=1 kv=2.4 kw=100 kvar=20");
2682 let q: f64 = net.generators[0].q_nom.iter().sum();
2683 assert!((q - 20e3).abs() < 1e-9);
2684 }
2685
2686 #[test]
2687 fn generator_pf_after_kvar_wins() {
2688 let net = parse_dss_str(
2691 "New Circuit.c\nNew Generator.g bus1=b.1.2.3 phases=3 kv=4.16 kvar=20 pf=0.9",
2692 );
2693 let q: f64 = net.generators[0].q_nom.iter().sum();
2694 assert!((q - 1000e3 * 0.9f64.acos().tan()).abs() < 1e-3);
2695 }
2696
2697 #[test]
2698 fn malformed_matrix_warns_and_keeps_text() {
2699 let net = parse_dss_str(
2703 "New Circuit.c\n\
2704 New Linecode.bad nphases=2 rmatrix=(1 2 3) units=m\n\
2705 New Line.l2 bus1=a.1.2 bus2=b.1.2 phases=2 rmatrix=(bogus) length=10",
2706 );
2707 assert!(has_warning(&net, "linecode bad") && has_warning(&net, "rmatrix"));
2708 assert!(
2709 !net.defaulted
2710 .get("linecode.bad")
2711 .is_some_and(|f| f.contains(&"rmatrix"))
2712 );
2713 let code = net.linecode("bad").unwrap();
2714 assert!(
2715 code.extras
2716 .get("rmatrix")
2717 .and_then(serde_json::Value::as_str)
2718 .is_some_and(|s| s.contains("1 2 3"))
2719 );
2720 let diag = (2.0 * dd::line::R1 + dd::line::R0) / 3.0;
2722 assert!((code.r_series[0][0] - diag).abs() < 1e-12);
2723 assert!(has_warning(&net, "line l2"));
2725 let l2 = net.lines.iter().find(|l| l.name == "l2").unwrap();
2726 assert!(
2727 l2.extras
2728 .get("rmatrix")
2729 .and_then(serde_json::Value::as_str)
2730 .is_some_and(|s| s.contains("bogus"))
2731 );
2732 }
2733
2734 #[test]
2735 fn switchedobj_class_prefix_is_case_insensitive() {
2736 let net = parse_dss_str(
2737 "New Circuit.c\n\
2738 New Line.sw1 bus1=a.1 bus2=b.1 phases=1 switch=y\n\
2739 New SwtControl.s1 SwitchedObj=LINE.sw1 Action=open",
2740 );
2741 assert!(net.switches[0].open);
2742 }
2743
2744 #[test]
2745 fn phases_token_rides_in_extras() {
2746 let net = parse_dss_str(
2749 "New Circuit.c\n\
2750 New Load.l bus1=b.1.2 phases=2 conn=delta kw=50 kvar=10 kv=4.8\n\
2751 New Generator.g bus1=b.1.2.3 kw=10 kvar=2 kv=4.16\n\
2752 New Capacitor.cap bus1=b.1.2.3 phases=3 kvar=600 kv=4.16",
2753 );
2754 let l = &net.loads[0];
2755 assert_eq!(l.terminal_map.len(), 3);
2756 assert_eq!(
2757 l.extras.get("phases").and_then(serde_json::Value::as_str),
2758 Some("2")
2759 );
2760 assert_eq!(
2762 net.generators[0]
2763 .extras
2764 .get("phases")
2765 .and_then(serde_json::Value::as_u64),
2766 Some(3)
2767 );
2768 assert_eq!(
2769 net.shunts[0]
2770 .extras
2771 .get("phases")
2772 .and_then(serde_json::Value::as_str),
2773 Some("3")
2774 );
2775 }
2776
2777 #[test]
2778 fn rpn_kv_token_stashes_the_evaluated_value() {
2779 let net = parse_dss_str("New Circuit.c\nNew Load.l bus1=b.1 phases=1 kw=10 kv={4.8 2 /}");
2781 assert_eq!(
2782 net.loads[0]
2783 .extras
2784 .get("kv")
2785 .and_then(serde_json::Value::as_f64),
2786 Some(2.4)
2787 );
2788 }
2789
2790 #[test]
2791 fn string_input_disables_filesystem_includes() {
2792 let net = parse_dss_str(
2795 "New Circuit.c basekv=12.47\nRedirect /etc/passwd\nBuscoords /etc/hosts\n",
2796 );
2797 assert_eq!(
2798 net.warnings
2799 .iter()
2800 .filter(|w| w.contains("includes are disabled when parsing from a string"))
2801 .count(),
2802 2
2803 );
2804 }
2805
2806 #[test]
2807 fn oversized_count_properties_are_clamped() {
2808 let net = parse_dss_str(
2812 "New Circuit.c basekv=12.47\nNew Transformer.t phases=1000000 windings=999999\n",
2813 );
2814 assert!(
2815 net.warnings
2816 .iter()
2817 .any(|w| w.contains("exceeds the supported maximum")),
2818 "warnings: {:?}",
2819 net.warnings
2820 );
2821 }
2822
2823 #[test]
2824 fn oversized_winding_list_is_clamped() {
2825 let buses = (0..5000)
2829 .map(|i| format!("b{i}"))
2830 .collect::<Vec<_>>()
2831 .join(",");
2832 let net = parse_dss_str(&format!(
2833 "New Circuit.c basekv=12.47\nNew Transformer.t buses=({buses})\n"
2834 ));
2835 assert!(
2836 net.warnings
2837 .iter()
2838 .any(|w| w.contains("winding count") && w.contains("clamped")),
2839 "warnings: {:?}",
2840 net.warnings
2841 );
2842 }
2843}