use std::collections::{BTreeSet, HashMap};
use std::fmt;
use std::str::FromStr;
use std::sync::Arc;
use serde_json::{Map, Value};
use crate::network::{Bus, BusId, BusType, Network, SourceFormat};
use crate::{Error, Result};
mod egret;
mod matpower;
mod pandapower;
mod powermodels;
pub mod powerworld;
mod pslf;
mod psse;
mod pypsa;
pub use egret::{parse_egret_json, write_egret_json};
pub use matpower::{parse_matpower, parse_matpower_file, write_matpower};
pub use pandapower::{parse_pandapower_json, write_pandapower_json};
pub use powermodels::{parse_powermodels_json, write_powermodels_json};
pub use powerworld::{PwdDisplay, PwdSubstation, parse_powerworld, write_powerworld};
pub use pslf::parse_pslf;
pub use psse::{parse_psse, write_psse};
pub use pypsa::{PypsaCsvOutputs, read_pypsa_csv_folder, write_pypsa_csv_folder};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum TargetFormat {
PowerModelsJson,
EgretJson,
Psse,
PowerWorld,
PandapowerJson,
Matpower,
}
impl TargetFormat {
#[must_use]
pub fn extension(self) -> &'static str {
match self {
TargetFormat::PowerModelsJson
| TargetFormat::EgretJson
| TargetFormat::PandapowerJson => "json",
TargetFormat::Psse => "raw",
TargetFormat::PowerWorld => "aux",
TargetFormat::Matpower => "m",
}
}
#[must_use]
pub fn label(self) -> &'static str {
match self {
TargetFormat::PowerModelsJson => "PowerModels JSON",
TargetFormat::EgretJson => "egret JSON",
TargetFormat::Psse => "PSS/E .raw",
TargetFormat::PowerWorld => "PowerWorld .aux",
TargetFormat::PandapowerJson => "pandapower JSON",
TargetFormat::Matpower => "MATPOWER .m",
}
}
#[must_use]
pub fn token(self) -> &'static str {
match self {
TargetFormat::PowerModelsJson => "powermodels-json",
TargetFormat::EgretJson => "egret-json",
TargetFormat::Psse => "psse",
TargetFormat::PowerWorld => "powerworld",
TargetFormat::PandapowerJson => "pandapower-json",
TargetFormat::Matpower => "matpower",
}
}
}
impl fmt::Display for TargetFormat {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.token())
}
}
impl FromStr for TargetFormat {
type Err = Error;
fn from_str(name: &str) -> Result<Self> {
target_format_from_name(name).ok_or_else(|| Error::UnknownFormat(name.to_string()))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum DisplayFormat {
PowerWorld,
}
impl DisplayFormat {
#[must_use]
pub fn extension(self) -> &'static str {
match self {
DisplayFormat::PowerWorld => "pwd",
}
}
#[must_use]
pub fn label(self) -> &'static str {
match self {
DisplayFormat::PowerWorld => "PowerWorld .pwd",
}
}
#[must_use]
pub fn token(self) -> &'static str {
match self {
DisplayFormat::PowerWorld => "powerworld-display",
}
}
}
impl fmt::Display for DisplayFormat {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.token())
}
}
impl FromStr for DisplayFormat {
type Err = Error;
fn from_str(name: &str) -> Result<Self> {
display_format_from_name(name).ok_or_else(|| Error::UnknownFormat(name.to_string()))
}
}
#[must_use]
pub fn display_format_from_name(name: &str) -> Option<DisplayFormat> {
Some(match name.to_ascii_lowercase().as_str() {
"pwd" | "powerworld-pwd" | "powerworld-display" => DisplayFormat::PowerWorld,
_ => return None,
})
}
#[must_use]
pub fn target_format_from_name(name: &str) -> Option<TargetFormat> {
Some(match name.to_ascii_lowercase().as_str() {
"matpower" | "m" => TargetFormat::Matpower,
"powermodels-json" | "powermodels" | "powermodelsjson" | "pm" => {
TargetFormat::PowerModelsJson
}
"egret-json" | "egret" | "egretjson" => TargetFormat::EgretJson,
"psse" | "raw" => TargetFormat::Psse,
"powerworld" | "aux" => TargetFormat::PowerWorld,
"pandapower-json" | "pandapower" | "pandapowerjson" | "pp" => TargetFormat::PandapowerJson,
_ => return None,
})
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum DisplayData {
PowerWorld(PwdDisplay),
}
impl DisplayData {
#[must_use]
pub fn format(&self) -> DisplayFormat {
match self {
DisplayData::PowerWorld(_) => DisplayFormat::PowerWorld,
}
}
}
fn display_file_guidance() -> Error {
Error::UnknownFormat(
"a PowerWorld .pwd is display data, not a Network case; \
use parse_display_file(path, None)"
.into(),
)
}
pub fn parse_display_bytes(bytes: &[u8], format: &str) -> Result<DisplayData> {
let fmt =
display_format_from_name(format).ok_or_else(|| Error::UnknownFormat(format.to_string()))?;
match fmt {
DisplayFormat::PowerWorld => Ok(DisplayData::PowerWorld(powerworld::parse_pwd_display(
bytes,
)?)),
}
}
pub fn parse_display_file(
path: impl AsRef<std::path::Path>,
from: Option<&str>,
) -> Result<DisplayData> {
let path = path.as_ref();
let fmt = match from {
Some(f) => {
display_format_from_name(f).ok_or_else(|| Error::UnknownFormat(f.to_string()))?
}
None => match path
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
.as_deref()
{
Some("pwd") => DisplayFormat::PowerWorld,
other => {
return Err(Error::UnknownFormat(format!(
"cannot infer display format from file extension {other:?}; \
pass an explicit display format"
)));
}
},
};
let bytes = std::fs::read(path)?;
match fmt {
DisplayFormat::PowerWorld => Ok(DisplayData::PowerWorld(powerworld::parse_pwd_display(
&bytes,
)?)),
}
}
fn is_pypsa_csv_name(name: &str) -> bool {
matches!(
name.to_ascii_lowercase().replace(['-', '_'], "").as_str(),
"pypsacsv" | "pypsa"
)
}
fn is_pslf_name(name: &str) -> bool {
matches!(
name.to_ascii_lowercase().replace(['-', '_'], "").as_str(),
"pslf" | "epc" | "pslfepc"
)
}
pub fn parse_file(path: impl AsRef<std::path::Path>, from: Option<&str>) -> Result<Parsed> {
let path = path.as_ref();
if from.is_some_and(is_pypsa_csv_name)
|| (from.is_none() && path.is_dir() && path.join("network.csv").is_file())
{
return pypsa::read_pypsa_csv_folder(path);
}
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase);
if from.is_some_and(|f| f.eq_ignore_ascii_case("pwb"))
|| (from.is_none() && ext.as_deref() == Some("pwb"))
{
let bytes = std::fs::read(path)?;
let stem = path.file_stem().and_then(|s| s.to_str());
let network = powerworld::parse_pwb(&bytes, stem)?;
return Ok(Parsed {
network,
warnings: Vec::new(),
});
}
if from.is_some_and(is_pslf_name) || (from.is_none() && ext.as_deref() == Some("epc")) {
let text = std::fs::read_to_string(path)?;
let stem = path.file_stem().and_then(|s| s.to_str());
let mut warnings = Vec::new();
let network = pslf::parse_pslf_source(Arc::new(text), stem, &mut warnings)?;
reject_empty_case(&network, "PSLF .epc")?;
return Ok(Parsed { network, warnings });
}
if from.is_none() && ext.as_deref() == Some("pwd") {
return Err(display_file_guidance());
}
let fmt_hint = match from {
Some(f) => {
if display_format_from_name(f).is_some() {
return Err(display_file_guidance());
}
Some(target_format_from_name(f).ok_or_else(|| Error::UnknownFormat(f.to_string()))?)
}
None => {
match ext.as_deref() {
Some("m") => Some(TargetFormat::Matpower),
Some("raw") => Some(TargetFormat::Psse),
Some("aux") => Some(TargetFormat::PowerWorld),
Some("json") => None,
other => {
return Err(Error::UnknownFormat(format!(
"cannot infer from file extension {other:?}; \
pass an explicit source format"
)));
}
}
}
};
let text = std::fs::read_to_string(path)?;
let fmt = fmt_hint.unwrap_or_else(|| sniff_json(&text));
let stem = path.file_stem().and_then(|s| s.to_str());
read_source(Arc::new(text), fmt, stem)
}
fn read_source(source: Arc<String>, fmt: TargetFormat, name_hint: Option<&str>) -> Result<Parsed> {
let mut warnings = Vec::new();
let net = match fmt {
TargetFormat::Matpower => matpower::parse_matpower_source(source, name_hint),
TargetFormat::PowerModelsJson => {
powermodels::parse_powermodels_json_source(source, name_hint)
}
TargetFormat::Psse => psse::parse_psse_source(source, name_hint),
TargetFormat::PowerWorld => powerworld::parse_powerworld_source(source, name_hint),
TargetFormat::EgretJson => egret::parse_egret_source(source, name_hint),
TargetFormat::PandapowerJson => {
pandapower::parse_pandapower_source(source, name_hint, &mut warnings)
}
}?;
reject_empty_case(&net, fmt.label())?;
Ok(Parsed {
network: net,
warnings,
})
}
pub(crate) fn reject_empty_case(net: &Network, format: &'static str) -> Result<()> {
if net.buses.is_empty() {
return Err(Error::FormatRead {
format,
message: "case has no buses".into(),
});
}
Ok(())
}
fn sniff_json(text: &str) -> TargetFormat {
use serde::de::IgnoredAny;
#[derive(serde::Deserialize)]
struct Shape {
#[serde(rename = "_class")]
class: Option<String>,
elements: Option<IgnoredAny>,
system: Option<IgnoredAny>,
}
match serde_json::from_str::<Shape>(text) {
Ok(Shape {
class: Some(class), ..
}) if class == "pandapowerNet" => TargetFormat::PandapowerJson,
Ok(Shape {
elements: Some(_),
system: Some(_),
..
}) => TargetFormat::EgretJson,
_ => TargetFormat::PowerModelsJson,
}
}
pub fn parse_str(text: &str, format: &str) -> Result<Parsed> {
if is_pslf_name(format) {
let mut warnings = Vec::new();
let network = pslf::parse_pslf_source(Arc::new(text.to_owned()), None, &mut warnings)?;
reject_empty_case(&network, "PSLF .epc")?;
return Ok(Parsed { network, warnings });
}
let fmt =
target_format_from_name(format).ok_or_else(|| Error::UnknownFormat(format.to_string()))?;
read_source(Arc::new(text.to_owned()), fmt, None)
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Parsed {
pub network: Network,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Conversion {
pub text: String,
pub warnings: Vec<String>,
}
#[must_use]
pub fn write_as(net: &Network, format: TargetFormat) -> Conversion {
if is_echo(net, format) {
if let Some(src) = &net.source {
return Conversion {
text: src.to_string(),
warnings: Vec::new(),
};
}
}
let mut conv = match format {
TargetFormat::PowerModelsJson => write_powermodels_json(net),
TargetFormat::EgretJson => write_egret_json(net),
TargetFormat::Psse => write_psse(net),
TargetFormat::PowerWorld => write_powerworld(net),
TargetFormat::PandapowerJson => write_pandapower_json(net),
TargetFormat::Matpower => matpower::write_matpower_conversion(net),
};
warn_normalized_tap(net, format, &mut conv);
warn_missing_reference(net, format, &mut conv);
conv
}
pub fn convert_file(
path: impl AsRef<std::path::Path>,
to: TargetFormat,
from: Option<&str>,
) -> Result<Conversion> {
let parsed = parse_file(path, from)?;
let mut conv = write_as(&parsed.network, to);
if !is_echo(&parsed.network, to) {
conv.warnings.splice(0..0, parsed.warnings);
}
Ok(conv)
}
pub fn convert_str(text: &str, to: TargetFormat, format: &str) -> Result<Conversion> {
let parsed = parse_str(text, format)?;
let mut conv = write_as(&parsed.network, to);
if !is_echo(&parsed.network, to) {
conv.warnings.splice(0..0, parsed.warnings);
}
Ok(conv)
}
fn warn_missing_reference(net: &Network, format: TargetFormat, conv: &mut Conversion) {
let needs_ref = matches!(
format,
TargetFormat::Matpower
| TargetFormat::Psse
| TargetFormat::PowerModelsJson
| TargetFormat::PandapowerJson
);
if needs_ref {
conv.warnings.extend(missing_reference_warning(net));
}
}
pub(super) fn missing_reference_warning(net: &Network) -> Option<String> {
(!net.buses.iter().any(|b| b.kind == BusType::Ref)).then(|| {
"no reference (slack) bus in the source network; power flow tools \
reject such cases — to_normalized synthesizes a slack at the \
largest pmax in service generator bus"
.to_string()
})
}
#[allow(clippy::float_cmp)]
fn warn_normalized_tap(net: &Network, format: TargetFormat, conv: &mut Conversion) {
if matches!(format, TargetFormat::Matpower) {
return;
}
conv.warnings.extend(normalized_tap_warning(net));
}
#[allow(clippy::float_cmp)]
pub(super) fn normalized_tap_warning(net: &Network) -> Option<String> {
if !net.is_normalized() {
return None;
}
let ambiguous = net
.branches
.iter()
.filter(|b| b.tap == 1.0 && b.shift == 0.0)
.count();
(ambiguous > 0).then(|| {
format!(
"normalized network: {ambiguous} branch(es) have unit tap and no phase \
shift, so the line/transformer label is not preserved (the power flow \
is identical)"
)
})
}
fn nonzero_differs(value: f64, reference: f64) -> bool {
value.abs() > f64::EPSILON && (value - reference).abs() > f64::EPSILON
}
pub(crate) fn set_bus_kind(
buses: &mut [Bus],
bus_pos: &HashMap<BusId, usize>,
bus: BusId,
kind: BusType,
) {
if let Some(&idx) = bus_pos.get(&bus) {
if buses[idx].kind != BusType::Isolated {
buses[idx].kind = kind;
}
}
}
pub(crate) fn bus_kv(buses: &[Bus], bus_pos: &HashMap<BusId, usize>, bus: BusId) -> f64 {
bus_pos
.get(&bus)
.and_then(|&i| buses.get(i))
.map_or(0.0, |b| b.base_kv)
}
pub(crate) fn zbase(v_kv: f64, base_mva: f64) -> f64 {
if v_kv > 0.0 && base_mva > 0.0 {
v_kv * v_kv / base_mva
} else {
1.0
}
}
fn is_echo(net: &Network, target: TargetFormat) -> bool {
same_format(target, net.source_format) && net.source.is_some()
}
fn same_format(target: TargetFormat, source: SourceFormat) -> bool {
matches!(
(target, source),
(TargetFormat::Matpower, SourceFormat::Matpower)
| (TargetFormat::PowerModelsJson, SourceFormat::PowerModelsJson)
| (TargetFormat::EgretJson, SourceFormat::EgretJson)
| (TargetFormat::Psse, SourceFormat::Psse)
| (TargetFormat::PowerWorld, SourceFormat::PowerWorld)
| (TargetFormat::PandapowerJson, SourceFormat::PandapowerJson)
)
}
pub(crate) fn jnum(x: f64) -> Value {
serde_json::Number::from_f64(x).map_or(Value::Null, Value::Number)
}
pub(crate) fn finish(root: Map<String, Value>, mut warnings: Vec<String>) -> Conversion {
let value = Value::Object(root);
let mut nulls = BTreeSet::new();
collect_null_keys(&value, &mut nulls);
if !nulls.is_empty() {
warnings.push(format!(
"non-finite numeric values written as JSON null in field(s): {}",
nulls.into_iter().collect::<Vec<_>>().join(", ")
));
}
let text = serde_json::to_string_pretty(&value).expect("a serde_json::Value always serializes");
Conversion { text, warnings }
}
fn collect_null_keys(value: &Value, out: &mut BTreeSet<String>) {
match value {
Value::Object(map) => {
for (key, val) in map {
if val.is_null() {
out.insert(key.clone());
} else {
collect_null_keys(val, out);
}
}
}
Value::Array(items) => items.iter().for_each(|v| collect_null_keys(v, out)),
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::network::SourceFormat;
#[test]
fn source_format_strings_round_trip_to_a_target() {
for (sf, want) in [
(SourceFormat::Matpower, TargetFormat::Matpower),
(SourceFormat::PowerModelsJson, TargetFormat::PowerModelsJson),
(SourceFormat::EgretJson, TargetFormat::EgretJson),
(SourceFormat::Psse, TargetFormat::Psse),
(SourceFormat::PowerWorld, TargetFormat::PowerWorld),
(SourceFormat::PandapowerJson, TargetFormat::PandapowerJson),
] {
let token = format!("{sf:?}");
assert_eq!(
target_format_from_name(&token),
Some(want),
"source_format {token:?} did not round-trip"
);
}
for sf in [
SourceFormat::InMemory,
SourceFormat::Normalized,
SourceFormat::Gridfm,
SourceFormat::PypsaCsv,
SourceFormat::PowerWorldBinary,
SourceFormat::Pslf,
] {
assert_eq!(target_format_from_name(&format!("{sf:?}")), None);
}
}
}