Skip to main content

surge_io/psse/
writer.rs

1// SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0
2//! PSS/E RAW writer — v33 (default) and v35+.
3//!
4//! When `version >= 35`, the writer appends additional sections:
5//!
6//! * **GNE Device Data** — empty section (Surge has no GNE model)
7//! * **Induction Machine Data** — empty section (Surge stores these separately in
8//!   `Network::induction_machines` when parsed; see Issue #28)
9//! * **Substation Data** — written from `Network::topology` when present
10
11use std::fmt::Write as FmtWrite;
12use std::path::Path;
13
14use surge_network::Network;
15use surge_network::network::{BusType, SwitchedShunt};
16use thiserror::Error;
17
18#[derive(Error, Debug)]
19pub enum PsseWriteError {
20    #[error("I/O error: {0}")]
21    Io(#[from] std::io::Error),
22    #[error("format error: {0}")]
23    Fmt(#[from] std::fmt::Error),
24}
25
26/// Write a Network to a PSS/E RAW file on disk (v33 by default).
27pub fn write_file(network: &Network, path: &Path, version: u32) -> Result<(), PsseWriteError> {
28    let content = to_string(network, version)?;
29    std::fs::write(path, content)?;
30    Ok(())
31}
32
33/// Serialize a Network to a PSS/E RAW string.
34pub fn to_string(network: &Network, version: u32) -> Result<String, PsseWriteError> {
35    let mut out = String::with_capacity(64 * 1024);
36    let ver = if version == 0 { 33 } else { version };
37
38    // PSS/E RAW header (3 lines)
39    writeln!(
40        out,
41        " 0, {}, {ver}, 0, 0, 60.0   /  PSS/E {ver} Raw Data -- Exported by Surge",
42        network.base_mva
43    )?;
44    writeln!(out, " {}", sanitize_psse_name(&network.name))?;
45    writeln!(
46        out,
47        " Exported by Surge (https://github.com/amptimal/surge)"
48    )?;
49
50    // --- Bus Data ---
51
52    for bus in &network.buses {
53        let bus_type_code = match bus.bus_type {
54            BusType::PQ => 1,
55            BusType::PV => 2,
56            BusType::Slack => 3,
57            BusType::Isolated => 4,
58        };
59        let va_deg = bus.voltage_angle_rad.to_degrees();
60        let name = format_bus_name(&bus.name, bus.number);
61        writeln!(
62            out,
63            " {},{:12},{},{},{},{},{},{:.6},{:.4},1",
64            bus.number,
65            format!("'{name}'"),
66            bus.base_kv,
67            bus_type_code,
68            1,
69            bus.area,
70            bus.zone,
71            bus.voltage_magnitude_pu,
72            va_deg
73        )?;
74    }
75    writeln!(out, " 0 / END OF BUS DATA, BEGIN LOAD DATA")?;
76
77    // --- Load Data ---
78    // PSS/E LOAD format: I, ID, STATUS, AREA, ZONE, PL, QL, IP, IQ, YP, YQ, OWNER, SCALE
79
80    if !network.loads.is_empty() {
81        let bus_lookup: std::collections::HashMap<u32, &surge_network::network::Bus> =
82            network.buses.iter().map(|b| (b.number, b)).collect();
83        for load in &network.loads {
84            let status: i32 = if load.in_service { 1 } else { 0 };
85            let p = load.active_power_demand_mw;
86            let q = load.reactive_power_demand_mvar;
87            let pl = load.zip_p_power_frac * p;
88            let ip = load.zip_p_current_frac * p;
89            let yp = load.zip_p_impedance_frac * p;
90            let ql = load.zip_q_power_frac * q;
91            let iq = load.zip_q_current_frac * q;
92            let yq = load.zip_q_impedance_frac * q;
93            let (area, zone) = bus_lookup
94                .get(&load.bus)
95                .map(|b| (b.area, b.zone))
96                .unwrap_or((1, 1));
97            let scale: i32 = if load.conforming { 1 } else { 0 };
98            let id = if load.id.is_empty() { "1 " } else { &load.id };
99            let owner = load.owners.first().map(|entry| entry.owner).unwrap_or(1);
100            writeln!(
101                out,
102                " {},'{id}',{status},{area},{zone},{pl:.4},{ql:.4},{ip:.4},{iq:.4},{yp:.4},{yq:.4},{},{scale}",
103                load.bus, owner
104            )?;
105        }
106    } else {
107        // No explicit Load objects — demand lives exclusively on Load objects now,
108        // so nothing to write in this fallback path.
109    }
110    writeln!(out, " 0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA")?;
111
112    // --- Fixed Shunt Data --- (for buses with gs or bs)
113
114    for bus in &network.buses {
115        if bus.shunt_conductance_mw.abs() > 1e-10 || bus.shunt_susceptance_mvar.abs() > 1e-10 {
116            writeln!(
117                out,
118                " {},'1 ',1,{:.4},{:.4}",
119                bus.number, bus.shunt_conductance_mw, bus.shunt_susceptance_mvar
120            )?;
121        }
122    }
123    if version >= 36 {
124        writeln!(
125            out,
126            " 0 / END OF FIXED SHUNT DATA, BEGIN VOLTAGE DROOP CONTROL DATA"
127        )?;
128        for ctrl in &network.metadata.voltage_droop_controls {
129            writeln!(
130                out,
131                " {},'{}',{},{},{:.6},{:.6},{:.6}",
132                ctrl.bus,
133                ctrl.device_id,
134                ctrl.device_type,
135                ctrl.regulated_bus,
136                ctrl.vdrp,
137                ctrl.vmax,
138                ctrl.vmin
139            )?;
140        }
141        writeln!(
142            out,
143            " 0 / END OF VOLTAGE DROOP CONTROL DATA, BEGIN GENERATOR DATA"
144        )?;
145    } else {
146        writeln!(out, " 0 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA")?;
147    }
148
149    // --- Generator Data ---
150
151    for g in &network.generators {
152        let status = if g.in_service { 1 } else { 0 };
153        let qmax = clamp_finite(g.qmax, 9999.0);
154        let qmin = clamp_finite(g.qmin, -9999.0);
155        let pmax = clamp_finite(g.pmax, 9999.0);
156        let pmin = clamp_finite(g.pmin, -9999.0);
157        let mbase = clamp_finite(g.machine_base_mva, 100.0);
158        let mid = g.machine_id.as_deref().unwrap_or("1");
159        writeln!(
160            out,
161            " {},'{:2}',{:.4},{:.4},{:.4},{:.4},{:.6},{},{:.4},0,0,0,0,1.0,{},100,{:.4},{:.4},1",
162            g.bus,
163            mid,
164            g.p,
165            g.q,
166            qmax,
167            qmin,
168            g.voltage_setpoint_pu,
169            g.bus,
170            mbase,
171            status,
172            pmax,
173            pmin
174        )?;
175    }
176    if version >= 36 {
177        writeln!(
178            out,
179            " 0 / END OF GENERATOR DATA, BEGIN SWITCHING DEVICE RATING SET DATA"
180        )?;
181        for rs in &network.metadata.switching_device_rating_sets {
182            write!(
183                out,
184                " {},{},'{}',{},{:.2},{:.2},{:.2}",
185                rs.from_bus, rs.to_bus, rs.circuit, rs.rating_set, rs.rate1, rs.rate2, rs.rate3
186            )?;
187            for rate in &rs.additional_rates {
188                write!(out, ",{rate:.2}")?;
189            }
190            writeln!(out)?;
191        }
192        writeln!(
193            out,
194            " 0 / END OF SWITCHING DEVICE RATING SET DATA, BEGIN BRANCH DATA"
195        )?;
196    } else {
197        writeln!(out, " 0 / END OF GENERATOR DATA, BEGIN BRANCH DATA")?;
198    }
199
200    // --- Branch Data ---
201
202    for br in &network.branches {
203        let status = if br.in_service { 1 } else { 0 };
204        let ckt = if br.circuit.is_empty() {
205            "1"
206        } else {
207            &br.circuit
208        };
209        let ra = clamp_finite(br.rating_a_mva, 0.0);
210        let rb = clamp_finite(br.rating_b_mva, 0.0);
211        let rc = clamp_finite(br.rating_c_mva, 0.0);
212        if !br.is_transformer() {
213            // GI, BI, GJ, BJ are terminal shunt admittances — always zero for
214            // standard branches.  The B field already carries total line charging.
215            writeln!(
216                out,
217                " {},{},'{:2}',{:.6},{:.6},{:.6},{:.2},{:.2},{:.2},0,{:.6},0,{:.6},{},1,0,1",
218                br.from_bus,
219                br.to_bus,
220                ckt,
221                br.r,
222                br.x,
223                br.b,
224                ra,
225                rb,
226                rc,
227                0.0, // BI — NOT b/2 (B field already distributes charging)
228                0.0, // BJ — NOT b/2
229                status
230            )?;
231        }
232        // Transformers are written in the TRANSFORMER DATA section below
233    }
234    if ver >= 34 {
235        writeln!(
236            out,
237            " 0 / END OF BRANCH DATA, BEGIN SYSTEM SWITCHING DEVICE DATA"
238        )?;
239        writeln!(
240            out,
241            " 0 / END OF SYSTEM SWITCHING DEVICE DATA, BEGIN TRANSFORMER DATA"
242        )?;
243    } else {
244        writeln!(out, " 0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA")?;
245    }
246
247    // --- Transformer Data ---
248
249    for br in &network.branches {
250        if br.is_transformer() {
251            let status = if br.in_service { 1 } else { 0 };
252            let ckt = if br.circuit.is_empty() {
253                "1"
254            } else {
255                &br.circuit
256            };
257            let ra = clamp_finite(br.rating_a_mva, 0.0);
258            let rb = clamp_finite(br.rating_b_mva, 0.0);
259            let rc = clamp_finite(br.rating_c_mva, 0.0);
260            // PSS/E 2-winding transformer: 4 records
261            // Record 1: from, to, 0 (no star bus), ckt, cw, cz, cm, mag1, mag2, nmetr, name, stat, o1, f1
262            writeln!(
263                out,
264                " {},{},0,'{:2}',1,1,1,{:.6},{:.6},1,'XFMR    ',{},1,1.0",
265                br.from_bus, br.to_bus, ckt, br.g_mag, br.b_mag, status
266            )?;
267            // Record 2: r12, x12, sbase12 (pu on system base)
268            writeln!(out, " {:.6},{:.6},{:.4}", br.r, br.x, network.base_mva)?;
269            // Record 3: windv1, nomv1, ang1, rata1, ratb1, ratc1, cod1, cont1, rma1, rmi1, vma1, vmi1, ntp1, tab1, cr1, cx1
270            writeln!(
271                out,
272                " {:.6},0,{:.4},{:.2},{:.2},{:.2},0,0,1.1,0.9,1.1,0.9,33,0,0,0",
273                br.tap,
274                br.phase_shift_rad.to_degrees(),
275                ra,
276                rb,
277                rc
278            )?;
279            // Record 4: windv2, nomv2
280            writeln!(out, " 1.0,0")?;
281        }
282    }
283    writeln!(
284        out,
285        " 0 / END OF TRANSFORMER DATA, BEGIN AREA INTERCHANGE DATA"
286    )?;
287
288    // --- Area Interchange Data ---
289
290    for area in &network.area_schedules {
291        let name = truncate_name(&area.name, 12);
292        writeln!(
293            out,
294            " {},{},{:.4},{:.4},'{}'",
295            area.number, area.slack_bus, area.p_desired_mw, area.p_tolerance_mw, name
296        )?;
297    }
298    writeln!(
299        out,
300        " 0 / END OF AREA INTERCHANGE DATA, BEGIN TWO-TERMINAL DC DATA"
301    )?;
302
303    // --- Two-Terminal DC Line Data ---
304
305    for link in &network.hvdc.links {
306        let Some(dc) = link.as_lcc() else {
307            continue;
308        };
309        let mdc = dc.mode as u32;
310        writeln!(
311            out,
312            " '{}',{},{:.6},{:.4},{:.4},{:.4},{:.6},{:.6},'{}',{:.4},{},{:.4}",
313            sanitize_psse_name(&dc.name),
314            mdc,
315            dc.resistance_ohm,
316            dc.scheduled_setpoint,
317            dc.scheduled_voltage_kv,
318            dc.voltage_mode_switch_kv,
319            dc.compounding_resistance_ohm,
320            dc.current_margin_ka,
321            dc.meter,
322            dc.voltage_min_kv,
323            dc.ac_dc_iteration_max,
324            dc.ac_dc_iteration_acceleration
325        )?;
326        write_dc_converter(&mut out, &dc.rectifier)?;
327        write_dc_converter(&mut out, &dc.inverter)?;
328    }
329    writeln!(
330        out,
331        " 0 / END OF TWO-TERMINAL DC DATA, BEGIN VSC DC LINE DATA"
332    )?;
333
334    // --- VSC DC Line Data ---
335
336    for link in &network.hvdc.links {
337        let Some(vsc) = link.as_vsc() else {
338            continue;
339        };
340        let mdc = vsc.mode as u32;
341        writeln!(
342            out,
343            " '{}',{},{:.6},1,1.0,0,0.0",
344            sanitize_psse_name(&vsc.name),
345            mdc,
346            vsc.resistance_ohm
347        )?;
348        write_vsc_converter(&mut out, &vsc.converter1)?;
349        write_vsc_converter(&mut out, &vsc.converter2)?;
350    }
351    writeln!(
352        out,
353        " 0 / END OF VSC DC LINE DATA, BEGIN IMPEDANCE CORRECTION DATA"
354    )?;
355
356    // --- Impedance Correction Data ---
357
358    for table in &network.metadata.impedance_corrections {
359        write!(out, " {}", table.number)?;
360        for &(t, f) in &table.entries {
361            write!(out, ",{:.6},{:.6}", t, f)?;
362        }
363        writeln!(out)?;
364    }
365    writeln!(
366        out,
367        " 0 / END OF IMPEDANCE CORRECTION DATA, BEGIN MULTI-TERMINAL DC DATA"
368    )?;
369
370    // --- Multi-Terminal DC Data ---
371
372    for dc_grid in &network.hvdc.dc_grids {
373        let dc_buses: Vec<_> = dc_grid.buses.iter().collect();
374        let converters: Vec<_> = dc_grid
375            .converters
376            .iter()
377            .filter_map(|converter| converter.as_lcc())
378            .collect();
379        if converters.is_empty() {
380            continue;
381        }
382        let branches: Vec<_> = dc_grid.branches.iter().collect();
383
384        let mut local_bus_number = std::collections::HashMap::new();
385        for (idx, bus) in dc_buses.iter().enumerate() {
386            local_bus_number.insert(bus.bus_id, (idx + 1) as u32);
387        }
388
389        let dc_voltage_kv = dc_buses.first().map(|bus| bus.base_kv_dc).unwrap_or(500.0);
390        writeln!(
391            out,
392            " '{}',{},{},{},{},{:.4},{:.4},{:.4}",
393            sanitize_psse_name(
394                dc_grid
395                    .name
396                    .as_deref()
397                    .unwrap_or(&format!("DCGRID-{}", dc_grid.id))
398            ),
399            converters.len(),
400            dc_buses.len(),
401            branches.len(),
402            1,
403            dc_voltage_kv,
404            0.0,
405            0.0
406        )?;
407        for converter in &converters {
408            writeln!(
409                out,
410                " {},{},{:.4},{:.4},{:.6},{:.6},{:.4},{:.6},{:.6},{:.6},{:.6},{:.6},{:.4},{:.4},{:.4},{}",
411                converter.ac_bus,
412                converter.n_bridges,
413                converter.alpha_max_deg,
414                converter.alpha_min_deg,
415                converter.commutation_resistance_ohm,
416                converter.commutation_reactance_ohm,
417                converter.base_voltage_kv,
418                converter.turns_ratio,
419                converter.tap_ratio,
420                converter.tap_max,
421                converter.tap_min,
422                converter.tap_step,
423                converter.scheduled_setpoint.abs(),
424                converter.power_share_percent,
425                converter.current_margin_percent,
426                match converter.role {
427                    surge_network::network::LccDcConverterRole::Rectifier => 1,
428                    surge_network::network::LccDcConverterRole::Inverter => 2,
429                }
430            )?;
431        }
432        for bus in &dc_buses {
433            let ac_bus = converters
434                .iter()
435                .find(|converter| converter.dc_bus == bus.bus_id)
436                .map(|converter| converter.ac_bus)
437                .unwrap_or(0);
438            let (area, zone) = if ac_bus > 0 {
439                network
440                    .buses
441                    .iter()
442                    .find(|candidate| candidate.number == ac_bus)
443                    .map(|candidate| (candidate.area, candidate.zone))
444                    .unwrap_or((1, 1))
445            } else {
446                (1, 1)
447            };
448            let generated_name = format!("DC-{}", bus.bus_id);
449            let name = truncate_name(&generated_name, 12);
450            writeln!(
451                out,
452                " {},{},{},{},'{}',{},{:.6},{}",
453                local_bus_number[&bus.bus_id], ac_bus, area, zone, name, 0, bus.r_ground_ohm, 1
454            )?;
455        }
456        for (idx, branch) in branches.iter().enumerate() {
457            writeln!(
458                out,
459                " {},{},'{:2}',{},{:.6},{:.6}",
460                local_bus_number[&branch.from_bus],
461                local_bus_number[&branch.to_bus],
462                idx + 1,
463                1,
464                branch.r_ohm,
465                branch.l_mh
466            )?;
467        }
468    }
469    writeln!(
470        out,
471        " 0 / END OF MULTI-TERMINAL DC DATA, BEGIN MULTI-SECTION LINE DATA"
472    )?;
473
474    // --- Multi-Section Line Data ---
475
476    for ms in &network.metadata.multi_section_line_groups {
477        write!(
478            out,
479            " {},{},'{:2}',{}",
480            ms.from_bus, ms.to_bus, ms.id, ms.metered_end
481        )?;
482        for &dum in &ms.dummy_buses {
483            write!(out, ",{}", dum)?;
484        }
485        writeln!(out)?;
486    }
487    writeln!(out, " 0 / END OF MULTI-SECTION LINE DATA, BEGIN ZONE DATA")?;
488
489    // --- Zone Data ---
490
491    for region in &network.metadata.regions {
492        let name = truncate_name(&region.name, 12);
493        writeln!(out, " {},'{}'", region.number, name)?;
494    }
495    writeln!(out, " 0 / END OF ZONE DATA, BEGIN INTER-AREA TRANSFER DATA")?;
496
497    // --- Inter-Area Transfer Data ---
498
499    for xfer in &network.metadata.scheduled_area_transfers {
500        writeln!(
501            out,
502            " {},{},{},{:.4}",
503            xfer.from_area, xfer.to_area, xfer.id, xfer.p_transfer_mw
504        )?;
505    }
506    writeln!(
507        out,
508        " 0 / END OF INTER-AREA TRANSFER DATA, BEGIN OWNER DATA"
509    )?;
510
511    // --- Owner Data ---
512
513    for owner in &network.metadata.owners {
514        let name = truncate_name(&owner.name, 12);
515        writeln!(out, " {},'{}'", owner.number, name)?;
516    }
517    writeln!(
518        out,
519        " 0 / END OF OWNER DATA, BEGIN FACTS CONTROL DEVICE DATA"
520    )?;
521
522    // --- FACTS Device Data ---
523
524    for f in &network.facts_devices {
525        let mode = f.mode as u32;
526        writeln!(
527            out,
528            " '{}',{},{},{},{:.4},{:.4},{:.6},{:.4},1.1,0.9,1.1,99999,99999,{:.6},100,1",
529            sanitize_psse_name(&f.name),
530            f.bus_from,
531            f.bus_to,
532            mode,
533            f.p_setpoint_mw,
534            f.q_setpoint_mvar,
535            f.voltage_setpoint_pu,
536            clamp_finite(f.q_max, 9999.0),
537            f.series_reactance_pu
538        )?;
539    }
540    writeln!(
541        out,
542        " 0 / END OF FACTS CONTROL DEVICE DATA, BEGIN SWITCHED SHUNT DATA"
543    )?;
544
545    // --- Switched Shunt Data ---
546    // Group SwitchedShunt objects by bus index, reconstruct PSS/E block format.
547
548    write_switched_shunts(&mut out, network)?;
549
550    if ver >= 35 {
551        // v35+ sections not present in v33.
552        writeln!(
553            out,
554            " 0 / END OF SWITCHED SHUNT DATA, BEGIN GNE DEVICE DATA"
555        )?;
556        // GNE (General Network Element) — Surge has no GNE model; emit empty section.
557        writeln!(
558            out,
559            " 0 / END OF GNE DEVICE DATA, BEGIN INDUCTION MACHINE DATA"
560        )?;
561        // Induction Machine Data — written from network.induction_machines when present.
562        write_induction_machines(&mut out, network)?;
563        writeln!(
564            out,
565            " 0 / END OF INDUCTION MACHINE DATA, BEGIN SUBSTATION DATA"
566        )?;
567        write_substation_data(&mut out, network)?;
568        writeln!(out, " 0 / END OF SUBSTATION DATA")?;
569    } else {
570        writeln!(out, " 0 / END OF SWITCHED SHUNT DATA")?;
571    }
572
573    writeln!(out, "Q")?;
574
575    Ok(out)
576}
577
578/// Write one LCC-HVDC converter record (rectifier or inverter).
579fn write_dc_converter(
580    out: &mut String,
581    c: &surge_network::network::LccConverterTerminal,
582) -> Result<(), PsseWriteError> {
583    let ic = if c.in_service { 1 } else { 0 };
584    writeln!(
585        out,
586        " {},{},{:.4},{:.4},{:.6},{:.6},{:.4},{:.6},{:.6},{:.6},{:.6},{:.6},{},0,0,'1 ',0",
587        c.bus,
588        c.n_bridges,
589        c.alpha_max,
590        c.alpha_min,
591        c.commutation_resistance_ohm,
592        c.commutation_reactance_ohm,
593        c.base_voltage_kv,
594        c.turns_ratio,
595        c.tap,
596        c.tap_max,
597        c.tap_min,
598        c.tap_step,
599        ic
600    )?;
601    Ok(())
602}
603
604/// Write one VSC-HVDC converter record.
605fn write_vsc_converter(
606    out: &mut String,
607    c: &surge_network::network::VscConverterTerminal,
608) -> Result<(), PsseWriteError> {
609    let state = if c.in_service { 1 } else { 0 };
610    let mode = c.control_mode as u32;
611    writeln!(
612        out,
613        " {},1,{},{:.4},{:.4},{:.4},{:.4},0,{:.4},{:.4},{:.4},{:.4},1,{}",
614        c.bus,
615        mode,
616        c.dc_setpoint,
617        c.ac_setpoint,
618        c.loss_constant_mw,
619        c.loss_linear,
620        c.q_max_mvar,
621        c.q_min_mvar,
622        c.voltage_max_pu,
623        c.voltage_min_pu,
624        state
625    )?;
626    Ok(())
627}
628
629/// Write switched shunt records, grouping SwitchedShunt objects by bus.
630fn write_switched_shunts(out: &mut String, network: &Network) -> Result<(), PsseWriteError> {
631    use std::collections::BTreeMap;
632
633    // Group switched shunts by external bus number (BTreeMap for deterministic order).
634    let mut groups: BTreeMap<u32, Vec<&SwitchedShunt>> = BTreeMap::new();
635    for ss in &network.controls.switched_shunts {
636        groups.entry(ss.bus).or_default().push(ss);
637    }
638
639    for group in groups.values() {
640        let first = group[0];
641        let bus_num = first.bus;
642        let vswhi = first.v_target + first.v_band / 2.0;
643        let vswlo = first.v_target - first.v_band / 2.0;
644        let swrem = if first.bus_regulated != first.bus {
645            first.bus_regulated
646        } else {
647            0
648        };
649
650        // Compute BINIT and block list.
651        let mut binit = 0.0;
652        let mut blocks: Vec<(i32, f64)> = Vec::new();
653        for ss in group {
654            let b_mvar = ss.b_step * network.base_mva;
655            binit += ss.n_active_steps as f64 * b_mvar;
656            if ss.n_steps_cap > 0 {
657                blocks.push((ss.n_steps_cap, b_mvar));
658            }
659            if ss.n_steps_react > 0 {
660                blocks.push((ss.n_steps_react, -b_mvar));
661            }
662        }
663
664        // I, MODSW, ADJM, STAT, VSWHI, VSWLO, SWREM, RMPCT, RMIDNT, BINIT, [N1, B1, ...]
665        write!(
666            out,
667            " {},1,0,1,{:.4},{:.4},{},100,'',{:.4}",
668            bus_num, vswhi, vswlo, swrem, binit
669        )?;
670        for (n, b) in &blocks {
671            write!(out, ",{},{:.4}", n, b)?;
672        }
673        writeln!(out)?;
674    }
675
676    Ok(())
677}
678
679/// Truncate a name to at most `max_len` characters (PSS/E field width limit).
680fn truncate_name(name: &str, max_len: usize) -> &str {
681    let n = name.trim();
682    if n.len() > max_len { &n[..max_len] } else { n }
683}
684
685/// Clamp a value to a finite fallback if it is non-finite (±Inf, NaN) or at the
686/// f64::MAX/f64::MIN sentinel used internally for "unlimited".
687///
688/// PSS/E RAW format uses plain text floats — `inf` and `nan` are not valid tokens.
689fn clamp_finite(v: f64, fallback: f64) -> f64 {
690    if !v.is_finite() || v >= f64::MAX / 2.0 || v <= f64::MIN / 2.0 {
691        fallback
692    } else {
693        v
694    }
695}
696
697/// Sanitize a network name for the PSS/E header (avoid commas/quotes).
698fn sanitize_psse_name(name: &str) -> String {
699    name.chars()
700        .filter(|&c| c != '\'' && c != '"' && c != '\n')
701        .collect()
702}
703
704/// Write the INDUCTION MACHINE DATA section (v35+).
705///
706/// Emits two-line records for each `InductionMachine` in `network.induction_machines`.
707fn write_induction_machines(out: &mut String, network: &Network) -> Result<(), PsseWriteError> {
708    for m in &network.induction_machines {
709        let stat = if m.in_service { 1 } else { 0 };
710        // Line 1: I, ID, STAT, SCODE, DCODE, AREA, ZONE, OWNER, TCODE, BCODE,
711        //         MBASE, RATEKV, PCODE, PSET, H, A, B, D, E, F
712        writeln!(
713            out,
714            " {},'{:<2}',{},1,3,{},{},{},1,1,{:.4},{:.4},1,{:.4},{:.6},{:.6},{:.6},{:.6},{:.6},{:.6}",
715            m.bus,
716            m.id,
717            stat,
718            m.area,
719            m.zone,
720            m.owner,
721            m.mbase,
722            m.rate_kv,
723            m.pset,
724            m.h,
725            m.a,
726            m.b,
727            m.d,
728            m.e,
729            m.f_coeff
730        )?;
731        // Line 2: RA, XA, XM, R1, X1, R2, X2, X3, E1, SE1, E2, SE2, IA1, IA2, XAMULT
732        writeln!(
733            out,
734            " {:.6},{:.6},{:.6},{:.6},{:.6},{:.6},{:.6},{:.6},1.0,0.0,1.2,0.0,0.0,0.0,1.0",
735            m.ra, m.xa, m.xm, m.r1, m.x1, m.r2, m.x2, m.x3
736        )?;
737    }
738    Ok(())
739}
740
741/// Write the SUBSTATION DATA section (v35+).
742///
743/// Reconstructs PSS/E-style SUBSTATION / NODE / SWITCHING DEVICE / TERMINAL records
744/// from the `NodeBreakerTopology` stored on the network (when present).  The IDs stored
745/// by the PSS/E parser are in the form `"SUB_{isub}"` and `"SUB_{isub}_N{inode}"`,
746/// which lets us recover the original numeric indices.
747fn write_substation_data(out: &mut String, network: &Network) -> Result<(), PsseWriteError> {
748    let Some(sm) = &network.topology else {
749        return Ok(());
750    };
751
752    use std::collections::HashMap;
753
754    // O(vl) — maps voltage_level_id → substation_id
755    let vl_to_sub: HashMap<&str, &str> = sm
756        .voltage_levels
757        .iter()
758        .map(|vl| (vl.id.as_str(), vl.substation_id.as_str()))
759        .collect();
760
761    // O(cn) — group connectivity nodes by substation_id
762    let mut cn_by_sub: HashMap<&str, Vec<&surge_network::network::topology::ConnectivityNode>> =
763        HashMap::new();
764    for cn in &sm.connectivity_nodes {
765        let sub_id = vl_to_sub
766            .get(cn.voltage_level_id.as_str())
767            .copied()
768            .unwrap_or("");
769        cn_by_sub.entry(sub_id).or_default().push(cn);
770    }
771
772    // O(sw) — map cn1_id → substation_id for switch filtering
773    let cn_to_sub: HashMap<&str, &str> = sm
774        .connectivity_nodes
775        .iter()
776        .map(|cn| {
777            let sub_id = vl_to_sub
778                .get(cn.voltage_level_id.as_str())
779                .copied()
780                .unwrap_or("");
781            (cn.id.as_str(), sub_id)
782        })
783        .collect();
784
785    // O(sw) — group switches by substation_id (keyed on cn1)
786    let mut sw_by_sub: HashMap<&str, Vec<&surge_network::network::SwitchDevice>> = HashMap::new();
787    for sw in &sm.switches {
788        let sub_id = cn_to_sub.get(sw.cn1_id.as_str()).copied().unwrap_or("");
789        sw_by_sub.entry(sub_id).or_default().push(sw);
790    }
791
792    for sub in &sm.substations {
793        // Extract numeric ISUB from "SUB_{n}" or fall back to 0.
794        let isub: u32 = sub
795            .id
796            .strip_prefix("SUB_")
797            .and_then(|s| s.parse().ok())
798            .unwrap_or(0);
799
800        let sub_name = truncate_name(&sub.name, 12);
801        writeln!(out, " {isub},'{sub_name}',0.0,0.0,1")?;
802        writeln!(out, " 0 / BEGIN SUBSTATION NODE DATA")?;
803
804        // Emit nodes belonging to this substation — O(cn_in_sub).
805        let sub_id_prefix = format!("SUB_{isub}_N");
806        if let Some(cns) = cn_by_sub.get(sub.id.as_str()) {
807            for cn in cns {
808                let inode: u32 = cn
809                    .id
810                    .strip_prefix(&sub_id_prefix)
811                    .and_then(|s| s.parse().ok())
812                    .unwrap_or(0);
813                let node_name = truncate_name(&cn.name, 12);
814                writeln!(out, " {inode},'{node_name}',0,1,1.0,0.0")?;
815            }
816        }
817        writeln!(
818            out,
819            " 0 / END OF SUBSTATION NODE DATA, BEGIN SUBSTATION SWITCHING DEVICE DATA"
820        )?;
821
822        // Emit switches belonging to this substation — O(sw_in_sub).
823        if let Some(switches) = sw_by_sub.get(sub.id.as_str()) {
824            for sw in switches {
825                let sw_name = truncate_name(&sw.name, 12);
826                let sw_type_code: u32 = match sw.switch_type {
827                    surge_network::network::SwitchType::Breaker => 1,
828                    surge_network::network::SwitchType::Disconnector => 2,
829                    _ => 3,
830                };
831                let status = if sw.open { 0 } else { 1 };
832                writeln!(out, " 0,'{}',0,0,{},{}", sw_name, sw_type_code, status)?;
833            }
834        }
835        writeln!(
836            out,
837            " 0 / END OF SUBSTATION SWITCHING DEVICE DATA, BEGIN SUBSTATION TERMINAL DATA"
838        )?;
839        writeln!(out, " 0 / END OF SUBSTATION TERMINAL DATA")?;
840    }
841
842    Ok(())
843}
844
845/// Format a bus name: use existing name if non-empty, else "BUS_{number}".
846fn format_bus_name(name: &str, number: u32) -> String {
847    let n = name.trim();
848    if n.is_empty() {
849        format!("BUS_{number:06}")
850    } else if n.len() > 12 {
851        n[..12].to_string()
852    } else {
853        n.to_string()
854    }
855}
856
857#[cfg(test)]
858mod tests {
859    use super::*;
860    use surge_network::Network;
861    use surge_network::network::{Branch, Bus, BusType, Generator, Load};
862
863    fn simple_network() -> Network {
864        let mut net = Network::new("case9");
865        net.base_mva = 100.0;
866        let mut slack = Bus::new(1, BusType::Slack, 345.0);
867        slack.voltage_magnitude_pu = 1.04;
868        net.buses.push(slack);
869        let pq = Bus::new(2, BusType::PQ, 345.0);
870        net.buses.push(pq);
871        net.loads.push(Load::new(2, 125.0, 50.0));
872        net.generators.push(Generator::new(1, 72.3, 1.04));
873        net.branches
874            .push(Branch::new_line(1, 2, 0.01938, 0.05917, 0.0528));
875        net
876    }
877
878    #[test]
879    fn test_psse_header() {
880        let net = simple_network();
881        let s = to_string(&net, 33).unwrap();
882        assert!(s.contains("33"));
883        assert!(s.contains("END OF BUS DATA"));
884        assert!(s.contains("END OF GENERATOR DATA"));
885        assert!(s.contains("END OF BRANCH DATA"));
886        assert!(s.ends_with("Q\n") || s.ends_with("Q"));
887    }
888
889    #[test]
890    fn test_psse_bus_count() {
891        let net = simple_network();
892        let s = to_string(&net, 33).unwrap();
893        // Both buses appear in bus section
894        assert!(s.contains(" 1,") || s.contains("\n 1,"));
895        assert!(s.contains(" 2,") || s.contains("\n 2,"));
896    }
897
898    #[test]
899    fn test_psse_roundtrip() {
900        use crate::psse::parse_str;
901        let net = simple_network();
902        let s = to_string(&net, 33).unwrap();
903        let net2 = parse_str(&s).expect("round-trip parse failed");
904        assert_eq!(net2.n_buses(), net.n_buses());
905        // Generators should survive (1 generator)
906        assert_eq!(net2.generators.len(), 1);
907    }
908
909    #[test]
910    fn test_psse_load_id_and_owner_roundtrip() {
911        use crate::psse::parse_str;
912        use surge_network::network::OwnershipEntry;
913
914        let mut net = simple_network();
915        net.loads[0].id = "LD1".to_string();
916        net.loads[0].owners = vec![OwnershipEntry {
917            owner: 7,
918            fraction: 1.0,
919        }];
920
921        let s = to_string(&net, 33).unwrap();
922        assert!(s.contains("'LD1'"));
923        assert!(s.contains(",7,1"));
924
925        let parsed = parse_str(&s).expect("round-trip parse failed");
926        assert_eq!(parsed.loads.len(), 1);
927        assert_eq!(parsed.loads[0].id, "LD1");
928        assert_eq!(parsed.loads[0].owners.len(), 1);
929        assert_eq!(parsed.loads[0].owners[0].owner, 7);
930    }
931
932    #[test]
933    fn test_psse_file_write() {
934        let net = simple_network();
935        let tmp = std::env::temp_dir().join("surge_psse_writer_test.raw");
936        write_file(&net, &tmp, 33).unwrap();
937        let content = std::fs::read_to_string(&tmp).unwrap();
938        assert!(content.contains("END OF BUS DATA"));
939        let _ = std::fs::remove_file(&tmp);
940    }
941
942    #[test]
943    fn test_default_version() {
944        let net = simple_network();
945        let s = to_string(&net, 0).unwrap();
946        // version 0 → default to 33
947        assert!(s.contains("33"));
948    }
949
950    // -----------------------------------------------------------------------
951    // Round-trip tests for all sections
952    // -----------------------------------------------------------------------
953
954    #[test]
955    fn test_machine_id_roundtrip() {
956        use crate::psse::parse_str;
957        let mut net = simple_network();
958        net.generators[0].machine_id = Some("G1".to_string());
959        let mut g2 = Generator::new(1, 50.0, 1.04);
960        g2.machine_id = Some("G2".to_string());
961        net.generators.push(g2);
962
963        let s = to_string(&net, 33).unwrap();
964        let net2 = parse_str(&s).expect("round-trip parse failed");
965        assert_eq!(net2.generators.len(), 2);
966        assert_eq!(net2.generators[0].machine_id.as_deref(), Some("G1"));
967        assert_eq!(net2.generators[1].machine_id.as_deref(), Some("G2"));
968    }
969
970    #[test]
971    fn test_circuit_id_roundtrip() {
972        use crate::psse::parse_str;
973        let mut net = simple_network();
974        net.branches[0].circuit = "1".to_string();
975        let mut br2 = Branch::new_line(1, 2, 0.02, 0.06, 0.05);
976        br2.circuit = "2".to_string();
977        net.branches.push(br2);
978
979        let s = to_string(&net, 33).unwrap();
980        let net2 = parse_str(&s).expect("round-trip parse failed");
981        assert_eq!(net2.branches.len(), 2);
982        assert_eq!(net2.branches[0].circuit, "1");
983        assert_eq!(net2.branches[1].circuit, "2");
984    }
985
986    #[test]
987    fn test_transformer_magnetizing_roundtrip() {
988        use crate::psse::parse_str;
989        let mut net = simple_network();
990        let mut xfmr = Branch::new_line(1, 2, 0.01, 0.1, 0.0);
991        xfmr.tap = 1.05;
992        xfmr.g_mag = 0.001;
993        xfmr.b_mag = -0.05;
994        xfmr.circuit = "1".to_string();
995        net.branches.push(xfmr);
996
997        let s = to_string(&net, 33).unwrap();
998        let net2 = parse_str(&s).expect("round-trip parse failed");
999        // Find the transformer branch
1000        let xf = net2
1001            .branches
1002            .iter()
1003            .find(|b| b.is_transformer())
1004            .expect("transformer not found");
1005        assert!((xf.g_mag - 0.001).abs() < 1e-5, "g_mag={}", xf.g_mag);
1006        assert!((xf.b_mag - (-0.05)).abs() < 1e-5, "b_mag={}", xf.b_mag);
1007    }
1008
1009    #[test]
1010    fn test_area_schedule_roundtrip() {
1011        use crate::psse::parse_str;
1012        use surge_network::network::AreaSchedule;
1013        let mut net = simple_network();
1014        net.area_schedules.push(AreaSchedule {
1015            number: 1,
1016            slack_bus: 1,
1017            p_desired_mw: 150.0,
1018            p_tolerance_mw: 10.0,
1019            name: "AREA1".to_string(),
1020        });
1021
1022        let s = to_string(&net, 33).unwrap();
1023        let net2 = parse_str(&s).expect("round-trip parse failed");
1024        assert_eq!(net2.area_schedules.len(), 1);
1025        assert_eq!(net2.area_schedules[0].number, 1);
1026        assert_eq!(net2.area_schedules[0].slack_bus, 1);
1027        assert!((net2.area_schedules[0].p_desired_mw - 150.0).abs() < 1e-2);
1028        assert!(net2.area_schedules[0].name.contains("AREA1"));
1029    }
1030
1031    #[test]
1032    fn test_dc_line_roundtrip() {
1033        use crate::psse::parse_str;
1034        use surge_network::network::{LccConverterTerminal, LccHvdcControlMode, LccHvdcLink};
1035        let mut net = simple_network();
1036        net.hvdc.push_lcc_link(LccHvdcLink {
1037            name: "HVDC1".to_string(),
1038            mode: LccHvdcControlMode::PowerControl,
1039            resistance_ohm: 5.0,
1040            scheduled_setpoint: 500.0,
1041            scheduled_voltage_kv: 400.0,
1042            voltage_mode_switch_kv: 0.0,
1043            compounding_resistance_ohm: 0.0,
1044            current_margin_ka: 0.0,
1045            meter: 'R',
1046            voltage_min_kv: 0.0,
1047            ac_dc_iteration_max: 20,
1048            ac_dc_iteration_acceleration: 1.0,
1049            rectifier: LccConverterTerminal {
1050                bus: 1,
1051                n_bridges: 2,
1052                alpha_max: 90.0,
1053                alpha_min: 5.0,
1054                commutation_resistance_ohm: 0.5,
1055                commutation_reactance_ohm: 10.0,
1056                base_voltage_kv: 345.0,
1057                turns_ratio: 1.0,
1058                tap: 1.0,
1059                tap_max: 1.1,
1060                tap_min: 0.9,
1061                tap_step: 0.00625,
1062                in_service: true,
1063            },
1064            inverter: LccConverterTerminal {
1065                bus: 2,
1066                n_bridges: 2,
1067                alpha_max: 90.0,
1068                alpha_min: 5.0,
1069                commutation_resistance_ohm: 0.5,
1070                commutation_reactance_ohm: 10.0,
1071                base_voltage_kv: 345.0,
1072                turns_ratio: 1.0,
1073                tap: 1.0,
1074                tap_max: 1.1,
1075                tap_min: 0.9,
1076                tap_step: 0.00625,
1077                in_service: true,
1078            },
1079            p_dc_min_mw: 0.0,
1080            p_dc_max_mw: 0.0,
1081        });
1082
1083        let s = to_string(&net, 33).unwrap();
1084        let net2 = parse_str(&s).expect("round-trip parse failed");
1085        let dc = net2.hvdc.links[0].as_lcc().expect("lcc link");
1086        assert_eq!(net2.hvdc.links.len(), 1);
1087        assert!(dc.name.contains("HVDC1"));
1088        assert!((dc.resistance_ohm - 5.0).abs() < 1e-4);
1089        assert!((dc.scheduled_setpoint - 500.0).abs() < 1e-2);
1090        assert_eq!(dc.rectifier.bus, 1);
1091        assert_eq!(dc.inverter.bus, 2);
1092    }
1093
1094    #[test]
1095    fn test_facts_roundtrip() {
1096        use crate::psse::parse_str;
1097        use surge_network::network::{FactsDevice, FactsMode};
1098        let mut net = simple_network();
1099        net.facts_devices.push(FactsDevice {
1100            name: "SVC1".to_string(),
1101            bus_from: 1,
1102            bus_to: 0,
1103            mode: FactsMode::ShuntOnly,
1104            p_setpoint_mw: 0.0,
1105            q_setpoint_mvar: 50.0,
1106            voltage_setpoint_pu: 1.02,
1107            q_max: 200.0,
1108            series_reactance_pu: 0.05,
1109            in_service: true,
1110            ..FactsDevice::default()
1111        });
1112
1113        let s = to_string(&net, 33).unwrap();
1114        let net2 = parse_str(&s).expect("round-trip parse failed");
1115        assert_eq!(net2.facts_devices.len(), 1);
1116        let f = &net2.facts_devices[0];
1117        assert!(f.name.contains("SVC1"));
1118        assert_eq!(f.bus_from, 1);
1119        assert_eq!(f.mode, FactsMode::ShuntOnly);
1120        assert!((f.voltage_setpoint_pu - 1.02).abs() < 1e-4);
1121    }
1122
1123    #[test]
1124    fn test_switched_shunt_roundtrip() {
1125        use crate::psse::parse_str;
1126        use surge_network::network::SwitchedShunt;
1127        let mut net = simple_network();
1128        // Add a controlled switched shunt: 3 cap steps × 50 Mvar each,
1129        // 2 steps active, bus 1, self-regulating.
1130        net.controls.switched_shunts.push(SwitchedShunt {
1131            id: "ssh_1".into(),
1132            bus: 1,
1133            bus_regulated: 1,
1134            b_step: 0.5, // 50 Mvar / 100 MVA = 0.5 pu
1135            n_steps_cap: 3,
1136            n_steps_react: 0,
1137            v_target: 1.0,
1138            v_band: 0.10,
1139            n_active_steps: 2,
1140        });
1141
1142        let s = to_string(&net, 33).unwrap();
1143        assert!(s.contains("SWITCHED SHUNT"), "section marker missing");
1144
1145        let net2 = parse_str(&s).expect("round-trip parse failed");
1146        assert_eq!(
1147            net2.controls.switched_shunts.len(),
1148            1,
1149            "expected 1 switched shunt, got {}",
1150            net2.controls.switched_shunts.len()
1151        );
1152        let ss = &net2.controls.switched_shunts[0];
1153        assert_eq!(ss.n_steps_cap, 3);
1154        // b_step should be 50 Mvar / 100 MVA = 0.5 pu
1155        assert!(
1156            (ss.b_step - 0.5).abs() < 0.01,
1157            "b_step={}, expected ~0.5",
1158            ss.b_step
1159        );
1160        assert_eq!(ss.n_active_steps, 2);
1161    }
1162
1163    #[test]
1164    fn test_region_roundtrip() {
1165        use crate::psse::parse_str;
1166        use surge_network::network::Region;
1167        let mut net = simple_network();
1168        net.metadata.regions.push(Region {
1169            number: 1,
1170            name: "NORTH".to_string(),
1171        });
1172        net.metadata.regions.push(Region {
1173            number: 2,
1174            name: "SOUTH".to_string(),
1175        });
1176
1177        let s = to_string(&net, 33).unwrap();
1178        let net2 = parse_str(&s).expect("round-trip parse failed");
1179        assert_eq!(net2.metadata.regions.len(), 2);
1180        assert_eq!(net2.metadata.regions[0].number, 1);
1181        assert!(net2.metadata.regions[0].name.contains("NORTH"));
1182        assert_eq!(net2.metadata.regions[1].number, 2);
1183        assert!(net2.metadata.regions[1].name.contains("SOUTH"));
1184    }
1185
1186    #[test]
1187    fn test_owner_roundtrip() {
1188        use crate::psse::parse_str;
1189        use surge_network::network::Owner;
1190        let mut net = simple_network();
1191        net.metadata.owners.push(Owner {
1192            number: 1,
1193            name: "UTILITY_A".to_string(),
1194        });
1195
1196        let s = to_string(&net, 33).unwrap();
1197        let net2 = parse_str(&s).expect("round-trip parse failed");
1198        assert_eq!(net2.metadata.owners.len(), 1);
1199        assert_eq!(net2.metadata.owners[0].number, 1);
1200        assert!(net2.metadata.owners[0].name.contains("UTILITY_A"));
1201    }
1202
1203    #[test]
1204    fn test_scheduled_area_transfer_roundtrip() {
1205        use crate::psse::parse_str;
1206        use surge_network::network::scheduled_area_transfer::ScheduledAreaTransfer;
1207        let mut net = simple_network();
1208        net.metadata
1209            .scheduled_area_transfers
1210            .push(ScheduledAreaTransfer {
1211                from_area: 1,
1212                to_area: 2,
1213                id: 1,
1214                p_transfer_mw: 250.0,
1215            });
1216
1217        let s = to_string(&net, 33).unwrap();
1218        let net2 = parse_str(&s).expect("round-trip parse failed");
1219        assert_eq!(net2.metadata.scheduled_area_transfers.len(), 1);
1220        let xfer = &net2.metadata.scheduled_area_transfers[0];
1221        assert_eq!(xfer.from_area, 1);
1222        assert_eq!(xfer.to_area, 2);
1223        assert!((xfer.p_transfer_mw - 250.0).abs() < 1e-2);
1224    }
1225
1226    #[test]
1227    fn test_impedance_correction_roundtrip() {
1228        use crate::psse::parse_str;
1229        use surge_network::network::impedance_correction::ImpedanceCorrectionTable;
1230        let mut net = simple_network();
1231        net.metadata
1232            .impedance_corrections
1233            .push(ImpedanceCorrectionTable {
1234                number: 1,
1235                entries: vec![(0.9, 1.1), (1.0, 1.0), (1.1, 0.95)],
1236            });
1237
1238        let s = to_string(&net, 33).unwrap();
1239        let net2 = parse_str(&s).expect("round-trip parse failed");
1240        assert_eq!(net2.metadata.impedance_corrections.len(), 1);
1241        let table = &net2.metadata.impedance_corrections[0];
1242        assert_eq!(table.number, 1);
1243        assert_eq!(table.entries.len(), 3);
1244        assert!((table.entries[0].0 - 0.9).abs() < 1e-4);
1245        assert!((table.entries[0].1 - 1.1).abs() < 1e-4);
1246        assert!((table.entries[2].1 - 0.95).abs() < 1e-4);
1247    }
1248
1249    #[test]
1250    fn test_multi_section_line_roundtrip() {
1251        use crate::psse::parse_str;
1252        use surge_network::network::multi_section_line::MultiSectionLineGroup;
1253        let mut net = simple_network();
1254        // Add a dummy bus for the multi-section line
1255        net.buses.push(Bus::new(3, BusType::PQ, 345.0));
1256        net.metadata
1257            .multi_section_line_groups
1258            .push(MultiSectionLineGroup {
1259                from_bus: 1,
1260                to_bus: 2,
1261                id: "1".to_string(),
1262                metered_end: 1,
1263                dummy_buses: vec![3],
1264            });
1265
1266        let s = to_string(&net, 33).unwrap();
1267        let net2 = parse_str(&s).expect("round-trip parse failed");
1268        assert_eq!(net2.metadata.multi_section_line_groups.len(), 1);
1269        let ms = &net2.metadata.multi_section_line_groups[0];
1270        assert_eq!(ms.from_bus, 1);
1271        assert_eq!(ms.to_bus, 2);
1272        assert_eq!(ms.dummy_buses, vec![3]);
1273    }
1274
1275    #[test]
1276    fn test_all_sections_present_in_output() {
1277        let net = simple_network();
1278        let s = to_string(&net, 33).unwrap();
1279        // Verify all section markers exist in order
1280        assert!(s.contains("END OF BUS DATA"));
1281        assert!(s.contains("END OF LOAD DATA"));
1282        assert!(s.contains("END OF FIXED SHUNT DATA"));
1283        assert!(s.contains("END OF GENERATOR DATA"));
1284        assert!(s.contains("END OF BRANCH DATA"));
1285        assert!(s.contains("END OF TRANSFORMER DATA"));
1286        assert!(s.contains("END OF AREA INTERCHANGE DATA"));
1287        assert!(s.contains("END OF TWO-TERMINAL DC DATA"));
1288        assert!(s.contains("END OF VSC DC LINE DATA"));
1289        assert!(s.contains("END OF IMPEDANCE CORRECTION DATA"));
1290        assert!(s.contains("END OF MULTI-TERMINAL DC DATA"));
1291        assert!(s.contains("END OF MULTI-SECTION LINE DATA"));
1292        assert!(s.contains("END OF ZONE DATA"));
1293        assert!(s.contains("END OF INTER-AREA TRANSFER DATA"));
1294        assert!(s.contains("END OF OWNER DATA"));
1295        assert!(s.contains("END OF FACTS CONTROL DEVICE DATA"));
1296        assert!(s.contains("END OF SWITCHED SHUNT DATA"));
1297    }
1298
1299    /// v35 writer emits GNE, Induction Machine, and Substation section markers.
1300    #[test]
1301    fn test_v35_sections_present() {
1302        let net = simple_network();
1303        let s = to_string(&net, 35).unwrap();
1304        assert!(
1305            s.contains("END OF SYSTEM SWITCHING DEVICE DATA"),
1306            "v35 output missing System Switching Device section"
1307        );
1308        assert!(
1309            s.contains("END OF GNE DEVICE DATA"),
1310            "v35 output missing GNE section"
1311        );
1312        assert!(
1313            s.contains("END OF INDUCTION MACHINE DATA"),
1314            "v35 output missing Induction Machine section"
1315        );
1316        assert!(
1317            s.contains("END OF SUBSTATION DATA"),
1318            "v35 output missing Substation section"
1319        );
1320    }
1321
1322    /// v33 writer must NOT emit v35+ sections.
1323    #[test]
1324    fn test_v33_no_v35_sections() {
1325        let net = simple_network();
1326        let s = to_string(&net, 33).unwrap();
1327        assert!(
1328            !s.contains("SYSTEM SWITCHING DEVICE"),
1329            "v33 output should not contain System Switching Device section"
1330        );
1331        assert!(
1332            !s.contains("GNE DEVICE"),
1333            "v33 output should not contain GNE section"
1334        );
1335        assert!(
1336            !s.contains("INDUCTION MACHINE"),
1337            "v33 output should not contain Induction Machine section"
1338        );
1339        assert!(
1340            !s.contains("SUBSTATION DATA"),
1341            "v33 output should not contain Substation section"
1342        );
1343    }
1344}