Skip to main content

surge_io/dss/
to_network.rs

1// SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0
2//! Convert a resolved DSS catalog into a `surge_network::Network`.
3//!
4//! ## Mapping
5//!
6//! | DSS element         | Surge type        | Notes                                    |
7//! |---------------------|-------------------|------------------------------------------|
8//! | Circuit             | Bus (Slack)       | Source bus at bus number 1               |
9//! | Line                | Branch            | Positive-sequence impedance, per-unit    |
10//! | Transformer (2-wdg) | Branch            | Off-nominal tap ratio, %X as reactance   |
11//! | Transformer (3-wdg) | 3 Branches        | T-equivalent (star model)                |
12//! | Load                | Bus Pd/Qd         | Added to bus demand in MW/MVAr           |
13//! | Load (separate obj) | Load              | Also stored in network.loads             |
14//! | Generator           | Generator         | PV bus if voltage setpoint given         |
15//! | PVSystem            | Generator         | pmin=0, pmax=kva                         |
16//! | Storage             | Generator         | pmin=-kw_rated (discharge), pmax=kw      |
17//! | Capacitor           | Bus shunt Bs      | Added to bus.shunt_susceptance_mvar (MVAr at V=1 pu)       |
18//! | Reactor             | Bus shunt Gs/Bs   | Added to bus.shunt_conductance_mw / bs                     |
19
20use std::collections::HashMap;
21use std::path::Path;
22
23use thiserror::Error;
24
25use surge_network::Network;
26use surge_network::network::{
27    Branch, Bus, BusType, GenType, Generator, GeneratorTechnology, Load, TransformerConnection,
28    TransformerData,
29};
30
31use super::command::{DssCommand, parse_commands};
32use super::lexer::tokenize;
33use super::objects::{DssCatalog, DssObject, LengthUnit, WdgConn};
34use super::resolve::{build_bus_map, resolve_linecodes, resolve_xfmrcodes, strip_phases};
35
36/// Errors that can occur during DSS parsing or network construction.
37#[derive(Error, Debug)]
38pub enum DssParseError {
39    #[error("I/O error reading '{path}': {source}")]
40    Io {
41        path: String,
42        #[source]
43        source: std::io::Error,
44    },
45
46    #[error("missing circuit definition — DSS file must contain a 'New Circuit.*' command")]
47    NoCircuit,
48
49    #[error("bus '{0}' not found in bus map")]
50    BusNotFound(String),
51
52    #[error("unresolvable reference: {0}")]
53    UnresolvedRef(String),
54}
55
56/// Parse a .dss file from disk and return a `Network`.
57pub fn parse_dss(path: &Path) -> Result<Network, DssParseError> {
58    let content = std::fs::read_to_string(path).map_err(|e| DssParseError::Io {
59        path: path.to_string_lossy().to_string(),
60        source: e,
61    })?;
62    let base_dir = path.parent().unwrap_or(Path::new("."));
63    parse_dss_str_with_base(&content, Some(base_dir))
64}
65
66/// Parse a .dss script from a string and return a `Network`.
67pub fn parse_dss_str(content: &str) -> Result<Network, DssParseError> {
68    parse_dss_str_with_base(content, None)
69}
70
71/// Core parsing routine — processes DSS commands, resolves references,
72/// and maps to a `surge_network::Network`.
73fn parse_dss_str_with_base(
74    content: &str,
75    base_dir: Option<&Path>,
76) -> Result<Network, DssParseError> {
77    let mut catalog = DssCatalog::new();
78    let mut last_obj_idx: Option<usize> = None;
79    let mut last_was_circuit = false;
80    let mut frequency_hz = 60.0_f64;
81
82    process_dss_content(
83        content,
84        base_dir,
85        0,
86        &mut catalog,
87        &mut last_obj_idx,
88        &mut last_was_circuit,
89        &mut frequency_hz,
90    )?;
91
92    // ── Cross-reference resolution ───────────────────────────────────────────
93    resolve_linecodes(&mut catalog);
94    resolve_xfmrcodes(&mut catalog);
95
96    let bus_map = build_bus_map(&catalog);
97
98    // ── Build Network ────────────────────────────────────────────────────────
99    build_network(catalog, bus_map, frequency_hz)
100}
101
102fn process_dss_content(
103    content: &str,
104    base_dir: Option<&Path>,
105    depth: usize,
106    catalog: &mut DssCatalog,
107    last_obj_idx: &mut Option<usize>,
108    last_was_circuit: &mut bool,
109    frequency_hz: &mut f64,
110) -> Result<(), DssParseError> {
111    if depth > 16 {
112        return Err(DssParseError::UnresolvedRef(
113            "redirect/compile nesting depth exceeded".to_string(),
114        ));
115    }
116
117    let tokens = tokenize(content);
118    let commands = parse_commands(&tokens);
119    for cmd in &commands {
120        process_command(
121            cmd,
122            catalog,
123            last_obj_idx,
124            last_was_circuit,
125            frequency_hz,
126            base_dir,
127            depth,
128        )?;
129    }
130    Ok(())
131}
132
133/// Process a single DSS command, updating the catalog and tracking state.
134fn process_command(
135    cmd: &DssCommand,
136    catalog: &mut DssCatalog,
137    last_obj_idx: &mut Option<usize>,
138    last_was_circuit: &mut bool,
139    frequency_hz: &mut f64,
140    base_dir: Option<&Path>,
141    depth: usize,
142) -> Result<(), DssParseError> {
143    match cmd {
144        DssCommand::Clear => {
145            *catalog = DssCatalog::new();
146            *last_obj_idx = None;
147        }
148
149        DssCommand::New {
150            obj_type,
151            obj_name,
152            properties,
153        }
154        | DssCommand::Edit {
155            obj_type,
156            obj_name,
157            properties,
158        } => {
159            let is_circuit =
160                obj_type.to_lowercase() == "circuit" || obj_type.to_lowercase() == "vsource";
161
162            if is_circuit {
163                // Circuit is special — it becomes the source bus.
164                let mut circ = super::objects::CircuitData {
165                    name: obj_name.clone(),
166                    ..Default::default()
167                };
168                for (k, v) in properties {
169                    circ.apply_property(k, v);
170                }
171                catalog.circuit = Some(circ);
172                *last_obj_idx = None;
173                *last_was_circuit = true;
174            } else {
175                match DssObject::new_for_type(obj_type) {
176                    Some(mut obj) => {
177                        *obj.name_mut() = obj_name.clone();
178                        // Handle `like=<name>` by cloning the referenced object
179                        // before applying explicit properties.
180                        let like_name = properties
181                            .iter()
182                            .find(|(k, _)| k.to_lowercase() == "like")
183                            .map(|(_, v)| v);
184                        if let Some(base_name) = like_name
185                            && let Some(base_obj) = catalog.find(obj_type, base_name).cloned()
186                        {
187                            obj = base_obj;
188                            *obj.name_mut() = obj_name.clone();
189                        }
190                        for (k, v) in properties {
191                            if k.to_lowercase() != "like" {
192                                obj.apply_property(k, v);
193                            }
194                        }
195                        // Handle LineGeometry `cond=N x=... h=... wire=...` sequencing.
196                        if let DssObject::LineGeometry(ref mut geo) = obj {
197                            apply_geometry_cond_properties(geo, properties);
198                        }
199                        let idx = catalog.upsert(obj_type, obj_name, obj);
200                        *last_obj_idx = Some(idx);
201                        *last_was_circuit = false;
202                    }
203                    None => {
204                        tracing::debug!("DSS: unknown element type '{}' — skipping", obj_type);
205                        *last_obj_idx = None;
206                    }
207                }
208            }
209        }
210
211        DssCommand::More { properties } => {
212            if *last_was_circuit {
213                // Apply continuation properties to the circuit object.
214                if let Some(ref mut circ) = catalog.circuit {
215                    for (k, v) in properties {
216                        circ.apply_property(k, v);
217                    }
218                }
219            } else if last_obj_idx.is_some_and(|i| catalog.get_mut(i).is_some()) {
220                let obj = catalog
221                    .get_mut(last_obj_idx.expect("last_obj_idx is Some per is_some_and check"))
222                    .expect("catalog.get_mut succeeds per is_some_and check");
223                for (k, v) in properties {
224                    obj.apply_property(k, v);
225                }
226                // Handle LineGeometry cond property sequencing in continuation.
227                if let DssObject::LineGeometry(ref mut geo) = *obj {
228                    apply_geometry_cond_properties(geo, properties);
229                }
230            }
231        }
232
233        DssCommand::Set { key, value } => {
234            if key.to_lowercase() == "frequency" {
235                *frequency_hz = value.parse::<f64>().unwrap_or(*frequency_hz);
236            }
237        }
238
239        DssCommand::Redirect { path } | DssCommand::Compile { path } => {
240            if let Some(base) = base_dir {
241                let file_path = base.join(path);
242                let content =
243                    std::fs::read_to_string(&file_path).map_err(|source| DssParseError::Io {
244                        path: file_path.to_string_lossy().to_string(),
245                        source,
246                    })?;
247                let child_base = file_path.parent().unwrap_or(base);
248                process_dss_content(
249                    &content,
250                    Some(child_base),
251                    depth + 1,
252                    catalog,
253                    last_obj_idx,
254                    last_was_circuit,
255                    frequency_hz,
256                )?;
257            }
258        }
259
260        DssCommand::Solve | DssCommand::Unknown { .. } => {
261            // Skip.
262        }
263    }
264
265    Ok(())
266}
267
268/// Handle `cond=N x=val h=val wire=name` property groups in LineGeometry.
269///
270/// OpenDSS uses `cond` as a cursor to set per-conductor properties.
271fn apply_geometry_cond_properties(
272    geo: &mut super::objects::LineGeometryData,
273    props: &[(String, String)],
274) {
275    let mut current_cond: Option<usize> = None;
276
277    for (k, v) in props {
278        match k.to_lowercase().as_str() {
279            "cond" => {
280                current_cond = v.trim().parse::<usize>().ok().map(|n| n - 1); // convert to 0-based
281            }
282            "x" => {
283                if let Some(idx) = current_cond {
284                    let x = v.trim().parse::<f64>().unwrap_or(0.0);
285                    let factor = geo.units.to_km_factor() * 1000.0; // to metres
286                    geo.set_cond_x(idx, x * factor);
287                }
288            }
289            "h" => {
290                if let Some(idx) = current_cond {
291                    let h = v.trim().parse::<f64>().unwrap_or(0.0);
292                    let factor = geo.units.to_km_factor() * 1000.0;
293                    geo.set_cond_h(idx, h * factor);
294                }
295            }
296            "wire" => {
297                if let Some(idx) = current_cond {
298                    geo.set_cond_wire(idx, v.trim());
299                }
300            }
301            _ => {}
302        }
303    }
304}
305
306// ─────────────────────────────────────────────────────────────────────────────
307// Build Network from resolved catalog
308// ─────────────────────────────────────────────────────────────────────────────
309
310fn build_network(
311    catalog: DssCatalog,
312    bus_map: HashMap<String, u32>,
313    frequency_hz: f64,
314) -> Result<Network, DssParseError> {
315    let circ = catalog.circuit.as_ref().ok_or(DssParseError::NoCircuit)?;
316
317    let mut net = Network::new(&circ.name);
318    net.base_mva = 100.0;
319
320    // ── Buses ────────────────────────────────────────────────────────────────
321    // Create a Bus for every entry in the bus_map.
322    // Use BFS zone propagation to assign correct base_kv to each bus.
323    // This correctly handles multi-voltage networks (distribution feeders with
324    // substation transformers) where junction buses have no directly attached
325    // element that declares a kV.
326    let zone_kv = build_zone_base_kv(&catalog, &bus_map, circ);
327
328    let mut buses: Vec<Bus> = {
329        let mut v: Vec<(u32, String)> = bus_map
330            .iter()
331            .map(|(name, &num)| (num, name.clone()))
332            .collect();
333        v.sort_by_key(|(num, _)| *num);
334        v.into_iter()
335            .map(|(num, name)| {
336                let base_kv = zone_kv
337                    .get(&name)
338                    .copied()
339                    .unwrap_or_else(|| infer_base_kv(&name, &catalog, circ));
340
341                let mut bus = Bus::new(num, BusType::PQ, base_kv);
342                bus.name = name;
343                bus.voltage_max_pu = 1.10;
344                bus.voltage_min_pu = 0.90;
345                bus
346            })
347            .collect()
348    };
349
350    // Mark source bus as slack.
351    let source_name = circ.bus.to_lowercase();
352    for bus in &mut buses {
353        if bus.name == source_name {
354            bus.bus_type = BusType::Slack;
355            bus.voltage_magnitude_pu = circ.pu;
356            bus.voltage_angle_rad = 0.0;
357            bus.base_kv = circ.base_kv;
358            break;
359        }
360    }
361
362    net.buses = buses;
363
364    // ── Lines → Branches ─────────────────────────────────────────────────────
365    let base_mva = net.base_mva;
366
367    for obj in &catalog.objects {
368        if let DssObject::Line(line) = obj {
369            if line.bus1.is_empty() || line.bus2.is_empty() {
370                continue;
371            }
372            let from_name = strip_phases(&line.bus1).to_lowercase();
373            let to_name = strip_phases(&line.bus2).to_lowercase();
374
375            let from_num = match bus_map.get(&from_name) {
376                Some(&n) => n,
377                None => {
378                    tracing::warn!("Line.{}: bus '{}' not in bus map", line.name, from_name);
379                    continue;
380                }
381            };
382            let to_num = match bus_map.get(&to_name) {
383                Some(&n) => n,
384                None => {
385                    tracing::warn!("Line.{}: bus '{}' not in bus map", line.name, to_name);
386                    continue;
387                }
388            };
389
390            // Get base kV of the from-bus for per-unit conversion.
391            // Prefer the BFS zone map (correctly handles junction buses without
392            // directly attached loads/generators).
393            let base_kv = zone_kv
394                .get(&from_name)
395                .copied()
396                .unwrap_or_else(|| infer_base_kv_for_line(line, &catalog, circ));
397            let z_base = base_kv * base_kv / base_mva;
398
399            // Convert length to km.
400            let length_km = line.length * effective_units(line).to_km_factor();
401
402            // Compute per-unit impedance.
403            let (r_pu, x_pu, b_pu) = if !line.rmatrix.is_empty() {
404                // Use positive-sequence extracted from matrix (diagonal average minus off-diagonal average).
405                let (r1, x1, b1) = matrix_to_sequence(
406                    &line.rmatrix,
407                    &line.xmatrix,
408                    &line.cmatrix,
409                    line.phases as usize,
410                    length_km,
411                    z_base,
412                    frequency_hz,
413                );
414                (r1, x1, b1)
415            } else {
416                // Use explicit sequence values.
417                let r = line.r1 * length_km / z_base;
418                let x = line.x1 * length_km / z_base;
419                // Capacitance: c1 in µS/km → B = c1 * length_km * z_base * 2π f × 10⁻⁶
420                let b = if line.c1 > 0.0 {
421                    line.c1 * 1e-6 * length_km * z_base * 2.0 * std::f64::consts::PI * frequency_hz
422                } else {
423                    0.0
424                };
425                (r, x, b)
426            };
427
428            // Guard against degenerate impedance (open switch → very large Z).
429            if line.is_switch {
430                let mut br = Branch::new_line(from_num, to_num, 1e6, 1e6, 0.0);
431                br.in_service = true;
432                net.branches.push(br);
433                continue;
434            }
435
436            let mut branch =
437                Branch::new_line(from_num, to_num, r_pu.max(1e-9), x_pu.max(1e-9), b_pu);
438            branch.in_service = true;
439            branch.rating_a_mva = line.norm_amps * base_kv * 3.0_f64.sqrt() / 1000.0;
440            net.branches.push(branch);
441        }
442    }
443
444    // ── Transformers → Branches ──────────────────────────────────────────────
445    for obj in &catalog.objects {
446        if let DssObject::Transformer(xfmr) = obj {
447            if xfmr.buses.len() < 2 {
448                continue;
449            }
450
451            let primary_bus = strip_phases(&xfmr.buses[0]).to_lowercase();
452            let secondary_bus = strip_phases(&xfmr.buses[1]).to_lowercase();
453
454            let from_num = match bus_map.get(&primary_bus) {
455                Some(&n) => n,
456                None => {
457                    tracing::warn!(
458                        "Transformer.{}: primary bus '{}' not in map",
459                        xfmr.name,
460                        primary_bus
461                    );
462                    continue;
463                }
464            };
465            let to_num = match bus_map.get(&secondary_bus) {
466                Some(&n) => n,
467                None => {
468                    tracing::warn!(
469                        "Transformer.{}: secondary bus '{}' not in map",
470                        xfmr.name,
471                        secondary_bus
472                    );
473                    continue;
474                }
475            };
476
477            let _kv_primary = xfmr.kvs.first().copied().unwrap_or(115.0);
478            let kv_secondary = xfmr.kvs.get(1).copied().unwrap_or(12.47);
479            let kva = xfmr.kvas.first().copied().unwrap_or(1000.0);
480
481            // Per-unit leakage reactance (from %X, based on transformer MVA base).
482            let xfmr_mva = kva / 1000.0;
483            let x_pu_xfmr = xfmr.xhl / 100.0; // %Xhl → per-unit on xfmr base
484
485            // Total winding resistance: sum of %R from both windings / 2 (split equally).
486            let r_pu_xfmr = xfmr.pct_rs.iter().sum::<f64>() / 100.0 / 2.0;
487
488            // Convert from transformer MVA base to system base.
489            let r_pu = r_pu_xfmr * base_mva / xfmr_mva;
490            let x_pu = x_pu_xfmr * base_mva / xfmr_mva;
491
492            // Off-nominal tap: primary tap / secondary tap (account for winding voltages).
493            let tap1 = xfmr.taps.first().copied().unwrap_or(1.0);
494            let tap2 = xfmr.taps.get(1).copied().unwrap_or(1.0);
495
496            // Actual turns ratio vs nominal ratio (kV_primary / kV_secondary).
497            // Off-nominal tap: t = (tap1/tap2) * (kV_secondary_base / kV_primary_base).
498            // In DSS, base kV for the system is from the Circuit element.
499            // We use the transformer's own kV ratings as the "rated" values.
500            let tap = tap1 / tap2; // if different, there's an off-nominal ratio
501
502            // Connection: delta on primary → phase shift of -30° (convention).
503            let shift: f64 = match (&xfmr.conns.first(), &xfmr.conns.get(1)) {
504                (Some(WdgConn::Delta), Some(WdgConn::Wye) | Some(WdgConn::Ln)) => -30.0,
505                (Some(WdgConn::Wye) | Some(WdgConn::Ln), Some(WdgConn::Delta)) => 30.0,
506                _ => 0.0,
507            };
508
509            let connection = match (&xfmr.conns.first(), &xfmr.conns.get(1)) {
510                (Some(WdgConn::Delta), Some(WdgConn::Wye) | Some(WdgConn::Ln)) => {
511                    TransformerConnection::DeltaWyeG
512                }
513                (Some(WdgConn::Wye) | Some(WdgConn::Ln), Some(WdgConn::Delta)) => {
514                    TransformerConnection::WyeGDelta
515                }
516                (Some(WdgConn::Delta), Some(WdgConn::Delta)) => TransformerConnection::DeltaDelta,
517                _ => TransformerConnection::WyeGWyeG,
518            };
519
520            let mut branch = Branch::new_line(
521                from_num,
522                to_num,
523                if r_pu.abs() < 1e-6 { 1e-6 } else { r_pu },
524                if x_pu.abs() < 1e-6 {
525                    if x_pu < 0.0 { -1e-6 } else { 1e-6 }
526                } else {
527                    x_pu
528                },
529                0.0,
530            );
531            branch.tap = tap;
532            branch.phase_shift_rad = shift.to_radians();
533            branch
534                .transformer_data
535                .get_or_insert_with(TransformerData::default)
536                .transformer_connection = connection;
537            branch.in_service = true;
538            net.branches.push(branch);
539
540            // 3-winding: add tertiary branch.
541            if xfmr.windings >= 3 && xfmr.buses.len() >= 3 {
542                let tertiary_bus = strip_phases(&xfmr.buses[2]).to_lowercase();
543                if let Some(&tert_num) = bus_map.get(&tertiary_bus) {
544                    let _kv_tert = xfmr.kvs.get(2).copied().unwrap_or(kv_secondary);
545                    let kva_tert = xfmr.kvas.get(2).copied().unwrap_or(kva);
546                    let xfmr_mva_t = kva_tert / 1000.0;
547                    let x_ht_pu = xfmr.xht / 100.0 * base_mva / xfmr_mva_t;
548                    let r_tert =
549                        xfmr.pct_rs.get(2).copied().unwrap_or(0.5) / 100.0 * base_mva / xfmr_mva_t;
550                    let mut br_tert = Branch::new_line(
551                        from_num,
552                        tert_num,
553                        r_tert.max(1e-6),
554                        x_ht_pu.max(1e-6),
555                        0.0,
556                    );
557                    br_tert.in_service = true;
558                    net.branches.push(br_tert);
559                }
560            }
561        }
562    }
563
564    // ── Loads ────────────────────────────────────────────────────────────────
565    for obj in &catalog.objects {
566        if let DssObject::Load(load) = obj {
567            let bus_name = strip_phases(&load.bus1).to_lowercase();
568            if bus_name.is_empty() {
569                continue;
570            }
571            let bus_num = match bus_map.get(&bus_name) {
572                Some(&n) => n,
573                None => {
574                    tracing::warn!("Load.{}: bus '{}' not found", load.name, bus_name);
575                    continue;
576                }
577            };
578
579            let pd_mw = load.kw / 1000.0;
580            let qd_mvar = load.effective_kvar() / 1000.0;
581
582            // Add as explicit Load object.
583            net.loads.push(Load::new(bus_num, pd_mw, qd_mvar));
584        }
585    }
586
587    // ── Generators ──────────────────────────────────────────────────────────
588    for obj in &catalog.objects {
589        if let DssObject::Generator(dss_gen) = obj {
590            let bus_name = strip_phases(&dss_gen.bus1).to_lowercase();
591            if bus_name.is_empty() {
592                continue;
593            }
594            let bus_num = match bus_map.get(&bus_name) {
595                Some(&n) => n,
596                None => {
597                    tracing::warn!("Generator.{}: bus '{}' not found", dss_gen.name, bus_name);
598                    continue;
599                }
600            };
601
602            let pg_mw = dss_gen.kw / 1000.0;
603            let _base_kv_bus = dss_gen.kv;
604            let vs = 1.0; // default voltage setpoint
605
606            let mut g = Generator::new(bus_num, pg_mw, vs);
607            g.q = dss_gen.kvar / 1000.0;
608            g.pmax = dss_gen.kw_max / 1000.0;
609            g.pmin = dss_gen.kw_min / 1000.0;
610            g.qmax = dss_gen.kvar_max / 1000.0;
611            g.qmin = dss_gen.kvar_min / 1000.0;
612            g.machine_base_mva = dss_gen.kva / 1000.0;
613            g.gen_type = GenType::Synchronous;
614            g.technology = Some(GeneratorTechnology::Other);
615            g.fuel.get_or_insert_with(Default::default).fuel_type =
616                Some("dispatchable".to_string());
617
618            // Mark as PV bus.
619            if let Some(bus) = net.buses.iter_mut().find(|b| b.number == bus_num) {
620                bus.bus_type = BusType::PV;
621            }
622
623            net.generators.push(g);
624        }
625    }
626
627    // ── PV Systems ──────────────────────────────────────────────────────────
628    for obj in &catalog.objects {
629        if let DssObject::PvSystem(pv) = obj {
630            let bus_name = strip_phases(&pv.bus1).to_lowercase();
631            if bus_name.is_empty() {
632                continue;
633            }
634            let bus_num = match bus_map.get(&bus_name) {
635                Some(&n) => n,
636                None => {
637                    tracing::warn!("PVSystem.{}: bus '{}' not found", pv.name, bus_name);
638                    continue;
639                }
640            };
641
642            let pg_mw = pv.pmpp * pv.irradiance / 1000.0;
643
644            let mut g = Generator::new(bus_num, pg_mw, 1.0);
645            g.pmax = pv.kw_max / 1000.0;
646            g.pmin = 0.0;
647            g.qmax = pv.kva / 1000.0;
648            g.qmin = -(pv.kva / 1000.0);
649            g.machine_base_mva = pv.kva / 1000.0;
650            g.gen_type = GenType::InverterBased;
651            g.technology = Some(GeneratorTechnology::SolarPv);
652            g.fuel.get_or_insert_with(Default::default).fuel_type = Some("solar".to_string());
653
654            net.generators.push(g);
655        }
656    }
657
658    // ── Storage ─────────────────────────────────────────────────────────────
659    for obj in &catalog.objects {
660        if let DssObject::Storage(stor) = obj {
661            let bus_name = strip_phases(&stor.bus1).to_lowercase();
662            if bus_name.is_empty() {
663                continue;
664            }
665            let bus_num = match bus_map.get(&bus_name) {
666                Some(&n) => n,
667                None => {
668                    tracing::warn!("Storage.{}: bus '{}' not found", stor.name, bus_name);
669                    continue;
670                }
671            };
672
673            let mut g = Generator::new(bus_num, 0.0, 1.0);
674            g.pmax = stor.kw_rated / 1000.0;
675            g.pmin = -(stor.kw_rated / 1000.0); // can charge (inject negative P)
676            g.qmax = stor.kva / 1000.0;
677            g.qmin = -(stor.kva / 1000.0);
678            g.machine_base_mva = stor.kva / 1000.0;
679            g.gen_type = GenType::InverterBased;
680            g.technology = Some(GeneratorTechnology::BatteryStorage);
681            g.fuel.get_or_insert_with(Default::default).fuel_type = Some("storage".to_string());
682
683            net.generators.push(g);
684        }
685    }
686
687    // ── Capacitors → Bus shunt ───────────────────────────────────────────────
688    for obj in &catalog.objects {
689        if let DssObject::Capacitor(cap) = obj {
690            let bus_name = strip_phases(&cap.bus1).to_lowercase();
691            if bus_name.is_empty() {
692                continue;
693            }
694            let bus_num = match bus_map.get(&bus_name) {
695                Some(&n) => n,
696                None => {
697                    tracing::warn!("Capacitor.{}: bus '{}' not found", cap.name, bus_name);
698                    continue;
699                }
700            };
701
702            let q_mvar = cap.total_kvar() / 1000.0;
703
704            if let Some(bus) = net.buses.iter_mut().find(|b| b.number == bus_num) {
705                bus.shunt_susceptance_mvar += q_mvar;
706            }
707        }
708    }
709
710    // ── Reactors → Bus shunt ─────────────────────────────────────────────────
711    for obj in &catalog.objects {
712        if let DssObject::Reactor(react) = obj {
713            let bus_name = strip_phases(&react.bus1).to_lowercase();
714            if bus_name.is_empty() {
715                continue;
716            }
717            let bus_num = match bus_map.get(&bus_name) {
718                Some(&n) => n,
719                None => {
720                    tracing::warn!("Reactor.{}: bus '{}' not found", react.name, bus_name);
721                    continue;
722                }
723            };
724
725            if react.kvar > 0.0 {
726                let q_mvar = react.kvar / 1000.0;
727                // Reactor absorbs Q → negative susceptance.
728                if let Some(bus) = net.buses.iter_mut().find(|b| b.number == bus_num) {
729                    bus.shunt_susceptance_mvar -= q_mvar;
730                }
731            }
732        }
733    }
734
735    // Make sure there's at least one slack bus.
736    #[allow(clippy::collapsible_if)]
737    if net.buses.iter().all(|b| b.bus_type != BusType::Slack) {
738        if let Some(b) = net.buses.first_mut() {
739            b.bus_type = BusType::Slack;
740        }
741    }
742    Ok(net)
743}
744
745// ─────────────────────────────────────────────────────────────────────────────
746// Helpers
747// ─────────────────────────────────────────────────────────────────────────────
748
749/// Determine the effective length unit for a line, falling back to km.
750fn effective_units(line: &super::objects::LineData) -> LengthUnit {
751    if line.units == LengthUnit::None {
752        LengthUnit::Km
753    } else {
754        line.units
755    }
756}
757
758/// Build a map from bus name → base kV using BFS zone propagation.
759///
760/// Distribution networks have multiple voltage zones separated by transformers.
761/// A simple element-lookup cannot determine the base kV for junction buses
762/// (no load/generator directly attached) that lie in a voltage zone different
763/// from the source.  BFS through lines and transformers assigns the correct
764/// zone voltage to every bus.
765///
766/// Algorithm:
767/// 1. Build an adjacency list from all lines, series reactors, and transformers.
768///    - Lines and series reactors: both endpoints share the same kV (ratio = 1.0).
769///    - Transformers: endpoints have a kV ratio equal to their winding kV ratings.
770/// 2. Seed the BFS queue with the circuit source bus at `circ.base_kv`.
771/// 3. For each bus dequeued, propagate its kV to all unvisited neighbours via
772///    the edge ratio.  Only newly-assigned buses are enqueued — each bus is
773///    processed at most once, giving O(E) time instead of O(passes × E).
774/// 4. For any bus not reached by BFS, fall back to element-lookup heuristic.
775fn build_zone_base_kv(
776    catalog: &DssCatalog,
777    bus_map: &std::collections::HashMap<String, u32>,
778    circ: &super::objects::CircuitData,
779) -> std::collections::HashMap<String, f64> {
780    // ── Step 1: build adjacency list ─────────────────────────────────────────
781    // Each edge is (neighbour_bus_name, kv_ratio_to_apply).
782    // For a line edge (a → b): kv_ratio = 1.0 (same zone).
783    // For a transformer edge (winding_i → winding_j): kv_ratio = kv_j / kv_i
784    //   (the zone kV scales by the winding voltage ratio).
785
786    // adjacency: bus_name → Vec<(neighbour_name, ratio_neighbour_kv_from_self_kv)>
787    let mut adj: std::collections::HashMap<String, Vec<(String, f64)>> =
788        std::collections::HashMap::with_capacity(bus_map.len());
789
790    let mut add_edge = |a: String, b: String, ratio_b_from_a: f64| {
791        adj.entry(a.clone())
792            .or_default()
793            .push((b.clone(), ratio_b_from_a));
794        adj.entry(b).or_default().push((a, 1.0 / ratio_b_from_a));
795    };
796
797    for obj in &catalog.objects {
798        match obj {
799            DssObject::Line(line) => {
800                if line.bus1.is_empty() || line.bus2.is_empty() {
801                    continue;
802                }
803                let b1 = strip_phases(&line.bus1).to_lowercase();
804                let b2 = strip_phases(&line.bus2).to_lowercase();
805                if b1.is_empty() || b2.is_empty() {
806                    continue;
807                }
808                if !bus_map.contains_key(&b1) || !bus_map.contains_key(&b2) {
809                    continue;
810                }
811                add_edge(b1, b2, 1.0);
812            }
813            DssObject::Reactor(react) => {
814                if react.bus2.is_empty() {
815                    continue; // shunt reactor — no propagation
816                }
817                let b1 = strip_phases(&react.bus1).to_lowercase();
818                let b2 = strip_phases(&react.bus2).to_lowercase();
819                if b1.is_empty() || b2.is_empty() {
820                    continue;
821                }
822                if !bus_map.contains_key(&b1) || !bus_map.contains_key(&b2) {
823                    continue;
824                }
825                add_edge(b1, b2, 1.0);
826            }
827            DssObject::Transformer(xfmr) => {
828                if xfmr.buses.len() < 2 {
829                    continue;
830                }
831                // Collect (bus_name, rated_kv) for each winding.
832                let windings: Vec<(String, f64)> = xfmr
833                    .buses
834                    .iter()
835                    .enumerate()
836                    .filter_map(|(i, b)| {
837                        let name = strip_phases(b).to_lowercase();
838                        let kv = xfmr.kvs.get(i).copied().unwrap_or(0.0);
839                        if name.is_empty() || kv <= 0.0 || !bus_map.contains_key(&name) {
840                            None
841                        } else {
842                            Some((name, kv))
843                        }
844                    })
845                    .collect();
846
847                // Add edges between every pair of windings.
848                for i in 0..windings.len() {
849                    for j in (i + 1)..windings.len() {
850                        let (ref bi, kvi) = windings[i];
851                        let (ref bj, kvj) = windings[j];
852                        // ratio: zone_kv_j = zone_kv_i * (kvj / kvi)
853                        add_edge(bi.clone(), bj.clone(), kvj / kvi);
854                    }
855                }
856            }
857            _ => {}
858        }
859    }
860
861    // ── Step 2: BFS from the source bus ──────────────────────────────────────
862    let mut zone_kv: std::collections::HashMap<String, f64> =
863        std::collections::HashMap::with_capacity(bus_map.len());
864
865    let source = circ.bus.to_lowercase();
866    zone_kv.insert(source.clone(), circ.base_kv);
867
868    let mut queue = std::collections::VecDeque::new();
869    queue.push_back(source);
870
871    while let Some(bus) = queue.pop_front() {
872        let kv_bus = match zone_kv.get(&bus).copied() {
873            Some(k) => k,
874            None => continue,
875        };
876        if let Some(neighbours) = adj.get(&bus) {
877            for (nb, ratio) in neighbours {
878                if !zone_kv.contains_key(nb.as_str()) {
879                    let kv_nb = kv_bus * ratio;
880                    zone_kv.insert(nb.clone(), kv_nb);
881                    queue.push_back(nb.clone());
882                }
883            }
884        }
885    }
886
887    // ── Step 3: fall back for any bus not reached by BFS ─────────────────────
888    for bus_name in bus_map.keys() {
889        if !zone_kv.contains_key(bus_name.as_str()) {
890            let kv = infer_base_kv_heuristic(bus_name, catalog, circ);
891            zone_kv.insert(bus_name.clone(), kv);
892        }
893    }
894
895    zone_kv
896}
897
898/// Estimate base kV for a bus by looking at what elements directly connect to it.
899/// Used as a fallback when BFS zone propagation cannot determine the voltage.
900fn infer_base_kv_heuristic(
901    bus_name: &str,
902    catalog: &DssCatalog,
903    circ: &super::objects::CircuitData,
904) -> f64 {
905    // Check transformers: winding buses tell us the kV for each side.
906    for obj in &catalog.objects {
907        if let DssObject::Transformer(xfmr) = obj {
908            for (i, b) in xfmr.buses.iter().enumerate() {
909                if strip_phases(&b.to_lowercase()) == bus_name
910                    && xfmr.kvs.get(i).is_some_and(|&kv| kv > 0.0)
911                {
912                    return xfmr.kvs[i];
913                }
914            }
915        }
916    }
917
918    // Check loads and generators.
919    for obj in &catalog.objects {
920        match obj {
921            DssObject::Load(l) if strip_phases(&l.bus1.to_lowercase()) == bus_name => {
922                if l.kv > 0.0 {
923                    return l.kv;
924                }
925            }
926            DssObject::Generator(g) if strip_phases(&g.bus1.to_lowercase()) == bus_name => {
927                if g.kv > 0.0 {
928                    return g.kv;
929                }
930            }
931            DssObject::Capacitor(c) if strip_phases(&c.bus1.to_lowercase()) == bus_name => {
932                if c.kv > 0.0 {
933                    return c.kv;
934                }
935            }
936            _ => {}
937        }
938    }
939
940    circ.base_kv
941}
942
943/// Estimate base kV for a bus — uses the BFS zone map when available,
944/// then falls back to heuristic lookup.
945fn infer_base_kv(bus_name: &str, catalog: &DssCatalog, circ: &super::objects::CircuitData) -> f64 {
946    // Check circuit source bus first (fast path).
947    if bus_name == circ.bus.to_lowercase() {
948        return circ.base_kv;
949    }
950    infer_base_kv_heuristic(bus_name, catalog, circ)
951}
952
953/// Estimate base kV for a line's from-bus.
954fn infer_base_kv_for_line(
955    line: &super::objects::LineData,
956    catalog: &DssCatalog,
957    circ: &super::objects::CircuitData,
958) -> f64 {
959    infer_base_kv(strip_phases(&line.bus1.to_lowercase()), catalog, circ)
960}
961
962/// Extract positive-sequence impedance from a 3×3 (or n×n) phase matrix.
963///
964/// Uses the standard Fortescue transformation: Z1 = (1/3)(Zaa + 2*Zab)
965/// where Zaa is the self impedance and Zab is the mutual.
966///
967/// The matrix is stored row-major in the lower-triangular DSS format:
968///   [Zaa, Zab, Zbb, Zac, Zbc, Zcc] for a 3-phase system.
969fn matrix_to_sequence(
970    rmat: &[f64],
971    xmat: &[f64],
972    cmat: &[f64],
973    n: usize,
974    length_km: f64,
975    z_base: f64,
976    freq: f64,
977) -> (f64, f64, f64) {
978    if n == 0 || rmat.is_empty() {
979        return (1e-4, 1e-3, 0.0);
980    }
981
982    let n = n.min(3);
983
984    // Helper: get element (i,j) from lower-triangular storage.
985    let get = |mat: &[f64], i: usize, j: usize| -> f64 {
986        let (row, col) = if i >= j { (i, j) } else { (j, i) };
987        let idx = row * (row + 1) / 2 + col;
988        mat.get(idx).copied().unwrap_or(0.0)
989    };
990
991    // Self-impedance average (diagonal).
992    let r_self: f64 = (0..n).map(|i| get(rmat, i, i)).sum::<f64>() / n as f64;
993    let x_self: f64 = (0..n).map(|i| get(xmat, i, i)).sum::<f64>() / n as f64;
994
995    // Mutual impedance average (off-diagonal pairs).
996    let n_pairs = if n > 1 { n * (n - 1) / 2 } else { 1 };
997    let mut r_mut_sum = 0.0;
998    let mut x_mut_sum = 0.0;
999    for i in 0..n {
1000        for j in (i + 1)..n {
1001            r_mut_sum += get(rmat, i, j);
1002            x_mut_sum += get(xmat, i, j);
1003        }
1004    }
1005    let r_mut = r_mut_sum / n_pairs as f64;
1006    let x_mut = x_mut_sum / n_pairs as f64;
1007
1008    // Positive-sequence: Z1 = Z_self - Z_mutual.
1009    let r1 = (r_self - r_mut) * length_km / z_base;
1010    let x1 = (x_self - x_mut) * length_km / z_base;
1011
1012    // Shunt susceptance from capacitance matrix (µS/km → p.u.).
1013    let b_pu = if !cmat.is_empty() {
1014        let c_self: f64 = (0..n).map(|i| get(cmat, i, i)).sum::<f64>() / n as f64;
1015        // c_self is in nF/km → S/km = c_self × 1e-9 × 2π f
1016        let b_s_per_km = c_self * 1e-9 * 2.0 * std::f64::consts::PI * freq;
1017        b_s_per_km * length_km * z_base
1018    } else {
1019        0.0
1020    };
1021
1022    (r1.max(1e-9), x1.max(1e-9), b_pu)
1023}
1024
1025#[cfg(test)]
1026mod tests_zone_kv {
1027    use super::*;
1028
1029    fn benchmark_path(rel: &str) -> std::path::PathBuf {
1030        let path = std::path::Path::new(rel);
1031        if path.exists() {
1032            return path.to_path_buf();
1033        }
1034
1035        let mut base = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1036        base.pop();
1037        base.pop();
1038        base.push(rel);
1039        base
1040    }
1041
1042    /// Verify that BFS zone propagation correctly assigns 24.9 kV to IEEE-34
1043    /// distribution buses (not the 69 kV transmission source voltage).
1044    #[test]
1045    fn test_zone_kv_ieee34_file() {
1046        let path = benchmark_path("benchmarks/instances/dss/ieee34/ieee34Mod1.dss");
1047        if !path.exists() {
1048            return;
1049        }
1050        let net = parse_dss(&path).expect("parse ieee34");
1051
1052        // Bus 800 is the primary distribution bus at 24.9 kV
1053        let bus_800 = net.buses.iter().find(|b| b.name == "800");
1054        assert!(bus_800.is_some(), "bus 800 not found");
1055        let kv = bus_800.unwrap().base_kv;
1056        assert!(
1057            (kv - 24.9).abs() < 1.0,
1058            "bus 800 should be ~24.9 kV, got {:.4} kV",
1059            kv
1060        );
1061
1062        // Bus 802 propagated from 800 via line
1063        let bus_802 = net.buses.iter().find(|b| b.name == "802");
1064        if let Some(b) = bus_802 {
1065            assert!(
1066                (b.base_kv - 24.9).abs() < 1.0,
1067                "bus 802 should be ~24.9 kV, got {:.4} kV",
1068                b.base_kv
1069            );
1070        }
1071    }
1072}
1073
1074#[cfg(test)]
1075mod tests_truthfulness {
1076    use super::*;
1077
1078    #[test]
1079    fn test_nested_redirect_and_compile_follow_child_base_dir() {
1080        let dir = tempfile::tempdir().unwrap();
1081        let subdir = dir.path().join("sub");
1082        std::fs::create_dir_all(&subdir).unwrap();
1083
1084        std::fs::write(
1085            dir.path().join("main.dss"),
1086            "New Circuit.main basekv=12.47 bus1=source\nRedirect sub/child.dss\n",
1087        )
1088        .unwrap();
1089        std::fs::write(subdir.join("child.dss"), "Compile grandchild.dss\n").unwrap();
1090        std::fs::write(
1091            subdir.join("grandchild.dss"),
1092            "New Line.L1 bus1=source.1 bus2=load.1 phases=1 r1=0.1 x1=0.2 length=1\n",
1093        )
1094        .unwrap();
1095
1096        let net = parse_dss(&dir.path().join("main.dss")).expect("nested includes should resolve");
1097        assert_eq!(net.n_buses(), 2);
1098        assert_eq!(net.n_branches(), 1);
1099        assert!(net.branches[0].r > 0.0);
1100        assert!(net.branches[0].x > 0.0);
1101    }
1102
1103    #[test]
1104    fn test_like_clones_base_object_properties() {
1105        let dss = r#"
1106New Circuit.main basekv=12.47 bus1=source
1107New Line.Base bus1=source.1 bus2=load.1 phases=1 r1=0.1 x1=0.2 length=1 normamps=300
1108New Line.Clone like=Base
1109"#;
1110
1111        let net = parse_dss_str(dss).expect("like= clone should parse");
1112        assert_eq!(net.n_branches(), 2);
1113        assert!((net.branches[0].r - net.branches[1].r).abs() < 1e-12);
1114        assert!((net.branches[0].x - net.branches[1].x).abs() < 1e-12);
1115        assert!((net.branches[0].rating_a_mva - net.branches[1].rating_a_mva).abs() < 1e-12);
1116    }
1117}