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