Skip to main content

surge_io/epc/
writer.rs

1// SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0
2//! GE PSLF EPC (.epc) file writer.
3//!
4//! Writes the GE PSLF Electric Power Case format used in WECC base-case
5//! exchange and TAMU ACTIVSg synthetic test cases.
6//!
7//! The output is a minimal but round-trip-safe EPC file containing:
8//! - Title / Comments / Solution Parameters (preamble)
9//! - Bus Data
10//! - Branch Data (lines only, tap == 0 or 1)
11//! - Transformer Data (branches with tap != 0 and != 1)
12//! - Generator Data
13//! - Load Data (aggregated per bus from Load objects via bus_load_p_mw / bus_load_q_mvar)
14//! - Shunt Data (aggregated per bus from bus.shunt_conductance_mw / bus.shunt_susceptance_mvar)
15//! - Area Data
16
17use std::fmt::Write as FmtWrite;
18use std::path::Path;
19
20use surge_network::Network;
21use surge_network::network::BusType;
22use thiserror::Error;
23
24#[derive(Error, Debug)]
25pub enum EpcWriteError {
26    #[error("I/O error: {0}")]
27    Io(#[from] std::io::Error),
28    #[error("format error: {0}")]
29    Fmt(#[from] std::fmt::Error),
30}
31
32/// Write a Network to a GE PSLF EPC file on disk.
33pub fn write_file(network: &Network, path: &Path) -> Result<(), EpcWriteError> {
34    let content = to_string(network)?;
35    std::fs::write(path, content)?;
36    Ok(())
37}
38
39/// Serialize a Network to a GE PSLF EPC string.
40pub fn to_string(network: &Network) -> Result<String, EpcWriteError> {
41    let mut out = String::with_capacity(64 * 1024);
42
43    write_title(&mut out, network)?;
44    write_comments(&mut out)?;
45    write_solution_parameters(&mut out, network)?;
46    write_bus_data(&mut out, network)?;
47    write_branch_data(&mut out, network)?;
48    write_transformer_data(&mut out, network)?;
49    write_generator_data(&mut out, network)?;
50    write_load_data(&mut out, network)?;
51    write_shunt_data(&mut out, network)?;
52    write_area_data(&mut out, network)?;
53    writeln!(out, "end")?;
54
55    Ok(out)
56}
57
58// ---------------------------------------------------------------------------
59// Section writers
60// ---------------------------------------------------------------------------
61
62fn write_title(out: &mut String, network: &Network) -> Result<(), EpcWriteError> {
63    writeln!(out, "title")?;
64    writeln!(
65        out,
66        "{} — exported by Surge (https://github.com/amptimal/surge)",
67        network.name
68    )?;
69    writeln!(out, "!")?;
70    Ok(())
71}
72
73fn write_comments(out: &mut String) -> Result<(), EpcWriteError> {
74    writeln!(out, "comments")?;
75    writeln!(out, "!")?;
76    Ok(())
77}
78
79fn write_solution_parameters(out: &mut String, network: &Network) -> Result<(), EpcWriteError> {
80    writeln!(out, "solution parameters")?;
81    writeln!(out, "  {:.1}", network.base_mva)?;
82    writeln!(out, "!")?;
83    Ok(())
84}
85
86fn write_bus_data(out: &mut String, network: &Network) -> Result<(), EpcWriteError> {
87    writeln!(out, "bus data [{}]", network.buses.len())?;
88
89    for bus in &network.buses {
90        let ty = match bus.bus_type {
91            BusType::PQ => 0,
92            BusType::PV => 2,
93            BusType::Slack => 3,
94            BusType::Isolated => 4,
95        };
96        let st = if bus.bus_type == BusType::Isolated {
97            1
98        } else {
99            0
100        };
101        let name = format_epc_name(&bus.name);
102        let va_deg = bus.voltage_angle_rad.to_degrees();
103        let lat = bus.latitude.unwrap_or(0.0);
104        let lon = bus.longitude.unwrap_or(0.0);
105
106        // Identity: bus_number "name" base_kv
107        // Data: ty vsched volt angle ar zone vmax vmin date_in date_out pid L own st lat lon
108        writeln!(
109            out,
110            "  {} {} {:.4} : {} {:.6} {:.6} {:.6} {} {} {:.6} {:.6} 0 0 0 0 0 {} {:.6} {:.6}",
111            bus.number,
112            name,
113            bus.base_kv,
114            ty,
115            bus.voltage_magnitude_pu, // vsched = vm
116            bus.voltage_magnitude_pu, // volt
117            va_deg,
118            bus.area,
119            bus.zone,
120            bus.voltage_max_pu,
121            bus.voltage_min_pu,
122            st,
123            lat,
124            lon,
125        )?;
126    }
127
128    Ok(())
129}
130
131fn write_branch_data(out: &mut String, network: &Network) -> Result<(), EpcWriteError> {
132    // Lines only: tap == 0 or tap == 1.0 (not transformers)
133    let lines: Vec<_> = network.branches.iter().filter(|b| is_line(b)).collect();
134
135    writeln!(out, "branch data [{}]", lines.len())?;
136
137    // Build bus name/kv lookup
138    let bus_info: std::collections::HashMap<u32, (&str, f64)> = network
139        .buses
140        .iter()
141        .map(|b| (b.number, (b.name.as_str(), b.base_kv)))
142        .collect();
143
144    for br in &lines {
145        let (from_name, from_kv) = bus_info.get(&br.from_bus).copied().unwrap_or(("", 0.0));
146        let (to_name, to_kv) = bus_info.get(&br.to_bus).copied().unwrap_or(("", 0.0));
147
148        let st = if br.in_service { 1 } else { 0 };
149        let ck = &br.circuit;
150
151        // Identity: from_bus "from_name" from_kv  to_bus "to_name" to_kv  "ck" se
152        // Data: st resist react charge rate1 rate2 rate3 rate4 aloss lngth
153        writeln!(
154            out,
155            "  {} {} {:.4}  {} {} {:.4}  \"{}\" 1 : {} {:.8E} {:.8E} {:.8E} {:.2} {:.2} {:.2} 0.00 0.0 0.0",
156            br.from_bus,
157            format_epc_name(from_name),
158            from_kv,
159            br.to_bus,
160            format_epc_name(to_name),
161            to_kv,
162            ck,
163            st,
164            br.r,
165            br.x,
166            br.b,
167            br.rating_a_mva,
168            br.rating_b_mva,
169            br.rating_c_mva,
170        )?;
171    }
172
173    Ok(())
174}
175
176fn write_transformer_data(out: &mut String, network: &Network) -> Result<(), EpcWriteError> {
177    // Transformers: tap != 0 and tap != 1.0
178    let xfmrs: Vec<_> = network.branches.iter().filter(|b| !is_line(b)).collect();
179
180    writeln!(out, "transformer data [{}]", xfmrs.len())?;
181
182    let bus_info: std::collections::HashMap<u32, (&str, f64)> = network
183        .buses
184        .iter()
185        .map(|b| (b.number, (b.name.as_str(), b.base_kv)))
186        .collect();
187
188    for br in &xfmrs {
189        let (from_name, from_kv) = bus_info.get(&br.from_bus).copied().unwrap_or(("", 0.0));
190        let (to_name, to_kv) = bus_info.get(&br.to_bus).copied().unwrap_or(("", 0.0));
191
192        let st = if br.in_service { 1 } else { 0 };
193        let ck = &br.circuit;
194        let tbase = network.base_mva;
195
196        // Compute winding kVs from tap ratio:
197        //   tap = (kv_primary / from_basekv) / (kv_secondary / to_basekv)
198        // For writing, set kv_primary = tap * from_basekv, kv_secondary = to_basekv
199        let kv_primary = br.tap * from_kv;
200        let kv_secondary = to_kv;
201
202        // Identity: from_bus "from_name" from_kv  to_bus "to_name" to_kv  "ck" "long_id"
203        // Data line 1: st ty reg_bus "reg_name" reg_kv zt int_bus "int_name" int_kv
204        //              tert_bus "tert_name" tert_kv ar zone tbase ps_r ps_x pt_r pt_x ts_r ts_x /
205        // Data line 2 (cont): kv_primary kv_secondary ang1 ang2 ang3 ang4 rate1 rate2 rate3 rate4 /
206        // Data line 3 (cont): owner data /
207        // Data line 4 (cont): more data
208        writeln!(
209            out,
210            "  {} {} {:.4}  {} {} {:.4}  \"{}\" \"\" : {} 0 0 \"\" 0.000 0 0 \"\" 0.000 /",
211            br.from_bus,
212            format_epc_name(from_name),
213            from_kv,
214            br.to_bus,
215            format_epc_name(to_name),
216            to_kv,
217            ck,
218            st,
219        )?;
220        writeln!(
221            out,
222            "  0 \"\" 0.000 {} {} {:.1} {:.8E} {:.8E} 0.0000E+00 0.0000E+00 0.0000E+00 0.0000E+00 /",
223            br.from_bus.min(99999), // area (use from bus area via bus lookup, or placeholder)
224            1,                      // zone placeholder
225            tbase,
226            br.r,
227            br.x,
228        )?;
229        writeln!(
230            out,
231            "  {:.4} {:.4} 0.0 0.0 0.0 0.0 {:.2} {:.2} {:.2} 0.00 /",
232            kv_primary, kv_secondary, br.rating_a_mva, br.rating_b_mva, br.rating_c_mva,
233        )?;
234        writeln!(out, "  0 1.0 0 1.0 0 1.0 0 1.0")?;
235    }
236
237    Ok(())
238}
239
240fn write_generator_data(out: &mut String, network: &Network) -> Result<(), EpcWriteError> {
241    writeln!(out, "generator data [{}]", network.generators.len())?;
242
243    let bus_info: std::collections::HashMap<u32, (&str, f64)> = network
244        .buses
245        .iter()
246        .map(|b| (b.number, (b.name.as_str(), b.base_kv)))
247        .collect();
248
249    for g in &network.generators {
250        let (bus_name, bus_kv) = bus_info.get(&g.bus).copied().unwrap_or(("", 0.0));
251        let st = if g.in_service { 1 } else { 0 };
252        let gen_id = g.machine_id.as_deref().unwrap_or("1");
253
254        // Identity: bus "name" basekv "id" "long_id"
255        // Data: st reg_bus "reg_name" reg_kv  prf qrf ar zone
256        //       pgen pmax pmin qgen qmax qmin mbase  cmp_r cmp_x gen_r gen_x /
257        //       continuation data
258        writeln!(
259            out,
260            "  {} {} {:.4} \"{}\" \"\" : {} 0 \"\" 0.000 /",
261            g.bus,
262            format_epc_name(bus_name),
263            bus_kv,
264            gen_id,
265            st,
266        )?;
267        writeln!(
268            out,
269            "  1.0 1.0 1 1 {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} {:.1} 0.0 0.0 0.0 0.0 /",
270            g.p, g.pmax, g.pmin, g.q, g.qmax, g.qmin, g.machine_base_mva,
271        )?;
272        writeln!(out, "  0 \"\" 0.000 0 \"\" 0.000 0 0 0 0")?;
273    }
274
275    Ok(())
276}
277
278fn write_load_data(out: &mut String, network: &Network) -> Result<(), EpcWriteError> {
279    // Compute per-bus demand from Load objects.
280    let bus_demand_p = network.bus_load_p_mw();
281    let bus_demand_q = network.bus_load_q_mvar();
282    let _bus_map = network.bus_index_map();
283
284    let load_buses: Vec<(usize, &surge_network::network::Bus)> = network
285        .buses
286        .iter()
287        .enumerate()
288        .filter(|(i, _)| {
289            let pd = bus_demand_p.get(*i).copied().unwrap_or(0.0);
290            let qd = bus_demand_q.get(*i).copied().unwrap_or(0.0);
291            pd.abs() > 1e-10 || qd.abs() > 1e-10
292        })
293        .collect();
294
295    writeln!(out, "load data [{}]", load_buses.len())?;
296
297    for (bi, bus) in &load_buses {
298        let name = format_epc_name(&bus.name);
299        let pd = bus_demand_p.get(*bi).copied().unwrap_or(0.0);
300        let qd = bus_demand_q.get(*bi).copied().unwrap_or(0.0);
301        // Identity: bus "name" basekv "1" "long_id"
302        // Data: st mw mvar mw_i mvar_i mw_z mvar_z ar zone
303        writeln!(
304            out,
305            "  {} {} {:.4} \"1\" \"\" : 1 {:.4} {:.4} 0.0 0.0 0.0 0.0 {} {}",
306            bus.number, name, bus.base_kv, pd, qd, bus.area, bus.zone,
307        )?;
308    }
309
310    Ok(())
311}
312
313fn write_shunt_data(out: &mut String, network: &Network) -> Result<(), EpcWriteError> {
314    // Bus shunts from bus.shunt_conductance_mw / bus.shunt_susceptance_mvar
315    let shunts: Vec<_> = network
316        .buses
317        .iter()
318        .filter(|b| b.shunt_conductance_mw.abs() > 1e-10 || b.shunt_susceptance_mvar.abs() > 1e-10)
319        .collect();
320
321    writeln!(out, "shunt data [{}]", shunts.len())?;
322
323    for bus in &shunts {
324        let name = format_epc_name(&bus.name);
325        // Identity: bus "name" basekv "1" ... "ck" se "long_id"
326        // Data: st ar zone pu_mw pu_mvar
327        writeln!(
328            out,
329            "  {} {} {:.4} \"1\" \"\" \"1\" 1 \"\" : 1 {} {} {:.6} {:.6}",
330            bus.number,
331            name,
332            bus.base_kv,
333            bus.area,
334            bus.zone,
335            bus.shunt_conductance_mw,
336            bus.shunt_susceptance_mvar,
337        )?;
338    }
339
340    Ok(())
341}
342
343fn write_area_data(out: &mut String, network: &Network) -> Result<(), EpcWriteError> {
344    if network.area_schedules.is_empty() {
345        writeln!(out, "area data [0]")?;
346        return Ok(());
347    }
348
349    writeln!(out, "area data [{}]", network.area_schedules.len())?;
350    for area in &network.area_schedules {
351        let name = format_epc_name(&area.name);
352        // number "name" : swing desired tol pnet qnet
353        writeln!(
354            out,
355            "  {} {} : {} {:.2} 10.0 0.0 0.0",
356            area.number, name, area.slack_bus, area.p_desired_mw,
357        )?;
358    }
359
360    Ok(())
361}
362
363// ---------------------------------------------------------------------------
364// Helpers
365// ---------------------------------------------------------------------------
366
367/// Format a name string for EPC output (double-quoted).
368fn format_epc_name(name: &str) -> String {
369    let trimmed = name.trim();
370    if trimmed.is_empty() {
371        "\"\"".to_string()
372    } else {
373        format!("\"{}\"", trimmed.replace('"', "'"))
374    }
375}
376
377/// Determine if a branch is a line (not a transformer).
378fn is_line(branch: &surge_network::network::Branch) -> bool {
379    branch.tap == 0.0 || (branch.tap - 1.0).abs() < 1e-6
380}
381
382// ---------------------------------------------------------------------------
383// Tests
384// ---------------------------------------------------------------------------
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use surge_network::network::{Branch, Bus, BusType, Generator, Load};
390
391    fn mini_network() -> Network {
392        let mut net = Network::new("test_epc");
393        net.base_mva = 100.0;
394
395        let mut b1 = Bus::new(1, BusType::Slack, 345.0);
396        b1.name = "Bus1".into();
397        b1.voltage_magnitude_pu = 1.04;
398        b1.voltage_angle_rad = 0.0;
399        b1.area = 1;
400        b1.zone = 1;
401        b1.voltage_max_pu = 1.06;
402        b1.voltage_min_pu = 0.94;
403
404        let mut b2 = Bus::new(2, BusType::PV, 345.0);
405        b2.name = "Bus2".into();
406        b2.voltage_magnitude_pu = 1.025;
407        b2.voltage_angle_rad = 0.17;
408        b2.area = 1;
409        b2.zone = 1;
410        b2.voltage_max_pu = 1.06;
411        b2.voltage_min_pu = 0.94;
412
413        let mut b3 = Bus::new(3, BusType::PQ, 138.0);
414        b3.name = "Bus3".into();
415        b3.voltage_magnitude_pu = 1.01;
416        b3.voltage_angle_rad = -0.05;
417        b3.area = 1;
418        b3.zone = 1;
419        b3.voltage_max_pu = 1.06;
420        b3.voltage_min_pu = 0.94;
421        b3.shunt_conductance_mw = 5.0;
422        b3.shunt_susceptance_mvar = -10.0;
423
424        net.buses = vec![b1, b2, b3];
425        net.loads = vec![Load::new(2, 50.0, 20.0), Load::new(3, 100.0, 40.0)];
426
427        // Line between buses 1 and 2
428        let mut line = Branch::new_line(1, 2, 0.01, 0.1, 0.02);
429        line.rating_a_mva = 200.0;
430        line.circuit = "1".to_string();
431
432        // Transformer between buses 2 and 3
433        let mut xfmr = Branch::new_line(2, 3, 0.005, 0.05, 0.0);
434        xfmr.tap = 1.0; // nominal tap ratio (same winding ratio on both sides)
435        // Make it clearly a transformer
436        xfmr.tap = 1.05;
437        xfmr.rating_a_mva = 150.0;
438        xfmr.circuit = "1".to_string();
439
440        net.branches = vec![line, xfmr];
441
442        let mut g1 = Generator::new(1, 100.0, 1.04);
443        g1.machine_id = Some("1".into());
444        g1.pmax = 200.0;
445        g1.pmin = 10.0;
446        g1.qmax = 100.0;
447        g1.qmin = -50.0;
448        g1.machine_base_mva = 100.0;
449
450        net.generators = vec![g1];
451        net
452    }
453
454    #[test]
455    fn test_round_trip_to_string() {
456        let net = mini_network();
457        let epc = to_string(&net).unwrap();
458
459        // Verify basic structure
460        assert!(epc.contains("title"));
461        assert!(epc.contains("bus data [3]"));
462        assert!(epc.contains("branch data [1]"));
463        assert!(epc.contains("transformer data [1]"));
464        assert!(epc.contains("generator data [1]"));
465        assert!(epc.contains("load data [2]"));
466        assert!(epc.contains("shunt data [1]"));
467        assert!(epc.contains("end"));
468    }
469
470    #[test]
471    fn test_round_trip_parse() {
472        let net = mini_network();
473        let epc = to_string(&net).unwrap();
474
475        // Parse it back
476        let parsed = crate::epc::parse_str(&epc).unwrap();
477
478        // Verify counts match
479        assert_eq!(parsed.buses.len(), net.buses.len());
480        assert_eq!(parsed.generators.len(), net.generators.len());
481        // Branches = lines + transformers
482        assert_eq!(parsed.branches.len(), net.branches.len());
483
484        // Verify bus numbers
485        let bus_nums: Vec<u32> = parsed.buses.iter().map(|b| b.number).collect();
486        assert!(bus_nums.contains(&1));
487        assert!(bus_nums.contains(&2));
488        assert!(bus_nums.contains(&3));
489    }
490
491    #[test]
492    fn test_write_file_round_trip() {
493        let net = mini_network();
494        let tmp = std::env::temp_dir().join("surge_test_epc_writer.epc");
495        write_file(&net, &tmp).unwrap();
496
497        let parsed = crate::epc::parse_file(&tmp).unwrap();
498        assert_eq!(parsed.buses.len(), 3);
499        assert_eq!(parsed.generators.len(), 1);
500
501        // Cleanup
502        let _ = std::fs::remove_file(&tmp);
503    }
504}