1use std::collections::BTreeSet;
27use std::fmt::Write as FmtWrite;
28use std::path::Path;
29
30use surge_network::Network;
31use surge_network::network::{BusType, TransformerConnection};
32use thiserror::Error;
33
34#[derive(Error, Debug)]
35pub enum DssWriteError {
36 #[error("I/O error: {0}")]
37 Io(#[from] std::io::Error),
38 #[error("format error: {0}")]
39 Fmt(#[from] std::fmt::Error),
40 #[error("network has no slack bus — cannot determine circuit source")]
41 NoSlackBus,
42}
43
44pub fn write_dss(network: &Network, path: &Path) -> Result<(), DssWriteError> {
46 let content = to_dss_string(network)?;
47 std::fs::write(path, content)?;
48 Ok(())
49}
50
51pub fn to_dss_string(network: &Network) -> Result<String, DssWriteError> {
53 let mut out = String::with_capacity(32 * 1024);
54
55 let slack_bus = network
57 .buses
58 .iter()
59 .find(|b| b.bus_type == BusType::Slack)
60 .or_else(|| network.buses.first())
61 .ok_or(DssWriteError::NoSlackBus)?;
62
63 let base_mva = network.base_mva;
64
65 let bus_name = |bus_num: u32| -> String {
68 network
69 .buses
70 .iter()
71 .find(|b| b.number == bus_num)
72 .map(|b| {
73 if b.name.is_empty() {
74 format!("bus{}", b.number)
75 } else {
76 b.name.trim().replace(' ', "_")
80 }
81 })
82 .unwrap_or_else(|| format!("bus{}", bus_num))
83 };
84
85 let bus_base_kv = |bus_num: u32| -> f64 {
86 network
87 .buses
88 .iter()
89 .find(|b| b.number == bus_num)
90 .map(|b| b.base_kv)
91 .unwrap_or(1.0)
92 };
93
94 writeln!(
96 out,
97 "! OpenDSS script exported by Surge (https://github.com/amptimal/surge)"
98 )?;
99 writeln!(out, "! Network: {}", network.name)?;
100 writeln!(out, "! Base MVA: {}", base_mva)?;
101 writeln!(out)?;
102 writeln!(out, "Clear")?;
103 writeln!(out)?;
104
105 let circuit_name = sanitize_dss_name(&network.name);
107 let source_bus_name = bus_name(slack_bus.number);
108 writeln!(
109 out,
110 "New Circuit.{} Bus1={} BasekV={:.4} pu={:.6} phases=3",
111 circuit_name, source_bus_name, slack_bus.base_kv, slack_bus.voltage_magnitude_pu,
112 )?;
113 writeln!(out)?;
114
115 let lines: Vec<_> = network
117 .branches
118 .iter()
119 .enumerate()
120 .filter(|(_, br)| br.in_service && !br.is_transformer())
121 .collect();
122
123 if !lines.is_empty() {
124 writeln!(
125 out,
126 "! ── Lines ────────────────────────────────────────────────────"
127 )?;
128 }
129 for (i, br) in &lines {
130 let from_name = bus_name(br.from_bus);
131 let to_name = bus_name(br.to_bus);
132 let from_kv = bus_base_kv(br.from_bus);
133
134 let z_base = from_kv * from_kv / base_mva;
136 let r_ohm = br.r * z_base;
137 let x_ohm = br.x * z_base;
138
139 let b_siemens = br.b / z_base;
147
148 let b_us = b_siemens * 1e6;
151
152 let line_name = format!("line_{}_{}", br.from_bus, br.to_bus);
153 let line_name = if lines
155 .iter()
156 .filter(|(_, b)| {
157 (b.from_bus == br.from_bus && b.to_bus == br.to_bus)
158 || (b.from_bus == br.to_bus && b.to_bus == br.from_bus)
159 })
160 .count()
161 > 1
162 {
163 format!("{}_{}", line_name, i)
164 } else {
165 line_name
166 };
167
168 write!(
169 out,
170 "New Line.{} Bus1={} Bus2={} R1={:.8} X1={:.8}",
171 line_name, from_name, to_name, r_ohm, x_ohm,
172 )?;
173 if b_us.abs() > 1e-12 {
174 write!(out, " B1={:.8}", b_us)?;
175 }
176 writeln!(out, " Length=1 Units=none")?;
177 }
178 if !lines.is_empty() {
179 writeln!(out)?;
180 }
181
182 let xfmrs: Vec<_> = network
184 .branches
185 .iter()
186 .enumerate()
187 .filter(|(_, br)| br.in_service && br.is_transformer())
188 .collect();
189
190 if !xfmrs.is_empty() {
191 writeln!(
192 out,
193 "! ── Transformers ──────────────────────────────────────────────"
194 )?;
195 }
196 for (i, br) in &xfmrs {
197 let from_name = bus_name(br.from_bus);
198 let to_name = bus_name(br.to_bus);
199 let from_kv = bus_base_kv(br.from_bus);
200 let to_kv = bus_base_kv(br.to_bus);
201
202 let kva = if br.rating_a_mva > 0.0 {
204 br.rating_a_mva * 1000.0 } else {
206 base_mva * 1000.0
207 };
208
209 let xfmr_mva = kva / 1000.0;
213 let x_pct = br.x * (xfmr_mva / base_mva) * 100.0;
214 let r_pct = br.r * (xfmr_mva / base_mva) * 100.0;
215
216 let xfmr_name = format!("xfmr_{}_{}", br.from_bus, br.to_bus);
217 let xfmr_name = if xfmrs
218 .iter()
219 .filter(|(_, b)| {
220 (b.from_bus == br.from_bus && b.to_bus == br.to_bus)
221 || (b.from_bus == br.to_bus && b.to_bus == br.from_bus)
222 })
223 .count()
224 > 1
225 {
226 format!("{}_{}", xfmr_name, i)
227 } else {
228 xfmr_name
229 };
230
231 let xfmr_conn = br
233 .transformer_data
234 .as_ref()
235 .map(|t| t.transformer_connection)
236 .unwrap_or_default();
237 let (conn1, conn2) = match xfmr_conn {
238 TransformerConnection::DeltaWyeG => ("delta", "wye"),
239 TransformerConnection::WyeGDelta => ("wye", "delta"),
240 TransformerConnection::DeltaDelta => ("delta", "delta"),
241 TransformerConnection::WyeGWye | TransformerConnection::WyeGWyeG => ("wye", "wye"),
242 };
243
244 writeln!(
245 out,
246 "New Transformer.{name} Windings=2 Buses=[{b1}, {b2}] \
247 Conns=[{c1}, {c2}] kVs=[{kv1:.4}, {kv2:.4}] \
248 kVAs=[{kva:.1}, {kva:.1}] \
249 XHL={xhl:.6} %Rs=[{r1:.6}, {r2:.6}] \
250 Taps=[{t1:.6}, 1.0]",
251 name = xfmr_name,
252 b1 = from_name,
253 b2 = to_name,
254 c1 = conn1,
255 c2 = conn2,
256 kv1 = from_kv,
257 kv2 = to_kv,
258 kva = kva,
259 xhl = x_pct,
260 r1 = r_pct / 2.0,
261 r2 = r_pct / 2.0,
262 t1 = br.tap,
263 )?;
264 }
265 if !xfmrs.is_empty() {
266 writeln!(out)?;
267 }
268
269 let has_explicit_loads = !network.loads.is_empty();
273
274 if has_explicit_loads {
275 let active_loads: Vec<_> = network
276 .loads
277 .iter()
278 .filter(|l| {
279 l.in_service
280 && (l.active_power_demand_mw.abs() > 1e-9
281 || l.reactive_power_demand_mvar.abs() > 1e-9)
282 })
283 .collect();
284
285 if !active_loads.is_empty() {
286 writeln!(
287 out,
288 "! ── Loads ────────────────────────────────────────────────────"
289 )?;
290 }
291 let mut load_counter: std::collections::HashMap<u32, u32> =
292 std::collections::HashMap::new();
293 for load in &active_loads {
294 let bn = bus_name(load.bus);
295 let kv = bus_base_kv(load.bus);
296 let kw = load.active_power_demand_mw * 1000.0;
298 let kvar = load.reactive_power_demand_mvar * 1000.0;
299
300 let count = load_counter.entry(load.bus).or_insert(0);
301 *count += 1;
302 let load_name = if *count > 1 {
303 format!("load_{}_{}", load.bus, count)
304 } else {
305 format!("load_{}", load.bus)
306 };
307
308 writeln!(
309 out,
310 "New Load.{} Bus1={} kW={:.4} kvar={:.4} kV={:.4} Model=1",
311 load_name, bn, kw, kvar, kv,
312 )?;
313 }
314 if !active_loads.is_empty() {
315 writeln!(out)?;
316 }
317 } else {
318 if false {
320 writeln!(out)?;
321 }
322 }
323
324 let active_gens: Vec<_> = network.generators.iter().filter(|g| g.in_service).collect();
326
327 let slack_bus_num = slack_bus.number;
332 let n_gens_on_slack = active_gens
333 .iter()
334 .filter(|g| g.bus == slack_bus_num)
335 .count();
336
337 let gens_to_emit: Vec<_> = active_gens
338 .iter()
339 .enumerate()
340 .filter(|(idx, g)| {
341 if g.bus == slack_bus_num && n_gens_on_slack >= 1 {
343 let first_slack_gen_idx = active_gens
345 .iter()
346 .position(|gg| gg.bus == slack_bus_num)
347 .unwrap_or(usize::MAX);
348 *idx != first_slack_gen_idx
349 } else {
350 true
351 }
352 })
353 .map(|(_, g)| *g)
354 .collect();
355
356 if !gens_to_emit.is_empty() {
357 writeln!(
358 out,
359 "! ── Generators ────────────────────────────────────────────────"
360 )?;
361 }
362 let mut gen_counter: std::collections::HashMap<u32, u32> = std::collections::HashMap::new();
363 for g in &gens_to_emit {
364 let bn = bus_name(g.bus);
365 let kv = bus_base_kv(g.bus);
366 let kw = g.p * 1000.0;
367 let kvar = g.q * 1000.0;
368
369 let count = gen_counter.entry(g.bus).or_insert(0);
370 *count += 1;
371 let gen_name = if *count > 1 {
372 format!("gen_{}_{}", g.bus, count)
373 } else {
374 format!("gen_{}", g.bus)
375 };
376
377 write!(
378 out,
379 "New Generator.{} Bus1={} kW={:.4} kvar={:.4} kV={:.4} Model=1",
380 gen_name, bn, kw, kvar, kv,
381 )?;
382
383 let pmax = if g.pmax < 1e9 { g.pmax } else { g.p * 2.0 };
385 let pmin = if g.pmin > -1e9 { g.pmin } else { 0.0 };
386 if pmax.is_finite() && pmax > 0.0 {
387 write!(out, " Maxkw={:.4}", pmax * 1000.0)?;
388 }
389 if pmin.is_finite() {
390 write!(out, " Minkw={:.4}", pmin * 1000.0)?;
391 }
392 writeln!(out)?;
393 }
394 if !gens_to_emit.is_empty() {
395 writeln!(out)?;
396 }
397
398 let shunt_buses: Vec<_> = network
400 .buses
401 .iter()
402 .filter(|b| b.shunt_conductance_mw.abs() > 1e-9 || b.shunt_susceptance_mvar.abs() > 1e-9)
403 .collect();
404
405 if !shunt_buses.is_empty() {
406 writeln!(
407 out,
408 "! ── Shunts ────────────────────────────────────────────────────"
409 )?;
410 }
411 for bus in &shunt_buses {
412 let bn = bus_name(bus.number);
413
414 if bus.shunt_susceptance_mvar > 1e-9 {
424 let kvar = bus.shunt_susceptance_mvar * 1000.0; writeln!(
427 out,
428 "New Capacitor.shunt_{} Bus1={} kvar={:.4} kV={:.4}",
429 bus.number, bn, kvar, bus.base_kv,
430 )?;
431 } else if bus.shunt_susceptance_mvar < -1e-9 {
432 let kvar = (-bus.shunt_susceptance_mvar) * 1000.0;
434 writeln!(
435 out,
436 "New Reactor.shunt_{} Bus1={} kvar={:.4} kV={:.4}",
437 bus.number, bn, kvar, bus.base_kv,
438 )?;
439 }
440
441 if bus.shunt_conductance_mw.abs() > 1e-9 {
442 let kw = bus.shunt_conductance_mw * 1000.0;
446 writeln!(
447 out,
448 "New Load.gshunt_{} Bus1={} kW={:.4} kvar=0 kV={:.4} Model=2",
449 bus.number, bn, kw, bus.base_kv,
450 )?;
451 }
452 }
453 if !shunt_buses.is_empty() {
454 writeln!(out)?;
455 }
456
457 let mut voltage_bases: BTreeSet<OrderedF64> = BTreeSet::new();
459 for bus in &network.buses {
460 if bus.base_kv > 0.0 {
461 voltage_bases.insert(OrderedF64(bus.base_kv));
462 }
463 }
464
465 if !voltage_bases.is_empty() {
466 let vbases: Vec<String> = voltage_bases
467 .iter()
468 .map(|v| format!("{:.4}", v.0))
469 .collect();
470 writeln!(out, "Set VoltageBases=[{}]", vbases.join(", "))?;
471 writeln!(out, "CalcVoltageBases")?;
472 }
473
474 writeln!(out, "Solve")?;
475
476 Ok(out)
477}
478
479fn sanitize_dss_name(name: &str) -> String {
481 let s: String = name
482 .chars()
483 .map(|c| {
484 if c.is_alphanumeric() || c == '_' {
485 c
486 } else {
487 '_'
488 }
489 })
490 .collect();
491 if s.is_empty() {
492 "surge_network".to_string()
493 } else if s.starts_with(|c: char| c.is_ascii_digit()) {
494 format!("case_{}", s)
495 } else {
496 s
497 }
498}
499
500#[derive(Clone, Copy)]
503struct OrderedF64(f64);
504
505impl PartialEq for OrderedF64 {
506 fn eq(&self, other: &Self) -> bool {
507 self.0.to_bits() == other.0.to_bits()
508 }
509}
510
511impl Eq for OrderedF64 {}
512
513impl PartialOrd for OrderedF64 {
514 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
515 Some(self.cmp(other))
516 }
517}
518
519impl Ord for OrderedF64 {
520 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
521 self.0.total_cmp(&other.0)
522 }
523}
524
525#[cfg(test)]
526mod tests {
527 use super::*;
528 use surge_network::Network;
529 use surge_network::network::{Branch, Bus, BusType, Generator, Load};
530
531 fn simple_network() -> Network {
532 let mut net = Network::new("test_case");
533 net.base_mva = 100.0;
534
535 let mut slack = Bus::new(1, BusType::Slack, 138.0);
536 slack.voltage_magnitude_pu = 1.04;
537 slack.voltage_angle_rad = 0.0;
538 net.buses.push(slack);
539
540 let pq = Bus::new(2, BusType::PQ, 138.0);
541 net.buses.push(pq);
542
543 net.generators.push(Generator::new(1, 71.6, 1.04));
544
545 net.branches
546 .push(Branch::new_line(1, 2, 0.01938, 0.05917, 0.0528));
547
548 net.loads.push(Load::new(2, 21.7, 12.7));
549
550 net
551 }
552
553 fn network_with_transformer() -> Network {
554 let mut net = Network::new("xfmr_case");
555 net.base_mva = 100.0;
556
557 net.buses.push(Bus::new(1, BusType::Slack, 138.0));
558 let bus2 = Bus::new(2, BusType::PQ, 138.0);
559 net.buses.push(bus2);
560
561 net.generators.push(Generator::new(1, 50.0, 1.0));
562
563 let mut br = Branch::new_line(1, 2, 0.0, 0.20912, 0.0);
565 br.tap = 0.978;
566 br.rating_a_mva = 100.0;
567 net.branches.push(br);
568
569 net.loads.push(Load::new(2, 40.0, 15.0));
570
571 net
572 }
573
574 fn network_with_shunts() -> Network {
575 let mut net = Network::new("shunt_case");
576 net.base_mva = 100.0;
577
578 net.buses.push(Bus::new(1, BusType::Slack, 138.0));
579 let mut bus2 = Bus::new(2, BusType::PQ, 138.0);
580 bus2.shunt_susceptance_mvar = 1.9; net.buses.push(bus2);
582
583 net.generators.push(Generator::new(1, 10.0, 1.0));
584 net.branches.push(Branch::new_line(1, 2, 0.01, 0.05, 0.02));
585
586 net
587 }
588
589 #[test]
590 fn test_write_produces_dss_structure() {
591 let net = simple_network();
592 let s = to_dss_string(&net).unwrap();
593 assert!(s.contains("Clear"), "should contain Clear command");
594 assert!(
595 s.contains("New Circuit."),
596 "should contain circuit definition"
597 );
598 assert!(s.contains("New Line."), "should contain line definition");
599 assert!(s.contains("New Load."), "should contain load definition");
600 assert!(
601 s.contains("CalcVoltageBases"),
602 "should contain CalcVoltageBases"
603 );
604 assert!(s.contains("Solve"), "should contain Solve command");
605 }
606
607 #[test]
608 fn test_circuit_uses_slack_bus() {
609 let net = simple_network();
610 let s = to_dss_string(&net).unwrap();
611 assert!(
613 s.contains("BasekV=138.0"),
614 "circuit should use slack bus kV"
615 );
616 assert!(s.contains("pu=1.04"), "circuit should use slack bus vm");
617 }
618
619 #[test]
620 fn test_line_impedance_conversion() {
621 let net = simple_network();
622 let s = to_dss_string(&net).unwrap();
623 assert!(s.contains("R1="), "should have R1 parameter");
627 assert!(s.contains("X1="), "should have X1 parameter");
628 }
629
630 #[test]
631 fn test_load_kw_kvar() {
632 let net = simple_network();
633 let s = to_dss_string(&net).unwrap();
634 assert!(s.contains("kW=21700.0"), "load kW should be 21700");
636 assert!(s.contains("kvar=12700.0"), "load kvar should be 12700");
637 }
638
639 #[test]
640 fn test_transformer_written() {
641 let net = network_with_transformer();
642 let s = to_dss_string(&net).unwrap();
643 assert!(
644 s.contains("New Transformer."),
645 "should contain transformer definition"
646 );
647 assert!(s.contains("Taps=[0.978"), "should include tap ratio");
648 assert!(s.contains("XHL="), "should include XHL parameter");
649 }
650
651 #[test]
652 fn test_capacitor_shunt() {
653 let net = network_with_shunts();
654 let s = to_dss_string(&net).unwrap();
655 assert!(
656 s.contains("New Capacitor.shunt_2"),
657 "should create capacitor for positive bs"
658 );
659 assert!(s.contains("kvar=1900.0"), "capacitor kvar should be 1900");
660 }
661
662 #[test]
663 fn test_voltage_bases_set() {
664 let net = simple_network();
665 let s = to_dss_string(&net).unwrap();
666 assert!(
667 s.contains("Set VoltageBases=[138.0"),
668 "should set voltage bases"
669 );
670 }
671
672 #[test]
673 fn test_file_write() {
674 let net = simple_network();
675 let tmp = std::env::temp_dir().join("surge_dss_writer_test.dss");
676 write_dss(&net, &tmp).unwrap();
677 let content = std::fs::read_to_string(&tmp).unwrap();
678 assert!(content.contains("New Circuit."));
679 let _ = std::fs::remove_file(&tmp);
680 }
681
682 #[test]
683 fn test_empty_name_uses_bus_number() {
684 let mut net = Network::new("test");
685 net.base_mva = 100.0;
686 let mut b = Bus::new(42, BusType::Slack, 138.0);
687 b.name = String::new(); net.buses.push(b);
689 net.generators.push(Generator::new(42, 10.0, 1.0));
690 let s = to_dss_string(&net).unwrap();
691 assert!(
692 s.contains("bus42"),
693 "empty bus name should fall back to bus<number>"
694 );
695 }
696
697 #[test]
698 fn test_sanitize_name() {
699 assert_eq!(sanitize_dss_name("my-case.v2"), "my_case_v2");
700 assert_eq!(sanitize_dss_name("3bus"), "case_3bus");
701 assert_eq!(sanitize_dss_name(""), "surge_network");
702 assert_eq!(sanitize_dss_name("valid_name"), "valid_name");
703 }
704}