Skip to main content

powerio_dist/dss/
read.rs

1//! `.dss` raw objects into the canonical [`MulticonductorNetwork`].
2//!
3//! Every OpenDSS default materializes into an explicit model value, recorded
4//! in [`MulticonductorNetwork::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, PathBuf};
16use std::sync::Arc;
17
18use super::defaults as dd;
19use super::lex::{BusSpec, Value, VarMap};
20use super::raw::{
21    RawDss, RawObject, canonical_case_root, confined_fs_read, parse_raw_with,
22    parse_raw_with_confined,
23};
24use crate::error::{Error, Result};
25use crate::geo::{CoordinateSpace, CoordsKind, GeoMeta, Location};
26use crate::model::{
27    ActivePowerReference, ActivePowerUnit, Configuration, ControlVoltageReference, DistBus,
28    DistControlProfile, DistGenerator, DistIbr, DistLine, DistLineCode, DistLoad,
29    DistLoadVoltageModel, DistShunt, DistSourceFormat, DistSwitch, DistTransformer, Extras,
30    IbrPrimeMover, IbrTopology, Mat, MulticonductorNetwork, PowerFactorControl,
31    ReactivePowerReference, ReactivePowerUnit, UntypedObject, VoltVarControl, VoltWattControl,
32    VoltageSource, Winding, WindingConn, pair_keys, square_from_rows,
33};
34
35/// Upper bound on any count property (`phases`, conductors, `windings`, tap
36/// counts). `phases` sizes a dense n×n matrix and the winding count drives a
37/// per-winding vector, so an unbounded value from a few bytes of input could
38/// demand gigabytes. No physical distribution element comes near this bound;
39/// it matches the winding and matrix-index caps in the BMOPF reader. A larger
40/// value is clamped to it with a warning.
41const MAX_COUNT: usize = 64;
42
43const TYPED_DSS_CLASSES: &[&str] = &[
44    "linecode",
45    "vsource",
46    "line",
47    "transformer",
48    "load",
49    "capacitor",
50    "reactor",
51    "generator",
52    "pvsystem",
53    "xycurve",
54    "invcontrol",
55    "swtcontrol",
56    "regcontrol",
57];
58
59/// Drop a leading UTF-8 byte order mark: the tokenizer would read it as part
60/// of the first command word. Redirected files are stripped the same way.
61fn strip_bom_owned(text: String) -> String {
62    match text.strip_prefix('\u{feff}') {
63        Some(stripped) => stripped.to_owned(),
64        None => text,
65    }
66}
67
68/// A redirect loader for confined file parsing: reads through
69/// [`confined_fs_read`], which refuses a path whose canonical (symlink
70/// resolved) form escapes the case directory, then strips a leading byte
71/// order mark and records which files carried one, so each strip can be
72/// itemized as a warning instead of happening silently.
73fn confined_bom_stripping_loader(
74    canonical_root: Option<PathBuf>,
75    stripped_paths: &mut Vec<String>,
76) -> impl FnMut(&Path) -> std::io::Result<String> + '_ {
77    move |p: &Path| {
78        confined_fs_read(canonical_root.as_deref(), p).map(|text| {
79            if text.starts_with('\u{feff}') {
80                stripped_paths.push(p.display().to_string());
81            }
82            strip_bom_owned(text)
83        })
84    }
85}
86
87fn warn_stripped_boms(
88    net: &mut MulticonductorNetwork,
89    root_had_bom: bool,
90    stripped_paths: Vec<String>,
91) {
92    if root_had_bom {
93        net.warnings.push(crate::convert::BOM_WARNING.to_owned());
94    }
95    for path in stripped_paths {
96        net.warnings
97            .push(format!("{path}: {}", crate::convert::BOM_WARNING));
98    }
99}
100
101/// Parses a `.dss` file, following includes, into the canonical model.
102/// `Redirect`/`Compile`/`Buscoords` includes are confined to the directory of
103/// `path`: an include that resolves outside that directory is refused with a
104/// warning — whether it climbs out with `..`, is an absolute path outside the
105/// directory, or escapes through a symbolic link. Inside that directory nothing
106/// is restricted: a case file reads any file placed beneath it, and the leading
107/// token of each line the parser does not recognize comes back in the warnings,
108/// so an untrusted case belongs in a directory of its own.
109/// (`Executor::resolve` documents the exact lexical rule that decides whether
110/// an absolute include counts as inside the directory.) The includes a single
111/// parse follows are budgeted in files and in bytes; a case that exhausts the
112/// budget stops following includes and records an `Error` finding.
113pub fn parse_dss_file(path: impl AsRef<Path>) -> Result<MulticonductorNetwork> {
114    let path = path.as_ref();
115    let text = std::fs::read_to_string(path).map_err(|source| Error::Io {
116        path: path.display().to_string(),
117        source,
118    })?;
119    let had_bom = text.starts_with('\u{feff}');
120    let text = strip_bom_owned(text);
121    let mut stripped_paths = Vec::new();
122    let raw = parse_raw_with_confined(
123        &text,
124        &path.display().to_string(),
125        &mut confined_bom_stripping_loader(canonical_case_root(path), &mut stripped_paths),
126    );
127    let mut net = network_from_raw(&raw, Arc::new(text));
128    warn_stripped_boms(&mut net, had_bom, stripped_paths);
129    Ok(net)
130}
131
132/// Parses `.dss` text. Filesystem includes are disabled: string input has no
133/// base directory, so `Redirect`/`Compile`/`Buscoords` read nothing and each
134/// is recorded as a warning. This keeps untrusted text (an uploaded case, say)
135/// from reading arbitrary local files. Use [`parse_dss_file`] to follow
136/// includes, which then stay confined to the case directory.
137pub fn parse_dss_str(text: &str) -> MulticonductorNetwork {
138    let stripped = text.trim_start_matches('\u{feff}');
139    let mut no_includes = |_path: &Path| -> std::io::Result<String> {
140        Err(std::io::Error::new(
141            std::io::ErrorKind::Unsupported,
142            "file includes are disabled when parsing from a string",
143        ))
144    };
145    let raw = parse_raw_with(stripped, "<string>", &mut no_includes);
146    let mut net = network_from_raw(&raw, Arc::new(stripped.to_string()));
147    warn_stripped_boms(&mut net, stripped.len() != text.len(), Vec::new());
148    net
149}
150
151/// Lowers an executed raw script into the typed model.
152pub fn network_from_raw(raw: &RawDss, source: Arc<String>) -> MulticonductorNetwork {
153    let mut rd = Reader {
154        net: MulticonductorNetwork {
155            name: raw.circuit_name.clone(),
156            base_frequency: dd::BASE_FREQUENCY,
157            source: Some(source),
158            source_format: Some(DistSourceFormat::Dss),
159            warnings: raw.warnings.clone(),
160            parse_diagnostics: raw.diagnostics.clone(),
161            ..MulticonductorNetwork::default()
162        },
163        buses: BTreeMap::new(),
164        bus_order: Vec::new(),
165        linecode_units: BTreeMap::new(),
166        linecode_nconds: BTreeMap::new(),
167        xycurves: BTreeMap::new(),
168        vars: &raw.vars,
169    };
170
171    for (name, value) in &raw.options {
172        // Set option names resolve by first match in the engine's option
173        // table order (Command.cpp Getcommand → HashList FindAbbrev), so
174        // `Set defaultb=50` is DefaultBaseFrequency but anything shorter
175        // ("default", "d") binds DefaultDaily; the bound sits at the unique
176        // resolution point.
177        if name.len() >= "defaultb".len() && "defaultbasefrequency".starts_with(name.as_str()) {
178            if let Ok(f) = value.to_f64(Some(rd.vars)) {
179                rd.net.base_frequency = f;
180            }
181        }
182        rd.net.options.push((name.clone(), value.text.clone()));
183    }
184    for cmd in &raw.commands {
185        rd.net.commands.push((cmd.verb.clone(), cmd.args.clone()));
186    }
187
188    // Linecodes first: lines reference them. Then everything else in script
189    // order per class.
190    for obj in raw.of_class("linecode") {
191        let lc = rd.linecode(obj);
192        rd.net.linecodes.push(lc);
193    }
194    for obj in raw.of_class("vsource") {
195        let vs = rd.vsource(obj);
196        rd.net.sources.push(vs);
197    }
198    for obj in raw.of_class("line") {
199        rd.line(obj);
200    }
201    for obj in raw.of_class("transformer") {
202        let t = rd.transformer(obj);
203        rd.net.transformers.push(t);
204    }
205    for obj in raw.of_class("load") {
206        let l = rd.load(obj);
207        rd.net.loads.push(l);
208    }
209    for obj in raw.of_class("capacitor") {
210        rd.capacitor(obj);
211    }
212    for obj in raw.of_class("reactor") {
213        rd.reactor(obj);
214    }
215    for obj in raw.of_class("generator") {
216        let g = rd.generator(obj);
217        rd.net.generators.push(g);
218    }
219    read_ibr_objects(&mut rd, raw);
220    for obj in raw.of_class("swtcontrol") {
221        rd.swtcontrol(obj);
222    }
223    for obj in raw.of_class("regcontrol") {
224        rd.regcontrol(obj);
225    }
226    for obj in &raw.objects {
227        if !TYPED_DSS_CLASSES.contains(&obj.class.as_str()) {
228            rd.net.untyped.push(UntypedObject::from(obj));
229        }
230    }
231
232    // A dangling linecode reference otherwise surfaces only at write time,
233    // as a `linecode=` naming nothing. Naming it here puts it next to the
234    // reason it usually dangles: a refused or missing redirect, whose own
235    // warning is already in this list. The line keeps the reference verbatim
236    // either way; the reader does not substitute a default impedance.
237    let known: std::collections::BTreeSet<String> = rd
238        .net
239        .linecodes
240        .iter()
241        .map(|c| c.name.to_ascii_lowercase())
242        .collect();
243    let missing: Vec<String> = rd
244        .net
245        .lines
246        .iter()
247        .filter(|l| !known.contains(&l.linecode.to_ascii_lowercase()))
248        .map(|l| {
249            format!(
250                "line {} references unknown linecode `{}`",
251                l.name, l.linecode
252            )
253        })
254        .collect();
255    rd.net.warnings.extend(missing);
256
257    finish_buses(rd, raw)
258}
259
260/// Materializes the accumulated bus states, ground markers, and coordinates.
261///
262/// Element processing records ground connections (node 0) verbatim; here
263/// each grounded bus gains an explicit perfectly grounded neutral terminal
264/// named `max(4, highest node + 1)`, the number PowerModelsDistribution
265/// and the public BMOPF examples give the materialized neutral, and every
266/// element terminal map is rewritten from "0" to it.
267fn finish_buses(mut rd: Reader, raw: &RawDss) -> MulticonductorNetwork {
268    let mut coords: BTreeMap<String, (f64, f64)> = BTreeMap::new();
269    for c in &raw.buscoords {
270        coords.insert(c.bus.to_ascii_lowercase(), (c.x, c.y));
271    }
272    let buses = std::mem::take(&mut rd.bus_order);
273    let states = std::mem::take(&mut rd.buses);
274    let mut net = rd.net;
275    let mut neutral_names: BTreeMap<String, String> = BTreeMap::new();
276    for id in buses {
277        let st = &states[&id];
278        let mut terminals: Vec<i32> = st.nodes.iter().copied().filter(|&n| n != 0).collect();
279        terminals.sort_unstable();
280        let mut bus = DistBus {
281            id: st.display.clone(),
282            terminals: terminals.iter().map(ToString::to_string).collect(),
283            ..DistBus::default()
284        };
285        if st.nodes.contains(&0) {
286            let neutral = terminals.last().map_or(4, |&n| n.max(3) + 1);
287            bus.terminals.push(neutral.to_string());
288            bus.grounded.push(neutral.to_string());
289            neutral_names.insert(id.clone(), neutral.to_string());
290        }
291        if let Some((x, y)) = coords.get(&id) {
292            bus.location = Some(Location {
293                x: *x,
294                y: *y,
295                kind: None,
296            });
297        }
298        net.buses.push(bus);
299    }
300    if !coords.is_empty() {
301        net.geo = Some(GeoMeta {
302            space: CoordinateSpace::Unknown,
303            kind: Some(CoordsKind::Source),
304        });
305        if coords
306            .values()
307            .all(|(x, y)| (-180.0..=180.0).contains(x) && (-90.0..=90.0).contains(y))
308        {
309            net.warnings.push(
310                "OpenDSS buscoords fit longitude/latitude ranges; coordinate space remains unknown because Buscoords does not declare a CRS".to_owned(),
311            );
312        }
313    }
314
315    let rewrite = |bus: &str, map: &mut [String]| {
316        if let Some(neutral) = neutral_names.get(&bus.to_ascii_lowercase()) {
317            for t in map.iter_mut().filter(|t| *t == "0") {
318                t.clone_from(neutral);
319            }
320        }
321    };
322    for l in &mut net.lines {
323        rewrite(&l.bus_from, &mut l.terminal_map_from);
324        rewrite(&l.bus_to, &mut l.terminal_map_to);
325    }
326    for s in &mut net.switches {
327        rewrite(&s.bus_from, &mut s.terminal_map_from);
328        rewrite(&s.bus_to, &mut s.terminal_map_to);
329    }
330    for l in &mut net.loads {
331        rewrite(&l.bus, &mut l.terminal_map);
332    }
333    for g in &mut net.generators {
334        rewrite(&g.bus, &mut g.terminal_map);
335    }
336    for s in &mut net.shunts {
337        rewrite(&s.bus, &mut s.terminal_map);
338    }
339    for v in &mut net.sources {
340        rewrite(&v.bus, &mut v.terminal_map);
341    }
342    for t in &mut net.transformers {
343        for w in &mut t.windings {
344            rewrite(&w.bus, &mut w.terminal_map);
345        }
346    }
347    net
348}
349
350fn read_ibr_objects(rd: &mut Reader<'_>, raw: &RawDss) {
351    for obj in raw.of_class("pvsystem") {
352        rd.pvsystem(obj);
353    }
354    for obj in raw.of_class("xycurve") {
355        rd.xycurve(obj);
356    }
357    for obj in raw.of_class("invcontrol") {
358        rd.invcontrol(obj);
359    }
360}
361
362impl From<&RawObject> for UntypedObject {
363    fn from(obj: &RawObject) -> Self {
364        UntypedObject {
365            class: obj.class.clone(),
366            name: obj.name.clone(),
367            props: obj
368                .props
369                .iter()
370                .map(|p| (p.name.clone(), p.value.text.clone()))
371                .collect(),
372        }
373    }
374}
375
376struct BusState {
377    display: String,
378    nodes: std::collections::BTreeSet<i32>,
379}
380
381struct Reader<'a> {
382    net: MulticonductorNetwork,
383    buses: BTreeMap<String, BusState>,
384    bus_order: Vec<String>,
385    /// Linecode name (lowercase) → meters per its length unit, `None` when
386    /// the linecode has no units. Lines need it: `ConvertLineUnits` couples
387    /// the two sides' units.
388    linecode_units: BTreeMap<String, Option<f64>>,
389    /// Linecode name (lowercase) → conductor count. Lines need it:
390    /// `linecode=` sets the line's phase count in the engine
391    /// (Line.cpp FetchLineCode), exactly like `phases=`.
392    linecode_nconds: BTreeMap<String, usize>,
393    xycurves: BTreeMap<String, XyCurveRaw>,
394    vars: &'a VarMap,
395}
396
397/// Last-wins view of an object's resolved properties, plus the set of names
398/// actually written (for provenance and extras).
399struct Props<'a> {
400    by_name: BTreeMap<&'a str, &'a Value>,
401    consumed: std::cell::RefCell<Vec<&'a str>>,
402}
403
404impl<'a> Props<'a> {
405    fn new(obj: &'a RawObject) -> Self {
406        let mut by_name = BTreeMap::new();
407        for p in &obj.props {
408            if let Some(n) = &p.name {
409                by_name.insert(n.as_str(), &p.value);
410            }
411        }
412        Props {
413            by_name,
414            consumed: std::cell::RefCell::new(Vec::new()),
415        }
416    }
417
418    fn get(&self, name: &'a str) -> Option<&'a Value> {
419        self.consumed.borrow_mut().push(name);
420        self.by_name.get(name).copied()
421    }
422
423    /// Specified properties the typed fields did not consume, for extras.
424    fn leftovers(&self) -> Vec<(&str, &Value)> {
425        let consumed = self.consumed.borrow();
426        self.by_name
427            .iter()
428            .filter(|(k, _)| !consumed.contains(*k) && **k != "like")
429            .map(|(k, v)| (*k, *v))
430            .collect()
431    }
432}
433
434/// Reactor properties that set the impedance directly. When any is present
435/// the engine takes its SpecType from the impedance and ignores `kvar`/`kv`,
436/// so the kvar-shunt typing does not apply and the object stays untyped.
437/// `parallel` (series vs parallel R-X) and `rp` (a parallel damping
438/// resistance) are modifiers, not a SpecType of their own: a `kvar` reactor
439/// that also sets them is still a kvar shunt, so they are not listed here.
440const REACTOR_IMPEDANCE_FORMS: &[&str] = &[
441    "rmatrix", "xmatrix", "r", "x", "z1", "z2", "z0", "z", "rcurve", "lcurve", "lmh",
442];
443
444#[derive(Clone, Copy)]
445struct KvarShuntSpec {
446    class: &'static str,
447    series_name: &'static str,
448    default_phases: usize,
449    default_kvar: f64,
450    default_kv: f64,
451    b_sign: f64,
452}
453
454const CAPACITOR_KVAR_SHUNT: KvarShuntSpec = KvarShuntSpec {
455    class: "capacitor",
456    series_name: "capacitors",
457    default_phases: dd::capacitor::PHASES,
458    default_kvar: dd::capacitor::KVAR,
459    default_kv: dd::capacitor::KV,
460    b_sign: 1.0,
461};
462
463const REACTOR_KVAR_SHUNT: KvarShuntSpec = KvarShuntSpec {
464    class: "reactor",
465    series_name: "reactors",
466    default_phases: dd::reactor::PHASES,
467    default_kvar: dd::reactor::KVAR,
468    default_kv: dd::reactor::KV,
469    b_sign: -1.0,
470};
471
472#[derive(Clone, Debug)]
473struct XyCurveRaw {
474    x: Vec<f64>,
475    y: Vec<f64>,
476}
477
478impl Reader<'_> {
479    fn warn(&mut self, msg: impl Into<String>) {
480        self.net.warnings.push(msg.into());
481    }
482
483    fn defaulted(&mut self, class: &str, name: &str, field: &'static str) {
484        let fields = self
485            .net
486            .defaulted
487            .entry(format!("{class}.{name}"))
488            .or_default();
489        if !fields.contains(&field) {
490            fields.push(field);
491        }
492    }
493
494    fn f64_prop(&mut self, p: Option<&Value>) -> Option<f64> {
495        p.and_then(|v| v.to_f64(Some(self.vars)).ok())
496    }
497
498    fn usize_prop(&mut self, p: Option<&Value>) -> Option<usize> {
499        p.and_then(|v| v.to_i64(Some(self.vars)).ok()).map(|i| {
500            let n = usize::try_from(i).unwrap_or(0);
501            if n > MAX_COUNT {
502                self.net.warnings.push(format!(
503                    "count property {n} exceeds the supported maximum of {MAX_COUNT}; clamped"
504                ));
505                MAX_COUNT
506            } else {
507                n
508            }
509        })
510    }
511
512    /// Meters per source length unit, or `None` when no conversion applies:
513    /// the property is missing, `none`, or a code `GetUnitsCode`
514    /// (Shared/LineUnits.cpp) does not recognize — the engine maps unknown
515    /// codes to UNITS_NONE. Unknown codes warn.
516    fn units_code(&mut self, units: Option<&str>, class: &str, name: &str) -> Option<f64> {
517        let u = units?;
518        if let Some(f) = dd::unit_to_meters(u) {
519            return Some(f);
520        }
521        if !u.to_ascii_lowercase().starts_with("no") {
522            self.net.warnings.push(format!(
523                "{class} {name}: unknown units `{u}`; treated as none"
524            ));
525        }
526        None
527    }
528
529    /// Extras value for a written numeric token: the literal text when it
530    /// is already a plain number, otherwise the evaluated value — RPN or
531    /// `@var` text is no use to the dss writer, which needs an argument the
532    /// engine can read back.
533    fn stash_numeric(&self, v: &Value) -> serde_json::Value {
534        if v.text.parse::<f64>().is_ok() {
535            v.text.clone().into()
536        } else {
537            match v.to_f64(Some(self.vars)) {
538                Ok(n) => n.into(),
539                Err(_) => v.text.clone().into(),
540            }
541        }
542    }
543
544    /// `kv` and `phases` for the dss writer: the written token (evaluated
545    /// when not a plain number), the materialized default otherwise.
546    fn stash_kv_and_phases(&self, props: &Props, extras: &mut Extras, kv: f64, phases: usize) {
547        let kv_value = match props.by_name.get("kv") {
548            Some(written) => self.stash_numeric(written),
549            None => kv.into(),
550        };
551        extras.insert("kv".into(), kv_value);
552        let phases_value = match props.by_name.get("phases") {
553            Some(written) => self.stash_numeric(written),
554            None => (phases as u64).into(),
555        };
556        extras.insert("phases".into(), phases_value);
557        // A 1 phase delta types as SinglePhase, indistinguishable from a wye
558        // spot load without the written token; the writer reads this stash to
559        // re-emit conn=delta.
560        if let Some(written) = props.by_name.get("conn") {
561            extras.insert("conn".into(), written.text.clone().into());
562        }
563    }
564
565    /// The property's value, or the class default recorded with provenance.
566    fn f64_or(
567        &mut self,
568        props: &Props,
569        key: &'static str,
570        class: &str,
571        name: &str,
572        default: f64,
573    ) -> f64 {
574        if let Some(v) = self.f64_prop(props.get(key)) {
575            v
576        } else {
577            self.defaulted(class, name, key);
578            default
579        }
580    }
581
582    fn usize_or(
583        &mut self,
584        props: &Props,
585        key: &'static str,
586        class: &str,
587        name: &str,
588        default: usize,
589    ) -> usize {
590        if let Some(v) = self.usize_prop(props.get(key)) {
591            v
592        } else {
593            self.defaulted(class, name, key);
594            default
595        }
596    }
597
598    /// Registers a bus connection and returns the terminal names for the
599    /// element. `phases` conductors default to nodes 1..=phases; conductors
600    /// beyond that default to ground. `keep` limits how many conductors the
601    /// terminal map lists (delta maps exclude the unused trailing conductor).
602    fn terminals(
603        &mut self,
604        spec: &BusSpec,
605        phases: usize,
606        nconds: usize,
607        keep: usize,
608    ) -> Vec<String> {
609        let mut nodes: Vec<i32> = (1..=i32::try_from(nconds).unwrap_or(i32::MAX)).collect();
610        for n in nodes.iter_mut().skip(phases) {
611            *n = 0;
612        }
613        for (i, &n) in spec.nodes.iter().enumerate().take(nconds) {
614            nodes[i] = n.max(0); // parser marks bad nodes -1; treat as ground
615        }
616        let key = spec.name.to_ascii_lowercase();
617        let state = self.buses.entry(key.clone()).or_insert_with(|| {
618            self.bus_order.push(key.clone());
619            BusState {
620                display: spec.name.clone(),
621                nodes: std::collections::BTreeSet::new(),
622            }
623        });
624        for &n in nodes.iter().take(keep) {
625            state.nodes.insert(n);
626        }
627        nodes.truncate(keep);
628        nodes.iter().map(ToString::to_string).collect()
629    }
630
631    // ----- linecode ------------------------------------------------------
632
633    fn linecode(&mut self, obj: &RawObject) -> DistLineCode {
634        let props = Props::new(obj);
635        let n = self.usize_or(
636            &props,
637            "nphases",
638            "linecode",
639            &obj.name,
640            dd::linecode::NPHASES,
641        );
642        let units = props.get("units").map(|v| v.text.clone());
643        let units_m = self.units_code(units.as_deref(), "linecode", &obj.name);
644        let per_meter = units_m.unwrap_or(1.0);
645        self.linecode_units
646            .insert(obj.name.to_ascii_lowercase(), units_m);
647        self.linecode_nconds
648            .insert(obj.name.to_ascii_lowercase(), n);
649
650        let freq = self
651            .f64_prop(props.get("basefreq"))
652            .unwrap_or(self.net.base_frequency);
653
654        let z = self.impedance_matrices(
655            &props,
656            n,
657            "linecode",
658            &obj.name,
659            dd::line::R1,
660            dd::line::X1,
661            dd::line::R0,
662            dd::line::X0,
663            dd::line::C1_NF,
664            dd::line::C0_NF,
665        );
666        if z.all_default {
667            self.defaulted("linecode", &obj.name, "rmatrix");
668        }
669
670        // Half the total line charging susceptance at each end; OpenDSS
671        // carries one C matrix for the whole pi section.
672        let b_half = scale_mat(
673            &z.c_nf,
674            std::f64::consts::TAU * freq * 1e-9 / per_meter / 2.0,
675        );
676        let zero = vec![vec![0.0; n]; n];
677
678        // i_max carries the emergency rating: PMD's cm_ub and the public
679        // BMOPF examples both use emergamps. normamps stays in extras.
680        let amps = self.f64_or(
681            &props,
682            "emergamps",
683            "linecode",
684            &obj.name,
685            dd::line::EMERGAMPS,
686        );
687        let i_max = Some(vec![amps; n]);
688
689        let mut extras = extras_from_leftovers(&props);
690        if let Some(u) = units {
691            extras.insert("units".into(), u.into());
692        }
693        for (key, text) in z.malformed {
694            extras.insert(key.to_string(), text.into());
695        }
696        DistLineCode {
697            name: obj.name.clone(),
698            n_conductors: n,
699            r_series: scale_mat(&z.r, 1.0 / per_meter),
700            x_series: scale_mat(&z.x, 1.0 / per_meter),
701            g_from: zero.clone(),
702            b_from: b_half.clone(),
703            g_to: zero,
704            b_to: b_half,
705            i_max,
706            s_max: None,
707            source: None,
708            extras,
709        }
710    }
711
712    /// R, X (ohm per unit length) and C (nF per unit length) matrices from
713    /// either explicit matrices or sequence values.
714    #[allow(clippy::too_many_arguments)]
715    fn impedance_matrices(
716        &mut self,
717        props: &Props,
718        n: usize,
719        class: &str,
720        name: &str,
721        r1d: f64,
722        x1d: f64,
723        r0d: f64,
724        x0d: f64,
725        c1d: f64,
726        c0d: f64,
727    ) -> SeriesImpedance {
728        let mut malformed: Vec<(&'static str, String)> = Vec::new();
729        let mut rows = |key: &'static str| -> Option<Mat> {
730            let v = props.get(key)?;
731            let parsed = v
732                .to_rows(Some(self.vars))
733                .ok()
734                .and_then(|rows| square_from_rows(&rows, n));
735            if parsed.is_none() {
736                malformed.push((key, v.text.clone()));
737            }
738            parsed
739        };
740        let rm = rows("rmatrix");
741        let xm = rows("xmatrix");
742        let cm = rows("cmatrix");
743        // The engine rejects the whole script on a bad matrix; the liberal
744        // reader falls back to the sequence values but says so and keeps
745        // the text. A written property is never reported as defaulted.
746        for (key, _) in &malformed {
747            self.warn(format!(
748                "{class} {name}: `{key}` does not parse as a {n}x{n} matrix; \
749                 sequence values apply and the text is kept in extras"
750            ));
751        }
752        let any_written = [
753            "rmatrix", "xmatrix", "cmatrix", "r1", "x1", "r0", "x0", "c1", "c0", "b1", "b0",
754        ]
755        .iter()
756        .any(|k| props.by_name.contains_key(*k));
757
758        let seq = |props: &Props, k1: &'static str, k0: &'static str, d1: f64, d0: f64| {
759            let v1 = props
760                .get(k1)
761                .and_then(|v| v.to_f64(Some(self.vars)).ok())
762                .unwrap_or(d1);
763            let v0 = props
764                .get(k0)
765                .and_then(|v| v.to_f64(Some(self.vars)).ok())
766                .unwrap_or(d0);
767            if n == 1 {
768                return vec![vec![v1]];
769            }
770            // Symmetric component to phase: diag (2 z1 + z0)/3, off
771            // diagonal (z0 - z1)/3.
772            let s = (2.0 * v1 + v0) / 3.0;
773            let m = (v0 - v1) / 3.0;
774            let mut mat = vec![vec![m; n]; n];
775            for (i, row) in mat.iter_mut().enumerate() {
776                row[i] = s;
777            }
778            mat
779        };
780
781        SeriesImpedance {
782            r: rm.unwrap_or_else(|| seq(props, "r1", "r0", r1d, r0d)),
783            x: xm.unwrap_or_else(|| seq(props, "x1", "x0", x1d, x0d)),
784            c_nf: cm.unwrap_or_else(|| seq(props, "c1", "c0", c1d, c0d)),
785            all_default: !any_written,
786            malformed,
787        }
788    }
789
790    // ----- vsource -------------------------------------------------------
791
792    fn vsource(&mut self, obj: &RawObject) -> VoltageSource {
793        let props = Props::new(obj);
794        let phases = self.usize_or(&props, "phases", "vsource", &obj.name, dd::vsource::PHASES);
795        let basekv = self.f64_or(&props, "basekv", "vsource", &obj.name, dd::vsource::BASEKV);
796        let pu = self.f64_or(&props, "pu", "vsource", &obj.name, dd::vsource::PU);
797        let angle_deg = self.f64_or(
798            &props,
799            "angle",
800            "vsource",
801            &obj.name,
802            dd::vsource::ANGLE_DEG,
803        );
804        let spec = if let Some(v) = props.get("bus1") {
805            v.to_bus_spec()
806        } else {
807            self.defaulted("vsource", &obj.name, "bus1");
808            Value::new(dd::vsource::BUS1).to_bus_spec()
809        };
810        let map = self.terminals(&spec, phases, phases + 1, phases + 1);
811
812        // VSource.cpp ~995-1003: one phase takes basekv outright, otherwise
813        // the per phase magnitude is basekv / (2 sin(pi/n)) — the chord of
814        // the n-gon, which is sqrt(3) only at n = 3. Angles space at
815        // -360/n degrees (positive sequence, ~1272), wrapped to (-180, 180]
816        // in radians, matching the reference conversion.
817        let v_ln = if phases == 1 {
818            basekv * 1e3 * pu
819        } else {
820            basekv * 1e3 * pu / (2.0 * (std::f64::consts::PI / phases as f64).sin())
821        };
822        let mut v_magnitude = vec![v_ln; phases];
823        let mut v_angle: Vec<f64> = (0..phases)
824            .map(|k| {
825                let deg = angle_deg - 360.0 / phases as f64 * k as f64;
826                let a = deg.to_radians();
827                // rem_euclid yields [0, tau); shifting puts the result in
828                // [-pi, pi), and the reference maps the open end to +pi.
829                let shifted = (a + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU);
830                if shifted <= 0.0 {
831                    std::f64::consts::PI
832                } else {
833                    shifted - std::f64::consts::PI
834                }
835            })
836            .collect();
837        // The neutral conductor rides at ground.
838        v_magnitude.push(0.0);
839        v_angle.push(0.0);
840
841        // The raw base voltage rides in extras: the magnitudes fold in pu,
842        // and downstream writers need the unscaled base.
843        let mut extras = extras_from_leftovers(&props);
844        extras.insert("basekv".into(), basekv.into());
845        extras.insert("angle".into(), angle_deg.into());
846        if (pu - 1.0).abs() > 0.0 {
847            extras.insert("pu".into(), pu.into());
848        }
849        VoltageSource {
850            name: obj.name.clone(),
851            bus: spec.name,
852            terminal_map: map,
853            v_magnitude,
854            v_angle,
855            extras,
856        }
857    }
858
859    // ----- line / switch -------------------------------------------------
860
861    fn line(&mut self, obj: &RawObject) {
862        let props = Props::new(obj);
863        // `linecode=` assigns the line's phase count from the code
864        // (Line.cpp FetchLineCode) exactly like `phases=`; properties
865        // apply in order, so the later of the two wins. Bus node lists
866        // materialize after the whole script parses (MakeBusList), so
867        // they always see the final count regardless of where the bus
868        // properties sit.
869        let explicit = self.usize_prop(props.get("phases"));
870        let from_code = props.get("linecode").and_then(|c| {
871            self.linecode_nconds
872                .get(&c.text.to_ascii_lowercase())
873                .copied()
874        });
875        let linecode_last = obj
876            .props
877            .iter()
878            .rev()
879            .find_map(|p| match p.name.as_deref() {
880                Some("phases") => Some(false),
881                Some("linecode") => Some(true),
882                _ => None,
883            })
884            .unwrap_or(false);
885        let phases = match (explicit, from_code) {
886            (Some(_), Some(n)) if linecode_last => n,
887            (Some(p), _) => p,
888            (None, Some(n)) => n,
889            (None, None) => dd::line::PHASES,
890        };
891        let spec1 = bus_spec(props.get("bus1"), "");
892        let spec2 = bus_spec(props.get("bus2"), "");
893        // A line has no neutral conductor of its own: nconds == phases.
894        let map_from = self.terminals(&spec1, phases, phases, phases);
895        let map_to = self.terminals(&spec2, phases, phases, phases);
896
897        let is_switch = props.get("switch").is_some_and(super::lex::Value::to_bool);
898        if is_switch {
899            let amps = self.f64_or(&props, "emergamps", "line", &obj.name, dd::line::EMERGAMPS);
900            let i_max = Some(vec![amps; phases]);
901            let mut extras = extras_from_leftovers(&props);
902            // OpenDSS replaces a switch line's impedance with fixed dummy
903            // values; record anything written so nothing drops silently.
904            for k in ["linecode", "length", "r1", "x1", "rmatrix", "xmatrix"] {
905                if let Some(v) = props.by_name.get(k) {
906                    extras.insert(k.to_string(), v.text.clone().into());
907                    self.warn(format!(
908                        "line {}: `{k}` is ignored by OpenDSS on switch=yes; kept in extras",
909                        obj.name
910                    ));
911                }
912            }
913            self.net.switches.push(DistSwitch {
914                name: obj.name.clone(),
915                bus_from: spec1.name,
916                bus_to: spec2.name,
917                terminal_map_from: map_from,
918                terminal_map_to: map_to,
919                open: false,
920                i_max,
921                extras,
922            });
923            return;
924        }
925
926        let length_units = props.get("units").map(|v| v.text.clone());
927        let line_units_m = self.units_code(length_units.as_deref(), "line", &obj.name);
928        let length = self.f64_or(&props, "length", "line", &obj.name, dd::line::LENGTH);
929
930        // ConvertLineUnits (Shared/LineUnits.cpp ~166) is 1.0 when either
931        // side is UNITS_NONE, and the engine scales the linecode matrices
932        // by Len / FUnitsConvert (Line.cpp ~1177). A unitless line length
933        // is therefore in the linecode's units, and a unitless linecode is
934        // per line length unit, so the raw length preserves the Z·length
935        // product.
936        let mut malformed: Vec<(&'static str, String)> = Vec::new();
937        let (linecode, length_factor, synthesized) = if let Some(code) = props.get("linecode") {
938            let lc_units_m = self
939                .linecode_units
940                .get(&code.text.to_ascii_lowercase())
941                .copied()
942                .flatten();
943            let factor = match (lc_units_m, line_units_m) {
944                (Some(_), Some(lf)) => lf,
945                (Some(lcf), None) => lcf,
946                (None, _) => 1.0,
947            };
948            (code.text.clone(), factor, false)
949        } else {
950            let factor = line_units_m.unwrap_or(1.0);
951            let (code, bad) = self.synthesize_linecode(&props, phases, factor, &obj.name);
952            malformed = bad;
953            (code, factor, true)
954        };
955
956        let (i_max, raw_emergamps) = self.line_rating(&props, phases, synthesized);
957        let mut extras = extras_from_leftovers(&props);
958        if let Some(u) = length_units {
959            extras.insert("units".into(), u.into());
960        }
961        if let Some(text) = raw_emergamps {
962            extras.insert("emergamps".into(), text.into());
963        }
964        for (key, text) in malformed {
965            extras.insert(key.to_string(), text.into());
966        }
967        self.net.lines.push(DistLine {
968            name: obj.name.clone(),
969            bus_from: spec1.name,
970            bus_to: spec2.name,
971            terminal_map_from: map_from,
972            terminal_map_to: map_to,
973            linecode,
974            length: length * length_factor,
975            route: None,
976            i_max,
977            s_max: None,
978            extras,
979        });
980    }
981
982    /// The line's own `i_max`, and the raw `emergamps` token to keep in extras.
983    ///
984    /// A line with no `linecode=` gets a synthetic one holding its own
985    /// impedance, and `synthesize_linecode` already read `emergamps` into that
986    /// linecode's `i_max`. Repeating it on the line would state a per line
987    /// override of a shared default that does not exist: the synthetic
988    /// linecode belongs to this line alone.
989    fn line_rating(
990        &self,
991        props: &Props,
992        phases: usize,
993        synthesized_linecode: bool,
994    ) -> (Option<Vec<f64>>, Option<String>) {
995        // `emergamps` on a Line reads as that line's `i_max`, one entry for
996        // each phase, the same mapping a linecode uses. An absent property
997        // leaves the linecode rating in control; an unparsable token returns
998        // as text.
999        if synthesized_linecode {
1000            return (None, None);
1001        }
1002        let Some(v) = props.get("emergamps") else {
1003            return (None, None);
1004        };
1005        match v.to_f64(Some(self.vars)) {
1006            Ok(amps) => (Some(vec![amps; phases]), None),
1007            Err(_) => (None, Some(v.text.clone())),
1008        }
1009    }
1010
1011    /// A line without `linecode=` carries inline or default impedance;
1012    /// materialize it as a linecode named `_line_<name>` in the line's own
1013    /// length units. Malformed matrix texts return for the line's extras.
1014    fn synthesize_linecode(
1015        &mut self,
1016        props: &Props,
1017        phases: usize,
1018        length_factor: f64,
1019        line_name: &str,
1020    ) -> (String, Vec<(&'static str, String)>) {
1021        let z = self.impedance_matrices(
1022            props,
1023            phases,
1024            "line",
1025            line_name,
1026            dd::line::R1,
1027            dd::line::X1,
1028            dd::line::R0,
1029            dd::line::X0,
1030            dd::line::C1_NF,
1031            dd::line::C0_NF,
1032        );
1033        if z.all_default {
1034            self.defaulted("line", line_name, "r1");
1035            self.defaulted("line", line_name, "x1");
1036        }
1037        let b_half = scale_mat(
1038            &z.c_nf,
1039            std::f64::consts::TAU * self.net.base_frequency * 1e-9 / length_factor / 2.0,
1040        );
1041        let zero = vec![vec![0.0; phases]; phases];
1042        let amps = self.f64_or(props, "emergamps", "line", line_name, dd::line::EMERGAMPS);
1043        let i_max = Some(vec![amps; phases]);
1044        let name = format!("_line_{line_name}");
1045        self.net.linecodes.push(DistLineCode {
1046            name: name.clone(),
1047            n_conductors: phases,
1048            r_series: scale_mat(&z.r, 1.0 / length_factor),
1049            x_series: scale_mat(&z.x, 1.0 / length_factor),
1050            g_from: zero.clone(),
1051            b_from: b_half.clone(),
1052            g_to: zero,
1053            b_to: b_half,
1054            i_max,
1055            s_max: None,
1056            source: None,
1057            extras: Extras::new(),
1058        });
1059        (name, z.malformed)
1060    }
1061
1062    // ----- load ----------------------------------------------------------
1063
1064    /// Final (kWBase, kvarBase, PFNominal, LoadSpecType) after the last
1065    /// edit boundary, with write provenance for kw and pf.
1066    ///
1067    /// Load.cpp runs RecalcElementData at the end of EVERY Edit (~773), so
1068    /// kw/kvar/pf fold per edit, not flat. Within an edit, kw (case 4,
1069    /// ~691) sets LoadSpecType 0 (kW + PF), kvar (case 12, ~753) sets 1
1070    /// (kW + kvar), and pf (case 5, ~699) updates PFNominal without
1071    /// touching the spec. The boundary recalc (~1342) rederives kvar from
1072    /// kW and PF under spec 0, and PFNominal from kW and kvar under spec 1
1073    /// (~1352-1360). like= splices the source's boundaries in the raw
1074    /// layer, matching MakeLike's copy of the recalced state.
1075    fn load_power(&mut self, obj: &RawObject) -> LoadPower {
1076        let mut s = LoadPower {
1077            kw: dd::load::KW,
1078            // Constructor kvarBase is 5.0, never observable: spec 1
1079            // requires a kvar write and the first spec 0 boundary
1080            // overwrites the seed.
1081            kvar: 0.0,
1082            pf: dd::load::PF,
1083            spec_kvar: false, // LoadSpecType: false = 0, true = 1
1084            kw_written: false,
1085            pf_written: false,
1086        };
1087        let mut start = 0;
1088        for end in obj.edit_bounds() {
1089            for p in &obj.props[start..end] {
1090                let Some(key @ ("kw" | "kvar" | "pf")) = p.name.as_deref() else {
1091                    continue;
1092                };
1093                let Some(v) = self.f64_prop(Some(&p.value)) else {
1094                    continue;
1095                };
1096                match key {
1097                    "kw" => {
1098                        s.kw = v;
1099                        s.spec_kvar = false;
1100                        s.kw_written = true;
1101                    }
1102                    "kvar" => {
1103                        s.kvar = v;
1104                        s.spec_kvar = true;
1105                    }
1106                    _ => {
1107                        s.pf = v;
1108                        s.pf_written = true;
1109                    }
1110                }
1111            }
1112            start = end;
1113            // RecalcElementData at the edit boundary.
1114            if s.spec_kvar {
1115                let kva = s.kw.hypot(s.kvar);
1116                if kva > 0.0 {
1117                    s.pf = s.kw / kva;
1118                    // Mixed signs make PF negative (Sign(kWBase*kvarBase)).
1119                    if s.kw * s.kvar < 0.0 {
1120                        s.pf = -s.pf;
1121                    }
1122                }
1123            } else {
1124                s.kvar = s.kw * (1.0 / (s.pf * s.pf) - 1.0).sqrt();
1125                if s.pf < 0.0 {
1126                    s.kvar = -s.kvar;
1127                }
1128            }
1129        }
1130        s
1131    }
1132
1133    fn load(&mut self, obj: &RawObject) -> DistLoad {
1134        let props = Props::new(obj);
1135        let phases = self.usize_or(&props, "phases", "load", &obj.name, dd::load::PHASES);
1136        let conn_delta = props.get("conn").is_some_and(|v| {
1137            v.text.to_ascii_lowercase().starts_with('d') || v.text.eq_ignore_ascii_case("ll")
1138        });
1139        let kv = self.f64_or(&props, "kv", "load", &obj.name, dd::load::KV);
1140        let LoadPower {
1141            kw,
1142            kvar: q_total,
1143            pf,
1144            spec_kvar,
1145            kw_written,
1146            pf_written,
1147        } = self.load_power(obj);
1148        if !kw_written {
1149            self.defaulted("load", &obj.name, "kw");
1150        }
1151        // Mark the walked properties consumed so they stay out of extras.
1152        let _ = (props.get("kw"), props.get("kvar"), props.get("pf"));
1153        // When the final spec is 0, q derives from the power factor; the
1154        // source pf rides in extras so the dss writer can emit pf= and let
1155        // the engine do its own trigonometry — transcendental rounding
1156        // across implementations would otherwise leak into regenerated
1157        // cases. Under spec 1 the writer emits kvar=.
1158        let mut pf_source: Option<f64> = None;
1159        if !spec_kvar {
1160            if !pf_written {
1161                self.defaulted("load", &obj.name, "pf");
1162            }
1163            pf_source = Some(pf);
1164        }
1165        let model = self
1166            .usize_prop(props.get("model"))
1167            .map_or(dd::load::MODEL, |m| i64::try_from(m).unwrap_or(i64::MAX));
1168
1169        let spec = bus_spec(props.get("bus1"), "");
1170        let nconds = if conn_delta && phases == 3 {
1171            phases
1172        } else {
1173            phases + 1
1174        };
1175        let map = self.terminals(&spec, phases, nconds, nconds);
1176
1177        let configuration = if phases == 1 {
1178            Configuration::SinglePhase
1179        } else if conn_delta {
1180            Configuration::Delta
1181        } else {
1182            Configuration::Wye
1183        };
1184
1185        // kv is the load's own base and model its dss load model code;
1186        // both ride in extras for the writers (the kv default materializes
1187        // here like every other constructor default), while the typed
1188        // fields hold explicit power per phase. phases rides too: a 2
1189        // phase delta load also has 3 conductors, so the terminal map
1190        // alone cannot reconstruct `phases=`.
1191        let mut extras = extras_from_leftovers(&props);
1192        self.stash_kv_and_phases(&props, &mut extras, kv, phases);
1193        if let Some(pf) = pf_source {
1194            extras.insert("pf".into(), pf.into());
1195        }
1196        if model != 1 {
1197            extras.insert("model".into(), model.into());
1198        }
1199        let v_phase = if phases >= 2 && configuration == Configuration::Wye {
1200            kv * 1e3 / 3f64.sqrt()
1201        } else {
1202            kv * 1e3
1203        };
1204        let v_nom = vec![v_phase; phases];
1205        let zipv = props
1206            .get("zipv")
1207            .and_then(|v| v.to_vector(Some(self.vars)).ok())
1208            .unwrap_or_default();
1209        let voltage_model = match model {
1210            2 => DistLoadVoltageModel::ConstantImpedance { v_nom },
1211            5 => DistLoadVoltageModel::ConstantCurrent { v_nom },
1212            8 if zipv.len() >= 6 => DistLoadVoltageModel::Zip {
1213                v_nom,
1214                alpha_z: vec![zipv[0]; phases],
1215                alpha_i: vec![zipv[1]; phases],
1216                alpha_p: vec![zipv[2]; phases],
1217                beta_z: vec![zipv[3]; phases],
1218                beta_i: vec![zipv[4]; phases],
1219                beta_p: vec![zipv[5]; phases],
1220            },
1221            8 => DistLoadVoltageModel::Zip {
1222                v_nom,
1223                alpha_z: Vec::new(),
1224                alpha_i: Vec::new(),
1225                alpha_p: Vec::new(),
1226                beta_z: Vec::new(),
1227                beta_i: Vec::new(),
1228                beta_p: Vec::new(),
1229            },
1230            _ => DistLoadVoltageModel::ConstantPower { v_nom },
1231        };
1232        DistLoad {
1233            name: obj.name.clone(),
1234            bus: spec.name,
1235            terminal_map: map,
1236            configuration,
1237            p_nom: vec![kw * 1e3 / phases as f64; phases],
1238            q_nom: vec![q_total * 1e3 / phases as f64; phases],
1239            voltage_model,
1240            extras,
1241        }
1242    }
1243
1244    // ----- transformer ---------------------------------------------------
1245
1246    #[allow(clippy::too_many_lines)] // OpenDSS transformer edits must be replayed in order
1247    fn transformer(&mut self, obj: &RawObject) -> DistTransformer {
1248        // Order matters: wdg= switches the winding under edit, windings=
1249        // reallocates. Walk assignments sequentially.
1250        let mut phases = dd::transformer::PHASES;
1251        let mut n_windings = dd::transformer::WINDINGS;
1252        let mut windings = vec![WindingRaw::default(); n_windings];
1253        let mut active = 0usize;
1254        let mut xhl = dd::transformer::XHL;
1255        let mut xht = dd::transformer::XHT;
1256        let mut xlt = dd::transformer::XLT;
1257        let mut xhl_specified = false;
1258        let mut x_pairs: BTreeMap<(usize, usize), f64> = BTreeMap::new();
1259        let mut extras = Extras::new();
1260        let conn_is_delta =
1261            |t: &str| t.to_ascii_lowercase().starts_with('d') || t.eq_ignore_ascii_case("ll");
1262        for p in &obj.props {
1263            let Some(name) = &p.name else { continue };
1264            let v = &p.value;
1265            match name.as_str() {
1266                "phases" => {
1267                    phases = self.usize_prop(Some(v)).unwrap_or(phases);
1268                }
1269                "windings" => {
1270                    n_windings = self.usize_prop(Some(v)).unwrap_or(n_windings).max(1);
1271                    windings = vec![WindingRaw::default(); n_windings];
1272                    active = 0;
1273                }
1274                "wdg" => {
1275                    let k = self.usize_prop(Some(v)).unwrap_or(1).max(1);
1276                    grow(
1277                        &mut windings,
1278                        k,
1279                        &mut n_windings,
1280                        &obj.name,
1281                        &mut self.net.warnings,
1282                    );
1283                    active = k - 1;
1284                }
1285                "bus" => windings[active].bus = Some(v.to_bus_spec()),
1286                "conn" => windings[active].conn_delta = conn_is_delta(&v.text),
1287                "kv" | "kva" | "tap" | "%r" | "rneut" | "xneut" => {
1288                    let parsed = self.f64_prop(Some(v));
1289                    let w = &mut windings[active];
1290                    match name.as_str() {
1291                        "kv" => {
1292                            w.kv = parsed.unwrap_or(w.kv);
1293                            w.kv_specified = true;
1294                        }
1295                        "kva" => {
1296                            w.kva = parsed.unwrap_or(w.kva);
1297                            w.kva_specified = true;
1298                        }
1299                        "tap" => w.tap = parsed.unwrap_or(w.tap),
1300                        "%r" => w.r_pct = parsed.unwrap_or(w.r_pct),
1301                        "rneut" => w.r_neutral = parsed,
1302                        "xneut" => w.x_neutral = parsed,
1303                        _ => unreachable!("matched transformer scalar property"),
1304                    }
1305                }
1306                "buses" | "conns" => {
1307                    let items = v.to_string_list(Some(self.vars));
1308                    grow(
1309                        &mut windings,
1310                        items.len(),
1311                        &mut n_windings,
1312                        &obj.name,
1313                        &mut self.net.warnings,
1314                    );
1315                    apply_winding_strings(&mut windings, name, &items);
1316                }
1317                "kvs" | "kvas" | "taps" | "%rs" => match v.to_vector(Some(self.vars)) {
1318                    Ok(items) => {
1319                        grow(
1320                            &mut windings,
1321                            items.len(),
1322                            &mut n_windings,
1323                            &obj.name,
1324                            &mut self.net.warnings,
1325                        );
1326                        apply_winding_numbers(&mut windings, name, &items);
1327                    }
1328                    Err(e) => self.warn(format!("transformer {}: {name}: {e}", obj.name)),
1329                },
1330                "%loadloss" => {
1331                    // The engine splits load loss across the first two
1332                    // windings: %R each = %loadloss / 2 (Transformer.cpp,
1333                    // property 26). The written value also rides in extras
1334                    // for the canonical echo.
1335                    if let Some(ll) = self.f64_prop(Some(v)) {
1336                        for w in windings.iter_mut().take(2) {
1337                            w.r_pct = ll / 2.0;
1338                        }
1339                    }
1340                    extras.insert("%loadloss".to_string(), v.text.clone().into());
1341                }
1342                "xhl" | "x12" => {
1343                    xhl = self.f64_prop(Some(v)).unwrap_or(xhl);
1344                    xhl_specified = true;
1345                    x_pairs.insert((0, 1), xhl);
1346                }
1347                "xht" | "x13" => {
1348                    xht = self.f64_prop(Some(v)).unwrap_or(xht);
1349                    x_pairs.insert((0, 2), xht);
1350                }
1351                "xlt" | "x23" => {
1352                    xlt = self.f64_prop(Some(v)).unwrap_or(xlt);
1353                    x_pairs.insert((1, 2), xlt);
1354                }
1355                other if x_pair_key(other).is_some() => {
1356                    if let Some((i, j)) = x_pair_key(other) {
1357                        let x = self.f64_prop(Some(v)).unwrap_or(0.0);
1358                        x_pairs.insert((i, j), x);
1359                    }
1360                }
1361                other => {
1362                    extras.insert(other.to_string(), v.text.clone().into());
1363                }
1364            }
1365        }
1366
1367        if !xhl_specified {
1368            self.defaulted("transformer", &obj.name, "xhl");
1369        }
1370        let out = self.finish_windings(&windings, phases, &obj.name);
1371
1372        let xsc_pct = if n_windings >= 3 {
1373            pair_keys(n_windings)
1374                .into_iter()
1375                .map(|pair| {
1376                    x_pairs.get(&pair).copied().unwrap_or(match pair {
1377                        (0, 1) => xhl,
1378                        (0, 2) => xht,
1379                        (1, 2) => xlt,
1380                        _ => 0.0,
1381                    })
1382                })
1383                .collect()
1384        } else {
1385            vec![xhl]
1386        };
1387        DistTransformer {
1388            name: obj.name.clone(),
1389            windings: out,
1390            xsc_pct,
1391            phases,
1392            extras,
1393        }
1394    }
1395
1396    /// Resolves winding bus specs, terminal maps, and SI ratings, recording
1397    /// provenance for defaulted kv/kva.
1398    fn finish_windings(
1399        &mut self,
1400        windings: &[WindingRaw],
1401        phases: usize,
1402        name: &str,
1403    ) -> Vec<Winding> {
1404        let mut out = Vec::with_capacity(windings.len());
1405        for (i, w) in windings.iter().enumerate() {
1406            if !w.kv_specified {
1407                self.defaulted("transformer", name, "kv");
1408            }
1409            if !w.kva_specified {
1410                self.defaulted("transformer", name, "kva");
1411            }
1412            let spec = w
1413                .bus
1414                .clone()
1415                .unwrap_or_else(|| Value::new(format!("{name}_w{}", i + 1)).to_bus_spec());
1416            // Each winding terminal has phases + 1 conductors; wye keeps the
1417            // neutral in the map, delta leaves the unused conductor out. A
1418            // delta winding is wired line to line, so a single phase delta leg
1419            // (an open delta secondary, bus spec `.1.2`) still spans two phase
1420            // terminals; keep both rather than collapsing to one.
1421            let keep = if w.conn_delta {
1422                phases.max(2)
1423            } else {
1424                phases + 1
1425            };
1426            let map = self.terminals(&spec, phases, phases + 1, keep);
1427            out.push(Winding {
1428                bus: spec.name,
1429                terminal_map: map,
1430                conn: if w.conn_delta {
1431                    WindingConn::Delta
1432                } else {
1433                    WindingConn::Wye
1434                },
1435                v_ref: w.kv * 1e3,
1436                s_rating: w.kva * 1e3,
1437                r_pct: w.r_pct,
1438                tap: w.tap,
1439                r_neutral: w.r_neutral,
1440                x_neutral: w.x_neutral,
1441            });
1442        }
1443        out
1444    }
1445
1446    // ----- capacitor → shunt ---------------------------------------------
1447
1448    fn capacitor(&mut self, obj: &RawObject) {
1449        self.kvar_shunt(obj, CAPACITOR_KVAR_SHUNT);
1450    }
1451
1452    // ----- reactor → shunt -----------------------------------------------
1453
1454    /// A grounding (shunt) reactor specified by `kvar`/`kv` maps to a shunt
1455    /// with inductive (negative) susceptance, the sign mirror of a capacitor.
1456    /// A reactor from a bus terminal to the same bus's node 0 is also a shunt;
1457    /// when it uses `r`/`x`, store the equivalent conductance and susceptance.
1458    /// Other `bus2` reactors are series elements and stay untyped.
1459    fn reactor(&mut self, obj: &RawObject) {
1460        let props = Props::new(obj);
1461        let phases = self.usize_or(&props, "phases", "reactor", &obj.name, dd::reactor::PHASES);
1462        if phases == 0 {
1463            self.warn(format!(
1464                "reactor {}: nonpositive `phases` value is not a typed shunt; kept untyped",
1465                obj.name
1466            ));
1467            self.net.untyped.push(UntypedObject::from(obj));
1468            return;
1469        }
1470        let bus = bus_spec(props.get("bus1"), "");
1471        let bus2 = props.get("bus2").map(super::lex::Value::to_bus_spec);
1472        let explicit_single_grounding = bus2
1473            .as_ref()
1474            .is_some_and(|return_bus| explicit_single_terminal_ground_return(&bus, return_bus));
1475        let grounding_return = explicit_single_grounding
1476            || bus2
1477                .as_ref()
1478                .is_some_and(|return_bus| same_bus_ground_return(&bus, return_bus, phases));
1479
1480        if bus2.is_some() && !grounding_return {
1481            self.warn(format!(
1482                "reactor {}: series reactors (bus2) are not typed yet; kept untyped",
1483                obj.name
1484            ));
1485            self.net.untyped.push(UntypedObject::from(obj));
1486            return;
1487        }
1488
1489        if let Some(form) = REACTOR_IMPEDANCE_FORMS
1490            .iter()
1491            .find(|k| !matches!(**k, "r" | "x") && props.by_name.contains_key(**k))
1492        {
1493            self.warn(format!(
1494                "reactor {}: impedance form (`{form}`) is not typed yet; kept untyped",
1495                obj.name
1496            ));
1497            self.net.untyped.push(UntypedObject::from(obj));
1498            return;
1499        }
1500        let has_rx = props.by_name.contains_key("r") || props.by_name.contains_key("x");
1501        if has_rx {
1502            if grounding_return {
1503                let grounding_phases = if explicit_single_grounding { 1 } else { phases };
1504                self.grounding_impedance_reactor(obj, &props, &bus, grounding_phases);
1505            } else {
1506                let form = if props.by_name.contains_key("r") {
1507                    "r"
1508                } else {
1509                    "x"
1510                };
1511                self.warn(format!(
1512                    "reactor {}: impedance form (`{form}`) is not typed yet; kept untyped",
1513                    obj.name
1514                ));
1515                self.net.untyped.push(UntypedObject::from(obj));
1516            }
1517            return;
1518        }
1519
1520        self.kvar_shunt_with_props(obj, &props, REACTOR_KVAR_SHUNT);
1521    }
1522
1523    fn grounding_impedance_reactor(
1524        &mut self,
1525        obj: &RawObject,
1526        props: &Props<'_>,
1527        bus: &BusSpec,
1528        phases: usize,
1529    ) {
1530        // An absent `r`/`x` key defaults to 0, but a key whose token fails to
1531        // evaluate keeps the object untyped instead of silently substituting 0,
1532        // which would emit a lossless grounding reactor with no warning that
1533        // the resistance was dropped.
1534        let term = |v: Option<&Value>| v.map_or(Ok(0.0), |val| val.to_f64(Some(self.vars)));
1535        let (Ok(resistance), Ok(reactance)) = (term(props.get("r")), term(props.get("x"))) else {
1536            self.warn(format!(
1537                "reactor {}: `r`/`x` does not evaluate to a number; kept untyped",
1538                obj.name
1539            ));
1540            self.net.untyped.push(UntypedObject::from(obj));
1541            return;
1542        };
1543        let denom = resistance * resistance + reactance * reactance;
1544        if !denom.is_finite() || denom <= 0.0 {
1545            self.warn(format!(
1546                "reactor {}: zero impedance grounding reactor is not a typed shunt; kept untyped",
1547                obj.name
1548            ));
1549            self.net.untyped.push(UntypedObject::from(obj));
1550            return;
1551        }
1552        let map = self.terminals(bus, phases, phases + 1, phases);
1553        let dim = map.len();
1554        let mut conductance = vec![vec![0.0; dim]; dim];
1555        let mut susceptance = vec![vec![0.0; dim]; dim];
1556        let y_g = resistance / denom;
1557        let y_b = -reactance / denom;
1558        for idx in 0..dim {
1559            conductance[idx][idx] = y_g;
1560            susceptance[idx][idx] = y_b;
1561        }
1562        self.net.shunts.push(DistShunt {
1563            name: obj.name.clone(),
1564            bus: bus.name.clone(),
1565            terminal_map: map,
1566            g: conductance,
1567            b: susceptance,
1568            extras: extras_from_leftovers(props),
1569        });
1570    }
1571
1572    fn kvar_shunt(&mut self, obj: &RawObject, spec: KvarShuntSpec) {
1573        let props = Props::new(obj);
1574        self.kvar_shunt_with_props(obj, &props, spec);
1575    }
1576
1577    fn kvar_shunt_with_props(&mut self, obj: &RawObject, props: &Props<'_>, spec: KvarShuntSpec) {
1578        let phases = self.usize_or(props, "phases", spec.class, &obj.name, spec.default_phases);
1579        if phases == 0 {
1580            self.warn(format!(
1581                "{} {}: nonpositive `phases` value is not a typed shunt; kept untyped",
1582                spec.class, obj.name
1583            ));
1584            self.net.untyped.push(UntypedObject::from(obj));
1585            return;
1586        }
1587        // InterpretConnection: `d*` and `ll` are delta for both Capacitor and
1588        // Reactor. Delta banks are line to line shunts represented by a nodal
1589        // admittance matrix.
1590        let conn_delta = props.get("conn").is_some_and(|v| {
1591            v.text.to_ascii_lowercase().starts_with('d') || v.text.eq_ignore_ascii_case("ll")
1592        });
1593        let bus = bus_spec(props.get("bus1"), "");
1594        if let Some(return_bus) = props.get("bus2").map(super::lex::Value::to_bus_spec) {
1595            if !same_bus_ground_return(&bus, &return_bus, phases) {
1596                self.warn(format!(
1597                    "{} {}: series {} (bus2) are not typed yet; kept untyped",
1598                    spec.class, obj.name, spec.series_name
1599                ));
1600                self.net.untyped.push(UntypedObject::from(obj));
1601                return;
1602            }
1603        }
1604
1605        if conn_delta && phases == 1 && bus.nodes.len() < 2 {
1606            self.warn(format!(
1607                "{} {}: single phase delta shunt needs two bus nodes; kept untyped",
1608                spec.class, obj.name
1609            ));
1610            self.net.untyped.push(UntypedObject::from(obj));
1611            return;
1612        }
1613        // Read the first kvar array entry, as the DSS engine does for a
1614        // grounding shunt bank.
1615        let kvar = props
1616            .get("kvar")
1617            .and_then(|v| v.to_vector(Some(self.vars)).ok())
1618            .and_then(|v| v.first().copied())
1619            .unwrap_or_else(|| {
1620                self.defaulted(spec.class, &obj.name, "kvar");
1621                spec.default_kvar
1622            });
1623        let kv = self.f64_or(props, "kv", spec.class, &obj.name, spec.default_kv);
1624        // A wye bank's kv is line to line for 2 or 3 phases, line to neutral
1625        // otherwise. A delta bank's kv is line to line across each branch.
1626        let v_ref = if conn_delta {
1627            kv * 1e3
1628        } else if phases == 2 || phases == 3 {
1629            kv * 1e3 / 3f64.sqrt()
1630        } else {
1631            kv * 1e3
1632        };
1633        // `kvar_shunt_matrix` divides by `v_ref * v_ref`; a positive but tiny
1634        // `v_ref` can square to zero (or a non-finite) and turn the admittance
1635        // into an infinity, so reject the squared value here too.
1636        let v_sq = v_ref * v_ref;
1637        if !v_ref.is_finite() || v_ref <= 0.0 || !v_sq.is_finite() || v_sq == 0.0 {
1638            self.warn(format!(
1639                "{} {}: invalid `kv` value is not a typed shunt; kept untyped",
1640                spec.class, obj.name
1641            ));
1642            self.net.untyped.push(UntypedObject::from(obj));
1643            return;
1644        }
1645
1646        let (nconds, keep) = if conn_delta {
1647            let keep = match phases {
1648                1 => 2,
1649                2 => 3,
1650                _ => phases,
1651            };
1652            (keep, keep)
1653        } else {
1654            // The default return is the same bus's ground; register the ground
1655            // connection but keep the map and matrices phase only, the shape a
1656            // shunt-to-ground admittance has downstream.
1657            (phases + 1, phases)
1658        };
1659        let map = self.terminals(&bus, phases, nconds, keep);
1660        let Some(susceptance) =
1661            kvar_shunt_matrix(&map, phases, conn_delta, kvar, v_ref, spec.b_sign)
1662        else {
1663            self.warn(format!(
1664                "{} {}: delta shunt terminal map is not typed; kept untyped",
1665                spec.class, obj.name
1666            ));
1667            self.net.untyped.push(UntypedObject::from(obj));
1668            return;
1669        };
1670        let mut extras = extras_from_leftovers(props);
1671        self.stash_kv_and_phases(props, &mut extras, kv, phases);
1672        extras.insert("kvar".into(), kvar.into());
1673        if conn_delta {
1674            extras.insert("conn".into(), "delta".into());
1675        }
1676        self.net.shunts.push(DistShunt {
1677            name: obj.name.clone(),
1678            bus: bus.name,
1679            terminal_map: map,
1680            g: vec![vec![0.0; susceptance.len()]; susceptance.len()],
1681            b: susceptance,
1682            extras,
1683        });
1684    }
1685
1686    // ----- generator -----------------------------------------------------
1687
1688    fn generator(&mut self, obj: &RawObject) -> DistGenerator {
1689        let props = Props::new(obj);
1690        let phases = self.usize_or(
1691            &props,
1692            "phases",
1693            "generator",
1694            &obj.name,
1695            dd::generator::PHASES,
1696        );
1697        // InterpretConnection (generator.cpp ~299): `d*` and `ll` are delta.
1698        let conn_delta = props.get("conn").is_some_and(|v| {
1699            v.text.to_ascii_lowercase().starts_with('d') || v.text.eq_ignore_ascii_case("ll")
1700        });
1701        // generator.cpp: kw and pf writes (props 4-5, side effect ~588)
1702        // call SyncUpPowerQuantities (~3879), rederiving kvar from kW and
1703        // PF; a kvar write (Set_Presentkvar, ~3857) stores kvar and
1704        // rederives PF from kW and kvar. The state carries across writes
1705        // in source order, seeded by the constructor values. Verified
1706        // asymmetry with Load: the generator resyncs eagerly AT each write
1707        // and has no end-of-edit recalc, so a flat fold over all writes is
1708        // correct here while loads need the per edit boundary walk above.
1709        let mut kw = dd::generator::KW;
1710        let mut kvar = dd::generator::KVAR;
1711        let mut pf = dd::generator::PF;
1712        let (mut kw_written, mut q_written) = (false, false);
1713        for p in &obj.props {
1714            let Some(key @ ("kw" | "kvar" | "pf")) = p.name.as_deref() else {
1715                continue;
1716            };
1717            let Some(v) = self.f64_prop(Some(&p.value)) else {
1718                continue;
1719            };
1720            match key {
1721                "kw" | "pf" => {
1722                    if key == "kw" {
1723                        kw = v;
1724                        kw_written = true;
1725                    } else {
1726                        pf = v;
1727                        q_written = true;
1728                    }
1729                    if pf != 0.0 {
1730                        kvar = kw * (pf.acos().tan()).copysign(pf);
1731                    }
1732                }
1733                _ => {
1734                    kvar = v;
1735                    q_written = true;
1736                    let kva = kw.hypot(kvar);
1737                    pf = if kva == 0.0 { 1.0 } else { kw / kva };
1738                    if kw * kvar < 0.0 {
1739                        pf = -pf;
1740                    }
1741                }
1742            }
1743        }
1744        if !kw_written {
1745            self.defaulted("generator", &obj.name, "kw");
1746        }
1747        if !q_written {
1748            self.defaulted("generator", &obj.name, "kvar");
1749        }
1750        // Mark the walked properties consumed so they stay out of extras.
1751        let _ = (props.get("kw"), props.get("kvar"), props.get("pf"));
1752        let kv = self.f64_or(&props, "kv", "generator", &obj.name, dd::generator::KV);
1753        let maxkvar = self.f64_prop(props.get("maxkvar"));
1754        let minkvar = self.f64_prop(props.get("minkvar"));
1755
1756        let spec = bus_spec(props.get("bus1"), "");
1757        let nconds = if conn_delta && phases == 3 {
1758            phases
1759        } else {
1760            phases + 1
1761        };
1762        let map = self.terminals(&spec, phases, nconds, nconds);
1763
1764        let per_phase = |total_kw: f64| vec![total_kw * 1e3 / phases as f64; phases];
1765        let mut extras = extras_from_leftovers(&props);
1766        self.stash_kv_and_phases(&props, &mut extras, kv, phases);
1767        DistGenerator {
1768            name: obj.name.clone(),
1769            bus: spec.name,
1770            terminal_map: map,
1771            configuration: if phases == 1 {
1772                Configuration::SinglePhase
1773            } else if conn_delta {
1774                Configuration::Delta
1775            } else {
1776                Configuration::Wye
1777            },
1778            p_nom: per_phase(kw),
1779            q_nom: per_phase(kvar),
1780            p_min: None,
1781            p_max: None,
1782            q_min: minkvar.map(per_phase),
1783            q_max: maxkvar.map(per_phase),
1784            cost: None,
1785            s_max: None,
1786            i_max: None,
1787            extras,
1788        }
1789    }
1790
1791    // ----- PVSystem / InvControl ----------------------------------------
1792
1793    fn pvsystem(&mut self, obj: &RawObject) {
1794        let props = Props::new(obj);
1795        let phases = self.usize_or(
1796            &props,
1797            "phases",
1798            "pvsystem",
1799            &obj.name,
1800            dd::pvsystem::PHASES,
1801        );
1802        if phases == 0 {
1803            self.warn(format!(
1804                "pvsystem {}: nonpositive `phases` value is not typed; kept untyped",
1805                obj.name
1806            ));
1807            self.net.untyped.push(UntypedObject::from(obj));
1808            return;
1809        }
1810        let conn_delta = props.get("conn").is_some_and(|v| {
1811            v.text.to_ascii_lowercase().starts_with('d') || v.text.eq_ignore_ascii_case("ll")
1812        });
1813        let spec = bus_spec(props.get("bus1"), "");
1814        let nconds = if conn_delta && phases == 3 {
1815            phases
1816        } else {
1817            phases + 1
1818        };
1819        let map = self.terminals(&spec, phases, nconds, nconds);
1820        let kv = self.f64_or(&props, "kv", "pvsystem", &obj.name, dd::pvsystem::KV);
1821        let irradiance = self.f64_or(
1822            &props,
1823            "irradiance",
1824            "pvsystem",
1825            &obj.name,
1826            dd::pvsystem::IRRADIANCE,
1827        );
1828        let pmpp = self.f64_or(&props, "pmpp", "pvsystem", &obj.name, dd::pvsystem::PMPP);
1829        let pct_pmpp = self
1830            .f64_prop(props.get("%pmpp"))
1831            .or_else(|| self.f64_prop(props.get("pctpmpp")))
1832            .unwrap_or(dd::pvsystem::PCT_PMPP);
1833        let kva = self
1834            .f64_prop(props.get("kva"))
1835            .unwrap_or(pmpp.max(f64::EPSILON));
1836        let per_phase = |total_kw: f64| vec![total_kw * 1e3 / phases as f64; phases];
1837        let p_avail = pmpp * irradiance * pct_pmpp / 100.0 * 1e3;
1838        let q_max = self
1839            .f64_prop(props.get("kvarmax"))
1840            .map(per_phase)
1841            .or_else(|| Some(vec![kva * 1e3 / phases as f64; phases]));
1842        let q_min = self
1843            .f64_prop(props.get("kvarmaxabs"))
1844            .map(|v| vec![-v * 1e3 / phases as f64; phases])
1845            .or_else(|| q_max.as_ref().map(|v| v.iter().map(|x| -*x).collect()));
1846        let topology = if phases == 1 {
1847            IbrTopology::SinglePhase
1848        } else if conn_delta {
1849            IbrTopology::ThreeLeg
1850        } else {
1851            IbrTopology::FourLeg
1852        };
1853        let pf = self.f64_prop(props.get("pf"));
1854        let mut extras = extras_from_leftovers(&props);
1855        self.stash_kv_and_phases(&props, &mut extras, kv, phases);
1856        extras.remove("conn");
1857        let mut ibr = DistIbr {
1858            name: obj.name.clone(),
1859            bus: spec.name,
1860            terminal_map: map,
1861            topology,
1862            prime_mover: IbrPrimeMover::Pv,
1863            s_max: vec![kva * 1e3 / phases as f64; phases],
1864            i_max: None,
1865            p_avail: Some(p_avail),
1866            p_min: Some(vec![0.0; phases]),
1867            p_max: Some(per_phase(pmpp * pct_pmpp / 100.0)),
1868            q_min,
1869            q_max,
1870            control_profile: None,
1871            voltage_aggregation: None,
1872            extras,
1873        };
1874        if let Some(pf) = pf {
1875            let profile = format!("{}_pf", obj.name);
1876            ibr.control_profile = Some(profile.clone());
1877            self.net.control_profiles.push(DistControlProfile {
1878                name: profile,
1879                power_factor: Some(PowerFactorControl { pf }),
1880                volt_var: None,
1881                volt_watt: None,
1882                extras: Extras::new(),
1883            });
1884        }
1885        self.net.ibrs.push(ibr);
1886    }
1887
1888    fn xycurve(&mut self, obj: &RawObject) {
1889        let props = Props::new(obj);
1890        let x = props
1891            .get("xarray")
1892            .and_then(|v| v.to_vector(Some(self.vars)).ok())
1893            .unwrap_or_default();
1894        let y = props
1895            .get("yarray")
1896            .and_then(|v| v.to_vector(Some(self.vars)).ok())
1897            .unwrap_or_default();
1898        if x.is_empty() || y.is_empty() {
1899            self.warn(format!(
1900                "xycurve {}: xarray/yarray are incomplete; kept untyped",
1901                obj.name
1902            ));
1903            self.net.untyped.push(UntypedObject::from(obj));
1904            return;
1905        }
1906        self.xycurves
1907            .insert(obj.name.to_ascii_lowercase(), XyCurveRaw { x, y });
1908    }
1909
1910    fn invcontrol(&mut self, obj: &RawObject) {
1911        let props = Props::new(obj);
1912        let derlist = props
1913            .get("derlist")
1914            .map(|v| dss_name_list(&v.text))
1915            .unwrap_or_default();
1916        let mode = props
1917            .get("mode")
1918            .map(|v| v.text.to_ascii_lowercase())
1919            .unwrap_or_default();
1920        let combimode = props
1921            .get("combimode")
1922            .map(|v| v.text.to_ascii_lowercase())
1923            .unwrap_or_default();
1924        let mon = props
1925            .get("monvoltagecalc")
1926            .map(|v| v.text.to_ascii_lowercase())
1927            .unwrap_or_default();
1928        let voltage_reference = if mon.contains("avg") {
1929            ControlVoltageReference::PgAveraged
1930        } else {
1931            ControlVoltageReference::PgPerPhase
1932        };
1933        let mut profile = DistControlProfile::new(obj.name.clone());
1934        profile.volt_var =
1935            self.invcontrol_volt_var(obj, &props, &derlist, voltage_reference, &mode, &combimode);
1936        profile.volt_watt =
1937            self.invcontrol_volt_watt(&props, &derlist, voltage_reference, &mode, &combimode);
1938        if profile.power_factor.is_none()
1939            && profile.volt_var.is_none()
1940            && profile.volt_watt.is_none()
1941        {
1942            self.warn(format!(
1943                "invcontrol {}: control mode is not typed; kept untyped",
1944                obj.name
1945            ));
1946            self.net.untyped.push(UntypedObject::from(obj));
1947            return;
1948        }
1949        for der in derlist {
1950            let name = der.rsplit_once('.').map_or(der.as_str(), |(_, name)| name);
1951            if let Some(ibr) = self
1952                .net
1953                .ibrs
1954                .iter_mut()
1955                .find(|ibr| ibr.name.eq_ignore_ascii_case(name))
1956            {
1957                ibr.control_profile = Some(profile.name.clone());
1958                if profile.volt_var.is_some() {
1959                    ibr.extras.remove("%pminnovars");
1960                    ibr.extras.remove("%pminkvarmax");
1961                }
1962            } else {
1963                self.warn(format!(
1964                    "invcontrol {}: DER `{der}` does not match a typed PVSystem",
1965                    obj.name
1966                ));
1967            }
1968        }
1969        self.net.control_profiles.push(profile);
1970    }
1971
1972    fn invcontrol_volt_var(
1973        &mut self,
1974        obj: &RawObject,
1975        props: &Props<'_>,
1976        derlist: &[String],
1977        voltage_reference: ControlVoltageReference,
1978        mode: &str,
1979        combimode: &str,
1980    ) -> Option<VoltVarControl> {
1981        if !(mode.contains("voltvar") || combimode.contains("vv")) {
1982            return None;
1983        }
1984        let curve_name = props.get("vvc_curve1").map(|v| v.text.clone())?;
1985        let curve = self
1986            .xycurves
1987            .get(&curve_name.to_ascii_lowercase())
1988            .cloned()?;
1989        let base_v = self.control_base_voltage(derlist).unwrap_or_else(|| {
1990            self.warn(format!(
1991                "invcontrol {}: no rated voltage found for vvc_curve1; using 1 pu as 1 V",
1992                obj.name
1993            ));
1994            1.0
1995        });
1996        let q_ref = props
1997            .get("refreactivepower")
1998            .map(|v| v.text.to_ascii_uppercase())
1999            .filter(|s| s.contains("VARAVAL"))
2000            .map_or(ReactivePowerReference::VarMax, |_| {
2001                ReactivePowerReference::VarAvailable
2002            });
2003        let p_min_for_q = self
2004            .ibr_extra_f64(derlist, "%pminnovars")
2005            .or_else(|| self.f64_prop(props.get("%pminnovars")));
2006        let p_min_for_q_max = self
2007            .ibr_extra_f64(derlist, "%pminkvarmax")
2008            .or_else(|| self.f64_prop(props.get("%pminkvarmax")));
2009        Some(VoltVarControl {
2010            voltage_reference: Some(voltage_reference),
2011            breakpoints: curve.x.iter().map(|x| x * base_v).collect(),
2012            q_limits: if curve.y.len() >= 4 {
2013                vec![curve.y[3], curve.y[0]]
2014            } else {
2015                curve.y
2016            },
2017            q_unit: Some(ReactivePowerUnit::VaFraction),
2018            q_ref: Some(q_ref),
2019            p_min_for_q,
2020            p_min_for_q_max,
2021        })
2022    }
2023
2024    fn invcontrol_volt_watt(
2025        &mut self,
2026        props: &Props<'_>,
2027        derlist: &[String],
2028        voltage_reference: ControlVoltageReference,
2029        mode: &str,
2030        combimode: &str,
2031    ) -> Option<VoltWattControl> {
2032        if !(mode.contains("voltwatt") || combimode.contains("vw")) {
2033            return None;
2034        }
2035        let curve_name = props
2036            .get("voltwatt_curve")
2037            .or_else(|| props.get("volt_watt_curve"))
2038            .map(|v| v.text.clone())?;
2039        let curve = self
2040            .xycurves
2041            .get(&curve_name.to_ascii_lowercase())
2042            .cloned()?;
2043        let base_v = self.control_base_voltage(derlist).unwrap_or(1.0);
2044        let p_ref = props
2045            .get("voltwattyaxis")
2046            .map(|v| v.text.to_ascii_uppercase())
2047            .map_or(ActivePowerReference::SMax, |s| {
2048                if s.contains("PAVAILABLE") {
2049                    ActivePowerReference::PAvailable
2050                } else if s.contains("PMPP") {
2051                    ActivePowerReference::PMax
2052                } else {
2053                    ActivePowerReference::SMax
2054                }
2055            });
2056        Some(VoltWattControl {
2057            voltage_reference: Some(voltage_reference),
2058            breakpoints: curve.x.iter().map(|x| x * base_v).collect(),
2059            p_limits: if curve.y.len() >= 2 {
2060                vec![curve.y[1], curve.y[0]]
2061            } else {
2062                curve.y
2063            },
2064            p_unit: Some(ActivePowerUnit::VaFraction),
2065            p_ref: Some(p_ref),
2066        })
2067    }
2068
2069    fn ibr_extra_f64(&self, derlist: &[String], key: &str) -> Option<f64> {
2070        derlist.iter().find_map(|der| {
2071            let name = der.rsplit_once('.').map_or(der.as_str(), |(_, name)| name);
2072            self.net
2073                .ibrs
2074                .iter()
2075                .find(|ibr| ibr.name.eq_ignore_ascii_case(name))
2076                .and_then(|ibr| ibr.extras.get(key))
2077                .and_then(json_value_f64)
2078        })
2079    }
2080
2081    fn control_base_voltage(&self, derlist: &[String]) -> Option<f64> {
2082        let der = derlist.first()?;
2083        let name = der.rsplit_once('.').map_or(der.as_str(), |(_, name)| name);
2084        let ibr = self
2085            .net
2086            .ibrs
2087            .iter()
2088            .find(|ibr| ibr.name.eq_ignore_ascii_case(name))?;
2089        let kv = ibr
2090            .extras
2091            .get("kv")
2092            .and_then(|v| {
2093                v.as_f64()
2094                    .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
2095            })
2096            .unwrap_or(dd::pvsystem::KV);
2097        Some(match ibr.topology {
2098            IbrTopology::FourLeg => kv * 1e3 / 3f64.sqrt(),
2099            IbrTopology::SinglePhase | IbrTopology::ThreeLeg => kv * 1e3,
2100        })
2101    }
2102
2103    // ----- controls ------------------------------------------------------
2104
2105    fn swtcontrol(&mut self, obj: &RawObject) {
2106        let props = Props::new(obj);
2107        let Some(target) = props.get("switchedobj").map(|v| v.text.clone()) else {
2108            self.warn(format!("swtcontrol {}: no SwitchedObj; ignored", obj.name));
2109            return;
2110        };
2111        // Element references compare class names case insensitively, like
2112        // every dss identifier.
2113        let line_name = match target.split_once('.') {
2114            Some((class, rest)) if class.eq_ignore_ascii_case("line") => rest,
2115            _ => target.as_str(),
2116        };
2117        // The present state follows the last `action`/`state` assignment in
2118        // source order; `normal` applies only when neither was written.
2119        let mut open = None;
2120        for p in &obj.props {
2121            match p.name.as_deref() {
2122                Some("action" | "state") => {
2123                    open = Some(p.value.text.to_ascii_lowercase().starts_with('o'));
2124                }
2125                Some("normal") if open.is_none() => {
2126                    open = Some(p.value.text.to_ascii_lowercase().starts_with('o'));
2127                }
2128                _ => {}
2129            }
2130        }
2131        let open = open.unwrap_or(false);
2132        match self
2133            .net
2134            .switches
2135            .iter_mut()
2136            .find(|s| s.name.eq_ignore_ascii_case(line_name))
2137        {
2138            Some(sw) => sw.open = open,
2139            None => self.warn(format!(
2140                "swtcontrol {}: switched object `{target}` is not a switch line",
2141                obj.name
2142            )),
2143        }
2144    }
2145
2146    fn regcontrol(&mut self, obj: &RawObject) {
2147        let props = Props::new(obj);
2148        let target = props
2149            .get("transformer")
2150            .map_or_else(String::new, |v| v.text.clone());
2151        self.warn(format!(
2152            "regcontrol {}: voltage regulation is ignored; transformer `{target}` keeps its written taps",
2153            obj.name
2154        ));
2155        self.net.untyped.push(UntypedObject::from(obj));
2156    }
2157}
2158
2159/// Every entry times `k`.
2160fn scale_mat(m: &Mat, k: f64) -> Mat {
2161    m.iter()
2162        .map(|row| row.iter().map(|v| v * k).collect())
2163        .collect()
2164}
2165
2166fn filled_phase_nodes(spec: &BusSpec, phases: usize) -> Vec<i32> {
2167    let mut nodes: Vec<i32> = (1..=i32::try_from(phases).unwrap_or(i32::MAX)).collect();
2168    for (idx, &node) in spec.nodes.iter().enumerate().take(phases) {
2169        nodes[idx] = node.max(0);
2170    }
2171    nodes
2172}
2173
2174fn same_bus_ground_return(bus: &BusSpec, return_bus: &BusSpec, phases: usize) -> bool {
2175    bus.name.eq_ignore_ascii_case(&return_bus.name)
2176        && !return_bus.nodes.is_empty()
2177        && filled_phase_nodes(return_bus, phases)
2178            .iter()
2179            .all(|&n| n <= 0)
2180}
2181
2182fn explicit_single_terminal_ground_return(bus: &BusSpec, return_bus: &BusSpec) -> bool {
2183    bus.name.eq_ignore_ascii_case(&return_bus.name)
2184        && bus.nodes.len() == 1
2185        && return_bus.nodes.len() == 1
2186        && return_bus.nodes[0] <= 0
2187}
2188
2189fn dss_name_list(text: &str) -> Vec<String> {
2190    text.trim_matches(|c: char| matches!(c, '[' | ']' | '(' | ')'))
2191        .split(|c: char| c == ',' || c.is_whitespace())
2192        .filter(|s| !s.is_empty())
2193        .map(str::to_string)
2194        .collect()
2195}
2196
2197fn json_value_f64(value: &serde_json::Value) -> Option<f64> {
2198    value
2199        .as_f64()
2200        .or_else(|| value.as_str().and_then(|s| s.parse().ok()))
2201}
2202
2203/// The line to line branches of a delta bank over `n` terminals: a closed
2204/// ring for a 3+ phase bank, an open chain otherwise. Shared with the writer
2205/// so the reader and writer cannot disagree on the branch topology.
2206pub(super) fn delta_edges(n: usize, phases: usize) -> Vec<(usize, usize)> {
2207    if n < 2 {
2208        Vec::new()
2209    } else if phases >= 3 && n >= 3 {
2210        (0..n).map(|i| (i, (i + 1) % n)).collect()
2211    } else {
2212        let branches = phases.max(1).min(n - 1);
2213        (0..branches).map(|i| (i, i + 1)).collect()
2214    }
2215}
2216
2217fn kvar_shunt_matrix(
2218    map: &[String],
2219    phases: usize,
2220    conn_delta: bool,
2221    kvar: f64,
2222    v_ref: f64,
2223    b_sign: f64,
2224) -> Option<Mat> {
2225    let dim = map.len();
2226    let mut susceptance = vec![vec![0.0; dim]; dim];
2227    if conn_delta {
2228        let edges = delta_edges(dim, phases);
2229        if edges.is_empty() || map.iter().any(|t| t == "0") {
2230            return None;
2231        }
2232        let b_branch = b_sign * kvar * 1e3 / edges.len() as f64 / (v_ref * v_ref);
2233        for (from, to) in edges {
2234            susceptance[from][from] += b_branch;
2235            susceptance[to][to] += b_branch;
2236            susceptance[from][to] -= b_branch;
2237            susceptance[to][from] -= b_branch;
2238        }
2239    } else {
2240        let b_phase = b_sign * kvar * 1e3 / phases as f64 / (v_ref * v_ref);
2241        for (idx, row) in susceptance.iter_mut().enumerate().take(phases) {
2242            row[idx] = b_phase;
2243        }
2244    }
2245    Some(susceptance)
2246}
2247
2248fn bus_spec(v: Option<&Value>, fallback: &str) -> BusSpec {
2249    v.map_or_else(
2250        || Value::new(fallback).to_bus_spec(),
2251        super::lex::Value::to_bus_spec,
2252    )
2253}
2254
2255fn extras_from_leftovers(props: &Props) -> Extras {
2256    let mut extras = Extras::new();
2257    for (k, v) in props.leftovers() {
2258        extras.insert(k.to_string(), v.text.clone().into());
2259    }
2260    extras
2261}
2262
2263/// `buses=(...)` / `conns=(...)` applied across windings.
2264fn apply_winding_strings(windings: &mut [WindingRaw], name: &str, items: &[String]) {
2265    let conn_is_delta =
2266        |t: &str| t.to_ascii_lowercase().starts_with('d') || t.eq_ignore_ascii_case("ll");
2267    // Zip over windings so a list longer than the (clamped) winding count is
2268    // bounded rather than indexing past the vector.
2269    for (w, item) in windings.iter_mut().zip(items) {
2270        if name == "buses" {
2271            w.bus = Some(Value::new(item.clone()).to_bus_spec());
2272        } else {
2273            w.conn_delta = conn_is_delta(item);
2274        }
2275    }
2276}
2277
2278/// A numeric transformer array (`kvs=(...)`, RPN entries included) applied
2279/// across windings.
2280fn apply_winding_numbers(windings: &mut [WindingRaw], name: &str, items: &[f64]) {
2281    for (w, &item) in windings.iter_mut().zip(items) {
2282        match name {
2283            "kvs" => {
2284                w.kv = item;
2285                w.kv_specified = true;
2286            }
2287            "kvas" => {
2288                w.kva = item;
2289                w.kva_specified = true;
2290            }
2291            "taps" => w.tap = item,
2292            _ => w.r_pct = item,
2293        }
2294    }
2295}
2296
2297fn x_pair_key(name: &str) -> Option<(usize, usize)> {
2298    let rest = name.strip_prefix('x')?;
2299    if rest.len() != 2 || !rest.chars().all(|c| c.is_ascii_digit()) {
2300        return None;
2301    }
2302    let mut chars = rest.chars();
2303    let i = chars.next()?.to_digit(10)? as usize;
2304    let j = chars.next()?.to_digit(10)? as usize;
2305    if i == 0 || j == 0 || i == j {
2306        return None;
2307    }
2308    Some((i.min(j) - 1, i.max(j) - 1))
2309}
2310
2311/// A load's power state after the last edit boundary: the engine's
2312/// (kWBase, kvarBase, PFNominal, LoadSpecType), plus which of kw/pf were
2313/// ever written (for default provenance).
2314struct LoadPower {
2315    kw: f64,
2316    kvar: f64,
2317    pf: f64,
2318    /// LoadSpecType: false = 0 (kW + PF), true = 1 (kW + kvar).
2319    spec_kvar: bool,
2320    kw_written: bool,
2321    pf_written: bool,
2322}
2323
2324/// Series impedance of a linecode or inline line, per source length unit.
2325struct SeriesImpedance {
2326    r: Mat,
2327    x: Mat,
2328    c_nf: Mat,
2329    /// No matrix or sequence property was written at all.
2330    all_default: bool,
2331    /// Matrix properties written but unparseable as n x n, with their raw
2332    /// text (the engine rejects the whole script; the reader keeps them
2333    /// in extras).
2334    malformed: Vec<(&'static str, String)>,
2335}
2336
2337#[derive(Clone)]
2338struct WindingRaw {
2339    bus: Option<BusSpec>,
2340    conn_delta: bool,
2341    kv: f64,
2342    kva: f64,
2343    tap: f64,
2344    r_pct: f64,
2345    r_neutral: Option<f64>,
2346    x_neutral: Option<f64>,
2347    kv_specified: bool,
2348    kva_specified: bool,
2349}
2350
2351impl Default for WindingRaw {
2352    fn default() -> Self {
2353        WindingRaw {
2354            bus: None,
2355            conn_delta: false,
2356            kv: dd::transformer::KV,
2357            kva: dd::transformer::KVA,
2358            tap: dd::transformer::TAP,
2359            r_pct: dd::transformer::PCT_R,
2360            r_neutral: None,
2361            x_neutral: None,
2362            kv_specified: false,
2363            kva_specified: false,
2364        }
2365    }
2366}
2367
2368/// Grows the winding list to at least `n`, tracking the winding count.
2369fn grow(
2370    windings: &mut Vec<WindingRaw>,
2371    requested: usize,
2372    count: &mut usize,
2373    name: &str,
2374    warnings: &mut Vec<String>,
2375) {
2376    // The winding count also arrives as the length of a token list (`buses=`,
2377    // `conns=`, `kvs=`, ...), which never passes through the scalar `usize_prop`
2378    // cap. Clamp here, the single point every winding-growth path funnels
2379    // through, so a long list cannot drive the O(n^2) `pair_keys` fanout.
2380    let n = if requested > MAX_COUNT {
2381        warnings.push(format!(
2382            "transformer {name}: winding count {requested} exceeds the supported maximum \
2383             of {MAX_COUNT}; clamped"
2384        ));
2385        MAX_COUNT
2386    } else {
2387        requested
2388    };
2389    if n > windings.len() {
2390        windings.resize(n, WindingRaw::default());
2391        *count = n;
2392    }
2393}
2394
2395#[cfg(test)]
2396mod tests {
2397    use super::*;
2398
2399    fn has_warning(net: &MulticonductorNetwork, needle: &str) -> bool {
2400        net.warnings.iter().any(|w| w.contains(needle))
2401    }
2402
2403    #[test]
2404    fn vsource_magnitude_is_the_polygon_chord() {
2405        // VSource.cpp ~999-1002: one phase takes basekv outright, n > 1
2406        // divides by 2 sin(pi/n); sqrt(3) is the n = 3 special case.
2407        let net = parse_dss_str(
2408            "New Circuit.c basekv=12.47 pu=1.05 phases=2 bus1=src.1.2\n\
2409             New Vsource.aux basekv=12.47 phases=4 bus1=b2\n\
2410             New Vsource.solo basekv=2.4 phases=1 bus1=b3.1",
2411        );
2412        let two = &net.sources[0];
2413        assert!((two.v_magnitude[0] - 12.47e3 * 1.05 / 2.0).abs() < 1e-9);
2414        // Spacing is -360/n degrees: the second phase of a 2 phase source
2415        // wraps to +pi.
2416        assert!((two.v_angle[1] - std::f64::consts::PI).abs() < 1e-12);
2417        let four = &net.sources[1];
2418        let chord = 2.0 * (std::f64::consts::PI / 4.0).sin();
2419        assert!((four.v_magnitude[0] - 12.47e3 / chord).abs() < 1e-9);
2420        let solo = &net.sources[2];
2421        assert!((solo.v_magnitude[0] - 2.4e3).abs() < 1e-9);
2422    }
2423
2424    #[test]
2425    fn vsource_defaults_are_recorded() {
2426        let net = parse_dss_str("New Circuit.c1");
2427        let fields = net.defaulted.get("vsource.source").expect("entry");
2428        for key in ["phases", "pu", "angle", "basekv", "bus1"] {
2429            assert!(fields.contains(&key), "missing {key}");
2430        }
2431    }
2432
2433    /// One single phase linecode + line; (r per meter, length meters).
2434    fn r_and_length(lc_tail: &str, line_tail: &str) -> (f64, f64) {
2435        let net = parse_dss_str(&format!(
2436            "New Circuit.c\n\
2437             New Linecode.lc nphases=1 rmatrix=(0.5){lc_tail}\n\
2438             New Line.l1 bus1=a.1 bus2=b.1 phases=1 linecode=lc{line_tail}"
2439        ));
2440        let line = net.lines.iter().find(|l| l.name == "l1").unwrap();
2441        let code = net.linecode(&line.linecode).unwrap();
2442        (code.r_series[0][0], line.length)
2443    }
2444
2445    #[test]
2446    fn unitless_line_length_is_in_linecode_units() {
2447        // ConvertLineUnits is 1.0 when the line has no units, so the
2448        // engine reads `length=2` against a km linecode as 2 km:
2449        // 0.5 ohm/km * 2 km = 1 ohm total.
2450        let (r, len) = r_and_length(" units=km", " length=2");
2451        assert!((len - 2000.0).abs() < 1e-9);
2452        assert!((r * len - 1.0).abs() < 1e-12);
2453    }
2454
2455    #[test]
2456    fn unitless_linecode_is_per_line_unit() {
2457        // The mirror case: a unitless linecode is per line length unit,
2458        // so the raw length carries and the total is again 1 ohm.
2459        let (r, len) = r_and_length("", " length=2 units=km");
2460        assert!((len - 2.0).abs() < 1e-12);
2461        assert!((r * len - 1.0).abs() < 1e-12);
2462    }
2463
2464    #[test]
2465    fn written_units_on_both_sides_convert() {
2466        // 0.5 ohm/km over 500 m = 0.25 ohm.
2467        let (r, len) = r_and_length(" units=km", " length=500 units=m");
2468        assert!((len - 500.0).abs() < 1e-9);
2469        assert!((r * len - 0.25).abs() < 1e-12);
2470    }
2471
2472    #[test]
2473    fn one_phase_inline_sequence_values_stay_positive_sequence() {
2474        let net = parse_dss_str(
2475            "New Circuit.c\n\
2476             New Line.l1 bus1=a.1 bus2=b.1 phases=1 length=0.5 units=km r1=0.5 x1=0.2 c1=3",
2477        );
2478        let line = net.lines.iter().find(|l| l.name == "l1").unwrap();
2479        let code = net.linecode(&line.linecode).unwrap();
2480        assert!((line.length - 500.0).abs() < 1e-9);
2481        assert!((code.r_series[0][0] * line.length - 0.25).abs() < 1e-12);
2482        assert!((code.x_series[0][0] * line.length - 0.1).abs() < 1e-12);
2483    }
2484
2485    #[test]
2486    fn no_units_anywhere_takes_the_raw_product() {
2487        let (r, len) = r_and_length("", " length=2");
2488        assert!((len - 2.0).abs() < 1e-12);
2489        assert!((r * len - 1.0).abs() < 1e-12);
2490    }
2491
2492    #[test]
2493    fn two_phase_wye_capacitor_kv_is_line_to_line() {
2494        // Capacitor.cpp ~621-630: PhasekV = kv/sqrt(3) for 2 AND 3 phase
2495        // wye banks, kv outright otherwise.
2496        let net = parse_dss_str(
2497            "New Circuit.c\n\
2498             New Capacitor.c2 bus1=b.1.2 phases=2 kv=12.47 kvar=600\n\
2499             New Capacitor.c1 bus1=b.3 phases=1 kv=7.2 kvar=300",
2500        );
2501        let c2 = net.shunts.iter().find(|s| s.name == "c2").unwrap();
2502        let v2 = 12.47e3 / 3f64.sqrt();
2503        assert!((c2.b[0][0] * v2 * v2 / 300e3 - 1.0).abs() < 1e-12);
2504        let c1 = net.shunts.iter().find(|s| s.name == "c1").unwrap();
2505        let v1 = 7.2e3;
2506        assert!((c1.b[0][0] * v1 * v1 / 300e3 - 1.0).abs() < 1e-12);
2507    }
2508
2509    #[test]
2510    fn capacitor_and_reactor_kvar_shunts_share_magnitude_with_opposite_sign() {
2511        let net = parse_dss_str(
2512            "New Circuit.c\n\
2513             New Capacitor.cap bus1=b.1 phases=1 kv=7.2 kvar=300\n\
2514             New Reactor.rea bus1=b.2 phases=1 kv=7.2 kvar=300",
2515        );
2516        let cap = net.shunts.iter().find(|s| s.name == "cap").unwrap();
2517        let rea = net.shunts.iter().find(|s| s.name == "rea").unwrap();
2518        assert!(cap.b[0][0] > 0.0);
2519        assert!(rea.b[0][0] < 0.0);
2520        assert!((cap.b[0][0] + rea.b[0][0]).abs() < 1e-18);
2521    }
2522
2523    #[test]
2524    fn kvar_shunts_with_nonpositive_phases_stay_untyped() {
2525        let net = parse_dss_str(
2526            "New Circuit.c\n\
2527             New Capacitor.cap bus1=b.1 phases=0 kv=7.2 kvar=300\n\
2528             New Reactor.rea bus1=b.2 phases=0 kv=7.2 kvar=300",
2529        );
2530        assert!(net.shunts.is_empty());
2531        assert!(
2532            net.untyped
2533                .iter()
2534                .any(|u| u.class.eq_ignore_ascii_case("capacitor") && u.name == "cap")
2535        );
2536        assert!(
2537            net.untyped
2538                .iter()
2539                .any(|u| u.class.eq_ignore_ascii_case("reactor") && u.name == "rea")
2540        );
2541        assert!(
2542            net.warnings
2543                .iter()
2544                .any(|w| w.contains("capacitor cap: nonpositive `phases`"))
2545        );
2546        assert!(
2547            net.warnings
2548                .iter()
2549                .any(|w| w.contains("reactor rea: nonpositive `phases`"))
2550        );
2551    }
2552
2553    #[test]
2554    fn ll_connection_means_delta() {
2555        // InterpretConnection maps `ll` to delta for every class.
2556        let net = parse_dss_str(
2557            "New Circuit.c\n\
2558             New Generator.g bus1=b.1.2.3 phases=3 conn=ll kw=90 kvar=30 kv=4.16\n\
2559             New Capacitor.cap bus1=b.1.2.3 phases=3 conn=ll kvar=600 kv=4.16",
2560        );
2561        assert_eq!(net.generators[0].configuration, Configuration::Delta);
2562        // `ll` capacitor banks use the delta shunt path.
2563        assert_eq!(net.shunts.len(), 1);
2564        let sh = &net.shunts[0];
2565        assert!(sh.b[0][1] < 0.0, "{:?}", sh.b);
2566        assert_eq!(sh.terminal_map, vec!["1", "2", "3"]);
2567        assert!(
2568            net.untyped
2569                .iter()
2570                .all(|u| !(u.class.eq_ignore_ascii_case("capacitor") && u.name == "cap"))
2571        );
2572    }
2573
2574    #[test]
2575    fn load_kw_after_kvar_reverts_to_pf() {
2576        // Load.cpp: kw flips LoadSpecType back to 0 (kW + PF), so the
2577        // earlier kvar is discarded and q comes from the default pf 0.88.
2578        let net =
2579            parse_dss_str("New Circuit.c\nNew Load.l bus1=b.1 phases=1 kv=2.4 kvar=20 kw=100");
2580        let l = &net.loads[0];
2581        let q: f64 = l.q_nom.iter().sum();
2582        assert!((q - 100e3 * 0.88f64.acos().tan()).abs() < 1e-6);
2583        assert_eq!(
2584            l.extras.get("pf").and_then(serde_json::Value::as_f64),
2585            Some(0.88)
2586        );
2587        assert!(
2588            net.defaulted
2589                .get("load.l")
2590                .is_some_and(|f| f.contains(&"pf"))
2591        );
2592    }
2593
2594    #[test]
2595    fn load_like_replays_the_sources_recalced_pf() {
2596        // Load.a ends its New under spec 1: recalc derives
2597        // PFNominal = 10/sqrt(10² + 20²) = 0.4472 (kw still the constructor
2598        // 10). MakeLike copies that recalced state, so b's kw=100 flips to
2599        // spec 0 and the end-of-edit recalc lands kvar =
2600        // 100·tan(acos(0.4472)) = 200, not the 53.97 a flat walk against
2601        // pf 0.88 would give. Confirmed against opendssdirect.
2602        let net = parse_dss_str(
2603            "New Circuit.c\n\
2604             New Load.a bus1=b.1 phases=1 kv=2.4 kvar=20\n\
2605             New Load.b like=a kw=100",
2606        );
2607        let b = net.loads.iter().find(|l| l.name == "b").unwrap();
2608        let q: f64 = b.q_nom.iter().sum();
2609        assert!((q - 200e3).abs() < 1e-6);
2610        // Final spec is 0: the writer emits pf=, the recalced 0.4472.
2611        let pf = b.extras.get("pf").and_then(serde_json::Value::as_f64);
2612        assert!((pf.unwrap() - 0.447_213_595_499_957_9).abs() < 1e-12);
2613        // The source itself keeps its written kvar.
2614        let a = net.loads.iter().find(|l| l.name == "a").unwrap();
2615        let qa: f64 = a.q_nom.iter().sum();
2616        assert!((qa - 20e3).abs() < 1e-9);
2617    }
2618
2619    #[test]
2620    fn load_tilde_continuation_recalcs_at_each_edit() {
2621        // Same numbers via `~`: the New line's recalc fixes pf at 0.4472,
2622        // the continuation's kw=100 reverts to spec 0 and its own recalc
2623        // gives kvar = 200. A flat last-write walk would say 53.97.
2624        let net = parse_dss_str(
2625            "New Circuit.c\n\
2626             New Load.l bus1=b.1 phases=1 kv=2.4 kvar=20\n\
2627             ~ kw=100",
2628        );
2629        let q: f64 = net.loads[0].q_nom.iter().sum();
2630        assert!((q - 200e3).abs() < 1e-6);
2631    }
2632
2633    #[test]
2634    fn load_pf_between_kvar_and_kw_applies() {
2635        // pf (case 5) updates PFNominal without touching the spec; the
2636        // later kw sets spec 0, so the single recalc uses pf 0.95:
2637        // q = 100·tan(acos(0.95)) = 32.868. Confirmed against
2638        // opendssdirect.
2639        let net = parse_dss_str(
2640            "New Circuit.c\nNew Load.l bus1=b.1 phases=1 kv=2.4 kvar=20 pf=0.95 kw=100",
2641        );
2642        let l = &net.loads[0];
2643        let q: f64 = l.q_nom.iter().sum();
2644        assert!((q - 100e3 * 0.95f64.acos().tan()).abs() < 1e-6);
2645        assert_eq!(
2646            l.extras.get("pf").and_then(serde_json::Value::as_f64),
2647            Some(0.95)
2648        );
2649        assert!(
2650            !net.defaulted
2651                .get("load.l")
2652                .is_some_and(|f| f.contains(&"pf"))
2653        );
2654    }
2655
2656    #[test]
2657    fn load_kvar_after_kw_stays() {
2658        let net =
2659            parse_dss_str("New Circuit.c\nNew Load.l bus1=b.1 phases=1 kv=2.4 kw=100 kvar=20");
2660        let l = &net.loads[0];
2661        let q: f64 = l.q_nom.iter().sum();
2662        assert!((q - 20e3).abs() < 1e-9);
2663        // The writer must emit kvar=, not pf=.
2664        assert!(!l.extras.contains_key("pf"));
2665    }
2666
2667    #[test]
2668    fn generator_kw_after_kvar_resyncs_q() {
2669        // Set_Presentkvar rederives PF from kW and kvar; the later kw
2670        // write resyncs kvar from that PF. Constructor kW is 1000, so
2671        // kvar=20 kw=100 scales q to 100 * 20/1000 = 2 kvar.
2672        let net =
2673            parse_dss_str("New Circuit.c\nNew Generator.g bus1=b.1 phases=1 kv=2.4 kvar=20 kw=100");
2674        let q: f64 = net.generators[0].q_nom.iter().sum();
2675        assert!((q - 2e3).abs() < 1e-6);
2676    }
2677
2678    #[test]
2679    fn generator_kvar_after_kw_stays() {
2680        let net =
2681            parse_dss_str("New Circuit.c\nNew Generator.g bus1=b.1 phases=1 kv=2.4 kw=100 kvar=20");
2682        let q: f64 = net.generators[0].q_nom.iter().sum();
2683        assert!((q - 20e3).abs() < 1e-9);
2684    }
2685
2686    #[test]
2687    fn generator_pf_after_kvar_wins() {
2688        // pf calls SyncUpPowerQuantities: kvar = kW tan(acos(pf)) with the
2689        // constructor kW 1000.
2690        let net = parse_dss_str(
2691            "New Circuit.c\nNew Generator.g bus1=b.1.2.3 phases=3 kv=4.16 kvar=20 pf=0.9",
2692        );
2693        let q: f64 = net.generators[0].q_nom.iter().sum();
2694        assert!((q - 1000e3 * 0.9f64.acos().tan()).abs() < 1e-3);
2695    }
2696
2697    #[test]
2698    fn malformed_matrix_warns_and_keeps_text() {
2699        // The engine rejects a bad rmatrix outright; the reader keeps
2700        // going on sequence values but must not call the property
2701        // defaulted, and the text must survive in extras.
2702        let net = parse_dss_str(
2703            "New Circuit.c\n\
2704             New Linecode.bad nphases=2 rmatrix=(1 2 3) units=m\n\
2705             New Line.l2 bus1=a.1.2 bus2=b.1.2 phases=2 rmatrix=(bogus) length=10",
2706        );
2707        assert!(has_warning(&net, "linecode bad") && has_warning(&net, "rmatrix"));
2708        assert!(
2709            !net.defaulted
2710                .get("linecode.bad")
2711                .is_some_and(|f| f.contains(&"rmatrix"))
2712        );
2713        let code = net.linecode("bad").unwrap();
2714        assert!(
2715            code.extras
2716                .get("rmatrix")
2717                .and_then(serde_json::Value::as_str)
2718                .is_some_and(|s| s.contains("1 2 3"))
2719        );
2720        // Sequence defaults filled in: diag (2 r1 + r0) / 3.
2721        let diag = (2.0 * dd::line::R1 + dd::line::R0) / 3.0;
2722        assert!((code.r_series[0][0] - diag).abs() < 1e-12);
2723        // The inline line path lands the text on the line's extras.
2724        assert!(has_warning(&net, "line l2"));
2725        let l2 = net.lines.iter().find(|l| l.name == "l2").unwrap();
2726        assert!(
2727            l2.extras
2728                .get("rmatrix")
2729                .and_then(serde_json::Value::as_str)
2730                .is_some_and(|s| s.contains("bogus"))
2731        );
2732    }
2733
2734    #[test]
2735    fn switchedobj_class_prefix_is_case_insensitive() {
2736        let net = parse_dss_str(
2737            "New Circuit.c\n\
2738             New Line.sw1 bus1=a.1 bus2=b.1 phases=1 switch=y\n\
2739             New SwtControl.s1 SwitchedObj=LINE.sw1 Action=open",
2740        );
2741        assert!(net.switches[0].open);
2742    }
2743
2744    #[test]
2745    fn phases_token_rides_in_extras() {
2746        // A 2 phase delta load has 3 conductors, indistinguishable from a
2747        // 3 phase delta by terminal map alone.
2748        let net = parse_dss_str(
2749            "New Circuit.c\n\
2750             New Load.l bus1=b.1.2 phases=2 conn=delta kw=50 kvar=10 kv=4.8\n\
2751             New Generator.g bus1=b.1.2.3 kw=10 kvar=2 kv=4.16\n\
2752             New Capacitor.cap bus1=b.1.2.3 phases=3 kvar=600 kv=4.16",
2753        );
2754        let l = &net.loads[0];
2755        assert_eq!(l.terminal_map.len(), 3);
2756        assert_eq!(
2757            l.extras.get("phases").and_then(serde_json::Value::as_str),
2758            Some("2")
2759        );
2760        // An unwritten phases= materializes the class default.
2761        assert_eq!(
2762            net.generators[0]
2763                .extras
2764                .get("phases")
2765                .and_then(serde_json::Value::as_u64),
2766            Some(3)
2767        );
2768        assert_eq!(
2769            net.shunts[0]
2770                .extras
2771                .get("phases")
2772                .and_then(serde_json::Value::as_str),
2773            Some("3")
2774        );
2775    }
2776
2777    #[test]
2778    fn rpn_kv_token_stashes_the_evaluated_value() {
2779        // The writer needs a number; RPN text would not read back.
2780        let net = parse_dss_str("New Circuit.c\nNew Load.l bus1=b.1 phases=1 kw=10 kv={4.8 2 /}");
2781        assert_eq!(
2782            net.loads[0]
2783                .extras
2784                .get("kv")
2785                .and_then(serde_json::Value::as_f64),
2786            Some(2.4)
2787        );
2788    }
2789
2790    #[test]
2791    fn string_input_disables_filesystem_includes() {
2792        // Untrusted string input must not read local files: Redirect/Compile/
2793        // Buscoords resolve to nothing and are recorded as warnings.
2794        let net = parse_dss_str(
2795            "New Circuit.c basekv=12.47\nRedirect /etc/passwd\nBuscoords /etc/hosts\n",
2796        );
2797        assert_eq!(
2798            net.warnings
2799                .iter()
2800                .filter(|w| w.contains("includes are disabled when parsing from a string"))
2801                .count(),
2802            2
2803        );
2804    }
2805
2806    #[test]
2807    fn oversized_count_properties_are_clamped() {
2808        // `phases` sizes an n×n matrix and `windings` a per-winding vector; a
2809        // huge value must clamp with a warning, never allocate gigabytes. This
2810        // test completing quickly is the assertion that no huge alloc happened.
2811        let net = parse_dss_str(
2812            "New Circuit.c basekv=12.47\nNew Transformer.t phases=1000000 windings=999999\n",
2813        );
2814        assert!(
2815            net.warnings
2816                .iter()
2817                .any(|w| w.contains("exceeds the supported maximum")),
2818            "warnings: {:?}",
2819            net.warnings
2820        );
2821    }
2822
2823    #[test]
2824    fn oversized_winding_list_is_clamped() {
2825        // The winding count also arrives as a token list length (`buses=`),
2826        // which bypasses the scalar `phases`/`windings` cap and would drive the
2827        // O(n^2) pair_keys fanout. That path must clamp too.
2828        let buses = (0..5000)
2829            .map(|i| format!("b{i}"))
2830            .collect::<Vec<_>>()
2831            .join(",");
2832        let net = parse_dss_str(&format!(
2833            "New Circuit.c basekv=12.47\nNew Transformer.t buses=({buses})\n"
2834        ));
2835        assert!(
2836            net.warnings
2837                .iter()
2838                .any(|w| w.contains("winding count") && w.contains("clamped")),
2839            "warnings: {:?}",
2840            net.warnings
2841        );
2842    }
2843}