use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::sync::Arc;
use super::Conversion;
use crate::network::{
Branch, Bus, BusId, BusType, Extras, Generator, Load, Network, Shunt, SourceFormat,
};
use crate::{Error, Result};
const FMT: &str = "PSS/E .raw";
const REV: u32 = 33;
#[must_use]
#[expect(clippy::too_many_lines)]
pub fn write_psse(net: &Network) -> Conversion {
let mut warnings = Vec::new();
let mut nonfinite = false;
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, 0, 60.00 / powerio export: {}",
net.base_mva, net.name
);
let _ = writeln!(s, "{}", net.name);
let _ = writeln!(s);
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 name = b.name.as_deref().unwrap_or("");
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.vmax),
num(b.vmin)
);
}
let _ = writeln!(s, "0 / END OF BUS DATA, BEGIN LOAD DATA");
for l in &net.loads {
let (area, zone) = bus_area.get(&l.bus).copied().unwrap_or((1, 1));
let _ = writeln!(
s,
"{}, '1', {}, {}, {}, {}, {}, 0, 0, 0, 0, 1, 1, 0",
l.bus,
i32::from(l.in_service),
area,
zone,
num(l.p),
num(l.q)
);
}
let _ = writeln!(s, "0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA");
for sh in &net.shunts {
let _ = writeln!(
s,
"{}, '1', {}, {}, {}",
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");
for g in &net.generators {
let _ = writeln!(
s,
"{}, '1', {}, {}, {}, {}, {}, 0, {}, 0, 1, 0, 0, 1, {}, 100, {}, {}, 1, 1",
g.bus,
num(g.pg),
num(g.qg),
num(g.qmax),
num(g.qmin),
num(g.vg),
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");
for br in net.branches.iter().filter(|b| !b.is_transformer()) {
let _ = writeln!(
s,
"{}, {}, '1', {}, {}, {}, {}, {}, {}, 0, 0, 0, 0, {}, 1, 0, 1, 1",
br.from,
br.to,
num(br.r),
num(br.x),
num(br.b),
num(br.rate_a),
num(br.rate_b),
num(br.rate_c),
i32::from(br.in_service)
);
}
let _ = writeln!(s, "0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA");
for br in net.branches.iter().filter(|b| b.is_transformer()) {
let _ = writeln!(
s,
"{}, {}, 0, '1', 1, 1, 1, 0, 0, 2, ' ', {}, 1, 1, 0, 1, 0, 1, 0, 1, ' '",
br.from,
br.to,
i32::from(br.in_service)
);
let _ = writeln!(s, "{}, {}, {}", num(br.r), num(br.x), net.base_mva);
let _ = writeln!(
s,
"{}, 0, {}, {}, {}, {}, 0, 0, 1.1, 0.9, 1.1, 0.9, 33, 0, 0, 0, 0",
num(br.effective_tap()),
num(br.shift),
num(br.rate_a),
num(br.rate_b),
num(br.rate_c)
);
let _ = writeln!(s, "1.0, 0");
}
let _ = writeln!(s, "0 / END OF TRANSFORMER DATA, BEGIN AREA DATA");
for line in EMPTY_SECTIONS {
let _ = writeln!(s, "{line}");
}
let _ = writeln!(s, "Q");
if !net.hvdc.is_empty() {
warnings.push(format!(
"{} dcline(s) dropped: PSS/E HVDC not modeled",
net.hvdc.len()
));
}
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(),
);
}
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());
}
Conversion { text: s, warnings }
}
fn ide(kind: BusType) -> u8 {
kind as u8 }
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> {
parse_psse_source(Arc::new(content.to_owned()), None)
}
pub(crate) fn parse_psse_source(source: Arc<String>, name_hint: Option<&str>) -> 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 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 section = Section::Bus;
let mut saw_bus_marker = false;
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) {
section = section_after_marker(line);
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::Skip;
}
Section::Bus => buses.push(read_bus(&f)?),
Section::Load => loads.push(read_load(&f)?),
Section::FixedShunt => shunts.push(read_shunt(&f)?),
Section::SwitchedShunt => shunts.push(read_switched_shunt(&f)?),
Section::Generator => generators.push(read_gen(&f)?),
Section::Branch => branches.push(read_branch(&f, raw_rev)?),
Section::Transformer => {
let two_winding = f.get(2).and_then(|x| x.parse::<i64>().ok()) == Some(0);
let l2 = lines.next().map_or("", str::trim);
let l3 = lines.next().map_or("", str::trim);
let l4 = lines.next().map_or("", str::trim);
if two_winding {
branches.push(read_transformer(&f, &fields(l2), &fields(l3), &fields(l4))?);
} else {
lines.next();
}
}
Section::Skip => {}
}
}
let net = Network {
name,
base_mva,
buses,
loads,
shunts,
branches,
generators,
storage: Vec::new(),
hvdc: Vec::new(),
source_format: SourceFormat::Psse,
source: Some(source),
};
net.check_references(FMT)?;
Ok(net)
}
#[derive(Clone, Copy)]
enum Section {
Bus,
Load,
FixedShunt,
SwitchedShunt,
Generator,
Branch,
Transformer,
Skip,
}
fn section_after_marker(line: &str) -> Section {
let u = line.to_ascii_uppercase();
if u.contains("BEGIN BUS DATA") {
Section::Bus
} else if u.contains("BEGIN LOAD DATA") {
Section::Load
} else if u.contains("BEGIN FIXED SHUNT DATA") {
Section::FixedShunt
} else if u.contains("BEGIN SWITCHED SHUNT DATA") {
Section::SwitchedShunt
} else if u.contains("BEGIN GENERATOR DATA") {
Section::Generator
} else if u.contains("BEGIN BRANCH DATA") {
Section::Branch
} else if u.contains("BEGIN TRANSFORMER DATA") {
Section::Transformer
} else {
Section::Skip
}
}
fn is_terminator(line: &str) -> bool {
fields(line).first().map(String::as_str) == Some("0")
}
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")
)
}
fn fields(line: &str) -> Vec<String> {
let code = line.split('/').next().unwrap_or(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),
Some(s) => s.parse().map_err(|_| bad_field(i, s)),
}
}
fn bustype(code: i64) -> BusType {
match code {
2 => BusType::Pv,
3 => BusType::Ref,
4 => BusType::Isolated,
_ => BusType::Pq,
}
}
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());
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: num_at(f, 9, 1.1)?,
vmin: num_at(f, 10, 0.9)?,
area: id_at(f, 4, 0)?,
zone: id_at(f, 5, 0)?,
name,
extras: Extras::new(),
})
}
fn read_load(f: &[String]) -> Result<Load> {
Ok(Load {
bus: BusId(id_at(f, 0, 0)?),
p: num_at(f, 5, 0.0)?,
q: num_at(f, 6, 0.0)?,
in_service: on_at(f, 2, true)?,
extras: Extras::new(),
})
}
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)?,
extras: Extras::new(),
})
}
fn read_switched_shunt(f: &[String]) -> Result<Shunt> {
Ok(Shunt {
bus: BusId(id_at(f, 0, 0)?),
g: 0.0,
b: num_at(f, 9, 0.0)?,
in_service: on_at(f, 3, true)?,
extras: Extras::new(),
})
}
fn read_gen(f: &[String]) -> Result<Generator> {
Ok(Generator {
bus: BusId(id_at(f, 0, 0)?),
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, 100.0)?,
in_service: on_at(f, 14, true)?,
pmax: num_at(f, 16, 0.0)?,
pmin: num_at(f, 17, 0.0)?,
cost: None,
caps: Default::default(),
})
}
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 };
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: num_at(f, 5, 0.0)?,
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)?,
tap: 0.0,
shift: 0.0,
in_service: on_at(f, status, true)?,
angmin: -360.0,
angmax: 360.0,
extras: Extras::new(),
})
}
fn read_transformer(l1: &[String], l2: &[String], l3: &[String], _l4: &[String]) -> Result<Branch> {
Ok(Branch {
from: BusId(id_at(l1, 0, 0)?),
to: BusId(id_at(l1, 1, 0)?),
r: num_at(l2, 0, 0.0)?,
x: num_at(l2, 1, 0.0)?,
b: 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)?,
tap: num_at(l3, 0, 1.0)?,
shift: num_at(l3, 2, 0.0)?,
in_service: on_at(l1, 11, true)?,
angmin: -360.0,
angmax: 360.0,
extras: Extras::new(),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn close(actual: f64, expected: f64) {
assert!((actual - expected).abs() < 1e-12, "{actual} != {expected}");
}
#[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,0.0,0.0,0.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 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!(net.branches[0].in_service);
}
#[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 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}"
);
}
}