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