1use 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#[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
56pub 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
66pub fn parse_dss_str(content: &str) -> Result<Network, DssParseError> {
68 parse_dss_str_with_base(content, None)
69}
70
71fn 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 resolve_linecodes(&mut catalog);
94 resolve_xfmrcodes(&mut catalog);
95
96 let bus_map = build_bus_map(&catalog);
97
98 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
133fn 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 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 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 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 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 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 }
263 }
264
265 Ok(())
266}
267
268fn 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); }
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; 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
306fn 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 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 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 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 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 let length_km = line.length * effective_units(line).to_km_factor();
401
402 let (r_pu, x_pu, b_pu) = if !line.rmatrix.is_empty() {
404 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 let r = line.r1 * length_km / z_base;
418 let x = line.x1 * length_km / z_base;
419 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 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 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 let xfmr_mva = kva / 1000.0;
483 let x_pu_xfmr = xfmr.xhl / 100.0; let r_pu_xfmr = xfmr.pct_rs.iter().sum::<f64>() / 100.0 / 2.0;
487
488 let r_pu = r_pu_xfmr * base_mva / xfmr_mva;
490 let x_pu = x_pu_xfmr * base_mva / xfmr_mva;
491
492 let tap1 = xfmr.taps.first().copied().unwrap_or(1.0);
494 let tap2 = xfmr.taps.get(1).copied().unwrap_or(1.0);
495
496 let tap = tap1 / tap2; 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 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 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 net.loads.push(Load::new(bus_num, pd_mw, qd_mvar));
584 }
585 }
586
587 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; 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 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 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 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); 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 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 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 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 #[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
745fn effective_units(line: &super::objects::LineData) -> LengthUnit {
751 if line.units == LengthUnit::None {
752 LengthUnit::Km
753 } else {
754 line.units
755 }
756}
757
758fn 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 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; }
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 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 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 add_edge(bi.clone(), bj.clone(), kvj / kvi);
854 }
855 }
856 }
857 _ => {}
858 }
859 }
860
861 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 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
898fn infer_base_kv_heuristic(
901 bus_name: &str,
902 catalog: &DssCatalog,
903 circ: &super::objects::CircuitData,
904) -> f64 {
905 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 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
943fn infer_base_kv(bus_name: &str, catalog: &DssCatalog, circ: &super::objects::CircuitData) -> f64 {
946 if bus_name == circ.bus.to_lowercase() {
948 return circ.base_kv;
949 }
950 infer_base_kv_heuristic(bus_name, catalog, circ)
951}
952
953fn 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
962fn 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 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 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 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 let r1 = (r_self - r_mut) * length_km / z_base;
1010 let x1 = (x_self - x_mut) * length_km / z_base;
1011
1012 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 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 #[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 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 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}