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::collections::BTreeMap;
16use std::fmt::Write as _;
17
18use crate::convert::Conversion;
19use crate::model::{
20    Configuration, DistBus, DistLoadVoltageModel, DistNetwork, Extras, Mat, Winding, WindingConn,
21};
22
23use super::read::delta_edges;
24use super::{lex, prop};
25
26/// Writes canonical `.dss` text from the model.
27pub fn write_dss(net: &DistNetwork) -> Conversion {
28    let mut w = DssWriter {
29        out: String::new(),
30        warnings: Vec::new(),
31        grounded: net
32            .buses
33            .iter()
34            .map(|b| (b.id.to_ascii_lowercase(), b.grounded.clone()))
35            .collect(),
36        terminals: net
37            .buses
38            .iter()
39            .map(|b| (b.id.to_ascii_lowercase(), b.terminals.clone()))
40            .collect(),
41        kv_estimate: estimate_bus_kv(net),
42    };
43    w.network(net);
44    Conversion {
45        text: w.out,
46        warnings: w.warnings,
47    }
48}
49
50struct DssWriter {
51    out: String,
52    warnings: Vec<String>,
53    /// Bus id (lowercase) → perfectly grounded terminal names.
54    grounded: BTreeMap<String, Vec<String>>,
55    /// Bus id (lowercase) → ordered terminal names.
56    terminals: BTreeMap<String, Vec<String>>,
57    /// Bus id (lowercase) → phase to neutral voltage estimate, volts.
58    kv_estimate: BTreeMap<String, f64>,
59}
60
61#[derive(Clone, Copy)]
62struct ElementKv<'a> {
63    bus: &'a str,
64    phases: usize,
65    configuration: Configuration,
66    name: &'a str,
67    class: &'a str,
68    typed_kv: Option<f64>,
69}
70
71/// Phase to neutral voltage per bus, propagated from the sources through
72/// lines and switches (same level) and transformers (winding ratios). The
73/// estimate feeds load/capacitor `kv` and `Set VoltageBases` when the
74/// source format did not carry them.
75///
76/// The seed is not the model voltage directly: it is the basekv the writer
77/// will emit (the stashed token when the source carried one), run through
78/// the reader's basekv → per phase formula. A reparse then reproduces the
79/// same floats bit for bit; seeding from `v_magnitude` is not a fixed
80/// point of the sqrt round trip and `Set VoltageBases` would drift one ulp
81/// per write. Transformer ratios use `(v_ref / 1e3) * 1e3`, the value a
82/// reparse of the emitted `kvs=` rebuilds, for the same reason.
83fn estimate_bus_kv(net: &DistNetwork) -> BTreeMap<String, f64> {
84    let mut kv: BTreeMap<String, f64> = BTreeMap::new();
85    for vs in &net.sources {
86        let phases = source_phases(net, vs);
87        let basekv = extras_f64(&vs.extras, "basekv").unwrap_or_else(|| source_basekv(vs, phases));
88        let pu = extras_f64(&vs.extras, "pu").unwrap_or(1.0);
89        let vln = basekv * 1e3 * pu / source_chord(phases);
90        if vln > 0.0 {
91            kv.insert(vs.bus.to_ascii_lowercase(), vln);
92        }
93    }
94    // Per bus grounded terminal sets, to tell a line to neutral winding (a
95    // terminal tied to ground in its map) from a line to line one. Grounding
96    // and the terminal map both survive a BMOPF round trip, the wye/delta
97    // label does not, so this is what the transformer ratio keys on below.
98    let grounded: BTreeMap<String, &Vec<String>> = net
99        .buses
100        .iter()
101        .map(|b| (b.id.to_ascii_lowercase(), &b.grounded))
102        .collect();
103    for _ in 0..net.buses.len() {
104        let mut changed = false;
105        for l in &net.lines {
106            let (f, t) = (
107                l.bus_from.to_ascii_lowercase(),
108                l.bus_to.to_ascii_lowercase(),
109            );
110            match (kv.get(&f).copied(), kv.get(&t).copied()) {
111                (Some(v), None) => {
112                    kv.insert(t, v);
113                    changed = true;
114                }
115                (None, Some(v)) => {
116                    kv.insert(f, v);
117                    changed = true;
118                }
119                _ => {}
120            }
121        }
122        for s in &net.switches {
123            let (f, t) = (
124                s.bus_from.to_ascii_lowercase(),
125                s.bus_to.to_ascii_lowercase(),
126            );
127            match (kv.get(&f).copied(), kv.get(&t).copied()) {
128                (Some(v), None) => {
129                    kv.insert(t, v);
130                    changed = true;
131                }
132                (None, Some(v)) => {
133                    kv.insert(f, v);
134                    changed = true;
135                }
136                _ => {}
137            }
138        }
139        for t in &net.transformers {
140            // Propagate by winding voltage ratio from any known winding bus.
141            // The bus map holds phase to neutral voltages, so each winding's
142            // v_ref is first reduced to that base. A winding's rating is the
143            // voltage across its two terminals: line to line when both are
144            // phases (a polyphase winding, or a single phase delta leg), line
145            // to neutral when one terminal is the bus's grounded neutral.
146            // Matched windings (wye-wye, three phase wye-delta) cancel the
147            // factor; only a mixed open delta leg (single phase wye to delta)
148            // shifts, where the old raw ratio was a sqrt(3) off.
149            let pn = |w: &Winding| {
150                let v = (w.v_ref / 1e3) * 1e3;
151                let line_to_neutral = t.phases < 2
152                    && grounded
153                        .get(&w.bus.to_ascii_lowercase())
154                        .is_some_and(|g| w.terminal_map.iter().any(|tm| g.contains(tm)));
155                if line_to_neutral { v } else { v / 3f64.sqrt() }
156            };
157            let known: Option<(usize, f64)> = t
158                .windings
159                .iter()
160                .enumerate()
161                .find_map(|(i, w)| kv.get(&w.bus.to_ascii_lowercase()).map(|v| (i, *v)));
162            if let Some((i, v_known)) = known {
163                let pn_known = pn(&t.windings[i]);
164                if pn_known > 0.0 {
165                    for (j, w) in t.windings.iter().enumerate() {
166                        if j != i && !kv.contains_key(&w.bus.to_ascii_lowercase()) {
167                            kv.insert(w.bus.to_ascii_lowercase(), v_known * pn(w) / pn_known);
168                            changed = true;
169                        }
170                    }
171                }
172            }
173        }
174        if !changed {
175            break;
176        }
177    }
178    kv
179}
180
181/// A float in the shortest form Rust round trips. Negative zero canonicalizes
182/// to `0` so a `-x/denom` that lands on `-0.0` does not emit the literal `-0`.
183fn num(v: f64) -> String {
184    let v = if v == 0.0 { 0.0 } else { v };
185    format!("{v}")
186}
187
188/// VSource.cpp's per phase magnitude divisor: the chord of the n-gon
189/// (1 for a single phase source, sqrt(3) at n = 3). Division by the
190/// 1 phase chord is exact, so one expression serves both reader branches.
191fn source_chord(phases: usize) -> f64 {
192    if phases <= 1 {
193        1.0
194    } else {
195        2.0 * (std::f64::consts::PI / phases as f64).sin()
196    }
197}
198
199/// The basekv a source without a stashed token emits: the model magnitude
200/// through the inverse of the reader's chord formula.
201fn source_basekv(vs: &crate::model::VoltageSource, phases: usize) -> f64 {
202    vs.v_magnitude.iter().copied().fold(0.0_f64, f64::max) * source_chord(phases) / 1e3
203}
204
205/// An extra as a number: the reader stashes written tokens as strings and
206/// materialized defaults as numbers.
207fn extras_f64(extras: &Extras, key: &str) -> Option<f64> {
208    let v = extras.get(key)?;
209    v.as_f64()
210        .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
211        // A stashed `inf`/`NaN` token parses to a non-finite f64; reject it so
212        // it never reaches `num()` and emits a literal `inf`/`NaN` DSS token.
213        .filter(|f| f.is_finite())
214}
215
216fn extras_usize(extras: &Extras, key: &str) -> Option<usize> {
217    let v = extras.get(key)?;
218    v.as_u64()
219        .and_then(|u| usize::try_from(u).ok())
220        .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
221        .or_else(|| {
222            v.as_f64()
223                .filter(|f| f.fract() == 0.0 && *f >= 0.0)
224                .map(|f| f as usize)
225        })
226}
227
228fn zipv_cutoff(value: Option<&serde_json::Value>) -> Option<f64> {
229    let text = value?.as_str()?;
230    lex::Value::new(text)
231        .to_vector(None)
232        .ok()
233        .and_then(|v| v.get(6).copied())
234        .filter(|v| v.is_finite())
235}
236
237/// Whether the dss tokenizer would split this name: its delimiters, quote
238/// pair characters, comment openers, and (in bus ids) the node dot.
239fn name_breaks_dss(name: &str, is_bus_id: bool) -> bool {
240    name.contains("//")
241        || name.chars().any(|c| {
242            matches!(
243                c,
244                ' ' | '\t' | ',' | '=' | '!' | '"' | '\'' | '(' | ')' | '[' | ']' | '{' | '}'
245            ) || (is_bus_id && c == '.')
246        })
247}
248
249/// A `key=value` value as dss text. A value the lexer scans back as one
250/// bare token emits bare; anything else wraps in the first quote pair
251/// whose closer is absent from the value. The lexer honors all five pairs,
252/// and its quoted scan runs to the closer without checking delimiters or
253/// comment openers, so the wrapper protects spaces, commas, `=`, `!`, and
254/// `//`. The choice depends only on the value: the reader strips the
255/// wrapper, so the next write sees the bare value and picks the same form.
256/// `false` means nothing reparses to the value — every closer appears in
257/// it and bare scanning splits it — and the caller must warn.
258fn dss_value_out(value: &str) -> (String, bool) {
259    // An empty value is never bare representable: `key=` makes the lexer
260    // eat the next token as the value. `()` strips back to the empty string.
261    if value.is_empty() {
262        return ("()".to_string(), true);
263    }
264    let mut scan = lex::Scanner::new(value, None);
265    let bare = scan.next_param().is_some_and(|p| {
266        p.name.is_none() && !p.value.quoted && p.value.text == value && scan.next_param().is_none()
267    });
268    if bare {
269        return (value.to_string(), true);
270    }
271    for (open, close) in [('(', ')'), ('[', ']'), ('{', '}'), ('"', '"'), ('\'', '\'')] {
272        if !value.contains(close) {
273            return (format!("{open}{value}{close}"), true);
274        }
275    }
276    (value.to_string(), false)
277}
278
279/// Emitted source `phases=`: the stashed token when the source carried
280/// one, otherwise the terminal map entries outside the bus's grounded
281/// set. The engine counts conductors, not energized phases, so a phase
282/// at v_magnitude 0 keeps its place on the dot list; the emission site
283/// warns about the disagreement.
284fn source_phases(net: &DistNetwork, vs: &crate::model::VoltageSource) -> usize {
285    if let Some(p) = extras_usize(&vs.extras, "phases") {
286        return p.max(1);
287    }
288    let energized = vs.v_magnitude.iter().filter(|&&v| v > 0.0).count();
289    if energized > 0
290        && vs.v_magnitude.len() == vs.terminal_map.len()
291        && energized + 1 == vs.v_magnitude.len()
292        && vs.v_magnitude.last().is_some_and(|&v| v == 0.0)
293    {
294        return energized;
295    }
296    let grounded = net
297        .buses
298        .iter()
299        .find(|b| b.id.eq_ignore_ascii_case(&vs.bus))
300        .map(|b| b.grounded.as_slice())
301        .unwrap_or_default();
302    vs.terminal_map
303        .iter()
304        .filter(|t| !grounded.contains(t))
305        .count()
306        .max(1)
307}
308
309/// First row (self, mutual) of a series matrix extra, without consuming it.
310fn seq_parts(extras: &Extras, key: &str) -> Option<(f64, f64)> {
311    let row = extras.get(key)?.as_array()?.first()?.as_array()?;
312    let self_v = row.first()?.as_f64()?;
313    let mutual = row
314        .get(1)
315        .and_then(serde_json::Value::as_f64)
316        .unwrap_or(0.0);
317    Some((self_v, mutual))
318}
319
320impl DssWriter {
321    fn warn(&mut self, msg: impl Into<String>) {
322        self.warnings.push(msg.into());
323    }
324
325    /// The engine's bus fill rule gives every conductor the dot list does
326    /// not cover a default — nodes 1..=phases for the phase conductors,
327    /// ground for the rest — so a map shorter than the class's conductor
328    /// count comes back from a reparse one grounded neutral longer. The
329    /// first write of such a model is not a fixed point; the second is.
330    fn warn_short_map(&mut self, class: &str, name: &str, map_len: usize, nconds: usize) {
331        if map_len < nconds {
332            self.warn(format!(
333                "{class} {name}: terminal map lists {map_len} of {nconds} conductors; \
334                 dss materializes a grounded neutral terminal and the reparsed model \
335                 gains one"
336            ));
337        }
338    }
339
340    /// A numeric source extra. A present token that does not parse warns;
341    /// the derived value substitutes and the extra is consumed either way.
342    fn source_extra_f64(&mut self, vs: &crate::model::VoltageSource, key: &str) -> Option<f64> {
343        let v = vs.extras.get(key)?;
344        let parsed = v
345            .as_f64()
346            .or_else(|| v.as_str().and_then(|s| s.parse().ok()));
347        if parsed.is_none() {
348            self.warn(format!(
349                "vsource {}: {key} extra `{v}` does not parse as a number; \
350                 using the derived value",
351                vs.name
352            ));
353        }
354        parsed
355    }
356
357    fn line_out(&mut self, s: &str) {
358        self.out.push_str(s);
359        self.out.push('\n');
360    }
361
362    fn check_name(&mut self, class: &str, name: &str) {
363        if name_breaks_dss(name, false) {
364            self.warn(format!(
365                "{class} `{name}`: name contains characters dss cannot represent; \
366                 output will not reparse identically"
367            ));
368        }
369    }
370
371    /// `bus.1.2.0` syntax: terminals in the bus's perfectly grounded set
372    /// emit as node 0, the inverse of the reader's neutral naming. dss
373    /// nodes are positional integers, so a non numeric terminal name emits
374    /// as its 1 based position on the bus (the element map position when
375    /// the bus does not list it), reported, keeping the conductor structure
376    /// intact across the trip.
377    fn bus_ref(&mut self, bus: &str, map: &[String]) -> String {
378        let key = bus.to_ascii_lowercase();
379        if name_breaks_dss(bus, true) {
380            self.warn(format!(
381                "bus `{bus}`: id contains characters dss cannot represent; \
382                 output will not reparse identically"
383            ));
384        }
385        let grounded = self.grounded.get(&key).cloned();
386        let terminals = self.terminals.get(&key).cloned().unwrap_or_default();
387        let nodes: Vec<String> = map
388            .iter()
389            .enumerate()
390            .map(|(i, t)| {
391                if grounded.as_ref().is_some_and(|g| g.contains(t)) {
392                    "0".to_string()
393                } else if t.parse::<u32>().is_ok() {
394                    t.clone()
395                } else {
396                    let pos = terminals.iter().position(|x| x == t).unwrap_or(i) + 1;
397                    self.warn(format!(
398                        "bus {bus}: terminal `{t}` is not a dss node number; \
399                         emitted as node {pos}, its position on the bus"
400                    ));
401                    pos.to_string()
402                }
403            })
404            .collect();
405        if nodes.is_empty() {
406            bus.to_string()
407        } else {
408            format!("{bus}.{}", nodes.join("."))
409        }
410    }
411
412    /// Extras whose keys are dss properties of `class` emit as written;
413    /// the rest are reported per key.
414    fn extras_tail(&mut self, class: &str, name: &str, extras: &Extras) -> String {
415        let table = prop::class_by_name(class);
416        let mut tail = String::new();
417        for (key, value) in extras {
418            if matches!(key.as_str(), "bmopf_subtype") || key.starts_with("pmd_") {
419                continue; // converter bookkeeping
420            }
421            let known = table.is_some_and(|t| t.props.contains(&key.as_str()));
422            let text = value
423                .as_str()
424                .map(ToString::to_string)
425                .or_else(|| value.as_f64().map(num))
426                .or_else(|| value.as_i64().map(|v| v.to_string()));
427            match (known, text) {
428                (true, Some(text)) => {
429                    let (out, representable) = dss_value_out(&text);
430                    if !representable {
431                        self.warn(format!(
432                            "{class} {name}: extra `{key}` value `{text}` contains every \
433                             dss quote closer and splits when scanned bare; emitted as \
434                             written and a reparse will not see the same value"
435                        ));
436                    }
437                    let _ = write!(tail, " {key}={out}");
438                }
439                _ => self.warn(format!(
440                    "{class} {name}: extra `{key}` is not a dss property; dropped from the output"
441                )),
442            }
443        }
444        tail
445    }
446
447    /// Lower triangle matrix text. Rows shorter than the triangle pad
448    /// with 0 instead of panicking, and the padding is reported.
449    fn matrix_arg(&mut self, m: &Mat, what: &str) -> String {
450        let mut short = false;
451        let rows: Vec<String> = m
452            .iter()
453            .enumerate()
454            .map(|(i, row)| {
455                let take = row.len().min(i + 1);
456                let mut vals: Vec<String> = row[..take].iter().map(|v| num(*v)).collect();
457                if take < i + 1 {
458                    short = true;
459                    vals.resize(i + 1, "0".to_string());
460                }
461                vals.join(" ")
462            })
463            .collect();
464        if short {
465            self.warn(format!(
466                "{what}: matrix rows are shorter than the lower triangle; \
467                 missing entries emitted as 0"
468            ));
469        }
470        format!("({})", rows.join(" | "))
471    }
472
473    /// Consumes an rs/xs extras pair only when both first rows parse; a
474    /// half present or unusable pair stays in extras and is reported.
475    fn take_seq_pair(
476        &mut self,
477        extras: &mut Extras,
478        r_key: &str,
479        x_key: &str,
480        what: &str,
481    ) -> Option<((f64, f64), (f64, f64))> {
482        let r = seq_parts(extras, r_key);
483        let x = seq_parts(extras, x_key);
484        if let (Some(r), Some(x)) = (r, x) {
485            extras.remove(r_key);
486            extras.remove(x_key);
487            return Some((r, x));
488        }
489        if extras.contains_key(r_key) || extras.contains_key(x_key) {
490            let state = |key: &str, parsed: bool| {
491                if !extras.contains_key(key) {
492                    format!("`{key}` is missing")
493                } else if parsed {
494                    format!("`{key}` is usable")
495                } else {
496                    format!("`{key}` is not a numeric matrix")
497                }
498            };
499            self.warn(format!(
500                "{what}: series impedance extras unusable ({}, {}); left in extras",
501                state(r_key, r.is_some()),
502                state(x_key, x.is_some()),
503            ));
504        }
505        None
506    }
507
508    /// Emitted `phases=`: the reader's stash when present, otherwise
509    /// inferred from the terminal map shape. A delta map with 3 conductors
510    /// is 2 or 3 phase; without the stash the 3 phase reading wins, loudly.
511    fn element_phases(
512        &mut self,
513        extras: &Extras,
514        terminal_map: &[String],
515        configuration: Configuration,
516        class: &str,
517        name: &str,
518    ) -> usize {
519        if let Some(p) = extras_usize(extras, "phases") {
520            return p.max(1);
521        }
522        match configuration {
523            Configuration::Delta => match terminal_map.len() {
524                2 => 1,
525                3 => {
526                    self.warn(format!(
527                        "{class} {name}: a delta terminal map with 3 conductors is 2 or 3 \
528                         phase and no phases record disambiguates; emitted phases=3"
529                    ));
530                    3
531                }
532                n => {
533                    self.warn(format!(
534                        "{class} {name}: a delta terminal map with {n} conductors has no \
535                         dss phases mapping; emitted phases={}",
536                        n.max(1)
537                    ));
538                    n.max(1)
539                }
540            },
541            Configuration::Wye => terminal_map.len().saturating_sub(1).max(1),
542            _ => 1,
543        }
544    }
545
546    fn network(&mut self, net: &DistNetwork) {
547        self.line_out("Clear");
548        self.line_out(&format!(
549            "Set DefaultBaseFrequency={}",
550            num(net.base_frequency)
551        ));
552        self.out.push('\n');
553
554        self.sources(net);
555        self.linecodes(net);
556        self.lines(net);
557        self.switches(net);
558        self.transformers(net);
559        self.loads(net);
560        self.shunts(net);
561        self.generators(net);
562
563        for u in &net.untyped {
564            self.warn(format!(
565                "{} {}: untyped object is not regenerated in canonical dss output",
566                u.class, u.name
567            ));
568        }
569        for b in &net.buses {
570            self.bus_extras(b);
571        }
572
573        self.out.push('\n');
574        // Source options re-emit in stored order, except the keys this
575        // writer derives itself (the DefaultBaseFrequency header, the
576        // VoltageBases tail). Commands do not re-emit: their position in
577        // the script matters and the canonical element order does not
578        // preserve it, so each drop is reported instead.
579        for (key, value) in &net.options {
580            if key.is_empty() {
581                self.warn(format!(
582                    "option `{value}` has no name; not regenerated in canonical dss output"
583                ));
584                continue;
585            }
586            // The engine resolves Set names by first match in option table
587            // order (Command.cpp Getcommand → HashList FindAbbrev). Every
588            // prefix of "voltagebases" binds Voltagebases (it precedes the
589            // other v options), but prefixes of "defaultbasefrequency"
590            // shorter than "defaultb" bind DefaultDaily, so the frequency
591            // skip is bounded at the engine's unique resolution point.
592            // Calcvoltagebases is a command, never a Set option, so it does
593            // not belong here.
594            let key_lc = key.to_ascii_lowercase();
595            if "voltagebases".starts_with(&key_lc)
596                || (key_lc.len() >= "defaultb".len() && "defaultbasefrequency".starts_with(&key_lc))
597            {
598                continue;
599            }
600            let (text, representable) = dss_value_out(value);
601            if !representable {
602                self.warn(format!(
603                    "option `{key}`: value `{value}` contains every dss quote closer \
604                     and splits when scanned bare; emitted as written and a reparse \
605                     will not see the same value"
606                ));
607            }
608            self.line_out(&format!("Set {key}={text}"));
609        }
610        for (verb, args) in &net.commands {
611            if verb.eq_ignore_ascii_case("calcvoltagebases") || verb.eq_ignore_ascii_case("solve") {
612                continue; // the tail emits these
613            }
614            let shown = if args.is_empty() {
615                verb.clone()
616            } else {
617                format!("{verb} {args}")
618            };
619            self.warn(format!(
620                "command `{shown}` is not regenerated in canonical dss output"
621            ));
622        }
623        let mut bases: Vec<f64> = self
624            .kv_estimate
625            .values()
626            .map(|v| v * 3f64.sqrt() / 1e3)
627            .collect();
628        bases.sort_by(f64::total_cmp);
629        bases.dedup_by(|a, b| (*a - *b).abs() < 1e-9);
630        if !bases.is_empty() {
631            let list: Vec<String> = bases.iter().map(|v| num(*v)).collect();
632            self.line_out(&format!("Set VoltageBases=[{}]", list.join(", ")));
633            self.line_out("Calcvoltagebases");
634        }
635        self.line_out("Solve");
636    }
637
638    fn bus_extras(&mut self, b: &DistBus) {
639        for key in b.extras.keys() {
640            if key == "x" || key == "y" {
641                continue; // coordinates have no command in canonical output yet
642            }
643            self.warnings.push(format!(
644                "bus {}: extra `{key}` is not regenerated in canonical dss output",
645                b.id
646            ));
647        }
648        for (field, present) in [
649            ("v_min", b.v_min.is_some()),
650            ("v_max", b.v_max.is_some()),
651            ("vpn_min", b.vpn_min.is_some()),
652            ("vpn_max", b.vpn_max.is_some()),
653            ("vpp_min", b.vpp_min.is_some()),
654            ("vpp_max", b.vpp_max.is_some()),
655            ("vsym_min", b.vsym_min.is_some()),
656            ("vsym_max", b.vsym_max.is_some()),
657        ] {
658            if present {
659                self.warnings.push(format!(
660                    "bus {}: `{field}` voltage bounds have no dss expression; dropped",
661                    b.id
662                ));
663            }
664        }
665    }
666
667    fn sources(&mut self, net: &DistNetwork) {
668        let mut order: Vec<usize> = (0..net.sources.len()).collect();
669        if let Some(source_idx) = net
670            .sources
671            .iter()
672            .position(|vs| vs.name.eq_ignore_ascii_case("source"))
673        {
674            order.swap(0, source_idx);
675        }
676        for (i, source_idx) in order.into_iter().enumerate() {
677            let vs = &net.sources[source_idx];
678            let phases = source_phases(net, vs);
679            let energized = vs.v_magnitude.iter().filter(|&&v| v > 0.0).count();
680            if energized > 0 && energized != phases {
681                self.warn(format!(
682                    "vsource {}: emitted phases={phases} but {energized} v_magnitude \
683                     entries are positive; a reparse energizes all {phases}",
684                    vs.name
685                ));
686            }
687            self.warn_short_map("vsource", &vs.name, vs.terminal_map.len(), phases + 1);
688            let basekv = self
689                .source_extra_f64(vs, "basekv")
690                .unwrap_or_else(|| source_basekv(vs, phases));
691            let pu = self.source_extra_f64(vs, "pu").unwrap_or(1.0);
692            let angle = self
693                .source_extra_f64(vs, "angle")
694                .unwrap_or_else(|| vs.v_angle.first().copied().unwrap_or(0.0).to_degrees());
695            let head = if i == 0 {
696                let name = net.name.clone().unwrap_or_else(|| "converted".into());
697                self.check_name("circuit", &name);
698                format!("New Circuit.{name}")
699            } else {
700                self.check_name("vsource", &vs.name);
701                format!("New Vsource.{}", vs.name)
702            };
703            let mut s = format!(
704                "{head} basekv={} pu={} angle={} phases={phases} bus1={}",
705                num(basekv),
706                num(pu),
707                num(angle),
708                self.bus_ref(&vs.bus, &vs.terminal_map),
709            );
710            let mut extras = vs.extras.clone();
711            extras.remove("basekv");
712            extras.remove("pu");
713            extras.remove("angle");
714            extras.remove("phases"); // the head already prints phases=
715            // A source that came through the ENGINEERING model carries its
716            // Thevenin impedance as rs/xs matrices; sequence values
717            // reconstruct exactly (z1 = self - mutual, z0 = self + 2 mutual).
718            let what = format!("vsource {}", vs.name);
719            if let Some(((rs, rm), (xs, xm))) = self.take_seq_pair(&mut extras, "rs", "xs", &what) {
720                // Lowercase keys in sorted order: a reparse keeps these in
721                // extras and the next write emits them from there verbatim.
722                let _ = write!(
723                    s,
724                    " z0=({}, {}) z1=({}, {})",
725                    num(rs + 2.0 * rm),
726                    num(xs + 2.0 * xm),
727                    num(rs - rm),
728                    num(xs - xm)
729                );
730            }
731            s.push_str(&self.extras_tail("vsource", &vs.name, &extras));
732            self.line_out(&s);
733        }
734        self.out.push('\n');
735    }
736
737    fn linecodes(&mut self, net: &DistNetwork) {
738        let omega_nf = std::f64::consts::TAU * net.base_frequency * 1e-9;
739        for c in &net.linecodes {
740            self.check_name("linecode", &c.name);
741            let n = c.n_conductors;
742            let what = format!("linecode {}", c.name);
743            let mut s = format!("New Linecode.{} nphases={n} units=m", c.name);
744            let rm = self.matrix_arg(&c.r_series, &what);
745            let _ = write!(s, " rmatrix={rm}");
746            let xm = self.matrix_arg(&c.x_series, &what);
747            let _ = write!(s, " xmatrix={xm}");
748            // cmatrix in nF per meter: each half is omega C / 2, so
749            // C_nF = 2 b / (omega 1e-9).
750            let c_nf: Mat = c
751                .b_from
752                .iter()
753                .map(|row| row.iter().map(|b| 2.0 * b / omega_nf).collect())
754                .collect();
755            let cm = self.matrix_arg(&c_nf, &what);
756            let _ = write!(s, " cmatrix={cm}");
757            match c.i_max.as_deref() {
758                Some([amps, ..]) => {
759                    let _ = write!(s, " emergamps={}", num(*amps));
760                }
761                Some([]) => self.warn(format!(
762                    "linecode {}: i_max is empty; emergamps not emitted",
763                    c.name
764                )),
765                None => {}
766            }
767            if !c.g_from.iter().flatten().all(|&g| g == 0.0) {
768                self.warn(format!(
769                    "linecode {}: shunt conductance has no dss linecode field; dropped",
770                    c.name
771                ));
772            }
773            let mut extras = c.extras.clone();
774            extras.remove("units"); // canonical output is in meters
775            s.push_str(&self.extras_tail("linecode", &c.name, &extras));
776            self.line_out(&s);
777        }
778        self.out.push('\n');
779    }
780
781    fn lines(&mut self, net: &DistNetwork) {
782        for l in &net.lines {
783            self.check_name("line", &l.name);
784            let phases = l.terminal_map_from.len();
785            let mut s = format!(
786                "New Line.{} bus1={} bus2={} phases={phases} linecode={} length={} units=m",
787                l.name,
788                self.bus_ref(&l.bus_from, &l.terminal_map_from),
789                self.bus_ref(&l.bus_to, &l.terminal_map_to),
790                l.linecode,
791                num(l.length),
792            );
793            let mut extras = l.extras.clone();
794            extras.remove("units"); // canonical output is in meters
795            s.push_str(&self.extras_tail("line", &l.name, &extras));
796            self.line_out(&s);
797        }
798        self.out.push('\n');
799    }
800
801    fn switches(&mut self, net: &DistNetwork) {
802        for sw in &net.switches {
803            self.check_name("line", &sw.name);
804            let phases = sw.terminal_map_from.len();
805            let mut s = format!(
806                "New Line.{} bus1={} bus2={} phases={phases} switch=y",
807                sw.name,
808                self.bus_ref(&sw.bus_from, &sw.terminal_map_from),
809                self.bus_ref(&sw.bus_to, &sw.terminal_map_to),
810            );
811            match sw.i_max.as_deref() {
812                Some([amps, ..]) => {
813                    let _ = write!(s, " emergamps={}", num(*amps));
814                }
815                Some([]) => self.warn(format!(
816                    "line {}: i_max is empty; emergamps not emitted",
817                    sw.name
818                )),
819                None => {}
820            }
821            // A switch that came through the ENGINEERING model carries its
822            // total series matrices; sequence overrides reproduce them over
823            // the forced 0.001 length (the engine's switch dummy values
824            // would otherwise apply).
825            let mut extras = sw.extras.clone();
826            let what = format!("line {}", sw.name);
827            if let Some(((rs, rm), (xs, xm))) =
828                self.take_seq_pair(&mut extras, "pmd_rs", "pmd_xs", &what)
829            {
830                let _ = write!(
831                    s,
832                    " c0=0 c1=0 r0={} r1={} x0={} x1={}",
833                    num((rs + 2.0 * rm) / 0.001),
834                    num((rs - rm) / 0.001),
835                    num((xs + 2.0 * xm) / 0.001),
836                    num((xs - xm) / 0.001)
837                );
838            }
839            s.push_str(&self.extras_tail("line", &sw.name, &extras));
840            self.line_out(&s);
841            self.line_out(&format!(
842                "New SwtControl.{}_state SwitchedObj=Line.{} Action={}",
843                sw.name,
844                sw.name,
845                if sw.open { "open" } else { "close" },
846            ));
847        }
848        self.out.push('\n');
849    }
850
851    fn transformers(&mut self, net: &DistNetwork) {
852        for t in &net.transformers {
853            self.check_name("transformer", &t.name);
854            let nw = t.windings.len();
855            let buses: Vec<String> = t
856                .windings
857                .iter()
858                .map(|w| self.bus_ref(&w.bus, &w.terminal_map))
859                .collect();
860            let conns: Vec<&str> = t
861                .windings
862                .iter()
863                .map(|w| match w.conn {
864                    WindingConn::Wye => "wye",
865                    WindingConn::Delta => "delta",
866                })
867                .collect();
868            let kvs: Vec<String> = t.windings.iter().map(|w| num(w.v_ref / 1e3)).collect();
869            let kvas: Vec<String> = t.windings.iter().map(|w| num(w.s_rating / 1e3)).collect();
870            let rs: Vec<String> = t.windings.iter().map(|w| num(w.r_pct)).collect();
871            let taps: Vec<String> = t.windings.iter().map(|w| num(w.tap)).collect();
872            let mut s = format!(
873                "New Transformer.{} phases={} windings={nw} buses=({}) conns=({}) kvs=({}) kvas=({}) %Rs=({}) taps=({})",
874                t.name,
875                t.phases,
876                buses.join(", "),
877                conns.join(", "),
878                kvs.join(", "),
879                kvas.join(", "),
880                rs.join(", "),
881                taps.join(", "),
882            );
883            if let Some(xhl) = t.xsc_pct.first() {
884                let _ = write!(s, " xhl={}", num(*xhl));
885                if t.xsc_pct.len() >= 3 {
886                    let _ = write!(s, " xht={} xlt={}", num(t.xsc_pct[1]), num(t.xsc_pct[2]));
887                }
888            } else {
889                self.warn(format!(
890                    "transformer {}: xsc_pct is empty; emitted xhl=0",
891                    t.name
892                ));
893                s.push_str(" xhl=0");
894            }
895            s.push_str(&self.extras_tail("transformer", &t.name, &t.extras));
896            self.line_out(&s);
897        }
898        self.out.push('\n');
899    }
900
901    fn loads(&mut self, net: &DistNetwork) {
902        for l in &net.loads {
903            self.check_name("load", &l.name);
904            let phases =
905                self.element_phases(&l.extras, &l.terminal_map, l.configuration, "load", &l.name);
906            let conn = self.element_conn(&l.extras, l.configuration, &l.bus, &l.terminal_map);
907            // The reader's nconds: a 3 phase delta has no neutral conductor,
908            // every other connection carries phases + 1.
909            let nconds = if conn == "delta" && phases == 3 {
910                phases
911            } else {
912                phases + 1
913            };
914            self.warn_short_map("load", &l.name, l.terminal_map.len(), nconds);
915            let kw: f64 = l.p_nom.iter().sum::<f64>() / 1e3;
916            let kvar: f64 = l.q_nom.iter().sum::<f64>() / 1e3;
917            let typed_kv = self.load_nominal_kv(&l.voltage_model, phases, l.configuration, &l.name);
918            let kv = self.element_kv(
919                &l.extras,
920                ElementKv {
921                    bus: &l.bus,
922                    phases,
923                    configuration: l.configuration,
924                    name: &l.name,
925                    class: "load",
926                    typed_kv,
927                },
928            );
929            let mut extras = l.extras.clone();
930            extras.remove("kv");
931            extras.remove("phases");
932            extras.remove("conn");
933            let retained_model = extras.remove("model");
934            let retained_zipv = extras.remove("zipv");
935            // q that came from a power factor goes back as pf=, so the
936            // engine recomputes its own kvar bit for bit.
937            let reactive = match extras.remove("pf").and_then(|v| v.as_f64()) {
938                Some(pf) => format!("pf={}", num(pf)),
939                None => format!("kvar={}", num(kvar)),
940            };
941            let mut s = format!(
942                "New Load.{} bus1={} phases={phases} conn={conn} kv={} kw={} {reactive}",
943                l.name,
944                self.bus_ref(&l.bus, &l.terminal_map),
945                num(kv),
946                num(kw),
947            );
948            match &l.voltage_model {
949                DistLoadVoltageModel::ConstantPower { .. } => {
950                    if let Some(model) = retained_model {
951                        extras.insert("model".into(), model);
952                    }
953                }
954                DistLoadVoltageModel::ConstantImpedance { .. } => {
955                    s.push_str(" model=2");
956                }
957                DistLoadVoltageModel::ConstantCurrent { .. } => {
958                    s.push_str(" model=5");
959                }
960                DistLoadVoltageModel::Zip {
961                    alpha_z,
962                    alpha_i,
963                    alpha_p,
964                    beta_z,
965                    beta_i,
966                    beta_p,
967                    ..
968                } => {
969                    s.push_str(" model=8");
970                    if let (Some(az), Some(ai), Some(ap), Some(bz), Some(bi), Some(bp)) = (
971                        alpha_z.first(),
972                        alpha_i.first(),
973                        alpha_p.first(),
974                        beta_z.first(),
975                        beta_i.first(),
976                        beta_p.first(),
977                    ) {
978                        let cutoff = zipv_cutoff(retained_zipv.as_ref()).unwrap_or(0.0);
979                        let _ = write!(
980                            s,
981                            " zipv=({}, {}, {}, {}, {}, {}, {})",
982                            num(*az),
983                            num(*ai),
984                            num(*ap),
985                            num(*bz),
986                            num(*bi),
987                            num(*bp),
988                            num(cutoff)
989                        );
990                    }
991                }
992                DistLoadVoltageModel::Exponential { .. } => {
993                    self.warn(format!(
994                        "load {}: exponential voltage model has no OpenDSS load model code; emitted constant power",
995                        l.name
996                    ));
997                }
998            }
999            s.push_str(&self.extras_tail("load", &l.name, &extras));
1000            self.line_out(&s);
1001        }
1002        self.out.push('\n');
1003    }
1004
1005    /// `kv` for a load or capacitor: the recorded value when the source
1006    /// carried one, otherwise the propagated bus estimate.
1007    fn element_kv(&mut self, extras: &Extras, ctx: ElementKv<'_>) -> f64 {
1008        if let Some(v) = extras.get("kv") {
1009            match v
1010                .as_f64()
1011                .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
1012            {
1013                Some(kv) => return kv,
1014                None => self.warn(format!(
1015                    "{} {}: kv extra `{v}` does not parse as a number; \
1016                     using the bus voltage estimate",
1017                    ctx.class, ctx.name
1018                )),
1019            }
1020        }
1021        if let Some(kv) = ctx.typed_kv {
1022            return kv;
1023        }
1024        if let Some(vln) = self.kv_estimate.get(&ctx.bus.to_ascii_lowercase()).copied() {
1025            // OpenDSS convention: line to line for 2 and 3 phase, line to
1026            // neutral for single phase.
1027            let v = if ctx.phases >= 2 || ctx.configuration == Configuration::Delta {
1028                vln * 3f64.sqrt()
1029            } else {
1030                vln
1031            };
1032            v / 1e3
1033        } else {
1034            self.warn(format!(
1035                "{} {}: no kv in the source and no bus voltage estimate; \
1036                 emitted 12.47",
1037                ctx.class, ctx.name
1038            ));
1039            12.47
1040        }
1041    }
1042
1043    fn load_nominal_kv(
1044        &mut self,
1045        model: &DistLoadVoltageModel,
1046        phases: usize,
1047        configuration: Configuration,
1048        name: &str,
1049    ) -> Option<f64> {
1050        let v_nom = model.v_nom();
1051        let v_phase = v_nom
1052            .first()
1053            .copied()
1054            .filter(|v| v.is_finite() && *v > 0.0)?;
1055        if v_nom
1056            .iter()
1057            .any(|v| (*v - v_phase).abs() > 1e-9 * v.abs().max(v_phase.abs()).max(1.0))
1058        {
1059            self.warn(format!(
1060                "load {name}: nonuniform nominal voltage array has no OpenDSS scalar kv; emitted the first value"
1061            ));
1062        }
1063        let v = if phases >= 2 && configuration == Configuration::Wye {
1064            v_phase * 3f64.sqrt()
1065        } else {
1066            v_phase
1067        };
1068        Some(v / 1e3)
1069    }
1070
1071    /// Emitted `conn=`: delta for typed delta, for a stashed DSS delta token,
1072    /// and for a single phase two terminal map that does not include a grounded
1073    /// return conductor.
1074    fn element_conn(
1075        &self,
1076        extras: &Extras,
1077        configuration: Configuration,
1078        bus: &str,
1079        terminal_map: &[String],
1080    ) -> &'static str {
1081        let stash_delta = extras
1082            .get("conn")
1083            .and_then(|v| v.as_str())
1084            .is_some_and(|t| {
1085                t.to_ascii_lowercase().starts_with('d') || t.eq_ignore_ascii_case("ll")
1086            });
1087        let has_grounded_return = self
1088            .grounded
1089            .get(&bus.to_ascii_lowercase())
1090            .is_some_and(|g| terminal_map.iter().any(|t| g.contains(t)));
1091        match configuration {
1092            Configuration::Delta => "delta",
1093            Configuration::SinglePhase
1094                if stash_delta || (terminal_map.len() == 2 && !has_grounded_return) =>
1095            {
1096                "delta"
1097            }
1098            _ => "wye",
1099        }
1100    }
1101
1102    fn write_impedance_shunt(&mut self, sh: &crate::model::DistShunt, phases: usize) {
1103        self.check_name("reactor", &sh.name);
1104        let Some((conductance, susceptance)) = first_diag_admittance(&sh.g, &sh.b, phases) else {
1105            self.warn(format!(
1106                "shunt {}: conductance matrix has no diagonal admittance; dropped from the output",
1107                sh.name
1108            ));
1109            return;
1110        };
1111        if has_off_diagonal(&sh.g) || has_off_diagonal(&sh.b) {
1112            self.warn(format!(
1113                "shunt {}: off diagonal admittance has no scalar reactor expression; \
1114                 only the first diagonal admittance is regenerated",
1115                sh.name
1116            ));
1117        }
1118        if !uniform_diag_admittance(&sh.g, &sh.b, phases, conductance, susceptance) {
1119            self.warn(format!(
1120                "shunt {}: diagonal admittances differ; only the first diagonal \
1121                 admittance is regenerated",
1122                sh.name
1123            ));
1124        }
1125        let denom = conductance * conductance + susceptance * susceptance;
1126        if !denom.is_finite() || denom <= 0.0 {
1127            self.warn(format!(
1128                "shunt {}: invalid grounding admittance; dropped from the output",
1129                sh.name
1130            ));
1131            return;
1132        }
1133        let resistance = conductance / denom;
1134        let reactance = -susceptance / denom;
1135        let mut extras = sh.extras.clone();
1136        strip_shunt_extras(&mut extras);
1137        let ground = vec!["0".to_string(); phases.max(1)];
1138        let mut line = format!(
1139            "New Reactor.{} bus1={} bus2={} phases={} r={} x={}",
1140            sh.name,
1141            self.bus_ref(&sh.bus, &sh.terminal_map),
1142            self.bus_ref(&sh.bus, &ground),
1143            phases.max(1),
1144            num(resistance),
1145            num(reactance),
1146        );
1147        line.push_str(&self.extras_tail("reactor", &sh.name, &extras));
1148        self.line_out(&line);
1149    }
1150
1151    fn shunt_phases(
1152        &mut self,
1153        sh: &crate::model::DistShunt,
1154        conn_delta: bool,
1155        inferred_phases: usize,
1156    ) -> usize {
1157        if let Some(p) = extras_usize(&sh.extras, "phases") {
1158            p.max(1)
1159        } else if conn_delta {
1160            self.element_phases(
1161                &sh.extras,
1162                &sh.terminal_map,
1163                Configuration::Delta,
1164                "shunt",
1165                &sh.name,
1166            )
1167        } else {
1168            inferred_phases
1169        }
1170    }
1171
1172    fn write_kvar_shunt(&mut self, sh: &crate::model::DistShunt, phases: usize, conn_delta: bool) {
1173        // Scan every diagonal conductor, not just the first `phases` of them: a
1174        // delta bank's conductor count exceeds its stashed `phases`, and a
1175        // sign-flipped diagonal past that bound must still set the class.
1176        let (b_max, b_min) = (0..sh.b.len())
1177            .map(|idx| diag_at(&sh.b, idx))
1178            .fold((0.0_f64, 0.0_f64), |(mx, mn), v| (mx.max(v), mn.min(v)));
1179        let (class, b_phase) = if b_max > 0.0 {
1180            ("capacitor", b_max)
1181        } else if b_min < 0.0 {
1182            ("reactor", b_min)
1183        } else {
1184            self.warn(format!(
1185                "shunt {}: no nonzero susceptance; dropped from the output",
1186                sh.name
1187            ));
1188            return;
1189        };
1190        if b_max > 0.0 && b_min < 0.0 {
1191            self.warn(format!(
1192                "shunt {}: diagonal mixes capacitive and inductive phases; only the \
1193                 {class} phases are regenerated",
1194                sh.name
1195            ));
1196        }
1197        self.check_name(class, &sh.name);
1198        let off_diag = has_off_diagonal(&sh.b);
1199        if off_diag && !conn_delta {
1200            self.warn(format!(
1201                "shunt {}: off diagonal susceptance has no {class} expression; \
1202                 only the diagonal is regenerated",
1203                sh.name
1204            ));
1205        }
1206        let edges = if conn_delta {
1207            delta_edges(sh.terminal_map.len(), phases)
1208        } else {
1209            Vec::new()
1210        };
1211        if conn_delta && edges.is_empty() {
1212            self.warn(format!(
1213                "shunt {}: delta terminal map has no branch expression; dropped from the output",
1214                sh.name
1215            ));
1216            return;
1217        }
1218        if conn_delta && delta_branch_susceptance(&sh.b, &edges, sh.terminal_map.len()).is_none() {
1219            self.warn(format!(
1220                "shunt {}: delta susceptance matrix has no scalar {class} expression; \
1221                 only the average branch susceptance is regenerated",
1222                sh.name
1223            ));
1224        }
1225        let configuration = if conn_delta {
1226            Configuration::Delta
1227        } else {
1228            Configuration::Wye
1229        };
1230        let kv = self.element_kv(
1231            &sh.extras,
1232            ElementKv {
1233                bus: &sh.bus,
1234                phases,
1235                configuration,
1236                name: &sh.name,
1237                class,
1238                typed_kv: None,
1239            },
1240        );
1241        let kvar = extras_f64(&sh.extras, "kvar")
1242            .unwrap_or_else(|| shunt_kvar(sh, phases, conn_delta, &edges, b_phase, kv));
1243        let mut extras = sh.extras.clone();
1244        strip_shunt_extras(&mut extras);
1245        let conn = if conn_delta { "delta" } else { "wye" };
1246        let decl = if class == "reactor" {
1247            "Reactor"
1248        } else {
1249            "Capacitor"
1250        };
1251        let mut line = format!(
1252            "New {decl}.{} bus1={} phases={phases} conn={conn} kv={} kvar={}",
1253            sh.name,
1254            self.bus_ref(&sh.bus, &sh.terminal_map),
1255            num(kv),
1256            num(kvar),
1257        );
1258        line.push_str(&self.extras_tail(class, &sh.name, &extras));
1259        self.line_out(&line);
1260    }
1261
1262    fn shunts(&mut self, net: &DistNetwork) {
1263        for sh in &net.shunts {
1264            let stashed_delta = shunt_stashed_delta(sh);
1265            let inferred_phases =
1266                extras_usize(&sh.extras, "phases").unwrap_or_else(|| sh.terminal_map.len().max(1));
1267            let conn_delta = stashed_delta
1268                || looks_like_delta_shunt(&sh.b, sh.terminal_map.len(), inferred_phases);
1269            let phases = self.shunt_phases(sh, conn_delta, inferred_phases);
1270            if has_nonzero(&sh.g) {
1271                self.write_impedance_shunt(sh, phases);
1272            } else {
1273                self.write_kvar_shunt(sh, phases, conn_delta);
1274            }
1275        }
1276        self.out.push('\n');
1277    }
1278
1279    fn generators(&mut self, net: &DistNetwork) {
1280        for g in &net.generators {
1281            self.check_name("generator", &g.name);
1282            let phases = self.element_phases(
1283                &g.extras,
1284                &g.terminal_map,
1285                g.configuration,
1286                "generator",
1287                &g.name,
1288            );
1289            let conn = self.element_conn(&g.extras, g.configuration, &g.bus, &g.terminal_map);
1290            let nconds = if conn == "delta" && phases == 3 {
1291                phases
1292            } else {
1293                phases + 1
1294            };
1295            self.warn_short_map("generator", &g.name, g.terminal_map.len(), nconds);
1296            let kw: f64 = g.p_nom.iter().sum::<f64>() / 1e3;
1297            let kvar: f64 = g.q_nom.iter().sum::<f64>() / 1e3;
1298            let kv = self.element_kv(
1299                &g.extras,
1300                ElementKv {
1301                    bus: &g.bus,
1302                    phases,
1303                    configuration: g.configuration,
1304                    name: &g.name,
1305                    class: "generator",
1306                    typed_kv: None,
1307                },
1308            );
1309            let mut s = format!(
1310                "New Generator.{} bus1={} phases={phases} conn={conn} kv={} kw={} kvar={}",
1311                g.name,
1312                self.bus_ref(&g.bus, &g.terminal_map),
1313                num(kv),
1314                num(kw),
1315                num(kvar),
1316            );
1317            if let Some(q) = &g.q_max {
1318                let _ = write!(s, " maxkvar={}", num(q.iter().sum::<f64>() / 1e3));
1319            }
1320            if let Some(q) = &g.q_min {
1321                let _ = write!(s, " minkvar={}", num(q.iter().sum::<f64>() / 1e3));
1322            }
1323            if g.cost.is_some() {
1324                self.warn(format!(
1325                    "generator {}: generation cost has no dss field; dropped",
1326                    g.name
1327                ));
1328            }
1329            let mut extras = g.extras.clone();
1330            extras.remove("kv");
1331            extras.remove("phases");
1332            extras.remove("conn");
1333            s.push_str(&self.extras_tail("generator", &g.name, &extras));
1334            self.line_out(&s);
1335        }
1336    }
1337}
1338
1339/// Drop the shunt keys the writer regenerates from the typed model so a stale
1340/// copy is not re-emitted in the extras tail.
1341fn strip_shunt_extras(extras: &mut Extras) {
1342    for key in ["kv", "kvar", "phases", "conn"] {
1343        extras.remove(key);
1344    }
1345}
1346
1347fn has_nonzero(m: &Mat) -> bool {
1348    m.iter().flatten().any(|&v| v != 0.0)
1349}
1350
1351fn has_off_diagonal(m: &Mat) -> bool {
1352    m.iter()
1353        .enumerate()
1354        .any(|(i, row)| row.iter().enumerate().any(|(j, &v)| i != j && v != 0.0))
1355}
1356
1357fn diag_at(m: &Mat, i: usize) -> f64 {
1358    m.get(i).and_then(|row| row.get(i)).copied().unwrap_or(0.0)
1359}
1360
1361fn matrix_scale(m: &Mat) -> f64 {
1362    m.iter().flatten().fold(0.0_f64, |acc, &v| acc.max(v.abs()))
1363}
1364
1365fn close(a: f64, b: f64, scale: f64) -> bool {
1366    (a - b).abs() <= 1e-12_f64.max(scale * 1e-9)
1367}
1368
1369fn first_diag_admittance(g: &Mat, b: &Mat, phases: usize) -> Option<(f64, f64)> {
1370    (0..phases.max(1)).find_map(|i| {
1371        let gi = diag_at(g, i);
1372        let bi = diag_at(b, i);
1373        (gi != 0.0 || bi != 0.0).then_some((gi, bi))
1374    })
1375}
1376
1377fn uniform_diag_admittance(g: &Mat, b: &Mat, phases: usize, g0: f64, b0: f64) -> bool {
1378    let scale = matrix_scale(g)
1379        .max(matrix_scale(b))
1380        .max(g0.abs())
1381        .max(b0.abs());
1382    (0..phases.max(1)).all(|i| close(diag_at(g, i), g0, scale) && close(diag_at(b, i), b0, scale))
1383}
1384
1385fn shunt_stashed_delta(sh: &crate::model::DistShunt) -> bool {
1386    sh.extras
1387        .get("conn")
1388        .and_then(|v| v.as_str())
1389        .is_some_and(|t| t.to_ascii_lowercase().starts_with('d') || t.eq_ignore_ascii_case("ll"))
1390}
1391
1392fn mat_at(m: &Mat, i: usize, j: usize) -> f64 {
1393    m.get(i).and_then(|row| row.get(j)).copied().unwrap_or(0.0)
1394}
1395
1396fn looks_like_delta_shunt(b: &Mat, terminals: usize, phases: usize) -> bool {
1397    if terminals < 2 || !has_off_diagonal(b) {
1398        return false;
1399    }
1400    let edges = delta_edges(terminals, phases);
1401    delta_branch_susceptance(b, &edges, terminals).is_some()
1402}
1403
1404fn delta_branch_abs(b: &Mat, edges: &[(usize, usize)]) -> Option<f64> {
1405    if edges.is_empty() {
1406        return None;
1407    }
1408    // Average over every edge (a missing entry contributes 0), so the divisor
1409    // matches the `edges.len()` that `shunt_kvar` multiplies back in; counting
1410    // only present entries would over-scale the regenerated kvar on a ragged
1411    // matrix.
1412    let total: f64 = edges
1413        .iter()
1414        .map(|&(i, j)| {
1415            b.get(i)
1416                .and_then(|row| row.get(j))
1417                .copied()
1418                .unwrap_or(0.0)
1419                .abs()
1420        })
1421        .sum();
1422    Some(total / edges.len() as f64)
1423}
1424
1425fn delta_branch_susceptance(b: &Mat, edges: &[(usize, usize)], terminals: usize) -> Option<f64> {
1426    if terminals < 2 || edges.is_empty() {
1427        return None;
1428    }
1429    let scale = matrix_scale(b);
1430    if scale == 0.0 {
1431        return None;
1432    }
1433    let first = edges[0];
1434    let branch = -mat_at(b, first.0, first.1);
1435    if branch == 0.0 {
1436        return None;
1437    }
1438    let scale = scale.max(branch.abs());
1439    for (i, row) in b.iter().enumerate() {
1440        for (j, &value) in row.iter().enumerate() {
1441            if (i >= terminals || j >= terminals) && !close(value, 0.0, scale) {
1442                return None;
1443            }
1444        }
1445    }
1446    for i in 0..terminals {
1447        let incident = edges
1448            .iter()
1449            .filter(|&&(from, to)| from == i || to == i)
1450            .count() as f64;
1451        for j in 0..terminals {
1452            let linked = edges
1453                .iter()
1454                .any(|&(from, to)| (from == i && to == j) || (from == j && to == i));
1455            let expected = if i == j {
1456                incident * branch
1457            } else if linked {
1458                -branch
1459            } else {
1460                0.0
1461            };
1462            if !close(mat_at(b, i, j), expected, scale) {
1463                return None;
1464            }
1465        }
1466    }
1467    Some(branch)
1468}
1469
1470fn shunt_kvar(
1471    sh: &crate::model::DistShunt,
1472    phases: usize,
1473    conn_delta: bool,
1474    edges: &[(usize, usize)],
1475    b_phase: f64,
1476    kv: f64,
1477) -> f64 {
1478    if conn_delta {
1479        let b_branch = delta_branch_abs(&sh.b, edges).unwrap_or(b_phase.abs());
1480        b_branch * (kv * 1e3) * (kv * 1e3) * edges.len() as f64 / 1e3
1481    } else {
1482        let v_phase = if matches!(phases, 2 | 3) {
1483            kv * 1e3 / 3f64.sqrt()
1484        } else {
1485            kv * 1e3
1486        };
1487        b_phase.abs() * v_phase * v_phase * phases as f64 / 1e3
1488    }
1489}
1490
1491#[cfg(test)]
1492mod tests {
1493    use super::super::read::parse_dss_str;
1494    use super::*;
1495    use crate::model::{
1496        DistGenerator, DistLine, DistLineCode, DistLoad, DistShunt, DistSwitch, DistTransformer,
1497        VoltageSource, Winding,
1498    };
1499
1500    fn strings(v: &[&str]) -> Vec<String> {
1501        v.iter().map(ToString::to_string).collect()
1502    }
1503
1504    fn bus(id: &str, terminals: &[&str], grounded: &[&str]) -> DistBus {
1505        DistBus {
1506            id: id.into(),
1507            terminals: strings(terminals),
1508            grounded: strings(grounded),
1509            ..DistBus::default()
1510        }
1511    }
1512
1513    fn three_phase_source(vln: f64) -> (DistBus, VoltageSource) {
1514        let third = 2.0 * std::f64::consts::FRAC_PI_3;
1515        (
1516            bus("sb", &["1", "2", "3", "4"], &["4"]),
1517            VoltageSource {
1518                name: "source".into(),
1519                bus: "sb".into(),
1520                terminal_map: strings(&["1", "2", "3", "4"]),
1521                v_magnitude: vec![vln, vln, vln, 0.0],
1522                v_angle: vec![0.0, -third, third, 0.0],
1523                extras: Extras::new(),
1524            },
1525        )
1526    }
1527
1528    fn load_on(bus: &str, map: &[&str], configuration: Configuration) -> DistLoad {
1529        let phases = map.len();
1530        DistLoad {
1531            name: "ld".into(),
1532            bus: bus.into(),
1533            terminal_map: strings(map),
1534            configuration,
1535            p_nom: vec![1e3; phases],
1536            q_nom: vec![0.0; phases],
1537            voltage_model: DistLoadVoltageModel::ConstantPower { v_nom: Vec::new() },
1538            extras: Extras::from([("kv".to_string(), serde_json::json!("0.4"))]),
1539        }
1540    }
1541
1542    fn roundtrip(net: &DistNetwork) -> (String, String) {
1543        let first = write_dss(net);
1544        let second = write_dss(&parse_dss_str(&first.text));
1545        (first.text, second.text)
1546    }
1547
1548    #[test]
1549    fn voltage_bases_survive_the_sqrt_round_trip() {
1550        // basekv = vln*sqrt(3)/1e3 then vln' = basekv*1e3/sqrt(3) is not a
1551        // float fixed point for this PMD shaped value; the second write must
1552        // reuse the stashed basekv instead of re-deriving the entry.
1553        let vln = 9_336.235_056_420_312_f64;
1554        let basekv = vln * 3f64.sqrt() / 1e3;
1555        assert!(
1556            (basekv * 1e3 / 3f64.sqrt()).to_bits() != vln.to_bits(),
1557            "test value no longer reproduces the drift"
1558        );
1559        let (b, vs) = three_phase_source(vln);
1560        let net = DistNetwork {
1561            name: Some("t".into()),
1562            base_frequency: 60.0,
1563            buses: vec![b],
1564            sources: vec![vs],
1565            ..DistNetwork::default()
1566        };
1567        let (first, second) = roundtrip(&net);
1568        assert!(first.contains("Set VoltageBases="), "{first}");
1569        assert_eq!(first, second);
1570    }
1571
1572    #[test]
1573    fn load_phases_prefer_the_reader_stash() {
1574        let (b, vs) = three_phase_source(2400.0);
1575        let mut load = load_on("sb", &["1", "2", "3"], Configuration::Delta);
1576        load.extras.insert("phases".into(), serde_json::json!("2"));
1577        let net = DistNetwork {
1578            base_frequency: 60.0,
1579            buses: vec![b],
1580            sources: vec![vs],
1581            loads: vec![load],
1582            ..DistNetwork::default()
1583        };
1584        let out = write_dss(&net);
1585        let line = out.text.lines().find(|l| l.contains("Load.ld")).unwrap();
1586        assert!(line.contains("phases=2 conn=delta"), "{line}");
1587        // The stash must not double emit through the extras tail.
1588        assert_eq!(line.matches("phases=").count(), 1, "{line}");
1589        assert!(!out.warnings.iter().any(|w| w.contains("2 or 3 phase")));
1590    }
1591
1592    #[test]
1593    fn ambiguous_delta_keeps_three_phases_loudly() {
1594        let (b, vs) = three_phase_source(2400.0);
1595        let net = DistNetwork {
1596            base_frequency: 60.0,
1597            buses: vec![b],
1598            sources: vec![vs],
1599            loads: vec![load_on("sb", &["1", "2", "3"], Configuration::Delta)],
1600            ..DistNetwork::default()
1601        };
1602        let out = write_dss(&net);
1603        let line = out.text.lines().find(|l| l.contains("Load.ld")).unwrap();
1604        assert!(line.contains("phases=3 conn=delta"), "{line}");
1605        assert!(
1606            out.warnings.iter().any(|w| w.contains("2 or 3 phase")),
1607            "{:?}",
1608            out.warnings
1609        );
1610    }
1611
1612    #[test]
1613    fn single_phase_delta_emits_conn_delta() {
1614        let (b, vs) = three_phase_source(2400.0);
1615        // Two conductor delta typed as Delta: phases=1 conn=delta.
1616        let two_wire = load_on("sb", &["1", "2"], Configuration::Delta);
1617        // The reader types 1 phase delta as SinglePhase; the stashed conn
1618        // token carries the delta.
1619        let mut stashed = load_on("sb", &["1", "2"], Configuration::SinglePhase);
1620        stashed.name = "ld2".into();
1621        stashed
1622            .extras
1623            .insert("conn".into(), serde_json::json!("delta"));
1624        let net = DistNetwork {
1625            base_frequency: 60.0,
1626            buses: vec![b],
1627            sources: vec![vs],
1628            loads: vec![two_wire, stashed],
1629            ..DistNetwork::default()
1630        };
1631        let out = write_dss(&net);
1632        let l1 = out.text.lines().find(|l| l.contains("Load.ld ")).unwrap();
1633        assert!(l1.contains("phases=1 conn=delta"), "{l1}");
1634        let l2 = out.text.lines().find(|l| l.contains("Load.ld2 ")).unwrap();
1635        assert!(l2.contains("phases=1 conn=delta"), "{l2}");
1636        assert_eq!(l2.matches("conn=").count(), 1, "{l2}");
1637    }
1638
1639    #[test]
1640    fn unrepresentable_names_are_reported() {
1641        let (b, vs) = three_phase_source(2400.0);
1642        let mut load = load_on("sb", &["1", "2", "3", "4"], Configuration::Wye);
1643        load.name = "load 1".into();
1644        let net = DistNetwork {
1645            name: Some("my circuit".into()),
1646            base_frequency: 60.0,
1647            buses: vec![b, bus("a=b", &["1"], &[])],
1648            sources: vec![vs],
1649            loads: vec![load],
1650            ..DistNetwork::default()
1651        };
1652        let out = write_dss(&net);
1653        let hits = |needle: &str| {
1654            out.warnings
1655                .iter()
1656                .any(|w| w.contains(needle) && w.contains("cannot represent"))
1657        };
1658        assert!(hits("load 1"), "{:?}", out.warnings);
1659        assert!(hits("my circuit"), "{:?}", out.warnings);
1660        // The bad bus id warns at its bus_ref emission site.
1661        let mut net2 = net.clone();
1662        net2.lines.push(DistLine {
1663            name: "l1".into(),
1664            bus_from: "sb".into(),
1665            bus_to: "a=b".into(),
1666            terminal_map_from: strings(&["1"]),
1667            terminal_map_to: strings(&["1"]),
1668            linecode: "lc".into(),
1669            length: 1.0,
1670            extras: Extras::new(),
1671        });
1672        let out2 = write_dss(&net2);
1673        assert!(
1674            out2.warnings
1675                .iter()
1676                .any(|w| w.contains("a=b") && w.contains("cannot represent")),
1677            "{:?}",
1678            out2.warnings
1679        );
1680    }
1681
1682    #[test]
1683    fn unparseable_kv_extra_warns_instead_of_silently_substituting() {
1684        let (b, vs) = three_phase_source(2400.0);
1685        let mut load = load_on("sb", &["1", "2", "3", "4"], Configuration::Wye);
1686        load.extras.insert("kv".into(), serde_json::json!("@kv"));
1687        let net = DistNetwork {
1688            base_frequency: 60.0,
1689            buses: vec![b],
1690            sources: vec![vs],
1691            loads: vec![load],
1692            ..DistNetwork::default()
1693        };
1694        let out = write_dss(&net);
1695        assert!(
1696            out.warnings
1697                .iter()
1698                .any(|w| w.contains("@kv") && w.contains("does not parse")),
1699            "{:?}",
1700            out.warnings
1701        );
1702        // The estimate substitutes: 2400*sqrt(3)/1e3 line to line.
1703        let line = out.text.lines().find(|l| l.contains("Load.ld")).unwrap();
1704        assert!(
1705            line.contains(&format!("kv={}", num(2400.0 * 3f64.sqrt() / 1e3))),
1706            "{line}"
1707        );
1708    }
1709
1710    #[test]
1711    fn options_reemit_and_commands_warn() {
1712        let src = "Clear\n\
1713                   New Circuit.c1 basekv=12.47 pu=1 angle=0 phases=3 bus1=sb\n\
1714                   Set mode=snapshot\n\
1715                   Set controlmode=OFF\n\
1716                   Disable Line.l1\n\
1717                   Set VoltageBases=[12.47]\n\
1718                   Calcvoltagebases\n\
1719                   Solve\n";
1720        let out = write_dss(&parse_dss_str(src));
1721        assert!(out.text.contains("Set mode=snapshot"), "{}", out.text);
1722        assert!(out.text.contains("Set controlmode=OFF"), "{}", out.text);
1723        // The writer derives these; the stored options must not double them.
1724        assert_eq!(out.text.matches("Set VoltageBases").count(), 1);
1725        assert_eq!(out.text.matches("Calcvoltagebases").count(), 1);
1726        assert_eq!(out.text.matches("DefaultBaseFrequency").count(), 1);
1727        assert!(!out.text.to_lowercase().contains("disable"));
1728        assert!(
1729            out.warnings
1730                .iter()
1731                .any(|w| w.contains("disable Line.l1") && w.contains("not regenerated")),
1732            "{:?}",
1733            out.warnings
1734        );
1735        // Solve and Calcvoltagebases re-derive; no warning claims they drop.
1736        assert!(!out.warnings.iter().any(|w| w.contains("`solve`")));
1737        let again = write_dss(&parse_dss_str(&out.text));
1738        assert_eq!(out.text, again.text);
1739    }
1740
1741    #[test]
1742    fn non_numeric_terminal_positionalizes() {
1743        let mut load = load_on("b1", &["a", "n"], Configuration::Wye);
1744        load.extras.insert("kv".into(), serde_json::json!("0.23"));
1745        let net = DistNetwork {
1746            base_frequency: 60.0,
1747            buses: vec![bus("b1", &["a", "n"], &["n"])],
1748            loads: vec![load],
1749            ..DistNetwork::default()
1750        };
1751        let (first, second) = roundtrip(&net);
1752        let line = first.lines().find(|l| l.contains("Load.ld")).unwrap();
1753        assert!(line.contains("bus1=b1.1.0"), "{line}");
1754        let out = write_dss(&net);
1755        assert!(
1756            out.warnings
1757                .iter()
1758                .any(|w| w.contains("`a`") && w.contains("position")),
1759            "{:?}",
1760            out.warnings
1761        );
1762        assert_eq!(first, second);
1763    }
1764
1765    #[test]
1766    fn half_present_thevenin_pair_stays_and_warns() {
1767        let (b, mut vs) = three_phase_source(2400.0);
1768        vs.extras
1769            .insert("rs".into(), serde_json::json!([[1.0, 0.1], [0.1, 1.0]]));
1770        let net = DistNetwork {
1771            base_frequency: 60.0,
1772            buses: vec![b],
1773            sources: vec![vs],
1774            ..DistNetwork::default()
1775        };
1776        let out = write_dss(&net);
1777        assert!(!out.text.contains("z1="), "{}", out.text);
1778        assert!(
1779            out.warnings.iter().any(|w| w.contains("`xs` is missing")),
1780            "{:?}",
1781            out.warnings
1782        );
1783    }
1784
1785    #[test]
1786    fn unusable_switch_sequence_extras_warn() {
1787        let (b, vs) = three_phase_source(2400.0);
1788        let sw = DistSwitch {
1789            name: "sw1".into(),
1790            bus_from: "sb".into(),
1791            bus_to: "b2".into(),
1792            terminal_map_from: strings(&["1", "2", "3"]),
1793            terminal_map_to: strings(&["1", "2", "3"]),
1794            open: false,
1795            i_max: Some(Vec::new()),
1796            extras: Extras::from([("pmd_rs".to_string(), serde_json::json!("oops"))]),
1797        };
1798        let net = DistNetwork {
1799            base_frequency: 60.0,
1800            buses: vec![b, bus("b2", &["1", "2", "3"], &[])],
1801            sources: vec![vs],
1802            switches: vec![sw],
1803            ..DistNetwork::default()
1804        };
1805        let out = write_dss(&net);
1806        assert!(!out.text.contains("r0="), "{}", out.text);
1807        assert!(
1808            out.warnings
1809                .iter()
1810                .any(|w| w.contains("pmd_rs") && w.contains("not a numeric matrix")),
1811            "{:?}",
1812            out.warnings
1813        );
1814        assert!(
1815            out.warnings.iter().any(|w| w.contains("i_max is empty")),
1816            "{:?}",
1817            out.warnings
1818        );
1819    }
1820
1821    #[test]
1822    fn degenerate_shapes_warn_instead_of_panicking() {
1823        let (b, vs) = three_phase_source(2400.0);
1824        let lc = DistLineCode {
1825            name: "lc1".into(),
1826            n_conductors: 2,
1827            r_series: vec![vec![1.0], vec![0.5]], // second row short
1828            x_series: vec![vec![1.0, 0.0], vec![0.0, 1.0]],
1829            g_from: vec![vec![0.0; 2]; 2],
1830            b_from: vec![vec![0.0; 2]; 2],
1831            g_to: vec![vec![0.0; 2]; 2],
1832            b_to: vec![vec![0.0; 2]; 2],
1833            i_max: Some(Vec::new()),
1834            s_max: None,
1835            extras: Extras::new(),
1836        };
1837        let t = DistTransformer {
1838            name: "t1".into(),
1839            windings: vec![
1840                Winding {
1841                    bus: "sb".into(),
1842                    terminal_map: strings(&["1", "2"]),
1843                    conn: WindingConn::Wye,
1844                    v_ref: 2400.0,
1845                    s_rating: 25e3,
1846                    r_pct: 0.5,
1847                    tap: 1.0,
1848                },
1849                Winding {
1850                    bus: "b2".into(),
1851                    terminal_map: strings(&["1", "2"]),
1852                    conn: WindingConn::Wye,
1853                    v_ref: 240.0,
1854                    s_rating: 25e3,
1855                    r_pct: 0.5,
1856                    tap: 1.0,
1857                },
1858            ],
1859            xsc_pct: Vec::new(),
1860            phases: 1,
1861            extras: Extras::new(),
1862        };
1863        let net = DistNetwork {
1864            base_frequency: 60.0,
1865            buses: vec![b, bus("b2", &["1", "2"], &[])],
1866            sources: vec![vs],
1867            linecodes: vec![lc],
1868            transformers: vec![t],
1869            ..DistNetwork::default()
1870        };
1871        let out = write_dss(&net); // must not panic
1872        assert!(out.text.contains("rmatrix=(1 | 0.5 0)"), "{}", out.text);
1873        assert!(out.text.contains("xhl=0"), "{}", out.text);
1874        let has = |needle: &str| out.warnings.iter().any(|w| w.contains(needle));
1875        assert!(has("shorter than the lower triangle"), "{:?}", out.warnings);
1876        assert!(has("xsc_pct is empty"), "{:?}", out.warnings);
1877        assert!(has("i_max is empty"), "{:?}", out.warnings);
1878    }
1879
1880    #[test]
1881    fn two_phase_capacitor_kvar_uses_line_to_line_kv() {
1882        // The reader treats wye capacitor kv as line to line for 2 and 3
1883        // phase; the kvar fallback must invert with the same convention.
1884        let (b, vs) = three_phase_source(2400.0);
1885        let b_phase = 1e-3;
1886        let sh = DistShunt {
1887            name: "c1".into(),
1888            bus: "sb".into(),
1889            terminal_map: strings(&["1", "2"]),
1890            g: vec![vec![0.0; 2]; 2],
1891            b: vec![vec![b_phase, 0.0], vec![0.0, b_phase]],
1892            extras: Extras::new(),
1893        };
1894        let net = DistNetwork {
1895            base_frequency: 60.0,
1896            buses: vec![b],
1897            sources: vec![vs],
1898            shunts: vec![sh],
1899            ..DistNetwork::default()
1900        };
1901        let out = write_dss(&net);
1902        let kv = 2400.0 * 3f64.sqrt() / 1e3;
1903        let v_phase = kv * 1e3 / 3f64.sqrt();
1904        let expected = b_phase * v_phase * v_phase * 2.0 / 1e3;
1905        let line = out
1906            .text
1907            .lines()
1908            .find(|l| l.contains("Capacitor.c1"))
1909            .unwrap();
1910        assert!(line.contains(&format!("kvar={}", num(expected))), "{line}");
1911    }
1912
1913    #[test]
1914    fn inductive_shunt_regenerates_as_a_reactor() {
1915        // A negative diagonal susceptance is the grounding-reactor sign; it
1916        // must emit `New Reactor`, not a capacitor, with the positive kvar
1917        // rating recovered from |b| v^2.
1918        let (b, vs) = three_phase_source(2400.0);
1919        let b_phase = -1e-3;
1920        let sh = DistShunt {
1921            name: "rx".into(),
1922            bus: "sb".into(),
1923            terminal_map: strings(&["1", "2", "3"]),
1924            g: vec![vec![0.0; 3]; 3],
1925            b: vec![
1926                vec![b_phase, 0.0, 0.0],
1927                vec![0.0, b_phase, 0.0],
1928                vec![0.0, 0.0, b_phase],
1929            ],
1930            extras: Extras::new(),
1931        };
1932        let net = DistNetwork {
1933            base_frequency: 60.0,
1934            buses: vec![b],
1935            sources: vec![vs],
1936            shunts: vec![sh],
1937            ..DistNetwork::default()
1938        };
1939        let out = write_dss(&net);
1940        let line = out
1941            .text
1942            .lines()
1943            .find(|l| l.contains("Reactor.rx"))
1944            .unwrap_or_else(|| panic!("no reactor emitted in:\n{}", out.text));
1945        assert!(!out.text.contains("Capacitor.rx"), "{}", out.text);
1946        let kv = 2400.0 * 3f64.sqrt() / 1e3;
1947        let v_phase = kv * 1e3 / 3f64.sqrt();
1948        let expected = b_phase.abs() * v_phase * v_phase * 3.0 / 1e3;
1949        assert!(line.contains(&format!("kvar={}", num(expected))), "{line}");
1950    }
1951
1952    #[test]
1953    fn conductive_shunt_regenerates_as_grounding_reactor() {
1954        let (_, vs) = three_phase_source(2400.0);
1955        let b = bus("sb", &["1", "2", "3", "4"], &[]);
1956        let sh = DistShunt {
1957            name: "gnd".into(),
1958            bus: "sb".into(),
1959            terminal_map: strings(&["4"]),
1960            g: vec![vec![1.0 / 0.3]],
1961            b: vec![vec![0.0]],
1962            extras: Extras::new(),
1963        };
1964        let net = DistNetwork {
1965            base_frequency: 60.0,
1966            buses: vec![b],
1967            sources: vec![vs],
1968            shunts: vec![sh],
1969            ..DistNetwork::default()
1970        };
1971        let out = write_dss(&net);
1972        let line = out
1973            .text
1974            .lines()
1975            .find(|l| l.contains("Reactor.gnd"))
1976            .unwrap_or_else(|| panic!("no reactor emitted in:\n{}", out.text));
1977        assert!(line.contains("bus1=sb.4"), "{line}");
1978        assert!(line.contains("bus2=sb.0"), "{line}");
1979        assert!(line.contains("phases=1"), "{line}");
1980        assert!(line.contains("r=0.3"), "{line}");
1981        assert!(line.contains("x=0"), "{line}");
1982        assert!(
1983            !line.contains("x=-0"),
1984            "negative zero must canonicalize: {line}"
1985        );
1986    }
1987
1988    #[test]
1989    fn delta_shunt_regenerates_conn_delta() {
1990        let (b, vs) = three_phase_source(2400.0);
1991        let b_branch = 2e-4;
1992        let bmat = vec![
1993            vec![2.0 * b_branch, -b_branch, -b_branch],
1994            vec![-b_branch, 2.0 * b_branch, -b_branch],
1995            vec![-b_branch, -b_branch, 2.0 * b_branch],
1996        ];
1997        let mut extras = Extras::new();
1998        extras.insert("conn".into(), serde_json::json!("delta"));
1999        extras.insert("phases".into(), serde_json::json!("3"));
2000        let sh = DistShunt {
2001            name: "capd".into(),
2002            bus: "sb".into(),
2003            terminal_map: strings(&["1", "2", "3"]),
2004            g: vec![vec![0.0; 3]; 3],
2005            b: bmat,
2006            extras,
2007        };
2008        let net = DistNetwork {
2009            base_frequency: 60.0,
2010            buses: vec![b],
2011            sources: vec![vs],
2012            shunts: vec![sh],
2013            ..DistNetwork::default()
2014        };
2015        let out = write_dss(&net);
2016        let line = out
2017            .text
2018            .lines()
2019            .find(|l| l.contains("Capacitor.capd"))
2020            .unwrap_or_else(|| panic!("no capacitor emitted in:\n{}", out.text));
2021        assert!(line.contains("phases=3 conn=delta"), "{line}");
2022        assert!(
2023            !out.warnings.iter().any(|w| w.contains("off diagonal")),
2024            "{:?}",
2025            out.warnings
2026        );
2027    }
2028
2029    #[test]
2030    fn non_scalar_delta_matrix_is_not_inferred_silently() {
2031        let (b, vs) = three_phase_source(2400.0);
2032        let bmat = vec![
2033            vec![0.003, -0.001, -0.002],
2034            vec![-0.001, 0.003, -0.002],
2035            vec![-0.002, -0.002, 0.004],
2036        ];
2037        let sh = DistShunt {
2038            name: "capx".into(),
2039            bus: "sb".into(),
2040            terminal_map: strings(&["1", "2", "3"]),
2041            g: vec![vec![0.0; 3]; 3],
2042            b: bmat,
2043            extras: Extras::new(),
2044        };
2045        let net = DistNetwork {
2046            base_frequency: 60.0,
2047            buses: vec![b],
2048            sources: vec![vs],
2049            shunts: vec![sh],
2050            ..DistNetwork::default()
2051        };
2052        let out = write_dss(&net);
2053        let line = out
2054            .text
2055            .lines()
2056            .find(|l| l.contains("Capacitor.capx"))
2057            .unwrap_or_else(|| panic!("no capacitor emitted in:\n{}", out.text));
2058        assert!(line.contains("conn=wye"), "{line}");
2059        assert!(
2060            out.warnings.iter().any(|w| w.contains("off diagonal")),
2061            "{:?}",
2062            out.warnings
2063        );
2064    }
2065
2066    #[test]
2067    fn stashed_delta_matrix_warns_when_scalar_emission_is_lossy() {
2068        let (b, vs) = three_phase_source(2400.0);
2069        let bmat = vec![
2070            vec![0.003, -0.001, -0.002],
2071            vec![-0.001, 0.003, -0.002],
2072            vec![-0.002, -0.002, 0.004],
2073        ];
2074        let mut extras = Extras::new();
2075        extras.insert("conn".into(), serde_json::json!("delta"));
2076        extras.insert("phases".into(), serde_json::json!("3"));
2077        let sh = DistShunt {
2078            name: "capx".into(),
2079            bus: "sb".into(),
2080            terminal_map: strings(&["1", "2", "3"]),
2081            g: vec![vec![0.0; 3]; 3],
2082            b: bmat,
2083            extras,
2084        };
2085        let net = DistNetwork {
2086            base_frequency: 60.0,
2087            buses: vec![b],
2088            sources: vec![vs],
2089            shunts: vec![sh],
2090            ..DistNetwork::default()
2091        };
2092        let out = write_dss(&net);
2093        let line = out
2094            .text
2095            .lines()
2096            .find(|l| l.contains("Capacitor.capx"))
2097            .unwrap_or_else(|| panic!("no capacitor emitted in:\n{}", out.text));
2098        assert!(line.contains("conn=delta"), "{line}");
2099        assert!(
2100            out.warnings
2101                .iter()
2102                .any(|w| w.contains("no scalar capacitor expression")),
2103            "{:?}",
2104            out.warnings
2105        );
2106    }
2107
2108    #[test]
2109    fn option_values_choose_a_wrapper_the_lexer_undoes() {
2110        let src = "Clear\n\
2111                   New Circuit.c1 basekv=12.47 pu=1 angle=0 phases=3 bus1=sb\n\
2112                   Set foo=[a!b]\n\
2113                   Set bar=[(abc]\n\
2114                   Set baz=(x ] y)\n\
2115                   Set qux=[a ) b]\n\
2116                   Solve\n";
2117        let net = parse_dss_str(src);
2118        let first = write_dss(&net);
2119        for line in [
2120            "Set foo=(a!b)",
2121            "Set bar=((abc)",
2122            "Set baz=(x ] y)",
2123            "Set qux=[a ) b]",
2124        ] {
2125            assert!(
2126                first.text.contains(line),
2127                "{line} missing in {}",
2128                first.text
2129            );
2130        }
2131        assert!(
2132            !first
2133                .warnings
2134                .iter()
2135                .any(|w| w.contains("emitted as written")),
2136            "{:?}",
2137            first.warnings
2138        );
2139        // The reader strips the wrapper back off...
2140        let reparsed = parse_dss_str(&first.text);
2141        let opt = |k: &str| {
2142            reparsed
2143                .options
2144                .iter()
2145                .find(|(name, _)| name == k)
2146                .map(|(_, v)| v.as_str())
2147        };
2148        assert_eq!(opt("foo"), Some("a!b"));
2149        assert_eq!(opt("bar"), Some("(abc"));
2150        assert_eq!(opt("baz"), Some("x ] y"));
2151        assert_eq!(opt("qux"), Some("a ) b"));
2152        // ...and the second write picks the same wrapper from the bare value.
2153        let second = write_dss(&reparsed);
2154        assert_eq!(first.text, second.text);
2155    }
2156
2157    #[test]
2158    fn extras_tail_values_wrap_like_options() {
2159        let (b, vs) = three_phase_source(2400.0);
2160        let mut load = load_on("sb", &["1", "2", "3", "4"], Configuration::Wye);
2161        load.extras
2162            .insert("daily".into(), serde_json::json!("a ) b"));
2163        let net = DistNetwork {
2164            base_frequency: 60.0,
2165            buses: vec![b],
2166            sources: vec![vs],
2167            loads: vec![load],
2168            ..DistNetwork::default()
2169        };
2170        let (first, second) = roundtrip(&net);
2171        // A paren wrapper would close at the `)` and land `b)` on the next
2172        // positional property (duty); brackets survive.
2173        assert!(first.contains("daily=[a ) b]"), "{first}");
2174        assert_eq!(first, second);
2175        let back = parse_dss_str(&first);
2176        assert_eq!(
2177            back.loads[0]
2178                .extras
2179                .get("daily")
2180                .and_then(serde_json::Value::as_str),
2181            Some("a ) b")
2182        );
2183    }
2184
2185    #[test]
2186    fn unrepresentable_values_emit_as_written_and_warn() {
2187        // Every quote closer appears, and the spaces split a bare scan: no
2188        // emitted form reparses to this value.
2189        let bad = "a )]}\"' b";
2190        let (b, vs) = three_phase_source(2400.0);
2191        let mut load = load_on("sb", &["1", "2", "3", "4"], Configuration::Wye);
2192        load.extras.insert("daily".into(), serde_json::json!(bad));
2193        let mut net = DistNetwork {
2194            base_frequency: 60.0,
2195            buses: vec![b],
2196            sources: vec![vs],
2197            loads: vec![load],
2198            ..DistNetwork::default()
2199        };
2200        net.options.push(("foo".into(), bad.into()));
2201        let out = write_dss(&net);
2202        assert!(out.text.contains(&format!("Set foo={bad}")), "{}", out.text);
2203        assert!(out.text.contains(&format!("daily={bad}")), "{}", out.text);
2204        let warned = |needle: &str| {
2205            out.warnings
2206                .iter()
2207                .any(|w| w.contains(needle) && w.contains("emitted as written"))
2208        };
2209        assert!(warned("option `foo`"), "{:?}", out.warnings);
2210        assert!(warned("`daily`"), "{:?}", out.warnings);
2211    }
2212
2213    #[test]
2214    fn empty_extras_values_wrap_instead_of_eating_the_next_token() {
2215        let dss = "clear\nnew circuit.c basekv=12.47 bus1=sb\n\
2216                   new load.ld bus1=sb.1 phases=1 kv=7.2 kw=10 daily=() duty=sh\nsolve\n";
2217        let net = parse_dss_str(dss);
2218        let load = &net.loads[0];
2219        assert_eq!(load.extras.get("daily").and_then(|v| v.as_str()), Some(""));
2220        let w1 = write_dss(&net).text;
2221        let again = parse_dss_str(&w1);
2222        let load2 = &again.loads[0];
2223        assert_eq!(load2.extras.get("daily").and_then(|v| v.as_str()), Some(""));
2224        assert_eq!(
2225            load2.extras.get("duty").and_then(|v| v.as_str()),
2226            Some("sh")
2227        );
2228        assert_eq!(w1, write_dss(&again).text);
2229    }
2230
2231    #[test]
2232    fn sub_unique_option_prefixes_re_emit_instead_of_vanishing() {
2233        // "ca" is CapkVAR and "default" is DefaultDaily in the engine's
2234        // option table; neither may be skipped as a derived key, and
2235        // `Set default=2.5` must not change the base frequency.
2236        let dss = "clear\nnew circuit.c basekv=12.47 bus1=sb\n\
2237                   Set ca=600\nSet default=2.5\nsolve\n";
2238        let net = parse_dss_str(dss);
2239        assert!((net.base_frequency - 60.0).abs() < 1e-12);
2240        let out = write_dss(&net).text;
2241        assert!(out.contains("Set ca=600"), "{out}");
2242        assert!(out.contains("Set default=2.5"), "{out}");
2243    }
2244
2245    #[test]
2246    fn abbreviated_derived_options_skip_and_set_the_frequency() {
2247        // The engine resolves Set names by unique prefix, so volt= IS
2248        // Voltagebases and defaultb= IS DefaultBaseFrequency.
2249        let src = "Clear\n\
2250                   New Circuit.c1 basekv=12.47 pu=1 angle=0 phases=3 bus1=sb\n\
2251                   Set volt=[115, 132]\n\
2252                   Set defaultb=50\n\
2253                   Solve\n";
2254        let net = parse_dss_str(src);
2255        assert!((net.base_frequency - 50.0).abs() < 1e-12);
2256        let out = write_dss(&net);
2257        assert!(
2258            out.text.contains("Set DefaultBaseFrequency=50"),
2259            "{}",
2260            out.text
2261        );
2262        assert_eq!(
2263            out.text
2264                .to_lowercase()
2265                .matches("defaultbasefrequency")
2266                .count(),
2267            1,
2268            "{}",
2269            out.text
2270        );
2271        assert_eq!(
2272            out.text.matches("Set VoltageBases").count(),
2273            1,
2274            "{}",
2275            out.text
2276        );
2277        assert!(!out.text.contains("Set volt="), "{}", out.text);
2278        assert!(!out.text.contains("Set defaultb="), "{}", out.text);
2279        let second = write_dss(&parse_dss_str(&out.text));
2280        assert_eq!(out.text, second.text);
2281    }
2282
2283    #[test]
2284    fn non_numeric_source_extras_warn_before_falling_back() {
2285        let (b, mut vs) = three_phase_source(2400.0);
2286        vs.extras
2287            .insert("basekv".into(), serde_json::json!("@base"));
2288        vs.extras.insert("pu".into(), serde_json::json!("unity"));
2289        vs.extras.insert("angle".into(), serde_json::json!([0.0]));
2290        let net = DistNetwork {
2291            base_frequency: 60.0,
2292            buses: vec![b],
2293            sources: vec![vs],
2294            ..DistNetwork::default()
2295        };
2296        let out = write_dss(&net);
2297        for key in ["basekv", "pu", "angle"] {
2298            assert!(
2299                out.warnings
2300                    .iter()
2301                    .any(|w| w.contains(&format!("{key} extra")) && w.contains("does not parse")),
2302                "{key}: {:?}",
2303                out.warnings
2304            );
2305        }
2306        // The derived values substitute.
2307        let line = out.text.lines().find(|l| l.contains("Circuit.")).unwrap();
2308        assert!(line.contains("pu=1 angle=0"), "{line}");
2309    }
2310
2311    #[test]
2312    fn de_energized_source_phase_keeps_its_conductor() {
2313        let (b, mut vs) = three_phase_source(2400.0);
2314        vs.v_magnitude[2] = 0.0; // de-energized, but still a phase conductor
2315        let net = DistNetwork {
2316            name: Some("t".into()),
2317            base_frequency: 60.0,
2318            buses: vec![b],
2319            sources: vec![vs],
2320            ..DistNetwork::default()
2321        };
2322        let (first, second) = roundtrip(&net);
2323        let line = first.lines().find(|l| l.contains("Circuit.")).unwrap();
2324        // phases=2 against the 4 node dot list would drop a node on reparse.
2325        assert!(line.contains("phases=3"), "{line}");
2326        assert!(line.contains("bus1=sb.1.2.3.0"), "{line}");
2327        assert_eq!(first, second);
2328        let out = write_dss(&net);
2329        assert!(
2330            out.warnings
2331                .iter()
2332                .any(|w| w.contains("phases=3") && w.contains("positive")),
2333            "{:?}",
2334            out.warnings
2335        );
2336    }
2337
2338    #[test]
2339    fn multiple_sources_keep_named_vsource_when_source_exists() {
2340        let third = 2.0 * std::f64::consts::FRAC_PI_3;
2341        let source = VoltageSource {
2342            name: "source".into(),
2343            bus: "Bx".into(),
2344            terminal_map: strings(&["1", "2", "3", "4"]),
2345            v_magnitude: vec![20_000.0, 20_000.0, 20_000.0, 0.0],
2346            v_angle: vec![0.0, -third, third, 0.0],
2347            extras: Extras::new(),
2348        };
2349        let wind = VoltageSource {
2350            name: "WindGen1".into(),
2351            bus: "Bg".into(),
2352            terminal_map: strings(&["1", "2", "3", "4"]),
2353            v_magnitude: vec![400.0, 400.0, 400.0, 0.0],
2354            v_angle: vec![
2355                -std::f64::consts::FRAC_PI_3,
2356                std::f64::consts::PI,
2357                third / 2.0,
2358                0.0,
2359            ],
2360            extras: Extras::new(),
2361        };
2362        let net = DistNetwork {
2363            name: Some("dg".into()),
2364            base_frequency: 60.0,
2365            buses: vec![
2366                bus("Bg", &["1", "2", "3", "4"], &["4"]),
2367                bus("Bx", &["1", "2", "3", "4"], &["4"]),
2368            ],
2369            sources: vec![wind, source],
2370            ..DistNetwork::default()
2371        };
2372
2373        let out = write_dss(&net).text;
2374        let circuit = out.lines().find(|l| l.starts_with("New Circuit")).unwrap();
2375        assert!(circuit.contains("bus1=Bx.1.2.3.0"), "{circuit}");
2376        assert!(
2377            out.lines()
2378                .any(|l| l.starts_with("New Vsource.WindGen1") && l.contains("bus1=Bg.1.2.3.0")),
2379            "{out}"
2380        );
2381        let reparsed = parse_dss_str(&out);
2382        assert!(
2383            reparsed
2384                .sources
2385                .iter()
2386                .any(|vs| vs.name.eq_ignore_ascii_case("WindGen1")),
2387            "{:?}",
2388            reparsed.sources
2389        );
2390    }
2391
2392    #[test]
2393    fn source_phases_stash_wins_and_does_not_double_emit() {
2394        let (b, mut vs) = three_phase_source(2400.0);
2395        vs.extras.insert("phases".into(), serde_json::json!("3"));
2396        let net = DistNetwork {
2397            base_frequency: 60.0,
2398            buses: vec![b],
2399            sources: vec![vs],
2400            ..DistNetwork::default()
2401        };
2402        let out = write_dss(&net);
2403        let line = out.text.lines().find(|l| l.contains("Circuit.")).unwrap();
2404        assert!(line.contains("phases=3"), "{line}");
2405        assert_eq!(line.matches("phases=").count(), 1, "{line}");
2406    }
2407
2408    #[test]
2409    fn foreign_maps_without_a_neutral_warn_and_converge_at_write2() {
2410        // A vsource/wye load map with no grounded terminal: the engine's
2411        // nconds fill extends the reparsed bus with a grounded neutral, so
2412        // write1 is not a fixed point. The writer must say so.
2413        let third = 2.0 * std::f64::consts::FRAC_PI_3;
2414        let vs = VoltageSource {
2415            name: "source".into(),
2416            bus: "sb".into(),
2417            terminal_map: strings(&["1", "2", "3"]),
2418            v_magnitude: vec![2400.0; 3],
2419            v_angle: vec![0.0, -third, third],
2420            extras: Extras::new(),
2421        };
2422        let load = load_on("sb", &["1"], Configuration::Wye);
2423        let net = DistNetwork {
2424            name: Some("t".into()),
2425            base_frequency: 60.0,
2426            buses: vec![bus("sb", &["1", "2", "3"], &[])],
2427            sources: vec![vs],
2428            loads: vec![load],
2429            ..DistNetwork::default()
2430        };
2431        let first = write_dss(&net);
2432        let hits = |warnings: &[String], name: &str| {
2433            warnings
2434                .iter()
2435                .any(|w| w.contains(name) && w.contains("materializes a grounded neutral"))
2436        };
2437        assert!(
2438            hits(&first.warnings, "vsource source"),
2439            "{:?}",
2440            first.warnings
2441        );
2442        assert!(hits(&first.warnings, "load ld"), "{:?}", first.warnings);
2443        let second = write_dss(&parse_dss_str(&first.text));
2444        assert_ne!(first.text, second.text);
2445        assert!(!hits(&second.warnings, "vsource"), "{:?}", second.warnings);
2446        assert!(!hits(&second.warnings, "load"), "{:?}", second.warnings);
2447        let third_write = write_dss(&parse_dss_str(&second.text));
2448        assert_eq!(second.text, third_write.text);
2449    }
2450
2451    #[test]
2452    fn generator_phases_and_conn_match_the_load_rules() {
2453        let (b, vs) = three_phase_source(2400.0);
2454        let g = DistGenerator {
2455            name: "g1".into(),
2456            bus: "sb".into(),
2457            terminal_map: strings(&["1", "2", "3"]),
2458            configuration: Configuration::Delta,
2459            p_nom: vec![1e3; 3],
2460            q_nom: vec![0.0; 3],
2461            p_min: None,
2462            p_max: None,
2463            q_min: None,
2464            q_max: None,
2465            cost: None,
2466            extras: Extras::from([
2467                ("kv".to_string(), serde_json::json!("4.16")),
2468                ("phases".to_string(), serde_json::json!("2")),
2469            ]),
2470        };
2471        let net = DistNetwork {
2472            base_frequency: 60.0,
2473            buses: vec![b],
2474            sources: vec![vs],
2475            generators: vec![g],
2476            ..DistNetwork::default()
2477        };
2478        let out = write_dss(&net);
2479        let line = out
2480            .text
2481            .lines()
2482            .find(|l| l.contains("Generator.g1"))
2483            .unwrap();
2484        assert!(line.contains("phases=2 conn=delta"), "{line}");
2485        assert_eq!(line.matches("phases=").count(), 1, "{line}");
2486    }
2487}