Skip to main content

surge_io/dss/
writer.rs

1// SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0
2//! OpenDSS (.dss) script writer.
3//!
4//! Converts a `surge_network::Network` into an OpenDSS-compatible script file.
5//! The output script is structured as:
6//!
7//! 1. `Clear` — reset the DSS engine
8//! 2. `New Circuit.*` — define the source (slack) bus
9//! 3. `New Line.*` — transmission lines (branches with tap ~= 1.0 and shift ~= 0.0)
10//! 4. `New Transformer.*` — branches with off-nominal tap or phase shift
11//! 5. `New Load.*` — load elements
12//! 6. `New Generator.*` — generator elements
13//! 7. `New Capacitor.*` / `New Reactor.*` — bus shunt admittance
14//! 8. `Set VoltageBases=[...]` + `CalcVoltageBases` + `Solve`
15//!
16//! ## Per-unit to physical unit conversion
17//!
18//! The Network model stores impedances in per-unit (on system base_mva) and
19//! powers in MW/MVAr. OpenDSS expects:
20//! - Line impedance in ohms/unit-length (we use `Length=1 Units=none` with total ohms)
21//! - Load/generator power in kW/kvar
22//! - Transformer impedance as %X on the transformer's own kVA base
23//!
24//! Conversion: `Z_ohms = Z_pu * base_kv^2 / base_mva`
25
26use 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
44/// Write a Network to an OpenDSS .dss file on disk.
45pub 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
51/// Serialize a Network to an OpenDSS script string.
52pub fn to_dss_string(network: &Network) -> Result<String, DssWriteError> {
53    let mut out = String::with_capacity(32 * 1024);
54
55    // Find the slack bus to use as the circuit source.
56    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    // Build a bus-number-to-name map. If a bus has a name, use it; otherwise
66    // use the bus number as the name.
67    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                    // Sanitize: DSS bus names cannot contain spaces (they're
77                    // used as tokens in "Bus1=name" syntax). Replace spaces
78                    // with underscores and trim.
79                    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    // ── Header ──────────────────────────────────────────────────────────────
95    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    // ── Circuit (source / slack bus) ────────────────────────────────────────
106    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    // ── Lines (branches where tap ~= 1.0 and shift ~= 0.0) ────────────────
116    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        // Convert per-unit impedance to ohms: Z_ohm = Z_pu * (kV^2 / base_mva)
135        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        // Line charging susceptance: b_pu → nanofarads not needed; DSS accepts
140        // B1 in per-unit-length (we use Length=1 Units=none, so B1 = total b/2).
141        // Actually DSS Line element expects B1 as total positive-sequence
142        // susceptance in per-unit-length of the line's own base. Since we set
143        // Length=1 and Units=none, and R1/X1 are already total ohms, we express
144        // B1 as total Mvar at 1 kV, i.e. susceptance in Siemens.
145        // b_pu (system base) = B_siemens * z_base, so B_siemens = b_pu / z_base.
146        let b_siemens = br.b / z_base;
147
148        // Use C1 (nF) or B1 (µS)? DSS Line accepts B1 in µS/unit-length.
149        // Since Length=1, B1 = total µS.
150        let b_us = b_siemens * 1e6;
151
152        let line_name = format!("line_{}_{}", br.from_bus, br.to_bus);
153        // Use a unique suffix if there are parallel lines.
154        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    // ── Transformers (branches with off-nominal tap or phase shift) ─────────
183    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        // Transformer rating: use rate_a if available, otherwise use base_mva.
203        let kva = if br.rating_a_mva > 0.0 {
204            br.rating_a_mva * 1000.0 // rate_a is in MVA, convert to kVA
205        } else {
206            base_mva * 1000.0
207        };
208
209        // Convert per-unit impedance (system base) to percent on transformer base.
210        // Z_pu_sys = Z_pu_xfmr * (S_base / S_xfmr)
211        // Z_pu_xfmr = Z_pu_sys * (S_xfmr / S_base)
212        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        // Determine winding connections from TransformerConnection.
232        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    // ── Loads ────────────────────────────────────────────────────────────────
270    // Prefer explicit Load objects (network.loads). If the loads vec is empty,
271    // fall back: if no Load objects exist, nothing to write.
272    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            // kW = MW * 1000, kvar = MVAr * 1000
297            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        // No Load objects — nothing to write.
319        if false {
320            writeln!(out)?;
321        }
322    }
323
324    // ── Generators ──────────────────────────────────────────────────────────
325    let active_gens: Vec<_> = network.generators.iter().filter(|g| g.in_service).collect();
326
327    // Skip the generator that corresponds to the slack bus circuit source —
328    // OpenDSS models the slack bus as the Circuit element, not a separate
329    // Generator. We emit Generator elements only for non-slack generators.
330    // If there is only one generator on the slack bus, skip it.
331    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            // Skip the first generator on the slack bus (it is the circuit source).
342            if g.bus == slack_bus_num && n_gens_on_slack >= 1 {
343                // Find the index of the first gen on the slack bus.
344                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        // Clamp extreme pmax/pmin for DSS compatibility.
384        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    // ── Shunt capacitors and reactors (from bus.shunt_conductance_mw / bus.shunt_susceptance_mvar) ────────────────
399    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        // bus.shunt_susceptance_mvar is shunt susceptance in MVAr injected at V=1.0 p.u. (on system base).
415        // Positive bs = capacitive (inject reactive power) → Capacitor
416        // Negative bs = inductive (absorb reactive power) → Reactor
417        //
418        // bus.shunt_conductance_mw is shunt conductance in MW demanded at V=1.0 p.u.
419        // Positive gs = absorbs real power → Reactor with R component
420        // (DSS Reactor can represent both R and X; for pure G shunt, we
421        //  approximate with a Reactor at the bus.)
422
423        if bus.shunt_susceptance_mvar > 1e-9 {
424            // Capacitive shunt: bs (MVAr at 1.0 pu) → kvar
425            let kvar = bus.shunt_susceptance_mvar * 1000.0; // bs is already in MVAr-equivalent
426            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            // Inductive shunt: negative bs → reactor absorbing kvar
433            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            // Conductance shunt: gs (MW demanded at 1.0 pu) → approximate as a
443            // small constant-impedance load. DSS doesn't have a pure G shunt
444            // element; a Load with Model=2 (constant-Z) is the closest match.
445            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    // ── Voltage bases and solve ─────────────────────────────────────────────
458    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
479/// Sanitize a network name for use as a DSS Circuit name.
480fn 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/// Wrapper around f64 that implements Ord for use in BTreeSet.
501/// NaN values are treated as equal and sort last.
502#[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        // Transformer: tap=0.978, shift=0
564        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; // 1.9 MVAr capacitive shunt
581        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        // Slack bus is bus 1, base_kv=138.0
612        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        // Z_base = 138^2 / 100 = 190.44
624        // R_ohm = 0.01938 * 190.44 = 3.6907...
625        // X_ohm = 0.05917 * 190.44 = 11.2727...
626        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        // Load: 21.7 MW = 21700 kW, 12.7 MVAr = 12700 kvar
635        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(); // empty name
688        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}