Skip to main content

powerio_dist/dss/
read.rs

1//! `.dss` raw objects into the canonical [`DistNetwork`].
2//!
3//! Every OpenDSS default materializes into an explicit model value, recorded
4//! in [`DistNetwork::defaulted`] under the `"class.name"` key. Specified
5//! properties the typed fields do not capture go into the element's `extras`
6//! verbatim (string values), so a later writer can reproduce them. Bus specs
7//! resolve with the engine's fill rule: phase conductors default to nodes
8//! `1..=phases`, every remaining conductor to ground (node 0), and the
9//! written dot list overrides from the left. Ground connections become an
10//! explicit perfectly grounded neutral terminal on the bus, named
11//! `max(4, highest node + 1)` to match PowerModelsDistribution and the
12//! public BMOPF examples.
13
14use std::collections::BTreeMap;
15use std::path::Path;
16use std::sync::Arc;
17
18use super::defaults as dd;
19use super::lex::{BusSpec, Value, VarMap};
20use super::raw::{RawDss, RawObject, parse_raw_with};
21use crate::error::{Error, Result};
22use crate::model::{
23    Configuration, DistBus, DistGenerator, DistLine, DistLineCode, DistLoad, DistLoadVoltageModel,
24    DistNetwork, DistShunt, DistSourceFormat, DistSwitch, DistTransformer, Extras, Mat,
25    UntypedObject, VoltageSource, Winding, WindingConn, pair_keys, square_from_rows,
26};
27
28/// Parses a `.dss` file, following includes, into the canonical model.
29pub fn parse_dss_file(path: impl AsRef<Path>) -> Result<DistNetwork> {
30    let path = path.as_ref();
31    let text = std::fs::read_to_string(path).map_err(|source| Error::Io {
32        path: path.display().to_string(),
33        source,
34    })?;
35    let raw = parse_raw_with(&text, &path.display().to_string(), &mut |p: &Path| {
36        std::fs::read_to_string(p)
37    });
38    Ok(network_from_raw(&raw, Arc::new(text)))
39}
40
41/// Parses `.dss` text; `Redirect`/`Compile` resolve relative to the working
42/// directory.
43pub fn parse_dss_str(text: &str) -> DistNetwork {
44    let raw = parse_raw_with(text, "<string>", &mut |p: &Path| std::fs::read_to_string(p));
45    network_from_raw(&raw, Arc::new(text.to_string()))
46}
47
48/// Lowers an executed raw script into the typed model.
49pub fn network_from_raw(raw: &RawDss, source: Arc<String>) -> DistNetwork {
50    let mut rd = Reader {
51        net: DistNetwork {
52            name: raw.circuit_name.clone(),
53            base_frequency: dd::BASE_FREQUENCY,
54            source: Some(source),
55            source_format: Some(DistSourceFormat::Dss),
56            warnings: raw.warnings.clone(),
57            ..DistNetwork::default()
58        },
59        buses: BTreeMap::new(),
60        bus_order: Vec::new(),
61        linecode_units: BTreeMap::new(),
62        vars: &raw.vars,
63    };
64
65    for (name, value) in &raw.options {
66        // Set option names resolve by first match in the engine's option
67        // table order (Command.cpp Getcommand → HashList FindAbbrev), so
68        // `Set defaultb=50` is DefaultBaseFrequency but anything shorter
69        // ("default", "d") binds DefaultDaily; the bound sits at the unique
70        // resolution point.
71        if name.len() >= "defaultb".len() && "defaultbasefrequency".starts_with(name.as_str()) {
72            if let Ok(f) = value.to_f64(Some(rd.vars)) {
73                rd.net.base_frequency = f;
74            }
75        }
76        rd.net.options.push((name.clone(), value.text.clone()));
77    }
78    for cmd in &raw.commands {
79        rd.net.commands.push((cmd.verb.clone(), cmd.args.clone()));
80    }
81
82    // Linecodes first: lines reference them. Then everything else in script
83    // order per class.
84    for obj in raw.of_class("linecode") {
85        let lc = rd.linecode(obj);
86        rd.net.linecodes.push(lc);
87    }
88    for obj in raw.of_class("vsource") {
89        let vs = rd.vsource(obj);
90        rd.net.sources.push(vs);
91    }
92    for obj in raw.of_class("line") {
93        rd.line(obj);
94    }
95    for obj in raw.of_class("transformer") {
96        let t = rd.transformer(obj);
97        rd.net.transformers.push(t);
98    }
99    for obj in raw.of_class("load") {
100        let l = rd.load(obj);
101        rd.net.loads.push(l);
102    }
103    for obj in raw.of_class("capacitor") {
104        rd.capacitor(obj);
105    }
106    for obj in raw.of_class("reactor") {
107        rd.reactor(obj);
108    }
109    for obj in raw.of_class("generator") {
110        let g = rd.generator(obj);
111        rd.net.generators.push(g);
112    }
113    for obj in raw.of_class("swtcontrol") {
114        rd.swtcontrol(obj);
115    }
116    for obj in raw.of_class("regcontrol") {
117        rd.regcontrol(obj);
118    }
119    for obj in &raw.objects {
120        if !matches!(
121            obj.class.as_str(),
122            "linecode"
123                | "vsource"
124                | "line"
125                | "transformer"
126                | "load"
127                | "capacitor"
128                | "reactor"
129                | "generator"
130                | "swtcontrol"
131                | "regcontrol"
132        ) {
133            rd.net.untyped.push(UntypedObject::from(obj));
134        }
135    }
136
137    // A dangling linecode reference would otherwise surface only at write
138    // time; the engine refuses it at parse time.
139    let known: std::collections::BTreeSet<String> = rd
140        .net
141        .linecodes
142        .iter()
143        .map(|c| c.name.to_ascii_lowercase())
144        .collect();
145    let missing: Vec<String> = rd
146        .net
147        .lines
148        .iter()
149        .filter(|l| !known.contains(&l.linecode.to_ascii_lowercase()))
150        .map(|l| {
151            format!(
152                "line {} references unknown linecode `{}`",
153                l.name, l.linecode
154            )
155        })
156        .collect();
157    rd.net.warnings.extend(missing);
158
159    finish_buses(rd, raw)
160}
161
162/// Materializes the accumulated bus states, ground markers, and coordinates.
163///
164/// Element processing records ground connections (node 0) verbatim; here
165/// each grounded bus gains an explicit perfectly grounded neutral terminal
166/// named `max(4, highest node + 1)`, the number PowerModelsDistribution
167/// and the public BMOPF examples give the materialized neutral, and every
168/// element terminal map is rewritten from "0" to it.
169fn finish_buses(mut rd: Reader, raw: &RawDss) -> DistNetwork {
170    let mut coords: BTreeMap<String, (f64, f64)> = BTreeMap::new();
171    for c in &raw.buscoords {
172        coords.insert(c.bus.to_ascii_lowercase(), (c.x, c.y));
173    }
174    let buses = std::mem::take(&mut rd.bus_order);
175    let states = std::mem::take(&mut rd.buses);
176    let mut net = rd.net;
177    let mut neutral_names: BTreeMap<String, String> = BTreeMap::new();
178    for id in buses {
179        let st = &states[&id];
180        let mut terminals: Vec<i32> = st.nodes.iter().copied().filter(|&n| n != 0).collect();
181        terminals.sort_unstable();
182        let mut bus = DistBus {
183            id: st.display.clone(),
184            terminals: terminals.iter().map(ToString::to_string).collect(),
185            ..DistBus::default()
186        };
187        if st.nodes.contains(&0) {
188            let neutral = terminals.last().map_or(4, |&n| n.max(3) + 1);
189            bus.terminals.push(neutral.to_string());
190            bus.grounded.push(neutral.to_string());
191            neutral_names.insert(id.clone(), neutral.to_string());
192        }
193        if let Some((x, y)) = coords.get(&id) {
194            bus.extras.insert("x".into(), (*x).into());
195            bus.extras.insert("y".into(), (*y).into());
196        }
197        net.buses.push(bus);
198    }
199
200    let rewrite = |bus: &str, map: &mut [String]| {
201        if let Some(neutral) = neutral_names.get(&bus.to_ascii_lowercase()) {
202            for t in map.iter_mut().filter(|t| *t == "0") {
203                t.clone_from(neutral);
204            }
205        }
206    };
207    for l in &mut net.lines {
208        rewrite(&l.bus_from, &mut l.terminal_map_from);
209        rewrite(&l.bus_to, &mut l.terminal_map_to);
210    }
211    for s in &mut net.switches {
212        rewrite(&s.bus_from, &mut s.terminal_map_from);
213        rewrite(&s.bus_to, &mut s.terminal_map_to);
214    }
215    for l in &mut net.loads {
216        rewrite(&l.bus, &mut l.terminal_map);
217    }
218    for g in &mut net.generators {
219        rewrite(&g.bus, &mut g.terminal_map);
220    }
221    for s in &mut net.shunts {
222        rewrite(&s.bus, &mut s.terminal_map);
223    }
224    for v in &mut net.sources {
225        rewrite(&v.bus, &mut v.terminal_map);
226    }
227    for t in &mut net.transformers {
228        for w in &mut t.windings {
229            rewrite(&w.bus, &mut w.terminal_map);
230        }
231    }
232    net
233}
234
235impl From<&RawObject> for UntypedObject {
236    fn from(obj: &RawObject) -> Self {
237        UntypedObject {
238            class: obj.class.clone(),
239            name: obj.name.clone(),
240            props: obj
241                .props
242                .iter()
243                .map(|p| (p.name.clone(), p.value.text.clone()))
244                .collect(),
245        }
246    }
247}
248
249struct BusState {
250    display: String,
251    nodes: std::collections::BTreeSet<i32>,
252}
253
254struct Reader<'a> {
255    net: DistNetwork,
256    buses: BTreeMap<String, BusState>,
257    bus_order: Vec<String>,
258    /// Linecode name (lowercase) → meters per its length unit, `None` when
259    /// the linecode has no units. Lines need it: `ConvertLineUnits` couples
260    /// the two sides' units.
261    linecode_units: BTreeMap<String, Option<f64>>,
262    vars: &'a VarMap,
263}
264
265/// Last-wins view of an object's resolved properties, plus the set of names
266/// actually written (for provenance and extras).
267struct Props<'a> {
268    by_name: BTreeMap<&'a str, &'a Value>,
269    consumed: std::cell::RefCell<Vec<&'a str>>,
270}
271
272impl<'a> Props<'a> {
273    fn new(obj: &'a RawObject) -> Self {
274        let mut by_name = BTreeMap::new();
275        for p in &obj.props {
276            if let Some(n) = &p.name {
277                by_name.insert(n.as_str(), &p.value);
278            }
279        }
280        Props {
281            by_name,
282            consumed: std::cell::RefCell::new(Vec::new()),
283        }
284    }
285
286    fn get(&self, name: &'a str) -> Option<&'a Value> {
287        self.consumed.borrow_mut().push(name);
288        self.by_name.get(name).copied()
289    }
290
291    /// Specified properties the typed fields did not consume, for extras.
292    fn leftovers(&self) -> Vec<(&str, &Value)> {
293        let consumed = self.consumed.borrow();
294        self.by_name
295            .iter()
296            .filter(|(k, _)| !consumed.contains(*k) && **k != "like")
297            .map(|(k, v)| (*k, *v))
298            .collect()
299    }
300}
301
302/// Reactor properties that set the impedance directly. When any is present
303/// the engine takes its SpecType from the impedance and ignores `kvar`/`kv`,
304/// so the kvar-shunt typing does not apply and the object stays untyped.
305/// `parallel` (series vs parallel R-X) and `rp` (a parallel damping
306/// resistance) are modifiers, not a SpecType of their own: a `kvar` reactor
307/// that also sets them is still a kvar shunt, so they are not listed here.
308const REACTOR_IMPEDANCE_FORMS: &[&str] = &[
309    "rmatrix", "xmatrix", "r", "x", "z1", "z2", "z0", "z", "rcurve", "lcurve", "lmh",
310];
311
312#[derive(Clone, Copy)]
313struct KvarShuntSpec {
314    class: &'static str,
315    series_name: &'static str,
316    default_phases: usize,
317    default_kvar: f64,
318    default_kv: f64,
319    b_sign: f64,
320}
321
322const CAPACITOR_KVAR_SHUNT: KvarShuntSpec = KvarShuntSpec {
323    class: "capacitor",
324    series_name: "capacitors",
325    default_phases: dd::capacitor::PHASES,
326    default_kvar: dd::capacitor::KVAR,
327    default_kv: dd::capacitor::KV,
328    b_sign: 1.0,
329};
330
331const REACTOR_KVAR_SHUNT: KvarShuntSpec = KvarShuntSpec {
332    class: "reactor",
333    series_name: "reactors",
334    default_phases: dd::reactor::PHASES,
335    default_kvar: dd::reactor::KVAR,
336    default_kv: dd::reactor::KV,
337    b_sign: -1.0,
338};
339
340impl Reader<'_> {
341    fn warn(&mut self, msg: impl Into<String>) {
342        self.net.warnings.push(msg.into());
343    }
344
345    fn defaulted(&mut self, class: &str, name: &str, field: &'static str) {
346        let fields = self
347            .net
348            .defaulted
349            .entry(format!("{class}.{name}"))
350            .or_default();
351        if !fields.contains(&field) {
352            fields.push(field);
353        }
354    }
355
356    fn f64_prop(&mut self, p: Option<&Value>) -> Option<f64> {
357        p.and_then(|v| v.to_f64(Some(self.vars)).ok())
358    }
359
360    fn usize_prop(&mut self, p: Option<&Value>) -> Option<usize> {
361        p.and_then(|v| v.to_i64(Some(self.vars)).ok())
362            .map(|i| usize::try_from(i).unwrap_or(0))
363    }
364
365    /// Meters per source length unit, or `None` when no conversion applies:
366    /// the property is missing, `none`, or a code `GetUnitsCode`
367    /// (Shared/LineUnits.cpp) does not recognize — the engine maps unknown
368    /// codes to UNITS_NONE. Unknown codes warn.
369    fn units_code(&mut self, units: Option<&str>, class: &str, name: &str) -> Option<f64> {
370        let u = units?;
371        if let Some(f) = dd::unit_to_meters(u) {
372            return Some(f);
373        }
374        if !u.to_ascii_lowercase().starts_with("no") {
375            self.net.warnings.push(format!(
376                "{class} {name}: unknown units `{u}`; treated as none"
377            ));
378        }
379        None
380    }
381
382    /// Extras value for a written numeric token: the literal text when it
383    /// is already a plain number, otherwise the evaluated value — RPN or
384    /// `@var` text is no use to the dss writer, which needs an argument the
385    /// engine can read back.
386    fn stash_numeric(&self, v: &Value) -> serde_json::Value {
387        if v.text.parse::<f64>().is_ok() {
388            v.text.clone().into()
389        } else {
390            match v.to_f64(Some(self.vars)) {
391                Ok(n) => n.into(),
392                Err(_) => v.text.clone().into(),
393            }
394        }
395    }
396
397    /// `kv` and `phases` for the dss writer: the written token (evaluated
398    /// when not a plain number), the materialized default otherwise.
399    fn stash_kv_and_phases(&self, props: &Props, extras: &mut Extras, kv: f64, phases: usize) {
400        let kv_value = match props.by_name.get("kv") {
401            Some(written) => self.stash_numeric(written),
402            None => kv.into(),
403        };
404        extras.insert("kv".into(), kv_value);
405        let phases_value = match props.by_name.get("phases") {
406            Some(written) => self.stash_numeric(written),
407            None => (phases as u64).into(),
408        };
409        extras.insert("phases".into(), phases_value);
410        // A 1 phase delta types as SinglePhase, indistinguishable from a wye
411        // spot load without the written token; the writer reads this stash to
412        // re-emit conn=delta.
413        if let Some(written) = props.by_name.get("conn") {
414            extras.insert("conn".into(), written.text.clone().into());
415        }
416    }
417
418    /// The property's value, or the class default recorded with provenance.
419    fn f64_or(
420        &mut self,
421        props: &Props,
422        key: &'static str,
423        class: &str,
424        name: &str,
425        default: f64,
426    ) -> f64 {
427        if let Some(v) = self.f64_prop(props.get(key)) {
428            v
429        } else {
430            self.defaulted(class, name, key);
431            default
432        }
433    }
434
435    fn usize_or(
436        &mut self,
437        props: &Props,
438        key: &'static str,
439        class: &str,
440        name: &str,
441        default: usize,
442    ) -> usize {
443        if let Some(v) = self.usize_prop(props.get(key)) {
444            v
445        } else {
446            self.defaulted(class, name, key);
447            default
448        }
449    }
450
451    /// Registers a bus connection and returns the terminal names for the
452    /// element. `phases` conductors default to nodes 1..=phases; conductors
453    /// beyond that default to ground. `keep` limits how many conductors the
454    /// terminal map lists (delta maps exclude the unused trailing conductor).
455    fn terminals(
456        &mut self,
457        spec: &BusSpec,
458        phases: usize,
459        nconds: usize,
460        keep: usize,
461    ) -> Vec<String> {
462        let mut nodes: Vec<i32> = (1..=i32::try_from(nconds).unwrap_or(i32::MAX)).collect();
463        for n in nodes.iter_mut().skip(phases) {
464            *n = 0;
465        }
466        for (i, &n) in spec.nodes.iter().enumerate().take(nconds) {
467            nodes[i] = n.max(0); // parser marks bad nodes -1; treat as ground
468        }
469        let key = spec.name.to_ascii_lowercase();
470        let state = self.buses.entry(key.clone()).or_insert_with(|| {
471            self.bus_order.push(key.clone());
472            BusState {
473                display: spec.name.clone(),
474                nodes: std::collections::BTreeSet::new(),
475            }
476        });
477        for &n in nodes.iter().take(keep) {
478            state.nodes.insert(n);
479        }
480        nodes.truncate(keep);
481        nodes.iter().map(ToString::to_string).collect()
482    }
483
484    // ----- linecode ------------------------------------------------------
485
486    fn linecode(&mut self, obj: &RawObject) -> DistLineCode {
487        let props = Props::new(obj);
488        let n = self.usize_or(
489            &props,
490            "nphases",
491            "linecode",
492            &obj.name,
493            dd::linecode::NPHASES,
494        );
495        let units = props.get("units").map(|v| v.text.clone());
496        let units_m = self.units_code(units.as_deref(), "linecode", &obj.name);
497        let per_meter = units_m.unwrap_or(1.0);
498        self.linecode_units
499            .insert(obj.name.to_ascii_lowercase(), units_m);
500
501        let freq = self
502            .f64_prop(props.get("basefreq"))
503            .unwrap_or(self.net.base_frequency);
504
505        let z = self.impedance_matrices(
506            &props,
507            n,
508            "linecode",
509            &obj.name,
510            dd::line::R1,
511            dd::line::X1,
512            dd::line::R0,
513            dd::line::X0,
514            dd::line::C1_NF,
515            dd::line::C0_NF,
516        );
517        if z.all_default {
518            self.defaulted("linecode", &obj.name, "rmatrix");
519        }
520
521        // Half the total line charging susceptance at each end; OpenDSS
522        // carries one C matrix for the whole pi section.
523        let b_half = scale_mat(
524            &z.c_nf,
525            std::f64::consts::TAU * freq * 1e-9 / per_meter / 2.0,
526        );
527        let zero = vec![vec![0.0; n]; n];
528
529        // i_max carries the emergency rating: PMD's cm_ub and the public
530        // BMOPF examples both use emergamps. normamps stays in extras.
531        let amps = self.f64_or(
532            &props,
533            "emergamps",
534            "linecode",
535            &obj.name,
536            dd::line::EMERGAMPS,
537        );
538        let i_max = Some(vec![amps; n]);
539
540        let mut extras = extras_from_leftovers(&props);
541        if let Some(u) = units {
542            extras.insert("units".into(), u.into());
543        }
544        for (key, text) in z.malformed {
545            extras.insert(key.to_string(), text.into());
546        }
547        DistLineCode {
548            name: obj.name.clone(),
549            n_conductors: n,
550            r_series: scale_mat(&z.r, 1.0 / per_meter),
551            x_series: scale_mat(&z.x, 1.0 / per_meter),
552            g_from: zero.clone(),
553            b_from: b_half.clone(),
554            g_to: zero,
555            b_to: b_half,
556            i_max,
557            s_max: None,
558            extras,
559        }
560    }
561
562    /// R, X (ohm per unit length) and C (nF per unit length) matrices from
563    /// either explicit matrices or sequence values.
564    #[allow(clippy::too_many_arguments)]
565    fn impedance_matrices(
566        &mut self,
567        props: &Props,
568        n: usize,
569        class: &str,
570        name: &str,
571        r1d: f64,
572        x1d: f64,
573        r0d: f64,
574        x0d: f64,
575        c1d: f64,
576        c0d: f64,
577    ) -> SeriesImpedance {
578        let mut malformed: Vec<(&'static str, String)> = Vec::new();
579        let mut rows = |key: &'static str| -> Option<Mat> {
580            let v = props.get(key)?;
581            let parsed = v
582                .to_rows(Some(self.vars))
583                .ok()
584                .and_then(|rows| square_from_rows(&rows, n));
585            if parsed.is_none() {
586                malformed.push((key, v.text.clone()));
587            }
588            parsed
589        };
590        let rm = rows("rmatrix");
591        let xm = rows("xmatrix");
592        let cm = rows("cmatrix");
593        // The engine rejects the whole script on a bad matrix; the liberal
594        // reader falls back to the sequence values but says so and keeps
595        // the text. A written property is never reported as defaulted.
596        for (key, _) in &malformed {
597            self.warn(format!(
598                "{class} {name}: `{key}` does not parse as a {n}x{n} matrix; \
599                 sequence values apply and the text is kept in extras"
600            ));
601        }
602        let any_written = [
603            "rmatrix", "xmatrix", "cmatrix", "r1", "x1", "r0", "x0", "c1", "c0", "b1", "b0",
604        ]
605        .iter()
606        .any(|k| props.by_name.contains_key(*k));
607
608        let seq = |props: &Props, k1: &'static str, k0: &'static str, d1: f64, d0: f64| {
609            let v1 = props
610                .get(k1)
611                .and_then(|v| v.to_f64(Some(self.vars)).ok())
612                .unwrap_or(d1);
613            let v0 = props
614                .get(k0)
615                .and_then(|v| v.to_f64(Some(self.vars)).ok())
616                .unwrap_or(d0);
617            // Symmetric component to phase: diag (2 z1 + z0)/3, off
618            // diagonal (z0 - z1)/3.
619            let s = (2.0 * v1 + v0) / 3.0;
620            let m = (v0 - v1) / 3.0;
621            let mut mat = vec![vec![m; n]; n];
622            for (i, row) in mat.iter_mut().enumerate() {
623                row[i] = s;
624            }
625            mat
626        };
627
628        SeriesImpedance {
629            r: rm.unwrap_or_else(|| seq(props, "r1", "r0", r1d, r0d)),
630            x: xm.unwrap_or_else(|| seq(props, "x1", "x0", x1d, x0d)),
631            c_nf: cm.unwrap_or_else(|| seq(props, "c1", "c0", c1d, c0d)),
632            all_default: !any_written,
633            malformed,
634        }
635    }
636
637    // ----- vsource -------------------------------------------------------
638
639    fn vsource(&mut self, obj: &RawObject) -> VoltageSource {
640        let props = Props::new(obj);
641        let phases = self.usize_or(&props, "phases", "vsource", &obj.name, dd::vsource::PHASES);
642        let basekv = self.f64_or(&props, "basekv", "vsource", &obj.name, dd::vsource::BASEKV);
643        let pu = self.f64_or(&props, "pu", "vsource", &obj.name, dd::vsource::PU);
644        let angle_deg = self.f64_or(
645            &props,
646            "angle",
647            "vsource",
648            &obj.name,
649            dd::vsource::ANGLE_DEG,
650        );
651        let spec = if let Some(v) = props.get("bus1") {
652            v.to_bus_spec()
653        } else {
654            self.defaulted("vsource", &obj.name, "bus1");
655            Value::new(dd::vsource::BUS1).to_bus_spec()
656        };
657        let map = self.terminals(&spec, phases, phases + 1, phases + 1);
658
659        // VSource.cpp ~995-1003: one phase takes basekv outright, otherwise
660        // the per phase magnitude is basekv / (2 sin(pi/n)) — the chord of
661        // the n-gon, which is sqrt(3) only at n = 3. Angles space at
662        // -360/n degrees (positive sequence, ~1272), wrapped to (-180, 180]
663        // in radians, matching the reference conversion.
664        let v_ln = if phases == 1 {
665            basekv * 1e3 * pu
666        } else {
667            basekv * 1e3 * pu / (2.0 * (std::f64::consts::PI / phases as f64).sin())
668        };
669        let mut v_magnitude = vec![v_ln; phases];
670        let mut v_angle: Vec<f64> = (0..phases)
671            .map(|k| {
672                let deg = angle_deg - 360.0 / phases as f64 * k as f64;
673                let a = deg.to_radians();
674                // rem_euclid yields [0, tau); shifting puts the result in
675                // [-pi, pi), and the reference maps the open end to +pi.
676                let shifted = (a + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU);
677                if shifted <= 0.0 {
678                    std::f64::consts::PI
679                } else {
680                    shifted - std::f64::consts::PI
681                }
682            })
683            .collect();
684        // The neutral conductor rides at ground.
685        v_magnitude.push(0.0);
686        v_angle.push(0.0);
687
688        // The raw base voltage rides in extras: the magnitudes fold in pu,
689        // and downstream writers need the unscaled base.
690        let mut extras = extras_from_leftovers(&props);
691        extras.insert("basekv".into(), basekv.into());
692        extras.insert("angle".into(), angle_deg.into());
693        if (pu - 1.0).abs() > 0.0 {
694            extras.insert("pu".into(), pu.into());
695        }
696        VoltageSource {
697            name: obj.name.clone(),
698            bus: spec.name,
699            terminal_map: map,
700            v_magnitude,
701            v_angle,
702            extras,
703        }
704    }
705
706    // ----- line / switch -------------------------------------------------
707
708    fn line(&mut self, obj: &RawObject) {
709        let props = Props::new(obj);
710        let phases = self
711            .usize_prop(props.get("phases"))
712            .unwrap_or(dd::line::PHASES);
713        let spec1 = bus_spec(props.get("bus1"), "");
714        let spec2 = bus_spec(props.get("bus2"), "");
715        // A line has no neutral conductor of its own: nconds == phases.
716        let map_from = self.terminals(&spec1, phases, phases, phases);
717        let map_to = self.terminals(&spec2, phases, phases, phases);
718
719        let is_switch = props.get("switch").is_some_and(super::lex::Value::to_bool);
720        if is_switch {
721            let amps = self.f64_or(&props, "emergamps", "line", &obj.name, dd::line::EMERGAMPS);
722            let i_max = Some(vec![amps; phases]);
723            let mut extras = extras_from_leftovers(&props);
724            // OpenDSS replaces a switch line's impedance with fixed dummy
725            // values; record anything written so nothing drops silently.
726            for k in ["linecode", "length", "r1", "x1", "rmatrix", "xmatrix"] {
727                if let Some(v) = props.by_name.get(k) {
728                    extras.insert(k.to_string(), v.text.clone().into());
729                    self.warn(format!(
730                        "line {}: `{k}` is ignored by OpenDSS on switch=yes; kept in extras",
731                        obj.name
732                    ));
733                }
734            }
735            self.net.switches.push(DistSwitch {
736                name: obj.name.clone(),
737                bus_from: spec1.name,
738                bus_to: spec2.name,
739                terminal_map_from: map_from,
740                terminal_map_to: map_to,
741                open: false,
742                i_max,
743                extras,
744            });
745            return;
746        }
747
748        let length_units = props.get("units").map(|v| v.text.clone());
749        let line_units_m = self.units_code(length_units.as_deref(), "line", &obj.name);
750        let length = self.f64_or(&props, "length", "line", &obj.name, dd::line::LENGTH);
751
752        // ConvertLineUnits (Shared/LineUnits.cpp ~166) is 1.0 when either
753        // side is UNITS_NONE, and the engine scales the linecode matrices
754        // by Len / FUnitsConvert (Line.cpp ~1177). A unitless line length
755        // is therefore in the linecode's units, and a unitless linecode is
756        // per line length unit, so the raw length preserves the Z·length
757        // product.
758        let mut malformed: Vec<(&'static str, String)> = Vec::new();
759        let (linecode, length_factor) = if let Some(code) = props.get("linecode") {
760            let lc_units_m = self
761                .linecode_units
762                .get(&code.text.to_ascii_lowercase())
763                .copied()
764                .flatten();
765            let factor = match (lc_units_m, line_units_m) {
766                (Some(_), Some(lf)) => lf,
767                (Some(lcf), None) => lcf,
768                (None, _) => 1.0,
769            };
770            (code.text.clone(), factor)
771        } else {
772            let factor = line_units_m.unwrap_or(1.0);
773            let (code, bad) = self.synthesize_linecode(&props, phases, factor, &obj.name);
774            malformed = bad;
775            (code, factor)
776        };
777
778        let mut extras = extras_from_leftovers(&props);
779        if let Some(u) = length_units {
780            extras.insert("units".into(), u.into());
781        }
782        for (key, text) in malformed {
783            extras.insert(key.to_string(), text.into());
784        }
785        self.net.lines.push(DistLine {
786            name: obj.name.clone(),
787            bus_from: spec1.name,
788            bus_to: spec2.name,
789            terminal_map_from: map_from,
790            terminal_map_to: map_to,
791            linecode,
792            length: length * length_factor,
793            extras,
794        });
795    }
796
797    /// A line without `linecode=` carries inline or default impedance;
798    /// materialize it as a linecode named `_line_<name>` in the line's own
799    /// length units. Malformed matrix texts return for the line's extras.
800    fn synthesize_linecode(
801        &mut self,
802        props: &Props,
803        phases: usize,
804        length_factor: f64,
805        line_name: &str,
806    ) -> (String, Vec<(&'static str, String)>) {
807        let z = self.impedance_matrices(
808            props,
809            phases,
810            "line",
811            line_name,
812            dd::line::R1,
813            dd::line::X1,
814            dd::line::R0,
815            dd::line::X0,
816            dd::line::C1_NF,
817            dd::line::C0_NF,
818        );
819        if z.all_default {
820            self.defaulted("line", line_name, "r1");
821            self.defaulted("line", line_name, "x1");
822        }
823        let b_half = scale_mat(
824            &z.c_nf,
825            std::f64::consts::TAU * self.net.base_frequency * 1e-9 / length_factor / 2.0,
826        );
827        let zero = vec![vec![0.0; phases]; phases];
828        let amps = self.f64_or(props, "emergamps", "line", line_name, dd::line::EMERGAMPS);
829        let i_max = Some(vec![amps; phases]);
830        let name = format!("_line_{line_name}");
831        self.net.linecodes.push(DistLineCode {
832            name: name.clone(),
833            n_conductors: phases,
834            r_series: scale_mat(&z.r, 1.0 / length_factor),
835            x_series: scale_mat(&z.x, 1.0 / length_factor),
836            g_from: zero.clone(),
837            b_from: b_half.clone(),
838            g_to: zero,
839            b_to: b_half,
840            i_max,
841            s_max: None,
842            extras: Extras::new(),
843        });
844        (name, z.malformed)
845    }
846
847    // ----- load ----------------------------------------------------------
848
849    /// Final (kWBase, kvarBase, PFNominal, LoadSpecType) after the last
850    /// edit boundary, with write provenance for kw and pf.
851    ///
852    /// Load.cpp runs RecalcElementData at the end of EVERY Edit (~773), so
853    /// kw/kvar/pf fold per edit, not flat. Within an edit, kw (case 4,
854    /// ~691) sets LoadSpecType 0 (kW + PF), kvar (case 12, ~753) sets 1
855    /// (kW + kvar), and pf (case 5, ~699) updates PFNominal without
856    /// touching the spec. The boundary recalc (~1342) rederives kvar from
857    /// kW and PF under spec 0, and PFNominal from kW and kvar under spec 1
858    /// (~1352-1360). like= splices the source's boundaries in the raw
859    /// layer, matching MakeLike's copy of the recalced state.
860    fn load_power(&mut self, obj: &RawObject) -> LoadPower {
861        let mut s = LoadPower {
862            kw: dd::load::KW,
863            // Constructor kvarBase is 5.0, never observable: spec 1
864            // requires a kvar write and the first spec 0 boundary
865            // overwrites the seed.
866            kvar: 0.0,
867            pf: dd::load::PF,
868            spec_kvar: false, // LoadSpecType: false = 0, true = 1
869            kw_written: false,
870            pf_written: false,
871        };
872        let mut start = 0;
873        for end in obj.edit_bounds() {
874            for p in &obj.props[start..end] {
875                let Some(key @ ("kw" | "kvar" | "pf")) = p.name.as_deref() else {
876                    continue;
877                };
878                let Some(v) = self.f64_prop(Some(&p.value)) else {
879                    continue;
880                };
881                match key {
882                    "kw" => {
883                        s.kw = v;
884                        s.spec_kvar = false;
885                        s.kw_written = true;
886                    }
887                    "kvar" => {
888                        s.kvar = v;
889                        s.spec_kvar = true;
890                    }
891                    _ => {
892                        s.pf = v;
893                        s.pf_written = true;
894                    }
895                }
896            }
897            start = end;
898            // RecalcElementData at the edit boundary.
899            if s.spec_kvar {
900                let kva = s.kw.hypot(s.kvar);
901                if kva > 0.0 {
902                    s.pf = s.kw / kva;
903                    // Mixed signs make PF negative (Sign(kWBase*kvarBase)).
904                    if s.kw * s.kvar < 0.0 {
905                        s.pf = -s.pf;
906                    }
907                }
908            } else {
909                s.kvar = s.kw * (1.0 / (s.pf * s.pf) - 1.0).sqrt();
910                if s.pf < 0.0 {
911                    s.kvar = -s.kvar;
912                }
913            }
914        }
915        s
916    }
917
918    fn load(&mut self, obj: &RawObject) -> DistLoad {
919        let props = Props::new(obj);
920        let phases = self.usize_or(&props, "phases", "load", &obj.name, dd::load::PHASES);
921        let conn_delta = props.get("conn").is_some_and(|v| {
922            v.text.to_ascii_lowercase().starts_with('d') || v.text.eq_ignore_ascii_case("ll")
923        });
924        let kv = self.f64_or(&props, "kv", "load", &obj.name, dd::load::KV);
925        let LoadPower {
926            kw,
927            kvar: q_total,
928            pf,
929            spec_kvar,
930            kw_written,
931            pf_written,
932        } = self.load_power(obj);
933        if !kw_written {
934            self.defaulted("load", &obj.name, "kw");
935        }
936        // Mark the walked properties consumed so they stay out of extras.
937        let _ = (props.get("kw"), props.get("kvar"), props.get("pf"));
938        // When the final spec is 0, q derives from the power factor; the
939        // source pf rides in extras so the dss writer can emit pf= and let
940        // the engine do its own trigonometry — transcendental rounding
941        // across implementations would otherwise leak into regenerated
942        // cases. Under spec 1 the writer emits kvar=.
943        let mut pf_source: Option<f64> = None;
944        if !spec_kvar {
945            if !pf_written {
946                self.defaulted("load", &obj.name, "pf");
947            }
948            pf_source = Some(pf);
949        }
950        let model = self
951            .usize_prop(props.get("model"))
952            .map_or(dd::load::MODEL, |m| i64::try_from(m).unwrap_or(i64::MAX));
953
954        let spec = bus_spec(props.get("bus1"), "");
955        let nconds = if conn_delta && phases == 3 {
956            phases
957        } else {
958            phases + 1
959        };
960        let map = self.terminals(&spec, phases, nconds, nconds);
961
962        let configuration = if phases == 1 {
963            Configuration::SinglePhase
964        } else if conn_delta {
965            Configuration::Delta
966        } else {
967            Configuration::Wye
968        };
969
970        // kv is the load's own base and model its dss load model code;
971        // both ride in extras for the writers (the kv default materializes
972        // here like every other constructor default), while the typed
973        // fields hold explicit power per phase. phases rides too: a 2
974        // phase delta load also has 3 conductors, so the terminal map
975        // alone cannot reconstruct `phases=`.
976        let mut extras = extras_from_leftovers(&props);
977        self.stash_kv_and_phases(&props, &mut extras, kv, phases);
978        if let Some(pf) = pf_source {
979            extras.insert("pf".into(), pf.into());
980        }
981        if model != 1 {
982            extras.insert("model".into(), model.into());
983        }
984        let v_phase = if phases >= 2 && configuration == Configuration::Wye {
985            kv * 1e3 / 3f64.sqrt()
986        } else {
987            kv * 1e3
988        };
989        let v_nom = vec![v_phase; phases];
990        let zipv = props
991            .get("zipv")
992            .and_then(|v| v.to_vector(Some(self.vars)).ok())
993            .unwrap_or_default();
994        let voltage_model = match model {
995            2 => DistLoadVoltageModel::ConstantImpedance { v_nom },
996            5 => DistLoadVoltageModel::ConstantCurrent { v_nom },
997            8 if zipv.len() >= 6 => DistLoadVoltageModel::Zip {
998                v_nom,
999                alpha_z: vec![zipv[0]; phases],
1000                alpha_i: vec![zipv[1]; phases],
1001                alpha_p: vec![zipv[2]; phases],
1002                beta_z: vec![zipv[3]; phases],
1003                beta_i: vec![zipv[4]; phases],
1004                beta_p: vec![zipv[5]; phases],
1005            },
1006            8 => DistLoadVoltageModel::Zip {
1007                v_nom,
1008                alpha_z: Vec::new(),
1009                alpha_i: Vec::new(),
1010                alpha_p: Vec::new(),
1011                beta_z: Vec::new(),
1012                beta_i: Vec::new(),
1013                beta_p: Vec::new(),
1014            },
1015            _ => DistLoadVoltageModel::ConstantPower { v_nom },
1016        };
1017        DistLoad {
1018            name: obj.name.clone(),
1019            bus: spec.name,
1020            terminal_map: map,
1021            configuration,
1022            p_nom: vec![kw * 1e3 / phases as f64; phases],
1023            q_nom: vec![q_total * 1e3 / phases as f64; phases],
1024            voltage_model,
1025            extras,
1026        }
1027    }
1028
1029    // ----- transformer ---------------------------------------------------
1030
1031    #[allow(clippy::too_many_lines)] // OpenDSS transformer edits must be replayed in order
1032    fn transformer(&mut self, obj: &RawObject) -> DistTransformer {
1033        // Order matters: wdg= switches the winding under edit, windings=
1034        // reallocates. Walk assignments sequentially.
1035        let mut phases = dd::transformer::PHASES;
1036        let mut n_windings = dd::transformer::WINDINGS;
1037        let mut windings = vec![WindingRaw::default(); n_windings];
1038        let mut active = 0usize;
1039        let mut xhl = dd::transformer::XHL;
1040        let mut xht = dd::transformer::XHT;
1041        let mut xlt = dd::transformer::XLT;
1042        let mut xhl_specified = false;
1043        let mut x_pairs: BTreeMap<(usize, usize), f64> = BTreeMap::new();
1044        let mut extras = Extras::new();
1045        let conn_is_delta =
1046            |t: &str| t.to_ascii_lowercase().starts_with('d') || t.eq_ignore_ascii_case("ll");
1047        for p in &obj.props {
1048            let Some(name) = &p.name else { continue };
1049            let v = &p.value;
1050            match name.as_str() {
1051                "phases" => {
1052                    phases = self.usize_prop(Some(v)).unwrap_or(phases);
1053                }
1054                "windings" => {
1055                    n_windings = self.usize_prop(Some(v)).unwrap_or(n_windings).max(1);
1056                    windings = vec![WindingRaw::default(); n_windings];
1057                    active = 0;
1058                }
1059                "wdg" => {
1060                    let k = self.usize_prop(Some(v)).unwrap_or(1).max(1);
1061                    grow(&mut windings, k, &mut n_windings);
1062                    active = k - 1;
1063                }
1064                "bus" => windings[active].bus = Some(v.to_bus_spec()),
1065                "conn" => windings[active].conn_delta = conn_is_delta(&v.text),
1066                "kv" | "kva" | "tap" | "%r" => {
1067                    let parsed = self.f64_prop(Some(v));
1068                    let w = &mut windings[active];
1069                    match name.as_str() {
1070                        "kv" => {
1071                            w.kv = parsed.unwrap_or(w.kv);
1072                            w.kv_specified = true;
1073                        }
1074                        "kva" => {
1075                            w.kva = parsed.unwrap_or(w.kva);
1076                            w.kva_specified = true;
1077                        }
1078                        "tap" => w.tap = parsed.unwrap_or(w.tap),
1079                        _ => w.r_pct = parsed.unwrap_or(w.r_pct),
1080                    }
1081                }
1082                "buses" | "conns" => {
1083                    let items = v.to_string_list(Some(self.vars));
1084                    grow(&mut windings, items.len(), &mut n_windings);
1085                    apply_winding_strings(&mut windings, name, &items);
1086                }
1087                "kvs" | "kvas" | "taps" | "%rs" => match v.to_vector(Some(self.vars)) {
1088                    Ok(items) => {
1089                        grow(&mut windings, items.len(), &mut n_windings);
1090                        apply_winding_numbers(&mut windings, name, &items);
1091                    }
1092                    Err(e) => self.warn(format!("transformer {}: {name}: {e}", obj.name)),
1093                },
1094                "%loadloss" => {
1095                    // The engine splits load loss across the first two
1096                    // windings: %R each = %loadloss / 2 (Transformer.cpp,
1097                    // property 26). The written value also rides in extras
1098                    // for the canonical echo.
1099                    if let Some(ll) = self.f64_prop(Some(v)) {
1100                        for w in windings.iter_mut().take(2) {
1101                            w.r_pct = ll / 2.0;
1102                        }
1103                    }
1104                    extras.insert("%loadloss".to_string(), v.text.clone().into());
1105                }
1106                "xhl" | "x12" => {
1107                    xhl = self.f64_prop(Some(v)).unwrap_or(xhl);
1108                    xhl_specified = true;
1109                    x_pairs.insert((0, 1), xhl);
1110                }
1111                "xht" | "x13" => {
1112                    xht = self.f64_prop(Some(v)).unwrap_or(xht);
1113                    x_pairs.insert((0, 2), xht);
1114                }
1115                "xlt" | "x23" => {
1116                    xlt = self.f64_prop(Some(v)).unwrap_or(xlt);
1117                    x_pairs.insert((1, 2), xlt);
1118                }
1119                other if x_pair_key(other).is_some() => {
1120                    if let Some((i, j)) = x_pair_key(other) {
1121                        let x = self.f64_prop(Some(v)).unwrap_or(0.0);
1122                        x_pairs.insert((i, j), x);
1123                    }
1124                }
1125                other => {
1126                    extras.insert(other.to_string(), v.text.clone().into());
1127                }
1128            }
1129        }
1130
1131        if !xhl_specified {
1132            self.defaulted("transformer", &obj.name, "xhl");
1133        }
1134        let out = self.finish_windings(&windings, phases, &obj.name);
1135
1136        let xsc_pct = if n_windings >= 3 {
1137            pair_keys(n_windings)
1138                .into_iter()
1139                .map(|pair| {
1140                    x_pairs.get(&pair).copied().unwrap_or(match pair {
1141                        (0, 1) => xhl,
1142                        (0, 2) => xht,
1143                        (1, 2) => xlt,
1144                        _ => 0.0,
1145                    })
1146                })
1147                .collect()
1148        } else {
1149            vec![xhl]
1150        };
1151        DistTransformer {
1152            name: obj.name.clone(),
1153            windings: out,
1154            xsc_pct,
1155            phases,
1156            extras,
1157        }
1158    }
1159
1160    /// Resolves winding bus specs, terminal maps, and SI ratings, recording
1161    /// provenance for defaulted kv/kva.
1162    fn finish_windings(
1163        &mut self,
1164        windings: &[WindingRaw],
1165        phases: usize,
1166        name: &str,
1167    ) -> Vec<Winding> {
1168        let mut out = Vec::with_capacity(windings.len());
1169        for (i, w) in windings.iter().enumerate() {
1170            if !w.kv_specified {
1171                self.defaulted("transformer", name, "kv");
1172            }
1173            if !w.kva_specified {
1174                self.defaulted("transformer", name, "kva");
1175            }
1176            let spec = w
1177                .bus
1178                .clone()
1179                .unwrap_or_else(|| Value::new(format!("{name}_w{}", i + 1)).to_bus_spec());
1180            // Each winding terminal has phases + 1 conductors; wye keeps the
1181            // neutral in the map, delta leaves the unused conductor out. A
1182            // delta winding is wired line to line, so a single phase delta leg
1183            // (an open delta secondary, bus spec `.1.2`) still spans two phase
1184            // terminals; keep both rather than collapsing to one.
1185            let keep = if w.conn_delta {
1186                phases.max(2)
1187            } else {
1188                phases + 1
1189            };
1190            let map = self.terminals(&spec, phases, phases + 1, keep);
1191            out.push(Winding {
1192                bus: spec.name,
1193                terminal_map: map,
1194                conn: if w.conn_delta {
1195                    WindingConn::Delta
1196                } else {
1197                    WindingConn::Wye
1198                },
1199                v_ref: w.kv * 1e3,
1200                s_rating: w.kva * 1e3,
1201                r_pct: w.r_pct,
1202                tap: w.tap,
1203            });
1204        }
1205        out
1206    }
1207
1208    // ----- capacitor → shunt ---------------------------------------------
1209
1210    fn capacitor(&mut self, obj: &RawObject) {
1211        self.kvar_shunt(obj, CAPACITOR_KVAR_SHUNT);
1212    }
1213
1214    // ----- reactor → shunt -----------------------------------------------
1215
1216    /// A grounding (shunt) reactor specified by `kvar`/`kv` maps to a shunt
1217    /// with inductive (negative) susceptance, the sign mirror of a capacitor.
1218    /// A reactor from a bus terminal to the same bus's node 0 is also a shunt;
1219    /// when it uses `r`/`x`, store the equivalent conductance and susceptance.
1220    /// Other `bus2` reactors are series elements and stay untyped.
1221    fn reactor(&mut self, obj: &RawObject) {
1222        let props = Props::new(obj);
1223        let phases = self.usize_or(&props, "phases", "reactor", &obj.name, dd::reactor::PHASES);
1224        if phases == 0 {
1225            self.warn(format!(
1226                "reactor {}: nonpositive `phases` value is not a typed shunt; kept untyped",
1227                obj.name
1228            ));
1229            self.net.untyped.push(UntypedObject::from(obj));
1230            return;
1231        }
1232        let bus = bus_spec(props.get("bus1"), "");
1233        let bus2 = props.get("bus2").map(super::lex::Value::to_bus_spec);
1234        let grounding_return = bus2
1235            .as_ref()
1236            .is_some_and(|return_bus| same_bus_ground_return(&bus, return_bus, phases));
1237
1238        if bus2.is_some() && !grounding_return {
1239            self.warn(format!(
1240                "reactor {}: series reactors (bus2) are not typed yet; kept untyped",
1241                obj.name
1242            ));
1243            self.net.untyped.push(UntypedObject::from(obj));
1244            return;
1245        }
1246
1247        if let Some(form) = REACTOR_IMPEDANCE_FORMS
1248            .iter()
1249            .find(|k| !matches!(**k, "r" | "x") && props.by_name.contains_key(**k))
1250        {
1251            self.warn(format!(
1252                "reactor {}: impedance form (`{form}`) is not typed yet; kept untyped",
1253                obj.name
1254            ));
1255            self.net.untyped.push(UntypedObject::from(obj));
1256            return;
1257        }
1258        let has_rx = props.by_name.contains_key("r") || props.by_name.contains_key("x");
1259        if has_rx {
1260            if grounding_return {
1261                self.grounding_impedance_reactor(obj, &props, &bus, phases);
1262            } else {
1263                let form = if props.by_name.contains_key("r") {
1264                    "r"
1265                } else {
1266                    "x"
1267                };
1268                self.warn(format!(
1269                    "reactor {}: impedance form (`{form}`) is not typed yet; kept untyped",
1270                    obj.name
1271                ));
1272                self.net.untyped.push(UntypedObject::from(obj));
1273            }
1274            return;
1275        }
1276
1277        self.kvar_shunt_with_props(obj, &props, REACTOR_KVAR_SHUNT);
1278    }
1279
1280    fn grounding_impedance_reactor(
1281        &mut self,
1282        obj: &RawObject,
1283        props: &Props<'_>,
1284        bus: &BusSpec,
1285        phases: usize,
1286    ) {
1287        // An absent `r`/`x` key defaults to 0, but a key whose token fails to
1288        // evaluate keeps the object untyped instead of silently substituting 0,
1289        // which would emit a lossless grounding reactor with no warning that
1290        // the resistance was dropped.
1291        let term = |v: Option<&Value>| v.map_or(Ok(0.0), |val| val.to_f64(Some(self.vars)));
1292        let (Ok(resistance), Ok(reactance)) = (term(props.get("r")), term(props.get("x"))) else {
1293            self.warn(format!(
1294                "reactor {}: `r`/`x` does not evaluate to a number; kept untyped",
1295                obj.name
1296            ));
1297            self.net.untyped.push(UntypedObject::from(obj));
1298            return;
1299        };
1300        let denom = resistance * resistance + reactance * reactance;
1301        if !denom.is_finite() || denom <= 0.0 {
1302            self.warn(format!(
1303                "reactor {}: zero impedance grounding reactor is not a typed shunt; kept untyped",
1304                obj.name
1305            ));
1306            self.net.untyped.push(UntypedObject::from(obj));
1307            return;
1308        }
1309        let map = self.terminals(bus, phases, phases + 1, phases);
1310        let dim = map.len();
1311        let mut conductance = vec![vec![0.0; dim]; dim];
1312        let mut susceptance = vec![vec![0.0; dim]; dim];
1313        let y_g = resistance / denom;
1314        let y_b = -reactance / denom;
1315        for idx in 0..dim {
1316            conductance[idx][idx] = y_g;
1317            susceptance[idx][idx] = y_b;
1318        }
1319        self.net.shunts.push(DistShunt {
1320            name: obj.name.clone(),
1321            bus: bus.name.clone(),
1322            terminal_map: map,
1323            g: conductance,
1324            b: susceptance,
1325            extras: extras_from_leftovers(props),
1326        });
1327    }
1328
1329    fn kvar_shunt(&mut self, obj: &RawObject, spec: KvarShuntSpec) {
1330        let props = Props::new(obj);
1331        self.kvar_shunt_with_props(obj, &props, spec);
1332    }
1333
1334    fn kvar_shunt_with_props(&mut self, obj: &RawObject, props: &Props<'_>, spec: KvarShuntSpec) {
1335        let phases = self.usize_or(props, "phases", spec.class, &obj.name, spec.default_phases);
1336        if phases == 0 {
1337            self.warn(format!(
1338                "{} {}: nonpositive `phases` value is not a typed shunt; kept untyped",
1339                spec.class, obj.name
1340            ));
1341            self.net.untyped.push(UntypedObject::from(obj));
1342            return;
1343        }
1344        // InterpretConnection: `d*` and `ll` are delta for both Capacitor and
1345        // Reactor. Delta banks are line to line shunts represented by a nodal
1346        // admittance matrix.
1347        let conn_delta = props.get("conn").is_some_and(|v| {
1348            v.text.to_ascii_lowercase().starts_with('d') || v.text.eq_ignore_ascii_case("ll")
1349        });
1350        let bus = bus_spec(props.get("bus1"), "");
1351        if let Some(return_bus) = props.get("bus2").map(super::lex::Value::to_bus_spec) {
1352            if !same_bus_ground_return(&bus, &return_bus, phases) {
1353                self.warn(format!(
1354                    "{} {}: series {} (bus2) are not typed yet; kept untyped",
1355                    spec.class, obj.name, spec.series_name
1356                ));
1357                self.net.untyped.push(UntypedObject::from(obj));
1358                return;
1359            }
1360        }
1361
1362        if conn_delta && phases == 1 && bus.nodes.len() < 2 {
1363            self.warn(format!(
1364                "{} {}: single phase delta shunt needs two bus nodes; kept untyped",
1365                spec.class, obj.name
1366            ));
1367            self.net.untyped.push(UntypedObject::from(obj));
1368            return;
1369        }
1370        // Read the first kvar array entry, as the DSS engine does for a
1371        // grounding shunt bank.
1372        let kvar = props
1373            .get("kvar")
1374            .and_then(|v| v.to_vector(Some(self.vars)).ok())
1375            .and_then(|v| v.first().copied())
1376            .unwrap_or_else(|| {
1377                self.defaulted(spec.class, &obj.name, "kvar");
1378                spec.default_kvar
1379            });
1380        let kv = self.f64_or(props, "kv", spec.class, &obj.name, spec.default_kv);
1381        // A wye bank's kv is line to line for 2 or 3 phases, line to neutral
1382        // otherwise. A delta bank's kv is line to line across each branch.
1383        let v_ref = if conn_delta {
1384            kv * 1e3
1385        } else if phases == 2 || phases == 3 {
1386            kv * 1e3 / 3f64.sqrt()
1387        } else {
1388            kv * 1e3
1389        };
1390        // `kvar_shunt_matrix` divides by `v_ref * v_ref`; a positive but tiny
1391        // `v_ref` can square to zero (or a non-finite) and turn the admittance
1392        // into an infinity, so reject the squared value here too.
1393        let v_sq = v_ref * v_ref;
1394        if !v_ref.is_finite() || v_ref <= 0.0 || !v_sq.is_finite() || v_sq == 0.0 {
1395            self.warn(format!(
1396                "{} {}: invalid `kv` value is not a typed shunt; kept untyped",
1397                spec.class, obj.name
1398            ));
1399            self.net.untyped.push(UntypedObject::from(obj));
1400            return;
1401        }
1402
1403        let (nconds, keep) = if conn_delta {
1404            let keep = match phases {
1405                1 => 2,
1406                2 => 3,
1407                _ => phases,
1408            };
1409            (keep, keep)
1410        } else {
1411            // The default return is the same bus's ground; register the ground
1412            // connection but keep the map and matrices phase only, the shape a
1413            // shunt-to-ground admittance has downstream.
1414            (phases + 1, phases)
1415        };
1416        let map = self.terminals(&bus, phases, nconds, keep);
1417        let Some(susceptance) =
1418            kvar_shunt_matrix(&map, phases, conn_delta, kvar, v_ref, spec.b_sign)
1419        else {
1420            self.warn(format!(
1421                "{} {}: delta shunt terminal map is not typed; kept untyped",
1422                spec.class, obj.name
1423            ));
1424            self.net.untyped.push(UntypedObject::from(obj));
1425            return;
1426        };
1427        let mut extras = extras_from_leftovers(props);
1428        self.stash_kv_and_phases(props, &mut extras, kv, phases);
1429        extras.insert("kvar".into(), kvar.into());
1430        if conn_delta {
1431            extras.insert("conn".into(), "delta".into());
1432        }
1433        self.net.shunts.push(DistShunt {
1434            name: obj.name.clone(),
1435            bus: bus.name,
1436            terminal_map: map,
1437            g: vec![vec![0.0; susceptance.len()]; susceptance.len()],
1438            b: susceptance,
1439            extras,
1440        });
1441    }
1442
1443    // ----- generator -----------------------------------------------------
1444
1445    fn generator(&mut self, obj: &RawObject) -> DistGenerator {
1446        let props = Props::new(obj);
1447        let phases = self.usize_or(
1448            &props,
1449            "phases",
1450            "generator",
1451            &obj.name,
1452            dd::generator::PHASES,
1453        );
1454        // InterpretConnection (generator.cpp ~299): `d*` and `ll` are delta.
1455        let conn_delta = props.get("conn").is_some_and(|v| {
1456            v.text.to_ascii_lowercase().starts_with('d') || v.text.eq_ignore_ascii_case("ll")
1457        });
1458        // generator.cpp: kw and pf writes (props 4-5, side effect ~588)
1459        // call SyncUpPowerQuantities (~3879), rederiving kvar from kW and
1460        // PF; a kvar write (Set_Presentkvar, ~3857) stores kvar and
1461        // rederives PF from kW and kvar. The state carries across writes
1462        // in source order, seeded by the constructor values. Verified
1463        // asymmetry with Load: the generator resyncs eagerly AT each write
1464        // and has no end-of-edit recalc, so a flat fold over all writes is
1465        // correct here while loads need the per edit boundary walk above.
1466        let mut kw = dd::generator::KW;
1467        let mut kvar = dd::generator::KVAR;
1468        let mut pf = dd::generator::PF;
1469        let (mut kw_written, mut q_written) = (false, false);
1470        for p in &obj.props {
1471            let Some(key @ ("kw" | "kvar" | "pf")) = p.name.as_deref() else {
1472                continue;
1473            };
1474            let Some(v) = self.f64_prop(Some(&p.value)) else {
1475                continue;
1476            };
1477            match key {
1478                "kw" | "pf" => {
1479                    if key == "kw" {
1480                        kw = v;
1481                        kw_written = true;
1482                    } else {
1483                        pf = v;
1484                        q_written = true;
1485                    }
1486                    if pf != 0.0 {
1487                        kvar = kw * (pf.acos().tan()).copysign(pf);
1488                    }
1489                }
1490                _ => {
1491                    kvar = v;
1492                    q_written = true;
1493                    let kva = kw.hypot(kvar);
1494                    pf = if kva == 0.0 { 1.0 } else { kw / kva };
1495                    if kw * kvar < 0.0 {
1496                        pf = -pf;
1497                    }
1498                }
1499            }
1500        }
1501        if !kw_written {
1502            self.defaulted("generator", &obj.name, "kw");
1503        }
1504        if !q_written {
1505            self.defaulted("generator", &obj.name, "kvar");
1506        }
1507        // Mark the walked properties consumed so they stay out of extras.
1508        let _ = (props.get("kw"), props.get("kvar"), props.get("pf"));
1509        let kv = self.f64_or(&props, "kv", "generator", &obj.name, dd::generator::KV);
1510        let maxkvar = self.f64_prop(props.get("maxkvar"));
1511        let minkvar = self.f64_prop(props.get("minkvar"));
1512
1513        let spec = bus_spec(props.get("bus1"), "");
1514        let nconds = if conn_delta && phases == 3 {
1515            phases
1516        } else {
1517            phases + 1
1518        };
1519        let map = self.terminals(&spec, phases, nconds, nconds);
1520
1521        let per_phase = |total_kw: f64| vec![total_kw * 1e3 / phases as f64; phases];
1522        let mut extras = extras_from_leftovers(&props);
1523        self.stash_kv_and_phases(&props, &mut extras, kv, phases);
1524        DistGenerator {
1525            name: obj.name.clone(),
1526            bus: spec.name,
1527            terminal_map: map,
1528            configuration: if phases == 1 {
1529                Configuration::SinglePhase
1530            } else if conn_delta {
1531                Configuration::Delta
1532            } else {
1533                Configuration::Wye
1534            },
1535            p_nom: per_phase(kw),
1536            q_nom: per_phase(kvar),
1537            p_min: None,
1538            p_max: None,
1539            q_min: minkvar.map(per_phase),
1540            q_max: maxkvar.map(per_phase),
1541            cost: None,
1542            extras,
1543        }
1544    }
1545
1546    // ----- controls ------------------------------------------------------
1547
1548    fn swtcontrol(&mut self, obj: &RawObject) {
1549        let props = Props::new(obj);
1550        let Some(target) = props.get("switchedobj").map(|v| v.text.clone()) else {
1551            self.warn(format!("swtcontrol {}: no SwitchedObj; ignored", obj.name));
1552            return;
1553        };
1554        // Element references compare class names case insensitively, like
1555        // every dss identifier.
1556        let line_name = match target.split_once('.') {
1557            Some((class, rest)) if class.eq_ignore_ascii_case("line") => rest,
1558            _ => target.as_str(),
1559        };
1560        // The present state follows the last `action`/`state` assignment in
1561        // source order; `normal` applies only when neither was written.
1562        let mut open = None;
1563        for p in &obj.props {
1564            match p.name.as_deref() {
1565                Some("action" | "state") => {
1566                    open = Some(p.value.text.to_ascii_lowercase().starts_with('o'));
1567                }
1568                Some("normal") if open.is_none() => {
1569                    open = Some(p.value.text.to_ascii_lowercase().starts_with('o'));
1570                }
1571                _ => {}
1572            }
1573        }
1574        let open = open.unwrap_or(false);
1575        match self
1576            .net
1577            .switches
1578            .iter_mut()
1579            .find(|s| s.name.eq_ignore_ascii_case(line_name))
1580        {
1581            Some(sw) => sw.open = open,
1582            None => self.warn(format!(
1583                "swtcontrol {}: switched object `{target}` is not a switch line",
1584                obj.name
1585            )),
1586        }
1587    }
1588
1589    fn regcontrol(&mut self, obj: &RawObject) {
1590        let props = Props::new(obj);
1591        let target = props
1592            .get("transformer")
1593            .map_or_else(String::new, |v| v.text.clone());
1594        self.warn(format!(
1595            "regcontrol {}: voltage regulation is ignored; transformer `{target}` keeps its written taps",
1596            obj.name
1597        ));
1598        self.net.untyped.push(UntypedObject::from(obj));
1599    }
1600}
1601
1602/// Every entry times `k`.
1603fn scale_mat(m: &Mat, k: f64) -> Mat {
1604    m.iter()
1605        .map(|row| row.iter().map(|v| v * k).collect())
1606        .collect()
1607}
1608
1609fn filled_phase_nodes(spec: &BusSpec, phases: usize) -> Vec<i32> {
1610    let mut nodes: Vec<i32> = (1..=i32::try_from(phases).unwrap_or(i32::MAX)).collect();
1611    for (idx, &node) in spec.nodes.iter().enumerate().take(phases) {
1612        nodes[idx] = node.max(0);
1613    }
1614    nodes
1615}
1616
1617fn same_bus_ground_return(bus: &BusSpec, return_bus: &BusSpec, phases: usize) -> bool {
1618    bus.name.eq_ignore_ascii_case(&return_bus.name)
1619        && !return_bus.nodes.is_empty()
1620        && filled_phase_nodes(return_bus, phases)
1621            .iter()
1622            .all(|&n| n <= 0)
1623}
1624
1625/// The line to line branches of a delta bank over `n` terminals: a closed
1626/// ring for a 3+ phase bank, an open chain otherwise. Shared with the writer
1627/// so the reader and writer cannot disagree on the branch topology.
1628pub(super) fn delta_edges(n: usize, phases: usize) -> Vec<(usize, usize)> {
1629    if n < 2 {
1630        Vec::new()
1631    } else if phases >= 3 && n >= 3 {
1632        (0..n).map(|i| (i, (i + 1) % n)).collect()
1633    } else {
1634        let branches = phases.max(1).min(n - 1);
1635        (0..branches).map(|i| (i, i + 1)).collect()
1636    }
1637}
1638
1639fn kvar_shunt_matrix(
1640    map: &[String],
1641    phases: usize,
1642    conn_delta: bool,
1643    kvar: f64,
1644    v_ref: f64,
1645    b_sign: f64,
1646) -> Option<Mat> {
1647    let dim = map.len();
1648    let mut susceptance = vec![vec![0.0; dim]; dim];
1649    if conn_delta {
1650        let edges = delta_edges(dim, phases);
1651        if edges.is_empty() || map.iter().any(|t| t == "0") {
1652            return None;
1653        }
1654        let b_branch = b_sign * kvar * 1e3 / edges.len() as f64 / (v_ref * v_ref);
1655        for (from, to) in edges {
1656            susceptance[from][from] += b_branch;
1657            susceptance[to][to] += b_branch;
1658            susceptance[from][to] -= b_branch;
1659            susceptance[to][from] -= b_branch;
1660        }
1661    } else {
1662        let b_phase = b_sign * kvar * 1e3 / phases as f64 / (v_ref * v_ref);
1663        for (idx, row) in susceptance.iter_mut().enumerate().take(phases) {
1664            row[idx] = b_phase;
1665        }
1666    }
1667    Some(susceptance)
1668}
1669
1670fn bus_spec(v: Option<&Value>, fallback: &str) -> BusSpec {
1671    v.map_or_else(
1672        || Value::new(fallback).to_bus_spec(),
1673        super::lex::Value::to_bus_spec,
1674    )
1675}
1676
1677fn extras_from_leftovers(props: &Props) -> Extras {
1678    let mut extras = Extras::new();
1679    for (k, v) in props.leftovers() {
1680        extras.insert(k.to_string(), v.text.clone().into());
1681    }
1682    extras
1683}
1684
1685/// `buses=(...)` / `conns=(...)` applied across windings.
1686fn apply_winding_strings(windings: &mut [WindingRaw], name: &str, items: &[String]) {
1687    let conn_is_delta =
1688        |t: &str| t.to_ascii_lowercase().starts_with('d') || t.eq_ignore_ascii_case("ll");
1689    for (i, item) in items.iter().enumerate() {
1690        let w = &mut windings[i];
1691        if name == "buses" {
1692            w.bus = Some(Value::new(item.clone()).to_bus_spec());
1693        } else {
1694            w.conn_delta = conn_is_delta(item);
1695        }
1696    }
1697}
1698
1699/// A numeric transformer array (`kvs=(...)`, RPN entries included) applied
1700/// across windings.
1701fn apply_winding_numbers(windings: &mut [WindingRaw], name: &str, items: &[f64]) {
1702    for (i, &item) in items.iter().enumerate() {
1703        let w = &mut windings[i];
1704        match name {
1705            "kvs" => {
1706                w.kv = item;
1707                w.kv_specified = true;
1708            }
1709            "kvas" => {
1710                w.kva = item;
1711                w.kva_specified = true;
1712            }
1713            "taps" => w.tap = item,
1714            _ => w.r_pct = item,
1715        }
1716    }
1717}
1718
1719fn x_pair_key(name: &str) -> Option<(usize, usize)> {
1720    let rest = name.strip_prefix('x')?;
1721    if rest.len() != 2 || !rest.chars().all(|c| c.is_ascii_digit()) {
1722        return None;
1723    }
1724    let mut chars = rest.chars();
1725    let i = chars.next()?.to_digit(10)? as usize;
1726    let j = chars.next()?.to_digit(10)? as usize;
1727    if i == 0 || j == 0 || i == j {
1728        return None;
1729    }
1730    Some((i.min(j) - 1, i.max(j) - 1))
1731}
1732
1733/// A load's power state after the last edit boundary: the engine's
1734/// (kWBase, kvarBase, PFNominal, LoadSpecType), plus which of kw/pf were
1735/// ever written (for default provenance).
1736struct LoadPower {
1737    kw: f64,
1738    kvar: f64,
1739    pf: f64,
1740    /// LoadSpecType: false = 0 (kW + PF), true = 1 (kW + kvar).
1741    spec_kvar: bool,
1742    kw_written: bool,
1743    pf_written: bool,
1744}
1745
1746/// Series impedance of a linecode or inline line, per source length unit.
1747struct SeriesImpedance {
1748    r: Mat,
1749    x: Mat,
1750    c_nf: Mat,
1751    /// No matrix or sequence property was written at all.
1752    all_default: bool,
1753    /// Matrix properties written but unparseable as n x n, with their raw
1754    /// text (the engine rejects the whole script; the reader keeps them
1755    /// in extras).
1756    malformed: Vec<(&'static str, String)>,
1757}
1758
1759#[derive(Clone)]
1760struct WindingRaw {
1761    bus: Option<BusSpec>,
1762    conn_delta: bool,
1763    kv: f64,
1764    kva: f64,
1765    tap: f64,
1766    r_pct: f64,
1767    kv_specified: bool,
1768    kva_specified: bool,
1769}
1770
1771impl Default for WindingRaw {
1772    fn default() -> Self {
1773        WindingRaw {
1774            bus: None,
1775            conn_delta: false,
1776            kv: dd::transformer::KV,
1777            kva: dd::transformer::KVA,
1778            tap: dd::transformer::TAP,
1779            r_pct: dd::transformer::PCT_R,
1780            kv_specified: false,
1781            kva_specified: false,
1782        }
1783    }
1784}
1785
1786/// Grows the winding list to at least `n`, tracking the winding count.
1787fn grow(windings: &mut Vec<WindingRaw>, n: usize, count: &mut usize) {
1788    if n > windings.len() {
1789        windings.resize(n, WindingRaw::default());
1790        *count = n;
1791    }
1792}
1793
1794#[cfg(test)]
1795mod tests {
1796    use super::*;
1797
1798    fn has_warning(net: &DistNetwork, needle: &str) -> bool {
1799        net.warnings.iter().any(|w| w.contains(needle))
1800    }
1801
1802    #[test]
1803    fn vsource_magnitude_is_the_polygon_chord() {
1804        // VSource.cpp ~999-1002: one phase takes basekv outright, n > 1
1805        // divides by 2 sin(pi/n); sqrt(3) is the n = 3 special case.
1806        let net = parse_dss_str(
1807            "New Circuit.c basekv=12.47 pu=1.05 phases=2 bus1=src.1.2\n\
1808             New Vsource.aux basekv=12.47 phases=4 bus1=b2\n\
1809             New Vsource.solo basekv=2.4 phases=1 bus1=b3.1",
1810        );
1811        let two = &net.sources[0];
1812        assert!((two.v_magnitude[0] - 12.47e3 * 1.05 / 2.0).abs() < 1e-9);
1813        // Spacing is -360/n degrees: the second phase of a 2 phase source
1814        // wraps to +pi.
1815        assert!((two.v_angle[1] - std::f64::consts::PI).abs() < 1e-12);
1816        let four = &net.sources[1];
1817        let chord = 2.0 * (std::f64::consts::PI / 4.0).sin();
1818        assert!((four.v_magnitude[0] - 12.47e3 / chord).abs() < 1e-9);
1819        let solo = &net.sources[2];
1820        assert!((solo.v_magnitude[0] - 2.4e3).abs() < 1e-9);
1821    }
1822
1823    #[test]
1824    fn vsource_defaults_are_recorded() {
1825        let net = parse_dss_str("New Circuit.c1");
1826        let fields = net.defaulted.get("vsource.source").expect("entry");
1827        for key in ["phases", "pu", "angle", "basekv", "bus1"] {
1828            assert!(fields.contains(&key), "missing {key}");
1829        }
1830    }
1831
1832    /// One single phase linecode + line; (r per meter, length meters).
1833    fn r_and_length(lc_tail: &str, line_tail: &str) -> (f64, f64) {
1834        let net = parse_dss_str(&format!(
1835            "New Circuit.c\n\
1836             New Linecode.lc nphases=1 rmatrix=(0.5){lc_tail}\n\
1837             New Line.l1 bus1=a.1 bus2=b.1 phases=1 linecode=lc{line_tail}"
1838        ));
1839        let line = net.lines.iter().find(|l| l.name == "l1").unwrap();
1840        let code = net.linecode(&line.linecode).unwrap();
1841        (code.r_series[0][0], line.length)
1842    }
1843
1844    #[test]
1845    fn unitless_line_length_is_in_linecode_units() {
1846        // ConvertLineUnits is 1.0 when the line has no units, so the
1847        // engine reads `length=2` against a km linecode as 2 km:
1848        // 0.5 ohm/km * 2 km = 1 ohm total.
1849        let (r, len) = r_and_length(" units=km", " length=2");
1850        assert!((len - 2000.0).abs() < 1e-9);
1851        assert!((r * len - 1.0).abs() < 1e-12);
1852    }
1853
1854    #[test]
1855    fn unitless_linecode_is_per_line_unit() {
1856        // The mirror case: a unitless linecode is per line length unit,
1857        // so the raw length carries and the total is again 1 ohm.
1858        let (r, len) = r_and_length("", " length=2 units=km");
1859        assert!((len - 2.0).abs() < 1e-12);
1860        assert!((r * len - 1.0).abs() < 1e-12);
1861    }
1862
1863    #[test]
1864    fn written_units_on_both_sides_convert() {
1865        // 0.5 ohm/km over 500 m = 0.25 ohm.
1866        let (r, len) = r_and_length(" units=km", " length=500 units=m");
1867        assert!((len - 500.0).abs() < 1e-9);
1868        assert!((r * len - 0.25).abs() < 1e-12);
1869    }
1870
1871    #[test]
1872    fn no_units_anywhere_takes_the_raw_product() {
1873        let (r, len) = r_and_length("", " length=2");
1874        assert!((len - 2.0).abs() < 1e-12);
1875        assert!((r * len - 1.0).abs() < 1e-12);
1876    }
1877
1878    #[test]
1879    fn two_phase_wye_capacitor_kv_is_line_to_line() {
1880        // Capacitor.cpp ~621-630: PhasekV = kv/sqrt(3) for 2 AND 3 phase
1881        // wye banks, kv outright otherwise.
1882        let net = parse_dss_str(
1883            "New Circuit.c\n\
1884             New Capacitor.c2 bus1=b.1.2 phases=2 kv=12.47 kvar=600\n\
1885             New Capacitor.c1 bus1=b.3 phases=1 kv=7.2 kvar=300",
1886        );
1887        let c2 = net.shunts.iter().find(|s| s.name == "c2").unwrap();
1888        let v2 = 12.47e3 / 3f64.sqrt();
1889        assert!((c2.b[0][0] * v2 * v2 / 300e3 - 1.0).abs() < 1e-12);
1890        let c1 = net.shunts.iter().find(|s| s.name == "c1").unwrap();
1891        let v1 = 7.2e3;
1892        assert!((c1.b[0][0] * v1 * v1 / 300e3 - 1.0).abs() < 1e-12);
1893    }
1894
1895    #[test]
1896    fn capacitor_and_reactor_kvar_shunts_share_magnitude_with_opposite_sign() {
1897        let net = parse_dss_str(
1898            "New Circuit.c\n\
1899             New Capacitor.cap bus1=b.1 phases=1 kv=7.2 kvar=300\n\
1900             New Reactor.rea bus1=b.2 phases=1 kv=7.2 kvar=300",
1901        );
1902        let cap = net.shunts.iter().find(|s| s.name == "cap").unwrap();
1903        let rea = net.shunts.iter().find(|s| s.name == "rea").unwrap();
1904        assert!(cap.b[0][0] > 0.0);
1905        assert!(rea.b[0][0] < 0.0);
1906        assert!((cap.b[0][0] + rea.b[0][0]).abs() < 1e-18);
1907    }
1908
1909    #[test]
1910    fn kvar_shunts_with_nonpositive_phases_stay_untyped() {
1911        let net = parse_dss_str(
1912            "New Circuit.c\n\
1913             New Capacitor.cap bus1=b.1 phases=0 kv=7.2 kvar=300\n\
1914             New Reactor.rea bus1=b.2 phases=0 kv=7.2 kvar=300",
1915        );
1916        assert!(net.shunts.is_empty());
1917        assert!(
1918            net.untyped
1919                .iter()
1920                .any(|u| u.class.eq_ignore_ascii_case("capacitor") && u.name == "cap")
1921        );
1922        assert!(
1923            net.untyped
1924                .iter()
1925                .any(|u| u.class.eq_ignore_ascii_case("reactor") && u.name == "rea")
1926        );
1927        assert!(
1928            net.warnings
1929                .iter()
1930                .any(|w| w.contains("capacitor cap: nonpositive `phases`"))
1931        );
1932        assert!(
1933            net.warnings
1934                .iter()
1935                .any(|w| w.contains("reactor rea: nonpositive `phases`"))
1936        );
1937    }
1938
1939    #[test]
1940    fn ll_connection_means_delta() {
1941        // InterpretConnection maps `ll` to delta for every class.
1942        let net = parse_dss_str(
1943            "New Circuit.c\n\
1944             New Generator.g bus1=b.1.2.3 phases=3 conn=ll kw=90 kvar=30 kv=4.16\n\
1945             New Capacitor.cap bus1=b.1.2.3 phases=3 conn=ll kvar=600 kv=4.16",
1946        );
1947        assert_eq!(net.generators[0].configuration, Configuration::Delta);
1948        // `ll` capacitor banks use the delta shunt path.
1949        assert_eq!(net.shunts.len(), 1);
1950        let sh = &net.shunts[0];
1951        assert!(sh.b[0][1] < 0.0, "{:?}", sh.b);
1952        assert_eq!(sh.terminal_map, vec!["1", "2", "3"]);
1953        assert!(
1954            net.untyped
1955                .iter()
1956                .all(|u| !(u.class.eq_ignore_ascii_case("capacitor") && u.name == "cap"))
1957        );
1958    }
1959
1960    #[test]
1961    fn load_kw_after_kvar_reverts_to_pf() {
1962        // Load.cpp: kw flips LoadSpecType back to 0 (kW + PF), so the
1963        // earlier kvar is discarded and q comes from the default pf 0.88.
1964        let net =
1965            parse_dss_str("New Circuit.c\nNew Load.l bus1=b.1 phases=1 kv=2.4 kvar=20 kw=100");
1966        let l = &net.loads[0];
1967        let q: f64 = l.q_nom.iter().sum();
1968        assert!((q - 100e3 * 0.88f64.acos().tan()).abs() < 1e-6);
1969        assert_eq!(
1970            l.extras.get("pf").and_then(serde_json::Value::as_f64),
1971            Some(0.88)
1972        );
1973        assert!(
1974            net.defaulted
1975                .get("load.l")
1976                .is_some_and(|f| f.contains(&"pf"))
1977        );
1978    }
1979
1980    #[test]
1981    fn load_like_replays_the_sources_recalced_pf() {
1982        // Load.a ends its New under spec 1: recalc derives
1983        // PFNominal = 10/sqrt(10² + 20²) = 0.4472 (kw still the constructor
1984        // 10). MakeLike copies that recalced state, so b's kw=100 flips to
1985        // spec 0 and the end-of-edit recalc lands kvar =
1986        // 100·tan(acos(0.4472)) = 200, not the 53.97 a flat walk against
1987        // pf 0.88 would give. Confirmed against opendssdirect.
1988        let net = parse_dss_str(
1989            "New Circuit.c\n\
1990             New Load.a bus1=b.1 phases=1 kv=2.4 kvar=20\n\
1991             New Load.b like=a kw=100",
1992        );
1993        let b = net.loads.iter().find(|l| l.name == "b").unwrap();
1994        let q: f64 = b.q_nom.iter().sum();
1995        assert!((q - 200e3).abs() < 1e-6);
1996        // Final spec is 0: the writer emits pf=, the recalced 0.4472.
1997        let pf = b.extras.get("pf").and_then(serde_json::Value::as_f64);
1998        assert!((pf.unwrap() - 0.447_213_595_499_957_9).abs() < 1e-12);
1999        // The source itself keeps its written kvar.
2000        let a = net.loads.iter().find(|l| l.name == "a").unwrap();
2001        let qa: f64 = a.q_nom.iter().sum();
2002        assert!((qa - 20e3).abs() < 1e-9);
2003    }
2004
2005    #[test]
2006    fn load_tilde_continuation_recalcs_at_each_edit() {
2007        // Same numbers via `~`: the New line's recalc fixes pf at 0.4472,
2008        // the continuation's kw=100 reverts to spec 0 and its own recalc
2009        // gives kvar = 200. A flat last-write walk would say 53.97.
2010        let net = parse_dss_str(
2011            "New Circuit.c\n\
2012             New Load.l bus1=b.1 phases=1 kv=2.4 kvar=20\n\
2013             ~ kw=100",
2014        );
2015        let q: f64 = net.loads[0].q_nom.iter().sum();
2016        assert!((q - 200e3).abs() < 1e-6);
2017    }
2018
2019    #[test]
2020    fn load_pf_between_kvar_and_kw_applies() {
2021        // pf (case 5) updates PFNominal without touching the spec; the
2022        // later kw sets spec 0, so the single recalc uses pf 0.95:
2023        // q = 100·tan(acos(0.95)) = 32.868. Confirmed against
2024        // opendssdirect.
2025        let net = parse_dss_str(
2026            "New Circuit.c\nNew Load.l bus1=b.1 phases=1 kv=2.4 kvar=20 pf=0.95 kw=100",
2027        );
2028        let l = &net.loads[0];
2029        let q: f64 = l.q_nom.iter().sum();
2030        assert!((q - 100e3 * 0.95f64.acos().tan()).abs() < 1e-6);
2031        assert_eq!(
2032            l.extras.get("pf").and_then(serde_json::Value::as_f64),
2033            Some(0.95)
2034        );
2035        assert!(
2036            !net.defaulted
2037                .get("load.l")
2038                .is_some_and(|f| f.contains(&"pf"))
2039        );
2040    }
2041
2042    #[test]
2043    fn load_kvar_after_kw_stays() {
2044        let net =
2045            parse_dss_str("New Circuit.c\nNew Load.l bus1=b.1 phases=1 kv=2.4 kw=100 kvar=20");
2046        let l = &net.loads[0];
2047        let q: f64 = l.q_nom.iter().sum();
2048        assert!((q - 20e3).abs() < 1e-9);
2049        // The writer must emit kvar=, not pf=.
2050        assert!(!l.extras.contains_key("pf"));
2051    }
2052
2053    #[test]
2054    fn generator_kw_after_kvar_resyncs_q() {
2055        // Set_Presentkvar rederives PF from kW and kvar; the later kw
2056        // write resyncs kvar from that PF. Constructor kW is 1000, so
2057        // kvar=20 kw=100 scales q to 100 * 20/1000 = 2 kvar.
2058        let net =
2059            parse_dss_str("New Circuit.c\nNew Generator.g bus1=b.1 phases=1 kv=2.4 kvar=20 kw=100");
2060        let q: f64 = net.generators[0].q_nom.iter().sum();
2061        assert!((q - 2e3).abs() < 1e-6);
2062    }
2063
2064    #[test]
2065    fn generator_kvar_after_kw_stays() {
2066        let net =
2067            parse_dss_str("New Circuit.c\nNew Generator.g bus1=b.1 phases=1 kv=2.4 kw=100 kvar=20");
2068        let q: f64 = net.generators[0].q_nom.iter().sum();
2069        assert!((q - 20e3).abs() < 1e-9);
2070    }
2071
2072    #[test]
2073    fn generator_pf_after_kvar_wins() {
2074        // pf calls SyncUpPowerQuantities: kvar = kW tan(acos(pf)) with the
2075        // constructor kW 1000.
2076        let net = parse_dss_str(
2077            "New Circuit.c\nNew Generator.g bus1=b.1.2.3 phases=3 kv=4.16 kvar=20 pf=0.9",
2078        );
2079        let q: f64 = net.generators[0].q_nom.iter().sum();
2080        assert!((q - 1000e3 * 0.9f64.acos().tan()).abs() < 1e-3);
2081    }
2082
2083    #[test]
2084    fn malformed_matrix_warns_and_keeps_text() {
2085        // The engine rejects a bad rmatrix outright; the reader keeps
2086        // going on sequence values but must not call the property
2087        // defaulted, and the text must survive in extras.
2088        let net = parse_dss_str(
2089            "New Circuit.c\n\
2090             New Linecode.bad nphases=2 rmatrix=(1 2 3) units=m\n\
2091             New Line.l2 bus1=a.1.2 bus2=b.1.2 phases=2 rmatrix=(bogus) length=10",
2092        );
2093        assert!(has_warning(&net, "linecode bad") && has_warning(&net, "rmatrix"));
2094        assert!(
2095            !net.defaulted
2096                .get("linecode.bad")
2097                .is_some_and(|f| f.contains(&"rmatrix"))
2098        );
2099        let code = net.linecode("bad").unwrap();
2100        assert!(
2101            code.extras
2102                .get("rmatrix")
2103                .and_then(serde_json::Value::as_str)
2104                .is_some_and(|s| s.contains("1 2 3"))
2105        );
2106        // Sequence defaults filled in: diag (2 r1 + r0) / 3.
2107        let diag = (2.0 * dd::line::R1 + dd::line::R0) / 3.0;
2108        assert!((code.r_series[0][0] - diag).abs() < 1e-12);
2109        // The inline line path lands the text on the line's extras.
2110        assert!(has_warning(&net, "line l2"));
2111        let l2 = net.lines.iter().find(|l| l.name == "l2").unwrap();
2112        assert!(
2113            l2.extras
2114                .get("rmatrix")
2115                .and_then(serde_json::Value::as_str)
2116                .is_some_and(|s| s.contains("bogus"))
2117        );
2118    }
2119
2120    #[test]
2121    fn switchedobj_class_prefix_is_case_insensitive() {
2122        let net = parse_dss_str(
2123            "New Circuit.c\n\
2124             New Line.sw1 bus1=a.1 bus2=b.1 phases=1 switch=y\n\
2125             New SwtControl.s1 SwitchedObj=LINE.sw1 Action=open",
2126        );
2127        assert!(net.switches[0].open);
2128    }
2129
2130    #[test]
2131    fn phases_token_rides_in_extras() {
2132        // A 2 phase delta load has 3 conductors, indistinguishable from a
2133        // 3 phase delta by terminal map alone.
2134        let net = parse_dss_str(
2135            "New Circuit.c\n\
2136             New Load.l bus1=b.1.2 phases=2 conn=delta kw=50 kvar=10 kv=4.8\n\
2137             New Generator.g bus1=b.1.2.3 kw=10 kvar=2 kv=4.16\n\
2138             New Capacitor.cap bus1=b.1.2.3 phases=3 kvar=600 kv=4.16",
2139        );
2140        let l = &net.loads[0];
2141        assert_eq!(l.terminal_map.len(), 3);
2142        assert_eq!(
2143            l.extras.get("phases").and_then(serde_json::Value::as_str),
2144            Some("2")
2145        );
2146        // An unwritten phases= materializes the class default.
2147        assert_eq!(
2148            net.generators[0]
2149                .extras
2150                .get("phases")
2151                .and_then(serde_json::Value::as_u64),
2152            Some(3)
2153        );
2154        assert_eq!(
2155            net.shunts[0]
2156                .extras
2157                .get("phases")
2158                .and_then(serde_json::Value::as_str),
2159            Some("3")
2160        );
2161    }
2162
2163    #[test]
2164    fn rpn_kv_token_stashes_the_evaluated_value() {
2165        // The writer needs a number; RPN text would not read back.
2166        let net = parse_dss_str("New Circuit.c\nNew Load.l bus1=b.1 phases=1 kw=10 kv={4.8 2 /}");
2167        assert_eq!(
2168            net.loads[0]
2169                .extras
2170                .get("kv")
2171                .and_then(serde_json::Value::as_f64),
2172            Some(2.4)
2173        );
2174    }
2175}