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