1use std::borrow::Cow;
16use std::collections::BTreeMap;
17use std::fmt::Write as _;
18
19use crate::convert::{TextEmission, TextSidecar};
20use crate::diagnostics::codes as C;
21use crate::model::{
22 ActivePowerReference, ConductorMatrix, Configuration, ControlVoltageReference, DistBus,
23 DistControlProfile, DistIbr, DistLineCode, DistLoad, DistLoadVoltageModel, DistTransformer,
24 DistWinding, DistWindingConn, Extras, IbrPrimeMover, IbrTopology, IbrVoltageAggregation,
25 MulticonductorNetwork, ReactivePowerReference, VoltVarControl, VoltWattControl,
26};
27
28use super::read::delta_edges;
29use super::{lex, prop};
30
31#[derive(Clone, Debug, PartialEq)]
33#[non_exhaustive]
34pub struct DssEmitOptions {
35 pub default_load_voltage_bounds: Option<DssLoadVoltageBounds>,
38 pub buscoords_filename: Option<String>,
41}
42
43impl Default for DssEmitOptions {
44 fn default() -> Self {
45 Self {
46 default_load_voltage_bounds: Some(DssLoadVoltageBounds::default()),
47 buscoords_filename: Some("buscoords.csv".to_owned()),
48 }
49 }
50}
51
52#[derive(Clone, Copy, Debug, PartialEq)]
54#[non_exhaustive]
55pub struct DssLoadVoltageBounds {
56 pub vminpu: f64,
57 pub vmaxpu: f64,
58}
59
60impl Default for DssLoadVoltageBounds {
61 fn default() -> Self {
62 Self {
63 vminpu: 0.0,
64 vmaxpu: 2.0,
65 }
66 }
67}
68
69#[cfg(test)]
71pub(crate) fn emit_dss_text(net: &MulticonductorNetwork) -> TextEmission {
72 emit_dss_text_with_options(net, &DssEmitOptions::default())
73}
74
75pub(crate) fn emit_dss_text_with_options(
77 net: &MulticonductorNetwork,
78 options: &DssEmitOptions,
79) -> TextEmission {
80 let mut w = DssWriter {
81 out: String::new(),
82 sidecars: Vec::new(),
83 warnings: crate::diagnostics::Diagnostics::new(),
84 options: options.clone(),
85 grounded: net
86 .buses()
87 .iter()
88 .map(|b| (b.id.to_ascii_lowercase(), b.grounded.clone()))
89 .collect(),
90 terminals: net
91 .buses()
92 .iter()
93 .map(|b| (b.id.to_ascii_lowercase(), b.terminals.clone()))
94 .collect(),
95 kv_estimate: estimate_bus_kv(net),
96 };
97 w.network(net);
98 TextEmission::new(w.out, w.sidecars, w.warnings)
99}
100
101struct DssWriter {
102 out: String,
103 sidecars: Vec<TextSidecar>,
104 warnings: crate::diagnostics::Diagnostics,
105 options: DssEmitOptions,
106 grounded: BTreeMap<String, Vec<String>>,
108 terminals: BTreeMap<String, Vec<String>>,
110 kv_estimate: BTreeMap<String, f64>,
112}
113
114#[derive(Clone, Copy)]
115struct ElementKv<'a> {
116 bus: &'a str,
117 phases: usize,
118 configuration: Configuration,
119 name: &'a str,
120 class: &'a str,
121 typed_kv: Option<f64>,
122}
123
124fn estimate_bus_kv(net: &MulticonductorNetwork) -> BTreeMap<String, f64> {
137 let mut kv: BTreeMap<String, f64> = BTreeMap::new();
138 for vs in net.sources() {
139 let phases = source_phases(net, vs);
140 let basekv = extras_f64(&vs.extras, "basekv").unwrap_or_else(|| source_basekv(vs, phases));
141 let pu = extras_f64(&vs.extras, "pu").unwrap_or(1.0);
142 let vln = basekv * 1e3 * pu / source_chord(phases);
143 if vln > 0.0 {
144 kv.insert(vs.bus.to_ascii_lowercase(), vln);
145 }
146 }
147 let grounded: BTreeMap<String, &Vec<String>> = net
152 .buses()
153 .iter()
154 .map(|b| (b.id.to_ascii_lowercase(), &b.grounded))
155 .collect();
156 for _ in 0..net.buses().len() {
157 let mut changed = false;
158 for l in net.lines() {
159 let (f, t) = (
160 l.bus_from.to_ascii_lowercase(),
161 l.bus_to.to_ascii_lowercase(),
162 );
163 match (kv.get(&f).copied(), kv.get(&t).copied()) {
164 (Some(v), None) => {
165 kv.insert(t, v);
166 changed = true;
167 }
168 (None, Some(v)) => {
169 kv.insert(f, v);
170 changed = true;
171 }
172 _ => {}
173 }
174 }
175 for s in net.switches() {
176 let (f, t) = (
177 s.bus_from.to_ascii_lowercase(),
178 s.bus_to.to_ascii_lowercase(),
179 );
180 match (kv.get(&f).copied(), kv.get(&t).copied()) {
181 (Some(v), None) => {
182 kv.insert(t, v);
183 changed = true;
184 }
185 (None, Some(v)) => {
186 kv.insert(f, v);
187 changed = true;
188 }
189 _ => {}
190 }
191 }
192 for t in net.transformers() {
193 let pn = |w: &DistWinding| {
203 let v = (w.v_ref / 1e3) * 1e3;
204 if winding_is_line_to_neutral(t.phases, w, |b| {
205 grounded.get(b).map(|g| g.as_slice())
206 }) {
207 v
208 } else {
209 v / 3f64.sqrt()
210 }
211 };
212 let known: Option<(usize, f64)> = t
213 .windings
214 .iter()
215 .enumerate()
216 .find_map(|(i, w)| kv.get(&w.bus.to_ascii_lowercase()).map(|v| (i, *v)));
217 if let Some((i, v_known)) = known {
218 let pn_known = pn(&t.windings[i]);
219 if pn_known > 0.0 {
220 for (j, w) in t.windings.iter().enumerate() {
221 if j != i && !kv.contains_key(&w.bus.to_ascii_lowercase()) {
222 kv.insert(w.bus.to_ascii_lowercase(), v_known * pn(w) / pn_known);
223 changed = true;
224 }
225 }
226 }
227 }
228 }
229 if !changed {
230 break;
231 }
232 }
233 kv
234}
235
236fn winding_is_line_to_neutral<'g>(
244 phases: usize,
245 w: &DistWinding,
246 grounded: impl Fn(&str) -> Option<&'g [String]>,
247) -> bool {
248 phases < 2
249 && grounded(&w.bus.to_ascii_lowercase())
250 .is_some_and(|g| w.terminal_map.iter().any(|tm| g.contains(tm)))
251}
252
253fn is_positive_finite(v: f64) -> bool {
259 v.is_finite() && v > 0.0
260}
261
262fn nconds_for(conn: &str, phases: usize) -> usize {
265 if conn == "delta" && phases == 3 {
266 phases
267 } else {
268 phases + 1
269 }
270}
271
272fn strip_emitted_extras(extras: &mut Extras, keys: &[&str]) {
275 for key in keys {
276 extras.remove(*key);
277 }
278}
279
280fn num(v: f64) -> String {
281 let v = if v == 0.0 { 0.0 } else { v };
282 format!("{v}")
283}
284
285fn winding_array(
289 head: &mut String,
290 edits: &mut [String],
291 array_key: &str,
292 scalar_key: &str,
293 values: &[Option<f64>],
294) {
295 if values.iter().all(Option::is_some) {
296 let toks: Vec<String> = values.iter().map(|v| num(v.unwrap_or(0.0))).collect();
297 let _ = write!(head, " {array_key}=({})", toks.join(", "));
298 } else {
299 for (edit, v) in edits.iter_mut().zip(values) {
300 if let Some(v) = v {
301 let _ = write!(edit, " {scalar_key}={}", num(*v));
302 }
303 }
304 }
305}
306
307fn source_chord(phases: usize) -> f64 {
311 if phases <= 1 {
312 1.0
313 } else {
314 2.0 * (std::f64::consts::PI / phases as f64).sin()
315 }
316}
317
318fn source_basekv(vs: &crate::model::VoltageSource, phases: usize) -> f64 {
321 vs.v_magnitude.iter().copied().fold(0.0_f64, f64::max) * source_chord(phases) / 1e3
322}
323
324fn extras_f64(extras: &Extras, key: &str) -> Option<f64> {
327 let v = extras.get(key)?;
328 v.as_f64()
329 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
330 .filter(|f| f.is_finite())
333}
334
335fn extras_usize(extras: &Extras, key: &str) -> Option<usize> {
336 let v = extras.get(key)?;
337 v.as_u64()
338 .and_then(|u| usize::try_from(u).ok())
339 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
340 .or_else(|| {
341 v.as_f64()
342 .filter(|f| f.fract() == 0.0 && *f >= 0.0)
343 .map(|f| f as usize)
344 })
345}
346
347fn zipv_cutoff(value: Option<&serde_json::Value>) -> Option<f64> {
348 let text = value?.as_str()?;
349 lex::Value::new(text)
350 .to_vector(None)
351 .ok()
352 .and_then(|v| v.get(6).copied())
353 .filter(|v| v.is_finite())
354}
355
356fn name_breaks_dss(name: &str, is_bus_id: bool) -> bool {
359 name.contains("//")
360 || name.chars().any(|c| {
361 matches!(
364 c,
365 ' ' | '\t'
366 | '\n'
367 | '\r'
368 | ','
369 | '='
370 | '!'
371 | '"'
372 | '\''
373 | '('
374 | ')'
375 | '['
376 | ']'
377 | '{'
378 | '}'
379 ) || (is_bus_id && c == '.')
380 })
381}
382
383fn dss_value_out(value: &str) -> (String, bool) {
393 if value.is_empty() {
396 return ("()".to_string(), true);
397 }
398 let mut scan = lex::Scanner::new(value, None);
399 let bare = scan.next_param().is_some_and(|p| {
400 p.name.is_none() && !p.value.quoted && p.value.text == value && scan.next_param().is_none()
401 });
402 if bare {
403 return (value.to_string(), true);
404 }
405 for (open, close) in [('(', ')'), ('[', ']'), ('{', '}'), ('"', '"'), ('\'', '\'')] {
406 if !value.contains(close) {
407 return (format!("{open}{value}{close}"), true);
408 }
409 }
410 (value.to_string(), false)
411}
412
413fn source_phases(net: &MulticonductorNetwork, vs: &crate::model::VoltageSource) -> usize {
419 if let Some(p) = extras_usize(&vs.extras, "phases") {
420 return p.max(1);
421 }
422 let energized = vs.v_magnitude.iter().filter(|&&v| v > 0.0).count();
423 if energized > 0
424 && vs.v_magnitude.len() == vs.terminal_map.len()
425 && energized + 1 == vs.v_magnitude.len()
426 && vs.v_magnitude.last().is_some_and(|&v| v == 0.0)
427 {
428 return energized;
429 }
430 let grounded = net
431 .buses()
432 .iter()
433 .find(|b| b.id.eq_ignore_ascii_case(&vs.bus))
434 .map(|b| b.grounded.as_slice())
435 .unwrap_or_default();
436 vs.terminal_map
437 .iter()
438 .filter(|t| !grounded.contains(t))
439 .count()
440 .max(1)
441}
442
443fn seq_parts(extras: &Extras, key: &str) -> Option<(f64, f64)> {
445 let row = extras.get(key)?.as_array()?.first()?.as_array()?;
446 let self_v = row.first()?.as_f64()?;
447 let mutual = row
448 .get(1)
449 .and_then(serde_json::Value::as_f64)
450 .unwrap_or(0.0);
451 Some((self_v, mutual))
452}
453
454impl DssWriter {
455 fn warn(&mut self, info: &'static crate::diagnostics::DiagnosticInfo, msg: impl Into<String>) {
456 self.warnings.push(info, msg);
457 }
458
459 fn warn_map_arity(&mut self, class: &str, name: &str, map_len: usize, nconds: usize) {
467 if map_len < nconds {
468 self.warn(
469 &C::EMIT_DSS_VALUE_COLLAPSED,
470 format!(
471 "{class} {name}: terminal map lists {map_len} of {nconds} conductors; \
472 dss materializes a grounded neutral terminal and the reparsed model \
473 gains one"
474 ),
475 );
476 } else if map_len > nconds {
477 self.warn(
478 &C::EMIT_DSS_VALUE_COLLAPSED,
479 format!(
480 "{class} {name}: terminal map lists {map_len} conductors but the record \
481 addresses {nconds}; dss discards the last {} and the model loses them",
482 map_len - nconds
483 ),
484 );
485 }
486 }
487
488 fn return_terminal_index(&self, bus: &str, map: &[String]) -> Option<usize> {
492 let grounded = self.grounded.get(&bus.to_ascii_lowercase())?;
493 let mut found = map
494 .iter()
495 .enumerate()
496 .filter(|(_, t)| grounded.contains(*t));
497 let (idx, _) = found.next()?;
498 found.next().is_none().then_some(idx)
499 }
500
501 fn endpoint_return_index(
507 &self,
508 bus_from: &str,
509 map_from: &[String],
510 bus_to: &str,
511 map_to: &[String],
512 ) -> Option<usize> {
513 let n = map_from.len();
514 if map_to.len() != n {
515 return None;
516 }
517 let from = self.return_terminal_index(bus_from, map_from);
518 let to = self.return_terminal_index(bus_to, map_to);
519 let k = match (from, to) {
520 (Some(a), Some(b)) if a == b => a,
521 (Some(a), None) => a,
522 (None, Some(b)) => b,
523 _ => return None,
524 };
525 (k + 1 != n).then_some(k)
526 }
527
528 fn return_last(&mut self, class: &str, name: &str, bus: &str, map: &[String]) -> Vec<String> {
535 let Some(index) = self.return_terminal_index(bus, map) else {
536 return map.to_vec();
537 };
538 if index + 1 == map.len() {
539 return map.to_vec();
540 }
541 let mut reordered = map.to_vec();
542 let terminal = reordered.remove(index);
543 self.warn(
544 &C::EMIT_DSS_VALUE_SUBSTITUTED,
545 format!(
546 "{class} {name} on bus {bus}: grounded return `{terminal}` moved last in \
547 the node list; dss reads the list positionally and this record carries \
548 no per conductor data to keep in step"
549 ),
550 );
551 reordered.push(terminal);
552 reordered
553 }
554
555 fn warn_terminal_order(
560 &mut self,
561 class: &str,
562 name: &str,
563 bus: &str,
564 endpoint: Option<&str>,
565 map: &[String],
566 ) {
567 let Some(index) = self.return_terminal_index(bus, map) else {
568 return;
569 };
570 if index + 1 == map.len() {
571 return;
572 }
573
574 let terminal = &map[index];
575 let position = index + 1;
576 let endpoint_text = endpoint.map_or(String::new(), |value| format!(" {value}"));
577 let mut details = serde_json::Map::new();
578 details.insert("class".into(), serde_json::json!(class));
579 details.insert("element_name".into(), serde_json::json!(name));
580 details.insert("bus".into(), serde_json::json!(bus));
581 if let Some(endpoint) = endpoint {
582 details.insert("endpoint".into(), serde_json::json!(endpoint));
583 }
584 details.insert("grounded_terminal".into(), serde_json::json!(terminal));
585 details.insert("position".into(), serde_json::json!(position));
586 details.insert("terminal_count".into(), serde_json::json!(map.len()));
587
588 let mut diagnostic = crate::diagnostics::Diagnostic::of(
589 &C::EMIT_DSS_TERMINAL_ORDER_UNREPRESENTABLE,
590 format!(
591 "{class} {name}{endpoint_text} on bus {bus}: grounded terminal `{terminal}` \
592 is at 1-based position {position} of {}; dss requires the grounded return \
593 last",
594 map.len()
595 ),
596 )
597 .with_details(details)
598 .expect("writer-built details stay within the record bounds");
599 crate::diagnostics::attach_target(&mut diagnostic, format!("{class} {name}"));
600 self.warnings.record(diagnostic);
601 }
602
603 fn source_extra_f64(&mut self, vs: &crate::model::VoltageSource, key: &str) -> Option<f64> {
606 let v = vs.extras.get(key)?;
607 let parsed = v
608 .as_f64()
609 .or_else(|| v.as_str().and_then(|s| s.parse().ok()));
610 if parsed.is_none() {
611 self.warn(
612 &C::EMIT_DSS_VALUE_DEFAULTED,
613 format!(
614 "vsource {}: {key} extra `{v}` does not parse as a number; \
615 using the derived value",
616 vs.name
617 ),
618 );
619 }
620 parsed
621 }
622
623 fn line_out(&mut self, s: &str) {
624 self.out.push_str(s);
625 self.out.push('\n');
626 }
627
628 fn check_name(&mut self, class: &str, name: &str) {
629 if name_breaks_dss(name, false) {
630 self.warn(
631 &C::EMIT_DSS_VALUE_SUBSTITUTED,
632 format!(
633 "{class} `{name}`: name contains characters dss cannot represent; \
634 output will not reparse identically"
635 ),
636 );
637 }
638 }
639
640 fn bus_ref(&mut self, bus: &str, map: &[String]) -> String {
647 let key = bus.to_ascii_lowercase();
648 if name_breaks_dss(bus, true) {
649 self.warn(
650 &C::EMIT_DSS_VALUE_SUBSTITUTED,
651 format!(
652 "bus `{bus}`: id contains characters dss cannot represent; \
653 output will not reparse identically"
654 ),
655 );
656 }
657 let grounded = self.grounded.get(&key).cloned();
658 let terminals = self.terminals.get(&key).cloned().unwrap_or_default();
659 let nodes: Vec<String> = map
660 .iter()
661 .enumerate()
662 .map(|(i, t)| {
663 if grounded.as_ref().is_some_and(|g| g.contains(t)) {
664 "0".to_string()
665 } else if t.parse::<u32>().is_ok() {
666 t.clone()
667 } else {
668 let pos = terminals.iter().position(|x| x == t).unwrap_or(i) + 1;
669 self.warn(
670 &C::EMIT_DSS_VALUE_SUBSTITUTED,
671 format!(
672 "bus {bus}: terminal `{t}` is not a dss node number; \
673 emitted as node {pos}, its position on the bus"
674 ),
675 );
676 pos.to_string()
677 }
678 })
679 .collect();
680 if nodes.is_empty() {
681 bus.to_string()
682 } else {
683 format!("{bus}.{}", nodes.join("."))
684 }
685 }
686
687 fn extras_tail(&mut self, class: &str, name: &str, extras: &Extras) -> String {
690 let table = prop::class_by_name(class);
691 let mut tail = String::new();
692 for (key, value) in extras {
693 if matches!(key.as_str(), "bmopf_subtype") || key.starts_with("pmd_") {
694 continue; }
696 let known = table.is_some_and(|t| t.props.contains(&key.as_str()));
697 let text = value
698 .as_str()
699 .map(ToString::to_string)
700 .or_else(|| value.as_f64().map(num))
701 .or_else(|| value.as_i64().map(|v| v.to_string()));
702 match (known, text) {
703 (true, Some(text)) => {
704 let (out, representable) = dss_value_out(&text);
705 if !representable {
706 self.warn(&C::EMIT_DSS_EXTRAS_DROPPED, format!(
707 "{class} {name}: extra `{key}` value `{text}` contains every \
708 dss quote closer and splits when scanned bare; emitted as \
709 written and a reparse will not see the same value"
710 ));
711 }
712 let _ = write!(tail, " {key}={out}");
713 }
714 _ => self.warn(&C::EMIT_DSS_VALUE_SUBSTITUTED, format!(
715 "{class} {name}: extra `{key}` is not a dss property; dropped from the output"
716 )),
717 }
718 }
719 tail
720 }
721
722 fn matrix_arg(&mut self, m: &ConductorMatrix, what: &str) -> String {
725 let mut short = false;
726 let rows: Vec<String> = m
727 .iter()
728 .enumerate()
729 .map(|(i, row)| {
730 let take = row.len().min(i + 1);
731 let mut vals: Vec<String> = row[..take].iter().map(|v| num(*v)).collect();
732 if take < i + 1 {
733 short = true;
734 vals.resize(i + 1, "0".to_string());
735 }
736 vals.join(" ")
737 })
738 .collect();
739 if short {
740 self.warn(
741 &C::EMIT_DSS_VALUE_DEFAULTED,
742 format!(
743 "{what}: matrix rows are shorter than the lower triangle; \
744 missing entries emitted as 0"
745 ),
746 );
747 }
748 format!("({})", rows.join(" | "))
749 }
750
751 fn take_seq_pair(
754 &mut self,
755 extras: &mut Extras,
756 r_key: &str,
757 x_key: &str,
758 what: &str,
759 ) -> Option<((f64, f64), (f64, f64))> {
760 let r = seq_parts(extras, r_key);
761 let x = seq_parts(extras, x_key);
762 if let (Some(r), Some(x)) = (r, x) {
763 extras.remove(r_key);
764 extras.remove(x_key);
765 return Some((r, x));
766 }
767 if extras.contains_key(r_key) || extras.contains_key(x_key) {
768 let state = |key: &str, parsed: bool| {
769 if !extras.contains_key(key) {
770 format!("`{key}` is missing")
771 } else if parsed {
772 format!("`{key}` is usable")
773 } else {
774 format!("`{key}` is not a numeric matrix")
775 }
776 };
777 self.warn(
778 &C::EMIT_DSS_EXTRAS_DROPPED,
779 format!(
780 "{what}: series impedance extras unusable ({}, {}); left in extras",
781 state(r_key, r.is_some()),
782 state(x_key, x.is_some()),
783 ),
784 );
785 }
786 None
787 }
788
789 fn element_phases(
793 &mut self,
794 extras: &Extras,
795 terminal_map: &[String],
796 configuration: Configuration,
797 class: &str,
798 name: &str,
799 ) -> usize {
800 if let Some(p) = extras_usize(extras, "phases") {
801 return p.max(1);
802 }
803 match configuration {
804 Configuration::Delta => match terminal_map.len() {
805 2 => 1,
806 3 => {
807 self.warn(
808 &C::EMIT_DSS_VALUE_DEFAULTED,
809 format!(
810 "{class} {name}: a delta terminal map with 3 conductors is 2 or 3 \
811 phase and no phases record disambiguates; emitted phases=3"
812 ),
813 );
814 3
815 }
816 n => {
817 self.warn(
818 &C::EMIT_DSS_VALUE_DEFAULTED,
819 format!(
820 "{class} {name}: a delta terminal map with {n} conductors has no \
821 dss phases mapping; emitted phases={}",
822 n.max(1)
823 ),
824 );
825 n.max(1)
826 }
827 },
828 Configuration::Wye => terminal_map.len().saturating_sub(1).max(1),
829 _ => 1,
830 }
831 }
832
833 fn network(&mut self, net: &MulticonductorNetwork) {
834 self.line_out("Clear");
835 self.line_out(&format!(
836 "Set DefaultBaseFrequency={}",
837 num(net.base_frequency())
838 ));
839 self.out.push('\n');
840
841 for source in net
842 .sources()
843 .iter()
844 .filter(|source| source.energy_cost_rate.is_some())
845 {
846 self.warn(
847 &C::EMIT_DSS_FIELD_DROPPED,
848 format!(
849 "voltage source {}: energy_cost_rate has no target field",
850 source.name
851 ),
852 );
853 }
854 self.buscoords(net);
855 self.sources(net);
856 self.line_codes(net);
857 self.lines(net);
858 self.switches(net);
859 self.transformers(net);
860 self.loads(net);
861 self.shunts(net);
862 self.capacitors(net);
863 self.generators(net);
864 self.ibrs(net);
865
866 for u in net.untyped_objects() {
867 self.warn(
868 &C::EMIT_DSS_RECORD_DROPPED,
869 format!(
870 "{} {}: untyped object is not regenerated in canonical dss output",
871 u.class, u.name
872 ),
873 );
874 }
875 for b in net.buses() {
876 self.bus_extras(b);
877 }
878
879 self.out.push('\n');
880 for (key, value) in net.options() {
886 if key.is_empty() {
887 self.warn(
888 &C::EMIT_DSS_VALUE_DEFAULTED,
889 format!(
890 "option `{value}` has no name; not regenerated in canonical dss output"
891 ),
892 );
893 continue;
894 }
895 let key_lc = key.to_ascii_lowercase();
904 if "voltagebases".starts_with(&key_lc)
905 || (key_lc.len() >= "defaultb".len() && "defaultbasefrequency".starts_with(&key_lc))
906 {
907 continue;
908 }
909 let (text, representable) = dss_value_out(value);
910 if !representable {
911 self.warn(
912 &C::EMIT_DSS_VALUE_SUBSTITUTED,
913 format!(
914 "option `{key}`: value `{value}` contains every dss quote closer \
915 and splits when scanned bare; emitted as written and a reparse \
916 will not see the same value"
917 ),
918 );
919 }
920 self.line_out(&format!("Set {key}={text}"));
921 }
922 for (verb, args) in net.commands() {
923 if verb.eq_ignore_ascii_case("calcvoltagebases") || verb.eq_ignore_ascii_case("solve") {
924 continue; }
926 let shown = if args.is_empty() {
927 verb.clone()
928 } else {
929 format!("{verb} {args}")
930 };
931 self.warn(
932 &C::EMIT_DSS_RECORD_DROPPED,
933 format!("command `{shown}` is not regenerated in canonical dss output"),
934 );
935 }
936 let mut bases: Vec<f64> = self
937 .kv_estimate
938 .values()
939 .map(|v| v * 3f64.sqrt() / 1e3)
940 .collect();
941 bases.sort_by(f64::total_cmp);
942 bases.dedup_by(|a, b| (*a - *b).abs() < 1e-9);
943 if !bases.is_empty() {
944 let list: Vec<String> = bases.iter().map(|v| num(*v)).collect();
945 self.line_out(&format!("Set VoltageBases=[{}]", list.join(", ")));
946 self.line_out("Calcvoltagebases");
947 }
948 self.line_out("Solve");
949 }
950
951 fn bus_extras(&mut self, b: &DistBus) {
952 for key in b.extras.keys() {
953 if key == "x" || key == "y" {
954 continue; }
956 self.warnings.push(
957 &C::EMIT_DSS_EXTRAS_DROPPED,
958 format!(
959 "bus {}: extra `{key}` is not regenerated in canonical dss output",
960 b.id
961 ),
962 );
963 }
964 for (field, present) in [
965 ("v_min", b.v_min.is_some() || b.v_min_phase.is_some()),
966 ("v_max", b.v_max.is_some() || b.v_max_phase.is_some()),
967 ("vpn_min", b.vpn_min.is_some()),
968 ("vpn_max", b.vpn_max.is_some()),
969 ("vpp_min", b.vpp_min.is_some()),
970 ("vpp_max", b.vpp_max.is_some()),
971 ("vpos_min", b.vpos_min.is_some()),
972 ("vpos_max", b.vpos_max.is_some()),
973 ("vneg_max", b.vneg_max.is_some()),
974 ("vzero_max", b.vzero_max.is_some()),
975 ("vn_max", b.vn_max.is_some()),
976 ] {
977 if present {
978 self.warnings.push(
979 &C::EMIT_DSS_FIELD_DROPPED,
980 format!(
981 "bus {}: `{field}` voltage bounds have no dss expression; dropped",
982 b.id
983 ),
984 );
985 }
986 }
987 }
988
989 fn buscoords(&mut self, net: &MulticonductorNetwork) {
990 let rows: Vec<(&DistBus, crate::geo::DistLocation)> = net
991 .buses()
992 .iter()
993 .filter_map(|b| b.location.map(|location| (b, location)))
994 .collect();
995 if rows.is_empty() {
996 return;
997 }
998 let Some(path) = self.options.buscoords_filename.clone() else {
999 self.warn(
1000 &C::EMIT_DSS_FIELD_DROPPED,
1001 "typed bus locations have no OpenDSS buscoords filename; dropped",
1002 );
1003 return;
1004 };
1005 if path.is_empty() {
1006 self.warn(
1007 &C::EMIT_DSS_FIELD_DROPPED,
1008 "typed bus locations have an empty OpenDSS buscoords filename; dropped",
1009 );
1010 return;
1011 }
1012 let (path_out, path_representable) = dss_value_out(&path);
1013 if !path_representable {
1014 self.warn(&C::EMIT_DSS_VALUE_SUBSTITUTED, format!(
1015 "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"
1016 ));
1017 }
1018
1019 let mut text = String::new();
1020 for (bus, location) in rows {
1021 if !location.x.is_finite() || !location.y.is_finite() {
1022 self.warn(
1023 &C::EMIT_DSS_FIELD_DROPPED,
1024 format!(
1025 "bus {}: nonfinite location is not emitted to OpenDSS buscoords",
1026 bus.id
1027 ),
1028 );
1029 continue;
1030 }
1031 let (bus_out, bus_representable) = dss_value_out(&bus.id);
1032 if !bus_representable {
1033 self.warn(&C::EMIT_DSS_FIELD_DROPPED, format!(
1034 "bus {}: id contains every dss quote closer and splits in buscoords; coordinates dropped",
1035 bus.id
1036 ));
1037 continue;
1038 }
1039 let _ = writeln!(text, "{bus_out},{},{}", num(location.x), num(location.y));
1040 }
1041 if text.is_empty() {
1042 return;
1043 }
1044 self.line_out(&format!("Buscoords {path_out}"));
1045 self.sidecars.push(TextSidecar { path, text });
1046 }
1047
1048 fn sources(&mut self, net: &MulticonductorNetwork) {
1049 let mut order: Vec<usize> = (0..net.sources().len()).collect();
1050 if let Some(source_idx) = net
1051 .sources()
1052 .iter()
1053 .position(|vs| vs.name.eq_ignore_ascii_case("source"))
1054 {
1055 order.swap(0, source_idx);
1056 }
1057 for (i, source_idx) in order.into_iter().enumerate() {
1058 let vs = &net.sources()[source_idx];
1059 let phases = source_phases(net, vs);
1060 let energized = vs.v_magnitude.iter().filter(|&&v| v > 0.0).count();
1061 if energized > 0 && energized != phases {
1062 self.warn(
1063 &C::EMIT_DSS_VALUE_DEFAULTED,
1064 format!(
1065 "vsource {}: emitted phases={phases} but {energized} v_magnitude \
1066 entries are positive; a reparse energizes all {phases}",
1067 vs.name
1068 ),
1069 );
1070 }
1071 self.warn_map_arity("vsource", &vs.name, vs.terminal_map.len(), phases + 1);
1072 let basekv = self
1073 .source_extra_f64(vs, "basekv")
1074 .unwrap_or_else(|| source_basekv(vs, phases));
1075 let pu = self.source_extra_f64(vs, "pu").unwrap_or(1.0);
1076 let angle = self
1077 .source_extra_f64(vs, "angle")
1078 .unwrap_or_else(|| vs.v_angle.first().copied().unwrap_or(0.0).to_degrees());
1079 let head = if i == 0 {
1080 let name = net.name().clone().unwrap_or_else(|| "converted".into());
1081 self.check_name("circuit", &name);
1082 format!("New Circuit.{name}")
1083 } else {
1084 self.check_name("vsource", &vs.name);
1085 format!("New Vsource.{}", vs.name)
1086 };
1087 let mut s = format!(
1088 "{head} basekv={} pu={} angle={} phases={phases} bus1={}",
1089 num(basekv),
1090 num(pu),
1091 num(angle),
1092 self.bus_ref(&vs.bus, &vs.terminal_map),
1093 );
1094 let mut extras = vs.extras.clone();
1095 extras.remove("basekv");
1096 extras.remove("pu");
1097 extras.remove("angle");
1098 extras.remove("phases"); let what = format!("vsource {}", vs.name);
1103 if let Some(((rs, rm), (xs, xm))) = self.take_seq_pair(&mut extras, "rs", "xs", &what) {
1104 let _ = write!(
1107 s,
1108 " z0=({}, {}) z1=({}, {})",
1109 num(rs + 2.0 * rm),
1110 num(xs + 2.0 * xm),
1111 num(rs - rm),
1112 num(xs - xm)
1113 );
1114 }
1115 s.push_str(&self.extras_tail("vsource", &vs.name, &extras));
1116 self.line_out(&s);
1117 }
1118 self.out.push('\n');
1119 }
1120
1121 fn line_codes(&mut self, net: &MulticonductorNetwork) {
1122 let omega_nf = std::f64::consts::TAU * net.base_frequency() * 1e-9;
1123 for c in net.line_codes() {
1124 self.emit_linecode(c, omega_nf);
1125 }
1126 let mut permuted: std::collections::HashSet<(String, usize)> =
1132 std::collections::HashSet::new();
1133 for l in net.lines() {
1134 let Some(k) = self.endpoint_return_index(
1135 &l.bus_from,
1136 &l.terminal_map_from,
1137 &l.bus_to,
1138 &l.terminal_map_to,
1139 ) else {
1140 continue;
1141 };
1142 let Some(code) = net
1143 .line_codes()
1144 .iter()
1145 .find(|c| c.name.eq_ignore_ascii_case(&l.linecode))
1146 else {
1147 continue;
1148 };
1149 if code.n_conductors != l.terminal_map_from.len()
1150 || !permuted.insert((code.name.to_ascii_lowercase(), k))
1151 {
1152 continue;
1153 }
1154 let perm = return_permutation(k, code.n_conductors);
1155 let mut clone = code.clone();
1156 clone.name = format!("{}_ret{k}", code.name);
1157 clone.r_series = permute_symmetric(&code.r_series, &perm);
1158 clone.x_series = permute_symmetric(&code.x_series, &perm);
1159 clone.g_from = permute_symmetric(&code.g_from, &perm);
1160 clone.b_from = permute_symmetric(&code.b_from, &perm);
1161 clone.g_to = permute_symmetric(&code.g_to, &perm);
1162 clone.b_to = permute_symmetric(&code.b_to, &perm);
1163 clone.i_max = code.i_max.as_ref().map(|v| permute_padded(v, &perm));
1164 clone.s_max = code.s_max.as_ref().map(|v| permute_padded(v, &perm));
1165 self.emit_linecode(&clone, omega_nf);
1166 }
1167 self.out.push('\n');
1168 }
1169
1170 fn emit_linecode(&mut self, c: &DistLineCode, omega_nf: f64) {
1171 {
1172 self.check_name("linecode", &c.name);
1173 let n = c.n_conductors;
1174 let what = format!("linecode {}", c.name);
1175 let mut s = format!("New Linecode.{} nphases={n} units=m", c.name);
1176 let rm = self.matrix_arg(&c.r_series, &what);
1177 let _ = write!(s, " rmatrix={rm}");
1178 let xm = self.matrix_arg(&c.x_series, &what);
1179 let _ = write!(s, " xmatrix={xm}");
1180 let c_nf: ConductorMatrix = c
1183 .b_from
1184 .iter()
1185 .map(|row| row.iter().map(|b| 2.0 * b / omega_nf).collect())
1186 .collect();
1187 let cm = self.matrix_arg(&c_nf, &what);
1188 let _ = write!(s, " cmatrix={cm}");
1189 match c.i_max.as_deref() {
1190 Some([amps, ..]) if amps.is_finite() => {
1191 let _ = write!(s, " emergamps={}", num(*amps));
1192 }
1193 Some([_, ..]) => self.warn(
1194 &C::EMIT_DSS_FIELD_DROPPED,
1195 format!(
1196 "linecode {}: first i_max entry is nonfinite (an unbounded \
1197 conductor); emergamps not emitted",
1198 c.name
1199 ),
1200 ),
1201 Some([]) => self.warn(
1202 &C::EMIT_DSS_FIELD_DROPPED,
1203 format!("linecode {}: i_max is empty; emergamps not emitted", c.name),
1204 ),
1205 None => {}
1206 }
1207 if !c.g_from.iter().flatten().all(|&g| g == 0.0) {
1208 self.warn(
1209 &C::EMIT_DSS_FIELD_DROPPED,
1210 format!(
1211 "linecode {}: shunt conductance has no dss linecode field; dropped",
1212 c.name
1213 ),
1214 );
1215 }
1216 if c.source.is_some() {
1217 self.warn(
1218 &C::EMIT_DSS_EXTRAS_DROPPED,
1219 format!(
1220 "linecode {}: matrix provenance `source` has no dss field; dropped",
1221 c.name
1222 ),
1223 );
1224 }
1225 let mut extras = c.extras.clone();
1226 extras.remove("units"); s.push_str(&self.extras_tail("linecode", &c.name, &extras));
1228 self.line_out(&s);
1229 }
1230 }
1231
1232 #[allow(clippy::too_many_lines)]
1235 fn lines(&mut self, net: &MulticonductorNetwork) {
1236 for l in net.lines() {
1237 self.check_name("line", &l.name);
1238 let reorder = self
1242 .endpoint_return_index(
1243 &l.bus_from,
1244 &l.terminal_map_from,
1245 &l.bus_to,
1246 &l.terminal_map_to,
1247 )
1248 .filter(|_| {
1249 net.line_codes().iter().any(|c| {
1250 c.name.eq_ignore_ascii_case(&l.linecode)
1251 && c.n_conductors == l.terminal_map_from.len()
1252 })
1253 });
1254 let (map_from, map_to, code_name);
1255 if let Some(k) = reorder {
1256 let perm = return_permutation(k, l.terminal_map_from.len());
1257 map_from = permute_names(&l.terminal_map_from, &perm);
1258 map_to = permute_names(&l.terminal_map_to, &perm);
1259 code_name = format!("{}_ret{k}", l.linecode);
1260 self.warn(
1261 &C::EMIT_DSS_VALUE_SUBSTITUTED,
1262 format!(
1263 "line {}: grounded return moved last in both node lists; \
1264 linecode `{code_name}` carries the matrices permuted in step",
1265 l.name
1266 ),
1267 );
1268 } else {
1269 self.warn_terminal_order(
1270 "line",
1271 &l.name,
1272 &l.bus_from,
1273 Some("bus1"),
1274 &l.terminal_map_from,
1275 );
1276 self.warn_terminal_order(
1277 "line",
1278 &l.name,
1279 &l.bus_to,
1280 Some("bus2"),
1281 &l.terminal_map_to,
1282 );
1283 map_from = l.terminal_map_from.clone();
1284 map_to = l.terminal_map_to.clone();
1285 code_name = l.linecode.clone();
1286 }
1287 let phases = l.terminal_map_from.len();
1288 let mut s = format!(
1289 "New Line.{} bus1={} bus2={} phases={phases} linecode={} length={} units=m",
1290 l.name,
1291 self.bus_ref(&l.bus_from, &map_from),
1292 self.bus_ref(&l.bus_to, &map_to),
1293 code_name,
1294 self.checked_num(l.length, 1.0, &format!("line {}: length", l.name)),
1295 );
1296 let mut extras = l.extras.clone();
1297 extras.remove("units"); let line_i_max = match reorder {
1299 Some(k) => l
1300 .i_max
1301 .as_ref()
1302 .map(|v| permute_padded(v, &return_permutation(k, l.terminal_map_from.len()))),
1303 None => l.i_max.clone(),
1304 };
1305 match line_i_max.as_deref() {
1308 Some([amps, rest @ ..]) if is_positive_finite(*amps) => {
1309 extras.remove("emergamps");
1310 let _ = write!(s, " emergamps={}", num(*amps));
1311 #[allow(clippy::float_cmp)]
1314 let uneven = rest.iter().any(|a| *a != *amps);
1315 if uneven {
1316 self.warn(
1317 &C::EMIT_DSS_VALUE_COLLAPSED,
1318 format!(
1319 "line {}: i_max is not equal on all phases; emergamps \
1320 holds the first phase only",
1321 l.name
1322 ),
1323 );
1324 }
1325 }
1326 Some([_, ..]) => self.warn(
1327 &C::EMIT_DSS_FIELD_DROPPED,
1328 format!(
1329 "line {}: first i_max entry is nonfinite (an unbounded \
1330 conductor); emergamps not emitted",
1331 l.name
1332 ),
1333 ),
1334 Some([]) => self.warn(
1335 &C::EMIT_DSS_FIELD_DROPPED,
1336 format!("line {}: i_max is empty; emergamps not emitted", l.name),
1337 ),
1338 None => {}
1339 }
1340 if l.s_max.is_some() {
1341 self.warn(
1345 &C::EMIT_DSS_FIELD_DROPPED,
1346 format!(
1347 "line {}: `s_max` is an apparent power limit and dss Line \
1348 states current ratings only; dropped",
1349 l.name
1350 ),
1351 );
1352 }
1353 s.push_str(&self.extras_tail("line", &l.name, &extras));
1354 self.line_out(&s);
1355 }
1356 self.out.push('\n');
1357 }
1358
1359 fn switches(&mut self, net: &MulticonductorNetwork) {
1360 for sw in net.switches() {
1361 self.check_name("line", &sw.name);
1362 let reorder = self.endpoint_return_index(
1365 &sw.bus_from,
1366 &sw.terminal_map_from,
1367 &sw.bus_to,
1368 &sw.terminal_map_to,
1369 );
1370 let (map_from, map_to);
1371 if let Some(k) = reorder {
1372 let perm = return_permutation(k, sw.terminal_map_from.len());
1373 map_from = permute_names(&sw.terminal_map_from, &perm);
1374 map_to = permute_names(&sw.terminal_map_to, &perm);
1375 self.warn(
1376 &C::EMIT_DSS_VALUE_SUBSTITUTED,
1377 format!(
1378 "switch {}: grounded return moved last in both node lists; \
1379 the switch carries no per conductor data to keep in step",
1380 sw.name
1381 ),
1382 );
1383 } else {
1384 self.warn_terminal_order(
1385 "switch",
1386 &sw.name,
1387 &sw.bus_from,
1388 Some("bus1"),
1389 &sw.terminal_map_from,
1390 );
1391 self.warn_terminal_order(
1392 "switch",
1393 &sw.name,
1394 &sw.bus_to,
1395 Some("bus2"),
1396 &sw.terminal_map_to,
1397 );
1398 map_from = sw.terminal_map_from.clone();
1399 map_to = sw.terminal_map_to.clone();
1400 }
1401 let phases = sw.terminal_map_from.len();
1402 let mut s = format!(
1403 "New Line.{} bus1={} bus2={} phases={phases} switch=y",
1404 sw.name,
1405 self.bus_ref(&sw.bus_from, &map_from),
1406 self.bus_ref(&sw.bus_to, &map_to),
1407 );
1408 match sw.i_max.as_deref() {
1409 Some([amps, ..]) if amps.is_finite() => {
1410 let _ = write!(s, " emergamps={}", num(*amps));
1411 }
1412 Some([_, ..]) => self.warn(
1413 &C::EMIT_DSS_FIELD_DROPPED,
1414 format!(
1415 "line {}: first i_max entry is nonfinite (an unbounded \
1416 conductor); emergamps not emitted",
1417 sw.name
1418 ),
1419 ),
1420 Some([]) => self.warn(
1421 &C::EMIT_DSS_FIELD_DROPPED,
1422 format!("line {}: i_max is empty; emergamps not emitted", sw.name),
1423 ),
1424 None => {}
1425 }
1426 let mut extras = sw.extras.clone();
1431 let what = format!("line {}", sw.name);
1432 if let Some(((rs, rm), (xs, xm))) =
1433 self.take_seq_pair(&mut extras, "pmd_rs", "pmd_xs", &what)
1434 {
1435 let _ = write!(
1436 s,
1437 " c0=0 c1=0 r0={} r1={} x0={} x1={}",
1438 num((rs + 2.0 * rm) / 0.001),
1439 num((rs - rm) / 0.001),
1440 num((xs + 2.0 * xm) / 0.001),
1441 num((xs - xm) / 0.001)
1442 );
1443 }
1444 s.push_str(&self.extras_tail("line", &sw.name, &extras));
1445 self.line_out(&s);
1446 self.line_out(&format!(
1447 "New SwtControl.{}_state SwitchedObj=Line.{} Action={}",
1448 sw.name,
1449 sw.name,
1450 if sw.open { "open" } else { "close" },
1451 ));
1452 }
1453 self.out.push('\n');
1454 }
1455
1456 fn transformers(&mut self, net: &MulticonductorNetwork) {
1457 for t in net.transformers() {
1458 self.check_name("transformer", &t.name);
1459 let nw = t.windings.len();
1460 let buses: Vec<String> = t
1461 .windings
1462 .iter()
1463 .map(|w| self.bus_ref(&w.bus, &w.terminal_map))
1464 .collect();
1465 let conns: Vec<&str> = t
1466 .windings
1467 .iter()
1468 .map(|w| match w.conn {
1469 DistWindingConn::Wye => "wye",
1470 DistWindingConn::Delta => "delta",
1471 })
1472 .collect();
1473 let kvs: Vec<Option<f64>> = t
1474 .windings
1475 .iter()
1476 .enumerate()
1477 .map(|(idx, w)| self.winding_kv(t, idx, w))
1478 .collect();
1479 let kvas: Vec<Option<f64>> = t
1480 .windings
1481 .iter()
1482 .enumerate()
1483 .map(|(idx, w)| {
1484 if is_positive_finite(w.s_rating) {
1485 Some(w.s_rating / 1e3)
1486 } else {
1487 self.warn(
1488 &C::EMIT_DSS_VALUE_DEFAULTED,
1489 format!(
1490 "transformer {}: winding {} has no usable rating; kva not \
1491 emitted (the OpenDSS default applies)",
1492 t.name,
1493 idx + 1
1494 ),
1495 );
1496 None
1497 }
1498 })
1499 .collect();
1500 let rs: Vec<String> = t
1501 .windings
1502 .iter()
1503 .enumerate()
1504 .map(|(idx, w)| {
1505 let what = format!("transformer {}: winding {} %r", t.name, idx + 1);
1506 self.checked_num(w.r_pct, 0.0, &what)
1507 })
1508 .collect();
1509 let taps: Vec<String> = t.windings.iter().map(|w| num(w.tap)).collect();
1510 let mut s = format!(
1511 "New Transformer.{} phases={} windings={nw} buses=({}) conns=({})",
1512 t.name,
1513 t.phases,
1514 buses.join(", "),
1515 conns.join(", "),
1516 );
1517 let mut edits: Vec<String> = vec![String::new(); nw];
1518 winding_array(&mut s, &mut edits, "kvs", "kv", &kvs);
1519 winding_array(&mut s, &mut edits, "kvas", "kva", &kvas);
1520 let _ = write!(s, " %Rs=({}) taps=({})", rs.join(", "), taps.join(", "));
1521 self.transformer_reactances(t, &mut s);
1522 let mut extras = t.extras.clone();
1523 if nw > 3 && t.xsc_pct.len() == nw * (nw - 1) / 2 {
1525 extras.remove("xscarray");
1526 }
1527 if let Some((loss, imag)) = crate::model::transformer_no_load_percentages(t) {
1528 extras.remove("no_load_shunt");
1529 extras.insert("%noloadloss".into(), serde_json::json!(loss));
1530 extras.insert("%imag".into(), serde_json::json!(imag));
1531 }
1532 s.push_str(&self.extras_tail("transformer", &t.name, &extras));
1533 self.line_out(&s);
1534 for (idx, w) in t.windings.iter().enumerate() {
1535 if let Some(r) = w.r_neutral {
1536 let _ = write!(edits[idx], " rneut={}", num(r));
1537 }
1538 if let Some(x) = w.x_neutral {
1539 let _ = write!(edits[idx], " xneut={}", num(x));
1540 }
1541 }
1542 for (idx, edit) in edits.iter().enumerate() {
1543 if !edit.is_empty() {
1544 self.line_out(&format!("~ wdg={}{edit}", idx + 1));
1545 }
1546 }
1547 }
1548 self.out.push('\n');
1549 }
1550
1551 fn transformer_reactances(&mut self, t: &DistTransformer, output: &mut String) {
1553 let nw = t.windings.len();
1554 if nw > 3 {
1555 let expected = nw * (nw - 1) / 2;
1556 if t.xsc_pct.len() != expected {
1557 self.warn(
1558 &C::EMIT_DSS_VALUE_DEFAULTED,
1559 format!("transformer {}: xsc_pct has {} values; expected {expected}; OpenDSS defaults apply to absent pairs", t.name, t.xsc_pct.len()),
1560 );
1561 }
1562 let values: Vec<_> = t.xsc_pct.iter().map(|x| num(*x)).collect();
1563 let _ = write!(output, " xscarray=({})", values.join(", "));
1564 } else if let Some(xhl) = t.xsc_pct.first() {
1565 let _ = write!(output, " xhl={}", num(*xhl));
1566 if t.xsc_pct.len() >= 3 {
1567 let xlt = self.star_xlt(t);
1568 let _ = write!(output, " xht={} xlt={}", num(t.xsc_pct[1]), num(xlt));
1569 }
1570 } else {
1571 self.warn(
1572 &C::EMIT_DSS_VALUE_DEFAULTED,
1573 format!("transformer {}: xsc_pct is empty; emitted xhl=0", t.name),
1574 );
1575 output.push_str(" xhl=0");
1576 }
1577 }
1578
1579 fn star_xlt(&mut self, t: &DistTransformer) -> f64 {
1586 let (xhl, xht, xlt) = (t.xsc_pct[0], t.xsc_pct[1], t.xsc_pct[2]);
1587 if xlt > 0.0 && xlt.is_finite() {
1588 return xlt;
1589 }
1590 #[allow(clippy::float_cmp)]
1591 let lumped_on_primary = xhl == xht && xhl > 0.0 && xhl.is_finite();
1592 if !lumped_on_primary {
1593 self.warn(
1594 &C::EMIT_DSS_VALUE_SUBSTITUTED,
1595 format!(
1596 "transformer {}: xlt={} is not a reactance dss can solve, and the \
1597 other two arms do not determine a replacement; emitted as stated",
1598 t.name,
1599 num(xlt)
1600 ),
1601 );
1602 return xlt;
1603 }
1604 let repaired = 2.0 / 3.0 * xhl;
1605 self.warn(
1606 &C::EMIT_DSS_VALUE_COLLAPSED,
1607 format!(
1608 "transformer {}: the source puts the whole leakage on the primary arm, \
1609 leaving xlt={}; dss solves that star as a collapsed secondary, so \
1610 xlt={} went out instead, holding xhl={}",
1611 t.name,
1612 num(xlt),
1613 num(repaired),
1614 num(xhl)
1615 ),
1616 );
1617 repaired
1618 }
1619
1620 fn winding_kv(
1627 &mut self,
1628 t: &crate::model::DistTransformer,
1629 idx: usize,
1630 w: &DistWinding,
1631 ) -> Option<f64> {
1632 if is_positive_finite(w.v_ref) {
1633 return Some(w.v_ref / 1e3);
1634 }
1635 let bus = w.bus.to_ascii_lowercase();
1636 let scale =
1637 if winding_is_line_to_neutral(t.phases, w, |b| self.grounded.get(b).map(Vec::as_slice))
1638 {
1639 1.0
1640 } else {
1641 3f64.sqrt()
1642 };
1643 let Some(v_pn) = self.kv_estimate.get(&bus).copied() else {
1644 self.warn(
1645 &C::EMIT_DSS_VALUE_DEFAULTED,
1646 format!(
1647 "transformer {}: winding {} has no rated voltage and bus `{}` has \
1648 no voltage estimate; kv not emitted (the OpenDSS default applies)",
1649 t.name,
1650 idx + 1,
1651 w.bus
1652 ),
1653 );
1654 return None;
1655 };
1656 let kv = v_pn * scale / 1e3;
1657 self.warn(
1658 &C::EMIT_DSS_FIELD_DROPPED,
1659 format!(
1660 "transformer {}: winding {} has no rated voltage; kv={} derived \
1661 from the bus `{}` voltage estimate",
1662 t.name,
1663 idx + 1,
1664 num(kv),
1665 w.bus
1666 ),
1667 );
1668 Some(kv)
1669 }
1670
1671 fn load_parts<'l>(&mut self, l: &'l DistLoad) -> Vec<Cow<'l, DistLoad>> {
1682 let n = l.p_nom.len();
1683 #[allow(clippy::float_cmp)]
1687 let uniform = |xs: &[f64]| xs.iter().all(|x| *x == xs[0]);
1688 let stated_per_phase = n >= 2 && l.q_nom.len() == n;
1689 let unbalanced = stated_per_phase && !(uniform(&l.p_nom) && uniform(&l.q_nom));
1690 let return_index = self.return_terminal_index(&l.bus, &l.terminal_map);
1695 let misordered = return_index.is_some_and(|i| i + 1 != l.terminal_map.len());
1696 if !unbalanced && !misordered {
1697 return vec![Cow::Borrowed(l)];
1698 }
1699 if l.configuration == Configuration::Delta {
1700 self.warn(
1701 &C::EMIT_DSS_VALUE_COLLAPSED,
1702 format!(
1703 "load {}: per phase power on a delta load has no dss expression; \
1704 emitted one balanced Load carrying the total",
1705 l.name
1706 ),
1707 );
1708 return vec![Cow::Borrowed(l)];
1709 }
1710 let (hot_indices, return_terminal) = match return_index {
1713 Some(i) => (
1714 (0..l.terminal_map.len()).filter(|j| *j != i).collect(),
1715 Some(l.terminal_map[i].clone()),
1716 ),
1717 None if l.terminal_map.len() > n => ((0..n).collect(), Some(l.terminal_map[n].clone())),
1718 None => ((0..l.terminal_map.len()).collect::<Vec<_>>(), None),
1719 };
1720 if !stated_per_phase || hot_indices.len() != n {
1721 self.warn(
1722 &C::EMIT_DSS_VALUE_COLLAPSED,
1723 format!(
1724 "load {}: {} over a terminal map with {} phase conductors; \
1725 emitted one Load carrying the total",
1726 l.name,
1727 if stated_per_phase {
1728 format!("per phase power over {n} phases")
1729 } else {
1730 "one power value".to_string()
1731 },
1732 hot_indices.len()
1733 ),
1734 );
1735 return vec![Cow::Borrowed(l)];
1736 }
1737 hot_indices
1738 .into_iter()
1739 .enumerate()
1740 .map(|(i, hot)| {
1741 let mut part = l.clone();
1742 part.name = format!("{}_{}", l.name, l.terminal_map[hot]);
1743 part.terminal_map = match &return_terminal {
1744 Some(r) => vec![l.terminal_map[hot].clone(), r.clone()],
1745 None => vec![l.terminal_map[hot].clone()],
1746 };
1747 part.configuration = Configuration::Wye;
1748 part.p_nom = vec![l.p_nom[i]];
1749 part.q_nom = vec![l.q_nom[i]];
1750 for key in ["phases", "conn", "kv", "pf"] {
1755 part.extras.remove(key);
1756 }
1757 Cow::Owned(part)
1758 })
1759 .collect()
1760 }
1761
1762 fn loads(&mut self, net: &MulticonductorNetwork) {
1763 for load in net.loads() {
1764 for part in self.load_parts(load) {
1765 self.write_load(&part);
1766 }
1767 }
1768 self.out.push('\n');
1769 }
1770
1771 fn write_load(&mut self, l: &DistLoad) {
1774 self.check_name("load", &l.name);
1775 let phases =
1776 self.element_phases(&l.extras, &l.terminal_map, l.configuration, "load", &l.name);
1777 let conn = self.element_conn(&l.extras, l.configuration, &l.bus, &l.terminal_map);
1778 let nconds = nconds_for(conn, phases);
1781 self.warn_map_arity("load", &l.name, l.terminal_map.len(), nconds);
1782 let kw: f64 = l.p_nom.iter().sum::<f64>() / 1e3;
1783 let kvar: f64 = l.q_nom.iter().sum::<f64>() / 1e3;
1784 let typed_kv = self.load_nominal_kv(&l.voltage_model, phases, l.configuration, &l.name);
1785 let kv = self.element_kv(
1786 &l.extras,
1787 ElementKv {
1788 bus: &l.bus,
1789 phases,
1790 configuration: l.configuration,
1791 name: &l.name,
1792 class: "load",
1793 typed_kv,
1794 },
1795 );
1796 let mut extras = l.extras.clone();
1797 strip_emitted_extras(&mut extras, &["kv", "phases", "conn"]);
1798 let retained_model = extras.remove("model");
1799 let retained_zipv = extras.remove("zipv");
1800 let reactive = match extras.remove("pf").and_then(|v| v.as_f64()) {
1803 Some(pf) => format!("pf={}", num(pf)),
1804 None => format!("kvar={}", num(kvar)),
1805 };
1806 let mut s = format!(
1807 "New Load.{} bus1={} phases={phases} conn={conn} kv={} kw={} {reactive}",
1808 l.name,
1809 self.bus_ref(&l.bus, &l.terminal_map),
1810 num(kv),
1811 num(kw),
1812 );
1813 match &l.voltage_model {
1814 DistLoadVoltageModel::ConstantPower { .. } => {
1815 if let Some(model) = retained_model {
1816 extras.insert("model".into(), model);
1817 }
1818 }
1819 DistLoadVoltageModel::ConstantImpedance { .. } => {
1820 s.push_str(" model=2");
1821 }
1822 DistLoadVoltageModel::ConstantCurrent { .. } => {
1823 s.push_str(" model=5");
1824 }
1825 DistLoadVoltageModel::Zip {
1826 alpha_z,
1827 alpha_i,
1828 alpha_p,
1829 beta_z,
1830 beta_i,
1831 beta_p,
1832 ..
1833 } => {
1834 s.push_str(" model=8");
1835 if let (Some(az), Some(ai), Some(ap), Some(bz), Some(bi), Some(bp)) = (
1836 alpha_z.first(),
1837 alpha_i.first(),
1838 alpha_p.first(),
1839 beta_z.first(),
1840 beta_i.first(),
1841 beta_p.first(),
1842 ) {
1843 let cutoff = zipv_cutoff(retained_zipv.as_ref()).unwrap_or(0.0);
1844 let _ = write!(
1845 s,
1846 " zipv=({}, {}, {}, {}, {}, {}, {})",
1847 num(*az),
1848 num(*ai),
1849 num(*ap),
1850 num(*bz),
1851 num(*bi),
1852 num(*bp),
1853 num(cutoff)
1854 );
1855 }
1856 }
1857 DistLoadVoltageModel::Exponential { .. } => {
1858 self.warn(&C::EMIT_DSS_VALUE_SUBSTITUTED, format!(
1859 "load {}: exponential voltage model has no OpenDSS load model code; emitted constant power",
1860 l.name
1861 ));
1862 }
1863 }
1864 self.add_default_load_voltage_bounds(&mut extras);
1865 s.push_str(&self.extras_tail("load", &l.name, &extras));
1866 self.line_out(&s);
1867 }
1868
1869 fn add_default_load_voltage_bounds(&self, extras: &mut Extras) {
1870 if let Some(bounds) = self.options.default_load_voltage_bounds {
1871 extras
1872 .entry("vminpu".into())
1873 .or_insert_with(|| bounds.vminpu.into());
1874 extras
1875 .entry("vmaxpu".into())
1876 .or_insert_with(|| bounds.vmaxpu.into());
1877 }
1878 }
1879
1880 fn checked_num(&mut self, v: f64, fallback: f64, what: &str) -> String {
1887 if v.is_finite() {
1888 return num(v);
1889 }
1890 self.warn(
1891 &C::EMIT_DSS_VALUE_SUBSTITUTED,
1892 format!("{what}: {v} has no dss spelling; emitted {}", num(fallback)),
1893 );
1894 num(fallback)
1895 }
1896
1897 fn element_kv(&mut self, extras: &Extras, ctx: ElementKv<'_>) -> f64 {
1898 if let Some(v) = extras.get("kv") {
1899 match v
1900 .as_f64()
1901 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
1902 {
1903 Some(kv) => return kv,
1904 None => self.warn(
1905 &C::EMIT_DSS_VALUE_DEFAULTED,
1906 format!(
1907 "{} {}: kv extra `{v}` does not parse as a number; \
1908 using the bus voltage estimate",
1909 ctx.class, ctx.name
1910 ),
1911 ),
1912 }
1913 }
1914 if let Some(kv) = ctx.typed_kv {
1915 return kv;
1916 }
1917 if let Some(vln) = self.kv_estimate.get(&ctx.bus.to_ascii_lowercase()).copied() {
1918 let v = if ctx.phases >= 2 || ctx.configuration == Configuration::Delta {
1921 vln * 3f64.sqrt()
1922 } else {
1923 vln
1924 };
1925 v / 1e3
1926 } else {
1927 self.warn(
1928 &C::EMIT_DSS_VALUE_DEFAULTED,
1929 format!(
1930 "{} {}: no kv in the source and no bus voltage estimate; \
1931 emitted 12.47",
1932 ctx.class, ctx.name
1933 ),
1934 );
1935 12.47
1936 }
1937 }
1938
1939 fn load_nominal_kv(
1940 &mut self,
1941 model: &DistLoadVoltageModel,
1942 phases: usize,
1943 configuration: Configuration,
1944 name: &str,
1945 ) -> Option<f64> {
1946 let v_nom = model.v_nom();
1947 let v_phase = v_nom.first().copied().filter(|v| is_positive_finite(*v))?;
1948 if v_nom
1949 .iter()
1950 .any(|v| (*v - v_phase).abs() > 1e-9 * v.abs().max(v_phase.abs()).max(1.0))
1951 {
1952 self.warn(&C::EMIT_DSS_VALUE_COLLAPSED, format!(
1953 "load {name}: nonuniform nominal voltage array has no OpenDSS scalar kv; emitted the first value"
1954 ));
1955 }
1956 let v = if phases >= 2 && configuration == Configuration::Wye {
1957 v_phase * 3f64.sqrt()
1958 } else {
1959 v_phase
1960 };
1961 Some(v / 1e3)
1962 }
1963
1964 fn element_conn(
1968 &self,
1969 extras: &Extras,
1970 configuration: Configuration,
1971 bus: &str,
1972 terminal_map: &[String],
1973 ) -> &'static str {
1974 let stash_delta = extras
1975 .get("conn")
1976 .and_then(|v| v.as_str())
1977 .is_some_and(|t| {
1978 t.to_ascii_lowercase().starts_with('d') || t.eq_ignore_ascii_case("ll")
1979 });
1980 let has_grounded_return = self
1981 .grounded
1982 .get(&bus.to_ascii_lowercase())
1983 .is_some_and(|g| terminal_map.iter().any(|t| g.contains(t)));
1984 match configuration {
1985 Configuration::Delta => "delta",
1986 Configuration::SinglePhase
1987 if stash_delta || (terminal_map.len() == 2 && !has_grounded_return) =>
1988 {
1989 "delta"
1990 }
1991 _ => "wye",
1992 }
1993 }
1994
1995 fn write_impedance_shunt(&mut self, sh: &crate::model::DistShunt, phases: usize) {
1996 self.check_name("reactor", &sh.name);
1997 let Some((conductance, susceptance)) = first_diag_admittance(&sh.g, &sh.b, phases) else {
1998 self.warn(&C::EMIT_DSS_RECORD_DROPPED, format!(
1999 "shunt {}: conductance matrix has no diagonal admittance; dropped from the output",
2000 sh.name
2001 ));
2002 return;
2003 };
2004 if has_off_diagonal(&sh.g) || has_off_diagonal(&sh.b) {
2005 self.warn(
2006 &C::EMIT_DSS_VALUE_COLLAPSED,
2007 format!(
2008 "shunt {}: off diagonal admittance has no scalar reactor expression; \
2009 only the first diagonal admittance is regenerated",
2010 sh.name
2011 ),
2012 );
2013 }
2014 if !uniform_diag_admittance(&sh.g, &sh.b, phases, conductance, susceptance) {
2015 self.warn(
2016 &C::EMIT_DSS_VALUE_COLLAPSED,
2017 format!(
2018 "shunt {}: diagonal admittances differ; only the first diagonal \
2019 admittance is regenerated",
2020 sh.name
2021 ),
2022 );
2023 }
2024 let denom = conductance * conductance + susceptance * susceptance;
2025 if !denom.is_finite() || denom <= 0.0 {
2026 self.warn(
2027 &C::EMIT_DSS_RECORD_DROPPED,
2028 format!(
2029 "shunt {}: invalid grounding admittance; dropped from the output",
2030 sh.name
2031 ),
2032 );
2033 return;
2034 }
2035 let resistance = conductance / denom;
2036 let reactance = -susceptance / denom;
2037 let mut extras = sh.extras.clone();
2038 strip_shunt_extras(&mut extras);
2039 let ground = vec!["0".to_string(); phases.max(1)];
2040 let mut line = format!(
2041 "New Reactor.{} bus1={} bus2={} phases={} r={} x={}",
2042 sh.name,
2043 self.bus_ref(&sh.bus, &sh.terminal_map),
2044 self.bus_ref(&sh.bus, &ground),
2045 phases.max(1),
2046 num(resistance),
2047 num(reactance),
2048 );
2049 line.push_str(&self.extras_tail("reactor", &sh.name, &extras));
2050 self.line_out(&line);
2051 }
2052
2053 fn shunt_phases(
2054 &mut self,
2055 sh: &crate::model::DistShunt,
2056 conn_delta: bool,
2057 inferred_phases: usize,
2058 ) -> usize {
2059 if let Some(p) = extras_usize(&sh.extras, "phases") {
2060 p.max(1)
2061 } else if conn_delta {
2062 self.element_phases(
2063 &sh.extras,
2064 &sh.terminal_map,
2065 Configuration::Delta,
2066 "shunt",
2067 &sh.name,
2068 )
2069 } else {
2070 inferred_phases
2071 }
2072 }
2073
2074 fn write_kvar_shunt(&mut self, sh: &crate::model::DistShunt, phases: usize, conn_delta: bool) {
2075 let (b_max, b_min) = (0..sh.b.len())
2079 .map(|idx| diag_at(&sh.b, idx))
2080 .fold((0.0_f64, 0.0_f64), |(mx, mn), v| (mx.max(v), mn.min(v)));
2081 let (class, b_phase) = if b_max > 0.0 {
2082 ("capacitor", b_max)
2083 } else if b_min < 0.0 {
2084 ("reactor", b_min)
2085 } else {
2086 self.warn(
2087 &C::EMIT_DSS_RECORD_DROPPED,
2088 format!(
2089 "shunt {}: no nonzero susceptance; dropped from the output",
2090 sh.name
2091 ),
2092 );
2093 return;
2094 };
2095 if b_max > 0.0 && b_min < 0.0 {
2096 self.warn(
2097 &C::EMIT_DSS_FIELD_DROPPED,
2098 format!(
2099 "shunt {}: diagonal mixes capacitive and inductive phases; only the \
2100 {class} phases are regenerated",
2101 sh.name
2102 ),
2103 );
2104 }
2105 self.check_name(class, &sh.name);
2106 let off_diag = has_off_diagonal(&sh.b);
2107 if off_diag && !conn_delta {
2108 self.warn(
2109 &C::EMIT_DSS_VALUE_COLLAPSED,
2110 format!(
2111 "shunt {}: off diagonal susceptance has no {class} expression; \
2112 only the diagonal is regenerated",
2113 sh.name
2114 ),
2115 );
2116 }
2117 let edges = if conn_delta {
2118 delta_edges(sh.terminal_map.len(), phases)
2119 } else {
2120 Vec::new()
2121 };
2122 if conn_delta && edges.is_empty() {
2123 self.warn(&C::EMIT_DSS_RECORD_DROPPED, format!(
2124 "shunt {}: delta terminal map has no branch expression; dropped from the output",
2125 sh.name
2126 ));
2127 return;
2128 }
2129 if conn_delta && delta_branch_susceptance(&sh.b, &edges, sh.terminal_map.len()).is_none() {
2130 self.warn(
2131 &C::EMIT_DSS_VALUE_COLLAPSED,
2132 format!(
2133 "shunt {}: delta susceptance matrix has no scalar {class} expression; \
2134 only the average branch susceptance is regenerated",
2135 sh.name
2136 ),
2137 );
2138 }
2139 let configuration = if conn_delta {
2140 Configuration::Delta
2141 } else {
2142 Configuration::Wye
2143 };
2144 let kv = self.element_kv(
2145 &sh.extras,
2146 ElementKv {
2147 bus: &sh.bus,
2148 phases,
2149 configuration,
2150 name: &sh.name,
2151 class,
2152 typed_kv: None,
2153 },
2154 );
2155 let kvar = extras_f64(&sh.extras, "kvar")
2156 .unwrap_or_else(|| shunt_kvar(sh, phases, conn_delta, &edges, b_phase, kv));
2157 let mut extras = sh.extras.clone();
2158 strip_shunt_extras(&mut extras);
2159 let conn = if conn_delta { "delta" } else { "wye" };
2160 let decl = if class == "reactor" {
2161 "Reactor"
2162 } else {
2163 "Capacitor"
2164 };
2165 let mut line = format!(
2166 "New {decl}.{} bus1={} phases={phases} conn={conn} kv={} kvar={}",
2167 sh.name,
2168 self.bus_ref(&sh.bus, &sh.terminal_map),
2169 num(kv),
2170 num(kvar),
2171 );
2172 line.push_str(&self.extras_tail(class, &sh.name, &extras));
2173 self.line_out(&line);
2174 }
2175
2176 fn capacitors(&mut self, net: &MulticonductorNetwork) {
2187 for c in net.capacitors() {
2188 if !is_positive_finite(c.q_rated) {
2189 self.warn(
2190 &C::EMIT_DSS_VALUE_SUBSTITUTED,
2191 format!(
2192 "capacitor {}: rating {} is not a positive number; dropped from the output",
2193 c.name, c.q_rated
2194 ),
2195 );
2196 continue;
2197 }
2198 self.check_name("capacitor", &c.name);
2199 let node_map = self.return_last("capacitor", &c.name, &c.bus, &c.terminal_map);
2200 let phases = self.element_phases(
2201 &c.extras,
2202 &c.terminal_map,
2203 c.configuration,
2204 "capacitor",
2205 &c.name,
2206 );
2207 let conn = self.element_conn(&c.extras, c.configuration, &c.bus, &c.terminal_map);
2208 let nconds = nconds_for(conn, phases);
2209 self.warn_map_arity("capacitor", &c.name, c.terminal_map.len(), nconds);
2210 let typed_kv = is_positive_finite(c.v_nom).then(|| c.v_nom / 1e3);
2211 if typed_kv.is_none() {
2212 self.warn(
2213 &C::EMIT_DSS_VALUE_SUBSTITUTED,
2214 format!(
2215 "capacitor {}: nominal voltage {} is not a positive number; \
2216 using the bus voltage estimate",
2217 c.name, c.v_nom
2218 ),
2219 );
2220 }
2221 let kv = self.element_kv(
2222 &c.extras,
2223 ElementKv {
2224 bus: &c.bus,
2225 phases,
2226 configuration: c.configuration,
2227 name: &c.name,
2228 class: "capacitor",
2229 typed_kv,
2230 },
2231 );
2232 let mut extras = c.extras.clone();
2233 strip_emitted_extras(&mut extras, &["kv", "phases", "conn", "kvar"]);
2234 let mut line = format!(
2235 "New Capacitor.{} bus1={} phases={phases} conn={conn} kv={} kvar={}",
2236 c.name,
2237 self.bus_ref(&c.bus, &node_map),
2238 num(kv),
2239 num(c.q_rated / 1e3),
2240 );
2241 line.push_str(&self.extras_tail("capacitor", &c.name, &extras));
2242 self.line_out(&line);
2243 }
2244 }
2245
2246 fn shunts(&mut self, net: &MulticonductorNetwork) {
2247 for sh in net.shunts() {
2248 let stashed_delta = shunt_stashed_delta(sh);
2249 let inferred_phases =
2250 extras_usize(&sh.extras, "phases").unwrap_or_else(|| sh.terminal_map.len().max(1));
2251 let conn_delta = stashed_delta
2252 || looks_like_delta_shunt(&sh.b, sh.terminal_map.len(), inferred_phases);
2253 let phases = self.shunt_phases(sh, conn_delta, inferred_phases);
2254 if has_nonzero(&sh.g) {
2255 self.write_impedance_shunt(sh, phases);
2256 } else {
2257 self.write_kvar_shunt(sh, phases, conn_delta);
2258 }
2259 }
2260 self.out.push('\n');
2261 }
2262
2263 fn generators(&mut self, net: &MulticonductorNetwork) {
2264 for g in net.generators() {
2265 self.check_name("generator", &g.name);
2266 let node_map = self.return_last("generator", &g.name, &g.bus, &g.terminal_map);
2267 let phases = extras_usize(&g.extras, "phases")
2268 .or_else(|| {
2269 let count = [
2270 Some(g.p_nom.as_slice()),
2271 g.p_min.as_deref(),
2272 g.p_max.as_deref(),
2273 g.q_min.as_deref(),
2274 g.q_max.as_deref(),
2275 ]
2276 .into_iter()
2277 .flatten()
2278 .map(<[f64]>::len)
2279 .max()
2280 .unwrap_or(0);
2281 (count > 0).then_some(count)
2282 })
2283 .unwrap_or_else(|| {
2284 self.element_phases(
2285 &g.extras,
2286 &g.terminal_map,
2287 g.configuration,
2288 "generator",
2289 &g.name,
2290 )
2291 })
2292 .max(1);
2293 let conn = self.element_conn(&g.extras, g.configuration, &g.bus, &g.terminal_map);
2294 let nconds = nconds_for(conn, phases);
2295 self.warn_map_arity("generator", &g.name, g.terminal_map.len(), nconds);
2296 let kw: f64 = g.p_nom.iter().sum::<f64>() / 1e3;
2297 let kvar: f64 = g.q_nom.iter().sum::<f64>() / 1e3;
2298 let kv = self.element_kv(
2299 &g.extras,
2300 ElementKv {
2301 bus: &g.bus,
2302 phases,
2303 configuration: g.configuration,
2304 name: &g.name,
2305 class: "generator",
2306 typed_kv: None,
2307 },
2308 );
2309 let mut s = format!(
2310 "New Generator.{} bus1={} phases={phases} conn={conn} kv={} kw={} kvar={}",
2311 g.name,
2312 self.bus_ref(&g.bus, &node_map),
2313 num(kv),
2314 num(kw),
2315 num(kvar),
2316 );
2317 if let Some(q) = &g.q_max {
2318 let _ = write!(s, " maxkvar={}", num(q.iter().sum::<f64>() / 1e3));
2319 }
2320 if let Some(q) = &g.q_min {
2321 let _ = write!(s, " minkvar={}", num(q.iter().sum::<f64>() / 1e3));
2322 }
2323 if g.cost.is_some() {
2324 self.warn(
2325 &C::EMIT_DSS_FIELD_DROPPED,
2326 format!(
2327 "generator {}: generation cost has no dss field; dropped",
2328 g.name
2329 ),
2330 );
2331 }
2332 for (key, present) in [("s_max", g.s_max.is_some()), ("i_max", g.i_max.is_some())] {
2335 if present {
2336 self.warn(
2337 &C::EMIT_DSS_FIELD_DROPPED,
2338 format!(
2339 "generator {}: `{key}` has no dss Generator field mapping yet; dropped",
2340 g.name
2341 ),
2342 );
2343 }
2344 }
2345 let mut extras = g.extras.clone();
2346 strip_emitted_extras(&mut extras, &["kv", "phases", "conn"]);
2347 s.push_str(&self.extras_tail("generator", &g.name, &extras));
2348 self.line_out(&s);
2349 }
2350 }
2351
2352 fn ibrs(&mut self, net: &MulticonductorNetwork) {
2353 for ibr in net.ibrs() {
2354 self.check_name("pvsystem", &ibr.name);
2355 if ibr_is_fixed_dispatch(ibr) {
2356 self.write_fixed_ibr_generator(ibr);
2357 } else {
2358 self.write_pvsystem(ibr, net);
2359 }
2360 }
2361 for ibr in net.ibrs() {
2362 if !ibr_is_fixed_dispatch(ibr) {
2363 self.write_ibr_controls(ibr, net);
2364 }
2365 }
2366 if !net.ibrs().is_empty() {
2367 self.out.push('\n');
2368 }
2369 }
2370
2371 fn write_fixed_ibr_generator(&mut self, ibr: &DistIbr) {
2372 let node_map = self.return_last("ibr", &ibr.name, &ibr.bus, &ibr.terminal_map);
2373 let phases = ibr_phases(ibr);
2374 let configuration = ibr_configuration(ibr);
2375 let conn = self.element_conn(&ibr.extras, configuration, &ibr.bus, &ibr.terminal_map);
2376 let kv = self.ibr_kv(ibr, phases, configuration, "generator");
2377 let kw = ibr
2378 .p_min
2379 .as_ref()
2380 .map_or(0.0, |p| p.iter().sum::<f64>() / 1e3);
2381 let kvar = ibr
2382 .q_min
2383 .as_ref()
2384 .map_or(0.0, |q| q.iter().sum::<f64>() / 1e3);
2385 let mut line = format!(
2386 "New Generator.{} bus1={} phases={phases} conn={conn} kv={} kw={} kvar={} model=1 vminpu=0 vmaxpu=2",
2387 ibr.name,
2388 self.bus_ref(&ibr.bus, &node_map),
2389 num(kv),
2390 num(kw),
2391 num(kvar),
2392 );
2393 if let Some(q) = &ibr.q_max {
2394 let _ = write!(line, " maxkvar={}", num(q.iter().sum::<f64>() / 1e3));
2395 }
2396 if let Some(q) = &ibr.q_min {
2397 let _ = write!(line, " minkvar={}", num(q.iter().sum::<f64>() / 1e3));
2398 }
2399 self.warn_ibr_dss_drops(ibr);
2400 self.line_out(&line);
2401 }
2402
2403 fn write_pvsystem(&mut self, ibr: &DistIbr, net: &MulticonductorNetwork) {
2404 let node_map = self.return_last("ibr", &ibr.name, &ibr.bus, &ibr.terminal_map);
2405 let phases = ibr_phases(ibr);
2406 let configuration = ibr_configuration(ibr);
2407 let conn = self.element_conn(&ibr.extras, configuration, &ibr.bus, &ibr.terminal_map);
2408 let kv = self.ibr_kv(ibr, phases, configuration, "pvsystem");
2409 let kva = ibr.s_max.iter().sum::<f64>() / 1e3;
2410 let pmpp = ibr
2411 .p_avail
2412 .or_else(|| ibr.p_max.as_ref().map(|p| p.iter().sum()))
2413 .unwrap_or(0.0)
2414 / 1e3;
2415 let mut line = format!(
2416 "New PVSystem.{} bus1={} phases={phases} conn={conn} kv={} kVA={} Pmpp={} irradiance=1 %Pmpp=100 WattPriority=No VarFollowInverter=Yes",
2417 ibr.name,
2418 self.bus_ref(&ibr.bus, &node_map),
2419 num(kv),
2420 num(kva),
2421 num(pmpp),
2422 );
2423 if let Some(q) = &ibr.q_max {
2424 let _ = write!(line, " kvarMax={}", num(q.iter().sum::<f64>() / 1e3));
2425 }
2426 if let Some(q) = &ibr.q_min {
2427 let _ = write!(
2428 line,
2429 " kvarMaxAbs={}",
2430 num(q.iter().map(|v| v.abs()).sum::<f64>() / 1e3)
2431 );
2432 }
2433 if let Some(profile) = ibr_profile(ibr, net) {
2434 if let Some(pf) = &profile.power_factor {
2435 let _ = write!(line, " pf={}", num(pf.pf));
2436 }
2437 if let Some(vv) = &profile.volt_var {
2438 if let Some(v) = vv.p_min_for_q {
2439 let _ = write!(line, " %PminNoVars={}", num(v));
2440 }
2441 if let Some(v) = vv.p_min_for_q_max {
2442 let _ = write!(line, " %PminkvarMax={}", num(v));
2443 }
2444 }
2445 }
2446 self.warn_ibr_dss_drops(ibr);
2447 self.line_out(&line);
2448 }
2449
2450 fn write_ibr_controls(&mut self, ibr: &DistIbr, net: &MulticonductorNetwork) {
2451 let Some(profile) = ibr_profile(ibr, net) else {
2452 if let Some(name) = &ibr.control_profile {
2453 self.warn(
2454 &C::EMIT_DSS_RECORD_DROPPED,
2455 format!(
2456 "ibr {}: control_profile `{name}` not found; no InvControl emitted",
2457 ibr.name
2458 ),
2459 );
2460 }
2461 return;
2462 };
2463 let phases = ibr_phases(ibr);
2464 let configuration = ibr_configuration(ibr);
2465 let kv = self.ibr_kv(ibr, phases, configuration, "pvsystem");
2466 let base_v = if phases >= 2 && configuration != Configuration::Delta {
2467 kv * 1e3 / 3f64.sqrt()
2468 } else {
2469 kv * 1e3
2470 };
2471 let mut curves = Vec::new();
2472 let mut has_vv = false;
2473 let mut has_vw = false;
2474 if let Some(vv) = &profile.volt_var
2475 && let Some(curve) = self.volt_var_curve(ibr, vv, base_v)
2476 {
2477 curves.push(curve);
2478 has_vv = true;
2479 }
2480 if let Some(vw) = &profile.volt_watt
2481 && let Some(curve) = self.volt_watt_curve(ibr, vw, base_v)
2482 {
2483 curves.push(curve);
2484 has_vw = true;
2485 }
2486 for line in &curves {
2487 self.line_out(line);
2488 }
2489 if !has_vv && !has_vw {
2490 return;
2491 }
2492 let mon = self.control_mon_voltage(ibr, profile);
2493 let inv_name = format!("ivc_{}", ibr.name);
2494 self.check_name("invcontrol", &inv_name);
2495 let mut line = format!(
2496 "New InvControl.{inv_name} DERList=[PVSystem.{}] voltage_curvex_ref=rated monVoltageCalc={mon}",
2497 ibr.name
2498 );
2499 match (has_vv, has_vw) {
2500 (true, true) => {
2501 let _ = write!(
2502 line,
2503 " CombiMode=VV_VW vvc_curve1=vv_{} voltwatt_curve=vw_{}",
2504 ibr.name, ibr.name
2505 );
2506 if let Some(vv) = &profile.volt_var {
2507 let _ = write!(
2508 line,
2509 " RefReactivePower={}",
2510 reactive_reference(vv.q_ref.unwrap_or(ReactivePowerReference::VarMax))
2511 );
2512 }
2513 if let Some(vw) = &profile.volt_watt {
2514 let _ = write!(
2515 line,
2516 " VoltwattYAxis={}",
2517 active_reference(vw.p_ref.unwrap_or(ActivePowerReference::SMax))
2518 );
2519 }
2520 }
2521 (true, false) => {
2522 line.push_str(" mode=VOLTVAR");
2523 let _ = write!(line, " vvc_curve1=vv_{}", ibr.name);
2524 if let Some(vv) = &profile.volt_var {
2525 let _ = write!(
2526 line,
2527 " RefReactivePower={}",
2528 reactive_reference(vv.q_ref.unwrap_or(ReactivePowerReference::VarMax))
2529 );
2530 }
2531 }
2532 (false, true) => {
2533 line.push_str(" mode=VOLTWATT");
2534 let _ = write!(line, " voltwatt_curve=vw_{}", ibr.name);
2535 if let Some(vw) = &profile.volt_watt {
2536 let _ = write!(
2537 line,
2538 " VoltwattYAxis={}",
2539 active_reference(vw.p_ref.unwrap_or(ActivePowerReference::SMax))
2540 );
2541 }
2542 }
2543 (false, false) => {}
2544 }
2545 self.line_out(&line);
2546 }
2547
2548 fn volt_var_curve(
2549 &mut self,
2550 ibr: &DistIbr,
2551 vv: &VoltVarControl,
2552 base_v: f64,
2553 ) -> Option<String> {
2554 self.check_control_reference(ibr, vv.voltage_reference)?;
2555 if !matches!(
2556 vv.q_unit,
2557 None | Some(crate::model::ReactivePowerUnit::VaFraction)
2558 ) {
2559 self.warn(
2560 &C::EMIT_DSS_FIELD_DROPPED,
2561 format!(
2562 "ibr {}: volt_var q_unit is absolute VAR; DSS export only maps VA_FRACTION",
2563 ibr.name
2564 ),
2565 );
2566 return None;
2567 }
2568 if vv.breakpoints.len() < 4 || vv.q_limits.len() < 2 || base_v <= 0.0 {
2569 self.warn(
2570 &C::EMIT_DSS_RECORD_DROPPED,
2571 format!(
2572 "ibr {}: volt_var profile is incomplete; no XYcurve emitted",
2573 ibr.name
2574 ),
2575 );
2576 return None;
2577 }
2578 let xs: Vec<String> = vv
2579 .breakpoints
2580 .iter()
2581 .take(4)
2582 .map(|v| num(v / base_v))
2583 .collect();
2584 let ys = [num(vv.q_limits[1]), num(0.0), num(0.0), num(vv.q_limits[0])];
2585 Some(format!(
2586 "New XYcurve.vv_{} npts=4 Xarray=[{}] Yarray=[{}]",
2587 ibr.name,
2588 xs.join(" "),
2589 ys.join(" ")
2590 ))
2591 }
2592
2593 fn volt_watt_curve(
2594 &mut self,
2595 ibr: &DistIbr,
2596 vw: &VoltWattControl,
2597 base_v: f64,
2598 ) -> Option<String> {
2599 self.check_control_reference(ibr, vw.voltage_reference)?;
2600 if !matches!(
2601 vw.p_unit,
2602 None | Some(crate::model::ActivePowerUnit::VaFraction)
2603 ) {
2604 self.warn(
2605 &C::EMIT_DSS_FIELD_DROPPED,
2606 format!(
2607 "ibr {}: volt_watt p_unit is absolute W; DSS export only maps VA_FRACTION",
2608 ibr.name
2609 ),
2610 );
2611 return None;
2612 }
2613 if vw.breakpoints.len() < 2 || vw.p_limits.len() < 2 || base_v <= 0.0 {
2614 self.warn(
2615 &C::EMIT_DSS_RECORD_DROPPED,
2616 format!(
2617 "ibr {}: volt_watt profile is incomplete; no XYcurve emitted",
2618 ibr.name
2619 ),
2620 );
2621 return None;
2622 }
2623 let xs: Vec<String> = vw
2624 .breakpoints
2625 .iter()
2626 .take(2)
2627 .map(|v| num(v / base_v))
2628 .collect();
2629 let ys = [num(vw.p_limits[1]), num(vw.p_limits[0])];
2630 Some(format!(
2631 "New XYcurve.vw_{} npts=2 Xarray=[{}] Yarray=[{}]",
2632 ibr.name,
2633 xs.join(" "),
2634 ys.join(" ")
2635 ))
2636 }
2637
2638 fn check_control_reference(
2639 &mut self,
2640 ibr: &DistIbr,
2641 reference: Option<ControlVoltageReference>,
2642 ) -> Option<()> {
2643 match reference.unwrap_or(ControlVoltageReference::PnPerPhase) {
2644 ControlVoltageReference::PgPerPhase | ControlVoltageReference::PgAveraged => Some(()),
2645 ControlVoltageReference::PnPerPhase | ControlVoltageReference::PnAveraged => {
2646 self.warn(&C::EMIT_DSS_VALUE_SUBSTITUTED, format!(
2647 "ibr {}: PN voltage control is approximated by OpenDSS phase-to-ground InvControl",
2648 ibr.name
2649 ));
2650 Some(())
2651 }
2652 ControlVoltageReference::PpPerPhase | ControlVoltageReference::PpAveraged => {
2653 self.warn(&C::EMIT_DSS_RECORD_DROPPED, format!(
2654 "ibr {}: PP voltage control is not representable by OpenDSS InvControl; skipped",
2655 ibr.name
2656 ));
2657 None
2658 }
2659 }
2660 }
2661
2662 fn control_mon_voltage(&mut self, ibr: &DistIbr, profile: &DistControlProfile) -> &'static str {
2663 let reference = profile
2664 .volt_var
2665 .as_ref()
2666 .and_then(|vv| vv.voltage_reference)
2667 .or_else(|| {
2668 profile
2669 .volt_watt
2670 .as_ref()
2671 .and_then(|vw| vw.voltage_reference)
2672 })
2673 .unwrap_or(ControlVoltageReference::PnPerPhase);
2674 let averaged = matches!(
2675 ibr.voltage_aggregation,
2676 Some(IbrVoltageAggregation::Average)
2677 ) || matches!(
2678 reference,
2679 ControlVoltageReference::PgAveraged
2680 | ControlVoltageReference::PnAveraged
2681 | ControlVoltageReference::PpAveraged
2682 );
2683 if !averaged && ibr_phases(ibr) > 1 {
2684 self.warn(
2685 &C::EMIT_DSS_FIELD_DROPPED,
2686 format!(
2687 "ibr {}: per phase InvControl needs split PVSystems; emitted AVG monitor",
2688 ibr.name
2689 ),
2690 );
2691 }
2692 "AVG"
2693 }
2694
2695 fn ibr_kv(
2696 &mut self,
2697 ibr: &DistIbr,
2698 phases: usize,
2699 configuration: Configuration,
2700 class: &'static str,
2701 ) -> f64 {
2702 self.element_kv(
2703 &ibr.extras,
2704 ElementKv {
2705 bus: &ibr.bus,
2706 phases,
2707 configuration,
2708 name: &ibr.name,
2709 class,
2710 typed_kv: None,
2711 },
2712 )
2713 }
2714
2715 fn warn_ibr_dss_drops(&mut self, ibr: &DistIbr) {
2716 for key in ibr.extras.keys() {
2717 if matches!(key.as_str(), "kv" | "phases") {
2718 continue;
2719 }
2720 self.warn(
2721 &C::EMIT_DSS_FIELD_DROPPED,
2722 format!(
2723 "ibr {}: `{key}` has no OpenDSS export mapping; dropped",
2724 ibr.name
2725 ),
2726 );
2727 }
2728 if ibr.i_max.is_some() {
2729 self.warn(
2730 &C::EMIT_DSS_FIELD_DROPPED,
2731 format!(
2732 "ibr {}: i_max has no OpenDSS PVSystem current limit field; dropped",
2733 ibr.name
2734 ),
2735 );
2736 }
2737 if !matches!(ibr.prime_mover, IbrPrimeMover::Pv | IbrPrimeMover::Generic) {
2738 self.warn(&C::EMIT_DSS_FIELD_DROPPED, format!(
2739 "ibr {}: prime_mover {:?} has no dedicated OpenDSS export path; emitted with the generic inverter mapping",
2740 ibr.name, ibr.prime_mover
2741 ));
2742 }
2743 }
2744}
2745
2746fn strip_shunt_extras(extras: &mut Extras) {
2749 for key in ["kv", "kvar", "phases", "conn"] {
2750 extras.remove(key);
2751 }
2752}
2753
2754fn ibr_is_fixed_dispatch(ibr: &DistIbr) -> bool {
2755 ibr.control_profile.is_none()
2756 && matches!((&ibr.p_min, &ibr.p_max), (Some(a), Some(b)) if a == b)
2757 && matches!((&ibr.q_min, &ibr.q_max), (Some(a), Some(b)) if a == b)
2758}
2759
2760fn ibr_profile<'a>(
2761 ibr: &DistIbr,
2762 net: &'a MulticonductorNetwork,
2763) -> Option<&'a DistControlProfile> {
2764 let name = ibr.control_profile.as_ref()?;
2765 net.control_profiles()
2766 .iter()
2767 .find(|profile| profile.name.eq_ignore_ascii_case(name))
2768}
2769
2770fn ibr_phases(ibr: &DistIbr) -> usize {
2771 match ibr.topology {
2772 IbrTopology::SinglePhase => 1,
2773 IbrTopology::ThreeLeg | IbrTopology::FourLeg => 3,
2774 }
2775}
2776
2777fn ibr_configuration(ibr: &DistIbr) -> Configuration {
2778 match ibr.topology {
2779 IbrTopology::SinglePhase => Configuration::SinglePhase,
2780 IbrTopology::ThreeLeg => Configuration::Delta,
2781 IbrTopology::FourLeg => Configuration::Wye,
2782 }
2783}
2784
2785fn reactive_reference(reference: ReactivePowerReference) -> &'static str {
2786 match reference {
2787 ReactivePowerReference::VarMax => "VARMAX",
2788 ReactivePowerReference::VarAvailable => "VARAVAL_WATTS",
2789 }
2790}
2791
2792fn active_reference(reference: ActivePowerReference) -> &'static str {
2793 match reference {
2794 ActivePowerReference::SMax => "KVARATINGPU",
2795 ActivePowerReference::PAvailable => "PAVAILABLEPU",
2796 ActivePowerReference::PMax => "PMPPPU",
2797 }
2798}
2799
2800fn has_nonzero(m: &ConductorMatrix) -> bool {
2801 m.iter().flatten().any(|&v| v != 0.0)
2802}
2803
2804fn has_off_diagonal(m: &ConductorMatrix) -> bool {
2805 m.iter()
2806 .enumerate()
2807 .any(|(i, row)| row.iter().enumerate().any(|(j, &v)| i != j && v != 0.0))
2808}
2809
2810fn return_permutation(k: usize, n: usize) -> Vec<usize> {
2812 (0..n).filter(|&i| i != k).chain([k]).collect()
2813}
2814
2815fn permute_symmetric(m: &ConductorMatrix, perm: &[usize]) -> ConductorMatrix {
2819 let at = |i: usize, j: usize| {
2820 m.get(i)
2821 .and_then(|row| row.get(j))
2822 .copied()
2823 .or_else(|| m.get(j).and_then(|row| row.get(i)).copied())
2824 .unwrap_or(0.0)
2825 };
2826 perm.iter()
2827 .map(|&i| perm.iter().map(|&j| at(i, j)).collect())
2828 .collect()
2829}
2830
2831fn permute_names(map: &[String], perm: &[usize]) -> Vec<String> {
2833 perm.iter().map(|&i| map[i].clone()).collect()
2834}
2835
2836fn permute_padded(v: &[f64], perm: &[usize]) -> Vec<f64> {
2838 perm.iter()
2839 .map(|&i| v.get(i).copied().unwrap_or(f64::INFINITY))
2840 .collect()
2841}
2842
2843fn diag_at(m: &ConductorMatrix, i: usize) -> f64 {
2844 m.get(i).and_then(|row| row.get(i)).copied().unwrap_or(0.0)
2845}
2846
2847fn matrix_scale(m: &ConductorMatrix) -> f64 {
2848 m.iter().flatten().fold(0.0_f64, |acc, &v| acc.max(v.abs()))
2849}
2850
2851fn close(a: f64, b: f64, scale: f64) -> bool {
2852 (a - b).abs() <= 1e-12_f64.max(scale * 1e-9)
2853}
2854
2855fn first_diag_admittance(
2856 g: &ConductorMatrix,
2857 b: &ConductorMatrix,
2858 phases: usize,
2859) -> Option<(f64, f64)> {
2860 (0..phases.max(1)).find_map(|i| {
2861 let gi = diag_at(g, i);
2862 let bi = diag_at(b, i);
2863 (gi != 0.0 || bi != 0.0).then_some((gi, bi))
2864 })
2865}
2866
2867fn uniform_diag_admittance(
2868 g: &ConductorMatrix,
2869 b: &ConductorMatrix,
2870 phases: usize,
2871 g0: f64,
2872 b0: f64,
2873) -> bool {
2874 let scale = matrix_scale(g)
2875 .max(matrix_scale(b))
2876 .max(g0.abs())
2877 .max(b0.abs());
2878 (0..phases.max(1)).all(|i| close(diag_at(g, i), g0, scale) && close(diag_at(b, i), b0, scale))
2879}
2880
2881fn shunt_stashed_delta(sh: &crate::model::DistShunt) -> bool {
2882 sh.extras
2883 .get("conn")
2884 .and_then(|v| v.as_str())
2885 .is_some_and(|t| t.to_ascii_lowercase().starts_with('d') || t.eq_ignore_ascii_case("ll"))
2886}
2887
2888fn mat_at(m: &ConductorMatrix, i: usize, j: usize) -> f64 {
2889 m.get(i).and_then(|row| row.get(j)).copied().unwrap_or(0.0)
2890}
2891
2892fn looks_like_delta_shunt(b: &ConductorMatrix, terminals: usize, phases: usize) -> bool {
2893 if terminals < 2 || !has_off_diagonal(b) {
2894 return false;
2895 }
2896 let edges = delta_edges(terminals, phases);
2897 delta_branch_susceptance(b, &edges, terminals).is_some()
2898}
2899
2900fn delta_branch_abs(b: &ConductorMatrix, edges: &[(usize, usize)]) -> Option<f64> {
2901 if edges.is_empty() {
2902 return None;
2903 }
2904 let total: f64 = edges
2909 .iter()
2910 .map(|&(i, j)| {
2911 b.get(i)
2912 .and_then(|row| row.get(j))
2913 .copied()
2914 .unwrap_or(0.0)
2915 .abs()
2916 })
2917 .sum();
2918 Some(total / edges.len() as f64)
2919}
2920
2921fn delta_branch_susceptance(
2922 b: &ConductorMatrix,
2923 edges: &[(usize, usize)],
2924 terminals: usize,
2925) -> Option<f64> {
2926 if terminals < 2 || edges.is_empty() {
2927 return None;
2928 }
2929 let scale = matrix_scale(b);
2930 if scale == 0.0 {
2931 return None;
2932 }
2933 let first = edges[0];
2934 let branch = -mat_at(b, first.0, first.1);
2935 if branch == 0.0 {
2936 return None;
2937 }
2938 let scale = scale.max(branch.abs());
2939 for (i, row) in b.iter().enumerate() {
2940 for (j, &value) in row.iter().enumerate() {
2941 if (i >= terminals || j >= terminals) && !close(value, 0.0, scale) {
2942 return None;
2943 }
2944 }
2945 }
2946 for i in 0..terminals {
2947 let incident = edges
2948 .iter()
2949 .filter(|&&(from, to)| from == i || to == i)
2950 .count() as f64;
2951 for j in 0..terminals {
2952 let linked = edges
2953 .iter()
2954 .any(|&(from, to)| (from == i && to == j) || (from == j && to == i));
2955 let expected = if i == j {
2956 incident * branch
2957 } else if linked {
2958 -branch
2959 } else {
2960 0.0
2961 };
2962 if !close(mat_at(b, i, j), expected, scale) {
2963 return None;
2964 }
2965 }
2966 }
2967 Some(branch)
2968}
2969
2970fn shunt_kvar(
2971 sh: &crate::model::DistShunt,
2972 phases: usize,
2973 conn_delta: bool,
2974 edges: &[(usize, usize)],
2975 b_phase: f64,
2976 kv: f64,
2977) -> f64 {
2978 if conn_delta {
2979 let b_branch = delta_branch_abs(&sh.b, edges).unwrap_or(b_phase.abs());
2980 b_branch * (kv * 1e3) * (kv * 1e3) * edges.len() as f64 / 1e3
2981 } else {
2982 let v_phase = if matches!(phases, 2 | 3) {
2983 kv * 1e3 / 3f64.sqrt()
2984 } else {
2985 kv * 1e3
2986 };
2987 b_phase.abs() * v_phase * v_phase * phases as f64 / 1e3
2988 }
2989}
2990
2991#[cfg(test)]
2992mod tests {
2993 use super::*;
2994 use crate::model::MulticonductorNetworkTables;
2995 use crate::model::{
2996 ControlVoltageReference, DistControlProfile, DistGenerator, DistIbr, DistLine,
2997 DistLineCode, DistLoad, DistShunt, DistSwitch, DistTransformer, DistWinding, IbrPrimeMover,
2998 IbrTopology, ReactivePowerReference, ReactivePowerUnit, VoltVarControl, VoltageSource,
2999 };
3000 use crate::testkit::parse_dss_str;
3001
3002 fn strings(v: &[&str]) -> Vec<String> {
3003 v.iter().map(ToString::to_string).collect()
3004 }
3005
3006 fn bus(id: &str, terminals: &[&str], grounded: &[&str]) -> DistBus {
3007 DistBus {
3008 id: id.into(),
3009 terminals: strings(terminals),
3010 grounded: strings(grounded),
3011 ..DistBus::default()
3012 }
3013 }
3014
3015 fn center_tap_service(vln: f64) -> (DistBus, VoltageSource, DistBus) {
3018 let (mut source, vs) = three_phase_source(vln);
3019 source.id = "sb".into();
3020 (source, vs, bus("lv", &["p1", "n", "p2"], &["n"]))
3021 }
3022
3023 fn three_phase_source(vln: f64) -> (DistBus, VoltageSource) {
3024 let third = 2.0 * std::f64::consts::FRAC_PI_3;
3025 (
3026 bus("sb", &["1", "2", "3", "4"], &["4"]),
3027 VoltageSource {
3028 name: "source".into(),
3029 bus: "sb".into(),
3030 terminal_map: strings(&["1", "2", "3", "4"]),
3031 v_magnitude: vec![vln, vln, vln, 0.0],
3032 v_angle: vec![0.0, -third, third, 0.0],
3033 energy_cost_rate: None,
3034 extras: Extras::new(),
3035 },
3036 )
3037 }
3038
3039 fn load_on(bus: &str, map: &[&str], configuration: Configuration) -> DistLoad {
3040 let phases = map.len();
3041 DistLoad {
3042 name: "ld".into(),
3043 bus: bus.into(),
3044 terminal_map: strings(map),
3045 configuration,
3046 p_nom: vec![1e3; phases],
3047 q_nom: vec![0.0; phases],
3048 voltage_model: DistLoadVoltageModel::ConstantPower { v_nom: Vec::new() },
3049 extras: Extras::from([("kv".to_string(), serde_json::json!("0.4"))]),
3050 }
3051 }
3052
3053 fn terminal_order_network(map: &[&str]) -> MulticonductorNetwork {
3054 let (source_bus, source) = three_phase_source(240.0);
3055 let element_map = strings(map);
3056
3057 let capacitor = crate::model::DistCapacitor::new(
3058 "cap",
3059 "lv",
3060 element_map.clone(),
3061 Configuration::SinglePhase,
3062 1_000.0,
3063 240.0,
3064 );
3065 let mut generator = DistGenerator::new(
3066 "gen",
3067 "lv",
3068 element_map.clone(),
3069 Configuration::SinglePhase,
3070 vec![1_000.0],
3071 vec![0.0],
3072 );
3073 generator
3074 .extras
3075 .insert("kv".into(), serde_json::json!(0.24));
3076
3077 let mut fixed_ibr = DistIbr::new(
3078 "fixed_ibr",
3079 "lv",
3080 element_map.clone(),
3081 IbrTopology::SinglePhase,
3082 IbrPrimeMover::Pv,
3083 vec![1_000.0],
3084 );
3085 fixed_ibr.p_min = Some(vec![1_000.0]);
3086 fixed_ibr.p_max = Some(vec![1_000.0]);
3087 fixed_ibr.q_min = Some(vec![0.0]);
3088 fixed_ibr.q_max = Some(vec![0.0]);
3089 fixed_ibr
3090 .extras
3091 .insert("kv".into(), serde_json::json!(0.24));
3092
3093 let mut pv_ibr = DistIbr::new(
3094 "pv_ibr",
3095 "lv",
3096 element_map.clone(),
3097 IbrTopology::SinglePhase,
3098 IbrPrimeMover::Pv,
3099 vec![1_000.0],
3100 );
3101 pv_ibr.p_avail = Some(1_000.0);
3102 pv_ibr.extras.insert("kv".into(), serde_json::json!(0.24));
3103
3104 MulticonductorNetwork::from_tables(MulticonductorNetworkTables {
3105 name: Some("terminal_order".into()),
3106 base_frequency: 60.0,
3107 buses: vec![source_bus, bus("lv", map, &["n"])],
3108 sources: vec![source],
3109 lines: vec![DistLine::new(
3110 "l1",
3111 "lv",
3112 "lv",
3113 element_map.clone(),
3114 element_map.clone(),
3115 "lc",
3116 1.0,
3117 )],
3118 switches: vec![DistSwitch::new(
3119 "sw1",
3120 "lv",
3121 "lv",
3122 element_map.clone(),
3123 element_map,
3124 false,
3125 )],
3126 generators: vec![generator],
3127 ibrs: vec![fixed_ibr, pv_ibr],
3128 capacitors: vec![capacitor],
3129 ..MulticonductorNetworkTables::default()
3130 })
3131 }
3132
3133 fn terminal_order_diagnostics(
3134 out: &crate::convert::TextEmission,
3135 ) -> Vec<&crate::diagnostics::Diagnostic> {
3136 out.diagnostics
3137 .iter()
3138 .filter(|d| d.code() == C::EMIT_DSS_TERMINAL_ORDER_UNREPRESENTABLE.code)
3139 .collect()
3140 }
3141
3142 fn roundtrip(net: &MulticonductorNetwork) -> (String, String) {
3143 let first = emit_dss_text(net);
3144 let second = emit_dss_text(&parse_dss_str(&first.text));
3145 (first.text, second.text)
3146 }
3147
3148 #[test]
3149 fn constant_power_loads_get_wide_voltage_bounds_by_default() {
3150 let (b, vs) = three_phase_source(2400.0);
3151 let load = load_on("sb", &["1"], Configuration::Wye);
3152 let net = MulticonductorNetwork::from_tables(MulticonductorNetworkTables {
3153 base_frequency: 60.0,
3154 buses: vec![b],
3155 sources: vec![vs],
3156 loads: vec![load],
3157 ..MulticonductorNetworkTables::default()
3158 });
3159 let out = emit_dss_text(&net);
3160 let line = out.text.lines().find(|l| l.contains("Load.ld")).unwrap();
3161 assert!(line.contains("vminpu=0"), "{line}");
3162 assert!(line.contains("vmaxpu=2"), "{line}");
3163 }
3164
3165 #[test]
3166 fn explicit_load_voltage_bounds_are_preserved() {
3167 let (b, vs) = three_phase_source(2400.0);
3168 let mut load = load_on("sb", &["1"], Configuration::Wye);
3169 load.extras.insert("vminpu".into(), serde_json::json!(0.8));
3170 load.extras.insert("vmaxpu".into(), serde_json::json!(1.2));
3171 let net = MulticonductorNetwork::from_tables(MulticonductorNetworkTables {
3172 base_frequency: 60.0,
3173 buses: vec![b],
3174 sources: vec![vs],
3175 loads: vec![load],
3176 ..MulticonductorNetworkTables::default()
3177 });
3178 let out = emit_dss_text(&net);
3179 let line = out.text.lines().find(|l| l.contains("Load.ld")).unwrap();
3180 assert!(line.contains("vminpu=0.8"), "{line}");
3181 assert!(line.contains("vmaxpu=1.2"), "{line}");
3182 }
3183
3184 #[test]
3185 fn default_load_voltage_bounds_can_be_disabled() {
3186 let (b, vs) = three_phase_source(2400.0);
3187 let load = load_on("sb", &["1"], Configuration::Wye);
3188 let net = MulticonductorNetwork::from_tables(MulticonductorNetworkTables {
3189 base_frequency: 60.0,
3190 buses: vec![b],
3191 sources: vec![vs],
3192 loads: vec![load],
3193 ..MulticonductorNetworkTables::default()
3194 });
3195 let options = DssEmitOptions {
3196 default_load_voltage_bounds: None,
3197 ..DssEmitOptions::default()
3198 };
3199 let out = emit_dss_text_with_options(&net, &options);
3200 let line = out.text.lines().find(|l| l.contains("Load.ld")).unwrap();
3201 assert!(!line.contains("vminpu="), "{line}");
3202 assert!(!line.contains("vmaxpu="), "{line}");
3203 }
3204
3205 #[test]
3206 fn voltage_bases_survive_the_sqrt_round_trip() {
3207 let vln = 9_336.235_056_420_312_f64;
3211 let basekv = vln * 3f64.sqrt() / 1e3;
3212 assert!(
3213 (basekv * 1e3 / 3f64.sqrt()).to_bits() != vln.to_bits(),
3214 "test value no longer reproduces the drift"
3215 );
3216 let (b, vs) = three_phase_source(vln);
3217 let net = MulticonductorNetwork::from_tables(MulticonductorNetworkTables {
3218 name: Some("t".into()),
3219 base_frequency: 60.0,
3220 buses: vec![b],
3221 sources: vec![vs],
3222 ..MulticonductorNetworkTables::default()
3223 });
3224 let (first, second) = roundtrip(&net);
3225 assert!(first.contains("Set VoltageBases="), "{first}");
3226 assert_eq!(first, second);
3227 }
3228
3229 #[test]
3230 fn load_phases_prefer_the_reader_stash() {
3231 let (b, vs) = three_phase_source(2400.0);
3232 let mut load = load_on("sb", &["1", "2", "3"], Configuration::Delta);
3233 load.extras.insert("phases".into(), serde_json::json!("2"));
3234 let net = MulticonductorNetwork::from_tables(MulticonductorNetworkTables {
3235 base_frequency: 60.0,
3236 buses: vec![b],
3237 sources: vec![vs],
3238 loads: vec![load],
3239 ..MulticonductorNetworkTables::default()
3240 });
3241 let out = emit_dss_text(&net);
3242 let line = out.text.lines().find(|l| l.contains("Load.ld")).unwrap();
3243 assert!(line.contains("phases=2 conn=delta"), "{line}");
3244 assert_eq!(line.matches("phases=").count(), 1, "{line}");
3246 assert!(
3247 !out.render_diagnostics()
3248 .iter()
3249 .any(|w| w.contains("2 or 3 phase"))
3250 );
3251 }
3252
3253 #[test]
3254 fn ambiguous_delta_keeps_three_phases_loudly() {
3255 let (b, vs) = three_phase_source(2400.0);
3256 let net = MulticonductorNetwork::from_tables(MulticonductorNetworkTables {
3257 base_frequency: 60.0,
3258 buses: vec![b],
3259 sources: vec![vs],
3260 loads: vec![load_on("sb", &["1", "2", "3"], Configuration::Delta)],
3261 ..MulticonductorNetworkTables::default()
3262 });
3263 let out = emit_dss_text(&net);
3264 let line = out.text.lines().find(|l| l.contains("Load.ld")).unwrap();
3265 assert!(line.contains("phases=3 conn=delta"), "{line}");
3266 assert!(
3267 out.render_diagnostics()
3268 .iter()
3269 .any(|w| w.contains("2 or 3 phase")),
3270 "{:?}",
3271 out.render_diagnostics()
3272 );
3273 }
3274
3275 #[test]
3276 fn single_phase_delta_emits_conn_delta() {
3277 let (b, vs) = three_phase_source(2400.0);
3278 let two_wire = load_on("sb", &["1", "2"], Configuration::Delta);
3280 let mut stashed = load_on("sb", &["1", "2"], Configuration::SinglePhase);
3283 stashed.name = "ld2".into();
3284 stashed
3285 .extras
3286 .insert("conn".into(), serde_json::json!("delta"));
3287 let net = MulticonductorNetwork::from_tables(MulticonductorNetworkTables {
3288 base_frequency: 60.0,
3289 buses: vec![b],
3290 sources: vec![vs],
3291 loads: vec![two_wire, stashed],
3292 ..MulticonductorNetworkTables::default()
3293 });
3294 let out = emit_dss_text(&net);
3295 let l1 = out.text.lines().find(|l| l.contains("Load.ld ")).unwrap();
3296 assert!(l1.contains("phases=1 conn=delta"), "{l1}");
3297 let l2 = out.text.lines().find(|l| l.contains("Load.ld2 ")).unwrap();
3298 assert!(l2.contains("phases=1 conn=delta"), "{l2}");
3299 assert_eq!(l2.matches("conn=").count(), 1, "{l2}");
3300 }
3301
3302 #[test]
3303 fn unrepresentable_names_are_reported() {
3304 let (b, vs) = three_phase_source(2400.0);
3305 let mut load = load_on("sb", &["1", "2", "3", "4"], Configuration::Wye);
3306 load.name = "load 1".into();
3307 let net = MulticonductorNetwork::from_tables(MulticonductorNetworkTables {
3308 name: Some("my circuit".into()),
3309 base_frequency: 60.0,
3310 buses: vec![b, bus("a=b", &["1"], &[])],
3311 sources: vec![vs],
3312 loads: vec![load],
3313 ..MulticonductorNetworkTables::default()
3314 });
3315 let out = emit_dss_text(&net);
3316 let hits = |needle: &str| {
3317 out.render_diagnostics()
3318 .iter()
3319 .any(|w| w.contains(needle) && w.contains("cannot represent"))
3320 };
3321 assert!(hits("load 1"), "{:?}", out.render_diagnostics());
3322 assert!(hits("my circuit"), "{:?}", out.render_diagnostics());
3323 let mut net2 = net.clone();
3325 net2.lines_mut().push(DistLine {
3326 name: "l1".into(),
3327 bus_from: "sb".into(),
3328 bus_to: "a=b".into(),
3329 terminal_map_from: strings(&["1"]),
3330 terminal_map_to: strings(&["1"]),
3331 linecode: "lc".into(),
3332 length: 1.0,
3333 route: None,
3334 i_max: None,
3335 s_max: None,
3336 extras: Extras::new(),
3337 });
3338 let out2 = emit_dss_text(&net2);
3339 assert!(
3340 out2.render_diagnostics()
3341 .iter()
3342 .any(|w| w.contains("a=b") && w.contains("cannot represent")),
3343 "{:?}",
3344 out2.render_diagnostics()
3345 );
3346 }
3347
3348 #[test]
3349 fn unequal_per_phase_i_max_warns_that_emergamps_holds_one_phase() {
3350 let (b, vs) = three_phase_source(2400.0);
3351 let net = MulticonductorNetwork::from_tables(MulticonductorNetworkTables {
3352 base_frequency: 60.0,
3353 buses: vec![b, bus("b2", &["1", "2", "3"], &[])],
3354 sources: vec![vs],
3355 lines: vec![DistLine {
3356 name: "l1".into(),
3357 bus_from: "sb".into(),
3358 bus_to: "b2".into(),
3359 terminal_map_from: strings(&["1", "2", "3"]),
3360 terminal_map_to: strings(&["1", "2", "3"]),
3361 linecode: "lc".into(),
3362 length: 1.0,
3363 route: None,
3364 i_max: Some(vec![400.0, 300.0, 200.0]),
3365 s_max: None,
3366 extras: Extras::new(),
3367 }],
3368 ..MulticonductorNetworkTables::default()
3369 });
3370 let out = emit_dss_text(&net);
3371 let line = out.text.lines().find(|l| l.contains("Line.l1 ")).unwrap();
3372 assert!(line.contains("emergamps=400"), "{line}");
3373 assert!(
3374 out.render_diagnostics()
3375 .iter()
3376 .any(|w| w.contains("line l1") && w.contains("not equal on all phases")),
3377 "{:?}",
3378 out.render_diagnostics()
3379 );
3380 }
3381
3382 #[test]
3383 fn line_level_i_max_emits_emergamps_and_s_max_drops_with_a_warning() {
3384 let (b, vs) = three_phase_source(2400.0);
3385 let net = MulticonductorNetwork::from_tables(MulticonductorNetworkTables {
3386 base_frequency: 60.0,
3387 buses: vec![b, bus("b2", &["1"], &[])],
3388 sources: vec![vs],
3389 lines: vec![DistLine {
3390 name: "l1".into(),
3391 bus_from: "sb".into(),
3392 bus_to: "b2".into(),
3393 terminal_map_from: strings(&["1"]),
3394 terminal_map_to: strings(&["1"]),
3395 linecode: "lc".into(),
3396 length: 1.0,
3397 route: None,
3398 i_max: Some(vec![400.0]),
3399 s_max: Some(vec![600.0]),
3400 extras: Extras::new(),
3401 }],
3402 ..MulticonductorNetworkTables::default()
3403 });
3404 let out = emit_dss_text(&net);
3405 let line = out.text.lines().find(|l| l.contains("Line.l1 ")).unwrap();
3406 assert!(line.contains("emergamps=400"), "{line}");
3407 assert!(
3408 !out.render_diagnostics()
3409 .iter()
3410 .any(|w| w.contains("line l1") && w.contains("i_max")),
3411 "{:?}",
3412 out.render_diagnostics()
3413 );
3414 assert!(
3415 out.render_diagnostics()
3416 .iter()
3417 .any(|w| w.contains("line l1") && w.contains("s_max") && w.contains("dropped")),
3418 "{:?}",
3419 out.render_diagnostics()
3420 );
3421 }
3422
3423 #[test]
3424 fn line_level_emergamps_round_trips_as_i_max() {
3425 let src = "Clear\n\
3426 New Circuit.c1 basekv=12.47 pu=1 angle=0 phases=3 bus1=sb.1.2.3\n\
3427 New Linecode.lc nphases=3 r1=0.1 x1=0.2 emergamps=600\n\
3428 New Line.l1 bus1=sb.1.2.3 bus2=b2.1.2.3 phases=3 linecode=lc \
3429 length=10 units=m emergamps=250\n\
3430 New Line.l2 bus1=b2.1.2.3 bus2=b3.1.2.3 phases=3 linecode=lc \
3431 length=10 units=m\n";
3432 let net = parse_dss_str(src);
3433 let l1 = net.lines().iter().find(|l| l.name == "l1").unwrap();
3434 assert_eq!(l1.i_max.as_deref(), Some(&[250.0, 250.0, 250.0][..]));
3435 assert!(!l1.extras.contains_key("emergamps"), "{:?}", l1.extras);
3436 let l2 = net.lines().iter().find(|l| l.name == "l2").unwrap();
3438 assert_eq!(l2.i_max, None);
3439
3440 let (first, second) = roundtrip(&net);
3441 let line = first.lines().find(|l| l.contains("Line.l1 ")).unwrap();
3442 assert!(line.contains("emergamps=250"), "{line}");
3443 assert_eq!(line.matches("emergamps=").count(), 1, "{line}");
3444 let line2 = first.lines().find(|l| l.contains("Line.l2 ")).unwrap();
3445 assert!(!line2.contains("emergamps="), "{line2}");
3446 assert_eq!(first, second);
3447 }
3448
3449 #[test]
3450 fn unparsable_line_emergamps_stays_in_extras_for_the_echo() {
3451 let src = "Clear\n\
3452 New Circuit.c1 basekv=12.47 pu=1 angle=0 phases=3 bus1=sb.1.2.3\n\
3453 New Linecode.lc nphases=3 r1=0.1 x1=0.2\n\
3454 New Line.l1 bus1=sb.1.2.3 bus2=b2.1.2.3 phases=3 linecode=lc \
3455 length=10 units=m emergamps=@amps\n";
3456 let net = parse_dss_str(src);
3457 let l1 = net.lines().iter().find(|l| l.name == "l1").unwrap();
3458 assert_eq!(l1.i_max, None);
3459 assert_eq!(
3460 l1.extras.get("emergamps").and_then(|v| v.as_str()),
3461 Some("@amps")
3462 );
3463 let (first, second) = roundtrip(&net);
3464 let line = first.lines().find(|l| l.contains("Line.l1 ")).unwrap();
3465 assert!(line.contains("emergamps=@amps"), "{line}");
3466 assert_eq!(first, second);
3467 }
3468
3469 #[test]
3470 fn unparseable_kv_extra_warns_instead_of_silently_substituting() {
3471 let (b, vs) = three_phase_source(2400.0);
3472 let mut load = load_on("sb", &["1", "2", "3", "4"], Configuration::Wye);
3473 load.extras.insert("kv".into(), serde_json::json!("@kv"));
3474 let net = MulticonductorNetwork::from_tables(MulticonductorNetworkTables {
3475 base_frequency: 60.0,
3476 buses: vec![b],
3477 sources: vec![vs],
3478 loads: vec![load],
3479 ..MulticonductorNetworkTables::default()
3480 });
3481 let out = emit_dss_text(&net);
3482 assert!(
3483 out.render_diagnostics()
3484 .iter()
3485 .any(|w| w.contains("@kv") && w.contains("does not parse")),
3486 "{:?}",
3487 out.render_diagnostics()
3488 );
3489 let line = out.text.lines().find(|l| l.contains("Load.ld")).unwrap();
3491 assert!(
3492 line.contains(&format!("kv={}", num(2400.0 * 3f64.sqrt() / 1e3))),
3493 "{line}"
3494 );
3495 }
3496
3497 #[test]
3498 fn options_reemit_and_commands_warn() {
3499 let src = "Clear\n\
3500 New Circuit.c1 basekv=12.47 pu=1 angle=0 phases=3 bus1=sb\n\
3501 Set mode=snapshot\n\
3502 Set controlmode=OFF\n\
3503 Disable Line.l1\n\
3504 Set VoltageBases=[12.47]\n\
3505 Calcvoltagebases\n\
3506 Solve\n";
3507 let out = emit_dss_text(&parse_dss_str(src));
3508 assert!(out.text.contains("Set mode=snapshot"), "{}", out.text);
3509 assert!(out.text.contains("Set controlmode=OFF"), "{}", out.text);
3510 assert_eq!(out.text.matches("Set VoltageBases").count(), 1);
3512 assert_eq!(out.text.matches("Calcvoltagebases").count(), 1);
3513 assert_eq!(out.text.matches("DefaultBaseFrequency").count(), 1);
3514 assert!(!out.text.to_lowercase().contains("disable"));
3515 assert!(
3516 out.render_diagnostics()
3517 .iter()
3518 .any(|w| w.contains("disable Line.l1") && w.contains("not regenerated")),
3519 "{:?}",
3520 out.render_diagnostics()
3521 );
3522 assert!(
3524 !out.render_diagnostics()
3525 .iter()
3526 .any(|w| w.contains("`solve`"))
3527 );
3528 let again = emit_dss_text(&parse_dss_str(&out.text));
3529 assert_eq!(out.text, again.text);
3530 }
3531
3532 #[test]
3533 fn non_numeric_terminal_positionalizes() {
3534 let mut load = load_on("b1", &["a", "n"], Configuration::Wye);
3535 load.extras.insert("kv".into(), serde_json::json!("0.23"));
3536 let net = MulticonductorNetwork::from_tables(MulticonductorNetworkTables {
3537 base_frequency: 60.0,
3538 buses: vec![bus("b1", &["a", "n"], &["n"])],
3539 loads: vec![load],
3540 ..MulticonductorNetworkTables::default()
3541 });
3542 let (first, second) = roundtrip(&net);
3543 let line = first.lines().find(|l| l.contains("Load.ld")).unwrap();
3544 assert!(line.contains("bus1=b1.1.0"), "{line}");
3545 let out = emit_dss_text(&net);
3546 assert!(
3547 out.render_diagnostics()
3548 .iter()
3549 .any(|w| w.contains("`a`") && w.contains("position")),
3550 "{:?}",
3551 out.render_diagnostics()
3552 );
3553 assert_eq!(first, second);
3554 }
3555
3556 #[test]
3557 fn half_present_thevenin_pair_stays_and_warns() {
3558 let (b, mut vs) = three_phase_source(2400.0);
3559 vs.extras
3560 .insert("rs".into(), serde_json::json!([[1.0, 0.1], [0.1, 1.0]]));
3561 let net = MulticonductorNetwork::from_tables(MulticonductorNetworkTables {
3562 base_frequency: 60.0,
3563 buses: vec![b],
3564 sources: vec![vs],
3565 ..MulticonductorNetworkTables::default()
3566 });
3567 let out = emit_dss_text(&net);
3568 assert!(!out.text.contains("z1="), "{}", out.text);
3569 assert!(
3570 out.render_diagnostics()
3571 .iter()
3572 .any(|w| w.contains("`xs` is missing")),
3573 "{:?}",
3574 out.render_diagnostics()
3575 );
3576 }
3577
3578 #[test]
3579 fn unusable_switch_sequence_extras_warn() {
3580 let (b, vs) = three_phase_source(2400.0);
3581 let sw = DistSwitch {
3582 name: "sw1".into(),
3583 bus_from: "sb".into(),
3584 bus_to: "b2".into(),
3585 terminal_map_from: strings(&["1", "2", "3"]),
3586 terminal_map_to: strings(&["1", "2", "3"]),
3587 open: false,
3588 i_max: Some(Vec::new()),
3589 extras: Extras::from([("pmd_rs".to_string(), serde_json::json!("oops"))]),
3590 };
3591 let net = MulticonductorNetwork::from_tables(MulticonductorNetworkTables {
3592 base_frequency: 60.0,
3593 buses: vec![b, bus("b2", &["1", "2", "3"], &[])],
3594 sources: vec![vs],
3595 switches: vec![sw],
3596 ..MulticonductorNetworkTables::default()
3597 });
3598 let out = emit_dss_text(&net);
3599 assert!(!out.text.contains("r0="), "{}", out.text);
3600 assert!(
3601 out.render_diagnostics()
3602 .iter()
3603 .any(|w| w.contains("pmd_rs") && w.contains("not a numeric matrix")),
3604 "{:?}",
3605 out.render_diagnostics()
3606 );
3607 assert!(
3608 out.render_diagnostics()
3609 .iter()
3610 .any(|w| w.contains("i_max is empty")),
3611 "{:?}",
3612 out.render_diagnostics()
3613 );
3614 }
3615
3616 #[test]
3617 fn degenerate_shapes_warn_instead_of_panicking() {
3618 let (b, vs) = three_phase_source(2400.0);
3619 let lc = DistLineCode {
3620 name: "lc1".into(),
3621 n_conductors: 2,
3622 r_series: vec![vec![1.0], vec![0.5]], x_series: vec![vec![1.0, 0.0], vec![0.0, 1.0]],
3624 g_from: vec![vec![0.0; 2]; 2],
3625 b_from: vec![vec![0.0; 2]; 2],
3626 g_to: vec![vec![0.0; 2]; 2],
3627 b_to: vec![vec![0.0; 2]; 2],
3628 i_max: Some(Vec::new()),
3629 s_max: None,
3630 source: None,
3631 extras: Extras::new(),
3632 };
3633 let t = DistTransformer {
3634 name: "t1".into(),
3635 windings: vec![
3636 DistWinding {
3637 bus: "sb".into(),
3638 terminal_map: strings(&["1", "2"]),
3639 conn: DistWindingConn::Wye,
3640 v_ref: 2400.0,
3641 s_rating: 25e3,
3642 r_pct: 0.5,
3643 tap: 1.0,
3644 r_neutral: None,
3645 x_neutral: None,
3646 },
3647 DistWinding {
3648 bus: "b2".into(),
3649 terminal_map: strings(&["1", "2"]),
3650 conn: DistWindingConn::Wye,
3651 v_ref: 240.0,
3652 s_rating: 25e3,
3653 r_pct: 0.5,
3654 tap: 1.0,
3655 r_neutral: None,
3656 x_neutral: None,
3657 },
3658 ],
3659 xsc_pct: Vec::new(),
3660 phases: 1,
3661 extras: Extras::new(),
3662 };
3663 let net = MulticonductorNetwork::from_tables(MulticonductorNetworkTables {
3664 base_frequency: 60.0,
3665 buses: vec![b, bus("b2", &["1", "2"], &[])],
3666 sources: vec![vs],
3667 line_codes: vec![lc],
3668 transformers: vec![t],
3669 ..MulticonductorNetworkTables::default()
3670 });
3671 let out = emit_dss_text(&net); assert!(out.text.contains("rmatrix=(1 | 0.5 0)"), "{}", out.text);
3673 assert!(out.text.contains("xhl=0"), "{}", out.text);
3674 let has = |needle: &str| out.render_diagnostics().iter().any(|w| w.contains(needle));
3675 assert!(
3676 has("shorter than the lower triangle"),
3677 "{:?}",
3678 out.render_diagnostics()
3679 );
3680 assert!(has("xsc_pct is empty"), "{:?}", out.render_diagnostics());
3681 assert!(has("i_max is empty"), "{:?}", out.render_diagnostics());
3682 }
3683
3684 #[test]
3685 fn a_rated_capacitor_bank_writes_as_a_dss_capacitor() {
3686 let (b, vs) = three_phase_source(2400.0);
3690 let cap = crate::model::DistCapacitor::new(
3691 "c1",
3692 "sb",
3693 strings(&["1", "2", "3", "n"]),
3694 Configuration::Wye,
3695 600e3,
3696 4160.0,
3697 );
3698 let net = MulticonductorNetwork::from_tables(MulticonductorNetworkTables {
3699 base_frequency: 60.0,
3700 buses: vec![b],
3701 sources: vec![vs],
3702 capacitors: vec![cap],
3703 ..MulticonductorNetworkTables::default()
3704 });
3705 let out = emit_dss_text(&net);
3706 let line = out
3707 .text
3708 .lines()
3709 .find(|l| l.contains("Capacitor.c1"))
3710 .unwrap_or_else(|| panic!("no capacitor emitted: {}", out.text));
3711 assert!(line.contains("kvar=600"), "{line}");
3712 assert!(line.contains("kv=4.16"), "{line}");
3713 assert!(line.contains("phases=3"), "{line}");
3714 assert!(
3715 !out.render_diagnostics()
3716 .iter()
3717 .any(|w| w.contains("dropped")),
3718 "{:?}",
3719 out.render_diagnostics()
3720 );
3721
3722 let back = parse_dss_str(&out.text);
3725 assert_eq!(back.shunts().len(), 1, "{}", out.text);
3726 }
3727
3728 #[test]
3729 fn an_unbalanced_load_splits_into_one_load_per_phase() {
3730 let (b, vs) = three_phase_source(2400.0);
3734 let mut unbalanced = DistLoad::new(
3735 "l1",
3736 "sb",
3737 strings(&["1", "2", "3", "n"]),
3738 Configuration::Wye,
3739 vec![1e3, 2e3, 3e3],
3740 vec![100.0, 200.0, 300.0],
3741 );
3742 unbalanced.extras.insert("kv".into(), 4.16.into());
3743 let balanced = DistLoad::new(
3744 "l2",
3745 "sb",
3746 strings(&["1", "2", "3", "n"]),
3747 Configuration::Wye,
3748 vec![1e3, 1e3, 1e3],
3749 vec![100.0, 100.0, 100.0],
3750 );
3751 let net = MulticonductorNetwork::from_tables(MulticonductorNetworkTables {
3752 base_frequency: 60.0,
3753 buses: vec![b],
3754 sources: vec![vs],
3755 loads: vec![unbalanced, balanced],
3756 ..MulticonductorNetworkTables::default()
3757 });
3758 let out = emit_dss_text(&net);
3759 let loads: Vec<&str> = out
3760 .text
3761 .lines()
3762 .filter(|l| l.contains("New Load."))
3763 .collect();
3764 assert_eq!(loads.len(), 4, "{}", out.text);
3765 for (name, kw, kvar) in [
3766 ("l1_1", "1", "0.1"),
3767 ("l1_2", "2", "0.2"),
3768 ("l1_3", "3", "0.3"),
3769 ] {
3770 let line = loads
3771 .iter()
3772 .find(|l| l.contains(&format!("New Load.{name} ")))
3773 .unwrap_or_else(|| panic!("no {name}: {}", out.text));
3774 assert!(line.contains(&format!("kw={kw} ")), "{line}");
3775 assert!(line.contains(&format!("kvar={kvar}")), "{line}");
3776 assert!(line.contains("phases=1"), "{line}");
3777 assert!(!line.contains("kv=4.16"), "{line}");
3779 }
3780 assert_eq!(
3781 loads.iter().filter(|l| l.contains("New Load.l2 ")).count(),
3782 1,
3783 "a balanced load stays one object: {}",
3784 out.text
3785 );
3786 }
3787
3788 #[test]
3789 fn a_center_tap_load_splits_onto_its_two_legs() {
3790 let (b, vs, lv) = center_tap_service(11000.0);
3795 let l = DistLoad {
3796 name: "ld".into(),
3797 bus: "lv".into(),
3798 terminal_map: strings(&["p1", "n", "p2"]),
3799 configuration: Configuration::Wye,
3800 p_nom: vec![1304.0, 1304.0],
3801 q_nom: vec![978.0, 978.0],
3802 voltage_model: DistLoadVoltageModel::ConstantImpedance { v_nom: Vec::new() },
3803 extras: Extras::new(),
3804 };
3805 let net = MulticonductorNetwork::from_tables(MulticonductorNetworkTables {
3806 base_frequency: 60.0,
3807 buses: vec![b, lv],
3808 sources: vec![vs],
3809 loads: vec![l],
3810 ..MulticonductorNetworkTables::default()
3811 });
3812 let out = emit_dss_text(&net);
3813 let loads: Vec<&str> = out
3814 .text
3815 .lines()
3816 .filter(|l| l.contains("New Load."))
3817 .collect();
3818 assert_eq!(loads.len(), 2, "{}", out.text);
3819 for (name, node) in [("ld_p1", "lv.1.0"), ("ld_p2", "lv.3.0")] {
3822 let line = loads
3823 .iter()
3824 .find(|l| l.contains(&format!("New Load.{name} ")))
3825 .unwrap_or_else(|| panic!("no {name}: {}", out.text));
3826 assert!(line.contains(&format!("bus1={node} ")), "{line}");
3827 assert!(line.contains("phases=1"), "{line}");
3828 assert!(line.contains("kw=1.304"), "{line}");
3829 assert!(line.contains("kvar=0.978"), "{line}");
3830 }
3831 }
3832
3833 #[test]
3834 fn misordered_grounded_returns_reorder_when_nothing_is_indexed() {
3835 let out = emit_dss_text(&terminal_order_network(&["p1", "n", "p2"]));
3836 let diagnostics = terminal_order_diagnostics(&out);
3837
3838 assert_eq!(diagnostics.len(), 2, "{:?}", out.diagnostics);
3844 for diagnostic in &diagnostics {
3845 assert_eq!(diagnostic.details()["class"], "line");
3846 assert_eq!(diagnostic.details()["element_name"], "l1");
3847 }
3848 let reordered = out
3849 .diagnostics
3850 .iter()
3851 .filter(|d| d.message().contains("moved last"))
3852 .count();
3853 assert_eq!(reordered, 5, "{:?}", out.render_diagnostics());
3854
3855 for expected in [
3856 "New Capacitor.cap bus1=lv.1.3.0 ",
3857 "New Generator.gen bus1=lv.1.3.0 ",
3858 "New Generator.fixed_ibr bus1=lv.1.3.0 ",
3859 "New PVSystem.pv_ibr bus1=lv.1.3.0 ",
3860 "New Line.sw1 bus1=lv.1.3.0 bus2=lv.1.3.0 phases=3 switch=y",
3861 "New Line.l1 bus1=lv.1.0.3 bus2=lv.1.0.3 phases=3 linecode=lc ",
3862 ] {
3863 assert!(
3864 out.text.contains(expected),
3865 "missing {expected:?}: {}",
3866 out.text
3867 );
3868 }
3869 }
3870
3871 #[test]
3872 fn a_line_with_its_linecode_permutes_the_matrices_in_step() {
3873 let mut net = terminal_order_network(&["p1", "n", "p2"]);
3874 net.line_codes_mut().push(DistLineCode::new(
3875 "lc",
3876 vec![vec![1.0], vec![0.2, 2.0], vec![0.3, 0.4, 3.0]],
3877 vec![vec![10.0], vec![0.0, 20.0], vec![0.0, 0.0, 30.0]],
3878 ));
3879 let out = emit_dss_text(&net);
3880
3881 assert_eq!(
3882 terminal_order_diagnostics(&out).len(),
3883 0,
3884 "{:?}",
3885 out.render_diagnostics()
3886 );
3887 assert!(
3890 out.text
3891 .contains("New Line.l1 bus1=lv.1.3.0 bus2=lv.1.3.0 phases=3 linecode=lc_ret1 "),
3892 "{}",
3893 out.text
3894 );
3895 assert!(
3899 out.text
3900 .contains("New Linecode.lc_ret1 nphases=3 units=m rmatrix=(1 | 0.3 3 | 0.2 0.4 2)"),
3901 "{}",
3902 out.text
3903 );
3904 assert!(
3906 out.text
3907 .contains("New Linecode.lc nphases=3 units=m rmatrix=(1 | 0.2 2 | 0.3 0.4 3)"),
3908 "{}",
3909 out.text
3910 );
3911 }
3912
3913 #[test]
3914 fn disagreeing_endpoint_returns_keep_the_order_error() {
3915 let mut net = terminal_order_network(&["p1", "n", "p2"]);
3916 net.lines_mut()[0].terminal_map_to = strings(&["n", "p1", "p2"]);
3920 net.switches_mut()[0].terminal_map_to = strings(&["n", "p1", "p2"]);
3921 let out = emit_dss_text(&net);
3922 let diagnostics = terminal_order_diagnostics(&out);
3923 assert!(
3924 diagnostics
3925 .iter()
3926 .any(|d| d.details()["class"] == "switch" && d.details()["endpoint"] == "bus2"),
3927 "{:?}",
3928 out.render_diagnostics()
3929 );
3930 assert!(
3931 out.text.contains("New Line.sw1 bus1=lv.1.0.3 "),
3932 "{}",
3933 out.text
3934 );
3935 }
3936
3937 #[test]
3938 fn grounded_return_last_needs_no_terminal_order_diagnostic() {
3939 let out = emit_dss_text(&terminal_order_network(&["p1", "p2", "n"]));
3940 assert!(
3941 terminal_order_diagnostics(&out).is_empty(),
3942 "{:?}",
3943 out.diagnostics
3944 );
3945 assert!(out.text.contains("bus1=lv.1.2.0"), "{}", out.text);
3946 }
3947
3948 #[test]
3949 fn an_unbalanced_center_tap_load_keeps_each_leg_with_its_own_power() {
3950 let (b, vs, lv) = center_tap_service(11000.0);
3954 let l = DistLoad::new(
3955 "ld",
3956 "lv",
3957 strings(&["p1", "n", "p2"]),
3958 Configuration::Wye,
3959 vec![1000.0, 2000.0],
3960 vec![100.0, 200.0],
3961 );
3962 let net = MulticonductorNetwork::from_tables(MulticonductorNetworkTables {
3963 base_frequency: 60.0,
3964 buses: vec![b, lv],
3965 sources: vec![vs],
3966 loads: vec![l],
3967 ..MulticonductorNetworkTables::default()
3968 });
3969 let out = emit_dss_text(&net);
3970 let loads: Vec<&str> = out
3971 .text
3972 .lines()
3973 .filter(|l| l.contains("New Load."))
3974 .collect();
3975 assert_eq!(loads.len(), 2, "{}", out.text);
3976 for (name, node, kw, kvar) in [
3977 ("ld_p1", "lv.1.0", "1", "0.1"),
3978 ("ld_p2", "lv.3.0", "2", "0.2"),
3979 ] {
3980 let line = loads
3981 .iter()
3982 .find(|l| l.contains(&format!("New Load.{name} ")))
3983 .unwrap_or_else(|| panic!("no {name}: {}", out.text));
3984 assert!(line.contains(&format!("bus1={node} ")), "{line}");
3985 assert!(line.contains(&format!("kw={kw} ")), "{line}");
3986 assert!(line.contains(&format!("kvar={kvar}")), "{line}");
3987 }
3988 assert!(!out.text.contains("New Load.ld_n "), "{}", out.text);
3990 }
3991
3992 #[test]
3993 fn a_map_longer_than_the_record_says_what_dss_drops() {
3994 let (b, vs, lv) = center_tap_service(11000.0);
3997 let mut l = DistLoad::new(
3998 "ld",
3999 "lv",
4000 strings(&["p1", "n", "p2"]),
4001 Configuration::Wye,
4002 vec![2608.0],
4003 vec![1956.0],
4004 );
4005 l.extras.insert("phases".into(), 1.into());
4006 let net = MulticonductorNetwork::from_tables(MulticonductorNetworkTables {
4007 base_frequency: 60.0,
4008 buses: vec![b, lv],
4009 sources: vec![vs],
4010 loads: vec![l],
4011 ..MulticonductorNetworkTables::default()
4012 });
4013 let out = emit_dss_text(&net);
4014 assert_eq!(
4015 out.text.lines().filter(|l| l.contains("New Load.")).count(),
4016 1,
4017 "{}",
4018 out.text
4019 );
4020 assert!(
4021 out.render_diagnostics()
4022 .iter()
4023 .any(|w| w.contains("addresses 2") && w.contains("loses them")),
4024 "{:?}",
4025 out.render_diagnostics()
4026 );
4027 }
4028
4029 #[test]
4030 fn a_star_with_no_secondary_leakage_goes_out_solvable() {
4031 let (b, vs, lv) = center_tap_service(11000.0);
4035 let winding = |bus: &str, map: &[&str], v: f64| DistWinding {
4036 bus: bus.into(),
4037 terminal_map: strings(map),
4038 conn: DistWindingConn::Wye,
4039 v_ref: v,
4040 s_rating: 25e3,
4041 r_pct: 0.5,
4042 tap: 1.0,
4043 r_neutral: None,
4044 x_neutral: None,
4045 };
4046 let t = DistTransformer {
4047 name: "tx".into(),
4048 phases: 1,
4049 windings: vec![
4050 winding("sb", &["1", "4"], 11000.0),
4051 winding("lv", &["p1", "n"], 240.0),
4052 winding("lv", &["n", "p2"], 240.0),
4053 ],
4054 xsc_pct: vec![2.5, 2.5, 0.0],
4055 extras: Extras::new(),
4056 };
4057 let net = MulticonductorNetwork::from_tables(MulticonductorNetworkTables {
4058 base_frequency: 60.0,
4059 buses: vec![b, lv],
4060 sources: vec![vs],
4061 transformers: vec![t],
4062 ..MulticonductorNetworkTables::default()
4063 });
4064 let out = emit_dss_text(&net);
4065 let line = out
4066 .text
4067 .lines()
4068 .find(|l| l.contains("New Transformer.tx"))
4069 .unwrap_or_else(|| panic!("no transformer: {}", out.text));
4070 assert!(line.contains("xhl=2.5 xht=2.5 xlt=1.666"), "{line}");
4071 assert!(line.contains("buses=(sb.1.0, lv.1.0, lv.0.3)"), "{line}");
4074 assert!(
4075 out.render_diagnostics()
4076 .iter()
4077 .any(|w| w.contains("collapsed secondary")),
4078 "{:?}",
4079 out.render_diagnostics()
4080 );
4081 }
4082
4083 #[test]
4084 fn a_delta_load_with_per_phase_power_stays_balanced_and_says_so() {
4085 let (b, vs) = three_phase_source(2400.0);
4088 let l = DistLoad::new(
4089 "d1",
4090 "sb",
4091 strings(&["1", "2", "3"]),
4092 Configuration::Delta,
4093 vec![1e3, 2e3, 3e3],
4094 vec![0.0, 0.0, 0.0],
4095 );
4096 let net = MulticonductorNetwork::from_tables(MulticonductorNetworkTables {
4097 base_frequency: 60.0,
4098 buses: vec![b],
4099 sources: vec![vs],
4100 loads: vec![l],
4101 ..MulticonductorNetworkTables::default()
4102 });
4103 let out = emit_dss_text(&net);
4104 assert_eq!(
4105 out.text.lines().filter(|l| l.contains("New Load.")).count(),
4106 1,
4107 "{}",
4108 out.text
4109 );
4110 assert!(
4111 out.render_diagnostics()
4112 .iter()
4113 .any(|w| w.contains("per phase power on a delta load")),
4114 "{:?}",
4115 out.render_diagnostics()
4116 );
4117 }
4118
4119 #[test]
4120 fn two_phase_capacitor_kvar_uses_line_to_line_kv() {
4121 let (b, vs) = three_phase_source(2400.0);
4124 let b_phase = 1e-3;
4125 let sh = DistShunt {
4126 name: "c1".into(),
4127 bus: "sb".into(),
4128 terminal_map: strings(&["1", "2"]),
4129 g: vec![vec![0.0; 2]; 2],
4130 b: vec![vec![b_phase, 0.0], vec![0.0, b_phase]],
4131 extras: Extras::new(),
4132 };
4133 let net = MulticonductorNetwork::from_tables(MulticonductorNetworkTables {
4134 base_frequency: 60.0,
4135 buses: vec![b],
4136 sources: vec![vs],
4137 shunts: vec![sh],
4138 ..MulticonductorNetworkTables::default()
4139 });
4140 let out = emit_dss_text(&net);
4141 let kv = 2400.0 * 3f64.sqrt() / 1e3;
4142 let v_phase = kv * 1e3 / 3f64.sqrt();
4143 let expected = b_phase * v_phase * v_phase * 2.0 / 1e3;
4144 let line = out
4145 .text
4146 .lines()
4147 .find(|l| l.contains("Capacitor.c1"))
4148 .unwrap();
4149 assert!(line.contains(&format!("kvar={}", num(expected))), "{line}");
4150 }
4151
4152 #[test]
4153 fn inductive_shunt_regenerates_as_a_reactor() {
4154 let (b, vs) = three_phase_source(2400.0);
4158 let b_phase = -1e-3;
4159 let sh = DistShunt {
4160 name: "rx".into(),
4161 bus: "sb".into(),
4162 terminal_map: strings(&["1", "2", "3"]),
4163 g: vec![vec![0.0; 3]; 3],
4164 b: vec![
4165 vec![b_phase, 0.0, 0.0],
4166 vec![0.0, b_phase, 0.0],
4167 vec![0.0, 0.0, b_phase],
4168 ],
4169 extras: Extras::new(),
4170 };
4171 let net = MulticonductorNetwork::from_tables(MulticonductorNetworkTables {
4172 base_frequency: 60.0,
4173 buses: vec![b],
4174 sources: vec![vs],
4175 shunts: vec![sh],
4176 ..MulticonductorNetworkTables::default()
4177 });
4178 let out = emit_dss_text(&net);
4179 let line = out
4180 .text
4181 .lines()
4182 .find(|l| l.contains("Reactor.rx"))
4183 .unwrap_or_else(|| panic!("no reactor emitted in:\n{}", out.text));
4184 assert!(!out.text.contains("Capacitor.rx"), "{}", out.text);
4185 let kv = 2400.0 * 3f64.sqrt() / 1e3;
4186 let v_phase = kv * 1e3 / 3f64.sqrt();
4187 let expected = b_phase.abs() * v_phase * v_phase * 3.0 / 1e3;
4188 assert!(line.contains(&format!("kvar={}", num(expected))), "{line}");
4189 }
4190
4191 #[test]
4192 fn conductive_shunt_regenerates_as_grounding_reactor() {
4193 let (_, vs) = three_phase_source(2400.0);
4194 let b = bus("sb", &["1", "2", "3", "4"], &[]);
4195 let sh = DistShunt {
4196 name: "gnd".into(),
4197 bus: "sb".into(),
4198 terminal_map: strings(&["4"]),
4199 g: vec![vec![1.0 / 0.3]],
4200 b: vec![vec![0.0]],
4201 extras: Extras::new(),
4202 };
4203 let net = MulticonductorNetwork::from_tables(MulticonductorNetworkTables {
4204 base_frequency: 60.0,
4205 buses: vec![b],
4206 sources: vec![vs],
4207 shunts: vec![sh],
4208 ..MulticonductorNetworkTables::default()
4209 });
4210 let out = emit_dss_text(&net);
4211 let line = out
4212 .text
4213 .lines()
4214 .find(|l| l.contains("Reactor.gnd"))
4215 .unwrap_or_else(|| panic!("no reactor emitted in:\n{}", out.text));
4216 assert!(line.contains("bus1=sb.4"), "{line}");
4217 assert!(line.contains("bus2=sb.0"), "{line}");
4218 assert!(line.contains("phases=1"), "{line}");
4219 assert!(line.contains("r=0.3"), "{line}");
4220 assert!(line.contains("x=0"), "{line}");
4221 assert!(
4222 !line.contains("x=-0"),
4223 "negative zero must canonicalize: {line}"
4224 );
4225 }
4226
4227 #[test]
4228 fn delta_shunt_regenerates_conn_delta() {
4229 let (b, vs) = three_phase_source(2400.0);
4230 let b_branch = 2e-4;
4231 let bmat = vec![
4232 vec![2.0 * b_branch, -b_branch, -b_branch],
4233 vec![-b_branch, 2.0 * b_branch, -b_branch],
4234 vec![-b_branch, -b_branch, 2.0 * b_branch],
4235 ];
4236 let mut extras = Extras::new();
4237 extras.insert("conn".into(), serde_json::json!("delta"));
4238 extras.insert("phases".into(), serde_json::json!("3"));
4239 let sh = DistShunt {
4240 name: "capd".into(),
4241 bus: "sb".into(),
4242 terminal_map: strings(&["1", "2", "3"]),
4243 g: vec![vec![0.0; 3]; 3],
4244 b: bmat,
4245 extras,
4246 };
4247 let net = MulticonductorNetwork::from_tables(MulticonductorNetworkTables {
4248 base_frequency: 60.0,
4249 buses: vec![b],
4250 sources: vec![vs],
4251 shunts: vec![sh],
4252 ..MulticonductorNetworkTables::default()
4253 });
4254 let out = emit_dss_text(&net);
4255 let line = out
4256 .text
4257 .lines()
4258 .find(|l| l.contains("Capacitor.capd"))
4259 .unwrap_or_else(|| panic!("no capacitor emitted in:\n{}", out.text));
4260 assert!(line.contains("phases=3 conn=delta"), "{line}");
4261 assert!(
4262 !out.render_diagnostics()
4263 .iter()
4264 .any(|w| w.contains("off diagonal")),
4265 "{:?}",
4266 out.render_diagnostics()
4267 );
4268 }
4269
4270 #[test]
4271 fn non_scalar_delta_matrix_is_not_inferred_silently() {
4272 let (b, vs) = three_phase_source(2400.0);
4273 let bmat = vec![
4274 vec![0.003, -0.001, -0.002],
4275 vec![-0.001, 0.003, -0.002],
4276 vec![-0.002, -0.002, 0.004],
4277 ];
4278 let sh = DistShunt {
4279 name: "capx".into(),
4280 bus: "sb".into(),
4281 terminal_map: strings(&["1", "2", "3"]),
4282 g: vec![vec![0.0; 3]; 3],
4283 b: bmat,
4284 extras: Extras::new(),
4285 };
4286 let net = MulticonductorNetwork::from_tables(MulticonductorNetworkTables {
4287 base_frequency: 60.0,
4288 buses: vec![b],
4289 sources: vec![vs],
4290 shunts: vec![sh],
4291 ..MulticonductorNetworkTables::default()
4292 });
4293 let out = emit_dss_text(&net);
4294 let line = out
4295 .text
4296 .lines()
4297 .find(|l| l.contains("Capacitor.capx"))
4298 .unwrap_or_else(|| panic!("no capacitor emitted in:\n{}", out.text));
4299 assert!(line.contains("conn=wye"), "{line}");
4300 assert!(
4301 out.render_diagnostics()
4302 .iter()
4303 .any(|w| w.contains("off diagonal")),
4304 "{:?}",
4305 out.render_diagnostics()
4306 );
4307 }
4308
4309 #[test]
4310 fn stashed_delta_matrix_warns_when_scalar_emission_is_lossy() {
4311 let (b, vs) = three_phase_source(2400.0);
4312 let bmat = vec![
4313 vec![0.003, -0.001, -0.002],
4314 vec![-0.001, 0.003, -0.002],
4315 vec![-0.002, -0.002, 0.004],
4316 ];
4317 let mut extras = Extras::new();
4318 extras.insert("conn".into(), serde_json::json!("delta"));
4319 extras.insert("phases".into(), serde_json::json!("3"));
4320 let sh = DistShunt {
4321 name: "capx".into(),
4322 bus: "sb".into(),
4323 terminal_map: strings(&["1", "2", "3"]),
4324 g: vec![vec![0.0; 3]; 3],
4325 b: bmat,
4326 extras,
4327 };
4328 let net = MulticonductorNetwork::from_tables(MulticonductorNetworkTables {
4329 base_frequency: 60.0,
4330 buses: vec![b],
4331 sources: vec![vs],
4332 shunts: vec![sh],
4333 ..MulticonductorNetworkTables::default()
4334 });
4335 let out = emit_dss_text(&net);
4336 let line = out
4337 .text
4338 .lines()
4339 .find(|l| l.contains("Capacitor.capx"))
4340 .unwrap_or_else(|| panic!("no capacitor emitted in:\n{}", out.text));
4341 assert!(line.contains("conn=delta"), "{line}");
4342 assert!(
4343 out.render_diagnostics()
4344 .iter()
4345 .any(|w| w.contains("no scalar capacitor expression")),
4346 "{:?}",
4347 out.render_diagnostics()
4348 );
4349 }
4350
4351 #[test]
4352 fn option_values_choose_a_wrapper_the_lexer_undoes() {
4353 let src = "Clear\n\
4354 New Circuit.c1 basekv=12.47 pu=1 angle=0 phases=3 bus1=sb\n\
4355 Set foo=[a!b]\n\
4356 Set bar=[(abc]\n\
4357 Set baz=(x ] y)\n\
4358 Set qux=[a ) b]\n\
4359 Solve\n";
4360 let net = parse_dss_str(src);
4361 let first = emit_dss_text(&net);
4362 for line in [
4363 "Set foo=(a!b)",
4364 "Set bar=((abc)",
4365 "Set baz=(x ] y)",
4366 "Set qux=[a ) b]",
4367 ] {
4368 assert!(
4369 first.text.contains(line),
4370 "{line} missing in {}",
4371 first.text
4372 );
4373 }
4374 assert!(
4375 !first
4376 .render_diagnostics()
4377 .iter()
4378 .any(|w| w.contains("emitted as written")),
4379 "{:?}",
4380 first.render_diagnostics()
4381 );
4382 let reparsed = parse_dss_str(&first.text);
4384 let opt = |k: &str| {
4385 reparsed
4386 .options()
4387 .iter()
4388 .find(|(name, _)| name == k)
4389 .map(|(_, v)| v.as_str())
4390 };
4391 assert_eq!(opt("foo"), Some("a!b"));
4392 assert_eq!(opt("bar"), Some("(abc"));
4393 assert_eq!(opt("baz"), Some("x ] y"));
4394 assert_eq!(opt("qux"), Some("a ) b"));
4395 let second = emit_dss_text(&reparsed);
4397 assert_eq!(first.text, second.text);
4398 }
4399
4400 #[test]
4401 fn extras_tail_values_wrap_like_options() {
4402 let (b, vs) = three_phase_source(2400.0);
4403 let mut load = load_on("sb", &["1", "2", "3", "4"], Configuration::Wye);
4404 load.extras
4405 .insert("daily".into(), serde_json::json!("a ) b"));
4406 let net = MulticonductorNetwork::from_tables(MulticonductorNetworkTables {
4407 base_frequency: 60.0,
4408 buses: vec![b],
4409 sources: vec![vs],
4410 loads: vec![load],
4411 ..MulticonductorNetworkTables::default()
4412 });
4413 let (first, second) = roundtrip(&net);
4414 assert!(first.contains("daily=[a ) b]"), "{first}");
4417 assert_eq!(first, second);
4418 let back = parse_dss_str(&first);
4419 assert_eq!(
4420 back.loads()[0]
4421 .extras
4422 .get("daily")
4423 .and_then(serde_json::Value::as_str),
4424 Some("a ) b")
4425 );
4426 }
4427
4428 #[test]
4429 fn unrepresentable_values_emit_as_written_and_warn() {
4430 let bad = "a )]}\"' b";
4433 let (b, vs) = three_phase_source(2400.0);
4434 let mut load = load_on("sb", &["1", "2", "3", "4"], Configuration::Wye);
4435 load.extras.insert("daily".into(), serde_json::json!(bad));
4436 let mut net = MulticonductorNetwork::from_tables(MulticonductorNetworkTables {
4437 base_frequency: 60.0,
4438 buses: vec![b],
4439 sources: vec![vs],
4440 loads: vec![load],
4441 ..MulticonductorNetworkTables::default()
4442 });
4443 net.options_mut().push(("foo".into(), bad.into()));
4444 let out = emit_dss_text(&net);
4445 assert!(out.text.contains(&format!("Set foo={bad}")), "{}", out.text);
4446 assert!(out.text.contains(&format!("daily={bad}")), "{}", out.text);
4447 let warned = |needle: &str| {
4448 out.render_diagnostics()
4449 .iter()
4450 .any(|w| w.contains(needle) && w.contains("emitted as written"))
4451 };
4452 assert!(warned("option `foo`"), "{:?}", out.render_diagnostics());
4453 assert!(warned("`daily`"), "{:?}", out.render_diagnostics());
4454 }
4455
4456 #[test]
4457 fn empty_extras_values_wrap_instead_of_eating_the_next_token() {
4458 let dss = "clear\nnew circuit.c basekv=12.47 bus1=sb\n\
4459 new load.ld bus1=sb.1 phases=1 kv=7.2 kw=10 daily=() duty=sh\nsolve\n";
4460 let net = parse_dss_str(dss);
4461 let load = &net.loads()[0];
4462 assert_eq!(load.extras.get("daily").and_then(|v| v.as_str()), Some(""));
4463 let w1 = emit_dss_text(&net).text;
4464 let again = parse_dss_str(&w1);
4465 let load2 = &again.loads()[0];
4466 assert_eq!(load2.extras.get("daily").and_then(|v| v.as_str()), Some(""));
4467 assert_eq!(
4468 load2.extras.get("duty").and_then(|v| v.as_str()),
4469 Some("sh")
4470 );
4471 assert_eq!(w1, emit_dss_text(&again).text);
4472 }
4473
4474 #[test]
4475 fn sub_unique_option_prefixes_re_emit_instead_of_vanishing() {
4476 let dss = "clear\nnew circuit.c basekv=12.47 bus1=sb\n\
4480 Set ca=600\nSet default=2.5\nsolve\n";
4481 let net = parse_dss_str(dss);
4482 assert!((net.base_frequency() - 60.0).abs() < 1e-12);
4483 let out = emit_dss_text(&net).text;
4484 assert!(out.contains("Set ca=600"), "{out}");
4485 assert!(out.contains("Set default=2.5"), "{out}");
4486 }
4487
4488 #[test]
4489 fn abbreviated_derived_options_skip_and_set_the_frequency() {
4490 let src = "Clear\n\
4493 New Circuit.c1 basekv=12.47 pu=1 angle=0 phases=3 bus1=sb\n\
4494 Set volt=[115, 132]\n\
4495 Set defaultb=50\n\
4496 Solve\n";
4497 let net = parse_dss_str(src);
4498 assert!((net.base_frequency() - 50.0).abs() < 1e-12);
4499 let out = emit_dss_text(&net);
4500 assert!(
4501 out.text.contains("Set DefaultBaseFrequency=50"),
4502 "{}",
4503 out.text
4504 );
4505 assert_eq!(
4506 out.text
4507 .to_lowercase()
4508 .matches("defaultbasefrequency")
4509 .count(),
4510 1,
4511 "{}",
4512 out.text
4513 );
4514 assert_eq!(
4515 out.text.matches("Set VoltageBases").count(),
4516 1,
4517 "{}",
4518 out.text
4519 );
4520 assert!(!out.text.contains("Set volt="), "{}", out.text);
4521 assert!(!out.text.contains("Set defaultb="), "{}", out.text);
4522 let second = emit_dss_text(&parse_dss_str(&out.text));
4523 assert_eq!(out.text, second.text);
4524 }
4525
4526 #[test]
4527 fn non_numeric_source_extras_warn_before_falling_back() {
4528 let (b, mut vs) = three_phase_source(2400.0);
4529 vs.extras
4530 .insert("basekv".into(), serde_json::json!("@base"));
4531 vs.extras.insert("pu".into(), serde_json::json!("unity"));
4532 vs.extras.insert("angle".into(), serde_json::json!([0.0]));
4533 let net = MulticonductorNetwork::from_tables(MulticonductorNetworkTables {
4534 base_frequency: 60.0,
4535 buses: vec![b],
4536 sources: vec![vs],
4537 ..MulticonductorNetworkTables::default()
4538 });
4539 let out = emit_dss_text(&net);
4540 for key in ["basekv", "pu", "angle"] {
4541 assert!(
4542 out.render_diagnostics()
4543 .iter()
4544 .any(|w| w.contains(&format!("{key} extra")) && w.contains("does not parse")),
4545 "{key}: {:?}",
4546 out.render_diagnostics()
4547 );
4548 }
4549 let line = out.text.lines().find(|l| l.contains("Circuit.")).unwrap();
4551 assert!(line.contains("pu=1 angle=0"), "{line}");
4552 }
4553
4554 #[test]
4555 fn de_energized_source_phase_keeps_its_conductor() {
4556 let (b, mut vs) = three_phase_source(2400.0);
4557 vs.v_magnitude[2] = 0.0; let net = MulticonductorNetwork::from_tables(MulticonductorNetworkTables {
4559 name: Some("t".into()),
4560 base_frequency: 60.0,
4561 buses: vec![b],
4562 sources: vec![vs],
4563 ..MulticonductorNetworkTables::default()
4564 });
4565 let (first, second) = roundtrip(&net);
4566 let line = first.lines().find(|l| l.contains("Circuit.")).unwrap();
4567 assert!(line.contains("phases=3"), "{line}");
4569 assert!(line.contains("bus1=sb.1.2.3.0"), "{line}");
4570 assert_eq!(first, second);
4571 let out = emit_dss_text(&net);
4572 assert!(
4573 out.render_diagnostics()
4574 .iter()
4575 .any(|w| w.contains("phases=3") && w.contains("positive")),
4576 "{:?}",
4577 out.render_diagnostics()
4578 );
4579 }
4580
4581 #[test]
4582 fn multiple_sources_keep_named_vsource_when_source_exists() {
4583 let third = 2.0 * std::f64::consts::FRAC_PI_3;
4584 let source = VoltageSource {
4585 name: "source".into(),
4586 bus: "Bx".into(),
4587 terminal_map: strings(&["1", "2", "3", "4"]),
4588 v_magnitude: vec![20_000.0, 20_000.0, 20_000.0, 0.0],
4589 v_angle: vec![0.0, -third, third, 0.0],
4590 energy_cost_rate: None,
4591 extras: Extras::new(),
4592 };
4593 let wind = VoltageSource {
4594 name: "WindGen1".into(),
4595 bus: "Bg".into(),
4596 terminal_map: strings(&["1", "2", "3", "4"]),
4597 v_magnitude: vec![400.0, 400.0, 400.0, 0.0],
4598 v_angle: vec![
4599 -std::f64::consts::FRAC_PI_3,
4600 std::f64::consts::PI,
4601 third / 2.0,
4602 0.0,
4603 ],
4604 energy_cost_rate: None,
4605 extras: Extras::new(),
4606 };
4607 let net = MulticonductorNetwork::from_tables(MulticonductorNetworkTables {
4608 name: Some("dg".into()),
4609 base_frequency: 60.0,
4610 buses: vec![
4611 bus("Bg", &["1", "2", "3", "4"], &["4"]),
4612 bus("Bx", &["1", "2", "3", "4"], &["4"]),
4613 ],
4614 sources: vec![wind, source],
4615 ..MulticonductorNetworkTables::default()
4616 });
4617
4618 let out = emit_dss_text(&net).text;
4619 let circuit = out.lines().find(|l| l.starts_with("New Circuit")).unwrap();
4620 assert!(circuit.contains("bus1=Bx.1.2.3.0"), "{circuit}");
4621 assert!(
4622 out.lines()
4623 .any(|l| l.starts_with("New Vsource.WindGen1") && l.contains("bus1=Bg.1.2.3.0")),
4624 "{out}"
4625 );
4626 let reparsed = parse_dss_str(&out);
4627 assert!(
4628 reparsed
4629 .sources()
4630 .iter()
4631 .any(|vs| vs.name.eq_ignore_ascii_case("WindGen1")),
4632 "{:?}",
4633 reparsed.sources()
4634 );
4635 }
4636
4637 #[test]
4638 fn source_phases_stash_wins_and_does_not_double_emit() {
4639 let (b, mut vs) = three_phase_source(2400.0);
4640 vs.extras.insert("phases".into(), serde_json::json!("3"));
4641 let net = MulticonductorNetwork::from_tables(MulticonductorNetworkTables {
4642 base_frequency: 60.0,
4643 buses: vec![b],
4644 sources: vec![vs],
4645 ..MulticonductorNetworkTables::default()
4646 });
4647 let out = emit_dss_text(&net);
4648 let line = out.text.lines().find(|l| l.contains("Circuit.")).unwrap();
4649 assert!(line.contains("phases=3"), "{line}");
4650 assert_eq!(line.matches("phases=").count(), 1, "{line}");
4651 }
4652
4653 #[test]
4654 fn foreign_maps_without_a_neutral_warn_and_converge_at_write2() {
4655 let third = 2.0 * std::f64::consts::FRAC_PI_3;
4659 let vs = VoltageSource {
4660 name: "source".into(),
4661 bus: "sb".into(),
4662 terminal_map: strings(&["1", "2", "3"]),
4663 v_magnitude: vec![2400.0; 3],
4664 v_angle: vec![0.0, -third, third],
4665 energy_cost_rate: None,
4666 extras: Extras::new(),
4667 };
4668 let load = load_on("sb", &["1"], Configuration::Wye);
4669 let net = MulticonductorNetwork::from_tables(MulticonductorNetworkTables {
4670 name: Some("t".into()),
4671 base_frequency: 60.0,
4672 buses: vec![bus("sb", &["1", "2", "3"], &[])],
4673 sources: vec![vs],
4674 loads: vec![load],
4675 ..MulticonductorNetworkTables::default()
4676 });
4677 let first = emit_dss_text(&net);
4678 let hits = |warnings: &[String], name: &str| {
4679 warnings
4680 .iter()
4681 .any(|w| w.contains(name) && w.contains("materializes a grounded neutral"))
4682 };
4683 assert!(
4684 hits(&first.render_diagnostics(), "vsource source"),
4685 "{:?}",
4686 first.render_diagnostics()
4687 );
4688 assert!(
4689 hits(&first.render_diagnostics(), "load ld"),
4690 "{:?}",
4691 first.render_diagnostics()
4692 );
4693 let second = emit_dss_text(&parse_dss_str(&first.text));
4694 assert_ne!(first.text, second.text);
4695 assert!(
4696 !hits(&second.render_diagnostics(), "vsource"),
4697 "{:?}",
4698 second.render_diagnostics()
4699 );
4700 assert!(
4701 !hits(&second.render_diagnostics(), "load"),
4702 "{:?}",
4703 second.render_diagnostics()
4704 );
4705 let third_write = emit_dss_text(&parse_dss_str(&second.text));
4706 assert_eq!(second.text, third_write.text);
4707 }
4708
4709 #[test]
4710 fn generator_phases_and_conn_match_the_load_rules() {
4711 let (b, vs) = three_phase_source(2400.0);
4712 let g = DistGenerator {
4713 name: "g1".into(),
4714 bus: "sb".into(),
4715 terminal_map: strings(&["1", "2", "3"]),
4716 configuration: Configuration::Delta,
4717 p_nom: vec![1e3; 3],
4718 q_nom: vec![0.0; 3],
4719 p_min: None,
4720 p_max: None,
4721 q_min: None,
4722 q_max: None,
4723 cost: None,
4724 s_max: None,
4725 i_max: None,
4726 extras: Extras::from([
4727 ("kv".to_string(), serde_json::json!("4.16")),
4728 ("phases".to_string(), serde_json::json!("2")),
4729 ]),
4730 };
4731 let net = MulticonductorNetwork::from_tables(MulticonductorNetworkTables {
4732 base_frequency: 60.0,
4733 buses: vec![b],
4734 sources: vec![vs],
4735 generators: vec![g],
4736 ..MulticonductorNetworkTables::default()
4737 });
4738 let out = emit_dss_text(&net);
4739 let line = out
4740 .text
4741 .lines()
4742 .find(|l| l.contains("Generator.g1"))
4743 .unwrap();
4744 assert!(line.contains("phases=2 conn=delta"), "{line}");
4745 assert_eq!(line.matches("phases=").count(), 1, "{line}");
4746 }
4747
4748 #[test]
4749 fn fixed_dispatch_ibr_exports_as_generator_model_one() {
4750 let (b, vs) = three_phase_source(240.0);
4751 let ibr = DistIbr {
4752 name: "pv".into(),
4753 bus: "sb".into(),
4754 terminal_map: strings(&["1", "2", "3", "4"]),
4755 topology: IbrTopology::FourLeg,
4756 prime_mover: IbrPrimeMover::Pv,
4757 s_max: vec![10_000.0; 3],
4758 i_max: None,
4759 p_avail: Some(24_000.0),
4760 p_min: Some(vec![8_000.0; 3]),
4761 p_max: Some(vec![8_000.0; 3]),
4762 q_min: Some(vec![0.0; 3]),
4763 q_max: Some(vec![0.0; 3]),
4764 control_profile: None,
4765 voltage_aggregation: None,
4766 extras: Extras::from([("kv".to_string(), serde_json::json!("0.416"))]),
4767 };
4768 let net = MulticonductorNetwork::from_tables(MulticonductorNetworkTables {
4769 name: Some("fixed".into()),
4770 base_frequency: 60.0,
4771 buses: vec![b],
4772 sources: vec![vs],
4773 ibrs: vec![ibr],
4774 ..MulticonductorNetworkTables::default()
4775 });
4776
4777 let out = emit_dss_text(&net);
4778
4779 assert!(
4780 out.render_diagnostics().is_empty(),
4781 "{:?}",
4782 out.render_diagnostics()
4783 );
4784 let line = out
4785 .text
4786 .lines()
4787 .find(|l| l.starts_with("New Generator.pv"))
4788 .unwrap();
4789 assert!(line.contains("model=1 vminpu=0 vmaxpu=2"), "{line}");
4790 assert!(line.contains("kw=24"), "{line}");
4791 assert!(!out.text.contains("PVSystem.pv"), "{}", out.text);
4792 }
4793
4794 #[test]
4795 fn volt_var_ibr_exports_pvsystem_xycurve_and_invcontrol() {
4796 let (b, vs) = three_phase_source(240.0);
4797 let base_v = 416.0 / 3f64.sqrt();
4798 let ibr = DistIbr {
4799 name: "pv".into(),
4800 bus: "sb".into(),
4801 terminal_map: strings(&["1", "2", "3", "4"]),
4802 topology: IbrTopology::FourLeg,
4803 prime_mover: IbrPrimeMover::Pv,
4804 s_max: vec![10_000.0; 3],
4805 i_max: None,
4806 p_avail: Some(24_000.0),
4807 p_min: Some(vec![0.0; 3]),
4808 p_max: Some(vec![8_000.0; 3]),
4809 q_min: Some(vec![-4_000.0; 3]),
4810 q_max: Some(vec![4_000.0; 3]),
4811 control_profile: Some("cp".into()),
4812 voltage_aggregation: None,
4813 extras: Extras::from([("kv".to_string(), serde_json::json!("0.416"))]),
4814 };
4815 let profile = DistControlProfile {
4816 name: "cp".into(),
4817 power_factor: None,
4818 volt_var: Some(VoltVarControl {
4819 voltage_reference: Some(ControlVoltageReference::PgAveraged),
4820 breakpoints: [0.92, 0.98, 1.02, 1.08]
4821 .into_iter()
4822 .map(|v| v * base_v)
4823 .collect(),
4824 q_limits: vec![-0.44, 0.44],
4825 q_unit: Some(ReactivePowerUnit::VaFraction),
4826 q_ref: Some(ReactivePowerReference::VarMax),
4827 p_min_for_q: Some(10.0),
4828 p_min_for_q_max: Some(50.0),
4829 }),
4830 volt_watt: None,
4831 extras: Extras::new(),
4832 };
4833 let net = MulticonductorNetwork::from_tables(MulticonductorNetworkTables {
4834 name: Some("controlled".into()),
4835 base_frequency: 60.0,
4836 buses: vec![b],
4837 sources: vec![vs],
4838 ibrs: vec![ibr],
4839 control_profiles: vec![profile],
4840 ..MulticonductorNetworkTables::default()
4841 });
4842
4843 let out = emit_dss_text(&net);
4844
4845 assert!(
4846 out.render_diagnostics().is_empty(),
4847 "{:?}",
4848 out.render_diagnostics()
4849 );
4850 let pv = out
4851 .text
4852 .lines()
4853 .find(|l| l.starts_with("New PVSystem.pv"))
4854 .unwrap();
4855 assert!(pv.contains("WattPriority=No VarFollowInverter=Yes"), "{pv}");
4856 assert!(pv.contains("kvarMax=12"), "{pv}");
4857 assert!(pv.contains("kvarMaxAbs=12"), "{pv}");
4858 assert!(pv.contains("%PminNoVars=10"), "{pv}");
4859 assert!(pv.contains("%PminkvarMax=50"), "{pv}");
4860
4861 let curve = out
4862 .text
4863 .lines()
4864 .find(|l| l.starts_with("New XYcurve.vv_pv"))
4865 .unwrap();
4866 assert!(curve.contains("Xarray=[0.92 0.98 1.02 1.08]"), "{curve}");
4867 assert!(curve.contains("Yarray=[0.44 0 0 -0.44]"), "{curve}");
4868
4869 let inv = out
4870 .text
4871 .lines()
4872 .find(|l| l.starts_with("New InvControl.ivc_pv"))
4873 .unwrap();
4874 assert!(inv.contains("mode=VOLTVAR"), "{inv}");
4875 assert!(inv.contains("vvc_curve1=vv_pv"), "{inv}");
4876 assert!(inv.contains("RefReactivePower=VARMAX"), "{inv}");
4877 assert!(inv.contains("monVoltageCalc=AVG"), "{inv}");
4878 }
4879}