use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write as _;
use std::sync::Arc;
use serde_json::Value;
use super::{
Conversion, branch_rating_set_drop_warning, jnum, sanitize_quoted,
warn_extra_branch_rating_sets,
};
use crate::network::{
Area, Branch, BranchCharging, BranchRatingSet, Bus, BusId, BusType, Extras, Generator, Hvdc,
Impedance, Load, LoadVoltageModel, Network, Shunt, ShuntBlock, SolverParams, SourceFormat,
SwitchedShuntControl, SwitchedShuntMode, Transformer3W, TransformerControl,
TransformerControlMode, Winding,
};
use crate::{Error, Result};
const FMT: &str = "PSS/E .raw";
const REV: u32 = 33;
const PSSE_EXTRA_BRANCH_RATINGS: usize = 9;
fn psse_extra_rating_name(slot: usize) -> String {
format!("RATE{}", slot + 4)
}
fn psse_extra_rating_slot(name: &str) -> Option<usize> {
let upper = name.trim().to_ascii_uppercase();
let suffix = upper
.strip_prefix("RATE")
.or_else(|| upper.strip_prefix("RATING"))?
.trim_start_matches([' ', '_']);
let n = suffix.parse::<usize>().ok()?;
(4..=12).contains(&n).then_some(n - 4)
}
fn read_extra_branch_ratings(
fields: &[String],
rating_start: usize,
named_record: bool,
) -> Result<Vec<BranchRatingSet>> {
if !named_record {
return Ok(Vec::new());
}
let mut ratings = Vec::new();
for slot in 0..PSSE_EXTRA_BRANCH_RATINGS {
let rate_mva = num_at(fields, rating_start + 3 + slot, 0.0)?;
if rate_mva.abs() > f64::EPSILON {
ratings.push(BranchRatingSet::new(psse_extra_rating_name(slot), rate_mva));
}
}
Ok(ratings)
}
fn psse_extra_rating_values(
branch: &Branch,
branch_index: usize,
warnings: &mut Vec<String>,
) -> [f64; PSSE_EXTRA_BRANCH_RATINGS] {
let mut values = [0.0; PSSE_EXTRA_BRANCH_RATINGS];
let mut used = [false; PSSE_EXTRA_BRANCH_RATINGS];
let mut deferred = Vec::new();
for rating in &branch.rating_sets {
if let Some(slot) = psse_extra_rating_slot(&rating.name) {
if !used[slot] {
values[slot] = rating.rate_mva;
used[slot] = true;
continue;
}
}
deferred.push(rating);
}
for rating in deferred {
if let Some(slot) = used.iter().position(|is_used| !*is_used) {
values[slot] = rating.rate_mva;
used[slot] = true;
warnings.push(branch_rating_set_rename_warning(
branch_index,
branch,
rating,
&psse_extra_rating_name(slot),
));
} else {
warnings.push(branch_rating_set_drop_warning(
"PSS/E v34/v35",
branch_index,
branch,
rating,
));
}
}
values
}
fn branch_rating_set_rename_warning(
branch_index: usize,
branch: &Branch,
rating: &BranchRatingSet,
emitted_name: &str,
) -> String {
format!(
"branch {} ({} to {}) rating set {}={} MVA emitted as {} in PSS/E v34/v35; rating set names outside RATE4-RATE12 are not preserved",
branch_index + 1,
branch.from,
branch.to,
rating.name,
rating.rate_mva,
emitted_name
)
}
fn warn_psse_extra_branch_ratings_dropped(net: &Network, warnings: &mut Vec<String>) {
warn_extra_branch_rating_sets("PSS/E v33", net, warnings);
}
const NAME_FORBIDDEN: &[char] = &['\'', '/'];
#[must_use]
pub fn write_psse(net: &Network) -> Conversion {
write_psse_rev(net, REV)
}
#[must_use]
#[expect(clippy::too_many_lines)]
pub fn write_psse_rev(net: &Network, rev: u32) -> Conversion {
let modern = rev >= 34;
let mut warnings = Vec::new();
let mut nonfinite = false;
let mut sanitized_quoted = 0usize;
let mut s = String::new();
let mut num = |x: f64| -> String {
if x.is_finite() {
let s = format!("{x}");
if s.bytes().all(|b| b.is_ascii_digit() || b == b'-') {
format!("{s}.0")
} else {
s
}
} else {
nonfinite = true;
let sentinel = if x > 0.0 {
1.0e10
} else if x < 0.0 {
-1.0e10
} else {
0.0
};
format!("{sentinel}.0")
}
};
let _ = writeln!(
s,
"0, {}, {rev}, 0, {}, {} / powerio export: {}",
net.base_mva,
i32::from(modern),
num(net.base_frequency),
net.name
);
let _ = writeln!(s, "{}", net.name);
let _ = writeln!(s);
if modern {
if let Some(sp) = &net.solver {
if let Some(t) = sp.zero_impedance_threshold {
let _ = writeln!(s, "GENERAL, THRSHZ={}", num(t));
}
let mut newton = Vec::new();
if let Some(t) = sp.newton_tolerance {
newton.push(format!("TOLN={}", num(t)));
}
if let Some(n) = sp.max_iterations {
newton.push(format!("ITMXN={n}"));
}
if !newton.is_empty() {
let _ = writeln!(s, "NEWTON, {}", newton.join(", "));
}
let flags: Vec<String> = [
("ACTAPS", sp.adjust_taps),
("AREAIN", sp.adjust_area_interchange),
("PHSHFT", sp.adjust_phase_shift),
("DCTAPS", sp.adjust_dc_taps),
("SWSHNT", sp.adjust_switched_shunt),
]
.into_iter()
.filter_map(|(name, v)| v.map(|b| format!("{name}={}", i32::from(b))))
.collect();
if !flags.is_empty() {
let _ = writeln!(s, "SOLVER, {}", flags.join(", "));
}
}
let _ = writeln!(s, "0 / END OF SYSTEM-WIDE DATA, BEGIN BUS DATA");
}
let mut bus_area: BTreeMap<BusId, (usize, usize)> = BTreeMap::new();
for b in &net.buses {
bus_area.insert(b.id, (b.area, b.zone));
let raw_name = b.name.as_deref().unwrap_or("");
let name = sanitize_quoted(raw_name, NAME_FORBIDDEN, ' ');
if matches!(name, std::borrow::Cow::Owned(_)) {
sanitized_quoted += 1;
}
let _ = writeln!(
s,
"{}, '{:<12}', {}, {}, {}, {}, 1, {}, {}, {}, {}, {}, {}",
b.id,
name,
num(b.base_kv),
ide(b.kind),
b.area,
b.zone,
num(b.vm),
num(b.va),
num(b.vmax),
num(b.vmin),
num(b.evhi.unwrap_or(b.vmax)),
num(b.evlo.unwrap_or(b.vmin))
);
}
let _ = writeln!(s, "0 / END OF BUS DATA, BEGIN LOAD DATA");
let mut load_ids: BTreeMap<BusId, BTreeSet<String>> = BTreeMap::new();
for l in &net.loads {
let (area, zone) = bus_area.get(&l.bus).copied().unwrap_or((1, 1));
let id = quoted_device_id(&l.extras, l.bus, &mut load_ids, &mut sanitized_quoted);
let (pl, ql, ip, iq, yp, yq) = load_components_for_write(l, &id, &mut warnings);
let owner = extra_i64(&l.extras, "psse_owner").unwrap_or(1);
let scal = typed_psse_scal(l, &id, &mut warnings)
.or_else(|| extra_i64(&l.extras, "psse_scal"))
.unwrap_or(1);
let intrpt = extra_i64(&l.extras, "psse_intrpt").unwrap_or(0);
let typed_load_type = l.voltage_model.as_ref().and_then(typed_psse_load_type);
if rev < 35 && typed_load_type.is_some() {
warnings.push(format!(
"PSS/E load at bus {} id {id:?}: load type requires revision 35; dropped",
l.bus
));
}
let modern_tail = if rev >= 35 {
let pdgen = extra_f64(&l.extras, "psse_pdgen").unwrap_or(0.0);
let qdgen = extra_f64(&l.extras, "psse_qdgen").unwrap_or(0.0);
let flagstatus = extra_i64(&l.extras, "psse_flagstatus").unwrap_or(0);
let raw_loadtype = typed_load_type.or_else(|| {
l.extras
.get("psse_loadtype")
.and_then(Value::as_str)
.map(str::to_owned)
});
let loadtype =
sanitize_quoted(raw_loadtype.as_deref().unwrap_or(""), NAME_FORBIDDEN, ' ');
if matches!(loadtype, std::borrow::Cow::Owned(_)) {
sanitized_quoted += 1;
}
format!(
", {}, {}, {flagstatus}, '{loadtype}'",
num(pdgen),
num(qdgen)
)
} else if modern {
let pdgen = extra_f64(&l.extras, "psse_pdgen").unwrap_or(0.0);
let qdgen = extra_f64(&l.extras, "psse_qdgen").unwrap_or(0.0);
let flagstatus = extra_i64(&l.extras, "psse_flagstatus").unwrap_or(0);
format!(", {}, {}, {flagstatus}", num(pdgen), num(qdgen))
} else {
String::new()
};
let _ = writeln!(
s,
"{}, '{id}', {}, {}, {}, {}, {}, {}, {}, {}, {}, {owner}, {scal}, {intrpt}{modern_tail}",
l.bus,
i32::from(l.in_service),
area,
zone,
num(pl),
num(ql),
num(ip),
num(iq),
num(yp),
num(yq)
);
}
let _ = writeln!(s, "0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA");
let mut shunt_ids: BTreeMap<BusId, BTreeSet<String>> = BTreeMap::new();
for sh in net.shunts.iter().filter(|s| s.control.is_none()) {
let id = quoted_device_id(&sh.extras, sh.bus, &mut shunt_ids, &mut sanitized_quoted);
let _ = writeln!(
s,
"{}, '{id}', {}, {}, {}",
sh.bus,
i32::from(sh.in_service),
num(sh.g),
num(sh.b)
);
}
let _ = writeln!(s, "0 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA");
let mut gen_ids: BTreeMap<BusId, u32> = BTreeMap::new();
for g in &net.generators {
let id = positional_id(g.bus, &mut gen_ids);
let ireg = g.regulated_bus.map_or(0, |b| b.0);
let (nreg, baslod) = if rev >= 35 { (" 0,", " 0,") } else { ("", "") };
let _ = writeln!(
s,
"{}, '{id}', {}, {}, {}, {}, {}, {},{nreg} {}, 0, 1, 0, 0, 1, {}, 100, {}, {},{baslod} 1, 1",
g.bus,
num(g.pg),
num(g.qg),
num(g.qmax),
num(g.qmin),
num(g.vg),
ireg,
num(g.mbase),
i32::from(g.in_service),
num(g.pmax),
num(g.pmin)
);
}
let _ = writeln!(s, "0 / END OF GENERATOR DATA, BEGIN BRANCH DATA");
let mut branch_ids: BTreeMap<(BusId, BusId), BTreeSet<String>> = BTreeMap::new();
for (branch_index, br) in net
.branches
.iter()
.enumerate()
.filter(|(_, b)| !b.is_transformer())
{
let ckt = quoted_circuit_id(
br.extras.get("id").and_then(Value::as_str),
(br.from, br.to),
&mut branch_ids,
&mut sanitized_quoted,
);
let charging = br.terminal_charging();
let b_total = charging.total_b();
let b_mid = b_total / 2.0;
let bi = charging.b_fr - b_mid;
let bj = charging.b_to - b_mid;
if modern {
let extra_ratings = psse_extra_rating_values(br, branch_index, &mut warnings);
let _ = writeln!(
s,
"{}, {}, '{ckt}', {}, {}, {}, ' ', {}, {}, {}, \
{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, 1, 0, 1, 1",
br.from,
br.to,
num(br.r),
num(br.x),
num(b_total),
num(br.rate_a),
num(br.rate_b),
num(br.rate_c),
num(extra_ratings[0]),
num(extra_ratings[1]),
num(extra_ratings[2]),
num(extra_ratings[3]),
num(extra_ratings[4]),
num(extra_ratings[5]),
num(extra_ratings[6]),
num(extra_ratings[7]),
num(extra_ratings[8]),
num(charging.g_fr),
num(bi),
num(charging.g_to),
num(bj),
i32::from(br.in_service)
);
} else {
let _ = writeln!(
s,
"{}, {}, '{ckt}', {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, 1, 0, 1, 1",
br.from,
br.to,
num(br.r),
num(br.x),
num(b_total),
num(br.rate_a),
num(br.rate_b),
num(br.rate_c),
num(charging.g_fr),
num(bi),
num(charging.g_to),
num(bj),
i32::from(br.in_service)
);
}
}
let _ = writeln!(s, "0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA");
for (branch_index, br) in net
.branches
.iter()
.enumerate()
.filter(|(_, b)| b.is_transformer())
{
let charging = br.terminal_charging();
let _ = writeln!(
s,
"{}, {}, 0, '1', 1, 1, 1, {}, {}, 2, ' ', {}, 1, 1, 0, 1, 0, 1, 0, 1, ' '",
br.from,
br.to,
num(charging.total_g()),
num(charging.total_b()),
i32::from(br.in_service)
);
let ctl = br.control.as_ref();
let sbase = ctl
.filter(|c| c.mva_base > 0.0)
.map_or(net.base_mva, |c| c.mva_base);
let cod = ctl.map_or(0, |c| mode_to_cod(c.mode));
let cont = ctl.and_then(|c| c.controlled_bus).map_or(0, |b| b.0);
let (rma, rmi, vma, vmi, ntp) = ctl.map_or((1.1, 0.9, 1.1, 0.9, 33), |c| {
(c.tap_max, c.tap_min, c.band_max, c.band_min, c.ntp)
});
let _ = writeln!(s, "{}, {}, {}", num(br.r), num(br.x), num(sbase));
if modern {
let extra_ratings = psse_extra_rating_values(br, branch_index, &mut warnings);
let _ = writeln!(
s,
"{}, 0, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, \
{cod}, {cont}, 0, {}, {}, {}, {}, {ntp}, 0, 0, 0, 0",
num(br.effective_tap()),
num(br.shift),
num(br.rate_a),
num(br.rate_b),
num(br.rate_c),
num(extra_ratings[0]),
num(extra_ratings[1]),
num(extra_ratings[2]),
num(extra_ratings[3]),
num(extra_ratings[4]),
num(extra_ratings[5]),
num(extra_ratings[6]),
num(extra_ratings[7]),
num(extra_ratings[8]),
num(rma),
num(rmi),
num(vma),
num(vmi)
);
} else {
let _ = writeln!(
s,
"{}, 0, {}, {}, {}, {}, {cod}, {cont}, {}, {}, {}, {}, {ntp}, 0, 0, 0, 0",
num(br.effective_tap()),
num(br.shift),
num(br.rate_a),
num(br.rate_b),
num(br.rate_c),
num(rma),
num(rmi),
num(vma),
num(vmi)
);
}
let _ = writeln!(s, "1.0, 0");
}
for t in &net.transformers_3w {
let raw_name = t.name.as_deref().unwrap_or("");
let name = sanitize_quoted(raw_name, NAME_FORBIDDEN, ' ');
if matches!(name, std::borrow::Cow::Owned(_)) {
sanitized_quoted += 1;
}
let _ = writeln!(
s,
"{}, {}, {}, '1', 1, 1, 1, {}, {}, 2, '{:<12}', {}, 1, 1, 0, 1, 0, 1, 0, 1, ' '",
t.windings[0].bus,
t.windings[1].bus,
t.windings[2].bus,
num(t.mag_g),
num(t.mag_b),
name,
i32::from(t.in_service)
);
let [z12, z23, z31] = t.z;
let _ = writeln!(
s,
"{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}",
num(z12.r),
num(z12.x),
num(z12.base_mva),
num(z23.r),
num(z23.x),
num(z23.base_mva),
num(z31.r),
num(z31.x),
num(z31.base_mva),
num(t.star_vm),
num(t.star_va)
);
for w in &t.windings {
if modern {
let _ = writeln!(
s,
"{}, {}, {}, {}, {}, {}, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, \
0, 0, 0, 1.1, 0.9, 1.1, 0.9, 33, 0, 0, 0, 0",
num(w.tap),
num(w.nominal_kv),
num(w.shift),
num(w.rate_a),
num(w.rate_b),
num(w.rate_c)
);
} else {
let _ = writeln!(
s,
"{}, {}, {}, {}, {}, {}, 0, 0, 1.1, 0.9, 1.1, 0.9, 33, 0, 0, 0, 0",
num(w.tap),
num(w.nominal_kv),
num(w.shift),
num(w.rate_a),
num(w.rate_b),
num(w.rate_c)
);
}
}
}
let _ = writeln!(s, "0 / END OF TRANSFORMER DATA, BEGIN AREA DATA");
for a in &net.areas {
let raw_name = a.name.as_deref().unwrap_or("");
let name = sanitize_quoted(raw_name, NAME_FORBIDDEN, ' ');
if matches!(name, std::borrow::Cow::Owned(_)) {
sanitized_quoted += 1;
}
let _ = writeln!(
s,
"{}, {}, {}, {}, '{:<12}'",
a.number,
a.slack_bus.map_or(0, |b| b.0),
num(a.net_interchange),
num(a.tolerance),
name
);
}
let _ = writeln!(s, "{}", EMPTY_SECTIONS[0]);
for (i, dc) in net.hvdc.iter().enumerate() {
let raw_name = dc_str(&dc.extras, "psse_dc_name").unwrap_or_else(|| format!("DC{}", i + 1));
let name = sanitize_quoted(&raw_name, NAME_FORBIDDEN, ' ');
if matches!(name, std::borrow::Cow::Owned(_)) {
sanitized_quoted += 1;
}
let name = format!("'{name}'");
let mdc = if dc.in_service {
dc_int(&dc.extras, "psse_dc_mdc").unwrap_or(1)
} else {
0
};
let rdc = dc_f64(&dc.extras, "psse_dc_rdc").unwrap_or(0.0);
let vschd = dc_f64(&dc.extras, "psse_dc_vschd").unwrap_or(0.0);
let l1_tail = dc_tail(
&dc.extras,
"psse_dc_control_tail",
"0.0, 0.0, 0.0, 'I', 0.0, 20, 1.0",
);
let rect_tail = dc_tail(&dc.extras, "psse_dc_rectifier_tail", DEFAULT_CONVERTER_TAIL);
let inv_tail = dc_tail(&dc.extras, "psse_dc_inverter_tail", DEFAULT_CONVERTER_TAIL);
let _ = writeln!(
s,
"{name}, {mdc}, {}, {}, {}, {l1_tail}",
num(rdc),
num(dc.pf),
num(vschd)
);
let _ = writeln!(s, "{}, {rect_tail}", dc.from);
let _ = writeln!(s, "{}, {inv_tail}", dc.to);
}
for line in &EMPTY_SECTIONS[1..=9] {
let _ = writeln!(s, "{line}");
}
let mut sw_ids: BTreeMap<BusId, BTreeSet<String>> = BTreeMap::new();
for sh in net.shunts.iter().filter(|s| s.control.is_some()) {
let Some(c) = sh.control.as_ref() else {
continue;
};
let swrem = c.control_bus.map_or(0, |b| b.0);
let mut blocks = String::new();
for blk in &c.blocks {
if rev >= 35 {
let _ = write!(blocks, ", 1, {}, {}", blk.steps, num(blk.b));
} else {
let _ = write!(blocks, ", {}, {}", blk.steps, num(blk.b));
}
}
if rev >= 35 {
let id = quoted_device_id(&sh.extras, sh.bus, &mut sw_ids, &mut sanitized_quoted);
let _ = writeln!(
s,
"{}, '{id}', {}, 0, {}, {}, {}, {swrem}, 0, {}, '', {}{blocks}",
sh.bus,
mode_to_modsw(c.mode),
i32::from(sh.in_service),
num(c.vhigh),
num(c.vlow),
num(c.rmpct),
num(sh.b)
);
} else {
let _ = writeln!(
s,
"{}, {}, 0, {}, {}, {}, {swrem}, {}, '', {}{blocks}",
sh.bus,
mode_to_modsw(c.mode),
i32::from(sh.in_service),
num(c.vhigh),
num(c.vlow),
num(c.rmpct),
num(sh.b)
);
}
}
for line in &EMPTY_SECTIONS[10..] {
let _ = writeln!(s, "{line}");
}
let _ = writeln!(s, "Q");
if net
.hvdc
.iter()
.any(|d| !d.extras.contains_key("psse_dc_name"))
{
warnings.push(
"DC line converter detail (firing angles, converter transformer taps, reactive \
output) defaulted: PSS/E two-terminal DC is written from the power setpoint and \
line resistance only"
.into(),
);
}
if !net.storage.is_empty() {
warnings.push(format!(
"{} storage unit(s) dropped: PSS/E has no storage record",
net.storage.len()
));
}
if net.generators.iter().any(|g| g.cost.is_some()) {
warnings.push("generator cost curves dropped: PSS/E .raw has no cost data".into());
}
if net.branches.iter().any(Branch::has_angle_limits) {
warnings.push(
"branch angle limits (angmin/angmax) dropped: PSS/E branch records carry none".into(),
);
}
let current_ratings = net
.branches
.iter()
.filter(|b| b.current_ratings.is_some())
.count();
if current_ratings > 0 {
warnings.push(format!(
"{current_ratings} branch current rating record(s) dropped: PSS/E branch ratings are MVA ratings"
));
}
if !modern {
warn_psse_extra_branch_ratings_dropped(net, &mut warnings);
}
let branch_solutions = net.branches.iter().filter(|b| b.solution.is_some()).count();
if branch_solutions > 0 {
warnings.push(format!(
"{branch_solutions} branch solution value set(s) dropped: PSS/E RAW power flow result fields are not written"
));
}
let transformer_terminal_shunts = net
.branches
.iter()
.filter(|b| {
b.is_transformer()
&& b.charging
.is_some_and(|c| c.g_to.abs() > f64::EPSILON || c.b_to.abs() > f64::EPSILON)
})
.count();
if transformer_terminal_shunts > 0 {
warnings.push(format!(
"{transformer_terminal_shunts} transformer terminal admittance record(s) collapsed to magnetizing admittance: PSS/E transformer records cannot preserve terminal side assignment"
));
}
if net.generators.iter().any(Generator::has_caps) {
warnings.push(
"generator ramp/capability columns dropped: PSS/E .raw has no equivalent fields".into(),
);
}
if nonfinite {
warnings.push("non-finite values written as ±1e10 sentinels (PSS/E has no Inf/NaN)".into());
}
if sanitized_quoted > 0 {
warnings.push(format!(
"{sanitized_quoted} quoted PSS/E field(s) contained a quote or '/' that would \
corrupt a record; replaced with spaces"
));
}
Conversion { text: s, warnings }
}
fn ide(kind: BusType) -> u8 {
kind as u8 }
fn quoted_device_id(
extras: &Extras,
bus: BusId,
used: &mut BTreeMap<BusId, BTreeSet<String>>,
sanitized_quoted: &mut usize,
) -> String {
quoted_circuit_id(
extras.get("id").and_then(Value::as_str),
bus,
used,
sanitized_quoted,
)
}
fn quoted_circuit_id<K: Ord + Clone>(
preferred: Option<&str>,
key: K,
used: &mut BTreeMap<K, BTreeSet<String>>,
sanitized_quoted: &mut usize,
) -> String {
let sanitized = preferred.map(|id| {
let cleaned = sanitize_quoted(id, NAME_FORBIDDEN, ' ');
if matches!(cleaned, std::borrow::Cow::Owned(_)) {
*sanitized_quoted += 1;
}
cleaned.into_owned()
});
super::allocate_circuit_id(sanitized.as_deref(), key, used)
}
fn positional_id(bus: BusId, counters: &mut BTreeMap<BusId, u32>) -> String {
let n = counters.entry(bus).or_insert(0);
*n += 1;
n.to_string()
}
const DEFAULT_CONVERTER_TAIL: &str =
"1, 15.0, 5.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.5, 0.51, 0.00625, 0, 0, 0, '1', 0.0";
const EMPTY_SECTIONS: [&str; 13] = [
"0 / END OF AREA DATA, BEGIN TWO-TERMINAL DC DATA",
"0 / END OF TWO-TERMINAL DC DATA, BEGIN VSC DC LINE DATA",
"0 / END OF VSC DC LINE DATA, BEGIN IMPEDANCE CORRECTION DATA",
"0 / END OF IMPEDANCE CORRECTION DATA, BEGIN MULTI-TERMINAL DC DATA",
"0 / END OF MULTI-TERMINAL DC DATA, BEGIN MULTI-SECTION LINE DATA",
"0 / END OF MULTI-SECTION LINE DATA, BEGIN ZONE DATA",
"0 / END OF ZONE DATA, BEGIN INTER-AREA TRANSFER DATA",
"0 / END OF INTER-AREA TRANSFER DATA, BEGIN OWNER DATA",
"0 / END OF OWNER DATA, BEGIN FACTS DEVICE DATA",
"0 / END OF FACTS DEVICE DATA, BEGIN SWITCHED SHUNT DATA",
"0 / END OF SWITCHED SHUNT DATA, BEGIN GNE DEVICE DATA",
"0 / END OF GNE DEVICE DATA, BEGIN INDUCTION MACHINE DATA",
"0 / END OF INDUCTION MACHINE DATA",
];
pub fn parse_psse(content: &str) -> Result<Network> {
let mut warnings = Vec::new();
parse_psse_source(Arc::new(content.to_owned()), None, &mut warnings)
}
pub(crate) fn header_rev(source: &str) -> u32 {
let Some(header) = source
.lines()
.map(str::trim)
.find(|line| !line.is_empty() && !is_comment(line))
else {
return 33;
};
fields(header)
.get(2)
.and_then(|f| f.parse::<f64>().ok())
.filter(|v| v.is_finite() && *v >= 0.0)
.map_or(33, |v| v as u32)
}
#[expect(clippy::too_many_lines)]
pub(crate) fn parse_psse_source(
source: Arc<String>,
name_hint: Option<&str>,
warnings: &mut Vec<String>,
) -> Result<Network> {
let content: &str = &source;
let mut lines = content.lines();
let header = lines
.by_ref()
.find(|line| {
let line = line.trim();
!line.is_empty() && !is_comment(line)
})
.ok_or_else(|| Error::FormatRead {
format: FMT,
message: "empty file".into(),
})?;
let header_fields = fields(header);
let base_mva = header_fields
.get(1)
.and_then(|f| f.parse::<f64>().ok())
.ok_or_else(|| Error::FormatRead {
format: FMT,
message: "missing SBASE in header".into(),
})?;
let raw_rev = header_fields
.get(2)
.and_then(|f| f.parse::<f64>().ok())
.filter(|v| v.is_finite() && *v >= 0.0)
.map_or(33, |v| v as u32);
let base_frequency = header_fields
.get(5)
.and_then(|f| f.parse::<f64>().ok())
.filter(|v| v.is_finite() && *v > 0.0)
.unwrap_or(crate::network::DEFAULT_BASE_FREQUENCY);
let title = lines.next().unwrap_or("").trim();
let name = if title.is_empty() {
name_hint.unwrap_or("case").to_string()
} else {
title.to_string()
};
lines.next();
let mut buses = Vec::new();
let mut loads = Vec::new();
let mut shunts = Vec::new();
let mut generators = Vec::new();
let mut branches = Vec::new();
let mut transformers_3w = Vec::new();
let mut hvdc = Vec::new();
let mut areas = Vec::new();
let mut solver = SolverParams::default();
let mut bus_base_kv: BTreeMap<BusId, f64> = BTreeMap::new();
let mut unmodeled_sections: BTreeMap<String, usize> = BTreeMap::new();
let mut section = Section::Bus;
let mut saw_bus_marker = false;
let mut skipped_section_name: Option<String> = None;
let mut lines = lines.peekable();
while let Some(raw) = lines.next() {
let line = raw.trim();
if line.is_empty() {
continue;
}
if is_comment(line) {
continue;
}
if line == "Q" {
break;
}
if is_terminator(line) {
let next_section = section_after_marker(line);
skipped_section_name =
introduced_section_name(line).filter(|_| matches!(next_section, Section::Skip));
section = next_section;
saw_bus_marker |= matches!(section, Section::Bus);
continue;
}
let f = fields(line);
match section {
Section::Bus if !saw_bus_marker && buses.is_empty() && is_system_wide_record(&f) => {
section = Section::SystemWide;
parse_solver_line(&f, &mut solver);
}
Section::Bus => {
let bus = read_bus(&f)?;
bus_base_kv.insert(bus.id, bus.base_kv);
buses.push(bus);
}
Section::Load => loads.push(read_load(&f, raw_rev, warnings)?),
Section::FixedShunt => shunts.push(read_shunt(&f)?),
Section::SwitchedShunt => shunts.push(read_switched_shunt(&f, raw_rev)?),
Section::Generator => generators.push(read_gen(&f, raw_rev)?),
Section::Branch => branches.push(read_branch(&f, raw_rev)?),
Section::Transformer => {
let two_winding = int_at(&f, 2, 0)? == 0;
let l2 = next_continuation_line(
&mut lines,
"transformer",
"transformer impedance line",
)?;
let l3 = next_continuation_line(&mut lines, "transformer", "winding data line 1")?;
let l4 = next_continuation_line(&mut lines, "transformer", "winding data line 2")?;
if two_winding {
if int_at(&f, 6, 1)? != 1 && num_at(&f, 8, 0.0)? != 0.0 {
warnings.push(format!(
"transformer {}-{}: magnetizing data with CM != 1 dropped \
(only CM = 1 p.u. susceptance is read as branch charging)",
f.first().map_or("?", String::as_str),
f.get(1).map_or("?", String::as_str),
));
}
branches.push(read_transformer(
&f,
&fields(l2),
&fields(l3),
&fields(l4),
raw_rev,
base_mva,
&bus_base_kv,
warnings,
)?);
} else {
let l5 =
next_continuation_line(&mut lines, "transformer", "winding data line 3")?;
transformers_3w.push(read_transformer_3w(
&f,
&fields(l2),
&fields(l3),
&fields(l4),
&fields(l5),
base_mva,
&bus_base_kv,
warnings,
)?);
}
}
Section::TwoTerminalDc => {
let rectifier =
next_continuation_line(&mut lines, "two-terminal DC", "rectifier line")?;
let inverter =
next_continuation_line(&mut lines, "two-terminal DC", "inverter line")?;
hvdc.push(read_dc_line(&f, &fields(rectifier), &fields(inverter))?);
}
Section::Area => areas.push(read_area(&f)?),
Section::SystemWide => parse_solver_line(&f, &mut solver),
Section::Skip => {
if let Some(name) = skipped_section_name.as_ref() {
*unmodeled_sections.entry(name.clone()).or_default() += 1;
}
}
}
}
warn_unmodeled_sections(unmodeled_sections, warnings);
let mut net = Network {
name,
base_mva,
base_frequency,
buses,
loads,
shunts,
branches,
switches: Vec::new(),
generators,
storage: Vec::new(),
hvdc,
transformers_3w,
areas,
solver: (!solver.is_empty()).then_some(solver),
source_format: SourceFormat::Psse,
source: Some(source),
};
drop_stale_control_pointers(&mut net, warnings);
net.check_references(FMT)?;
Ok(net)
}
#[derive(Clone, Copy)]
enum Section {
Bus,
Load,
FixedShunt,
SwitchedShunt,
Generator,
Branch,
Transformer,
TwoTerminalDc,
Area,
SystemWide,
Skip,
}
fn section_after_marker(line: &str) -> Section {
match introduced_section_name(line).as_deref() {
Some("BUS") => Section::Bus,
Some("LOAD") => Section::Load,
Some("FIXED SHUNT") => Section::FixedShunt,
Some("SWITCHED SHUNT") => Section::SwitchedShunt,
Some("GENERATOR" | "GEN") => Section::Generator,
Some("BRANCH") => Section::Branch,
Some("TRANSFORMER") => Section::Transformer,
Some("TWO-TERMINAL DC" | "TWO TERMINAL DC" | "2-TERMINAL DC" | "2 TERMINAL DC") => {
Section::TwoTerminalDc
}
Some("AREA" | "AREA INTERCHANGE") => Section::Area,
_ => Section::Skip,
}
}
fn is_terminator(line: &str) -> bool {
fields(line).first().map(String::as_str) == Some("0")
}
fn next_continuation_line<'a>(
lines: &mut std::iter::Peekable<std::str::Lines<'a>>,
record: &str,
expected: &str,
) -> Result<&'a str> {
for line in lines.by_ref().map(str::trim) {
if line.is_empty() || is_comment(line) {
continue;
}
if line.eq_ignore_ascii_case("q") || is_section_marker(line) || is_bare_terminator(line) {
return Err(Error::FormatRead {
format: FMT,
message: format!(
"PSS/E {record} record ended before {expected}: found section terminator `{line}`"
),
});
}
return Ok(line);
}
Err(Error::FormatRead {
format: FMT,
message: format!("PSS/E {record} record ended before {expected}"),
})
}
fn is_bare_terminator(line: &str) -> bool {
let f = fields(line);
f.len() == 1 && f.first().map(String::as_str) == Some("0")
}
fn transformer_basis_codes(f: &[String]) -> Result<(i64, i64)> {
let cw = num_at(f, 4, 1.0)?;
if cw.fract() != 0.0 {
return Err(bad_field(4, f.get(4).map_or("", String::as_str)));
}
let cz = num_at(f, 5, 1.0)?;
if cz.fract() != 0.0 {
return Err(bad_field(5, f.get(5).map_or("", String::as_str)));
}
#[allow(clippy::cast_possible_truncation)]
Ok((cw as i64, cz as i64))
}
fn transformer_label(f: &[String]) -> String {
let i = f.first().map_or("?", String::as_str);
let j = f.get(1).map_or("?", String::as_str);
let k = f.get(2).map_or("?", String::as_str);
let id = f.get(3).map_or("", String::as_str);
format!("{i}-{j}-{k} id {id:?}")
}
#[expect(clippy::too_many_arguments)]
fn convert_transformer_impedance(
r: f64,
x: f64,
sbase: f64,
system_base: f64,
cz: i64,
label: &str,
pair: &str,
warnings: &mut Vec<String>,
) -> (f64, f64) {
let base_ok = sbase.is_finite() && sbase > 0.0;
match cz {
1 => (r, x),
2 => {
if base_ok {
let scale = system_base / sbase;
(r * scale, x * scale)
} else {
warnings.push(format!(
"PSS/E transformer {label} pair {pair}: CZ=2 impedance has invalid SBASE {sbase}; read as system-base p.u."
));
(r, x)
}
}
3 => {
if !base_ok {
warnings.push(format!(
"PSS/E transformer {label} pair {pair}: CZ=3 impedance has invalid SBASE {sbase}; read as system-base p.u."
));
return (r, x);
}
let r_pair = (r / 1_000_000.0) / sbase;
let z_pair = x.abs();
let x_pair = (z_pair.mul_add(z_pair, -(r_pair * r_pair)))
.max(0.0)
.sqrt()
.copysign(x);
let scale = system_base / sbase;
(r_pair * scale, x_pair * scale)
}
other => {
warnings.push(format!(
"PSS/E transformer {label} pair {pair}: unsupported CZ={other}; read impedance as system-base p.u."
));
(r, x)
}
}
}
fn default_windv(cw: i64, bus: BusId, bus_base_kv: &BTreeMap<BusId, f64>) -> f64 {
if cw == 2 {
bus_base_kv
.get(&bus)
.copied()
.filter(|v| *v > 0.0)
.unwrap_or(1.0)
} else {
1.0
}
}
fn winding_ratio(
w: &[String],
bus: BusId,
cw: i64,
bus_base_kv: &BTreeMap<BusId, f64>,
label: &str,
winding: &str,
warnings: &mut Vec<String>,
) -> Result<f64> {
let windv = num_at(w, 0, default_windv(cw, bus, bus_base_kv))?;
let nomv = num_at(w, 1, 0.0)?;
let base_kv = bus_base_kv.get(&bus).copied().unwrap_or(0.0);
let needs_base = matches!(cw, 2 | 3);
if needs_base && !(base_kv.is_finite() && base_kv > 0.0) {
warnings.push(format!(
"PSS/E transformer {label} {winding}: CW={cw} needs a positive bus base kV for bus {bus}; read WINDV as a p.u. tap ratio"
));
return Ok(windv);
}
match cw {
1 => Ok(windv),
2 => Ok(windv / base_kv),
3 => {
let nominal = if nomv.is_finite() && nomv > 0.0 {
nomv
} else {
base_kv
};
Ok(windv * nominal / base_kv)
}
other => {
warnings.push(format!(
"PSS/E transformer {label} {winding}: unsupported CW={other}; read WINDV as a p.u. tap ratio"
));
Ok(windv)
}
}
}
#[expect(clippy::too_many_arguments)]
fn two_winding_tap(
l1: &[String],
l3: &[String],
l4: &[String],
from: BusId,
to: BusId,
cw: i64,
bus_base_kv: &BTreeMap<BusId, f64>,
warnings: &mut Vec<String>,
) -> Result<f64> {
let label = transformer_label(l1);
let ratio1 = winding_ratio(l3, from, cw, bus_base_kv, &label, "winding 1", warnings)?;
let ratio2 = winding_ratio(l4, to, cw, bus_base_kv, &label, "winding 2", warnings)?;
if ratio2.abs() <= f64::EPSILON {
warnings.push(format!(
"PSS/E transformer {label}: winding 2 ratio is zero; used winding 1 ratio as the branch tap"
));
Ok(ratio1)
} else {
Ok(ratio1 / ratio2)
}
}
fn is_section_marker(line: &str) -> bool {
if !is_terminator(line) {
return false;
}
let u = line.to_ascii_uppercase();
u.contains("END OF") || u.contains("BEGIN ") || u.contains("START OF ")
}
fn introduced_section_name(line: &str) -> Option<String> {
let u = line.to_ascii_uppercase();
let (start, prefix_len) = u
.find("BEGIN ")
.map(|idx| (idx, "BEGIN ".len()))
.or_else(|| u.find("START OF ").map(|idx| (idx, "START OF ".len())))?;
let start = start + prefix_len;
let rest = &u[start..];
let end = rest.find(" DATA")?;
Some(rest[..end].trim().to_string())
}
fn warn_unmodeled_sections(totals: BTreeMap<String, usize>, warnings: &mut Vec<String>) {
for (name, rows) in totals {
warnings.push(format!(
"PSS/E {name} section ({rows} record line(s)) is not modeled: preserved only in a \
same-format .raw echo, dropped on any other write"
));
}
}
fn drop_stale_control_pointers(net: &mut Network, warnings: &mut Vec<String>) {
let bus_ids: BTreeSet<BusId> = net.buses.iter().map(|b| b.id).collect();
let missing = |bus: BusId| !bus_ids.contains(&bus);
for (idx, g) in net.generators.iter_mut().enumerate() {
let Some(bus) = g.regulated_bus.filter(|b| missing(*b)) else {
continue;
};
warnings.push(format!(
"PSS/E GENERATOR DATA record {} at bus {}: IREG references missing bus id {}; dropped remote voltage control",
idx + 1,
g.bus,
bus
));
g.regulated_bus = None;
}
for (idx, br) in net.branches.iter_mut().enumerate() {
let Some(control) = br.control.as_mut() else {
continue;
};
let Some(bus) = control.controlled_bus.filter(|b| missing(*b)) else {
continue;
};
warnings.push(format!(
"PSS/E TRANSFORMER DATA record {} ({}-{}): CONT references missing bus id {}; dropped transformer control pointer",
idx + 1,
br.from,
br.to,
bus
));
control.controlled_bus = None;
}
for (idx, shunt) in net.shunts.iter_mut().enumerate() {
let Some(control) = shunt.control.as_mut() else {
continue;
};
let Some(bus) = control.control_bus.filter(|b| missing(*b)) else {
continue;
};
warnings.push(format!(
"PSS/E SWITCHED SHUNT DATA record {} at bus {}: SWREM references missing bus id {}; dropped switched shunt control pointer",
idx + 1,
shunt.bus,
bus
));
control.control_bus = None;
}
for (idx, area) in net.areas.iter_mut().enumerate() {
let Some(bus) = area.slack_bus.filter(|b| missing(*b)) else {
continue;
};
warnings.push(format!(
"PSS/E AREA DATA record {} area {}: ISW references missing bus id {}; dropped area swing pointer",
idx + 1,
area.number,
bus
));
area.slack_bus = None;
}
}
fn is_comment(line: &str) -> bool {
line.starts_with("@!") || line.starts_with('@')
}
fn is_system_wide_record(f: &[String]) -> bool {
matches!(
f.first().map(|s| s.to_ascii_uppercase()),
Some(first) if matches!(first.as_str(), "GENERAL" | "RATING" | "NEWTON" | "SOLVER")
)
}
fn parse_solver_line(f: &[String], solver: &mut SolverParams) {
let Some(keyword) = f.first().map(|s| s.to_ascii_uppercase()) else {
return;
};
for tok in &f[1..] {
let Some((key, val)) = tok.split_once('=') else {
continue;
};
let (key, val) = (key.trim().to_ascii_uppercase(), val.trim());
match (keyword.as_str(), key.as_str()) {
("GENERAL", "THRSHZ") => solver.zero_impedance_threshold = val.parse().ok(),
("NEWTON", "TOLN") => solver.newton_tolerance = val.parse().ok(),
("NEWTON", "ITMXN") => solver.max_iterations = val.parse().ok(),
("SOLVER", "ACTAPS") => solver.adjust_taps = Some(parse_enable(val)),
("SOLVER", "AREAIN") => solver.adjust_area_interchange = Some(parse_enable(val)),
("SOLVER", "PHSHFT") => solver.adjust_phase_shift = Some(parse_enable(val)),
("SOLVER", "DCTAPS") => solver.adjust_dc_taps = Some(parse_enable(val)),
("SOLVER", "SWSHNT") => solver.adjust_switched_shunt = Some(parse_enable(val)),
_ => {}
}
}
}
fn parse_enable(val: &str) -> bool {
val.parse::<f64>().map_or_else(
|_| !matches!(val.to_ascii_uppercase().as_str(), "DISABLED" | "OFF" | "NO"),
|n| n != 0.0,
)
}
fn strip_inline_comment(line: &str) -> &str {
let mut quoted = false;
for (i, c) in line.char_indices() {
match c {
'\'' => quoted = !quoted,
'/' if !quoted => return &line[..i],
_ => {}
}
}
line
}
fn fields(line: &str) -> Vec<String> {
let code = strip_inline_comment(line);
let mut out = Vec::new();
let mut cur = String::new();
let mut quoted = false;
let comma_delimited = code.contains(',');
for c in code.chars() {
match c {
'\'' => quoted = !quoted,
',' if !quoted && comma_delimited => {
out.push(std::mem::take(&mut cur).trim().to_string());
}
c if c.is_whitespace() && !quoted && !comma_delimited => {
if !cur.is_empty() {
out.push(std::mem::take(&mut cur));
}
}
c => cur.push(c),
}
}
let last = cur.trim().to_string();
if comma_delimited || !last.is_empty() {
out.push(last);
}
out
}
fn bad_field(i: usize, tok: &str) -> Error {
Error::FormatRead {
format: FMT,
message: format!("field {i} {tok:?} is not a number"),
}
}
fn num_at(f: &[String], i: usize, default: f64) -> Result<f64> {
match f.get(i).map(String::as_str) {
None | Some("") => Ok(default),
Some(s) => s.parse().map_err(|_| bad_field(i, s)),
}
}
fn id_at(f: &[String], i: usize, default: usize) -> Result<usize> {
match f.get(i).map(String::as_str) {
None | Some("") => Ok(default),
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
Some(s) => s
.parse::<f64>()
.map(|v| v as usize)
.map_err(|_| bad_field(i, s)),
}
}
fn on_at(f: &[String], i: usize, default: bool) -> Result<bool> {
match f.get(i).map(String::as_str) {
None | Some("") => Ok(default),
Some(s) => s
.parse::<f64>()
.map(|v| v != 0.0)
.map_err(|_| bad_field(i, s)),
}
}
fn int_at(f: &[String], i: usize, default: i64) -> Result<i64> {
match f.get(i).map(String::as_str) {
None | Some("") => Ok(default),
#[allow(clippy::cast_possible_truncation)]
Some(s) => s
.parse::<f64>()
.map(|v| v as i64)
.map_err(|_| bad_field(i, s)),
}
}
fn bustype(code: i64) -> BusType {
match code {
2 => BusType::Pv,
3 => BusType::Ref,
4 => BusType::Isolated,
_ => BusType::Pq,
}
}
#[allow(clippy::float_cmp)]
fn read_bus(f: &[String]) -> Result<Bus> {
let id = f
.first()
.and_then(|x| x.parse::<f64>().ok())
.ok_or_else(|| Error::FormatRead {
format: FMT,
message: "bus record missing numeric id (field I)".into(),
})? as usize;
let name = f
.get(1)
.filter(|n| !n.is_empty())
.map(|n| n.trim().to_string());
let vmax = num_at(f, 9, 1.1)?;
let vmin = num_at(f, 10, 0.9)?;
let evhi = num_at(f, 11, vmax)?;
let evlo = num_at(f, 12, vmin)?;
Ok(Bus {
id: BusId(id),
kind: bustype(int_at(f, 3, 1)?),
vm: num_at(f, 7, 1.0)?,
va: num_at(f, 8, 0.0)?,
base_kv: num_at(f, 2, 0.0)?,
vmax,
vmin,
evhi: (evhi != vmax).then_some(evhi),
evlo: (evlo != vmin).then_some(evlo),
area: id_at(f, 4, 0)?,
zone: id_at(f, 5, 0)?,
name,
uid: None,
extras: Extras::new(),
})
}
fn device_extras(f: &[String], i: usize) -> Extras {
let mut extras = Extras::new();
if let Some(id) = f.get(i).map(|s| s.trim()).filter(|s| !s.is_empty()) {
extras.insert("id".into(), Value::String(id.to_string()));
}
extras
}
fn read_load(f: &[String], raw_rev: u32, warnings: &mut Vec<String>) -> Result<Load> {
let bus = id_at(f, 0, 0)?;
let id = f.get(1).map_or("", |s| s.trim());
let pl = num_at(f, 5, 0.0)?;
let ql = num_at(f, 6, 0.0)?;
let ip = num_at(f, 7, 0.0)?;
let iq = num_at(f, 8, 0.0)?;
let yp = num_at(f, 9, 0.0)?;
let yq = num_at(f, 10, 0.0)?;
let mut extras = device_extras(f, 1);
for (key, value) in [
("psse_pl", pl),
("psse_ql", ql),
("psse_ip", ip),
("psse_iq", iq),
("psse_yp", yp),
("psse_yq", yq),
] {
extras.insert(key.into(), jnum(value));
}
for (field, key, default) in [
(11, "psse_owner", 1_i64),
(12, "psse_scal", 1_i64),
(13, "psse_intrpt", 0_i64),
] {
let value = int_at(f, field, default)?;
if value != default {
extras.insert(key.into(), Value::from(value));
}
}
if raw_rev >= 34 {
for (field, key) in [(14, "psse_pdgen"), (15, "psse_qdgen")] {
let value = num_at(f, field, 0.0)?;
if value != 0.0 {
extras.insert(key.into(), jnum(value));
}
}
let flag = int_at(f, 16, 0)?;
if flag != 0 {
extras.insert("psse_flagstatus".into(), Value::from(flag));
}
}
if raw_rev >= 35 {
if let Some(loadtype) = f.get(17).map(|s| s.trim()).filter(|s| !s.is_empty()) {
extras.insert("psse_loadtype".into(), Value::String(loadtype.to_string()));
}
}
let scal = int_at(f, 12, 1)?;
let load_type = f.get(17).and_then(|s| s.trim().parse::<i32>().ok());
let has_zip_components = [ip, iq, yp, yq].iter().any(|v| *v != 0.0);
let voltage_model =
(has_zip_components || scal != 1 || load_type.is_some()).then_some(LoadVoltageModel::Zip {
p_constant_power: pl,
q_constant_power: ql,
p_constant_current: ip,
q_constant_current: iq,
p_constant_impedance: yp,
q_constant_impedance: yq,
v_nom: None,
load_type,
scaling: (scal != 1).then_some(scal as f64),
});
let has_load_options = extras.contains_key("psse_intrpt")
|| extras.contains_key("psse_pdgen")
|| extras.contains_key("psse_qdgen")
|| extras.contains_key("psse_flagstatus");
if has_load_options {
warnings.push(format!(
"PSS/E load at bus {bus} id {id:?}: interruptible/DG/flag fields are retained in extras"
));
}
Ok(Load {
bus: BusId(bus),
p: pl + ip + yp,
q: ql + iq + yq,
voltage_model,
in_service: on_at(f, 2, true)?,
uid: None,
extras,
})
}
fn read_shunt(f: &[String]) -> Result<Shunt> {
Ok(Shunt {
bus: BusId(id_at(f, 0, 0)?),
g: num_at(f, 3, 0.0)?,
b: num_at(f, 4, 0.0)?,
in_service: on_at(f, 2, true)?,
control: None,
uid: None,
extras: device_extras(f, 1),
})
}
fn read_switched_shunt(f: &[String], rev: u32) -> Result<Shunt> {
let o = usize::from(rev >= 35);
let o2 = 2 * o;
let bus = id_at(f, 0, 0)?;
let swrem = id_at(f, 6 + o, 0)?;
let mut blocks = Vec::new();
let mut i = 10 + o2;
let stride = 2 + o;
while i + stride <= f.len() {
let steps = int_at(f, i + o, 0)?;
let b = num_at(f, i + o + 1, 0.0)?;
if steps == 0 && b == 0.0 {
break;
}
blocks.push(ShuntBlock {
steps: steps.clamp(0, i64::from(u32::MAX)) as u32,
b,
});
i += stride;
}
let control = SwitchedShuntControl {
mode: modsw_to_mode(int_at(f, 1 + o, 1)?),
vhigh: num_at(f, 4 + o, 0.0)?,
vlow: num_at(f, 5 + o, 0.0)?,
control_bus: (swrem != 0 && swrem != bus).then_some(BusId(swrem)),
rmpct: num_at(f, 7 + o2, 100.0)?,
blocks,
};
Ok(Shunt {
bus: BusId(bus),
g: 0.0,
b: num_at(f, 9 + o2, 0.0)?,
in_service: on_at(f, 3 + o, true)?,
control: Some(control),
uid: None,
extras: if rev >= 35 {
device_extras(f, 1)
} else {
Extras::new()
},
})
}
fn modsw_to_mode(modsw: i64) -> SwitchedShuntMode {
match modsw {
0 => SwitchedShuntMode::Locked,
1 => SwitchedShuntMode::Continuous,
_ => SwitchedShuntMode::Discrete,
}
}
fn mode_to_modsw(mode: SwitchedShuntMode) -> i64 {
match mode {
SwitchedShuntMode::Locked => 0,
SwitchedShuntMode::Continuous => 1,
SwitchedShuntMode::Discrete => 2,
}
}
fn read_area(f: &[String]) -> Result<Area> {
let isw = id_at(f, 1, 0)?;
Ok(Area {
number: id_at(f, 0, 0)?,
slack_bus: (isw != 0).then_some(BusId(isw)),
net_interchange: num_at(f, 2, 0.0)?,
tolerance: num_at(f, 3, 0.0)?,
name: f
.get(4)
.filter(|n| !n.trim().is_empty())
.map(|n| n.trim().to_string()),
})
}
fn read_gen(f: &[String], raw_rev: u32) -> Result<Generator> {
let o = usize::from(raw_rev >= 35);
let bus = id_at(f, 0, 0)?;
let ireg = id_at(f, 7, 0)?;
Ok(Generator {
bus: BusId(bus),
pg: num_at(f, 2, 0.0)?,
qg: num_at(f, 3, 0.0)?,
qmax: num_at(f, 4, 0.0)?,
qmin: num_at(f, 5, 0.0)?,
vg: num_at(f, 6, 1.0)?,
mbase: num_at(f, 8 + o, 100.0)?,
in_service: on_at(f, 14 + o, true)?,
pmax: num_at(f, 16 + o, 0.0)?,
pmin: num_at(f, 17 + o, 0.0)?,
cost: None,
caps: Default::default(),
regulated_bus: (ireg != 0 && ireg != bus).then_some(BusId(ireg)),
uid: None,
})
}
fn read_branch(f: &[String], raw_rev: u32) -> Result<Branch> {
let named_record = raw_rev >= 34 && f.len() >= 24;
let rating = if named_record { 7 } else { 6 };
let status = if named_record { 23 } else { 13 };
let shunt = if named_record { 19 } else { 9 };
let br_b = num_at(f, 5, 0.0)?;
let g_fr = num_at(f, shunt, 0.0)?;
let b_fr_extra = num_at(f, shunt + 1, 0.0)?;
let g_to = num_at(f, shunt + 2, 0.0)?;
let b_to_extra = num_at(f, shunt + 3, 0.0)?;
let b_fr = br_b / 2.0 + b_fr_extra;
let b_to = br_b / 2.0 + b_to_extra;
Ok(Branch {
from: BusId(id_at(f, 0, 0)?),
to: BusId(id_at(f, 1, 0)?),
r: num_at(f, 3, 0.0)?,
x: num_at(f, 4, 0.0)?,
b: b_fr + b_to,
charging: Some(BranchCharging {
g_fr,
b_fr,
g_to,
b_to,
}),
rate_a: num_at(f, rating, 0.0)?,
rate_b: num_at(f, rating + 1, 0.0)?,
rate_c: num_at(f, rating + 2, 0.0)?,
rating_sets: read_extra_branch_ratings(f, rating, named_record)?,
current_ratings: None,
tap: 0.0,
shift: 0.0,
in_service: on_at(f, status, true)?,
angmin: -360.0,
angmax: 360.0,
control: None,
solution: None,
uid: None,
extras: device_extras(f, 2),
})
}
#[expect(clippy::too_many_arguments)]
fn read_transformer(
l1: &[String],
l2: &[String],
l3: &[String],
l4: &[String],
raw_rev: u32,
system_base: f64,
bus_base_kv: &BTreeMap<BusId, f64>,
warnings: &mut Vec<String>,
) -> Result<Branch> {
let (cw, cz) = transformer_basis_codes(l1)?;
let from = BusId(id_at(l1, 0, 0)?);
let to = BusId(id_at(l1, 1, 0)?);
let sbase = num_at(l2, 2, system_base)?;
let label = transformer_label(l1);
let (r, x) = convert_transformer_impedance(
num_at(l2, 0, 0.0)?,
num_at(l2, 1, 0.0)?,
sbase,
system_base,
cz,
&label,
"1-2",
warnings,
);
let tap = two_winding_tap(l1, l3, l4, from, to, cw, bus_base_kv, warnings)?;
let modern = raw_rev >= 34;
let (cod_i, cont_i, rma_i) = if modern { (15, 16, 18) } else { (6, 7, 8) };
let cod = int_at(l3, cod_i, 0)?;
let control = (cod != 0)
.then(|| -> Result<TransformerControl> {
let cont = id_at(l3, cont_i, 0)?;
Ok(TransformerControl {
mode: cod_to_mode(cod),
controlled_bus: (cont != 0).then_some(BusId(cont)),
tap_max: num_at(l3, rma_i, 1.1)?,
tap_min: num_at(l3, rma_i + 1, 0.9)?,
band_max: num_at(l3, rma_i + 2, 1.1)?,
band_min: num_at(l3, rma_i + 3, 0.9)?,
ntp: int_at(l3, rma_i + 4, 33)?.clamp(0, i64::from(u32::MAX)) as u32,
mva_base: sbase,
})
})
.transpose()?;
let mag_g = if int_at(l1, 6, 1)? == 1 {
num_at(l1, 7, 0.0)?
} else {
0.0
};
let mag_b = if int_at(l1, 6, 1)? == 1 {
num_at(l1, 8, 0.0)?
} else {
0.0
};
Ok(Branch {
from,
to,
r,
x,
b: mag_b,
charging: Some(BranchCharging {
g_fr: mag_g,
b_fr: mag_b,
g_to: 0.0,
b_to: 0.0,
}),
rate_a: num_at(l3, 3, 0.0)?,
rate_b: num_at(l3, 4, 0.0)?,
rate_c: num_at(l3, 5, 0.0)?,
rating_sets: read_extra_branch_ratings(l3, 3, modern)?,
current_ratings: None,
tap,
shift: num_at(l3, 2, 0.0)?,
in_service: on_at(l1, 11, true)?,
angmin: -360.0,
angmax: 360.0,
control,
solution: None,
uid: None,
extras: Extras::new(),
})
}
fn cod_to_mode(cod: i64) -> TransformerControlMode {
match cod.abs() {
1 => TransformerControlMode::Voltage,
2 => TransformerControlMode::ReactiveFlow,
3 => TransformerControlMode::ActiveFlow,
_ => TransformerControlMode::Fixed,
}
}
fn mode_to_cod(mode: TransformerControlMode) -> i64 {
match mode {
TransformerControlMode::Fixed => 0,
TransformerControlMode::Voltage => 1,
TransformerControlMode::ReactiveFlow => 2,
TransformerControlMode::ActiveFlow => 3,
}
}
#[expect(clippy::too_many_arguments)]
fn read_transformer_3w(
l1: &[String],
l2: &[String],
l3: &[String],
l4: &[String],
l5: &[String],
system_base: f64,
bus_base_kv: &BTreeMap<BusId, f64>,
warnings: &mut Vec<String>,
) -> Result<Transformer3W> {
let (cw, cz) = transformer_basis_codes(l1)?;
let label = transformer_label(l1);
let buses = [
BusId(id_at(l1, 0, 0)?),
BusId(id_at(l1, 1, 0)?),
BusId(id_at(l1, 2, 0)?),
];
let z = {
let mut imp = |off: usize, pair: &str| -> Result<Impedance> {
let sbase = num_at(l2, off + 2, system_base)?;
let (r, x) = convert_transformer_impedance(
num_at(l2, off, 0.0)?,
num_at(l2, off + 1, 0.0)?,
sbase,
system_base,
cz,
&label,
pair,
warnings,
);
Ok(Impedance {
r,
x,
base_mva: sbase,
})
};
[imp(0, "1-2")?, imp(3, "2-3")?, imp(6, "3-1")?]
};
let windings = {
let mut winding = |idx: usize, w: &[String]| -> Result<Winding> {
let bus = buses[idx];
let tap = winding_ratio(
w,
bus,
cw,
bus_base_kv,
&label,
match idx {
0 => "winding 1",
1 => "winding 2",
_ => "winding 3",
},
warnings,
)?;
Ok(Winding {
bus,
tap,
shift: num_at(w, 2, 0.0)?,
nominal_kv: num_at(w, 1, 0.0)?,
rate_a: num_at(w, 3, 0.0)?,
rate_b: num_at(w, 4, 0.0)?,
rate_c: num_at(w, 5, 0.0)?,
})
};
[winding(0, l3)?, winding(1, l4)?, winding(2, l5)?]
};
Ok(Transformer3W {
windings,
z,
star_vm: num_at(l2, 9, 1.0)?,
star_va: num_at(l2, 10, 0.0)?,
mag_g: num_at(l1, 7, 0.0)?,
mag_b: num_at(l1, 8, 0.0)?,
in_service: int_at(l1, 11, 1)? != 0,
name: l1
.get(10)
.filter(|n| !n.is_empty())
.map(|n| n.trim().to_string()),
uid: None,
extras: Extras::new(),
})
}
fn read_dc_line(l1: &[String], rect: &[String], inv: &[String]) -> Result<Hvdc> {
let mdc = int_at(l1, 1, 1)?;
let rdc = num_at(l1, 2, 0.0)?;
let setvl = num_at(l1, 3, 0.0)?;
let vschd = num_at(l1, 4, 0.0)?;
let mut extras = Extras::new();
if let Some(name) = l1.first().filter(|n| !n.is_empty()) {
extras.insert("psse_dc_name".into(), Value::String(name.clone()));
}
extras.insert("psse_dc_mdc".into(), Value::from(mdc));
extras.insert("psse_dc_rdc".into(), jnum(rdc));
extras.insert("psse_dc_vschd".into(), jnum(vschd));
extras.insert("psse_dc_control_tail".into(), tail_array(l1, 5));
extras.insert("psse_dc_rectifier_tail".into(), tail_array(rect, 1));
extras.insert("psse_dc_inverter_tail".into(), tail_array(inv, 1));
Ok(Hvdc {
from: BusId(id_at(rect, 0, 0)?),
to: BusId(id_at(inv, 0, 0)?),
in_service: mdc != 0,
pf: setvl,
pt: setvl,
qf: 0.0,
qt: 0.0,
vf: 1.0,
vt: 1.0,
pmin: 0.0,
pmax: setvl.abs(),
qminf: 0.0,
qmaxf: 0.0,
qmint: 0.0,
qmaxt: 0.0,
loss0: 0.0,
loss1: 0.0,
cost: None,
uid: None,
extras,
})
}
fn tail_array(f: &[String], start: usize) -> Value {
Value::Array(
f.iter()
.skip(start)
.map(|s| Value::String(s.clone()))
.collect(),
)
}
fn dc_str(extras: &Extras, key: &str) -> Option<String> {
extras.get(key).and_then(Value::as_str).map(str::to_owned)
}
fn dc_int(extras: &Extras, key: &str) -> Option<i64> {
extras.get(key).and_then(Value::as_i64)
}
fn dc_f64(extras: &Extras, key: &str) -> Option<f64> {
extras.get(key).and_then(Value::as_f64)
}
fn extra_f64(extras: &Extras, key: &str) -> Option<f64> {
extras
.get(key)
.and_then(Value::as_f64)
.filter(|v| v.is_finite())
}
fn extra_i64(extras: &Extras, key: &str) -> Option<i64> {
extras.get(key).and_then(Value::as_i64)
}
fn same_load_total(a: f64, b: f64) -> bool {
(a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0)
}
fn typed_psse_scal(l: &Load, id: &str, warnings: &mut Vec<String>) -> Option<i64> {
let Some(LoadVoltageModel::Zip {
scaling: Some(scaling),
..
}) = &l.voltage_model
else {
return None;
};
let scaling = *scaling;
if !scaling.is_finite() {
warnings.push(format!(
"PSS/E load at bus {} id {id:?}: non-finite typed scaling has no SCAL value; used source/default SCAL",
l.bus
));
return None;
}
let rounded = scaling.round();
if (scaling - rounded).abs() > 1e-9 || rounded < i64::MIN as f64 || rounded > i64::MAX as f64 {
warnings.push(format!(
"PSS/E load at bus {} id {id:?}: non-integer typed scaling {scaling} has no SCAL value; used source/default SCAL",
l.bus
));
return None;
}
Some(rounded as i64)
}
fn typed_psse_load_type(model: &LoadVoltageModel) -> Option<String> {
match model {
LoadVoltageModel::Zip {
load_type: Some(load_type),
..
} => Some(load_type.to_string()),
_ => None,
}
}
fn load_components_for_write(
l: &Load,
id: &str,
warnings: &mut Vec<String>,
) -> (f64, f64, f64, f64, f64, f64) {
if let Some(LoadVoltageModel::Zip {
p_constant_power,
q_constant_power,
p_constant_current,
q_constant_current,
p_constant_impedance,
q_constant_impedance,
v_nom,
..
}) = &l.voltage_model
{
if same_load_total(
p_constant_power + p_constant_current + p_constant_impedance,
l.p,
) && same_load_total(
q_constant_power + q_constant_current + q_constant_impedance,
l.q,
) {
if v_nom.is_some() {
warnings.push(format!(
"PSS/E load at bus {} id {id:?}: nominal voltage has no load record field; dropped",
l.bus
));
}
return (
*p_constant_power,
*q_constant_power,
*p_constant_current,
*q_constant_current,
*p_constant_impedance,
*q_constant_impedance,
);
}
warnings.push(format!(
"PSS/E load at bus {} id {id:?}: stale voltage model components did not match \
typed p/q; wrote typed p/q as constant power",
l.bus
));
return (l.p, l.q, 0.0, 0.0, 0.0, 0.0);
}
if matches!(l.voltage_model, Some(LoadVoltageModel::Exponential { .. })) {
warnings.push(format!(
"PSS/E load at bus {} id {id:?}: exponential voltage model has no load record fields; wrote typed p/q as constant power",
l.bus
));
return (l.p, l.q, 0.0, 0.0, 0.0, 0.0);
}
let pl = extra_f64(&l.extras, "psse_pl").unwrap_or(l.p);
let ql = extra_f64(&l.extras, "psse_ql").unwrap_or(l.q);
let ip = extra_f64(&l.extras, "psse_ip").unwrap_or(0.0);
let iq = extra_f64(&l.extras, "psse_iq").unwrap_or(0.0);
let yp = extra_f64(&l.extras, "psse_yp").unwrap_or(0.0);
let yq = extra_f64(&l.extras, "psse_yq").unwrap_or(0.0);
let has_components = [
"psse_pl", "psse_ql", "psse_ip", "psse_iq", "psse_yp", "psse_yq",
]
.iter()
.any(|key| l.extras.contains_key(*key));
if has_components
&& (!same_load_total(pl + ip + yp, l.p) || !same_load_total(ql + iq + yq, l.q))
{
warnings.push(format!(
"PSS/E load at bus {} id {id:?}: stale PL/QL/IP/IQ/YP/YQ extras did not match \
typed p/q; wrote typed p/q as constant power",
l.bus
));
(l.p, l.q, 0.0, 0.0, 0.0, 0.0)
} else {
(pl, ql, ip, iq, yp, yq)
}
}
fn dc_tail(extras: &Extras, key: &str, default: &str) -> String {
match extras.get(key).and_then(Value::as_array) {
Some(arr) if !arr.is_empty() => arr
.iter()
.filter_map(Value::as_str)
.collect::<Vec<_>>()
.join(", "),
_ => default.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn close(actual: f64, expected: f64) {
assert!((actual - expected).abs() < 1e-12, "{actual} != {expected}");
}
fn test_bus(id: usize, kind: BusType) -> Bus {
Bus {
id: BusId(id),
kind,
vm: 1.0,
va: 0.0,
base_kv: 230.0,
vmax: 1.1,
vmin: 0.9,
evhi: None,
evlo: None,
area: 1,
zone: 1,
name: None,
uid: None,
extras: Extras::default(),
}
}
fn branch_with_terminal_charging() -> Branch {
Branch {
from: BusId(1),
to: BusId(2),
r: 0.01,
x: 0.1,
b: 0.0,
charging: Some(BranchCharging {
g_fr: 0.01,
b_fr: 0.02,
g_to: 0.03,
b_to: 0.05,
}),
rate_a: 100.0,
rate_b: 110.0,
rate_c: 120.0,
rating_sets: Vec::new(),
current_ratings: None,
tap: 0.0,
shift: 0.0,
in_service: true,
angmin: -360.0,
angmax: 360.0,
control: None,
solution: None,
uid: None,
extras: Extras::default(),
}
}
fn transformer_with_terminal_charging(charging: BranchCharging) -> Branch {
Branch {
from: BusId(1),
to: BusId(2),
r: 0.01,
x: 0.1,
b: 0.0,
charging: Some(charging),
rate_a: 100.0,
rate_b: 110.0,
rate_c: 120.0,
rating_sets: Vec::new(),
current_ratings: None,
tap: 1.05,
shift: 0.0,
in_service: true,
angmin: -360.0,
angmax: 360.0,
control: None,
solution: None,
uid: None,
extras: Extras::default(),
}
}
fn assert_terminal_charging_round_trip(text: &str) {
let back = parse_psse(text).unwrap();
let charging = back.branches[0].terminal_charging();
close(charging.g_fr, 0.01);
close(charging.b_fr, 0.02);
close(charging.g_to, 0.03);
close(charging.b_to, 0.05);
close(back.branches[0].b, 0.07);
}
#[test]
fn branch_terminal_charging_writes_gi_bi_gj_bj() {
let mut net = Network::in_memory(
"terminal-shunts",
100.0,
vec![test_bus(1, BusType::Ref), test_bus(2, BusType::Pq)],
Vec::new(),
);
net.branches.push(branch_with_terminal_charging());
let rev33 = write_psse(&net);
assert!(rev33.warnings.is_empty(), "{:?}", rev33.warnings);
assert_terminal_charging_round_trip(&rev33.text);
let rev35 = write_psse_rev(&net, 35);
assert!(rev35.warnings.is_empty(), "{:?}", rev35.warnings);
assert_terminal_charging_round_trip(&rev35.text);
}
#[test]
fn transformer_magnetizing_admittance_writes_mag1_mag2() {
let mut net = Network::in_memory(
"xfmr-mag",
100.0,
vec![test_bus(1, BusType::Ref), test_bus(2, BusType::Pq)],
Vec::new(),
);
net.branches
.push(transformer_with_terminal_charging(BranchCharging {
g_fr: 0.01,
b_fr: 0.02,
g_to: 0.0,
b_to: 0.0,
}));
let conv = write_psse(&net);
assert!(
!conv
.warnings
.iter()
.any(|w| w.contains("magnetizing admittance")),
"{:?}",
conv.warnings
);
let back = parse_psse(&conv.text).unwrap();
let charging = back.branches[0].terminal_charging();
close(charging.g_fr, 0.01);
close(charging.b_fr, 0.02);
close(charging.g_to, 0.0);
close(charging.b_to, 0.0);
close(back.branches[0].b, 0.02);
}
#[test]
fn transformer_to_side_terminal_admittance_warns_and_collapses_to_mag() {
let mut net = Network::in_memory(
"xfmr-mag-collapse",
100.0,
vec![test_bus(1, BusType::Ref), test_bus(2, BusType::Pq)],
Vec::new(),
);
net.branches
.push(transformer_with_terminal_charging(BranchCharging {
g_fr: 0.01,
b_fr: 0.02,
g_to: 0.03,
b_to: 0.05,
}));
let conv = write_psse(&net);
assert!(
conv.warnings
.iter()
.any(|w| w.contains("magnetizing admittance")),
"{:?}",
conv.warnings
);
let back = parse_psse(&conv.text).unwrap();
let charging = back.branches[0].terminal_charging();
close(charging.g_fr, 0.04);
close(charging.b_fr, 0.07);
close(charging.g_to, 0.0);
close(charging.b_to, 0.0);
close(back.branches[0].b, 0.07);
}
#[test]
fn slash_inside_a_quoted_field_is_not_a_comment() {
let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic
CASE
COMMENT
1,'A/B ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
0 / END OF BUS DATA, BEGIN LOAD DATA
Q
";
let net = parse_psse(raw).unwrap();
assert_eq!(net.buses.len(), 1);
assert_eq!(net.buses[0].name.as_deref(), Some("A/B"));
}
#[test]
fn load_zip_components_are_typed_and_round_trip() {
let raw = r"0, 100.00, 35, 0, 1, 60.00 / synthetic
CASE
COMMENT
0 / END OF SYSTEM-WIDE DATA, BEGIN BUS DATA
1,'BUS1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
2,'BUS2 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
0 / END OF BUS DATA, BEGIN LOAD DATA
2,'L1',1,1,1,10.0,3.0,1.0,0.5,2.0,1.5,1,0,1,4.0,2.0,1,'industrial'
0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
Q
";
let mut warnings = Vec::new();
let net =
parse_psse_source(std::sync::Arc::new(raw.to_string()), None, &mut warnings).unwrap();
assert_eq!(net.loads.len(), 1);
close(net.loads[0].p, 13.0);
close(net.loads[0].q, 5.0);
let Some(LoadVoltageModel::Zip {
p_constant_power,
q_constant_current,
p_constant_impedance,
..
}) = &net.loads[0].voltage_model
else {
panic!("missing typed ZIP load model");
};
close(*p_constant_power, 10.0);
close(*q_constant_current, 0.5);
close(*p_constant_impedance, 2.0);
assert!(
warnings.iter().any(|w| w.contains("interruptible/DG/flag")),
"missing load option warning: {warnings:?}"
);
let text = write_psse_rev(&net, 35).text;
assert!(
text.contains("10.0, 3.0, 1.0, 0.5, 2.0, 1.5"),
"ZIP components were not replayed: {text}"
);
assert!(
text.contains("4.0, 2.0, 1, 'industrial'"),
"modern load tail was not replayed: {text}"
);
let net2 = parse_psse(&text).unwrap();
close(net2.loads[0].p, 13.0);
close(net2.loads[0].q, 5.0);
}
#[test]
fn tiny_nonzero_zip_components_are_preserved_as_typed_fields() {
let raw = r"0, 100.00, 35, 0, 1, 60.00 / synthetic
CASE
COMMENT
0 / END OF SYSTEM-WIDE DATA, BEGIN BUS DATA
1,'BUS1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
2,'BUS2 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
0 / END OF BUS DATA, BEGIN LOAD DATA
2,'L1',1,1,1,10.0,3.0,1e-20,0.0,0.0,0.0,1,1,0,0.0,0.0,0,''
0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
Q
";
let net = parse_psse(raw).unwrap();
let Some(LoadVoltageModel::Zip {
p_constant_current, ..
}) = &net.loads[0].voltage_model
else {
panic!("tiny nonzero ZIP component was not typed");
};
assert_eq!(p_constant_current.to_bits(), 1.0e-20_f64.to_bits());
let matpower = crate::format::matpower::write_matpower_conversion(&net);
assert!(
matpower
.warnings
.iter()
.any(|w| w.contains("voltage dependent load model")),
"missing MATPOWER voltage model warning: {:?}",
matpower.warnings
);
}
#[test]
fn typed_psse_load_scaling_and_type_write_without_extras() {
let raw = r"0, 100.00, 35, 0, 1, 60.00 / synthetic
CASE
COMMENT
0 / END OF SYSTEM-WIDE DATA, BEGIN BUS DATA
1,'BUS1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
2,'BUS2 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
0 / END OF BUS DATA, BEGIN LOAD DATA
2,'L1',1,1,1,10.0,3.0,1.0,0.5,2.0,1.5,1,1,0,0.0,0.0,0,''
0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
Q
";
let mut net = parse_psse(raw).unwrap();
let Some(LoadVoltageModel::Zip {
scaling,
load_type,
v_nom,
..
}) = &mut net.loads[0].voltage_model
else {
panic!("missing typed ZIP load model");
};
*scaling = Some(0.0);
*load_type = Some(7);
*v_nom = Some(230_000.0);
net.loads[0].extras.remove("psse_scal");
net.loads[0].extras.remove("psse_loadtype");
let conv = write_psse_rev(&net, 35);
assert!(
conv.text.contains(", 1, 0, 0, 0.0, 0.0, 0, '7'"),
"typed SCAL/LOADTYPE were not written: {}",
conv.text
);
assert!(
conv.warnings.iter().any(|w| w.contains("nominal voltage")),
"missing nominal voltage warning: {:?}",
conv.warnings
);
let rev33 = write_psse(&net);
assert!(
rev33
.warnings
.iter()
.any(|w| w.contains("load type requires revision 35")),
"missing rev33 load type warning: {:?}",
rev33.warnings
);
let reparsed = parse_psse(&conv.text).unwrap();
let Some(LoadVoltageModel::Zip {
scaling, load_type, ..
}) = &reparsed.loads[0].voltage_model
else {
panic!("missing reparsed ZIP load model");
};
assert_eq!(*scaling, Some(0.0));
assert_eq!(*load_type, Some(7));
}
#[test]
fn mutated_load_does_not_replay_stale_psse_zip_extras() {
let raw = r"0, 100.00, 35, 0, 1, 60.00 / synthetic
CASE
COMMENT
0 / END OF SYSTEM-WIDE DATA, BEGIN BUS DATA
1,'BUS1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
2,'BUS2 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
0 / END OF BUS DATA, BEGIN LOAD DATA
2,'L1',1,1,1,10.0,3.0,1.0,0.5,2.0,1.5,1,0,1,4.0,2.0,1,'industrial'
0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
Q
";
let mut net = parse_psse(raw).unwrap();
net.loads[0].p = 20.0;
net.loads[0].q = 7.0;
let conv = write_psse_rev(&net, 35);
assert!(
conv.text.contains("20.0, 7.0, 0.0, 0.0, 0.0, 0.0"),
"typed p/q were not written as constant power: {}",
conv.text
);
assert!(
conv.warnings
.iter()
.any(|w| w.contains("stale voltage model components")),
"missing stale voltage model warning: {:?}",
conv.warnings
);
let reparsed = parse_psse(&conv.text).unwrap();
close(reparsed.loads[0].p, 20.0);
close(reparsed.loads[0].q, 7.0);
}
#[test]
fn transformer_continuation_rejects_section_terminator() {
let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic
CASE
COMMENT
1,'BUS1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
2,'BUS2 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
0 / END OF BUS DATA, BEGIN LOAD DATA
0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
0 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
0 / END OF GENERATOR DATA, BEGIN BRANCH DATA
0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
1,2,0,'1 ',1,1,1,0,0,1,'xf'
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA
Q
";
let err = parse_psse(raw).unwrap_err().to_string();
assert!(
err.contains("transformer record ended before transformer impedance line"),
"{err}"
);
}
#[test]
fn transformer_impedance_line_can_start_with_zero_resistance() {
let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic
CASE
COMMENT
1,'BUS1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
2,'BUS2 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
0 / END OF BUS DATA, BEGIN LOAD DATA
0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
0 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
0 / END OF GENERATOR DATA, BEGIN BRANCH DATA
0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
1,2,0,'1 ',1,1,1,0,0,1,'xf',1
0,0.10,100.0
1.0,230.0,0.0,100.0,90.0,80.0,0,0,1.1,0.9,1.1,0.9,33
1.0,230.0
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA
Q
";
let net = parse_psse(raw).unwrap();
assert_eq!(net.branches.len(), 1);
close(net.branches[0].r, 0.0);
close(net.branches[0].x, 0.10);
}
#[test]
fn transformer_non_integral_cz_is_a_hard_error() {
let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic
CASE
COMMENT
1,'BUS1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
2,'BUS2 ', 115.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
0 / END OF BUS DATA, BEGIN LOAD DATA
0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
0 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
0 / END OF GENERATOR DATA, BEGIN BRANCH DATA
0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
1,2,0,'1 ',1,2.9,1,0,0,1,'xf',1
0,0.10,100.0
1.0,230.0,0.0,100.0,90.0,80.0,0,0,1.1,0.9,1.1,0.9,33
1.0,230.0
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA
Q
";
let err = parse_psse(raw).unwrap_err().to_string();
assert!(err.contains("field 5") && err.contains("2.9"), "{err}");
}
#[test]
fn non_unit_two_winding_transformer_bases_are_converted() {
let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic
CASE
COMMENT
1,'BUS1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
2,'BUS2 ', 115.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
0 / END OF BUS DATA, BEGIN LOAD DATA
0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
0 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
0 / END OF GENERATOR DATA, BEGIN BRANCH DATA
0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
1,2,0,'1 ',2,2,1,0,0,1,'xf',1
0.01,0.10,50.0
241.5,230.0,0.0,100.0,90.0,80.0,0,0,1.1,0.9,1.1,0.9,33
115.0,115.0
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA
Q
";
let parsed = crate::parse_str(raw, "psse").unwrap();
assert!(
!parsed
.warnings
.iter()
.any(|w| w.contains("unsupported CZ") || w.contains("unsupported CW")),
"unexpected transformer base warning: {:?}",
parsed.warnings
);
let br = &parsed.network.branches[0];
close(br.r, 0.02);
close(br.x, 0.20);
close(br.tap, 1.05);
}
#[test]
fn cz3_load_loss_and_cw3_nominal_voltage_are_converted() {
let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic
CASE
COMMENT
1,'BUS1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
2,'BUS2 ', 115.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
0 / END OF BUS DATA, BEGIN LOAD DATA
0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
0 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
0 / END OF GENERATOR DATA, BEGIN BRANCH DATA
0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
1,2,0,'1 ',3,3,1,0,0,1,'xf',1
250000.0,0.10,50.0
1.05,230.0,0.0,100.0,90.0,80.0,0,0,1.1,0.9,1.1,0.9,33
1.0,115.0
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA
Q
";
let parsed = crate::parse_str(raw, "psse").unwrap();
assert!(
!parsed
.warnings
.iter()
.any(|w| w.contains("unsupported CZ") || w.contains("unsupported CW")),
"unexpected transformer base warning: {:?}",
parsed.warnings
);
let br = &parsed.network.branches[0];
close(br.r, 0.01);
close(br.x, (0.10_f64 * 0.10 - 0.005_f64 * 0.005).sqrt() * 2.0);
close(br.tap, 1.05);
}
#[test]
fn non_unit_three_winding_transformer_bases_are_converted() {
let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic
CASE
COMMENT
1,'BUS1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
2,'BUS2 ', 115.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
3,'BUS3 ', 13.8,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
0 / END OF BUS DATA, BEGIN LOAD DATA
0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
0 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
0 / END OF GENERATOR DATA, BEGIN BRANCH DATA
0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
1,2,3,'1 ',2,2,1,0,0,1,'xf3',1
0.01,0.10,50.0,0.02,0.20,100.0,0.03,0.30,200.0,1.0,0.0
241.5,230.0,0.0,100.0,90.0,80.0,0,0,1.1,0.9,1.1,0.9,33
115.0,115.0,0.0,100.0,90.0,80.0,0,0,1.1,0.9,1.1,0.9,33
13.8,13.8,0.0,100.0,90.0,80.0,0,0,1.1,0.9,1.1,0.9,33
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA
Q
";
let parsed = crate::parse_str(raw, "psse").unwrap();
assert!(
!parsed
.warnings
.iter()
.any(|w| w.contains("unsupported CZ") || w.contains("unsupported CW")),
"unexpected transformer base warning: {:?}",
parsed.warnings
);
let t = &parsed.network.transformers_3w[0];
close(t.z[0].r, 0.02);
close(t.z[0].x, 0.20);
close(t.z[1].r, 0.02);
close(t.z[1].x, 0.20);
close(t.z[2].r, 0.015);
close(t.z[2].x, 0.15);
close(t.windings[0].tap, 1.05);
close(t.windings[1].tap, 1.0);
close(t.windings[2].tap, 1.0);
}
#[test]
fn dc_continuation_rejects_section_terminator() {
let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic
CASE
COMMENT
0 / END OF SYSTEM-WIDE DATA, BEGIN TWO-TERMINAL DC DATA
'DC1',1
0 / END OF TWO-TERMINAL DC DATA, BEGIN VSC DC LINE DATA
Q
";
let err = parse_psse(raw).unwrap_err().to_string();
assert!(
err.contains("two-terminal DC record ended before rectifier line"),
"{err}"
);
}
#[test]
fn reads_comment_headers_system_wide_block_and_named_branch_records() {
let raw = r#"@!IC, SBASE,REV,XFRRAT,NXFRAT,BASFRQ
0, 100.00, 34, 0, 0, 60.00 / synthetic v34 export
GENERAL, THRSHZ=0.0002
RATING, 1, " ", " "
0 / END OF SYSTEM-WIDE DATA, BEGIN BUS DATA
@! I,'NAME ', BASKV, IDE,AREA,ZONE,OWNER, VM, VA, NVHI, NVLO, EVHI, EVLO
1,'BUS1 ', 230.0000,3,1,1,1,1.00000,0.0000,1.1000,0.9000,1.1000,0.9000
2,'BUS2 ', 230.0000,1,1,1,1,1.00000,0.0000,1.1000,0.9000,1.1000,0.9000
0 / END OF BUS DATA, BEGIN LOAD DATA
@! I,'ID',STAT,AREA,ZONE, PL, QL
2,'1 ',1,1,1,10.0,5.0
0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
0 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
@! I,'ID', PG, QG, QT, QB, VS, IREG, MBASE, ZR, ZX, RT, XT, GTAP,STAT, RMPCT, PT, PB
1,'1 ',50.0,5.0,20.0,-10.0,1.0,0,100.0,0.0,1.0,0.0,0.0,1.0,1,100.0,80.0,10.0
0 / END OF GENERATOR DATA, BEGIN BRANCH DATA
@! I, J,'CKT', R, X, B, 'N A M E' , RATE1, RATE2, RATE3, RATE4, RATE5, RATE6, RATE7, RATE8, RATE9, RATE10, RATE11, RATE12, GI, BI, GJ, BJ,STAT,MET, LEN
1,2,'1 ',0.01,0.05,0.001,'named branch',100.0,90.0,80.0,70.0,0.0,60.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,1,1,0.0
0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA
Q
"#;
let mut net = parse_psse(raw).unwrap();
close(net.base_mva, 100.0);
assert_eq!(net.buses.len(), 2);
assert_eq!(net.loads.len(), 1);
assert_eq!(net.generators.len(), 1);
assert_eq!(net.branches.len(), 1);
close(net.branches[0].rate_a, 100.0);
assert_eq!(net.branches[0].rating_sets.len(), 2);
assert_eq!(net.branches[0].rating_sets[0].name, "RATE4");
close(net.branches[0].rating_sets[0].rate_mva, 70.0);
assert_eq!(net.branches[0].rating_sets[1].name, "RATE6");
close(net.branches[0].rating_sets[1].rate_mva, 60.0);
assert!(net.branches[0].in_service);
net.source = None;
let written = write_psse_rev(&net, 34);
assert!(
!written.warnings.iter().any(|w| w.contains("rating set")),
"v34 should carry RATE4-RATE12, got {:?}",
written.warnings
);
let back = parse_psse(&written.text).unwrap();
assert_eq!(back.branches[0].rating_sets.len(), 2);
assert_eq!(back.branches[0].rating_sets[0].name, "RATE4");
close(back.branches[0].rating_sets[0].rate_mva, 70.0);
assert_eq!(back.branches[0].rating_sets[1].name, "RATE6");
close(back.branches[0].rating_sets[1].rate_mva, 60.0);
}
#[test]
fn v34_transformer_reads_float_k_and_modern_winding_columns() {
let raw = r"0, 100.00, 34, 0, 0, 60.00 / synthetic v34 export
CASE
COMMENT
0 / END OF SYSTEM-WIDE DATA, BEGIN BUS DATA
1,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
2,'B2 ', 18.0,2,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
0 / END OF BUS DATA, BEGIN LOAD DATA
0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
0 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
0 / END OF GENERATOR DATA, BEGIN BRANCH DATA
0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
1, 2, 0.00, '1', 1, 1, 1, 0.0, 0.0, 2, 'T1 ', 1, 1, 1.0, 0, 1, 0, 1, 0, 1, ' '
0.01, 0.10, 100.0
1.05, 0.0, 0.0, 100.0, 90.0, 80.0, 70.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1, 2, 0, 1.08, 0.92, 1.05, 0.98, 17, 0, 0, 0, 0
1.0, 0.0
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA
1, 1, 0.0, 0.0, 'AREA '
Q
";
let net = parse_psse(raw).unwrap();
assert_eq!(net.branches.len(), 1, "K = 0.00 is a 2-winding record");
assert!(net.transformers_3w.is_empty());
assert_eq!(
net.areas.len(),
1,
"the section after the transformer parsed"
);
let br = &net.branches[0];
close(br.tap, 1.05);
close(br.rate_a, 100.0);
assert_eq!(br.rating_sets.len(), 1);
assert_eq!(br.rating_sets[0].name, "RATE4");
close(br.rating_sets[0].rate_mva, 70.0);
let c = br.control.as_ref().expect("COD at 15 marks the control");
assert_eq!(c.mode, TransformerControlMode::Voltage);
assert_eq!(c.controlled_bus, Some(BusId(2)));
close(c.tap_max, 1.08);
close(c.tap_min, 0.92);
close(c.band_max, 1.05);
close(c.band_min, 0.98);
assert_eq!(c.ntp, 17);
}
#[test]
fn v34_warns_when_custom_rating_name_is_emitted_as_rate_slot() {
let mut net = Network::in_memory(
"ratings",
100.0,
vec![
Bus::new(BusId(1), BusType::Ref, 230.0),
Bus::new(BusId(2), BusType::Pq, 230.0),
],
Vec::new(),
);
let mut branch = Branch::new(BusId(1), BusId(2), 0.01, 0.05);
branch.rate_a = 100.0;
branch
.rating_sets
.push(BranchRatingSet::new("emergency", 125.0));
net.branches.push(branch);
let written = write_psse_rev(&net, 34);
assert!(
written.warnings.iter().any(|w| {
w.contains("rating set emergency=125")
&& w.contains("emitted as RATE4")
&& w.contains("names outside RATE4-RATE12 are not preserved")
}),
"missing rating rename warning: {:?}",
written.warnings
);
let back = parse_psse(&written.text).unwrap();
assert_eq!(back.branches[0].rating_sets.len(), 1);
assert_eq!(back.branches[0].rating_sets[0].name, "RATE4");
close(back.branches[0].rating_sets[0].rate_mva, 125.0);
}
#[test]
fn reads_start_of_section_markers_and_gen_alias() {
let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic v33 export
CASE
COMMENT
1,'BUS1 ', 230.0000,3,1,1,1,1.00000,0.0000,1.1000,0.9000,1.1000,0.9000
2,'BUS2 ', 230.0000,1,1,1,1,1.00000,0.0000,1.1000,0.9000,1.1000,0.9000
0 / End of Bus Data, Start of Load Data
2,'1 ',1,1,1,10.0,5.0
0 / End of Load Data, Start of Fixed Shunt Data
0 / End of Fixed Shunt Data, Start of Gen Data
1,'1 ',50.0,5.0,20.0,-10.0,1.0,0,100.0,0.0,1.0,0.0,0.0,1.0,1,100.0,80.0,10.0
0 / End of Gen Data, Start of Branch Data
1,2,'1 ',0.01,0.05,0.001,100.0,90.0,80.0,0.0,0.0,0.0,0.0,1,1,0.0,1,1
0 / End of Branch Data, Start of Transformer Data
0 / End of Transformer Data, Start of Area Interchange Data
Q
";
let net = parse_psse(raw).unwrap();
assert_eq!(net.buses.len(), 2);
assert_eq!(net.loads.len(), 1);
assert_eq!(net.generators.len(), 1);
assert_eq!(net.branches.len(), 1);
}
#[test]
fn v33_long_branch_with_blank_ratea_keeps_v33_columns() {
let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic v33 export
CASE
COMMENT
1,'BUS1 ', 230.0000,3,1,1,1,1.00000,0.0000,1.1000,0.9000,1.1000,0.9000
2,'BUS2 ', 230.0000,1,1,1,1,1.00000,0.0000,1.1000,0.9000,1.1000,0.9000
0 / END OF BUS DATA, BEGIN LOAD DATA
0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
0 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
0 / END OF GENERATOR DATA, BEGIN BRANCH DATA
1,2,'1 ',0.01,0.05,0.001,,90.0,80.0,0.0,0.0,0.0,0.0,1,1,0.0,1,1.0,2,0.0,3,0.0,4,0.0
0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA
Q
";
let net = parse_psse(raw).unwrap();
assert_eq!(net.branches.len(), 1);
close(net.branches[0].rate_a, 0.0);
close(net.branches[0].rate_b, 90.0);
close(net.branches[0].rate_c, 80.0);
assert!(net.branches[0].in_service);
}
#[test]
fn captured_load_ids_round_trip_and_parallel_loads_stay_distinct() {
let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
CASE
COMMENT
1,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
2,'B2 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
0 / END OF BUS DATA, BEGIN LOAD DATA
2,'A',1,1,1,10.0,5.0,0,0,0,0,1,1,0
2,'B',1,1,1,20.0,8.0,0,0,0,0,1,1,0
0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
0 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
0 / END OF GENERATOR DATA, BEGIN BRANCH DATA
0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA
Q
";
let id = |l: &Load| {
l.extras
.get("id")
.and_then(|v| v.as_str())
.map(str::to_owned)
};
let net = parse_psse(raw).unwrap();
assert_eq!(net.loads.len(), 2);
assert_eq!(id(&net.loads[0]).as_deref(), Some("A"));
assert_eq!(id(&net.loads[1]).as_deref(), Some("B"));
let net2 = parse_psse(&write_psse(&net).text).unwrap();
assert_eq!(id(&net2.loads[0]).as_deref(), Some("A"));
assert_eq!(id(&net2.loads[1]).as_deref(), Some("B"));
let mut synth = net.clone();
for l in &mut synth.loads {
l.extras.remove("id");
}
let net3 = parse_psse(&write_psse(&synth).text).unwrap();
let ids: Vec<_> = net3.loads.iter().filter_map(&id).collect();
assert_eq!(ids, vec!["1".to_string(), "2".to_string()]);
}
#[test]
fn sanitized_load_ids_are_allocated_after_cleaning() {
let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
CASE
COMMENT
1,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
2,'B2 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
0 / END OF BUS DATA, BEGIN LOAD DATA
2,'A',1,1,1,10.0,5.0,0,0,0,0,1,1,0
2,'B',1,1,1,20.0,8.0,0,0,0,0,1,1,0
0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
0 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
0 / END OF GENERATOR DATA, BEGIN BRANCH DATA
0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA
Q
";
let mut net = parse_psse(raw).unwrap();
net.loads[0]
.extras
.insert("id".into(), Value::String("A/B".into()));
net.loads[1]
.extras
.insert("id".into(), Value::String("A'B".into()));
let conv = write_psse(&net);
let reparsed = parse_psse(&conv.text).unwrap();
let ids: Vec<_> = reparsed
.loads
.iter()
.filter_map(|l| l.extras.get("id").and_then(Value::as_str))
.collect();
assert_eq!(ids, vec!["A B", "1"]);
assert!(
conv.warnings
.iter()
.any(|w| w.contains("2 quoted PSS/E field")),
"missing sanitation warning: {:?}",
conv.warnings
);
}
#[test]
fn two_winding_transformer_charging_round_trips_via_mag2() {
let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
CASE
COMMENT
1,'B1 ', 230.0,3,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
2,'B2 ', 138.0,1,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
0 / END OF BUS DATA, BEGIN LOAD DATA
0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
0 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
0 / END OF GENERATOR DATA, BEGIN BRANCH DATA
0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
1, 2, 0, '1', 1, 1, 1, 0, 0.04, 2, 'XF ', 1, 1, 1, 0, 1, 0, 1, 0, 1, ' '
0.01, 0.10, 100.0
1.025, 0, 0.0, 100.0, 90.0, 80.0, 0, 0, 1.1, 0.9, 1.1, 0.9, 33, 0, 0, 0, 0
1.0, 0
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA
Q
";
let net = parse_psse(raw).unwrap();
assert_eq!(net.branches.len(), 1);
assert!(net.branches[0].is_transformer());
close(net.branches[0].b, 0.04);
let net2 = parse_psse(&write_psse(&net).text).unwrap();
close(net2.branches[0].b, 0.04);
}
#[test]
fn parallel_branches_round_trip_and_stay_distinct() {
let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
CASE
COMMENT
1,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
2,'B2 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
0 / END OF BUS DATA, BEGIN LOAD DATA
0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
0 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
0 / END OF GENERATOR DATA, BEGIN BRANCH DATA
1,2,'1 ',0.01,0.05,0.001,0,0,0,0,0,0,0,1,1,0.0
1,2,'2 ',0.02,0.06,0.002,0,0,0,0,0,0,0,1,1,0.0
0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA
Q
";
let ckt = |b: &Branch| {
b.extras
.get("id")
.and_then(|v| v.as_str())
.map(str::to_owned)
};
let net = parse_psse(raw).unwrap();
assert_eq!(net.branches.len(), 2);
assert_eq!(ckt(&net.branches[0]).as_deref(), Some("1"));
assert_eq!(ckt(&net.branches[1]).as_deref(), Some("2"));
let net2 = parse_psse(&write_psse(&net).text).unwrap();
assert_eq!(net2.branches.len(), 2);
assert_eq!(ckt(&net2.branches[0]).as_deref(), Some("1"));
assert_eq!(ckt(&net2.branches[1]).as_deref(), Some("2"));
let mut synth = net.clone();
for b in &mut synth.branches {
b.extras.remove("id");
}
let net3 = parse_psse(&write_psse(&synth).text).unwrap();
let ids: Vec<_> = net3.branches.iter().filter_map(&ckt).collect();
assert_eq!(ids, vec!["1".to_string(), "2".to_string()]);
}
#[test]
fn reads_and_writes_solver_params() {
let raw = r"0, 100.00, 34, 0, 1, 60.00 / x
CASE
COMMENT
GENERAL, THRSHZ=0.0001
NEWTON, TOLN=0.1, ITMXN=25
SOLVER, ACTAPS=1, AREAIN=0, PHSHFT=1, DCTAPS=1, SWSHNT=0
0 / END OF SYSTEM-WIDE DATA, BEGIN BUS DATA
1,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
0 / END OF BUS DATA, BEGIN LOAD DATA
Q
";
let net = parse_psse(raw).unwrap();
let sp = net.solver.as_ref().expect("solver params parsed");
close(sp.zero_impedance_threshold.unwrap(), 0.0001);
close(sp.newton_tolerance.unwrap(), 0.1);
assert_eq!(sp.max_iterations, Some(25));
assert_eq!(sp.adjust_taps, Some(true));
assert_eq!(sp.adjust_area_interchange, Some(false));
assert_eq!(sp.adjust_phase_shift, Some(true));
assert_eq!(sp.adjust_switched_shunt, Some(false));
let net2 = parse_psse(&write_psse_rev(&net, 34).text).unwrap();
let sp2 = net2
.solver
.as_ref()
.expect("solver params survive the write");
close(sp2.newton_tolerance.unwrap(), 0.1);
assert_eq!(sp2.max_iterations, Some(25));
assert_eq!(sp2.adjust_taps, Some(true));
assert_eq!(sp2.adjust_area_interchange, Some(false));
}
#[test]
fn reads_and_writes_area_records() {
let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
CASE
COMMENT
1,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
5,'B5 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
0 / END OF BUS DATA, BEGIN LOAD DATA
0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
0 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
0 / END OF GENERATOR DATA, BEGIN BRANCH DATA
0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA
1, 5, 100.0, 10.0, 'AREA-ONE '
0 / END OF AREA DATA, BEGIN TWO-TERMINAL DC DATA
Q
";
let net = parse_psse(raw).unwrap();
assert_eq!(net.areas.len(), 1, "the area record was read");
let a = &net.areas[0];
assert_eq!(a.number, 1);
assert_eq!(a.slack_bus, Some(BusId(5)));
close(a.net_interchange, 100.0);
close(a.tolerance, 10.0);
assert_eq!(a.name.as_deref(), Some("AREA-ONE"));
let net2 = parse_psse(&write_psse(&net).text).unwrap();
assert_eq!(net2.areas.len(), 1);
let a2 = &net2.areas[0];
assert_eq!(a2.number, 1);
assert_eq!(a2.slack_bus, Some(BusId(5)));
close(a2.net_interchange, 100.0);
assert_eq!(a2.name.as_deref(), Some("AREA-ONE"));
}
#[test]
fn reads_and_writes_a_switched_shunt() {
let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
CASE
COMMENT
1,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
3,'B3 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
7,'B7 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
0 / END OF BUS DATA, BEGIN LOAD DATA
0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
0 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
0 / END OF GENERATOR DATA, BEGIN BRANCH DATA
0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA
0 / END OF AREA DATA, BEGIN SWITCHED SHUNT DATA
3, 2, 0, 1, 1.05, 0.95, 7, 100.0, '', 19.0, 2, 25.0, 1, 50.0
0 / END OF SWITCHED SHUNT DATA, BEGIN GNE DEVICE DATA
Q
";
let net = parse_psse(raw).unwrap();
assert_eq!(net.shunts.len(), 1);
let sh = &net.shunts[0];
assert_eq!(sh.bus, BusId(3));
close(sh.b, 19.0);
let c = sh.control.as_ref().expect("switched-shunt control parsed");
assert_eq!(c.mode, SwitchedShuntMode::Discrete);
close(c.vhigh, 1.05);
close(c.vlow, 0.95);
assert_eq!(c.control_bus, Some(BusId(7)));
close(c.rmpct, 100.0);
assert_eq!(c.blocks.len(), 2);
assert_eq!(c.blocks[0].steps, 2);
close(c.blocks[0].b, 25.0);
assert_eq!(c.blocks[1].steps, 1);
close(c.blocks[1].b, 50.0);
let text = write_psse(&net).text;
assert!(text.contains("BEGIN SWITCHED SHUNT DATA"));
let net2 = parse_psse(&text).unwrap();
assert_eq!(net2.shunts.len(), 1);
let c2 = net2.shunts[0]
.control
.as_ref()
.expect("control survives the write");
assert_eq!(c2.mode, SwitchedShuntMode::Discrete);
assert_eq!(c2.control_bus, Some(BusId(7)));
assert_eq!(c2.blocks.len(), 2);
close(c2.blocks[0].b, 25.0);
close(net2.shunts[0].b, 19.0);
}
#[test]
fn v35_switched_shunt_write_round_trips_through_the_id_column() {
let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
CASE
COMMENT
3,'B3 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
7,'B7 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
0 / END OF BUS DATA, BEGIN LOAD DATA
0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
0 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
0 / END OF GENERATOR DATA, BEGIN BRANCH DATA
0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA
0 / END OF AREA DATA, BEGIN SWITCHED SHUNT DATA
3, 2, 0, 1, 1.05, 0.95, 7, 100.0, '', 19.0, 2, 25.0, 1, 50.0
0 / END OF SWITCHED SHUNT DATA, BEGIN GNE DEVICE DATA
Q
";
let net = parse_psse(raw).unwrap();
let text = write_psse_rev(&net, 35).text;
let net2 = parse_psse(&text).unwrap();
assert_eq!(net2.shunts.len(), 1);
let sh = &net2.shunts[0];
assert_eq!(sh.bus, BusId(3));
close(sh.b, 19.0);
let c = sh
.control
.as_ref()
.expect("v35 switched-shunt control survives the write");
assert_eq!(c.mode, SwitchedShuntMode::Discrete);
close(c.vhigh, 1.05);
close(c.vlow, 0.95);
assert_eq!(c.control_bus, Some(BusId(7)));
close(c.rmpct, 100.0);
assert_eq!(c.blocks.len(), 2);
close(c.blocks[0].b, 25.0);
close(c.blocks[1].b, 50.0);
}
#[test]
fn reads_and_writes_a_generator_remote_regulated_bus() {
let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
CASE
COMMENT
1,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
3,'B3 ', 18.0,2,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
7,'B7 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
0 / END OF BUS DATA, BEGIN LOAD DATA
0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
0 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
3,'1', 50.0, 5.0, 30.0, -20.0, 1.02, 7, 100.0, 0, 1, 0, 0, 1, 1, 100.0, 80.0, 0.0, 1, 1
1,'1', 10.0, 0.0, 10.0, -10.0, 1.0, 0, 100.0, 0, 1, 0, 0, 1, 1, 100.0, 50.0, 0.0, 1, 1
0 / END OF GENERATOR DATA, BEGIN BRANCH DATA
0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA
Q
";
let net = parse_psse(raw).unwrap();
assert_eq!(net.generators.len(), 2);
let g3 = net.generators.iter().find(|g| g.bus == BusId(3)).unwrap();
assert_eq!(
g3.regulated_bus,
Some(BusId(7)),
"IREG names the remote regulated bus"
);
let g1 = net.generators.iter().find(|g| g.bus == BusId(1)).unwrap();
assert_eq!(g1.regulated_bus, None);
let text = write_psse(&net).text;
let net2 = parse_psse(&text).unwrap();
let g3b = net2.generators.iter().find(|g| g.bus == BusId(3)).unwrap();
assert_eq!(g3b.regulated_bus, Some(BusId(7)));
let g1b = net2.generators.iter().find(|g| g.bus == BusId(1)).unwrap();
assert_eq!(g1b.regulated_bus, None);
}
#[test]
fn reads_a_v35_generator_record_with_nreg() {
let raw = "0, 100.00, 35, 0, 0, 60.00 / x
CASE
COMMENT
1,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
0 / END OF BUS DATA, BEGIN LOAD DATA
0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
0 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
1,'1 ',50.0,5.0,20.0,-10.0,1.0,0,2,900.0,0.0,1.0,0.0,0.0,1.0,0,100.0,80.0,10.0,0.0,1,1.0
0 / END OF GENERATOR DATA, BEGIN BRANCH DATA
0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA
Q
";
let net = parse_psse(raw).unwrap();
assert_eq!(net.generators.len(), 1);
let g = &net.generators[0];
close(g.mbase, 900.0);
assert!(!g.in_service, "STAT = 0 at the shifted index");
close(g.pmax, 80.0);
close(g.pmin, 10.0);
assert_eq!(g.regulated_bus, None, "IREG stays at field 7");
let net2 = parse_psse(&write_psse_rev(&net, 35).text).unwrap();
let g2 = &net2.generators[0];
close(g2.mbase, 900.0);
assert!(!g2.in_service);
close(g2.pmax, 80.0);
close(g2.pmin, 10.0);
}
#[test]
fn stale_control_pointers_warn_and_drop() {
let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
CASE
COMMENT
1,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
2,'B2 ', 18.0,2,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
0 / END OF BUS DATA, BEGIN LOAD DATA
0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
0 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
1,'1', 50.0, 5.0, 30.0, -20.0, 1.02, 99, 100.0, 0, 1, 0, 0, 1, 1, 100.0, 80.0, 0.0, 1, 1
0 / END OF GENERATOR DATA, BEGIN BRANCH DATA
0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
1, 2, 0, '1', 1, 1, 1, 0, 0, 2, 'REG ', 1, 1, 1, 0, 1, 0, 1, 0, 1, ' '
0.01, 0.10, 100.0
1.025, 0, 2.5, 100.0, 90.0, 80.0, 1, 98, 1.08, 0.92, 1.05, 0.98, 17, 0, 0, 0, 0
1.0, 0
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA
1, 97, 0.0, 0.0, 'AREA '
0 / END OF AREA DATA, BEGIN TWO-TERMINAL DC DATA
0 / END OF TWO-TERMINAL DC DATA, BEGIN VSC DC LINE DATA
0 / END OF VSC DC LINE DATA, BEGIN IMPEDANCE CORRECTION DATA
0 / END OF IMPEDANCE CORRECTION DATA, BEGIN MULTI-TERMINAL DC DATA
0 / END OF MULTI-TERMINAL DC DATA, BEGIN MULTI-SECTION LINE DATA
0 / END OF MULTI-SECTION LINE DATA, BEGIN ZONE DATA
0 / END OF ZONE DATA, BEGIN INTER-AREA TRANSFER DATA
0 / END OF INTER-AREA TRANSFER DATA, BEGIN OWNER DATA
0 / END OF OWNER DATA, BEGIN FACTS DEVICE DATA
0 / END OF FACTS DEVICE DATA, BEGIN SWITCHED SHUNT DATA
2, 2, 0, 1, 1.05, 0.95, 96, 100.0, '', 19.0, 2, 25.0
0 / END OF SWITCHED SHUNT DATA, BEGIN GNE DEVICE DATA
Q
";
let mut warnings = Vec::new();
let net =
parse_psse_source(std::sync::Arc::new(raw.to_string()), None, &mut warnings).unwrap();
assert_eq!(net.generators[0].regulated_bus, None);
assert_eq!(
net.branches[0]
.control
.as_ref()
.and_then(|c| c.controlled_bus),
None
);
assert_eq!(
net.shunts[0].control.as_ref().and_then(|c| c.control_bus),
None
);
assert_eq!(net.areas[0].slack_bus, None);
assert!(
warnings.iter().any(|w| w.contains("GENERATOR DATA")
&& w.contains("IREG")
&& w.contains("missing bus id 99")),
"missing IREG warning: {warnings:?}"
);
assert!(
warnings.iter().any(|w| w.contains("TRANSFORMER DATA")
&& w.contains("CONT")
&& w.contains("missing bus id 98")),
"missing CONT warning: {warnings:?}"
);
assert!(
warnings.iter().any(|w| w.contains("SWITCHED SHUNT DATA")
&& w.contains("SWREM")
&& w.contains("missing bus id 96")),
"missing SWREM warning: {warnings:?}"
);
assert!(
warnings.iter().any(|w| w.contains("AREA DATA")
&& w.contains("ISW")
&& w.contains("missing bus id 97")),
"missing ISW warning: {warnings:?}"
);
}
#[test]
fn truncated_transformer_continuation_names_expected_line() {
let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
CASE
COMMENT
1,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
2,'B2 ', 18.0,2,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
0 / END OF BUS DATA, BEGIN LOAD DATA
0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
0 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
0 / END OF GENERATOR DATA, BEGIN BRANCH DATA
0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
1, 2, 0, '1', 1, 1, 1, 0, 0, 2, 'REG ', 1, 1, 1, 0, 1, 0, 1, 0, 1, ' '
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA
Q
";
let err = parse_psse(raw).unwrap_err().to_string();
assert!(
err.contains("transformer record ended before transformer impedance line"),
"got {err}"
);
}
#[test]
fn unmodeled_section_counts_skip_bare_terminators() {
let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
CASE
COMMENT
1,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
0 / END OF BUS DATA, BEGIN LOAD DATA
0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
0 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
0 / END OF GENERATOR DATA, BEGIN BRANCH DATA
0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA
0 / END OF AREA DATA, BEGIN TWO-TERMINAL DC DATA
0 / END OF TWO-TERMINAL DC DATA, BEGIN VSC DC LINE DATA
'VSC1', 1
2, 3
0
0 / END OF VSC DC LINE DATA, BEGIN IMPEDANCE CORRECTION DATA
Q
";
let mut warnings = Vec::new();
parse_psse_source(std::sync::Arc::new(raw.to_string()), None, &mut warnings).unwrap();
assert!(
warnings
.iter()
.any(|w| w.contains("VSC DC LINE section (2 record line(s))")),
"bare terminator should not be counted as skipped data: {warnings:?}"
);
}
#[test]
fn reads_a_v35_switched_shunt_with_an_id_column() {
let raw = "0, 100.00, 35, 0, 0, 60.00 / x
CASE
COMMENT
5,'B5 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
7,'B7 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
0 / END OF BUS DATA, BEGIN LOAD DATA
0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
0 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
0 / END OF GENERATOR DATA, BEGIN BRANCH DATA
0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA
0 / END OF AREA DATA, BEGIN SWITCHED SHUNT DATA
5,'1 ',2,0,1,1.05,0.95,7,3,80.0,'',19.0,1,2,25.0,0,1,50.0
0 / END OF SWITCHED SHUNT DATA, BEGIN GNE DEVICE DATA
Q
";
let net = parse_psse(raw).unwrap();
assert_eq!(net.shunts.len(), 1);
let sh = &net.shunts[0];
assert_eq!(sh.bus, BusId(5));
close(sh.b, 19.0);
assert!(sh.in_service);
let c = sh.control.as_ref().expect("switched-shunt control parsed");
assert_eq!(c.mode, SwitchedShuntMode::Discrete);
close(c.vhigh, 1.05);
close(c.vlow, 0.95);
assert_eq!(
c.control_bus,
Some(BusId(7)),
"SWREG at field 7, not NREG at 8"
);
close(c.rmpct, 80.0);
assert_eq!(c.blocks.len(), 2);
assert_eq!(c.blocks[0].steps, 2);
close(c.blocks[0].b, 25.0);
assert_eq!(c.blocks[1].steps, 1);
close(c.blocks[1].b, 50.0);
}
#[test]
fn reads_and_writes_a_two_terminal_dc_line() {
let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
CASE
COMMENT
1,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
4,'B4 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
5,'B5 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
0 / END OF BUS DATA, BEGIN LOAD DATA
0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
0 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
0 / END OF GENERATOR DATA, BEGIN BRANCH DATA
0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA
0 / END OF AREA DATA, BEGIN TWO-TERMINAL DC DATA
'DCLINE1', 1, 2.5, 350.0, 500.0, 0.0, 0.0, 0.0, 'I', 0.0, 20, 1.0
4, 1, 15.0, 5.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.5, 0.51, 0.00625, 0, 0, 0, '1', 0.0
5, 1, 15.0, 5.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.5, 0.51, 0.00625, 0, 0, 0, '1', 0.0
0 / END OF TWO-TERMINAL DC DATA, BEGIN VSC DC LINE DATA
Q
";
let net = parse_psse(raw).unwrap();
assert_eq!(net.hvdc.len(), 1, "the two-terminal DC line was read");
let dc = &net.hvdc[0];
assert_eq!(dc.from, BusId(4), "rectifier bus is the from end");
assert_eq!(dc.to, BusId(5), "inverter bus is the to end");
assert!(dc.in_service);
close(dc.pf, 350.0);
close(dc.pt, 350.0);
let net2 = parse_psse(&write_psse(&net).text).unwrap();
assert_eq!(net2.hvdc.len(), 1, "the DC line survives the write");
let dc2 = &net2.hvdc[0];
assert_eq!(dc2.from, BusId(4));
assert_eq!(dc2.to, BusId(5));
assert!(dc2.in_service);
close(dc2.pf, 350.0);
}
#[test]
fn reads_and_writes_a_regulating_transformer_control() {
let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
CASE
COMMENT
1,'B1 ', 230.0,3,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
2,'B2 ', 138.0,1,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
3,'B3 ', 13.8,1,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
0 / END OF BUS DATA, BEGIN LOAD DATA
0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
0 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
0 / END OF GENERATOR DATA, BEGIN BRANCH DATA
0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
1, 2, 0, '1', 1, 1, 1, 0, 0, 2, 'REG ', 1, 1, 1, 0, 1, 0, 1, 0, 1, ' '
0.01, 0.10, 100.0
1.025, 0, 2.5, 100.0, 90.0, 80.0, 1, 3, 1.08, 0.92, 1.05, 0.98, 17, 0, 0, 0, 0
1.0, 0
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA
Q
";
let net = parse_psse(raw).unwrap();
assert_eq!(net.branches.len(), 1);
let c = net.branches[0].control.as_ref().expect("control parsed");
assert_eq!(c.mode, TransformerControlMode::Voltage);
assert_eq!(c.controlled_bus, Some(BusId(3)));
close(c.tap_max, 1.08);
close(c.tap_min, 0.92);
close(c.band_min, 0.98);
assert_eq!(c.ntp, 17);
close(c.mva_base, 100.0);
let net2 = parse_psse(&write_psse(&net).text).unwrap();
let c2 = net2.branches[0].control.as_ref().expect("control survives");
assert_eq!(c2.mode, TransformerControlMode::Voltage);
assert_eq!(c2.controlled_bus, Some(BusId(3)));
close(c2.tap_max, 1.08);
assert_eq!(c2.ntp, 17);
close(net2.branches[0].tap, 1.025);
close(net2.branches[0].shift, 2.5);
}
#[test]
fn reads_and_writes_a_three_winding_transformer() {
let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
CASE
COMMENT
1,'B1 ', 230.0,3,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
2,'B2 ', 138.0,1,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
3,'B3 ', 13.8,1,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
0 / END OF BUS DATA, BEGIN LOAD DATA
0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
0 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
0 / END OF GENERATOR DATA, BEGIN BRANCH DATA
0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
1, 2, 3, '1', 1, 1, 1, 0.0, 0.0, 2, 'T3W ', 1, 1, 1, 0, 1, 0, 1, 0, 1, ' '
0.01, 0.10, 100.0, 0.02, 0.20, 100.0, 0.03, 0.30, 100.0, 0.98, -1.5
1.0, 230.0, 0.0, 100.0, 90.0, 80.0, 0, 0, 1.1, 0.9, 1.1, 0.9, 33, 0, 0, 0, 0
1.025, 138.0, 0.0, 110.0, 0, 0, 0, 0, 1.1, 0.9, 1.1, 0.9, 33, 0, 0, 0, 0
0.95, 13.8, 30.0, 50.0, 0, 0, 0, 0, 1.1, 0.9, 1.1, 0.9, 33, 0, 0, 0, 0
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA
Q
";
let net = parse_psse(raw).unwrap();
assert_eq!(
net.transformers_3w.len(),
1,
"the 3-winding record was read"
);
assert!(net.branches.is_empty(), "a 3W is not folded into branches");
let t = &net.transformers_3w[0];
assert_eq!(
[t.windings[0].bus, t.windings[1].bus, t.windings[2].bus],
[BusId(1), BusId(2), BusId(3)]
);
close(t.z[0].r, 0.01);
close(t.z[2].x, 0.30);
close(t.windings[0].rate_a, 100.0);
close(t.windings[1].tap, 1.025);
close(t.windings[2].shift, 30.0);
close(t.star_vm, 0.98);
close(t.star_va, -1.5);
let net2 = parse_psse(&write_psse(&net).text).unwrap();
assert_eq!(net2.transformers_3w.len(), 1);
assert!(net2.branches.is_empty());
let t2 = &net2.transformers_3w[0];
close(t2.z[1].x, 0.20);
close(t2.windings[2].tap, 0.95);
close(t2.star_va, -1.5);
assert_eq!(t2.name.as_deref(), Some("T3W"));
}
#[test]
fn three_winding_cross_format_warns_and_survives_normalization() {
let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
CASE
COMMENT
1,'B1 ', 230.0,3,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
2,'B2 ', 138.0,1,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
3,'B3 ', 13.8,1,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
0 / END OF BUS DATA, BEGIN LOAD DATA
0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
0 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
1,'1 ',50.0,5.0,20.0,-10.0,1.0,0,100.0,0.0,1.0,0.0,0.0,1.0,1,100.0,80.0,10.0
0 / END OF GENERATOR DATA, BEGIN BRANCH DATA
0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
1, 2, 3, '1', 1, 1, 1, 0.0, 0.0, 2, 'T3W ', 1, 1, 1, 0, 1, 0, 1, 0, 1, ' '
0.01, 0.10, 100.0, 0.02, 0.20, 100.0, 0.03, 0.30, 100.0, 0.98, -1.5
1.0, 230.0, 0.0, 100.0, 90.0, 80.0, 0, 0, 1.1, 0.9, 1.1, 0.9, 33, 0, 0, 0, 0
1.025, 138.0, 0.0, 110.0, 0, 0, 0, 0, 1.1, 0.9, 1.1, 0.9, 33, 0, 0, 0, 0
0.95, 13.8, 30.0, 50.0, 0, 0, 0, 0, 1.1, 0.9, 1.1, 0.9, 33, 0, 0, 0, 0
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA
Q
";
let net = parse_psse(raw).unwrap();
assert_eq!(net.transformers_3w.len(), 1);
let mpc = net.to_format(crate::TargetFormat::Matpower).unwrap();
assert!(
mpc.warnings.iter().any(|w| w.contains("3-winding")),
"MATPOWER write must warn on the dropped 3-winding transformer, got {:?}",
mpc.warnings
);
let norm = net.to_normalized().unwrap();
assert_eq!(norm.transformers_3w.len(), 1, "to_normalized keeps the 3W");
norm.validate().unwrap();
}
#[test]
fn writing_a_different_revision_re_emits_instead_of_echoing() {
let raw = "0, 100.00, 33, 0, 0, 60.00 / x
CASE
COMMENT
1,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
0 / END OF BUS DATA, BEGIN LOAD DATA
0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
0 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
0 / END OF GENERATOR DATA, BEGIN BRANCH DATA
0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA
Q
";
let parsed = crate::parse_str(raw, "psse").unwrap();
let same = crate::write_as(&parsed.network, crate::TargetFormat::Psse { rev: 33 }).unwrap();
assert_eq!(same.text, raw, "same revision echoes the retained source");
let v34 = crate::write_as(&parsed.network, crate::TargetFormat::Psse { rev: 34 }).unwrap();
assert_ne!(v34.text, raw, "a different revision must re-emit, not echo");
assert!(
v34.text.contains("END OF SYSTEM-WIDE DATA"),
"v34 output carries the system-wide marker, got:\n{}",
v34.text
);
}
#[test]
fn warns_on_a_nonempty_unmodeled_section() {
let raw = "0, 100.00, 34, 0, 0, 60.00 / x
CASE
COMMENT
1,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
0 / END OF BUS DATA, BEGIN LOAD DATA
0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
0 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
0 / END OF GENERATOR DATA, BEGIN BRANCH DATA
0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA
0 / END OF AREA DATA, BEGIN SUBSTATION DATA
1, 'SUB1', 21.3, -157.8, 0.001
0 / END OF SUBSTATION DATA, BEGIN GNE DEVICE DATA
Q
";
let parsed = crate::parse_str(raw, "psse").unwrap();
assert!(
parsed
.warnings
.iter()
.any(|w| w.contains("SUBSTATION") && w.contains("not modeled")),
"an unmodeled substation section must be reported, got {:?}",
parsed.warnings
);
}
#[test]
fn reads_writes_and_drops_an_emergency_voltage_band() {
let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
CASE
COMMENT
1,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.2,0.8
2,'B2 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
0 / END OF BUS DATA, BEGIN LOAD DATA
0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
0 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
1,'1 ',50.0,5.0,20.0,-10.0,1.0,0,100.0,0.0,1.0,0.0,0.0,1.0,1,100.0,80.0,10.0
0 / END OF GENERATOR DATA, BEGIN BRANCH DATA
0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA
Q
";
let net = parse_psse(raw).unwrap();
let b1 = net.buses.iter().find(|b| b.id == BusId(1)).unwrap();
assert!(
b1.evhi.is_some() && b1.evlo.is_some(),
"distinct band typed"
);
close(b1.evhi.unwrap(), 1.2);
close(b1.evlo.unwrap(), 0.8);
let b2 = net.buses.iter().find(|b| b.id == BusId(2)).unwrap();
assert!(
b2.evhi.is_none() && b2.evlo.is_none(),
"an emergency band equal to the normal band stays None"
);
let net2 = parse_psse(&write_psse(&net).text).unwrap();
let r1 = net2.buses.iter().find(|b| b.id == BusId(1)).unwrap();
close(r1.evhi.unwrap(), 1.2);
close(r1.evlo.unwrap(), 0.8);
let mpc = net.to_format(crate::TargetFormat::Matpower).unwrap();
assert!(
mpc.warnings
.iter()
.any(|w| w.contains("emergency voltage band")),
"MATPOWER write must warn on the dropped emergency band, got {:?}",
mpc.warnings
);
}
#[test]
fn writes_v34_v35_layouts_that_round_trip() {
let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
CASE
COMMENT
1,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
2,'B2 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
0 / END OF BUS DATA, BEGIN LOAD DATA
2,'1',1,1,1,10.0,5.0,0,0,0,0,1,1,0
0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
0 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
0 / END OF GENERATOR DATA, BEGIN BRANCH DATA
1,2,'1 ',0.01,0.05,0.001,111.0,90.0,80.0,0,0,0,0,1,1,0,1,1
0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA
Q
";
let net = parse_psse(raw).unwrap();
for rev in [34u32, 35] {
let text = write_psse_rev(&net, rev).text;
assert!(
text.contains("END OF SYSTEM-WIDE DATA, BEGIN BUS DATA"),
"rev {rev} missing the system-wide marker"
);
let header = text.lines().next().unwrap();
assert!(header.contains(&format!(", {rev}, ")), "header {header:?}");
let branch = text.lines().find(|l| l.starts_with("1, 2, '1'")).unwrap();
assert!(
branch.split(',').count() >= 24,
"rev {rev} branch is not the named layout: {branch:?}"
);
let back = parse_psse(&text).unwrap();
assert_eq!(back.buses.len(), 2);
assert_eq!(back.loads.len(), 1);
assert_eq!(back.branches.len(), 1);
close(back.branches[0].rate_a, 111.0);
close(back.loads[0].p, 10.0);
assert!(back.branches[0].in_service);
}
assert!(
write_psse_rev(&net, 35).text.contains(", ''"),
"v35 load should carry a LOADTYPE field"
);
}
#[test]
fn writer_sanitizes_bus_names_that_would_corrupt_a_record() {
let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
CASE
COMMENT
1,'BUS1 ', 230.0000,3,1,1,1,1.00000,0.0000,1.1000,0.9000,1.1000,0.9000
2,'BUS2 ', 138.0000,1,1,1,1,1.00000,0.0000,1.1000,0.9000,1.1000,0.9000
0 / END OF BUS DATA, BEGIN LOAD DATA
0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
0 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
0 / END OF GENERATOR DATA, BEGIN BRANCH DATA
0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA
Q
";
let mut net = parse_psse(raw).unwrap();
net.buses[0].name = Some("O'Brien/X".to_string());
let conv = write_psse(&net);
let reparsed = parse_psse(&conv.text).unwrap();
assert_eq!(reparsed.buses.len(), 2);
close(reparsed.buses[0].base_kv, 230.0);
close(reparsed.buses[1].base_kv, 138.0);
let name = reparsed.buses[0].name.as_deref().unwrap();
assert!(!name.contains('\'') && !name.contains('/'), "got {name:?}");
assert!(
conv.warnings
.iter()
.any(|w| w.contains("quoted PSS/E field")),
"expected a sanitization warning, got {:?}",
conv.warnings
);
}
#[test]
fn malformed_first_bus_id_is_not_treated_as_system_wide_data() {
let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic malformed export
CASE
COMMENT
BAD,'BUS1 ', 230.0000,3,1,1,1,1.00000,0.0000,1.1000,0.9000,1.1000,0.9000
0 / END OF BUS DATA, BEGIN LOAD DATA
Q
";
let err = parse_psse(raw).unwrap_err();
assert!(
err.to_string().contains("bus record missing numeric id"),
"malformed bus id should be reported directly: {err}"
);
}
}