use std::fmt::Write as FmtWrite;
use std::path::Path;
use surge_network::Network;
use surge_network::network::{BusType, SwitchedShunt};
use thiserror::Error;
#[derive(Error, Debug)]
pub enum PsseWriteError {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("format error: {0}")]
Fmt(#[from] std::fmt::Error),
}
pub fn write_file(network: &Network, path: &Path, version: u32) -> Result<(), PsseWriteError> {
let content = to_string(network, version)?;
std::fs::write(path, content)?;
Ok(())
}
pub fn to_string(network: &Network, version: u32) -> Result<String, PsseWriteError> {
let mut out = String::with_capacity(64 * 1024);
let ver = if version == 0 { 33 } else { version };
writeln!(
out,
" 0, {}, {ver}, 0, 0, 60.0 / PSS/E {ver} Raw Data -- Exported by Surge",
network.base_mva
)?;
writeln!(out, " {}", sanitize_psse_name(&network.name))?;
writeln!(
out,
" Exported by Surge (https://github.com/amptimal/surge)"
)?;
for bus in &network.buses {
let bus_type_code = match bus.bus_type {
BusType::PQ => 1,
BusType::PV => 2,
BusType::Slack => 3,
BusType::Isolated => 4,
};
let va_deg = bus.voltage_angle_rad.to_degrees();
let name = format_bus_name(&bus.name, bus.number);
writeln!(
out,
" {},{:12},{},{},{},{},{},{:.6},{:.4},1",
bus.number,
format!("'{name}'"),
bus.base_kv,
bus_type_code,
1,
bus.area,
bus.zone,
bus.voltage_magnitude_pu,
va_deg
)?;
}
writeln!(out, " 0 / END OF BUS DATA, BEGIN LOAD DATA")?;
if !network.loads.is_empty() {
let bus_lookup: std::collections::HashMap<u32, &surge_network::network::Bus> =
network.buses.iter().map(|b| (b.number, b)).collect();
for load in &network.loads {
let status: i32 = if load.in_service { 1 } else { 0 };
let p = load.active_power_demand_mw;
let q = load.reactive_power_demand_mvar;
let pl = load.zip_p_power_frac * p;
let ip = load.zip_p_current_frac * p;
let yp = load.zip_p_impedance_frac * p;
let ql = load.zip_q_power_frac * q;
let iq = load.zip_q_current_frac * q;
let yq = load.zip_q_impedance_frac * q;
let (area, zone) = bus_lookup
.get(&load.bus)
.map(|b| (b.area, b.zone))
.unwrap_or((1, 1));
let scale: i32 = if load.conforming { 1 } else { 0 };
let id = if load.id.is_empty() { "1 " } else { &load.id };
let owner = load.owners.first().map(|entry| entry.owner).unwrap_or(1);
writeln!(
out,
" {},'{id}',{status},{area},{zone},{pl:.4},{ql:.4},{ip:.4},{iq:.4},{yp:.4},{yq:.4},{},{scale}",
load.bus, owner
)?;
}
} else {
}
writeln!(out, " 0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA")?;
for bus in &network.buses {
if bus.shunt_conductance_mw.abs() > 1e-10 || bus.shunt_susceptance_mvar.abs() > 1e-10 {
writeln!(
out,
" {},'1 ',1,{:.4},{:.4}",
bus.number, bus.shunt_conductance_mw, bus.shunt_susceptance_mvar
)?;
}
}
if version >= 36 {
writeln!(
out,
" 0 / END OF FIXED SHUNT DATA, BEGIN VOLTAGE DROOP CONTROL DATA"
)?;
for ctrl in &network.metadata.voltage_droop_controls {
writeln!(
out,
" {},'{}',{},{},{:.6},{:.6},{:.6}",
ctrl.bus,
ctrl.device_id,
ctrl.device_type,
ctrl.regulated_bus,
ctrl.vdrp,
ctrl.vmax,
ctrl.vmin
)?;
}
writeln!(
out,
" 0 / END OF VOLTAGE DROOP CONTROL DATA, BEGIN GENERATOR DATA"
)?;
} else {
writeln!(out, " 0 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA")?;
}
for g in &network.generators {
let status = if g.in_service { 1 } else { 0 };
let qmax = clamp_finite(g.qmax, 9999.0);
let qmin = clamp_finite(g.qmin, -9999.0);
let pmax = clamp_finite(g.pmax, 9999.0);
let pmin = clamp_finite(g.pmin, -9999.0);
let mbase = clamp_finite(g.machine_base_mva, 100.0);
let mid = g.machine_id.as_deref().unwrap_or("1");
writeln!(
out,
" {},'{:2}',{:.4},{:.4},{:.4},{:.4},{:.6},{},{:.4},0,0,0,0,1.0,{},100,{:.4},{:.4},1",
g.bus,
mid,
g.p,
g.q,
qmax,
qmin,
g.voltage_setpoint_pu,
g.bus,
mbase,
status,
pmax,
pmin
)?;
}
if version >= 36 {
writeln!(
out,
" 0 / END OF GENERATOR DATA, BEGIN SWITCHING DEVICE RATING SET DATA"
)?;
for rs in &network.metadata.switching_device_rating_sets {
write!(
out,
" {},{},'{}',{},{:.2},{:.2},{:.2}",
rs.from_bus, rs.to_bus, rs.circuit, rs.rating_set, rs.rate1, rs.rate2, rs.rate3
)?;
for rate in &rs.additional_rates {
write!(out, ",{rate:.2}")?;
}
writeln!(out)?;
}
writeln!(
out,
" 0 / END OF SWITCHING DEVICE RATING SET DATA, BEGIN BRANCH DATA"
)?;
} else {
writeln!(out, " 0 / END OF GENERATOR DATA, BEGIN BRANCH DATA")?;
}
for br in &network.branches {
let status = if br.in_service { 1 } else { 0 };
let ckt = if br.circuit.is_empty() {
"1"
} else {
&br.circuit
};
let ra = clamp_finite(br.rating_a_mva, 0.0);
let rb = clamp_finite(br.rating_b_mva, 0.0);
let rc = clamp_finite(br.rating_c_mva, 0.0);
if !br.is_transformer() {
writeln!(
out,
" {},{},'{:2}',{:.6},{:.6},{:.6},{:.2},{:.2},{:.2},0,{:.6},0,{:.6},{},1,0,1",
br.from_bus,
br.to_bus,
ckt,
br.r,
br.x,
br.b,
ra,
rb,
rc,
0.0, 0.0, status
)?;
}
}
if ver >= 34 {
writeln!(
out,
" 0 / END OF BRANCH DATA, BEGIN SYSTEM SWITCHING DEVICE DATA"
)?;
writeln!(
out,
" 0 / END OF SYSTEM SWITCHING DEVICE DATA, BEGIN TRANSFORMER DATA"
)?;
} else {
writeln!(out, " 0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA")?;
}
for br in &network.branches {
if br.is_transformer() {
let status = if br.in_service { 1 } else { 0 };
let ckt = if br.circuit.is_empty() {
"1"
} else {
&br.circuit
};
let ra = clamp_finite(br.rating_a_mva, 0.0);
let rb = clamp_finite(br.rating_b_mva, 0.0);
let rc = clamp_finite(br.rating_c_mva, 0.0);
writeln!(
out,
" {},{},0,'{:2}',1,1,1,{:.6},{:.6},1,'XFMR ',{},1,1.0",
br.from_bus, br.to_bus, ckt, br.g_mag, br.b_mag, status
)?;
writeln!(out, " {:.6},{:.6},{:.4}", br.r, br.x, network.base_mva)?;
writeln!(
out,
" {:.6},0,{:.4},{:.2},{:.2},{:.2},0,0,1.1,0.9,1.1,0.9,33,0,0,0",
br.tap,
br.phase_shift_rad.to_degrees(),
ra,
rb,
rc
)?;
writeln!(out, " 1.0,0")?;
}
}
writeln!(
out,
" 0 / END OF TRANSFORMER DATA, BEGIN AREA INTERCHANGE DATA"
)?;
for area in &network.area_schedules {
let name = truncate_name(&area.name, 12);
writeln!(
out,
" {},{},{:.4},{:.4},'{}'",
area.number, area.slack_bus, area.p_desired_mw, area.p_tolerance_mw, name
)?;
}
writeln!(
out,
" 0 / END OF AREA INTERCHANGE DATA, BEGIN TWO-TERMINAL DC DATA"
)?;
for link in &network.hvdc.links {
let Some(dc) = link.as_lcc() else {
continue;
};
let mdc = dc.mode as u32;
writeln!(
out,
" '{}',{},{:.6},{:.4},{:.4},{:.4},{:.6},{:.6},'{}',{:.4},{},{:.4}",
sanitize_psse_name(&dc.name),
mdc,
dc.resistance_ohm,
dc.scheduled_setpoint,
dc.scheduled_voltage_kv,
dc.voltage_mode_switch_kv,
dc.compounding_resistance_ohm,
dc.current_margin_ka,
dc.meter,
dc.voltage_min_kv,
dc.ac_dc_iteration_max,
dc.ac_dc_iteration_acceleration
)?;
write_dc_converter(&mut out, &dc.rectifier)?;
write_dc_converter(&mut out, &dc.inverter)?;
}
writeln!(
out,
" 0 / END OF TWO-TERMINAL DC DATA, BEGIN VSC DC LINE DATA"
)?;
for link in &network.hvdc.links {
let Some(vsc) = link.as_vsc() else {
continue;
};
let mdc = vsc.mode as u32;
writeln!(
out,
" '{}',{},{:.6},1,1.0,0,0.0",
sanitize_psse_name(&vsc.name),
mdc,
vsc.resistance_ohm
)?;
write_vsc_converter(&mut out, &vsc.converter1)?;
write_vsc_converter(&mut out, &vsc.converter2)?;
}
writeln!(
out,
" 0 / END OF VSC DC LINE DATA, BEGIN IMPEDANCE CORRECTION DATA"
)?;
for table in &network.metadata.impedance_corrections {
write!(out, " {}", table.number)?;
for &(t, f) in &table.entries {
write!(out, ",{:.6},{:.6}", t, f)?;
}
writeln!(out)?;
}
writeln!(
out,
" 0 / END OF IMPEDANCE CORRECTION DATA, BEGIN MULTI-TERMINAL DC DATA"
)?;
for dc_grid in &network.hvdc.dc_grids {
let dc_buses: Vec<_> = dc_grid.buses.iter().collect();
let converters: Vec<_> = dc_grid
.converters
.iter()
.filter_map(|converter| converter.as_lcc())
.collect();
if converters.is_empty() {
continue;
}
let branches: Vec<_> = dc_grid.branches.iter().collect();
let mut local_bus_number = std::collections::HashMap::new();
for (idx, bus) in dc_buses.iter().enumerate() {
local_bus_number.insert(bus.bus_id, (idx + 1) as u32);
}
let dc_voltage_kv = dc_buses.first().map(|bus| bus.base_kv_dc).unwrap_or(500.0);
writeln!(
out,
" '{}',{},{},{},{},{:.4},{:.4},{:.4}",
sanitize_psse_name(
dc_grid
.name
.as_deref()
.unwrap_or(&format!("DCGRID-{}", dc_grid.id))
),
converters.len(),
dc_buses.len(),
branches.len(),
1,
dc_voltage_kv,
0.0,
0.0
)?;
for converter in &converters {
writeln!(
out,
" {},{},{:.4},{:.4},{:.6},{:.6},{:.4},{:.6},{:.6},{:.6},{:.6},{:.6},{:.4},{:.4},{:.4},{}",
converter.ac_bus,
converter.n_bridges,
converter.alpha_max_deg,
converter.alpha_min_deg,
converter.commutation_resistance_ohm,
converter.commutation_reactance_ohm,
converter.base_voltage_kv,
converter.turns_ratio,
converter.tap_ratio,
converter.tap_max,
converter.tap_min,
converter.tap_step,
converter.scheduled_setpoint.abs(),
converter.power_share_percent,
converter.current_margin_percent,
match converter.role {
surge_network::network::LccDcConverterRole::Rectifier => 1,
surge_network::network::LccDcConverterRole::Inverter => 2,
}
)?;
}
for bus in &dc_buses {
let ac_bus = converters
.iter()
.find(|converter| converter.dc_bus == bus.bus_id)
.map(|converter| converter.ac_bus)
.unwrap_or(0);
let (area, zone) = if ac_bus > 0 {
network
.buses
.iter()
.find(|candidate| candidate.number == ac_bus)
.map(|candidate| (candidate.area, candidate.zone))
.unwrap_or((1, 1))
} else {
(1, 1)
};
let generated_name = format!("DC-{}", bus.bus_id);
let name = truncate_name(&generated_name, 12);
writeln!(
out,
" {},{},{},{},'{}',{},{:.6},{}",
local_bus_number[&bus.bus_id], ac_bus, area, zone, name, 0, bus.r_ground_ohm, 1
)?;
}
for (idx, branch) in branches.iter().enumerate() {
writeln!(
out,
" {},{},'{:2}',{},{:.6},{:.6}",
local_bus_number[&branch.from_bus],
local_bus_number[&branch.to_bus],
idx + 1,
1,
branch.r_ohm,
branch.l_mh
)?;
}
}
writeln!(
out,
" 0 / END OF MULTI-TERMINAL DC DATA, BEGIN MULTI-SECTION LINE DATA"
)?;
for ms in &network.metadata.multi_section_line_groups {
write!(
out,
" {},{},'{:2}',{}",
ms.from_bus, ms.to_bus, ms.id, ms.metered_end
)?;
for &dum in &ms.dummy_buses {
write!(out, ",{}", dum)?;
}
writeln!(out)?;
}
writeln!(out, " 0 / END OF MULTI-SECTION LINE DATA, BEGIN ZONE DATA")?;
for region in &network.metadata.regions {
let name = truncate_name(®ion.name, 12);
writeln!(out, " {},'{}'", region.number, name)?;
}
writeln!(out, " 0 / END OF ZONE DATA, BEGIN INTER-AREA TRANSFER DATA")?;
for xfer in &network.metadata.scheduled_area_transfers {
writeln!(
out,
" {},{},{},{:.4}",
xfer.from_area, xfer.to_area, xfer.id, xfer.p_transfer_mw
)?;
}
writeln!(
out,
" 0 / END OF INTER-AREA TRANSFER DATA, BEGIN OWNER DATA"
)?;
for owner in &network.metadata.owners {
let name = truncate_name(&owner.name, 12);
writeln!(out, " {},'{}'", owner.number, name)?;
}
writeln!(
out,
" 0 / END OF OWNER DATA, BEGIN FACTS CONTROL DEVICE DATA"
)?;
for f in &network.facts_devices {
let mode = f.mode as u32;
writeln!(
out,
" '{}',{},{},{},{:.4},{:.4},{:.6},{:.4},1.1,0.9,1.1,99999,99999,{:.6},100,1",
sanitize_psse_name(&f.name),
f.bus_from,
f.bus_to,
mode,
f.p_setpoint_mw,
f.q_setpoint_mvar,
f.voltage_setpoint_pu,
clamp_finite(f.q_max, 9999.0),
f.series_reactance_pu
)?;
}
writeln!(
out,
" 0 / END OF FACTS CONTROL DEVICE DATA, BEGIN SWITCHED SHUNT DATA"
)?;
write_switched_shunts(&mut out, network)?;
if ver >= 35 {
writeln!(
out,
" 0 / END OF SWITCHED SHUNT DATA, BEGIN GNE DEVICE DATA"
)?;
writeln!(
out,
" 0 / END OF GNE DEVICE DATA, BEGIN INDUCTION MACHINE DATA"
)?;
write_induction_machines(&mut out, network)?;
writeln!(
out,
" 0 / END OF INDUCTION MACHINE DATA, BEGIN SUBSTATION DATA"
)?;
write_substation_data(&mut out, network)?;
writeln!(out, " 0 / END OF SUBSTATION DATA")?;
} else {
writeln!(out, " 0 / END OF SWITCHED SHUNT DATA")?;
}
writeln!(out, "Q")?;
Ok(out)
}
fn write_dc_converter(
out: &mut String,
c: &surge_network::network::LccConverterTerminal,
) -> Result<(), PsseWriteError> {
let ic = if c.in_service { 1 } else { 0 };
writeln!(
out,
" {},{},{:.4},{:.4},{:.6},{:.6},{:.4},{:.6},{:.6},{:.6},{:.6},{:.6},{},0,0,'1 ',0",
c.bus,
c.n_bridges,
c.alpha_max,
c.alpha_min,
c.commutation_resistance_ohm,
c.commutation_reactance_ohm,
c.base_voltage_kv,
c.turns_ratio,
c.tap,
c.tap_max,
c.tap_min,
c.tap_step,
ic
)?;
Ok(())
}
fn write_vsc_converter(
out: &mut String,
c: &surge_network::network::VscConverterTerminal,
) -> Result<(), PsseWriteError> {
let state = if c.in_service { 1 } else { 0 };
let mode = c.control_mode as u32;
writeln!(
out,
" {},1,{},{:.4},{:.4},{:.4},{:.4},0,{:.4},{:.4},{:.4},{:.4},1,{}",
c.bus,
mode,
c.dc_setpoint,
c.ac_setpoint,
c.loss_constant_mw,
c.loss_linear,
c.q_max_mvar,
c.q_min_mvar,
c.voltage_max_pu,
c.voltage_min_pu,
state
)?;
Ok(())
}
fn write_switched_shunts(out: &mut String, network: &Network) -> Result<(), PsseWriteError> {
use std::collections::BTreeMap;
let mut groups: BTreeMap<u32, Vec<&SwitchedShunt>> = BTreeMap::new();
for ss in &network.controls.switched_shunts {
groups.entry(ss.bus).or_default().push(ss);
}
for group in groups.values() {
let first = group[0];
let bus_num = first.bus;
let vswhi = first.v_target + first.v_band / 2.0;
let vswlo = first.v_target - first.v_band / 2.0;
let swrem = if first.bus_regulated != first.bus {
first.bus_regulated
} else {
0
};
let mut binit = 0.0;
let mut blocks: Vec<(i32, f64)> = Vec::new();
for ss in group {
let b_mvar = ss.b_step * network.base_mva;
binit += ss.n_active_steps as f64 * b_mvar;
if ss.n_steps_cap > 0 {
blocks.push((ss.n_steps_cap, b_mvar));
}
if ss.n_steps_react > 0 {
blocks.push((ss.n_steps_react, -b_mvar));
}
}
write!(
out,
" {},1,0,1,{:.4},{:.4},{},100,'',{:.4}",
bus_num, vswhi, vswlo, swrem, binit
)?;
for (n, b) in &blocks {
write!(out, ",{},{:.4}", n, b)?;
}
writeln!(out)?;
}
Ok(())
}
fn truncate_name(name: &str, max_len: usize) -> &str {
let n = name.trim();
if n.len() > max_len { &n[..max_len] } else { n }
}
fn clamp_finite(v: f64, fallback: f64) -> f64 {
if !v.is_finite() || v >= f64::MAX / 2.0 || v <= f64::MIN / 2.0 {
fallback
} else {
v
}
}
fn sanitize_psse_name(name: &str) -> String {
name.chars()
.filter(|&c| c != '\'' && c != '"' && c != '\n')
.collect()
}
fn write_induction_machines(out: &mut String, network: &Network) -> Result<(), PsseWriteError> {
for m in &network.induction_machines {
let stat = if m.in_service { 1 } else { 0 };
writeln!(
out,
" {},'{:<2}',{},1,3,{},{},{},1,1,{:.4},{:.4},1,{:.4},{:.6},{:.6},{:.6},{:.6},{:.6},{:.6}",
m.bus,
m.id,
stat,
m.area,
m.zone,
m.owner,
m.mbase,
m.rate_kv,
m.pset,
m.h,
m.a,
m.b,
m.d,
m.e,
m.f_coeff
)?;
writeln!(
out,
" {:.6},{:.6},{:.6},{:.6},{:.6},{:.6},{:.6},{:.6},1.0,0.0,1.2,0.0,0.0,0.0,1.0",
m.ra, m.xa, m.xm, m.r1, m.x1, m.r2, m.x2, m.x3
)?;
}
Ok(())
}
fn write_substation_data(out: &mut String, network: &Network) -> Result<(), PsseWriteError> {
let Some(sm) = &network.topology else {
return Ok(());
};
use std::collections::HashMap;
let vl_to_sub: HashMap<&str, &str> = sm
.voltage_levels
.iter()
.map(|vl| (vl.id.as_str(), vl.substation_id.as_str()))
.collect();
let mut cn_by_sub: HashMap<&str, Vec<&surge_network::network::topology::ConnectivityNode>> =
HashMap::new();
for cn in &sm.connectivity_nodes {
let sub_id = vl_to_sub
.get(cn.voltage_level_id.as_str())
.copied()
.unwrap_or("");
cn_by_sub.entry(sub_id).or_default().push(cn);
}
let cn_to_sub: HashMap<&str, &str> = sm
.connectivity_nodes
.iter()
.map(|cn| {
let sub_id = vl_to_sub
.get(cn.voltage_level_id.as_str())
.copied()
.unwrap_or("");
(cn.id.as_str(), sub_id)
})
.collect();
let mut sw_by_sub: HashMap<&str, Vec<&surge_network::network::SwitchDevice>> = HashMap::new();
for sw in &sm.switches {
let sub_id = cn_to_sub.get(sw.cn1_id.as_str()).copied().unwrap_or("");
sw_by_sub.entry(sub_id).or_default().push(sw);
}
for sub in &sm.substations {
let isub: u32 = sub
.id
.strip_prefix("SUB_")
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let sub_name = truncate_name(&sub.name, 12);
writeln!(out, " {isub},'{sub_name}',0.0,0.0,1")?;
writeln!(out, " 0 / BEGIN SUBSTATION NODE DATA")?;
let sub_id_prefix = format!("SUB_{isub}_N");
if let Some(cns) = cn_by_sub.get(sub.id.as_str()) {
for cn in cns {
let inode: u32 = cn
.id
.strip_prefix(&sub_id_prefix)
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let node_name = truncate_name(&cn.name, 12);
writeln!(out, " {inode},'{node_name}',0,1,1.0,0.0")?;
}
}
writeln!(
out,
" 0 / END OF SUBSTATION NODE DATA, BEGIN SUBSTATION SWITCHING DEVICE DATA"
)?;
if let Some(switches) = sw_by_sub.get(sub.id.as_str()) {
for sw in switches {
let sw_name = truncate_name(&sw.name, 12);
let sw_type_code: u32 = match sw.switch_type {
surge_network::network::SwitchType::Breaker => 1,
surge_network::network::SwitchType::Disconnector => 2,
_ => 3,
};
let status = if sw.open { 0 } else { 1 };
writeln!(out, " 0,'{}',0,0,{},{}", sw_name, sw_type_code, status)?;
}
}
writeln!(
out,
" 0 / END OF SUBSTATION SWITCHING DEVICE DATA, BEGIN SUBSTATION TERMINAL DATA"
)?;
writeln!(out, " 0 / END OF SUBSTATION TERMINAL DATA")?;
}
Ok(())
}
fn format_bus_name(name: &str, number: u32) -> String {
let n = name.trim();
if n.is_empty() {
format!("BUS_{number:06}")
} else if n.len() > 12 {
n[..12].to_string()
} else {
n.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use surge_network::Network;
use surge_network::network::{Branch, Bus, BusType, Generator, Load};
fn simple_network() -> Network {
let mut net = Network::new("case9");
net.base_mva = 100.0;
let mut slack = Bus::new(1, BusType::Slack, 345.0);
slack.voltage_magnitude_pu = 1.04;
net.buses.push(slack);
let pq = Bus::new(2, BusType::PQ, 345.0);
net.buses.push(pq);
net.loads.push(Load::new(2, 125.0, 50.0));
net.generators.push(Generator::new(1, 72.3, 1.04));
net.branches
.push(Branch::new_line(1, 2, 0.01938, 0.05917, 0.0528));
net
}
#[test]
fn test_psse_header() {
let net = simple_network();
let s = to_string(&net, 33).unwrap();
assert!(s.contains("33"));
assert!(s.contains("END OF BUS DATA"));
assert!(s.contains("END OF GENERATOR DATA"));
assert!(s.contains("END OF BRANCH DATA"));
assert!(s.ends_with("Q\n") || s.ends_with("Q"));
}
#[test]
fn test_psse_bus_count() {
let net = simple_network();
let s = to_string(&net, 33).unwrap();
assert!(s.contains(" 1,") || s.contains("\n 1,"));
assert!(s.contains(" 2,") || s.contains("\n 2,"));
}
#[test]
fn test_psse_roundtrip() {
use crate::psse::parse_str;
let net = simple_network();
let s = to_string(&net, 33).unwrap();
let net2 = parse_str(&s).expect("round-trip parse failed");
assert_eq!(net2.n_buses(), net.n_buses());
assert_eq!(net2.generators.len(), 1);
}
#[test]
fn test_psse_load_id_and_owner_roundtrip() {
use crate::psse::parse_str;
use surge_network::network::OwnershipEntry;
let mut net = simple_network();
net.loads[0].id = "LD1".to_string();
net.loads[0].owners = vec![OwnershipEntry {
owner: 7,
fraction: 1.0,
}];
let s = to_string(&net, 33).unwrap();
assert!(s.contains("'LD1'"));
assert!(s.contains(",7,1"));
let parsed = parse_str(&s).expect("round-trip parse failed");
assert_eq!(parsed.loads.len(), 1);
assert_eq!(parsed.loads[0].id, "LD1");
assert_eq!(parsed.loads[0].owners.len(), 1);
assert_eq!(parsed.loads[0].owners[0].owner, 7);
}
#[test]
fn test_psse_file_write() {
let net = simple_network();
let tmp = std::env::temp_dir().join("surge_psse_writer_test.raw");
write_file(&net, &tmp, 33).unwrap();
let content = std::fs::read_to_string(&tmp).unwrap();
assert!(content.contains("END OF BUS DATA"));
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn test_default_version() {
let net = simple_network();
let s = to_string(&net, 0).unwrap();
assert!(s.contains("33"));
}
#[test]
fn test_machine_id_roundtrip() {
use crate::psse::parse_str;
let mut net = simple_network();
net.generators[0].machine_id = Some("G1".to_string());
let mut g2 = Generator::new(1, 50.0, 1.04);
g2.machine_id = Some("G2".to_string());
net.generators.push(g2);
let s = to_string(&net, 33).unwrap();
let net2 = parse_str(&s).expect("round-trip parse failed");
assert_eq!(net2.generators.len(), 2);
assert_eq!(net2.generators[0].machine_id.as_deref(), Some("G1"));
assert_eq!(net2.generators[1].machine_id.as_deref(), Some("G2"));
}
#[test]
fn test_circuit_id_roundtrip() {
use crate::psse::parse_str;
let mut net = simple_network();
net.branches[0].circuit = "1".to_string();
let mut br2 = Branch::new_line(1, 2, 0.02, 0.06, 0.05);
br2.circuit = "2".to_string();
net.branches.push(br2);
let s = to_string(&net, 33).unwrap();
let net2 = parse_str(&s).expect("round-trip parse failed");
assert_eq!(net2.branches.len(), 2);
assert_eq!(net2.branches[0].circuit, "1");
assert_eq!(net2.branches[1].circuit, "2");
}
#[test]
fn test_transformer_magnetizing_roundtrip() {
use crate::psse::parse_str;
let mut net = simple_network();
let mut xfmr = Branch::new_line(1, 2, 0.01, 0.1, 0.0);
xfmr.tap = 1.05;
xfmr.g_mag = 0.001;
xfmr.b_mag = -0.05;
xfmr.circuit = "1".to_string();
net.branches.push(xfmr);
let s = to_string(&net, 33).unwrap();
let net2 = parse_str(&s).expect("round-trip parse failed");
let xf = net2
.branches
.iter()
.find(|b| b.is_transformer())
.expect("transformer not found");
assert!((xf.g_mag - 0.001).abs() < 1e-5, "g_mag={}", xf.g_mag);
assert!((xf.b_mag - (-0.05)).abs() < 1e-5, "b_mag={}", xf.b_mag);
}
#[test]
fn test_area_schedule_roundtrip() {
use crate::psse::parse_str;
use surge_network::network::AreaSchedule;
let mut net = simple_network();
net.area_schedules.push(AreaSchedule {
number: 1,
slack_bus: 1,
p_desired_mw: 150.0,
p_tolerance_mw: 10.0,
name: "AREA1".to_string(),
});
let s = to_string(&net, 33).unwrap();
let net2 = parse_str(&s).expect("round-trip parse failed");
assert_eq!(net2.area_schedules.len(), 1);
assert_eq!(net2.area_schedules[0].number, 1);
assert_eq!(net2.area_schedules[0].slack_bus, 1);
assert!((net2.area_schedules[0].p_desired_mw - 150.0).abs() < 1e-2);
assert!(net2.area_schedules[0].name.contains("AREA1"));
}
#[test]
fn test_dc_line_roundtrip() {
use crate::psse::parse_str;
use surge_network::network::{LccConverterTerminal, LccHvdcControlMode, LccHvdcLink};
let mut net = simple_network();
net.hvdc.push_lcc_link(LccHvdcLink {
name: "HVDC1".to_string(),
mode: LccHvdcControlMode::PowerControl,
resistance_ohm: 5.0,
scheduled_setpoint: 500.0,
scheduled_voltage_kv: 400.0,
voltage_mode_switch_kv: 0.0,
compounding_resistance_ohm: 0.0,
current_margin_ka: 0.0,
meter: 'R',
voltage_min_kv: 0.0,
ac_dc_iteration_max: 20,
ac_dc_iteration_acceleration: 1.0,
rectifier: LccConverterTerminal {
bus: 1,
n_bridges: 2,
alpha_max: 90.0,
alpha_min: 5.0,
commutation_resistance_ohm: 0.5,
commutation_reactance_ohm: 10.0,
base_voltage_kv: 345.0,
turns_ratio: 1.0,
tap: 1.0,
tap_max: 1.1,
tap_min: 0.9,
tap_step: 0.00625,
in_service: true,
},
inverter: LccConverterTerminal {
bus: 2,
n_bridges: 2,
alpha_max: 90.0,
alpha_min: 5.0,
commutation_resistance_ohm: 0.5,
commutation_reactance_ohm: 10.0,
base_voltage_kv: 345.0,
turns_ratio: 1.0,
tap: 1.0,
tap_max: 1.1,
tap_min: 0.9,
tap_step: 0.00625,
in_service: true,
},
p_dc_min_mw: 0.0,
p_dc_max_mw: 0.0,
});
let s = to_string(&net, 33).unwrap();
let net2 = parse_str(&s).expect("round-trip parse failed");
let dc = net2.hvdc.links[0].as_lcc().expect("lcc link");
assert_eq!(net2.hvdc.links.len(), 1);
assert!(dc.name.contains("HVDC1"));
assert!((dc.resistance_ohm - 5.0).abs() < 1e-4);
assert!((dc.scheduled_setpoint - 500.0).abs() < 1e-2);
assert_eq!(dc.rectifier.bus, 1);
assert_eq!(dc.inverter.bus, 2);
}
#[test]
fn test_facts_roundtrip() {
use crate::psse::parse_str;
use surge_network::network::{FactsDevice, FactsMode};
let mut net = simple_network();
net.facts_devices.push(FactsDevice {
name: "SVC1".to_string(),
bus_from: 1,
bus_to: 0,
mode: FactsMode::ShuntOnly,
p_setpoint_mw: 0.0,
q_setpoint_mvar: 50.0,
voltage_setpoint_pu: 1.02,
q_max: 200.0,
series_reactance_pu: 0.05,
in_service: true,
..FactsDevice::default()
});
let s = to_string(&net, 33).unwrap();
let net2 = parse_str(&s).expect("round-trip parse failed");
assert_eq!(net2.facts_devices.len(), 1);
let f = &net2.facts_devices[0];
assert!(f.name.contains("SVC1"));
assert_eq!(f.bus_from, 1);
assert_eq!(f.mode, FactsMode::ShuntOnly);
assert!((f.voltage_setpoint_pu - 1.02).abs() < 1e-4);
}
#[test]
fn test_switched_shunt_roundtrip() {
use crate::psse::parse_str;
use surge_network::network::SwitchedShunt;
let mut net = simple_network();
net.controls.switched_shunts.push(SwitchedShunt {
id: "ssh_1".into(),
bus: 1,
bus_regulated: 1,
b_step: 0.5, n_steps_cap: 3,
n_steps_react: 0,
v_target: 1.0,
v_band: 0.10,
n_active_steps: 2,
});
let s = to_string(&net, 33).unwrap();
assert!(s.contains("SWITCHED SHUNT"), "section marker missing");
let net2 = parse_str(&s).expect("round-trip parse failed");
assert_eq!(
net2.controls.switched_shunts.len(),
1,
"expected 1 switched shunt, got {}",
net2.controls.switched_shunts.len()
);
let ss = &net2.controls.switched_shunts[0];
assert_eq!(ss.n_steps_cap, 3);
assert!(
(ss.b_step - 0.5).abs() < 0.01,
"b_step={}, expected ~0.5",
ss.b_step
);
assert_eq!(ss.n_active_steps, 2);
}
#[test]
fn test_region_roundtrip() {
use crate::psse::parse_str;
use surge_network::network::Region;
let mut net = simple_network();
net.metadata.regions.push(Region {
number: 1,
name: "NORTH".to_string(),
});
net.metadata.regions.push(Region {
number: 2,
name: "SOUTH".to_string(),
});
let s = to_string(&net, 33).unwrap();
let net2 = parse_str(&s).expect("round-trip parse failed");
assert_eq!(net2.metadata.regions.len(), 2);
assert_eq!(net2.metadata.regions[0].number, 1);
assert!(net2.metadata.regions[0].name.contains("NORTH"));
assert_eq!(net2.metadata.regions[1].number, 2);
assert!(net2.metadata.regions[1].name.contains("SOUTH"));
}
#[test]
fn test_owner_roundtrip() {
use crate::psse::parse_str;
use surge_network::network::Owner;
let mut net = simple_network();
net.metadata.owners.push(Owner {
number: 1,
name: "UTILITY_A".to_string(),
});
let s = to_string(&net, 33).unwrap();
let net2 = parse_str(&s).expect("round-trip parse failed");
assert_eq!(net2.metadata.owners.len(), 1);
assert_eq!(net2.metadata.owners[0].number, 1);
assert!(net2.metadata.owners[0].name.contains("UTILITY_A"));
}
#[test]
fn test_scheduled_area_transfer_roundtrip() {
use crate::psse::parse_str;
use surge_network::network::scheduled_area_transfer::ScheduledAreaTransfer;
let mut net = simple_network();
net.metadata
.scheduled_area_transfers
.push(ScheduledAreaTransfer {
from_area: 1,
to_area: 2,
id: 1,
p_transfer_mw: 250.0,
});
let s = to_string(&net, 33).unwrap();
let net2 = parse_str(&s).expect("round-trip parse failed");
assert_eq!(net2.metadata.scheduled_area_transfers.len(), 1);
let xfer = &net2.metadata.scheduled_area_transfers[0];
assert_eq!(xfer.from_area, 1);
assert_eq!(xfer.to_area, 2);
assert!((xfer.p_transfer_mw - 250.0).abs() < 1e-2);
}
#[test]
fn test_impedance_correction_roundtrip() {
use crate::psse::parse_str;
use surge_network::network::impedance_correction::ImpedanceCorrectionTable;
let mut net = simple_network();
net.metadata
.impedance_corrections
.push(ImpedanceCorrectionTable {
number: 1,
entries: vec![(0.9, 1.1), (1.0, 1.0), (1.1, 0.95)],
});
let s = to_string(&net, 33).unwrap();
let net2 = parse_str(&s).expect("round-trip parse failed");
assert_eq!(net2.metadata.impedance_corrections.len(), 1);
let table = &net2.metadata.impedance_corrections[0];
assert_eq!(table.number, 1);
assert_eq!(table.entries.len(), 3);
assert!((table.entries[0].0 - 0.9).abs() < 1e-4);
assert!((table.entries[0].1 - 1.1).abs() < 1e-4);
assert!((table.entries[2].1 - 0.95).abs() < 1e-4);
}
#[test]
fn test_multi_section_line_roundtrip() {
use crate::psse::parse_str;
use surge_network::network::multi_section_line::MultiSectionLineGroup;
let mut net = simple_network();
net.buses.push(Bus::new(3, BusType::PQ, 345.0));
net.metadata
.multi_section_line_groups
.push(MultiSectionLineGroup {
from_bus: 1,
to_bus: 2,
id: "1".to_string(),
metered_end: 1,
dummy_buses: vec![3],
});
let s = to_string(&net, 33).unwrap();
let net2 = parse_str(&s).expect("round-trip parse failed");
assert_eq!(net2.metadata.multi_section_line_groups.len(), 1);
let ms = &net2.metadata.multi_section_line_groups[0];
assert_eq!(ms.from_bus, 1);
assert_eq!(ms.to_bus, 2);
assert_eq!(ms.dummy_buses, vec![3]);
}
#[test]
fn test_all_sections_present_in_output() {
let net = simple_network();
let s = to_string(&net, 33).unwrap();
assert!(s.contains("END OF BUS DATA"));
assert!(s.contains("END OF LOAD DATA"));
assert!(s.contains("END OF FIXED SHUNT DATA"));
assert!(s.contains("END OF GENERATOR DATA"));
assert!(s.contains("END OF BRANCH DATA"));
assert!(s.contains("END OF TRANSFORMER DATA"));
assert!(s.contains("END OF AREA INTERCHANGE DATA"));
assert!(s.contains("END OF TWO-TERMINAL DC DATA"));
assert!(s.contains("END OF VSC DC LINE DATA"));
assert!(s.contains("END OF IMPEDANCE CORRECTION DATA"));
assert!(s.contains("END OF MULTI-TERMINAL DC DATA"));
assert!(s.contains("END OF MULTI-SECTION LINE DATA"));
assert!(s.contains("END OF ZONE DATA"));
assert!(s.contains("END OF INTER-AREA TRANSFER DATA"));
assert!(s.contains("END OF OWNER DATA"));
assert!(s.contains("END OF FACTS CONTROL DEVICE DATA"));
assert!(s.contains("END OF SWITCHED SHUNT DATA"));
}
#[test]
fn test_v35_sections_present() {
let net = simple_network();
let s = to_string(&net, 35).unwrap();
assert!(
s.contains("END OF SYSTEM SWITCHING DEVICE DATA"),
"v35 output missing System Switching Device section"
);
assert!(
s.contains("END OF GNE DEVICE DATA"),
"v35 output missing GNE section"
);
assert!(
s.contains("END OF INDUCTION MACHINE DATA"),
"v35 output missing Induction Machine section"
);
assert!(
s.contains("END OF SUBSTATION DATA"),
"v35 output missing Substation section"
);
}
#[test]
fn test_v33_no_v35_sections() {
let net = simple_network();
let s = to_string(&net, 33).unwrap();
assert!(
!s.contains("SYSTEM SWITCHING DEVICE"),
"v33 output should not contain System Switching Device section"
);
assert!(
!s.contains("GNE DEVICE"),
"v33 output should not contain GNE section"
);
assert!(
!s.contains("INDUCTION MACHINE"),
"v33 output should not contain Induction Machine section"
);
assert!(
!s.contains("SUBSTATION DATA"),
"v33 output should not contain Substation section"
);
}
}