Skip to main content

powerio_tx/format/
routing.rs

1//! Shared format alias and JSON shape routing for the `powerio` crate.
2//!
3//! It maps format name aliases with no parsing at all, and classifies JSON
4//! content by deserializing a typed header instead of materializing the
5//! whole document into a generic value tree.
6
7use serde::Deserialize;
8
9// The top-level facade owns PowerIO IR. This private routing marker lets the
10// lower-level JSON classifier recognize the stable identity without making
11// `powerio-core` own the stored representation.
12const POWERIO_IR_SCHEMA: &str = "pio-ir";
13
14/// A classification result that can be known, absent, or unsafe to choose.
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16pub enum Detection<T> {
17    Known(T),
18    Unknown,
19    Ambiguous,
20}
21
22impl<T> Detection<T> {
23    pub fn known(self) -> Option<T> {
24        match self {
25            Self::Known(value) => Some(value),
26            Self::Unknown | Self::Ambiguous => None,
27        }
28    }
29}
30
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32#[non_exhaustive]
33pub enum Domain {
34    Transmission,
35    Distribution,
36}
37
38#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39#[non_exhaustive]
40pub enum TransmissionFormat {
41    Matpower,
42    PowerModelsJson,
43    EgretJson,
44    Psse,
45    Psse34,
46    Psse35,
47    PsseRawx,
48    PowerWorld,
49    PandapowerJson,
50    PypsaCsv,
51    Pslf,
52    Pwb,
53    Gridfm,
54    Goc3Json,
55    SurgeJson,
56    DeepMindOpfDataJson,
57    Xiidm,
58    Jiidm,
59    Cgmes,
60    Ucte,
61    /// The IEEE Common Data Format, a read only fixed column text case.
62    IeeeCdf,
63}
64
65impl TransmissionFormat {
66    pub fn name(self) -> &'static str {
67        match self {
68            Self::Matpower => "matpower",
69            Self::PowerModelsJson => "powermodels-json",
70            Self::EgretJson => "egret-json",
71            Self::Psse => "psse",
72            Self::Psse34 => "psse34",
73            Self::Psse35 => "psse35",
74            Self::PsseRawx => "psse-rawx",
75            Self::PowerWorld => "powerworld",
76            Self::PandapowerJson => "pandapower-json",
77            Self::PypsaCsv => "pypsa-csv",
78            Self::Pslf => "pslf",
79            Self::Pwb => "pwb",
80            Self::Gridfm => "gridfm",
81            Self::Goc3Json => "goc3-json",
82            Self::SurgeJson => "surge-json",
83            Self::DeepMindOpfDataJson => "opfdata-json",
84            Self::Xiidm => "xiidm",
85            Self::Jiidm => "jiidm",
86            Self::Cgmes => "cgmes",
87            Self::Ucte => "ucte",
88            Self::IeeeCdf => "ieee-cdf",
89        }
90    }
91}
92
93#[derive(Clone, Copy, Debug, PartialEq, Eq)]
94#[non_exhaustive]
95pub enum DistributionFormat {
96    Dss,
97    PmdJson,
98    BmopfJson,
99}
100
101impl DistributionFormat {
102    pub fn name(self) -> &'static str {
103        match self {
104            Self::Dss => "dss",
105            Self::PmdJson => "pmd-json",
106            Self::BmopfJson => "bmopf-json",
107        }
108    }
109}
110
111#[derive(Clone, Copy, Debug, PartialEq, Eq)]
112#[non_exhaustive]
113pub enum SourceFormat {
114    Transmission(TransmissionFormat),
115    Distribution(DistributionFormat),
116}
117
118impl SourceFormat {
119    pub fn domain(self) -> Domain {
120        match self {
121            Self::Transmission(_) => Domain::Transmission,
122            Self::Distribution(_) => Domain::Distribution,
123        }
124    }
125
126    pub fn name(self) -> &'static str {
127        match self {
128            Self::Transmission(format) => format.name(),
129            Self::Distribution(format) => format.name(),
130        }
131    }
132}
133
134pub type JsonFormat = SourceFormat;
135
136/// Resolve a source format name or common alias.
137pub fn classify_format_name(name: &str) -> Detection<SourceFormat> {
138    if let Some(format) = parse_transmission_format(name) {
139        return Detection::Known(SourceFormat::Transmission(format));
140    }
141    if let Some(format) = parse_distribution_format(name) {
142        return Detection::Known(SourceFormat::Distribution(format));
143    }
144    Detection::Unknown
145}
146
147pub fn parse_transmission_format(name: &str) -> Option<TransmissionFormat> {
148    let key = canonical_key(name);
149    match key.as_str() {
150        "matpower" | "m" => Some(TransmissionFormat::Matpower),
151        "powermodelsjson" | "powermodels" | "pm" => Some(TransmissionFormat::PowerModelsJson),
152        "egretjson" | "egret" => Some(TransmissionFormat::EgretJson),
153        "psse" | "psse33" | "raw" | "raw33" => Some(TransmissionFormat::Psse),
154        "psse34" | "raw34" => Some(TransmissionFormat::Psse34),
155        "psse35" | "raw35" => Some(TransmissionFormat::Psse35),
156        "psserawx" | "rawx" => Some(TransmissionFormat::PsseRawx),
157        "powerworld" | "aux" => Some(TransmissionFormat::PowerWorld),
158        "pandapowerjson" | "pandapower" | "pp" => Some(TransmissionFormat::PandapowerJson),
159        "pypsacsv" | "pypsa" => Some(TransmissionFormat::PypsaCsv),
160        "pslf" | "epc" | "pslfepc" => Some(TransmissionFormat::Pslf),
161        "pwb" => Some(TransmissionFormat::Pwb),
162        "gridfm" => Some(TransmissionFormat::Gridfm),
163        "goc3" | "goc3json" | "go3" | "gochallenge3" | "c3" => Some(TransmissionFormat::Goc3Json),
164        "surge" | "surgejson" => Some(TransmissionFormat::SurgeJson),
165        "xiidm" | "iidm" => Some(TransmissionFormat::Xiidm),
166        "jiidm" => Some(TransmissionFormat::Jiidm),
167        "cgmes" => Some(TransmissionFormat::Cgmes),
168        "ucte" | "uct" | "uctedef" => Some(TransmissionFormat::Ucte),
169        "ieeecdf" | "cdf" => Some(TransmissionFormat::IeeeCdf),
170        "opfdata"
171        | "opfdatajson"
172        | "deepmindopfdata"
173        | "deepmindopfdatajson"
174        | "gridopt"
175        | "gridoptjson" => Some(TransmissionFormat::DeepMindOpfDataJson),
176        _ => None,
177    }
178}
179
180pub fn parse_distribution_format(name: &str) -> Option<DistributionFormat> {
181    let key = canonical_key(name);
182    match key.as_str() {
183        "dss" | "opendss" => Some(DistributionFormat::Dss),
184        "pmd" | "pmdjson" | "engineering" => Some(DistributionFormat::PmdJson),
185        "bmopf" | "bmopfjson" => Some(DistributionFormat::BmopfJson),
186        _ => None,
187    }
188}
189
190/// Top level classification of bare JSON text: a PowerIO IR document or a case
191/// document with its format detection. One header read answers every question
192/// instead of requiring a full document parse per question.
193#[derive(Clone, Copy, Debug, PartialEq, Eq)]
194pub enum JsonClass {
195    /// A `.pio.json` stored module document. The stored document is not a
196    /// converter boundary format, so it stays out of [`SourceFormat`];
197    /// callers route it to the stored module reader instead of a case parser.
198    Module,
199    /// A case document and its format detection.
200    Case(Detection<JsonFormat>),
201}
202
203/// The closed set of classification families, in the one spelling every
204/// surface uses: the Rust [`JsonClass`], the C `pio_classify_str` label before
205/// its optional `:<format>` tail, the Python `classify_json_text` status, and
206/// the Julia family symbol.
207///
208/// A consumer may cache this set. A spelling never changes, a new family is
209/// appended within a release line, and a family leaves only in a breaking
210/// release that records the removal in the changelog and the migration guide,
211/// as 0.11 recorded `model-json`.
212pub const JSON_CLASSES: [&str; 5] = [
213    "transmission",
214    "distribution",
215    "module",
216    "ambiguous",
217    "unknown",
218];
219
220impl JsonClass {
221    /// This classification's family token, one of [`JSON_CLASSES`]. The
222    /// detected format, where there is one, is [`SourceFormat::name`].
223    #[must_use]
224    pub fn family(self) -> &'static str {
225        match self {
226            Self::Module => "module",
227            Self::Case(Detection::Known(format)) => match format.domain() {
228                Domain::Transmission => "transmission",
229                Domain::Distribution => "distribution",
230            },
231            Self::Case(Detection::Ambiguous) => "ambiguous",
232            Self::Case(Detection::Unknown) => "unknown",
233        }
234    }
235}
236
237/// Classify a JSON document: a PowerIO IR module or a case document across the
238/// transmission and distribution domains.
239///
240/// PowerIO IR is recognized by its `schema: "pio-ir"` header.
241/// For a case, Unknown means there is no recognized top level marker, and
242/// Ambiguous means the document contains strong markers from both domains, so
243/// the caller must ask the user for an explicit format.
244pub fn classify_json_text(text: &str) -> JsonClass {
245    // PowSybl JIIDM opens with its `version` key, the same test PowSybl's
246    // importer applies; no other JSON case format starts that way.
247    if super::xiidm::looks_like_jiidm(text) {
248        return JsonClass::Case(Detection::Known(SourceFormat::Transmission(
249            TransmissionFormat::Jiidm,
250        )));
251    }
252    // Windows tooling saves JSON with a UTF-8 byte order mark, which
253    // serde_json rejects; strip it so a BOM never hides the format.
254    let Ok(header) = serde_json::from_str::<JsonHeader>(text.trim_start_matches('\u{feff}')) else {
255        return JsonClass::Case(Detection::Unknown);
256    };
257    // PowerIO IR names itself in its header.
258    if header.schema.as_deref() == Some(POWERIO_IR_SCHEMA) {
259        return JsonClass::Module;
260    }
261    header.classify()
262}
263
264/// Classify JSON bytes without performing lossy text replacement.
265///
266/// Invalid UTF-8 has no trustworthy JSON markers and returns the same
267/// [`Detection::Unknown`] classification as malformed JSON. A leading UTF-8
268/// byte order mark is accepted through [`classify_json_text`].
269#[must_use]
270pub fn classify_json_bytes(bytes: &[u8]) -> JsonClass {
271    let Ok(text) = std::str::from_utf8(bytes) else {
272        return JsonClass::Case(Detection::Unknown);
273    };
274    classify_json_text(text)
275}
276
277fn canonical_key(name: &str) -> String {
278    name.to_ascii_lowercase()
279        .chars()
280        .filter(|c| *c != '-' && *c != '_')
281        .collect()
282}
283
284/// Reads `true` when a key is present, for whatever value it holds — a JSON
285/// `null` included, unlike a plain `Option<T>` field, which would treat a
286/// `null` value the same as a missing key. [`serde::de::IgnoredAny`] walks
287/// and discards the value without materializing it, so a marker key whose
288/// value is a large array or object (an unrelated top level table in the
289/// same document) costs one scan, not an allocation per element.
290fn present<'de, D>(deserializer: D) -> Result<bool, D::Error>
291where
292    D: serde::Deserializer<'de>,
293{
294    serde::de::IgnoredAny::deserialize(deserializer)?;
295    Ok(true)
296}
297
298/// The key's string value, or `None` for a missing key, a `null`, or any
299/// other non-string value — matching `serde_json::Value::as_str`'s
300/// permissive read, so a marker key of the wrong type carries no marker
301/// instead of failing the whole header.
302fn maybe_str<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
303where
304    D: serde::Deserializer<'de>,
305{
306    Ok(serde_json::Value::deserialize(deserializer)?
307        .as_str()
308        .map(str::to_owned))
309}
310
311/// `Some(header)` when the key's value is a JSON object, `None` for a
312/// missing key or any non-object value — matching the object guard the
313/// nested markers (`grid`, `solution`, `metadata`) read before checking a
314/// key within them.
315fn maybe_object<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
316where
317    D: serde::Deserializer<'de>,
318    T: Deserialize<'de>,
319{
320    let value = serde_json::Value::deserialize(deserializer)?;
321    if value.is_object() {
322        T::deserialize(value)
323            .map(Some)
324            .map_err(serde::de::Error::custom)
325    } else {
326        Ok(None)
327    }
328}
329
330/// The `network` key's presence, independent of its shape (the surge marker
331/// only needs presence), paired with its nested markers when it does turn
332/// out to be an object (the GO Challenge 3 markers). One key answers both,
333/// since a `maybe_object` field alone would lose the presence signal for a
334/// non-object value.
335#[derive(Default)]
336struct NetworkField {
337    present: bool,
338    header: NetworkHeader,
339}
340
341impl<'de> Deserialize<'de> for NetworkField {
342    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
343    where
344        D: serde::Deserializer<'de>,
345    {
346        let value = serde_json::Value::deserialize(deserializer)?;
347        let header = if value.is_object() {
348            NetworkHeader::deserialize(value).map_err(serde::de::Error::custom)?
349        } else {
350            NetworkHeader::default()
351        };
352        Ok(Self {
353            present: true,
354            header,
355        })
356    }
357}
358
359#[derive(Default, Deserialize)]
360#[expect(clippy::struct_excessive_bools)]
361struct NetworkHeader {
362    #[serde(default, deserialize_with = "present")]
363    caseid: bool,
364    #[serde(default, deserialize_with = "present")]
365    simple_dispatchable_device: bool,
366    #[serde(default, deserialize_with = "present")]
367    ac_line: bool,
368    #[serde(default, deserialize_with = "present")]
369    two_winding_transformer: bool,
370}
371
372#[derive(Deserialize)]
373struct GridHeader {
374    #[serde(default, deserialize_with = "present")]
375    nodes: bool,
376    #[serde(default, deserialize_with = "present")]
377    edges: bool,
378    #[serde(default, deserialize_with = "present")]
379    context: bool,
380}
381
382#[derive(Deserialize)]
383struct SolutionHeader {
384    #[serde(default, deserialize_with = "present")]
385    nodes: bool,
386    #[serde(default, deserialize_with = "present")]
387    edges: bool,
388}
389
390#[derive(Deserialize)]
391struct MetadataHeader {
392    #[serde(default, deserialize_with = "present")]
393    objective: bool,
394}
395
396/// The top level markers [`JsonClass`] needs, read in one pass over the
397/// document: unlisted keys (every case format's actual data rows) are
398/// skipped by serde's derived `Deserialize` without being materialized, and
399/// the listed keys cost at most a presence check or one short string.
400// Every bool here is an independent named field a derived `Deserialize`
401// fills from its own JSON key, never a positional constructor argument, so
402// the excessive-bools lint's mix-up concern does not apply.
403#[allow(clippy::struct_excessive_bools)]
404#[derive(Default, Deserialize)]
405struct JsonHeader {
406    #[serde(default, deserialize_with = "maybe_str")]
407    schema: Option<String>,
408    #[serde(default, deserialize_with = "maybe_str", rename = "_class")]
409    pandapower_class: Option<String>,
410    #[serde(default, deserialize_with = "present")]
411    elements: bool,
412    #[serde(default, deserialize_with = "present")]
413    system: bool,
414    #[serde(default)]
415    network: NetworkField,
416    #[serde(default, deserialize_with = "present")]
417    time_series_input: bool,
418    #[serde(default, deserialize_with = "present")]
419    time_series_output: bool,
420    #[serde(default, deserialize_with = "present")]
421    reliability: bool,
422    #[serde(default, deserialize_with = "maybe_str")]
423    format: Option<String>,
424    #[serde(default, deserialize_with = "present")]
425    schema_version: bool,
426    #[serde(default, deserialize_with = "maybe_object")]
427    grid: Option<GridHeader>,
428    #[serde(default, deserialize_with = "maybe_object")]
429    solution: Option<SolutionHeader>,
430    #[serde(default, deserialize_with = "maybe_object")]
431    metadata: Option<MetadataHeader>,
432    #[serde(default, deserialize_with = "present", rename = "baseMVA")]
433    base_mva_camel: bool,
434    #[serde(default, deserialize_with = "present")]
435    branch: bool,
436    #[serde(default, deserialize_with = "present")]
437    r#gen: bool,
438    #[serde(default, deserialize_with = "present")]
439    gencost: bool,
440    #[serde(default, deserialize_with = "present")]
441    data_model: bool,
442    #[serde(default, deserialize_with = "present")]
443    line: bool,
444    #[serde(default, deserialize_with = "present")]
445    linecode: bool,
446    #[serde(default, deserialize_with = "present")]
447    transformer: bool,
448    #[serde(default, deserialize_with = "present")]
449    voltage_source: bool,
450    #[serde(default, deserialize_with = "present")]
451    bus: bool,
452    #[serde(default, deserialize_with = "present")]
453    load: bool,
454    #[serde(default, deserialize_with = "present")]
455    generator: bool,
456    #[serde(default, deserialize_with = "present")]
457    shunt: bool,
458    #[serde(default, deserialize_with = "present")]
459    switch: bool,
460}
461
462impl JsonHeader {
463    fn classify(&self) -> JsonClass {
464        let is_pandapower = self.pandapower_class.as_deref() == Some("pandapowerNet");
465        let is_egret = self.elements && self.system;
466        let is_goc3 = self.time_series_output
467            || ((self.time_series_input || self.reliability)
468                && (self.network.header.simple_dispatchable_device
469                    || self.network.header.ac_line
470                    || self.network.header.two_winding_transformer));
471        let is_rawx = self.network.header.caseid;
472        let is_surge = self.format.as_deref() == Some("surge-json")
473            && self.schema_version
474            && self.network.present;
475        let is_opfdata = self
476            .grid
477            .as_ref()
478            .is_some_and(|grid| grid.nodes && grid.edges && grid.context)
479            && self
480                .solution
481                .as_ref()
482                .is_some_and(|solution| solution.nodes && solution.edges)
483            && self
484                .metadata
485                .as_ref()
486                .is_some_and(|metadata| metadata.objective);
487        let is_power_models = self.base_mva_camel || self.branch || self.r#gen || self.gencost;
488        let transmission = is_pandapower
489            || is_egret
490            || is_goc3
491            || is_rawx
492            || is_surge
493            || is_opfdata
494            || is_power_models;
495
496        let is_pmd = self.data_model;
497        let strong_bmopf = self.line || self.linecode || self.transformer || self.voltage_source;
498        let weak_bmopf = self.bus || self.load || self.generator || self.shunt || self.switch;
499        let distribution = is_pmd || strong_bmopf || (weak_bmopf && !transmission);
500
501        match (transmission, distribution) {
502            (true, true) => JsonClass::Case(Detection::Ambiguous),
503            (true, false) => JsonClass::Case(Detection::Known(SourceFormat::Transmission(
504                if is_pandapower {
505                    TransmissionFormat::PandapowerJson
506                } else if is_egret {
507                    TransmissionFormat::EgretJson
508                } else if is_rawx {
509                    TransmissionFormat::PsseRawx
510                } else if is_goc3 {
511                    TransmissionFormat::Goc3Json
512                } else if is_surge {
513                    TransmissionFormat::SurgeJson
514                } else if is_opfdata {
515                    TransmissionFormat::DeepMindOpfDataJson
516                } else {
517                    TransmissionFormat::PowerModelsJson
518                },
519            ))),
520            (false, true) => {
521                JsonClass::Case(Detection::Known(SourceFormat::Distribution(if is_pmd {
522                    DistributionFormat::PmdJson
523                } else {
524                    DistributionFormat::BmopfJson
525                })))
526            }
527            (false, false) => JsonClass::Case(Detection::Unknown),
528        }
529    }
530}
531
532#[cfg(test)]
533mod tests {
534    use super::{
535        Detection, DistributionFormat, JsonClass, SourceFormat, TransmissionFormat,
536        classify_json_bytes, classify_json_text,
537    };
538
539    #[test]
540    fn classifies_powerio_ir() {
541        assert_eq!(
542            classify_json_text(r#"{"schema":"pio-ir","version":2}"#),
543            JsonClass::Module
544        );
545        // Routing is the schema name's job alone. Every PowerIO IR generation
546        // reaches the deserializer, which owns the version rule and can then
547        // name the version it refuses.
548        for version in ["1", "3", r#""0.11.0""#, "null"] {
549            assert_eq!(
550                classify_json_text(&format!(r#"{{"schema":"pio-ir","version":{version}}}"#)),
551                JsonClass::Module,
552                "version {version}"
553            );
554        }
555        assert_eq!(
556            classify_json_text(r#"{"buses":[],"linecodes":[]}"#),
557            JsonClass::Case(Detection::Unknown)
558        );
559        assert_eq!(
560            classify_json_text(r#"{"baseMVA":100.0,"bus":{},"model":"ACP","model_kind":"opf"}"#),
561            JsonClass::Case(Detection::Known(SourceFormat::Transmission(
562                TransmissionFormat::PowerModelsJson
563            )))
564        );
565        assert_eq!(
566            classify_json_text("not json"),
567            JsonClass::Case(Detection::Unknown)
568        );
569    }
570
571    #[test]
572    fn classifies_pmd_json() {
573        assert_eq!(
574            classify_json_text(r#"{"data_model":"ENGINEERING","bus":{}}"#),
575            JsonClass::Case(Detection::Known(SourceFormat::Distribution(
576                DistributionFormat::PmdJson
577            )))
578        );
579    }
580
581    #[test]
582    fn classifies_full_bmopf_json() {
583        assert_eq!(
584            classify_json_text(r#"{"bus":{},"linecode":{},"voltage_source":{}}"#),
585            JsonClass::Case(Detection::Known(SourceFormat::Distribution(
586                DistributionFormat::BmopfJson
587            )))
588        );
589    }
590
591    #[test]
592    fn classifies_minimal_bmopf_json() {
593        assert_eq!(
594            classify_json_text(r#"{"bus":{"a":{"terminal_names":["1"]}}}"#),
595            JsonClass::Case(Detection::Known(SourceFormat::Distribution(
596                DistributionFormat::BmopfJson
597            )))
598        );
599    }
600
601    #[test]
602    fn classifies_power_models_with_bus_and_base_mva_as_transmission() {
603        assert_eq!(
604            classify_json_text(
605                r#"{"baseMVA":100.0,"bus":{},"branch":{},"gen":{},"load":{},"switch":{}}"#
606            ),
607            JsonClass::Case(Detection::Known(SourceFormat::Transmission(
608                TransmissionFormat::PowerModelsJson
609            )))
610        );
611    }
612
613    #[test]
614    fn a_bare_network_object_is_not_a_case_or_powerio_ir() {
615        assert_eq!(
616            classify_json_text(r#"{"base_mva":100.0,"buses":[],"branches":[]}"#),
617            JsonClass::Case(Detection::Unknown)
618        );
619    }
620
621    #[test]
622    fn every_family_is_in_the_closed_set() {
623        for class in [
624            JsonClass::Module,
625            JsonClass::Case(Detection::Ambiguous),
626            JsonClass::Case(Detection::Unknown),
627            JsonClass::Case(Detection::Known(SourceFormat::Transmission(
628                TransmissionFormat::Matpower,
629            ))),
630            JsonClass::Case(Detection::Known(SourceFormat::Distribution(
631                DistributionFormat::Dss,
632            ))),
633        ] {
634            assert!(
635                super::JSON_CLASSES.contains(&class.family()),
636                "{class:?} answers with a family outside the closed set"
637            );
638        }
639    }
640
641    #[test]
642    fn classifies_pandapower_json() {
643        assert_eq!(
644            classify_json_text(r#"{"_class":"pandapowerNet","_object":{}}"#),
645            JsonClass::Case(Detection::Known(SourceFormat::Transmission(
646                TransmissionFormat::PandapowerJson
647            )))
648        );
649    }
650
651    #[test]
652    fn classifies_egret_json() {
653        assert_eq!(
654            classify_json_text(r#"{"elements":{},"system":{}}"#),
655            JsonClass::Case(Detection::Known(SourceFormat::Transmission(
656                TransmissionFormat::EgretJson
657            )))
658        );
659    }
660
661    #[test]
662    fn classifies_goc3_json() {
663        assert_eq!(
664            classify_json_text(
665                r#"{"network":{"bus":[],"simple_dispatchable_device":[]},"time_series_input":{}}"#
666            ),
667            JsonClass::Case(Detection::Known(SourceFormat::Transmission(
668                TransmissionFormat::Goc3Json
669            )))
670        );
671        assert_eq!(
672            classify_json_text(r#"{"time_series_output":{"bus":[]}}"#),
673            JsonClass::Case(Detection::Known(SourceFormat::Transmission(
674                TransmissionFormat::Goc3Json
675            )))
676        );
677    }
678
679    #[test]
680    fn classifies_rawx_and_normalizes_its_alias() {
681        assert_eq!(
682            classify_json_text(
683                r#"{"network":{"caseid":{"fields":["rev"],"data":[35]},"bus":{"fields":[],"data":[]}}}"#
684            ),
685            JsonClass::Case(Detection::Known(SourceFormat::Transmission(
686                TransmissionFormat::PsseRawx
687            )))
688        );
689        for alias in ["psse-rawx", "rawx", "PSSERAWX"] {
690            assert_eq!(
691                super::parse_transmission_format(alias),
692                Some(TransmissionFormat::PsseRawx)
693            );
694        }
695        assert_eq!(TransmissionFormat::PsseRawx.name(), "psse-rawx");
696    }
697
698    #[test]
699    fn resolves_goc3_aliases() {
700        for alias in ["goc3-json", "goc3", "go3", "go-challenge-3", "c3"] {
701            assert_eq!(
702                super::parse_transmission_format(alias),
703                Some(TransmissionFormat::Goc3Json),
704                "{alias}"
705            );
706        }
707    }
708
709    #[test]
710    fn classifies_surge_json() {
711        assert_eq!(
712            classify_json_text(
713                r#"{"format":"surge-json","schema_version":"0.1.0","network":{"buses":[]}}"#
714            ),
715            JsonClass::Case(Detection::Known(SourceFormat::Transmission(
716                TransmissionFormat::SurgeJson
717            )))
718        );
719    }
720
721    #[test]
722    fn resolves_ieee_cdf_aliases() {
723        for alias in ["ieee-cdf", "ieee_cdf", "IEEECDF", "cdf"] {
724            assert_eq!(
725                super::parse_transmission_format(alias),
726                Some(TransmissionFormat::IeeeCdf),
727                "{alias}"
728            );
729        }
730        assert_eq!(TransmissionFormat::IeeeCdf.name(), "ieee-cdf");
731    }
732
733    #[test]
734    fn resolves_surge_aliases() {
735        for alias in ["surge-json", "surge", "surgejson"] {
736            assert_eq!(
737                super::parse_transmission_format(alias),
738                Some(TransmissionFormat::SurgeJson),
739                "{alias}"
740            );
741        }
742    }
743
744    #[test]
745    fn classifies_opfdata_json() {
746        assert_eq!(
747            classify_json_text(
748                r#"{
749                    "grid":{"nodes":{},"edges":{},"context":[]},
750                    "solution":{"nodes":{},"edges":{}},
751                    "metadata":{"objective":0.0}
752                }"#
753            ),
754            JsonClass::Case(Detection::Known(SourceFormat::Transmission(
755                TransmissionFormat::DeepMindOpfDataJson
756            )))
757        );
758        assert_eq!(
759            classify_json_text(r#"{"grid":{},"solution":{},"metadata":{}}"#),
760            JsonClass::Case(Detection::Unknown)
761        );
762    }
763
764    #[test]
765    fn resolves_opfdata_aliases() {
766        for alias in [
767            "opfdata-json",
768            "opfdata",
769            "OPFData",
770            "deepmind-opfdata-json",
771            "deepmind-opfdata",
772            "gridopt-json",
773            "gridopt",
774        ] {
775            assert_eq!(
776                super::parse_transmission_format(alias),
777                Some(TransmissionFormat::DeepMindOpfDataJson),
778                "{alias}"
779            );
780        }
781    }
782
783    #[test]
784    fn classifies_json_with_leading_byte_order_mark() {
785        assert_eq!(
786            classify_json_text("\u{feff}{\"baseMVA\":100.0,\"bus\":{},\"branch\":{}}"),
787            JsonClass::Case(Detection::Known(SourceFormat::Transmission(
788                TransmissionFormat::PowerModelsJson
789            )))
790        );
791    }
792
793    #[test]
794    fn classifies_json_bytes_without_lossy_utf8_replacement() {
795        assert_eq!(
796            classify_json_bytes(b"\xef\xbb\xbf{\"baseMVA\":100.0,\"bus\":{},\"branch\":{}}"),
797            JsonClass::Case(Detection::Known(SourceFormat::Transmission(
798                TransmissionFormat::PowerModelsJson
799            )))
800        );
801        assert_eq!(
802            classify_json_bytes(b"{\"base_mva\":100.0,\"buses\":[],\"branches\":[]}"),
803            JsonClass::Case(Detection::Unknown)
804        );
805        assert_eq!(
806            classify_json_bytes(b"{\"baseMVA\":100.0,\"bus\":{}\xff}"),
807            JsonClass::Case(Detection::Unknown)
808        );
809    }
810
811    #[test]
812    fn unknown_json_has_no_signal() {
813        assert_eq!(
814            classify_json_text(r#"{"name":"case"}"#),
815            JsonClass::Case(Detection::Unknown)
816        );
817    }
818
819    #[test]
820    fn mixed_transmission_and_distribution_markers_are_ambiguous() {
821        assert_eq!(
822            classify_json_text(r#"{"baseMVA":100.0,"voltage_source":{}}"#),
823            JsonClass::Case(Detection::Ambiguous)
824        );
825    }
826
827    /// A key's presence check is true even when its value is JSON `null`;
828    /// only an absent key is absent. A string marker (`schema`, `_class`,
829    /// `format`) whose value is not a string carries no marker from that key, matching
830    /// `serde_json::Value::as_str`'s permissive read; classification still
831    /// proceeds over the document's other markers instead of erroring.
832    #[test]
833    fn a_non_string_value_at_a_string_marker_key_is_read_as_absent() {
834        assert_eq!(
835            classify_json_text(r#"{"schema":123,"baseMVA":100.0,"bus":{},"branch":{}}"#),
836            JsonClass::Case(Detection::Known(SourceFormat::Transmission(
837                TransmissionFormat::PowerModelsJson
838            )))
839        );
840    }
841
842    /// A `network` value that is not a JSON object carries no GO Challenge 3
843    /// marker, but does not stop the surge marker (also keyed off `network`,
844    /// as a presence check rather than a shape check) from resolving.
845    #[test]
846    fn a_non_object_network_value_does_not_stop_the_surge_marker() {
847        assert_eq!(
848            classify_json_text(
849                r#"{"format":"surge-json","schema_version":"0.1.0","network":"opaque"}"#
850            ),
851            JsonClass::Case(Detection::Known(SourceFormat::Transmission(
852                TransmissionFormat::SurgeJson
853            )))
854        );
855    }
856}