use crate::model::{DistNetwork, DistSourceFormat};
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Conversion {
pub text: String,
pub warnings: Vec<String>,
}
#[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()
}
fn has_top_level_key(text: &str, key: &str) -> bool {
serde_json::from_str::<serde_json::Value>(text).is_ok_and(|value| {
value
.as_object()
.is_some_and(|shape| shape.contains_key(key))
})
}
fn infer_distribution_json_format(text: &str) -> DistTargetFormat {
if has_top_level_key(text, "data_model") {
DistTargetFormat::PmdJson
} else {
DistTargetFormat::BmopfJson
}
}
pub fn parse_str(text: &str, format: &str) -> crate::Result<DistNetwork> {
match format.parse::<DistTargetFormat>()? {
DistTargetFormat::Dss => Ok(crate::dss::parse_dss_str(text)),
DistTargetFormat::BmopfJson => crate::bmopf::parse_bmopf_str(text),
DistTargetFormat::PmdJson => crate::pmd::parse_pmd_str(text),
}
}
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 if infer_distribution_json_format(&text) == DistTargetFormat::PmdJson {
crate::pmd::parse_pmd_str(&text)
} else {
crate::bmopf::parse_bmopf_str(&text)
};
}
other => return Err(crate::Error::UnknownFormat(other.to_string())),
}
};
match format {
DistTargetFormat::Dss => crate::dss::parse_dss_file(path),
DistTargetFormat::BmopfJson => crate::bmopf::parse_bmopf_str(&read(path)?),
DistTargetFormat::PmdJson => crate::pmd::parse_pmd_str(&read(path)?),
}
}
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,
warnings,
}
}
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_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(),
warnings: Vec::new(),
};
}
}
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),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn distribution_json_classifier_preserves_pmd_marker_and_bmopf_fallback() {
assert_eq!(
infer_distribution_json_format(r#"{"data_model": "ENGINEERING"}"#),
DistTargetFormat::PmdJson
);
assert_eq!(
infer_distribution_json_format(r#"{"bus": {"data_model": {}}}"#),
DistTargetFormat::BmopfJson
);
assert_eq!(
infer_distribution_json_format(r#"{"name": "data_model"}"#),
DistTargetFormat::BmopfJson
);
assert_eq!(
infer_distribution_json_format("{not json"),
DistTargetFormat::BmopfJson
);
}
#[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
);
}
}