use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::fmt::Write as _;
use serde_json::Value;
use super::{
TextEmission, branch_rating_set_drop_warning, jnum, sanitize_quoted,
warn_extra_branch_rating_sets, warn_winding_rating_sets,
};
use std::borrow::Cow;
use crate::diagnostics::codes::EMIT_PSSE as F;
use crate::diagnostics::{Diagnostics, codes};
use crate::network::{
Area, BalancedNetwork, BalancedNetworkTables, Branch, BranchCharging, BranchRatingSet, Bus,
BusId, BusType, ComponentMetadata, DetailedConnectivity, Extras, Generator,
GeneratorEnergySource, Hvdc, HvdcConverter, HvdcConverterKind, HvdcConvertersMode, Impedance,
Load, LoadVoltageModel, Shunt, ShuntBlock, SolverParams, SourceFormat, StaticVarCompensator,
StaticVarCompensatorRegulationMode, Switch, SwitchedShuntControl, SwitchedShuntMode,
TerminalReference, Transformer3W, TransformerControl, TransformerControlMode,
WINDING_IN_SERVICE_EXTRA, WINDING_RATING_SETS_EXTRA, Winding,
};
use crate::network::{
IMPEDANCE_CORRECTION_APPLIED_EXTRA, IMPEDANCE_CORRECTION_EXTRA,
IMPEDANCE_CORRECTION_NOMINAL_EXTRA, ImpedanceCorrection, ImpedanceCorrectionPoint,
ImpedanceCorrectionVariable, correction_nominal_edited, current_correction_nominal,
};
use crate::{Error, Result};
const FMT: &str = "PSS/E .raw";
#[cfg(test)]
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: &[Cow<'_, str>],
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_slots(
rating_sets: &[BranchRatingSet],
mut report: impl FnMut(&BranchRatingSet, Option<usize>),
) -> [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 rating_sets {
if let Some(slot) = psse_extra_rating_slot(&rating.name)
&& !used[slot]
{
values[slot] = rating.rate_mva;
used[slot] = true;
continue;
}
deferred.push(rating);
}
for rating in deferred {
let slot = used.iter().position(|is_used| !*is_used);
if let Some(slot) = slot {
values[slot] = rating.rate_mva;
used[slot] = true;
}
report(rating, slot);
}
values
}
fn psse_extra_rating_values(
branch: &Branch,
branch_index: usize,
warnings: &mut Diagnostics,
) -> [f64; PSSE_EXTRA_BRANCH_RATINGS] {
psse_extra_rating_slots(&branch.rating_sets, |rating, slot| match slot {
Some(slot) => warnings.push(
&codes::EMIT_PSSE_RATING_SET_REMAPPED,
branch_rating_set_rename_warning(
branch_index,
branch,
rating,
&psse_extra_rating_name(slot),
),
),
None => warnings.push(
&F.rating_set_dropped,
branch_rating_set_drop_warning("PSS/E v34/v35", branch_index, branch, rating),
),
})
}
fn psse_winding_extra_rating_values(
transformer: &Transformer3W,
winding: usize,
warnings: &mut Diagnostics,
) -> [f64; PSSE_EXTRA_BRANCH_RATINGS] {
let label = three_winding_label(transformer, winding);
let rating_sets = transformer.winding_rating_sets(winding);
psse_extra_rating_slots(&rating_sets, |rating, slot| {
let set = format!("{label} rating set {}={} MVA", rating.name, rating.rate_mva);
let Some(slot) = slot else {
let message = format!("{set} dropped: PSS/E v34/v35 has twelve ratings per winding");
warnings.push(&F.rating_set_dropped, message);
return;
};
let name = psse_extra_rating_name(slot);
warnings.push(
&codes::EMIT_PSSE_RATING_SET_REMAPPED,
format!(
"{set} emitted as {name} in PSS/E v34/v35; rating set names outside RATE4-RATE12 are not preserved"
),
);
})
}
fn three_winding_label(transformer: &Transformer3W, winding: usize) -> String {
let [i, j, k] = transformer.windings.each_ref().map(|winding| winding.bus);
format!(
"three winding transformer {i}-{j}-{k} winding {}",
winding + 1
)
}
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: &BalancedNetwork, warnings: &mut Diagnostics) {
warn_extra_branch_rating_sets(&F, "PSS/E v33", net, warnings);
warn_winding_rating_sets(&F, "PSS/E v33", net, warnings);
}
fn warn_generator_energy_sources_dropped(net: &BalancedNetwork, warnings: &mut Diagnostics) {
let mut counts = [0_usize; 5];
for generator in net.generators() {
let index = match generator.energy_source {
GeneratorEnergySource::Hydro => Some(0),
GeneratorEnergySource::Nuclear => Some(1),
GeneratorEnergySource::Wind => Some(2),
GeneratorEnergySource::Thermal => Some(3),
GeneratorEnergySource::Solar => Some(4),
GeneratorEnergySource::Other => None,
};
if let Some(index) = index {
counts[index] += 1;
}
}
let total = counts.iter().sum::<usize>();
if total == 0 {
return;
}
let summary = ["hydro", "nuclear", "wind", "thermal", "solar"]
.into_iter()
.zip(counts)
.filter(|(_, count)| *count > 0)
.map(|(source, count)| format!("{source}={count}"))
.collect::<Vec<_>>()
.join(", ");
warnings.push(
&F.field_dropped,
format!(
"{total} generator energy source value(s) dropped ({summary}): PSS/E RAW and RAWX generator records have no energy source field"
),
);
}
#[derive(Default)]
struct WrittenImpedanceTables {
tables: BTreeMap<u32, Vec<ImpedanceCorrectionPoint>>,
branches: BTreeMap<usize, u32>,
branch_ratio2: BTreeMap<usize, f64>,
windings: BTreeMap<(usize, usize), u32>,
}
fn assign_impedance_tables(
net: &BalancedNetwork,
rev: u32,
warnings: &mut Diagnostics,
) -> WrittenImpedanceTables {
let mut corrections = Vec::new();
for (row, branch) in net.branches().iter().enumerate() {
if branch.is_transformer()
&& let Some(correction) = branch.impedance_correction()
{
corrections.push((ImpedanceTarget::Branch(row), correction));
}
}
for (row, transformer) in net.transformers_3w().iter().enumerate() {
for winding in 0..3 {
if let Some(correction) = transformer.winding_impedance_correction(winding) {
corrections.push((ImpedanceTarget::Winding(row, winding), correction));
}
}
}
let mut written = WrittenImpedanceTables::default();
let mut next_free = corrections
.iter()
.map(|(_, correction)| correction.table)
.max()
.unwrap_or(0)
.saturating_add(1);
let mut zero_table = None;
for (target, mut correction) in corrections {
let Some(ratio2) = prepare_impedance_points(&mut correction, target, rev, net, warnings)
else {
continue;
};
if correction.table == 0 {
let table = *zero_table.get_or_insert_with(|| {
let table = next_free;
next_free = next_free.saturating_add(1);
table
});
warnings.push(
&F.value_substituted,
format!(
"impedance correction table 0 for {} would end the section; written as table {table}",
impedance_target_label(net, target)
),
);
correction.table = table;
}
if let ImpedanceTarget::Branch(row) = target {
written.branch_ratio2.insert(row, ratio2);
}
let same_points = |points: &Vec<ImpedanceCorrectionPoint>| *points == correction.points;
let table = match written.tables.get(&correction.table) {
None => {
written
.tables
.insert(correction.table, correction.points.clone());
correction.table
}
Some(points) if same_points(points) => correction.table,
Some(_) => {
let existing = written
.tables
.iter()
.find(|(_, points)| same_points(points))
.map(|(table, _)| *table);
let table = if let Some(table) = existing {
table
} else {
let table = next_free;
next_free = next_free.saturating_add(1);
written.tables.insert(table, correction.points.clone());
table
};
warnings.push(
&F.value_substituted,
format!(
"impedance correction table {} states different per unit points for {}; written as table {table}",
correction.table,
impedance_target_label(net, target)
),
);
table
}
};
match target {
ImpedanceTarget::Branch(row) => {
written.branches.insert(row, table);
}
ImpedanceTarget::Winding(row, winding) => {
written.windings.insert((row, winding), table);
}
}
}
written
}
fn prepare_impedance_points(
correction: &mut ImpedanceCorrection,
target: ImpedanceTarget,
rev: u32,
net: &BalancedNetwork,
warnings: &mut Diagnostics,
) -> Option<f64> {
let mut ratio2 = 1.0;
correction
.points
.sort_by(|a, b| a.value.total_cmp(&b.value));
if correction.variable == ImpedanceCorrectionVariable::Ratio
&& impedance_table_variable(&correction.points) != ImpedanceCorrectionVariable::Ratio
{
if let (ImpedanceTarget::Branch(_), Some(first), Some(last)) =
(target, correction.points.first(), correction.points.last())
&& first.value > 0.0
&& last.value <= 3.0 * first.value
{
ratio2 = 2.0 / (first.value + last.value);
for point in &mut correction.points {
point.value = (point.value * ratio2).clamp(0.5, 1.5);
}
} else {
warnings.push(&F.field_dropped, format!(
"impedance correction table {} for {} dropped: its ratio range cannot be represented as a PSS/E ratio table",
correction.table, impedance_target_label(net, target),
));
return None;
}
}
let written_points = if rev < 34 {
correction.points.len().min(11)
} else {
correction.points.len()
};
if correction.variable == ImpedanceCorrectionVariable::Angle
&& let Some(first) = correction.points.first()
&& first.value >= 0.5
&& correction.points[..written_points]
.last()
.is_some_and(|last| last.value <= 1.5)
{
correction.points.insert(
0,
ImpedanceCorrectionPoint::new(-1.0, first.factor_re, first.factor_im),
);
}
Some(ratio2)
}
fn impedance_target_label(net: &BalancedNetwork, target: ImpedanceTarget) -> String {
match target {
ImpedanceTarget::Branch(row) => {
let branch = &net.branches()[row];
format!("transformer {}-{}", branch.from, branch.to)
}
ImpedanceTarget::Winding(row, winding) => {
let [i, j, k] = net.transformers_3w()[row]
.windings
.each_ref()
.map(|winding| winding.bus);
format!(
"three winding transformer {i}-{j}-{k} winding {}",
winding + 1
)
}
}
}
fn warn_edited_correction_nominals(net: &BalancedNetwork, warnings: &mut Diagnostics) {
for branch in net.branches() {
if let Some(correction) = branch.impedance_correction()
&& correction_nominal_edited(&branch.extras, &[branch.r, branch.x])
{
warnings.push(
&F.value_substituted,
format!(
"transformer {}-{} impedance was edited after its impedance correction was applied; the edited R, X are written as the nominal impedance table {} corrects",
branch.from, branch.to, correction.table
),
);
}
}
for t in net.transformers_3w() {
if (0..3).any(|w| t.winding_impedance_correction(w).is_some())
&& correction_nominal_edited(&t.extras, &t.z.map(|z| [z.r, z.x]))
{
warnings.push(
&F.value_substituted,
format!(
"three winding transformer {}-{}-{} impedance was edited after its impedance correction was applied; the edited pairwise R, X are written as the nominal impedances its tables correct",
t.windings[0].bus, t.windings[1].bus, t.windings[2].bus,
),
);
}
}
}
fn warn_unbacked_table_references(net: &BalancedNetwork, warnings: &mut Diagnostics) {
let mut unbacked = Vec::new();
for b in net.branches() {
if b.impedance_correction().is_none()
&& let Some(tab) = extra_i64(&b.extras, "psse_tab").filter(|tab| *tab != 0)
{
unbacked.push(format!("transformer {}-{} TAB {tab}", b.from, b.to));
}
}
for t in net.transformers_3w() {
for w in 0..3 {
if t.winding_impedance_correction(w).is_none()
&& let Some(tab) =
extra_i64(&t.extras, &format!("psse_tab{}", w + 1)).filter(|tab| *tab != 0)
{
unbacked.push(format!(
"three winding transformer {}-{}-{} winding {} TAB {tab}",
t.windings[0].bus,
t.windings[1].bus,
t.windings[2].bus,
w + 1
));
}
}
}
if !unbacked.is_empty() {
warnings.push(
&F.field_dropped,
format!(
"{} impedance correction table reference(s) written as TAB = 0: the network carries no table for them ({})",
unbacked.len(),
unbacked.join(", ")
),
);
}
}
#[derive(Clone, Copy)]
enum CorrectionFreeze {
PairwiseBeforeRevision35,
PairwiseWithoutControlWinding,
}
fn three_winding_correction_freeze(t: &Transformer3W, rev: u32) -> Option<CorrectionFreeze> {
if !(0..3).any(|w| t.winding_impedance_correction(w).is_some()) {
return None;
}
if extra_i64(&t.extras, "psse_zcod") != Some(1) {
return None;
}
if rev < 35 {
Some(CorrectionFreeze::PairwiseBeforeRevision35)
} else if t.impedance_correction_control_winding().is_none() {
Some(CorrectionFreeze::PairwiseWithoutControlWinding)
} else {
None
}
}
fn freeze_unwritable_corrections(
net: &BalancedNetwork,
rev: u32,
warnings: &mut Diagnostics,
) -> Option<BalancedNetwork> {
let reasons: Vec<_> = net
.transformers_3w()
.iter()
.map(|t| three_winding_correction_freeze(t, rev))
.collect();
if reasons.iter().all(Option::is_none) {
return None;
}
let mut projected = net.clone();
projected.apply_impedance_correction();
for (t, reason) in projected.transformers_3w_mut().iter_mut().zip(reasons) {
let Some(reason) = reason else {
continue;
};
for w in 0..3 {
t.set_winding_impedance_correction(w, None);
t.extras.remove(&format!("psse_tab{}", w + 1));
}
let [i, j, k] = t.windings.each_ref().map(|w| w.bus);
let message = match reason {
CorrectionFreeze::PairwiseBeforeRevision35 => {
t.extras.remove("psse_zcod");
format!(
"three winding transformer {i}-{j}-{k} ZCOD=1 impedance correction frozen at its present setting: revision {rev} cannot state pairwise correction tables"
)
}
CorrectionFreeze::PairwiseWithoutControlWinding => format!(
"three winding transformer {i}-{j}-{k} ZCOD=1 impedance correction frozen at its present impedances with TAB = 0: no single winding has an adjustable control to select the table setting, and PSS/E cannot read a ZCOD=1 table without one"
),
};
warnings.push(&F.value_collapsed, message);
}
Some(projected)
}
fn write_impedance_tables(
out: &mut String,
written: &WrittenImpedanceTables,
rev: u32,
num: &mut impl FnMut(f64) -> String,
warnings: &mut Diagnostics,
) {
const REVISION_33_POINTS: usize = 11;
for (table, points) in &written.tables {
if rev >= 34 {
let mut triples = points
.iter()
.map(|point| {
format!(
"{}, {}, {}",
num(point.value),
num(point.factor_re),
num(point.factor_im)
)
})
.collect::<Vec<_>>();
triples.push("0.0, 0.0, 0.0".to_owned());
for (line, chunk) in triples.chunks(6).enumerate() {
if line == 0 {
let _ = writeln!(out, "{table}, {}", chunk.join(", "));
} else {
let _ = writeln!(out, "{}", chunk.join(", "));
}
}
continue;
}
if points.len() > REVISION_33_POINTS {
warnings.push(
&F.value_truncated,
format!(
"impedance correction table {table} states {} points; a PSS/E revision 33 table holds {REVISION_33_POINTS}, so the first {REVISION_33_POINTS} were written",
points.len()
),
);
}
if points.iter().any(|point| point.factor_im != 0.0) {
warnings.push(
&F.value_collapsed,
format!(
"impedance correction table {table} states complex factors; a PSS/E revision 33 table states real factors, so the imaginary parts were dropped"
),
);
}
let mut pairs = points
.iter()
.take(REVISION_33_POINTS)
.map(|point| format!("{}, {}", num(point.value), num(point.factor_re)))
.collect::<Vec<_>>();
pairs.resize(REVISION_33_POINTS, "0.0, 0.0".to_owned());
let _ = writeln!(out, "{table}, {}", pairs.join(", "));
}
}
fn shunt_facts_names(
net: &BalancedNetwork,
warnings: &mut Diagnostics,
sanitized_quoted: &mut usize,
) -> Vec<String> {
let requested = net
.static_var_compensators()
.iter()
.map(|svc| {
svc.extras
.get("psse_name")
.and_then(Value::as_str)
.map(|raw| {
let name = sanitize_quoted(raw, NAME_FORBIDDEN, ' ').trim().to_owned();
*sanitized_quoted += usize::from(name != raw);
name
})
.filter(|name| !name.is_empty())
})
.collect::<Vec<_>>();
let reserved = requested.iter().flatten().cloned().collect::<BTreeSet<_>>();
let mut used = BTreeSet::new();
let mut renamed = 0usize;
let names = requested
.into_iter()
.enumerate()
.map(|(index, requested)| {
if let Some(name) = requested
.as_ref()
.filter(|name| used.insert((*name).clone()))
{
return name.clone();
}
renamed += usize::from(requested.is_some());
let base = format!("SVC{}", index + 1);
let mut candidate = base.clone();
let mut suffix = 2usize;
while reserved.contains(&candidate) || used.contains(&candidate) {
candidate = format!("{base}_{suffix}");
suffix += 1;
}
used.insert(candidate.clone());
candidate
})
.collect();
if renamed > 0 {
warnings.push(&F.value_substituted, format!(
"{renamed} FACTS device name(s) collided after PSS/E sanitation; allocated unique names"
));
}
names
}
fn write_shunt_facts(
out: &mut String,
net: &BalancedNetwork,
rev: u32,
num: &mut impl FnMut(f64) -> String,
warnings: &mut Diagnostics,
sanitized_quoted: &mut usize,
) -> Vec<String> {
let mut asymmetric = 0usize;
let mut stated_output = 0usize;
let mut other_control = 0usize;
let mut defaulted_setpoint = 0usize;
let names = shunt_facts_names(net, warnings, sanitized_quoted);
for (svc, name) in net.static_var_compensators().iter().zip(&names) {
let limit = svc.b_max_siemens.abs().max(svc.b_min_siemens.abs());
asymmetric +=
usize::from((svc.b_max_siemens + svc.b_min_siemens).abs() > 1e-9 * limit.max(1.0));
stated_output += usize::from(svc.p != 0.0 || svc.q != 0.0);
other_control += usize::from(
svc.regulation_mode != StaticVarCompensatorRegulationMode::Voltage || !svc.regulating,
);
defaulted_setpoint += usize::from(svc.voltage_setpoint_kv <= 0.0);
let record = shunt_facts_record(net, svc, name, rev, num, warnings, sanitized_quoted);
let _ = writeln!(out, "{record}");
}
let approximated = net
.static_var_compensators()
.iter()
.filter(|svc| svc.b_min_siemens != 0.0 || svc.b_max_siemens != 0.0)
.count();
for (count, code, what) in [
(
approximated,
&F.value_substituted,
"have fixed susceptance limits; SHMX is a current limit matched at unity device-bus voltage only, so reactive limits differ away from unity voltage",
),
(
asymmetric,
&F.value_collapsed,
"state unequal capacitive and inductive limits; a shunt FACTS device states one limit, SHMX, so the larger was written",
),
(
stated_output,
&F.field_dropped,
"state terminal power; a FACTS device record states none",
),
(
other_control,
&F.value_substituted,
"do not regulate voltage; each was written as a shunt FACTS device, which regulates voltage",
),
(
defaulted_setpoint,
&F.value_defaulted,
"state no voltage setpoint; VSET 1.0 was written",
),
] {
if count > 0 {
warnings.push(code, format!("{count} static var compensator(s) {what}"));
}
}
names
}
#[allow(clippy::float_cmp)] fn shunt_facts_record(
net: &BalancedNetwork,
svc: &StaticVarCompensator,
name: &str,
rev: u32,
num: &mut impl FnMut(f64) -> String,
warnings: &mut Diagnostics,
sanitized_quoted: &mut usize,
) -> String {
let kv = net
.buses()
.iter()
.find(|bus| bus.id == svc.bus)
.map(|bus| bus.base_kv)
.filter(|kv| kv.is_finite() && *kv > 0.0)
.unwrap_or(1.0);
let mut quoted = |key: &str, fallback: &str| {
let raw = svc
.extras
.get(key)
.and_then(Value::as_str)
.unwrap_or(fallback);
let value = sanitize_quoted(raw, NAME_FORBIDDEN, ' ');
*sanitized_quoted += usize::from(matches!(value, std::borrow::Cow::Owned(_)));
value.into_owned()
};
let mname = quoted("psse_mname", "");
let mode = if svc.in_service {
extra_i64(&svc.extras, "psse_mode").unwrap_or(1)
} else {
0
};
let (regulated, node) = regulating_target(
net,
svc.regulating_terminal.as_ref(),
facts_regulated_bus(&svc.extras),
format_args!("FACTS device at bus {}", svc.bus),
warnings,
);
let regulated_kv = if regulated == 0 {
kv
} else {
net.buses().iter().find(|bus| bus.id.0 == regulated)
.map(|bus| bus.base_kv).filter(|base| base.is_finite() && *base > 0.0)
.unwrap_or_else(|| {
warnings.push(&F.value_substituted, format!(
"PSS/E FACTS device {name:?}: regulated bus {regulated} has no positive base kV; VSET uses the device bus base"
));
kv
})
};
let vset = if svc.voltage_setpoint_kv > 0.0 {
extra_f64(&svc.extras, "psse_vset")
.filter(|vset| vset * regulated_kv == svc.voltage_setpoint_kv)
.unwrap_or(svc.voltage_setpoint_kv / regulated_kv)
} else {
1.0
};
let regulation = if rev >= 35 {
format!("{regulated}, {node}, '{mname}'")
} else if rev == 34 {
format!("{regulated}, '{mname}', {node}")
} else {
if node != 0 {
warnings.push(&F.field_dropped, format!(
"PSS/E FACTS device at bus {}: regulating node {node} has no NREG field before revision 34; emitted only REMOT", svc.bus
));
}
format!("{regulated}, '{mname}'")
};
let float = |key: &str, default: f64| extra_f64(&svc.extras, key).unwrap_or(default);
let limit = svc.b_max_siemens.abs().max(svc.b_min_siemens.abs());
let shmx = extra_f64(&svc.extras, "psse_shmx")
.filter(|shmx| shmx / (kv * kv) == limit)
.unwrap_or(limit * (kv * kv));
let fields = [
float("psse_pdes", 0.0),
float("psse_qdes", 0.0),
vset,
shmx,
float("psse_trmx", 9999.0),
float("psse_vtmn", 0.9),
float("psse_vtmx", 1.1),
float("psse_vsmx", 1.0),
float("psse_imx", 0.0),
float("psse_linx", 0.05),
float("psse_rmpct", 100.0),
]
.map(&mut *num)
.join(", ");
let owner = extra_i64(&svc.extras, "psse_owner").unwrap_or(1);
let set1 = num(float("psse_set1", 0.0));
let set2 = num(float("psse_set2", 0.0));
let vsref = extra_i64(&svc.extras, "psse_vsref").unwrap_or(0);
format!(
"'{name}', {}, 0, {mode}, {fields}, {owner}, {set1}, {set2}, {vsref}, {regulation}",
svc.bus
)
}
const NAME_FORBIDDEN: &[char] = &['\'', '/'];
const BARE_FORBIDDEN: &[char] = &['\'', '"', '/'];
const BARE_REPLACEMENT: char = '_';
fn write_system_switch(
out: &mut String,
switch: &Switch,
ckt: &str,
num: &mut impl FnMut(f64) -> String,
sanitized_quoted: &mut usize,
) {
let raw_name = switch
.extras
.get("psse_name")
.and_then(Value::as_str)
.unwrap_or("");
let name = sanitize_quoted(raw_name, NAME_FORBIDDEN, ' ');
*sanitized_quoted += usize::from(matches!(name, std::borrow::Cow::Owned(_)));
let mut record = vec![
switch.from.to_string(),
switch.to.to_string(),
format!("'{ckt}'"),
num(extra_f64(&switch.extras, "psse_xpu").unwrap_or(0.0)),
num(switch.thermal_rating.unwrap_or(0.0)),
];
record.extend((2..=12).map(|rating| {
num(extra_f64(&switch.extras, &format!("psse_rate{rating}")).unwrap_or(0.0))
}));
record.extend([
i32::from(switch.closed).to_string(),
extra_i64(&switch.extras, "psse_nstat")
.unwrap_or(1)
.to_string(),
extra_i64(&switch.extras, "psse_met")
.unwrap_or(1)
.to_string(),
extra_i64(&switch.extras, "psse_stype")
.unwrap_or(1)
.to_string(),
format!("'{name}'"),
]);
let _ = writeln!(out, "{}", record.join(", "));
}
#[must_use]
#[cfg(test)]
fn write_psse(net: &BalancedNetwork) -> TextEmission {
write_psse_rev(net, REV)
}
#[must_use]
pub fn write_psse_rev(net: &BalancedNetwork, rev: u32) -> TextEmission {
write_psse_rev_inner(net, rev, true)
}
pub(super) fn write_psse_rev_for_rawx(net: &BalancedNetwork, rev: u32) -> TextEmission {
write_psse_rev_inner(net, rev, false)
}
#[expect(clippy::too_many_lines)]
fn write_psse_rev_inner(
net: &BalancedNetwork,
rev: u32,
include_detailed_connectivity: bool,
) -> TextEmission {
let modern = rev >= 34;
let mut warnings = Diagnostics::new();
warn_edited_correction_nominals(net, &mut warnings);
let projected = freeze_unwritable_corrections(net, rev, &mut warnings);
let net = projected.as_ref().unwrap_or(net);
let numbered;
let net = if include_detailed_connectivity {
let mut numbering = Diagnostics::new();
if let Some(prepared) = super::rawx::with_psse_node_numbers(net, &mut numbering) {
if modern {
warnings.absorb(numbering);
}
numbered = prepared;
&numbered
} else {
net
}
} else {
net
};
let rated;
let net = if let Some((projected, count)) =
super::powsybl_psse::rate_branches_from_current_limits(net)
{
warnings.push(
&F.value_substituted,
format!(
"{count} branch current limit set(s) written as MVA ratings, sqrt(3) * Vnom * I / 1000 at the terminal's nominal voltage and sorted into RATE1-RATE12 as PowSybl's exporter writes them: PSS/E branch ratings are MVA ratings"
),
);
rated = projected;
&rated
} else {
net
};
let stored;
let net = if let Some((projected, count)) = super::powsybl_psse::storage_as_loads(net) {
warnings.push(
&F.value_collapsed,
format!(
"{count} storage unit(s) written as PSS/E loads consuming the negated storage injection, as PowSybl's exporter writes a battery: PSS/E has no storage record, so energy, charge and discharge ratings, efficiencies, and reactive limits are dropped"
),
);
stored = projected;
&stored
} else {
net
};
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 case_name = sanitize_quoted(net.name(), NAME_FORBIDDEN, ' ');
let _ = writeln!(
s,
"0, {}, {rev}, 0, {}, {} / powerio export: {}",
net.base_mva(),
i32::from(modern),
num(net.base_frequency()),
case_name
);
let _ = writeln!(s, "{case_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 owner = extra_i64(&b.extras, "psse_owner").unwrap_or(1);
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}', {}, {}, {}, {}, {owner}, {}, {}, {}, {}, {}, {}",
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 (bus_area, bus_zone) = bus_area.get(&l.bus).copied().unwrap_or((1, 1));
let area = extra_i64(&l.extras, "psse_area")
.and_then(|value| usize::try_from(value).ok())
.unwrap_or(bus_area);
let zone = extra_i64(&l.extras, "psse_zone")
.and_then(|value| usize::try_from(value).ok())
.unwrap_or(bus_zone);
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 yq = psse_yq_consumption(yq);
let (pdgen, qdgen) = load_distributed_generation(&l.extras);
let (pl, ql) = if modern {
(
gross_load_power(pl, pdgen, extra_f64(&l.extras, "psse_pl")),
gross_load_power(ql, qdgen, extra_f64(&l.extras, "psse_ql")),
)
} else {
if pdgen != 0.0 || qdgen != 0.0 {
warnings.push(
&F.value_collapsed,
format!(
"PSS/E load at bus {} id {id:?}: distributed generation {} MW, {} Mvar has no revision 33 field; PL and QL state the load net of it",
l.bus, pdgen, qdgen
),
);
}
(pl, ql)
};
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(
&F.record_dropped,
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, BTreeSet<String>> = BTreeMap::new();
for g in net.generators() {
let preferred_id =
detailed_source_property(net, "generator", g.uid.as_deref(), "psse_eqid")
.filter(|id| !id.is_empty());
let id = quoted_circuit_id(preferred_id, g.bus, &mut gen_ids, &mut sanitized_quoted);
let (ireg, regulated_node) = regulating_target(
net,
g.regulating_terminal.as_ref(),
g.regulated_bus,
format_args!("generator at bus {}", g.bus),
&mut warnings,
);
if rev < 34 && regulated_node != 0 {
warnings.push(
&F.field_dropped,
format!(
"PSS/E generator at bus {} id {id:?}: regulating node {regulated_node} has no NREG field before revision 34; emitted only IREG",
g.bus
),
);
}
let source_float = |property, default, warnings: &mut Diagnostics| {
generator_source_float(net, g, property, default, warnings)
};
let source_integer = |property, default, warnings: &mut Diagnostics| {
generator_source_integer(net, g, property, default, warnings)
};
let zr = source_float("psse_zr", 0.0, &mut warnings);
let zx = source_float("psse_zx", 1.0, &mut warnings);
let rt = source_float("psse_rt", 0.0, &mut warnings);
let xt = source_float("psse_xt", 0.0, &mut warnings);
let gtap = source_float("psse_gtap", 1.0, &mut warnings);
let rmpct = source_float("psse_rmpct", 100.0, &mut warnings);
let baslod = source_integer("psse_baslod", 0, &mut warnings);
let owners = [
(
source_integer("psse_o1", 1, &mut warnings),
source_float("psse_f1", 1.0, &mut warnings),
),
(
source_integer("psse_o2", 0, &mut warnings),
source_float("psse_f2", 1.0, &mut warnings),
),
(
source_integer("psse_o3", 0, &mut warnings),
source_float("psse_f3", 1.0, &mut warnings),
),
(
source_integer("psse_o4", 0, &mut warnings),
source_float("psse_f4", 1.0, &mut warnings),
),
];
let wmod = source_integer("psse_wmod", 0, &mut warnings);
let wpf = source_float("psse_wpf", 1.0, &mut warnings);
if rev < 35 && baslod != 0 {
warnings.push(
&F.field_dropped,
format!(
"PSS/E generator at bus {} id {id:?}: BASLOD {baslod} has no field before revision 35; dropped",
g.bus
),
);
}
let mut record = vec![
g.bus.to_string(),
format!("'{id}'"),
num(g.pg),
num(g.qg),
num(g.qmax),
num(g.qmin),
num(g.vg),
ireg.to_string(),
];
if rev >= 35 {
record.push(regulated_node.to_string());
}
record.extend([
num(g.mbase),
num(zr),
num(zx),
num(rt),
num(xt),
num(gtap),
i32::from(g.in_service).to_string(),
num(rmpct),
num(g.pmax),
num(g.pmin),
]);
if rev >= 35 {
record.push(baslod.to_string());
}
for (owner, fraction) in owners {
record.push(owner.to_string());
record.push(num(fraction));
}
record.push(wmod.to_string());
record.push(num(wpf));
if rev == 34 {
record.push(regulated_node.to_string());
}
let _ = writeln!(s, "{}", record.join(", "));
}
let _ = writeln!(s, "0 / END OF GENERATOR DATA, BEGIN BRANCH DATA");
let mut branch_ids: BTreeMap<(BusId, BusId), BTreeSet<String>> = BTreeMap::new();
let mut transformer_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.calc_terminal_charging();
let b_total = charging.calc_total_b();
let b_mid = b_total / 2.0;
let bi = charging.b_fr - b_mid;
let bj = charging.b_to - b_mid;
let met = extra_i64(&br.extras, "psse_met").unwrap_or(1);
let len = extra_f64(&br.extras, "psse_len").unwrap_or(0.0);
let owners = psse_ownership(&br.extras);
if modern {
let raw_name = br.name.as_deref().unwrap_or("");
let name = sanitize_quoted(raw_name, NAME_FORBIDDEN, ' ');
if matches!(name, std::borrow::Cow::Owned(_)) {
sanitized_quoted += 1;
}
let extra_ratings = psse_extra_rating_values(br, branch_index, &mut warnings);
let _ = writeln!(
s,
"{}, {}, '{ckt}', {}, {}, {}, '{:<12}', {}, {}, {}, \
{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {met}, {}, \
{}, {}, {}, {}, {}, {}, {}, {}",
br.from,
br.to,
num(br.r),
num(br.x),
num(b_total),
name,
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),
num(len),
owners[0].0,
num(owners[0].1),
owners[1].0,
num(owners[1].1),
owners[2].0,
num(owners[2].1),
owners[3].0,
num(owners[3].1)
);
} else {
let _ = writeln!(
s,
"{}, {}, '{ckt}', {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {met}, {}, \
{}, {}, {}, {}, {}, {}, {}, {}",
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),
num(len),
owners[0].0,
num(owners[0].1),
owners[1].0,
num(owners[1].1),
owners[2].0,
num(owners[2].1),
owners[3].0,
num(owners[3].1)
);
}
}
if rev >= 34 {
let _ = writeln!(
s,
"0 / END OF BRANCH DATA, BEGIN SYSTEM SWITCHING DEVICE DATA"
);
if include_detailed_connectivity {
let mut switch_ids = BTreeMap::new();
for switch in net.switches() {
let ckt = quoted_circuit_id(
switch.extras.get("psse_ckt").and_then(Value::as_str),
(switch.from, switch.to),
&mut switch_ids,
&mut sanitized_quoted,
);
write_system_switch(&mut s, switch, &ckt, &mut num, &mut sanitized_quoted);
}
}
let _ = writeln!(
s,
"0 / END OF SYSTEM SWITCHING DEVICE DATA, BEGIN TRANSFORMER DATA"
);
} else {
let _ = writeln!(s, "0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA");
if include_detailed_connectivity && !net.switches().is_empty() {
warnings.push(
&F.record_dropped,
format!(
"{} system switching device record(s) dropped: PSS/E revision {rev} has no system switching device section",
net.switches().len()
),
);
}
}
let impedance_tables = assign_impedance_tables(net, rev, &mut warnings);
warn_unbacked_table_references(net, &mut warnings);
for (branch_index, br) in net
.branches()
.iter()
.enumerate()
.filter(|(_, b)| b.is_transformer())
{
let source_id = br
.extras
.get("id")
.and_then(Value::as_str)
.or_else(|| detailed_source_id(net, "transformer", br.uid.as_deref()));
let transformer_id = quoted_circuit_id(
source_id,
(br.from, br.to),
&mut transformer_ids,
&mut sanitized_quoted,
);
let charging = br.calc_terminal_charging();
let referral = magnetizing_referral(br.calc_effective_tap());
let raw_name = br.name.as_deref().unwrap_or("");
let name = sanitize_quoted(raw_name, NAME_FORBIDDEN, ' ');
if matches!(name, std::borrow::Cow::Owned(_)) {
sanitized_quoted += 1;
}
let raw_vecgrp = br
.extras
.get("psse_vecgrp")
.and_then(Value::as_str)
.unwrap_or("");
let vecgrp = sanitize_quoted(raw_vecgrp, NAME_FORBIDDEN, ' ');
if matches!(vecgrp, std::borrow::Cow::Owned(_)) {
sanitized_quoted += 1;
}
let nmetr = extra_i64(&br.extras, "psse_nmetr").unwrap_or(2);
let owners = psse_ownership(&br.extras);
let zcod = extra_i64(&br.extras, "psse_zcod").unwrap_or(0);
if rev < 35 && zcod != 0 {
warnings.push(
&F.field_dropped,
format!(
"PSS/E transformer {}-{} ZCOD {zcod} dropped: the field requires revision 35",
br.from, br.to
),
);
}
let mut main = vec![
br.from.to_string(),
br.to.to_string(),
"0".to_owned(),
format!("'{transformer_id}'"),
"1".to_owned(),
"1".to_owned(),
"1".to_owned(),
num(unrefer_magnetizing(charging.calc_total_g(), referral)),
num(unrefer_magnetizing(charging.calc_total_b(), referral)),
nmetr.to_string(),
format!("'{name:<12}'"),
i32::from(br.in_service).to_string(),
];
for (owner, fraction) in owners {
main.push(owner.to_string());
main.push(num(fraction));
}
main.push(format!("'{vecgrp:<12}'"));
if rev >= 35 {
main.push(zcod.to_string());
}
let _ = writeln!(s, "{}", main.join(", "));
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| {
let code = mode_to_cod(c.mode);
if c.enabled { code } else { -code }
});
let (cont, node) = ctl.map_or((0, 0), |control| {
regulating_target(
net,
control.regulating_terminal.as_ref(),
control.controlled_bus,
format_args!("transformer {}-{}", br.from, br.to),
&mut warnings,
)
});
let cont = ctl.map_or(i64::try_from(cont).unwrap_or(i64::MAX), |control| {
emit_controlled_bus(
control,
cont,
format_args!("transformer {}-{}", br.from, br.to),
&mut warnings,
)
});
let ratio2 = impedance_tables
.branch_ratio2
.get(&branch_index)
.copied()
.unwrap_or(1.0);
let (mut rma, mut 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)
});
if ctl.is_some_and(|control| {
matches!(
control.mode,
TransformerControlMode::Voltage | TransformerControlMode::ReactiveFlow
)
}) {
rma *= ratio2;
rmi *= ratio2;
}
let cnxa = ctl
.and_then(|control| control.winding_connection_angle)
.unwrap_or(0.0);
let tab = impedance_tables
.branches
.get(&branch_index)
.map_or(0, |table| i64::from(*table));
let cr = extra_f64(&br.extras, "psse_cr").unwrap_or(0.0);
let cx = extra_f64(&br.extras, "psse_cx").unwrap_or(0.0);
let [r, x] = impedance_tables
.branches
.contains_key(&branch_index)
.then(|| current_correction_nominal(&br.extras, &[br.r, br.x]))
.flatten()
.unwrap_or([br.r, br.x]);
let _ = writeln!(
s,
"{}, {}, {}",
num(r / ratio2.powi(2)),
num(x / ratio2.powi(2)),
num(sbase)
);
let control = place_winding_node(
vec![
cod.to_string(),
cont.to_string(),
num(rma),
num(rmi),
num(vma),
num(vmi),
ntp.to_string(),
tab.to_string(),
num(cr),
num(cx),
num(cnxa),
],
rev,
node,
);
if modern {
let extra_ratings = psse_extra_rating_values(br, branch_index, &mut warnings);
let _ = writeln!(
s,
"{}, 0, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {control}",
num(br.calc_effective_tap() * ratio2),
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]),
);
} else {
let _ = writeln!(
s,
"{}, 0, {}, {}, {}, {}, {control}",
num(br.calc_effective_tap() * ratio2),
num(br.shift),
num(br.rate_a),
num(br.rate_b),
num(br.rate_c),
);
}
let _ = writeln!(s, "{}, 0", num(ratio2));
}
let mut transformer_3w_ids: BTreeMap<(BusId, BusId, BusId), u32> = BTreeMap::new();
for (transformer_index, t) in net.transformers_3w().iter().enumerate() {
let transformer_id =
transformer_3w_id(net, t, &mut transformer_3w_ids, &mut sanitized_quoted);
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 raw_vecgrp = t
.extras
.get("psse_vecgrp")
.and_then(Value::as_str)
.unwrap_or("");
let vecgrp = sanitize_quoted(raw_vecgrp, NAME_FORBIDDEN, ' ');
if matches!(vecgrp, std::borrow::Cow::Owned(_)) {
sanitized_quoted += 1;
}
let nmetr = extra_i64(&t.extras, "psse_nmetr").unwrap_or(2);
let owners = psse_ownership(&t.extras);
let zcod = extra_i64(&t.extras, "psse_zcod").unwrap_or(0);
if rev < 35 && zcod != 0 {
warnings.push(
&F.field_dropped,
format!(
"PSS/E three winding transformer {}-{}-{} ZCOD {zcod} dropped: the field requires revision 35",
t.windings[0].bus, t.windings[1].bus, t.windings[2].bus
),
);
}
let stat = three_winding_stat(t).unwrap_or_else(|| {
warnings.push(
&F.value_collapsed,
format!(
"PSS/E three winding transformer {}-{}-{} has more than one winding out of service; no STAT code states that, so it was emitted out of service (STAT 0)",
t.windings[0].bus, t.windings[1].bus, t.windings[2].bus
),
);
0
});
let mut main = vec![
t.windings[0].bus.to_string(),
t.windings[1].bus.to_string(),
t.windings[2].bus.to_string(),
format!("'{transformer_id}'"),
"1".to_owned(),
"1".to_owned(),
"1".to_owned(),
num(t.mag_g),
num(t.mag_b),
nmetr.to_string(),
format!("'{name:<12}'"),
stat.to_string(),
];
for (owner, fraction) in owners {
main.push(owner.to_string());
main.push(num(fraction));
}
main.push(format!("'{vecgrp:<12}'"));
if rev >= 35 {
main.push(zcod.to_string());
}
let _ = writeln!(s, "{}", main.join(", "));
let [mut z12, mut z23, mut z31] = t.z;
let corrected = (0..3).any(|winding| {
impedance_tables
.windings
.contains_key(&(transformer_index, winding))
});
if let Some(nominal) = corrected
.then(|| current_correction_nominal(&t.extras, &t.z.map(|z| [z.r, z.x])))
.flatten()
{
for (z, [r, x]) in [&mut z12, &mut z23, &mut z31].into_iter().zip(nominal) {
(z.r, z.x) = (r, x);
}
}
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 (winding_index, w) in t.windings.iter().enumerate() {
let control = w.control.as_ref();
if control.is_some_and(|control| control.mode == TransformerControlMode::DcLineQuantity)
{
warnings.push(
&F.field_dropped,
format!(
"PSS/E three winding transformer winding at bus {}: COD 4 DC line quantity control is valid only for two winding transformers; emitted fixed control",
w.bus
),
);
}
let control =
control.filter(|control| control.mode != TransformerControlMode::DcLineQuantity);
let cod = control.map_or(0, |control| {
let code = mode_to_cod(control.mode);
if control.enabled { code } else { -code }
});
let (cont, node) = control.map_or((0, 0), |control| {
regulating_target(
net,
control.regulating_terminal.as_ref(),
control.controlled_bus,
format_args!("three winding transformer winding at bus {}", w.bus),
&mut warnings,
)
});
let cont = control.map_or(i64::try_from(cont).unwrap_or(i64::MAX), |control| {
emit_controlled_bus(
control,
cont,
format_args!("three winding transformer winding at bus {}", w.bus),
&mut warnings,
)
});
let (rma, rmi, vma, vmi, ntp) = control.map_or((1.1, 0.9, 1.1, 0.9, 33), |control| {
(
control.tap_max,
control.tap_min,
control.band_max,
control.band_min,
control.ntp,
)
});
let cnxa = control
.and_then(|control| control.winding_connection_angle)
.unwrap_or(0.0);
let suffix = winding_index + 1;
let tab = impedance_tables
.windings
.get(&(transformer_index, winding_index))
.map_or(0, |table| i64::from(*table));
let cr = extra_f64(&t.extras, &format!("psse_cr{suffix}")).unwrap_or(0.0);
let cx = extra_f64(&t.extras, &format!("psse_cx{suffix}")).unwrap_or(0.0);
let control = place_winding_node(
vec![
cod.to_string(),
cont.to_string(),
num(rma),
num(rmi),
num(vma),
num(vmi),
ntp.to_string(),
tab.to_string(),
num(cr),
num(cx),
num(cnxa),
],
rev,
node,
);
if modern {
let extra_ratings =
psse_winding_extra_rating_values(t, winding_index, &mut warnings);
let _ = writeln!(
s,
"{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {control}",
num(w.tap),
num(w.nominal_kv),
num(w.shift),
num(w.rate_a),
num(w.rate_b),
num(w.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]),
);
} else {
let _ = writeln!(
s,
"{}, {}, {}, {}, {}, {}, {control}",
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().filter(|dc| !is_vsc_line(dc)).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", DEFAULT_CONTROL_TAIL);
let (rect_tail, dropped_rectifier_node) =
dc_converter_tail(&dc.extras, "psse_dc_rectifier_tail", rev);
let (inv_tail, dropped_inverter_node) =
dc_converter_tail(&dc.extras, "psse_dc_inverter_tail", rev);
for (end, node) in [
("rectifier", dropped_rectifier_node),
("inverter", dropped_inverter_node),
] {
if let Some(node) = node {
warnings.push(
&codes::EMIT_PSSE.field_dropped,
format!(
"DC line `{}` {end} states node {node} of its commutating transformer bus; a PSS/E revision 33 two-terminal DC record has no NDR/NDI column",
dc.uid.as_deref().unwrap_or("<unnamed>")
),
);
}
}
let setvl = if let Some(stated) = dc_f64(&dc.extras, "psse_dc_setvl") {
stated
} else if mdc == 2 && vschd > 0.0 {
1000.0 * dc.pf / vschd
} else if mdc == 1 && dc.extras.contains_key(SETVL_AT_INVERTER) {
-dc.pt
} else {
dc.pf
};
let _ = writeln!(
s,
"{name}, {mdc}, {}, {}, {}, {l1_tail}",
num(rdc),
num(setvl),
num(vschd)
);
let _ = writeln!(s, "{}, {rect_tail}", dc.from);
let _ = writeln!(s, "{}, {inv_tail}", dc.to);
}
let _ = writeln!(s, "{}", EMPTY_SECTIONS[1]);
for (i, dc) in net.hvdc().iter().filter(|dc| is_vsc_line(dc)).enumerate() {
for line in vsc_record_lines(
net,
dc,
i,
rev,
&mut num,
&mut warnings,
&mut sanitized_quoted,
) {
let _ = writeln!(s, "{line}");
}
}
let _ = writeln!(s, "{}", EMPTY_SECTIONS[2]);
write_impedance_tables(&mut s, &impedance_tables, rev, &mut num, &mut warnings);
for line in &EMPTY_SECTIONS[3..=8] {
let _ = writeln!(s, "{line}");
}
let facts_names = write_shunt_facts(
&mut s,
net,
rev,
&mut num,
&mut warnings,
&mut sanitized_quoted,
);
let _ = writeln!(s, "{}", EMPTY_SECTIONS[9]);
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 modsw = extra_i64(&sh.extras, "psse_modsw")
.filter(|raw| modsw_to_mode(*raw) == c.mode)
.unwrap_or_else(|| mode_to_modsw(c.mode));
let adjm = extra_i64(&sh.extras, "psse_adjm").unwrap_or(0);
let raw_rmidnt = sh
.extras
.get("psse_rmidnt")
.and_then(Value::as_str)
.unwrap_or("");
let rmidnt = sanitize_quoted(raw_rmidnt, NAME_FORBIDDEN, ' ');
if matches!(rmidnt, std::borrow::Cow::Owned(_)) {
sanitized_quoted += 1;
}
let (swrem, nreg) = regulating_target(
net,
c.regulating_terminal.as_ref(),
c.control_bus,
format_args!("switched shunt at bus {}", sh.bus),
&mut warnings,
);
let written_blocks = if rev == 34 {
c.blocks.len().min(8)
} else {
c.blocks.len()
};
if written_blocks < c.blocks.len() {
warnings.push(
&F.field_dropped,
format!(
"PSS/E switched shunt at bus {}: {} step block(s) after the eighth dropped: revision 34 states NREG after the eighth (N, B) pair",
sh.bus,
c.blocks.len() - written_blocks
),
);
}
let mut blocks = String::new();
for blk in &c.blocks[..written_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}', {}, {adjm}, {}, {}, {}, {swrem}, {nreg}, {}, '{rmidnt}', {}{blocks}",
sh.bus,
modsw,
i32::from(sh.in_service),
num(c.vhigh),
num(c.vlow),
num(c.rmpct),
num(sh.b)
);
} else {
if nreg != 0 {
if rev == 34 {
for _ in written_blocks..8 {
blocks.push_str(", 0, 0.0");
}
let _ = write!(blocks, ", {nreg}");
} else {
warnings.push(
&F.field_dropped,
format!(
"PSS/E switched shunt at bus {}: regulating node {nreg} has no NREG field before revision 34; emitted only SWREG",
sh.bus
),
);
}
}
let _ = writeln!(
s,
"{}, {}, {adjm}, {}, {}, {}, {swrem}, {}, '{rmidnt}', {}{blocks}",
sh.bus,
modsw,
i32::from(sh.in_service),
num(c.vhigh),
num(c.vlow),
num(c.rmpct),
num(sh.b)
);
}
}
for line in &EMPTY_SECTIONS[10..12] {
let _ = writeln!(s, "{line}");
}
let detailed_records = if include_detailed_connectivity && net.detailed_connectivity().is_some()
{
match super::rawx::write_raw_substation_data(net, &facts_names) {
Ok((records, detailed_warnings)) => {
warnings.absorb(detailed_warnings);
records
}
Err(error) if modern => {
warnings.push(
&F.field_dropped,
format!(
"detailed connectivity dropped from PSS/E revision {rev} output: {error}"
),
);
None
}
Err(_) => {
warnings.push(
&F.record_dropped,
"detailed substation connectivity dropped: PSS/E revision 33 has no substation data block",
);
None
}
}
} else {
None
};
if rev >= 34 {
let _ = writeln!(
s,
"0 / END OF INDUCTION MACHINE DATA, BEGIN SUBSTATION DATA"
);
if let Some(records) = detailed_records {
s.push_str(&records);
}
let _ = writeln!(s, "0 / END OF SUBSTATION DATA");
} else {
let _ = writeln!(s, "{}", EMPTY_SECTIONS[12]);
if detailed_records.is_some() {
warnings.push(
&F.record_dropped,
"detailed substation connectivity dropped: PSS/E revision 33 has no substation data block",
);
}
}
let _ = writeln!(s, "Q");
warn_dc_record_gaps(net, &mut warnings);
warn_case_metadata_and_area_types(net, &mut warnings);
if net.generators().iter().any(|g| g.cost.is_some()) {
warnings.push(
&F.field_dropped,
"generator cost curves dropped: PSS/E .raw has no cost data",
);
}
warn_generator_energy_sources_dropped(net, &mut warnings);
if net.hvdc().iter().any(|d| d.cost.is_some()) {
warnings.push(
&F.field_dropped,
"DC line cost curves dropped: PSS/E .raw has no cost data",
);
}
if net.branches().iter().any(Branch::has_angle_limits) {
warnings.push(
&F.field_dropped,
"branch angle limits (angmin/angmax) dropped: PSS/E branch records carry none",
);
}
if rev == 33 {
let named_branches = net
.branches()
.iter()
.filter(|branch| {
!branch.is_transformer()
&& branch
.name
.as_deref()
.is_some_and(|name| !name.trim().is_empty())
})
.count();
if named_branches > 0 {
warnings.push(
&F.field_dropped,
format!(
"{named_branches} non-transformer branch name(s) dropped: PSS/E revision 33 branch records have no name field"
),
);
}
}
let current_ratings = net
.branches()
.iter()
.filter(|b| b.current_ratings.is_some())
.count();
if current_ratings > 0 {
warnings.push(&F.field_dropped, format!(
"{current_ratings} branch current rating record(s) dropped: PSS/E branch ratings are MVA ratings"
));
}
if modern {
let switch_current_ratings = net
.switches()
.iter()
.filter(|switch| switch.current_rating.is_some())
.count();
if switch_current_ratings > 0 {
warnings.push(
&F.field_dropped,
format!(
"{switch_current_ratings} switch current rating(s) dropped: PSS/E system switching device records carry MVA ratings, not current ratings"
),
);
}
let switch_power_flows = net
.switches()
.iter()
.filter(|switch| {
switch.pf.is_some()
|| switch.qf.is_some()
|| switch.pt.is_some()
|| switch.qt.is_some()
})
.count();
if switch_power_flows > 0 {
warnings.push(
&F.field_dropped,
format!(
"{switch_power_flows} switch power flow result set(s) dropped: PSS/E system switching device records carry no power flow result fields"
),
);
}
if include_detailed_connectivity {
let rating_set_names = net
.switches()
.iter()
.filter(|switch| switch.extras.contains_key("psse_rsetnam"))
.count();
if rating_set_names > 0 {
warnings.push(
&F.field_dropped,
format!(
"{rating_set_names} system switching device rating set name(s) dropped: PSS/E RAW revision {rev} writes explicit RATE1-RATE12 fields"
),
);
}
}
}
if !modern {
warn_psse_extra_branch_ratings_dropped(net, &mut warnings);
}
super::warn_dropped_extras(
&F,
"PSS/E .raw",
net,
|key| {
key == "id"
|| key.starts_with("psse_")
|| key == IMPEDANCE_CORRECTION_EXTRA
|| key == IMPEDANCE_CORRECTION_NOMINAL_EXTRA
|| key == IMPEDANCE_CORRECTION_APPLIED_EXTRA
|| key == WINDING_IN_SERVICE_EXTRA
|| key == WINDING_RATING_SETS_EXTRA
},
&mut warnings,
);
let branch_solutions = net
.branches()
.iter()
.filter(|b| b.solution.is_some())
.count();
if branch_solutions > 0 {
warnings.push(&F.field_dropped, 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(&F.value_collapsed, 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(
&F.field_dropped,
"generator ramp/capability columns dropped: PSS/E .raw has no equivalent fields",
);
}
if nonfinite {
warnings.push(
&F.not_a_number,
"non-finite values written as ±1e10 sentinels (PSS/E has no Inf/NaN)",
);
}
if sanitized_quoted > 0 {
warnings.push(
&F.value_substituted,
format!(
"{sanitized_quoted} quoted PSS/E field(s) contained a quote or '/' that would \
corrupt a record; replaced with spaces"
),
);
}
TextEmission::new(s, warnings)
}
fn ide(kind: BusType) -> u8 {
kind as u8 }
pub(crate) 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,
)
}
pub(crate) 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()
});
let allocated = super::allocate_circuit_id(sanitized.as_deref().map(str::trim), key, used);
match sanitized {
Some(id) if id.trim() == allocated => id,
_ => allocated,
}
}
#[allow(clippy::float_cmp)] #[derive(Default)]
struct DcRecordGaps {
setpoint: Option<(&'static str, f64, f64)>,
limits: bool,
loss_model: bool,
detail: bool,
}
#[allow(clippy::float_cmp)]
fn dc_record_gaps(d: &Hvdc) -> DcRecordGaps {
let rdc = dc_f64(&d.extras, "psse_dc_rdc").unwrap_or(0.0);
let vschd = dc_f64(&d.extras, "psse_dc_vschd").unwrap_or(0.0);
let priced = d.in_service && vschd > 0.0;
let at_inverter = d.in_service && d.extras.contains_key(SETVL_AT_INVERTER);
let mut gaps = DcRecordGaps::default();
if at_inverter {
let expected_pf = if priced {
let i = d.pt / vschd;
d.pt + i * i * rdc
} else {
d.pt
};
if (d.pf - expected_pf).abs() > 1e-9 * d.pt.abs().max(1.0) {
gaps.setpoint = Some(("sent power Pf", d.pf, expected_pf));
}
} else {
let expected_pt = if priced {
let i = d.pf / vschd;
d.pf - i * i * rdc
} else {
d.pf
};
if (d.pt - expected_pt).abs() > 1e-9 * d.pf.abs().max(1.0) {
gaps.setpoint = Some(("received power Pt", d.pt, expected_pt));
}
}
gaps.limits = d.pmin != 0.0 || d.pmax != d.pf.abs().max(d.pt.abs());
gaps.loss_model = d.loss0 != 0.0 || d.loss1 != 0.0;
gaps.detail = d.vf != 1.0
|| d.vt != 1.0
|| [d.qf, d.qt, d.qminf, d.qmaxf, d.qmint, d.qmaxt]
.iter()
.any(|q| *q != 0.0);
gaps
}
fn warn_dc_record_gaps(net: &BalancedNetwork, warnings: &mut Diagnostics) {
let mut setpoints = Vec::new();
let mut limits = 0usize;
let mut loss_models = 0usize;
let mut detail = false;
for d in net.hvdc().iter().filter(|d| !is_vsc_line(d)) {
let gaps = dc_record_gaps(d);
if let Some((end, stated, reproduced)) = gaps.setpoint {
setpoints.push(format!(
"line {}-{} {end} {stated} MW reads back as {reproduced} MW",
d.from, d.to
));
}
limits += usize::from(gaps.limits);
loss_models += usize::from(gaps.loss_model);
detail |= gaps.detail;
}
if !setpoints.is_empty() {
let count = setpoints.len();
setpoints.truncate(3);
warnings.push(
&F.value_substituted,
format!(
"{count} two-terminal DC line(s) state an end power the record's SETVL, VSCHD, and RDC do not reproduce; the record is written from the other end, so the stated value is replaced by the one the drop model gives ({})",
setpoints.join("; ")
),
);
}
if limits > 0 {
warnings.push(
&F.field_dropped,
format!(
"{limits} two-terminal DC line active power limit pair(s) (Pmin, Pmax) dropped: a PSS/E two-terminal DC record has no limit field, and a reread takes 0 to the larger end power"
),
);
}
if loss_models > 0 {
warnings.push(
&F.field_dropped,
format!(
"{loss_models} two-terminal DC line loss model(s) (loss0, loss1) dropped: a PSS/E two-terminal DC record prices its loss from RDC and VSCHD only"
),
);
}
if detail {
warnings.push(
&F.value_defaulted,
"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",
);
}
}
fn warn_case_metadata_and_area_types(net: &BalancedNetwork, warnings: &mut Diagnostics) {
let metadata = net.case_metadata();
let fields = [
("case_date", metadata.case_date.as_deref()),
("forecast_distance", metadata.forecast_distance.map(|_| "")),
(
"source_model_format",
metadata.source_model_format.as_deref(),
),
(
"minimum_validation_level",
metadata.minimum_validation_level.as_deref(),
),
]
.into_iter()
.filter_map(|(name, value)| value.map(|_| format!("`{name}`")))
.collect::<Vec<_>>();
if !fields.is_empty() {
warnings.push(
&F.field_dropped,
format!(
"case metadata field(s) {} dropped: a PSS/E case states none of them",
fields.join(", ")
),
);
}
let retyped = net
.areas()
.iter()
.filter(|area| area.area_type.as_deref() != Some("ControlArea"))
.count();
if retyped > 0 {
warnings.push(
&F.value_substituted,
format!(
"{retyped} area(s) with no area type or another one written as PSS/E areas, which are control areas: they read back with area type `ControlArea`"
),
);
}
}
pub(crate) fn transformer_3w_id(
net: &BalancedNetwork,
t: &Transformer3W,
used: &mut BTreeMap<(BusId, BusId, BusId), u32>,
sanitized_quoted: &mut usize,
) -> String {
let buses = (t.windings[0].bus, t.windings[1].bus, t.windings[2].bus);
let next_id = used.entry(buses).or_default();
*next_id += 1;
let positional = next_id.to_string();
let raw_id = t
.extras
.get("id")
.and_then(Value::as_str)
.or_else(|| detailed_source_id(net, "transformer", t.uid.as_deref()))
.unwrap_or(positional.as_str());
let sanitized = sanitize_quoted(raw_id, NAME_FORBIDDEN, ' ');
if matches!(sanitized, std::borrow::Cow::Owned(_)) {
*sanitized_quoted += 1;
}
sanitized.into_owned()
}
pub(crate) fn detailed_source_id<'a>(
net: &'a BalancedNetwork,
component_type: &str,
uid: Option<&str>,
) -> Option<&'a str> {
detailed_source_property(net, component_type, uid, "psse_eqid")
}
pub(crate) fn detailed_source_property<'a>(
net: &'a BalancedNetwork,
component_type: &str,
uid: Option<&str>,
property: &str,
) -> Option<&'a str> {
let uid = uid?;
net.detailed_connectivity()
.as_deref()?
.component_metadata
.iter()
.find(|metadata| {
metadata.component.component_type() == component_type
&& metadata.component.local_id() == uid
})?
.properties
.get(property)
.map(String::as_str)
}
fn generator_source_float(
net: &BalancedNetwork,
generator: &Generator,
property: &str,
default: f64,
warnings: &mut Diagnostics,
) -> f64 {
let Some(raw) = detailed_source_property(net, "generator", generator.uid.as_deref(), property)
else {
return default;
};
if let Some(value) = raw.parse::<f64>().ok().filter(|value| value.is_finite()) {
return value;
}
warnings.push(
&F.value_substituted,
format!(
"PSS/E generator at bus {}: retained {} value {raw:?} is not a finite number; emitted default {default}",
generator.bus,
property.trim_start_matches("psse_").to_ascii_uppercase()
),
);
default
}
fn generator_source_integer(
net: &BalancedNetwork,
generator: &Generator,
property: &str,
default: i64,
warnings: &mut Diagnostics,
) -> i64 {
let Some(raw) = detailed_source_property(net, "generator", generator.uid.as_deref(), property)
else {
return default;
};
let value = raw.parse::<f64>().ok().filter(|value| {
value.is_finite() && *value >= i64::MIN as f64 && *value <= i64::MAX as f64
});
if let Some(value) = value {
#[allow(clippy::cast_possible_truncation)]
return value as i64;
}
warnings.push(
&F.value_substituted,
format!(
"PSS/E generator at bus {}: retained {} value {raw:?} is not a finite integer code; emitted default {default}",
generator.bus,
property.trim_start_matches("psse_").to_ascii_uppercase()
),
);
default
}
fn detailed_regulating_target(
net: &BalancedNetwork,
reference: &TerminalReference,
) -> Option<(BusId, i32)> {
let detailed = net.detailed_connectivity().as_deref()?;
let terminal = detailed.terminals.iter().find(|terminal| {
terminal.equipment == reference.equipment && terminal.terminal == reference.terminal
})?;
let node_id = terminal.node.as_ref()?;
let node = detailed
.connectivity_nodes
.iter()
.find(|node| &node.component == node_id)?;
Some((node.calculated_bus?, node.node_number?))
}
fn regulating_target(
net: &BalancedNetwork,
reference: Option<&TerminalReference>,
bus: Option<BusId>,
description: impl std::fmt::Display,
warnings: &mut Diagnostics,
) -> (usize, i32) {
if let Some(reference) = reference {
if let Some((bus, node)) = detailed_regulating_target(net, reference) {
return (bus.0, node);
}
warnings.push(
&F.field_dropped,
format!(
"PSS/E {description}: the regulating terminal has no PSS/E bus and node mapping; emitted the regulated bus with node 0"
),
);
}
(bus.map_or(0, |bus| bus.0), 0)
}
fn signed_controlled_bus(bus: usize, on_winding_side: bool) -> i64 {
let bus = i64::try_from(bus).unwrap_or(i64::MAX);
if on_winding_side { -bus } else { bus }
}
fn emit_controlled_bus(
control: &TransformerControl,
bus: usize,
description: impl std::fmt::Display,
warnings: &mut Diagnostics,
) -> i64 {
if control.controlled_bus_on_winding_side
&& control
.controlled_bus
.is_none_or(|controlled| controlled.0 == 0)
{
warnings.push(
&F.field_dropped,
format!(
"PSS/E {description}: a negative CONT requires a nonzero controlled bus; emitted CONT=0"
),
);
return 0;
}
signed_controlled_bus(bus, control.controlled_bus_on_winding_side)
}
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 CONVERTER_TAIL_FIELDS_33: usize = 16;
const CONVERTER_TAIL_NODE_INDEX: usize = 12;
fn converter_tail_node_index(rev: u32) -> Option<usize> {
match rev {
35.. => Some(CONVERTER_TAIL_NODE_INDEX),
34 => Some(CONVERTER_TAIL_FIELDS_33),
_ => None,
}
}
fn dc_converter_tail(extras: &Extras, key: &str, rev: u32) -> (String, Option<String>) {
let node_index = converter_tail_node_index(rev);
let stated = extras
.get(key)
.and_then(Value::as_array)
.filter(|fields| !fields.is_empty());
let Some(stated) = stated else {
let mut fields = DEFAULT_CONVERTER_TAIL
.split(", ")
.map(str::to_owned)
.collect::<Vec<_>>();
if let Some(index) = node_index {
fields.insert(index, "0".to_owned());
}
return (fields.join(", "), None);
};
let mut fields = stated
.iter()
.filter_map(Value::as_str)
.map(|field| sanitize_quoted(field, BARE_FORBIDDEN, BARE_REPLACEMENT).into_owned())
.collect::<Vec<_>>();
let mut dropped_node = None;
match (fields.len() > CONVERTER_TAIL_FIELDS_33, node_index) {
(true, None) => {
let node = fields.remove(CONVERTER_TAIL_NODE_INDEX);
if !matches!(node.trim(), "" | "0") {
dropped_node = Some(node);
}
}
(true, Some(index)) if index != CONVERTER_TAIL_NODE_INDEX => {
let node = fields.remove(CONVERTER_TAIL_NODE_INDEX);
fields.insert(index, node);
}
(false, Some(index)) if fields.len() == CONVERTER_TAIL_FIELDS_33 => {
fields.insert(index, "0".to_owned());
}
_ => {}
}
(fields.join(", "), dropped_node)
}
const DEFAULT_CONTROL_TAIL: &str = "0.0, 0.0, 0.0, 'I', 0.0, 20, 1.0";
const SETVL_AT_INVERTER: &str = "psse_dc_setvl_at_inverter";
fn is_vsc_line(dc: &Hvdc) -> bool {
[&dc.converter1, &dc.converter2]
.iter()
.all(|c| c.as_ref().is_some_and(|c| c.kind == HvdcConverterKind::Vsc))
}
#[expect(clippy::too_many_lines)]
fn vsc_record_lines(
net: &BalancedNetwork,
dc: &Hvdc,
index: usize,
rev: u32,
num: &mut impl FnMut(f64) -> String,
warnings: &mut Diagnostics,
sanitized_quoted: &mut usize,
) -> [String; 3] {
let label = dc
.uid
.clone()
.unwrap_or_else(|| format!("{}-{}", dc.from, dc.to));
let raw_name =
dc_str(&dc.extras, "psse_vsc_name").unwrap_or_else(|| format!("VSC{}", index + 1));
let name = sanitize_quoted(&raw_name, NAME_FORBIDDEN, ' ');
if matches!(name, Cow::Owned(_)) {
*sanitized_quoted += 1;
}
let mdc = if dc.in_service {
dc_int(&dc.extras, "psse_vsc_mdc").unwrap_or(1)
} else {
0
};
let owners = dc
.extras
.get("psse_vsc_owners")
.and_then(Value::as_array)
.map(|tokens| {
tokens
.iter()
.filter_map(Value::as_str)
.collect::<Vec<_>>()
.join(", ")
})
.filter(|tail| !tail.is_empty())
.unwrap_or_else(|| "1, 1.0".to_owned());
let rdc = dc.resistance_ohm.unwrap_or(0.0);
let base_kv = |bus: usize| {
net.buses()
.iter()
.find(|b| b.id.0 == bus)
.map(|b| b.base_kv)
.filter(|kv| *kv > 0.0)
};
let injections = dc.calc_terminal_injections();
let mut converters = Vec::with_capacity(2);
for side in 1..=2 {
let typed = if side == 1 {
dc.converter1.as_ref()
} else {
dc.converter2.as_ref()
};
let stored = dc
.extras
.get(&vsc_converter_key(side))
.and_then(Value::as_object);
let stored_f64 = |key: &str| {
stored
.and_then(|record| record.get(key))
.and_then(Value::as_f64)
};
let stored_int = |key: &str| {
stored
.and_then(|record| record.get(key))
.and_then(Value::as_i64)
};
let (bus, p_ac, q_ac) = injections[side - 1];
let bus = bus.0;
let kind = stored_int("type").unwrap_or(if side == 1 { 2 } else { 1 });
let mode = match typed.and_then(|c| c.voltage_regulator_on) {
Some(true) => 1,
Some(false) => 2,
None => stored_int("mode").unwrap_or(1),
};
let dcset = if kind == 2 {
p_ac
} else {
dc.nominal_voltage_kv
.or_else(|| stored_f64("dcset"))
.unwrap_or(0.0)
};
let vsreg = stored_int("vsreg")
.and_then(|v| usize::try_from(v).ok())
.unwrap_or(0);
let regulated = if vsreg == 0 { bus } else { vsreg };
let acset = if mode == 1 {
let typed_pu = typed
.and_then(|c| c.voltage_setpoint_kv)
.zip(base_kv(regulated))
.map(|(kv, base)| kv / base);
let typed_pu = typed_pu.map(|pu| (pu * 1e10).round() / 1e10);
match (typed_pu, stored_f64("acset")) {
(Some(pu), Some(acset)) if (pu - acset).abs() <= 1e-9 => acset,
(Some(pu), _) => pu,
(None, Some(acset)) => acset,
(None, None) => {
if side == 1 {
dc.vf
} else {
dc.vt
}
}
}
} else if let Some(pf) = typed.and_then(|c| c.power_factor) {
pf
} else if p_ac == 0.0 || q_ac == 0.0 {
1.0
} else {
(p_ac.abs() / p_ac.hypot(q_ac)).copysign(q_ac)
};
converters.push(VscConverter {
bus,
kind,
mode,
dcset,
acset,
aloss: stored_f64("aloss").unwrap_or(0.0),
bloss: stored_f64("bloss").unwrap_or(0.0),
minloss: stored_f64("minloss").unwrap_or(0.0),
smax: stored_f64("smax").unwrap_or(0.0),
imax: stored_f64("imax").unwrap_or(0.0),
pwf: stored_f64("pwf").unwrap_or(1.0),
maxq: if side == 1 { dc.qmaxf } else { dc.qmaxt },
minq: if side == 1 { dc.qminf } else { dc.qmint },
vsreg,
nreg: stored_int("nreg").unwrap_or(0),
rmpct: stored_f64("rmpct").unwrap_or(100.0),
});
}
let synthesized = dc.extras.keys().all(|key| !key.starts_with("psse_vsc_"));
if synthesized
&& dc.in_service
&& let Some((_, p2, _)) = calc_vsc_powers([&converters[0], &converters[1]], rdc)
{
let loss = p2 - injections[1].1;
if loss > 0.0 {
converters[1].aloss = loss * 1000.0;
}
}
if dc.in_service {
let (p1, p2) = calc_vsc_powers([&converters[0], &converters[1]], rdc)
.map_or((0.0, 0.0), |(p1, p2, _)| (p1, p2));
let reread = [
("from end active", p1, injections[0].1),
("to end active", p2, injections[1].1),
(
"from end reactive",
calc_vsc_reactive_power(&converters[0], p1),
injections[0].2,
),
(
"to end reactive",
calc_vsc_reactive_power(&converters[1], p2),
injections[1].2,
),
];
let changed: Vec<String> = reread
.iter()
.filter(|(_, back, stated)| (back - stated).abs() > 1e-6 * stated.abs().max(1.0))
.map(|(power, back, stated)| {
format!("the {power} injection {stated} reads back as {back}")
})
.collect();
if !changed.is_empty() {
warnings.push(
&F.value_substituted,
format!(
"VSC DC line `{label}`: the PSS/E record states converter losses, the DC line drop, and voltage-controlled reactive output through its own model; {}",
changed.join(", ")
),
);
}
}
let line1 = format!("'{name}', {mdc}, {}, {owners}", num(rdc));
let mut lines = [line1, String::new(), String::new()];
for (slot, c) in converters.iter().enumerate() {
let regulation = if rev >= 35 {
format!("{}, {}", c.vsreg, c.nreg)
} else {
c.vsreg.to_string()
};
lines[slot + 1] = format!(
"{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {regulation}, {}",
c.bus,
c.kind,
c.mode,
num(c.dcset),
num(c.acset),
num(c.aloss),
num(c.bloss),
num(c.minloss),
num(c.smax),
num(c.imax),
num(c.pwf),
num(c.maxq),
num(c.minq),
num(c.rmpct)
);
}
lines
}
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(crate) fn header_rev(source: &str) -> Result<u32> {
let header = source
.lines()
.map(str::trim)
.find(|line| !line.is_empty() && !is_comment(line))
.ok_or_else(|| Error::FormatRead {
format: FMT,
message: "empty file".into(),
})?;
let header_fields = fields(header);
parse_revision(header_fields.get(2).map(AsRef::as_ref))
}
fn parse_revision(field: Option<&str>) -> Result<u32> {
let Some(field) = field.filter(|field| !field.is_empty()) else {
return Ok(33);
};
let unsupported = || Error::FormatRead {
format: FMT,
message: format!(
"header REV {field:?} is not a supported revision; expected integral 32, 33, 34, or 35"
),
};
let revision = field.parse::<f64>().map_err(|_| unsupported())?;
match revision.to_bits() {
value if value == 32.0_f64.to_bits() => Ok(32),
value if value == 33.0_f64.to_bits() => Ok(33),
value if value == 34.0_f64.to_bits() => Ok(34),
value if value == 35.0_f64.to_bits() => Ok(35),
_ => Err(unsupported()),
}
}
#[derive(Clone, Copy)]
struct Revision32Shape {
record: &'static str,
width: usize,
last: &'static str,
}
impl Revision32Shape {
const fn new(record: &'static str, width: usize, last: &'static str) -> Self {
Self {
record,
width,
last,
}
}
}
const REVISION32_TRANSFORMER_IMPEDANCE_2W: Revision32Shape =
Revision32Shape::new("TRANSFORMER DATA impedance line", 3, "SBASE1-2");
const REVISION32_TRANSFORMER_IMPEDANCE_3W: Revision32Shape =
Revision32Shape::new("TRANSFORMER DATA impedance line", 11, "ANSTAR");
const REVISION32_TRANSFORMER_WINDING: Revision32Shape =
Revision32Shape::new("TRANSFORMER DATA winding line", 13, "NTP");
const REVISION32_TRANSFORMER_WINDING_2: Revision32Shape =
Revision32Shape::new("TRANSFORMER DATA winding 2 line", 2, "NOMV2");
fn revision32_shape(section: Section) -> Option<Revision32Shape> {
Some(match section {
Section::Bus => Revision32Shape::new("BUS DATA", 9, "VA"),
Section::Load => Revision32Shape::new("LOAD DATA", 13, "SCALE"),
Section::FixedShunt => Revision32Shape::new("FIXED SHUNT DATA", 5, "BL"),
Section::SwitchedShunt => Revision32Shape::new("SWITCHED SHUNT DATA", 10, "BINIT"),
Section::Generator => Revision32Shape::new("GENERATOR DATA", 18, "PB"),
Section::Branch => Revision32Shape::new("BRANCH DATA", 14, "ST"),
Section::Transformer => Revision32Shape::new("TRANSFORMER DATA", 12, "STAT"),
Section::TwoTerminalDc => Revision32Shape::new("TWO-TERMINAL DC DATA", 5, "VSCHD"),
Section::Area => Revision32Shape::new("AREA DATA", 4, "PTOL"),
Section::SystemSwitch
| Section::VscDc
| Section::ImpedanceCorrection
| Section::Facts
| Section::SystemWide
| Section::Skip => return None,
})
}
fn check_revision32_width(f: &[Cow<'_, str>], shape: Revision32Shape, warnings: &mut Diagnostics) {
if f.len() >= shape.width {
return;
}
let first = f.first().map_or("", Cow::as_ref);
warnings.push(
&codes::READ_PSSE_VALUE_DEFAULTED,
format!(
"PSS/E revision 32 {} record beginning {first:?} has {} field(s); the revision 32 layout reads {} fields through {}, so the missing fields took their defaults",
shape.record,
f.len(),
shape.width,
shape.last
),
);
}
#[derive(Debug, Clone, Copy)]
enum RegulatingNodeTarget {
Generator(usize),
SwitchedShunt(usize),
StaticVarCompensator(usize),
Transformer(usize),
ThreeWindingTransformer { transformer: usize, winding: usize },
}
#[derive(Debug, Clone, Copy)]
struct PendingRegulatingNode {
target: RegulatingNodeTarget,
node: i32,
}
fn apply_pending_regulating_nodes(
net: &mut BalancedNetwork,
pending: &[PendingRegulatingNode],
warnings: &mut Diagnostics,
) {
let detailed = net.detailed_connectivity().clone();
for pending in pending {
let (target, bus, description): (&mut Option<TerminalReference>, BusId, String) =
match pending.target {
RegulatingNodeTarget::Generator(index) => {
let generator = &mut net.generators_mut()[index];
(
&mut generator.regulating_terminal,
generator.regulated_bus.unwrap_or(generator.bus),
format!("PSS/E generator at bus {}", generator.bus),
)
}
RegulatingNodeTarget::SwitchedShunt(index) => {
let shunt = &mut net.shunts_mut()[index];
let control = shunt
.control
.as_mut()
.expect("a pending switched shunt node has control data");
(
&mut control.regulating_terminal,
control.control_bus.unwrap_or(shunt.bus),
format!("PSS/E switched shunt at bus {}", shunt.bus),
)
}
RegulatingNodeTarget::StaticVarCompensator(index) => {
let svc = &mut net.static_var_compensators_mut()[index];
let bus = facts_regulated_bus(&svc.extras).unwrap_or(svc.bus);
(
&mut svc.regulating_terminal,
bus,
format!("PSS/E FACTS device at bus {}", svc.bus),
)
}
RegulatingNodeTarget::Transformer(index) => {
let transformer = &mut net.branches_mut()[index];
let control = transformer
.control
.as_mut()
.expect("a pending transformer node has control data");
(
&mut control.regulating_terminal,
control.controlled_bus.unwrap_or(transformer.from),
format!(
"PSS/E two winding transformer {}-{}",
transformer.from, transformer.to
),
)
}
RegulatingNodeTarget::ThreeWindingTransformer {
transformer,
winding,
} => {
let winding_data =
&mut net.transformers_3w_mut()[transformer].windings[winding];
let control = winding_data
.control
.as_mut()
.expect("a pending transformer winding node has control data");
(
&mut control.regulating_terminal,
control.controlled_bus.unwrap_or(winding_data.bus),
format!(
"PSS/E three winding transformer winding {} at bus {}",
winding + 1,
winding_data.bus
),
)
}
};
if let Some(reference) = detailed
.as_deref()
.and_then(|detailed| super::rawx::regulating_terminal(detailed, bus, pending.node))
{
*target = Some(reference);
} else {
warnings.push(
&codes::READ_PSSE_REFERENCE_DROPPED,
format!(
"{description}: node {} at regulated bus {bus} has no detailed connectivity terminal",
pending.node
),
);
}
}
}
pub(crate) fn parse_psse_source(
source: &str,
name_hint: Option<&str>,
warnings: &mut Diagnostics,
) -> Result<BalancedNetwork> {
parse_psse_source_inner(source, name_hint, warnings, false)
}
pub(super) fn parse_psse_source_deferred_regulating_nodes(
source: &str,
name_hint: Option<&str>,
warnings: &mut Diagnostics,
) -> Result<BalancedNetwork> {
parse_psse_source_inner(source, name_hint, warnings, true)
}
#[expect(clippy::too_many_lines)]
fn parse_psse_source_inner(
source: &str,
name_hint: Option<&str>,
warnings: &mut Diagnostics,
defer_regulating_nodes: bool,
) -> Result<BalancedNetwork> {
let content: &str = source;
let mut lines = RawLines::new(content);
let header = loop {
let Some((start, raw)) = lines.next_line() else {
return Err(Error::FormatRead {
format: FMT,
message: "empty file".into(),
});
};
let line = raw.trim();
if !line.is_empty() && !is_comment(line) {
mark_record(warnings, start, raw);
break raw;
}
};
let header_fields = fields(header);
let base_mva = header_fields
.get(1)
.filter(|field| !field.is_empty())
.ok_or_else(|| Error::FormatRead {
format: FMT,
message: "missing SBASE in header".into(),
})?
.parse::<f64>()
.map_err(|_| Error::FormatRead {
format: FMT,
message: "header SBASE is not a number".into(),
})?;
if !base_mva.is_finite() || base_mva <= 0.0 {
return Err(Error::FormatRead {
format: FMT,
message: format!("header SBASE must be positive and finite, got {base_mva}"),
});
}
let raw_rev = parse_revision(header_fields.get(2).map(AsRef::as_ref))?;
let base_frequency = match header_fields.get(5).map(AsRef::as_ref) {
None | Some("") => crate::network::DEFAULT_BASE_FREQUENCY,
Some(field) => {
let value = field.parse::<f64>().map_err(|_| Error::FormatRead {
format: FMT,
message: format!("header BASFRQ {field:?} is not a number"),
})?;
if !value.is_finite() || value <= 0.0 {
return Err(Error::FormatRead {
format: FMT,
message: format!(
"header BASFRQ must be positive and finite when present, got {value}"
),
});
}
value
}
};
warnings.leave_record();
let title = lines.next_line().map_or("", |(_, line)| line).trim();
let name = if title.is_empty() {
name_hint.unwrap_or("case").to_string()
} else {
title.to_string()
};
lines.next_line();
let mut buses = Vec::new();
let mut loads = Vec::new();
let mut shunts = Vec::new();
let mut generators = Vec::new();
let mut generator_source_metadata = Vec::new();
let mut generator_ids: BTreeMap<BusId, BTreeSet<String>> = BTreeMap::new();
let mut branches = Vec::new();
let mut switches = 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 bus_area_zone: BTreeMap<BusId, (usize, usize)> = BTreeMap::new();
let mut unmodeled_sections: BTreeMap<String, usize> = BTreeMap::new();
let mut vsc_lines = 0usize;
let mut regulating_nodes = Vec::new();
let mut impedance_tables: BTreeMap<u32, Vec<ImpedanceCorrectionPoint>> = BTreeMap::new();
let mut impedance_references = Vec::new();
let mut static_var_compensators = Vec::new();
let mut series_facts = 0usize;
let mut section = Section::Bus;
let mut saw_bus_marker = false;
let mut skipped_section_name: Option<String> = None;
while let Some((start, raw)) = lines.next_line() {
let line = raw.trim();
if line.is_empty() {
continue;
}
if is_comment(line) {
continue;
}
if line == "Q" {
break;
}
if is_terminator(line) {
warnings.leave_record();
let next_section = section_after_marker(line, raw_rev);
skipped_section_name =
introduced_section_name(line).filter(|_| matches!(next_section, Section::Skip));
section = next_section;
saw_bus_marker |= matches!(section, Section::Bus);
continue;
}
mark_record(warnings, start, raw);
let f = fields(line);
if raw_rev == 32
&& let Some(shape) = revision32_shape(section)
{
check_revision32_width(&f, shape, warnings);
}
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, warnings);
}
Section::Bus => {
let bus = read_bus(&f, raw_rev)?;
bus_base_kv.insert(bus.id, bus.base_kv);
bus_area_zone.insert(bus.id, (bus.area, bus.zone));
buses.push(bus);
}
Section::Load => {
let mut load = read_load(&f, raw_rev, warnings)?;
drop_bus_default_area_zone(&mut load, &bus_area_zone);
loads.push(load);
}
Section::FixedShunt => shunts.push(read_shunt(&f)?),
Section::SwitchedShunt => {
let index = shunts.len();
let (shunt, node) = read_switched_shunt(&f, raw_rev, warnings)?;
shunts.push(shunt);
if node != 0 {
regulating_nodes.push(PendingRegulatingNode {
target: RegulatingNodeTarget::SwitchedShunt(index),
node,
});
}
}
Section::Generator => {
let index = generators.len();
let (generator, node, source_metadata) = read_gen(&f, raw_rev, &mut generator_ids)?;
generators.push(generator);
generator_source_metadata.push(source_metadata);
if node != 0 {
regulating_nodes.push(PendingRegulatingNode {
target: RegulatingNodeTarget::Generator(index),
node,
});
}
}
Section::Branch => branches.push(read_branch(&f, raw_rev)?),
Section::SystemSwitch => switches.push(read_system_switch(&f)?),
Section::Transformer => {
let two_winding = int_at(&f, 2, 0)? == 0;
let l2 = next_continuation_line(
&mut lines,
warnings,
"transformer",
"transformer impedance line",
)?;
let l3 = next_continuation_line(
&mut lines,
warnings,
"transformer",
"winding data line 1",
)?;
let l4 = next_continuation_line(
&mut lines,
warnings,
"transformer",
"winding data line 2",
)?;
let (f2, f3, f4) = (fields(l2), fields(l3), fields(l4));
if two_winding {
if raw_rev == 32 {
for (f, shape) in [
(&f2, REVISION32_TRANSFORMER_IMPEDANCE_2W),
(&f3, REVISION32_TRANSFORMER_WINDING),
(&f4, REVISION32_TRANSFORMER_WINDING_2),
] {
check_revision32_width(f, shape, warnings);
}
}
let index = branches.len();
let (transformer, node) = read_transformer(
&f,
&f2,
&f3,
&f4,
raw_rev,
base_mva,
&bus_base_kv,
warnings,
)?;
if let Some(reference) = impedance_reference(
ImpedanceTarget::Branch(index),
&transformer.extras,
"psse_tab",
1.0 / two_winding_tap(
&f,
&f3,
&f4,
transformer.from,
transformer.to,
transformer_basis_codes(&f)?.0,
&bus_base_kv,
&mut Diagnostics::new(),
)?
.1,
) {
impedance_references.push(reference);
}
branches.push(transformer);
if node != 0 {
regulating_nodes.push(PendingRegulatingNode {
target: RegulatingNodeTarget::Transformer(index),
node,
});
}
} else {
let l5 = next_continuation_line(
&mut lines,
warnings,
"transformer",
"winding data line 3",
)?;
let f5 = fields(l5);
if raw_rev == 32 {
for (f, shape) in [
(&f2, REVISION32_TRANSFORMER_IMPEDANCE_3W),
(&f3, REVISION32_TRANSFORMER_WINDING),
(&f4, REVISION32_TRANSFORMER_WINDING),
(&f5, REVISION32_TRANSFORMER_WINDING),
] {
check_revision32_width(f, shape, warnings);
}
}
let index = transformers_3w.len();
let (transformer, nodes) = read_transformer_3w(
&f,
&f2,
&f3,
&f4,
&f5,
raw_rev,
base_mva,
&bus_base_kv,
warnings,
)?;
if extra_i64(&transformer.extras, "psse_zcod") == Some(1)
&& (1..=3).any(|w| {
extra_i64(&transformer.extras, &format!("psse_tab{w}")).unwrap_or(0) > 0
})
&& transformer.impedance_correction_control_winding().is_none()
{
return Err(Error::FormatRead {
format: FMT,
message: format!(
"PSS/E transformer {}: ZCOD=1 impedance correction requires exactly one nonzero winding COD",
transformer_label(&f)
),
});
}
for winding in 0..3 {
if let Some(reference) = impedance_reference(
ImpedanceTarget::Winding(index, winding),
&transformer.extras,
&format!("psse_tab{}", winding + 1),
1.0,
) {
impedance_references.push(reference);
}
}
transformers_3w.push(transformer);
for (winding, node) in nodes.into_iter().enumerate() {
if node != 0 {
regulating_nodes.push(PendingRegulatingNode {
target: RegulatingNodeTarget::ThreeWindingTransformer {
transformer: index,
winding,
},
node,
});
}
}
}
}
Section::TwoTerminalDc => {
let rectifier = next_continuation_line(
&mut lines,
warnings,
"two-terminal DC",
"rectifier line",
)?;
let inverter = next_continuation_line(
&mut lines,
warnings,
"two-terminal DC",
"inverter line",
)?;
hvdc.push(read_dc_line(
&f,
&fields(rectifier),
&fields(inverter),
hvdc.len(),
raw_rev,
warnings,
)?);
}
Section::VscDc => {
let first = next_continuation_line(
&mut lines,
warnings,
"VSC DC line",
"converter 1 line",
)?;
let second = next_continuation_line(
&mut lines,
warnings,
"VSC DC line",
"converter 2 line",
)?;
hvdc.push(read_vsc_line(
&f,
&fields(first),
&fields(second),
vsc_lines,
raw_rev,
&bus_base_kv,
warnings,
)?);
vsc_lines += 1;
}
Section::Area => areas.push(read_area(&f)?),
Section::ImpedanceCorrection => {
let (table, points) = read_impedance_correction(&f, raw_rev, &mut lines, warnings)?;
if let std::collections::btree_map::Entry::Vacant(slot) =
impedance_tables.entry(table)
{
slot.insert(points);
} else {
warnings.push(
&codes::READ_PSSE_VALUE_SUBSTITUTED,
format!(
"PSS/E impedance correction table {table} is stated twice; kept the first"
),
);
}
}
Section::Facts => {
let index = static_var_compensators.len();
match read_shunt_facts(&f, raw_rev, &bus_base_kv, warnings)? {
Some((svc, node)) => {
static_var_compensators.push(svc);
if node != 0 {
regulating_nodes.push(PendingRegulatingNode {
target: RegulatingNodeTarget::StaticVarCompensator(index),
node,
});
}
}
None => series_facts += 1,
}
}
Section::SystemWide => parse_solver_line(&f, &mut solver, warnings),
Section::Skip => {
if let Some(name) = skipped_section_name.as_ref() {
*unmodeled_sections.entry(name.clone()).or_default() += 1;
}
}
}
}
warnings.leave_record();
if raw_rev >= 34 {
unmodeled_sections.retain(|name, _| !name.starts_with("SUBSTATION"));
}
warn_unmodeled_sections(unmodeled_sections, raw_rev, warnings);
attach_impedance_corrections(
&impedance_tables,
impedance_references,
&mut branches,
&mut transformers_3w,
warnings,
);
if series_facts > 0 {
warnings.push(
&codes::READ_PSSE_SECTION_UNSUPPORTED,
format!(
"PSS/E FACTS DEVICE section: {series_facts} series device record(s) (J nonzero) are not modeled: {}",
unmodeled_retention(raw_rev)
),
);
}
let mut net = BalancedNetwork::from_tables(BalancedNetworkTables {
name,
base_mva,
base_frequency,
geo: None,
case_metadata: crate::network::CaseMetadata::default(),
detailed_connectivity: None,
generated_uids: std::sync::Arc::default(),
buses: buses.into(),
loads: loads.into(),
shunts: shunts.into(),
static_var_compensators: static_var_compensators.into(),
branches: branches.into(),
switches: switches.into(),
generators: generators.into(),
storage: Vec::new().into(),
hvdc: hvdc.into(),
transformers_3w: transformers_3w.into(),
areas: areas.into(),
solver: (!solver.is_empty()).then_some(solver),
source_format: SourceFormat::Psse,
});
attach_generator_source_metadata(&mut net, generator_source_metadata)?;
if raw_rev >= 34 {
super::rawx::read_raw_detailed_connectivity(source, &mut net, warnings)?;
}
if !defer_regulating_nodes {
apply_pending_regulating_nodes(&mut net, ®ulating_nodes, warnings);
}
drop_stale_control_pointers(&mut net, warnings);
net.check_references(FMT)?;
Ok(net)
}
#[derive(Clone, Copy)]
enum Section {
Bus,
Load,
FixedShunt,
SwitchedShunt,
Generator,
Branch,
SystemSwitch,
Transformer,
TwoTerminalDc,
VscDc,
Area,
ImpedanceCorrection,
Facts,
SystemWide,
Skip,
}
fn section_after_marker(line: &str, rev: u32) -> Section {
if let Some(name) = introduced_section_name(line) {
return section_from_name(&name);
}
match ended_section_name(line).as_deref() {
Some("SYSTEM-WIDE") => Section::Bus,
Some("BUS") => Section::Load,
Some("LOAD") => Section::FixedShunt,
Some("FIXED SHUNT" | "FIXED BUS SHUNT") => Section::Generator,
Some("GENERATOR" | "GEN") => Section::Branch,
Some("BRANCH" | "NON TRANSFORMER BRANCH" | "NON-TRANSFORMER BRANCH") => {
if rev >= 34 {
Section::SystemSwitch
} else {
Section::Transformer
}
}
Some("SYSTEM SWITCHING DEVICE") => Section::Transformer,
Some("TRANSFORMER" | "TRANSFORMER BRANCH") => Section::Area,
Some("AREA" | "AREA INTERCHANGE") => Section::TwoTerminalDc,
Some("TWO-TERMINAL DC" | "TWO TERMINAL DC" | "2-TERMINAL DC" | "2 TERMINAL DC") => {
Section::VscDc
}
Some("VSC DC LINE" | "VOLTAGE SOURCE CONVERTER") => Section::ImpedanceCorrection,
Some("OWNER") => Section::Facts,
Some("FACTS DEVICE" | "FACTS CONTROL DEVICE") => Section::SwitchedShunt,
_ => Section::Skip,
}
}
fn section_from_name(name: &str) -> Section {
match name {
"BUS" => Section::Bus,
"LOAD" => Section::Load,
"FIXED SHUNT" => Section::FixedShunt,
"SWITCHED SHUNT" => Section::SwitchedShunt,
"GENERATOR" | "GEN" => Section::Generator,
"BRANCH" => Section::Branch,
"SYSTEM SWITCHING DEVICE" => Section::SystemSwitch,
"TRANSFORMER" => Section::Transformer,
"TWO-TERMINAL DC" | "TWO TERMINAL DC" | "2-TERMINAL DC" | "2 TERMINAL DC" => {
Section::TwoTerminalDc
}
"VSC DC LINE" | "VSC DC" | "VOLTAGE SOURCE CONVERTER" => Section::VscDc,
"AREA" | "AREA INTERCHANGE" => Section::Area,
"IMPEDANCE CORRECTION" | "TRANSFORMER IMPEDANCE CORRECTION" => Section::ImpedanceCorrection,
"FACTS DEVICE" | "FACTS CONTROL DEVICE" | "FACTS" => Section::Facts,
_ => Section::Skip,
}
}
fn is_terminator(line: &str) -> bool {
fields(line).first().map(Cow::as_ref) == Some("0")
}
#[derive(Clone)]
struct RawLines<'a> {
text: &'a str,
offset: usize,
}
impl<'a> RawLines<'a> {
fn new(text: &'a str) -> Self {
Self { text, offset: 0 }
}
fn next_line(&mut self) -> Option<(usize, &'a str)> {
if self.offset >= self.text.len() {
return None;
}
let start = self.offset;
let rest = &self.text[start..];
let (line, consumed) = match rest.find('\n') {
Some(end) => (&rest[..end], end + 1),
None => (rest, rest.len()),
};
self.offset += consumed;
Some((start, line.strip_suffix('\r').unwrap_or(line)))
}
}
fn mark_record(warnings: &mut Diagnostics, start: usize, raw: &str) {
let record_start = start + (raw.len() - raw.trim_start().len());
warnings.enter_record(record_start, record_start + raw.trim().len());
}
fn next_continuation_line<'a>(
lines: &mut RawLines<'a>,
warnings: &mut Diagnostics,
record: &str,
expected: &str,
) -> Result<&'a str> {
while let Some((start, raw)) = lines.next_line() {
let line = raw.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}`"
),
});
}
let line_start = start + (raw.len() - raw.trim_start().len());
warnings.extend_record(line_start + line.len());
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(Cow::as_ref) == Some("0")
}
fn transformer_basis_codes(f: &[Cow<'_, str>]) -> 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("", Cow::as_ref)));
}
let cz = num_at(f, 5, 1.0)?;
if cz.fract() != 0.0 {
return Err(bad_field(5, f.get(5).map_or("", Cow::as_ref)));
}
#[allow(clippy::cast_possible_truncation)]
Ok((cw as i64, cz as i64))
}
fn transformer_label(f: &[Cow<'_, str>]) -> String {
let i = f.first().map_or("?", Cow::as_ref);
let j = f.get(1).map_or("?", Cow::as_ref);
let k = f.get(2).map_or("?", Cow::as_ref);
let id = f.get(3).map_or("", Cow::as_ref);
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 Diagnostics,
) -> (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(&codes::READ_PSSE_VALUE_SUBSTITUTED, 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(&codes::READ_PSSE_VALUE_SUBSTITUTED, 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(&codes::READ_PSSE_VALUE_UNSUPPORTED, format!(
"PSS/E transformer {label} pair {pair}: unsupported CZ={other}; read impedance as system-base p.u."
));
(r, x)
}
}
}
fn magnetizing_referral(tap: f64) -> f64 {
let square = tap * tap;
if tap.abs() >= crate::dc::MIN_DIVISIBLE_MAGNITUDE && square.is_finite() {
square
} else {
1.0
}
}
#[allow(clippy::float_cmp)]
fn unrefer_magnetizing(total: f64, referral: f64) -> f64 {
let quotient = total / referral;
if referral == 1.0 || quotient == 0.0 || !quotient.is_finite() {
return quotient;
}
(0..17)
.filter_map(|digits| format!("{quotient:.digits$e}").parse::<f64>().ok())
.find(|candidate| candidate * referral == total)
.unwrap_or(quotient)
}
#[expect(clippy::too_many_arguments)]
fn convert_transformer_magnetizing_admittance(
mag_g: f64,
mag_b: f64,
system_base: f64,
winding_base: f64,
bus_base_kv: f64,
nominal_kv: f64,
cm: i64,
label: &str,
warnings: &mut Diagnostics,
) -> (f64, f64) {
match cm {
1 => (mag_g, mag_b),
2 => {
let system_base_ok = system_base.is_finite() && system_base > 0.0;
let winding_base_ok = winding_base.is_finite() && winding_base > 0.0;
let bus_base_ok = bus_base_kv.is_finite() && bus_base_kv > 0.0;
let nominal = if nominal_kv.is_finite() && nominal_kv > 0.0 {
nominal_kv
} else {
bus_base_kv
};
if !(system_base_ok && winding_base_ok && bus_base_ok && nominal > 0.0) {
warnings.push(&codes::READ_PSSE_VALUE_SUBSTITUTED, format!(
"PSS/E transformer {label}: CM=2 magnetizing data needs positive system, winding, bus-voltage, and nominal-voltage bases; read MAG1/MAG2 as p.u. admittance"
));
return (mag_g, mag_b);
}
let voltage_scale = (bus_base_kv / nominal).powi(2);
let g = mag_g / (1_000_000.0 * system_base) * voltage_scale;
let y = mag_b * (winding_base / system_base) * voltage_scale;
let b_squared = y.mul_add(y, -(g * g));
if b_squared >= 0.0 {
(g, -b_squared.sqrt())
} else {
warnings.push(&codes::READ_PSSE_VALUE_SUBSTITUTED, format!(
"PSS/E transformer {label}: CM=2 exciting current magnitude {y} is below conductance {g}; set magnetizing susceptance to 0"
));
(g, 0.0)
}
}
other => {
warnings.push(&codes::READ_PSSE_VALUE_UNSUPPORTED, format!(
"PSS/E transformer {label}: unsupported CM={other}; read MAG1/MAG2 as p.u. admittance"
));
(mag_g, mag_b)
}
}
}
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: &[Cow<'_, str>],
bus: BusId,
cw: i64,
bus_base_kv: &BTreeMap<BusId, f64>,
label: &str,
winding: &str,
warnings: &mut Diagnostics,
) -> Result<f64> {
let windv = num_at(w, 0, default_windv(cw, bus, bus_base_kv))?;
let nomv = num_at(w, 1, 0.0)?;
Ok(winding_ratio_value(
windv,
nomv,
bus,
cw,
bus_base_kv,
label,
winding,
"WINDV",
warnings,
))
}
#[expect(clippy::too_many_arguments)]
fn winding_ratio_value(
value: f64,
nominal_kv: f64,
bus: BusId,
cw: i64,
bus_base_kv: &BTreeMap<BusId, f64>,
label: &str,
winding: &str,
field: &str,
warnings: &mut Diagnostics,
) -> f64 {
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(&codes::READ_PSSE_VALUE_SUBSTITUTED, format!(
"PSS/E transformer {label} {winding}: CW={cw} needs a positive bus base kV for bus {bus}; read {field} as a p.u. tap ratio"
));
return value;
}
match cw {
1 => value,
2 => value / base_kv,
3 => {
let nominal = if nominal_kv.is_finite() && nominal_kv > 0.0 {
nominal_kv
} else {
base_kv
};
value * nominal / base_kv
}
other => {
warnings.push(&codes::READ_PSSE_VALUE_UNSUPPORTED, format!(
"PSS/E transformer {label} {winding}: unsupported CW={other}; read {field} as a p.u. tap ratio"
));
value
}
}
}
#[expect(clippy::too_many_arguments)]
fn two_winding_tap(
l1: &[Cow<'_, str>],
l3: &[Cow<'_, str>],
l4: &[Cow<'_, str>],
from: BusId,
to: BusId,
cw: i64,
bus_base_kv: &BTreeMap<BusId, f64>,
warnings: &mut Diagnostics,
) -> Result<(f64, 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(&codes::READ_PSSE_VALUE_SUBSTITUTED, format!(
"PSS/E transformer {label}: winding 2 ratio is zero; used winding 1 ratio as the branch tap"
));
Ok((ratio1, 1.0))
} else {
Ok((ratio1 / ratio2, 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 ended_section_name(line: &str) -> Option<String> {
let u = line.to_ascii_uppercase();
let start = u.find("END OF ")? + "END OF ".len();
let rest = &u[start..];
let end = rest.find(" DATA")?;
Some(rest[..end].trim().to_string())
}
fn warn_unmodeled_sections(
totals: BTreeMap<String, usize>,
raw_rev: u32,
warnings: &mut Diagnostics,
) {
let retention = unmodeled_retention(raw_rev);
for (name, rows) in totals {
warnings.push(
&codes::READ_PSSE_SECTION_UNSUPPORTED,
format!("PSS/E {name} section ({rows} record line(s)) is not modeled: {retention}"),
);
}
}
fn unmodeled_retention(raw_rev: u32) -> &'static str {
if raw_rev == 32 {
"retained only in the module source; fresh output uses revision 33 or later and drops it"
} else {
"preserved only in a same-format .raw echo, dropped on any other write"
}
}
fn drop_stale_control_pointers(net: &mut BalancedNetwork, warnings: &mut Diagnostics) {
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_mut().iter_mut().enumerate() {
let Some(bus) = g.regulated_bus.filter(|b| missing(*b)) else {
continue;
};
warnings.push(&codes::READ_PSSE_REFERENCE_DROPPED, 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_mut().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(&codes::READ_PSSE_REFERENCE_DROPPED, 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;
control.controlled_bus_on_winding_side = false;
}
for (transformer_index, transformer) in net.transformers_3w_mut().iter_mut().enumerate() {
for (winding_index, winding) in transformer.windings.iter_mut().enumerate() {
let Some(control) = winding.control.as_mut() else {
continue;
};
let Some(bus) = control.controlled_bus.filter(|bus| missing(*bus)) else {
continue;
};
warnings.push(
&codes::READ_PSSE_REFERENCE_DROPPED,
format!(
"PSS/E TRANSFORMER DATA record {} winding {} at bus {}: CONT references missing bus id {}; dropped transformer control pointer",
transformer_index + 1,
winding_index + 1,
winding.bus,
bus
),
);
control.controlled_bus = None;
control.controlled_bus_on_winding_side = false;
}
}
for (idx, shunt) in net.shunts_mut().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(&codes::READ_PSSE_REFERENCE_DROPPED, 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_mut().iter_mut().enumerate() {
let Some(bus) = area.slack_bus.filter(|b| missing(*b)) else {
continue;
};
warnings.push(&codes::READ_PSSE_REFERENCE_DROPPED, 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: &[Cow<'_, str>]) -> bool {
matches!(
f.first().map(|s| s.to_ascii_uppercase()),
Some(first) if matches!(
first.as_str(),
"GENERAL" | "GAUSS" | "NEWTON" | "SOLVER" | "ADJUST" | "TYSL" | "RATING"
)
)
}
fn parse_solver_line(f: &[Cow<'_, str>], solver: &mut SolverParams, warnings: &mut Diagnostics) {
let Some(keyword) = f.first().map(|s| s.to_ascii_uppercase()) else {
return;
};
if !matches!(keyword.as_str(), "GENERAL" | "NEWTON" | "SOLVER") {
warnings.push(
&codes::READ_PSSE_FIELD_DROPPED,
format!(
"PSS/E system wide {keyword} record has no typed representation; retained only in the same format source"
),
);
return;
}
for tok in &f[1..] {
if tok.is_empty() {
continue;
}
let Some((key, val)) = tok.split_once('=') else {
warnings.push(
&codes::READ_PSSE_FIELD_DROPPED,
format!(
"PSS/E system wide {keyword} token {tok:?} is not a KEY=VALUE field; retained only in the same format source"
),
);
continue;
};
let (key, val) = (key.trim().to_ascii_uppercase(), val.trim());
match (keyword.as_str(), key.as_str()) {
("GENERAL", "THRSHZ") => {
solver.zero_impedance_threshold = parse_system_wide_float(
&keyword,
&key,
val,
warnings,
);
}
("NEWTON", "TOLN") => {
solver.newton_tolerance =
parse_system_wide_float(&keyword, &key, val, warnings);
}
("NEWTON", "ITMXN") => {
solver.max_iterations = parse_system_wide_u32(&keyword, &key, val, warnings);
}
("SOLVER", "ACTAPS") => {
solver.adjust_taps = parse_system_wide_enable(&keyword, &key, val, warnings);
}
("SOLVER", "AREAIN") => {
solver.adjust_area_interchange =
parse_system_wide_enable(&keyword, &key, val, warnings);
}
("SOLVER", "PHSHFT") => {
solver.adjust_phase_shift =
parse_system_wide_enable(&keyword, &key, val, warnings);
}
("SOLVER", "DCTAPS") => {
solver.adjust_dc_taps =
parse_system_wide_enable(&keyword, &key, val, warnings);
}
("SOLVER", "SWSHNT") => {
solver.adjust_switched_shunt =
parse_system_wide_enable(&keyword, &key, val, warnings);
}
_ => warnings.push(
&codes::READ_PSSE_FIELD_DROPPED,
format!(
"PSS/E system wide {keyword}.{key} has no typed representation; retained only in the same format source"
),
),
}
}
}
fn invalid_system_wide_value(
keyword: &str,
key: &str,
value: &str,
expected: &str,
warnings: &mut Diagnostics,
) {
warnings.push(
&codes::READ_PSSE_VALUE_SUBSTITUTED,
format!(
"PSS/E system wide {keyword}.{key} value {value:?} is not {expected}; left the typed solver field unset"
),
);
}
fn parse_system_wide_float(
keyword: &str,
key: &str,
value: &str,
warnings: &mut Diagnostics,
) -> Option<f64> {
let parsed = value.parse::<f64>().ok().filter(|value| value.is_finite());
if parsed.is_none() {
invalid_system_wide_value(keyword, key, value, "a finite number", warnings);
}
parsed
}
fn parse_system_wide_u32(
keyword: &str,
key: &str,
value: &str,
warnings: &mut Diagnostics,
) -> Option<u32> {
let parsed = value.parse::<f64>().ok().filter(|value| {
value.is_finite() && value.fract() == 0.0 && *value >= 0.0 && *value <= f64::from(u32::MAX)
});
if let Some(parsed) = parsed {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
return Some(parsed as u32);
}
invalid_system_wide_value(
keyword,
key,
value,
"a nonnegative integer in the u32 range",
warnings,
);
None
}
fn parse_system_wide_enable(
keyword: &str,
key: &str,
value: &str,
warnings: &mut Diagnostics,
) -> Option<bool> {
if let Some(number) = value.parse::<f64>().ok().filter(|value| value.is_finite()) {
return Some(number != 0.0);
}
match value.to_ascii_uppercase().as_str() {
"ENABLED" | "ON" | "YES" => Some(true),
"DISABLED" | "OFF" | "NO" => Some(false),
_ => {
invalid_system_wide_value(
keyword,
key,
value,
"a finite number or ENABLED/DISABLED, ON/OFF, or YES/NO",
warnings,
);
None
}
}
}
fn strip_inline_comment(line: &str) -> &str {
let mut quote = None;
for (i, c) in line.char_indices() {
match c {
'\'' | '"' if closes_or_opens(quote, c) => quote = toggle_quote(quote, c),
'/' if quote.is_none() => return &line[..i],
_ => {}
}
}
line
}
fn closes_or_opens(quote: Option<char>, c: char) -> bool {
quote.is_none_or(|open| open == c)
}
fn toggle_quote(quote: Option<char>, c: char) -> Option<char> {
if quote.is_some() { None } else { Some(c) }
}
pub(super) fn fields(line: &str) -> Vec<Cow<'_, str>> {
let code = strip_inline_comment(line);
let comma_delimited = code.contains(',');
let mut out = Vec::with_capacity(if comma_delimited {
code.bytes().filter(|b| *b == b',').count() + 1
} else {
8
});
let mut owned: Option<String> = None;
let mut field_start = 0usize;
let mut segment_start = 0usize;
let mut quote = None;
let mut was_quoted = false;
let mut after_comma = false;
for (i, c) in code.char_indices() {
match c {
'\'' | '"' if closes_or_opens(quote, c) => {
owned
.get_or_insert_with(String::new)
.push_str(&code[segment_start..i]);
segment_start = i + 1;
quote = toggle_quote(quote, c);
was_quoted = true;
}
',' if quote.is_none() => {
let content = owned.is_some() || !code[field_start..i].trim().is_empty();
if content || was_quoted {
push_field(&mut out, owned.take(), &code[segment_start..i]);
} else if after_comma {
out.push(Cow::Borrowed(""));
}
field_start = i + 1;
segment_start = field_start;
was_quoted = false;
after_comma = true;
}
c if c.is_whitespace() && quote.is_none() => {
let content = owned.is_some() || !code[field_start..i].trim().is_empty();
if content || was_quoted {
push_field(&mut out, owned.take(), &code[segment_start..i]);
after_comma = false;
}
field_start = i + c.len_utf8();
segment_start = field_start;
was_quoted = false;
}
_ => {}
}
}
let content = owned.is_some() || !code[field_start..].trim().is_empty();
if was_quoted || content {
push_field(&mut out, owned.take(), &code[segment_start..]);
} else if after_comma {
out.push(Cow::Borrowed(""));
}
out
}
fn push_field<'a>(out: &mut Vec<Cow<'a, str>>, owned: Option<String>, raw: &'a str) {
match owned {
Some(mut joined) => {
joined.push_str(raw);
let trimmed = joined.trim();
if trimmed.len() == joined.len() {
out.push(Cow::Owned(joined));
} else {
out.push(Cow::Owned(trimmed.to_string()));
}
}
None => out.push(Cow::Borrowed(raw.trim())),
}
}
fn bad_field(i: usize, tok: &str) -> Error {
Error::FormatRead {
format: FMT,
message: format!("field {i} {tok:?} is not a finite number"),
}
}
fn finite_field(i: usize, token: &str) -> Result<f64> {
token
.parse::<f64>()
.ok()
.filter(|value| value.is_finite())
.ok_or_else(|| bad_field(i, token))
}
fn num_at(f: &[Cow<'_, str>], i: usize, default: f64) -> Result<f64> {
match f.get(i).map(Cow::as_ref) {
None | Some("") => Ok(default),
Some(s) => finite_field(i, s),
}
}
fn id_at(f: &[Cow<'_, str>], i: usize, default: usize) -> Result<usize> {
match f.get(i).map(Cow::as_ref) {
None | Some("") => Ok(default),
Some(s) => {
let v = finite_field(i, s)?;
crate::format::id_from_f64(v, format_args!("field {i}")).map_err(|message| {
Error::FormatRead {
format: FMT,
message,
}
})
}
}
}
fn signed_id_at(f: &[Cow<'_, str>], i: usize, default: usize) -> Result<(usize, bool)> {
match f.get(i).map(Cow::as_ref) {
None | Some("") => Ok((default, false)),
Some(s) => {
let value = finite_field(i, s)?;
let negative = value.is_sign_negative() && value != 0.0;
crate::format::id_from_f64(value.abs(), format_args!("field {i}"))
.map(|id| (id, negative))
.map_err(|message| Error::FormatRead {
format: FMT,
message,
})
}
}
}
fn on_at(f: &[Cow<'_, str>], i: usize, default: bool) -> Result<bool> {
match f.get(i).map(Cow::as_ref) {
None | Some("") => Ok(default),
Some(s) => finite_field(i, s).map(|value| value != 0.0),
}
}
fn int_at(f: &[Cow<'_, str>], i: usize, default: i64) -> Result<i64> {
match f.get(i).map(Cow::as_ref) {
None | Some("") => Ok(default),
#[allow(clippy::cast_possible_truncation)]
Some(s) => finite_field(i, s).map(|value| value as i64),
}
}
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: &[Cow<'_, str>], raw_rev: u32) -> 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(),
})?;
let id =
crate::format::id_from_f64(id, "bus field I").map_err(|message| Error::FormatRead {
format: FMT,
message,
})?;
let name = f
.get(1)
.filter(|n| !n.is_empty())
.map(|n| n.trim().to_string());
let (vmax, vmin) = if raw_rev >= 33 {
(num_at(f, 9, 1.1)?, num_at(f, 10, 0.9)?)
} else {
(1.1, 0.9)
};
let (evhi, evlo) = if raw_rev >= 33 {
(num_at(f, 11, vmax)?, num_at(f, 12, vmin)?)
} else {
(vmax, vmin)
};
let owner = int_at(f, 6, 1)?;
let mut extras = Extras::new();
if owner != 1 {
extras.insert("psse_owner".into(), Value::from(owner));
}
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,
location: None,
extras,
})
}
fn device_extras(f: &[Cow<'_, str>], i: usize) -> Extras {
let mut extras = Extras::new();
if let Some(id) = f
.get(i)
.map(|s| s.trim())
.filter(|s| !s.is_empty() && *s != "1")
{
extras.insert("id".into(), Value::String(id.to_string()));
}
extras
}
#[allow(clippy::float_cmp)]
fn retain_float_extra(
extras: &mut Extras,
fields: &[Cow<'_, str>],
index: usize,
key: &str,
default: f64,
) -> Result<()> {
let value = num_at(fields, index, default)?;
if value != default {
extras.insert(key.into(), jnum(value));
}
Ok(())
}
fn retain_integer_extra(
extras: &mut Extras,
fields: &[Cow<'_, str>],
index: usize,
key: &str,
default: i64,
) -> Result<()> {
let value = int_at(fields, index, default)?;
if value != default {
extras.insert(key.into(), Value::from(value));
}
Ok(())
}
fn retain_string_extra(extras: &mut Extras, fields: &[Cow<'_, str>], index: usize, key: &str) {
if let Some(value) = fields
.get(index)
.map(|value| value.trim())
.filter(|value| !value.is_empty())
{
extras.insert(key.into(), Value::String(value.to_owned()));
}
}
fn drop_bus_default_area_zone(load: &mut Load, bus_area_zone: &BTreeMap<BusId, (usize, usize)>) {
let Some(&(bus_area, bus_zone)) = bus_area_zone.get(&load.bus) else {
return;
};
for (key, bus_value) in [("psse_area", bus_area), ("psse_zone", bus_zone)] {
let matches_bus = extra_i64(&load.extras, key)
.and_then(|value| usize::try_from(value).ok())
.is_some_and(|value| value == bus_value);
if matches_bus {
load.extras.remove(key);
}
}
}
fn read_load(f: &[Cow<'_, str>], raw_rev: u32, warnings: &mut Diagnostics) -> 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 (pdgen, qdgen) = if raw_rev >= 34 && int_at(f, 16, 0)? != 0 {
(num_at(f, 14, 0.0)?, num_at(f, 15, 0.0)?)
} else {
(0.0, 0.0)
};
let mut extras = device_extras(f, 1);
for (field, key) in [(3, "psse_area"), (4, "psse_zone")] {
let value = id_at(f, field, 0)?;
if value != 0 {
extras.insert(key.into(), Value::from(value as u64));
}
}
if [ip, iq, yp, yq, pdgen, qdgen].iter().any(|v| *v != 0.0) {
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));
}
}
let mut retained_integers = vec![(11, "psse_owner", 1_i64), (12, "psse_scal", 1_i64)];
if raw_rev >= 33 {
retained_integers.push((13, "psse_intrpt", 0_i64));
}
for (field, key, default) in retained_integers {
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
&& 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 = if raw_rev >= 35 {
f.get(17).and_then(|s| s.trim().parse::<i32>().ok())
} else {
None
};
let q_impedance = psse_yq_consumption(yq);
let (p_power, q_power) = (pl - pdgen, ql - qdgen);
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: p_power,
q_constant_power: q_power,
p_constant_current: ip,
q_constant_current: iq,
p_constant_impedance: yp,
q_constant_impedance: q_impedance,
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(&codes::READ_PSSE_RETAINED_SOURCE_ONLY, format!(
"PSS/E load at bus {bus} id {id:?}: interruptible/DG/flag fields are retained in extras"
));
}
Ok(Load {
bus: BusId(bus),
p: p_power + ip + yp,
q: q_power + iq + q_impedance,
voltage_model,
in_service: on_at(f, 2, true)?,
uid: None,
extras,
})
}
fn psse_yq_consumption(yq: f64) -> f64 {
0.0 - yq
}
fn load_distributed_generation(extras: &Extras) -> (f64, f64) {
if extra_i64(extras, "psse_flagstatus").unwrap_or(0) == 0 {
return (0.0, 0.0);
}
(
extra_f64(extras, "psse_pdgen").unwrap_or(0.0),
extra_f64(extras, "psse_qdgen").unwrap_or(0.0),
)
}
#[allow(clippy::float_cmp)]
fn gross_load_power(net: f64, dg: f64, retained: Option<f64>) -> f64 {
retained
.filter(|gross| gross - dg == net)
.unwrap_or(net + dg)
}
fn read_shunt(f: &[Cow<'_, str>]) -> 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)?,
section_count: None,
control: None,
uid: None,
extras: device_extras(f, 1),
})
}
fn read_switched_shunt(
f: &[Cow<'_, str>],
rev: u32,
warnings: &mut Diagnostics,
) -> Result<(Shunt, i32)> {
let o = usize::from(rev >= 35);
let o2 = 2 * o;
let bus = id_at(f, 0, 0)?;
let modsw = int_at(f, 1 + o, 1)?;
let adjm = int_at(f, 2 + o, 0)?;
let swrem = id_at(f, 6 + o, 0)?;
let mut blocks = Vec::new();
let mut i = 10 + o2;
let stride = 2 + o;
let mut block_number = 1usize;
let blocks_end = if rev == 34 { f.len().min(26) } else { f.len() };
if rev == 34 && f.len() > 27 {
warnings.push(
&codes::READ_PSSE_FIELD_DROPPED,
format!(
"PSS/E switched shunt at bus {bus}: a revision 34 record states NREG after its eighth step pair; the {} field(s) after NREG were ignored",
f.len() - 27
),
);
}
while i + stride <= blocks_end {
if rev >= 35 {
let status = int_at(f, i, 1)?;
if status != 1 {
warnings.push(
&codes::READ_PSSE_FIELD_DROPPED,
format!(
"PSS/E switched shunt at bus {bus} block {block_number} has S={status}; block status is not represented and the block was retained as enabled"
),
);
}
}
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,
g: 0.0,
b,
});
i += stride;
block_number += 1;
}
let mode = modsw_to_mode(modsw);
let control = SwitchedShuntControl {
mode,
vhigh: num_at(f, 4 + o, 0.0)?,
vlow: num_at(f, 5 + o, 0.0)?,
control_bus: (swrem != 0).then_some(BusId(swrem)),
regulating_terminal: None,
rmpct: num_at(f, 7 + o2, 100.0)?,
blocks,
};
let mut extras = if rev >= 35 {
device_extras(f, 1)
} else {
Extras::new()
};
if modsw != mode_to_modsw(mode) {
extras.insert("psse_modsw".into(), Value::from(modsw));
}
if adjm != 0 {
extras.insert("psse_adjm".into(), Value::from(adjm));
}
retain_string_extra(&mut extras, f, 8 + o2, "psse_rmidnt");
let nreg_at = match rev {
35.. => Some(8),
34 => Some(26),
_ => None,
};
let regulating_node = match nreg_at {
Some(index) => i32::try_from(int_at(f, index, 0)?).map_err(|_| Error::FormatRead {
format: FMT,
message: "switched shunt NREG is outside the i32 range".into(),
})?,
None => 0,
};
Ok((
Shunt {
bus: BusId(bus),
g: 0.0,
b: num_at(f, 9 + o2, 0.0)?,
in_service: on_at(f, 3 + o, true)?,
section_count: None,
control: Some(control),
uid: None,
extras,
},
regulating_node,
))
}
fn modsw_to_mode(modsw: i64) -> SwitchedShuntMode {
match modsw {
0 => SwitchedShuntMode::Locked,
2 => SwitchedShuntMode::Continuous,
_ => SwitchedShuntMode::Discrete,
}
}
fn mode_to_modsw(mode: SwitchedShuntMode) -> i64 {
match mode {
SwitchedShuntMode::Locked => 0,
SwitchedShuntMode::Discrete => 1,
SwitchedShuntMode::Continuous => 2,
}
}
fn facts_regulated_bus(extras: &Extras) -> Option<BusId> {
extra_i64(extras, "psse_fcreg")
.and_then(|bus| usize::try_from(bus).ok())
.map(BusId)
}
#[allow(clippy::float_cmp)] fn read_shunt_facts(
f: &[Cow<'_, str>],
raw_rev: u32,
bus_base_kv: &BTreeMap<BusId, f64>,
warnings: &mut Diagnostics,
) -> Result<Option<(StaticVarCompensator, i32)>> {
if id_at(f, 2, 0)? != 0 {
return Ok(None);
}
let bus = BusId(id_at(f, 1, 0)?);
let name = f.first().map_or("", |name| name.trim());
let kv = bus_base_kv.get(&bus).copied().unwrap_or(0.0);
let kv = if kv.is_finite() && kv > 0.0 {
kv
} else {
warnings.push(
&codes::READ_PSSE_VALUE_SUBSTITUTED,
format!(
"PSS/E FACTS device {name:?} at bus {bus}: the bus states no base kV; read SHMX and VSET on a 1 kV base"
),
);
1.0
};
let mode = int_at(f, 3, 1)?;
let shmx = num_at(f, 7, 9999.0)?;
let vset = num_at(f, 6, 1.0)?;
let mut svc = StaticVarCompensator::new(bus, -shmx / (kv * kv), shmx / (kv * kv));
svc.in_service = mode != 0;
svc.regulating = true;
let regulated = id_at(f, 19, 0)?;
let regulated_kv = if regulated == 0 || regulated == bus.0 {
kv
} else {
bus_base_kv.get(&BusId(regulated)).copied()
.filter(|base| base.is_finite() && *base > 0.0)
.unwrap_or_else(|| {
warnings.push(&codes::READ_PSSE_VALUE_SUBSTITUTED, format!(
"PSS/E FACTS device {name:?}: regulated bus {regulated} has no positive base kV; VSET uses the device bus base"
));
kv
})
};
svc.voltage_setpoint_kv = vset * regulated_kv;
if shmx != 0.0 {
warnings.push(&codes::READ_PSSE_VALUE_SUBSTITUTED, format!(
"PSS/E FACTS device {name:?}: SHMX is a current limit; projected to fixed susceptance limits at unity device-bus voltage only, so reactive limits differ away from unity voltage"
));
}
let mut extras = Extras::new();
if svc.b_max_siemens * (kv * kv) != shmx {
extras.insert("psse_shmx".into(), jnum(shmx));
}
if svc.voltage_setpoint_kv / regulated_kv != vset {
extras.insert("psse_vset".into(), jnum(vset));
}
retain_string_extra(&mut extras, f, 0, "psse_name");
if !matches!(mode, 0 | 1) {
extras.insert("psse_mode".into(), Value::from(mode));
}
for (index, key, default) in [
(4, "psse_pdes", 0.0),
(5, "psse_qdes", 0.0),
(8, "psse_trmx", 9999.0),
(9, "psse_vtmn", 0.9),
(10, "psse_vtmx", 1.1),
(11, "psse_vsmx", 1.0),
(12, "psse_imx", 0.0),
(13, "psse_linx", 0.05),
(14, "psse_rmpct", 100.0),
(16, "psse_set1", 0.0),
(17, "psse_set2", 0.0),
] {
retain_float_extra(&mut extras, f, index, key, default)?;
}
retain_integer_extra(&mut extras, f, 15, "psse_owner", 1)?;
retain_integer_extra(&mut extras, f, 18, "psse_vsref", 0)?;
if regulated != 0 && regulated != bus.0 {
extras.insert("psse_fcreg".into(), Value::from(regulated as u64));
}
let (node, mname) = if raw_rev >= 34 {
let (node_index, name_index) = if raw_rev >= 35 { (20, 21) } else { (21, 20) };
let node = i32::try_from(int_at(f, node_index, 0)?).map_err(|_| Error::FormatRead {
format: FMT,
message: "FACTS device NREG is outside the i32 range".into(),
})?;
(node, name_index)
} else {
(0, 20)
};
retain_string_extra(&mut extras, f, mname, "psse_mname");
if regulated != 0 && regulated != bus.0 && node == 0 {
warnings.push(
&codes::READ_PSSE_FIELD_DROPPED,
format!(
"PSS/E FACTS device {name:?} at bus {bus} regulates bus {regulated}, which a static var compensator names only through a regulating terminal; retained for PSS/E output; the typed voltage setpoint uses the regulated bus base but its remote control target is unavailable"
),
);
}
svc.extras = extras;
Ok(Some((svc, node)))
}
fn read_area(f: &[Cow<'_, str>]) -> 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()),
uid: None,
area_type: Some("ControlArea".to_owned()),
})
}
fn retain_generator_float(
properties: &mut BTreeMap<String, String>,
f: &[Cow<'_, str>],
index: usize,
property: &str,
default: f64,
) -> Result<()> {
let Some(token) = f
.get(index)
.map(Cow::as_ref)
.filter(|token| !token.is_empty())
else {
return Ok(());
};
let value = finite_field(index, token)?;
#[allow(clippy::float_cmp)]
if value != default {
properties.insert(property.to_owned(), value.to_string());
}
Ok(())
}
fn retain_generator_integer(
properties: &mut BTreeMap<String, String>,
f: &[Cow<'_, str>],
index: usize,
property: &str,
default: i64,
) -> Result<()> {
let Some(token) = f
.get(index)
.map(Cow::as_ref)
.filter(|token| !token.is_empty())
else {
return Ok(());
};
#[allow(clippy::cast_possible_truncation)]
let value = finite_field(index, token)? as i64;
if value != default {
properties.insert(property.to_owned(), value.to_string());
}
Ok(())
}
fn retain_generator_id(
properties: &mut BTreeMap<String, String>,
f: &[Cow<'_, str>],
bus: BusId,
used: &mut BTreeMap<BusId, BTreeSet<String>>,
) {
let Some(id) = f.get(1).map(Cow::as_ref).filter(|id| !id.is_empty()) else {
return;
};
let taken = used.get(&bus);
let mut n = 1u32;
let generated = loop {
let candidate = n.to_string();
if taken.is_none_or(|ids| !ids.contains(&candidate)) {
break candidate;
}
n += 1;
};
super::allocate_circuit_id(Some(id), bus, used);
if id != generated {
properties.insert("psse_eqid".to_owned(), id.to_owned());
}
}
fn read_gen(
f: &[Cow<'_, str>],
raw_rev: u32,
generator_ids: &mut BTreeMap<BusId, BTreeSet<String>>,
) -> Result<(Generator, i32, BTreeMap<String, String>)> {
let o = usize::from(raw_rev >= 35);
let bus = id_at(f, 0, 0)?;
let ireg = id_at(f, 7, 0)?;
let owner_start = if raw_rev >= 35 { 20 } else { 18 };
let nreg_at = match raw_rev {
35.. => Some(8),
34 => Some(owner_start + 10),
_ => None,
};
let regulating_node = match nreg_at {
Some(index) => i32::try_from(int_at(f, index, 0)?).map_err(|_| Error::FormatRead {
format: FMT,
message: "generator NREG is outside the i32 range".into(),
})?,
None => 0,
};
let mut source_metadata = BTreeMap::new();
retain_generator_id(&mut source_metadata, f, BusId(bus), generator_ids);
for (index, property, default) in [
(9 + o, "psse_zr", 0.0),
(10 + o, "psse_zx", 1.0),
(11 + o, "psse_rt", 0.0),
(12 + o, "psse_xt", 0.0),
(13 + o, "psse_gtap", 1.0),
(15 + o, "psse_rmpct", 100.0),
] {
retain_generator_float(&mut source_metadata, f, index, property, default)?;
}
if raw_rev >= 35 {
retain_generator_integer(&mut source_metadata, f, 19, "psse_baslod", 0)?;
}
for owner in 0..4 {
retain_generator_integer(
&mut source_metadata,
f,
owner_start + owner * 2,
&format!("psse_o{}", owner + 1),
i64::from(owner == 0),
)?;
retain_generator_float(
&mut source_metadata,
f,
owner_start + owner * 2 + 1,
&format!("psse_f{}", owner + 1),
1.0,
)?;
}
retain_generator_integer(&mut source_metadata, f, owner_start + 8, "psse_wmod", 0)?;
retain_generator_float(&mut source_metadata, f, owner_start + 9, "psse_wpf", 1.0)?;
Ok((
Generator {
bus: BusId(bus),
energy_source: GeneratorEnergySource::default(),
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(),
voltage_regulation_on: true,
regulating_terminal: None,
regulated_bus: (ireg != 0).then_some(BusId(ireg)),
active_power_control: None,
uid: None,
},
regulating_node,
source_metadata,
))
}
fn attach_generator_source_metadata(
net: &mut BalancedNetwork,
generator_metadata: Vec<BTreeMap<String, String>>,
) -> Result<()> {
if generator_metadata.iter().all(BTreeMap::is_empty) {
return Ok(());
}
net.assign_missing_component_ids();
let component_ids = net
.generators()
.iter()
.map(|generator| {
let uid = generator.uid.as_deref().ok_or_else(|| Error::FormatRead {
format: FMT,
message: "generator identity assignment failed".into(),
})?;
powerio_core::ComponentId::new("generator", uid).map_err(|error| Error::FormatRead {
format: FMT,
message: error.to_string(),
})
})
.collect::<Result<Vec<_>>>()?;
if net.detailed_connectivity().is_none() {
*net.detailed_connectivity_mut() =
Some(std::sync::Arc::new(DetailedConnectivity::default()));
}
let detailed = std::sync::Arc::make_mut(
net.detailed_connectivity_mut()
.as_mut()
.expect("detailed connectivity was initialized"),
);
let mut attached: HashMap<powerio_core::ComponentId, usize> =
HashMap::with_capacity(detailed.component_metadata.len());
for (row, metadata) in detailed.component_metadata.iter().enumerate() {
attached.entry(metadata.component.clone()).or_insert(row);
}
for (component, properties) in component_ids.into_iter().zip(generator_metadata) {
if properties.is_empty() {
continue;
}
if let Some(&row) = attached.get(&component) {
detailed.component_metadata[row]
.properties
.extend(properties);
} else {
attached.insert(component.clone(), detailed.component_metadata.len());
detailed.component_metadata.push(ComponentMetadata {
component,
name: None,
equipment_container: None,
aliases: Vec::new(),
external_identifiers: Vec::new(),
properties,
fictitious: false,
});
}
}
Ok(())
}
fn read_branch(f: &[Cow<'_, str>], 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;
let from = BusId(id_at(f, 0, 0)?);
let to = BusId(id_at(f, 1, 0)?);
let name = if named_record {
f.get(6)
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.map(str::to_owned)
} else {
None
};
let mut extras = device_extras(f, 2);
retain_integer_extra(&mut extras, f, status + 1, "psse_met", 1)?;
retain_float_extra(&mut extras, f, status + 2, "psse_len", 0.0)?;
retain_psse_ownership(&mut extras, f, status + 3)?;
Ok(Branch {
name,
from,
to,
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,
route: None,
extras,
})
}
fn read_system_switch(f: &[Cow<'_, str>]) -> Result<Switch> {
let from = BusId(id_at(f, 0, 0)?);
let to = BusId(id_at(f, 1, 0)?);
let ckt = f
.get(2)
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.unwrap_or("1");
let mut switch = Switch::new(from, to, on_at(f, 16, true)?);
let rate1 = num_at(f, 4, 0.0)?;
switch.thermal_rating = (rate1 > 0.0).then_some(rate1);
switch.uid = Some(format!("{from}-{to}-{ckt}"));
if ckt != "1" {
switch
.extras
.insert("psse_ckt".into(), Value::String(ckt.to_owned()));
}
retain_float_extra(&mut switch.extras, f, 3, "psse_xpu", 0.0)?;
for rating in 2..=12 {
retain_float_extra(
&mut switch.extras,
f,
3 + rating,
&format!("psse_rate{rating}"),
0.0,
)?;
}
retain_integer_extra(&mut switch.extras, f, 17, "psse_nstat", 1)?;
retain_integer_extra(&mut switch.extras, f, 18, "psse_met", 1)?;
retain_integer_extra(&mut switch.extras, f, 19, "psse_stype", 1)?;
retain_string_extra(&mut switch.extras, f, 20, "psse_name");
Ok(switch)
}
fn retain_transformer_main_extras(
extras: &mut Extras,
fields: &[Cow<'_, str>],
raw_rev: u32,
) -> Result<()> {
retain_integer_extra(extras, fields, 9, "psse_nmetr", 2)?;
retain_psse_ownership(extras, fields, 12)?;
if raw_rev >= 33 {
retain_string_extra(extras, fields, 20, "psse_vecgrp");
}
if raw_rev >= 35 {
retain_integer_extra(extras, fields, 21, "psse_zcod", 0)?;
}
Ok(())
}
struct WindingColumns {
cod: usize,
cont: usize,
node: Option<usize>,
rma: usize,
}
fn winding_columns(raw_rev: u32) -> WindingColumns {
match raw_rev {
35.. => WindingColumns {
cod: 15,
cont: 16,
node: Some(17),
rma: 18,
},
34 => WindingColumns {
cod: 15,
cont: 16,
node: Some(26),
rma: 17,
},
_ => WindingColumns {
cod: 6,
cont: 7,
node: None,
rma: 8,
},
}
}
fn place_winding_node(mut control: Vec<String>, rev: u32, node: i32) -> String {
let columns = winding_columns(rev);
if let Some(index) = columns.node {
control.insert(index - columns.cod, node.to_string());
}
control.join(", ")
}
fn retain_transformer_winding_extras(
extras: &mut Extras,
fields: &[Cow<'_, str>],
raw_rev: u32,
suffix: &str,
) -> Result<()> {
let start = winding_columns(raw_rev).rma + 5;
retain_integer_extra(extras, fields, start, &format!("psse_tab{suffix}"), 0)?;
retain_float_extra(extras, fields, start + 1, &format!("psse_cr{suffix}"), 0.0)?;
retain_float_extra(extras, fields, start + 2, &format!("psse_cx{suffix}"), 0.0)?;
Ok(())
}
#[expect(clippy::too_many_arguments)]
fn read_transformer(
l1: &[Cow<'_, str>],
l2: &[Cow<'_, str>],
l3: &[Cow<'_, str>],
l4: &[Cow<'_, str>],
raw_rev: u32,
system_base: f64,
bus_base_kv: &BTreeMap<BusId, f64>,
warnings: &mut Diagnostics,
) -> Result<(Branch, i32)> {
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, ratio2) = two_winding_tap(l1, l3, l4, from, to, cw, bus_base_kv, warnings)?;
let (r, x) = (r * ratio2 * ratio2, x * ratio2 * ratio2);
let modern = raw_rev >= 34;
let (mut control, control_node) = read_transformer_control(
l3,
raw_rev,
sbase,
from,
cw,
bus_base_kv,
&label,
"winding 1",
warnings,
)?;
if let Some(control) = control.as_mut()
&& matches!(
control.mode,
TransformerControlMode::Voltage | TransformerControlMode::ReactiveFlow
)
{
control.tap_min /= ratio2;
control.tap_max /= ratio2;
}
let bus_kv = bus_base_kv.get(&from).copied().unwrap_or(0.0);
let (mag_g, mag_b) = convert_transformer_magnetizing_admittance(
num_at(l1, 7, 0.0)?,
num_at(l1, 8, 0.0)?,
system_base,
sbase,
bus_kv,
num_at(l3, 1, bus_kv)?,
int_at(l1, 6, 1)?,
&label,
warnings,
);
let referral = magnetizing_referral(tap);
let (mag_g, mag_b) = (mag_g * referral, mag_b * referral);
let mut extras = device_extras(l1, 3);
retain_transformer_main_extras(&mut extras, l1, raw_rev)?;
retain_transformer_winding_extras(&mut extras, l3, raw_rev, "")?;
Ok((
Branch {
name: l1
.get(10)
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.map(str::to_owned),
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,
route: None,
extras,
},
control_node,
))
}
fn cod_to_mode(cod: i64) -> TransformerControlMode {
match cod.unsigned_abs() {
1 => TransformerControlMode::Voltage,
2 => TransformerControlMode::ReactiveFlow,
3 => TransformerControlMode::ActiveFlow,
4 => TransformerControlMode::DcLineQuantity,
5 => TransformerControlMode::AsymmetricActiveFlow,
_ => TransformerControlMode::Fixed,
}
}
fn mode_to_cod(mode: TransformerControlMode) -> i64 {
match mode {
TransformerControlMode::Fixed => 0,
TransformerControlMode::Voltage => 1,
TransformerControlMode::ReactiveFlow => 2,
TransformerControlMode::ActiveFlow => 3,
TransformerControlMode::DcLineQuantity => 4,
TransformerControlMode::AsymmetricActiveFlow => 5,
}
}
#[allow(clippy::float_cmp)]
#[expect(clippy::too_many_arguments)]
fn read_transformer_control(
winding: &[Cow<'_, str>],
raw_rev: u32,
mva_base: f64,
bus: BusId,
cw: i64,
bus_base_kv: &BTreeMap<BusId, f64>,
label: &str,
winding_name: &str,
warnings: &mut Diagnostics,
) -> Result<(Option<TransformerControl>, i32)> {
let columns = winding_columns(raw_rev);
let rma_i = columns.rma;
let cod = int_at(winding, columns.cod, 0)?;
let (cont, controlled_bus_on_winding_side) = signed_id_at(winding, columns.cont, 0)?;
let node = columns
.node
.map_or(Ok(0), |index| int_at(winding, index, 0))?;
let mode = cod_to_mode(cod);
if cod != 0 && mode == TransformerControlMode::Fixed {
warnings.push(
&codes::READ_PSSE_VALUE_UNSUPPORTED,
format!(
"PSS/E transformer {label} {winding_name}: unsupported COD={cod}; read its remaining fields with fixed control"
),
);
}
let raw_tap_max = num_at(winding, rma_i, 1.1)?;
let raw_tap_min = num_at(winding, rma_i + 1, 0.9)?;
let mut tap_max = raw_tap_max;
let mut tap_min = raw_tap_min;
if matches!(
mode,
TransformerControlMode::Voltage | TransformerControlMode::ReactiveFlow
) {
let nominal_kv = num_at(winding, 1, 0.0)?;
tap_max = winding_ratio_value(
tap_max,
nominal_kv,
bus,
cw,
bus_base_kv,
label,
winding_name,
"RMA",
warnings,
);
tap_min = winding_ratio_value(
tap_min,
nominal_kv,
bus,
cw,
bus_base_kv,
label,
winding_name,
"RMI",
warnings,
);
}
let band_max = num_at(winding, rma_i + 2, 1.1)?;
let band_min = num_at(winding, rma_i + 3, 0.9)?;
let ntp = int_at(winding, rma_i + 4, 33)?.clamp(0, i64::from(u32::MAX)) as u32;
let winding_connection_angle = if raw_rev >= 33 {
num_at(winding, rma_i + 8, 0.0)?
} else {
0.0
};
let present = cod != 0
|| cont != 0
|| node != 0
|| raw_tap_max != 1.1
|| raw_tap_min != 0.9
|| band_max != 1.1
|| band_min != 0.9
|| ntp != 33
|| winding_connection_angle != 0.0;
Ok((
present.then_some(TransformerControl {
mode,
enabled: cod > 0,
controlled_bus: (cont != 0).then_some(BusId(cont)),
controlled_bus_on_winding_side,
regulating_terminal: None,
tap_min,
tap_max,
band_min,
band_max,
ntp,
mva_base,
winding_connection_angle: (mode == TransformerControlMode::AsymmetricActiveFlow
|| winding_connection_angle != 0.0)
.then_some(winding_connection_angle),
}),
i32::try_from(node).map_err(|_| Error::FormatRead {
format: FMT,
message: format!("transformer {winding_name} NODE is outside the i32 range"),
})?,
))
}
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
fn read_transformer_3w(
l1: &[Cow<'_, str>],
l2: &[Cow<'_, str>],
l3: &[Cow<'_, str>],
l4: &[Cow<'_, str>],
l5: &[Cow<'_, str>],
raw_rev: u32,
system_base: f64,
bus_base_kv: &BTreeMap<BusId, f64>,
warnings: &mut Diagnostics,
) -> Result<(Transformer3W, [i32; 3])> {
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 stat = int_at(l1, 11, 1)?;
let (in_service, windings_in_service) = three_winding_status(stat).unwrap_or_else(|| {
warnings.push(
&codes::READ_PSSE_VALUE_SUBSTITUTED,
format!(
"PSS/E three winding transformer {label}: STAT {stat} is not a code from 0 to 4; read as 1, every winding in service"
),
);
(true, [true; 3])
});
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: &[Cow<'_, str>]| -> Result<(Winding, i32)> {
let bus = buses[idx];
let winding_name = match idx {
0 => "winding 1",
1 => "winding 2",
_ => "winding 3",
};
let tap = winding_ratio(w, bus, cw, bus_base_kv, &label, winding_name, warnings)?;
let (control, node) = read_transformer_control(
w,
raw_rev,
system_base,
bus,
cw,
bus_base_kv,
&label,
winding_name,
warnings,
)?;
if control
.as_ref()
.is_some_and(|control| control.mode == TransformerControlMode::DcLineQuantity)
{
return Err(Error::FormatRead {
format: FMT,
message: format!(
"transformer {label} {winding_name} uses COD 4 DC line quantity control, which is valid only for two winding transformers"
),
});
}
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)?,
control,
},
node,
))
};
let (winding1, node1) = winding(0, l3)?;
let (winding2, node2) = winding(1, l4)?;
let (winding3, node3) = winding(2, l5)?;
([winding1, winding2, winding3], [node1, node2, node3])
};
let (windings, control_nodes) = windings;
let winding_base = num_at(l2, 2, system_base)?;
let bus_kv = bus_base_kv.get(&buses[0]).copied().unwrap_or(0.0);
let (mag_g, mag_b) = convert_transformer_magnetizing_admittance(
num_at(l1, 7, 0.0)?,
num_at(l1, 8, 0.0)?,
system_base,
winding_base,
bus_kv,
num_at(l3, 1, bus_kv)?,
int_at(l1, 6, 1)?,
&label,
warnings,
);
let mut extras = device_extras(l1, 3);
retain_transformer_main_extras(&mut extras, l1, raw_rev)?;
for (winding, suffix) in [(l3, "1"), (l4, "2"), (l5, "3")] {
retain_transformer_winding_extras(&mut extras, winding, raw_rev, suffix)?;
}
let mut transformer = Transformer3W {
windings,
z,
star_vm: num_at(l2, 9, 1.0)?,
star_va: num_at(l2, 10, 0.0)?,
mag_g,
mag_b,
in_service,
name: l1
.get(10)
.filter(|n| !n.is_empty())
.map(|n| n.trim().to_string()),
uid: None,
extras,
};
transformer.set_winding_in_service(windings_in_service);
for (winding, line) in [l3, l4, l5].into_iter().enumerate() {
transformer
.set_winding_rating_sets(winding, read_extra_branch_ratings(line, 3, raw_rev >= 34)?);
}
Ok((transformer, control_nodes))
}
fn three_winding_status(stat: i64) -> Option<(bool, [bool; 3])> {
match stat {
0 => Some((false, [true; 3])),
1 => Some((true, [true; 3])),
2 => Some((true, [true, false, true])),
3 => Some((true, [true, true, false])),
4 => Some((true, [false, true, true])),
_ => None,
}
}
fn three_winding_stat(transformer: &Transformer3W) -> Option<i64> {
if !transformer.in_service {
return Some(0);
}
match transformer.winding_in_service() {
[true, true, true] => Some(1),
[true, false, true] => Some(2),
[true, true, false] => Some(3),
[false, true, true] => Some(4),
_ => None,
}
}
#[derive(Debug, Clone, Copy)]
enum ImpedanceTarget {
Branch(usize),
Winding(usize, usize),
}
#[derive(Debug, Clone, Copy)]
struct ImpedanceReference {
target: ImpedanceTarget,
table: u32,
ratio_scale: f64,
}
fn impedance_reference(
target: ImpedanceTarget,
extras: &Extras,
tab_key: &str,
ratio_scale: f64,
) -> Option<ImpedanceReference> {
let table = extra_i64(extras, tab_key)
.filter(|table| *table > 0)
.and_then(|table| u32::try_from(table).ok())?;
Some(ImpedanceReference {
target,
table,
ratio_scale,
})
}
fn impedance_table_variable(points: &[ImpedanceCorrectionPoint]) -> ImpedanceCorrectionVariable {
if points.first().is_some_and(|point| point.value < 0.5)
|| points.last().is_some_and(|point| point.value > 1.5)
{
ImpedanceCorrectionVariable::Angle
} else {
ImpedanceCorrectionVariable::Ratio
}
}
fn read_impedance_correction(
f: &[Cow<'_, str>],
raw_rev: u32,
lines: &mut RawLines<'_>,
warnings: &mut Diagnostics,
) -> Result<(u32, Vec<ImpedanceCorrectionPoint>)> {
let table = u32::try_from(id_at(f, 0, 0)?).map_err(|_| Error::FormatRead {
format: FMT,
message: "impedance correction table number exceeds the u32 range".into(),
})?;
let numbers = |fields: &[Cow<'_, str>], start: usize| {
(start..fields.len())
.map(|i| finite_field(i, &fields[i]))
.collect::<Result<Vec<_>>>()
};
let mut points = Vec::new();
if raw_rev < 34 {
for &[value, factor] in numbers(f, 1)?.as_chunks::<2>().0 {
if value != 0.0 || factor != 0.0 {
points.push(ImpedanceCorrectionPoint::new(value, factor, 0.0));
}
}
return Ok(finish_impedance_correction_table(table, points, warnings));
}
let mut values = numbers(f, 1)?;
let mut pending = Vec::new();
loop {
pending.extend(values);
let complete_len = pending.len() / 3 * 3;
for &[value, factor_re, factor_im] in pending[..complete_len].as_chunks::<3>().0 {
if value == 0.0 && factor_re == 0.0 && factor_im == 0.0 {
return Ok(finish_impedance_correction_table(table, points, warnings));
}
points.push(ImpedanceCorrectionPoint::new(value, factor_re, factor_im));
}
pending.drain(..complete_len);
let Some(line) = next_open_continuation_line(lines, warnings) else {
warnings.push(
&codes::READ_PSSE_VALUE_SUBSTITUTED,
format!(
"PSS/E impedance correction table {table} ends without its 0.0, 0.0, 0.0 point; read the {} point(s) it states and discarded {} trailing field(s)",
points.len(),
pending.len()
),
);
return Ok(finish_impedance_correction_table(table, points, warnings));
};
values = numbers(&fields(line), 0)?;
}
}
fn next_open_continuation_line<'a>(
lines: &mut RawLines<'a>,
warnings: &mut Diagnostics,
) -> Option<&'a str> {
let mut ahead = lines.clone();
while let Some((start, raw)) = ahead.next_line() {
let line = raw.trim();
if line.is_empty() || is_comment(line) {
continue;
}
if line.eq_ignore_ascii_case("q") || is_impedance_correction_terminator(line) {
return None;
}
*lines = ahead;
let line_start = start + (raw.len() - raw.trim_start().len());
warnings.extend_record(line_start + line.len());
return Some(line);
}
None
}
fn is_impedance_correction_terminator(line: &str) -> bool {
if line.trim() == "0" {
return true;
}
line.split_once('/').is_some_and(|(record, comment)| {
record.trim() == "0"
&& comment
.trim_start()
.to_ascii_uppercase()
.starts_with("END OF")
})
}
fn finish_impedance_correction_table(
table: u32,
mut points: Vec<ImpedanceCorrectionPoint>,
warnings: &mut Diagnostics,
) -> (u32, Vec<ImpedanceCorrectionPoint>) {
if points.windows(2).any(|pair| pair[0].value >= pair[1].value) {
warnings.push(
&codes::READ_PSSE_VALUE_SUBSTITUTED,
format!(
"PSS/E impedance correction table {table} has non-ascending T values; points were sorted by T"
),
);
points.sort_by(|a, b| a.value.total_cmp(&b.value));
}
(table, points)
}
fn attach_impedance_corrections(
tables: &BTreeMap<u32, Vec<ImpedanceCorrectionPoint>>,
references: Vec<ImpedanceReference>,
branches: &mut [Branch],
transformers_3w: &mut [Transformer3W],
warnings: &mut Diagnostics,
) {
let mut named = BTreeSet::new();
let mut missing = BTreeSet::new();
for reference in references {
let Some(points) = tables.get(&reference.table) else {
missing.insert(reference.table);
continue;
};
named.insert(reference.table);
let variable = impedance_table_variable(points);
let points = points
.iter()
.map(|point| {
let value = match variable {
ImpedanceCorrectionVariable::Ratio => point.value * reference.ratio_scale,
_ => point.value,
};
ImpedanceCorrectionPoint::new(value, point.factor_re, point.factor_im)
})
.collect();
let correction = ImpedanceCorrection::new(reference.table, variable, points);
match reference.target {
ImpedanceTarget::Branch(row) => {
branches[row].set_impedance_correction(Some(correction));
}
ImpedanceTarget::Winding(row, winding) => {
transformers_3w[row].set_winding_impedance_correction(winding, Some(correction));
}
}
}
if !missing.is_empty() {
warnings.push(
&codes::READ_PSSE_REFERENCE_DROPPED,
format!(
"PSS/E transformer windings name impedance correction table(s) {} that the case does not state; their impedance is read uncorrected",
missing.iter().map(u32::to_string).collect::<Vec<_>>().join(", ")
),
);
}
let unnamed = tables
.keys()
.filter(|table| !named.contains(*table))
.count();
if unnamed > 0 {
warnings.push(
&codes::READ_PSSE_RETAINED_SOURCE_ONLY,
format!(
"{unnamed} PSS/E impedance correction table(s) that no transformer names are retained only in the same format source"
),
);
}
}
#[expect(clippy::too_many_lines)]
fn read_dc_line(
l1: &[Cow<'_, str>],
rect: &[Cow<'_, str>],
inv: &[Cow<'_, str>],
index: usize,
raw_rev: u32,
warnings: &mut Diagnostics,
) -> 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 measured_at_inverter = false;
let mut unpriceable_current = false;
let (pf, pt) = match (mdc, vschd > 0.0) {
(2, true) => {
let i = setvl / 1000.0;
let pf = vschd * i;
(pf, pf - i * i * rdc)
}
(2, false) => {
unpriceable_current = true;
(0.0, 0.0)
}
(1, true) if setvl < 0.0 => {
measured_at_inverter = true;
let pt = -setvl;
let i = pt / vschd;
(pt + i * i * rdc, pt)
}
(1, true) => {
let i = setvl / vschd;
(setvl, setvl - i * i * rdc)
}
_ => (setvl, setvl),
};
let (pf, pt) =
if pf.is_finite() && pt.is_finite() {
(pf, pt)
} else {
warnings.push(&codes::READ_PSSE_VALUE_SUBSTITUTED, format!(
"two-terminal DC record {} states a drop model that does not evaluate to a finite \
power; both ends read as zero",
index + 1
));
measured_at_inverter = false;
(0.0, 0.0)
};
if unpriceable_current {
warnings.push(
&codes::READ_PSSE_VALUE_SUBSTITUTED,
format!(
"two-terminal DC record {} schedules a current with no scheduled voltage; the \
demand cannot be priced into power and both ends read as zero",
index + 1
),
);
}
let mut extras = Extras::new();
if let Some(name) = l1
.first()
.filter(|n| !n.is_empty() && **n != format!("DC{}", index + 1))
{
extras.insert("psse_dc_name".into(), Value::String(name.to_string()));
}
if !(0..=1).contains(&mdc) {
extras.insert("psse_dc_mdc".into(), Value::from(mdc));
}
if rdc != 0.0 {
extras.insert("psse_dc_rdc".into(), jnum(rdc));
}
if vschd != 0.0 {
extras.insert("psse_dc_vschd".into(), jnum(vschd));
}
if measured_at_inverter {
extras.insert(SETVL_AT_INVERTER.into(), Value::Bool(true));
}
if unpriceable_current {
extras.insert("psse_dc_setvl".into(), jnum(setvl));
}
let rect_tail = converter_tail_fields(rect, raw_rev, "rectifier", warnings);
let inv_tail = converter_tail_fields(inv, raw_rev, "inverter", warnings);
for (key, fields, start, default) in [
("psse_dc_control_tail", l1, 5, DEFAULT_CONTROL_TAIL),
(
"psse_dc_rectifier_tail",
rect_tail.as_slice(),
0,
DEFAULT_CONVERTER_TAIL,
),
(
"psse_dc_inverter_tail",
inv_tail.as_slice(),
0,
DEFAULT_CONVERTER_TAIL,
),
] {
if !tail_is_default(fields, start, default) {
extras.insert(key.into(), tail_array(fields, start));
}
}
Ok(Hvdc {
from: BusId(id_at(rect, 0, 0)?),
to: BusId(id_at(inv, 0, 0)?),
in_service: mdc != 0,
pf,
pt,
qf: 0.0,
qt: 0.0,
vf: 1.0,
vt: 1.0,
pmin: 0.0,
pmax: pf.abs().max(pt.abs()),
qminf: 0.0,
qmaxf: 0.0,
qmint: 0.0,
qmaxt: 0.0,
loss0: 0.0,
loss1: 0.0,
resistance_ohm: None,
nominal_voltage_kv: None,
converters_mode: None,
converter1: None,
converter2: None,
cost: None,
uid: None,
extras,
})
}
fn vsc_converter_key(side: usize) -> String {
format!("psse_vsc_converter{side}")
}
#[derive(Debug, Clone, Copy)]
struct VscConverter {
bus: usize,
kind: i64,
mode: i64,
dcset: f64,
acset: f64,
aloss: f64,
bloss: f64,
minloss: f64,
smax: f64,
imax: f64,
pwf: f64,
maxq: f64,
minq: f64,
vsreg: usize,
nreg: i64,
rmpct: f64,
}
impl VscConverter {
fn calc_loss_mw(&self, current_ka: f64) -> f64 {
(self.aloss + self.bloss * current_ka.abs() * 1000.0).max(self.minloss) / 1000.0
}
fn to_extra(self) -> Value {
let mut record = serde_json::Map::new();
record.insert("type".into(), Value::from(self.kind));
record.insert("mode".into(), Value::from(self.mode));
for (key, value) in [
("dcset", self.dcset),
("acset", self.acset),
("aloss", self.aloss),
("bloss", self.bloss),
("minloss", self.minloss),
("smax", self.smax),
("imax", self.imax),
("pwf", self.pwf),
("rmpct", self.rmpct),
] {
record.insert(key.into(), jnum(value));
}
record.insert("vsreg".into(), Value::from(self.vsreg));
record.insert("nreg".into(), Value::from(self.nreg));
Value::Object(record)
}
}
fn read_vsc_converter(
f: &[Cow<'_, str>],
raw_rev: u32,
record: usize,
side: usize,
warnings: &mut Diagnostics,
) -> Result<VscConverter> {
let (nreg, rmpct_at) = if raw_rev >= 35 {
(int_at(f, 14, 0)?, 15)
} else {
(0, 14)
};
let mut rmpct = num_at(f, rmpct_at, 100.0)?;
if !(rmpct > 0.0 && rmpct <= 100.0) {
warnings.push(
&codes::READ_PSSE_VALUE_SUBSTITUTED,
format!(
"VSC DC line record {record} converter {side}: RMPCT {rmpct} is outside (0, 100]; read as 100"
),
);
rmpct = 100.0;
}
Ok(VscConverter {
bus: id_at(f, 0, 0)?,
kind: int_at(f, 1, 0)?,
mode: int_at(f, 2, 1)?,
dcset: num_at(f, 3, 0.0)?,
acset: num_at(f, 4, 1.0)?,
aloss: num_at(f, 5, 0.0)?,
bloss: num_at(f, 6, 0.0)?,
minloss: num_at(f, 7, 0.0)?,
smax: num_at(f, 8, 0.0)?,
imax: num_at(f, 9, 0.0)?,
pwf: num_at(f, 10, 1.0)?,
maxq: num_at(f, 11, 9999.0)?,
minq: num_at(f, 12, -9999.0)?,
vsreg: id_at(f, 13, 0)?,
nreg,
rmpct,
})
}
fn calc_vsc_powers(converters: [&VscConverter; 2], rdc: f64) -> Option<(f64, f64, f64)> {
let (power, voltage) = match (converters[0].kind, converters[1].kind) {
(2, 1) => (0, 1),
(1, 2) => (1, 0),
_ => return None,
};
let v = converters[voltage].dcset;
if v.is_nan() || v <= 0.0 {
return None;
}
let withdrawn = -converters[power].dcset;
let mut current = withdrawn / v;
for _ in 0..50 {
let sent = withdrawn - converters[power].calc_loss_mw(current);
let next = if rdc > 0.0 {
let discriminant = v.mul_add(v, 4.0 * rdc * sent);
if discriminant < 0.0 {
return None;
}
(discriminant.sqrt() - v) / (2.0 * rdc)
} else {
sent / v
};
let settled = (next - current).abs() <= 1e-12 * next.abs().max(1.0);
current = next;
if settled {
break;
}
}
let arrived = v * current;
let delivered = arrived - converters[voltage].calc_loss_mw(current);
let mut powers = [0.0; 2];
powers[power] = -withdrawn;
powers[voltage] = delivered;
(powers.iter().all(|p| p.is_finite())).then_some((powers[0], powers[1], v))
}
fn calc_vsc_reactive_power(converter: &VscConverter, p: f64) -> f64 {
if converter.mode != 2 {
return 0.0;
}
let pf = converter.acset.abs().min(1.0);
if pf == 0.0 {
return 0.0;
}
p.abs() * (1.0 - pf * pf).sqrt() / pf * converter.acset.signum()
}
#[expect(clippy::too_many_lines)]
fn read_vsc_line(
l1: &[Cow<'_, str>],
c1: &[Cow<'_, str>],
c2: &[Cow<'_, str>],
index: usize,
raw_rev: u32,
bus_base_kv: &BTreeMap<BusId, f64>,
warnings: &mut Diagnostics,
) -> Result<Hvdc> {
let record = index + 1;
let name = l1.first().map_or_else(String::new, ToString::to_string);
let mdc = int_at(l1, 1, 1)?;
let rdc = num_at(l1, 2, 0.0)?;
let converters = [
read_vsc_converter(c1, raw_rev, record, 1, warnings)?,
read_vsc_converter(c2, raw_rev, record, 2, warnings)?,
];
let blocked = mdc == 0 || converters.iter().any(|c| c.kind == 0 || c.mode == 0);
let schedule = calc_vsc_powers([&converters[0], &converters[1]], rdc);
let (p1, p2, nominal) = if let Some(powers) = schedule {
powers
} else {
if !blocked {
warnings.push(
&codes::READ_PSSE_VALUE_SUBSTITUTED,
format!(
"VSC DC line record {record} states converter types {} and {} with DC setpoints {} and {}; a line needs one MW-controlling (TYPE 2) and one DC voltage-controlling (TYPE 1) converter at a positive voltage, so both ends read as zero",
converters[0].kind, converters[1].kind, converters[0].dcset, converters[1].dcset
),
);
}
(0.0, 0.0, 0.0)
};
let q = [
calc_vsc_reactive_power(&converters[0], p1),
calc_vsc_reactive_power(&converters[1], p2),
];
let converter = |side: usize, c: &VscConverter| -> Result<HvdcConverter> {
let regulated = BusId(if c.vsreg == 0 { c.bus } else { c.vsreg });
let base_kv = bus_base_kv.get(®ulated).copied().filter(|kv| *kv > 0.0);
let local = if name.is_empty() {
format!("vsc{record}:{side}")
} else {
format!("{name}:{side}")
};
Ok(HvdcConverter {
component: powerio_core::ComponentId::new("hvdc_converter", local).map_err(
|error| Error::FormatRead {
format: FMT,
message: error.to_string(),
},
)?,
kind: HvdcConverterKind::Vsc,
loss_factor_percent: 0.0,
voltage_regulator_on: Some(c.mode == 1),
voltage_setpoint_kv: (c.mode == 1)
.then(|| base_kv.map(|kv| c.acset * kv))
.flatten(),
reactive_power_setpoint_mvar: None,
power_factor: (c.mode == 2).then_some(c.acset),
regulating_terminal: None,
})
};
let mut extras = Extras::new();
if name != format!("VSC{record}") {
extras.insert("psse_vsc_name".into(), Value::String(name.clone()));
}
if !(0..=1).contains(&mdc) {
extras.insert("psse_vsc_mdc".into(), Value::from(mdc));
}
let owners = l1.len()
- l1.iter()
.skip(3)
.rev()
.take_while(|token| token.is_empty())
.count();
if owners > 3 {
extras.insert("psse_vsc_owners".into(), tail_array(&l1[..owners], 3));
}
for (side, c) in converters.iter().enumerate() {
extras.insert(vsc_converter_key(side + 1), c.to_extra());
}
let limit = converters
.iter()
.map(|c| c.smax)
.filter(|s| *s > 0.0)
.fold(f64::INFINITY, f64::min);
let pmax = if limit.is_finite() {
limit
} else {
p1.abs().max(p2.abs())
};
let voltage = |c: &VscConverter| if c.mode == 1 { c.acset } else { 1.0 };
Ok(Hvdc {
from: BusId(converters[0].bus),
to: BusId(converters[1].bus),
in_service: !blocked,
pf: -p1,
pt: p2,
qf: q[0],
qt: q[1],
vf: voltage(&converters[0]),
vt: voltage(&converters[1]),
pmin: -pmax,
pmax,
qminf: converters[0].minq,
qmaxf: converters[0].maxq,
qmint: converters[1].minq,
qmaxt: converters[1].maxq,
loss0: 0.0,
loss1: 0.0,
resistance_ohm: Some(rdc),
nominal_voltage_kv: (nominal > 0.0).then_some(nominal),
converters_mode: Some(if p1 <= 0.0 {
HvdcConvertersMode::Side1RectifierSide2Inverter
} else {
HvdcConvertersMode::Side1InverterSide2Rectifier
}),
converter1: Some(converter(1, &converters[0])?),
converter2: Some(converter(2, &converters[1])?),
cost: None,
uid: None,
extras,
})
}
fn tail_is_default(f: &[Cow<'_, str>], start: usize, default: &str) -> bool {
let defaults = default
.split(", ")
.map(|t| t.trim_matches('\''))
.collect::<Vec<_>>();
let mut tail = f.iter().skip(start).map(Cow::as_ref).collect::<Vec<_>>();
if tail.len() == defaults.len() + 1
&& default == DEFAULT_CONVERTER_TAIL
&& tail[CONVERTER_TAIL_NODE_INDEX] == "0"
{
tail.remove(CONVERTER_TAIL_NODE_INDEX);
}
tail == defaults
}
fn converter_tail_fields<'a>(
line: &[Cow<'a, str>],
raw_rev: u32,
end: &str,
warnings: &mut Diagnostics,
) -> Vec<Cow<'a, str>> {
let mut defaults = DEFAULT_CONVERTER_TAIL
.split(", ")
.map(|field| field.trim_matches('\''))
.collect::<Vec<_>>();
if let Some(index) = converter_tail_node_index(raw_rev) {
defaults.insert(index, "0");
}
let mut tail = line.get(1..).unwrap_or_default().to_vec();
let past_end = tail
.iter()
.skip(defaults.len())
.filter(|field| !field.trim().is_empty())
.count();
if past_end > 0 {
warnings.push(
&codes::READ_PSSE_FIELD_DROPPED,
format!(
"PSS/E two-terminal DC {end} line at bus {} states {past_end} field(s) past its last column; ignored",
line.first().map_or("", |bus| bus.as_ref())
),
);
}
tail.truncate(defaults.len());
for (field, default) in tail.iter_mut().zip(&defaults) {
if field.trim().is_empty() {
*field = Cow::Borrowed(*default);
}
}
let stated = tail.len();
tail.extend(
defaults[stated..]
.iter()
.map(|default| Cow::Borrowed(*default)),
);
if raw_rev == 34 {
let node = tail.remove(CONVERTER_TAIL_FIELDS_33);
tail.insert(CONVERTER_TAIL_NODE_INDEX, node);
}
tail
}
fn tail_array(f: &[Cow<'_, str>], start: usize) -> Value {
Value::Array(
f.iter()
.skip(start)
.map(|s| Value::String(s.to_string()))
.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 psse_ownership(extras: &Extras) -> [(i64, f64); 4] {
[
(
extra_i64(extras, "psse_o1").unwrap_or(1),
extra_f64(extras, "psse_f1").unwrap_or(1.0),
),
(
extra_i64(extras, "psse_o2").unwrap_or(0),
extra_f64(extras, "psse_f2").unwrap_or(1.0),
),
(
extra_i64(extras, "psse_o3").unwrap_or(0),
extra_f64(extras, "psse_f3").unwrap_or(1.0),
),
(
extra_i64(extras, "psse_o4").unwrap_or(0),
extra_f64(extras, "psse_f4").unwrap_or(1.0),
),
]
}
fn retain_psse_ownership(extras: &mut Extras, fields: &[Cow<'_, str>], start: usize) -> Result<()> {
for (offset, key, default) in [
(0, "psse_o1", 1),
(2, "psse_o2", 0),
(4, "psse_o3", 0),
(6, "psse_o4", 0),
] {
retain_integer_extra(extras, fields, start + offset, key, default)?;
}
for (offset, key) in [
(1, "psse_f1"),
(3, "psse_f2"),
(5, "psse_f3"),
(7, "psse_f4"),
] {
retain_float_extra(extras, fields, start + offset, key, 1.0)?;
}
Ok(())
}
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 Diagnostics) -> Option<i64> {
let Some(LoadVoltageModel::Zip {
scaling: Some(scaling),
..
}) = &l.voltage_model
else {
return None;
};
let scaling = *scaling;
if !scaling.is_finite() {
warnings.push(&codes::READ_PSSE_VALUE_SUBSTITUTED, 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(&codes::READ_PSSE_VALUE_SUBSTITUTED, 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 Diagnostics,
) -> (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(&F.field_dropped, 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(
&F.value_substituted,
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(&F.field_dropped, 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 (pdgen, qdgen) = load_distributed_generation(&l.extras);
let pl = extra_f64(&l.extras, "psse_pl").map_or(l.p, |pl| pl - pdgen);
let ql = extra_f64(&l.extras, "psse_ql").map_or(l.q, |ql| ql - qdgen);
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").map_or(0.0, psse_yq_consumption);
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(
&F.value_substituted,
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)
.map(|f| sanitize_quoted(f, BARE_FORBIDDEN, BARE_REPLACEMENT).into_owned())
.collect::<Vec<_>>()
.join(", "),
_ => default.to_string(),
}
}
#[cfg(test)]
mod tests {
fn parse_psse(content: &str) -> Result<BalancedNetwork> {
let mut warnings = Diagnostics::new();
parse_psse_source(content, None, &mut warnings)
}
use super::*;
use crate::diagnostics::Diagnostic;
fn close(actual: f64, expected: f64) {
assert!((actual - expected).abs() < 1e-12, "{actual} != {expected}");
}
#[test]
fn rejects_malformed_fractional_and_unsupported_header_revisions() {
assert_eq!(header_rev("0, 100.00\nCASE\nCOMMENT\nQ\n").unwrap(), 33);
assert_eq!(
header_rev("0, 100.00, , 0, 0, 60.00\nCASE\nCOMMENT\nQ\n").unwrap(),
33
);
for revision in ["not-a-revision", "NaN", "34.5", "31", "36"] {
let raw = format!(
"0, 100.00, {revision}, 0, 0, 60.00 / revision check\n\
CASE\nCOMMENT\n\
1,'B1 ',230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9\n\
0 / END OF BUS DATA, BEGIN LOAD DATA\nQ\n"
);
let error = parse_psse(&raw).unwrap_err().to_string();
assert!(
error.contains("expected integral 32, 33, 34, or 35"),
"REV {revision:?} returned the wrong error: {error}"
);
}
assert_eq!(
header_rev("0, 100.00, 32, 0, 0, 60.00\nCASE\nCOMMENT\nQ\n").unwrap(),
32
);
}
#[test]
fn revision32_layouts_read_and_short_records_are_reported() {
let raw = "0, 100.00, 32, 0, 0, 60.00 / revision 32 layouts\n\
CASE\nCOMMENT\n\
1,'B1 ',230.0,3,1,1,1,1.02,0.0\n\
2,'B2 ',115.0,1,1,1,1,1.0,-1.5\n\
3,'B3 ',115.0,1,1,1,1\n\
0 / END OF BUS DATA, BEGIN LOAD DATA\n\
2,'1 ',1,1,1,15.0,5.0,0.0,0.0,0.0,0.0,1,1\n\
3,'1 ',1,1,1,10.0\n\
0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA\n\
0 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA\n\
1,'1 ',30.0,0.0,20.0,-20.0,1.02,0,100.0,0.0,1.0,0.0,0.0,1.0,1,100.0,80.0,0.0\n\
0 / END OF GENERATOR DATA, BEGIN BRANCH DATA\n\
2,3,'1 ',0.01,0.05,0.02,100.0,0.0,0.0,0.0,0.0,0.0,0.0,1,1,0.0\n\
0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA\n\
1,2,0,'1 ',1,1,1,0.0,0.0,2,'T1 ',1,1,1.0\n\
0.0,0.1,100.0\n\
0.98,0.0,0.0,50.0,0.0,0.0,0,0,1.5,0.51,1.5,0.51,10,0,0.0,0.0\n\
1.0,0.0\n\
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA\n\
0 / END OF AREA DATA, BEGIN TWO-TERMINAL DC DATA\n\
0 / END OF TWO-TERMINAL DC DATA, BEGIN VOLTAGE SOURCE CONVERTER DATA\n\
0 / END OF VOLTAGE SOURCE CONVERTER DATA, BEGIN IMPEDANCE CORRECTION DATA\n\
0 / END OF IMPEDANCE CORRECTION DATA, BEGIN MULTI-TERMINAL DC DATA\n\
0 / END OF MULTI-TERMINAL DC DATA, BEGIN MULTI-SECTION LINE DATA\n\
0 / END OF MULTI-SECTION LINE DATA, BEGIN ZONE DATA\n\
1,'ZONE 1 '\n\
0 / END OF ZONE DATA, BEGIN INTER-AREA TRANSFER DATA\n\
0 / END OF INTER-AREA TRANSFER DATA, BEGIN OWNER DATA\n\
0 / END OF OWNER DATA, BEGIN FACTS CONTROL DEVICE DATA\n\
0 / END OF FACTS CONTROL DEVICE DATA, BEGIN SWITCHED SHUNT DATA\n\
0 / END OF SWITCHED SHUNT DATA, BEGIN GNE DEVICE DATA\n\
0 / END OF GNE DEVICE DATA\nQ\n";
let mut warnings = Diagnostics::new();
let net = parse_psse_source(raw, None, &mut warnings).unwrap();
assert_eq!(net.buses().len(), 3);
let bus1 = &net.buses()[0];
close(bus1.vm, 1.02);
close(bus1.vmax, 1.1);
close(bus1.vmin, 0.9);
assert_eq!(bus1.evhi, None);
let bus3 = &net.buses()[2];
close(bus3.vm, 1.0);
close(bus3.va, 0.0);
assert_eq!(net.loads().len(), 2);
close(net.loads()[0].p, 15.0);
assert!(!net.loads()[0].extras.contains_key("psse_intrpt"));
close(net.loads()[1].p, 10.0);
close(net.loads()[1].q, 0.0);
let transformer = net
.branches()
.iter()
.find(|branch| branch.is_transformer())
.unwrap();
close(transformer.calc_effective_tap(), 0.98);
assert!(!transformer.extras.contains_key("psse_vecgrp"));
let control = transformer.control.as_ref().unwrap();
close(control.tap_max, 1.5);
assert_eq!(control.ntp, 10);
assert_eq!(control.winding_connection_angle, None);
let short: Vec<&Diagnostic> = warnings
.records()
.iter()
.filter(|diagnostic| diagnostic.code() == "READ.PSSE.VALUE_DEFAULTED")
.collect();
assert_eq!(short.len(), 2, "{:?}", warnings.lines());
assert!(
short[0]
.message()
.contains("BUS DATA record beginning \"3\" has 7 field(s)")
);
assert!(short[0].message().contains("reads 9 fields through VA"));
assert!(
short[1]
.message()
.contains("LOAD DATA record beginning \"3\" has 6 field(s)")
);
assert!(short[1].message().contains("reads 13 fields through SCALE"));
assert!(
short.iter().all(|diagnostic| diagnostic.spans().is_empty()),
"an unlocated collector attaches no span"
);
let unmodeled = warnings
.lines()
.into_iter()
.find(|line| line.contains("ZONE section"))
.expect("the zone record is reported");
assert!(
unmodeled.contains("fresh output uses revision 33 or later"),
"{unmodeled}"
);
}
#[test]
fn rejects_invalid_system_bases_and_nonfinite_record_fields() {
let raw = |sbase: &str, frequency: &str, ide: &str, vm: &str| {
format!(
"0, {sbase}, 35, 0, 0, {frequency} / validation\n\
CASE\nCOMMENT\n\
1,'B1 ',230.0,{ide},1,1,1,{vm},0.0,1.1,0.9,1.1,0.9\n\
0 / END OF BUS DATA, BEGIN LOAD DATA\nQ\n"
)
};
for sbase in ["NaN", "inf", "0", "-100"] {
let error = parse_psse(&raw(sbase, "60", "3", "1.0"))
.unwrap_err()
.to_string();
assert!(error.contains("SBASE"), "bad SBASE {sbase}: {error}");
}
for frequency in ["NaN", "inf", "0", "-60", "not-a-frequency"] {
let error = parse_psse(&raw("100", frequency, "3", "1.0"))
.unwrap_err()
.to_string();
assert!(error.contains("BASFRQ"), "bad BASFRQ {frequency}: {error}");
}
for (ide, vm) in [("NaN", "1.0"), ("3", "NaN"), ("3", "inf")] {
let error = parse_psse(&raw("100", "60", ide, vm))
.unwrap_err()
.to_string();
assert!(
error.contains("not a finite number"),
"IDE={ide}, VM={vm}: {error}"
);
}
for token in ["NaN", "inf", "-inf"] {
let fields = [Cow::Borrowed(token)];
assert!(on_at(&fields, 0, true).is_err());
assert!(int_at(&fields, 0, 0).is_err());
}
}
#[test]
fn refuses_unsupported_output_revisions_and_invalid_output_base() {
let mut net = BalancedNetwork::in_memory(
"revision",
100.0,
vec![test_bus(1, BusType::Ref)],
Vec::new(),
);
for rev in [0, 32, 36, u32::MAX] {
let error =
crate::format::emit_value_text(&net, crate::format::TargetFormat::Psse { rev })
.unwrap_err()
.to_string();
assert!(
error.contains("only revisions 33, 34, and 35"),
"revision {rev}: {error}"
);
}
*net.base_mva_mut() = f64::NAN;
assert!(
crate::format::emit_value_text(&net, crate::format::TargetFormat::Psse { rev: 35 },)
.is_err()
);
assert!(
crate::format::emit_value_text(&net, crate::format::TargetFormat::PsseRawx).is_err()
);
}
#[test]
fn fields_tokenize_the_same_record_the_same_way_in_both_delimiter_styles() {
assert_eq!(
fields("1, ' 1', 'BUS A ', 2.5 / trailing comment"),
vec!["1", "1", "BUS A", "2.5"]
);
assert_eq!(
fields("1 ' 1' 'BUS A ' 2.5 / trailing comment"),
vec!["1", "1", "BUS A", "2.5"]
);
assert_eq!(fields("1, 'O/H LINE', 2"), vec!["1", "O/H LINE", "2"]);
assert_eq!(fields("1 2, 3"), vec!["1", "2", "3"]);
let generator = fields(
"30,'1 ',250,83.211,800,-500,1.0475,0,100,0,1.00000 0.00000,0,1,1,100,9999.9,0,1,1",
);
assert_eq!(generator[16], "9999.9");
assert_eq!(generator[17], "0");
assert_eq!(
fields("1, '', 3"),
vec!["1", "", "3"],
"column position holds"
);
assert_eq!(
fields("1 '' 3"),
vec!["1", "", "3"],
"and holds under whitespace too"
);
}
#[test]
fn fields_accept_double_quoted_strings() {
assert_eq!(
fields(r#"1, "BUS A, N/S ", 2.5 / trailing comment"#),
vec!["1", "BUS A, N/S", "2.5"]
);
assert_eq!(
fields(r#"1 "BUS A, N/S " 2.5 / trailing comment"#),
vec!["1", "BUS A, N/S", "2.5"]
);
assert_eq!(
fields(r#"RATING, 1, "RATE1 ", "RATING SET 1 ""#),
vec!["RATING", "1", "RATE1", "RATING SET 1"]
);
let facts = fields(
r#"'FACTS 1 ',3,0,1,0.0,0.0,1.0,9999.0,9999.0,0.9,1.1,1.0,0.0,0.05,100.0,1,0.0,0.0,0,0,0," ""#,
);
assert_eq!(facts.len(), 22);
assert_eq!(facts[0], "FACTS 1");
assert_eq!(facts[21], "");
assert_eq!(fields(r#"1 "" 3"#), vec!["1", "", "3"]);
assert_eq!(fields(r#"1, '12" PIPE', 2"#), vec!["1", r#"12" PIPE"#, "2"]);
assert_eq!(
fields(r#"1, "OWNER'S TIE", 2"#),
vec!["1", "OWNER'S TIE", "2"]
);
assert_eq!(
fields(r#"1, '12" A/B' / comment with "quotes""#),
vec!["1", r#"12" A/B"#]
);
}
#[test]
fn reads_double_quoted_string_fields() {
let raw = r#"0, 100.00, 35, 0, 1, 60.00 / synthetic
CASE
COMMENT
GENERAL, THRSHZ=0.0001
RATING, 1, "RATE1 ", "RATING SET 1 "
RATING, 2, "RATE2 ", "RATING SET 2 "
0 / END OF SYSTEM-WIDE DATA, BEGIN BUS DATA
1,"BUS A, N/S ", 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
2,'12" PIPE ', 115.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
3,"OWNER'S TIE ", 69.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,3.0,0.0,0.0,0.0,0.0,1,1,0,0.0,0.0,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,"LINE A-B, N/S",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,1,1.0
0 / END OF BRANCH DATA, BEGIN FACTS DEVICE DATA
"FACTS 1 ",3,0,1,0.0,0.0,1.0,9999.0,9999.0,0.9,1.1,1.0,0.0,0.05,100.0,1,0.0,0.0,0,0,0," "
0 / END OF FACTS DEVICE DATA, BEGIN SWITCHED SHUNT DATA
Q
"#;
let mut warnings = Diagnostics::new();
let net = parse_psse_source(raw, None, &mut warnings).unwrap();
let names = net
.buses()
.iter()
.map(|bus| bus.name.as_deref())
.collect::<Vec<_>>();
assert_eq!(
names,
[Some("BUS A, N/S"), Some(r#"12" PIPE"#), Some("OWNER'S TIE")]
);
close(net.buses()[0].base_kv, 230.0);
close(net.buses()[1].base_kv, 115.0);
close(net.buses()[2].base_kv, 69.0);
close(net.loads()[0].p, 10.0);
close(net.loads()[0].q, 3.0);
let branch = &net.branches()[0];
assert_eq!(branch.name.as_deref(), Some("LINE A-B, N/S"));
close(branch.rate_a, 100.0);
close(branch.rate_c, 80.0);
assert!(branch.in_service);
close(
net.solver()
.as_ref()
.and_then(|solver| solver.zero_impedance_threshold)
.unwrap(),
0.0001,
);
assert_eq!(net.static_var_compensators().len(), 1);
let svc = &net.static_var_compensators()[0];
assert_eq!(svc.bus, BusId(3));
assert_eq!(svc.extras["psse_name"], Value::from("FACTS 1"));
close(svc.voltage_setpoint_kv, 69.0);
close(svc.b_max_siemens, 9999.0 / (69.0 * 69.0));
let again = parse_psse(&write_psse_rev(&net, 35).text).unwrap();
assert_eq!(again.buses()[1].name.as_deref(), Some(r#"12" PIPE"#));
close(again.buses()[0].base_kv, 230.0);
}
#[test]
fn extreme_transformer_cod_does_not_overflow() {
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,-1e300,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();
let control = parsed.network.branches()[0].control.as_ref().unwrap();
assert_eq!(control.mode, TransformerControlMode::Fixed);
assert!(
parsed
.render_diagnostics()
.iter()
.any(|diagnostic| diagnostic.contains("unsupported COD=")),
"unknown COD must be diagnosed: {:?}",
parsed.render_diagnostics()
);
}
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,
location: None,
extras: Extras::default(),
}
}
fn branch_with_terminal_charging() -> Branch {
Branch {
name: None,
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,
route: None,
extras: Extras::default(),
}
}
fn transformer_with_terminal_charging(charging: BranchCharging) -> Branch {
Branch {
name: None,
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,
route: None,
extras: Extras::default(),
}
}
fn assert_terminal_charging_round_trip(text: &str) {
let back = parse_psse(text).unwrap();
let charging = back.branches()[0].calc_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 = BalancedNetwork::in_memory(
"terminal-shunts",
100.0,
vec![test_bus(1, BusType::Ref), test_bus(2, BusType::Pq)],
Vec::new(),
);
net.branches_mut().push(branch_with_terminal_charging());
let rev33 = write_psse(&net);
assert!(
rev33.render_diagnostics().is_empty(),
"{:?}",
rev33.render_diagnostics()
);
assert_terminal_charging_round_trip(&rev33.text);
let rev35 = write_psse_rev(&net, 35);
assert!(
rev35.render_diagnostics().is_empty(),
"{:?}",
rev35.render_diagnostics()
);
assert_terminal_charging_round_trip(&rev35.text);
}
#[test]
fn transformer_magnetizing_admittance_writes_mag1_mag2() {
let mut net = BalancedNetwork::in_memory(
"xfmr-mag",
100.0,
vec![test_bus(1, BusType::Ref), test_bus(2, BusType::Pq)],
Vec::new(),
);
net.branches_mut()
.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
.render_diagnostics()
.iter()
.any(|w| w.contains("magnetizing admittance")),
"{:?}",
conv.render_diagnostics()
);
let main = conv
.text
.lines()
.find(|line| line.starts_with("1, 2, 0, '1'"))
.unwrap();
let main_fields = fields(main);
close(main_fields[7].parse().unwrap(), 0.01 / (1.05 * 1.05));
close(main_fields[8].parse().unwrap(), 0.02 / (1.05 * 1.05));
let back = parse_psse(&conv.text).unwrap();
let charging = back.branches()[0].calc_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);
let rev35 = write_psse_rev(&net, 35).text;
assert!(rev35.contains(
"0 / END OF BRANCH DATA, BEGIN SYSTEM SWITCHING DEVICE DATA\n\
0 / END OF SYSTEM SWITCHING DEVICE DATA, BEGIN TRANSFORMER DATA"
));
let main = rev35
.lines()
.find(|line| line.starts_with("1, 2, 0, '1'"))
.unwrap();
let main_fields = fields(main);
assert_eq!(main_fields.len(), 22, "rev35 transformer row: {main:?}");
assert_eq!(main_fields[21], "0", "ZCOD must be an integer");
}
#[test]
fn transformer_to_side_terminal_admittance_warns_and_collapses_to_mag() {
let mut net = BalancedNetwork::in_memory(
"xfmr-mag-collapse",
100.0,
vec![test_bus(1, BusType::Ref), test_bus(2, BusType::Pq)],
Vec::new(),
);
net.branches_mut()
.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.render_diagnostics()
.iter()
.any(|w| w.contains("magnetizing admittance")),
"{:?}",
conv.render_diagnostics()
);
let back = parse_psse(&conv.text).unwrap();
let charging = back.branches()[0].calc_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 = Diagnostics::new();
let net = parse_psse_source(raw, None, &mut warnings).unwrap();
assert_eq!(net.loads().len(), 1);
close(net.loads()[0].p, 9.0);
close(net.loads()[0].q, 0.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, 6.0);
close(*q_constant_current, 0.5);
close(*p_constant_impedance, 2.0);
assert!(
warnings
.lines()
.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, 9.0);
close(net2.loads()[0].q, 0.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
.render_diagnostics()
.iter()
.any(|w| w.contains("voltage dependent load model")),
"missing MATPOWER voltage model warning: {:?}",
matpower.render_diagnostics()
);
}
#[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_mut()[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_mut()[0].extras.remove("psse_scal");
net.loads_mut()[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.render_diagnostics()
.iter()
.any(|w| w.contains("nominal voltage")),
"missing nominal voltage warning: {:?}",
conv.render_diagnostics()
);
let rev33 = write_psse(&net);
assert!(
rev33
.render_diagnostics()
.iter()
.any(|w| w.contains("load type requires revision 35")),
"missing rev33 load type warning: {:?}",
rev33.render_diagnostics()
);
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 load_yq_sign_and_distributed_generation_read_as_psse_defines_them() {
let records = [
"2,'L1',1,1,1,10.1,3.3,1.0,0.5,2.0,-1.7,1,1,0,4.3,1.1,1,''",
"2,'L2',1,1,1,5.0,1.0,0.0,0.0,0.0,0.9,1,1,0,2.0,0.4,0,''",
"3,'L3',1,1,1,7.0,2.0,0.0,0.0,0.0,0.0,1,1,0,1.5,0.25,1,''",
];
let raw = format!(
"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
3,'BUS3 ', 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
Q
",
records.join("\n")
);
let net = parse_psse(&raw).unwrap();
let zip = |load: &Load| match &load.voltage_model {
Some(LoadVoltageModel::Zip {
p_constant_power,
q_constant_power,
q_constant_impedance,
..
}) => Some((*p_constant_power, *q_constant_power, *q_constant_impedance)),
_ => None,
};
let [l1, l2, l3] = [&net.loads()[0], &net.loads()[1], &net.loads()[2]];
close(l1.p, 10.1 - 4.3 + 1.0 + 2.0);
close(l1.q, 3.3 - 1.1 + 0.5 + 1.7);
let (p_power, q_power, q_impedance) = zip(l1).unwrap();
close(p_power, 10.1 - 4.3);
close(q_power, 3.3 - 1.1);
close(q_impedance, 1.7);
close(l2.p, 5.0);
close(l2.q, 1.0 - 0.9);
close(zip(l2).unwrap().2, -0.9);
close(l3.p, 7.0 - 1.5);
close(l3.q, 2.0 - 0.25);
assert_eq!(zip(l3), None);
let load_fields = |text: &str| {
text.lines()
.filter(|line| line.contains("'L"))
.map(|line| {
fields(line)[5..17]
.iter()
.map(|field| field.parse::<f64>().unwrap())
.collect::<Vec<_>>()
})
.collect::<Vec<_>>()
};
let stated = load_fields(&raw);
for revision in [34, 35] {
let emitted = write_psse_rev(&net, revision);
assert_eq!(load_fields(&emitted.text), stated, "revision {revision}");
let back = parse_psse(&emitted.text).unwrap();
assert_eq!(back.loads(), net.loads(), "revision {revision}");
}
let rawx = super::super::rawx::write_rawx(&net).unwrap();
let mut back =
super::super::rawx::parse_rawx_source(&rawx.text, None, &mut Diagnostics::new())
.unwrap();
for load in back.loads_mut() {
load.uid = None;
}
assert_eq!(back.loads(), net.loads(), "RAWX");
let rev33 = write_psse_rev(&net, 33);
let collapsed = rev33
.render_diagnostics()
.iter()
.filter(|line| {
line.contains("EMIT.PSSE.VALUE_COLLAPSED")
&& line.contains("distributed generation")
})
.count();
assert_eq!(collapsed, 2, "{:?}", rev33.render_diagnostics());
let back = parse_psse(&rev33.text).unwrap();
for (before, after) in net.loads().iter().zip(back.loads()) {
close(after.p, before.p);
close(after.q, before.q);
}
}
#[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_mut()[0].p = 20.0;
net.loads_mut()[0].q = 7.0;
let conv = write_psse_rev(&net, 35);
assert!(
conv.text.contains("24.0, 9.0, 0.0, 0.0, 0.0, 0.0"),
"typed p/q were not written as constant power: {}",
conv.text
);
assert!(
conv.render_diagnostics()
.iter()
.any(|w| w.contains("stale voltage model components")),
"missing stale voltage model warning: {:?}",
conv.render_diagnostics()
);
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
.render_diagnostics()
.iter()
.any(|w| w.contains("unsupported CZ") || w.contains("unsupported CW")),
"unexpected transformer base warning: {:?}",
parsed.render_diagnostics()
);
let br = &parsed.network.branches()[0];
close(br.r, 0.02);
close(br.x, 0.20);
close(br.tap, 1.05);
}
#[test]
#[allow(clippy::many_single_char_names)] fn a_winding_2_ratio_scales_the_impedance_behind_the_branch_tap() {
let record = |cw: u8, w1: &str, w2: &str| {
format!(
"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 ',{cw},1,1,0,0,1,'xf',1
0.01,0.10,100.0
{w1},0.0,100.0,90.0,80.0,1,2,1.15,0.95,1.05,0.95,33
{w2}
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA
Q
"
)
};
let cases = [
(1, "1.04, 0.0", "0.98, 0.0", 1.04, 0.98),
(2, "241.5, 0.0", "112.7, 0.0", 241.5 / 230.0, 112.7 / 115.0),
(
3,
"1.0, 240.0",
"0.96, 120.0",
240.0 / 230.0,
0.96 * 120.0 / 115.0,
),
];
for (cw, w1, w2, t1, t2) in cases {
let br = parse_psse(&record(cw, w1, w2)).unwrap().branches()[0].clone();
let z = (0.01_f64, 0.10_f64);
let y = |r: f64, x: f64| {
let d = r * r + x * x;
(r / d, -x / d)
};
let (g, b) = y(z.0, z.1);
let psse = [
(g / (t1 * t1), b / (t1 * t1)),
(-g / (t1 * t2), -b / (t1 * t2)),
(g / (t2 * t2), b / (t2 * t2)),
];
let t = br.tap;
let (g2, b2) = y(br.r, br.x);
let branch = [(g2 / (t * t), b2 / (t * t)), (-g2 / t, -b2 / t), (g2, b2)];
for (a, b) in psse.iter().zip(&branch) {
assert!(
(a.0 - b.0).abs() < 1e-9 && (a.1 - b.1).abs() < 1e-9,
"CW {cw}: {psse:?} vs {branch:?}"
);
}
assert!((br.tap - t1 / t2).abs() < 1e-12, "CW {cw}");
let control = br.control.as_ref().unwrap();
let range = |v: f64| match cw {
2 => v / 230.0 / t2,
3 => v * 240.0 / 230.0 / t2,
_ => v / t2,
};
assert!((control.tap_max - range(1.15)).abs() < 1e-12, "CW {cw}");
assert!((control.tap_min - range(0.95)).abs() < 1e-12, "CW {cw}");
let text = record(cw, w1, w2);
let source = powerio_core::Source::from_memory("case.raw", text.as_bytes().to_vec())
.unwrap()
.with_format(powerio_core::FormatId::new("psse").unwrap());
let module = crate::format::parse(source).unwrap();
let echo =
crate::format::emit_text(&module, crate::TargetFormat::Psse { rev: 33 }).unwrap();
assert_eq!(echo.text, text, "CW {cw}: the same format write echoes");
let written = write_psse(&parse_psse(&text).unwrap()).text;
let winding2 = written
.lines()
.skip_while(|line| !line.contains("BEGIN TRANSFORMER DATA"))
.nth(4)
.unwrap();
assert!(winding2.starts_with("1.0, "), "CW {cw}: {winding2}");
let back = parse_psse(&written).unwrap().branches()[0].clone();
let again = parse_psse(&write_psse(&parse_psse(&written).unwrap()).text)
.unwrap()
.branches()[0]
.clone();
for (a, b) in [
(back.r, br.r),
(back.x, br.x),
(back.tap, br.tap),
(again.r, br.r),
(again.x, br.x),
(again.tap, br.tap),
] {
assert!((a - b).abs() < 1e-12, "CW {cw}: {a} != {b}");
}
}
}
#[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
.render_diagnostics()
.iter()
.any(|w| w.contains("unsupported CZ") || w.contains("unsupported CW")),
"unexpected transformer base warning: {:?}",
parsed.render_diagnostics()
);
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
.render_diagnostics()
.iter()
.any(|w| w.contains("unsupported CZ") || w.contains("unsupported CW")),
"unexpected transformer base warning: {:?}",
parsed.render_diagnostics()
);
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,9,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,2,3,10.0,5.0,0,0,0,0,4,1,0,0,0,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,2,12.5,7,0.6,8,0.4,0,1,0,1
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_eq!(net.branches()[0].name.as_deref(), Some("named branch"));
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);
assert_eq!(net.buses()[1].extras["psse_owner"], Value::from(9));
assert_eq!(net.loads()[0].extras["psse_area"], Value::from(2));
assert_eq!(net.loads()[0].extras["psse_zone"], Value::from(3));
assert_eq!(net.loads()[0].extras["psse_owner"], Value::from(4));
assert_eq!(net.branches()[0].extras["psse_met"], Value::from(2));
close(net.branches()[0].extras["psse_len"].as_f64().unwrap(), 12.5);
assert_eq!(net.branches()[0].extras["psse_o1"], Value::from(7));
close(net.branches()[0].extras["psse_f1"].as_f64().unwrap(), 0.6);
assert_eq!(net.branches()[0].extras["psse_o2"], Value::from(8));
close(net.branches()[0].extras["psse_f2"].as_f64().unwrap(), 0.4);
let rev33 = write_psse_rev(&net, 33);
assert!(rev33.diagnostics.iter().any(|diagnostic| {
diagnostic.code() == "EMIT.PSSE.FIELD_DROPPED"
&& diagnostic
.message()
.contains("1 non-transformer branch name")
&& diagnostic.message().contains("revision 33")
}));
assert_eq!(parse_psse(&rev33.text).unwrap().branches()[0].name, None);
let mut energy_source = net.clone();
energy_source.generators_mut()[0].energy_source = GeneratorEnergySource::Wind;
let energy_source = write_psse_rev(&energy_source, 35);
assert!(energy_source.diagnostics.iter().any(|diagnostic| {
diagnostic.code() == "EMIT.PSSE.FIELD_DROPPED"
&& diagnostic.message().contains("generator energy source")
&& diagnostic.message().contains("wind=1")
}));
let written = write_psse_rev(&net, 34);
assert!(
!written
.render_diagnostics()
.iter()
.any(|w| w.contains("rating set")),
"v34 should carry RATE4-RATE12, got {:?}",
written.render_diagnostics()
);
let back = parse_psse(&written.text).unwrap();
assert_eq!(back.branches()[0].name.as_deref(), Some("named branch"));
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);
assert_eq!(back.buses()[1].extras["psse_owner"], Value::from(9));
assert_eq!(back.loads()[0].extras["psse_area"], Value::from(2));
assert_eq!(back.loads()[0].extras["psse_zone"], Value::from(3));
assert_eq!(back.loads()[0].extras["psse_owner"], Value::from(4));
assert_eq!(back.branches()[0].extras["psse_met"], Value::from(2));
close(
back.branches()[0].extras["psse_len"].as_f64().unwrap(),
12.5,
);
assert_eq!(back.branches()[0].extras["psse_o1"], Value::from(7));
close(back.branches()[0].extras["psse_f1"].as_f64().unwrap(), 0.6);
}
const V35_SWITCHES: &str = r"0, 100.00, 35, 0, 0, 60.00 / synthetic v35 switch
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 ',230.0,1,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
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 SYSTEM SWITCHING DEVICE DATA
1,2,'S1',0.0001,55,54,53,52,51,50,49,48,47,46,45,44,0,2,2,3,'tie switch'
2,3,'1',0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,'pair one'
1,3,'1',0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,'other pair'
2,3,'2',0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,'parallel'
0 / END OF SYSTEM SWITCHING DEVICE DATA, BEGIN TRANSFORMER DATA
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA
Q
";
fn drop_switch_fields(net: &mut BalancedNetwork) {
net.switches_mut()[0].current_rating = Some(2_000.0);
net.switches_mut()[0].pf = Some(10.0);
net.switches_mut()[0].qf = Some(2.0);
net.switches_mut()[0].pt = Some(-9.8);
net.switches_mut()[0].qt = Some(-1.9);
net.switches_mut()[0]
.extras
.insert("psse_rsetnam".into(), Value::from("RATESET"));
}
#[test]
fn revision_35_system_switches_round_trip() {
let mut parsed = parse_psse(V35_SWITCHES).unwrap();
assert_eq!(parsed.switches().len(), 4);
let source_ids = parsed
.switches()
.iter()
.map(|switch| switch.uid.as_deref().unwrap().to_owned())
.collect::<Vec<_>>();
assert_eq!(source_ids, ["1-2-S1", "2-3-1", "1-3-1", "2-3-2"]);
let switch = &parsed.switches()[0];
assert_eq!((switch.from, switch.to), (BusId(1), BusId(2)));
assert!(!switch.closed);
assert_eq!(switch.thermal_rating, Some(55.0));
assert_eq!(switch.extras["psse_ckt"], Value::from("S1"));
assert_eq!(switch.extras["psse_xpu"], jnum(0.0001));
assert_eq!(switch.extras["psse_rate2"], Value::from(54.0));
assert_eq!(switch.extras["psse_nstat"], Value::from(2));
assert_eq!(switch.extras["psse_met"], Value::from(2));
assert_eq!(switch.extras["psse_stype"], Value::from(3));
assert_eq!(switch.extras["psse_name"], Value::from("tie switch"));
drop_switch_fields(&mut parsed);
let emitted = write_psse_rev(&parsed, 35);
assert!(emitted.diagnostics.iter().any(|diagnostic| {
diagnostic.code() == "EMIT.PSSE.FIELD_DROPPED"
&& diagnostic.message().contains("switch current rating")
}));
assert!(emitted.diagnostics.iter().any(|diagnostic| {
diagnostic.code() == "EMIT.PSSE.FIELD_DROPPED"
&& diagnostic.message().contains("switch power flow result")
}));
assert!(emitted.diagnostics.iter().any(|diagnostic| {
diagnostic.code() == "EMIT.PSSE.FIELD_DROPPED"
&& diagnostic.message().contains("rating set name")
&& diagnostic.message().contains("RATE1-RATE12")
}));
let reparsed = parse_psse(&emitted.text).unwrap();
assert_eq!(
reparsed
.switches()
.iter()
.map(|switch| switch.uid.as_deref().unwrap())
.collect::<Vec<_>>(),
source_ids.iter().map(String::as_str).collect::<Vec<_>>()
);
let switch = &reparsed.switches()[0];
assert_eq!((switch.from, switch.to), (BusId(1), BusId(2)));
assert!(!switch.closed);
assert_eq!(switch.thermal_rating, Some(55.0));
assert_eq!(switch.extras["psse_rate12"], Value::from(44.0));
assert_eq!(switch.extras["psse_name"], Value::from("tie switch"));
}
#[test]
fn revision_34_writes_system_switches_and_reports_what_they_drop() {
let mut parsed = parse_psse(V35_SWITCHES).unwrap();
drop_switch_fields(&mut parsed);
let rev34 = write_psse_rev(&parsed, 34);
assert!(
rev34
.text
.contains("0 / END OF BRANCH DATA, BEGIN SYSTEM SWITCHING DEVICE DATA")
);
assert!(
!rev34
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code() == "EMIT.PSSE.RECORD_DROPPED"),
"{:?}",
rev34.render_diagnostics()
);
for (dropped, revision) in [
("switch current rating", ""),
("switch power flow result", ""),
("rating set name", "revision 34"),
] {
assert!(
rev34.diagnostics.iter().any(|diagnostic| {
diagnostic.code() == "EMIT.PSSE.FIELD_DROPPED"
&& diagnostic.message().contains(dropped)
&& diagnostic.message().contains(revision)
}),
"{dropped}: {:?}",
rev34.render_diagnostics()
);
}
let reparsed = parse_psse(&rev34.text).unwrap();
assert_eq!(reparsed.switches().len(), 4);
assert_eq!(
reparsed.switches()[0].extras["psse_name"],
Value::from("tie switch")
);
let rev33 = write_psse_rev(&parsed, 33);
assert!(rev33.diagnostics.iter().any(|diagnostic| {
diagnostic.code() == "EMIT.PSSE.RECORD_DROPPED"
&& diagnostic.message().contains("system switching device")
&& diagnostic.message().contains("revision 33")
}));
}
#[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, 1, 'T1 ', 1, 7, 0.6, 8, 0.4, 0, 1, 0, 1, 'YNd1'
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, 1.08, 0.92, 1.05, 0.98, 17, 9, 0.01, 0.02, 0.0, 0
1.0, 0.0
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA
1, 1, 0.0, 0.0, 'AREA '
Q
";
let mut warnings = Diagnostics::new();
let net = parse_psse_source(raw, None, &mut warnings).unwrap();
let findings = warnings.lines();
assert_eq!(
findings.len(),
1,
"a NOD of 0 names no regulating node: {findings:?}"
);
assert!(
findings[0].contains("READ.PSSE.REFERENCE_DROPPED")
&& findings[0].contains("table(s) 9"),
"{findings:?}"
);
assert_eq!(net.branches().len(), 1, "K = 0.00 is a 2-winding record");
assert_eq!(net.transformers_3w().as_slice(), []);
assert_eq!(
net.areas().len(),
1,
"the section after the transformer parsed"
);
let br = &net.branches()[0];
assert_eq!(br.name.as_deref(), Some("T1"));
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);
assert_eq!(br.extras["psse_nmetr"], Value::from(1));
assert_eq!(br.extras["psse_o1"], Value::from(7));
close(br.extras["psse_f1"].as_f64().unwrap(), 0.6);
assert_eq!(br.extras["psse_o2"], Value::from(8));
close(br.extras["psse_f2"].as_f64().unwrap(), 0.4);
assert_eq!(br.extras["psse_vecgrp"], Value::from("YNd1"));
assert_eq!(br.extras["psse_tab"], Value::from(9));
close(br.extras["psse_cr"].as_f64().unwrap(), 0.01);
close(br.extras["psse_cx"].as_f64().unwrap(), 0.02);
let emission = write_psse_rev(&net, 34);
assert!(
emission
.render_diagnostics()
.iter()
.any(|line| line.contains("transformer 1-2 TAB 9")),
"{:?}",
emission.render_diagnostics()
);
let back = parse_psse(&emission.text).unwrap();
assert_eq!(back.branches()[0].name.as_deref(), Some("T1"));
let br = &back.branches()[0];
assert_eq!(br.extras["psse_nmetr"], Value::from(1));
assert_eq!(br.extras["psse_o1"], Value::from(7));
close(br.extras["psse_f1"].as_f64().unwrap(), 0.6);
assert_eq!(br.extras["psse_vecgrp"], Value::from("YNd1"));
assert_eq!(br.extras.get("psse_tab"), None);
close(br.extras["psse_cr"].as_f64().unwrap(), 0.01);
close(br.extras["psse_cx"].as_f64().unwrap(), 0.02);
}
#[test]
fn v34_winding_lines_read_as_pss_e_34_writes_them() {
let raw = r"0, 100.00, 34, 0, 1, 60.00 / PSS(R)E-34 shaped
CASE
COMMENT
0 / END OF SYSTEM-WIDE DATA, BEGIN BUS DATA
1,'B1 ', 230.0000,3, 1, 1, 1,1.00000, 0.0000,1.10000,0.90000,1.10000,0.90000
2,'B2 ', 115.0000,1, 1, 1, 1,1.00000, 0.0000,1.10000,0.90000,1.10000,0.90000
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 SYSTEM SWITCHING DEVICE DATA
0 / END OF SYSTEM SWITCHING DEVICE DATA, BEGIN TRANSFORMER DATA
@! I, J, K,'CKT',CW,CZ,CM, MAG1, MAG2,NMETR, 'N A M E', STAT,O1, F1, O2, F2, O3, F3, O4, F4, 'VECGRP', ZCOD
@! R1-2, X1-2, SBASE1-2, R2-3, X2-3, SBASE2-3, R3-1, X3-1, SBASE3-1, VMSTAR, ANSTAR
@!WINDV1, NOMV1, ANG1, RATE1-1, RATE1-2, RATE1-3, RATE1-4, RATE1-5, RATE1-6, RATE1-7, RATE1-8, RATE1-9, RATE1-10, RATE1-11, RATE1-12,COD1,CONT1, RMA1, RMI1, VMA1, VMI1, NTP1,TAB1, CR1, CX1, CNXA1,NOD1
@!WINDV2, NOMV2
1, 2, 0,'1 ', 1, 1, 1, 0.00000E+00, 0.00000E+00,2,'VOLTAGE ',1, 1,1.0000, 0,1.0000, 0,1.0000, 0,1.0000,' '
0.00000E+00, 1.81000E-02, 100.00
0.97143, 0.000, 0.000, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 1, 2, 1.20000, 0.80000, 1.17000, 0.98000, 8, 0, 0.00000, 0.00000, 0.000, 0
1.00000, 0.000
1, 2, 0,'2 ', 1, 1, 1, 0.00000E+00, 0.00000E+00,2,'ANGLE ',1, 1,1.0000, 0,1.0000, 0,1.0000, 0,1.0000,' '
0.00000E+00, 5.00000E-02, 100.00
1.0, 230.0, 10.0, 200, 200, 200, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 0, 30.0, -30.0, 60.0, 40.0, 33, 1, 0.0, 0.0, 0.0, 7
1.0, 115.0
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA
Q
";
let mut warnings = Diagnostics::new();
let net = parse_psse_source(raw, None, &mut warnings).unwrap();
assert_eq!(net.branches().len(), 2);
let voltage = net.branches()[0].control.as_ref().unwrap();
assert_eq!(voltage.mode, TransformerControlMode::Voltage);
assert_eq!(voltage.controlled_bus, Some(BusId(2)));
close(voltage.tap_max, 1.2);
close(voltage.tap_min, 0.8);
close(voltage.band_max, 1.17);
close(voltage.band_min, 0.98);
assert_eq!(voltage.ntp, 8);
let angle = &net.branches()[1];
let control = angle.control.as_ref().unwrap();
assert_eq!(control.mode, TransformerControlMode::ActiveFlow);
close(control.tap_max, 30.0);
close(control.tap_min, -30.0);
close(control.band_max, 60.0);
close(control.band_min, 40.0);
assert_eq!(control.ntp, 33);
assert_eq!(control.winding_connection_angle, None, "CNXA is 0.0");
assert_eq!(angle.extras["psse_tab"], Value::from(1));
let lines = warnings.lines();
assert_eq!(lines.len(), 2, "{lines:?}");
assert!(
lines
.iter()
.all(|line| line.contains("READ.PSSE.REFERENCE_DROPPED")),
"{lines:?}"
);
assert!(
lines.iter().any(|line| line.contains("node 7 ")),
"{lines:?}"
);
assert!(
lines.iter().any(|line| line.contains("table(s) 1")),
"{lines:?}"
);
}
#[test]
fn winding_node_columns_and_magnetizing_admittance_agree_across_revisions() {
let case = |rev: u32, winding1: &str| {
format!(
"0, 100.00, {rev}, 0, 1, 60.00 / synthetic
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 ', 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 SYSTEM SWITCHING DEVICE DATA
0 / END OF SYSTEM SWITCHING DEVICE DATA, BEGIN TRANSFORMER DATA
1,2,0,'1 ',1,1,1,0.002,-0.015,2,'T12A ',1,1,1,0,1,0,1,0,1,' '
0.005,0.08,100
{winding1}
1.05,0.0
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA
Q
"
)
};
let ratings = "100,0,0,0,0,0,0,0,0,0,0,0";
let rev34 = case(
34,
&format!("1.02,0.0,0.0,{ratings},1,2,1.12,0.88,1.04,0.96,25,0,0,0,0,0"),
);
let rev35 = case(
35,
&format!("1.02,0.0,0.0,{ratings},1,2,0,1.12,0.88,1.04,0.96,25,0,0,0,0"),
);
let rawx = serde_json::json!({
"network": {
"caseid": {"fields": ["rev", "sbase", "basfrq"], "data": [35, 100, 60]},
"bus": {
"fields": ["ibus", "name", "baskv", "ide"],
"data": [[1, "B1", 230.0, 3], [2, "B2", 115.0, 1]]
},
"transformer": {
"fields": [
"ibus", "jbus", "kbus", "ckt", "cw", "cz", "cm", "mag1", "mag2", "name",
"r1_2", "x1_2", "sbase1_2", "windv1", "nomv1", "ang1", "wdg1rate1",
"cod1", "cont1", "node1", "rma1", "rmi1", "vma1", "vmi1", "ntp1",
"windv2", "nomv2"
],
"data": [[
1, 2, 0, "1", 1, 1, 1, 0.002, -0.015, "T12A",
0.005, 0.08, 100.0, 1.02, 0.0, 0.0, 100.0,
1, 2, 0, 1.12, 0.88, 1.04, 0.96, 25,
1.05, 0.0
]]
}
}
})
.to_string();
let mut nets = Vec::new();
for (label, net) in [
("RAW 34", parse_psse(&rev34)),
("RAW 35", parse_psse(&rev35)),
(
"RAWX",
super::super::rawx::parse_rawx_source(&rawx, None, &mut Diagnostics::new()),
),
] {
let net = net.unwrap_or_else(|error| panic!("{label}: {error}"));
let branch = &net.branches()[0];
let tap = branch.calc_effective_tap();
close(tap, 1.02 / 1.05);
let control = branch.control.as_ref().unwrap();
assert_eq!(control.mode, TransformerControlMode::Voltage, "{label}");
assert_eq!(control.controlled_bus, Some(BusId(2)), "{label}");
close(control.tap_max, 1.12 / 1.05);
close(control.tap_min, 0.88 / 1.05);
close(control.band_max, 1.04);
close(control.band_min, 0.96);
assert_eq!(control.ntp, 25, "{label}");
let charging = branch.calc_terminal_charging();
close(charging.g_fr / (tap * tap), 0.002);
close(charging.b_fr / (tap * tap), -0.015);
close(charging.g_to, 0.0);
close(charging.b_to, 0.0);
nets.push(net);
}
assert_eq!(nets[0].branches(), nets[1].branches());
let mut rawx_branch = nets[2].branches()[0].clone();
rawx_branch.uid = None;
assert_eq!(*nets[1].branches(), [rawx_branch]);
}
const V34_NODE_COLUMNS: &str = "0, 100.00, 34, 0, 1, 60.00 / v34 node columns
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 ', 115.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
3,'B3 ', 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
1,'1 ',100,0,100,-100,1.02,1,100,0,1,0,0,1,1,100,200,0,1,1,0,1,0,1,0,1,0,1,2
0 / END OF GENERATOR DATA, BEGIN BRANCH DATA
0 / END OF BRANCH DATA, BEGIN SYSTEM SWITCHING DEVICE DATA
0 / END OF SYSTEM SWITCHING DEVICE DATA, BEGIN TRANSFORMER DATA
1,2,0,'2 ',1,1,1,0,0,2,'T2 ',1,1,1,0,1,0,1,0,1,' '
0.01,0.10,100
1.0,0.0,0.0,100,0,0,0,0,0,0,0,0,0,0,0,1,1,1.12,0.88,1.05,0.95,25,0,0,0,0,2
1.0,0.0
1,2,3,'3 ',1,1,1,0,0,2,'T3 ',1,1,1,0,1,0,1,0,1,' '
0.01,0.10,100,0.02,0.20,100,0.015,0.25,100,1.0,0.0
1.0,0.0,0.0,100,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,0
1.0,0.0,0.0,100,0,0,0,0,0,0,0,0,0,0,0,1,1,1.08,0.92,1.04,0.96,17,0,0,0,0,2
1.0,0.0,0.0,50,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,0
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
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,1,0,1,1.05,0.95,1,100,'',80.0,1,10.0,1,10.0,1,10.0,1,10.0,1,10.0,1,10.0,1,10.0,1,10.0,2
0 / END OF SWITCHED SHUNT DATA, BEGIN GNE DATA
0 / END OF GNE DATA, BEGIN INDUCTION MACHINE DATA
0 / END OF INDUCTION MACHINE DATA, BEGIN SUBSTATION DATA
1, 'SUB1', 0.0, 0.0, 0.1
1, 'N1', 1, 1, 1.0, 0.0
2, 'N2', 1, 1, 1.0, 0.0
0 / END OF SUBSTATION NODE DATA, BEGIN SUBSTATION SWITCHING DEVICE DATA
1, 2, 'S1', 'BREAKER', 2, 1, 1, 0.0001, 100, 90, 80
0 / END OF SUBSTATION SWITCHING DEVICE DATA, BEGIN SUBSTATION EQUIPMENT TERMINAL DATA
1, 2, 'M', '1 '
0 / END OF SUBSTATION EQUIPMENT TERMINAL DATA
0 / END OF SUBSTATION DATA
Q
";
fn section_records(text: &str, name: &str) -> Vec<Vec<String>> {
let marker = format!("BEGIN {name} DATA");
text.lines()
.skip_while(|line| !line.contains(&marker))
.skip(1)
.take_while(|line| !line.starts_with("0 /"))
.map(|line| fields(line).iter().map(ToString::to_string).collect())
.collect()
}
fn regulating_winding_lines(text: &str) -> Vec<Vec<String>> {
section_records(text, "TRANSFORMER")
.into_iter()
.filter(|fields| fields.len() == 27 && fields[15] == "1")
.collect()
}
fn assert_v34_nodes_resolved(net: &BalancedNetwork) {
let expected = net.generators()[0].regulating_terminal.as_ref();
assert!(expected.is_some(), "generator NREG resolved");
let transformer = net.branches()[0].control.as_ref().unwrap();
assert_eq!(transformer.regulating_terminal.as_ref(), expected);
close(transformer.tap_max, 1.12);
assert_eq!(transformer.ntp, 25);
let winding = net.transformers_3w()[0].windings[1]
.control
.as_ref()
.unwrap();
assert_eq!(winding.regulating_terminal.as_ref(), expected);
close(winding.tap_max, 1.08);
assert_eq!(winding.ntp, 17);
let shunt = net.shunts()[0].control.as_ref().unwrap();
assert_eq!(shunt.regulating_terminal.as_ref(), expected);
assert_eq!(shunt.blocks.len(), 8, "eight step pairs, then NREG");
}
#[test]
fn v34_node_columns_follow_their_records_and_round_trip() {
let mut warnings = Diagnostics::new();
let net = parse_psse_source(V34_NODE_COLUMNS, None, &mut warnings).unwrap();
assert!(warnings.is_empty(), "{:?}", warnings.lines());
assert_v34_nodes_resolved(&net);
let rev34 = write_psse_rev(&net, 34);
assert!(
!rev34
.diagnostics
.iter()
.any(|diagnostic| diagnostic.message().contains("regulating node")),
"{:?}",
rev34.render_diagnostics()
);
let generator = §ion_records(&rev34.text, "GENERATOR")[0];
assert_eq!(generator.len(), 29, "{generator:?}");
assert_eq!(generator[28], "2", "NREG after WPF");
let shunt = §ion_records(&rev34.text, "SWITCHED SHUNT")[0];
assert_eq!(shunt.len(), 27, "{shunt:?}");
assert_eq!(shunt[26], "2", "NREG after the eighth step pair");
let windings = regulating_winding_lines(&rev34.text);
assert_eq!(windings.len(), 2, "the 2W and the 3W regulating windings");
for winding in &windings {
assert_eq!(winding[16], "1", "CONT");
assert_eq!(winding[26], "2", "NOD after CNXA");
}
assert_eq!(windings[0][17], "1.12", "RMA right after CONT");
assert_eq!(windings[1][17], "1.08", "RMA right after CONT");
let mut warnings = Diagnostics::new();
let back = parse_psse_source(&rev34.text, None, &mut warnings).unwrap();
assert!(warnings.is_empty(), "{:?}", warnings.lines());
assert_v34_nodes_resolved(&back);
let rev35 = write_psse_rev(&net, 35).text;
let windings = regulating_winding_lines(&rev35);
assert_eq!(windings.len(), 2);
for winding in &windings {
assert_eq!(winding[17], "2", "NODE after CONT");
}
assert_v34_nodes_resolved(&parse_psse(&rev35).unwrap());
let rev33 = write_psse_rev(&net, 33);
for device in ["generator", "switched shunt"] {
assert!(
rev33.diagnostics.iter().any(|diagnostic| {
diagnostic.code() == "EMIT.PSSE.FIELD_DROPPED"
&& diagnostic.message().contains(device)
&& diagnostic.message().contains("before revision 34")
}),
"{device}: {:?}",
rev33.render_diagnostics()
);
}
}
#[test]
fn v34_switched_shunt_states_eight_step_pairs_before_nreg() {
let mut net = parse_psse(V34_NODE_COLUMNS).unwrap();
net.shunts_mut()[0].control.as_mut().unwrap().blocks = (1..=10)
.map(|step| ShuntBlock {
steps: 1,
g: 0.0,
b: f64::from(step) * 10.0,
})
.collect();
for (resolved, fields) in [(true, 27), (false, 26)] {
if !resolved {
net.shunts_mut()[0]
.control
.as_mut()
.unwrap()
.regulating_terminal = None;
}
let rev34 = write_psse_rev(&net, 34);
assert!(
rev34.diagnostics.iter().any(|diagnostic| {
diagnostic.code() == "EMIT.PSSE.FIELD_DROPPED"
&& diagnostic
.message()
.contains("2 step block(s) after the eighth dropped")
}),
"{:?}",
rev34.render_diagnostics()
);
let shunt = §ion_records(&rev34.text, "SWITCHED SHUNT")[0];
assert_eq!(shunt.len(), fields, "{shunt:?}");
let mut warnings = Diagnostics::new();
let back = parse_psse_source(&rev34.text, None, &mut warnings).unwrap();
assert!(warnings.is_empty(), "{:?}", warnings.lines());
let control = back.shunts()[0].control.as_ref().unwrap();
assert_eq!(control.blocks.len(), 8);
close(control.blocks[7].b, 80.0);
assert_eq!(control.regulating_terminal.is_some(), resolved);
}
let rev35 = parse_psse(&write_psse_rev(&net, 35).text).unwrap();
assert_eq!(rev35.shunts()[0].control.as_ref().unwrap().blocks.len(), 10);
let long = V34_NODE_COLUMNS.replace("1,10.0,1,10.0,2\n", "1,10.0,1,10.0,2,0.0\n");
let mut warnings = Diagnostics::new();
let net = parse_psse_source(&long, None, &mut warnings).unwrap();
assert_v34_nodes_resolved(&net);
let lines = warnings.lines();
assert!(
lines.len() == 1 && lines[0].contains("the 1 field(s) after NREG were ignored"),
"{lines:?}"
);
}
#[test]
fn dc_converter_node_column_follows_each_revision() {
fn rectifier_columns(text: &str) -> Vec<String> {
section_records(text, "TWO-TERMINAL DC")[1][12..].to_vec()
}
let raw = "0, 100.00, 34, 0, 1, 60.00 / v34 dc
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
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 SYSTEM SWITCHING DEVICE DATA
0 / END OF SYSTEM SWITCHING DEVICE 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, 1, 4, 5, '1', 0.0, 3
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
0 / END OF TWO-TERMINAL DC DATA
Q
";
let net = parse_psse(raw).unwrap();
let tail = net.hvdc()[0].extras["psse_dc_rectifier_tail"]
.as_array()
.unwrap();
assert_eq!(tail[11..15], ["1", "3", "4", "5"], "ICR, NDR, IFR, ITR");
assert!(
!net.hvdc()[0].extras.contains_key("psse_dc_inverter_tail"),
"a default inverter line with node 0 is not retained"
);
let rev34 = write_psse_rev(&net, 34).text;
assert_eq!(rectifier_columns(&rev34), ["1", "4", "5", "1", "0.0", "3"]);
let inverter = §ion_records(&rev34, "TWO-TERMINAL DC")[2];
assert_eq!(inverter.len(), 18, "{inverter:?}");
assert_eq!(inverter[17], "0", "a synthesized v34 node is last");
let rev35 = write_psse_rev(&net, 35).text;
assert_eq!(rectifier_columns(&rev35), ["1", "3", "4", "5", "1", "0.0"]);
assert_eq!(
rectifier_columns(&write_psse_rev(&parse_psse(&rev35).unwrap(), 34).text),
["1", "4", "5", "1", "0.0", "3"],
"35 to 34 moves the node to the end"
);
let rev33 = write_psse_rev(&net, 33);
assert_eq!(rectifier_columns(&rev33.text), ["1", "4", "5", "1", "0.0"]);
assert!(
rev33.diagnostics.iter().any(|diagnostic| {
diagnostic.code() == "EMIT.PSSE.FIELD_DROPPED"
&& diagnostic.message().contains("rectifier states node 3")
}),
"{:?}",
rev33.render_diagnostics()
);
}
#[test]
fn dc_converter_lines_take_defaults_for_blank_and_omitted_fields() {
let raw = "0, 100.00, 35, 0, 1, 60.00 / dc defaults
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
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 SYSTEM SWITCHING DEVICE DATA
0 / END OF SYSTEM SWITCHING DEVICE DATA, BEGIN TRANSFORMER DATA
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA
0 / END OF AREA DATA, BEGIN TWO-TERMINAL DC DATA
'DC1', 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, 1, 3, 4, 5, 'A'
5, 1, 15.0, 5.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.5, 0.51, 0.00625, 1, , 4, 5, 'B', 0.0
'DC2', 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, 1, 0, 4, 5, 'C', 0.0, 9
5, 1, 15.0, 5.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.5, 0.51, 0.00625, 1, 0, 4, 5, 'D', 0.0
0 / END OF TWO-TERMINAL DC DATA
Q
";
let mut warnings = Diagnostics::new();
let net = parse_psse_source(raw, None, &mut warnings).unwrap();
let lines = warnings.lines();
assert!(
lines.len() == 1 && lines[0].contains("states 1 field(s) past its last column"),
"{lines:?}"
);
let rev34 = write_psse_rev(&net, 34).text;
let records = section_records(&rev34, "TWO-TERMINAL DC");
assert_eq!(records[1][12..], ["1", "4", "5", "A", "0.0", "3"]);
assert_eq!(records[2][12..], ["1", "4", "5", "B", "0.0", "0"]);
assert_eq!(records[4][12..], ["1", "4", "5", "C", "0.0", "0"]);
let rev33 = write_psse_rev(&net, 33);
let records = section_records(&rev33.text, "TWO-TERMINAL DC");
assert_eq!(records[1][12..], ["1", "4", "5", "A", "0.0"]);
assert_eq!(records[2][12..], ["1", "4", "5", "B", "0.0"]);
let node_drops = rev33
.diagnostics
.iter()
.filter(|diagnostic| diagnostic.message().contains("NDR/NDI"))
.map(|diagnostic| diagnostic.message().to_owned())
.collect::<Vec<_>>();
assert!(
node_drops.len() == 1 && node_drops[0].contains("rectifier states node 3"),
"{node_drops:?}"
);
let v33 = raw
.replace("0, 100.00, 35,", "0, 100.00, 33,")
.replace("0 / END OF SYSTEM-WIDE DATA, BEGIN BUS DATA\n", "")
.replace(
"0 / END OF BRANCH DATA, BEGIN SYSTEM SWITCHING DEVICE DATA\n0 / END OF SYSTEM SWITCHING DEVICE DATA, BEGIN TRANSFORMER DATA",
"0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA",
)
.replace("1, 3, 4, 5, 'A'\n", "1, 4, 5, 'A', 0.5,\n");
let net = parse_psse(&v33).unwrap();
let records = section_records(&write_psse_rev(&net, 34).text, "TWO-TERMINAL DC");
assert_eq!(records[1][12..], ["1", "4", "5", "A", "0.5", "0"]);
}
#[test]
fn v34_warns_when_custom_rating_name_is_emitted_as_rate_slot() {
let mut net = BalancedNetwork::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_mut().push(branch);
let written = write_psse_rev(&net, 34);
assert!(
written.render_diagnostics().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.render_diagnostics()
);
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);
assert_eq!(net.branches()[0].name, None);
let back = parse_psse(&write_psse_rev(&net, 35).text).unwrap();
assert_eq!(back.branches()[0].name, None);
}
#[test]
fn reads_whitespace_records_with_end_only_section_markers() {
let raw = r"0 100.00 33 0 0 60.00 / whitespace-delimited export
CASE
COMMENT
1 'BUS 1' 230.0 3 1 1 1 1.0 0.0 1.1 0.9 1.1 0.9
2 'BUS 2' 230.0 1 1 1 1 1.0 0.0 1.1 0.9 1.1 0.9
0 / END OF BUS DATA
2 'L1' 1 1 1 10.0 5.0 0 0 0 0 1 1 0
0 / END OF LOAD DATA
0 / END OF FIXED BUS SHUNT DATA
1 'G1' 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
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 NON TRANSFORMER BRANCH DATA
0 / END OF TRANSFORMER BRANCH 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 synth.loads_mut() {
l.extras.remove("id");
}
let out = write_psse(&synth).text;
assert!(out.contains("2, '1',") && out.contains("2, '2',"), "{out}");
let net3 = parse_psse(&out).unwrap();
let ids: Vec<_> = net3.loads().iter().filter_map(&id).collect();
assert_eq!(ids, vec!["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_mut()[0]
.extras
.insert("id".into(), Value::String("A/B".into()));
net.loads_mut()[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"]);
assert!(
conv.render_diagnostics()
.iter()
.any(|w| w.contains("2 quoted PSS/E field")),
"missing sanitation warning: {:?}",
conv.render_diagnostics()
);
}
#[test]
fn a_sanitized_id_that_trims_onto_another_id_is_allocated_apart() {
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_mut()[0]
.extras
.insert("id".into(), Value::String("a'".into()));
net.loads_mut()[1]
.extras
.insert("id".into(), Value::String("a".into()));
let conv = write_psse(&net);
assert!(conv.text.contains("2, 'a ',"), "{}", conv.text);
assert!(conv.text.contains("2, '1',"), "{}", conv.text);
let reparsed = parse_psse(&conv.text).unwrap();
let ids: Vec<_> = reparsed
.loads()
.iter()
.map(|l| {
l.extras
.get("id")
.and_then(Value::as_str)
.unwrap_or_default()
})
.collect();
assert_eq!(ids, vec!["a", ""]);
}
#[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());
let referred = 0.04 * 1.025 * 1.025;
close(net.branches()[0].b, referred);
close(net.branches()[0].calc_terminal_charging().b_fr, referred);
let written = write_psse(&net).text;
let main = written
.lines()
.find(|line| line.starts_with("1, 2, 0, '1'"))
.unwrap();
close(fields(main)[8].parse().unwrap(), 0.04);
let net2 = parse_psse(&written).unwrap();
close(net2.branches()[0].b, referred);
}
#[test]
fn cm2_transformer_magnetizing_data_is_converted_and_round_trips() {
let raw = r"0, 100.00, 35, 0, 1, 60.00 / synthetic
CASE
COMMENT
0 / END OF SYSTEM-WIDE DATA, BEGIN BUS DATA
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, 2, 100000, 0.2, 2, 'XF ', 1, 1, 1, 0, 1, 0, 1, 0, 1, ' '
0.01, 0.10, 50.0
1.025, 230.0, 0.0, 100.0, 90.0, 80.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
1.0, 138.0
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA
Q
";
let net = parse_psse(raw).unwrap();
let branch = &net.branches()[0];
let stated_g: f64 = 0.001;
let stated_b = -f64::sqrt(0.1_f64.powi(2) - stated_g.powi(2));
let referral = 1.025 * 1.025;
let charging = branch.calc_terminal_charging();
close(charging.g_fr, stated_g * referral);
close(charging.b_fr, stated_b * referral);
let written = write_psse_rev(&net, 35).text;
let main = written
.lines()
.find(|line| line.starts_with("1, 2, 0, '1'"))
.unwrap();
let main_fields = fields(main);
assert_eq!(main_fields[6], "1", "CM");
close(main_fields[7].parse().unwrap(), stated_g);
close(main_fields[8].parse().unwrap(), stated_b);
let back = parse_psse(&written).unwrap();
let back_charging = back.branches()[0].calc_terminal_charging();
close(back_charging.g_fr, stated_g * referral);
close(back_charging.b_fr, stated_b * referral);
}
#[test]
fn magnetizing_admittance_writes_back_as_the_source_states_it() {
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.0019, -0.0151, 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 tap_squared: f64 = 1.025 * 1.025;
assert!((0.0019 * tap_squared / tap_squared - 0.0019).abs() > 0.0);
let net = parse_psse(raw).unwrap();
let charging = net.branches()[0].calc_terminal_charging();
for rev in [33, 34, 35] {
let written = write_psse_rev(&net, rev).text;
let main = §ion_records(&written, "TRANSFORMER")[0];
assert_eq!(main[7..9], ["0.0019", "-0.0151"], "revision {rev}");
let back = parse_psse(&written).unwrap();
assert_eq!(
back.branches()[0].calc_terminal_charging(),
charging,
"revision {rev} reads back the same charging"
);
}
let rawx: Value =
serde_json::from_str(&crate::format::rawx::write_rawx(&net).unwrap().text).unwrap();
let table = &rawx["network"]["transformer"];
let column = |name: &str| {
let fields = table["fields"].as_array().unwrap();
let index = fields.iter().position(|field| field == name).unwrap();
table["data"][0][index].as_f64().unwrap()
};
assert_eq!((column("mag1"), column("mag2")), (0.0019, -0.0151));
}
#[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(), None);
assert_eq!(ckt(&net.branches()[1]).as_deref(), Some("2"));
let out = write_psse(&net).text;
assert!(
out.contains("1, 2, '1',") && out.contains("1, 2, '2',"),
"{out}"
);
let net2 = parse_psse(&out).unwrap();
assert_eq!(net2.branches().len(), 2);
assert_eq!(ckt(&net2.branches()[0]).as_deref(), None);
assert_eq!(ckt(&net2.branches()[1]).as_deref(), Some("2"));
}
#[test]
fn parallel_transformers_preserve_distinct_circuit_ids() {
let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic
CASE
COMMENT
1,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
2,'B2 ', 138.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,2,'XF1 ',1,1,1,0,1,0,1,0,1,' '
0.01,0.10,100.0
1.0,0,0,100,90,80,0,0,1.1,0.9,1.1,0.9,33,0,0,0,0
1.0,0
1,2,0,'2 ',1,1,1,0,0,2,'XF2 ',1,1,1,0,1,0,1,0,1,' '
0.02,0.20,100.0
1.0,0,0,100,90,80,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 circuit_id = |branch: &Branch| {
branch
.extras
.get("id")
.and_then(|value| value.as_str())
.map(str::to_owned)
};
let network = parse_psse(raw).unwrap();
assert_eq!(network.branches().len(), 2);
assert_eq!(circuit_id(&network.branches()[0]).as_deref(), None);
assert_eq!(circuit_id(&network.branches()[1]).as_deref(), Some("2"));
let output = write_psse(&network).text;
assert!(output.lines().any(|line| line.starts_with("1, 2, 0, '1',")));
assert!(output.lines().any(|line| line.starts_with("1, 2, 0, '2',")));
let reparsed = parse_psse(&output).unwrap();
assert_eq!(reparsed.branches().len(), 2);
assert_eq!(circuit_id(&reparsed.branches()[0]).as_deref(), None);
assert_eq!(circuit_id(&reparsed.branches()[1]).as_deref(), Some("2"));
}
#[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 system_wide_losses_and_invalid_values_are_diagnosed() {
let raw = r"0, 100.00, 35, 0, 1, 60.00 / x
CASE
COMMENT
GENERAL, THRSHZ=bad, EXTRA=1
GAUSS, ITMXG=20
NEWTON, TOLN=NaN, ITMXN=-1, EXTRA=2
SOLVER, ACTAPS=MAYBE, AREAIN=OFF, UNKNOWN=3
ADJUST, TOL=0.1
TYSL, ITMX=5
RATING, 1, 'RATE SET'
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 mut warnings = Diagnostics::new();
let network = parse_psse_source(raw, None, &mut warnings).unwrap();
let solver = network.solver().as_ref().unwrap();
assert_eq!(solver.adjust_area_interchange, Some(false));
assert_eq!(solver.zero_impedance_threshold, None);
assert_eq!(solver.newton_tolerance, None);
assert_eq!(solver.max_iterations, None);
assert_eq!(solver.adjust_taps, None);
let diagnostics = warnings.lines();
for record in ["GAUSS", "ADJUST", "TYSL", "RATING"] {
assert!(
diagnostics.iter().any(|line| {
line.contains("READ.PSSE.FIELD_DROPPED")
&& line.contains(&format!("system wide {record} record"))
}),
"missing diagnostic for {record}: {diagnostics:?}"
);
}
for field in ["GENERAL.EXTRA", "NEWTON.EXTRA", "SOLVER.UNKNOWN"] {
assert!(
diagnostics.iter().any(|line| {
line.contains("READ.PSSE.FIELD_DROPPED") && line.contains(field)
}),
"missing diagnostic for {field}: {diagnostics:?}"
);
}
for field in [
"GENERAL.THRSHZ",
"NEWTON.TOLN",
"NEWTON.ITMXN",
"SOLVER.ACTAPS",
] {
assert!(
diagnostics.iter().any(|line| {
line.contains("READ.PSSE.VALUE_SUBSTITUTED") && line.contains(field)
}),
"missing invalid value diagnostic for {field}: {diagnostics:?}"
);
}
}
#[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"));
assert_eq!(a.area_type.as_deref(), Some("ControlArea"));
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"));
assert_eq!(a2.area_type.as_deref(), Some("ControlArea"));
}
#[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::Continuous, "MODSW 2");
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::Continuous);
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 fresh_emission_preserves_the_psse_switched_shunt_control_code() {
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
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, 3, 0, 1, 1.0, 0.0, 0, 100.0, '', 0.0, 1, 10.0
0 / END OF SWITCHED SHUNT DATA, BEGIN GNE DEVICE DATA
Q
";
let net = parse_psse(raw).unwrap();
let shunt = &net.shunts()[0];
assert_eq!(
shunt.control.as_ref().unwrap().mode,
SwitchedShuntMode::Discrete
);
assert_eq!(extra_i64(&shunt.extras, "psse_modsw"), Some(3));
let text = write_psse(&net).text;
assert!(text.lines().any(|line| line.starts_with("3, 3, 0, 1,")));
let reparsed = parse_psse(&text).unwrap();
assert_eq!(
extra_i64(&reparsed.shunts()[0].extras, "psse_modsw"),
Some(3)
);
let mut changed = net;
changed.shunts_mut()[0].control.as_mut().unwrap().mode = SwitchedShuntMode::Continuous;
let changed_text = write_psse(&changed).text;
assert!(
changed_text
.lines()
.any(|line| line.starts_with("3, 2, 0, 1,"))
);
}
#[test]
fn switched_shunt_modsw_codes_read_as_psse_defines_them_and_write_back() {
let cases = [
(0, SwitchedShuntMode::Locked, None),
(1, SwitchedShuntMode::Discrete, None),
(2, SwitchedShuntMode::Continuous, None),
(3, SwitchedShuntMode::Discrete, Some(3)),
(4, SwitchedShuntMode::Discrete, Some(4)),
(5, SwitchedShuntMode::Discrete, Some(5)),
(6, SwitchedShuntMode::Discrete, Some(6)),
];
for source_rev in [33, 35] {
for (modsw, mode, retained) in cases {
let record = if source_rev >= 35 {
format!("3, 'S1', {modsw}, 0, 1, 1.05, 0.95, 0, 0, 100.0, '', 7.5, 1, 2, 10.0")
} else {
format!("3, {modsw}, 0, 1, 1.05, 0.95, 0, 100.0, '', 7.5, 2, 10.0")
};
let raw = format!(
"0, 100.00, {source_rev}, 0, 0, 60.00 / MODSW {modsw}\n\
CASE\n\
COMMENT\n\
3,'B3',230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9\n\
0 / END OF BUS DATA, BEGIN LOAD DATA\n\
0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA\n\
0 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA\n\
0 / END OF GENERATOR DATA, BEGIN BRANCH DATA\n\
0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA\n\
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA\n\
0 / END OF AREA DATA, BEGIN SWITCHED SHUNT DATA\n\
{record}\n\
0 / END OF SWITCHED SHUNT DATA, BEGIN GNE DEVICE DATA\n\
Q\n"
);
let context = format!("revision {source_rev} MODSW {modsw}");
let net = parse_psse(&raw).unwrap();
let shunt = &net.shunts()[0];
assert_eq!(shunt.control.as_ref().unwrap().mode, mode, "{context}");
assert_eq!(
extra_i64(&shunt.extras, "psse_modsw"),
retained,
"{context} retained code"
);
for rev in [33, 34, 35] {
let emitted = write_psse_rev(&net, rev).text;
let written = emitted
.lines()
.skip_while(|line| !line.contains("BEGIN SWITCHED SHUNT DATA"))
.nth(1)
.expect("fresh output has a switched-shunt record");
let modsw_column = if rev >= 35 { 2 } else { 1 };
assert_eq!(
fields(written)[modsw_column],
modsw.to_string(),
"{context} written at revision {rev}"
);
let reparsed = parse_psse(&emitted).unwrap();
let shunt = &reparsed.shunts()[0];
assert_eq!(
shunt.control.as_ref().unwrap().mode,
mode,
"{context} reread from revision {rev}"
);
assert_eq!(
extra_i64(&shunt.extras, "psse_modsw"),
retained,
"{context} reread code from revision {rev}"
);
}
}
}
}
#[test]
fn fresh_emission_preserves_the_switched_shunt_adjustment_method() {
for (rev, record) in [
(
33,
"3, 2, 1, 1, 1.05, 0.95, 0, 100.0, '', 0.0, 1, -10.0, 1, 15.0",
),
(
35,
"3, 'S1', 2, 1, 1, 1.05, 0.95, 0, 0, 100.0, 'remote-id', 0.0, 0, 1, -10.0, 1, 1, 15.0",
),
] {
let raw = format!(
"0, 100.00, {rev}, 0, 0, 60.00 / adjustment method\n\
CASE\n\
COMMENT\n\
3,'B3',230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9\n\
0 / END OF BUS DATA, BEGIN LOAD DATA\n\
0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA\n\
0 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA\n\
0 / END OF GENERATOR DATA, BEGIN BRANCH DATA\n\
0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA\n\
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA\n\
0 / END OF AREA DATA, BEGIN SWITCHED SHUNT DATA\n\
{record}\n\
0 / END OF SWITCHED SHUNT DATA, BEGIN GNE DEVICE DATA\n\
Q\n"
);
let mut diagnostics = Diagnostics::new();
let net = parse_psse_source(&raw, None, &mut diagnostics).unwrap();
assert_eq!(extra_i64(&net.shunts()[0].extras, "psse_adjm"), Some(1));
if rev >= 35 {
assert_eq!(
net.shunts()[0].extras["psse_rmidnt"],
Value::from("remote-id")
);
assert!(diagnostics.records().iter().any(|diagnostic| {
diagnostic.code() == "READ.PSSE.FIELD_DROPPED"
&& diagnostic.message().contains("block 1")
&& diagnostic.message().contains("S=0")
}));
}
let emitted = write_psse_rev(&net, rev).text;
let record = emitted
.lines()
.skip_while(|line| !line.contains("BEGIN SWITCHED SHUNT DATA"))
.nth(1)
.expect("fresh output has a switched-shunt record");
let columns = fields(record);
let adjm_column = if rev >= 35 { 3 } else { 2 };
assert_eq!(columns[adjm_column], "1", "revision {rev} ADJM");
if rev >= 35 {
assert_eq!(columns[10], "remote-id");
assert_eq!(columns[12], "1", "fresh output enables retained blocks");
}
let reparsed = parse_psse(&emitted).unwrap();
assert_eq!(
extra_i64(&reparsed.shunts()[0].extras, "psse_adjm"),
Some(1),
"revision {rev} reparsed ADJM"
);
if rev >= 35 {
assert_eq!(
reparsed.shunts()[0].extras["psse_rmidnt"],
Value::from("remote-id")
);
}
}
}
#[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::Continuous);
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
3,'B3 ', 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
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
1, 2, 3, '3W', 1, 1, 1, 0, 0, 2, 'REG3W ', 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, 1.0, 0.0
1.0, 230.0, 0.0, 100.0, 90.0, 80.0, 1, -95, 1.08, 0.92, 1.05, 0.98, 17, 0, 0, 0, 0
1.0, 18.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, 13.8, 0.0, 100.0, 90.0, 80.0, 0, 0, 1.1, 0.9, 1.1, 0.9, 33, 0, 0, 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 = Diagnostics::new();
let net = parse_psse_source(raw, 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
);
let three_winding_control = net.transformers_3w()[0].windings[0]
.control
.as_ref()
.expect("the winding control remains after its stale pointer is dropped");
assert_eq!(three_winding_control.controlled_bus, None);
assert!(!three_winding_control.controlled_bus_on_winding_side);
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.lines().iter().any(|w| w.contains("GENERATOR DATA")
&& w.contains("IREG")
&& w.contains("missing bus id 99")),
"missing IREG warning: {warnings:?}"
);
assert!(
warnings
.lines()
.iter()
.any(|w| w.contains("TRANSFORMER DATA")
&& w.contains("CONT")
&& w.contains("missing bus id 98")),
"missing CONT warning: {warnings:?}"
);
assert!(
warnings.lines().iter().any(|w| {
w.contains("winding 1") && w.contains("CONT") && w.contains("missing bus id 95")
}),
"missing three winding CONT warning: {warnings:?}"
);
assert!(
warnings
.lines()
.iter()
.any(|w| w.contains("SWITCHED SHUNT DATA")
&& w.contains("SWREM")
&& w.contains("missing bus id 96")),
"missing SWREM warning: {warnings:?}"
);
assert!(
warnings.lines().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
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
1, 2, '&1', 1, 3
1, 3, '&2', 1, 2
0
0 / END OF MULTI-SECTION LINE DATA, BEGIN ZONE DATA
Q
";
let mut warnings = Diagnostics::new();
parse_psse_source(raw, None, &mut warnings).unwrap();
assert!(
warnings
.lines()
.iter()
.any(|w| w.contains("MULTI-SECTION 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::Continuous, "MODSW 2");
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);
}
fn vsc_case(rev: u32, mdc: &str, c1: &str, c2: &str) -> String {
format!(
"0, 100.00, {rev}, 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 ', 345.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
0 / END OF TWO-TERMINAL DC DATA, BEGIN VSC DC LINE DATA
'LINK A', {mdc}, 2.0, 1, 1.0
4, {c1}
5, {c2}
0 / END OF VSC DC LINE DATA, BEGIN IMPEDANCE CORRECTION DATA
Q
"
)
}
fn vsc_converters(rev: u32, rmpct: &str) -> (String, String) {
let regulation = if rev >= 35 { "0, 0" } else { "0" };
(
format!(
"2, 2, -200.0, 0.95, 500.0, 2.0, 600.0, 300.0, 1000.0, 1.0, 100.0, -100.0, {regulation}, {rmpct}"
),
format!(
"1, 1, 400.0, 1.02, 500.0, 2.0, 600.0, 300.0, 1000.0, 1.0, 120.0, -110.0, {regulation}, 100.0"
),
)
}
fn vsc_expected() -> (f64, f64, f64) {
let (v, r) = (400.0_f64, 2.0_f64);
let loss = |i: f64| (500.0 + 2.0 * i * 1000.0).max(600.0) / 1000.0;
let mut i = 0.5_f64;
for _ in 0..100 {
let sent = 200.0 - loss(i);
i = ((v * v + 4.0 * r * sent).sqrt() - v) / (2.0 * r);
}
let pt = v * i - loss(i);
let qf = 65.736_821_035_772_6;
(200.0, pt, qf)
}
fn close_to(actual: f64, expected: f64, tol: f64) {
assert!((actual - expected).abs() < tol, "{actual} != {expected}");
}
#[test]
fn reads_a_vsc_dc_line_at_revisions_33_34_and_35() {
let (pf, pt, qf) = vsc_expected();
for rev in [33, 34, 35] {
let (c1, c2) = vsc_converters(rev, "100.0");
let mut warnings = Diagnostics::new();
let net = parse_psse_source(&vsc_case(rev, "1", &c1, &c2), None, &mut warnings)
.unwrap_or_else(|e| panic!("revision {rev}: {e}"));
assert!(
!warnings.lines().iter().any(|w| w.contains("VSC DC LINE")),
"revision {rev}: the section is read, not skipped: {warnings:?}"
);
assert_eq!(net.hvdc().len(), 1, "revision {rev}");
let dc = &net.hvdc()[0];
assert_eq!((dc.from, dc.to), (BusId(4), BusId(5)));
assert!(dc.in_service);
close_to(dc.pf, pf, 1e-9);
close_to(dc.pt, pt, 1e-9);
assert!(dc.pt < dc.pf - 2.0, "losses and the DC drop: {}", dc.pt);
close_to(dc.qf, qf, 1e-9);
close(dc.qt, 0.0);
close(dc.vt, 1.02);
close(dc.qmaxt, 120.0);
close(dc.qmint, -110.0);
assert_eq!(dc.resistance_ohm, Some(2.0));
assert_eq!(dc.nominal_voltage_kv, Some(400.0));
let c1 = dc.converter1.as_ref().unwrap();
let c2 = dc.converter2.as_ref().unwrap();
assert_eq!(c1.kind, HvdcConverterKind::Vsc);
assert_eq!(c1.voltage_regulator_on, Some(false));
assert_eq!(c1.power_factor, Some(0.95));
assert_eq!(c2.voltage_regulator_on, Some(true));
close_to(c2.voltage_setpoint_kv.unwrap(), 1.02 * 345.0, 1e-9);
assert_eq!(
dc.extras["psse_vsc_name"],
Value::String("LINK A".into()),
"revision {rev}"
);
}
}
#[test]
fn a_vsc_line_injects_its_terminal_powers_into_the_indexed_view() {
let (c1, c2) = vsc_converters(35, "100.0");
let net = parse_psse(&vsc_case(35, "1", &c1, &c2)).unwrap();
let view = crate::IndexedNetwork::new(&net);
let (pf, pt, qf) = vsc_expected();
let at = |bus| view.bus_index(BusId(bus)).unwrap();
close_to(view.p_hvdc()[at(4)], -pf, 1e-9);
close_to(view.q_hvdc()[at(4)], qf, 1e-9);
close_to(view.p_hvdc()[at(5)], pt, 1e-9);
close(view.q_hvdc()[at(5)], 0.0);
}
#[test]
fn an_out_of_range_rmpct_reads_as_100_with_a_diagnostic() {
let (c1, c2) = vsc_converters(35, "278000.0");
let mut warnings = Diagnostics::new();
let net = parse_psse_source(&vsc_case(35, "1", &c1, &c2), None, &mut warnings).unwrap();
assert!(
warnings
.lines()
.iter()
.any(|w| w.contains("RMPCT 278000 is outside (0, 100]; read as 100")),
"{warnings:?}"
);
let stored = &net.hvdc()[0].extras["psse_vsc_converter1"];
assert_eq!(stored["rmpct"], Value::from(100.0));
close_to(net.hvdc()[0].pf, vsc_expected().0, 1e-9);
}
#[test]
fn a_vsc_line_with_mdc_0_is_out_of_service_and_keeps_its_record() {
let (c1, c2) = vsc_converters(35, "100.0");
let net = parse_psse(&vsc_case(35, "0", &c1, &c2)).unwrap();
let dc = &net.hvdc()[0];
assert!(!dc.in_service);
close_to(dc.pf, vsc_expected().0, 1e-9);
close_to(dc.pt, vsc_expected().1, 1e-9);
let back = parse_psse(&write_psse_rev(&net, 35).text).unwrap();
assert!(!back.hvdc()[0].in_service);
assert_eq!(
back.hvdc()[0].extras["psse_vsc_converter1"],
dc.extras["psse_vsc_converter1"]
);
}
#[test]
fn two_dc_voltage_controlling_converters_schedule_no_power() {
let (_, c2) = vsc_converters(35, "100.0");
let mut warnings = Diagnostics::new();
let net = parse_psse_source(&vsc_case(35, "1", &c2, &c2), None, &mut warnings).unwrap();
let dc = &net.hvdc()[0];
assert!(dc.in_service);
close(dc.pf, 0.0);
close(dc.pt, 0.0);
assert!(
warnings
.lines()
.iter()
.any(|w| w.contains("converter types 1 and 1")),
"{warnings:?}"
);
}
#[test]
fn the_writer_restates_vsc_lines_in_their_own_section() {
for rev in [33, 34, 35] {
let (c1, c2) = vsc_converters(rev, "100.0");
let net = parse_psse(&vsc_case(rev, "1", &c1, &c2)).unwrap();
let written = write_psse_rev(&net, rev);
assert!(
written
.diagnostics
.iter()
.all(|d| !d.message().contains("VSC")),
"revision {rev}: {:?}",
written.diagnostics
);
let vsc_section = written
.text
.split("BEGIN VSC DC LINE DATA")
.nth(1)
.and_then(|rest| rest.split("0 / END OF VSC DC LINE DATA").next())
.unwrap();
assert!(vsc_section.contains("'LINK A', 1, 2.0"), "{vsc_section}");
let two_terminal = written
.text
.split("BEGIN TWO-TERMINAL DC DATA")
.nth(1)
.and_then(|rest| rest.split("0 / END OF TWO-TERMINAL DC DATA").next())
.unwrap();
assert_eq!(two_terminal.trim(), "", "no LCC record for a VSC line");
let back = parse_psse(&written.text).unwrap();
let (dc, again) = (&net.hvdc()[0], &back.hvdc()[0]);
assert_eq!(again.extras, dc.extras, "revision {rev}");
for (a, b) in [
(again.pf, dc.pf),
(again.pt, dc.pt),
(again.qf, dc.qf),
(again.qt, dc.qt),
] {
close_to(a, b, 1e-9);
}
assert_eq!(again.converter2, dc.converter2);
}
let (c1, c2) = vsc_converters(35, "100.0");
let mut net = parse_psse(&vsc_case(35, "1", &c1, &c2)).unwrap();
net.hvdc_mut()[0].pf = 150.0;
let written = write_psse_rev(&net, 35);
assert!(
written
.diagnostics
.iter()
.any(|d| d.message().contains("to end active injection")
&& d.message().contains("reads back")),
"the stated pt no longer matches the record's loss model: {:?}",
written.diagnostics
);
let back = parse_psse(&written.text).unwrap();
close_to(back.hvdc()[0].pf, 150.0, 1e-9);
}
#[test]
fn a_vsc_line_from_another_format_is_written_with_a_synthesized_record() {
let (c1, c2) = vsc_converters(35, "100.0");
let mut net = parse_psse(&vsc_case(35, "1", &c1, &c2)).unwrap();
let dc = &mut net.hvdc_mut()[0];
dc.extras = Extras::new();
dc.converter1.as_mut().unwrap().power_factor = None;
let written = write_psse_rev(&net, 35);
let back = parse_psse(&written.text).unwrap();
let again = &back.hvdc()[0];
assert_eq!(again.extras["psse_vsc_converter1"]["type"], Value::from(2));
assert_eq!(again.extras["psse_vsc_converter2"]["type"], Value::from(1));
close_to(again.pf, net.hvdc()[0].pf, 1e-9);
close_to(again.qf, net.hvdc()[0].qf, 1e-9);
close_to(again.pt, net.hvdc()[0].pt, 1e-6);
assert!(
again.extras["psse_vsc_converter2"]["aloss"]
.as_f64()
.unwrap()
> 0.0
);
assert!(
written
.diagnostics
.iter()
.all(|d| !d.message().contains("VSC DC line")),
"{:?}",
written.diagnostics
);
}
#[test]
fn reads_a_hand_written_vsc_record_with_its_loss_model() {
let c1 =
"1, 1, 500.0, 1.01, 800.0, 1.5, 1200.0, 400.0, 1000.0, 1.0, 150.0, -150.0, 0, 100.0";
let c2 =
"2, 2, 300.0, 0.98, 600.0, 1.0, 2000.0, 400.0, 1000.0, 1.0, 100.0, -100.0, 0, 100.0";
let mut warnings = Diagnostics::new();
let net = parse_psse_source(&vsc_case(33, "1", c1, c2), None, &mut warnings).unwrap();
assert!(
warnings.lines().iter().all(|w| !w.contains("VSC")),
"{warnings:?}"
);
let dc = &net.hvdc()[0];
assert_eq!((dc.from, dc.to), (BusId(4), BusId(5)));
assert!(dc.in_service);
assert_eq!(dc.resistance_ohm, Some(2.0));
assert_eq!(dc.nominal_voltage_kv, Some(500.0));
assert_eq!(
dc.converters_mode,
Some(HvdcConvertersMode::Side1RectifierSide2Inverter)
);
let voltage_end = &dc.extras["psse_vsc_converter1"];
assert_eq!(voltage_end["type"], Value::from(1));
assert_eq!(voltage_end["dcset"], Value::from(500.0)); assert_eq!(voltage_end["aloss"], Value::from(800.0)); assert_eq!(voltage_end["bloss"], Value::from(1.5)); assert_eq!(voltage_end["minloss"], Value::from(1200.0)); let power_end = &dc.extras["psse_vsc_converter2"];
assert_eq!(power_end["type"], Value::from(2));
assert_eq!(power_end["dcset"], Value::from(300.0)); assert_eq!(power_end["aloss"], Value::from(600.0)); assert_eq!(power_end["bloss"], Value::from(1.0)); assert_eq!(power_end["minloss"], Value::from(2000.0)); let c1 = dc.converter1.as_ref().unwrap();
assert_eq!(c1.voltage_regulator_on, Some(true));
close_to(c1.voltage_setpoint_kv.unwrap(), 1.01 * 230.0, 1e-9);
let c2 = dc.converter2.as_ref().unwrap();
assert_eq!(c2.voltage_regulator_on, Some(false));
assert_eq!(c2.power_factor, Some(0.98));
let terminal = dc.calc_bus_to_device_powers();
close_to(terminal.p_from, 304.441_378_558_509, 1e-9);
close(terminal.q_from, 0.0);
close_to(terminal.p_to, -300.0, 1e-9);
close_to(terminal.q_to, -60.917_598_190_201_3, 1e-9);
close_to(terminal.calc_active_loss(), 4.441_378_558_509, 1e-9);
}
#[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, 348.775);
close(dc.pmax, 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);
close(dc2.pt, 348.775);
}
#[test]
fn replayed_dc_tail_never_opens_a_quoted_field() {
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, '1"2', 0.0, 25, 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();
let tail = net.hvdc()[0].extras["psse_dc_control_tail"]
.as_array()
.expect("a non-default control tail is retained");
assert_eq!(tail[3], Value::from(r#"1"2"#));
let text = write_psse(&net).text;
let back = parse_psse(&text).unwrap();
let tail = back.hvdc()[0].extras["psse_dc_control_tail"]
.as_array()
.expect("the control tail survives the write");
assert_eq!(tail.len(), 7, "{text}");
assert_eq!(tail[3], Value::from("1_2"));
assert_eq!(tail[5], Value::from("25"));
}
#[test]
fn dc_line_setvl_modes_price_the_line_drop() {
let record = |mdc: &str, setvl: &str, vschd: &str| {
format!(
"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
'DC1', {mdc}, 2.5, {setvl}, {vschd}, 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 dc = parse_psse(&record("1", "-350.0", "500.0")).unwrap().hvdc()[0].clone();
close(dc.pt, 350.0);
close(dc.pf, 351.225);
let net = parse_psse(&record("2", "200.0", "500.0")).unwrap();
let dc = &net.hvdc()[0];
close(dc.pf, 100.0);
close(dc.pt, 99.9);
let back = parse_psse(&write_psse(&net).text).unwrap();
close(back.hvdc()[0].pf, 100.0);
close(back.hvdc()[0].pt, 99.9);
let dc = parse_psse(&record("1", "350.0", "0.0")).unwrap().hvdc()[0].clone();
close(dc.pf, 350.0);
close(dc.pt, 350.0);
}
#[test]
fn every_setvl_spelling_round_trips() {
let record = |mdc: &str, setvl: &str, vschd: &str| {
format!(
"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
'DC1', {mdc}, 2.5, {setvl}, {vschd}, 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
"
)
};
for (mdc, setvl, vschd) in [
("1", "350.0", "500.0"),
("1", "-350.0", "500.0"),
("2", "200.0", "500.0"),
("1", "350.0", "0.0"),
] {
let net = parse_psse(&record(mdc, setvl, vschd)).unwrap();
let dc = net.hvdc()[0].clone();
let back = parse_psse(&write_psse(&net).text).unwrap();
let dc2 = &back.hvdc()[0];
close(dc2.pf, dc.pf);
close(dc2.pt, dc.pt);
assert!(
(dc2.pf - dc.pf).abs() < 1e-12 && (dc2.pt - dc.pt).abs() < 1e-12,
"MDC {mdc} SETVL {setvl} VSCHD {vschd} moved: \
{} -> {} and {} -> {}",
dc.pf,
dc2.pf,
dc.pt,
dc2.pt
);
}
}
#[test]
fn a_current_schedule_with_no_voltage_is_not_read_as_power() {
let text = "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
'DC1', 2, 2.5, 2000.0, 0.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 parsed = crate::parse_str(text, "psse").unwrap();
let dc = &parsed.network.hvdc()[0];
close(dc.pf, 0.0);
close(dc.pt, 0.0);
assert!(
parsed
.render_diagnostics()
.iter()
.any(|w| w.contains("cannot be priced into power")),
"{:?}",
parsed.render_diagnostics()
);
let out = write_psse(&parsed.network).text;
assert!(
out.lines().any(|l| l.contains("2000")),
"the schedule must survive the write: {out}"
);
}
#[test]
fn a_blocked_dc_record_prices_no_drop_and_earns_no_warning() {
let text = "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
'DC1', 0, 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(text).unwrap();
let dc = net.hvdc()[0].clone();
assert!(!dc.in_service);
close(dc.pf, 350.0);
close(dc.pt, 350.0);
let conv = write_psse(&net);
assert!(
!conv
.render_diagnostics()
.iter()
.any(|w| w.contains("converter detail")),
"a record the rewrite reproduces exactly earns no warning: {:?}",
conv.render_diagnostics()
);
let reparsed = parse_psse(&conv.text).unwrap();
let back = &reparsed.hvdc()[0];
assert!(!back.in_service);
close(back.pf, 350.0);
close(back.pt, 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_eq!(net2.branches().as_slice(), []);
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"));
let rev35 = write_psse_rev(&net, 35).text;
let main = rev35
.lines()
.find(|line| line.starts_with("1, 2, 3, '1'"))
.unwrap();
let main_fields = fields(main);
assert_eq!(main_fields.len(), 22, "rev35 transformer row: {main:?}");
assert_eq!(main_fields[21], "0", "ZCOD must be an integer");
let dc_control_raw = raw.replacen(
"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.0, 230.0, 0.0, 100.0, 90.0, 80.0, 4, 1, 1.1, 0.9, 1.1, 0.9, 33, 0, 0, 0, 0",
1,
);
let error = parse_psse(&dc_control_raw).unwrap_err().to_string();
assert!(
error.contains("COD 4 DC line quantity control")
&& error.contains("valid only for two winding transformers"),
"{error}"
);
let mut invalid_for_output = net;
let mut control = TransformerControl::new(TransformerControlMode::DcLineQuantity);
control.controlled_bus = Some(BusId(1));
invalid_for_output.transformers_3w_mut()[0].windings[0].control = Some(control);
let emitted = write_psse_rev(&invalid_for_output, 35);
assert!(
emitted.render_diagnostics().iter().any(|diagnostic| {
diagnostic.contains("COD 4 DC line quantity control")
&& diagnostic.contains("emitted fixed control")
}),
"invalid three winding control must be diagnosed: {:?}",
emitted.render_diagnostics()
);
let back = parse_psse(&emitted.text).unwrap();
assert!(back.transformers_3w()[0].windings[0].control.is_none());
}
#[test]
fn disabled_three_winding_controls_keep_negative_cod_in_v33_and_v35() {
let raw = r"0, 100.00, 33, 0, 0, 60.00 / disabled winding controls
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, 'T3-CONTROL ', 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, 1.0, 0.0
1.0, 230.0, 0.0, 100.0, 90.0, 80.0, -1, -1, 1.08, 0.92, 1.05, 0.98, 17, 0, 0, 0, 0
1.0, 138.0, 0.0, 70.0, 60.0, 50.0, -2, 2, 1.07, 0.93, 50.0, -50.0, 19, 0, 0, 0, 0
1.0, 13.8, 0.0, 40.0, 30.0, 20.0, -3, 3, 1.06, 0.94, 60.0, -60.0, 21, 0, 0, 0, 0
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA
Q
";
let net = parse_psse(raw).unwrap();
let modes = [
TransformerControlMode::Voltage,
TransformerControlMode::ReactiveFlow,
TransformerControlMode::ActiveFlow,
];
for (winding, mode) in net.transformers_3w()[0].windings.iter().zip(modes) {
let control = winding.control.as_ref().expect("control parsed");
assert_eq!(control.mode, mode);
assert!(!control.enabled);
}
let first = net.transformers_3w()[0].windings[0]
.control
.as_ref()
.unwrap();
assert_eq!(first.controlled_bus, Some(BusId(1)));
assert!(first.controlled_bus_on_winding_side);
for revision in [33, 35] {
let emitted = write_psse_rev(&net, revision).text;
let back = parse_psse(&emitted).unwrap();
for (winding, mode) in back.transformers_3w()[0].windings.iter().zip(modes) {
let control = winding.control.as_ref().expect("control survives");
assert_eq!(control.mode, mode);
assert!(!control.enabled, "revision {revision} lost negative COD");
}
assert!(
back.transformers_3w()[0].windings[0]
.control
.as_ref()
.unwrap()
.controlled_bus_on_winding_side,
"revision {revision} lost the negative CONT meaning"
);
}
}
#[test]
#[allow(clippy::too_many_lines)]
fn dc_line_and_asymmetric_active_power_controls_keep_cod_in_v33_and_v35() {
let raw = r"0, 100.00, 33, 0, 0, 60.00 / COD 4 and 5
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, '4P', 1, 1, 1, 0, 0, 2, 'DC ENABLED ', 1, 1, 1, 0, 1, 0, 1, 0, 1, ' '
0.01, 0.10, 100.0
1.0, 230.0, 0.0, 100.0, 90.0, 80.0, 4, 0, 1.08, 0.92, 0.0, 0.0, 17, 0, 0, 0, 0
1.0, 138.0
1, 2, 0, '4N', 1, 1, 1, 0, 0, 2, 'DC DISABLED ', 1, 1, 1, 0, 1, 0, 1, 0, 1, ' '
0.01, 0.10, 100.0
1.0, 230.0, 0.0, 100.0, 90.0, 80.0, -4, 0, 1.08, 0.92, 0.0, 0.0, 17, 0, 0, 0, 0
1.0, 138.0
1, 2, 0, '5P', 1, 1, 1, 0, 0, 2, 'ASYM ENABLED', 1, 1, 1, 0, 1, 0, 1, 0, 1, ' '
0.01, 0.10, 100.0
1.0, 230.0, 2.0, 100.0, 90.0, 80.0, 5, 0, 15.0, -15.0, 100.0, -100.0, 21, 0, 0, 0, 12.5
1.0, 138.0
1, 2, 0, '5N', 1, 1, 1, 0, 0, 2, 'ASYM DISABL ', 1, 1, 1, 0, 1, 0, 1, 0, 1, ' '
0.01, 0.10, 100.0
1.0, 230.0, -2.0, 100.0, 90.0, 80.0, -5, 0, 15.0, -15.0, 100.0, -100.0, 21, 0, 0, 0, 0
1.0, 138.0
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA
Q
";
let net = parse_psse(raw).unwrap();
let expected = [
(TransformerControlMode::DcLineQuantity, true),
(TransformerControlMode::DcLineQuantity, false),
(TransformerControlMode::AsymmetricActiveFlow, true),
(TransformerControlMode::AsymmetricActiveFlow, false),
];
assert_eq!(net.branches().len(), expected.len());
for (branch, (mode, enabled)) in net.branches().iter().zip(expected) {
let control = branch.control.as_ref().expect("control parsed");
assert_eq!(control.mode, mode);
assert_eq!(control.enabled, enabled);
}
assert_eq!(
net.branches()[2]
.control
.as_ref()
.unwrap()
.winding_connection_angle,
Some(12.5)
);
for revision in [33, 35] {
let emitted = write_psse_rev(&net, revision).text;
let back = parse_psse(&emitted).unwrap();
for (branch, (mode, enabled)) in back.branches().iter().zip(expected) {
let control = branch.control.as_ref().expect("control survives");
assert_eq!(control.mode, mode, "revision {revision} changed COD");
assert_eq!(
control.enabled, enabled,
"revision {revision} changed the COD sign"
);
}
assert_eq!(
back.branches()[2]
.control
.as_ref()
.unwrap()
.winding_connection_angle,
Some(12.5),
"revision {revision} lost CNXA"
);
}
let rawx = crate::format::write_rawx(&net).unwrap();
let rawx_back =
crate::format::rawx::parse_rawx_source(&rawx.text, None, &mut Diagnostics::new())
.unwrap();
for (branch, (mode, enabled)) in rawx_back.branches().iter().zip(expected) {
let control = branch.control.as_ref().expect("RAWX control survives");
assert_eq!(control.mode, mode);
assert_eq!(control.enabled, enabled);
}
let xiidm = crate::format::write_xiidm(&net).unwrap();
let xiidm_diagnostics = xiidm.render_diagnostics();
for mode in [
"DC line quantity control",
"asymmetric active power flow control",
] {
assert!(
xiidm_diagnostics.iter().any(|line| line.contains(mode)),
"XIIDM must diagnose unsupported {mode}: {xiidm_diagnostics:?}"
);
}
let (_, cgmes_diagnostics) = crate::format::cgmes::artifacts(&net).unwrap();
let dropped_controls = cgmes_diagnostics
.lines()
.into_iter()
.filter(|line| line.contains("automatic tap/phase control"))
.count();
assert_eq!(
dropped_controls,
expected.len(),
"CGMES must diagnose each unsupported control"
);
}
#[test]
fn output_diagnoses_unrepresentable_regulating_terminal_and_invalid_cont_sign() {
let mut net = BalancedNetwork::in_memory(
"controls",
100.0,
vec![test_bus(1, BusType::Ref), test_bus(2, BusType::Pq)],
Vec::new(),
);
let mut missing_terminal =
transformer_with_terminal_charging(BranchCharging::new(0.0, 0.0, 0.0, 0.0));
let mut control = TransformerControl::new(TransformerControlMode::Voltage);
control.controlled_bus = Some(BusId(2));
control.regulating_terminal = Some(
serde_json::from_value(serde_json::json!({
"equipment": {
"component_type": "transformer",
"local_id": "not-in-detailed-connectivity"
},
"terminal": 2
}))
.unwrap(),
);
missing_terminal.control = Some(control);
net.branches_mut().push(missing_terminal);
let mut missing_bus =
transformer_with_terminal_charging(BranchCharging::new(0.0, 0.0, 0.0, 0.0));
missing_bus.from = BusId(2);
missing_bus.to = BusId(1);
let mut control = TransformerControl::new(TransformerControlMode::Voltage);
control.controlled_bus_on_winding_side = true;
missing_bus.control = Some(control);
net.branches_mut().push(missing_bus);
let emitted = write_psse_rev(&net, 35);
let diagnostics = emitted.render_diagnostics();
assert!(diagnostics.iter().any(|line| {
line.contains("regulating terminal has no PSS/E bus and node mapping")
&& line.contains("node 0")
}));
assert!(diagnostics.iter().any(|line| {
line.contains("negative CONT requires a nonzero controlled bus")
&& line.contains("CONT=0")
}));
let invalid_control = net.branches()[1].control.as_ref().unwrap();
let mut direct_diagnostics = Diagnostics::new();
assert_eq!(
emit_controlled_bus(
invalid_control,
2,
"test transformer",
&mut direct_diagnostics
),
0,
"an unrelated derived bus must not give an absent CONT a meaning"
);
let back = parse_psse(&emitted.text).unwrap();
assert_eq!(
back.branches()[0].control.as_ref().unwrap().controlled_bus,
Some(BusId(2))
);
assert!(
!back.branches()[1]
.control
.as_ref()
.unwrap()
.controlled_bus_on_winding_side
);
}
fn three_winding_status_case() -> String {
let mut raw = String::from(
"0, 100.00, 35, 0, 1, 60.00 / synthetic
CASE
COMMENT
0 / END OF SYSTEM-WIDE DATA, BEGIN BUS DATA
1,'HV ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
2,'MV ', 115.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
3,'LV ', 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 SYSTEM SWITCHING DEVICE DATA
0 / END OF SYSTEM SWITCHING DEVICE DATA, BEGIN TRANSFORMER DATA
",
);
for stat in 0..=4 {
let _ = write!(
raw,
"1, 2, 3, '{stat}', 1, 1, 1, 0.0, 0.0, 2, 'T3W {stat} ', {stat}, 1, 1.0, 0, 1.0, 0, 1.0, 0, 1.0, 'YNyn0d1 ', 0
0.01, 0.10, 100.0, 0.02, 0.20, 100.0, 0.03, 0.30, 100.0, 1.0, 0.0
1.0, 230.0, 0.0, 100.0, 110.0, 120.0, 130.0, 140.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 190.0, 0, 0, 0, 1.1, 0.9, 1.1, 0.9, 33, 0, 0.0, 0.0, 0.0
1.0, 115.0, 0.0, 50.0, 55.0, 60.0, 65.0, 70.0, 75.0, 80.0, 85.0, 90.0, 95.0, 100.0, 105.0, 0, 0, 0, 1.1, 0.9, 1.1, 0.9, 33, 0, 0.0, 0.0, 0.0
1.0, 13.8, 0.0, 20.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.9, 1.1, 0.9, 33, 0, 0.0, 0.0, 0.0
"
);
}
raw.push_str("0 / END OF TRANSFORMER DATA, BEGIN AREA DATA\nQ\n");
raw
}
const THREE_WINDING_STATUS: [(bool, [bool; 3]); 5] = [
(false, [true, true, true]),
(true, [true, true, true]),
(true, [true, false, true]),
(true, [true, true, false]),
(true, [false, true, true]),
];
fn three_winding_statuses(net: &BalancedNetwork) -> Vec<(bool, [bool; 3])> {
net.transformers_3w()
.iter()
.map(|t| (t.in_service, t.winding_in_service()))
.collect()
}
fn rating_sets(transformer: &Transformer3W, winding: usize) -> Vec<(String, f64)> {
transformer
.winding_rating_sets(winding)
.into_iter()
.map(|set| (set.name, set.rate_mva))
.collect()
}
fn assert_three_winding_ratings(net: &BalancedNetwork) {
let rate = |n: u32, mva: f64| (format!("RATE{n}"), mva);
for t in net.transformers_3w() {
assert_eq!(
rating_sets(t, 0),
[rate(4, 130.0), rate(5, 140.0), rate(12, 190.0)]
);
assert_eq!(
rating_sets(t, 1),
(4..=12)
.map(|n| rate(n, 45.0 + 5.0 * f64::from(n)))
.collect::<Vec<_>>()
);
assert_eq!(rating_sets(t, 2), []);
close(t.windings[1].rate_c, 60.0);
}
}
#[test]
fn three_winding_stat_states_each_winding_and_round_trips() {
let net = parse_psse(&three_winding_status_case()).unwrap();
assert_eq!(three_winding_statuses(&net), THREE_WINDING_STATUS);
assert_three_winding_ratings(&net);
for revision in [34, 35] {
let emitted = write_psse_rev(&net, revision);
let stats = emitted
.text
.lines()
.filter(|line| line.starts_with("1, 2, 3, '"))
.map(|line| fields(line)[11].to_string())
.collect::<Vec<_>>();
assert_eq!(stats, ["0", "1", "2", "3", "4"], "revision {revision}");
assert!(
!emitted
.render_diagnostics()
.iter()
.any(|line| line.contains("rating set")),
"revision {revision} states every rating: {:?}",
emitted.render_diagnostics()
);
let back = parse_psse(&emitted.text).unwrap();
assert_eq!(three_winding_statuses(&back), THREE_WINDING_STATUS);
assert_three_winding_ratings(&back);
}
let rev33 = write_psse_rev(&net, 33);
let back = parse_psse(&rev33.text).unwrap();
assert_eq!(three_winding_statuses(&back), THREE_WINDING_STATUS);
assert!(
back.transformers_3w()
.iter()
.all(|t| (0..3).all(|winding| t.winding_rating_sets(winding).is_empty()))
);
let dropped = rev33
.render_diagnostics()
.iter()
.filter(|line| {
line.contains("EMIT.PSSE.RATING_SET_DROPPED")
&& line.contains("three winding transformer 1-2-3 winding")
})
.count();
assert_eq!(dropped, 5 * (3 + 9), "{:?}", rev33.render_diagnostics());
}
#[test]
fn three_winding_extra_ratings_keep_revision_specific_control_columns() {
let mut net = parse_psse(&three_winding_status_case()).unwrap();
let mut control = TransformerControl::new(TransformerControlMode::Voltage);
control.controlled_bus = Some(BusId(2));
control.tap_max = 1.08;
control.ntp = 17;
net.transformers_3w_mut()[0].windings[0].control = Some(control);
for revision in [34, 35] {
let emitted = write_psse_rev(&net, revision);
let lines: Vec<_> = emitted.text.lines().collect();
let header = lines
.iter()
.position(|line| line.starts_with("1, 2, 3, '"))
.unwrap();
let winding = fields(lines[header + 2]);
assert_eq!(winding.len(), 27);
close(winding[6].parse().unwrap(), 130.0);
close(winding[14].parse().unwrap(), 190.0);
let (node, rma, ntp) = if revision == 34 {
(26, 17, 21)
} else {
(17, 18, 22)
};
assert_eq!(winding[node], "0", "NOD at revision {revision}");
close(winding[rma].parse().unwrap(), 1.08);
assert_eq!(winding[ntp], "17", "NTP at revision {revision}");
let back = parse_psse(&emitted.text).unwrap();
assert_three_winding_ratings(&back);
close(
back.transformers_3w()[0].windings[0]
.control
.as_ref()
.unwrap()
.tap_max,
1.08,
);
}
}
#[test]
fn three_winding_status_and_ratings_survive_rawx() {
let net = parse_psse(&three_winding_status_case()).unwrap();
let emitted = super::super::rawx::write_rawx(&net).unwrap();
let back =
super::super::rawx::parse_rawx_source(&emitted.text, None, &mut Diagnostics::new())
.unwrap();
assert_eq!(three_winding_statuses(&back), THREE_WINDING_STATUS);
assert_three_winding_ratings(&back);
}
#[test]
fn three_winding_status_the_stat_codes_cannot_state_is_diagnosed() {
let raw =
three_winding_status_case().replacen("'T3W 1 ', 1,", "'T3W 1 ', 7,", 1);
let mut warnings = Diagnostics::new();
let net = parse_psse_source(&raw, None, &mut warnings).unwrap();
assert_eq!(three_winding_statuses(&net)[1], (true, [true; 3]));
assert!(
warnings.lines().iter().any(|line| {
line.contains("READ.PSSE.VALUE_SUBSTITUTED") && line.contains("STAT 7")
}),
"{warnings:?}"
);
let mut net = net;
net.transformers_3w_mut()[1].set_winding_in_service([true, false, false]);
let emitted = write_psse_rev(&net, 35);
let back = parse_psse(&emitted.text).unwrap();
assert!(!back.transformers_3w()[1].in_service);
assert!(
emitted.render_diagnostics().iter().any(|line| {
line.contains("EMIT.PSSE.VALUE_COLLAPSED") && line.contains("STAT 0")
}),
"{:?}",
emitted.render_diagnostics()
);
}
#[test]
fn three_winding_detail_other_writers_cannot_state_is_diagnosed() {
let net = parse_psse(&three_winding_status_case()).unwrap();
let xiidm = crate::format::emit_value_text(&net, crate::TargetFormat::Xiidm).unwrap();
let diagnostics = xiidm.render_diagnostics();
assert!(
diagnostics.iter().all(
|line| !line.contains("three winding transformer winding(s)")
&& !line.contains("RATING_SET_DROPPED")
),
"{diagnostics:?}"
);
let back =
super::super::xiidm::parse_xiidm_source(&xiidm.text, &mut Diagnostics::new()).unwrap();
assert_eq!(three_winding_statuses(&back), three_winding_statuses(&net));
for (actual, expected) in back.transformers_3w().iter().zip(net.transformers_3w()) {
for winding in 0..3 {
let sorted = |transformer| {
let mut sets = rating_sets(transformer, winding);
sets.sort_by(|a, b| a.0.cmp(&b.0));
sets
};
assert_eq!(sorted(actual), sorted(expected));
}
}
let pslf = crate::format::emit_value_text(&net, crate::TargetFormat::Pslf).unwrap();
assert!(
pslf.render_diagnostics().iter().any(|line| {
line.contains("EXTRAS_DROPPED")
&& line.contains("winding_in_service")
&& line.contains("winding_rating_sets")
}),
"{:?}",
pslf.render_diagnostics()
);
}
const FACTS_RECORDS: [&str; 3] = [
"'STATCOM A', 2, 0, 1, 0.0, 0.0, 1.02, 100.0, 9999.0, 0.9, 1.1, 1.0, 0.0, 0.05, 100.0, 1, 0.0, 0.0, 0, 0, 0, ''",
"'STATCOM B', 3, 0, 0, 0.0, 0.0, 0.98, 50.0, 9999.0, 0.95, 1.05, 1.1, 0.0, 0.1, 60.0, 2, 0.0, 0.0, 1, 2, 0, 'UNIT 1'",
"'SERIES 1', 2, 3, 1, 50.0, 10.0, 1.0, 9999.0, 9999.0, 0.9, 1.1, 1.0, 0.0, 0.05, 100.0, 1, 0.0, 0.0, 0, 0, 0, ''",
];
fn facts_case(records: &[&str]) -> String {
format!(
"0, 100.00, 35, 0, 1, 60.00 / synthetic
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 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
3,'B3 ', 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 SYSTEM SWITCHING DEVICE DATA
0 / END OF SYSTEM SWITCHING DEVICE 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
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
Q
",
records.join("\n")
)
}
fn facts_records(text: &str) -> Vec<(String, Vec<f64>, String)> {
let start = text.find("BEGIN FACTS DEVICE DATA").unwrap();
text[start..]
.lines()
.skip(1)
.take_while(|line| !line.starts_with("0 /"))
.map(|line| {
let f = fields(line);
let numbers = f[1..f.len() - 1]
.iter()
.map(|field| field.parse::<f64>().unwrap())
.collect();
(f[0].to_string(), numbers, f[f.len() - 1].to_string())
})
.collect()
}
#[test]
fn shunt_facts_devices_read_as_static_var_compensators() {
let raw = facts_case(&FACTS_RECORDS);
let mut warnings = Diagnostics::new();
let net = parse_psse_source(&raw, None, &mut warnings).unwrap();
let svcs = net.static_var_compensators();
assert_eq!(svcs.len(), 2, "the series device is not a compensator");
let [a, b] = [&svcs[0], &svcs[1]];
assert_eq!((a.bus, b.bus), (BusId(2), BusId(3)));
close(a.b_max_siemens, 100.0 / (230.0 * 230.0));
close(a.b_min_siemens, -100.0 / (230.0 * 230.0));
close(a.voltage_setpoint_kv, 1.02 * 230.0);
assert!(a.in_service && a.regulating);
assert_eq!(
a.regulation_mode,
StaticVarCompensatorRegulationMode::Voltage
);
assert_eq!(a.regulating_terminal, None);
assert_eq!(a.extras["psse_name"], Value::from("STATCOM A"));
assert_eq!(a.extras.len(), 1, "{:?}", a.extras);
close(b.b_max_siemens, 50.0 / (115.0 * 115.0));
close(b.voltage_setpoint_kv, 0.98 * 230.0);
assert!(!b.in_service);
for (key, value) in [
("psse_fcreg", Value::from(2)),
("psse_owner", Value::from(2)),
("psse_vsref", Value::from(1)),
("psse_mname", Value::from("UNIT 1")),
] {
assert_eq!(b.extras[key], value, "{key}");
}
close(b.extras["psse_rmpct"].as_f64().unwrap(), 60.0);
let lines = warnings.lines();
assert!(
lines
.iter()
.any(|line| line.contains("READ.PSSE.FIELD_DROPPED")
&& line.contains("regulates bus 2")),
"{lines:?}"
);
assert!(
lines
.iter()
.any(|line| line.contains("READ.PSSE.SECTION_UNSUPPORTED")
&& line.contains("1 series device record(s)")),
"{lines:?}"
);
let stated = facts_records(&raw)[..2].to_vec();
for revision in [33, 35] {
let emitted = write_psse_rev(&net, revision);
let expected = stated
.iter()
.cloned()
.map(|(name, mut numbers, mname)| {
if revision == 33 {
numbers.remove(19);
}
(name, numbers, mname)
})
.collect::<Vec<_>>();
assert_eq!(
facts_records(&emitted.text),
expected,
"revision {revision}"
);
let back = parse_psse(&emitted.text).unwrap();
assert_eq!(
back.static_var_compensators(),
net.static_var_compensators()
);
}
let rawx = super::super::rawx::write_rawx(&net).unwrap();
let mut back =
super::super::rawx::parse_rawx_source(&rawx.text, None, &mut Diagnostics::new())
.unwrap();
for svc in back.static_var_compensators_mut() {
svc.uid = None;
}
assert_eq!(
back.static_var_compensators(),
net.static_var_compensators(),
"RAWX"
);
}
#[test]
fn static_var_compensators_from_other_sources_write_as_shunt_facts() {
let mut net = parse_psse(&facts_case(&[])).unwrap();
let mut svc = StaticVarCompensator::new(BusId(2), -0.001, 0.002);
svc.voltage_setpoint_kv = 234.6;
svc.regulation_mode = StaticVarCompensatorRegulationMode::ReactivePower;
svc.q = -12.0;
net.static_var_compensators_mut().push(svc);
let emitted = write_psse_rev(&net, 35);
let [(name, numbers, _)] = facts_records(&emitted.text).try_into().unwrap();
assert_eq!(name, "SVC1");
assert_eq!(numbers[1..3], [0.0, 1.0]);
close(numbers[5], 1.02);
close(numbers[6], 0.002 * 230.0 * 230.0);
let lines = emitted.render_diagnostics();
for expected in [
"unequal capacitive and inductive limits",
"state terminal power",
"do not regulate voltage",
] {
assert!(
lines.iter().any(|line| line.contains(expected)),
"{expected}: {lines:?}"
);
}
}
#[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 = crate::format::emit_value_text(&net, crate::TargetFormat::Matpower).unwrap();
assert!(
mpc.render_diagnostics()
.iter()
.any(|w| w.contains("3-winding")),
"MATPOWER write must warn on the dropped 3-winding transformer, got {:?}",
mpc.render_diagnostics()
);
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 source =
powerio_core::Source::from_memory("case.raw", raw.as_bytes().to_vec()).unwrap();
let module =
crate::format::parse(source.with_format(powerio_core::FormatId::new("psse").unwrap()))
.unwrap();
let same =
crate::format::emit_text(&module, crate::TargetFormat::Psse { rev: 33 }).unwrap();
assert_eq!(same.text, raw, "same revision echoes the retained source");
let v34 = crate::format::emit_text(&module, 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 revision_34_substation_section_is_modeled() {
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
1, 'N1', 1, 1, 1.0, 0.0
2, 'N2', 1, 1, 1.0, 0.0
0 / END OF SUBSTATION NODE DATA, BEGIN SUBSTATION SWITCHING DEVICE DATA
1, 2, 'S1', 'BREAKER', 2, 1, 1, 0.0001, 100, 90, 80
0 / END OF SUBSTATION SWITCHING DEVICE DATA, BEGIN SUBSTATION EQUIPMENT TERMINAL DATA
0 / END OF SUBSTATION EQUIPMENT TERMINAL DATA
0 / END OF SUBSTATION DATA, BEGIN GNE DEVICE DATA
Q
";
let parsed = crate::parse_str(raw, "psse").unwrap();
let detailed = parsed
.network
.detailed_connectivity()
.as_deref()
.expect("revision 34 substation detail parsed");
assert_eq!(detailed.substations.len(), 1);
assert_eq!(detailed.connectivity_nodes.len(), 2);
assert_eq!(detailed.switches.len(), 1);
assert!(
parsed
.render_diagnostics()
.iter()
.all(|line| { !(line.contains("SUBSTATION") && line.contains("not modeled")) })
);
let emitted = write_psse_rev(&parsed.network, 34);
assert!(emitted.diagnostics.iter().all(|diagnostic| {
!diagnostic
.message()
.contains("detailed connectivity dropped")
}));
let reparsed = parse_psse(&emitted.text).unwrap();
let detailed = reparsed.detailed_connectivity().as_deref().unwrap();
assert_eq!(detailed.substations.len(), 1);
assert_eq!(detailed.connectivity_nodes.len(), 2);
assert_eq!(detailed.switches.len(), 1);
}
#[test]
fn revision_35_substation_connectivity_survives_fresh_emission() {
let raw = "0, 100.00, 35, 0, 1, 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
1,'G1',10,0,10,-10,1,0,0,100,0,1,0,0,1,1,100,20,0,0,1,1
0 / END OF GENERATOR DATA, BEGIN BRANCH DATA
1,2,'L1',0.01,0.1,0.0,100,100,100,0,0,0,0,1
0 / END OF BRANCH DATA, BEGIN SYSTEM SWITCHING DEVICE DATA
0 / END OF SYSTEM SWITCHING DEVICE DATA, BEGIN TRANSFORMER DATA
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA
0 / END OF AREA DATA, BEGIN SWITCHED SHUNT DATA
0 / END OF SWITCHED SHUNT DATA, BEGIN INDUCTION MACHINE DATA
0 / END OF INDUCTION MACHINE DATA, BEGIN SUBSTATION DATA
1,'SUB',0,0,0.1
1,'GEN',1,1,1,0
2,'LINE',1,1,1,0
3,'BUSBAR',1,1,1,0
0 / END OF SUBSTATION NODE DATA, BEGIN SUBSTATION SWITCHING DEVICE DATA
1,3,'S1','BREAKER',2,1,1,0,0,0,0
0 / END OF SUBSTATION SWITCHING DEVICE DATA, BEGIN SUBSTATION EQUIPMENT TERMINAL DATA
1,1,'M','G1'
1,2,'B',2,'L1'
0 / END OF SUBSTATION EQUIPMENT TERMINAL DATA
0 / END OF SUBSTATION DATA
Q
";
let parsed = parse_psse(raw).unwrap();
let detailed = parsed.detailed_connectivity().as_deref().unwrap();
assert_eq!(detailed.substations.len(), 1);
assert_eq!(detailed.connectivity_nodes.len(), 3);
assert_eq!(detailed.switches.len(), 1);
assert_eq!(detailed.busbar_sections.len(), 1);
let emitted = write_psse_rev(&parsed, 35).text;
assert!(emitted.contains("1, 'G1',"));
assert!(emitted.contains("1, 2, 'L1',"));
let reparsed = parse_psse(&emitted).unwrap();
let detailed = reparsed.detailed_connectivity().as_deref().unwrap();
assert_eq!(detailed.substations.len(), 1);
assert_eq!(detailed.connectivity_nodes.len(), 3);
assert_eq!(detailed.switches.len(), 1);
assert_eq!(detailed.busbar_sections.len(), 1);
}
#[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 = crate::format::emit_value_text(&net, crate::TargetFormat::Matpower).unwrap();
assert!(
mpc.render_diagnostics()
.iter()
.any(|w| w.contains("emergency voltage band")),
"MATPOWER write must warn on the dropped emergency band, got {:?}",
mpc.render_diagnostics()
);
}
#[test]
fn a_case_name_with_a_terminator_cannot_forge_a_bus_record() {
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
Q
";
let mut net = parse_psse(raw).unwrap();
*net.name_mut() =
"A\n42, 'INJECTED ', 500.0, 2, 9, 9, 1, 1.0, 0.0, 1.1, 0.9, 1.1, 0.9".to_owned();
let text = write_psse(&net).text;
let back = parse_psse(&text).unwrap();
assert_eq!(back.buses().len(), 1, "forged bus record in:\n{text}");
}
#[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_generator_defaults_do_not_become_source_metadata() {
let mut net = BalancedNetwork::new("generator defaults", 100.0);
net.buses_mut().push(test_bus(1, BusType::Ref));
net.generators_mut().push(Generator::new(BusId(1)));
for revision in [33, 34, 35] {
let back = parse_psse(&write_psse_rev(&net, revision).text).unwrap();
assert!(
back.detailed_connectivity().is_none(),
"revision {revision} retained generated generator fields"
);
}
}
#[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_mut()[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.render_diagnostics()
.iter()
.any(|w| w.contains("quoted PSS/E field")),
"expected a sanitization warning, got {:?}",
conv.render_diagnostics()
);
}
#[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}"
);
}
#[test]
fn a_bus_id_past_the_int64_ceiling_is_refused() {
let raw = r"0, 100.00, 33, 0, 0, 60.00 / synthetic out-of-range export
CASE
COMMENT
1e300,'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
Q
";
let err = parse_psse(raw).unwrap_err();
let text = err.to_string();
assert!(
text.contains("bus field I") && text.contains("outside the id range"),
"an out-of-range bus id should be refused: {err}"
);
}
fn impedance_winding_line(
windv: f64,
nomv: f64,
ang: f64,
cod: i32,
cont: u32,
tab: u32,
) -> String {
let limits = if cod.abs() == 3 {
"30.0, -30.0, 100.0, -100.0"
} else {
"1.1, 0.9, 1.1, 0.9"
};
format!(
"{windv}, {nomv}, {ang}, 100.0, 90.0, 80.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, {cod}, {cont}, 0, {limits}, 33, {tab}, 0.0, 0.0, 0.0"
)
}
fn impedance_correction_case() -> String {
let main = |i: u32, j: u32, k: u32, cw: u32, name: &str| {
format!(
"{i}, {j}, {k}, '1', {cw}, 1, 1, 0.0, 0.0, 2, '{name:<12}', 1, 1, 1.0, 0, 1.0, 0, 1.0, 0, 1.0, ' ', 0"
)
};
let w = impedance_winding_line;
let transformers = [
main(1, 2, 0, 1, "A"),
"0.01, 0.1, 100.0".to_owned(),
w(1.0, 0.0, -20.0, 3, 0, 1),
"1.0, 0.0".to_owned(),
main(2, 4, 0, 1, "B"),
"0.01, 0.1, 100.0".to_owned(),
w(1.0, 0.0, 30.0, 0, 0, 1),
"1.0, 0.0".to_owned(),
main(2, 3, 0, 1, "C"),
"0.02, 0.2, 100.0".to_owned(),
w(1.05, 0.0, 0.0, 1, 3, 2),
"1.0, 0.0".to_owned(),
main(1, 4, 0, 2, "D"),
"0.02, 0.2, 100.0".to_owned(),
w(241.5, 230.0, 0.0, 0, 0, 4),
"230.0, 230.0".to_owned(),
main(3, 4, 0, 1, "E"),
"0.02, 0.2, 100.0".to_owned(),
w(1.0, 0.0, 0.0, 0, 0, 9),
"1.0, 0.0".to_owned(),
main(1, 3, 5, 1, "T3W"),
"0.01, 0.1, 100.0, 0.02, 0.2, 100.0, 0.03, 0.3, 100.0, 1.0, 0.0".to_owned(),
w(1.0, 0.0, 0.0, 0, 0, 0),
w(1.0, 0.0, 0.0, 0, 0, 0),
w(0.95, 0.0, 0.0, 0, 0, 2),
]
.join("\n");
format!(
"0, 100.00, 35, 0, 1, 60.00 / synthetic
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 ', 230.0,1,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
3,'B3 ', 115.0,1,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 ', 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 SYSTEM SWITCHING DEVICE DATA
0 / END OF SYSTEM SWITCHING DEVICE DATA, BEGIN TRANSFORMER DATA
{transformers}
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
0 / END OF VSC DC LINE DATA, BEGIN IMPEDANCE CORRECTION DATA
1, -60.0, 1.5, 0.1, -30.0, 1.2, 0.0, 0.0, 1.0, 0.0
30.0, 1.2, 0.0, 60.0, 1.5, -0.1, 0.0, 0.0, 0.0
2, 0.9, 1.1, 0.0, 1.0, 1.0, 0.0, 1.1, 1.05, 0.0, 0.0, 0.0, 0.0
3, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0
4, 0.9, 1.1, 0.0, 1.0, 1.0, 0.0, 1.1, 1.05, 0.0, 0.0, 0.0, 0.0
0 / END OF IMPEDANCE CORRECTION DATA, BEGIN MULTI-TERMINAL DC DATA
Q
"
)
}
fn correction_values(correction: &ImpedanceCorrection) -> Vec<(f64, f64, f64)> {
correction
.points
.iter()
.map(|point| (point.value, point.factor_re, point.factor_im))
.collect()
}
fn transformer_corrections(net: &BalancedNetwork) -> Vec<Option<ImpedanceCorrection>> {
net.branches()
.iter()
.map(Branch::impedance_correction)
.chain(
(0..3)
.map(|winding| net.transformers_3w()[0].winding_impedance_correction(winding)),
)
.collect()
}
#[test]
fn impedance_correction_tables_attach_to_the_windings_that_name_them() {
let mut warnings = Diagnostics::new();
let net = parse_psse_source(&impedance_correction_case(), None, &mut warnings).unwrap();
let angle = [
(-60.0, 1.5, 0.1),
(-30.0, 1.2, 0.0),
(0.0, 1.0, 0.0),
(30.0, 1.2, 0.0),
(60.0, 1.5, -0.1),
];
let ratio = [(0.9, 1.1, 0.0), (1.0, 1.0, 0.0), (1.1, 1.05, 0.0)];
let corrections = transformer_corrections(&net);
let [
shifter,
fixed,
voltage,
kv,
dangling,
winding1,
winding2,
winding3,
] = corrections.as_slice()
else {
panic!("five transformers and three windings");
};
for (correction, table, variable, points) in [
(shifter, 1, ImpedanceCorrectionVariable::Angle, &angle[..]),
(fixed, 1, ImpedanceCorrectionVariable::Angle, &angle[..]),
(voltage, 2, ImpedanceCorrectionVariable::Ratio, &ratio[..]),
(winding3, 2, ImpedanceCorrectionVariable::Ratio, &ratio[..]),
] {
let correction = correction.as_ref().unwrap();
assert_eq!(correction.table, table);
assert_eq!(correction.variable, variable);
assert_eq!(correction_values(correction), points);
}
let kv = kv.as_ref().unwrap();
assert_eq!(kv.variable, ImpedanceCorrectionVariable::Ratio);
for (point, expected) in kv.points.iter().zip([0.9, 1.0, 1.1]) {
close(point.value, expected);
}
assert_eq!((dangling, winding1, winding2), (&None, &None, &None));
let lines = warnings.lines();
assert!(
lines
.iter()
.any(|line| line.contains("READ.PSSE.REFERENCE_DROPPED")
&& line.contains("table(s) 9")),
"{lines:?}"
);
assert!(
lines.iter().any(|line| line
.contains("1 PSS/E impedance correction table(s) that no transformer names")),
"{lines:?}"
);
assert!(
!lines
.iter()
.any(|line| line.contains("IMPEDANCE CORRECTION section")),
"the section is read: {lines:?}"
);
for revision in [34, 35] {
let emitted = write_psse_rev(&net, revision);
let back = parse_psse(&emitted.text).unwrap();
assert_eq!(
transformer_corrections(&back),
corrections,
"revision {revision}"
);
}
let rev33 = write_psse_rev(&net, 33);
assert!(
rev33.render_diagnostics().iter().any(|line| {
line.contains("EMIT.PSSE.VALUE_COLLAPSED") && line.contains("table 1")
}),
"{:?}",
rev33.render_diagnostics()
);
let back = parse_psse(&rev33.text).unwrap();
let back_a = back.branches()[0].impedance_correction().unwrap();
assert_eq!(
correction_values(&back_a),
angle.map(|(value, re, _)| (value, re, 0.0))
);
}
#[test]
fn impedance_correction_applies_at_the_winding_position_once() {
let mut net = parse_psse(&impedance_correction_case()).unwrap();
assert_eq!(net.apply_impedance_correction(), 5);
let branch =
|net: &BalancedNetwork, row: usize| (net.branches()[row].r, net.branches()[row].x);
let (r, x) = branch(&net, 0);
close(r, 0.01 * (1.2 - 0.2 / 3.0));
close(x, 0.1 * (1.2 - 0.2 / 3.0));
close(branch(&net, 1).1, 0.1 * 1.2);
close(branch(&net, 2).1, 0.2 * 1.025);
close(branch(&net, 3).1, 0.2 * 1.025);
close(branch(&net, 4).1, 0.2);
let z = net.transformers_3w()[0].z;
close(z[0].x, 0.1);
close(z[1].x, 0.0 + 0.2 * 1.05);
close(z[2].x, 0.2 * 1.05 + 0.1);
close(z[2].r, 0.02 * 1.05 + 0.01);
let corrected = net.clone();
assert_eq!(net.apply_impedance_correction(), 5);
assert_eq!(
net.branches(),
corrected.branches(),
"a second call changes nothing"
);
assert_eq!(net.transformers_3w(), corrected.transformers_3w());
let text = write_psse_rev(&net, 35).text;
let mut back = parse_psse(&text).unwrap();
close(back.branches()[0].x, 0.1);
close(back.transformers_3w()[0].z[1].x, 0.2);
back.apply_impedance_correction();
for (row, (before, after)) in net.branches().iter().zip(back.branches()).enumerate() {
close(after.r, before.r);
assert!((after.x - before.x).abs() < 1e-12, "transformer {row}");
}
net.branches_mut()[2].tap = 1.1;
net.apply_impedance_correction();
close(net.branches()[2].x, 0.2 * 1.05);
}
#[test]
#[allow(clippy::float_cmp)] fn impedance_correction_tables_follow_a_winding_2_ratio_off_one() {
let raw = format!(
"0, 100.00, 35, 0, 1, 60.00 / synthetic
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 ', 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 SYSTEM SWITCHING DEVICE DATA
0 / END OF SYSTEM SWITCHING DEVICE DATA, BEGIN TRANSFORMER DATA
1, 2, 0, '1', 1, 1, 1, 0.0, 0.0, 2, 'T ', 1, 1, 1.0, 0, 1.0, 0, 1.0, 0, 1.0, ' ', 0
0.02, 0.2, 100.0
{}
0.98, 0.0
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
0 / END OF VSC DC LINE DATA, BEGIN IMPEDANCE CORRECTION DATA
2, 0.9, 1.1, 0.0, 1.0, 1.0, 0.0, 1.1, 1.05, 0.0, 0.0, 0.0, 0.0
0 / END OF IMPEDANCE CORRECTION DATA, BEGIN MULTI-TERMINAL DC DATA
Q
",
impedance_winding_line(1.05, 0.0, 0.0, 1, 2, 2)
);
let mut net = parse_psse(&raw).unwrap();
let t2: f64 = 0.98;
close(net.branches()[0].tap, 1.05 / t2);
let correction = net.branches()[0].impedance_correction().unwrap();
for (point, t1) in correction.points.iter().zip([0.9, 1.0, 1.1]) {
close(point.value, t1 / t2);
}
let read = net.clone();
net.apply_impedance_correction();
close(net.branches()[0].r, t2 * t2 * 1.025 * 0.02);
close(net.branches()[0].x, t2 * t2 * 1.025 * 0.2);
let text = write_psse_rev(&net, 35).text;
let mut back = parse_psse(&text).unwrap();
assert_eq!(
back.branches()[0].impedance_correction(),
read.branches()[0].impedance_correction()
);
assert_eq!(back.branches()[0].r, read.branches()[0].r);
assert_eq!(back.branches()[0].x, read.branches()[0].x);
back.apply_impedance_correction();
assert_eq!(back.branches()[0].r, net.branches()[0].r);
assert_eq!(back.branches()[0].x, net.branches()[0].x);
}
#[test]
fn revision_33_impedance_correction_tables_are_one_line_of_real_pairs() {
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
0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
1, 2, 0, '1', 1, 1, 1, 0.0, 0.0, 2, 'PS ', 1, 1, 1, 0, 1, 0, 1, 0, 1, ' '
0.01, 0.10, 100.0
1.0, 0.0, 15.0, 100.0, 90.0, 80.0, 3, 0, 30.0, -30.0, 100.0, -100.0, 33, 7, 0, 0, 0
1.0, 0.0
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
0 / END OF VSC DC LINE DATA, BEGIN IMPEDANCE CORRECTION DATA
7, -30.0, 1.2, 0.0, 1.0, 30.0, 1.2, 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.0, 0.0, 0.0
0 / END OF IMPEDANCE CORRECTION DATA, BEGIN MULTI-TERMINAL DC DATA
Q
";
let mut net = parse_psse(raw).unwrap();
let correction = net.branches()[0].impedance_correction().unwrap();
assert_eq!(correction.table, 7);
assert_eq!(correction.variable, ImpedanceCorrectionVariable::Angle);
assert_eq!(
correction_values(&correction),
[(-30.0, 1.2, 0.0), (0.0, 1.0, 0.0), (30.0, 1.2, 0.0)]
);
assert_eq!(net.apply_impedance_correction(), 1);
close(net.branches()[0].x, 0.1 * 1.1);
let text = write_psse_rev(&net, 33).text;
let record = text
.lines()
.find(|line| line.starts_with("7, "))
.expect("table 7 is written");
assert_eq!(fields(record).len(), 23, "{record}");
assert_eq!(
parse_psse(&text).unwrap().branches()[0].impedance_correction(),
Some(correction)
);
}
fn correction_case_edit_transformer(
raw: &str,
name: &str,
edit: impl FnOnce(&mut [String]),
) -> String {
let mut lines: Vec<_> = raw.lines().map(str::to_owned).collect();
let row = lines
.iter()
.position(|line| line.contains(&format!("'{name:<12}'")))
.unwrap();
edit(&mut lines[row..]);
lines.join("\n")
}
fn correction_set_field(line: &mut String, index: usize, value: impl ToString + Copy) {
let mut fields: Vec<_> = line.split(',').map(str::trim).map(str::to_owned).collect();
fields[index] = value.to_string();
*line = fields.join(", ");
}
#[test]
fn impedance_correction_ratio_tables_use_bus_base_units_for_every_cw() {
for (cw, windv1, windv2) in [
(1, 1.05, 0.98),
(2, 241.5, 225.4),
(3, 241.5 / 220.0, 225.4 / 200.0),
] {
let raw =
correction_case_edit_transformer(&impedance_correction_case(), "D", |lines| {
correction_set_field(&mut lines[0], 4, cw);
lines[2] = impedance_winding_line(windv1, 220.0, 0.0, 0, 0, 4);
lines[3] = format!("{windv2}, 200.0");
});
let mut net = parse_psse(&raw).unwrap();
let branch = &net.branches()[3];
close(branch.tap, 1.05 / 0.98);
let correction = branch.impedance_correction().unwrap();
assert_eq!(correction.variable, ImpedanceCorrectionVariable::Ratio);
for (point, ratio) in correction.points.iter().zip([0.9, 1.0, 1.1]) {
close(point.value, ratio / 0.98);
}
net.apply_impedance_correction();
close(net.branches()[3].r, 0.02 * 0.98_f64.powi(2) * 1.025);
for revision in [33, 34, 35] {
let mut back = parse_psse(&write_psse_rev(&net, revision).text).unwrap();
back.apply_impedance_correction();
close(back.branches()[3].r, net.branches()[3].r);
close(back.branches()[3].tap, net.branches()[3].tap);
}
}
}
#[test]
fn impedance_correction_variable_follows_table_range_not_current_control() {
for shift in [0.0, 30.0] {
for (table, variable, factor) in [
(
1,
ImpedanceCorrectionVariable::Angle,
if shift == 0.0 { 1.0 } else { 1.2 },
),
(2, ImpedanceCorrectionVariable::Ratio, 1.0),
] {
let raw =
correction_case_edit_transformer(&impedance_correction_case(), "B", |lines| {
lines[2] = impedance_winding_line(1.0, 0.0, shift, 0, 0, table);
});
let mut net = parse_psse(&raw).unwrap();
assert_eq!(
net.branches()[1].impedance_correction().unwrap().variable,
variable
);
net.apply_impedance_correction();
close(net.branches()[1].r, 0.01 * factor);
let mut back = parse_psse(&write_psse_rev(&net, 35).text).unwrap();
assert_eq!(
back.branches()[1].impedance_correction().unwrap().variable,
variable
);
back.apply_impedance_correction();
close(back.branches()[1].r, net.branches()[1].r);
}
}
}
#[test]
fn impedance_correction_writer_keeps_all_eleven_angle_points_when_they_fit() {
let mut net = parse_psse(&impedance_correction_case()).unwrap();
let correction = ImpedanceCorrection::new(
8,
ImpedanceCorrectionVariable::Angle,
(0..11)
.map(|i| {
ImpedanceCorrectionPoint::new(
0.6 + f64::from(i) * 0.1,
1.0 + f64::from(i) * 0.02,
0.0,
)
})
.collect(),
);
net.branches_mut()[1].set_impedance_correction(Some(correction.clone()));
let written = write_psse_rev(&net, 33);
assert!(
!written
.render_diagnostics()
.iter()
.any(|message| message.contains("table 8"))
);
let back = parse_psse(&written.text).unwrap();
assert_eq!(back.branches()[1].impedance_correction(), Some(correction));
}
#[test]
fn impedance_correction_table_zero_is_renumbered_not_written_as_a_terminator() {
let mut net = parse_psse(&impedance_correction_case()).unwrap();
let correction = ImpedanceCorrection::new(
0,
ImpedanceCorrectionVariable::Ratio,
vec![
ImpedanceCorrectionPoint::new(0.9, 1.1, 0.0),
ImpedanceCorrectionPoint::new(1.1, 0.9, 0.0),
],
);
net.branches_mut()[1].set_impedance_correction(Some(correction.clone()));
let written = write_psse_rev(&net, 35);
assert!(
written
.render_diagnostics()
.iter()
.any(|message| message.contains("table 0"))
);
let back = parse_psse(&written.text).unwrap();
let read = back.branches()[1].impedance_correction().unwrap();
assert_ne!(read.table, 0);
assert_eq!(read.points, correction.points);
assert_eq!(
back.branches()
.iter()
.filter(|b| b.impedance_correction().is_some())
.count(),
net.branches()
.iter()
.filter(|b| b.impedance_correction().is_some())
.count()
);
}
#[test]
fn impedance_correction_writer_preserves_explicit_narrow_angle_tables() {
let mut net = parse_psse(&impedance_correction_case()).unwrap();
net.branches_mut()[1].shift = 0.8;
net.branches_mut()[1].set_impedance_correction(Some(ImpedanceCorrection::new(
8,
ImpedanceCorrectionVariable::Angle,
vec![
ImpedanceCorrectionPoint::new(0.6, 1.0, 0.0),
ImpedanceCorrectionPoint::new(1.0, 1.2, 0.0),
],
)));
net.apply_impedance_correction();
for revision in [33, 34, 35] {
let mut back = parse_psse(&write_psse_rev(&net, revision).text).unwrap();
assert_eq!(
back.branches()[1].impedance_correction().unwrap().variable,
ImpedanceCorrectionVariable::Angle
);
back.apply_impedance_correction();
close(back.branches()[1].r, 0.011);
for shift in [-10.0, 0.0, 0.7, 0.9, 10.0] {
let mut moved = net.clone();
moved.branches_mut()[1].shift = shift;
back.branches_mut()[1].shift = shift;
moved.apply_impedance_correction();
back.apply_impedance_correction();
close(back.branches()[1].r, moved.branches()[1].r);
}
}
}
#[test]
fn impedance_correction_writer_preserves_ratio_classification_and_referral() {
let raw = correction_case_edit_transformer(&impedance_correction_case(), "C", |lines| {
lines[3] = "0.5, 0.0".to_owned();
correction_set_field(&mut lines[0], 7, 0.012);
correction_set_field(&mut lines[0], 8, -0.023);
});
let mut net = parse_psse(&raw).unwrap();
net.apply_impedance_correction();
for revision in [33, 34, 35] {
let mut back = parse_psse(&write_psse_rev(&net, revision).text).unwrap();
let a = &net.branches()[2];
let b = &back.branches()[2];
assert_eq!(
b.impedance_correction().unwrap().variable,
ImpedanceCorrectionVariable::Ratio
);
close(b.tap, a.tap);
close(
b.calc_terminal_charging().calc_total_g(),
a.calc_terminal_charging().calc_total_g(),
);
close(
b.control.as_ref().unwrap().tap_min,
a.control.as_ref().unwrap().tap_min,
);
close(
b.control.as_ref().unwrap().tap_max,
a.control.as_ref().unwrap().tap_max,
);
back.apply_impedance_correction();
close(back.branches()[2].r, a.r);
close(back.branches()[2].x, a.x);
for tap in [1.7, 1.9, 2.0, 2.2, 2.4] {
let mut moved = net.clone();
moved.branches_mut()[2].tap = tap;
back.branches_mut()[2].tap = tap;
moved.apply_impedance_correction();
back.apply_impedance_correction();
close(back.branches()[2].r, moved.branches()[2].r);
}
}
}
fn pairwise_correction_case(cw: u32, controlling: usize) -> String {
correction_case_edit_transformer(&impedance_correction_case(), "T3W", |lines| {
correction_set_field(&mut lines[0], 4, cw);
correction_set_field(&mut lines[0], 11, 2); correction_set_field(&mut lines[0], 21, 1);
for (w, (base, nominal)) in [(230.0, 220.0), (115.0, 110.0), (13.8, 13.0)]
.into_iter()
.enumerate()
{
let ratio = if w == controlling { 0.95 } else { 1.02 };
let windv = match cw {
2 => ratio * base,
3 => ratio * base / nominal,
_ => ratio,
};
lines[2 + w] = impedance_winding_line(
windv,
nominal,
0.0,
if w == controlling { -1 } else { 0 },
1,
if w == 2 { 2 } else { 0 },
);
correction_set_field(&mut lines[2 + w], 14, 190.0); }
})
}
#[test]
fn impedance_correction_zcod_one_uses_adjustable_winding_and_pairwise_impedances() {
for cw in [1, 2, 3] {
for controlling in 0..3 {
let raw = pairwise_correction_case(cw, controlling);
let mut net = parse_psse(&raw).unwrap();
let nominal = net.transformers_3w()[0].z;
assert_eq!(net.apply_impedance_correction(), 5);
let corrected = net.transformers_3w()[0].clone();
for (pair, (actual, nominal)) in corrected.z.iter().zip(nominal).enumerate() {
let factor = if pair == 2 { 1.05 } else { 1.0 };
close(actual.r, nominal.r * factor);
close(actual.x, nominal.x * factor);
}
net.apply_impedance_correction();
assert_eq!(net.transformers_3w()[0], corrected);
for revision in [33, 34, 35] {
let emission = write_psse_rev(&net, revision);
if revision < 35 {
assert!(
emission
.render_diagnostics()
.iter()
.any(|message| message
.contains("ZCOD=1 impedance correction frozen"))
);
}
let mut back = parse_psse(&emission.text).unwrap();
back.apply_impedance_correction();
let t = &back.transformers_3w()[0];
assert_eq!(t.winding_in_service(), [true, false, true]);
for pair in 0..3 {
close(t.z[pair].r, corrected.z[pair].r);
close(t.z[pair].x, corrected.z[pair].x);
if revision >= 34 {
assert_eq!(
t.winding_rating_sets(pair),
corrected.winding_rating_sets(pair)
);
}
}
}
}
}
}
#[test]
fn impedance_correction_zcod_one_uses_the_adjustable_winding_angle() {
let raw =
correction_case_edit_transformer(&pairwise_correction_case(1, 0), "T3W", |lines| {
correction_set_field(&mut lines[2], 15, -3);
correction_set_field(&mut lines[2], 2, -45.0);
correction_set_field(&mut lines[4], 23, 1); });
let mut net = parse_psse(&raw).unwrap();
net.apply_impedance_correction();
let t = &net.transformers_3w()[0];
close(t.z[0].x, 0.1);
close(t.z[1].x, 0.2);
close(t.z[2].r, 0.03 * 1.35 - 0.3 * 0.05);
close(t.z[2].x, 0.3 * 1.35 + 0.03 * 0.05);
let mut back = parse_psse(&write_psse_rev(&net, 35).text).unwrap();
back.apply_impedance_correction();
close(back.transformers_3w()[0].z[2].r, t.z[2].r);
close(back.transformers_3w()[0].z[2].x, t.z[2].x);
}
#[test]
fn impedance_correction_zcod_one_skips_ambiguous_edited_controls() {
let mut net = parse_psse(&pairwise_correction_case(1, 0)).unwrap();
net.branches_mut().clear();
net.transformers_3w_mut()[0].windings[0].control = None;
let before = net.transformers_3w()[0].clone();
assert_eq!(net.apply_impedance_correction(), 0);
assert_eq!(net.transformers_3w()[0], before);
net.transformers_3w_mut()[0].windings[0].control =
Some(TransformerControl::new(TransformerControlMode::Voltage));
net.transformers_3w_mut()[0].windings[1].control =
Some(TransformerControl::new(TransformerControlMode::Voltage));
let before = net.transformers_3w()[0].clone();
assert_eq!(net.apply_impedance_correction(), 0);
assert_eq!(net.transformers_3w()[0], before);
}
fn has_diagnostic(emission: &TextEmission, text: &str) -> bool {
emission
.render_diagnostics()
.iter()
.any(|line| line.contains(text))
}
#[test]
fn impedance_correction_edit_after_apply_is_the_new_nominal() {
let mut net = parse_psse(&impedance_correction_case()).unwrap();
net.apply_impedance_correction();
assert!(!has_diagnostic(&write_psse_rev(&net, 35), "edited after"));
(net.branches_mut()[0].r, net.branches_mut()[0].x) = (0.05, 0.5);
net.transformers_3w_mut()[0].z[0].x = 0.4;
let emission = write_psse_rev(&net, 35);
assert!(has_diagnostic(
&emission,
"transformer 1-2 impedance was edited after its impedance correction was applied"
));
assert!(has_diagnostic(
&emission,
"three winding transformer 1-3-5 impedance was edited after"
));
let mut back = parse_psse(&emission.text).unwrap();
close(back.branches()[0].r, 0.05);
close(back.branches()[0].x, 0.5);
close(back.transformers_3w()[0].z[0].x, 0.4);
assert_eq!(
back.branches()[0].impedance_correction().map(|c| c.table),
Some(1)
);
net.apply_impedance_correction();
close(net.branches()[0].x, 0.5 * (1.2 - 0.2 / 3.0));
back.apply_impedance_correction();
for (written, read) in net.branches().iter().zip(back.branches()) {
close(read.r, written.r);
close(read.x, written.x);
}
for (written, read) in net.transformers_3w()[0]
.z
.iter()
.zip(back.transformers_3w()[0].z)
{
close(read.r, written.r);
close(read.x, written.x);
}
assert!(!has_diagnostic(&write_psse_rev(&net, 35), "edited after"));
}
#[test]
fn impedance_correction_zcod_one_without_a_control_winding_writes_a_readable_record() {
let mut net = parse_psse(&pairwise_correction_case(1, 0)).unwrap();
net.apply_impedance_correction();
net.transformers_3w_mut()[0].windings[0].control = None;
let present = net.transformers_3w()[0].z;
let emission = write_psse_rev(&net, 35);
assert!(has_diagnostic(
&emission,
"ZCOD=1 impedance correction frozen at its present impedances with TAB = 0"
));
let back = parse_psse(&emission.text).unwrap();
let t = &back.transformers_3w()[0];
assert_eq!(
(0..3)
.map(|w| t.winding_impedance_correction(w))
.collect::<Vec<_>>(),
[None, None, None]
);
for (read, written) in t.z.iter().zip(present) {
close(read.r, written.r);
close(read.x, written.x);
}
}
#[test]
fn impedance_correction_cleared_tables_write_no_tab() {
let mut net = parse_psse(&impedance_correction_case()).unwrap();
net.branches_mut()[0].set_impedance_correction(None);
net.transformers_3w_mut()[0].set_winding_impedance_correction(2, None);
let emission = write_psse_rev(&net, 35);
assert!(has_diagnostic(
&emission,
"3 impedance correction table reference(s) written as TAB = 0"
));
let mut warnings = Diagnostics::new();
let back = parse_psse_source(&emission.text, None, &mut warnings).unwrap();
assert!(
!warnings
.lines()
.iter()
.any(|line| line.contains("REFERENCE_DROPPED")),
"{:?}",
warnings.lines()
);
assert_eq!(back.branches()[0].extras.get("psse_tab"), None);
assert_eq!(back.branches()[0].impedance_correction(), None);
assert_eq!(back.transformers_3w()[0].extras.get("psse_tab3"), None);
assert_eq!(
back.branches()[1].impedance_correction().map(|c| c.table),
Some(1)
);
}
#[test]
fn rawx_writes_impedance_correction_tables_as_impcor() {
let net = parse_psse(&impedance_correction_case()).unwrap();
let emission = super::super::rawx::write_rawx(&net).unwrap();
assert!(
!has_diagnostic(&emission, "frozen"),
"{:?}",
emission.render_diagnostics()
);
let root: serde_json::Value = serde_json::from_str(&emission.text).unwrap();
let impcor = &root["network"]["impcor"];
assert_eq!(
impcor["fields"],
serde_json::json!(["itable", "tap", "refact", "imfact"])
);
let rows = impcor["data"].as_array().unwrap();
assert_eq!(rows.len(), 11, "{rows:?}");
assert_eq!(rows[0], serde_json::json!([1, -60.0, 1.5, 0.1]));
assert_eq!(rows[4], serde_json::json!([1, 60.0, 1.5, -0.1]));
assert_eq!(rows[5], serde_json::json!([2, 0.9, 1.1, 0.0]));
let mut warnings = Diagnostics::new();
let back =
super::super::rawx::parse_rawx_source(&emission.text, None, &mut warnings).unwrap();
assert_eq!(
transformer_corrections(&back),
transformer_corrections(&net)
);
let lines = warnings.lines();
assert!(
!lines.iter().any(|line| line.contains("impcor")),
"{lines:?}"
);
let (mut applied, mut back) = (net.clone(), back);
applied.apply_impedance_correction();
back.apply_impedance_correction();
for (read, written) in back.branches().iter().zip(applied.branches()) {
close(read.r, written.r);
close(read.x, written.x);
}
for (read, written) in back.transformers_3w()[0]
.z
.iter()
.zip(applied.transformers_3w()[0].z)
{
close(read.r, written.r);
close(read.x, written.x);
}
}
#[test]
fn impedance_correction_zcod_one_refuses_ambiguous_controlling_windings() {
for codes in [[0, 0, 0], [1, 1, 0]] {
let raw =
correction_case_edit_transformer(&pairwise_correction_case(1, 0), "T3W", |lines| {
for (w, cod) in codes.into_iter().enumerate() {
correction_set_field(&mut lines[2 + w], 15, cod);
}
});
assert!(
parse_psse(&raw)
.unwrap_err()
.to_string()
.contains("exactly one nonzero winding COD")
);
}
}
#[test]
fn impedance_correction_continuation_can_start_with_zero() {
let first = fields("7, 0.5, 1.1");
let mut lines = RawLines::new(concat!(
"0, 1.0, 1.05, 0.0, 0.0, 0.0\n",
"0 / END OF IMPEDANCE CORRECTION DATA\n",
"Q\n"
));
let mut warnings = Diagnostics::new();
let (table, points) =
read_impedance_correction(&first, 35, &mut lines, &mut warnings).unwrap();
assert_eq!(table, 7);
assert_eq!(points.len(), 2);
assert_eq!(points[0].value, 0.5);
assert_eq!(points[0].factor_re, 1.1);
assert_eq!(points[0].factor_im, 0.0);
assert_eq!(points[1].value, 1.0);
assert_eq!(points[1].factor_re, 1.05);
assert_eq!(points[1].factor_im, 0.0);
}
#[test]
fn impedance_correction_non_ascending_points_warn_and_sort() {
let first = fields("7, 30, 1.1, 0.0, -30, 1.2, 0.0, 0.0, 0.0, 0.0");
let mut lines = RawLines::new("Q\n");
let mut warnings = Diagnostics::new();
let (table, points) =
read_impedance_correction(&first, 35, &mut lines, &mut warnings).unwrap();
assert_eq!(table, 7);
assert_eq!(points.len(), 2);
assert_eq!(points[0].value, -30.0);
assert_eq!(points[1].value, 30.0);
assert!(
warnings
.lines()
.iter()
.any(|line| line.contains("non-ascending T values"))
);
assert_eq!(
impedance_table_variable(&points),
ImpedanceCorrectionVariable::Angle
);
}
#[test]
fn impedance_correction_point_can_straddle_raw_continuation_lines() {
let first = fields("7, 0.5, 1.1");
let mut lines = RawLines::new("1.2, 1.0, 0.0, 1.5, 1.2, 0.0, 0.0, 0.0, 0.0\nQ\n");
let mut warnings = Diagnostics::new();
let (table, points) =
read_impedance_correction(&first, 35, &mut lines, &mut warnings).unwrap();
assert_eq!(table, 7);
assert_eq!(points.len(), 3);
assert_eq!(points[0].value, 0.5);
assert_eq!(points[0].factor_re, 1.1);
assert_eq!(points[0].factor_im, 1.2);
assert_eq!(points[1].value, 1.0);
assert_eq!(points[1].factor_re, 0.0);
assert_eq!(points[1].factor_im, 1.5);
assert_eq!(points[2].value, 1.2);
assert_eq!(points[2].factor_re, 0.0);
assert_eq!(points[2].factor_im, 0.0);
}
}