use std::collections::{BTreeSet, HashMap};
use std::fmt;
use std::str::FromStr;
use std::sync::Arc;
use serde_json::{Map, Value};
use crate::gen_cost::{GenCostPatch, MissingGenCostPolicy};
use crate::network::{Branch, BranchRatingSet, Bus, BusId, BusType, Network, SourceFormat};
use crate::{Error, Result};
use routing::{Detection, SourceFormat as DetectedFormat, TransmissionFormat};
mod egret;
mod goc3;
mod matpower;
mod pandapower;
mod powermodels;
pub mod powerworld;
mod pslf;
mod psse;
mod pypsa;
pub mod routing;
mod surge;
pub use egret::{parse_egret_json, write_egret_json};
#[doc(hidden)]
pub use goc3::bridge as goc3_bridge;
pub use goc3::parse_goc3_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, write_pslf};
pub use psse::{parse_psse, write_psse, write_psse_rev};
pub use pypsa::{PypsaCsvOutputs, read_pypsa_csv_folder, write_pypsa_csv_folder};
pub use surge::{parse_surge_json, write_surge_json};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum TargetFormat {
PowerModelsJson,
EgretJson,
Psse { rev: u32 },
PowerWorld,
PandapowerJson,
Matpower,
PowerioJson,
Pslf,
Goc3Json,
SurgeJson,
}
impl TargetFormat {
#[must_use]
pub fn extension(self) -> &'static str {
match self {
TargetFormat::PowerModelsJson
| TargetFormat::EgretJson
| TargetFormat::PandapowerJson
| TargetFormat::PowerioJson
| TargetFormat::Goc3Json
| TargetFormat::SurgeJson => "json",
TargetFormat::Psse { .. } => "raw",
TargetFormat::PowerWorld => "aux",
TargetFormat::Matpower => "m",
TargetFormat::Pslf => "epc",
}
}
#[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",
TargetFormat::PowerioJson => "PowerIO JSON",
TargetFormat::Pslf => "PSLF .epc",
TargetFormat::Goc3Json => "GO Challenge 3 JSON",
TargetFormat::SurgeJson => "Surge JSON",
}
}
#[must_use]
pub fn token(self) -> &'static str {
match self {
TargetFormat::PowerModelsJson => "powermodels-json",
TargetFormat::EgretJson => "egret-json",
TargetFormat::Psse { rev: 34 } => "psse34",
TargetFormat::Psse { rev: 35 } => "psse35",
TargetFormat::Psse { .. } => "psse",
TargetFormat::PowerWorld => "powerworld",
TargetFormat::PandapowerJson => "pandapower-json",
TargetFormat::Matpower => "matpower",
TargetFormat::PowerioJson => "powerio-json",
TargetFormat::Pslf => "pslf",
TargetFormat::Goc3Json => "goc3-json",
TargetFormat::SurgeJson => "surge-json",
}
}
}
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 routing::transmission_format_from_name(name)? {
TransmissionFormat::Matpower => TargetFormat::Matpower,
TransmissionFormat::PowerModelsJson => TargetFormat::PowerModelsJson,
TransmissionFormat::EgretJson => TargetFormat::EgretJson,
TransmissionFormat::Psse => TargetFormat::Psse { rev: 33 },
TransmissionFormat::Psse34 => TargetFormat::Psse { rev: 34 },
TransmissionFormat::Psse35 => TargetFormat::Psse { rev: 35 },
TransmissionFormat::PowerWorld => TargetFormat::PowerWorld,
TransmissionFormat::PandapowerJson => TargetFormat::PandapowerJson,
TransmissionFormat::PowerioJson => TargetFormat::PowerioJson,
TransmissionFormat::Pslf => TargetFormat::Pslf,
TransmissionFormat::Goc3Json => TargetFormat::Goc3Json,
TransmissionFormat::SurgeJson => TargetFormat::SurgeJson,
TransmissionFormat::PypsaCsv | TransmissionFormat::Pwb | TransmissionFormat::Gridfm => {
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 { rev: 33 }),
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 = match fmt_hint {
Some(fmt) => fmt,
None => 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, &mut warnings)
}
TargetFormat::Psse { .. } => psse::parse_psse_source(source, name_hint, &mut warnings),
TargetFormat::PowerWorld => {
powerworld::parse_powerworld_source(source, name_hint, &mut warnings)
}
TargetFormat::EgretJson => egret::parse_egret_source(source, name_hint),
TargetFormat::PandapowerJson => {
pandapower::parse_pandapower_source(source, name_hint, &mut warnings)
}
TargetFormat::PowerioJson => Network::from_json(&source),
TargetFormat::Pslf => pslf::parse_pslf_source(source, name_hint, &mut warnings),
TargetFormat::Goc3Json => goc3::parse_goc3_source(source, name_hint, &mut warnings),
TargetFormat::SurgeJson => surge::parse_surge_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) -> Result<TargetFormat> {
match routing::classify_json_text(text) {
Detection::Known(DetectedFormat::Transmission(format)) => transmission_json_target(format),
Detection::Known(DetectedFormat::Distribution(format)) => {
Err(Error::UnknownFormat(format!(
"JSON looks like distribution `{}`; use the distribution parser or pass an explicit transmission format",
format.name()
)))
}
Detection::Ambiguous => Err(Error::UnknownFormat(
"ambiguous JSON markers; pass an explicit source format".into(),
)),
Detection::Unknown => Err(Error::UnknownFormat(
"cannot infer JSON format; pass an explicit source format".into(),
)),
}
}
fn transmission_json_target(format: TransmissionFormat) -> Result<TargetFormat> {
match format {
TransmissionFormat::PowerModelsJson => Ok(TargetFormat::PowerModelsJson),
TransmissionFormat::EgretJson => Ok(TargetFormat::EgretJson),
TransmissionFormat::PandapowerJson => Ok(TargetFormat::PandapowerJson),
TransmissionFormat::PowerioJson => Ok(TargetFormat::PowerioJson),
TransmissionFormat::Goc3Json => Ok(TargetFormat::Goc3Json),
TransmissionFormat::SurgeJson => Ok(TargetFormat::SurgeJson),
other => Err(Error::UnknownFormat(format!(
"JSON classifier returned non-JSON transmission format `{}`",
other.name()
))),
}
}
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>,
}
#[derive(Debug, Clone, Default)]
pub struct WriteOptions {
pub missing_gen_cost: MissingGenCostPolicy,
pub gen_cost_patches: Vec<GenCostPatch>,
}
impl WriteOptions {
#[must_use]
pub fn is_default(&self) -> bool {
self.missing_gen_cost.is_preserve() && self.gen_cost_patches.is_empty()
}
}
pub fn write_as(net: &Network, format: TargetFormat) -> Result<Conversion> {
if is_echo(net, format) {
if let Some(src) = &net.source {
return Ok(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 { rev } => write_psse_rev(net, rev),
TargetFormat::PowerWorld => write_powerworld(net),
TargetFormat::PandapowerJson => write_pandapower_json(net),
TargetFormat::Matpower => matpower::write_matpower_conversion(net),
TargetFormat::PowerioJson => {
return net.to_json().map(|text| Conversion {
text,
warnings: net
.non_finite_fields()
.into_iter()
.map(|path| {
format!(
"{path} is not finite; JSON has no Inf/NaN, so it is written as \
null and this snapshot will not read back as powerio-json"
)
})
.collect(),
});
}
TargetFormat::Pslf => write_pslf(net),
TargetFormat::SurgeJson => write_surge_json(net),
TargetFormat::Goc3Json => {
return Err(Error::WriteUnsupported {
format: "goc3-json",
});
}
};
warn_normalized_tap(net, format, &mut conv);
warn_missing_reference(net, format, &mut conv);
warn_dropped_frequency(net, format, &mut conv);
warn_psse_downgrade(net, format, &mut conv);
warn_dropped_transformer_charging(net, format, &mut conv);
Ok(conv)
}
pub fn write_as_with_options(
net: &Network,
format: TargetFormat,
options: &WriteOptions,
) -> Result<Conversion> {
if options.is_default() {
return write_as(net, format);
}
let mut working = net.clone();
let report =
working.apply_gen_cost_policy(&options.gen_cost_patches, options.missing_gen_cost)?;
let mut policy_warnings = Vec::new();
if report.patched > 0 {
policy_warnings.push(format!(
"generator cost patch applied to {} generator(s)",
report.patched
));
}
if report.synthesized > 0 {
policy_warnings.push(match options.missing_gen_cost {
MissingGenCostPolicy::Fill {
c2,
c1,
c0,
startup,
shutdown,
} => format!(
"generator cost synthesized for {} generator(s): model 2, ncost 3, \
coeffs [{c2}, {c1}, {c0}], startup {startup}, shutdown {shutdown}",
report.synthesized
),
_ => unreachable!("only Fill synthesizes costs"),
});
}
if report.patched > 0 || report.synthesized > 0 {
working.source = None;
}
let mut conv = write_as(&working, format)?;
policy_warnings.append(&mut conv.warnings);
conv.warnings = policy_warnings;
Ok(conv)
}
pub(super) fn allocate_circuit_id<K: Ord + Clone>(
preferred: Option<&str>,
key: K,
used: &mut std::collections::BTreeMap<K, std::collections::BTreeSet<String>>,
) -> String {
let taken = used.entry(key).or_default();
if let Some(id) = preferred {
if taken.insert(id.to_owned()) {
return id.to_owned();
}
}
let mut n = 1u32;
loop {
let candidate = n.to_string();
if taken.insert(candidate.clone()) {
return candidate;
}
n += 1;
}
}
fn warn_psse_downgrade(net: &Network, format: TargetFormat, conv: &mut Conversion) {
if let (TargetFormat::Psse { rev }, SourceFormat::Psse, Some(src)) =
(format, net.source_format, net.source.as_ref())
{
let src_rev = psse::header_rev(src);
if src_rev > rev {
conv.warnings.push(format!(
"PSS/E source is revision {src_rev} but the write target is revision {rev}; \
the older layout drops fields the source carried (write to psse{src_rev} to keep them)"
));
}
}
}
fn warn_dropped_frequency(net: &Network, format: TargetFormat, conv: &mut Conversion) {
let carries_frequency = matches!(
format,
TargetFormat::Psse { .. } | TargetFormat::PandapowerJson
);
if carries_frequency {
return;
}
if (net.base_frequency - crate::network::DEFAULT_BASE_FREQUENCY).abs() > 1e-9 {
conv.warnings.push(format!(
"system base frequency {} Hz dropped: {} has no frequency field (reads back as {} Hz)",
net.base_frequency,
format.label(),
crate::network::DEFAULT_BASE_FREQUENCY
));
}
}
fn warn_dropped_transformer_charging(net: &Network, format: TargetFormat, conv: &mut Conversion) {
if !matches!(format, TargetFormat::Pslf) {
return;
}
let n = net
.branches
.iter()
.filter(|b| b.is_transformer() && b.legacy_total_charging_b() != 0.0)
.count();
if n > 0 {
conv.warnings.push(format!(
"{n} transformer(s) carry line charging that the PSLF .epc transformer \
record cannot represent; the charging was dropped"
));
}
}
pub(super) fn branch_rating_set_drop_warning(
target: &str,
branch_index: usize,
branch: &Branch,
rating: &BranchRatingSet,
) -> String {
format!(
"branch {} ({} to {}) rating set {}={} MVA dropped: {} has no field for branch rating sets beyond rate_a, rate_b, and rate_c",
branch_index + 1,
branch.from,
branch.to,
rating.name,
rating.rate_mva,
target
)
}
pub(super) fn warn_extra_branch_rating_sets(
target: &str,
net: &Network,
warnings: &mut Vec<String>,
) {
for (branch_index, branch) in net.branches.iter().enumerate() {
for rating in &branch.rating_sets {
warnings.push(branch_rating_set_drop_warning(
target,
branch_index,
branch,
rating,
));
}
}
}
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_file_with_options(
path: impl AsRef<std::path::Path>,
to: TargetFormat,
from: Option<&str>,
options: &WriteOptions,
) -> Result<Conversion> {
let parsed = parse_file(path, from)?;
let mut conv = write_as_with_options(&parsed.network, to, options)?;
if !is_echo(&parsed.network, to) || !options.is_default() {
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)
}
pub fn convert_str_with_options(
text: &str,
to: TargetFormat,
format: &str,
options: &WriteOptions,
) -> Result<Conversion> {
let parsed = parse_str(text, format)?;
let mut conv = write_as_with_options(&parsed.network, to, options)?;
if !is_echo(&parsed.network, to) || !options.is_default() {
conv.warnings.splice(0..0, parsed.warnings);
}
Ok(conv)
}
pub fn write_dir(
net: &Network,
to: &str,
out_dir: impl AsRef<std::path::Path>,
) -> Result<Vec<String>> {
if is_pypsa_csv_name(to) {
return write_pypsa_csv_folder(net, out_dir.as_ref()).map(|o| o.warnings);
}
Err(Error::UnknownFormat(format!(
"{to} is not a directory format (directory targets: pypsa-csv/pypsa); \
text formats serialize through write_as / to_format"
)))
}
fn warn_missing_reference(net: &Network, format: TargetFormat, conv: &mut Conversion) {
let needs_ref = matches!(
format,
TargetFormat::Matpower
| TargetFormat::Psse { .. }
| TargetFormat::PowerModelsJson
| TargetFormat::PandapowerJson
| TargetFormat::Pslf
| TargetFormat::SurgeJson
);
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 sanitize_quoted<'a>(
value: &'a str,
forbidden: &[char],
replacement: char,
) -> std::borrow::Cow<'a, str> {
if value.contains(forbidden) {
value
.chars()
.map(|c| {
if forbidden.contains(&c) {
replacement
} else {
c
}
})
.collect::<String>()
.into()
} else {
std::borrow::Cow::Borrowed(value)
}
}
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 {
let Some(src) = &net.source else { return false };
if !same_format(target, net.source_format) {
return false;
}
if let TargetFormat::Psse { rev } = target {
return psse::header_rev(src) == rev;
}
true
}
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)
| (TargetFormat::Pslf, SourceFormat::Pslf)
| (TargetFormat::Goc3Json, SourceFormat::Goc3Json)
| (TargetFormat::SurgeJson, SourceFormat::SurgeJson)
)
}
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 { rev: 33 }),
(SourceFormat::PowerWorld, TargetFormat::PowerWorld),
(SourceFormat::PandapowerJson, TargetFormat::PandapowerJson),
(SourceFormat::Pslf, TargetFormat::Pslf),
(SourceFormat::Goc3Json, TargetFormat::Goc3Json),
(SourceFormat::SurgeJson, TargetFormat::SurgeJson),
] {
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,
] {
assert_eq!(target_format_from_name(&format!("{sf:?}")), None);
}
}
}