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