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