1use std::collections::BTreeMap;
16use std::fmt::Write as _;
17
18use crate::convert::Conversion;
19use crate::model::{
20 Configuration, DistBus, DistLoadVoltageModel, DistNetwork, Extras, Mat, Winding, WindingConn,
21};
22
23use super::read::delta_edges;
24use super::{lex, prop};
25
26pub fn write_dss(net: &DistNetwork) -> Conversion {
28 let mut w = DssWriter {
29 out: String::new(),
30 warnings: Vec::new(),
31 grounded: net
32 .buses
33 .iter()
34 .map(|b| (b.id.to_ascii_lowercase(), b.grounded.clone()))
35 .collect(),
36 terminals: net
37 .buses
38 .iter()
39 .map(|b| (b.id.to_ascii_lowercase(), b.terminals.clone()))
40 .collect(),
41 kv_estimate: estimate_bus_kv(net),
42 };
43 w.network(net);
44 Conversion {
45 text: w.out,
46 warnings: w.warnings,
47 }
48}
49
50struct DssWriter {
51 out: String,
52 warnings: Vec<String>,
53 grounded: BTreeMap<String, Vec<String>>,
55 terminals: BTreeMap<String, Vec<String>>,
57 kv_estimate: BTreeMap<String, f64>,
59}
60
61#[derive(Clone, Copy)]
62struct ElementKv<'a> {
63 bus: &'a str,
64 phases: usize,
65 configuration: Configuration,
66 name: &'a str,
67 class: &'a str,
68 typed_kv: Option<f64>,
69}
70
71fn estimate_bus_kv(net: &DistNetwork) -> BTreeMap<String, f64> {
84 let mut kv: BTreeMap<String, f64> = BTreeMap::new();
85 for vs in &net.sources {
86 let phases = source_phases(net, vs);
87 let basekv = extras_f64(&vs.extras, "basekv").unwrap_or_else(|| source_basekv(vs, phases));
88 let pu = extras_f64(&vs.extras, "pu").unwrap_or(1.0);
89 let vln = basekv * 1e3 * pu / source_chord(phases);
90 if vln > 0.0 {
91 kv.insert(vs.bus.to_ascii_lowercase(), vln);
92 }
93 }
94 let grounded: BTreeMap<String, &Vec<String>> = net
99 .buses
100 .iter()
101 .map(|b| (b.id.to_ascii_lowercase(), &b.grounded))
102 .collect();
103 for _ in 0..net.buses.len() {
104 let mut changed = false;
105 for l in &net.lines {
106 let (f, t) = (
107 l.bus_from.to_ascii_lowercase(),
108 l.bus_to.to_ascii_lowercase(),
109 );
110 match (kv.get(&f).copied(), kv.get(&t).copied()) {
111 (Some(v), None) => {
112 kv.insert(t, v);
113 changed = true;
114 }
115 (None, Some(v)) => {
116 kv.insert(f, v);
117 changed = true;
118 }
119 _ => {}
120 }
121 }
122 for s in &net.switches {
123 let (f, t) = (
124 s.bus_from.to_ascii_lowercase(),
125 s.bus_to.to_ascii_lowercase(),
126 );
127 match (kv.get(&f).copied(), kv.get(&t).copied()) {
128 (Some(v), None) => {
129 kv.insert(t, v);
130 changed = true;
131 }
132 (None, Some(v)) => {
133 kv.insert(f, v);
134 changed = true;
135 }
136 _ => {}
137 }
138 }
139 for t in &net.transformers {
140 let pn = |w: &Winding| {
150 let v = (w.v_ref / 1e3) * 1e3;
151 let line_to_neutral = t.phases < 2
152 && grounded
153 .get(&w.bus.to_ascii_lowercase())
154 .is_some_and(|g| w.terminal_map.iter().any(|tm| g.contains(tm)));
155 if line_to_neutral { v } else { v / 3f64.sqrt() }
156 };
157 let known: Option<(usize, f64)> = t
158 .windings
159 .iter()
160 .enumerate()
161 .find_map(|(i, w)| kv.get(&w.bus.to_ascii_lowercase()).map(|v| (i, *v)));
162 if let Some((i, v_known)) = known {
163 let pn_known = pn(&t.windings[i]);
164 if pn_known > 0.0 {
165 for (j, w) in t.windings.iter().enumerate() {
166 if j != i && !kv.contains_key(&w.bus.to_ascii_lowercase()) {
167 kv.insert(w.bus.to_ascii_lowercase(), v_known * pn(w) / pn_known);
168 changed = true;
169 }
170 }
171 }
172 }
173 }
174 if !changed {
175 break;
176 }
177 }
178 kv
179}
180
181fn num(v: f64) -> String {
184 let v = if v == 0.0 { 0.0 } else { v };
185 format!("{v}")
186}
187
188fn source_chord(phases: usize) -> f64 {
192 if phases <= 1 {
193 1.0
194 } else {
195 2.0 * (std::f64::consts::PI / phases as f64).sin()
196 }
197}
198
199fn source_basekv(vs: &crate::model::VoltageSource, phases: usize) -> f64 {
202 vs.v_magnitude.iter().copied().fold(0.0_f64, f64::max) * source_chord(phases) / 1e3
203}
204
205fn extras_f64(extras: &Extras, key: &str) -> Option<f64> {
208 let v = extras.get(key)?;
209 v.as_f64()
210 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
211 .filter(|f| f.is_finite())
214}
215
216fn extras_usize(extras: &Extras, key: &str) -> Option<usize> {
217 let v = extras.get(key)?;
218 v.as_u64()
219 .and_then(|u| usize::try_from(u).ok())
220 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
221 .or_else(|| {
222 v.as_f64()
223 .filter(|f| f.fract() == 0.0 && *f >= 0.0)
224 .map(|f| f as usize)
225 })
226}
227
228fn zipv_cutoff(value: Option<&serde_json::Value>) -> Option<f64> {
229 let text = value?.as_str()?;
230 lex::Value::new(text)
231 .to_vector(None)
232 .ok()
233 .and_then(|v| v.get(6).copied())
234 .filter(|v| v.is_finite())
235}
236
237fn name_breaks_dss(name: &str, is_bus_id: bool) -> bool {
240 name.contains("//")
241 || name.chars().any(|c| {
242 matches!(
243 c,
244 ' ' | '\t' | ',' | '=' | '!' | '"' | '\'' | '(' | ')' | '[' | ']' | '{' | '}'
245 ) || (is_bus_id && c == '.')
246 })
247}
248
249fn dss_value_out(value: &str) -> (String, bool) {
259 if value.is_empty() {
262 return ("()".to_string(), true);
263 }
264 let mut scan = lex::Scanner::new(value, None);
265 let bare = scan.next_param().is_some_and(|p| {
266 p.name.is_none() && !p.value.quoted && p.value.text == value && scan.next_param().is_none()
267 });
268 if bare {
269 return (value.to_string(), true);
270 }
271 for (open, close) in [('(', ')'), ('[', ']'), ('{', '}'), ('"', '"'), ('\'', '\'')] {
272 if !value.contains(close) {
273 return (format!("{open}{value}{close}"), true);
274 }
275 }
276 (value.to_string(), false)
277}
278
279fn source_phases(net: &DistNetwork, vs: &crate::model::VoltageSource) -> usize {
285 if let Some(p) = extras_usize(&vs.extras, "phases") {
286 return p.max(1);
287 }
288 let energized = vs.v_magnitude.iter().filter(|&&v| v > 0.0).count();
289 if energized > 0
290 && vs.v_magnitude.len() == vs.terminal_map.len()
291 && energized + 1 == vs.v_magnitude.len()
292 && vs.v_magnitude.last().is_some_and(|&v| v == 0.0)
293 {
294 return energized;
295 }
296 let grounded = net
297 .buses
298 .iter()
299 .find(|b| b.id.eq_ignore_ascii_case(&vs.bus))
300 .map(|b| b.grounded.as_slice())
301 .unwrap_or_default();
302 vs.terminal_map
303 .iter()
304 .filter(|t| !grounded.contains(t))
305 .count()
306 .max(1)
307}
308
309fn seq_parts(extras: &Extras, key: &str) -> Option<(f64, f64)> {
311 let row = extras.get(key)?.as_array()?.first()?.as_array()?;
312 let self_v = row.first()?.as_f64()?;
313 let mutual = row
314 .get(1)
315 .and_then(serde_json::Value::as_f64)
316 .unwrap_or(0.0);
317 Some((self_v, mutual))
318}
319
320impl DssWriter {
321 fn warn(&mut self, msg: impl Into<String>) {
322 self.warnings.push(msg.into());
323 }
324
325 fn warn_short_map(&mut self, class: &str, name: &str, map_len: usize, nconds: usize) {
331 if map_len < nconds {
332 self.warn(format!(
333 "{class} {name}: terminal map lists {map_len} of {nconds} conductors; \
334 dss materializes a grounded neutral terminal and the reparsed model \
335 gains one"
336 ));
337 }
338 }
339
340 fn source_extra_f64(&mut self, vs: &crate::model::VoltageSource, key: &str) -> Option<f64> {
343 let v = vs.extras.get(key)?;
344 let parsed = v
345 .as_f64()
346 .or_else(|| v.as_str().and_then(|s| s.parse().ok()));
347 if parsed.is_none() {
348 self.warn(format!(
349 "vsource {}: {key} extra `{v}` does not parse as a number; \
350 using the derived value",
351 vs.name
352 ));
353 }
354 parsed
355 }
356
357 fn line_out(&mut self, s: &str) {
358 self.out.push_str(s);
359 self.out.push('\n');
360 }
361
362 fn check_name(&mut self, class: &str, name: &str) {
363 if name_breaks_dss(name, false) {
364 self.warn(format!(
365 "{class} `{name}`: name contains characters dss cannot represent; \
366 output will not reparse identically"
367 ));
368 }
369 }
370
371 fn bus_ref(&mut self, bus: &str, map: &[String]) -> String {
378 let key = bus.to_ascii_lowercase();
379 if name_breaks_dss(bus, true) {
380 self.warn(format!(
381 "bus `{bus}`: id contains characters dss cannot represent; \
382 output will not reparse identically"
383 ));
384 }
385 let grounded = self.grounded.get(&key).cloned();
386 let terminals = self.terminals.get(&key).cloned().unwrap_or_default();
387 let nodes: Vec<String> = map
388 .iter()
389 .enumerate()
390 .map(|(i, t)| {
391 if grounded.as_ref().is_some_and(|g| g.contains(t)) {
392 "0".to_string()
393 } else if t.parse::<u32>().is_ok() {
394 t.clone()
395 } else {
396 let pos = terminals.iter().position(|x| x == t).unwrap_or(i) + 1;
397 self.warn(format!(
398 "bus {bus}: terminal `{t}` is not a dss node number; \
399 emitted as node {pos}, its position on the bus"
400 ));
401 pos.to_string()
402 }
403 })
404 .collect();
405 if nodes.is_empty() {
406 bus.to_string()
407 } else {
408 format!("{bus}.{}", nodes.join("."))
409 }
410 }
411
412 fn extras_tail(&mut self, class: &str, name: &str, extras: &Extras) -> String {
415 let table = prop::class_by_name(class);
416 let mut tail = String::new();
417 for (key, value) in extras {
418 if matches!(key.as_str(), "bmopf_subtype") || key.starts_with("pmd_") {
419 continue; }
421 let known = table.is_some_and(|t| t.props.contains(&key.as_str()));
422 let text = value
423 .as_str()
424 .map(ToString::to_string)
425 .or_else(|| value.as_f64().map(num))
426 .or_else(|| value.as_i64().map(|v| v.to_string()));
427 match (known, text) {
428 (true, Some(text)) => {
429 let (out, representable) = dss_value_out(&text);
430 if !representable {
431 self.warn(format!(
432 "{class} {name}: extra `{key}` value `{text}` contains every \
433 dss quote closer and splits when scanned bare; emitted as \
434 written and a reparse will not see the same value"
435 ));
436 }
437 let _ = write!(tail, " {key}={out}");
438 }
439 _ => self.warn(format!(
440 "{class} {name}: extra `{key}` is not a dss property; dropped from the output"
441 )),
442 }
443 }
444 tail
445 }
446
447 fn matrix_arg(&mut self, m: &Mat, what: &str) -> String {
450 let mut short = false;
451 let rows: Vec<String> = m
452 .iter()
453 .enumerate()
454 .map(|(i, row)| {
455 let take = row.len().min(i + 1);
456 let mut vals: Vec<String> = row[..take].iter().map(|v| num(*v)).collect();
457 if take < i + 1 {
458 short = true;
459 vals.resize(i + 1, "0".to_string());
460 }
461 vals.join(" ")
462 })
463 .collect();
464 if short {
465 self.warn(format!(
466 "{what}: matrix rows are shorter than the lower triangle; \
467 missing entries emitted as 0"
468 ));
469 }
470 format!("({})", rows.join(" | "))
471 }
472
473 fn take_seq_pair(
476 &mut self,
477 extras: &mut Extras,
478 r_key: &str,
479 x_key: &str,
480 what: &str,
481 ) -> Option<((f64, f64), (f64, f64))> {
482 let r = seq_parts(extras, r_key);
483 let x = seq_parts(extras, x_key);
484 if let (Some(r), Some(x)) = (r, x) {
485 extras.remove(r_key);
486 extras.remove(x_key);
487 return Some((r, x));
488 }
489 if extras.contains_key(r_key) || extras.contains_key(x_key) {
490 let state = |key: &str, parsed: bool| {
491 if !extras.contains_key(key) {
492 format!("`{key}` is missing")
493 } else if parsed {
494 format!("`{key}` is usable")
495 } else {
496 format!("`{key}` is not a numeric matrix")
497 }
498 };
499 self.warn(format!(
500 "{what}: series impedance extras unusable ({}, {}); left in extras",
501 state(r_key, r.is_some()),
502 state(x_key, x.is_some()),
503 ));
504 }
505 None
506 }
507
508 fn element_phases(
512 &mut self,
513 extras: &Extras,
514 terminal_map: &[String],
515 configuration: Configuration,
516 class: &str,
517 name: &str,
518 ) -> usize {
519 if let Some(p) = extras_usize(extras, "phases") {
520 return p.max(1);
521 }
522 match configuration {
523 Configuration::Delta => match terminal_map.len() {
524 2 => 1,
525 3 => {
526 self.warn(format!(
527 "{class} {name}: a delta terminal map with 3 conductors is 2 or 3 \
528 phase and no phases record disambiguates; emitted phases=3"
529 ));
530 3
531 }
532 n => {
533 self.warn(format!(
534 "{class} {name}: a delta terminal map with {n} conductors has no \
535 dss phases mapping; emitted phases={}",
536 n.max(1)
537 ));
538 n.max(1)
539 }
540 },
541 Configuration::Wye => terminal_map.len().saturating_sub(1).max(1),
542 _ => 1,
543 }
544 }
545
546 fn network(&mut self, net: &DistNetwork) {
547 self.line_out("Clear");
548 self.line_out(&format!(
549 "Set DefaultBaseFrequency={}",
550 num(net.base_frequency)
551 ));
552 self.out.push('\n');
553
554 self.sources(net);
555 self.linecodes(net);
556 self.lines(net);
557 self.switches(net);
558 self.transformers(net);
559 self.loads(net);
560 self.shunts(net);
561 self.generators(net);
562
563 for u in &net.untyped {
564 self.warn(format!(
565 "{} {}: untyped object is not regenerated in canonical dss output",
566 u.class, u.name
567 ));
568 }
569 for b in &net.buses {
570 self.bus_extras(b);
571 }
572
573 self.out.push('\n');
574 for (key, value) in &net.options {
580 if key.is_empty() {
581 self.warn(format!(
582 "option `{value}` has no name; not regenerated in canonical dss output"
583 ));
584 continue;
585 }
586 let key_lc = key.to_ascii_lowercase();
595 if "voltagebases".starts_with(&key_lc)
596 || (key_lc.len() >= "defaultb".len() && "defaultbasefrequency".starts_with(&key_lc))
597 {
598 continue;
599 }
600 let (text, representable) = dss_value_out(value);
601 if !representable {
602 self.warn(format!(
603 "option `{key}`: value `{value}` contains every dss quote closer \
604 and splits when scanned bare; emitted as written and a reparse \
605 will not see the same value"
606 ));
607 }
608 self.line_out(&format!("Set {key}={text}"));
609 }
610 for (verb, args) in &net.commands {
611 if verb.eq_ignore_ascii_case("calcvoltagebases") || verb.eq_ignore_ascii_case("solve") {
612 continue; }
614 let shown = if args.is_empty() {
615 verb.clone()
616 } else {
617 format!("{verb} {args}")
618 };
619 self.warn(format!(
620 "command `{shown}` is not regenerated in canonical dss output"
621 ));
622 }
623 let mut bases: Vec<f64> = self
624 .kv_estimate
625 .values()
626 .map(|v| v * 3f64.sqrt() / 1e3)
627 .collect();
628 bases.sort_by(f64::total_cmp);
629 bases.dedup_by(|a, b| (*a - *b).abs() < 1e-9);
630 if !bases.is_empty() {
631 let list: Vec<String> = bases.iter().map(|v| num(*v)).collect();
632 self.line_out(&format!("Set VoltageBases=[{}]", list.join(", ")));
633 self.line_out("Calcvoltagebases");
634 }
635 self.line_out("Solve");
636 }
637
638 fn bus_extras(&mut self, b: &DistBus) {
639 for key in b.extras.keys() {
640 if key == "x" || key == "y" {
641 continue; }
643 self.warnings.push(format!(
644 "bus {}: extra `{key}` is not regenerated in canonical dss output",
645 b.id
646 ));
647 }
648 for (field, present) in [
649 ("v_min", b.v_min.is_some()),
650 ("v_max", b.v_max.is_some()),
651 ("vpn_min", b.vpn_min.is_some()),
652 ("vpn_max", b.vpn_max.is_some()),
653 ("vpp_min", b.vpp_min.is_some()),
654 ("vpp_max", b.vpp_max.is_some()),
655 ("vsym_min", b.vsym_min.is_some()),
656 ("vsym_max", b.vsym_max.is_some()),
657 ] {
658 if present {
659 self.warnings.push(format!(
660 "bus {}: `{field}` voltage bounds have no dss expression; dropped",
661 b.id
662 ));
663 }
664 }
665 }
666
667 fn sources(&mut self, net: &DistNetwork) {
668 let mut order: Vec<usize> = (0..net.sources.len()).collect();
669 if let Some(source_idx) = net
670 .sources
671 .iter()
672 .position(|vs| vs.name.eq_ignore_ascii_case("source"))
673 {
674 order.swap(0, source_idx);
675 }
676 for (i, source_idx) in order.into_iter().enumerate() {
677 let vs = &net.sources[source_idx];
678 let phases = source_phases(net, vs);
679 let energized = vs.v_magnitude.iter().filter(|&&v| v > 0.0).count();
680 if energized > 0 && energized != phases {
681 self.warn(format!(
682 "vsource {}: emitted phases={phases} but {energized} v_magnitude \
683 entries are positive; a reparse energizes all {phases}",
684 vs.name
685 ));
686 }
687 self.warn_short_map("vsource", &vs.name, vs.terminal_map.len(), phases + 1);
688 let basekv = self
689 .source_extra_f64(vs, "basekv")
690 .unwrap_or_else(|| source_basekv(vs, phases));
691 let pu = self.source_extra_f64(vs, "pu").unwrap_or(1.0);
692 let angle = self
693 .source_extra_f64(vs, "angle")
694 .unwrap_or_else(|| vs.v_angle.first().copied().unwrap_or(0.0).to_degrees());
695 let head = if i == 0 {
696 let name = net.name.clone().unwrap_or_else(|| "converted".into());
697 self.check_name("circuit", &name);
698 format!("New Circuit.{name}")
699 } else {
700 self.check_name("vsource", &vs.name);
701 format!("New Vsource.{}", vs.name)
702 };
703 let mut s = format!(
704 "{head} basekv={} pu={} angle={} phases={phases} bus1={}",
705 num(basekv),
706 num(pu),
707 num(angle),
708 self.bus_ref(&vs.bus, &vs.terminal_map),
709 );
710 let mut extras = vs.extras.clone();
711 extras.remove("basekv");
712 extras.remove("pu");
713 extras.remove("angle");
714 extras.remove("phases"); let what = format!("vsource {}", vs.name);
719 if let Some(((rs, rm), (xs, xm))) = self.take_seq_pair(&mut extras, "rs", "xs", &what) {
720 let _ = write!(
723 s,
724 " z0=({}, {}) z1=({}, {})",
725 num(rs + 2.0 * rm),
726 num(xs + 2.0 * xm),
727 num(rs - rm),
728 num(xs - xm)
729 );
730 }
731 s.push_str(&self.extras_tail("vsource", &vs.name, &extras));
732 self.line_out(&s);
733 }
734 self.out.push('\n');
735 }
736
737 fn linecodes(&mut self, net: &DistNetwork) {
738 let omega_nf = std::f64::consts::TAU * net.base_frequency * 1e-9;
739 for c in &net.linecodes {
740 self.check_name("linecode", &c.name);
741 let n = c.n_conductors;
742 let what = format!("linecode {}", c.name);
743 let mut s = format!("New Linecode.{} nphases={n} units=m", c.name);
744 let rm = self.matrix_arg(&c.r_series, &what);
745 let _ = write!(s, " rmatrix={rm}");
746 let xm = self.matrix_arg(&c.x_series, &what);
747 let _ = write!(s, " xmatrix={xm}");
748 let c_nf: Mat = c
751 .b_from
752 .iter()
753 .map(|row| row.iter().map(|b| 2.0 * b / omega_nf).collect())
754 .collect();
755 let cm = self.matrix_arg(&c_nf, &what);
756 let _ = write!(s, " cmatrix={cm}");
757 match c.i_max.as_deref() {
758 Some([amps, ..]) => {
759 let _ = write!(s, " emergamps={}", num(*amps));
760 }
761 Some([]) => self.warn(format!(
762 "linecode {}: i_max is empty; emergamps not emitted",
763 c.name
764 )),
765 None => {}
766 }
767 if !c.g_from.iter().flatten().all(|&g| g == 0.0) {
768 self.warn(format!(
769 "linecode {}: shunt conductance has no dss linecode field; dropped",
770 c.name
771 ));
772 }
773 let mut extras = c.extras.clone();
774 extras.remove("units"); s.push_str(&self.extras_tail("linecode", &c.name, &extras));
776 self.line_out(&s);
777 }
778 self.out.push('\n');
779 }
780
781 fn lines(&mut self, net: &DistNetwork) {
782 for l in &net.lines {
783 self.check_name("line", &l.name);
784 let phases = l.terminal_map_from.len();
785 let mut s = format!(
786 "New Line.{} bus1={} bus2={} phases={phases} linecode={} length={} units=m",
787 l.name,
788 self.bus_ref(&l.bus_from, &l.terminal_map_from),
789 self.bus_ref(&l.bus_to, &l.terminal_map_to),
790 l.linecode,
791 num(l.length),
792 );
793 let mut extras = l.extras.clone();
794 extras.remove("units"); s.push_str(&self.extras_tail("line", &l.name, &extras));
796 self.line_out(&s);
797 }
798 self.out.push('\n');
799 }
800
801 fn switches(&mut self, net: &DistNetwork) {
802 for sw in &net.switches {
803 self.check_name("line", &sw.name);
804 let phases = sw.terminal_map_from.len();
805 let mut s = format!(
806 "New Line.{} bus1={} bus2={} phases={phases} switch=y",
807 sw.name,
808 self.bus_ref(&sw.bus_from, &sw.terminal_map_from),
809 self.bus_ref(&sw.bus_to, &sw.terminal_map_to),
810 );
811 match sw.i_max.as_deref() {
812 Some([amps, ..]) => {
813 let _ = write!(s, " emergamps={}", num(*amps));
814 }
815 Some([]) => self.warn(format!(
816 "line {}: i_max is empty; emergamps not emitted",
817 sw.name
818 )),
819 None => {}
820 }
821 let mut extras = sw.extras.clone();
826 let what = format!("line {}", sw.name);
827 if let Some(((rs, rm), (xs, xm))) =
828 self.take_seq_pair(&mut extras, "pmd_rs", "pmd_xs", &what)
829 {
830 let _ = write!(
831 s,
832 " c0=0 c1=0 r0={} r1={} x0={} x1={}",
833 num((rs + 2.0 * rm) / 0.001),
834 num((rs - rm) / 0.001),
835 num((xs + 2.0 * xm) / 0.001),
836 num((xs - xm) / 0.001)
837 );
838 }
839 s.push_str(&self.extras_tail("line", &sw.name, &extras));
840 self.line_out(&s);
841 self.line_out(&format!(
842 "New SwtControl.{}_state SwitchedObj=Line.{} Action={}",
843 sw.name,
844 sw.name,
845 if sw.open { "open" } else { "close" },
846 ));
847 }
848 self.out.push('\n');
849 }
850
851 fn transformers(&mut self, net: &DistNetwork) {
852 for t in &net.transformers {
853 self.check_name("transformer", &t.name);
854 let nw = t.windings.len();
855 let buses: Vec<String> = t
856 .windings
857 .iter()
858 .map(|w| self.bus_ref(&w.bus, &w.terminal_map))
859 .collect();
860 let conns: Vec<&str> = t
861 .windings
862 .iter()
863 .map(|w| match w.conn {
864 WindingConn::Wye => "wye",
865 WindingConn::Delta => "delta",
866 })
867 .collect();
868 let kvs: Vec<String> = t.windings.iter().map(|w| num(w.v_ref / 1e3)).collect();
869 let kvas: Vec<String> = t.windings.iter().map(|w| num(w.s_rating / 1e3)).collect();
870 let rs: Vec<String> = t.windings.iter().map(|w| num(w.r_pct)).collect();
871 let taps: Vec<String> = t.windings.iter().map(|w| num(w.tap)).collect();
872 let mut s = format!(
873 "New Transformer.{} phases={} windings={nw} buses=({}) conns=({}) kvs=({}) kvas=({}) %Rs=({}) taps=({})",
874 t.name,
875 t.phases,
876 buses.join(", "),
877 conns.join(", "),
878 kvs.join(", "),
879 kvas.join(", "),
880 rs.join(", "),
881 taps.join(", "),
882 );
883 if let Some(xhl) = t.xsc_pct.first() {
884 let _ = write!(s, " xhl={}", num(*xhl));
885 if t.xsc_pct.len() >= 3 {
886 let _ = write!(s, " xht={} xlt={}", num(t.xsc_pct[1]), num(t.xsc_pct[2]));
887 }
888 } else {
889 self.warn(format!(
890 "transformer {}: xsc_pct is empty; emitted xhl=0",
891 t.name
892 ));
893 s.push_str(" xhl=0");
894 }
895 s.push_str(&self.extras_tail("transformer", &t.name, &t.extras));
896 self.line_out(&s);
897 }
898 self.out.push('\n');
899 }
900
901 fn loads(&mut self, net: &DistNetwork) {
902 for l in &net.loads {
903 self.check_name("load", &l.name);
904 let phases =
905 self.element_phases(&l.extras, &l.terminal_map, l.configuration, "load", &l.name);
906 let conn = self.element_conn(&l.extras, l.configuration, &l.bus, &l.terminal_map);
907 let nconds = if conn == "delta" && phases == 3 {
910 phases
911 } else {
912 phases + 1
913 };
914 self.warn_short_map("load", &l.name, l.terminal_map.len(), nconds);
915 let kw: f64 = l.p_nom.iter().sum::<f64>() / 1e3;
916 let kvar: f64 = l.q_nom.iter().sum::<f64>() / 1e3;
917 let typed_kv = self.load_nominal_kv(&l.voltage_model, phases, l.configuration, &l.name);
918 let kv = self.element_kv(
919 &l.extras,
920 ElementKv {
921 bus: &l.bus,
922 phases,
923 configuration: l.configuration,
924 name: &l.name,
925 class: "load",
926 typed_kv,
927 },
928 );
929 let mut extras = l.extras.clone();
930 extras.remove("kv");
931 extras.remove("phases");
932 extras.remove("conn");
933 let retained_model = extras.remove("model");
934 let retained_zipv = extras.remove("zipv");
935 let reactive = match extras.remove("pf").and_then(|v| v.as_f64()) {
938 Some(pf) => format!("pf={}", num(pf)),
939 None => format!("kvar={}", num(kvar)),
940 };
941 let mut s = format!(
942 "New Load.{} bus1={} phases={phases} conn={conn} kv={} kw={} {reactive}",
943 l.name,
944 self.bus_ref(&l.bus, &l.terminal_map),
945 num(kv),
946 num(kw),
947 );
948 match &l.voltage_model {
949 DistLoadVoltageModel::ConstantPower { .. } => {
950 if let Some(model) = retained_model {
951 extras.insert("model".into(), model);
952 }
953 }
954 DistLoadVoltageModel::ConstantImpedance { .. } => {
955 s.push_str(" model=2");
956 }
957 DistLoadVoltageModel::ConstantCurrent { .. } => {
958 s.push_str(" model=5");
959 }
960 DistLoadVoltageModel::Zip {
961 alpha_z,
962 alpha_i,
963 alpha_p,
964 beta_z,
965 beta_i,
966 beta_p,
967 ..
968 } => {
969 s.push_str(" model=8");
970 if let (Some(az), Some(ai), Some(ap), Some(bz), Some(bi), Some(bp)) = (
971 alpha_z.first(),
972 alpha_i.first(),
973 alpha_p.first(),
974 beta_z.first(),
975 beta_i.first(),
976 beta_p.first(),
977 ) {
978 let cutoff = zipv_cutoff(retained_zipv.as_ref()).unwrap_or(0.0);
979 let _ = write!(
980 s,
981 " zipv=({}, {}, {}, {}, {}, {}, {})",
982 num(*az),
983 num(*ai),
984 num(*ap),
985 num(*bz),
986 num(*bi),
987 num(*bp),
988 num(cutoff)
989 );
990 }
991 }
992 DistLoadVoltageModel::Exponential { .. } => {
993 self.warn(format!(
994 "load {}: exponential voltage model has no OpenDSS load model code; emitted constant power",
995 l.name
996 ));
997 }
998 }
999 s.push_str(&self.extras_tail("load", &l.name, &extras));
1000 self.line_out(&s);
1001 }
1002 self.out.push('\n');
1003 }
1004
1005 fn element_kv(&mut self, extras: &Extras, ctx: ElementKv<'_>) -> f64 {
1008 if let Some(v) = extras.get("kv") {
1009 match v
1010 .as_f64()
1011 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
1012 {
1013 Some(kv) => return kv,
1014 None => self.warn(format!(
1015 "{} {}: kv extra `{v}` does not parse as a number; \
1016 using the bus voltage estimate",
1017 ctx.class, ctx.name
1018 )),
1019 }
1020 }
1021 if let Some(kv) = ctx.typed_kv {
1022 return kv;
1023 }
1024 if let Some(vln) = self.kv_estimate.get(&ctx.bus.to_ascii_lowercase()).copied() {
1025 let v = if ctx.phases >= 2 || ctx.configuration == Configuration::Delta {
1028 vln * 3f64.sqrt()
1029 } else {
1030 vln
1031 };
1032 v / 1e3
1033 } else {
1034 self.warn(format!(
1035 "{} {}: no kv in the source and no bus voltage estimate; \
1036 emitted 12.47",
1037 ctx.class, ctx.name
1038 ));
1039 12.47
1040 }
1041 }
1042
1043 fn load_nominal_kv(
1044 &mut self,
1045 model: &DistLoadVoltageModel,
1046 phases: usize,
1047 configuration: Configuration,
1048 name: &str,
1049 ) -> Option<f64> {
1050 let v_nom = model.v_nom();
1051 let v_phase = v_nom
1052 .first()
1053 .copied()
1054 .filter(|v| v.is_finite() && *v > 0.0)?;
1055 if v_nom
1056 .iter()
1057 .any(|v| (*v - v_phase).abs() > 1e-9 * v.abs().max(v_phase.abs()).max(1.0))
1058 {
1059 self.warn(format!(
1060 "load {name}: nonuniform nominal voltage array has no OpenDSS scalar kv; emitted the first value"
1061 ));
1062 }
1063 let v = if phases >= 2 && configuration == Configuration::Wye {
1064 v_phase * 3f64.sqrt()
1065 } else {
1066 v_phase
1067 };
1068 Some(v / 1e3)
1069 }
1070
1071 fn element_conn(
1075 &self,
1076 extras: &Extras,
1077 configuration: Configuration,
1078 bus: &str,
1079 terminal_map: &[String],
1080 ) -> &'static str {
1081 let stash_delta = extras
1082 .get("conn")
1083 .and_then(|v| v.as_str())
1084 .is_some_and(|t| {
1085 t.to_ascii_lowercase().starts_with('d') || t.eq_ignore_ascii_case("ll")
1086 });
1087 let has_grounded_return = self
1088 .grounded
1089 .get(&bus.to_ascii_lowercase())
1090 .is_some_and(|g| terminal_map.iter().any(|t| g.contains(t)));
1091 match configuration {
1092 Configuration::Delta => "delta",
1093 Configuration::SinglePhase
1094 if stash_delta || (terminal_map.len() == 2 && !has_grounded_return) =>
1095 {
1096 "delta"
1097 }
1098 _ => "wye",
1099 }
1100 }
1101
1102 fn write_impedance_shunt(&mut self, sh: &crate::model::DistShunt, phases: usize) {
1103 self.check_name("reactor", &sh.name);
1104 let Some((conductance, susceptance)) = first_diag_admittance(&sh.g, &sh.b, phases) else {
1105 self.warn(format!(
1106 "shunt {}: conductance matrix has no diagonal admittance; dropped from the output",
1107 sh.name
1108 ));
1109 return;
1110 };
1111 if has_off_diagonal(&sh.g) || has_off_diagonal(&sh.b) {
1112 self.warn(format!(
1113 "shunt {}: off diagonal admittance has no scalar reactor expression; \
1114 only the first diagonal admittance is regenerated",
1115 sh.name
1116 ));
1117 }
1118 if !uniform_diag_admittance(&sh.g, &sh.b, phases, conductance, susceptance) {
1119 self.warn(format!(
1120 "shunt {}: diagonal admittances differ; only the first diagonal \
1121 admittance is regenerated",
1122 sh.name
1123 ));
1124 }
1125 let denom = conductance * conductance + susceptance * susceptance;
1126 if !denom.is_finite() || denom <= 0.0 {
1127 self.warn(format!(
1128 "shunt {}: invalid grounding admittance; dropped from the output",
1129 sh.name
1130 ));
1131 return;
1132 }
1133 let resistance = conductance / denom;
1134 let reactance = -susceptance / denom;
1135 let mut extras = sh.extras.clone();
1136 strip_shunt_extras(&mut extras);
1137 let ground = vec!["0".to_string(); phases.max(1)];
1138 let mut line = format!(
1139 "New Reactor.{} bus1={} bus2={} phases={} r={} x={}",
1140 sh.name,
1141 self.bus_ref(&sh.bus, &sh.terminal_map),
1142 self.bus_ref(&sh.bus, &ground),
1143 phases.max(1),
1144 num(resistance),
1145 num(reactance),
1146 );
1147 line.push_str(&self.extras_tail("reactor", &sh.name, &extras));
1148 self.line_out(&line);
1149 }
1150
1151 fn shunt_phases(
1152 &mut self,
1153 sh: &crate::model::DistShunt,
1154 conn_delta: bool,
1155 inferred_phases: usize,
1156 ) -> usize {
1157 if let Some(p) = extras_usize(&sh.extras, "phases") {
1158 p.max(1)
1159 } else if conn_delta {
1160 self.element_phases(
1161 &sh.extras,
1162 &sh.terminal_map,
1163 Configuration::Delta,
1164 "shunt",
1165 &sh.name,
1166 )
1167 } else {
1168 inferred_phases
1169 }
1170 }
1171
1172 fn write_kvar_shunt(&mut self, sh: &crate::model::DistShunt, phases: usize, conn_delta: bool) {
1173 let (b_max, b_min) = (0..sh.b.len())
1177 .map(|idx| diag_at(&sh.b, idx))
1178 .fold((0.0_f64, 0.0_f64), |(mx, mn), v| (mx.max(v), mn.min(v)));
1179 let (class, b_phase) = if b_max > 0.0 {
1180 ("capacitor", b_max)
1181 } else if b_min < 0.0 {
1182 ("reactor", b_min)
1183 } else {
1184 self.warn(format!(
1185 "shunt {}: no nonzero susceptance; dropped from the output",
1186 sh.name
1187 ));
1188 return;
1189 };
1190 if b_max > 0.0 && b_min < 0.0 {
1191 self.warn(format!(
1192 "shunt {}: diagonal mixes capacitive and inductive phases; only the \
1193 {class} phases are regenerated",
1194 sh.name
1195 ));
1196 }
1197 self.check_name(class, &sh.name);
1198 let off_diag = has_off_diagonal(&sh.b);
1199 if off_diag && !conn_delta {
1200 self.warn(format!(
1201 "shunt {}: off diagonal susceptance has no {class} expression; \
1202 only the diagonal is regenerated",
1203 sh.name
1204 ));
1205 }
1206 let edges = if conn_delta {
1207 delta_edges(sh.terminal_map.len(), phases)
1208 } else {
1209 Vec::new()
1210 };
1211 if conn_delta && edges.is_empty() {
1212 self.warn(format!(
1213 "shunt {}: delta terminal map has no branch expression; dropped from the output",
1214 sh.name
1215 ));
1216 return;
1217 }
1218 if conn_delta && delta_branch_susceptance(&sh.b, &edges, sh.terminal_map.len()).is_none() {
1219 self.warn(format!(
1220 "shunt {}: delta susceptance matrix has no scalar {class} expression; \
1221 only the average branch susceptance is regenerated",
1222 sh.name
1223 ));
1224 }
1225 let configuration = if conn_delta {
1226 Configuration::Delta
1227 } else {
1228 Configuration::Wye
1229 };
1230 let kv = self.element_kv(
1231 &sh.extras,
1232 ElementKv {
1233 bus: &sh.bus,
1234 phases,
1235 configuration,
1236 name: &sh.name,
1237 class,
1238 typed_kv: None,
1239 },
1240 );
1241 let kvar = extras_f64(&sh.extras, "kvar")
1242 .unwrap_or_else(|| shunt_kvar(sh, phases, conn_delta, &edges, b_phase, kv));
1243 let mut extras = sh.extras.clone();
1244 strip_shunt_extras(&mut extras);
1245 let conn = if conn_delta { "delta" } else { "wye" };
1246 let decl = if class == "reactor" {
1247 "Reactor"
1248 } else {
1249 "Capacitor"
1250 };
1251 let mut line = format!(
1252 "New {decl}.{} bus1={} phases={phases} conn={conn} kv={} kvar={}",
1253 sh.name,
1254 self.bus_ref(&sh.bus, &sh.terminal_map),
1255 num(kv),
1256 num(kvar),
1257 );
1258 line.push_str(&self.extras_tail(class, &sh.name, &extras));
1259 self.line_out(&line);
1260 }
1261
1262 fn shunts(&mut self, net: &DistNetwork) {
1263 for sh in &net.shunts {
1264 let stashed_delta = shunt_stashed_delta(sh);
1265 let inferred_phases =
1266 extras_usize(&sh.extras, "phases").unwrap_or_else(|| sh.terminal_map.len().max(1));
1267 let conn_delta = stashed_delta
1268 || looks_like_delta_shunt(&sh.b, sh.terminal_map.len(), inferred_phases);
1269 let phases = self.shunt_phases(sh, conn_delta, inferred_phases);
1270 if has_nonzero(&sh.g) {
1271 self.write_impedance_shunt(sh, phases);
1272 } else {
1273 self.write_kvar_shunt(sh, phases, conn_delta);
1274 }
1275 }
1276 self.out.push('\n');
1277 }
1278
1279 fn generators(&mut self, net: &DistNetwork) {
1280 for g in &net.generators {
1281 self.check_name("generator", &g.name);
1282 let phases = self.element_phases(
1283 &g.extras,
1284 &g.terminal_map,
1285 g.configuration,
1286 "generator",
1287 &g.name,
1288 );
1289 let conn = self.element_conn(&g.extras, g.configuration, &g.bus, &g.terminal_map);
1290 let nconds = if conn == "delta" && phases == 3 {
1291 phases
1292 } else {
1293 phases + 1
1294 };
1295 self.warn_short_map("generator", &g.name, g.terminal_map.len(), nconds);
1296 let kw: f64 = g.p_nom.iter().sum::<f64>() / 1e3;
1297 let kvar: f64 = g.q_nom.iter().sum::<f64>() / 1e3;
1298 let kv = self.element_kv(
1299 &g.extras,
1300 ElementKv {
1301 bus: &g.bus,
1302 phases,
1303 configuration: g.configuration,
1304 name: &g.name,
1305 class: "generator",
1306 typed_kv: None,
1307 },
1308 );
1309 let mut s = format!(
1310 "New Generator.{} bus1={} phases={phases} conn={conn} kv={} kw={} kvar={}",
1311 g.name,
1312 self.bus_ref(&g.bus, &g.terminal_map),
1313 num(kv),
1314 num(kw),
1315 num(kvar),
1316 );
1317 if let Some(q) = &g.q_max {
1318 let _ = write!(s, " maxkvar={}", num(q.iter().sum::<f64>() / 1e3));
1319 }
1320 if let Some(q) = &g.q_min {
1321 let _ = write!(s, " minkvar={}", num(q.iter().sum::<f64>() / 1e3));
1322 }
1323 if g.cost.is_some() {
1324 self.warn(format!(
1325 "generator {}: generation cost has no dss field; dropped",
1326 g.name
1327 ));
1328 }
1329 let mut extras = g.extras.clone();
1330 extras.remove("kv");
1331 extras.remove("phases");
1332 extras.remove("conn");
1333 s.push_str(&self.extras_tail("generator", &g.name, &extras));
1334 self.line_out(&s);
1335 }
1336 }
1337}
1338
1339fn strip_shunt_extras(extras: &mut Extras) {
1342 for key in ["kv", "kvar", "phases", "conn"] {
1343 extras.remove(key);
1344 }
1345}
1346
1347fn has_nonzero(m: &Mat) -> bool {
1348 m.iter().flatten().any(|&v| v != 0.0)
1349}
1350
1351fn has_off_diagonal(m: &Mat) -> bool {
1352 m.iter()
1353 .enumerate()
1354 .any(|(i, row)| row.iter().enumerate().any(|(j, &v)| i != j && v != 0.0))
1355}
1356
1357fn diag_at(m: &Mat, i: usize) -> f64 {
1358 m.get(i).and_then(|row| row.get(i)).copied().unwrap_or(0.0)
1359}
1360
1361fn matrix_scale(m: &Mat) -> f64 {
1362 m.iter().flatten().fold(0.0_f64, |acc, &v| acc.max(v.abs()))
1363}
1364
1365fn close(a: f64, b: f64, scale: f64) -> bool {
1366 (a - b).abs() <= 1e-12_f64.max(scale * 1e-9)
1367}
1368
1369fn first_diag_admittance(g: &Mat, b: &Mat, phases: usize) -> Option<(f64, f64)> {
1370 (0..phases.max(1)).find_map(|i| {
1371 let gi = diag_at(g, i);
1372 let bi = diag_at(b, i);
1373 (gi != 0.0 || bi != 0.0).then_some((gi, bi))
1374 })
1375}
1376
1377fn uniform_diag_admittance(g: &Mat, b: &Mat, phases: usize, g0: f64, b0: f64) -> bool {
1378 let scale = matrix_scale(g)
1379 .max(matrix_scale(b))
1380 .max(g0.abs())
1381 .max(b0.abs());
1382 (0..phases.max(1)).all(|i| close(diag_at(g, i), g0, scale) && close(diag_at(b, i), b0, scale))
1383}
1384
1385fn shunt_stashed_delta(sh: &crate::model::DistShunt) -> bool {
1386 sh.extras
1387 .get("conn")
1388 .and_then(|v| v.as_str())
1389 .is_some_and(|t| t.to_ascii_lowercase().starts_with('d') || t.eq_ignore_ascii_case("ll"))
1390}
1391
1392fn mat_at(m: &Mat, i: usize, j: usize) -> f64 {
1393 m.get(i).and_then(|row| row.get(j)).copied().unwrap_or(0.0)
1394}
1395
1396fn looks_like_delta_shunt(b: &Mat, terminals: usize, phases: usize) -> bool {
1397 if terminals < 2 || !has_off_diagonal(b) {
1398 return false;
1399 }
1400 let edges = delta_edges(terminals, phases);
1401 delta_branch_susceptance(b, &edges, terminals).is_some()
1402}
1403
1404fn delta_branch_abs(b: &Mat, edges: &[(usize, usize)]) -> Option<f64> {
1405 if edges.is_empty() {
1406 return None;
1407 }
1408 let total: f64 = edges
1413 .iter()
1414 .map(|&(i, j)| {
1415 b.get(i)
1416 .and_then(|row| row.get(j))
1417 .copied()
1418 .unwrap_or(0.0)
1419 .abs()
1420 })
1421 .sum();
1422 Some(total / edges.len() as f64)
1423}
1424
1425fn delta_branch_susceptance(b: &Mat, edges: &[(usize, usize)], terminals: usize) -> Option<f64> {
1426 if terminals < 2 || edges.is_empty() {
1427 return None;
1428 }
1429 let scale = matrix_scale(b);
1430 if scale == 0.0 {
1431 return None;
1432 }
1433 let first = edges[0];
1434 let branch = -mat_at(b, first.0, first.1);
1435 if branch == 0.0 {
1436 return None;
1437 }
1438 let scale = scale.max(branch.abs());
1439 for (i, row) in b.iter().enumerate() {
1440 for (j, &value) in row.iter().enumerate() {
1441 if (i >= terminals || j >= terminals) && !close(value, 0.0, scale) {
1442 return None;
1443 }
1444 }
1445 }
1446 for i in 0..terminals {
1447 let incident = edges
1448 .iter()
1449 .filter(|&&(from, to)| from == i || to == i)
1450 .count() as f64;
1451 for j in 0..terminals {
1452 let linked = edges
1453 .iter()
1454 .any(|&(from, to)| (from == i && to == j) || (from == j && to == i));
1455 let expected = if i == j {
1456 incident * branch
1457 } else if linked {
1458 -branch
1459 } else {
1460 0.0
1461 };
1462 if !close(mat_at(b, i, j), expected, scale) {
1463 return None;
1464 }
1465 }
1466 }
1467 Some(branch)
1468}
1469
1470fn shunt_kvar(
1471 sh: &crate::model::DistShunt,
1472 phases: usize,
1473 conn_delta: bool,
1474 edges: &[(usize, usize)],
1475 b_phase: f64,
1476 kv: f64,
1477) -> f64 {
1478 if conn_delta {
1479 let b_branch = delta_branch_abs(&sh.b, edges).unwrap_or(b_phase.abs());
1480 b_branch * (kv * 1e3) * (kv * 1e3) * edges.len() as f64 / 1e3
1481 } else {
1482 let v_phase = if matches!(phases, 2 | 3) {
1483 kv * 1e3 / 3f64.sqrt()
1484 } else {
1485 kv * 1e3
1486 };
1487 b_phase.abs() * v_phase * v_phase * phases as f64 / 1e3
1488 }
1489}
1490
1491#[cfg(test)]
1492mod tests {
1493 use super::super::read::parse_dss_str;
1494 use super::*;
1495 use crate::model::{
1496 DistGenerator, DistLine, DistLineCode, DistLoad, DistShunt, DistSwitch, DistTransformer,
1497 VoltageSource, Winding,
1498 };
1499
1500 fn strings(v: &[&str]) -> Vec<String> {
1501 v.iter().map(ToString::to_string).collect()
1502 }
1503
1504 fn bus(id: &str, terminals: &[&str], grounded: &[&str]) -> DistBus {
1505 DistBus {
1506 id: id.into(),
1507 terminals: strings(terminals),
1508 grounded: strings(grounded),
1509 ..DistBus::default()
1510 }
1511 }
1512
1513 fn three_phase_source(vln: f64) -> (DistBus, VoltageSource) {
1514 let third = 2.0 * std::f64::consts::FRAC_PI_3;
1515 (
1516 bus("sb", &["1", "2", "3", "4"], &["4"]),
1517 VoltageSource {
1518 name: "source".into(),
1519 bus: "sb".into(),
1520 terminal_map: strings(&["1", "2", "3", "4"]),
1521 v_magnitude: vec![vln, vln, vln, 0.0],
1522 v_angle: vec![0.0, -third, third, 0.0],
1523 extras: Extras::new(),
1524 },
1525 )
1526 }
1527
1528 fn load_on(bus: &str, map: &[&str], configuration: Configuration) -> DistLoad {
1529 let phases = map.len();
1530 DistLoad {
1531 name: "ld".into(),
1532 bus: bus.into(),
1533 terminal_map: strings(map),
1534 configuration,
1535 p_nom: vec![1e3; phases],
1536 q_nom: vec![0.0; phases],
1537 voltage_model: DistLoadVoltageModel::ConstantPower { v_nom: Vec::new() },
1538 extras: Extras::from([("kv".to_string(), serde_json::json!("0.4"))]),
1539 }
1540 }
1541
1542 fn roundtrip(net: &DistNetwork) -> (String, String) {
1543 let first = write_dss(net);
1544 let second = write_dss(&parse_dss_str(&first.text));
1545 (first.text, second.text)
1546 }
1547
1548 #[test]
1549 fn voltage_bases_survive_the_sqrt_round_trip() {
1550 let vln = 9_336.235_056_420_312_f64;
1554 let basekv = vln * 3f64.sqrt() / 1e3;
1555 assert!(
1556 (basekv * 1e3 / 3f64.sqrt()).to_bits() != vln.to_bits(),
1557 "test value no longer reproduces the drift"
1558 );
1559 let (b, vs) = three_phase_source(vln);
1560 let net = DistNetwork {
1561 name: Some("t".into()),
1562 base_frequency: 60.0,
1563 buses: vec![b],
1564 sources: vec![vs],
1565 ..DistNetwork::default()
1566 };
1567 let (first, second) = roundtrip(&net);
1568 assert!(first.contains("Set VoltageBases="), "{first}");
1569 assert_eq!(first, second);
1570 }
1571
1572 #[test]
1573 fn load_phases_prefer_the_reader_stash() {
1574 let (b, vs) = three_phase_source(2400.0);
1575 let mut load = load_on("sb", &["1", "2", "3"], Configuration::Delta);
1576 load.extras.insert("phases".into(), serde_json::json!("2"));
1577 let net = DistNetwork {
1578 base_frequency: 60.0,
1579 buses: vec![b],
1580 sources: vec![vs],
1581 loads: vec![load],
1582 ..DistNetwork::default()
1583 };
1584 let out = write_dss(&net);
1585 let line = out.text.lines().find(|l| l.contains("Load.ld")).unwrap();
1586 assert!(line.contains("phases=2 conn=delta"), "{line}");
1587 assert_eq!(line.matches("phases=").count(), 1, "{line}");
1589 assert!(!out.warnings.iter().any(|w| w.contains("2 or 3 phase")));
1590 }
1591
1592 #[test]
1593 fn ambiguous_delta_keeps_three_phases_loudly() {
1594 let (b, vs) = three_phase_source(2400.0);
1595 let net = DistNetwork {
1596 base_frequency: 60.0,
1597 buses: vec![b],
1598 sources: vec![vs],
1599 loads: vec![load_on("sb", &["1", "2", "3"], Configuration::Delta)],
1600 ..DistNetwork::default()
1601 };
1602 let out = write_dss(&net);
1603 let line = out.text.lines().find(|l| l.contains("Load.ld")).unwrap();
1604 assert!(line.contains("phases=3 conn=delta"), "{line}");
1605 assert!(
1606 out.warnings.iter().any(|w| w.contains("2 or 3 phase")),
1607 "{:?}",
1608 out.warnings
1609 );
1610 }
1611
1612 #[test]
1613 fn single_phase_delta_emits_conn_delta() {
1614 let (b, vs) = three_phase_source(2400.0);
1615 let two_wire = load_on("sb", &["1", "2"], Configuration::Delta);
1617 let mut stashed = load_on("sb", &["1", "2"], Configuration::SinglePhase);
1620 stashed.name = "ld2".into();
1621 stashed
1622 .extras
1623 .insert("conn".into(), serde_json::json!("delta"));
1624 let net = DistNetwork {
1625 base_frequency: 60.0,
1626 buses: vec![b],
1627 sources: vec![vs],
1628 loads: vec![two_wire, stashed],
1629 ..DistNetwork::default()
1630 };
1631 let out = write_dss(&net);
1632 let l1 = out.text.lines().find(|l| l.contains("Load.ld ")).unwrap();
1633 assert!(l1.contains("phases=1 conn=delta"), "{l1}");
1634 let l2 = out.text.lines().find(|l| l.contains("Load.ld2 ")).unwrap();
1635 assert!(l2.contains("phases=1 conn=delta"), "{l2}");
1636 assert_eq!(l2.matches("conn=").count(), 1, "{l2}");
1637 }
1638
1639 #[test]
1640 fn unrepresentable_names_are_reported() {
1641 let (b, vs) = three_phase_source(2400.0);
1642 let mut load = load_on("sb", &["1", "2", "3", "4"], Configuration::Wye);
1643 load.name = "load 1".into();
1644 let net = DistNetwork {
1645 name: Some("my circuit".into()),
1646 base_frequency: 60.0,
1647 buses: vec![b, bus("a=b", &["1"], &[])],
1648 sources: vec![vs],
1649 loads: vec![load],
1650 ..DistNetwork::default()
1651 };
1652 let out = write_dss(&net);
1653 let hits = |needle: &str| {
1654 out.warnings
1655 .iter()
1656 .any(|w| w.contains(needle) && w.contains("cannot represent"))
1657 };
1658 assert!(hits("load 1"), "{:?}", out.warnings);
1659 assert!(hits("my circuit"), "{:?}", out.warnings);
1660 let mut net2 = net.clone();
1662 net2.lines.push(DistLine {
1663 name: "l1".into(),
1664 bus_from: "sb".into(),
1665 bus_to: "a=b".into(),
1666 terminal_map_from: strings(&["1"]),
1667 terminal_map_to: strings(&["1"]),
1668 linecode: "lc".into(),
1669 length: 1.0,
1670 extras: Extras::new(),
1671 });
1672 let out2 = write_dss(&net2);
1673 assert!(
1674 out2.warnings
1675 .iter()
1676 .any(|w| w.contains("a=b") && w.contains("cannot represent")),
1677 "{:?}",
1678 out2.warnings
1679 );
1680 }
1681
1682 #[test]
1683 fn unparseable_kv_extra_warns_instead_of_silently_substituting() {
1684 let (b, vs) = three_phase_source(2400.0);
1685 let mut load = load_on("sb", &["1", "2", "3", "4"], Configuration::Wye);
1686 load.extras.insert("kv".into(), serde_json::json!("@kv"));
1687 let net = DistNetwork {
1688 base_frequency: 60.0,
1689 buses: vec![b],
1690 sources: vec![vs],
1691 loads: vec![load],
1692 ..DistNetwork::default()
1693 };
1694 let out = write_dss(&net);
1695 assert!(
1696 out.warnings
1697 .iter()
1698 .any(|w| w.contains("@kv") && w.contains("does not parse")),
1699 "{:?}",
1700 out.warnings
1701 );
1702 let line = out.text.lines().find(|l| l.contains("Load.ld")).unwrap();
1704 assert!(
1705 line.contains(&format!("kv={}", num(2400.0 * 3f64.sqrt() / 1e3))),
1706 "{line}"
1707 );
1708 }
1709
1710 #[test]
1711 fn options_reemit_and_commands_warn() {
1712 let src = "Clear\n\
1713 New Circuit.c1 basekv=12.47 pu=1 angle=0 phases=3 bus1=sb\n\
1714 Set mode=snapshot\n\
1715 Set controlmode=OFF\n\
1716 Disable Line.l1\n\
1717 Set VoltageBases=[12.47]\n\
1718 Calcvoltagebases\n\
1719 Solve\n";
1720 let out = write_dss(&parse_dss_str(src));
1721 assert!(out.text.contains("Set mode=snapshot"), "{}", out.text);
1722 assert!(out.text.contains("Set controlmode=OFF"), "{}", out.text);
1723 assert_eq!(out.text.matches("Set VoltageBases").count(), 1);
1725 assert_eq!(out.text.matches("Calcvoltagebases").count(), 1);
1726 assert_eq!(out.text.matches("DefaultBaseFrequency").count(), 1);
1727 assert!(!out.text.to_lowercase().contains("disable"));
1728 assert!(
1729 out.warnings
1730 .iter()
1731 .any(|w| w.contains("disable Line.l1") && w.contains("not regenerated")),
1732 "{:?}",
1733 out.warnings
1734 );
1735 assert!(!out.warnings.iter().any(|w| w.contains("`solve`")));
1737 let again = write_dss(&parse_dss_str(&out.text));
1738 assert_eq!(out.text, again.text);
1739 }
1740
1741 #[test]
1742 fn non_numeric_terminal_positionalizes() {
1743 let mut load = load_on("b1", &["a", "n"], Configuration::Wye);
1744 load.extras.insert("kv".into(), serde_json::json!("0.23"));
1745 let net = DistNetwork {
1746 base_frequency: 60.0,
1747 buses: vec![bus("b1", &["a", "n"], &["n"])],
1748 loads: vec![load],
1749 ..DistNetwork::default()
1750 };
1751 let (first, second) = roundtrip(&net);
1752 let line = first.lines().find(|l| l.contains("Load.ld")).unwrap();
1753 assert!(line.contains("bus1=b1.1.0"), "{line}");
1754 let out = write_dss(&net);
1755 assert!(
1756 out.warnings
1757 .iter()
1758 .any(|w| w.contains("`a`") && w.contains("position")),
1759 "{:?}",
1760 out.warnings
1761 );
1762 assert_eq!(first, second);
1763 }
1764
1765 #[test]
1766 fn half_present_thevenin_pair_stays_and_warns() {
1767 let (b, mut vs) = three_phase_source(2400.0);
1768 vs.extras
1769 .insert("rs".into(), serde_json::json!([[1.0, 0.1], [0.1, 1.0]]));
1770 let net = DistNetwork {
1771 base_frequency: 60.0,
1772 buses: vec![b],
1773 sources: vec![vs],
1774 ..DistNetwork::default()
1775 };
1776 let out = write_dss(&net);
1777 assert!(!out.text.contains("z1="), "{}", out.text);
1778 assert!(
1779 out.warnings.iter().any(|w| w.contains("`xs` is missing")),
1780 "{:?}",
1781 out.warnings
1782 );
1783 }
1784
1785 #[test]
1786 fn unusable_switch_sequence_extras_warn() {
1787 let (b, vs) = three_phase_source(2400.0);
1788 let sw = DistSwitch {
1789 name: "sw1".into(),
1790 bus_from: "sb".into(),
1791 bus_to: "b2".into(),
1792 terminal_map_from: strings(&["1", "2", "3"]),
1793 terminal_map_to: strings(&["1", "2", "3"]),
1794 open: false,
1795 i_max: Some(Vec::new()),
1796 extras: Extras::from([("pmd_rs".to_string(), serde_json::json!("oops"))]),
1797 };
1798 let net = DistNetwork {
1799 base_frequency: 60.0,
1800 buses: vec![b, bus("b2", &["1", "2", "3"], &[])],
1801 sources: vec![vs],
1802 switches: vec![sw],
1803 ..DistNetwork::default()
1804 };
1805 let out = write_dss(&net);
1806 assert!(!out.text.contains("r0="), "{}", out.text);
1807 assert!(
1808 out.warnings
1809 .iter()
1810 .any(|w| w.contains("pmd_rs") && w.contains("not a numeric matrix")),
1811 "{:?}",
1812 out.warnings
1813 );
1814 assert!(
1815 out.warnings.iter().any(|w| w.contains("i_max is empty")),
1816 "{:?}",
1817 out.warnings
1818 );
1819 }
1820
1821 #[test]
1822 fn degenerate_shapes_warn_instead_of_panicking() {
1823 let (b, vs) = three_phase_source(2400.0);
1824 let lc = DistLineCode {
1825 name: "lc1".into(),
1826 n_conductors: 2,
1827 r_series: vec![vec![1.0], vec![0.5]], x_series: vec![vec![1.0, 0.0], vec![0.0, 1.0]],
1829 g_from: vec![vec![0.0; 2]; 2],
1830 b_from: vec![vec![0.0; 2]; 2],
1831 g_to: vec![vec![0.0; 2]; 2],
1832 b_to: vec![vec![0.0; 2]; 2],
1833 i_max: Some(Vec::new()),
1834 s_max: None,
1835 extras: Extras::new(),
1836 };
1837 let t = DistTransformer {
1838 name: "t1".into(),
1839 windings: vec![
1840 Winding {
1841 bus: "sb".into(),
1842 terminal_map: strings(&["1", "2"]),
1843 conn: WindingConn::Wye,
1844 v_ref: 2400.0,
1845 s_rating: 25e3,
1846 r_pct: 0.5,
1847 tap: 1.0,
1848 },
1849 Winding {
1850 bus: "b2".into(),
1851 terminal_map: strings(&["1", "2"]),
1852 conn: WindingConn::Wye,
1853 v_ref: 240.0,
1854 s_rating: 25e3,
1855 r_pct: 0.5,
1856 tap: 1.0,
1857 },
1858 ],
1859 xsc_pct: Vec::new(),
1860 phases: 1,
1861 extras: Extras::new(),
1862 };
1863 let net = DistNetwork {
1864 base_frequency: 60.0,
1865 buses: vec![b, bus("b2", &["1", "2"], &[])],
1866 sources: vec![vs],
1867 linecodes: vec![lc],
1868 transformers: vec![t],
1869 ..DistNetwork::default()
1870 };
1871 let out = write_dss(&net); assert!(out.text.contains("rmatrix=(1 | 0.5 0)"), "{}", out.text);
1873 assert!(out.text.contains("xhl=0"), "{}", out.text);
1874 let has = |needle: &str| out.warnings.iter().any(|w| w.contains(needle));
1875 assert!(has("shorter than the lower triangle"), "{:?}", out.warnings);
1876 assert!(has("xsc_pct is empty"), "{:?}", out.warnings);
1877 assert!(has("i_max is empty"), "{:?}", out.warnings);
1878 }
1879
1880 #[test]
1881 fn two_phase_capacitor_kvar_uses_line_to_line_kv() {
1882 let (b, vs) = three_phase_source(2400.0);
1885 let b_phase = 1e-3;
1886 let sh = DistShunt {
1887 name: "c1".into(),
1888 bus: "sb".into(),
1889 terminal_map: strings(&["1", "2"]),
1890 g: vec![vec![0.0; 2]; 2],
1891 b: vec![vec![b_phase, 0.0], vec![0.0, b_phase]],
1892 extras: Extras::new(),
1893 };
1894 let net = DistNetwork {
1895 base_frequency: 60.0,
1896 buses: vec![b],
1897 sources: vec![vs],
1898 shunts: vec![sh],
1899 ..DistNetwork::default()
1900 };
1901 let out = write_dss(&net);
1902 let kv = 2400.0 * 3f64.sqrt() / 1e3;
1903 let v_phase = kv * 1e3 / 3f64.sqrt();
1904 let expected = b_phase * v_phase * v_phase * 2.0 / 1e3;
1905 let line = out
1906 .text
1907 .lines()
1908 .find(|l| l.contains("Capacitor.c1"))
1909 .unwrap();
1910 assert!(line.contains(&format!("kvar={}", num(expected))), "{line}");
1911 }
1912
1913 #[test]
1914 fn inductive_shunt_regenerates_as_a_reactor() {
1915 let (b, vs) = three_phase_source(2400.0);
1919 let b_phase = -1e-3;
1920 let sh = DistShunt {
1921 name: "rx".into(),
1922 bus: "sb".into(),
1923 terminal_map: strings(&["1", "2", "3"]),
1924 g: vec![vec![0.0; 3]; 3],
1925 b: vec![
1926 vec![b_phase, 0.0, 0.0],
1927 vec![0.0, b_phase, 0.0],
1928 vec![0.0, 0.0, b_phase],
1929 ],
1930 extras: Extras::new(),
1931 };
1932 let net = DistNetwork {
1933 base_frequency: 60.0,
1934 buses: vec![b],
1935 sources: vec![vs],
1936 shunts: vec![sh],
1937 ..DistNetwork::default()
1938 };
1939 let out = write_dss(&net);
1940 let line = out
1941 .text
1942 .lines()
1943 .find(|l| l.contains("Reactor.rx"))
1944 .unwrap_or_else(|| panic!("no reactor emitted in:\n{}", out.text));
1945 assert!(!out.text.contains("Capacitor.rx"), "{}", out.text);
1946 let kv = 2400.0 * 3f64.sqrt() / 1e3;
1947 let v_phase = kv * 1e3 / 3f64.sqrt();
1948 let expected = b_phase.abs() * v_phase * v_phase * 3.0 / 1e3;
1949 assert!(line.contains(&format!("kvar={}", num(expected))), "{line}");
1950 }
1951
1952 #[test]
1953 fn conductive_shunt_regenerates_as_grounding_reactor() {
1954 let (_, vs) = three_phase_source(2400.0);
1955 let b = bus("sb", &["1", "2", "3", "4"], &[]);
1956 let sh = DistShunt {
1957 name: "gnd".into(),
1958 bus: "sb".into(),
1959 terminal_map: strings(&["4"]),
1960 g: vec![vec![1.0 / 0.3]],
1961 b: vec![vec![0.0]],
1962 extras: Extras::new(),
1963 };
1964 let net = DistNetwork {
1965 base_frequency: 60.0,
1966 buses: vec![b],
1967 sources: vec![vs],
1968 shunts: vec![sh],
1969 ..DistNetwork::default()
1970 };
1971 let out = write_dss(&net);
1972 let line = out
1973 .text
1974 .lines()
1975 .find(|l| l.contains("Reactor.gnd"))
1976 .unwrap_or_else(|| panic!("no reactor emitted in:\n{}", out.text));
1977 assert!(line.contains("bus1=sb.4"), "{line}");
1978 assert!(line.contains("bus2=sb.0"), "{line}");
1979 assert!(line.contains("phases=1"), "{line}");
1980 assert!(line.contains("r=0.3"), "{line}");
1981 assert!(line.contains("x=0"), "{line}");
1982 assert!(
1983 !line.contains("x=-0"),
1984 "negative zero must canonicalize: {line}"
1985 );
1986 }
1987
1988 #[test]
1989 fn delta_shunt_regenerates_conn_delta() {
1990 let (b, vs) = three_phase_source(2400.0);
1991 let b_branch = 2e-4;
1992 let bmat = vec![
1993 vec![2.0 * b_branch, -b_branch, -b_branch],
1994 vec![-b_branch, 2.0 * b_branch, -b_branch],
1995 vec![-b_branch, -b_branch, 2.0 * b_branch],
1996 ];
1997 let mut extras = Extras::new();
1998 extras.insert("conn".into(), serde_json::json!("delta"));
1999 extras.insert("phases".into(), serde_json::json!("3"));
2000 let sh = DistShunt {
2001 name: "capd".into(),
2002 bus: "sb".into(),
2003 terminal_map: strings(&["1", "2", "3"]),
2004 g: vec![vec![0.0; 3]; 3],
2005 b: bmat,
2006 extras,
2007 };
2008 let net = DistNetwork {
2009 base_frequency: 60.0,
2010 buses: vec![b],
2011 sources: vec![vs],
2012 shunts: vec![sh],
2013 ..DistNetwork::default()
2014 };
2015 let out = write_dss(&net);
2016 let line = out
2017 .text
2018 .lines()
2019 .find(|l| l.contains("Capacitor.capd"))
2020 .unwrap_or_else(|| panic!("no capacitor emitted in:\n{}", out.text));
2021 assert!(line.contains("phases=3 conn=delta"), "{line}");
2022 assert!(
2023 !out.warnings.iter().any(|w| w.contains("off diagonal")),
2024 "{:?}",
2025 out.warnings
2026 );
2027 }
2028
2029 #[test]
2030 fn non_scalar_delta_matrix_is_not_inferred_silently() {
2031 let (b, vs) = three_phase_source(2400.0);
2032 let bmat = vec![
2033 vec![0.003, -0.001, -0.002],
2034 vec![-0.001, 0.003, -0.002],
2035 vec![-0.002, -0.002, 0.004],
2036 ];
2037 let sh = DistShunt {
2038 name: "capx".into(),
2039 bus: "sb".into(),
2040 terminal_map: strings(&["1", "2", "3"]),
2041 g: vec![vec![0.0; 3]; 3],
2042 b: bmat,
2043 extras: Extras::new(),
2044 };
2045 let net = DistNetwork {
2046 base_frequency: 60.0,
2047 buses: vec![b],
2048 sources: vec![vs],
2049 shunts: vec![sh],
2050 ..DistNetwork::default()
2051 };
2052 let out = write_dss(&net);
2053 let line = out
2054 .text
2055 .lines()
2056 .find(|l| l.contains("Capacitor.capx"))
2057 .unwrap_or_else(|| panic!("no capacitor emitted in:\n{}", out.text));
2058 assert!(line.contains("conn=wye"), "{line}");
2059 assert!(
2060 out.warnings.iter().any(|w| w.contains("off diagonal")),
2061 "{:?}",
2062 out.warnings
2063 );
2064 }
2065
2066 #[test]
2067 fn stashed_delta_matrix_warns_when_scalar_emission_is_lossy() {
2068 let (b, vs) = three_phase_source(2400.0);
2069 let bmat = vec![
2070 vec![0.003, -0.001, -0.002],
2071 vec![-0.001, 0.003, -0.002],
2072 vec![-0.002, -0.002, 0.004],
2073 ];
2074 let mut extras = Extras::new();
2075 extras.insert("conn".into(), serde_json::json!("delta"));
2076 extras.insert("phases".into(), serde_json::json!("3"));
2077 let sh = DistShunt {
2078 name: "capx".into(),
2079 bus: "sb".into(),
2080 terminal_map: strings(&["1", "2", "3"]),
2081 g: vec![vec![0.0; 3]; 3],
2082 b: bmat,
2083 extras,
2084 };
2085 let net = DistNetwork {
2086 base_frequency: 60.0,
2087 buses: vec![b],
2088 sources: vec![vs],
2089 shunts: vec![sh],
2090 ..DistNetwork::default()
2091 };
2092 let out = write_dss(&net);
2093 let line = out
2094 .text
2095 .lines()
2096 .find(|l| l.contains("Capacitor.capx"))
2097 .unwrap_or_else(|| panic!("no capacitor emitted in:\n{}", out.text));
2098 assert!(line.contains("conn=delta"), "{line}");
2099 assert!(
2100 out.warnings
2101 .iter()
2102 .any(|w| w.contains("no scalar capacitor expression")),
2103 "{:?}",
2104 out.warnings
2105 );
2106 }
2107
2108 #[test]
2109 fn option_values_choose_a_wrapper_the_lexer_undoes() {
2110 let src = "Clear\n\
2111 New Circuit.c1 basekv=12.47 pu=1 angle=0 phases=3 bus1=sb\n\
2112 Set foo=[a!b]\n\
2113 Set bar=[(abc]\n\
2114 Set baz=(x ] y)\n\
2115 Set qux=[a ) b]\n\
2116 Solve\n";
2117 let net = parse_dss_str(src);
2118 let first = write_dss(&net);
2119 for line in [
2120 "Set foo=(a!b)",
2121 "Set bar=((abc)",
2122 "Set baz=(x ] y)",
2123 "Set qux=[a ) b]",
2124 ] {
2125 assert!(
2126 first.text.contains(line),
2127 "{line} missing in {}",
2128 first.text
2129 );
2130 }
2131 assert!(
2132 !first
2133 .warnings
2134 .iter()
2135 .any(|w| w.contains("emitted as written")),
2136 "{:?}",
2137 first.warnings
2138 );
2139 let reparsed = parse_dss_str(&first.text);
2141 let opt = |k: &str| {
2142 reparsed
2143 .options
2144 .iter()
2145 .find(|(name, _)| name == k)
2146 .map(|(_, v)| v.as_str())
2147 };
2148 assert_eq!(opt("foo"), Some("a!b"));
2149 assert_eq!(opt("bar"), Some("(abc"));
2150 assert_eq!(opt("baz"), Some("x ] y"));
2151 assert_eq!(opt("qux"), Some("a ) b"));
2152 let second = write_dss(&reparsed);
2154 assert_eq!(first.text, second.text);
2155 }
2156
2157 #[test]
2158 fn extras_tail_values_wrap_like_options() {
2159 let (b, vs) = three_phase_source(2400.0);
2160 let mut load = load_on("sb", &["1", "2", "3", "4"], Configuration::Wye);
2161 load.extras
2162 .insert("daily".into(), serde_json::json!("a ) b"));
2163 let net = DistNetwork {
2164 base_frequency: 60.0,
2165 buses: vec![b],
2166 sources: vec![vs],
2167 loads: vec![load],
2168 ..DistNetwork::default()
2169 };
2170 let (first, second) = roundtrip(&net);
2171 assert!(first.contains("daily=[a ) b]"), "{first}");
2174 assert_eq!(first, second);
2175 let back = parse_dss_str(&first);
2176 assert_eq!(
2177 back.loads[0]
2178 .extras
2179 .get("daily")
2180 .and_then(serde_json::Value::as_str),
2181 Some("a ) b")
2182 );
2183 }
2184
2185 #[test]
2186 fn unrepresentable_values_emit_as_written_and_warn() {
2187 let bad = "a )]}\"' b";
2190 let (b, vs) = three_phase_source(2400.0);
2191 let mut load = load_on("sb", &["1", "2", "3", "4"], Configuration::Wye);
2192 load.extras.insert("daily".into(), serde_json::json!(bad));
2193 let mut net = DistNetwork {
2194 base_frequency: 60.0,
2195 buses: vec![b],
2196 sources: vec![vs],
2197 loads: vec![load],
2198 ..DistNetwork::default()
2199 };
2200 net.options.push(("foo".into(), bad.into()));
2201 let out = write_dss(&net);
2202 assert!(out.text.contains(&format!("Set foo={bad}")), "{}", out.text);
2203 assert!(out.text.contains(&format!("daily={bad}")), "{}", out.text);
2204 let warned = |needle: &str| {
2205 out.warnings
2206 .iter()
2207 .any(|w| w.contains(needle) && w.contains("emitted as written"))
2208 };
2209 assert!(warned("option `foo`"), "{:?}", out.warnings);
2210 assert!(warned("`daily`"), "{:?}", out.warnings);
2211 }
2212
2213 #[test]
2214 fn empty_extras_values_wrap_instead_of_eating_the_next_token() {
2215 let dss = "clear\nnew circuit.c basekv=12.47 bus1=sb\n\
2216 new load.ld bus1=sb.1 phases=1 kv=7.2 kw=10 daily=() duty=sh\nsolve\n";
2217 let net = parse_dss_str(dss);
2218 let load = &net.loads[0];
2219 assert_eq!(load.extras.get("daily").and_then(|v| v.as_str()), Some(""));
2220 let w1 = write_dss(&net).text;
2221 let again = parse_dss_str(&w1);
2222 let load2 = &again.loads[0];
2223 assert_eq!(load2.extras.get("daily").and_then(|v| v.as_str()), Some(""));
2224 assert_eq!(
2225 load2.extras.get("duty").and_then(|v| v.as_str()),
2226 Some("sh")
2227 );
2228 assert_eq!(w1, write_dss(&again).text);
2229 }
2230
2231 #[test]
2232 fn sub_unique_option_prefixes_re_emit_instead_of_vanishing() {
2233 let dss = "clear\nnew circuit.c basekv=12.47 bus1=sb\n\
2237 Set ca=600\nSet default=2.5\nsolve\n";
2238 let net = parse_dss_str(dss);
2239 assert!((net.base_frequency - 60.0).abs() < 1e-12);
2240 let out = write_dss(&net).text;
2241 assert!(out.contains("Set ca=600"), "{out}");
2242 assert!(out.contains("Set default=2.5"), "{out}");
2243 }
2244
2245 #[test]
2246 fn abbreviated_derived_options_skip_and_set_the_frequency() {
2247 let src = "Clear\n\
2250 New Circuit.c1 basekv=12.47 pu=1 angle=0 phases=3 bus1=sb\n\
2251 Set volt=[115, 132]\n\
2252 Set defaultb=50\n\
2253 Solve\n";
2254 let net = parse_dss_str(src);
2255 assert!((net.base_frequency - 50.0).abs() < 1e-12);
2256 let out = write_dss(&net);
2257 assert!(
2258 out.text.contains("Set DefaultBaseFrequency=50"),
2259 "{}",
2260 out.text
2261 );
2262 assert_eq!(
2263 out.text
2264 .to_lowercase()
2265 .matches("defaultbasefrequency")
2266 .count(),
2267 1,
2268 "{}",
2269 out.text
2270 );
2271 assert_eq!(
2272 out.text.matches("Set VoltageBases").count(),
2273 1,
2274 "{}",
2275 out.text
2276 );
2277 assert!(!out.text.contains("Set volt="), "{}", out.text);
2278 assert!(!out.text.contains("Set defaultb="), "{}", out.text);
2279 let second = write_dss(&parse_dss_str(&out.text));
2280 assert_eq!(out.text, second.text);
2281 }
2282
2283 #[test]
2284 fn non_numeric_source_extras_warn_before_falling_back() {
2285 let (b, mut vs) = three_phase_source(2400.0);
2286 vs.extras
2287 .insert("basekv".into(), serde_json::json!("@base"));
2288 vs.extras.insert("pu".into(), serde_json::json!("unity"));
2289 vs.extras.insert("angle".into(), serde_json::json!([0.0]));
2290 let net = DistNetwork {
2291 base_frequency: 60.0,
2292 buses: vec![b],
2293 sources: vec![vs],
2294 ..DistNetwork::default()
2295 };
2296 let out = write_dss(&net);
2297 for key in ["basekv", "pu", "angle"] {
2298 assert!(
2299 out.warnings
2300 .iter()
2301 .any(|w| w.contains(&format!("{key} extra")) && w.contains("does not parse")),
2302 "{key}: {:?}",
2303 out.warnings
2304 );
2305 }
2306 let line = out.text.lines().find(|l| l.contains("Circuit.")).unwrap();
2308 assert!(line.contains("pu=1 angle=0"), "{line}");
2309 }
2310
2311 #[test]
2312 fn de_energized_source_phase_keeps_its_conductor() {
2313 let (b, mut vs) = three_phase_source(2400.0);
2314 vs.v_magnitude[2] = 0.0; let net = DistNetwork {
2316 name: Some("t".into()),
2317 base_frequency: 60.0,
2318 buses: vec![b],
2319 sources: vec![vs],
2320 ..DistNetwork::default()
2321 };
2322 let (first, second) = roundtrip(&net);
2323 let line = first.lines().find(|l| l.contains("Circuit.")).unwrap();
2324 assert!(line.contains("phases=3"), "{line}");
2326 assert!(line.contains("bus1=sb.1.2.3.0"), "{line}");
2327 assert_eq!(first, second);
2328 let out = write_dss(&net);
2329 assert!(
2330 out.warnings
2331 .iter()
2332 .any(|w| w.contains("phases=3") && w.contains("positive")),
2333 "{:?}",
2334 out.warnings
2335 );
2336 }
2337
2338 #[test]
2339 fn multiple_sources_keep_named_vsource_when_source_exists() {
2340 let third = 2.0 * std::f64::consts::FRAC_PI_3;
2341 let source = VoltageSource {
2342 name: "source".into(),
2343 bus: "Bx".into(),
2344 terminal_map: strings(&["1", "2", "3", "4"]),
2345 v_magnitude: vec![20_000.0, 20_000.0, 20_000.0, 0.0],
2346 v_angle: vec![0.0, -third, third, 0.0],
2347 extras: Extras::new(),
2348 };
2349 let wind = VoltageSource {
2350 name: "WindGen1".into(),
2351 bus: "Bg".into(),
2352 terminal_map: strings(&["1", "2", "3", "4"]),
2353 v_magnitude: vec![400.0, 400.0, 400.0, 0.0],
2354 v_angle: vec![
2355 -std::f64::consts::FRAC_PI_3,
2356 std::f64::consts::PI,
2357 third / 2.0,
2358 0.0,
2359 ],
2360 extras: Extras::new(),
2361 };
2362 let net = DistNetwork {
2363 name: Some("dg".into()),
2364 base_frequency: 60.0,
2365 buses: vec![
2366 bus("Bg", &["1", "2", "3", "4"], &["4"]),
2367 bus("Bx", &["1", "2", "3", "4"], &["4"]),
2368 ],
2369 sources: vec![wind, source],
2370 ..DistNetwork::default()
2371 };
2372
2373 let out = write_dss(&net).text;
2374 let circuit = out.lines().find(|l| l.starts_with("New Circuit")).unwrap();
2375 assert!(circuit.contains("bus1=Bx.1.2.3.0"), "{circuit}");
2376 assert!(
2377 out.lines()
2378 .any(|l| l.starts_with("New Vsource.WindGen1") && l.contains("bus1=Bg.1.2.3.0")),
2379 "{out}"
2380 );
2381 let reparsed = parse_dss_str(&out);
2382 assert!(
2383 reparsed
2384 .sources
2385 .iter()
2386 .any(|vs| vs.name.eq_ignore_ascii_case("WindGen1")),
2387 "{:?}",
2388 reparsed.sources
2389 );
2390 }
2391
2392 #[test]
2393 fn source_phases_stash_wins_and_does_not_double_emit() {
2394 let (b, mut vs) = three_phase_source(2400.0);
2395 vs.extras.insert("phases".into(), serde_json::json!("3"));
2396 let net = DistNetwork {
2397 base_frequency: 60.0,
2398 buses: vec![b],
2399 sources: vec![vs],
2400 ..DistNetwork::default()
2401 };
2402 let out = write_dss(&net);
2403 let line = out.text.lines().find(|l| l.contains("Circuit.")).unwrap();
2404 assert!(line.contains("phases=3"), "{line}");
2405 assert_eq!(line.matches("phases=").count(), 1, "{line}");
2406 }
2407
2408 #[test]
2409 fn foreign_maps_without_a_neutral_warn_and_converge_at_write2() {
2410 let third = 2.0 * std::f64::consts::FRAC_PI_3;
2414 let vs = VoltageSource {
2415 name: "source".into(),
2416 bus: "sb".into(),
2417 terminal_map: strings(&["1", "2", "3"]),
2418 v_magnitude: vec![2400.0; 3],
2419 v_angle: vec![0.0, -third, third],
2420 extras: Extras::new(),
2421 };
2422 let load = load_on("sb", &["1"], Configuration::Wye);
2423 let net = DistNetwork {
2424 name: Some("t".into()),
2425 base_frequency: 60.0,
2426 buses: vec![bus("sb", &["1", "2", "3"], &[])],
2427 sources: vec![vs],
2428 loads: vec![load],
2429 ..DistNetwork::default()
2430 };
2431 let first = write_dss(&net);
2432 let hits = |warnings: &[String], name: &str| {
2433 warnings
2434 .iter()
2435 .any(|w| w.contains(name) && w.contains("materializes a grounded neutral"))
2436 };
2437 assert!(
2438 hits(&first.warnings, "vsource source"),
2439 "{:?}",
2440 first.warnings
2441 );
2442 assert!(hits(&first.warnings, "load ld"), "{:?}", first.warnings);
2443 let second = write_dss(&parse_dss_str(&first.text));
2444 assert_ne!(first.text, second.text);
2445 assert!(!hits(&second.warnings, "vsource"), "{:?}", second.warnings);
2446 assert!(!hits(&second.warnings, "load"), "{:?}", second.warnings);
2447 let third_write = write_dss(&parse_dss_str(&second.text));
2448 assert_eq!(second.text, third_write.text);
2449 }
2450
2451 #[test]
2452 fn generator_phases_and_conn_match_the_load_rules() {
2453 let (b, vs) = three_phase_source(2400.0);
2454 let g = DistGenerator {
2455 name: "g1".into(),
2456 bus: "sb".into(),
2457 terminal_map: strings(&["1", "2", "3"]),
2458 configuration: Configuration::Delta,
2459 p_nom: vec![1e3; 3],
2460 q_nom: vec![0.0; 3],
2461 p_min: None,
2462 p_max: None,
2463 q_min: None,
2464 q_max: None,
2465 cost: None,
2466 extras: Extras::from([
2467 ("kv".to_string(), serde_json::json!("4.16")),
2468 ("phases".to_string(), serde_json::json!("2")),
2469 ]),
2470 };
2471 let net = DistNetwork {
2472 base_frequency: 60.0,
2473 buses: vec![b],
2474 sources: vec![vs],
2475 generators: vec![g],
2476 ..DistNetwork::default()
2477 };
2478 let out = write_dss(&net);
2479 let line = out
2480 .text
2481 .lines()
2482 .find(|l| l.contains("Generator.g1"))
2483 .unwrap();
2484 assert!(line.contains("phases=2 conn=delta"), "{line}");
2485 assert_eq!(line.matches("phases=").count(), 1, "{line}");
2486 }
2487}