Skip to main content

powerio_dist/dss/
write.rs

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