Skip to main content

powerio_dist/
convert.rs

1//! Cross format conversion output and the format dispatcher.
2
3use crate::model::{DistNetwork, DistSourceFormat};
4
5/// Text in the target format plus every fidelity loss the writer took.
6/// Nothing drops silently: a field the target cannot represent appears
7/// here as a warning naming the element and field.
8#[derive(Debug, Clone)]
9#[non_exhaustive]
10pub struct Conversion {
11    pub text: String,
12    pub warnings: Vec<String>,
13}
14
15/// A writable distribution format.
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17#[non_exhaustive]
18pub enum DistTargetFormat {
19    Dss,
20    BmopfJson,
21    PmdJson,
22}
23
24/// Resolves common names and file extensions to a target format.
25pub fn dist_target_from_name(name: &str) -> Option<DistTargetFormat> {
26    let key = canonical_key(name);
27    match key.as_str() {
28        "dss" | "opendss" => Some(DistTargetFormat::Dss),
29        "pmd" | "pmdjson" | "engineering" => Some(DistTargetFormat::PmdJson),
30        "bmopf" | "bmopfjson" => Some(DistTargetFormat::BmopfJson),
31        _ => None,
32    }
33}
34
35impl std::str::FromStr for DistTargetFormat {
36    type Err = crate::Error;
37
38    /// [`dist_target_from_name`] as a `Result`, matching the transmission
39    /// hub's `TargetFormat: FromStr`.
40    fn from_str(s: &str) -> crate::Result<Self> {
41        dist_target_from_name(s).ok_or_else(|| crate::Error::UnknownFormat(s.to_string()))
42    }
43}
44
45impl DistTargetFormat {
46    /// The canonical format name (`dss`, `pmd-json`, `bmopf-json`), accepted
47    /// back by [`dist_target_from_name`].
48    pub fn name(self) -> &'static str {
49        match self {
50            DistTargetFormat::Dss => "dss",
51            DistTargetFormat::PmdJson => "pmd-json",
52            DistTargetFormat::BmopfJson => "bmopf-json",
53        }
54    }
55}
56
57fn read(path: &std::path::Path) -> crate::Result<String> {
58    std::fs::read_to_string(path).map_err(|source| crate::Error::Io {
59        path: path.display().to_string(),
60        source,
61    })
62}
63
64fn canonical_key(name: &str) -> String {
65    name.to_ascii_lowercase()
66        .chars()
67        .filter(|c| *c != '-' && *c != '_')
68        .collect()
69}
70
71fn has_top_level_key(text: &str, key: &str) -> bool {
72    serde_json::from_str::<serde_json::Value>(text).is_ok_and(|value| {
73        value
74            .as_object()
75            .is_some_and(|shape| shape.contains_key(key))
76    })
77}
78
79/// Distribution parser policy for `.json`: PMD carries `data_model`; otherwise
80/// it is routed to BMOPF so the BMOPF reader can give the parse error or warning.
81fn infer_distribution_json_format(text: &str) -> DistTargetFormat {
82    if has_top_level_key(text, "data_model") {
83        DistTargetFormat::PmdJson
84    } else {
85        DistTargetFormat::BmopfJson
86    }
87}
88
89/// Parses `text` in the named format (see [`dist_target_from_name`]).
90pub fn parse_str(text: &str, format: &str) -> crate::Result<DistNetwork> {
91    match format.parse::<DistTargetFormat>()? {
92        DistTargetFormat::Dss => Ok(crate::dss::parse_dss_str(text)),
93        DistTargetFormat::BmopfJson => crate::bmopf::parse_bmopf_str(text),
94        DistTargetFormat::PmdJson => crate::pmd::parse_pmd_str(text),
95    }
96}
97
98/// Parses `path`, taking the format from `from` when given, the `.dss`
99/// extension otherwise, and for `.json` the shared distribution classifier.
100pub fn parse_file(
101    path: impl AsRef<std::path::Path>,
102    from: Option<&str>,
103) -> crate::Result<DistNetwork> {
104    let path = path.as_ref();
105    // Dss goes through the path-based parser (Redirect/Compile resolve
106    // against the file's directory); the JSON readers take text.
107    let format = if let Some(from) = from {
108        from.parse::<DistTargetFormat>()?
109    } else {
110        let ext = path
111            .extension()
112            .and_then(|e| e.to_str())
113            .unwrap_or_default()
114            .to_ascii_lowercase();
115        match ext.as_str() {
116            "dss" => DistTargetFormat::Dss,
117            "json" => {
118                let text = read(path)?;
119                return if infer_distribution_json_format(&text) == DistTargetFormat::PmdJson {
120                    crate::pmd::parse_pmd_str(&text)
121                } else {
122                    crate::bmopf::parse_bmopf_str(&text)
123                };
124            }
125            other => return Err(crate::Error::UnknownFormat(other.to_string())),
126        }
127    };
128    match format {
129        DistTargetFormat::Dss => crate::dss::parse_dss_file(path),
130        DistTargetFormat::BmopfJson => crate::bmopf::parse_bmopf_str(&read(path)?),
131        DistTargetFormat::PmdJson => crate::pmd::parse_pmd_str(&read(path)?),
132    }
133}
134
135/// Prepend the reader's parse warnings to the writer's fidelity warnings: the
136/// one-shot converters return no handle to query, so this is the only place
137/// the loud half of the parse can surface.
138fn convert(net: &DistNetwork, target: DistTargetFormat) -> Conversion {
139    let conv = net.to_format(target);
140    let mut warnings = net.warnings.clone();
141    warnings.extend(conv.warnings);
142    Conversion {
143        text: conv.text,
144        warnings,
145    }
146}
147
148/// Parses `text` as `format` and writes it as `to` in one call. The warnings
149/// carry both the parse warnings and the writer's fidelity losses.
150pub fn convert_str(text: &str, to: DistTargetFormat, format: &str) -> crate::Result<Conversion> {
151    Ok(convert(&parse_str(text, format)?, to))
152}
153
154/// Parses `path` (format from `from` or the file itself) and writes it as
155/// `to` in one call. The warnings carry both the parse warnings and the
156/// writer's fidelity losses.
157pub fn convert_file(
158    path: impl AsRef<std::path::Path>,
159    to: DistTargetFormat,
160    from: Option<&str>,
161) -> crate::Result<Conversion> {
162    Ok(convert(&parse_file(path, from)?, to))
163}
164
165impl DistTargetFormat {
166    fn matches(self, source: DistSourceFormat) -> bool {
167        matches!(
168            (self, source),
169            (DistTargetFormat::Dss, DistSourceFormat::Dss)
170                | (DistTargetFormat::BmopfJson, DistSourceFormat::BmopfJson)
171                | (DistTargetFormat::PmdJson, DistSourceFormat::PmdJson)
172        )
173    }
174}
175
176impl DistNetwork {
177    /// Writes the network in `format`.
178    ///
179    /// Writing back to the source format echoes the retained source text
180    /// byte for byte; every cross format write regenerates from the typed
181    /// model and reports each fidelity loss in the warnings. The returned
182    /// warnings hold only the writer's losses: parse warnings stay on
183    /// [`DistNetwork::warnings`] (the one-shot [`convert_str`]/[`convert_file`]
184    /// merge the two). After mutating a parsed model, set `source = None`
185    /// (and `source_format`), or the echo tier returns the original text
186    /// and silently discards the edits.
187    pub fn to_format(&self, format: DistTargetFormat) -> Conversion {
188        if let (Some(source), Some(source_format)) = (&self.source, self.source_format) {
189            if format.matches(source_format) {
190                return Conversion {
191                    text: source.as_ref().clone(),
192                    warnings: Vec::new(),
193                };
194            }
195        }
196        match format {
197            DistTargetFormat::Dss => crate::dss::write_dss(self),
198            DistTargetFormat::BmopfJson => crate::bmopf::write_bmopf_json(self),
199            DistTargetFormat::PmdJson => crate::pmd::write_pmd_json(self),
200        }
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn distribution_json_classifier_preserves_pmd_marker_and_bmopf_fallback() {
210        assert_eq!(
211            infer_distribution_json_format(r#"{"data_model": "ENGINEERING"}"#),
212            DistTargetFormat::PmdJson
213        );
214        assert_eq!(
215            infer_distribution_json_format(r#"{"bus": {"data_model": {}}}"#),
216            DistTargetFormat::BmopfJson
217        );
218        assert_eq!(
219            infer_distribution_json_format(r#"{"name": "data_model"}"#),
220            DistTargetFormat::BmopfJson
221        );
222        assert_eq!(
223            infer_distribution_json_format("{not json"),
224            DistTargetFormat::BmopfJson
225        );
226    }
227
228    #[test]
229    fn unknown_format_names_fail_before_any_work() {
230        assert!(matches!(
231            parse_str("", "matpower"),
232            Err(crate::Error::UnknownFormat(_))
233        ));
234        assert!(matches!(
235            "matpower".parse::<DistTargetFormat>(),
236            Err(crate::Error::UnknownFormat(_))
237        ));
238        assert!(matches!(
239            parse_file("missing.dss", Some("matpower")),
240            Err(crate::Error::UnknownFormat(_))
241        ));
242    }
243
244    #[test]
245    fn one_shot_convert_carries_parse_warnings() {
246        let dss = "clear\nnew circuit.w basekv=12.47 bus1=src\n\
247                   new line.l1 bus1=src bus2=b2 length=1 units=furlong\n";
248        let conv = convert_str(dss, DistTargetFormat::BmopfJson, "dss").unwrap();
249        assert!(
250            conv.warnings.iter().any(|w| w.contains("furlong")),
251            "parse warnings must surface through the one-shot converter: {:?}",
252            conv.warnings
253        );
254    }
255}