Skip to main content

powerio/format/
routing.rs

1//! Shared format alias and JSON shape routing for the `powerio` crate.
2//!
3//! It maps format names and top level JSON markers without parsing a document.
4
5/// A classification result that can be known, absent, or unsafe to choose.
6#[derive(Clone, Copy, Debug, PartialEq, Eq)]
7pub enum Detection<T> {
8    Known(T),
9    Unknown,
10    Ambiguous,
11}
12
13impl<T> Detection<T> {
14    pub fn known(self) -> Option<T> {
15        match self {
16            Self::Known(value) => Some(value),
17            Self::Unknown | Self::Ambiguous => None,
18        }
19    }
20}
21
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23#[non_exhaustive]
24pub enum Domain {
25    Transmission,
26    Distribution,
27}
28
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30#[non_exhaustive]
31pub enum TransmissionFormat {
32    Matpower,
33    PowerModelsJson,
34    EgretJson,
35    Psse,
36    Psse34,
37    Psse35,
38    PowerWorld,
39    PandapowerJson,
40    #[doc(hidden)]
41    PowerioJson,
42    PypsaCsv,
43    Pslf,
44    Pwb,
45    Gridfm,
46    Goc3Json,
47    SurgeJson,
48    DeepMindOpfDataJson,
49}
50
51impl TransmissionFormat {
52    pub fn name(self) -> &'static str {
53        match self {
54            Self::Matpower => "matpower",
55            Self::PowerModelsJson => "powermodels-json",
56            Self::EgretJson => "egret-json",
57            Self::Psse => "psse",
58            Self::Psse34 => "psse34",
59            Self::Psse35 => "psse35",
60            Self::PowerWorld => "powerworld",
61            Self::PandapowerJson => "pandapower-json",
62            Self::PowerioJson => "powerio-json",
63            Self::PypsaCsv => "pypsa-csv",
64            Self::Pslf => "pslf",
65            Self::Pwb => "pwb",
66            Self::Gridfm => "gridfm",
67            Self::Goc3Json => "goc3-json",
68            Self::SurgeJson => "surge-json",
69            Self::DeepMindOpfDataJson => "opfdata-json",
70        }
71    }
72}
73
74#[derive(Clone, Copy, Debug, PartialEq, Eq)]
75#[non_exhaustive]
76pub enum DistributionFormat {
77    Dss,
78    PmdJson,
79    BmopfJson,
80}
81
82impl DistributionFormat {
83    pub fn name(self) -> &'static str {
84        match self {
85            Self::Dss => "dss",
86            Self::PmdJson => "pmd-json",
87            Self::BmopfJson => "bmopf-json",
88        }
89    }
90}
91
92#[derive(Clone, Copy, Debug, PartialEq, Eq)]
93#[non_exhaustive]
94pub enum SourceFormat {
95    Transmission(TransmissionFormat),
96    Distribution(DistributionFormat),
97}
98
99impl SourceFormat {
100    pub fn domain(self) -> Domain {
101        match self {
102            Self::Transmission(_) => Domain::Transmission,
103            Self::Distribution(_) => Domain::Distribution,
104        }
105    }
106
107    pub fn name(self) -> &'static str {
108        match self {
109            Self::Transmission(format) => format.name(),
110            Self::Distribution(format) => format.name(),
111        }
112    }
113}
114
115pub type JsonFormat = SourceFormat;
116
117/// Resolve a source format name or common alias.
118pub fn classify_format_name(name: &str) -> Detection<SourceFormat> {
119    if let Some(format) = transmission_format_from_name(name) {
120        return Detection::Known(SourceFormat::Transmission(format));
121    }
122    if let Some(format) = distribution_format_from_name(name) {
123        return Detection::Known(SourceFormat::Distribution(format));
124    }
125    Detection::Unknown
126}
127
128pub fn transmission_format_from_name(name: &str) -> Option<TransmissionFormat> {
129    let key = canonical_key(name);
130    match key.as_str() {
131        "matpower" | "m" => Some(TransmissionFormat::Matpower),
132        "powermodelsjson" | "powermodels" | "pm" => Some(TransmissionFormat::PowerModelsJson),
133        "egretjson" | "egret" => Some(TransmissionFormat::EgretJson),
134        "psse" | "psse33" | "raw" | "raw33" => Some(TransmissionFormat::Psse),
135        "psse34" | "raw34" => Some(TransmissionFormat::Psse34),
136        "psse35" | "raw35" => Some(TransmissionFormat::Psse35),
137        "powerworld" | "aux" => Some(TransmissionFormat::PowerWorld),
138        "pandapowerjson" | "pandapower" | "pp" => Some(TransmissionFormat::PandapowerJson),
139        "poweriojson" | "powerio" | "json" => Some(TransmissionFormat::PowerioJson),
140        "pypsacsv" | "pypsa" => Some(TransmissionFormat::PypsaCsv),
141        "pslf" | "epc" | "pslfepc" => Some(TransmissionFormat::Pslf),
142        "pwb" => Some(TransmissionFormat::Pwb),
143        "gridfm" => Some(TransmissionFormat::Gridfm),
144        "goc3" | "goc3json" | "go3" | "gochallenge3" | "c3" => Some(TransmissionFormat::Goc3Json),
145        "surge" | "surgejson" => Some(TransmissionFormat::SurgeJson),
146        "opfdata"
147        | "opfdatajson"
148        | "deepmindopfdata"
149        | "deepmindopfdatajson"
150        | "gridopt"
151        | "gridoptjson" => Some(TransmissionFormat::DeepMindOpfDataJson),
152        _ => None,
153    }
154}
155
156pub fn distribution_format_from_name(name: &str) -> Option<DistributionFormat> {
157    let key = canonical_key(name);
158    match key.as_str() {
159        "dss" | "opendss" => Some(DistributionFormat::Dss),
160        "pmd" | "pmdjson" | "engineering" => Some(DistributionFormat::PmdJson),
161        "bmopf" | "bmopfjson" => Some(DistributionFormat::BmopfJson),
162        _ => None,
163    }
164}
165
166/// Top level classification of bare JSON text: a `.pio.json` package
167/// or a case document with its format detection. The package outcome lives in
168/// the classifier's result rather than a separate predicate, so every consumer
169/// handles it, and one parse answers both questions.
170#[derive(Clone, Copy, Debug, PartialEq, Eq)]
171pub enum JsonClass {
172    /// A `.pio.json` package. A package is not a converter boundary
173    /// format, so it stays out of [`SourceFormat`]; callers route it to the
174    /// package reader instead of a case parser.
175    Package,
176    /// A case document and its format detection.
177    Case(Detection<JsonFormat>),
178}
179
180/// Classify a JSON document: a `.pio.json` package, or a case
181/// document across the transmission and distribution domains.
182///
183/// A package is recognized by a top level `model_kind` of `"balanced"` or
184/// `"multiconductor"` plus a `model` key; the value check keeps a case
185/// document that happens to carry those key names from being misrouted.
186/// For a case, Unknown means there is no recognized top level marker, and
187/// Ambiguous means the document contains strong markers from both domains, so
188/// the caller must ask the user for an explicit format.
189pub fn classify_json_text(text: &str) -> JsonClass {
190    // Windows tooling saves JSON with a UTF-8 byte order mark, which
191    // serde_json rejects; strip it so a BOM never hides the format.
192    let Ok(shape) = JsonShape::try_from(text.trim_start_matches('\u{feff}')) else {
193        return JsonClass::Case(Detection::Unknown);
194    };
195    if matches!(
196        shape.string("model_kind"),
197        Some("balanced" | "multiconductor")
198    ) && shape.has("model")
199    {
200        return JsonClass::Package;
201    }
202    JsonClass::Case(shape.classify())
203}
204
205fn canonical_key(name: &str) -> String {
206    name.to_ascii_lowercase()
207        .chars()
208        .filter(|c| *c != '-' && *c != '_')
209        .collect()
210}
211
212struct JsonShape {
213    object: serde_json::Map<String, serde_json::Value>,
214}
215
216impl TryFrom<&str> for JsonShape {
217    type Error = ();
218
219    fn try_from(text: &str) -> Result<Self, Self::Error> {
220        let value = serde_json::from_str::<serde_json::Value>(text).map_err(|_| ())?;
221        let serde_json::Value::Object(object) = value else {
222            return Err(());
223        };
224        Ok(Self { object })
225    }
226}
227
228impl JsonShape {
229    fn has(&self, key: &str) -> bool {
230        self.object.contains_key(key)
231    }
232
233    fn string(&self, key: &str) -> Option<&str> {
234        self.object.get(key).and_then(serde_json::Value::as_str)
235    }
236
237    fn classify(&self) -> Detection<JsonFormat> {
238        let is_pandapower = self.string("_class") == Some("pandapowerNet");
239        let is_egret = self.has("elements") && self.has("system");
240        let is_goc3 = self.has("network")
241            && (self.has("time_series_input") || self.has("reliability"))
242            && self.object.get("network").is_some_and(|network| {
243                network.as_object().is_some_and(|obj| {
244                    obj.contains_key("simple_dispatchable_device")
245                        || obj.contains_key("ac_line")
246                        || obj.contains_key("two_winding_transformer")
247                })
248            });
249        let is_surge = self.string("format") == Some("surge-json")
250            && self.has("schema_version")
251            && self.has("network");
252        let is_opfdata = self
253            .object
254            .get("grid")
255            .and_then(serde_json::Value::as_object)
256            .is_some_and(|grid| {
257                grid.contains_key("nodes")
258                    && grid.contains_key("edges")
259                    && grid.contains_key("context")
260            })
261            && self
262                .object
263                .get("solution")
264                .and_then(serde_json::Value::as_object)
265                .is_some_and(|solution| {
266                    solution.contains_key("nodes") && solution.contains_key("edges")
267                })
268            && self
269                .object
270                .get("metadata")
271                .and_then(serde_json::Value::as_object)
272                .is_some_and(|metadata| metadata.contains_key("objective"));
273        let is_powerio = self.has("buses")
274            && (self.has("branches")
275                || self.has("base_mva")
276                || self.has("loads")
277                || self.has("generators"));
278        let is_power_models =
279            self.has("baseMVA") || self.has("branch") || self.has("gen") || self.has("gencost");
280        let transmission = is_pandapower
281            || is_egret
282            || is_goc3
283            || is_surge
284            || is_opfdata
285            || is_powerio
286            || is_power_models;
287
288        let is_pmd = self.has("data_model");
289        let strong_bmopf = self.has("line")
290            || self.has("linecode")
291            || self.has("transformer")
292            || self.has("voltage_source");
293        let weak_bmopf = self.has("bus")
294            || self.has("load")
295            || self.has("generator")
296            || self.has("shunt")
297            || self.has("switch");
298        let distribution = is_pmd || strong_bmopf || (weak_bmopf && !transmission);
299
300        match (transmission, distribution) {
301            (true, true) => Detection::Ambiguous,
302            (true, false) => Detection::Known(SourceFormat::Transmission(if is_pandapower {
303                TransmissionFormat::PandapowerJson
304            } else if is_egret {
305                TransmissionFormat::EgretJson
306            } else if is_goc3 {
307                TransmissionFormat::Goc3Json
308            } else if is_surge {
309                TransmissionFormat::SurgeJson
310            } else if is_opfdata {
311                TransmissionFormat::DeepMindOpfDataJson
312            } else if is_powerio {
313                TransmissionFormat::PowerioJson
314            } else {
315                TransmissionFormat::PowerModelsJson
316            })),
317            (false, true) => Detection::Known(SourceFormat::Distribution(if is_pmd {
318                DistributionFormat::PmdJson
319            } else {
320                DistributionFormat::BmopfJson
321            })),
322            (false, false) => Detection::Unknown,
323        }
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::{
330        Detection, DistributionFormat, JsonClass, SourceFormat, TransmissionFormat,
331        classify_json_text,
332    };
333
334    #[test]
335    fn classifies_package() {
336        assert_eq!(
337            classify_json_text(
338                r#"{"model_kind":"multiconductor","model":{"kind":"multiconductor"}}"#
339            ),
340            JsonClass::Package
341        );
342        assert_eq!(
343            classify_json_text(r#"{"model_kind":"balanced","model":{}}"#),
344            JsonClass::Package
345        );
346        // A payload alone is not a package, and neither is a case document,
347        // even one that carries the package key names with case-file values.
348        assert_eq!(
349            classify_json_text(r#"{"buses":[],"linecodes":[]}"#),
350            JsonClass::Case(Detection::Unknown)
351        );
352        assert_eq!(
353            classify_json_text(r#"{"baseMVA":100.0,"bus":{},"model":"ACP","model_kind":"opf"}"#),
354            JsonClass::Case(Detection::Known(SourceFormat::Transmission(
355                TransmissionFormat::PowerModelsJson
356            )))
357        );
358        assert_eq!(
359            classify_json_text("not json"),
360            JsonClass::Case(Detection::Unknown)
361        );
362    }
363
364    #[test]
365    fn classifies_pmd_json() {
366        assert_eq!(
367            classify_json_text(r#"{"data_model":"ENGINEERING","bus":{}}"#),
368            JsonClass::Case(Detection::Known(SourceFormat::Distribution(
369                DistributionFormat::PmdJson
370            )))
371        );
372    }
373
374    #[test]
375    fn classifies_full_bmopf_json() {
376        assert_eq!(
377            classify_json_text(r#"{"bus":{},"linecode":{},"voltage_source":{}}"#),
378            JsonClass::Case(Detection::Known(SourceFormat::Distribution(
379                DistributionFormat::BmopfJson
380            )))
381        );
382    }
383
384    #[test]
385    fn classifies_minimal_bmopf_json() {
386        assert_eq!(
387            classify_json_text(r#"{"bus":{"a":{"terminal_names":["1"]}}}"#),
388            JsonClass::Case(Detection::Known(SourceFormat::Distribution(
389                DistributionFormat::BmopfJson
390            )))
391        );
392    }
393
394    #[test]
395    fn classifies_power_models_with_bus_and_base_mva_as_transmission() {
396        assert_eq!(
397            classify_json_text(
398                r#"{"baseMVA":100.0,"bus":{},"branch":{},"gen":{},"load":{},"switch":{}}"#
399            ),
400            JsonClass::Case(Detection::Known(SourceFormat::Transmission(
401                TransmissionFormat::PowerModelsJson
402            )))
403        );
404    }
405
406    #[test]
407    fn classifies_powerio_json() {
408        assert_eq!(
409            classify_json_text(r#"{"base_mva":100.0,"buses":[],"branches":[]}"#),
410            JsonClass::Case(Detection::Known(SourceFormat::Transmission(
411                TransmissionFormat::PowerioJson
412            )))
413        );
414    }
415
416    #[test]
417    fn classifies_pandapower_json() {
418        assert_eq!(
419            classify_json_text(r#"{"_class":"pandapowerNet","_object":{}}"#),
420            JsonClass::Case(Detection::Known(SourceFormat::Transmission(
421                TransmissionFormat::PandapowerJson
422            )))
423        );
424    }
425
426    #[test]
427    fn classifies_egret_json() {
428        assert_eq!(
429            classify_json_text(r#"{"elements":{},"system":{}}"#),
430            JsonClass::Case(Detection::Known(SourceFormat::Transmission(
431                TransmissionFormat::EgretJson
432            )))
433        );
434    }
435
436    #[test]
437    fn classifies_goc3_json() {
438        assert_eq!(
439            classify_json_text(
440                r#"{"network":{"bus":[],"simple_dispatchable_device":[]},"time_series_input":{}}"#
441            ),
442            JsonClass::Case(Detection::Known(SourceFormat::Transmission(
443                TransmissionFormat::Goc3Json
444            )))
445        );
446    }
447
448    #[test]
449    fn resolves_goc3_aliases() {
450        for alias in ["goc3-json", "goc3", "go3", "go-challenge-3", "c3"] {
451            assert_eq!(
452                super::transmission_format_from_name(alias),
453                Some(TransmissionFormat::Goc3Json),
454                "{alias}"
455            );
456        }
457    }
458
459    #[test]
460    fn classifies_surge_json() {
461        assert_eq!(
462            classify_json_text(
463                r#"{"format":"surge-json","schema_version":"0.1.0","network":{"buses":[]}}"#
464            ),
465            JsonClass::Case(Detection::Known(SourceFormat::Transmission(
466                TransmissionFormat::SurgeJson
467            )))
468        );
469    }
470
471    #[test]
472    fn resolves_surge_aliases() {
473        for alias in ["surge-json", "surge", "surgejson"] {
474            assert_eq!(
475                super::transmission_format_from_name(alias),
476                Some(TransmissionFormat::SurgeJson),
477                "{alias}"
478            );
479        }
480    }
481
482    #[test]
483    fn classifies_opfdata_json() {
484        assert_eq!(
485            classify_json_text(
486                r#"{
487                    "grid":{"nodes":{},"edges":{},"context":[]},
488                    "solution":{"nodes":{},"edges":{}},
489                    "metadata":{"objective":0.0}
490                }"#
491            ),
492            JsonClass::Case(Detection::Known(SourceFormat::Transmission(
493                TransmissionFormat::DeepMindOpfDataJson
494            )))
495        );
496        assert_eq!(
497            classify_json_text(r#"{"grid":{},"solution":{},"metadata":{}}"#),
498            JsonClass::Case(Detection::Unknown)
499        );
500    }
501
502    #[test]
503    fn resolves_opfdata_aliases() {
504        for alias in [
505            "opfdata-json",
506            "opfdata",
507            "OPFData",
508            "deepmind-opfdata-json",
509            "deepmind-opfdata",
510            "gridopt-json",
511            "gridopt",
512        ] {
513            assert_eq!(
514                super::transmission_format_from_name(alias),
515                Some(TransmissionFormat::DeepMindOpfDataJson),
516                "{alias}"
517            );
518        }
519    }
520
521    #[test]
522    fn classifies_json_with_leading_byte_order_mark() {
523        assert_eq!(
524            classify_json_text("\u{feff}{\"baseMVA\":100.0,\"bus\":{},\"branch\":{}}"),
525            JsonClass::Case(Detection::Known(SourceFormat::Transmission(
526                TransmissionFormat::PowerModelsJson
527            )))
528        );
529    }
530
531    #[test]
532    fn unknown_json_has_no_signal() {
533        assert_eq!(
534            classify_json_text(r#"{"name":"case"}"#),
535            JsonClass::Case(Detection::Unknown)
536        );
537    }
538
539    #[test]
540    fn mixed_transmission_and_distribution_markers_are_ambiguous() {
541        assert_eq!(
542            classify_json_text(r#"{"baseMVA":100.0,"voltage_source":{}}"#),
543            JsonClass::Case(Detection::Ambiguous)
544        );
545    }
546}