use crate::model::{DistSourceFormat, MulticonductorNetwork};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TextSidecar {
pub(crate) path: String,
pub(crate) text: String,
}
#[derive(Debug, Clone)]
pub(crate) struct TextEmission {
pub(crate) text: String,
pub(crate) sidecars: Vec<TextSidecar>,
pub(crate) diagnostics: Vec<crate::diagnostics::Diagnostic>,
pub(crate) fidelity: powerio_core::Fidelity,
}
impl TextEmission {
pub(crate) fn new(
text: String,
sidecars: Vec<TextSidecar>,
diagnostics: crate::diagnostics::Diagnostics,
) -> Self {
Self {
text,
sidecars,
diagnostics: diagnostics.into_records(),
fidelity: powerio_core::Fidelity::Canonical,
}
}
pub(crate) fn faithful(text: String) -> Self {
let mut emission = Self::new(text, Vec::new(), crate::diagnostics::Diagnostics::new());
emission.fidelity = powerio_core::Fidelity::ExactSameFormat;
emission
}
pub(crate) fn push(
&mut self,
info: &'static crate::diagnostics::DiagnosticInfo,
message: impl Into<String>,
) {
self.diagnostics
.push(crate::diagnostics::Diagnostic::of(info, message));
}
#[cfg(test)]
pub(crate) fn render_diagnostics(&self) -> Vec<String> {
crate::diagnostics::render_diagnostics(&self.diagnostics)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum DistTargetFormat {
Dss,
BmopfJson,
PmdJson,
}
#[derive(Clone, Debug, Default, PartialEq)]
#[non_exhaustive]
pub struct EmitOptions {
pub dss: crate::dss::DssEmitOptions,
pub bmopf: crate::bmopf::BmopfEmitOptions,
}
impl EmitOptions {
fn is_default_for(&self, format: DistTargetFormat) -> bool {
match format {
DistTargetFormat::Dss => self.dss == crate::dss::DssEmitOptions::default(),
DistTargetFormat::BmopfJson => self.bmopf == crate::bmopf::BmopfEmitOptions::default(),
DistTargetFormat::PmdJson => true,
}
}
}
pub fn parse_dist_target_format(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> {
parse_dist_target_format(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 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 fn parse(
source: powerio_core::Source,
) -> std::result::Result<powerio_core::PioModule<MulticonductorNetwork>, powerio_core::Error> {
let mut warnings = crate::collect::Diagnostics::new();
match parse_to_network(&source, &mut warnings) {
Ok(network) => {
let format = *network.source_format();
let source =
match format.and_then(|format| powerio_core::FormatId::new(format.name()).ok()) {
Some(format) => source.with_format(format),
None => source,
};
powerio_core::PioModule::parsed(network, source, warnings.into_records())
}
Err(error) => {
let core = powerio_core::Error::new(error.code(), error.to_string());
Err(core
.with_diagnostics(warnings.into_records())
.with_cause(error)
.with_source(source))
}
}
}
fn parse_to_network(
source: &powerio_core::Source,
warnings: &mut crate::collect::Diagnostics,
) -> crate::Result<MulticonductorNetwork> {
if source.is_directory() {
return Err(crate::Error::UnknownFormat(format!(
"{} is a directory, and no distribution case format is a directory; pass a case file",
source.name()
)));
}
let declared = source
.format()
.map(powerio_core::FormatId::as_str)
.map(|name| {
parse_dist_target_format(name)
.ok_or_else(|| crate::Error::UnknownFormat(name.to_string()))
})
.transpose()?;
let format = if let Some(format) = declared {
format
} else {
let ext = std::path::Path::new(source.name())
.extension()
.and_then(|e| e.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
match ext.as_str() {
"dss" => DistTargetFormat::Dss,
"json" => {
let buffer = primary(source)?;
classify_distribution_json(source_text(&buffer)?)?
}
other => return Err(crate::Error::UnknownFormat(other.to_string())),
}
};
match format {
DistTargetFormat::Dss => crate::dss::read::parse_dss_collecting(source, warnings),
DistTargetFormat::BmopfJson => {
let buffer = primary(source)?;
crate::bmopf::read::parse_bmopf_collecting(source_text(&buffer)?, warnings)
}
DistTargetFormat::PmdJson => {
let buffer = primary(source)?;
crate::pmd::read::parse_pmd_collecting(source_text(&buffer)?, warnings)
}
}
}
fn primary(source: &powerio_core::Source) -> crate::Result<powerio_core::SourceBuffer> {
source
.primary_buffer()
.map_err(|error| crate::Error::FormatRead {
format: "case text",
message: error.to_string(),
})
}
fn source_text(buffer: &powerio_core::SourceBuffer) -> crate::Result<&str> {
std::str::from_utf8(buffer.content_bytes()).map_err(|e| crate::Error::FormatRead {
format: "case text",
message: format!("not valid UTF-8: {e}"),
})
}
impl DistTargetFormat {
fn matches(self, source: Option<DistSourceFormat>) -> bool {
matches!(
(self, source),
(DistTargetFormat::Dss, Some(DistSourceFormat::Dss))
| (
DistTargetFormat::BmopfJson,
Some(DistSourceFormat::BmopfJson)
)
| (DistTargetFormat::PmdJson, Some(DistSourceFormat::PmdJson))
)
}
}
pub(crate) fn emit_text_with_options(
module: &powerio_core::PioModule<MulticonductorNetwork>,
format: DistTargetFormat,
options: &EmitOptions,
) -> Result<TextEmission, powerio_core::Error> {
let output = if options.is_default_for(format)
&& let Some(text) = echo_text(module, format)
{
TextEmission::faithful(text)
} else {
crate::readiness::require_resolved_geometry(module.value())?;
emit_value_text_with_options(module.value(), format, options)
};
Ok(output)
}
fn echo_text(
module: &powerio_core::PioModule<MulticonductorNetwork>,
target: DistTargetFormat,
) -> Option<String> {
let source = module.source()?;
let buffer = source.primary_buffer().ok()?;
let declared = source
.format()
.and_then(|format| parse_dist_target_format(format.as_str()))?;
if declared != target || !target.matches(*module.value().source_format()) {
return None;
}
let text = std::str::from_utf8(buffer.bytes()).ok()?;
Some(text.to_owned())
}
pub(crate) fn emit_value_text_with_options(
net: &MulticonductorNetwork,
format: DistTargetFormat,
options: &EmitOptions,
) -> TextEmission {
let mut conv = match format {
DistTargetFormat::Dss => crate::dss::emit_dss_text_with_options(net, &options.dss),
DistTargetFormat::BmopfJson => {
crate::bmopf::emit_bmopf_json_text_with_options(net, options.bmopf)
}
DistTargetFormat::PmdJson => crate::pmd::emit_pmd_json_text(net),
};
let routed = net
.lines()
.iter()
.filter(|line| line.route.is_some())
.count();
if routed > 0 {
conv.push(
&crate::diagnostics::codes::EMIT_MULTICONDUCTOR_ROUTE_DROPPED,
format!(
"{routed} line route(s) dropped: {} has no polyline field",
format.name()
),
);
}
conv
}
#[cfg(test)]
pub(crate) fn emit_value_text(
net: &MulticonductorNetwork,
format: DistTargetFormat,
) -> TextEmission {
emit_value_text_with_options(net, format, &EmitOptions::default())
}
pub fn emit(
module: &powerio_core::PioModule<MulticonductorNetwork>,
format: DistTargetFormat,
destination: powerio_core::Destination,
) -> std::result::Result<powerio_core::EmitResult, powerio_core::Error> {
emit_with_options(module, format, &EmitOptions::default(), destination)
}
pub fn emit_with_options(
module: &powerio_core::PioModule<MulticonductorNetwork>,
format: DistTargetFormat,
options: &EmitOptions,
destination: powerio_core::Destination,
) -> std::result::Result<powerio_core::EmitResult, powerio_core::Error> {
let conv = emit_text_with_options(module, format, options)?;
match format {
DistTargetFormat::Dss => {
let mut artifacts = vec![powerio_core::MemoryArtifact::new(
powerio_core::ArtifactPath::new("case.dss")
.expect("static name is a valid artifact path"),
conv.text.into_bytes(),
)];
for sidecar in conv.sidecars {
artifacts.push(powerio_core::MemoryArtifact::new(
powerio_core::ArtifactPath::new(sidecar.path)?,
sidecar.text.into_bytes(),
));
}
destination.__commit_artifacts(true, conv.fidelity, artifacts, conv.diagnostics)
}
DistTargetFormat::BmopfJson | DistTargetFormat::PmdJson => {
let artifact = powerio_core::MemoryArtifact::new(
powerio_core::ArtifactPath::new("case")
.expect("static name is a valid artifact path"),
conv.text.into_bytes(),
);
destination.__commit_artifacts(false, conv.fidelity, vec![artifact], conv.diagnostics)
}
}
}
#[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::testkit::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_retained_and_echoed() {
let dss = "\u{feff}clear\nnew circuit.c basekv=12.47 bus1=src\n";
let net = crate::testkit::parse_dss_str(dss);
assert_eq!(net.name().as_deref(), Some("c"));
assert!(
!net.warnings.iter().any(|w| w.contains("byte order mark")),
"retaining the mark is not a loss: {:?}",
net.warnings
);
assert_eq!(net.emit(DistTargetFormat::Dss).text, dss);
assert!(
net.source
.as_ref()
.is_some_and(|s| !s.starts_with('\u{feff}'))
);
}
#[test]
fn memory_and_file_sources_parse_the_same_fixture_alike() {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../tests/data/dist/micro/onephase_zip_load.dss"
);
let bytes = std::fs::read(path).unwrap();
let from_bytes =
crate::testkit::parse_str(std::str::from_utf8(&bytes).unwrap(), "dss").unwrap();
let from_file = crate::testkit::parse_file(path, None).unwrap();
assert_eq!(from_bytes.buses().len(), from_file.buses().len());
assert_eq!(from_bytes.loads().len(), from_file.loads().len());
assert_eq!(from_bytes.source, from_file.source);
}
#[test]
fn non_utf8_bytes_are_refused_and_name_the_encoding() {
let bytes: &[u8] = b"clear\nnew circuit.caf\xE9 basekv=12.47 bus1=src\n";
let source = powerio_core::Source::from_memory("<memory>", bytes.to_vec())
.unwrap()
.with_format(powerio_core::FormatId::new("dss").unwrap());
let err = parse(source).unwrap_err();
assert!(err.to_string().contains("UTF-8"), "{err}");
}
#[test]
fn parse_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 = crate::testkit::parse_file(&path, None).unwrap_err();
assert!(
err.to_string()
.contains("not a recognized distribution document"),
"{err}"
);
assert!(crate::testkit::parse_file(&path, Some("bmopf-json")).is_ok());
}
#[test]
fn unknown_format_names_fail_before_any_work() {
assert!(matches!(
crate::testkit::parse_str("", "matpower"),
Err(crate::Error::UnknownFormat(_))
));
assert!(matches!(
"matpower".parse::<DistTargetFormat>(),
Err(crate::Error::UnknownFormat(_))
));
assert!(matches!(
crate::testkit::parse_file("missing.dss", Some("matpower")),
Err(crate::Error::UnknownFormat(_))
));
}
#[test]
fn parse_diagnostics_remain_on_the_module_when_it_is_emitted() {
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 source = powerio_core::Source::from_memory("<memory>", dss.as_bytes().to_vec())
.unwrap()
.with_format(powerio_core::FormatId::new("dss").unwrap());
let module = parse(source).unwrap();
let lines = crate::diagnostics::render_diagnostics(module.diagnostics());
assert!(
lines.iter().any(|w| w.contains("furlong")),
"parse diagnostics stay on PioModule: {lines:?}"
);
emit(
&module,
DistTargetFormat::BmopfJson,
powerio_core::Destination::memory("case.json").unwrap(),
)
.unwrap();
}
#[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 = crate::testkit::parse_dss_str(src);
assert_eq!(net.emit(DistTargetFormat::Dss).text, src);
let canonical = net.emit_value(DistTargetFormat::Dss);
assert_ne!(canonical.text, src);
assert!(
canonical
.text
.lines()
.any(|l| l.contains("Load.l1") && l.contains("vminpu=0")),
"{}",
canonical.text
);
}
}