1use serde::Deserialize;
8
9const POWERIO_IR_SCHEMA: &str = "pio-ir";
13
14#[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 IeeeCdf,
63 SincalBalanced,
65 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 CimDist,
107 SincalMulticonductor,
108 Gridlabd,
109 GridlabdArras,
110 RavensJson,
112 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
157pub 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
230pub enum JsonClass {
231 Module,
235 Case(Detection<JsonFormat>),
237}
238
239pub const JSON_CLASSES: [&str; 5] = [
249 "transmission",
250 "distribution",
251 "module",
252 "ambiguous",
253 "unknown",
254];
255
256impl JsonClass {
257 #[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
273pub fn classify_json_text(text: &str) -> JsonClass {
281 if super::xiidm::looks_like_jiidm(text) {
284 return JsonClass::Case(Detection::Known(SourceFormat::Transmission(
285 TransmissionFormat::Jiidm,
286 )));
287 }
288 let Ok(header) = serde_json::from_str::<JsonHeader>(text.trim_start_matches('\u{feff}')) else {
291 return JsonClass::Case(Detection::Unknown);
292 };
293 if header.schema.as_deref() == Some(POWERIO_IR_SCHEMA) {
295 return JsonClass::Module;
296 }
297 header.classify()
298}
299
300#[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
320fn 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
334fn 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
347fn 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
362fn 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#[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#[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 #[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 #[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 let is_ravens_multiconductor =
561 is_ravens && (self.per_length_line_parameter || self.asset_info);
562 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 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 assert_eq!(
666 classify_json_text(r#"{"Versions":{},"ConnectivityNode":{},"PowerSystemResource":{}}"#),
667 JsonClass::Case(Detection::Known(SourceFormat::Transmission(
668 TransmissionFormat::RavensJson
669 )))
670 );
671 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 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 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 #[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 #[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}