use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::fmt::Write as _;
use std::sync::Arc;
use serde_json::{Number, Value};
use super::{Conversion, sanitize_quoted, warn_extra_branch_rating_sets};
use crate::network::{
Branch, Bus, BusId, BusType, Extras, Generator, Hvdc, Impedance, Load, LoadVoltageModel,
Network, Shunt, SourceFormat, Transformer3W, Winding,
};
use crate::{Error, Result};
const FMT: &str = "PSLF .epc";
const NAME_FORBIDDEN: &[char] = &['"'];
pub fn parse_pslf(content: &str) -> Result<Network> {
let mut warnings = Vec::new();
parse_pslf_source(Arc::new(content.to_owned()), None, &mut warnings)
}
pub(crate) fn parse_pslf_source(
source: Arc<String>,
name_hint: Option<&str>,
warnings: &mut Vec<String>,
) -> Result<Network> {
let doc = parse_document(&source, warnings);
let base_mva = doc.base_mva(warnings);
let name = doc.name(name_hint);
let mut once = HashSet::new();
let mut buses = Vec::new();
let mut bus_voltage = HashMap::new();
for rec in doc.records("bus data") {
let bus = read_bus(rec)?;
bus_voltage.insert(bus.id, (bus.vm, bus.base_kv));
buses.push(bus);
}
let mut loads = Vec::new();
for rec in doc.records("load data") {
loads.push(read_load(rec, warnings, &mut once)?);
}
let mut shunts = Vec::new();
for rec in doc.records("shunt data") {
shunts.push(read_shunt(rec, base_mva)?);
}
for rec in doc.records("svd data") {
shunts.push(read_svd(rec, base_mva, warnings, &mut once)?);
}
let jump = doc.jump_threshold();
let mut near_jump = 0usize;
let mut branches = Vec::new();
for rec in doc.records("branch data") {
let branch = read_branch(rec)?;
if let Some(threshold) = jump {
if branch.x.abs() <= threshold {
near_jump += 1;
}
}
branches.push(branch);
}
if near_jump > 0 {
warnings.push(format!(
"{near_jump} branch(es) have |x| at or below the PSLF jump threshold"
));
}
let mut transformers_3w = Vec::new();
for rec in doc.records("transformer data") {
match read_transformer(rec)? {
TransformerRecord::TwoWinding(branch) => branches.push(branch),
TransformerRecord::ThreeWinding(t) => transformers_3w.push(t),
}
}
if !transformers_3w.is_empty() {
warnings.push(
"PSLF 3-winding transformer(s) mapped with the primary winding ratio/ratings; \
secondary/tertiary winding ratios default to nominal"
.into(),
);
}
let mut generators = Vec::new();
for rec in doc.records("generator data") {
generators.push(read_generator(rec, &bus_voltage, warnings)?);
}
let dc_converters = read_dc_converters(&doc, warnings);
let hvdc = read_dc_lines(&doc, &dc_converters, warnings);
warn_unmodeled_sections(&doc, warnings);
let net = Network {
name,
base_mva,
base_frequency: crate::network::DEFAULT_BASE_FREQUENCY,
buses,
loads,
shunts,
branches,
switches: Vec::new(),
generators,
storage: Vec::new(),
hvdc,
transformers_3w,
areas: Vec::new(),
solver: None,
source_format: SourceFormat::Pslf,
source: Some(source),
};
net.check_references(FMT)?;
Ok(net)
}
#[derive(Debug)]
struct EpcDocument {
title: Vec<String>,
solution_parameters: Vec<String>,
sections: BTreeMap<String, Section>,
}
impl EpcDocument {
fn name(&self, name_hint: Option<&str>) -> String {
self.title
.iter()
.map(String::as_str)
.map(str::trim)
.find(|line| !line.is_empty())
.map_or_else(|| name_hint.unwrap_or("case").to_string(), str::to_string)
}
fn records(&self, section: &str) -> &[Record] {
self.sections
.get(section)
.map_or(&[], |section| section.records.as_slice())
}
fn base_mva(&self, warnings: &mut Vec<String>) -> f64 {
for line in &self.solution_parameters {
let toks = tokens(line);
if toks
.first()
.is_some_and(|tok| tok.eq_ignore_ascii_case("sbase"))
{
if let Some(base) = toks.get(1).and_then(|tok| tok.parse::<f64>().ok()) {
return base;
}
}
}
warnings.push("no PSLF sbase solution parameter found; defaulting baseMVA to 100".into());
100.0
}
fn jump_threshold(&self) -> Option<f64> {
self.solution_parameters.iter().find_map(|line| {
let toks = tokens(line);
toks.first()
.filter(|tok| tok.eq_ignore_ascii_case("jump"))
.and_then(|_| toks.get(1))
.and_then(|tok| tok.parse().ok())
})
}
}
#[derive(Debug)]
struct Section {
declared_count: usize,
header: String,
records: Vec<Record>,
}
#[derive(Debug)]
struct Record {
line_no: usize,
raw: Vec<String>,
lhs: Vec<String>,
rhs: Vec<String>,
}
#[expect(clippy::too_many_lines)]
fn parse_document(content: &str, warnings: &mut Vec<String>) -> EpcDocument {
let lines: Vec<&str> = content.lines().collect();
let mut i = 0usize;
let mut title = Vec::new();
let mut solution_parameters = Vec::new();
let mut sections = BTreeMap::new();
let mut end_seen = false;
while i < lines.len() {
let raw = lines[i].trim_end_matches('\r');
let stripped = raw.trim();
if stripped.is_empty() || stripped.starts_with('#') {
i += 1;
continue;
}
if stripped.eq_ignore_ascii_case("end") {
end_seen = true;
break;
}
let lower = stripped.to_ascii_lowercase();
if matches!(lower.as_str(), "title" | "comments" | "solution parameters") {
i += 1;
let mut block = Vec::new();
while i < lines.len() && lines[i].trim() != "!" {
block.push(lines[i].trim_end_matches('\r').to_string());
i += 1;
}
if i < lines.len() && lines[i].trim() == "!" {
i += 1;
}
match lower.as_str() {
"title" => title = block,
"solution parameters" => solution_parameters = block,
_ => {}
}
continue;
}
let Some((name, count, header)) = parse_section_header(stripped) else {
warnings.push(format!(
"line {} ignored outside a PSLF data section",
i + 1
));
i += 1;
continue;
};
i += 1;
let mut records = Vec::new();
while records.len() < count && i < lines.len() {
if lines[i].trim().is_empty() {
i += 1;
continue;
}
let next = lines[i].trim();
if parse_section_header(next).is_some() || next.eq_ignore_ascii_case("end") {
break;
}
let line_no = i + 1;
let mut raw_lines = Vec::new();
loop {
let (line, continued) = clean_line(lines[i]);
if !line.trim().is_empty() {
raw_lines.push(line);
}
i += 1;
if !continued || i >= lines.len() {
break;
}
}
let (lhs, rhs) = split_record(&raw_lines);
records.push(Record {
line_no,
raw: raw_lines,
lhs,
rhs,
});
}
if records.len() != count {
warnings.push(format!(
"{}: declared {count}, parsed {}",
name,
records.len()
));
}
if sections
.insert(
name.clone(),
Section {
declared_count: count,
header,
records,
},
)
.is_some()
{
warnings.push(format!(
"{name}: duplicate section replaced earlier records"
));
}
}
if !end_seen {
warnings.push("PSLF file has no end marker".into());
}
EpcDocument {
title,
solution_parameters,
sections,
}
}
fn parse_section_header(line: &str) -> Option<(String, usize, String)> {
let lower = line.to_ascii_lowercase();
let data_at = lower.find(" data")?;
let open = line[data_at + 5..].find('[')? + data_at + 5;
let close = line[open + 1..].find(']')? + open + 1;
let name = line[..data_at + 5].trim().to_ascii_lowercase();
let count = line[open + 1..close].trim().parse().ok()?;
let header = line[close + 1..].trim_end().to_string();
Some((name, count, header))
}
fn clean_line(raw: &str) -> (String, bool) {
let raw = raw.trim_end_matches('\r');
let trimmed = raw.trim_end();
let continued = ends_with_unquoted_slash(trimmed);
if continued {
let without = &trimmed[..trimmed.len() - 1];
(without.trim_end().to_string(), true)
} else {
(raw.to_string(), false)
}
}
fn ends_with_unquoted_slash(line: &str) -> bool {
if !line.ends_with('/') {
return false;
}
let before = &line[..line.len() - 1];
let mut quoted = false;
let mut chars = before.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '"' {
if quoted && chars.peek() == Some(&'"') {
chars.next();
} else {
quoted = !quoted;
}
}
}
!quoted
}
fn split_record(raw_lines: &[String]) -> (Vec<String>, Vec<String>) {
let toks = tokens(&raw_lines.join(" "));
split_tokens(toks)
}
fn split_tokens(toks: Vec<String>) -> (Vec<String>, Vec<String>) {
if let Some(colon) = toks.iter().position(|tok| tok == ":") {
(toks[..colon].to_vec(), toks[colon + 1..].to_vec())
} else {
(toks, Vec::new())
}
}
fn tokens(line: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut quoted = false;
let mut chars = line.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'"' => {
if quoted && chars.peek() == Some(&'"') {
cur.push('"');
chars.next();
} else {
quoted = !quoted;
if !quoted {
out.push(std::mem::take(&mut cur));
}
}
}
':' if !quoted => {
if !cur.is_empty() {
out.push(std::mem::take(&mut cur));
}
out.push(":".into());
}
c if c.is_whitespace() && !quoted => {
if !cur.is_empty() {
out.push(std::mem::take(&mut cur));
}
}
c => cur.push(c),
}
}
if !cur.is_empty() {
out.push(cur);
}
out
}
fn line_rhs(rec: &Record, line: usize) -> Vec<String> {
rec.raw
.get(line)
.map(|line| split_tokens(tokens(line)).1)
.unwrap_or_default()
}
fn line_tokens(rec: &Record, line: usize) -> Vec<String> {
rec.raw.get(line).map_or_else(Vec::new, |line| tokens(line))
}
fn read_bus(rec: &Record) -> Result<Bus> {
let id = BusId(req_id(&rec.lhs, 0, "bus id", rec)?);
let name = rec.lhs.get(1).map(|name| name.trim().to_string());
Ok(Bus {
id,
kind: pslf_bus_type(int_at(&rec.rhs, 0, 1, "bus type", rec)?),
vm: num_at(&rec.rhs, 2, 1.0, "bus voltage", rec)?,
va: num_at(&rec.rhs, 3, 0.0, "bus angle", rec)?,
base_kv: num_at(&rec.lhs, 2, 0.0, "bus nominal kV", rec)?,
vmax: num_at(&rec.rhs, 6, 1.1, "bus vmax", rec)?,
vmin: num_at(&rec.rhs, 7, 0.9, "bus vmin", rec)?,
evhi: None,
evlo: None,
area: id_at(&rec.rhs, 4, 1, "bus area", rec)?,
zone: id_at(&rec.rhs, 5, 1, "bus zone", rec)?,
name,
extras: extras(rec, "bus data", 3, 21),
})
}
fn pslf_bus_type(code: i64) -> BusType {
match code {
0 => BusType::Ref,
2 => BusType::Pv,
4 => BusType::Isolated,
_ => BusType::Pq,
}
}
fn read_branch(rec: &Record) -> Result<Branch> {
let mut extras = extras(rec, "branch data", 9, 10);
if let Some(circuit) = rec.lhs.get(6) {
extras.insert("pslf_circuit".into(), Value::String(circuit.clone()));
}
if let Some(section) = rec.lhs.get(7) {
extras.insert("pslf_section_id".into(), string_or_number(section));
}
Ok(Branch {
from: BusId(req_id(&rec.lhs, 0, "branch from bus", rec)?),
to: BusId(req_id(&rec.lhs, 3, "branch to bus", rec)?),
r: num_at(&rec.rhs, 1, 0.0, "branch r", rec)?,
x: num_at(&rec.rhs, 2, 0.0, "branch x", rec)?,
b: num_at(&rec.rhs, 3, 0.0, "branch b", rec)?,
charging: None,
rate_a: num_at(&rec.rhs, 4, 0.0, "branch rate1", rec)?,
rate_b: num_at(&rec.rhs, 5, 0.0, "branch rate2", rec)?,
rate_c: num_at(&rec.rhs, 6, 0.0, "branch rate3", rec)?,
rating_sets: Vec::new(),
current_ratings: None,
tap: 0.0,
shift: 0.0,
in_service: on_at(&rec.rhs, 0, true, "branch status", rec)?,
angmin: -360.0,
angmax: 360.0,
control: None,
solution: None,
extras,
})
}
#[allow(clippy::large_enum_variant)]
enum TransformerRecord {
TwoWinding(Branch),
ThreeWinding(Transformer3W),
}
fn read_transformer(rec: &Record) -> Result<TransformerRecord> {
let rhs1 = line_rhs(rec, 0);
let line2 = line_tokens(rec, 1);
let tertiary = id_at(&rhs1, 9, 0, "transformer tertiary bus", rec)?;
let pt_r = num_at(&rhs1, 17, 0.0, "transformer pt_r", rec)?;
let pt_x = num_at(&rhs1, 18, 0.0, "transformer pt_x", rec)?;
let ts_r = num_at(&rhs1, 19, 0.0, "transformer ts_r", rec)?;
let ts_x = num_at(&rhs1, 20, 0.0, "transformer ts_x", rec)?;
let from = BusId(req_id(&rec.lhs, 0, "transformer from bus", rec)?);
let to = BusId(req_id(&rec.lhs, 3, "transformer to bus", rec)?);
let r = num_at(&rhs1, 15, 0.0, "transformer r", rec)?;
let x = num_at(&rhs1, 16, 0.0, "transformer x", rec)?;
let tbase = num_at(&rhs1, 14, 0.0, "transformer base", rec)?;
let tap = num_at(&line2, 16, 1.0, "transformer tap", rec)?;
let shift = num_at(&line2, 10, 0.0, "transformer shift", rec)?;
let rate_a = num_at(&line2, 6, 0.0, "transformer rate1", rec)?;
let rate_b = num_at(&line2, 7, 0.0, "transformer rate2", rec)?;
let rate_c = num_at(&line2, 8, 0.0, "transformer rate3", rec)?;
let in_service = on_at(&rhs1, 0, true, "transformer status", rec)?;
let circuit = rec.lhs.get(6).cloned();
let name = rec
.lhs
.get(8)
.filter(|n| !n.trim().is_empty())
.map(|n| n.trim().to_string());
if tertiary != 0 || pt_r != 0.0 || pt_x != 0.0 || ts_r != 0.0 || ts_x != 0.0 {
let mut extras = extras(rec, "transformer data", 8, 21);
if let Some(c) = circuit {
extras.insert("pslf_circuit".into(), Value::String(c));
}
let nominal = |bus| Winding {
bus,
tap: 1.0,
shift: 0.0,
nominal_kv: 0.0,
rate_a: 0.0,
rate_b: 0.0,
rate_c: 0.0,
};
let imp = |r, x| Impedance {
r,
x,
base_mva: tbase,
};
let t3 = Transformer3W {
windings: [
Winding {
bus: from,
tap: if tap == 0.0 { 1.0 } else { tap },
shift,
nominal_kv: 0.0,
rate_a,
rate_b,
rate_c,
},
nominal(to),
nominal(BusId(tertiary)),
],
z: [imp(r, x), imp(ts_r, ts_x), imp(pt_r, pt_x)],
star_vm: 1.0,
star_va: 0.0,
mag_g: 0.0,
mag_b: 0.0,
in_service,
name,
extras,
};
return Ok(TransformerRecord::ThreeWinding(t3));
}
let mut extras = extras(rec, "transformer data", 8, 21);
if let Some(c) = circuit {
extras.insert("pslf_circuit".into(), Value::String(c));
}
extras.insert("pslf_tbase".into(), number_value(tbase));
Ok(TransformerRecord::TwoWinding(Branch {
from,
to,
r,
x,
b: 0.0,
charging: None,
rate_a,
rate_b,
rate_c,
rating_sets: Vec::new(),
current_ratings: None,
tap: if tap == 0.0 { 1.0 } else { tap },
shift,
in_service,
angmin: -360.0,
angmax: 360.0,
control: None,
solution: None,
extras,
}))
}
fn read_generator(
rec: &Record,
bus_voltage: &HashMap<BusId, (f64, f64)>,
warnings: &mut Vec<String>,
) -> Result<Generator> {
let bus = BusId(req_id(&rec.lhs, 0, "generator bus", rec)?);
let (bus_vm, base_kv) = bus_voltage.get(&bus).copied().unwrap_or((1.0, 0.0));
let reg_kv = num_at(&rec.rhs, 3, 0.0, "generator reg_kv", rec)?;
let vg = if reg_kv > 0.0 && base_kv > 0.0 {
reg_kv / base_kv
} else {
if reg_kv > 0.0 {
warnings.push(format!(
"PSLF generator at bus {bus}: reg_kv present but bus base kV is missing; used bus voltage"
));
}
bus_vm
};
Ok(Generator {
bus,
pg: num_at(&rec.rhs, 8, 0.0, "generator pgen", rec)?,
qg: num_at(&rec.rhs, 11, 0.0, "generator qgen", rec)?,
pmax: num_at(&rec.rhs, 9, 0.0, "generator pmax", rec)?,
pmin: num_at(&rec.rhs, 10, 0.0, "generator pmin", rec)?,
qmax: num_at(&rec.rhs, 12, 0.0, "generator qmax", rec)?,
qmin: num_at(&rec.rhs, 13, 0.0, "generator qmin", rec)?,
vg,
mbase: num_at(&rec.rhs, 14, 100.0, "generator mbase", rec)?,
in_service: on_at(&rec.rhs, 0, true, "generator status", rec)?,
cost: None,
caps: Default::default(),
regulated_bus: None,
})
}
fn read_load(
rec: &Record,
warnings: &mut Vec<String>,
once: &mut HashSet<&'static str>,
) -> Result<Load> {
let p_const = num_at(&rec.rhs, 1, 0.0, "load mw", rec)?;
let q_const = num_at(&rec.rhs, 2, 0.0, "load mvar", rec)?;
let p_i = num_at(&rec.rhs, 3, 0.0, "load mw_i", rec)?;
let q_i = num_at(&rec.rhs, 4, 0.0, "load mvar_i", rec)?;
let p_z = num_at(&rec.rhs, 5, 0.0, "load mw_z", rec)?;
let q_z = num_at(&rec.rhs, 6, 0.0, "load mvar_z", rec)?;
let has_zip_components = (p_i, q_i, p_z, q_z) != (0.0, 0.0, 0.0, 0.0);
if has_zip_components && once.insert("zip_load") {
warnings.push(
"PSLF ZIP load components folded into Network load p/q; component fields retained in the typed load voltage model"
.into(),
);
}
let mut extras = extras(rec, "load data", 5, 20);
capture_device_id(&mut extras, &rec.lhs);
extras.insert("pslf_mw".into(), number_value(p_const));
extras.insert("pslf_mvar".into(), number_value(q_const));
extras.insert("pslf_mw_i".into(), number_value(p_i));
extras.insert("pslf_mvar_i".into(), number_value(q_i));
extras.insert("pslf_mw_z".into(), number_value(p_z));
extras.insert("pslf_mvar_z".into(), number_value(q_z));
Ok(Load {
bus: BusId(req_id(&rec.lhs, 0, "load bus", rec)?),
p: p_const + p_i + p_z,
q: q_const + q_i + q_z,
voltage_model: has_zip_components.then_some(LoadVoltageModel::Zip {
p_constant_power: p_const,
q_constant_power: q_const,
p_constant_current: p_i,
q_constant_current: q_i,
p_constant_impedance: p_z,
q_constant_impedance: q_z,
v_nom: None,
load_type: None,
scaling: None,
}),
in_service: on_at(&rec.rhs, 0, true, "load status", rec)?,
extras,
})
}
fn read_shunt(rec: &Record, base_mva: f64) -> Result<Shunt> {
let g_pu = num_at(&rec.rhs, 3, 0.0, "shunt pu_mw", rec)?;
let b_pu = num_at(&rec.rhs, 4, 0.0, "shunt pu_mvar", rec)?;
let mut extras = extras(rec, "shunt data", 10, 29);
capture_device_id(&mut extras, &rec.lhs);
extras.insert("pslf_pu_mw".into(), number_value(g_pu));
extras.insert("pslf_pu_mvar".into(), number_value(b_pu));
Ok(Shunt {
bus: BusId(req_id(&rec.lhs, 0, "shunt bus", rec)?),
g: g_pu * base_mva,
b: b_pu * base_mva,
in_service: on_at(&rec.rhs, 0, true, "shunt status", rec)?,
control: None,
extras,
})
}
fn read_svd(
rec: &Record,
base_mva: f64,
warnings: &mut Vec<String>,
once: &mut HashSet<&'static str>,
) -> Result<Shunt> {
if once.insert("svd") {
warnings.push(
"PSLF controlled shunts (svd data) reduced to fixed shunts at initial g/b; control fields retained in extras"
.into(),
);
}
let g_pu = num_at(&rec.rhs, 7, 0.0, "svd g", rec)?;
let b_pu = num_at(&rec.rhs, 8, 0.0, "svd b", rec)?;
let mut extras = extras(rec, "svd data", 5, 30);
capture_device_id(&mut extras, &rec.lhs);
extras.insert("pslf_device".into(), Value::String("svd".into()));
extras.insert("pslf_pu_g".into(), number_value(g_pu));
extras.insert("pslf_pu_b".into(), number_value(b_pu));
Ok(Shunt {
bus: BusId(req_id(&rec.lhs, 0, "svd bus", rec)?),
g: g_pu * base_mva,
b: b_pu * base_mva,
in_service: on_at(&rec.rhs, 0, true, "svd status", rec)?,
control: None,
extras,
})
}
#[derive(Clone)]
struct DcConverter {
ac_bus: BusId,
dc_bus: usize,
in_service: bool,
p: f64,
q: f64,
extras: Extras,
}
fn read_dc_converters(
doc: &EpcDocument,
warnings: &mut Vec<String>,
) -> HashMap<usize, DcConverter> {
let mut out = HashMap::new();
for rec in doc.records("dc converter data") {
let parsed = (|| -> Result<DcConverter> {
let l2 = line_tokens(rec, 1);
let mut extras = extras(rec, "dc converter data", 8, 15);
extras.insert("pslf_device".into(), Value::String("dc_converter".into()));
Ok(DcConverter {
ac_bus: BusId(req_id(&rec.lhs, 0, "dc converter AC bus", rec)?),
dc_bus: req_id(&rec.lhs, 3, "dc converter DC bus", rec)?,
in_service: on_at(&rec.rhs, 0, true, "dc converter status", rec)?,
p: num_at(&l2, 2, 0.0, "dc converter p", rec)?,
q: num_at(&l2, 3, 0.0, "dc converter q", rec)?,
extras,
})
})();
match parsed {
Ok(conv) => {
out.insert(conv.dc_bus, conv);
}
Err(err) => warnings.push(format!(
"dc converter at line {} not mapped: {err}",
rec.line_no
)),
}
}
out
}
fn read_dc_lines(
doc: &EpcDocument,
converters: &HashMap<usize, DcConverter>,
warnings: &mut Vec<String>,
) -> Vec<Hvdc> {
let mut out = Vec::new();
for rec in doc.records("dc line data") {
let parsed = (|| -> Result<Hvdc> {
let from_dc = req_id(&rec.lhs, 0, "dc line from bus", rec)?;
let to_dc = req_id(&rec.lhs, 3, "dc line to bus", rec)?;
let from = converters.get(&from_dc).ok_or_else(|| Error::FormatRead {
format: FMT,
message: format!("dc line references DC bus {from_dc} with no converter"),
})?;
let to = converters.get(&to_dc).ok_or_else(|| Error::FormatRead {
format: FMT,
message: format!("dc line references DC bus {to_dc} with no converter"),
})?;
let rate = num_at(&rec.rhs, 6, 0.0, "dc line rate1", rec)?;
let pmax = if rate > 0.0 {
rate
} else {
from.p.abs().max(to.p.abs())
};
let mut extras = extras(rec, "dc line data", 8, 20);
extras.insert("pslf_device".into(), Value::String("dc_line".into()));
extras.insert(
"pslf_from_converter".into(),
Value::Object(from.extras.clone().into_iter().collect()),
);
extras.insert(
"pslf_to_converter".into(),
Value::Object(to.extras.clone().into_iter().collect()),
);
Ok(Hvdc {
from: from.ac_bus,
to: to.ac_bus,
in_service: on_at(&rec.rhs, 0, true, "dc line status", rec)?
&& from.in_service
&& to.in_service,
pf: from.p,
pt: to.p,
qf: from.q,
qt: to.q,
vf: 1.0,
vt: 1.0,
pmin: -pmax,
pmax,
qminf: from.q.min(0.0),
qmaxf: from.q.max(0.0),
qmint: to.q.min(0.0),
qmaxt: to.q.max(0.0),
loss0: 0.0,
loss1: 0.0,
cost: None,
extras,
})
})();
match parsed {
Ok(line) => {
warnings.push(
"PSLF DC line/converter data mapped to Network HVDC with unsupported control fields retained in extras"
.into(),
);
out.push(line);
}
Err(err) => warnings.push(format!("dc line at line {} not mapped: {err}", rec.line_no)),
}
}
out
}
fn warn_unmodeled_sections(doc: &EpcDocument, warnings: &mut Vec<String>) {
const MODELED: &[&str] = &[
"bus data",
"branch data",
"transformer data",
"generator data",
"load data",
"shunt data",
"svd data",
"dc line data",
"dc converter data",
];
for (name, section) in &doc.sections {
if section.declared_count > 0 && !MODELED.contains(&name.as_str()) {
warnings.push(format!(
"{name}: {} record(s) retained in source text only ({})",
section.declared_count, section.header
));
}
}
}
fn extras(rec: &Record, section: &str, used_lhs: usize, used_rhs: usize) -> Extras {
let mut extras = Extras::new();
extras.insert("pslf_section".into(), Value::String(section.into()));
extras.insert("pslf_line".into(), number_value(rec.line_no as f64));
extras.insert("pslf_raw".into(), string_array(rec.raw.iter().cloned()));
if rec.lhs.len() > used_lhs {
extras.insert(
"pslf_lhs_extra".into(),
string_array(rec.lhs[used_lhs..].iter().cloned()),
);
}
if rec.rhs.len() > used_rhs {
extras.insert(
"pslf_rhs_extra".into(),
string_array(rec.rhs[used_rhs..].iter().cloned()),
);
}
extras
}
fn capture_device_id(extras: &mut Extras, lhs: &[String]) {
if let Some(id) = lhs.get(3).map(|s| s.trim()).filter(|s| !s.is_empty()) {
extras.insert("id".into(), Value::String(id.to_string()));
}
}
fn string_array(values: impl IntoIterator<Item = String>) -> Value {
Value::Array(values.into_iter().map(Value::String).collect())
}
fn string_or_number(token: &str) -> Value {
token
.parse::<f64>()
.ok()
.map_or_else(|| Value::String(token.to_string()), number_value)
}
fn number_value(value: f64) -> Value {
Number::from_f64(value).map_or(Value::Null, Value::Number)
}
fn num_at(tokens: &[String], i: usize, default: f64, field: &str, rec: &Record) -> Result<f64> {
match tokens.get(i).map(String::as_str) {
None | Some("") => Ok(default),
Some(tok) => tok.parse().map_err(|_| bad_field(field, i, tok, rec)),
}
}
fn int_at(tokens: &[String], i: usize, default: i64, field: &str, rec: &Record) -> Result<i64> {
match tokens.get(i).map(String::as_str) {
None | Some("") => Ok(default),
Some(tok) => tok.parse().map_err(|_| bad_field(field, i, tok, rec)),
}
}
fn id_at(tokens: &[String], i: usize, default: usize, field: &str, rec: &Record) -> Result<usize> {
match tokens.get(i).map(String::as_str) {
None | Some("") => Ok(default),
Some(tok) => parse_id(tok).ok_or_else(|| bad_field(field, i, tok, rec)),
}
}
fn req_id(tokens: &[String], i: usize, field: &str, rec: &Record) -> Result<usize> {
tokens
.get(i)
.and_then(|tok| parse_id(tok))
.ok_or_else(|| Error::FormatRead {
format: FMT,
message: format!("{field} missing or invalid at line {}", rec.line_no),
})
}
fn parse_id(tok: &str) -> Option<usize> {
if let Ok(value) = tok.parse::<usize>() {
return Some(value);
}
let value = tok.parse::<f64>().ok()?;
if !value.is_finite() || value < 0.0 || value.fract() != 0.0 || value > usize::MAX as f64 {
return None;
}
Some(value as usize)
}
fn on_at(tokens: &[String], i: usize, default: bool, field: &str, rec: &Record) -> Result<bool> {
Ok(num_at(tokens, i, if default { 1.0 } else { 0.0 }, field, rec)? != 0.0)
}
fn bad_field(field: &str, i: usize, tok: &str, rec: &Record) -> Error {
Error::FormatRead {
format: FMT,
message: format!(
"{field} field {i} value {tok:?} is invalid at line {}",
rec.line_no
),
}
}
#[derive(Clone, Copy)]
struct BusRef<'a> {
name: &'a str,
base_kv: f64,
area: usize,
zone: usize,
}
#[must_use]
#[expect(clippy::too_many_lines)]
pub fn write_pslf(net: &Network) -> Conversion {
let mut warnings = Vec::new();
let mut nonfinite = false;
let mut sanitized_names = 0usize;
let mut sanitized_ids = 0usize;
let mut s = String::new();
let mut num = |x: f64| -> String {
if x.is_finite() {
format!("{x}")
} else {
nonfinite = true;
let sentinel = if x > 0.0 {
1.0e10
} else if x < 0.0 {
-1.0e10
} else {
0.0
};
format!("{sentinel}")
}
};
let bus_refs: HashMap<BusId, BusRef> = net
.buses
.iter()
.map(|b| {
(
b.id,
BusRef {
name: b.name.as_deref().unwrap_or(""),
base_kv: b.base_kv,
area: b.area,
zone: b.zone,
},
)
})
.collect();
let bus_ref = |id: BusId| -> BusRef {
bus_refs.get(&id).copied().unwrap_or(BusRef {
name: "",
base_kv: 0.0,
area: 1,
zone: 1,
})
};
let mut name_tok = |name: &str| -> String {
let clean = sanitize_quoted(name, NAME_FORBIDDEN, ' ');
if matches!(clean, std::borrow::Cow::Owned(_)) {
sanitized_names += 1;
}
format!("\"{clean}\"")
};
let _ = writeln!(s, "title");
let _ = writeln!(s, "{}", net.name);
let _ = writeln!(s, "!");
let _ = writeln!(s, "comments");
let _ = writeln!(s, "powerio export");
let _ = writeln!(s, "!");
let _ = writeln!(s, "solution parameters");
let _ = writeln!(s, "sbase {}", num(net.base_mva));
let _ = writeln!(s, "!");
let _ = writeln!(
s,
"bus data [{}] ty vsched volt angle ar zone vmax vmin",
net.buses.len()
);
for b in &net.buses {
let _ = writeln!(
s,
"{} {} {} : {} {} {} {} {} {} {} {}",
b.id,
name_tok(b.name.as_deref().unwrap_or("")),
num(b.base_kv),
pslf_type(b.kind),
num(b.vm),
num(b.vm),
num(b.va),
b.area,
b.zone,
num(b.vmax),
num(b.vmin),
);
}
if !net.loads.is_empty() {
let _ = writeln!(
s,
"load data [{}] id long_id st mw mvar mw_i mvar_i mw_z mvar_z ar zone",
net.loads.len()
);
let mut load_ids: BTreeMap<BusId, BTreeSet<String>> = BTreeMap::new();
for l in &net.loads {
let r = bus_ref(l.bus);
let (mw, mvar, mw_i, mvar_i, mw_z, mvar_z) =
load_components_for_write(l, &mut warnings);
let id = device_id(&l.extras, l.bus, &mut load_ids, &mut sanitized_ids);
let _ = writeln!(
s,
"{} {} {} \"{id}\" \"load\" : {} {} {} {} {} {} {} {} {}",
l.bus,
name_tok(r.name),
num(r.base_kv),
i32::from(l.in_service),
num(mw),
num(mvar),
num(mw_i),
num(mvar_i),
num(mw_z),
num(mvar_z),
r.area,
r.zone,
);
}
}
if !net.shunts.is_empty() {
let _ = writeln!(
s,
"shunt data [{}] id ck se long_id st ar zone pu_mw pu_mvar",
net.shunts.len()
);
let mut shunt_ids: BTreeMap<BusId, BTreeSet<String>> = BTreeMap::new();
for sh in &net.shunts {
let r = bus_ref(sh.bus);
let pu_mw = extra_f64(&sh.extras, "pslf_pu_mw")
.or_else(|| extra_f64(&sh.extras, "pslf_pu_g"))
.unwrap_or_else(|| safe_div(sh.g, net.base_mva));
let pu_mvar = extra_f64(&sh.extras, "pslf_pu_mvar")
.or_else(|| extra_f64(&sh.extras, "pslf_pu_b"))
.unwrap_or_else(|| safe_div(sh.b, net.base_mva));
let id = device_id(&sh.extras, sh.bus, &mut shunt_ids, &mut sanitized_ids);
let _ = writeln!(
s,
"{} {} {} \"{id}\" : {} {} {} {} {}",
sh.bus,
name_tok(r.name),
num(r.base_kv),
i32::from(sh.in_service),
r.area,
r.zone,
num(pu_mw),
num(pu_mvar),
);
}
}
let lines: Vec<&Branch> = net
.branches
.iter()
.filter(|b| !b.is_transformer())
.collect();
if !lines.is_empty() {
let _ = writeln!(
s,
"branch data [{}] ck se long_id st resist react charge rate1 rate2 rate3",
lines.len()
);
let mut branch_ids: BTreeMap<(BusId, BusId), BTreeSet<String>> = BTreeMap::new();
for br in lines {
let f = bus_ref(br.from);
let t = bus_ref(br.to);
let ck = super::allocate_circuit_id(
br.extras.get("pslf_circuit").and_then(Value::as_str),
(br.from, br.to),
&mut branch_ids,
);
let _ = writeln!(
s,
"{} {} {} {} {} {} \"{ck}\" 1 \"line\" : {} {} {} {} {} {} {}",
br.from,
name_tok(f.name),
num(f.base_kv),
br.to,
name_tok(t.name),
num(t.base_kv),
i32::from(br.in_service),
num(br.r),
num(br.x),
num(br.legacy_total_charging_b()),
num(br.rate_a),
num(br.rate_b),
num(br.rate_c),
);
}
}
let xfmrs: Vec<&Branch> = net.branches.iter().filter(|b| b.is_transformer()).collect();
let n_xfmr = xfmrs.len() + net.transformers_3w.len();
if n_xfmr > 0 {
let _ = writeln!(s, "transformer data [{n_xfmr}]");
for br in xfmrs {
let f = bus_ref(br.from);
let t = bus_ref(br.to);
let tbase = extra_f64(&br.extras, "pslf_tbase").unwrap_or(net.base_mva);
let mut rhs1 = vec!["0".to_string(); 21];
rhs1[0] = i32::from(br.in_service).to_string();
rhs1[14] = num(tbase);
rhs1[15] = num(br.r);
rhs1[16] = num(br.x);
let _ = writeln!(
s,
"{} {} {} {} {} {} {} 1 \"xfmr\" : {} /",
br.from,
name_tok(f.name),
num(f.base_kv),
br.to,
name_tok(t.name),
num(t.base_kv),
circuit_tok(&br.extras),
rhs1.join(" "),
);
let mut line2 = vec!["0".to_string(); 17];
line2[6] = num(br.rate_a);
line2[7] = num(br.rate_b);
line2[8] = num(br.rate_c);
line2[10] = num(br.shift);
line2[16] = num(br.effective_tap());
let _ = writeln!(s, "{}", line2.join(" "));
}
for tr in &net.transformers_3w {
let p = bus_ref(tr.windings[0].bus);
let sec = bus_ref(tr.windings[1].bus);
let [z12, z23, z31] = tr.z;
let mut rhs1 = vec!["0".to_string(); 21];
rhs1[0] = i32::from(tr.in_service).to_string();
rhs1[9] = tr.windings[2].bus.to_string();
rhs1[14] = num(z12.base_mva);
rhs1[15] = num(z12.r);
rhs1[16] = num(z12.x);
rhs1[17] = num(z31.r);
rhs1[18] = num(z31.x);
rhs1[19] = num(z23.r);
rhs1[20] = num(z23.x);
let _ = writeln!(
s,
"{} {} {} {} {} {} {} 1 \"xf3\" : {} /",
tr.windings[0].bus,
name_tok(p.name),
num(p.base_kv),
tr.windings[1].bus,
name_tok(sec.name),
num(sec.base_kv),
circuit_tok(&tr.extras),
rhs1.join(" "),
);
let mut line2 = vec!["0".to_string(); 17];
line2[6] = num(tr.windings[0].rate_a);
line2[7] = num(tr.windings[0].rate_b);
line2[8] = num(tr.windings[0].rate_c);
line2[10] = num(tr.windings[0].shift);
line2[16] = num(tr.windings[0].tap);
let _ = writeln!(s, "{}", line2.join(" "));
}
}
if !net.generators.is_empty() {
let _ = writeln!(
s,
"generator data [{}] id long_id st no reg_name reg_kv prf qrf ar zone \
pgen pmax pmin qgen qmax qmin mbase",
net.generators.len()
);
for g in &net.generators {
let r = bus_ref(g.bus);
let reg_kv = if g.vg.is_finite() && r.base_kv > 0.0 {
g.vg * r.base_kv
} else {
if g.vg.is_finite() && (g.vg - 1.0).abs() > 1e-9 {
warnings.push(format!(
"PSLF generator at bus {}: voltage setpoint {} p.u. could not be written because bus base kV is missing",
g.bus, g.vg
));
}
0.0
};
let _ = writeln!(
s,
"{} {} \"1\" \"gen\" : {} 1 0 {} 1 1 {} {} {} {} {} {} {} {} {}",
g.bus,
name_tok(r.name),
i32::from(g.in_service),
num(reg_kv),
r.area,
r.zone,
num(g.pg),
num(g.pmax),
num(g.pmin),
num(g.qg),
num(g.qmax),
num(g.qmin),
num(g.mbase),
);
}
}
if !net.hvdc.is_empty() {
let _ = writeln!(
s,
"dc converter data [{}] id name kv dc_bus",
net.hvdc.len() * 2
);
for (k, d) in net.hvdc.iter().enumerate() {
for (ac, dc_bus, p, q) in [
(d.from, 2 * k + 1, d.pf, d.qf),
(d.to, 2 * k + 2, d.pt, d.qt),
] {
let r = bus_ref(ac);
let _ = writeln!(
s,
"{} {} {} {} : {} /",
ac,
name_tok(r.name),
num(r.base_kv),
dc_bus,
i32::from(d.in_service),
);
let _ = writeln!(s, "0 0 {} {}", num(p), num(q));
}
}
let _ = writeln!(
s,
"dc line data [{}] from name kv to st rate1",
net.hvdc.len()
);
for (k, d) in net.hvdc.iter().enumerate() {
let _ = writeln!(
s,
"{} \"dc\" 0 {} : {} 0 0 0 0 0 {}",
2 * k + 1,
2 * k + 2,
i32::from(d.in_service),
num(d.pmax),
);
}
}
let _ = writeln!(s, "end");
let asymmetric_hvdc = net
.hvdc
.iter()
.filter(|d| (d.pmin + d.pmax).abs() > 1e-9)
.count();
if asymmetric_hvdc > 0 {
warnings.push(format!(
"{asymmetric_hvdc} HVDC line(s) have asymmetric power limits (pmin != -pmax); \
the PSLF .epc dc record carries only rate1 (= pmax), so pmin reads back as -pmax"
));
}
if !net.storage.is_empty() {
warnings.push(format!(
"{} storage unit(s) dropped: PSLF .epc has no storage record",
net.storage.len()
));
}
if net.generators.iter().any(|g| g.cost.is_some()) {
warnings.push("generator cost curves dropped: PSLF .epc carries no cost data".into());
}
let terminal_charging = net
.branches
.iter()
.filter(|b| b.has_non_matpower_charging() && !b.is_transformer())
.count();
if terminal_charging > 0 {
warnings.push(format!(
"{terminal_charging} branch terminal admittance record(s) collapsed to total susceptance: PSLF branch records written here cannot carry conductance or asymmetric terminal charging"
));
}
let transformer_charging = net
.branches
.iter()
.filter(|b| {
b.is_transformer()
&& (b.terminal_charging().total_g().abs() > 1e-12
|| b.terminal_charging().total_b().abs() > 1e-12)
})
.count();
if transformer_charging > 0 {
warnings.push(format!(
"{transformer_charging} transformer charging admittance record(s) dropped: PSLF transformer records written here carry series impedance, tap, shift, and ratings only"
));
}
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: PSLF branch records written here carry MVA ratings only"
));
}
warn_extra_branch_rating_sets("PSLF .epc", 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: PSLF solved flow fields are not written"
));
}
let dropped_reg = net
.generators
.iter()
.filter(|g| g.regulated_bus.is_some())
.count();
if dropped_reg > 0 {
warnings.push(format!(
"{dropped_reg} generator(s) lost their remote regulated bus: the PSLF .epc generator \
record this writer emits controls the unit's own terminal"
));
}
let drops_winding_detail = net.transformers_3w.iter().any(|t| {
t.windings[1..]
.iter()
.any(|w| (w.tap - 1.0).abs() > 1e-9 || w.rate_a.abs() > 1e-9)
});
if drops_winding_detail {
warnings.push(
"PSLF 3-winding export carries the primary winding ratio/ratings only; \
secondary/tertiary winding ratios/ratings dropped"
.into(),
);
}
let dropped_control = net.branches.iter().filter(|b| b.control.is_some()).count();
if dropped_control > 0 {
warnings.push(format!(
"{dropped_control} transformer(s) lost their regulating control (mode/tap limits/\
regulated bus): the PSLF .epc transformer record carries no control columns"
));
}
let dropped_sw = net.shunts.iter().filter(|s| s.control.is_some()).count();
if dropped_sw > 0 {
warnings.push(format!(
"{dropped_sw} switched shunt(s) written as fixed: the PSLF .epc shunt record this \
writer emits has no switching-control columns (mode/band/step blocks)"
));
}
let sanitized = sanitized_names + sanitized_ids;
if sanitized > 0 {
warnings.push(format!(
"{sanitized} quoted field(s) contained a double quote that would corrupt an EPC \
record; replaced with spaces"
));
}
if nonfinite {
warnings.push("non-finite values written as ±1e10 sentinels (PSLF has no Inf/NaN)".into());
}
Conversion { text: s, warnings }
}
fn pslf_type(kind: BusType) -> u8 {
match kind {
BusType::Ref => 0,
BusType::Pv => 2,
BusType::Isolated => 4,
BusType::Pq => 1,
}
}
fn device_id(
extras: &Extras,
bus: BusId,
used: &mut BTreeMap<BusId, BTreeSet<String>>,
sanitized: &mut usize,
) -> String {
let preferred = extras
.get("id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|id| !id.is_empty())
.map(|id| {
let clean = sanitize_quoted(id, NAME_FORBIDDEN, ' ');
if matches!(clean, std::borrow::Cow::Owned(_)) {
*sanitized += 1;
}
clean.into_owned()
});
super::allocate_circuit_id(preferred.as_deref(), bus, used)
}
fn circuit_tok(extras: &Extras) -> String {
let ck = extras
.get("pslf_circuit")
.and_then(Value::as_str)
.unwrap_or("1");
format!("\"{ck}\"")
}
fn extra_f64(extras: &Extras, key: &str) -> Option<f64> {
extras
.get(key)
.and_then(Value::as_f64)
.filter(|v| v.is_finite())
}
fn same_load_total(a: f64, b: f64) -> bool {
(a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0)
}
fn load_components_for_write(
l: &Load,
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,
load_type,
scaling,
..
}) = &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!(
"PSLF load at bus {}: nominal voltage has no load data field; dropped",
l.bus
));
}
if load_type.is_some() || scaling.is_some() {
warnings.push(format!(
"PSLF load at bus {}: PSS/E load type/scaling has no load data 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!(
"PSLF load at bus {}: 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!(
"PSLF load at bus {}: exponential voltage model has no PSLF load data columns; wrote typed p/q as constant power",
l.bus
));
return (l.p, l.q, 0.0, 0.0, 0.0, 0.0);
}
let mw = extra_f64(&l.extras, "pslf_mw").unwrap_or(l.p);
let mvar = extra_f64(&l.extras, "pslf_mvar").unwrap_or(l.q);
let mw_i = extra_f64(&l.extras, "pslf_mw_i").unwrap_or(0.0);
let mvar_i = extra_f64(&l.extras, "pslf_mvar_i").unwrap_or(0.0);
let mw_z = extra_f64(&l.extras, "pslf_mw_z").unwrap_or(0.0);
let mvar_z = extra_f64(&l.extras, "pslf_mvar_z").unwrap_or(0.0);
if l.extras.keys().any(|key| {
matches!(
key.as_str(),
"pslf_mw" | "pslf_mvar" | "pslf_mw_i" | "pslf_mvar_i" | "pslf_mw_z" | "pslf_mvar_z"
)
}) && (!same_load_total(mw + mw_i + mw_z, l.p)
|| !same_load_total(mvar + mvar_i + mvar_z, l.q))
{
warnings.push(format!(
"PSLF load at bus {}: stale PSLF load extras 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);
}
(mw, mvar, mw_i, mvar_i, mw_z, mvar_z)
}
fn safe_div(a: f64, b: f64) -> f64 {
if b.is_finite() && b != 0.0 {
a / b
} else {
0.0
}
}
#[cfg(test)]
mod tests {
use super::*;
fn close(actual: f64, expected: f64) {
assert!((actual - expected).abs() < 1e-9, "{actual} != {expected}");
}
#[test]
fn reads_minimal_epc_core() {
let epc = r#"title
minimal
!
solution parameters
sbase 100.0000
jump 0.000290
!
bus data [2] ty vsched volt angle ar zone vmax vmin date_in date_out pid L own st
1 "Slack " 230.0000 : 0 1.0000 1.0000 0.0 1 1 1.1 0.9 400101 391231 0 0 1 0
2 "Load " 230.0000 : 1 1.0000 1.0000 -1.0 1 1 1.1 0.9 400101 391231 0 0 1 0
branch data [1] ck se long_id st resist react charge rate1 rate2 rate3 rate4 aloss lngth
1 "Slack " 230.00 2 "Load " 230.00 "1 " 1 "line" : 1 0.01 0.05 0.001 100 90 80 0 0 1 /
1 1 0 0
generator data [1] id long_id st no reg_name prf qrf ar zone pgen pmax pmin qgen qmax qmin mbase
1 "Slack " 230.00 "1 " "gen" : 1 1 "Slack " 230.00 0 1 1 1 50 80 0 5 30 -20 100 /
0
load data [1] id long_id st mw mvar mw_i mvar_i mw_z mvar_z ar zone
2 "Load " 230.00 "1 " "load" : 1 10 3 1 0.5 2 1.5 1 1
shunt data [1] id ck se long_id st ar zone pu_mw pu_mvar
2 "Load " 230.00 "b " 0 "" 0.00 " " 0 "" : 1 1 1 0.00 0.10
end
"#;
let mut warnings = Vec::new();
let net = parse_pslf_source(Arc::new(epc.to_string()), None, &mut warnings).unwrap();
assert_eq!(net.source_format, SourceFormat::Pslf);
assert_eq!(net.buses.len(), 2);
assert_eq!(net.branches.len(), 1);
assert_eq!(net.loads.len(), 1);
assert_eq!(net.generators.len(), 1);
assert_eq!(net.shunts.len(), 1);
assert_eq!(net.buses[0].kind, BusType::Ref);
close(net.loads[0].p, 13.0);
close(net.loads[0].q, 5.0);
close(net.shunts[0].b, 10.0);
assert!(warnings.iter().any(|w| w.contains("ZIP load")));
}
#[test]
fn same_source_text_is_retained() {
let epc = "title\nx\n!\nsolution parameters\nsbase 100\n!\nbus data [1]\n1 \"A\" 1 : 0 1 1 0 1 1 1.1 0.9\nend\n";
let mut warnings = Vec::new();
let net = parse_pslf_source(Arc::new(epc.to_string()), None, &mut warnings).unwrap();
assert_eq!(net.source.as_deref().map(String::as_str), Some(epc));
}
#[test]
fn transformer_charging_drop_is_warned_on_write() {
let mut net = Network::in_memory(
"charging",
100.0,
vec![
Bus {
id: BusId(1),
kind: BusType::Ref,
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,
extras: Extras::new(),
},
Bus {
id: BusId(2),
kind: BusType::Pq,
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,
extras: Extras::new(),
},
],
Vec::new(),
);
net.branches.push(Branch {
from: BusId(1),
to: BusId(2),
r: 0.01,
x: 0.1,
b: 0.02,
charging: None,
rate_a: 100.0,
rate_b: 100.0,
rate_c: 100.0,
rating_sets: Vec::new(),
current_ratings: None,
tap: 1.0,
shift: 0.0,
in_service: true,
angmin: -360.0,
angmax: 360.0,
control: None,
solution: None,
extras: Extras::new(),
});
let conv = write_pslf(&net);
assert!(
conv.warnings
.iter()
.any(|w| w.contains("transformer charging admittance")),
"{:?}",
conv.warnings
);
}
#[test]
fn clean_line_continuation_slash_respects_quotes() {
assert_eq!(clean_line(r#"1 "A" : 0 /"#), (r#"1 "A" : 0"#.into(), true));
assert_eq!(
clean_line(r#"1 "name/" : 0"#),
(r#"1 "name/" : 0"#.into(), false)
);
assert_eq!(
clean_line(r#"1 "unterminated /"#),
(r#"1 "unterminated /"#.into(), false)
);
assert_eq!(
clean_line(r#"1 "has ""quote""" : 0 /"#),
(r#"1 "has ""quote""" : 0"#.into(), true)
);
}
#[test]
fn pslf_tokens_keep_slashes_inside_quoted_names() {
assert_eq!(
tokens(r#"1 "A/B" 230.0 : 0"#),
vec!["1", "A/B", "230.0", ":", "0"]
);
}
#[test]
fn parse_id_accepts_only_integer_values() {
assert_eq!(parse_id("12"), Some(12));
assert_eq!(parse_id("12.0"), Some(12));
assert_eq!(parse_id("1e3"), Some(1000));
assert_eq!(parse_id("12.9"), None);
assert_eq!(parse_id("-1"), None);
assert_eq!(parse_id("NaN"), None);
}
}