use std::collections::BTreeMap;
use std::fmt::Write as _;
use crate::convert::Conversion;
use crate::model::{
Configuration, DistBus, DistLoadVoltageModel, DistNetwork, Extras, Mat, Winding, WindingConn,
};
use super::read::delta_edges;
use super::{lex, prop};
pub fn write_dss(net: &DistNetwork) -> Conversion {
let mut w = DssWriter {
out: String::new(),
warnings: Vec::new(),
grounded: net
.buses
.iter()
.map(|b| (b.id.to_ascii_lowercase(), b.grounded.clone()))
.collect(),
terminals: net
.buses
.iter()
.map(|b| (b.id.to_ascii_lowercase(), b.terminals.clone()))
.collect(),
kv_estimate: estimate_bus_kv(net),
};
w.network(net);
Conversion {
text: w.out,
warnings: w.warnings,
}
}
struct DssWriter {
out: String,
warnings: Vec<String>,
grounded: BTreeMap<String, Vec<String>>,
terminals: BTreeMap<String, Vec<String>>,
kv_estimate: BTreeMap<String, f64>,
}
#[derive(Clone, Copy)]
struct ElementKv<'a> {
bus: &'a str,
phases: usize,
configuration: Configuration,
name: &'a str,
class: &'a str,
typed_kv: Option<f64>,
}
fn estimate_bus_kv(net: &DistNetwork) -> BTreeMap<String, f64> {
let mut kv: BTreeMap<String, f64> = BTreeMap::new();
for vs in &net.sources {
let phases = source_phases(net, vs);
let basekv = extras_f64(&vs.extras, "basekv").unwrap_or_else(|| source_basekv(vs, phases));
let pu = extras_f64(&vs.extras, "pu").unwrap_or(1.0);
let vln = basekv * 1e3 * pu / source_chord(phases);
if vln > 0.0 {
kv.insert(vs.bus.to_ascii_lowercase(), vln);
}
}
let grounded: BTreeMap<String, &Vec<String>> = net
.buses
.iter()
.map(|b| (b.id.to_ascii_lowercase(), &b.grounded))
.collect();
for _ in 0..net.buses.len() {
let mut changed = false;
for l in &net.lines {
let (f, t) = (
l.bus_from.to_ascii_lowercase(),
l.bus_to.to_ascii_lowercase(),
);
match (kv.get(&f).copied(), kv.get(&t).copied()) {
(Some(v), None) => {
kv.insert(t, v);
changed = true;
}
(None, Some(v)) => {
kv.insert(f, v);
changed = true;
}
_ => {}
}
}
for s in &net.switches {
let (f, t) = (
s.bus_from.to_ascii_lowercase(),
s.bus_to.to_ascii_lowercase(),
);
match (kv.get(&f).copied(), kv.get(&t).copied()) {
(Some(v), None) => {
kv.insert(t, v);
changed = true;
}
(None, Some(v)) => {
kv.insert(f, v);
changed = true;
}
_ => {}
}
}
for t in &net.transformers {
let pn = |w: &Winding| {
let v = (w.v_ref / 1e3) * 1e3;
let line_to_neutral = t.phases < 2
&& grounded
.get(&w.bus.to_ascii_lowercase())
.is_some_and(|g| w.terminal_map.iter().any(|tm| g.contains(tm)));
if line_to_neutral { v } else { v / 3f64.sqrt() }
};
let known: Option<(usize, f64)> = t
.windings
.iter()
.enumerate()
.find_map(|(i, w)| kv.get(&w.bus.to_ascii_lowercase()).map(|v| (i, *v)));
if let Some((i, v_known)) = known {
let pn_known = pn(&t.windings[i]);
if pn_known > 0.0 {
for (j, w) in t.windings.iter().enumerate() {
if j != i && !kv.contains_key(&w.bus.to_ascii_lowercase()) {
kv.insert(w.bus.to_ascii_lowercase(), v_known * pn(w) / pn_known);
changed = true;
}
}
}
}
}
if !changed {
break;
}
}
kv
}
fn num(v: f64) -> String {
let v = if v == 0.0 { 0.0 } else { v };
format!("{v}")
}
fn source_chord(phases: usize) -> f64 {
if phases <= 1 {
1.0
} else {
2.0 * (std::f64::consts::PI / phases as f64).sin()
}
}
fn source_basekv(vs: &crate::model::VoltageSource, phases: usize) -> f64 {
vs.v_magnitude.iter().copied().fold(0.0_f64, f64::max) * source_chord(phases) / 1e3
}
fn extras_f64(extras: &Extras, key: &str) -> Option<f64> {
let v = extras.get(key)?;
v.as_f64()
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
.filter(|f| f.is_finite())
}
fn extras_usize(extras: &Extras, key: &str) -> Option<usize> {
let v = extras.get(key)?;
v.as_u64()
.and_then(|u| usize::try_from(u).ok())
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
.or_else(|| {
v.as_f64()
.filter(|f| f.fract() == 0.0 && *f >= 0.0)
.map(|f| f as usize)
})
}
fn zipv_cutoff(value: Option<&serde_json::Value>) -> Option<f64> {
let text = value?.as_str()?;
lex::Value::new(text)
.to_vector(None)
.ok()
.and_then(|v| v.get(6).copied())
.filter(|v| v.is_finite())
}
fn name_breaks_dss(name: &str, is_bus_id: bool) -> bool {
name.contains("//")
|| name.chars().any(|c| {
matches!(
c,
' ' | '\t' | ',' | '=' | '!' | '"' | '\'' | '(' | ')' | '[' | ']' | '{' | '}'
) || (is_bus_id && c == '.')
})
}
fn dss_value_out(value: &str) -> (String, bool) {
if value.is_empty() {
return ("()".to_string(), true);
}
let mut scan = lex::Scanner::new(value, None);
let bare = scan.next_param().is_some_and(|p| {
p.name.is_none() && !p.value.quoted && p.value.text == value && scan.next_param().is_none()
});
if bare {
return (value.to_string(), true);
}
for (open, close) in [('(', ')'), ('[', ']'), ('{', '}'), ('"', '"'), ('\'', '\'')] {
if !value.contains(close) {
return (format!("{open}{value}{close}"), true);
}
}
(value.to_string(), false)
}
fn source_phases(net: &DistNetwork, vs: &crate::model::VoltageSource) -> usize {
if let Some(p) = extras_usize(&vs.extras, "phases") {
return p.max(1);
}
let energized = vs.v_magnitude.iter().filter(|&&v| v > 0.0).count();
if energized > 0
&& vs.v_magnitude.len() == vs.terminal_map.len()
&& energized + 1 == vs.v_magnitude.len()
&& vs.v_magnitude.last().is_some_and(|&v| v == 0.0)
{
return energized;
}
let grounded = net
.buses
.iter()
.find(|b| b.id.eq_ignore_ascii_case(&vs.bus))
.map(|b| b.grounded.as_slice())
.unwrap_or_default();
vs.terminal_map
.iter()
.filter(|t| !grounded.contains(t))
.count()
.max(1)
}
fn seq_parts(extras: &Extras, key: &str) -> Option<(f64, f64)> {
let row = extras.get(key)?.as_array()?.first()?.as_array()?;
let self_v = row.first()?.as_f64()?;
let mutual = row
.get(1)
.and_then(serde_json::Value::as_f64)
.unwrap_or(0.0);
Some((self_v, mutual))
}
impl DssWriter {
fn warn(&mut self, msg: impl Into<String>) {
self.warnings.push(msg.into());
}
fn warn_short_map(&mut self, class: &str, name: &str, map_len: usize, nconds: usize) {
if map_len < nconds {
self.warn(format!(
"{class} {name}: terminal map lists {map_len} of {nconds} conductors; \
dss materializes a grounded neutral terminal and the reparsed model \
gains one"
));
}
}
fn source_extra_f64(&mut self, vs: &crate::model::VoltageSource, key: &str) -> Option<f64> {
let v = vs.extras.get(key)?;
let parsed = v
.as_f64()
.or_else(|| v.as_str().and_then(|s| s.parse().ok()));
if parsed.is_none() {
self.warn(format!(
"vsource {}: {key} extra `{v}` does not parse as a number; \
using the derived value",
vs.name
));
}
parsed
}
fn line_out(&mut self, s: &str) {
self.out.push_str(s);
self.out.push('\n');
}
fn check_name(&mut self, class: &str, name: &str) {
if name_breaks_dss(name, false) {
self.warn(format!(
"{class} `{name}`: name contains characters dss cannot represent; \
output will not reparse identically"
));
}
}
fn bus_ref(&mut self, bus: &str, map: &[String]) -> String {
let key = bus.to_ascii_lowercase();
if name_breaks_dss(bus, true) {
self.warn(format!(
"bus `{bus}`: id contains characters dss cannot represent; \
output will not reparse identically"
));
}
let grounded = self.grounded.get(&key).cloned();
let terminals = self.terminals.get(&key).cloned().unwrap_or_default();
let nodes: Vec<String> = map
.iter()
.enumerate()
.map(|(i, t)| {
if grounded.as_ref().is_some_and(|g| g.contains(t)) {
"0".to_string()
} else if t.parse::<u32>().is_ok() {
t.clone()
} else {
let pos = terminals.iter().position(|x| x == t).unwrap_or(i) + 1;
self.warn(format!(
"bus {bus}: terminal `{t}` is not a dss node number; \
emitted as node {pos}, its position on the bus"
));
pos.to_string()
}
})
.collect();
if nodes.is_empty() {
bus.to_string()
} else {
format!("{bus}.{}", nodes.join("."))
}
}
fn extras_tail(&mut self, class: &str, name: &str, extras: &Extras) -> String {
let table = prop::class_by_name(class);
let mut tail = String::new();
for (key, value) in extras {
if matches!(key.as_str(), "bmopf_subtype") || key.starts_with("pmd_") {
continue; }
let known = table.is_some_and(|t| t.props.contains(&key.as_str()));
let text = value
.as_str()
.map(ToString::to_string)
.or_else(|| value.as_f64().map(num))
.or_else(|| value.as_i64().map(|v| v.to_string()));
match (known, text) {
(true, Some(text)) => {
let (out, representable) = dss_value_out(&text);
if !representable {
self.warn(format!(
"{class} {name}: extra `{key}` value `{text}` contains every \
dss quote closer and splits when scanned bare; emitted as \
written and a reparse will not see the same value"
));
}
let _ = write!(tail, " {key}={out}");
}
_ => self.warn(format!(
"{class} {name}: extra `{key}` is not a dss property; dropped from the output"
)),
}
}
tail
}
fn matrix_arg(&mut self, m: &Mat, what: &str) -> String {
let mut short = false;
let rows: Vec<String> = m
.iter()
.enumerate()
.map(|(i, row)| {
let take = row.len().min(i + 1);
let mut vals: Vec<String> = row[..take].iter().map(|v| num(*v)).collect();
if take < i + 1 {
short = true;
vals.resize(i + 1, "0".to_string());
}
vals.join(" ")
})
.collect();
if short {
self.warn(format!(
"{what}: matrix rows are shorter than the lower triangle; \
missing entries emitted as 0"
));
}
format!("({})", rows.join(" | "))
}
fn take_seq_pair(
&mut self,
extras: &mut Extras,
r_key: &str,
x_key: &str,
what: &str,
) -> Option<((f64, f64), (f64, f64))> {
let r = seq_parts(extras, r_key);
let x = seq_parts(extras, x_key);
if let (Some(r), Some(x)) = (r, x) {
extras.remove(r_key);
extras.remove(x_key);
return Some((r, x));
}
if extras.contains_key(r_key) || extras.contains_key(x_key) {
let state = |key: &str, parsed: bool| {
if !extras.contains_key(key) {
format!("`{key}` is missing")
} else if parsed {
format!("`{key}` is usable")
} else {
format!("`{key}` is not a numeric matrix")
}
};
self.warn(format!(
"{what}: series impedance extras unusable ({}, {}); left in extras",
state(r_key, r.is_some()),
state(x_key, x.is_some()),
));
}
None
}
fn element_phases(
&mut self,
extras: &Extras,
terminal_map: &[String],
configuration: Configuration,
class: &str,
name: &str,
) -> usize {
if let Some(p) = extras_usize(extras, "phases") {
return p.max(1);
}
match configuration {
Configuration::Delta => match terminal_map.len() {
2 => 1,
3 => {
self.warn(format!(
"{class} {name}: a delta terminal map with 3 conductors is 2 or 3 \
phase and no phases record disambiguates; emitted phases=3"
));
3
}
n => {
self.warn(format!(
"{class} {name}: a delta terminal map with {n} conductors has no \
dss phases mapping; emitted phases={}",
n.max(1)
));
n.max(1)
}
},
Configuration::Wye => terminal_map.len().saturating_sub(1).max(1),
_ => 1,
}
}
fn network(&mut self, net: &DistNetwork) {
self.line_out("Clear");
self.line_out(&format!(
"Set DefaultBaseFrequency={}",
num(net.base_frequency)
));
self.out.push('\n');
self.sources(net);
self.linecodes(net);
self.lines(net);
self.switches(net);
self.transformers(net);
self.loads(net);
self.shunts(net);
self.generators(net);
for u in &net.untyped {
self.warn(format!(
"{} {}: untyped object is not regenerated in canonical dss output",
u.class, u.name
));
}
for b in &net.buses {
self.bus_extras(b);
}
self.out.push('\n');
for (key, value) in &net.options {
if key.is_empty() {
self.warn(format!(
"option `{value}` has no name; not regenerated in canonical dss output"
));
continue;
}
let key_lc = key.to_ascii_lowercase();
if "voltagebases".starts_with(&key_lc)
|| (key_lc.len() >= "defaultb".len() && "defaultbasefrequency".starts_with(&key_lc))
{
continue;
}
let (text, representable) = dss_value_out(value);
if !representable {
self.warn(format!(
"option `{key}`: value `{value}` contains every dss quote closer \
and splits when scanned bare; emitted as written and a reparse \
will not see the same value"
));
}
self.line_out(&format!("Set {key}={text}"));
}
for (verb, args) in &net.commands {
if verb.eq_ignore_ascii_case("calcvoltagebases") || verb.eq_ignore_ascii_case("solve") {
continue; }
let shown = if args.is_empty() {
verb.clone()
} else {
format!("{verb} {args}")
};
self.warn(format!(
"command `{shown}` is not regenerated in canonical dss output"
));
}
let mut bases: Vec<f64> = self
.kv_estimate
.values()
.map(|v| v * 3f64.sqrt() / 1e3)
.collect();
bases.sort_by(f64::total_cmp);
bases.dedup_by(|a, b| (*a - *b).abs() < 1e-9);
if !bases.is_empty() {
let list: Vec<String> = bases.iter().map(|v| num(*v)).collect();
self.line_out(&format!("Set VoltageBases=[{}]", list.join(", ")));
self.line_out("Calcvoltagebases");
}
self.line_out("Solve");
}
fn bus_extras(&mut self, b: &DistBus) {
for key in b.extras.keys() {
if key == "x" || key == "y" {
continue; }
self.warnings.push(format!(
"bus {}: extra `{key}` is not regenerated in canonical dss output",
b.id
));
}
for (field, present) in [
("v_min", b.v_min.is_some()),
("v_max", b.v_max.is_some()),
("vpn_min", b.vpn_min.is_some()),
("vpn_max", b.vpn_max.is_some()),
("vpp_min", b.vpp_min.is_some()),
("vpp_max", b.vpp_max.is_some()),
("vsym_min", b.vsym_min.is_some()),
("vsym_max", b.vsym_max.is_some()),
] {
if present {
self.warnings.push(format!(
"bus {}: `{field}` voltage bounds have no dss expression; dropped",
b.id
));
}
}
}
fn sources(&mut self, net: &DistNetwork) {
let mut order: Vec<usize> = (0..net.sources.len()).collect();
if let Some(source_idx) = net
.sources
.iter()
.position(|vs| vs.name.eq_ignore_ascii_case("source"))
{
order.swap(0, source_idx);
}
for (i, source_idx) in order.into_iter().enumerate() {
let vs = &net.sources[source_idx];
let phases = source_phases(net, vs);
let energized = vs.v_magnitude.iter().filter(|&&v| v > 0.0).count();
if energized > 0 && energized != phases {
self.warn(format!(
"vsource {}: emitted phases={phases} but {energized} v_magnitude \
entries are positive; a reparse energizes all {phases}",
vs.name
));
}
self.warn_short_map("vsource", &vs.name, vs.terminal_map.len(), phases + 1);
let basekv = self
.source_extra_f64(vs, "basekv")
.unwrap_or_else(|| source_basekv(vs, phases));
let pu = self.source_extra_f64(vs, "pu").unwrap_or(1.0);
let angle = self
.source_extra_f64(vs, "angle")
.unwrap_or_else(|| vs.v_angle.first().copied().unwrap_or(0.0).to_degrees());
let head = if i == 0 {
let name = net.name.clone().unwrap_or_else(|| "converted".into());
self.check_name("circuit", &name);
format!("New Circuit.{name}")
} else {
self.check_name("vsource", &vs.name);
format!("New Vsource.{}", vs.name)
};
let mut s = format!(
"{head} basekv={} pu={} angle={} phases={phases} bus1={}",
num(basekv),
num(pu),
num(angle),
self.bus_ref(&vs.bus, &vs.terminal_map),
);
let mut extras = vs.extras.clone();
extras.remove("basekv");
extras.remove("pu");
extras.remove("angle");
extras.remove("phases"); let what = format!("vsource {}", vs.name);
if let Some(((rs, rm), (xs, xm))) = self.take_seq_pair(&mut extras, "rs", "xs", &what) {
let _ = write!(
s,
" z0=({}, {}) z1=({}, {})",
num(rs + 2.0 * rm),
num(xs + 2.0 * xm),
num(rs - rm),
num(xs - xm)
);
}
s.push_str(&self.extras_tail("vsource", &vs.name, &extras));
self.line_out(&s);
}
self.out.push('\n');
}
fn linecodes(&mut self, net: &DistNetwork) {
let omega_nf = std::f64::consts::TAU * net.base_frequency * 1e-9;
for c in &net.linecodes {
self.check_name("linecode", &c.name);
let n = c.n_conductors;
let what = format!("linecode {}", c.name);
let mut s = format!("New Linecode.{} nphases={n} units=m", c.name);
let rm = self.matrix_arg(&c.r_series, &what);
let _ = write!(s, " rmatrix={rm}");
let xm = self.matrix_arg(&c.x_series, &what);
let _ = write!(s, " xmatrix={xm}");
let c_nf: Mat = c
.b_from
.iter()
.map(|row| row.iter().map(|b| 2.0 * b / omega_nf).collect())
.collect();
let cm = self.matrix_arg(&c_nf, &what);
let _ = write!(s, " cmatrix={cm}");
match c.i_max.as_deref() {
Some([amps, ..]) => {
let _ = write!(s, " emergamps={}", num(*amps));
}
Some([]) => self.warn(format!(
"linecode {}: i_max is empty; emergamps not emitted",
c.name
)),
None => {}
}
if !c.g_from.iter().flatten().all(|&g| g == 0.0) {
self.warn(format!(
"linecode {}: shunt conductance has no dss linecode field; dropped",
c.name
));
}
let mut extras = c.extras.clone();
extras.remove("units"); s.push_str(&self.extras_tail("linecode", &c.name, &extras));
self.line_out(&s);
}
self.out.push('\n');
}
fn lines(&mut self, net: &DistNetwork) {
for l in &net.lines {
self.check_name("line", &l.name);
let phases = l.terminal_map_from.len();
let mut s = format!(
"New Line.{} bus1={} bus2={} phases={phases} linecode={} length={} units=m",
l.name,
self.bus_ref(&l.bus_from, &l.terminal_map_from),
self.bus_ref(&l.bus_to, &l.terminal_map_to),
l.linecode,
num(l.length),
);
let mut extras = l.extras.clone();
extras.remove("units"); s.push_str(&self.extras_tail("line", &l.name, &extras));
self.line_out(&s);
}
self.out.push('\n');
}
fn switches(&mut self, net: &DistNetwork) {
for sw in &net.switches {
self.check_name("line", &sw.name);
let phases = sw.terminal_map_from.len();
let mut s = format!(
"New Line.{} bus1={} bus2={} phases={phases} switch=y",
sw.name,
self.bus_ref(&sw.bus_from, &sw.terminal_map_from),
self.bus_ref(&sw.bus_to, &sw.terminal_map_to),
);
match sw.i_max.as_deref() {
Some([amps, ..]) => {
let _ = write!(s, " emergamps={}", num(*amps));
}
Some([]) => self.warn(format!(
"line {}: i_max is empty; emergamps not emitted",
sw.name
)),
None => {}
}
let mut extras = sw.extras.clone();
let what = format!("line {}", sw.name);
if let Some(((rs, rm), (xs, xm))) =
self.take_seq_pair(&mut extras, "pmd_rs", "pmd_xs", &what)
{
let _ = write!(
s,
" c0=0 c1=0 r0={} r1={} x0={} x1={}",
num((rs + 2.0 * rm) / 0.001),
num((rs - rm) / 0.001),
num((xs + 2.0 * xm) / 0.001),
num((xs - xm) / 0.001)
);
}
s.push_str(&self.extras_tail("line", &sw.name, &extras));
self.line_out(&s);
self.line_out(&format!(
"New SwtControl.{}_state SwitchedObj=Line.{} Action={}",
sw.name,
sw.name,
if sw.open { "open" } else { "close" },
));
}
self.out.push('\n');
}
fn transformers(&mut self, net: &DistNetwork) {
for t in &net.transformers {
self.check_name("transformer", &t.name);
let nw = t.windings.len();
let buses: Vec<String> = t
.windings
.iter()
.map(|w| self.bus_ref(&w.bus, &w.terminal_map))
.collect();
let conns: Vec<&str> = t
.windings
.iter()
.map(|w| match w.conn {
WindingConn::Wye => "wye",
WindingConn::Delta => "delta",
})
.collect();
let kvs: Vec<String> = t.windings.iter().map(|w| num(w.v_ref / 1e3)).collect();
let kvas: Vec<String> = t.windings.iter().map(|w| num(w.s_rating / 1e3)).collect();
let rs: Vec<String> = t.windings.iter().map(|w| num(w.r_pct)).collect();
let taps: Vec<String> = t.windings.iter().map(|w| num(w.tap)).collect();
let mut s = format!(
"New Transformer.{} phases={} windings={nw} buses=({}) conns=({}) kvs=({}) kvas=({}) %Rs=({}) taps=({})",
t.name,
t.phases,
buses.join(", "),
conns.join(", "),
kvs.join(", "),
kvas.join(", "),
rs.join(", "),
taps.join(", "),
);
if let Some(xhl) = t.xsc_pct.first() {
let _ = write!(s, " xhl={}", num(*xhl));
if t.xsc_pct.len() >= 3 {
let _ = write!(s, " xht={} xlt={}", num(t.xsc_pct[1]), num(t.xsc_pct[2]));
}
} else {
self.warn(format!(
"transformer {}: xsc_pct is empty; emitted xhl=0",
t.name
));
s.push_str(" xhl=0");
}
s.push_str(&self.extras_tail("transformer", &t.name, &t.extras));
self.line_out(&s);
}
self.out.push('\n');
}
fn loads(&mut self, net: &DistNetwork) {
for l in &net.loads {
self.check_name("load", &l.name);
let phases =
self.element_phases(&l.extras, &l.terminal_map, l.configuration, "load", &l.name);
let conn = self.element_conn(&l.extras, l.configuration, &l.bus, &l.terminal_map);
let nconds = if conn == "delta" && phases == 3 {
phases
} else {
phases + 1
};
self.warn_short_map("load", &l.name, l.terminal_map.len(), nconds);
let kw: f64 = l.p_nom.iter().sum::<f64>() / 1e3;
let kvar: f64 = l.q_nom.iter().sum::<f64>() / 1e3;
let typed_kv = self.load_nominal_kv(&l.voltage_model, phases, l.configuration, &l.name);
let kv = self.element_kv(
&l.extras,
ElementKv {
bus: &l.bus,
phases,
configuration: l.configuration,
name: &l.name,
class: "load",
typed_kv,
},
);
let mut extras = l.extras.clone();
extras.remove("kv");
extras.remove("phases");
extras.remove("conn");
let retained_model = extras.remove("model");
let retained_zipv = extras.remove("zipv");
let reactive = match extras.remove("pf").and_then(|v| v.as_f64()) {
Some(pf) => format!("pf={}", num(pf)),
None => format!("kvar={}", num(kvar)),
};
let mut s = format!(
"New Load.{} bus1={} phases={phases} conn={conn} kv={} kw={} {reactive}",
l.name,
self.bus_ref(&l.bus, &l.terminal_map),
num(kv),
num(kw),
);
match &l.voltage_model {
DistLoadVoltageModel::ConstantPower { .. } => {
if let Some(model) = retained_model {
extras.insert("model".into(), model);
}
}
DistLoadVoltageModel::ConstantImpedance { .. } => {
s.push_str(" model=2");
}
DistLoadVoltageModel::ConstantCurrent { .. } => {
s.push_str(" model=5");
}
DistLoadVoltageModel::Zip {
alpha_z,
alpha_i,
alpha_p,
beta_z,
beta_i,
beta_p,
..
} => {
s.push_str(" model=8");
if let (Some(az), Some(ai), Some(ap), Some(bz), Some(bi), Some(bp)) = (
alpha_z.first(),
alpha_i.first(),
alpha_p.first(),
beta_z.first(),
beta_i.first(),
beta_p.first(),
) {
let cutoff = zipv_cutoff(retained_zipv.as_ref()).unwrap_or(0.0);
let _ = write!(
s,
" zipv=({}, {}, {}, {}, {}, {}, {})",
num(*az),
num(*ai),
num(*ap),
num(*bz),
num(*bi),
num(*bp),
num(cutoff)
);
}
}
DistLoadVoltageModel::Exponential { .. } => {
self.warn(format!(
"load {}: exponential voltage model has no OpenDSS load model code; emitted constant power",
l.name
));
}
}
s.push_str(&self.extras_tail("load", &l.name, &extras));
self.line_out(&s);
}
self.out.push('\n');
}
fn element_kv(&mut self, extras: &Extras, ctx: ElementKv<'_>) -> f64 {
if let Some(v) = extras.get("kv") {
match v
.as_f64()
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
{
Some(kv) => return kv,
None => self.warn(format!(
"{} {}: kv extra `{v}` does not parse as a number; \
using the bus voltage estimate",
ctx.class, ctx.name
)),
}
}
if let Some(kv) = ctx.typed_kv {
return kv;
}
if let Some(vln) = self.kv_estimate.get(&ctx.bus.to_ascii_lowercase()).copied() {
let v = if ctx.phases >= 2 || ctx.configuration == Configuration::Delta {
vln * 3f64.sqrt()
} else {
vln
};
v / 1e3
} else {
self.warn(format!(
"{} {}: no kv in the source and no bus voltage estimate; \
emitted 12.47",
ctx.class, ctx.name
));
12.47
}
}
fn load_nominal_kv(
&mut self,
model: &DistLoadVoltageModel,
phases: usize,
configuration: Configuration,
name: &str,
) -> Option<f64> {
let v_nom = model.v_nom();
let v_phase = v_nom
.first()
.copied()
.filter(|v| v.is_finite() && *v > 0.0)?;
if v_nom
.iter()
.any(|v| (*v - v_phase).abs() > 1e-9 * v.abs().max(v_phase.abs()).max(1.0))
{
self.warn(format!(
"load {name}: nonuniform nominal voltage array has no OpenDSS scalar kv; emitted the first value"
));
}
let v = if phases >= 2 && configuration == Configuration::Wye {
v_phase * 3f64.sqrt()
} else {
v_phase
};
Some(v / 1e3)
}
fn element_conn(
&self,
extras: &Extras,
configuration: Configuration,
bus: &str,
terminal_map: &[String],
) -> &'static str {
let stash_delta = extras
.get("conn")
.and_then(|v| v.as_str())
.is_some_and(|t| {
t.to_ascii_lowercase().starts_with('d') || t.eq_ignore_ascii_case("ll")
});
let has_grounded_return = self
.grounded
.get(&bus.to_ascii_lowercase())
.is_some_and(|g| terminal_map.iter().any(|t| g.contains(t)));
match configuration {
Configuration::Delta => "delta",
Configuration::SinglePhase
if stash_delta || (terminal_map.len() == 2 && !has_grounded_return) =>
{
"delta"
}
_ => "wye",
}
}
fn write_impedance_shunt(&mut self, sh: &crate::model::DistShunt, phases: usize) {
self.check_name("reactor", &sh.name);
let Some((conductance, susceptance)) = first_diag_admittance(&sh.g, &sh.b, phases) else {
self.warn(format!(
"shunt {}: conductance matrix has no diagonal admittance; dropped from the output",
sh.name
));
return;
};
if has_off_diagonal(&sh.g) || has_off_diagonal(&sh.b) {
self.warn(format!(
"shunt {}: off diagonal admittance has no scalar reactor expression; \
only the first diagonal admittance is regenerated",
sh.name
));
}
if !uniform_diag_admittance(&sh.g, &sh.b, phases, conductance, susceptance) {
self.warn(format!(
"shunt {}: diagonal admittances differ; only the first diagonal \
admittance is regenerated",
sh.name
));
}
let denom = conductance * conductance + susceptance * susceptance;
if !denom.is_finite() || denom <= 0.0 {
self.warn(format!(
"shunt {}: invalid grounding admittance; dropped from the output",
sh.name
));
return;
}
let resistance = conductance / denom;
let reactance = -susceptance / denom;
let mut extras = sh.extras.clone();
strip_shunt_extras(&mut extras);
let ground = vec!["0".to_string(); phases.max(1)];
let mut line = format!(
"New Reactor.{} bus1={} bus2={} phases={} r={} x={}",
sh.name,
self.bus_ref(&sh.bus, &sh.terminal_map),
self.bus_ref(&sh.bus, &ground),
phases.max(1),
num(resistance),
num(reactance),
);
line.push_str(&self.extras_tail("reactor", &sh.name, &extras));
self.line_out(&line);
}
fn shunt_phases(
&mut self,
sh: &crate::model::DistShunt,
conn_delta: bool,
inferred_phases: usize,
) -> usize {
if let Some(p) = extras_usize(&sh.extras, "phases") {
p.max(1)
} else if conn_delta {
self.element_phases(
&sh.extras,
&sh.terminal_map,
Configuration::Delta,
"shunt",
&sh.name,
)
} else {
inferred_phases
}
}
fn write_kvar_shunt(&mut self, sh: &crate::model::DistShunt, phases: usize, conn_delta: bool) {
let (b_max, b_min) = (0..sh.b.len())
.map(|idx| diag_at(&sh.b, idx))
.fold((0.0_f64, 0.0_f64), |(mx, mn), v| (mx.max(v), mn.min(v)));
let (class, b_phase) = if b_max > 0.0 {
("capacitor", b_max)
} else if b_min < 0.0 {
("reactor", b_min)
} else {
self.warn(format!(
"shunt {}: no nonzero susceptance; dropped from the output",
sh.name
));
return;
};
if b_max > 0.0 && b_min < 0.0 {
self.warn(format!(
"shunt {}: diagonal mixes capacitive and inductive phases; only the \
{class} phases are regenerated",
sh.name
));
}
self.check_name(class, &sh.name);
let off_diag = has_off_diagonal(&sh.b);
if off_diag && !conn_delta {
self.warn(format!(
"shunt {}: off diagonal susceptance has no {class} expression; \
only the diagonal is regenerated",
sh.name
));
}
let edges = if conn_delta {
delta_edges(sh.terminal_map.len(), phases)
} else {
Vec::new()
};
if conn_delta && edges.is_empty() {
self.warn(format!(
"shunt {}: delta terminal map has no branch expression; dropped from the output",
sh.name
));
return;
}
if conn_delta && delta_branch_susceptance(&sh.b, &edges, sh.terminal_map.len()).is_none() {
self.warn(format!(
"shunt {}: delta susceptance matrix has no scalar {class} expression; \
only the average branch susceptance is regenerated",
sh.name
));
}
let configuration = if conn_delta {
Configuration::Delta
} else {
Configuration::Wye
};
let kv = self.element_kv(
&sh.extras,
ElementKv {
bus: &sh.bus,
phases,
configuration,
name: &sh.name,
class,
typed_kv: None,
},
);
let kvar = extras_f64(&sh.extras, "kvar")
.unwrap_or_else(|| shunt_kvar(sh, phases, conn_delta, &edges, b_phase, kv));
let mut extras = sh.extras.clone();
strip_shunt_extras(&mut extras);
let conn = if conn_delta { "delta" } else { "wye" };
let decl = if class == "reactor" {
"Reactor"
} else {
"Capacitor"
};
let mut line = format!(
"New {decl}.{} bus1={} phases={phases} conn={conn} kv={} kvar={}",
sh.name,
self.bus_ref(&sh.bus, &sh.terminal_map),
num(kv),
num(kvar),
);
line.push_str(&self.extras_tail(class, &sh.name, &extras));
self.line_out(&line);
}
fn shunts(&mut self, net: &DistNetwork) {
for sh in &net.shunts {
let stashed_delta = shunt_stashed_delta(sh);
let inferred_phases =
extras_usize(&sh.extras, "phases").unwrap_or_else(|| sh.terminal_map.len().max(1));
let conn_delta = stashed_delta
|| looks_like_delta_shunt(&sh.b, sh.terminal_map.len(), inferred_phases);
let phases = self.shunt_phases(sh, conn_delta, inferred_phases);
if has_nonzero(&sh.g) {
self.write_impedance_shunt(sh, phases);
} else {
self.write_kvar_shunt(sh, phases, conn_delta);
}
}
self.out.push('\n');
}
fn generators(&mut self, net: &DistNetwork) {
for g in &net.generators {
self.check_name("generator", &g.name);
let phases = self.element_phases(
&g.extras,
&g.terminal_map,
g.configuration,
"generator",
&g.name,
);
let conn = self.element_conn(&g.extras, g.configuration, &g.bus, &g.terminal_map);
let nconds = if conn == "delta" && phases == 3 {
phases
} else {
phases + 1
};
self.warn_short_map("generator", &g.name, g.terminal_map.len(), nconds);
let kw: f64 = g.p_nom.iter().sum::<f64>() / 1e3;
let kvar: f64 = g.q_nom.iter().sum::<f64>() / 1e3;
let kv = self.element_kv(
&g.extras,
ElementKv {
bus: &g.bus,
phases,
configuration: g.configuration,
name: &g.name,
class: "generator",
typed_kv: None,
},
);
let mut s = format!(
"New Generator.{} bus1={} phases={phases} conn={conn} kv={} kw={} kvar={}",
g.name,
self.bus_ref(&g.bus, &g.terminal_map),
num(kv),
num(kw),
num(kvar),
);
if let Some(q) = &g.q_max {
let _ = write!(s, " maxkvar={}", num(q.iter().sum::<f64>() / 1e3));
}
if let Some(q) = &g.q_min {
let _ = write!(s, " minkvar={}", num(q.iter().sum::<f64>() / 1e3));
}
if g.cost.is_some() {
self.warn(format!(
"generator {}: generation cost has no dss field; dropped",
g.name
));
}
let mut extras = g.extras.clone();
extras.remove("kv");
extras.remove("phases");
extras.remove("conn");
s.push_str(&self.extras_tail("generator", &g.name, &extras));
self.line_out(&s);
}
}
}
fn strip_shunt_extras(extras: &mut Extras) {
for key in ["kv", "kvar", "phases", "conn"] {
extras.remove(key);
}
}
fn has_nonzero(m: &Mat) -> bool {
m.iter().flatten().any(|&v| v != 0.0)
}
fn has_off_diagonal(m: &Mat) -> bool {
m.iter()
.enumerate()
.any(|(i, row)| row.iter().enumerate().any(|(j, &v)| i != j && v != 0.0))
}
fn diag_at(m: &Mat, i: usize) -> f64 {
m.get(i).and_then(|row| row.get(i)).copied().unwrap_or(0.0)
}
fn matrix_scale(m: &Mat) -> f64 {
m.iter().flatten().fold(0.0_f64, |acc, &v| acc.max(v.abs()))
}
fn close(a: f64, b: f64, scale: f64) -> bool {
(a - b).abs() <= 1e-12_f64.max(scale * 1e-9)
}
fn first_diag_admittance(g: &Mat, b: &Mat, phases: usize) -> Option<(f64, f64)> {
(0..phases.max(1)).find_map(|i| {
let gi = diag_at(g, i);
let bi = diag_at(b, i);
(gi != 0.0 || bi != 0.0).then_some((gi, bi))
})
}
fn uniform_diag_admittance(g: &Mat, b: &Mat, phases: usize, g0: f64, b0: f64) -> bool {
let scale = matrix_scale(g)
.max(matrix_scale(b))
.max(g0.abs())
.max(b0.abs());
(0..phases.max(1)).all(|i| close(diag_at(g, i), g0, scale) && close(diag_at(b, i), b0, scale))
}
fn shunt_stashed_delta(sh: &crate::model::DistShunt) -> bool {
sh.extras
.get("conn")
.and_then(|v| v.as_str())
.is_some_and(|t| t.to_ascii_lowercase().starts_with('d') || t.eq_ignore_ascii_case("ll"))
}
fn mat_at(m: &Mat, i: usize, j: usize) -> f64 {
m.get(i).and_then(|row| row.get(j)).copied().unwrap_or(0.0)
}
fn looks_like_delta_shunt(b: &Mat, terminals: usize, phases: usize) -> bool {
if terminals < 2 || !has_off_diagonal(b) {
return false;
}
let edges = delta_edges(terminals, phases);
delta_branch_susceptance(b, &edges, terminals).is_some()
}
fn delta_branch_abs(b: &Mat, edges: &[(usize, usize)]) -> Option<f64> {
if edges.is_empty() {
return None;
}
let total: f64 = edges
.iter()
.map(|&(i, j)| {
b.get(i)
.and_then(|row| row.get(j))
.copied()
.unwrap_or(0.0)
.abs()
})
.sum();
Some(total / edges.len() as f64)
}
fn delta_branch_susceptance(b: &Mat, edges: &[(usize, usize)], terminals: usize) -> Option<f64> {
if terminals < 2 || edges.is_empty() {
return None;
}
let scale = matrix_scale(b);
if scale == 0.0 {
return None;
}
let first = edges[0];
let branch = -mat_at(b, first.0, first.1);
if branch == 0.0 {
return None;
}
let scale = scale.max(branch.abs());
for (i, row) in b.iter().enumerate() {
for (j, &value) in row.iter().enumerate() {
if (i >= terminals || j >= terminals) && !close(value, 0.0, scale) {
return None;
}
}
}
for i in 0..terminals {
let incident = edges
.iter()
.filter(|&&(from, to)| from == i || to == i)
.count() as f64;
for j in 0..terminals {
let linked = edges
.iter()
.any(|&(from, to)| (from == i && to == j) || (from == j && to == i));
let expected = if i == j {
incident * branch
} else if linked {
-branch
} else {
0.0
};
if !close(mat_at(b, i, j), expected, scale) {
return None;
}
}
}
Some(branch)
}
fn shunt_kvar(
sh: &crate::model::DistShunt,
phases: usize,
conn_delta: bool,
edges: &[(usize, usize)],
b_phase: f64,
kv: f64,
) -> f64 {
if conn_delta {
let b_branch = delta_branch_abs(&sh.b, edges).unwrap_or(b_phase.abs());
b_branch * (kv * 1e3) * (kv * 1e3) * edges.len() as f64 / 1e3
} else {
let v_phase = if matches!(phases, 2 | 3) {
kv * 1e3 / 3f64.sqrt()
} else {
kv * 1e3
};
b_phase.abs() * v_phase * v_phase * phases as f64 / 1e3
}
}
#[cfg(test)]
mod tests {
use super::super::read::parse_dss_str;
use super::*;
use crate::model::{
DistGenerator, DistLine, DistLineCode, DistLoad, DistShunt, DistSwitch, DistTransformer,
VoltageSource, Winding,
};
fn strings(v: &[&str]) -> Vec<String> {
v.iter().map(ToString::to_string).collect()
}
fn bus(id: &str, terminals: &[&str], grounded: &[&str]) -> DistBus {
DistBus {
id: id.into(),
terminals: strings(terminals),
grounded: strings(grounded),
..DistBus::default()
}
}
fn three_phase_source(vln: f64) -> (DistBus, VoltageSource) {
let third = 2.0 * std::f64::consts::FRAC_PI_3;
(
bus("sb", &["1", "2", "3", "4"], &["4"]),
VoltageSource {
name: "source".into(),
bus: "sb".into(),
terminal_map: strings(&["1", "2", "3", "4"]),
v_magnitude: vec![vln, vln, vln, 0.0],
v_angle: vec![0.0, -third, third, 0.0],
extras: Extras::new(),
},
)
}
fn load_on(bus: &str, map: &[&str], configuration: Configuration) -> DistLoad {
let phases = map.len();
DistLoad {
name: "ld".into(),
bus: bus.into(),
terminal_map: strings(map),
configuration,
p_nom: vec![1e3; phases],
q_nom: vec![0.0; phases],
voltage_model: DistLoadVoltageModel::ConstantPower { v_nom: Vec::new() },
extras: Extras::from([("kv".to_string(), serde_json::json!("0.4"))]),
}
}
fn roundtrip(net: &DistNetwork) -> (String, String) {
let first = write_dss(net);
let second = write_dss(&parse_dss_str(&first.text));
(first.text, second.text)
}
#[test]
fn voltage_bases_survive_the_sqrt_round_trip() {
let vln = 9_336.235_056_420_312_f64;
let basekv = vln * 3f64.sqrt() / 1e3;
assert!(
(basekv * 1e3 / 3f64.sqrt()).to_bits() != vln.to_bits(),
"test value no longer reproduces the drift"
);
let (b, vs) = three_phase_source(vln);
let net = DistNetwork {
name: Some("t".into()),
base_frequency: 60.0,
buses: vec![b],
sources: vec![vs],
..DistNetwork::default()
};
let (first, second) = roundtrip(&net);
assert!(first.contains("Set VoltageBases="), "{first}");
assert_eq!(first, second);
}
#[test]
fn load_phases_prefer_the_reader_stash() {
let (b, vs) = three_phase_source(2400.0);
let mut load = load_on("sb", &["1", "2", "3"], Configuration::Delta);
load.extras.insert("phases".into(), serde_json::json!("2"));
let net = DistNetwork {
base_frequency: 60.0,
buses: vec![b],
sources: vec![vs],
loads: vec![load],
..DistNetwork::default()
};
let out = write_dss(&net);
let line = out.text.lines().find(|l| l.contains("Load.ld")).unwrap();
assert!(line.contains("phases=2 conn=delta"), "{line}");
assert_eq!(line.matches("phases=").count(), 1, "{line}");
assert!(!out.warnings.iter().any(|w| w.contains("2 or 3 phase")));
}
#[test]
fn ambiguous_delta_keeps_three_phases_loudly() {
let (b, vs) = three_phase_source(2400.0);
let net = DistNetwork {
base_frequency: 60.0,
buses: vec![b],
sources: vec![vs],
loads: vec![load_on("sb", &["1", "2", "3"], Configuration::Delta)],
..DistNetwork::default()
};
let out = write_dss(&net);
let line = out.text.lines().find(|l| l.contains("Load.ld")).unwrap();
assert!(line.contains("phases=3 conn=delta"), "{line}");
assert!(
out.warnings.iter().any(|w| w.contains("2 or 3 phase")),
"{:?}",
out.warnings
);
}
#[test]
fn single_phase_delta_emits_conn_delta() {
let (b, vs) = three_phase_source(2400.0);
let two_wire = load_on("sb", &["1", "2"], Configuration::Delta);
let mut stashed = load_on("sb", &["1", "2"], Configuration::SinglePhase);
stashed.name = "ld2".into();
stashed
.extras
.insert("conn".into(), serde_json::json!("delta"));
let net = DistNetwork {
base_frequency: 60.0,
buses: vec![b],
sources: vec![vs],
loads: vec![two_wire, stashed],
..DistNetwork::default()
};
let out = write_dss(&net);
let l1 = out.text.lines().find(|l| l.contains("Load.ld ")).unwrap();
assert!(l1.contains("phases=1 conn=delta"), "{l1}");
let l2 = out.text.lines().find(|l| l.contains("Load.ld2 ")).unwrap();
assert!(l2.contains("phases=1 conn=delta"), "{l2}");
assert_eq!(l2.matches("conn=").count(), 1, "{l2}");
}
#[test]
fn unrepresentable_names_are_reported() {
let (b, vs) = three_phase_source(2400.0);
let mut load = load_on("sb", &["1", "2", "3", "4"], Configuration::Wye);
load.name = "load 1".into();
let net = DistNetwork {
name: Some("my circuit".into()),
base_frequency: 60.0,
buses: vec![b, bus("a=b", &["1"], &[])],
sources: vec![vs],
loads: vec![load],
..DistNetwork::default()
};
let out = write_dss(&net);
let hits = |needle: &str| {
out.warnings
.iter()
.any(|w| w.contains(needle) && w.contains("cannot represent"))
};
assert!(hits("load 1"), "{:?}", out.warnings);
assert!(hits("my circuit"), "{:?}", out.warnings);
let mut net2 = net.clone();
net2.lines.push(DistLine {
name: "l1".into(),
bus_from: "sb".into(),
bus_to: "a=b".into(),
terminal_map_from: strings(&["1"]),
terminal_map_to: strings(&["1"]),
linecode: "lc".into(),
length: 1.0,
extras: Extras::new(),
});
let out2 = write_dss(&net2);
assert!(
out2.warnings
.iter()
.any(|w| w.contains("a=b") && w.contains("cannot represent")),
"{:?}",
out2.warnings
);
}
#[test]
fn unparseable_kv_extra_warns_instead_of_silently_substituting() {
let (b, vs) = three_phase_source(2400.0);
let mut load = load_on("sb", &["1", "2", "3", "4"], Configuration::Wye);
load.extras.insert("kv".into(), serde_json::json!("@kv"));
let net = DistNetwork {
base_frequency: 60.0,
buses: vec![b],
sources: vec![vs],
loads: vec![load],
..DistNetwork::default()
};
let out = write_dss(&net);
assert!(
out.warnings
.iter()
.any(|w| w.contains("@kv") && w.contains("does not parse")),
"{:?}",
out.warnings
);
let line = out.text.lines().find(|l| l.contains("Load.ld")).unwrap();
assert!(
line.contains(&format!("kv={}", num(2400.0 * 3f64.sqrt() / 1e3))),
"{line}"
);
}
#[test]
fn options_reemit_and_commands_warn() {
let src = "Clear\n\
New Circuit.c1 basekv=12.47 pu=1 angle=0 phases=3 bus1=sb\n\
Set mode=snapshot\n\
Set controlmode=OFF\n\
Disable Line.l1\n\
Set VoltageBases=[12.47]\n\
Calcvoltagebases\n\
Solve\n";
let out = write_dss(&parse_dss_str(src));
assert!(out.text.contains("Set mode=snapshot"), "{}", out.text);
assert!(out.text.contains("Set controlmode=OFF"), "{}", out.text);
assert_eq!(out.text.matches("Set VoltageBases").count(), 1);
assert_eq!(out.text.matches("Calcvoltagebases").count(), 1);
assert_eq!(out.text.matches("DefaultBaseFrequency").count(), 1);
assert!(!out.text.to_lowercase().contains("disable"));
assert!(
out.warnings
.iter()
.any(|w| w.contains("disable Line.l1") && w.contains("not regenerated")),
"{:?}",
out.warnings
);
assert!(!out.warnings.iter().any(|w| w.contains("`solve`")));
let again = write_dss(&parse_dss_str(&out.text));
assert_eq!(out.text, again.text);
}
#[test]
fn non_numeric_terminal_positionalizes() {
let mut load = load_on("b1", &["a", "n"], Configuration::Wye);
load.extras.insert("kv".into(), serde_json::json!("0.23"));
let net = DistNetwork {
base_frequency: 60.0,
buses: vec![bus("b1", &["a", "n"], &["n"])],
loads: vec![load],
..DistNetwork::default()
};
let (first, second) = roundtrip(&net);
let line = first.lines().find(|l| l.contains("Load.ld")).unwrap();
assert!(line.contains("bus1=b1.1.0"), "{line}");
let out = write_dss(&net);
assert!(
out.warnings
.iter()
.any(|w| w.contains("`a`") && w.contains("position")),
"{:?}",
out.warnings
);
assert_eq!(first, second);
}
#[test]
fn half_present_thevenin_pair_stays_and_warns() {
let (b, mut vs) = three_phase_source(2400.0);
vs.extras
.insert("rs".into(), serde_json::json!([[1.0, 0.1], [0.1, 1.0]]));
let net = DistNetwork {
base_frequency: 60.0,
buses: vec![b],
sources: vec![vs],
..DistNetwork::default()
};
let out = write_dss(&net);
assert!(!out.text.contains("z1="), "{}", out.text);
assert!(
out.warnings.iter().any(|w| w.contains("`xs` is missing")),
"{:?}",
out.warnings
);
}
#[test]
fn unusable_switch_sequence_extras_warn() {
let (b, vs) = three_phase_source(2400.0);
let sw = DistSwitch {
name: "sw1".into(),
bus_from: "sb".into(),
bus_to: "b2".into(),
terminal_map_from: strings(&["1", "2", "3"]),
terminal_map_to: strings(&["1", "2", "3"]),
open: false,
i_max: Some(Vec::new()),
extras: Extras::from([("pmd_rs".to_string(), serde_json::json!("oops"))]),
};
let net = DistNetwork {
base_frequency: 60.0,
buses: vec![b, bus("b2", &["1", "2", "3"], &[])],
sources: vec![vs],
switches: vec![sw],
..DistNetwork::default()
};
let out = write_dss(&net);
assert!(!out.text.contains("r0="), "{}", out.text);
assert!(
out.warnings
.iter()
.any(|w| w.contains("pmd_rs") && w.contains("not a numeric matrix")),
"{:?}",
out.warnings
);
assert!(
out.warnings.iter().any(|w| w.contains("i_max is empty")),
"{:?}",
out.warnings
);
}
#[test]
fn degenerate_shapes_warn_instead_of_panicking() {
let (b, vs) = three_phase_source(2400.0);
let lc = DistLineCode {
name: "lc1".into(),
n_conductors: 2,
r_series: vec![vec![1.0], vec![0.5]], x_series: vec![vec![1.0, 0.0], vec![0.0, 1.0]],
g_from: vec![vec![0.0; 2]; 2],
b_from: vec![vec![0.0; 2]; 2],
g_to: vec![vec![0.0; 2]; 2],
b_to: vec![vec![0.0; 2]; 2],
i_max: Some(Vec::new()),
s_max: None,
extras: Extras::new(),
};
let t = DistTransformer {
name: "t1".into(),
windings: vec![
Winding {
bus: "sb".into(),
terminal_map: strings(&["1", "2"]),
conn: WindingConn::Wye,
v_ref: 2400.0,
s_rating: 25e3,
r_pct: 0.5,
tap: 1.0,
},
Winding {
bus: "b2".into(),
terminal_map: strings(&["1", "2"]),
conn: WindingConn::Wye,
v_ref: 240.0,
s_rating: 25e3,
r_pct: 0.5,
tap: 1.0,
},
],
xsc_pct: Vec::new(),
phases: 1,
extras: Extras::new(),
};
let net = DistNetwork {
base_frequency: 60.0,
buses: vec![b, bus("b2", &["1", "2"], &[])],
sources: vec![vs],
linecodes: vec![lc],
transformers: vec![t],
..DistNetwork::default()
};
let out = write_dss(&net); assert!(out.text.contains("rmatrix=(1 | 0.5 0)"), "{}", out.text);
assert!(out.text.contains("xhl=0"), "{}", out.text);
let has = |needle: &str| out.warnings.iter().any(|w| w.contains(needle));
assert!(has("shorter than the lower triangle"), "{:?}", out.warnings);
assert!(has("xsc_pct is empty"), "{:?}", out.warnings);
assert!(has("i_max is empty"), "{:?}", out.warnings);
}
#[test]
fn two_phase_capacitor_kvar_uses_line_to_line_kv() {
let (b, vs) = three_phase_source(2400.0);
let b_phase = 1e-3;
let sh = DistShunt {
name: "c1".into(),
bus: "sb".into(),
terminal_map: strings(&["1", "2"]),
g: vec![vec![0.0; 2]; 2],
b: vec![vec![b_phase, 0.0], vec![0.0, b_phase]],
extras: Extras::new(),
};
let net = DistNetwork {
base_frequency: 60.0,
buses: vec![b],
sources: vec![vs],
shunts: vec![sh],
..DistNetwork::default()
};
let out = write_dss(&net);
let kv = 2400.0 * 3f64.sqrt() / 1e3;
let v_phase = kv * 1e3 / 3f64.sqrt();
let expected = b_phase * v_phase * v_phase * 2.0 / 1e3;
let line = out
.text
.lines()
.find(|l| l.contains("Capacitor.c1"))
.unwrap();
assert!(line.contains(&format!("kvar={}", num(expected))), "{line}");
}
#[test]
fn inductive_shunt_regenerates_as_a_reactor() {
let (b, vs) = three_phase_source(2400.0);
let b_phase = -1e-3;
let sh = DistShunt {
name: "rx".into(),
bus: "sb".into(),
terminal_map: strings(&["1", "2", "3"]),
g: vec![vec![0.0; 3]; 3],
b: vec![
vec![b_phase, 0.0, 0.0],
vec![0.0, b_phase, 0.0],
vec![0.0, 0.0, b_phase],
],
extras: Extras::new(),
};
let net = DistNetwork {
base_frequency: 60.0,
buses: vec![b],
sources: vec![vs],
shunts: vec![sh],
..DistNetwork::default()
};
let out = write_dss(&net);
let line = out
.text
.lines()
.find(|l| l.contains("Reactor.rx"))
.unwrap_or_else(|| panic!("no reactor emitted in:\n{}", out.text));
assert!(!out.text.contains("Capacitor.rx"), "{}", out.text);
let kv = 2400.0 * 3f64.sqrt() / 1e3;
let v_phase = kv * 1e3 / 3f64.sqrt();
let expected = b_phase.abs() * v_phase * v_phase * 3.0 / 1e3;
assert!(line.contains(&format!("kvar={}", num(expected))), "{line}");
}
#[test]
fn conductive_shunt_regenerates_as_grounding_reactor() {
let (_, vs) = three_phase_source(2400.0);
let b = bus("sb", &["1", "2", "3", "4"], &[]);
let sh = DistShunt {
name: "gnd".into(),
bus: "sb".into(),
terminal_map: strings(&["4"]),
g: vec![vec![1.0 / 0.3]],
b: vec![vec![0.0]],
extras: Extras::new(),
};
let net = DistNetwork {
base_frequency: 60.0,
buses: vec![b],
sources: vec![vs],
shunts: vec![sh],
..DistNetwork::default()
};
let out = write_dss(&net);
let line = out
.text
.lines()
.find(|l| l.contains("Reactor.gnd"))
.unwrap_or_else(|| panic!("no reactor emitted in:\n{}", out.text));
assert!(line.contains("bus1=sb.4"), "{line}");
assert!(line.contains("bus2=sb.0"), "{line}");
assert!(line.contains("phases=1"), "{line}");
assert!(line.contains("r=0.3"), "{line}");
assert!(line.contains("x=0"), "{line}");
assert!(
!line.contains("x=-0"),
"negative zero must canonicalize: {line}"
);
}
#[test]
fn delta_shunt_regenerates_conn_delta() {
let (b, vs) = three_phase_source(2400.0);
let b_branch = 2e-4;
let bmat = vec![
vec![2.0 * b_branch, -b_branch, -b_branch],
vec![-b_branch, 2.0 * b_branch, -b_branch],
vec![-b_branch, -b_branch, 2.0 * b_branch],
];
let mut extras = Extras::new();
extras.insert("conn".into(), serde_json::json!("delta"));
extras.insert("phases".into(), serde_json::json!("3"));
let sh = DistShunt {
name: "capd".into(),
bus: "sb".into(),
terminal_map: strings(&["1", "2", "3"]),
g: vec![vec![0.0; 3]; 3],
b: bmat,
extras,
};
let net = DistNetwork {
base_frequency: 60.0,
buses: vec![b],
sources: vec![vs],
shunts: vec![sh],
..DistNetwork::default()
};
let out = write_dss(&net);
let line = out
.text
.lines()
.find(|l| l.contains("Capacitor.capd"))
.unwrap_or_else(|| panic!("no capacitor emitted in:\n{}", out.text));
assert!(line.contains("phases=3 conn=delta"), "{line}");
assert!(
!out.warnings.iter().any(|w| w.contains("off diagonal")),
"{:?}",
out.warnings
);
}
#[test]
fn non_scalar_delta_matrix_is_not_inferred_silently() {
let (b, vs) = three_phase_source(2400.0);
let bmat = vec![
vec![0.003, -0.001, -0.002],
vec![-0.001, 0.003, -0.002],
vec![-0.002, -0.002, 0.004],
];
let sh = DistShunt {
name: "capx".into(),
bus: "sb".into(),
terminal_map: strings(&["1", "2", "3"]),
g: vec![vec![0.0; 3]; 3],
b: bmat,
extras: Extras::new(),
};
let net = DistNetwork {
base_frequency: 60.0,
buses: vec![b],
sources: vec![vs],
shunts: vec![sh],
..DistNetwork::default()
};
let out = write_dss(&net);
let line = out
.text
.lines()
.find(|l| l.contains("Capacitor.capx"))
.unwrap_or_else(|| panic!("no capacitor emitted in:\n{}", out.text));
assert!(line.contains("conn=wye"), "{line}");
assert!(
out.warnings.iter().any(|w| w.contains("off diagonal")),
"{:?}",
out.warnings
);
}
#[test]
fn stashed_delta_matrix_warns_when_scalar_emission_is_lossy() {
let (b, vs) = three_phase_source(2400.0);
let bmat = vec![
vec![0.003, -0.001, -0.002],
vec![-0.001, 0.003, -0.002],
vec![-0.002, -0.002, 0.004],
];
let mut extras = Extras::new();
extras.insert("conn".into(), serde_json::json!("delta"));
extras.insert("phases".into(), serde_json::json!("3"));
let sh = DistShunt {
name: "capx".into(),
bus: "sb".into(),
terminal_map: strings(&["1", "2", "3"]),
g: vec![vec![0.0; 3]; 3],
b: bmat,
extras,
};
let net = DistNetwork {
base_frequency: 60.0,
buses: vec![b],
sources: vec![vs],
shunts: vec![sh],
..DistNetwork::default()
};
let out = write_dss(&net);
let line = out
.text
.lines()
.find(|l| l.contains("Capacitor.capx"))
.unwrap_or_else(|| panic!("no capacitor emitted in:\n{}", out.text));
assert!(line.contains("conn=delta"), "{line}");
assert!(
out.warnings
.iter()
.any(|w| w.contains("no scalar capacitor expression")),
"{:?}",
out.warnings
);
}
#[test]
fn option_values_choose_a_wrapper_the_lexer_undoes() {
let src = "Clear\n\
New Circuit.c1 basekv=12.47 pu=1 angle=0 phases=3 bus1=sb\n\
Set foo=[a!b]\n\
Set bar=[(abc]\n\
Set baz=(x ] y)\n\
Set qux=[a ) b]\n\
Solve\n";
let net = parse_dss_str(src);
let first = write_dss(&net);
for line in [
"Set foo=(a!b)",
"Set bar=((abc)",
"Set baz=(x ] y)",
"Set qux=[a ) b]",
] {
assert!(
first.text.contains(line),
"{line} missing in {}",
first.text
);
}
assert!(
!first
.warnings
.iter()
.any(|w| w.contains("emitted as written")),
"{:?}",
first.warnings
);
let reparsed = parse_dss_str(&first.text);
let opt = |k: &str| {
reparsed
.options
.iter()
.find(|(name, _)| name == k)
.map(|(_, v)| v.as_str())
};
assert_eq!(opt("foo"), Some("a!b"));
assert_eq!(opt("bar"), Some("(abc"));
assert_eq!(opt("baz"), Some("x ] y"));
assert_eq!(opt("qux"), Some("a ) b"));
let second = write_dss(&reparsed);
assert_eq!(first.text, second.text);
}
#[test]
fn extras_tail_values_wrap_like_options() {
let (b, vs) = three_phase_source(2400.0);
let mut load = load_on("sb", &["1", "2", "3", "4"], Configuration::Wye);
load.extras
.insert("daily".into(), serde_json::json!("a ) b"));
let net = DistNetwork {
base_frequency: 60.0,
buses: vec![b],
sources: vec![vs],
loads: vec![load],
..DistNetwork::default()
};
let (first, second) = roundtrip(&net);
assert!(first.contains("daily=[a ) b]"), "{first}");
assert_eq!(first, second);
let back = parse_dss_str(&first);
assert_eq!(
back.loads[0]
.extras
.get("daily")
.and_then(serde_json::Value::as_str),
Some("a ) b")
);
}
#[test]
fn unrepresentable_values_emit_as_written_and_warn() {
let bad = "a )]}\"' b";
let (b, vs) = three_phase_source(2400.0);
let mut load = load_on("sb", &["1", "2", "3", "4"], Configuration::Wye);
load.extras.insert("daily".into(), serde_json::json!(bad));
let mut net = DistNetwork {
base_frequency: 60.0,
buses: vec![b],
sources: vec![vs],
loads: vec![load],
..DistNetwork::default()
};
net.options.push(("foo".into(), bad.into()));
let out = write_dss(&net);
assert!(out.text.contains(&format!("Set foo={bad}")), "{}", out.text);
assert!(out.text.contains(&format!("daily={bad}")), "{}", out.text);
let warned = |needle: &str| {
out.warnings
.iter()
.any(|w| w.contains(needle) && w.contains("emitted as written"))
};
assert!(warned("option `foo`"), "{:?}", out.warnings);
assert!(warned("`daily`"), "{:?}", out.warnings);
}
#[test]
fn empty_extras_values_wrap_instead_of_eating_the_next_token() {
let dss = "clear\nnew circuit.c basekv=12.47 bus1=sb\n\
new load.ld bus1=sb.1 phases=1 kv=7.2 kw=10 daily=() duty=sh\nsolve\n";
let net = parse_dss_str(dss);
let load = &net.loads[0];
assert_eq!(load.extras.get("daily").and_then(|v| v.as_str()), Some(""));
let w1 = write_dss(&net).text;
let again = parse_dss_str(&w1);
let load2 = &again.loads[0];
assert_eq!(load2.extras.get("daily").and_then(|v| v.as_str()), Some(""));
assert_eq!(
load2.extras.get("duty").and_then(|v| v.as_str()),
Some("sh")
);
assert_eq!(w1, write_dss(&again).text);
}
#[test]
fn sub_unique_option_prefixes_re_emit_instead_of_vanishing() {
let dss = "clear\nnew circuit.c basekv=12.47 bus1=sb\n\
Set ca=600\nSet default=2.5\nsolve\n";
let net = parse_dss_str(dss);
assert!((net.base_frequency - 60.0).abs() < 1e-12);
let out = write_dss(&net).text;
assert!(out.contains("Set ca=600"), "{out}");
assert!(out.contains("Set default=2.5"), "{out}");
}
#[test]
fn abbreviated_derived_options_skip_and_set_the_frequency() {
let src = "Clear\n\
New Circuit.c1 basekv=12.47 pu=1 angle=0 phases=3 bus1=sb\n\
Set volt=[115, 132]\n\
Set defaultb=50\n\
Solve\n";
let net = parse_dss_str(src);
assert!((net.base_frequency - 50.0).abs() < 1e-12);
let out = write_dss(&net);
assert!(
out.text.contains("Set DefaultBaseFrequency=50"),
"{}",
out.text
);
assert_eq!(
out.text
.to_lowercase()
.matches("defaultbasefrequency")
.count(),
1,
"{}",
out.text
);
assert_eq!(
out.text.matches("Set VoltageBases").count(),
1,
"{}",
out.text
);
assert!(!out.text.contains("Set volt="), "{}", out.text);
assert!(!out.text.contains("Set defaultb="), "{}", out.text);
let second = write_dss(&parse_dss_str(&out.text));
assert_eq!(out.text, second.text);
}
#[test]
fn non_numeric_source_extras_warn_before_falling_back() {
let (b, mut vs) = three_phase_source(2400.0);
vs.extras
.insert("basekv".into(), serde_json::json!("@base"));
vs.extras.insert("pu".into(), serde_json::json!("unity"));
vs.extras.insert("angle".into(), serde_json::json!([0.0]));
let net = DistNetwork {
base_frequency: 60.0,
buses: vec![b],
sources: vec![vs],
..DistNetwork::default()
};
let out = write_dss(&net);
for key in ["basekv", "pu", "angle"] {
assert!(
out.warnings
.iter()
.any(|w| w.contains(&format!("{key} extra")) && w.contains("does not parse")),
"{key}: {:?}",
out.warnings
);
}
let line = out.text.lines().find(|l| l.contains("Circuit.")).unwrap();
assert!(line.contains("pu=1 angle=0"), "{line}");
}
#[test]
fn de_energized_source_phase_keeps_its_conductor() {
let (b, mut vs) = three_phase_source(2400.0);
vs.v_magnitude[2] = 0.0; let net = DistNetwork {
name: Some("t".into()),
base_frequency: 60.0,
buses: vec![b],
sources: vec![vs],
..DistNetwork::default()
};
let (first, second) = roundtrip(&net);
let line = first.lines().find(|l| l.contains("Circuit.")).unwrap();
assert!(line.contains("phases=3"), "{line}");
assert!(line.contains("bus1=sb.1.2.3.0"), "{line}");
assert_eq!(first, second);
let out = write_dss(&net);
assert!(
out.warnings
.iter()
.any(|w| w.contains("phases=3") && w.contains("positive")),
"{:?}",
out.warnings
);
}
#[test]
fn multiple_sources_keep_named_vsource_when_source_exists() {
let third = 2.0 * std::f64::consts::FRAC_PI_3;
let source = VoltageSource {
name: "source".into(),
bus: "Bx".into(),
terminal_map: strings(&["1", "2", "3", "4"]),
v_magnitude: vec![20_000.0, 20_000.0, 20_000.0, 0.0],
v_angle: vec![0.0, -third, third, 0.0],
extras: Extras::new(),
};
let wind = VoltageSource {
name: "WindGen1".into(),
bus: "Bg".into(),
terminal_map: strings(&["1", "2", "3", "4"]),
v_magnitude: vec![400.0, 400.0, 400.0, 0.0],
v_angle: vec![
-std::f64::consts::FRAC_PI_3,
std::f64::consts::PI,
third / 2.0,
0.0,
],
extras: Extras::new(),
};
let net = DistNetwork {
name: Some("dg".into()),
base_frequency: 60.0,
buses: vec![
bus("Bg", &["1", "2", "3", "4"], &["4"]),
bus("Bx", &["1", "2", "3", "4"], &["4"]),
],
sources: vec![wind, source],
..DistNetwork::default()
};
let out = write_dss(&net).text;
let circuit = out.lines().find(|l| l.starts_with("New Circuit")).unwrap();
assert!(circuit.contains("bus1=Bx.1.2.3.0"), "{circuit}");
assert!(
out.lines()
.any(|l| l.starts_with("New Vsource.WindGen1") && l.contains("bus1=Bg.1.2.3.0")),
"{out}"
);
let reparsed = parse_dss_str(&out);
assert!(
reparsed
.sources
.iter()
.any(|vs| vs.name.eq_ignore_ascii_case("WindGen1")),
"{:?}",
reparsed.sources
);
}
#[test]
fn source_phases_stash_wins_and_does_not_double_emit() {
let (b, mut vs) = three_phase_source(2400.0);
vs.extras.insert("phases".into(), serde_json::json!("3"));
let net = DistNetwork {
base_frequency: 60.0,
buses: vec![b],
sources: vec![vs],
..DistNetwork::default()
};
let out = write_dss(&net);
let line = out.text.lines().find(|l| l.contains("Circuit.")).unwrap();
assert!(line.contains("phases=3"), "{line}");
assert_eq!(line.matches("phases=").count(), 1, "{line}");
}
#[test]
fn foreign_maps_without_a_neutral_warn_and_converge_at_write2() {
let third = 2.0 * std::f64::consts::FRAC_PI_3;
let vs = VoltageSource {
name: "source".into(),
bus: "sb".into(),
terminal_map: strings(&["1", "2", "3"]),
v_magnitude: vec![2400.0; 3],
v_angle: vec![0.0, -third, third],
extras: Extras::new(),
};
let load = load_on("sb", &["1"], Configuration::Wye);
let net = DistNetwork {
name: Some("t".into()),
base_frequency: 60.0,
buses: vec![bus("sb", &["1", "2", "3"], &[])],
sources: vec![vs],
loads: vec![load],
..DistNetwork::default()
};
let first = write_dss(&net);
let hits = |warnings: &[String], name: &str| {
warnings
.iter()
.any(|w| w.contains(name) && w.contains("materializes a grounded neutral"))
};
assert!(
hits(&first.warnings, "vsource source"),
"{:?}",
first.warnings
);
assert!(hits(&first.warnings, "load ld"), "{:?}", first.warnings);
let second = write_dss(&parse_dss_str(&first.text));
assert_ne!(first.text, second.text);
assert!(!hits(&second.warnings, "vsource"), "{:?}", second.warnings);
assert!(!hits(&second.warnings, "load"), "{:?}", second.warnings);
let third_write = write_dss(&parse_dss_str(&second.text));
assert_eq!(second.text, third_write.text);
}
#[test]
fn generator_phases_and_conn_match_the_load_rules() {
let (b, vs) = three_phase_source(2400.0);
let g = DistGenerator {
name: "g1".into(),
bus: "sb".into(),
terminal_map: strings(&["1", "2", "3"]),
configuration: Configuration::Delta,
p_nom: vec![1e3; 3],
q_nom: vec![0.0; 3],
p_min: None,
p_max: None,
q_min: None,
q_max: None,
cost: None,
extras: Extras::from([
("kv".to_string(), serde_json::json!("4.16")),
("phases".to_string(), serde_json::json!("2")),
]),
};
let net = DistNetwork {
base_frequency: 60.0,
buses: vec![b],
sources: vec![vs],
generators: vec![g],
..DistNetwork::default()
};
let out = write_dss(&net);
let line = out
.text
.lines()
.find(|l| l.contains("Generator.g1"))
.unwrap();
assert!(line.contains("phases=2 conn=delta"), "{line}");
assert_eq!(line.matches("phases=").count(), 1, "{line}");
}
}