1use 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
26pub 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
33pub 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 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 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 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 }
110 writeln!(out, " 0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA")?;
111
112 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 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 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 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, 0.0, status
230 )?;
231 }
232 }
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 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 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 writeln!(out, " {:.6},{:.6},{:.4}", br.r, br.x, network.base_mva)?;
269 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 writeln!(out, " 1.0,0")?;
281 }
282 }
283 writeln!(
284 out,
285 " 0 / END OF TRANSFORMER DATA, BEGIN AREA INTERCHANGE DATA"
286 )?;
287
288 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 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 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 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 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 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 for region in &network.metadata.regions {
492 let name = truncate_name(®ion.name, 12);
493 writeln!(out, " {},'{}'", region.number, name)?;
494 }
495 writeln!(out, " 0 / END OF ZONE DATA, BEGIN INTER-AREA TRANSFER DATA")?;
496
497 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 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 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 write_switched_shunts(&mut out, network)?;
549
550 if ver >= 35 {
551 writeln!(
553 out,
554 " 0 / END OF SWITCHED SHUNT DATA, BEGIN GNE DEVICE DATA"
555 )?;
556 writeln!(
558 out,
559 " 0 / END OF GNE DEVICE DATA, BEGIN INDUCTION MACHINE DATA"
560 )?;
561 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
578fn 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
604fn 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
629fn write_switched_shunts(out: &mut String, network: &Network) -> Result<(), PsseWriteError> {
631 use std::collections::BTreeMap;
632
633 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 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 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
679fn 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
685fn 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
697fn sanitize_psse_name(name: &str) -> String {
699 name.chars()
700 .filter(|&c| c != '\'' && c != '"' && c != '\n')
701 .collect()
702}
703
704fn 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 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 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
741fn 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 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 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 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 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 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 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 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
845fn 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 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 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 assert!(s.contains("33"));
948 }
949
950 #[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 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 net.controls.switched_shunts.push(SwitchedShunt {
1131 id: "ssh_1".into(),
1132 bus: 1,
1133 bus_regulated: 1,
1134 b_step: 0.5, 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 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 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 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 #[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 #[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}