1use std::borrow::Cow;
16use std::collections::BTreeMap;
17use std::fmt::Write as _;
18
19use crate::convert::{Conversion, ConversionSidecar};
20use crate::model::{
21 ActivePowerReference, Configuration, ControlVoltageReference, DistBus, DistControlProfile,
22 DistIbr, DistLoad, DistLoadVoltageModel, DistNetwork, DistTransformer, Extras, IbrPrimeMover,
23 IbrTopology, IbrVoltageAggregation, Mat, ReactivePowerReference, VoltVarControl,
24 VoltWattControl, Winding, WindingConn,
25};
26
27use super::read::delta_edges;
28use super::{lex, prop};
29
30#[derive(Clone, Debug, PartialEq)]
32#[non_exhaustive]
33pub struct DssWriteOptions {
34 pub default_load_voltage_bounds: Option<DssLoadVoltageBounds>,
37 pub buscoords_filename: Option<String>,
40}
41
42impl Default for DssWriteOptions {
43 fn default() -> Self {
44 Self {
45 default_load_voltage_bounds: Some(DssLoadVoltageBounds::default()),
46 buscoords_filename: Some("buscoords.csv".to_owned()),
47 }
48 }
49}
50
51#[derive(Clone, Copy, Debug, PartialEq)]
53#[non_exhaustive]
54pub struct DssLoadVoltageBounds {
55 pub vminpu: f64,
56 pub vmaxpu: f64,
57}
58
59impl Default for DssLoadVoltageBounds {
60 fn default() -> Self {
61 Self {
62 vminpu: 0.0,
63 vmaxpu: 2.0,
64 }
65 }
66}
67
68pub fn write_dss(net: &DistNetwork) -> Conversion {
70 write_dss_with_options(net, &DssWriteOptions::default())
71}
72
73pub fn write_dss_with_options(net: &DistNetwork, options: &DssWriteOptions) -> Conversion {
75 let mut w = DssWriter {
76 out: String::new(),
77 sidecars: Vec::new(),
78 warnings: Vec::new(),
79 options: options.clone(),
80 grounded: net
81 .buses
82 .iter()
83 .map(|b| (b.id.to_ascii_lowercase(), b.grounded.clone()))
84 .collect(),
85 terminals: net
86 .buses
87 .iter()
88 .map(|b| (b.id.to_ascii_lowercase(), b.terminals.clone()))
89 .collect(),
90 kv_estimate: estimate_bus_kv(net),
91 };
92 w.network(net);
93 Conversion {
94 text: w.out,
95 sidecars: w.sidecars,
96 warnings: w.warnings,
97 diagnostics: Vec::new(),
98 }
99}
100
101struct DssWriter {
102 out: String,
103 sidecars: Vec<ConversionSidecar>,
104 warnings: Vec<String>,
105 options: DssWriteOptions,
106 grounded: BTreeMap<String, Vec<String>>,
108 terminals: BTreeMap<String, Vec<String>>,
110 kv_estimate: BTreeMap<String, f64>,
112}
113
114#[derive(Clone, Copy)]
115struct ElementKv<'a> {
116 bus: &'a str,
117 phases: usize,
118 configuration: Configuration,
119 name: &'a str,
120 class: &'a str,
121 typed_kv: Option<f64>,
122}
123
124fn estimate_bus_kv(net: &DistNetwork) -> BTreeMap<String, f64> {
137 let mut kv: BTreeMap<String, f64> = BTreeMap::new();
138 for vs in &net.sources {
139 let phases = source_phases(net, vs);
140 let basekv = extras_f64(&vs.extras, "basekv").unwrap_or_else(|| source_basekv(vs, phases));
141 let pu = extras_f64(&vs.extras, "pu").unwrap_or(1.0);
142 let vln = basekv * 1e3 * pu / source_chord(phases);
143 if vln > 0.0 {
144 kv.insert(vs.bus.to_ascii_lowercase(), vln);
145 }
146 }
147 let grounded: BTreeMap<String, &Vec<String>> = net
152 .buses
153 .iter()
154 .map(|b| (b.id.to_ascii_lowercase(), &b.grounded))
155 .collect();
156 for _ in 0..net.buses.len() {
157 let mut changed = false;
158 for l in &net.lines {
159 let (f, t) = (
160 l.bus_from.to_ascii_lowercase(),
161 l.bus_to.to_ascii_lowercase(),
162 );
163 match (kv.get(&f).copied(), kv.get(&t).copied()) {
164 (Some(v), None) => {
165 kv.insert(t, v);
166 changed = true;
167 }
168 (None, Some(v)) => {
169 kv.insert(f, v);
170 changed = true;
171 }
172 _ => {}
173 }
174 }
175 for s in &net.switches {
176 let (f, t) = (
177 s.bus_from.to_ascii_lowercase(),
178 s.bus_to.to_ascii_lowercase(),
179 );
180 match (kv.get(&f).copied(), kv.get(&t).copied()) {
181 (Some(v), None) => {
182 kv.insert(t, v);
183 changed = true;
184 }
185 (None, Some(v)) => {
186 kv.insert(f, v);
187 changed = true;
188 }
189 _ => {}
190 }
191 }
192 for t in &net.transformers {
193 let pn = |w: &Winding| {
203 let v = (w.v_ref / 1e3) * 1e3;
204 if winding_is_line_to_neutral(t.phases, w, |b| {
205 grounded.get(b).map(|g| g.as_slice())
206 }) {
207 v
208 } else {
209 v / 3f64.sqrt()
210 }
211 };
212 let known: Option<(usize, f64)> = t
213 .windings
214 .iter()
215 .enumerate()
216 .find_map(|(i, w)| kv.get(&w.bus.to_ascii_lowercase()).map(|v| (i, *v)));
217 if let Some((i, v_known)) = known {
218 let pn_known = pn(&t.windings[i]);
219 if pn_known > 0.0 {
220 for (j, w) in t.windings.iter().enumerate() {
221 if j != i && !kv.contains_key(&w.bus.to_ascii_lowercase()) {
222 kv.insert(w.bus.to_ascii_lowercase(), v_known * pn(w) / pn_known);
223 changed = true;
224 }
225 }
226 }
227 }
228 }
229 if !changed {
230 break;
231 }
232 }
233 kv
234}
235
236fn winding_is_line_to_neutral<'g>(
244 phases: usize,
245 w: &Winding,
246 grounded: impl Fn(&str) -> Option<&'g [String]>,
247) -> bool {
248 phases < 2
249 && grounded(&w.bus.to_ascii_lowercase())
250 .is_some_and(|g| w.terminal_map.iter().any(|tm| g.contains(tm)))
251}
252
253fn is_positive_finite(v: f64) -> bool {
259 v.is_finite() && v > 0.0
260}
261
262fn nconds_for(conn: &str, phases: usize) -> usize {
265 if conn == "delta" && phases == 3 {
266 phases
267 } else {
268 phases + 1
269 }
270}
271
272fn strip_emitted_extras(extras: &mut Extras, keys: &[&str]) {
275 for key in keys {
276 extras.remove(*key);
277 }
278}
279
280fn num(v: f64) -> String {
281 let v = if v == 0.0 { 0.0 } else { v };
282 format!("{v}")
283}
284
285fn winding_array(
289 head: &mut String,
290 edits: &mut [String],
291 array_key: &str,
292 scalar_key: &str,
293 values: &[Option<f64>],
294) {
295 if values.iter().all(Option::is_some) {
296 let toks: Vec<String> = values.iter().map(|v| num(v.unwrap_or(0.0))).collect();
297 let _ = write!(head, " {array_key}=({})", toks.join(", "));
298 } else {
299 for (edit, v) in edits.iter_mut().zip(values) {
300 if let Some(v) = v {
301 let _ = write!(edit, " {scalar_key}={}", num(*v));
302 }
303 }
304 }
305}
306
307fn source_chord(phases: usize) -> f64 {
311 if phases <= 1 {
312 1.0
313 } else {
314 2.0 * (std::f64::consts::PI / phases as f64).sin()
315 }
316}
317
318fn source_basekv(vs: &crate::model::VoltageSource, phases: usize) -> f64 {
321 vs.v_magnitude.iter().copied().fold(0.0_f64, f64::max) * source_chord(phases) / 1e3
322}
323
324fn extras_f64(extras: &Extras, key: &str) -> Option<f64> {
327 let v = extras.get(key)?;
328 v.as_f64()
329 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
330 .filter(|f| f.is_finite())
333}
334
335fn extras_usize(extras: &Extras, key: &str) -> Option<usize> {
336 let v = extras.get(key)?;
337 v.as_u64()
338 .and_then(|u| usize::try_from(u).ok())
339 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
340 .or_else(|| {
341 v.as_f64()
342 .filter(|f| f.fract() == 0.0 && *f >= 0.0)
343 .map(|f| f as usize)
344 })
345}
346
347fn zipv_cutoff(value: Option<&serde_json::Value>) -> Option<f64> {
348 let text = value?.as_str()?;
349 lex::Value::new(text)
350 .to_vector(None)
351 .ok()
352 .and_then(|v| v.get(6).copied())
353 .filter(|v| v.is_finite())
354}
355
356fn name_breaks_dss(name: &str, is_bus_id: bool) -> bool {
359 name.contains("//")
360 || name.chars().any(|c| {
361 matches!(
364 c,
365 ' ' | '\t'
366 | '\n'
367 | '\r'
368 | ','
369 | '='
370 | '!'
371 | '"'
372 | '\''
373 | '('
374 | ')'
375 | '['
376 | ']'
377 | '{'
378 | '}'
379 ) || (is_bus_id && c == '.')
380 })
381}
382
383fn dss_value_out(value: &str) -> (String, bool) {
393 if value.is_empty() {
396 return ("()".to_string(), true);
397 }
398 let mut scan = lex::Scanner::new(value, None);
399 let bare = scan.next_param().is_some_and(|p| {
400 p.name.is_none() && !p.value.quoted && p.value.text == value && scan.next_param().is_none()
401 });
402 if bare {
403 return (value.to_string(), true);
404 }
405 for (open, close) in [('(', ')'), ('[', ']'), ('{', '}'), ('"', '"'), ('\'', '\'')] {
406 if !value.contains(close) {
407 return (format!("{open}{value}{close}"), true);
408 }
409 }
410 (value.to_string(), false)
411}
412
413fn source_phases(net: &DistNetwork, vs: &crate::model::VoltageSource) -> usize {
419 if let Some(p) = extras_usize(&vs.extras, "phases") {
420 return p.max(1);
421 }
422 let energized = vs.v_magnitude.iter().filter(|&&v| v > 0.0).count();
423 if energized > 0
424 && vs.v_magnitude.len() == vs.terminal_map.len()
425 && energized + 1 == vs.v_magnitude.len()
426 && vs.v_magnitude.last().is_some_and(|&v| v == 0.0)
427 {
428 return energized;
429 }
430 let grounded = net
431 .buses
432 .iter()
433 .find(|b| b.id.eq_ignore_ascii_case(&vs.bus))
434 .map(|b| b.grounded.as_slice())
435 .unwrap_or_default();
436 vs.terminal_map
437 .iter()
438 .filter(|t| !grounded.contains(t))
439 .count()
440 .max(1)
441}
442
443fn seq_parts(extras: &Extras, key: &str) -> Option<(f64, f64)> {
445 let row = extras.get(key)?.as_array()?.first()?.as_array()?;
446 let self_v = row.first()?.as_f64()?;
447 let mutual = row
448 .get(1)
449 .and_then(serde_json::Value::as_f64)
450 .unwrap_or(0.0);
451 Some((self_v, mutual))
452}
453
454impl DssWriter {
455 fn warn(&mut self, msg: impl Into<String>) {
456 self.warnings.push(msg.into());
457 }
458
459 fn warn_map_arity(&mut self, class: &str, name: &str, map_len: usize, nconds: usize) {
467 if map_len < nconds {
468 self.warn(format!(
469 "{class} {name}: terminal map lists {map_len} of {nconds} conductors; \
470 dss materializes a grounded neutral terminal and the reparsed model \
471 gains one"
472 ));
473 } else if map_len > nconds {
474 self.warn(format!(
475 "{class} {name}: terminal map lists {map_len} conductors but the record \
476 addresses {nconds}; dss discards the last {} and the model loses them",
477 map_len - nconds
478 ));
479 }
480 }
481
482 fn return_terminal_index(&self, bus: &str, map: &[String]) -> Option<usize> {
486 let grounded = self.grounded.get(&bus.to_ascii_lowercase())?;
487 let mut found = map
488 .iter()
489 .enumerate()
490 .filter(|(_, t)| grounded.contains(*t));
491 let (idx, _) = found.next()?;
492 found.next().is_none().then_some(idx)
493 }
494
495 fn source_extra_f64(&mut self, vs: &crate::model::VoltageSource, key: &str) -> Option<f64> {
498 let v = vs.extras.get(key)?;
499 let parsed = v
500 .as_f64()
501 .or_else(|| v.as_str().and_then(|s| s.parse().ok()));
502 if parsed.is_none() {
503 self.warn(format!(
504 "vsource {}: {key} extra `{v}` does not parse as a number; \
505 using the derived value",
506 vs.name
507 ));
508 }
509 parsed
510 }
511
512 fn line_out(&mut self, s: &str) {
513 self.out.push_str(s);
514 self.out.push('\n');
515 }
516
517 fn check_name(&mut self, class: &str, name: &str) {
518 if name_breaks_dss(name, false) {
519 self.warn(format!(
520 "{class} `{name}`: name contains characters dss cannot represent; \
521 output will not reparse identically"
522 ));
523 }
524 }
525
526 fn bus_ref(&mut self, bus: &str, map: &[String]) -> String {
533 let key = bus.to_ascii_lowercase();
534 if name_breaks_dss(bus, true) {
535 self.warn(format!(
536 "bus `{bus}`: id contains characters dss cannot represent; \
537 output will not reparse identically"
538 ));
539 }
540 let grounded = self.grounded.get(&key).cloned();
541 let terminals = self.terminals.get(&key).cloned().unwrap_or_default();
542 let nodes: Vec<String> = map
543 .iter()
544 .enumerate()
545 .map(|(i, t)| {
546 if grounded.as_ref().is_some_and(|g| g.contains(t)) {
547 "0".to_string()
548 } else if t.parse::<u32>().is_ok() {
549 t.clone()
550 } else {
551 let pos = terminals.iter().position(|x| x == t).unwrap_or(i) + 1;
552 self.warn(format!(
553 "bus {bus}: terminal `{t}` is not a dss node number; \
554 emitted as node {pos}, its position on the bus"
555 ));
556 pos.to_string()
557 }
558 })
559 .collect();
560 if nodes.is_empty() {
561 bus.to_string()
562 } else {
563 format!("{bus}.{}", nodes.join("."))
564 }
565 }
566
567 fn extras_tail(&mut self, class: &str, name: &str, extras: &Extras) -> String {
570 let table = prop::class_by_name(class);
571 let mut tail = String::new();
572 for (key, value) in extras {
573 if matches!(key.as_str(), "bmopf_subtype") || key.starts_with("pmd_") {
574 continue; }
576 let known = table.is_some_and(|t| t.props.contains(&key.as_str()));
577 let text = value
578 .as_str()
579 .map(ToString::to_string)
580 .or_else(|| value.as_f64().map(num))
581 .or_else(|| value.as_i64().map(|v| v.to_string()));
582 match (known, text) {
583 (true, Some(text)) => {
584 let (out, representable) = dss_value_out(&text);
585 if !representable {
586 self.warn(format!(
587 "{class} {name}: extra `{key}` value `{text}` contains every \
588 dss quote closer and splits when scanned bare; emitted as \
589 written and a reparse will not see the same value"
590 ));
591 }
592 let _ = write!(tail, " {key}={out}");
593 }
594 _ => self.warn(format!(
595 "{class} {name}: extra `{key}` is not a dss property; dropped from the output"
596 )),
597 }
598 }
599 tail
600 }
601
602 fn matrix_arg(&mut self, m: &Mat, what: &str) -> String {
605 let mut short = false;
606 let rows: Vec<String> = m
607 .iter()
608 .enumerate()
609 .map(|(i, row)| {
610 let take = row.len().min(i + 1);
611 let mut vals: Vec<String> = row[..take].iter().map(|v| num(*v)).collect();
612 if take < i + 1 {
613 short = true;
614 vals.resize(i + 1, "0".to_string());
615 }
616 vals.join(" ")
617 })
618 .collect();
619 if short {
620 self.warn(format!(
621 "{what}: matrix rows are shorter than the lower triangle; \
622 missing entries emitted as 0"
623 ));
624 }
625 format!("({})", rows.join(" | "))
626 }
627
628 fn take_seq_pair(
631 &mut self,
632 extras: &mut Extras,
633 r_key: &str,
634 x_key: &str,
635 what: &str,
636 ) -> Option<((f64, f64), (f64, f64))> {
637 let r = seq_parts(extras, r_key);
638 let x = seq_parts(extras, x_key);
639 if let (Some(r), Some(x)) = (r, x) {
640 extras.remove(r_key);
641 extras.remove(x_key);
642 return Some((r, x));
643 }
644 if extras.contains_key(r_key) || extras.contains_key(x_key) {
645 let state = |key: &str, parsed: bool| {
646 if !extras.contains_key(key) {
647 format!("`{key}` is missing")
648 } else if parsed {
649 format!("`{key}` is usable")
650 } else {
651 format!("`{key}` is not a numeric matrix")
652 }
653 };
654 self.warn(format!(
655 "{what}: series impedance extras unusable ({}, {}); left in extras",
656 state(r_key, r.is_some()),
657 state(x_key, x.is_some()),
658 ));
659 }
660 None
661 }
662
663 fn element_phases(
667 &mut self,
668 extras: &Extras,
669 terminal_map: &[String],
670 configuration: Configuration,
671 class: &str,
672 name: &str,
673 ) -> usize {
674 if let Some(p) = extras_usize(extras, "phases") {
675 return p.max(1);
676 }
677 match configuration {
678 Configuration::Delta => match terminal_map.len() {
679 2 => 1,
680 3 => {
681 self.warn(format!(
682 "{class} {name}: a delta terminal map with 3 conductors is 2 or 3 \
683 phase and no phases record disambiguates; emitted phases=3"
684 ));
685 3
686 }
687 n => {
688 self.warn(format!(
689 "{class} {name}: a delta terminal map with {n} conductors has no \
690 dss phases mapping; emitted phases={}",
691 n.max(1)
692 ));
693 n.max(1)
694 }
695 },
696 Configuration::Wye => terminal_map.len().saturating_sub(1).max(1),
697 _ => 1,
698 }
699 }
700
701 fn network(&mut self, net: &DistNetwork) {
702 self.line_out("Clear");
703 self.line_out(&format!(
704 "Set DefaultBaseFrequency={}",
705 num(net.base_frequency)
706 ));
707 self.out.push('\n');
708
709 self.buscoords(net);
710 self.sources(net);
711 self.linecodes(net);
712 self.lines(net);
713 self.switches(net);
714 self.transformers(net);
715 self.loads(net);
716 self.shunts(net);
717 self.capacitors(net);
718 self.generators(net);
719 self.ibrs(net);
720
721 for u in &net.untyped {
722 self.warn(format!(
723 "{} {}: untyped object is not regenerated in canonical dss output",
724 u.class, u.name
725 ));
726 }
727 for b in &net.buses {
728 self.bus_extras(b);
729 }
730
731 self.out.push('\n');
732 for (key, value) in &net.options {
738 if key.is_empty() {
739 self.warn(format!(
740 "option `{value}` has no name; not regenerated in canonical dss output"
741 ));
742 continue;
743 }
744 let key_lc = key.to_ascii_lowercase();
753 if "voltagebases".starts_with(&key_lc)
754 || (key_lc.len() >= "defaultb".len() && "defaultbasefrequency".starts_with(&key_lc))
755 {
756 continue;
757 }
758 let (text, representable) = dss_value_out(value);
759 if !representable {
760 self.warn(format!(
761 "option `{key}`: value `{value}` contains every dss quote closer \
762 and splits when scanned bare; emitted as written and a reparse \
763 will not see the same value"
764 ));
765 }
766 self.line_out(&format!("Set {key}={text}"));
767 }
768 for (verb, args) in &net.commands {
769 if verb.eq_ignore_ascii_case("calcvoltagebases") || verb.eq_ignore_ascii_case("solve") {
770 continue; }
772 let shown = if args.is_empty() {
773 verb.clone()
774 } else {
775 format!("{verb} {args}")
776 };
777 self.warn(format!(
778 "command `{shown}` is not regenerated in canonical dss output"
779 ));
780 }
781 let mut bases: Vec<f64> = self
782 .kv_estimate
783 .values()
784 .map(|v| v * 3f64.sqrt() / 1e3)
785 .collect();
786 bases.sort_by(f64::total_cmp);
787 bases.dedup_by(|a, b| (*a - *b).abs() < 1e-9);
788 if !bases.is_empty() {
789 let list: Vec<String> = bases.iter().map(|v| num(*v)).collect();
790 self.line_out(&format!("Set VoltageBases=[{}]", list.join(", ")));
791 self.line_out("Calcvoltagebases");
792 }
793 self.line_out("Solve");
794 }
795
796 fn bus_extras(&mut self, b: &DistBus) {
797 for key in b.extras.keys() {
798 if key == "x" || key == "y" {
799 continue; }
801 self.warnings.push(format!(
802 "bus {}: extra `{key}` is not regenerated in canonical dss output",
803 b.id
804 ));
805 }
806 for (field, present) in [
807 ("v_min", b.v_min.is_some()),
808 ("v_max", b.v_max.is_some()),
809 ("vpn_min", b.vpn_min.is_some()),
810 ("vpn_max", b.vpn_max.is_some()),
811 ("vpp_min", b.vpp_min.is_some()),
812 ("vpp_max", b.vpp_max.is_some()),
813 ("vpos_min", b.vpos_min.is_some()),
814 ("vpos_max", b.vpos_max.is_some()),
815 ("vneg_max", b.vneg_max.is_some()),
816 ("vzero_max", b.vzero_max.is_some()),
817 ("vn_max", b.vn_max.is_some()),
818 ] {
819 if present {
820 self.warnings.push(format!(
821 "bus {}: `{field}` voltage bounds have no dss expression; dropped",
822 b.id
823 ));
824 }
825 }
826 }
827
828 fn buscoords(&mut self, net: &DistNetwork) {
829 let rows: Vec<(&DistBus, crate::geo::Location)> = net
830 .buses
831 .iter()
832 .filter_map(|b| b.location.map(|location| (b, location)))
833 .collect();
834 if rows.is_empty() {
835 return;
836 }
837 let Some(path) = self.options.buscoords_filename.clone() else {
838 self.warn("typed bus locations have no OpenDSS buscoords filename; dropped");
839 return;
840 };
841 if path.is_empty() {
842 self.warn("typed bus locations have an empty OpenDSS buscoords filename; dropped");
843 return;
844 }
845 let (path_out, path_representable) = dss_value_out(&path);
846 if !path_representable {
847 self.warn(format!(
848 "buscoords filename `{path}` contains every dss quote closer and splits when scanned bare; emitted as written and a reparse will not see the same value"
849 ));
850 }
851
852 let mut text = String::new();
853 for (bus, location) in rows {
854 if !location.x.is_finite() || !location.y.is_finite() {
855 self.warn(format!(
856 "bus {}: nonfinite location is not emitted to OpenDSS buscoords",
857 bus.id
858 ));
859 continue;
860 }
861 let (bus_out, bus_representable) = dss_value_out(&bus.id);
862 if !bus_representable {
863 self.warn(format!(
864 "bus {}: id contains every dss quote closer and splits in buscoords; coordinates dropped",
865 bus.id
866 ));
867 continue;
868 }
869 let _ = writeln!(text, "{bus_out},{},{}", num(location.x), num(location.y));
870 }
871 if text.is_empty() {
872 return;
873 }
874 self.line_out(&format!("Buscoords {path_out}"));
875 self.sidecars.push(ConversionSidecar { path, text });
876 }
877
878 fn sources(&mut self, net: &DistNetwork) {
879 let mut order: Vec<usize> = (0..net.sources.len()).collect();
880 if let Some(source_idx) = net
881 .sources
882 .iter()
883 .position(|vs| vs.name.eq_ignore_ascii_case("source"))
884 {
885 order.swap(0, source_idx);
886 }
887 for (i, source_idx) in order.into_iter().enumerate() {
888 let vs = &net.sources[source_idx];
889 let phases = source_phases(net, vs);
890 let energized = vs.v_magnitude.iter().filter(|&&v| v > 0.0).count();
891 if energized > 0 && energized != phases {
892 self.warn(format!(
893 "vsource {}: emitted phases={phases} but {energized} v_magnitude \
894 entries are positive; a reparse energizes all {phases}",
895 vs.name
896 ));
897 }
898 self.warn_map_arity("vsource", &vs.name, vs.terminal_map.len(), phases + 1);
899 let basekv = self
900 .source_extra_f64(vs, "basekv")
901 .unwrap_or_else(|| source_basekv(vs, phases));
902 let pu = self.source_extra_f64(vs, "pu").unwrap_or(1.0);
903 let angle = self
904 .source_extra_f64(vs, "angle")
905 .unwrap_or_else(|| vs.v_angle.first().copied().unwrap_or(0.0).to_degrees());
906 let head = if i == 0 {
907 let name = net.name.clone().unwrap_or_else(|| "converted".into());
908 self.check_name("circuit", &name);
909 format!("New Circuit.{name}")
910 } else {
911 self.check_name("vsource", &vs.name);
912 format!("New Vsource.{}", vs.name)
913 };
914 let mut s = format!(
915 "{head} basekv={} pu={} angle={} phases={phases} bus1={}",
916 num(basekv),
917 num(pu),
918 num(angle),
919 self.bus_ref(&vs.bus, &vs.terminal_map),
920 );
921 let mut extras = vs.extras.clone();
922 extras.remove("basekv");
923 extras.remove("pu");
924 extras.remove("angle");
925 extras.remove("phases"); let what = format!("vsource {}", vs.name);
930 if let Some(((rs, rm), (xs, xm))) = self.take_seq_pair(&mut extras, "rs", "xs", &what) {
931 let _ = write!(
934 s,
935 " z0=({}, {}) z1=({}, {})",
936 num(rs + 2.0 * rm),
937 num(xs + 2.0 * xm),
938 num(rs - rm),
939 num(xs - xm)
940 );
941 }
942 s.push_str(&self.extras_tail("vsource", &vs.name, &extras));
943 self.line_out(&s);
944 }
945 self.out.push('\n');
946 }
947
948 fn linecodes(&mut self, net: &DistNetwork) {
949 let omega_nf = std::f64::consts::TAU * net.base_frequency * 1e-9;
950 for c in &net.linecodes {
951 self.check_name("linecode", &c.name);
952 let n = c.n_conductors;
953 let what = format!("linecode {}", c.name);
954 let mut s = format!("New Linecode.{} nphases={n} units=m", c.name);
955 let rm = self.matrix_arg(&c.r_series, &what);
956 let _ = write!(s, " rmatrix={rm}");
957 let xm = self.matrix_arg(&c.x_series, &what);
958 let _ = write!(s, " xmatrix={xm}");
959 let c_nf: Mat = c
962 .b_from
963 .iter()
964 .map(|row| row.iter().map(|b| 2.0 * b / omega_nf).collect())
965 .collect();
966 let cm = self.matrix_arg(&c_nf, &what);
967 let _ = write!(s, " cmatrix={cm}");
968 match c.i_max.as_deref() {
969 Some([amps, ..]) if amps.is_finite() => {
970 let _ = write!(s, " emergamps={}", num(*amps));
971 }
972 Some([_, ..]) => self.warn(format!(
973 "linecode {}: first i_max entry is nonfinite (an unbounded \
974 conductor); emergamps not emitted",
975 c.name
976 )),
977 Some([]) => self.warn(format!(
978 "linecode {}: i_max is empty; emergamps not emitted",
979 c.name
980 )),
981 None => {}
982 }
983 if !c.g_from.iter().flatten().all(|&g| g == 0.0) {
984 self.warn(format!(
985 "linecode {}: shunt conductance has no dss linecode field; dropped",
986 c.name
987 ));
988 }
989 if c.source.is_some() {
990 self.warn(format!(
991 "linecode {}: matrix provenance `source` has no dss field; dropped",
992 c.name
993 ));
994 }
995 let mut extras = c.extras.clone();
996 extras.remove("units"); s.push_str(&self.extras_tail("linecode", &c.name, &extras));
998 self.line_out(&s);
999 }
1000 self.out.push('\n');
1001 }
1002
1003 fn lines(&mut self, net: &DistNetwork) {
1004 for l in &net.lines {
1005 self.check_name("line", &l.name);
1006 let phases = l.terminal_map_from.len();
1007 let mut s = format!(
1008 "New Line.{} bus1={} bus2={} phases={phases} linecode={} length={} units=m",
1009 l.name,
1010 self.bus_ref(&l.bus_from, &l.terminal_map_from),
1011 self.bus_ref(&l.bus_to, &l.terminal_map_to),
1012 l.linecode,
1013 self.checked_num(l.length, 1.0, &format!("line {}: length", l.name)),
1014 );
1015 let mut extras = l.extras.clone();
1016 extras.remove("units"); match l.i_max.as_deref() {
1020 Some([amps, rest @ ..]) if is_positive_finite(*amps) => {
1021 extras.remove("emergamps");
1022 let _ = write!(s, " emergamps={}", num(*amps));
1023 #[allow(clippy::float_cmp)]
1026 let uneven = rest.iter().any(|a| *a != *amps);
1027 if uneven {
1028 self.warn(format!(
1029 "line {}: i_max is not equal on all phases; emergamps \
1030 holds the first phase only",
1031 l.name
1032 ));
1033 }
1034 }
1035 Some([_, ..]) => self.warn(format!(
1036 "line {}: first i_max entry is nonfinite (an unbounded \
1037 conductor); emergamps not emitted",
1038 l.name
1039 )),
1040 Some([]) => self.warn(format!(
1041 "line {}: i_max is empty; emergamps not emitted",
1042 l.name
1043 )),
1044 None => {}
1045 }
1046 if l.s_max.is_some() {
1047 self.warn(format!(
1048 "line {}: `s_max` has no dss Line field; dropped",
1049 l.name
1050 ));
1051 }
1052 s.push_str(&self.extras_tail("line", &l.name, &extras));
1053 self.line_out(&s);
1054 }
1055 self.out.push('\n');
1056 }
1057
1058 fn switches(&mut self, net: &DistNetwork) {
1059 for sw in &net.switches {
1060 self.check_name("line", &sw.name);
1061 let phases = sw.terminal_map_from.len();
1062 let mut s = format!(
1063 "New Line.{} bus1={} bus2={} phases={phases} switch=y",
1064 sw.name,
1065 self.bus_ref(&sw.bus_from, &sw.terminal_map_from),
1066 self.bus_ref(&sw.bus_to, &sw.terminal_map_to),
1067 );
1068 match sw.i_max.as_deref() {
1069 Some([amps, ..]) if amps.is_finite() => {
1070 let _ = write!(s, " emergamps={}", num(*amps));
1071 }
1072 Some([_, ..]) => self.warn(format!(
1073 "line {}: first i_max entry is nonfinite (an unbounded \
1074 conductor); emergamps not emitted",
1075 sw.name
1076 )),
1077 Some([]) => self.warn(format!(
1078 "line {}: i_max is empty; emergamps not emitted",
1079 sw.name
1080 )),
1081 None => {}
1082 }
1083 let mut extras = sw.extras.clone();
1088 let what = format!("line {}", sw.name);
1089 if let Some(((rs, rm), (xs, xm))) =
1090 self.take_seq_pair(&mut extras, "pmd_rs", "pmd_xs", &what)
1091 {
1092 let _ = write!(
1093 s,
1094 " c0=0 c1=0 r0={} r1={} x0={} x1={}",
1095 num((rs + 2.0 * rm) / 0.001),
1096 num((rs - rm) / 0.001),
1097 num((xs + 2.0 * xm) / 0.001),
1098 num((xs - xm) / 0.001)
1099 );
1100 }
1101 s.push_str(&self.extras_tail("line", &sw.name, &extras));
1102 self.line_out(&s);
1103 self.line_out(&format!(
1104 "New SwtControl.{}_state SwitchedObj=Line.{} Action={}",
1105 sw.name,
1106 sw.name,
1107 if sw.open { "open" } else { "close" },
1108 ));
1109 }
1110 self.out.push('\n');
1111 }
1112
1113 fn transformers(&mut self, net: &DistNetwork) {
1114 for t in &net.transformers {
1115 self.check_name("transformer", &t.name);
1116 let nw = t.windings.len();
1117 let buses: Vec<String> = t
1118 .windings
1119 .iter()
1120 .map(|w| self.bus_ref(&w.bus, &w.terminal_map))
1121 .collect();
1122 let conns: Vec<&str> = t
1123 .windings
1124 .iter()
1125 .map(|w| match w.conn {
1126 WindingConn::Wye => "wye",
1127 WindingConn::Delta => "delta",
1128 })
1129 .collect();
1130 let kvs: Vec<Option<f64>> = t
1131 .windings
1132 .iter()
1133 .enumerate()
1134 .map(|(idx, w)| self.winding_kv(t, idx, w))
1135 .collect();
1136 let kvas: Vec<Option<f64>> = t
1137 .windings
1138 .iter()
1139 .enumerate()
1140 .map(|(idx, w)| {
1141 if is_positive_finite(w.s_rating) {
1142 Some(w.s_rating / 1e3)
1143 } else {
1144 self.warn(format!(
1145 "transformer {}: winding {} has no usable rating; kva not \
1146 emitted (the OpenDSS default applies)",
1147 t.name,
1148 idx + 1
1149 ));
1150 None
1151 }
1152 })
1153 .collect();
1154 let rs: Vec<String> = t
1155 .windings
1156 .iter()
1157 .enumerate()
1158 .map(|(idx, w)| {
1159 let what = format!("transformer {}: winding {} %r", t.name, idx + 1);
1160 self.checked_num(w.r_pct, 0.0, &what)
1161 })
1162 .collect();
1163 let taps: Vec<String> = t.windings.iter().map(|w| num(w.tap)).collect();
1164 let mut s = format!(
1165 "New Transformer.{} phases={} windings={nw} buses=({}) conns=({})",
1166 t.name,
1167 t.phases,
1168 buses.join(", "),
1169 conns.join(", "),
1170 );
1171 let mut edits: Vec<String> = vec![String::new(); nw];
1172 winding_array(&mut s, &mut edits, "kvs", "kv", &kvs);
1173 winding_array(&mut s, &mut edits, "kvas", "kva", &kvas);
1174 let _ = write!(s, " %Rs=({}) taps=({})", rs.join(", "), taps.join(", "));
1175 if let Some(xhl) = t.xsc_pct.first() {
1176 let _ = write!(s, " xhl={}", num(*xhl));
1177 if t.xsc_pct.len() >= 3 {
1178 let xlt = self.star_xlt(t);
1179 let _ = write!(s, " xht={} xlt={}", num(t.xsc_pct[1]), num(xlt));
1180 }
1181 } else {
1182 self.warn(format!(
1183 "transformer {}: xsc_pct is empty; emitted xhl=0",
1184 t.name
1185 ));
1186 s.push_str(" xhl=0");
1187 }
1188 s.push_str(&self.extras_tail("transformer", &t.name, &t.extras));
1189 self.line_out(&s);
1190 for (idx, w) in t.windings.iter().enumerate() {
1191 if let Some(r) = w.r_neutral {
1192 let _ = write!(edits[idx], " rneut={}", num(r));
1193 }
1194 if let Some(x) = w.x_neutral {
1195 let _ = write!(edits[idx], " xneut={}", num(x));
1196 }
1197 }
1198 for (idx, edit) in edits.iter().enumerate() {
1199 if !edit.is_empty() {
1200 self.line_out(&format!("~ wdg={}{edit}", idx + 1));
1201 }
1202 }
1203 }
1204 self.out.push('\n');
1205 }
1206
1207 fn star_xlt(&mut self, t: &DistTransformer) -> f64 {
1214 let (xhl, xht, xlt) = (t.xsc_pct[0], t.xsc_pct[1], t.xsc_pct[2]);
1215 if xlt > 0.0 && xlt.is_finite() {
1216 return xlt;
1217 }
1218 #[allow(clippy::float_cmp)]
1219 let lumped_on_primary = xhl == xht && xhl > 0.0 && xhl.is_finite();
1220 if !lumped_on_primary {
1221 self.warn(format!(
1222 "transformer {}: xlt={} is not a reactance dss can solve, and the \
1223 other two arms do not determine a replacement; emitted as stated",
1224 t.name,
1225 num(xlt)
1226 ));
1227 return xlt;
1228 }
1229 let repaired = 2.0 / 3.0 * xhl;
1230 self.warn(format!(
1231 "transformer {}: the source puts the whole leakage on the primary arm, \
1232 leaving xlt={}; dss solves that star as a collapsed secondary, so \
1233 xlt={} went out instead, holding xhl={}",
1234 t.name,
1235 num(xlt),
1236 num(repaired),
1237 num(xhl)
1238 ));
1239 repaired
1240 }
1241
1242 fn winding_kv(
1249 &mut self,
1250 t: &crate::model::DistTransformer,
1251 idx: usize,
1252 w: &Winding,
1253 ) -> Option<f64> {
1254 if is_positive_finite(w.v_ref) {
1255 return Some(w.v_ref / 1e3);
1256 }
1257 let bus = w.bus.to_ascii_lowercase();
1258 let scale =
1259 if winding_is_line_to_neutral(t.phases, w, |b| self.grounded.get(b).map(Vec::as_slice))
1260 {
1261 1.0
1262 } else {
1263 3f64.sqrt()
1264 };
1265 let Some(v_pn) = self.kv_estimate.get(&bus).copied() else {
1266 self.warn(format!(
1267 "transformer {}: winding {} has no rated voltage and bus `{}` has \
1268 no voltage estimate; kv not emitted (the OpenDSS default applies)",
1269 t.name,
1270 idx + 1,
1271 w.bus
1272 ));
1273 return None;
1274 };
1275 let kv = v_pn * scale / 1e3;
1276 self.warn(format!(
1277 "transformer {}: winding {} has no rated voltage; kv={} derived \
1278 from the bus `{}` voltage estimate",
1279 t.name,
1280 idx + 1,
1281 num(kv),
1282 w.bus
1283 ));
1284 Some(kv)
1285 }
1286
1287 fn load_parts<'l>(&mut self, l: &'l DistLoad) -> Vec<Cow<'l, DistLoad>> {
1298 let n = l.p_nom.len();
1299 #[allow(clippy::float_cmp)]
1303 let uniform = |xs: &[f64]| xs.iter().all(|x| *x == xs[0]);
1304 let stated_per_phase = n >= 2 && l.q_nom.len() == n;
1305 let unbalanced = stated_per_phase && !(uniform(&l.p_nom) && uniform(&l.q_nom));
1306 let return_index = self.return_terminal_index(&l.bus, &l.terminal_map);
1311 let misordered = return_index.is_some_and(|i| i + 1 != l.terminal_map.len());
1312 if !unbalanced && !misordered {
1313 return vec![Cow::Borrowed(l)];
1314 }
1315 if l.configuration == Configuration::Delta {
1316 self.warn(format!(
1317 "load {}: per phase power on a delta load has no dss expression; \
1318 emitted one balanced Load carrying the total",
1319 l.name
1320 ));
1321 return vec![Cow::Borrowed(l)];
1322 }
1323 let (hot_indices, return_terminal) = match return_index {
1326 Some(i) => (
1327 (0..l.terminal_map.len()).filter(|j| *j != i).collect(),
1328 Some(l.terminal_map[i].clone()),
1329 ),
1330 None if l.terminal_map.len() > n => ((0..n).collect(), Some(l.terminal_map[n].clone())),
1331 None => ((0..l.terminal_map.len()).collect::<Vec<_>>(), None),
1332 };
1333 if !stated_per_phase || hot_indices.len() != n {
1334 self.warn(format!(
1335 "load {}: {} over a terminal map with {} phase conductors; \
1336 emitted one Load carrying the total",
1337 l.name,
1338 if stated_per_phase {
1339 format!("per phase power over {n} phases")
1340 } else {
1341 "one power value".to_string()
1342 },
1343 hot_indices.len()
1344 ));
1345 return vec![Cow::Borrowed(l)];
1346 }
1347 hot_indices
1348 .into_iter()
1349 .enumerate()
1350 .map(|(i, hot)| {
1351 let mut part = l.clone();
1352 part.name = format!("{}_{}", l.name, l.terminal_map[hot]);
1353 part.terminal_map = match &return_terminal {
1354 Some(r) => vec![l.terminal_map[hot].clone(), r.clone()],
1355 None => vec![l.terminal_map[hot].clone()],
1356 };
1357 part.configuration = Configuration::Wye;
1358 part.p_nom = vec![l.p_nom[i]];
1359 part.q_nom = vec![l.q_nom[i]];
1360 for key in ["phases", "conn", "kv", "pf"] {
1365 part.extras.remove(key);
1366 }
1367 Cow::Owned(part)
1368 })
1369 .collect()
1370 }
1371
1372 fn loads(&mut self, net: &DistNetwork) {
1373 for load in &net.loads {
1374 for part in self.load_parts(load) {
1375 self.write_load(&part);
1376 }
1377 }
1378 self.out.push('\n');
1379 }
1380
1381 fn write_load(&mut self, l: &DistLoad) {
1384 self.check_name("load", &l.name);
1385 let phases =
1386 self.element_phases(&l.extras, &l.terminal_map, l.configuration, "load", &l.name);
1387 let conn = self.element_conn(&l.extras, l.configuration, &l.bus, &l.terminal_map);
1388 let nconds = nconds_for(conn, phases);
1391 self.warn_map_arity("load", &l.name, l.terminal_map.len(), nconds);
1392 let kw: f64 = l.p_nom.iter().sum::<f64>() / 1e3;
1393 let kvar: f64 = l.q_nom.iter().sum::<f64>() / 1e3;
1394 let typed_kv = self.load_nominal_kv(&l.voltage_model, phases, l.configuration, &l.name);
1395 let kv = self.element_kv(
1396 &l.extras,
1397 ElementKv {
1398 bus: &l.bus,
1399 phases,
1400 configuration: l.configuration,
1401 name: &l.name,
1402 class: "load",
1403 typed_kv,
1404 },
1405 );
1406 let mut extras = l.extras.clone();
1407 strip_emitted_extras(&mut extras, &["kv", "phases", "conn"]);
1408 let retained_model = extras.remove("model");
1409 let retained_zipv = extras.remove("zipv");
1410 let reactive = match extras.remove("pf").and_then(|v| v.as_f64()) {
1413 Some(pf) => format!("pf={}", num(pf)),
1414 None => format!("kvar={}", num(kvar)),
1415 };
1416 let mut s = format!(
1417 "New Load.{} bus1={} phases={phases} conn={conn} kv={} kw={} {reactive}",
1418 l.name,
1419 self.bus_ref(&l.bus, &l.terminal_map),
1420 num(kv),
1421 num(kw),
1422 );
1423 match &l.voltage_model {
1424 DistLoadVoltageModel::ConstantPower { .. } => {
1425 if let Some(model) = retained_model {
1426 extras.insert("model".into(), model);
1427 }
1428 }
1429 DistLoadVoltageModel::ConstantImpedance { .. } => {
1430 s.push_str(" model=2");
1431 }
1432 DistLoadVoltageModel::ConstantCurrent { .. } => {
1433 s.push_str(" model=5");
1434 }
1435 DistLoadVoltageModel::Zip {
1436 alpha_z,
1437 alpha_i,
1438 alpha_p,
1439 beta_z,
1440 beta_i,
1441 beta_p,
1442 ..
1443 } => {
1444 s.push_str(" model=8");
1445 if let (Some(az), Some(ai), Some(ap), Some(bz), Some(bi), Some(bp)) = (
1446 alpha_z.first(),
1447 alpha_i.first(),
1448 alpha_p.first(),
1449 beta_z.first(),
1450 beta_i.first(),
1451 beta_p.first(),
1452 ) {
1453 let cutoff = zipv_cutoff(retained_zipv.as_ref()).unwrap_or(0.0);
1454 let _ = write!(
1455 s,
1456 " zipv=({}, {}, {}, {}, {}, {}, {})",
1457 num(*az),
1458 num(*ai),
1459 num(*ap),
1460 num(*bz),
1461 num(*bi),
1462 num(*bp),
1463 num(cutoff)
1464 );
1465 }
1466 }
1467 DistLoadVoltageModel::Exponential { .. } => {
1468 self.warn(format!(
1469 "load {}: exponential voltage model has no OpenDSS load model code; emitted constant power",
1470 l.name
1471 ));
1472 }
1473 }
1474 self.add_default_load_voltage_bounds(&mut extras);
1475 s.push_str(&self.extras_tail("load", &l.name, &extras));
1476 self.line_out(&s);
1477 }
1478
1479 fn add_default_load_voltage_bounds(&self, extras: &mut Extras) {
1480 if let Some(bounds) = self.options.default_load_voltage_bounds {
1481 extras
1482 .entry("vminpu".into())
1483 .or_insert_with(|| bounds.vminpu.into());
1484 extras
1485 .entry("vmaxpu".into())
1486 .or_insert_with(|| bounds.vmaxpu.into());
1487 }
1488 }
1489
1490 fn checked_num(&mut self, v: f64, fallback: f64, what: &str) -> String {
1497 if v.is_finite() {
1498 return num(v);
1499 }
1500 self.warn(format!(
1501 "{what}: {v} has no dss spelling; emitted {}",
1502 num(fallback)
1503 ));
1504 num(fallback)
1505 }
1506
1507 fn element_kv(&mut self, extras: &Extras, ctx: ElementKv<'_>) -> f64 {
1508 if let Some(v) = extras.get("kv") {
1509 match v
1510 .as_f64()
1511 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
1512 {
1513 Some(kv) => return kv,
1514 None => self.warn(format!(
1515 "{} {}: kv extra `{v}` does not parse as a number; \
1516 using the bus voltage estimate",
1517 ctx.class, ctx.name
1518 )),
1519 }
1520 }
1521 if let Some(kv) = ctx.typed_kv {
1522 return kv;
1523 }
1524 if let Some(vln) = self.kv_estimate.get(&ctx.bus.to_ascii_lowercase()).copied() {
1525 let v = if ctx.phases >= 2 || ctx.configuration == Configuration::Delta {
1528 vln * 3f64.sqrt()
1529 } else {
1530 vln
1531 };
1532 v / 1e3
1533 } else {
1534 self.warn(format!(
1535 "{} {}: no kv in the source and no bus voltage estimate; \
1536 emitted 12.47",
1537 ctx.class, ctx.name
1538 ));
1539 12.47
1540 }
1541 }
1542
1543 fn load_nominal_kv(
1544 &mut self,
1545 model: &DistLoadVoltageModel,
1546 phases: usize,
1547 configuration: Configuration,
1548 name: &str,
1549 ) -> Option<f64> {
1550 let v_nom = model.v_nom();
1551 let v_phase = v_nom.first().copied().filter(|v| is_positive_finite(*v))?;
1552 if v_nom
1553 .iter()
1554 .any(|v| (*v - v_phase).abs() > 1e-9 * v.abs().max(v_phase.abs()).max(1.0))
1555 {
1556 self.warn(format!(
1557 "load {name}: nonuniform nominal voltage array has no OpenDSS scalar kv; emitted the first value"
1558 ));
1559 }
1560 let v = if phases >= 2 && configuration == Configuration::Wye {
1561 v_phase * 3f64.sqrt()
1562 } else {
1563 v_phase
1564 };
1565 Some(v / 1e3)
1566 }
1567
1568 fn element_conn(
1572 &self,
1573 extras: &Extras,
1574 configuration: Configuration,
1575 bus: &str,
1576 terminal_map: &[String],
1577 ) -> &'static str {
1578 let stash_delta = extras
1579 .get("conn")
1580 .and_then(|v| v.as_str())
1581 .is_some_and(|t| {
1582 t.to_ascii_lowercase().starts_with('d') || t.eq_ignore_ascii_case("ll")
1583 });
1584 let has_grounded_return = self
1585 .grounded
1586 .get(&bus.to_ascii_lowercase())
1587 .is_some_and(|g| terminal_map.iter().any(|t| g.contains(t)));
1588 match configuration {
1589 Configuration::Delta => "delta",
1590 Configuration::SinglePhase
1591 if stash_delta || (terminal_map.len() == 2 && !has_grounded_return) =>
1592 {
1593 "delta"
1594 }
1595 _ => "wye",
1596 }
1597 }
1598
1599 fn write_impedance_shunt(&mut self, sh: &crate::model::DistShunt, phases: usize) {
1600 self.check_name("reactor", &sh.name);
1601 let Some((conductance, susceptance)) = first_diag_admittance(&sh.g, &sh.b, phases) else {
1602 self.warn(format!(
1603 "shunt {}: conductance matrix has no diagonal admittance; dropped from the output",
1604 sh.name
1605 ));
1606 return;
1607 };
1608 if has_off_diagonal(&sh.g) || has_off_diagonal(&sh.b) {
1609 self.warn(format!(
1610 "shunt {}: off diagonal admittance has no scalar reactor expression; \
1611 only the first diagonal admittance is regenerated",
1612 sh.name
1613 ));
1614 }
1615 if !uniform_diag_admittance(&sh.g, &sh.b, phases, conductance, susceptance) {
1616 self.warn(format!(
1617 "shunt {}: diagonal admittances differ; only the first diagonal \
1618 admittance is regenerated",
1619 sh.name
1620 ));
1621 }
1622 let denom = conductance * conductance + susceptance * susceptance;
1623 if !denom.is_finite() || denom <= 0.0 {
1624 self.warn(format!(
1625 "shunt {}: invalid grounding admittance; dropped from the output",
1626 sh.name
1627 ));
1628 return;
1629 }
1630 let resistance = conductance / denom;
1631 let reactance = -susceptance / denom;
1632 let mut extras = sh.extras.clone();
1633 strip_shunt_extras(&mut extras);
1634 let ground = vec!["0".to_string(); phases.max(1)];
1635 let mut line = format!(
1636 "New Reactor.{} bus1={} bus2={} phases={} r={} x={}",
1637 sh.name,
1638 self.bus_ref(&sh.bus, &sh.terminal_map),
1639 self.bus_ref(&sh.bus, &ground),
1640 phases.max(1),
1641 num(resistance),
1642 num(reactance),
1643 );
1644 line.push_str(&self.extras_tail("reactor", &sh.name, &extras));
1645 self.line_out(&line);
1646 }
1647
1648 fn shunt_phases(
1649 &mut self,
1650 sh: &crate::model::DistShunt,
1651 conn_delta: bool,
1652 inferred_phases: usize,
1653 ) -> usize {
1654 if let Some(p) = extras_usize(&sh.extras, "phases") {
1655 p.max(1)
1656 } else if conn_delta {
1657 self.element_phases(
1658 &sh.extras,
1659 &sh.terminal_map,
1660 Configuration::Delta,
1661 "shunt",
1662 &sh.name,
1663 )
1664 } else {
1665 inferred_phases
1666 }
1667 }
1668
1669 fn write_kvar_shunt(&mut self, sh: &crate::model::DistShunt, phases: usize, conn_delta: bool) {
1670 let (b_max, b_min) = (0..sh.b.len())
1674 .map(|idx| diag_at(&sh.b, idx))
1675 .fold((0.0_f64, 0.0_f64), |(mx, mn), v| (mx.max(v), mn.min(v)));
1676 let (class, b_phase) = if b_max > 0.0 {
1677 ("capacitor", b_max)
1678 } else if b_min < 0.0 {
1679 ("reactor", b_min)
1680 } else {
1681 self.warn(format!(
1682 "shunt {}: no nonzero susceptance; dropped from the output",
1683 sh.name
1684 ));
1685 return;
1686 };
1687 if b_max > 0.0 && b_min < 0.0 {
1688 self.warn(format!(
1689 "shunt {}: diagonal mixes capacitive and inductive phases; only the \
1690 {class} phases are regenerated",
1691 sh.name
1692 ));
1693 }
1694 self.check_name(class, &sh.name);
1695 let off_diag = has_off_diagonal(&sh.b);
1696 if off_diag && !conn_delta {
1697 self.warn(format!(
1698 "shunt {}: off diagonal susceptance has no {class} expression; \
1699 only the diagonal is regenerated",
1700 sh.name
1701 ));
1702 }
1703 let edges = if conn_delta {
1704 delta_edges(sh.terminal_map.len(), phases)
1705 } else {
1706 Vec::new()
1707 };
1708 if conn_delta && edges.is_empty() {
1709 self.warn(format!(
1710 "shunt {}: delta terminal map has no branch expression; dropped from the output",
1711 sh.name
1712 ));
1713 return;
1714 }
1715 if conn_delta && delta_branch_susceptance(&sh.b, &edges, sh.terminal_map.len()).is_none() {
1716 self.warn(format!(
1717 "shunt {}: delta susceptance matrix has no scalar {class} expression; \
1718 only the average branch susceptance is regenerated",
1719 sh.name
1720 ));
1721 }
1722 let configuration = if conn_delta {
1723 Configuration::Delta
1724 } else {
1725 Configuration::Wye
1726 };
1727 let kv = self.element_kv(
1728 &sh.extras,
1729 ElementKv {
1730 bus: &sh.bus,
1731 phases,
1732 configuration,
1733 name: &sh.name,
1734 class,
1735 typed_kv: None,
1736 },
1737 );
1738 let kvar = extras_f64(&sh.extras, "kvar")
1739 .unwrap_or_else(|| shunt_kvar(sh, phases, conn_delta, &edges, b_phase, kv));
1740 let mut extras = sh.extras.clone();
1741 strip_shunt_extras(&mut extras);
1742 let conn = if conn_delta { "delta" } else { "wye" };
1743 let decl = if class == "reactor" {
1744 "Reactor"
1745 } else {
1746 "Capacitor"
1747 };
1748 let mut line = format!(
1749 "New {decl}.{} bus1={} phases={phases} conn={conn} kv={} kvar={}",
1750 sh.name,
1751 self.bus_ref(&sh.bus, &sh.terminal_map),
1752 num(kv),
1753 num(kvar),
1754 );
1755 line.push_str(&self.extras_tail(class, &sh.name, &extras));
1756 self.line_out(&line);
1757 }
1758
1759 fn capacitors(&mut self, net: &DistNetwork) {
1770 for c in &net.capacitors {
1771 if !is_positive_finite(c.q_rated) {
1772 self.warn(format!(
1773 "capacitor {}: rating {} is not a positive number; dropped from the output",
1774 c.name, c.q_rated
1775 ));
1776 continue;
1777 }
1778 self.check_name("capacitor", &c.name);
1779 let phases = self.element_phases(
1780 &c.extras,
1781 &c.terminal_map,
1782 c.configuration,
1783 "capacitor",
1784 &c.name,
1785 );
1786 let conn = self.element_conn(&c.extras, c.configuration, &c.bus, &c.terminal_map);
1787 let nconds = nconds_for(conn, phases);
1788 self.warn_map_arity("capacitor", &c.name, c.terminal_map.len(), nconds);
1789 let typed_kv = is_positive_finite(c.v_nom).then(|| c.v_nom / 1e3);
1790 if typed_kv.is_none() {
1791 self.warn(format!(
1792 "capacitor {}: nominal voltage {} is not a positive number; \
1793 using the bus voltage estimate",
1794 c.name, c.v_nom
1795 ));
1796 }
1797 let kv = self.element_kv(
1798 &c.extras,
1799 ElementKv {
1800 bus: &c.bus,
1801 phases,
1802 configuration: c.configuration,
1803 name: &c.name,
1804 class: "capacitor",
1805 typed_kv,
1806 },
1807 );
1808 let mut extras = c.extras.clone();
1809 strip_emitted_extras(&mut extras, &["kv", "phases", "conn", "kvar"]);
1810 let mut line = format!(
1811 "New Capacitor.{} bus1={} phases={phases} conn={conn} kv={} kvar={}",
1812 c.name,
1813 self.bus_ref(&c.bus, &c.terminal_map),
1814 num(kv),
1815 num(c.q_rated / 1e3),
1816 );
1817 line.push_str(&self.extras_tail("capacitor", &c.name, &extras));
1818 self.line_out(&line);
1819 }
1820 }
1821
1822 fn shunts(&mut self, net: &DistNetwork) {
1823 for sh in &net.shunts {
1824 let stashed_delta = shunt_stashed_delta(sh);
1825 let inferred_phases =
1826 extras_usize(&sh.extras, "phases").unwrap_or_else(|| sh.terminal_map.len().max(1));
1827 let conn_delta = stashed_delta
1828 || looks_like_delta_shunt(&sh.b, sh.terminal_map.len(), inferred_phases);
1829 let phases = self.shunt_phases(sh, conn_delta, inferred_phases);
1830 if has_nonzero(&sh.g) {
1831 self.write_impedance_shunt(sh, phases);
1832 } else {
1833 self.write_kvar_shunt(sh, phases, conn_delta);
1834 }
1835 }
1836 self.out.push('\n');
1837 }
1838
1839 fn generators(&mut self, net: &DistNetwork) {
1840 for g in &net.generators {
1841 self.check_name("generator", &g.name);
1842 let phases = self.element_phases(
1843 &g.extras,
1844 &g.terminal_map,
1845 g.configuration,
1846 "generator",
1847 &g.name,
1848 );
1849 let conn = self.element_conn(&g.extras, g.configuration, &g.bus, &g.terminal_map);
1850 let nconds = nconds_for(conn, phases);
1851 self.warn_map_arity("generator", &g.name, g.terminal_map.len(), nconds);
1852 let kw: f64 = g.p_nom.iter().sum::<f64>() / 1e3;
1853 let kvar: f64 = g.q_nom.iter().sum::<f64>() / 1e3;
1854 let kv = self.element_kv(
1855 &g.extras,
1856 ElementKv {
1857 bus: &g.bus,
1858 phases,
1859 configuration: g.configuration,
1860 name: &g.name,
1861 class: "generator",
1862 typed_kv: None,
1863 },
1864 );
1865 let mut s = format!(
1866 "New Generator.{} bus1={} phases={phases} conn={conn} kv={} kw={} kvar={}",
1867 g.name,
1868 self.bus_ref(&g.bus, &g.terminal_map),
1869 num(kv),
1870 num(kw),
1871 num(kvar),
1872 );
1873 if let Some(q) = &g.q_max {
1874 let _ = write!(s, " maxkvar={}", num(q.iter().sum::<f64>() / 1e3));
1875 }
1876 if let Some(q) = &g.q_min {
1877 let _ = write!(s, " minkvar={}", num(q.iter().sum::<f64>() / 1e3));
1878 }
1879 if g.cost.is_some() {
1880 self.warn(format!(
1881 "generator {}: generation cost has no dss field; dropped",
1882 g.name
1883 ));
1884 }
1885 for (key, present) in [("s_max", g.s_max.is_some()), ("i_max", g.i_max.is_some())] {
1888 if present {
1889 self.warn(format!(
1890 "generator {}: `{key}` has no dss Generator field mapping yet; dropped",
1891 g.name
1892 ));
1893 }
1894 }
1895 let mut extras = g.extras.clone();
1896 strip_emitted_extras(&mut extras, &["kv", "phases", "conn"]);
1897 s.push_str(&self.extras_tail("generator", &g.name, &extras));
1898 self.line_out(&s);
1899 }
1900 }
1901
1902 fn ibrs(&mut self, net: &DistNetwork) {
1903 for ibr in &net.ibrs {
1904 self.check_name("pvsystem", &ibr.name);
1905 if ibr_is_fixed_dispatch(ibr) {
1906 self.write_fixed_ibr_generator(ibr);
1907 } else {
1908 self.write_pvsystem(ibr, net);
1909 }
1910 }
1911 for ibr in &net.ibrs {
1912 if !ibr_is_fixed_dispatch(ibr) {
1913 self.write_ibr_controls(ibr, net);
1914 }
1915 }
1916 if !net.ibrs.is_empty() {
1917 self.out.push('\n');
1918 }
1919 }
1920
1921 fn write_fixed_ibr_generator(&mut self, ibr: &DistIbr) {
1922 let phases = ibr_phases(ibr);
1923 let configuration = ibr_configuration(ibr);
1924 let conn = self.element_conn(&ibr.extras, configuration, &ibr.bus, &ibr.terminal_map);
1925 let kv = self.ibr_kv(ibr, phases, configuration, "generator");
1926 let kw = ibr
1927 .p_min
1928 .as_ref()
1929 .map_or(0.0, |p| p.iter().sum::<f64>() / 1e3);
1930 let kvar = ibr
1931 .q_min
1932 .as_ref()
1933 .map_or(0.0, |q| q.iter().sum::<f64>() / 1e3);
1934 let mut line = format!(
1935 "New Generator.{} bus1={} phases={phases} conn={conn} kv={} kw={} kvar={} model=1 vminpu=0 vmaxpu=2",
1936 ibr.name,
1937 self.bus_ref(&ibr.bus, &ibr.terminal_map),
1938 num(kv),
1939 num(kw),
1940 num(kvar),
1941 );
1942 if let Some(q) = &ibr.q_max {
1943 let _ = write!(line, " maxkvar={}", num(q.iter().sum::<f64>() / 1e3));
1944 }
1945 if let Some(q) = &ibr.q_min {
1946 let _ = write!(line, " minkvar={}", num(q.iter().sum::<f64>() / 1e3));
1947 }
1948 self.warn_ibr_dss_drops(ibr);
1949 self.line_out(&line);
1950 }
1951
1952 fn write_pvsystem(&mut self, ibr: &DistIbr, net: &DistNetwork) {
1953 let phases = ibr_phases(ibr);
1954 let configuration = ibr_configuration(ibr);
1955 let conn = self.element_conn(&ibr.extras, configuration, &ibr.bus, &ibr.terminal_map);
1956 let kv = self.ibr_kv(ibr, phases, configuration, "pvsystem");
1957 let kva = ibr.s_max.iter().sum::<f64>() / 1e3;
1958 let pmpp = ibr
1959 .p_avail
1960 .or_else(|| ibr.p_max.as_ref().map(|p| p.iter().sum()))
1961 .unwrap_or(0.0)
1962 / 1e3;
1963 let mut line = format!(
1964 "New PVSystem.{} bus1={} phases={phases} conn={conn} kv={} kVA={} Pmpp={} irradiance=1 %Pmpp=100 WattPriority=No VarFollowInverter=Yes",
1965 ibr.name,
1966 self.bus_ref(&ibr.bus, &ibr.terminal_map),
1967 num(kv),
1968 num(kva),
1969 num(pmpp),
1970 );
1971 if let Some(q) = &ibr.q_max {
1972 let _ = write!(line, " kvarMax={}", num(q.iter().sum::<f64>() / 1e3));
1973 }
1974 if let Some(q) = &ibr.q_min {
1975 let _ = write!(
1976 line,
1977 " kvarMaxAbs={}",
1978 num(q.iter().map(|v| v.abs()).sum::<f64>() / 1e3)
1979 );
1980 }
1981 if let Some(profile) = ibr_profile(ibr, net) {
1982 if let Some(pf) = &profile.power_factor {
1983 let _ = write!(line, " pf={}", num(pf.pf));
1984 }
1985 if let Some(vv) = &profile.volt_var {
1986 if let Some(v) = vv.p_min_for_q {
1987 let _ = write!(line, " %PminNoVars={}", num(v));
1988 }
1989 if let Some(v) = vv.p_min_for_q_max {
1990 let _ = write!(line, " %PminkvarMax={}", num(v));
1991 }
1992 }
1993 }
1994 self.warn_ibr_dss_drops(ibr);
1995 self.line_out(&line);
1996 }
1997
1998 fn write_ibr_controls(&mut self, ibr: &DistIbr, net: &DistNetwork) {
1999 let Some(profile) = ibr_profile(ibr, net) else {
2000 if let Some(name) = &ibr.control_profile {
2001 self.warn(format!(
2002 "ibr {}: control_profile `{name}` not found; no InvControl emitted",
2003 ibr.name
2004 ));
2005 }
2006 return;
2007 };
2008 let phases = ibr_phases(ibr);
2009 let configuration = ibr_configuration(ibr);
2010 let kv = self.ibr_kv(ibr, phases, configuration, "pvsystem");
2011 let base_v = if phases >= 2 && configuration != Configuration::Delta {
2012 kv * 1e3 / 3f64.sqrt()
2013 } else {
2014 kv * 1e3
2015 };
2016 let mut curves = Vec::new();
2017 let mut has_vv = false;
2018 let mut has_vw = false;
2019 if let Some(vv) = &profile.volt_var
2020 && let Some(curve) = self.volt_var_curve(ibr, vv, base_v)
2021 {
2022 curves.push(curve);
2023 has_vv = true;
2024 }
2025 if let Some(vw) = &profile.volt_watt
2026 && let Some(curve) = self.volt_watt_curve(ibr, vw, base_v)
2027 {
2028 curves.push(curve);
2029 has_vw = true;
2030 }
2031 for line in &curves {
2032 self.line_out(line);
2033 }
2034 if !has_vv && !has_vw {
2035 return;
2036 }
2037 let mon = self.control_mon_voltage(ibr, profile);
2038 let inv_name = format!("ivc_{}", ibr.name);
2039 self.check_name("invcontrol", &inv_name);
2040 let mut line = format!(
2041 "New InvControl.{inv_name} DERList=[PVSystem.{}] voltage_curvex_ref=rated monVoltageCalc={mon}",
2042 ibr.name
2043 );
2044 match (has_vv, has_vw) {
2045 (true, true) => {
2046 let _ = write!(
2047 line,
2048 " CombiMode=VV_VW vvc_curve1=vv_{} voltwatt_curve=vw_{}",
2049 ibr.name, ibr.name
2050 );
2051 if let Some(vv) = &profile.volt_var {
2052 let _ = write!(
2053 line,
2054 " RefReactivePower={}",
2055 reactive_reference(vv.q_ref.unwrap_or(ReactivePowerReference::VarMax))
2056 );
2057 }
2058 if let Some(vw) = &profile.volt_watt {
2059 let _ = write!(
2060 line,
2061 " VoltwattYAxis={}",
2062 active_reference(vw.p_ref.unwrap_or(ActivePowerReference::SMax))
2063 );
2064 }
2065 }
2066 (true, false) => {
2067 line.push_str(" mode=VOLTVAR");
2068 let _ = write!(line, " vvc_curve1=vv_{}", ibr.name);
2069 if let Some(vv) = &profile.volt_var {
2070 let _ = write!(
2071 line,
2072 " RefReactivePower={}",
2073 reactive_reference(vv.q_ref.unwrap_or(ReactivePowerReference::VarMax))
2074 );
2075 }
2076 }
2077 (false, true) => {
2078 line.push_str(" mode=VOLTWATT");
2079 let _ = write!(line, " voltwatt_curve=vw_{}", ibr.name);
2080 if let Some(vw) = &profile.volt_watt {
2081 let _ = write!(
2082 line,
2083 " VoltwattYAxis={}",
2084 active_reference(vw.p_ref.unwrap_or(ActivePowerReference::SMax))
2085 );
2086 }
2087 }
2088 (false, false) => {}
2089 }
2090 self.line_out(&line);
2091 }
2092
2093 fn volt_var_curve(
2094 &mut self,
2095 ibr: &DistIbr,
2096 vv: &VoltVarControl,
2097 base_v: f64,
2098 ) -> Option<String> {
2099 self.check_control_reference(ibr, vv.voltage_reference)?;
2100 if !matches!(
2101 vv.q_unit,
2102 None | Some(crate::model::ReactivePowerUnit::VaFraction)
2103 ) {
2104 self.warn(format!(
2105 "ibr {}: volt_var q_unit is absolute VAR; DSS export only maps VA_FRACTION",
2106 ibr.name
2107 ));
2108 return None;
2109 }
2110 if vv.breakpoints.len() < 4 || vv.q_limits.len() < 2 || base_v <= 0.0 {
2111 self.warn(format!(
2112 "ibr {}: volt_var profile is incomplete; no XYcurve emitted",
2113 ibr.name
2114 ));
2115 return None;
2116 }
2117 let xs: Vec<String> = vv
2118 .breakpoints
2119 .iter()
2120 .take(4)
2121 .map(|v| num(v / base_v))
2122 .collect();
2123 let ys = [num(vv.q_limits[1]), num(0.0), num(0.0), num(vv.q_limits[0])];
2124 Some(format!(
2125 "New XYcurve.vv_{} npts=4 Xarray=[{}] Yarray=[{}]",
2126 ibr.name,
2127 xs.join(" "),
2128 ys.join(" ")
2129 ))
2130 }
2131
2132 fn volt_watt_curve(
2133 &mut self,
2134 ibr: &DistIbr,
2135 vw: &VoltWattControl,
2136 base_v: f64,
2137 ) -> Option<String> {
2138 self.check_control_reference(ibr, vw.voltage_reference)?;
2139 if !matches!(
2140 vw.p_unit,
2141 None | Some(crate::model::ActivePowerUnit::VaFraction)
2142 ) {
2143 self.warn(format!(
2144 "ibr {}: volt_watt p_unit is absolute W; DSS export only maps VA_FRACTION",
2145 ibr.name
2146 ));
2147 return None;
2148 }
2149 if vw.breakpoints.len() < 2 || vw.p_limits.len() < 2 || base_v <= 0.0 {
2150 self.warn(format!(
2151 "ibr {}: volt_watt profile is incomplete; no XYcurve emitted",
2152 ibr.name
2153 ));
2154 return None;
2155 }
2156 let xs: Vec<String> = vw
2157 .breakpoints
2158 .iter()
2159 .take(2)
2160 .map(|v| num(v / base_v))
2161 .collect();
2162 let ys = [num(vw.p_limits[1]), num(vw.p_limits[0])];
2163 Some(format!(
2164 "New XYcurve.vw_{} npts=2 Xarray=[{}] Yarray=[{}]",
2165 ibr.name,
2166 xs.join(" "),
2167 ys.join(" ")
2168 ))
2169 }
2170
2171 fn check_control_reference(
2172 &mut self,
2173 ibr: &DistIbr,
2174 reference: Option<ControlVoltageReference>,
2175 ) -> Option<()> {
2176 match reference.unwrap_or(ControlVoltageReference::PnPerPhase) {
2177 ControlVoltageReference::PgPerPhase | ControlVoltageReference::PgAveraged => Some(()),
2178 ControlVoltageReference::PnPerPhase | ControlVoltageReference::PnAveraged => {
2179 self.warn(format!(
2180 "ibr {}: PN voltage control is approximated by OpenDSS phase-to-ground InvControl",
2181 ibr.name
2182 ));
2183 Some(())
2184 }
2185 ControlVoltageReference::PpPerPhase | ControlVoltageReference::PpAveraged => {
2186 self.warn(format!(
2187 "ibr {}: PP voltage control is not representable by OpenDSS InvControl; skipped",
2188 ibr.name
2189 ));
2190 None
2191 }
2192 }
2193 }
2194
2195 fn control_mon_voltage(&mut self, ibr: &DistIbr, profile: &DistControlProfile) -> &'static str {
2196 let reference = profile
2197 .volt_var
2198 .as_ref()
2199 .and_then(|vv| vv.voltage_reference)
2200 .or_else(|| {
2201 profile
2202 .volt_watt
2203 .as_ref()
2204 .and_then(|vw| vw.voltage_reference)
2205 })
2206 .unwrap_or(ControlVoltageReference::PnPerPhase);
2207 let averaged = matches!(
2208 ibr.voltage_aggregation,
2209 Some(IbrVoltageAggregation::Average)
2210 ) || matches!(
2211 reference,
2212 ControlVoltageReference::PgAveraged
2213 | ControlVoltageReference::PnAveraged
2214 | ControlVoltageReference::PpAveraged
2215 );
2216 if !averaged && ibr_phases(ibr) > 1 {
2217 self.warn(format!(
2218 "ibr {}: per phase InvControl needs split PVSystems; emitted AVG monitor",
2219 ibr.name
2220 ));
2221 }
2222 "AVG"
2223 }
2224
2225 fn ibr_kv(
2226 &mut self,
2227 ibr: &DistIbr,
2228 phases: usize,
2229 configuration: Configuration,
2230 class: &'static str,
2231 ) -> f64 {
2232 self.element_kv(
2233 &ibr.extras,
2234 ElementKv {
2235 bus: &ibr.bus,
2236 phases,
2237 configuration,
2238 name: &ibr.name,
2239 class,
2240 typed_kv: None,
2241 },
2242 )
2243 }
2244
2245 fn warn_ibr_dss_drops(&mut self, ibr: &DistIbr) {
2246 for key in ibr.extras.keys() {
2247 if matches!(key.as_str(), "kv" | "phases") {
2248 continue;
2249 }
2250 self.warn(format!(
2251 "ibr {}: `{key}` has no OpenDSS export mapping; dropped",
2252 ibr.name
2253 ));
2254 }
2255 if ibr.i_max.is_some() {
2256 self.warn(format!(
2257 "ibr {}: i_max has no OpenDSS PVSystem current limit field; dropped",
2258 ibr.name
2259 ));
2260 }
2261 if !matches!(ibr.prime_mover, IbrPrimeMover::Pv | IbrPrimeMover::Generic) {
2262 self.warn(format!(
2263 "ibr {}: prime_mover {:?} has no dedicated OpenDSS export path; emitted with the generic inverter mapping",
2264 ibr.name, ibr.prime_mover
2265 ));
2266 }
2267 }
2268}
2269
2270fn strip_shunt_extras(extras: &mut Extras) {
2273 for key in ["kv", "kvar", "phases", "conn"] {
2274 extras.remove(key);
2275 }
2276}
2277
2278fn ibr_is_fixed_dispatch(ibr: &DistIbr) -> bool {
2279 ibr.control_profile.is_none()
2280 && matches!((&ibr.p_min, &ibr.p_max), (Some(a), Some(b)) if a == b)
2281 && matches!((&ibr.q_min, &ibr.q_max), (Some(a), Some(b)) if a == b)
2282}
2283
2284fn ibr_profile<'a>(ibr: &DistIbr, net: &'a DistNetwork) -> Option<&'a DistControlProfile> {
2285 let name = ibr.control_profile.as_ref()?;
2286 net.control_profiles
2287 .iter()
2288 .find(|profile| profile.name.eq_ignore_ascii_case(name))
2289}
2290
2291fn ibr_phases(ibr: &DistIbr) -> usize {
2292 match ibr.topology {
2293 IbrTopology::SinglePhase => 1,
2294 IbrTopology::ThreeLeg | IbrTopology::FourLeg => 3,
2295 }
2296}
2297
2298fn ibr_configuration(ibr: &DistIbr) -> Configuration {
2299 match ibr.topology {
2300 IbrTopology::SinglePhase => Configuration::SinglePhase,
2301 IbrTopology::ThreeLeg => Configuration::Delta,
2302 IbrTopology::FourLeg => Configuration::Wye,
2303 }
2304}
2305
2306fn reactive_reference(reference: ReactivePowerReference) -> &'static str {
2307 match reference {
2308 ReactivePowerReference::VarMax => "VARMAX",
2309 ReactivePowerReference::VarAvailable => "VARAVAL_WATTS",
2310 }
2311}
2312
2313fn active_reference(reference: ActivePowerReference) -> &'static str {
2314 match reference {
2315 ActivePowerReference::SMax => "KVARATINGPU",
2316 ActivePowerReference::PAvailable => "PAVAILABLEPU",
2317 ActivePowerReference::PMax => "PMPPPU",
2318 }
2319}
2320
2321fn has_nonzero(m: &Mat) -> bool {
2322 m.iter().flatten().any(|&v| v != 0.0)
2323}
2324
2325fn has_off_diagonal(m: &Mat) -> bool {
2326 m.iter()
2327 .enumerate()
2328 .any(|(i, row)| row.iter().enumerate().any(|(j, &v)| i != j && v != 0.0))
2329}
2330
2331fn diag_at(m: &Mat, i: usize) -> f64 {
2332 m.get(i).and_then(|row| row.get(i)).copied().unwrap_or(0.0)
2333}
2334
2335fn matrix_scale(m: &Mat) -> f64 {
2336 m.iter().flatten().fold(0.0_f64, |acc, &v| acc.max(v.abs()))
2337}
2338
2339fn close(a: f64, b: f64, scale: f64) -> bool {
2340 (a - b).abs() <= 1e-12_f64.max(scale * 1e-9)
2341}
2342
2343fn first_diag_admittance(g: &Mat, b: &Mat, phases: usize) -> Option<(f64, f64)> {
2344 (0..phases.max(1)).find_map(|i| {
2345 let gi = diag_at(g, i);
2346 let bi = diag_at(b, i);
2347 (gi != 0.0 || bi != 0.0).then_some((gi, bi))
2348 })
2349}
2350
2351fn uniform_diag_admittance(g: &Mat, b: &Mat, phases: usize, g0: f64, b0: f64) -> bool {
2352 let scale = matrix_scale(g)
2353 .max(matrix_scale(b))
2354 .max(g0.abs())
2355 .max(b0.abs());
2356 (0..phases.max(1)).all(|i| close(diag_at(g, i), g0, scale) && close(diag_at(b, i), b0, scale))
2357}
2358
2359fn shunt_stashed_delta(sh: &crate::model::DistShunt) -> bool {
2360 sh.extras
2361 .get("conn")
2362 .and_then(|v| v.as_str())
2363 .is_some_and(|t| t.to_ascii_lowercase().starts_with('d') || t.eq_ignore_ascii_case("ll"))
2364}
2365
2366fn mat_at(m: &Mat, i: usize, j: usize) -> f64 {
2367 m.get(i).and_then(|row| row.get(j)).copied().unwrap_or(0.0)
2368}
2369
2370fn looks_like_delta_shunt(b: &Mat, terminals: usize, phases: usize) -> bool {
2371 if terminals < 2 || !has_off_diagonal(b) {
2372 return false;
2373 }
2374 let edges = delta_edges(terminals, phases);
2375 delta_branch_susceptance(b, &edges, terminals).is_some()
2376}
2377
2378fn delta_branch_abs(b: &Mat, edges: &[(usize, usize)]) -> Option<f64> {
2379 if edges.is_empty() {
2380 return None;
2381 }
2382 let total: f64 = edges
2387 .iter()
2388 .map(|&(i, j)| {
2389 b.get(i)
2390 .and_then(|row| row.get(j))
2391 .copied()
2392 .unwrap_or(0.0)
2393 .abs()
2394 })
2395 .sum();
2396 Some(total / edges.len() as f64)
2397}
2398
2399fn delta_branch_susceptance(b: &Mat, edges: &[(usize, usize)], terminals: usize) -> Option<f64> {
2400 if terminals < 2 || edges.is_empty() {
2401 return None;
2402 }
2403 let scale = matrix_scale(b);
2404 if scale == 0.0 {
2405 return None;
2406 }
2407 let first = edges[0];
2408 let branch = -mat_at(b, first.0, first.1);
2409 if branch == 0.0 {
2410 return None;
2411 }
2412 let scale = scale.max(branch.abs());
2413 for (i, row) in b.iter().enumerate() {
2414 for (j, &value) in row.iter().enumerate() {
2415 if (i >= terminals || j >= terminals) && !close(value, 0.0, scale) {
2416 return None;
2417 }
2418 }
2419 }
2420 for i in 0..terminals {
2421 let incident = edges
2422 .iter()
2423 .filter(|&&(from, to)| from == i || to == i)
2424 .count() as f64;
2425 for j in 0..terminals {
2426 let linked = edges
2427 .iter()
2428 .any(|&(from, to)| (from == i && to == j) || (from == j && to == i));
2429 let expected = if i == j {
2430 incident * branch
2431 } else if linked {
2432 -branch
2433 } else {
2434 0.0
2435 };
2436 if !close(mat_at(b, i, j), expected, scale) {
2437 return None;
2438 }
2439 }
2440 }
2441 Some(branch)
2442}
2443
2444fn shunt_kvar(
2445 sh: &crate::model::DistShunt,
2446 phases: usize,
2447 conn_delta: bool,
2448 edges: &[(usize, usize)],
2449 b_phase: f64,
2450 kv: f64,
2451) -> f64 {
2452 if conn_delta {
2453 let b_branch = delta_branch_abs(&sh.b, edges).unwrap_or(b_phase.abs());
2454 b_branch * (kv * 1e3) * (kv * 1e3) * edges.len() as f64 / 1e3
2455 } else {
2456 let v_phase = if matches!(phases, 2 | 3) {
2457 kv * 1e3 / 3f64.sqrt()
2458 } else {
2459 kv * 1e3
2460 };
2461 b_phase.abs() * v_phase * v_phase * phases as f64 / 1e3
2462 }
2463}
2464
2465#[cfg(test)]
2466mod tests {
2467 use super::super::read::parse_dss_str;
2468 use super::*;
2469 use crate::model::{
2470 ControlVoltageReference, DistControlProfile, DistGenerator, DistIbr, DistLine,
2471 DistLineCode, DistLoad, DistShunt, DistSwitch, DistTransformer, IbrPrimeMover, IbrTopology,
2472 ReactivePowerReference, ReactivePowerUnit, VoltVarControl, VoltageSource, Winding,
2473 };
2474
2475 fn strings(v: &[&str]) -> Vec<String> {
2476 v.iter().map(ToString::to_string).collect()
2477 }
2478
2479 fn bus(id: &str, terminals: &[&str], grounded: &[&str]) -> DistBus {
2480 DistBus {
2481 id: id.into(),
2482 terminals: strings(terminals),
2483 grounded: strings(grounded),
2484 ..DistBus::default()
2485 }
2486 }
2487
2488 fn center_tap_service(vln: f64) -> (DistBus, VoltageSource, DistBus) {
2491 let (mut source, vs) = three_phase_source(vln);
2492 source.id = "sb".into();
2493 (source, vs, bus("lv", &["p1", "n", "p2"], &["n"]))
2494 }
2495
2496 fn three_phase_source(vln: f64) -> (DistBus, VoltageSource) {
2497 let third = 2.0 * std::f64::consts::FRAC_PI_3;
2498 (
2499 bus("sb", &["1", "2", "3", "4"], &["4"]),
2500 VoltageSource {
2501 name: "source".into(),
2502 bus: "sb".into(),
2503 terminal_map: strings(&["1", "2", "3", "4"]),
2504 v_magnitude: vec![vln, vln, vln, 0.0],
2505 v_angle: vec![0.0, -third, third, 0.0],
2506 extras: Extras::new(),
2507 },
2508 )
2509 }
2510
2511 fn load_on(bus: &str, map: &[&str], configuration: Configuration) -> DistLoad {
2512 let phases = map.len();
2513 DistLoad {
2514 name: "ld".into(),
2515 bus: bus.into(),
2516 terminal_map: strings(map),
2517 configuration,
2518 p_nom: vec![1e3; phases],
2519 q_nom: vec![0.0; phases],
2520 voltage_model: DistLoadVoltageModel::ConstantPower { v_nom: Vec::new() },
2521 extras: Extras::from([("kv".to_string(), serde_json::json!("0.4"))]),
2522 }
2523 }
2524
2525 fn roundtrip(net: &DistNetwork) -> (String, String) {
2526 let first = write_dss(net);
2527 let second = write_dss(&parse_dss_str(&first.text));
2528 (first.text, second.text)
2529 }
2530
2531 #[test]
2532 fn constant_power_loads_get_wide_voltage_bounds_by_default() {
2533 let (b, vs) = three_phase_source(2400.0);
2534 let load = load_on("sb", &["1"], Configuration::Wye);
2535 let net = DistNetwork {
2536 base_frequency: 60.0,
2537 buses: vec![b],
2538 sources: vec![vs],
2539 loads: vec![load],
2540 ..DistNetwork::default()
2541 };
2542 let out = write_dss(&net);
2543 let line = out.text.lines().find(|l| l.contains("Load.ld")).unwrap();
2544 assert!(line.contains("vminpu=0"), "{line}");
2545 assert!(line.contains("vmaxpu=2"), "{line}");
2546 }
2547
2548 #[test]
2549 fn explicit_load_voltage_bounds_are_preserved() {
2550 let (b, vs) = three_phase_source(2400.0);
2551 let mut load = load_on("sb", &["1"], Configuration::Wye);
2552 load.extras.insert("vminpu".into(), serde_json::json!(0.8));
2553 load.extras.insert("vmaxpu".into(), serde_json::json!(1.2));
2554 let net = DistNetwork {
2555 base_frequency: 60.0,
2556 buses: vec![b],
2557 sources: vec![vs],
2558 loads: vec![load],
2559 ..DistNetwork::default()
2560 };
2561 let out = write_dss(&net);
2562 let line = out.text.lines().find(|l| l.contains("Load.ld")).unwrap();
2563 assert!(line.contains("vminpu=0.8"), "{line}");
2564 assert!(line.contains("vmaxpu=1.2"), "{line}");
2565 }
2566
2567 #[test]
2568 fn default_load_voltage_bounds_can_be_disabled() {
2569 let (b, vs) = three_phase_source(2400.0);
2570 let load = load_on("sb", &["1"], Configuration::Wye);
2571 let net = DistNetwork {
2572 base_frequency: 60.0,
2573 buses: vec![b],
2574 sources: vec![vs],
2575 loads: vec![load],
2576 ..DistNetwork::default()
2577 };
2578 let options = DssWriteOptions {
2579 default_load_voltage_bounds: None,
2580 ..DssWriteOptions::default()
2581 };
2582 let out = write_dss_with_options(&net, &options);
2583 let line = out.text.lines().find(|l| l.contains("Load.ld")).unwrap();
2584 assert!(!line.contains("vminpu="), "{line}");
2585 assert!(!line.contains("vmaxpu="), "{line}");
2586 }
2587
2588 #[test]
2589 fn voltage_bases_survive_the_sqrt_round_trip() {
2590 let vln = 9_336.235_056_420_312_f64;
2594 let basekv = vln * 3f64.sqrt() / 1e3;
2595 assert!(
2596 (basekv * 1e3 / 3f64.sqrt()).to_bits() != vln.to_bits(),
2597 "test value no longer reproduces the drift"
2598 );
2599 let (b, vs) = three_phase_source(vln);
2600 let net = DistNetwork {
2601 name: Some("t".into()),
2602 base_frequency: 60.0,
2603 buses: vec![b],
2604 sources: vec![vs],
2605 ..DistNetwork::default()
2606 };
2607 let (first, second) = roundtrip(&net);
2608 assert!(first.contains("Set VoltageBases="), "{first}");
2609 assert_eq!(first, second);
2610 }
2611
2612 #[test]
2613 fn load_phases_prefer_the_reader_stash() {
2614 let (b, vs) = three_phase_source(2400.0);
2615 let mut load = load_on("sb", &["1", "2", "3"], Configuration::Delta);
2616 load.extras.insert("phases".into(), serde_json::json!("2"));
2617 let net = DistNetwork {
2618 base_frequency: 60.0,
2619 buses: vec![b],
2620 sources: vec![vs],
2621 loads: vec![load],
2622 ..DistNetwork::default()
2623 };
2624 let out = write_dss(&net);
2625 let line = out.text.lines().find(|l| l.contains("Load.ld")).unwrap();
2626 assert!(line.contains("phases=2 conn=delta"), "{line}");
2627 assert_eq!(line.matches("phases=").count(), 1, "{line}");
2629 assert!(!out.warnings.iter().any(|w| w.contains("2 or 3 phase")));
2630 }
2631
2632 #[test]
2633 fn ambiguous_delta_keeps_three_phases_loudly() {
2634 let (b, vs) = three_phase_source(2400.0);
2635 let net = DistNetwork {
2636 base_frequency: 60.0,
2637 buses: vec![b],
2638 sources: vec![vs],
2639 loads: vec![load_on("sb", &["1", "2", "3"], Configuration::Delta)],
2640 ..DistNetwork::default()
2641 };
2642 let out = write_dss(&net);
2643 let line = out.text.lines().find(|l| l.contains("Load.ld")).unwrap();
2644 assert!(line.contains("phases=3 conn=delta"), "{line}");
2645 assert!(
2646 out.warnings.iter().any(|w| w.contains("2 or 3 phase")),
2647 "{:?}",
2648 out.warnings
2649 );
2650 }
2651
2652 #[test]
2653 fn single_phase_delta_emits_conn_delta() {
2654 let (b, vs) = three_phase_source(2400.0);
2655 let two_wire = load_on("sb", &["1", "2"], Configuration::Delta);
2657 let mut stashed = load_on("sb", &["1", "2"], Configuration::SinglePhase);
2660 stashed.name = "ld2".into();
2661 stashed
2662 .extras
2663 .insert("conn".into(), serde_json::json!("delta"));
2664 let net = DistNetwork {
2665 base_frequency: 60.0,
2666 buses: vec![b],
2667 sources: vec![vs],
2668 loads: vec![two_wire, stashed],
2669 ..DistNetwork::default()
2670 };
2671 let out = write_dss(&net);
2672 let l1 = out.text.lines().find(|l| l.contains("Load.ld ")).unwrap();
2673 assert!(l1.contains("phases=1 conn=delta"), "{l1}");
2674 let l2 = out.text.lines().find(|l| l.contains("Load.ld2 ")).unwrap();
2675 assert!(l2.contains("phases=1 conn=delta"), "{l2}");
2676 assert_eq!(l2.matches("conn=").count(), 1, "{l2}");
2677 }
2678
2679 #[test]
2680 fn unrepresentable_names_are_reported() {
2681 let (b, vs) = three_phase_source(2400.0);
2682 let mut load = load_on("sb", &["1", "2", "3", "4"], Configuration::Wye);
2683 load.name = "load 1".into();
2684 let net = DistNetwork {
2685 name: Some("my circuit".into()),
2686 base_frequency: 60.0,
2687 buses: vec![b, bus("a=b", &["1"], &[])],
2688 sources: vec![vs],
2689 loads: vec![load],
2690 ..DistNetwork::default()
2691 };
2692 let out = write_dss(&net);
2693 let hits = |needle: &str| {
2694 out.warnings
2695 .iter()
2696 .any(|w| w.contains(needle) && w.contains("cannot represent"))
2697 };
2698 assert!(hits("load 1"), "{:?}", out.warnings);
2699 assert!(hits("my circuit"), "{:?}", out.warnings);
2700 let mut net2 = net.clone();
2702 net2.lines.push(DistLine {
2703 name: "l1".into(),
2704 bus_from: "sb".into(),
2705 bus_to: "a=b".into(),
2706 terminal_map_from: strings(&["1"]),
2707 terminal_map_to: strings(&["1"]),
2708 linecode: "lc".into(),
2709 length: 1.0,
2710 route: None,
2711 i_max: None,
2712 s_max: None,
2713 extras: Extras::new(),
2714 });
2715 let out2 = write_dss(&net2);
2716 assert!(
2717 out2.warnings
2718 .iter()
2719 .any(|w| w.contains("a=b") && w.contains("cannot represent")),
2720 "{:?}",
2721 out2.warnings
2722 );
2723 }
2724
2725 #[test]
2726 fn unequal_per_phase_i_max_warns_that_emergamps_holds_one_phase() {
2727 let (b, vs) = three_phase_source(2400.0);
2728 let net = DistNetwork {
2729 base_frequency: 60.0,
2730 buses: vec![b, bus("b2", &["1", "2", "3"], &[])],
2731 sources: vec![vs],
2732 lines: vec![DistLine {
2733 name: "l1".into(),
2734 bus_from: "sb".into(),
2735 bus_to: "b2".into(),
2736 terminal_map_from: strings(&["1", "2", "3"]),
2737 terminal_map_to: strings(&["1", "2", "3"]),
2738 linecode: "lc".into(),
2739 length: 1.0,
2740 route: None,
2741 i_max: Some(vec![400.0, 300.0, 200.0]),
2742 s_max: None,
2743 extras: Extras::new(),
2744 }],
2745 ..DistNetwork::default()
2746 };
2747 let out = write_dss(&net);
2748 let line = out.text.lines().find(|l| l.contains("Line.l1 ")).unwrap();
2749 assert!(line.contains("emergamps=400"), "{line}");
2750 assert!(
2751 out.warnings
2752 .iter()
2753 .any(|w| w.contains("line l1") && w.contains("not equal on all phases")),
2754 "{:?}",
2755 out.warnings
2756 );
2757 }
2758
2759 #[test]
2760 fn line_level_i_max_emits_emergamps_and_s_max_drops_with_a_warning() {
2761 let (b, vs) = three_phase_source(2400.0);
2762 let net = DistNetwork {
2763 base_frequency: 60.0,
2764 buses: vec![b, bus("b2", &["1"], &[])],
2765 sources: vec![vs],
2766 lines: vec![DistLine {
2767 name: "l1".into(),
2768 bus_from: "sb".into(),
2769 bus_to: "b2".into(),
2770 terminal_map_from: strings(&["1"]),
2771 terminal_map_to: strings(&["1"]),
2772 linecode: "lc".into(),
2773 length: 1.0,
2774 route: None,
2775 i_max: Some(vec![400.0]),
2776 s_max: Some(vec![600.0]),
2777 extras: Extras::new(),
2778 }],
2779 ..DistNetwork::default()
2780 };
2781 let out = write_dss(&net);
2782 let line = out.text.lines().find(|l| l.contains("Line.l1 ")).unwrap();
2783 assert!(line.contains("emergamps=400"), "{line}");
2784 assert!(
2785 !out.warnings
2786 .iter()
2787 .any(|w| w.contains("line l1") && w.contains("i_max")),
2788 "{:?}",
2789 out.warnings
2790 );
2791 assert!(
2792 out.warnings
2793 .iter()
2794 .any(|w| w.contains("line l1") && w.contains("s_max") && w.contains("dropped")),
2795 "{:?}",
2796 out.warnings
2797 );
2798 }
2799
2800 #[test]
2801 fn line_level_emergamps_round_trips_as_i_max() {
2802 let src = "Clear\n\
2803 New Circuit.c1 basekv=12.47 pu=1 angle=0 phases=3 bus1=sb.1.2.3\n\
2804 New Linecode.lc nphases=3 r1=0.1 x1=0.2 emergamps=600\n\
2805 New Line.l1 bus1=sb.1.2.3 bus2=b2.1.2.3 phases=3 linecode=lc \
2806 length=10 units=m emergamps=250\n\
2807 New Line.l2 bus1=b2.1.2.3 bus2=b3.1.2.3 phases=3 linecode=lc \
2808 length=10 units=m\n";
2809 let net = parse_dss_str(src);
2810 let l1 = net.lines.iter().find(|l| l.name == "l1").unwrap();
2811 assert_eq!(l1.i_max.as_deref(), Some(&[250.0, 250.0, 250.0][..]));
2812 assert!(!l1.extras.contains_key("emergamps"), "{:?}", l1.extras);
2813 let l2 = net.lines.iter().find(|l| l.name == "l2").unwrap();
2815 assert_eq!(l2.i_max, None);
2816
2817 let (first, second) = roundtrip(&net);
2818 let line = first.lines().find(|l| l.contains("Line.l1 ")).unwrap();
2819 assert!(line.contains("emergamps=250"), "{line}");
2820 assert_eq!(line.matches("emergamps=").count(), 1, "{line}");
2821 let line2 = first.lines().find(|l| l.contains("Line.l2 ")).unwrap();
2822 assert!(!line2.contains("emergamps="), "{line2}");
2823 assert_eq!(first, second);
2824 }
2825
2826 #[test]
2827 fn unparsable_line_emergamps_stays_in_extras_for_the_echo() {
2828 let src = "Clear\n\
2829 New Circuit.c1 basekv=12.47 pu=1 angle=0 phases=3 bus1=sb.1.2.3\n\
2830 New Linecode.lc nphases=3 r1=0.1 x1=0.2\n\
2831 New Line.l1 bus1=sb.1.2.3 bus2=b2.1.2.3 phases=3 linecode=lc \
2832 length=10 units=m emergamps=@amps\n";
2833 let net = parse_dss_str(src);
2834 let l1 = net.lines.iter().find(|l| l.name == "l1").unwrap();
2835 assert_eq!(l1.i_max, None);
2836 assert_eq!(
2837 l1.extras.get("emergamps").and_then(|v| v.as_str()),
2838 Some("@amps")
2839 );
2840 let (first, second) = roundtrip(&net);
2841 let line = first.lines().find(|l| l.contains("Line.l1 ")).unwrap();
2842 assert!(line.contains("emergamps=@amps"), "{line}");
2843 assert_eq!(first, second);
2844 }
2845
2846 #[test]
2847 fn unparseable_kv_extra_warns_instead_of_silently_substituting() {
2848 let (b, vs) = three_phase_source(2400.0);
2849 let mut load = load_on("sb", &["1", "2", "3", "4"], Configuration::Wye);
2850 load.extras.insert("kv".into(), serde_json::json!("@kv"));
2851 let net = DistNetwork {
2852 base_frequency: 60.0,
2853 buses: vec![b],
2854 sources: vec![vs],
2855 loads: vec![load],
2856 ..DistNetwork::default()
2857 };
2858 let out = write_dss(&net);
2859 assert!(
2860 out.warnings
2861 .iter()
2862 .any(|w| w.contains("@kv") && w.contains("does not parse")),
2863 "{:?}",
2864 out.warnings
2865 );
2866 let line = out.text.lines().find(|l| l.contains("Load.ld")).unwrap();
2868 assert!(
2869 line.contains(&format!("kv={}", num(2400.0 * 3f64.sqrt() / 1e3))),
2870 "{line}"
2871 );
2872 }
2873
2874 #[test]
2875 fn options_reemit_and_commands_warn() {
2876 let src = "Clear\n\
2877 New Circuit.c1 basekv=12.47 pu=1 angle=0 phases=3 bus1=sb\n\
2878 Set mode=snapshot\n\
2879 Set controlmode=OFF\n\
2880 Disable Line.l1\n\
2881 Set VoltageBases=[12.47]\n\
2882 Calcvoltagebases\n\
2883 Solve\n";
2884 let out = write_dss(&parse_dss_str(src));
2885 assert!(out.text.contains("Set mode=snapshot"), "{}", out.text);
2886 assert!(out.text.contains("Set controlmode=OFF"), "{}", out.text);
2887 assert_eq!(out.text.matches("Set VoltageBases").count(), 1);
2889 assert_eq!(out.text.matches("Calcvoltagebases").count(), 1);
2890 assert_eq!(out.text.matches("DefaultBaseFrequency").count(), 1);
2891 assert!(!out.text.to_lowercase().contains("disable"));
2892 assert!(
2893 out.warnings
2894 .iter()
2895 .any(|w| w.contains("disable Line.l1") && w.contains("not regenerated")),
2896 "{:?}",
2897 out.warnings
2898 );
2899 assert!(!out.warnings.iter().any(|w| w.contains("`solve`")));
2901 let again = write_dss(&parse_dss_str(&out.text));
2902 assert_eq!(out.text, again.text);
2903 }
2904
2905 #[test]
2906 fn non_numeric_terminal_positionalizes() {
2907 let mut load = load_on("b1", &["a", "n"], Configuration::Wye);
2908 load.extras.insert("kv".into(), serde_json::json!("0.23"));
2909 let net = DistNetwork {
2910 base_frequency: 60.0,
2911 buses: vec![bus("b1", &["a", "n"], &["n"])],
2912 loads: vec![load],
2913 ..DistNetwork::default()
2914 };
2915 let (first, second) = roundtrip(&net);
2916 let line = first.lines().find(|l| l.contains("Load.ld")).unwrap();
2917 assert!(line.contains("bus1=b1.1.0"), "{line}");
2918 let out = write_dss(&net);
2919 assert!(
2920 out.warnings
2921 .iter()
2922 .any(|w| w.contains("`a`") && w.contains("position")),
2923 "{:?}",
2924 out.warnings
2925 );
2926 assert_eq!(first, second);
2927 }
2928
2929 #[test]
2930 fn half_present_thevenin_pair_stays_and_warns() {
2931 let (b, mut vs) = three_phase_source(2400.0);
2932 vs.extras
2933 .insert("rs".into(), serde_json::json!([[1.0, 0.1], [0.1, 1.0]]));
2934 let net = DistNetwork {
2935 base_frequency: 60.0,
2936 buses: vec![b],
2937 sources: vec![vs],
2938 ..DistNetwork::default()
2939 };
2940 let out = write_dss(&net);
2941 assert!(!out.text.contains("z1="), "{}", out.text);
2942 assert!(
2943 out.warnings.iter().any(|w| w.contains("`xs` is missing")),
2944 "{:?}",
2945 out.warnings
2946 );
2947 }
2948
2949 #[test]
2950 fn unusable_switch_sequence_extras_warn() {
2951 let (b, vs) = three_phase_source(2400.0);
2952 let sw = DistSwitch {
2953 name: "sw1".into(),
2954 bus_from: "sb".into(),
2955 bus_to: "b2".into(),
2956 terminal_map_from: strings(&["1", "2", "3"]),
2957 terminal_map_to: strings(&["1", "2", "3"]),
2958 open: false,
2959 i_max: Some(Vec::new()),
2960 extras: Extras::from([("pmd_rs".to_string(), serde_json::json!("oops"))]),
2961 };
2962 let net = DistNetwork {
2963 base_frequency: 60.0,
2964 buses: vec![b, bus("b2", &["1", "2", "3"], &[])],
2965 sources: vec![vs],
2966 switches: vec![sw],
2967 ..DistNetwork::default()
2968 };
2969 let out = write_dss(&net);
2970 assert!(!out.text.contains("r0="), "{}", out.text);
2971 assert!(
2972 out.warnings
2973 .iter()
2974 .any(|w| w.contains("pmd_rs") && w.contains("not a numeric matrix")),
2975 "{:?}",
2976 out.warnings
2977 );
2978 assert!(
2979 out.warnings.iter().any(|w| w.contains("i_max is empty")),
2980 "{:?}",
2981 out.warnings
2982 );
2983 }
2984
2985 #[test]
2986 fn degenerate_shapes_warn_instead_of_panicking() {
2987 let (b, vs) = three_phase_source(2400.0);
2988 let lc = DistLineCode {
2989 name: "lc1".into(),
2990 n_conductors: 2,
2991 r_series: vec![vec![1.0], vec![0.5]], x_series: vec![vec![1.0, 0.0], vec![0.0, 1.0]],
2993 g_from: vec![vec![0.0; 2]; 2],
2994 b_from: vec![vec![0.0; 2]; 2],
2995 g_to: vec![vec![0.0; 2]; 2],
2996 b_to: vec![vec![0.0; 2]; 2],
2997 i_max: Some(Vec::new()),
2998 s_max: None,
2999 source: None,
3000 extras: Extras::new(),
3001 };
3002 let t = DistTransformer {
3003 name: "t1".into(),
3004 windings: vec![
3005 Winding {
3006 bus: "sb".into(),
3007 terminal_map: strings(&["1", "2"]),
3008 conn: WindingConn::Wye,
3009 v_ref: 2400.0,
3010 s_rating: 25e3,
3011 r_pct: 0.5,
3012 tap: 1.0,
3013 r_neutral: None,
3014 x_neutral: None,
3015 },
3016 Winding {
3017 bus: "b2".into(),
3018 terminal_map: strings(&["1", "2"]),
3019 conn: WindingConn::Wye,
3020 v_ref: 240.0,
3021 s_rating: 25e3,
3022 r_pct: 0.5,
3023 tap: 1.0,
3024 r_neutral: None,
3025 x_neutral: None,
3026 },
3027 ],
3028 xsc_pct: Vec::new(),
3029 phases: 1,
3030 extras: Extras::new(),
3031 };
3032 let net = DistNetwork {
3033 base_frequency: 60.0,
3034 buses: vec![b, bus("b2", &["1", "2"], &[])],
3035 sources: vec![vs],
3036 linecodes: vec![lc],
3037 transformers: vec![t],
3038 ..DistNetwork::default()
3039 };
3040 let out = write_dss(&net); assert!(out.text.contains("rmatrix=(1 | 0.5 0)"), "{}", out.text);
3042 assert!(out.text.contains("xhl=0"), "{}", out.text);
3043 let has = |needle: &str| out.warnings.iter().any(|w| w.contains(needle));
3044 assert!(has("shorter than the lower triangle"), "{:?}", out.warnings);
3045 assert!(has("xsc_pct is empty"), "{:?}", out.warnings);
3046 assert!(has("i_max is empty"), "{:?}", out.warnings);
3047 }
3048
3049 #[test]
3050 fn a_rated_capacitor_bank_writes_as_a_dss_capacitor() {
3051 let (b, vs) = three_phase_source(2400.0);
3055 let cap = crate::model::DistCapacitor::new(
3056 "c1",
3057 "sb",
3058 strings(&["1", "2", "3", "n"]),
3059 Configuration::Wye,
3060 600e3,
3061 4160.0,
3062 );
3063 let net = DistNetwork {
3064 base_frequency: 60.0,
3065 buses: vec![b],
3066 sources: vec![vs],
3067 capacitors: vec![cap],
3068 ..DistNetwork::default()
3069 };
3070 let out = write_dss(&net);
3071 let line = out
3072 .text
3073 .lines()
3074 .find(|l| l.contains("Capacitor.c1"))
3075 .unwrap_or_else(|| panic!("no capacitor emitted: {}", out.text));
3076 assert!(line.contains("kvar=600"), "{line}");
3077 assert!(line.contains("kv=4.16"), "{line}");
3078 assert!(line.contains("phases=3"), "{line}");
3079 assert!(
3080 !out.warnings.iter().any(|w| w.contains("dropped")),
3081 "{:?}",
3082 out.warnings
3083 );
3084
3085 let back = parse_dss_str(&out.text);
3088 assert_eq!(back.shunts.len(), 1, "{}", out.text);
3089 }
3090
3091 #[test]
3092 fn an_unbalanced_load_splits_into_one_load_per_phase() {
3093 let (b, vs) = three_phase_source(2400.0);
3097 let mut unbalanced = DistLoad::new(
3098 "l1",
3099 "sb",
3100 strings(&["1", "2", "3", "n"]),
3101 Configuration::Wye,
3102 vec![1e3, 2e3, 3e3],
3103 vec![100.0, 200.0, 300.0],
3104 );
3105 unbalanced.extras.insert("kv".into(), 4.16.into());
3106 let balanced = DistLoad::new(
3107 "l2",
3108 "sb",
3109 strings(&["1", "2", "3", "n"]),
3110 Configuration::Wye,
3111 vec![1e3, 1e3, 1e3],
3112 vec![100.0, 100.0, 100.0],
3113 );
3114 let net = DistNetwork {
3115 base_frequency: 60.0,
3116 buses: vec![b],
3117 sources: vec![vs],
3118 loads: vec![unbalanced, balanced],
3119 ..DistNetwork::default()
3120 };
3121 let out = write_dss(&net);
3122 let loads: Vec<&str> = out
3123 .text
3124 .lines()
3125 .filter(|l| l.contains("New Load."))
3126 .collect();
3127 assert_eq!(loads.len(), 4, "{}", out.text);
3128 for (name, kw, kvar) in [
3129 ("l1_1", "1", "0.1"),
3130 ("l1_2", "2", "0.2"),
3131 ("l1_3", "3", "0.3"),
3132 ] {
3133 let line = loads
3134 .iter()
3135 .find(|l| l.contains(&format!("New Load.{name} ")))
3136 .unwrap_or_else(|| panic!("no {name}: {}", out.text));
3137 assert!(line.contains(&format!("kw={kw} ")), "{line}");
3138 assert!(line.contains(&format!("kvar={kvar}")), "{line}");
3139 assert!(line.contains("phases=1"), "{line}");
3140 assert!(!line.contains("kv=4.16"), "{line}");
3142 }
3143 assert_eq!(
3144 loads.iter().filter(|l| l.contains("New Load.l2 ")).count(),
3145 1,
3146 "a balanced load stays one object: {}",
3147 out.text
3148 );
3149 }
3150
3151 #[test]
3152 fn a_center_tap_load_splits_onto_its_two_legs() {
3153 let (b, vs, lv) = center_tap_service(11000.0);
3158 let l = DistLoad {
3159 name: "ld".into(),
3160 bus: "lv".into(),
3161 terminal_map: strings(&["p1", "n", "p2"]),
3162 configuration: Configuration::Wye,
3163 p_nom: vec![1304.0, 1304.0],
3164 q_nom: vec![978.0, 978.0],
3165 voltage_model: DistLoadVoltageModel::ConstantImpedance { v_nom: Vec::new() },
3166 extras: Extras::new(),
3167 };
3168 let net = DistNetwork {
3169 base_frequency: 60.0,
3170 buses: vec![b, lv],
3171 sources: vec![vs],
3172 loads: vec![l],
3173 ..DistNetwork::default()
3174 };
3175 let out = write_dss(&net);
3176 let loads: Vec<&str> = out
3177 .text
3178 .lines()
3179 .filter(|l| l.contains("New Load."))
3180 .collect();
3181 assert_eq!(loads.len(), 2, "{}", out.text);
3182 for (name, node) in [("ld_p1", "lv.1.0"), ("ld_p2", "lv.3.0")] {
3185 let line = loads
3186 .iter()
3187 .find(|l| l.contains(&format!("New Load.{name} ")))
3188 .unwrap_or_else(|| panic!("no {name}: {}", out.text));
3189 assert!(line.contains(&format!("bus1={node} ")), "{line}");
3190 assert!(line.contains("phases=1"), "{line}");
3191 assert!(line.contains("kw=1.304"), "{line}");
3192 assert!(line.contains("kvar=0.978"), "{line}");
3193 }
3194 }
3195
3196 #[test]
3197 fn an_unbalanced_center_tap_load_keeps_each_leg_with_its_own_power() {
3198 let (b, vs, lv) = center_tap_service(11000.0);
3202 let l = DistLoad::new(
3203 "ld",
3204 "lv",
3205 strings(&["p1", "n", "p2"]),
3206 Configuration::Wye,
3207 vec![1000.0, 2000.0],
3208 vec![100.0, 200.0],
3209 );
3210 let net = DistNetwork {
3211 base_frequency: 60.0,
3212 buses: vec![b, lv],
3213 sources: vec![vs],
3214 loads: vec![l],
3215 ..DistNetwork::default()
3216 };
3217 let out = write_dss(&net);
3218 let loads: Vec<&str> = out
3219 .text
3220 .lines()
3221 .filter(|l| l.contains("New Load."))
3222 .collect();
3223 assert_eq!(loads.len(), 2, "{}", out.text);
3224 for (name, node, kw, kvar) in [
3225 ("ld_p1", "lv.1.0", "1", "0.1"),
3226 ("ld_p2", "lv.3.0", "2", "0.2"),
3227 ] {
3228 let line = loads
3229 .iter()
3230 .find(|l| l.contains(&format!("New Load.{name} ")))
3231 .unwrap_or_else(|| panic!("no {name}: {}", out.text));
3232 assert!(line.contains(&format!("bus1={node} ")), "{line}");
3233 assert!(line.contains(&format!("kw={kw} ")), "{line}");
3234 assert!(line.contains(&format!("kvar={kvar}")), "{line}");
3235 }
3236 assert!(!out.text.contains("New Load.ld_n "), "{}", out.text);
3238 }
3239
3240 #[test]
3241 fn a_map_longer_than_the_record_says_what_dss_drops() {
3242 let (b, vs, lv) = center_tap_service(11000.0);
3245 let mut l = DistLoad::new(
3246 "ld",
3247 "lv",
3248 strings(&["p1", "n", "p2"]),
3249 Configuration::Wye,
3250 vec![2608.0],
3251 vec![1956.0],
3252 );
3253 l.extras.insert("phases".into(), 1.into());
3254 let net = DistNetwork {
3255 base_frequency: 60.0,
3256 buses: vec![b, lv],
3257 sources: vec![vs],
3258 loads: vec![l],
3259 ..DistNetwork::default()
3260 };
3261 let out = write_dss(&net);
3262 assert_eq!(
3263 out.text.lines().filter(|l| l.contains("New Load.")).count(),
3264 1,
3265 "{}",
3266 out.text
3267 );
3268 assert!(
3269 out.warnings
3270 .iter()
3271 .any(|w| w.contains("addresses 2") && w.contains("loses them")),
3272 "{:?}",
3273 out.warnings
3274 );
3275 }
3276
3277 #[test]
3278 fn a_star_with_no_secondary_leakage_goes_out_solvable() {
3279 let (b, vs, lv) = center_tap_service(11000.0);
3283 let winding = |bus: &str, map: &[&str], v: f64| Winding {
3284 bus: bus.into(),
3285 terminal_map: strings(map),
3286 conn: WindingConn::Wye,
3287 v_ref: v,
3288 s_rating: 25e3,
3289 r_pct: 0.5,
3290 tap: 1.0,
3291 r_neutral: None,
3292 x_neutral: None,
3293 };
3294 let t = DistTransformer {
3295 name: "tx".into(),
3296 phases: 1,
3297 windings: vec![
3298 winding("sb", &["1", "4"], 11000.0),
3299 winding("lv", &["p1", "n"], 240.0),
3300 winding("lv", &["n", "p2"], 240.0),
3301 ],
3302 xsc_pct: vec![2.5, 2.5, 0.0],
3303 extras: Extras::new(),
3304 };
3305 let net = DistNetwork {
3306 base_frequency: 60.0,
3307 buses: vec![b, lv],
3308 sources: vec![vs],
3309 transformers: vec![t],
3310 ..DistNetwork::default()
3311 };
3312 let out = write_dss(&net);
3313 let line = out
3314 .text
3315 .lines()
3316 .find(|l| l.contains("New Transformer.tx"))
3317 .unwrap_or_else(|| panic!("no transformer: {}", out.text));
3318 assert!(line.contains("xhl=2.5 xht=2.5 xlt=1.666"), "{line}");
3319 assert!(line.contains("buses=(sb.1.0, lv.1.0, lv.0.3)"), "{line}");
3322 assert!(
3323 out.warnings
3324 .iter()
3325 .any(|w| w.contains("collapsed secondary")),
3326 "{:?}",
3327 out.warnings
3328 );
3329 }
3330
3331 #[test]
3332 fn a_delta_load_with_per_phase_power_stays_balanced_and_says_so() {
3333 let (b, vs) = three_phase_source(2400.0);
3336 let l = DistLoad::new(
3337 "d1",
3338 "sb",
3339 strings(&["1", "2", "3"]),
3340 Configuration::Delta,
3341 vec![1e3, 2e3, 3e3],
3342 vec![0.0, 0.0, 0.0],
3343 );
3344 let net = DistNetwork {
3345 base_frequency: 60.0,
3346 buses: vec![b],
3347 sources: vec![vs],
3348 loads: vec![l],
3349 ..DistNetwork::default()
3350 };
3351 let out = write_dss(&net);
3352 assert_eq!(
3353 out.text.lines().filter(|l| l.contains("New Load.")).count(),
3354 1,
3355 "{}",
3356 out.text
3357 );
3358 assert!(
3359 out.warnings
3360 .iter()
3361 .any(|w| w.contains("per phase power on a delta load")),
3362 "{:?}",
3363 out.warnings
3364 );
3365 }
3366
3367 #[test]
3368 fn two_phase_capacitor_kvar_uses_line_to_line_kv() {
3369 let (b, vs) = three_phase_source(2400.0);
3372 let b_phase = 1e-3;
3373 let sh = DistShunt {
3374 name: "c1".into(),
3375 bus: "sb".into(),
3376 terminal_map: strings(&["1", "2"]),
3377 g: vec![vec![0.0; 2]; 2],
3378 b: vec![vec![b_phase, 0.0], vec![0.0, b_phase]],
3379 extras: Extras::new(),
3380 };
3381 let net = DistNetwork {
3382 base_frequency: 60.0,
3383 buses: vec![b],
3384 sources: vec![vs],
3385 shunts: vec![sh],
3386 ..DistNetwork::default()
3387 };
3388 let out = write_dss(&net);
3389 let kv = 2400.0 * 3f64.sqrt() / 1e3;
3390 let v_phase = kv * 1e3 / 3f64.sqrt();
3391 let expected = b_phase * v_phase * v_phase * 2.0 / 1e3;
3392 let line = out
3393 .text
3394 .lines()
3395 .find(|l| l.contains("Capacitor.c1"))
3396 .unwrap();
3397 assert!(line.contains(&format!("kvar={}", num(expected))), "{line}");
3398 }
3399
3400 #[test]
3401 fn inductive_shunt_regenerates_as_a_reactor() {
3402 let (b, vs) = three_phase_source(2400.0);
3406 let b_phase = -1e-3;
3407 let sh = DistShunt {
3408 name: "rx".into(),
3409 bus: "sb".into(),
3410 terminal_map: strings(&["1", "2", "3"]),
3411 g: vec![vec![0.0; 3]; 3],
3412 b: vec![
3413 vec![b_phase, 0.0, 0.0],
3414 vec![0.0, b_phase, 0.0],
3415 vec![0.0, 0.0, b_phase],
3416 ],
3417 extras: Extras::new(),
3418 };
3419 let net = DistNetwork {
3420 base_frequency: 60.0,
3421 buses: vec![b],
3422 sources: vec![vs],
3423 shunts: vec![sh],
3424 ..DistNetwork::default()
3425 };
3426 let out = write_dss(&net);
3427 let line = out
3428 .text
3429 .lines()
3430 .find(|l| l.contains("Reactor.rx"))
3431 .unwrap_or_else(|| panic!("no reactor emitted in:\n{}", out.text));
3432 assert!(!out.text.contains("Capacitor.rx"), "{}", out.text);
3433 let kv = 2400.0 * 3f64.sqrt() / 1e3;
3434 let v_phase = kv * 1e3 / 3f64.sqrt();
3435 let expected = b_phase.abs() * v_phase * v_phase * 3.0 / 1e3;
3436 assert!(line.contains(&format!("kvar={}", num(expected))), "{line}");
3437 }
3438
3439 #[test]
3440 fn conductive_shunt_regenerates_as_grounding_reactor() {
3441 let (_, vs) = three_phase_source(2400.0);
3442 let b = bus("sb", &["1", "2", "3", "4"], &[]);
3443 let sh = DistShunt {
3444 name: "gnd".into(),
3445 bus: "sb".into(),
3446 terminal_map: strings(&["4"]),
3447 g: vec![vec![1.0 / 0.3]],
3448 b: vec![vec![0.0]],
3449 extras: Extras::new(),
3450 };
3451 let net = DistNetwork {
3452 base_frequency: 60.0,
3453 buses: vec![b],
3454 sources: vec![vs],
3455 shunts: vec![sh],
3456 ..DistNetwork::default()
3457 };
3458 let out = write_dss(&net);
3459 let line = out
3460 .text
3461 .lines()
3462 .find(|l| l.contains("Reactor.gnd"))
3463 .unwrap_or_else(|| panic!("no reactor emitted in:\n{}", out.text));
3464 assert!(line.contains("bus1=sb.4"), "{line}");
3465 assert!(line.contains("bus2=sb.0"), "{line}");
3466 assert!(line.contains("phases=1"), "{line}");
3467 assert!(line.contains("r=0.3"), "{line}");
3468 assert!(line.contains("x=0"), "{line}");
3469 assert!(
3470 !line.contains("x=-0"),
3471 "negative zero must canonicalize: {line}"
3472 );
3473 }
3474
3475 #[test]
3476 fn delta_shunt_regenerates_conn_delta() {
3477 let (b, vs) = three_phase_source(2400.0);
3478 let b_branch = 2e-4;
3479 let bmat = vec![
3480 vec![2.0 * b_branch, -b_branch, -b_branch],
3481 vec![-b_branch, 2.0 * b_branch, -b_branch],
3482 vec![-b_branch, -b_branch, 2.0 * b_branch],
3483 ];
3484 let mut extras = Extras::new();
3485 extras.insert("conn".into(), serde_json::json!("delta"));
3486 extras.insert("phases".into(), serde_json::json!("3"));
3487 let sh = DistShunt {
3488 name: "capd".into(),
3489 bus: "sb".into(),
3490 terminal_map: strings(&["1", "2", "3"]),
3491 g: vec![vec![0.0; 3]; 3],
3492 b: bmat,
3493 extras,
3494 };
3495 let net = DistNetwork {
3496 base_frequency: 60.0,
3497 buses: vec![b],
3498 sources: vec![vs],
3499 shunts: vec![sh],
3500 ..DistNetwork::default()
3501 };
3502 let out = write_dss(&net);
3503 let line = out
3504 .text
3505 .lines()
3506 .find(|l| l.contains("Capacitor.capd"))
3507 .unwrap_or_else(|| panic!("no capacitor emitted in:\n{}", out.text));
3508 assert!(line.contains("phases=3 conn=delta"), "{line}");
3509 assert!(
3510 !out.warnings.iter().any(|w| w.contains("off diagonal")),
3511 "{:?}",
3512 out.warnings
3513 );
3514 }
3515
3516 #[test]
3517 fn non_scalar_delta_matrix_is_not_inferred_silently() {
3518 let (b, vs) = three_phase_source(2400.0);
3519 let bmat = vec![
3520 vec![0.003, -0.001, -0.002],
3521 vec![-0.001, 0.003, -0.002],
3522 vec![-0.002, -0.002, 0.004],
3523 ];
3524 let sh = DistShunt {
3525 name: "capx".into(),
3526 bus: "sb".into(),
3527 terminal_map: strings(&["1", "2", "3"]),
3528 g: vec![vec![0.0; 3]; 3],
3529 b: bmat,
3530 extras: Extras::new(),
3531 };
3532 let net = DistNetwork {
3533 base_frequency: 60.0,
3534 buses: vec![b],
3535 sources: vec![vs],
3536 shunts: vec![sh],
3537 ..DistNetwork::default()
3538 };
3539 let out = write_dss(&net);
3540 let line = out
3541 .text
3542 .lines()
3543 .find(|l| l.contains("Capacitor.capx"))
3544 .unwrap_or_else(|| panic!("no capacitor emitted in:\n{}", out.text));
3545 assert!(line.contains("conn=wye"), "{line}");
3546 assert!(
3547 out.warnings.iter().any(|w| w.contains("off diagonal")),
3548 "{:?}",
3549 out.warnings
3550 );
3551 }
3552
3553 #[test]
3554 fn stashed_delta_matrix_warns_when_scalar_emission_is_lossy() {
3555 let (b, vs) = three_phase_source(2400.0);
3556 let bmat = vec![
3557 vec![0.003, -0.001, -0.002],
3558 vec![-0.001, 0.003, -0.002],
3559 vec![-0.002, -0.002, 0.004],
3560 ];
3561 let mut extras = Extras::new();
3562 extras.insert("conn".into(), serde_json::json!("delta"));
3563 extras.insert("phases".into(), serde_json::json!("3"));
3564 let sh = DistShunt {
3565 name: "capx".into(),
3566 bus: "sb".into(),
3567 terminal_map: strings(&["1", "2", "3"]),
3568 g: vec![vec![0.0; 3]; 3],
3569 b: bmat,
3570 extras,
3571 };
3572 let net = DistNetwork {
3573 base_frequency: 60.0,
3574 buses: vec![b],
3575 sources: vec![vs],
3576 shunts: vec![sh],
3577 ..DistNetwork::default()
3578 };
3579 let out = write_dss(&net);
3580 let line = out
3581 .text
3582 .lines()
3583 .find(|l| l.contains("Capacitor.capx"))
3584 .unwrap_or_else(|| panic!("no capacitor emitted in:\n{}", out.text));
3585 assert!(line.contains("conn=delta"), "{line}");
3586 assert!(
3587 out.warnings
3588 .iter()
3589 .any(|w| w.contains("no scalar capacitor expression")),
3590 "{:?}",
3591 out.warnings
3592 );
3593 }
3594
3595 #[test]
3596 fn option_values_choose_a_wrapper_the_lexer_undoes() {
3597 let src = "Clear\n\
3598 New Circuit.c1 basekv=12.47 pu=1 angle=0 phases=3 bus1=sb\n\
3599 Set foo=[a!b]\n\
3600 Set bar=[(abc]\n\
3601 Set baz=(x ] y)\n\
3602 Set qux=[a ) b]\n\
3603 Solve\n";
3604 let net = parse_dss_str(src);
3605 let first = write_dss(&net);
3606 for line in [
3607 "Set foo=(a!b)",
3608 "Set bar=((abc)",
3609 "Set baz=(x ] y)",
3610 "Set qux=[a ) b]",
3611 ] {
3612 assert!(
3613 first.text.contains(line),
3614 "{line} missing in {}",
3615 first.text
3616 );
3617 }
3618 assert!(
3619 !first
3620 .warnings
3621 .iter()
3622 .any(|w| w.contains("emitted as written")),
3623 "{:?}",
3624 first.warnings
3625 );
3626 let reparsed = parse_dss_str(&first.text);
3628 let opt = |k: &str| {
3629 reparsed
3630 .options
3631 .iter()
3632 .find(|(name, _)| name == k)
3633 .map(|(_, v)| v.as_str())
3634 };
3635 assert_eq!(opt("foo"), Some("a!b"));
3636 assert_eq!(opt("bar"), Some("(abc"));
3637 assert_eq!(opt("baz"), Some("x ] y"));
3638 assert_eq!(opt("qux"), Some("a ) b"));
3639 let second = write_dss(&reparsed);
3641 assert_eq!(first.text, second.text);
3642 }
3643
3644 #[test]
3645 fn extras_tail_values_wrap_like_options() {
3646 let (b, vs) = three_phase_source(2400.0);
3647 let mut load = load_on("sb", &["1", "2", "3", "4"], Configuration::Wye);
3648 load.extras
3649 .insert("daily".into(), serde_json::json!("a ) b"));
3650 let net = DistNetwork {
3651 base_frequency: 60.0,
3652 buses: vec![b],
3653 sources: vec![vs],
3654 loads: vec![load],
3655 ..DistNetwork::default()
3656 };
3657 let (first, second) = roundtrip(&net);
3658 assert!(first.contains("daily=[a ) b]"), "{first}");
3661 assert_eq!(first, second);
3662 let back = parse_dss_str(&first);
3663 assert_eq!(
3664 back.loads[0]
3665 .extras
3666 .get("daily")
3667 .and_then(serde_json::Value::as_str),
3668 Some("a ) b")
3669 );
3670 }
3671
3672 #[test]
3673 fn unrepresentable_values_emit_as_written_and_warn() {
3674 let bad = "a )]}\"' b";
3677 let (b, vs) = three_phase_source(2400.0);
3678 let mut load = load_on("sb", &["1", "2", "3", "4"], Configuration::Wye);
3679 load.extras.insert("daily".into(), serde_json::json!(bad));
3680 let mut net = DistNetwork {
3681 base_frequency: 60.0,
3682 buses: vec![b],
3683 sources: vec![vs],
3684 loads: vec![load],
3685 ..DistNetwork::default()
3686 };
3687 net.options.push(("foo".into(), bad.into()));
3688 let out = write_dss(&net);
3689 assert!(out.text.contains(&format!("Set foo={bad}")), "{}", out.text);
3690 assert!(out.text.contains(&format!("daily={bad}")), "{}", out.text);
3691 let warned = |needle: &str| {
3692 out.warnings
3693 .iter()
3694 .any(|w| w.contains(needle) && w.contains("emitted as written"))
3695 };
3696 assert!(warned("option `foo`"), "{:?}", out.warnings);
3697 assert!(warned("`daily`"), "{:?}", out.warnings);
3698 }
3699
3700 #[test]
3701 fn empty_extras_values_wrap_instead_of_eating_the_next_token() {
3702 let dss = "clear\nnew circuit.c basekv=12.47 bus1=sb\n\
3703 new load.ld bus1=sb.1 phases=1 kv=7.2 kw=10 daily=() duty=sh\nsolve\n";
3704 let net = parse_dss_str(dss);
3705 let load = &net.loads[0];
3706 assert_eq!(load.extras.get("daily").and_then(|v| v.as_str()), Some(""));
3707 let w1 = write_dss(&net).text;
3708 let again = parse_dss_str(&w1);
3709 let load2 = &again.loads[0];
3710 assert_eq!(load2.extras.get("daily").and_then(|v| v.as_str()), Some(""));
3711 assert_eq!(
3712 load2.extras.get("duty").and_then(|v| v.as_str()),
3713 Some("sh")
3714 );
3715 assert_eq!(w1, write_dss(&again).text);
3716 }
3717
3718 #[test]
3719 fn sub_unique_option_prefixes_re_emit_instead_of_vanishing() {
3720 let dss = "clear\nnew circuit.c basekv=12.47 bus1=sb\n\
3724 Set ca=600\nSet default=2.5\nsolve\n";
3725 let net = parse_dss_str(dss);
3726 assert!((net.base_frequency - 60.0).abs() < 1e-12);
3727 let out = write_dss(&net).text;
3728 assert!(out.contains("Set ca=600"), "{out}");
3729 assert!(out.contains("Set default=2.5"), "{out}");
3730 }
3731
3732 #[test]
3733 fn abbreviated_derived_options_skip_and_set_the_frequency() {
3734 let src = "Clear\n\
3737 New Circuit.c1 basekv=12.47 pu=1 angle=0 phases=3 bus1=sb\n\
3738 Set volt=[115, 132]\n\
3739 Set defaultb=50\n\
3740 Solve\n";
3741 let net = parse_dss_str(src);
3742 assert!((net.base_frequency - 50.0).abs() < 1e-12);
3743 let out = write_dss(&net);
3744 assert!(
3745 out.text.contains("Set DefaultBaseFrequency=50"),
3746 "{}",
3747 out.text
3748 );
3749 assert_eq!(
3750 out.text
3751 .to_lowercase()
3752 .matches("defaultbasefrequency")
3753 .count(),
3754 1,
3755 "{}",
3756 out.text
3757 );
3758 assert_eq!(
3759 out.text.matches("Set VoltageBases").count(),
3760 1,
3761 "{}",
3762 out.text
3763 );
3764 assert!(!out.text.contains("Set volt="), "{}", out.text);
3765 assert!(!out.text.contains("Set defaultb="), "{}", out.text);
3766 let second = write_dss(&parse_dss_str(&out.text));
3767 assert_eq!(out.text, second.text);
3768 }
3769
3770 #[test]
3771 fn non_numeric_source_extras_warn_before_falling_back() {
3772 let (b, mut vs) = three_phase_source(2400.0);
3773 vs.extras
3774 .insert("basekv".into(), serde_json::json!("@base"));
3775 vs.extras.insert("pu".into(), serde_json::json!("unity"));
3776 vs.extras.insert("angle".into(), serde_json::json!([0.0]));
3777 let net = DistNetwork {
3778 base_frequency: 60.0,
3779 buses: vec![b],
3780 sources: vec![vs],
3781 ..DistNetwork::default()
3782 };
3783 let out = write_dss(&net);
3784 for key in ["basekv", "pu", "angle"] {
3785 assert!(
3786 out.warnings
3787 .iter()
3788 .any(|w| w.contains(&format!("{key} extra")) && w.contains("does not parse")),
3789 "{key}: {:?}",
3790 out.warnings
3791 );
3792 }
3793 let line = out.text.lines().find(|l| l.contains("Circuit.")).unwrap();
3795 assert!(line.contains("pu=1 angle=0"), "{line}");
3796 }
3797
3798 #[test]
3799 fn de_energized_source_phase_keeps_its_conductor() {
3800 let (b, mut vs) = three_phase_source(2400.0);
3801 vs.v_magnitude[2] = 0.0; let net = DistNetwork {
3803 name: Some("t".into()),
3804 base_frequency: 60.0,
3805 buses: vec![b],
3806 sources: vec![vs],
3807 ..DistNetwork::default()
3808 };
3809 let (first, second) = roundtrip(&net);
3810 let line = first.lines().find(|l| l.contains("Circuit.")).unwrap();
3811 assert!(line.contains("phases=3"), "{line}");
3813 assert!(line.contains("bus1=sb.1.2.3.0"), "{line}");
3814 assert_eq!(first, second);
3815 let out = write_dss(&net);
3816 assert!(
3817 out.warnings
3818 .iter()
3819 .any(|w| w.contains("phases=3") && w.contains("positive")),
3820 "{:?}",
3821 out.warnings
3822 );
3823 }
3824
3825 #[test]
3826 fn multiple_sources_keep_named_vsource_when_source_exists() {
3827 let third = 2.0 * std::f64::consts::FRAC_PI_3;
3828 let source = VoltageSource {
3829 name: "source".into(),
3830 bus: "Bx".into(),
3831 terminal_map: strings(&["1", "2", "3", "4"]),
3832 v_magnitude: vec![20_000.0, 20_000.0, 20_000.0, 0.0],
3833 v_angle: vec![0.0, -third, third, 0.0],
3834 extras: Extras::new(),
3835 };
3836 let wind = VoltageSource {
3837 name: "WindGen1".into(),
3838 bus: "Bg".into(),
3839 terminal_map: strings(&["1", "2", "3", "4"]),
3840 v_magnitude: vec![400.0, 400.0, 400.0, 0.0],
3841 v_angle: vec![
3842 -std::f64::consts::FRAC_PI_3,
3843 std::f64::consts::PI,
3844 third / 2.0,
3845 0.0,
3846 ],
3847 extras: Extras::new(),
3848 };
3849 let net = DistNetwork {
3850 name: Some("dg".into()),
3851 base_frequency: 60.0,
3852 buses: vec![
3853 bus("Bg", &["1", "2", "3", "4"], &["4"]),
3854 bus("Bx", &["1", "2", "3", "4"], &["4"]),
3855 ],
3856 sources: vec![wind, source],
3857 ..DistNetwork::default()
3858 };
3859
3860 let out = write_dss(&net).text;
3861 let circuit = out.lines().find(|l| l.starts_with("New Circuit")).unwrap();
3862 assert!(circuit.contains("bus1=Bx.1.2.3.0"), "{circuit}");
3863 assert!(
3864 out.lines()
3865 .any(|l| l.starts_with("New Vsource.WindGen1") && l.contains("bus1=Bg.1.2.3.0")),
3866 "{out}"
3867 );
3868 let reparsed = parse_dss_str(&out);
3869 assert!(
3870 reparsed
3871 .sources
3872 .iter()
3873 .any(|vs| vs.name.eq_ignore_ascii_case("WindGen1")),
3874 "{:?}",
3875 reparsed.sources
3876 );
3877 }
3878
3879 #[test]
3880 fn source_phases_stash_wins_and_does_not_double_emit() {
3881 let (b, mut vs) = three_phase_source(2400.0);
3882 vs.extras.insert("phases".into(), serde_json::json!("3"));
3883 let net = DistNetwork {
3884 base_frequency: 60.0,
3885 buses: vec![b],
3886 sources: vec![vs],
3887 ..DistNetwork::default()
3888 };
3889 let out = write_dss(&net);
3890 let line = out.text.lines().find(|l| l.contains("Circuit.")).unwrap();
3891 assert!(line.contains("phases=3"), "{line}");
3892 assert_eq!(line.matches("phases=").count(), 1, "{line}");
3893 }
3894
3895 #[test]
3896 fn foreign_maps_without_a_neutral_warn_and_converge_at_write2() {
3897 let third = 2.0 * std::f64::consts::FRAC_PI_3;
3901 let vs = VoltageSource {
3902 name: "source".into(),
3903 bus: "sb".into(),
3904 terminal_map: strings(&["1", "2", "3"]),
3905 v_magnitude: vec![2400.0; 3],
3906 v_angle: vec![0.0, -third, third],
3907 extras: Extras::new(),
3908 };
3909 let load = load_on("sb", &["1"], Configuration::Wye);
3910 let net = DistNetwork {
3911 name: Some("t".into()),
3912 base_frequency: 60.0,
3913 buses: vec![bus("sb", &["1", "2", "3"], &[])],
3914 sources: vec![vs],
3915 loads: vec![load],
3916 ..DistNetwork::default()
3917 };
3918 let first = write_dss(&net);
3919 let hits = |warnings: &[String], name: &str| {
3920 warnings
3921 .iter()
3922 .any(|w| w.contains(name) && w.contains("materializes a grounded neutral"))
3923 };
3924 assert!(
3925 hits(&first.warnings, "vsource source"),
3926 "{:?}",
3927 first.warnings
3928 );
3929 assert!(hits(&first.warnings, "load ld"), "{:?}", first.warnings);
3930 let second = write_dss(&parse_dss_str(&first.text));
3931 assert_ne!(first.text, second.text);
3932 assert!(!hits(&second.warnings, "vsource"), "{:?}", second.warnings);
3933 assert!(!hits(&second.warnings, "load"), "{:?}", second.warnings);
3934 let third_write = write_dss(&parse_dss_str(&second.text));
3935 assert_eq!(second.text, third_write.text);
3936 }
3937
3938 #[test]
3939 fn generator_phases_and_conn_match_the_load_rules() {
3940 let (b, vs) = three_phase_source(2400.0);
3941 let g = DistGenerator {
3942 name: "g1".into(),
3943 bus: "sb".into(),
3944 terminal_map: strings(&["1", "2", "3"]),
3945 configuration: Configuration::Delta,
3946 p_nom: vec![1e3; 3],
3947 q_nom: vec![0.0; 3],
3948 p_min: None,
3949 p_max: None,
3950 q_min: None,
3951 q_max: None,
3952 cost: None,
3953 s_max: None,
3954 i_max: None,
3955 extras: Extras::from([
3956 ("kv".to_string(), serde_json::json!("4.16")),
3957 ("phases".to_string(), serde_json::json!("2")),
3958 ]),
3959 };
3960 let net = DistNetwork {
3961 base_frequency: 60.0,
3962 buses: vec![b],
3963 sources: vec![vs],
3964 generators: vec![g],
3965 ..DistNetwork::default()
3966 };
3967 let out = write_dss(&net);
3968 let line = out
3969 .text
3970 .lines()
3971 .find(|l| l.contains("Generator.g1"))
3972 .unwrap();
3973 assert!(line.contains("phases=2 conn=delta"), "{line}");
3974 assert_eq!(line.matches("phases=").count(), 1, "{line}");
3975 }
3976
3977 #[test]
3978 fn fixed_dispatch_ibr_exports_as_generator_model_one() {
3979 let (b, vs) = three_phase_source(240.0);
3980 let ibr = DistIbr {
3981 name: "pv".into(),
3982 bus: "sb".into(),
3983 terminal_map: strings(&["1", "2", "3", "4"]),
3984 topology: IbrTopology::FourLeg,
3985 prime_mover: IbrPrimeMover::Pv,
3986 s_max: vec![10_000.0; 3],
3987 i_max: None,
3988 p_avail: Some(24_000.0),
3989 p_min: Some(vec![8_000.0; 3]),
3990 p_max: Some(vec![8_000.0; 3]),
3991 q_min: Some(vec![0.0; 3]),
3992 q_max: Some(vec![0.0; 3]),
3993 control_profile: None,
3994 voltage_aggregation: None,
3995 extras: Extras::from([("kv".to_string(), serde_json::json!("0.416"))]),
3996 };
3997 let net = DistNetwork {
3998 name: Some("fixed".into()),
3999 base_frequency: 60.0,
4000 buses: vec![b],
4001 sources: vec![vs],
4002 ibrs: vec![ibr],
4003 ..DistNetwork::default()
4004 };
4005
4006 let out = write_dss(&net);
4007
4008 assert!(out.warnings.is_empty(), "{:?}", out.warnings);
4009 let line = out
4010 .text
4011 .lines()
4012 .find(|l| l.starts_with("New Generator.pv"))
4013 .unwrap();
4014 assert!(line.contains("model=1 vminpu=0 vmaxpu=2"), "{line}");
4015 assert!(line.contains("kw=24"), "{line}");
4016 assert!(!out.text.contains("PVSystem.pv"), "{}", out.text);
4017 }
4018
4019 #[test]
4020 fn volt_var_ibr_exports_pvsystem_xycurve_and_invcontrol() {
4021 let (b, vs) = three_phase_source(240.0);
4022 let base_v = 416.0 / 3f64.sqrt();
4023 let ibr = DistIbr {
4024 name: "pv".into(),
4025 bus: "sb".into(),
4026 terminal_map: strings(&["1", "2", "3", "4"]),
4027 topology: IbrTopology::FourLeg,
4028 prime_mover: IbrPrimeMover::Pv,
4029 s_max: vec![10_000.0; 3],
4030 i_max: None,
4031 p_avail: Some(24_000.0),
4032 p_min: Some(vec![0.0; 3]),
4033 p_max: Some(vec![8_000.0; 3]),
4034 q_min: Some(vec![-4_000.0; 3]),
4035 q_max: Some(vec![4_000.0; 3]),
4036 control_profile: Some("cp".into()),
4037 voltage_aggregation: None,
4038 extras: Extras::from([("kv".to_string(), serde_json::json!("0.416"))]),
4039 };
4040 let profile = DistControlProfile {
4041 name: "cp".into(),
4042 power_factor: None,
4043 volt_var: Some(VoltVarControl {
4044 voltage_reference: Some(ControlVoltageReference::PgAveraged),
4045 breakpoints: [0.92, 0.98, 1.02, 1.08]
4046 .into_iter()
4047 .map(|v| v * base_v)
4048 .collect(),
4049 q_limits: vec![-0.44, 0.44],
4050 q_unit: Some(ReactivePowerUnit::VaFraction),
4051 q_ref: Some(ReactivePowerReference::VarMax),
4052 p_min_for_q: Some(10.0),
4053 p_min_for_q_max: Some(50.0),
4054 }),
4055 volt_watt: None,
4056 extras: Extras::new(),
4057 };
4058 let net = DistNetwork {
4059 name: Some("controlled".into()),
4060 base_frequency: 60.0,
4061 buses: vec![b],
4062 sources: vec![vs],
4063 ibrs: vec![ibr],
4064 control_profiles: vec![profile],
4065 ..DistNetwork::default()
4066 };
4067
4068 let out = write_dss(&net);
4069
4070 assert!(out.warnings.is_empty(), "{:?}", out.warnings);
4071 let pv = out
4072 .text
4073 .lines()
4074 .find(|l| l.starts_with("New PVSystem.pv"))
4075 .unwrap();
4076 assert!(pv.contains("WattPriority=No VarFollowInverter=Yes"), "{pv}");
4077 assert!(pv.contains("kvarMax=12"), "{pv}");
4078 assert!(pv.contains("kvarMaxAbs=12"), "{pv}");
4079 assert!(pv.contains("%PminNoVars=10"), "{pv}");
4080 assert!(pv.contains("%PminkvarMax=50"), "{pv}");
4081
4082 let curve = out
4083 .text
4084 .lines()
4085 .find(|l| l.starts_with("New XYcurve.vv_pv"))
4086 .unwrap();
4087 assert!(curve.contains("Xarray=[0.92 0.98 1.02 1.08]"), "{curve}");
4088 assert!(curve.contains("Yarray=[0.44 0 0 -0.44]"), "{curve}");
4089
4090 let inv = out
4091 .text
4092 .lines()
4093 .find(|l| l.starts_with("New InvControl.ivc_pv"))
4094 .unwrap();
4095 assert!(inv.contains("mode=VOLTVAR"), "{inv}");
4096 assert!(inv.contains("vvc_curve1=vv_pv"), "{inv}");
4097 assert!(inv.contains("RefReactivePower=VARMAX"), "{inv}");
4098 assert!(inv.contains("monVoltageCalc=AVG"), "{inv}");
4099 }
4100}