use crate::model::{DistNetwork, DistSourceFormat};
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ConversionSidecar {
pub path: String,
pub text: String,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Conversion {
pub text: String,
pub sidecars: Vec<ConversionSidecar>,
pub warnings: Vec<String>,
pub diagnostics: Vec<crate::diagnostics::StructuredDiagnostic>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum DistTargetFormat {
Dss,
BmopfJson,
PmdJson,
}
pub fn dist_target_from_name(name: &str) -> Option<DistTargetFormat> {
let key = canonical_key(name);
match key.as_str() {
"dss" | "opendss" => Some(DistTargetFormat::Dss),
"pmd" | "pmdjson" | "engineering" => Some(DistTargetFormat::PmdJson),
"bmopf" | "bmopfjson" => Some(DistTargetFormat::BmopfJson),
_ => None,
}
}
impl std::str::FromStr for DistTargetFormat {
type Err = crate::Error;
fn from_str(s: &str) -> crate::Result<Self> {
dist_target_from_name(s).ok_or_else(|| crate::Error::UnknownFormat(s.to_string()))
}
}
impl DistTargetFormat {
pub fn name(self) -> &'static str {
match self {
DistTargetFormat::Dss => "dss",
DistTargetFormat::PmdJson => "pmd-json",
DistTargetFormat::BmopfJson => "bmopf-json",
}
}
}
fn read(path: &std::path::Path) -> crate::Result<String> {
std::fs::read_to_string(path).map_err(|source| crate::Error::Io {
path: path.display().to_string(),
source,
})
}
fn canonical_key(name: &str) -> String {
name.to_ascii_lowercase()
.chars()
.filter(|c| *c != '-' && *c != '_')
.collect()
}
const DISTRIBUTION_ELEMENT_TABLES: &[&str] = &[
"capacitor",
"control_profile",
"generator",
"ibr",
"line",
"linecode",
"load",
"meta",
"shunt",
"switch",
"terminal_conventions",
"transformer",
"voltage_source",
];
const NOT_BMOPF_KEYS: &[&str] = &[
"baseMVA",
"branch",
"dcline",
"gen",
"per_unit",
"source_type",
"source_version",
"storage",
];
const PMD_MARKER: &str = "data_model";
#[allow(clippy::struct_excessive_bools)]
#[derive(Default)]
struct TopLevel {
is_object: bool,
pmd_marker: bool,
bus: bool,
dist_table: bool,
not_bmopf: bool,
}
impl<'de> serde::Deserialize<'de> for TopLevel {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct Probe;
impl<'de> serde::de::Visitor<'de> for Probe {
type Value = TopLevel;
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("a JSON document")
}
fn visit_map<A: serde::de::MapAccess<'de>>(
self,
mut map: A,
) -> Result<TopLevel, A::Error> {
let mut out = TopLevel {
is_object: true,
..TopLevel::default()
};
while let Some(key) = map.next_key::<std::borrow::Cow<'_, str>>()? {
let key = key.as_ref();
out.pmd_marker |= key == PMD_MARKER;
out.bus |= key == "bus";
out.dist_table |= DISTRIBUTION_ELEMENT_TABLES.contains(&key);
out.not_bmopf |= NOT_BMOPF_KEYS.contains(&key);
map.next_value::<serde::de::IgnoredAny>()?;
}
Ok(out)
}
fn visit_bool<E>(self, _: bool) -> Result<TopLevel, E> {
Ok(TopLevel::default())
}
fn visit_i64<E>(self, _: i64) -> Result<TopLevel, E> {
Ok(TopLevel::default())
}
fn visit_u64<E>(self, _: u64) -> Result<TopLevel, E> {
Ok(TopLevel::default())
}
fn visit_f64<E>(self, _: f64) -> Result<TopLevel, E> {
Ok(TopLevel::default())
}
fn visit_str<E>(self, _: &str) -> Result<TopLevel, E> {
Ok(TopLevel::default())
}
fn visit_unit<E>(self) -> Result<TopLevel, E> {
Ok(TopLevel::default())
}
fn visit_none<E>(self) -> Result<TopLevel, E> {
Ok(TopLevel::default())
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(
self,
mut seq: A,
) -> Result<TopLevel, A::Error> {
while seq.next_element::<serde::de::IgnoredAny>()?.is_some() {}
Ok(TopLevel::default())
}
}
deserializer.deserialize_any(Probe)
}
}
pub fn classify_distribution_json(text: &str) -> crate::Result<DistTargetFormat> {
let text = text.trim_start_matches('\u{feff}');
let unrecognized = |detail: &str| crate::Error::Json {
format: "distribution",
message: format!(
"not a recognized distribution document: {detail}. PMD ENGINEERING JSON \
carries `data_model`; BMOPF JSON carries a `bus` table beside one of \
{DISTRIBUTION_ELEMENT_TABLES:?}. Pass the format explicitly to override."
),
};
let Ok(top) = serde_json::from_str::<TopLevel>(text) else {
return Ok(DistTargetFormat::BmopfJson);
};
if !top.is_object {
return Err(unrecognized("the top level is not an object"));
}
if top.pmd_marker {
return Ok(DistTargetFormat::PmdJson);
}
if top.bus && top.dist_table && !top.not_bmopf {
return Ok(DistTargetFormat::BmopfJson);
}
Err(if top.bus && top.not_bmopf {
unrecognized(
"it carries a `bus` table with PowerModels keys beside it, so it is a \
transmission document; read it through the transmission hub",
)
} else if top.bus {
unrecognized("its `bus` table has no distribution element table beside it")
} else {
unrecognized("it carries no marker of either format")
})
}
pub(crate) const BOM_WARNING: &str =
"leading UTF-8 byte order mark removed; a same-format write returns the text without it";
fn parse_text(text: &str, format: DistTargetFormat) -> crate::Result<DistNetwork> {
let stripped = text.trim_start_matches('\u{feff}');
let mut net = match format {
DistTargetFormat::Dss => crate::dss::parse_dss_str(stripped),
DistTargetFormat::BmopfJson => crate::bmopf::parse_bmopf_str(stripped)?,
DistTargetFormat::PmdJson => crate::pmd::parse_pmd_str(stripped)?,
};
if stripped.len() != text.len() {
net.warnings.push(BOM_WARNING.to_owned());
}
Ok(net)
}
pub fn parse_str(text: &str, format: &str) -> crate::Result<DistNetwork> {
parse_text(text, format.parse::<DistTargetFormat>()?)
}
pub fn parse_file(
path: impl AsRef<std::path::Path>,
from: Option<&str>,
) -> crate::Result<DistNetwork> {
let path = path.as_ref();
let format = if let Some(from) = from {
from.parse::<DistTargetFormat>()?
} else {
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
match ext.as_str() {
"dss" => DistTargetFormat::Dss,
"json" => {
let text = read(path)?;
return parse_text(&text, classify_distribution_json(&text)?);
}
other => return Err(crate::Error::UnknownFormat(other.to_string())),
}
};
match format {
DistTargetFormat::Dss => crate::dss::parse_dss_file(path),
DistTargetFormat::BmopfJson | DistTargetFormat::PmdJson => parse_text(&read(path)?, format),
}
}
fn convert(net: &DistNetwork, target: DistTargetFormat) -> Conversion {
let conv = net.to_format(target);
let mut warnings = net.warnings.clone();
warnings.extend(conv.warnings);
Conversion {
text: conv.text,
sidecars: conv.sidecars,
warnings,
diagnostics: conv.diagnostics,
}
}
pub fn convert_str(text: &str, to: DistTargetFormat, format: &str) -> crate::Result<Conversion> {
Ok(convert(&parse_str(text, format)?, to))
}
pub fn convert_file(
path: impl AsRef<std::path::Path>,
to: DistTargetFormat,
from: Option<&str>,
) -> crate::Result<Conversion> {
Ok(convert(&parse_file(path, from)?, to))
}
impl DistTargetFormat {
fn matches(self, source: DistSourceFormat) -> bool {
matches!(
(self, source),
(DistTargetFormat::Dss, DistSourceFormat::Dss)
| (DistTargetFormat::BmopfJson, DistSourceFormat::BmopfJson)
| (DistTargetFormat::PmdJson, DistSourceFormat::PmdJson)
)
}
}
impl DistNetwork {
pub fn to_canonical_format(&self, format: DistTargetFormat) -> Conversion {
let mut conv = match format {
DistTargetFormat::Dss => crate::dss::write_dss(self),
DistTargetFormat::BmopfJson => crate::bmopf::write_bmopf_json(self),
DistTargetFormat::PmdJson => crate::pmd::write_pmd_json(self),
};
let routed = self
.lines
.iter()
.filter(|line| line.route.is_some())
.count();
if routed > 0 {
conv.warnings.push(format!(
"{routed} line route(s) dropped: {} has no polyline field",
format.name()
));
}
conv
}
pub fn to_format(&self, format: DistTargetFormat) -> Conversion {
if let (Some(source), Some(source_format)) = (&self.source, self.source_format) {
if format.matches(source_format) {
return Conversion {
text: source.as_ref().clone(),
sidecars: Vec::new(),
warnings: Vec::new(),
diagnostics: Vec::new(),
};
}
}
self.to_canonical_format(format)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn distribution_json_classifier_preserves_pmd_marker_and_bmopf_fallback() {
for doc in [
r#"{"data_model": "ENGINEERING"}"#,
r#"{"data_model": "MATHEMATICAL"}"#,
r#"{"data_model": 7}"#,
r#"{"data_model": null}"#,
] {
assert_eq!(
classify_distribution_json(doc).unwrap(),
DistTargetFormat::PmdJson,
"{doc}"
);
}
for doc in [
r#"{"bus": {}, "voltage_source": {}}"#,
r#"{"bus": {}, "line": {}, "linecode": {}}"#,
r#"{"bus": {}, "transformer": {}}"#,
r#"{"bus": {}, "capacitor": {}}"#,
r#"{"bus": {}, "generator": {}}"#,
r#"{"bus": {}, "ibr": {}}"#,
r#"{"bus": {}, "control_profile": {}}"#,
] {
assert_eq!(
classify_distribution_json(doc).unwrap(),
DistTargetFormat::BmopfJson,
"{doc}"
);
}
assert_eq!(
classify_distribution_json("{not json").unwrap(),
DistTargetFormat::BmopfJson
);
assert_eq!(
classify_distribution_json("\u{feff}{\"data_model\": \"ENGINEERING\"}").unwrap(),
DistTargetFormat::PmdJson
);
}
#[test]
fn a_powermodels_document_never_classifies_as_bmopf() {
let powermodels = r#"{"baseMVA": 100.0, "branch": {}, "bus": {}, "dcline": {},
"gen": {}, "load": {}, "name": "case14", "per_unit": true, "shunt": {},
"source_type": "matpower", "source_version": "2", "storage": {},
"switch": {}}"#;
assert!(classify_distribution_json(powermodels).is_err());
for marker in NOT_BMOPF_KEYS {
let doc = format!("{{\"bus\": {{}}, \"linecode\": {{}}, \"{marker}\": 1}}");
assert!(
classify_distribution_json(&doc).is_err(),
"`{marker}` must refuse the BMOPF reading: {doc}"
);
}
}
#[test]
fn shared_table_names_classify_as_bmopf_and_the_veto_still_refuses_powermodels() {
for doc in [
r#"{"bus": {}, "load": {}}"#,
r#"{"bus": {}, "shunt": {}}"#,
r#"{"bus": {}, "switch": {}}"#,
r#"{"bus": {}, "meta": {"frequency": 60}}"#,
] {
assert_eq!(
classify_distribution_json(doc).unwrap(),
DistTargetFormat::BmopfJson,
"{doc}"
);
}
for doc in [
r#"{"bus": {}, "load": {}, "baseMVA": 100.0}"#,
r#"{"bus": {}, "shunt": {}, "branch": {}}"#,
r#"{"bus": {}, "switch": {}, "per_unit": true}"#,
] {
assert!(classify_distribution_json(doc).is_err(), "{doc}");
}
}
#[test]
fn the_pmd_marker_wins_over_shared_element_tables() {
let both = r#"{"data_model": "ENGINEERING", "bus": {}, "line": {}, "linecode": {}}"#;
assert_eq!(
classify_distribution_json(both).unwrap(),
DistTargetFormat::PmdJson
);
}
#[test]
fn unclassifiable_documents_are_refused_with_a_reason() {
for (doc, needle) in [
(
r#"{"bus": {"data_model": {}}}"#,
"no distribution element table",
),
(r#"{"name": "data_model"}"#, "no marker of either format"),
("{}", "no marker of either format"),
("[]", "not an object"),
("null", "not an object"),
("3", "not an object"),
(r#""a string""#, "not an object"),
("true", "not an object"),
] {
let err = classify_distribution_json(doc).unwrap_err().to_string();
assert!(err.contains(needle), "{doc}: got {err}");
}
}
#[test]
fn the_probe_is_bounded_on_adversarial_shapes() {
let big = format!(
r#"{{"bus": {{}}, "linecode": {{}}, "junk": [{}]}}"#,
"0,".repeat(200_000) + "0"
);
assert_eq!(
classify_distribution_json(&big).unwrap(),
DistTargetFormat::BmopfJson
);
let mut keys = String::new();
for i in 0..50_000 {
use std::fmt::Write as _;
let _ = write!(keys, "\"k{i}\":0,");
}
let many = format!(r#"{{{keys}"bus":{{}},"linecode":{{}}}}"#);
assert_eq!(
classify_distribution_json(&many).unwrap(),
DistTargetFormat::BmopfJson
);
let deep = format!(
r#"{{"bus":{{}},"linecode":{{}},"junk":{}{}}}"#,
"[".repeat(20_000),
"]".repeat(20_000)
);
assert_eq!(
classify_distribution_json(&deep).unwrap(),
DistTargetFormat::BmopfJson
);
assert_eq!(
classify_distribution_json(
r#"{"data_model":"ENGINEERING","data_model":"ENGINEERING"}"#
)
.unwrap(),
DistTargetFormat::PmdJson
);
}
#[test]
fn a_document_the_probe_accepts_is_refused_by_the_reader_not_a_crash() {
for depth in [200usize, 20_000, 500_000] {
let doc = format!(
"{{\"bus\":{{}},\"linecode\":{{}},\"junk\":{}{}}}",
"[".repeat(depth),
"]".repeat(depth)
);
let format = classify_distribution_json(&doc).expect("markers are present");
assert_eq!(format, DistTargetFormat::BmopfJson);
let err = crate::parse_str(&doc, format.name())
.expect_err("the reader refuses past its recursion limit");
assert!(
err.to_string().contains("recursion limit"),
"depth {depth}: {err}"
);
}
}
#[test]
fn marker_matching_is_case_sensitive() {
for doc in [
r#"{"Data_Model": "ENGINEERING"}"#,
r#"{"DATA_MODEL": "ENGINEERING"}"#,
r#"{"Bus": {}, "Linecode": {}}"#,
] {
assert!(classify_distribution_json(doc).is_err(), "{doc}");
}
}
#[test]
fn byte_order_mark_is_stripped_and_warned() {
let dss = "\u{feff}clear\nnew circuit.c basekv=12.47 bus1=src\n";
let net = parse_str(dss, "dss").unwrap();
assert!(
net.warnings.iter().any(|w| w.contains("byte order mark")),
"warnings: {:?}",
net.warnings
);
assert!(
net.source
.as_ref()
.is_some_and(|s| !s.starts_with('\u{feff}'))
);
}
#[test]
fn parse_file_rejects_unclassifiable_json() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("case.json");
std::fs::write(
&path,
r#"{"bus": {}, "branch": {}, "gen": {}, "baseMVA": 100.0}"#,
)
.unwrap();
let err = parse_file(&path, None).unwrap_err();
assert!(
err.to_string()
.contains("not a recognized distribution document"),
"{err}"
);
assert!(parse_file(&path, Some("bmopf-json")).is_ok());
}
#[test]
fn unknown_format_names_fail_before_any_work() {
assert!(matches!(
parse_str("", "matpower"),
Err(crate::Error::UnknownFormat(_))
));
assert!(matches!(
"matpower".parse::<DistTargetFormat>(),
Err(crate::Error::UnknownFormat(_))
));
assert!(matches!(
parse_file("missing.dss", Some("matpower")),
Err(crate::Error::UnknownFormat(_))
));
}
#[test]
fn one_shot_convert_carries_parse_warnings() {
let dss = "clear\nnew circuit.w basekv=12.47 bus1=src\n\
new line.l1 bus1=src bus2=b2 length=1 units=furlong\n";
let conv = convert_str(dss, DistTargetFormat::BmopfJson, "dss").unwrap();
assert!(
conv.warnings.iter().any(|w| w.contains("furlong")),
"parse warnings must surface through the one-shot converter: {:?}",
conv.warnings
);
}
#[test]
fn canonical_format_bypasses_same_format_dss_echo() {
let src = "Clear\n\
New Circuit.c basekv=12.47 bus1=sourcebus\n\
New Load.l1 bus1=sourcebus.1 phases=1 conn=wye kv=7.2 kw=10 kvar=2\n";
let net = parse_str(src, "dss").unwrap();
assert_eq!(net.to_format(DistTargetFormat::Dss).text, src);
let canonical = net.to_canonical_format(DistTargetFormat::Dss);
assert_ne!(canonical.text, src);
assert!(
canonical
.text
.lines()
.any(|l| l.contains("Load.l1") && l.contains("vminpu=0")),
"{}",
canonical.text
);
}
}