Skip to main content

powerio/format/
routing.rs

1//! Shared format alias and JSON shape routing for the `powerio` crate.
2//!
3//! This module is deliberately parser free. It only answers routing questions:
4//! what a format name means, and what top level JSON markers imply.
5
6/// A classification result that can be known, absent, or unsafe to choose.
7#[derive(Clone, Copy, Debug, PartialEq, Eq)]
8pub enum Detection<T> {
9    Known(T),
10    Unknown,
11    Ambiguous,
12}
13
14impl<T> Detection<T> {
15    pub fn known(self) -> Option<T> {
16        match self {
17            Self::Known(value) => Some(value),
18            Self::Unknown | Self::Ambiguous => None,
19        }
20    }
21}
22
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24#[non_exhaustive]
25pub enum Domain {
26    Transmission,
27    Distribution,
28}
29
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31#[non_exhaustive]
32pub enum TransmissionFormat {
33    Matpower,
34    PowerModelsJson,
35    EgretJson,
36    Psse,
37    Psse34,
38    Psse35,
39    PowerWorld,
40    PandapowerJson,
41    PowerioJson,
42    PypsaCsv,
43    Pslf,
44    Pwb,
45    Gridfm,
46    Goc3Json,
47    SurgeJson,
48}
49
50impl TransmissionFormat {
51    pub fn name(self) -> &'static str {
52        match self {
53            Self::Matpower => "matpower",
54            Self::PowerModelsJson => "powermodels-json",
55            Self::EgretJson => "egret-json",
56            Self::Psse => "psse",
57            Self::Psse34 => "psse34",
58            Self::Psse35 => "psse35",
59            Self::PowerWorld => "powerworld",
60            Self::PandapowerJson => "pandapower-json",
61            Self::PowerioJson => "powerio-json",
62            Self::PypsaCsv => "pypsa-csv",
63            Self::Pslf => "pslf",
64            Self::Pwb => "pwb",
65            Self::Gridfm => "gridfm",
66            Self::Goc3Json => "goc3-json",
67            Self::SurgeJson => "surge-json",
68        }
69    }
70}
71
72#[derive(Clone, Copy, Debug, PartialEq, Eq)]
73#[non_exhaustive]
74pub enum DistributionFormat {
75    Dss,
76    PmdJson,
77    BmopfJson,
78}
79
80impl DistributionFormat {
81    pub fn name(self) -> &'static str {
82        match self {
83            Self::Dss => "dss",
84            Self::PmdJson => "pmd-json",
85            Self::BmopfJson => "bmopf-json",
86        }
87    }
88}
89
90#[derive(Clone, Copy, Debug, PartialEq, Eq)]
91#[non_exhaustive]
92pub enum SourceFormat {
93    Transmission(TransmissionFormat),
94    Distribution(DistributionFormat),
95}
96
97impl SourceFormat {
98    pub fn domain(self) -> Domain {
99        match self {
100            Self::Transmission(_) => Domain::Transmission,
101            Self::Distribution(_) => Domain::Distribution,
102        }
103    }
104
105    pub fn name(self) -> &'static str {
106        match self {
107            Self::Transmission(format) => format.name(),
108            Self::Distribution(format) => format.name(),
109        }
110    }
111}
112
113pub type JsonFormat = SourceFormat;
114
115/// Resolve a source format name or common alias.
116pub fn classify_format_name(name: &str) -> Detection<SourceFormat> {
117    if let Some(format) = transmission_format_from_name(name) {
118        return Detection::Known(SourceFormat::Transmission(format));
119    }
120    if let Some(format) = distribution_format_from_name(name) {
121        return Detection::Known(SourceFormat::Distribution(format));
122    }
123    Detection::Unknown
124}
125
126pub fn transmission_format_from_name(name: &str) -> Option<TransmissionFormat> {
127    let key = canonical_key(name);
128    match key.as_str() {
129        "matpower" | "m" => Some(TransmissionFormat::Matpower),
130        "powermodelsjson" | "powermodels" | "pm" => Some(TransmissionFormat::PowerModelsJson),
131        "egretjson" | "egret" => Some(TransmissionFormat::EgretJson),
132        "psse" | "psse33" | "raw" | "raw33" => Some(TransmissionFormat::Psse),
133        "psse34" | "raw34" => Some(TransmissionFormat::Psse34),
134        "psse35" | "raw35" => Some(TransmissionFormat::Psse35),
135        "powerworld" | "aux" => Some(TransmissionFormat::PowerWorld),
136        "pandapowerjson" | "pandapower" | "pp" => Some(TransmissionFormat::PandapowerJson),
137        "poweriojson" | "powerio" | "json" => Some(TransmissionFormat::PowerioJson),
138        "pypsacsv" | "pypsa" => Some(TransmissionFormat::PypsaCsv),
139        "pslf" | "epc" | "pslfepc" => Some(TransmissionFormat::Pslf),
140        "pwb" => Some(TransmissionFormat::Pwb),
141        "gridfm" => Some(TransmissionFormat::Gridfm),
142        "goc3" | "goc3json" | "go3" | "gochallenge3" | "c3" => Some(TransmissionFormat::Goc3Json),
143        "surge" | "surgejson" => Some(TransmissionFormat::SurgeJson),
144        _ => None,
145    }
146}
147
148pub fn distribution_format_from_name(name: &str) -> Option<DistributionFormat> {
149    let key = canonical_key(name);
150    match key.as_str() {
151        "dss" | "opendss" => Some(DistributionFormat::Dss),
152        "pmd" | "pmdjson" | "engineering" => Some(DistributionFormat::PmdJson),
153        "bmopf" | "bmopfjson" => Some(DistributionFormat::BmopfJson),
154        _ => None,
155    }
156}
157
158/// Classify a JSON document across the transmission and distribution domains.
159///
160/// Unknown means there is no recognized top level marker. Ambiguous means a
161/// document contains strong markers from both domains, so the caller must ask
162/// the user for an explicit format.
163pub fn classify_json_text(text: &str) -> Detection<JsonFormat> {
164    let Ok(shape) = JsonShape::try_from(text) else {
165        return Detection::Unknown;
166    };
167    shape.classify()
168}
169
170fn canonical_key(name: &str) -> String {
171    name.to_ascii_lowercase()
172        .chars()
173        .filter(|c| *c != '-' && *c != '_')
174        .collect()
175}
176
177struct JsonShape {
178    object: serde_json::Map<String, serde_json::Value>,
179}
180
181impl TryFrom<&str> for JsonShape {
182    type Error = ();
183
184    fn try_from(text: &str) -> Result<Self, Self::Error> {
185        let value = serde_json::from_str::<serde_json::Value>(text).map_err(|_| ())?;
186        let serde_json::Value::Object(object) = value else {
187            return Err(());
188        };
189        Ok(Self { object })
190    }
191}
192
193impl JsonShape {
194    fn has(&self, key: &str) -> bool {
195        self.object.contains_key(key)
196    }
197
198    fn string(&self, key: &str) -> Option<&str> {
199        self.object.get(key).and_then(serde_json::Value::as_str)
200    }
201
202    fn classify(&self) -> Detection<JsonFormat> {
203        let is_pandapower = self.string("_class") == Some("pandapowerNet");
204        let is_egret = self.has("elements") && self.has("system");
205        let is_goc3 = self.has("network")
206            && (self.has("time_series_input") || self.has("reliability"))
207            && self.object.get("network").is_some_and(|network| {
208                network.as_object().is_some_and(|obj| {
209                    obj.contains_key("simple_dispatchable_device")
210                        || obj.contains_key("ac_line")
211                        || obj.contains_key("two_winding_transformer")
212                })
213            });
214        let is_surge = self.string("format") == Some("surge-json")
215            && self.has("schema_version")
216            && self.has("network");
217        let is_powerio = self.has("buses")
218            && (self.has("branches")
219                || self.has("base_mva")
220                || self.has("loads")
221                || self.has("generators"));
222        let is_power_models =
223            self.has("baseMVA") || self.has("branch") || self.has("gen") || self.has("gencost");
224        let transmission =
225            is_pandapower || is_egret || is_goc3 || is_surge || is_powerio || is_power_models;
226
227        let is_pmd = self.has("data_model");
228        let strong_bmopf = self.has("line")
229            || self.has("linecode")
230            || self.has("transformer")
231            || self.has("voltage_source");
232        let weak_bmopf = self.has("bus")
233            || self.has("load")
234            || self.has("generator")
235            || self.has("shunt")
236            || self.has("switch");
237        let distribution = is_pmd || strong_bmopf || (weak_bmopf && !transmission);
238
239        match (transmission, distribution) {
240            (true, true) => Detection::Ambiguous,
241            (true, false) => Detection::Known(SourceFormat::Transmission(if is_pandapower {
242                TransmissionFormat::PandapowerJson
243            } else if is_egret {
244                TransmissionFormat::EgretJson
245            } else if is_goc3 {
246                TransmissionFormat::Goc3Json
247            } else if is_surge {
248                TransmissionFormat::SurgeJson
249            } else if is_powerio {
250                TransmissionFormat::PowerioJson
251            } else {
252                TransmissionFormat::PowerModelsJson
253            })),
254            (false, true) => Detection::Known(SourceFormat::Distribution(if is_pmd {
255                DistributionFormat::PmdJson
256            } else {
257                DistributionFormat::BmopfJson
258            })),
259            (false, false) => Detection::Unknown,
260        }
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::{
267        Detection, DistributionFormat, SourceFormat, TransmissionFormat, classify_json_text,
268    };
269
270    #[test]
271    fn classifies_pmd_json() {
272        assert_eq!(
273            classify_json_text(r#"{"data_model":"ENGINEERING","bus":{}}"#),
274            Detection::Known(SourceFormat::Distribution(DistributionFormat::PmdJson))
275        );
276    }
277
278    #[test]
279    fn classifies_full_bmopf_json() {
280        assert_eq!(
281            classify_json_text(r#"{"bus":{},"linecode":{},"voltage_source":{}}"#),
282            Detection::Known(SourceFormat::Distribution(DistributionFormat::BmopfJson))
283        );
284    }
285
286    #[test]
287    fn classifies_minimal_bmopf_json() {
288        assert_eq!(
289            classify_json_text(r#"{"bus":{"a":{"terminal_names":["1"]}}}"#),
290            Detection::Known(SourceFormat::Distribution(DistributionFormat::BmopfJson))
291        );
292    }
293
294    #[test]
295    fn classifies_power_models_with_bus_and_base_mva_as_transmission() {
296        assert_eq!(
297            classify_json_text(
298                r#"{"baseMVA":100.0,"bus":{},"branch":{},"gen":{},"load":{},"switch":{}}"#
299            ),
300            Detection::Known(SourceFormat::Transmission(
301                TransmissionFormat::PowerModelsJson
302            ))
303        );
304    }
305
306    #[test]
307    fn classifies_powerio_json() {
308        assert_eq!(
309            classify_json_text(r#"{"base_mva":100.0,"buses":[],"branches":[]}"#),
310            Detection::Known(SourceFormat::Transmission(TransmissionFormat::PowerioJson))
311        );
312    }
313
314    #[test]
315    fn classifies_pandapower_json() {
316        assert_eq!(
317            classify_json_text(r#"{"_class":"pandapowerNet","_object":{}}"#),
318            Detection::Known(SourceFormat::Transmission(
319                TransmissionFormat::PandapowerJson
320            ))
321        );
322    }
323
324    #[test]
325    fn classifies_egret_json() {
326        assert_eq!(
327            classify_json_text(r#"{"elements":{},"system":{}}"#),
328            Detection::Known(SourceFormat::Transmission(TransmissionFormat::EgretJson))
329        );
330    }
331
332    #[test]
333    fn classifies_goc3_json() {
334        assert_eq!(
335            classify_json_text(
336                r#"{"network":{"bus":[],"simple_dispatchable_device":[]},"time_series_input":{}}"#
337            ),
338            Detection::Known(SourceFormat::Transmission(TransmissionFormat::Goc3Json))
339        );
340    }
341
342    #[test]
343    fn resolves_goc3_aliases() {
344        for alias in ["goc3-json", "goc3", "go3", "go-challenge-3", "c3"] {
345            assert_eq!(
346                super::transmission_format_from_name(alias),
347                Some(TransmissionFormat::Goc3Json),
348                "{alias}"
349            );
350        }
351    }
352
353    #[test]
354    fn classifies_surge_json() {
355        assert_eq!(
356            classify_json_text(
357                r#"{"format":"surge-json","schema_version":"0.1.0","network":{"buses":[]}}"#
358            ),
359            Detection::Known(SourceFormat::Transmission(TransmissionFormat::SurgeJson))
360        );
361    }
362
363    #[test]
364    fn resolves_surge_aliases() {
365        for alias in ["surge-json", "surge", "surgejson"] {
366            assert_eq!(
367                super::transmission_format_from_name(alias),
368                Some(TransmissionFormat::SurgeJson),
369                "{alias}"
370            );
371        }
372    }
373
374    #[test]
375    fn unknown_json_has_no_signal() {
376        assert_eq!(classify_json_text(r#"{"name":"case"}"#), Detection::Unknown);
377    }
378
379    #[test]
380    fn mixed_transmission_and_distribution_markers_are_ambiguous() {
381        assert_eq!(
382            classify_json_text(r#"{"baseMVA":100.0,"voltage_source":{}}"#),
383            Detection::Ambiguous
384        );
385    }
386}