Skip to main content

powerio_dist/dss/
write.rs

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