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}
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 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
148pub 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
214pub enum JsonClass {
215 Module,
219 Case(Detection<JsonFormat>),
221}
222
223pub const JSON_CLASSES: [&str; 5] = [
233 "transmission",
234 "distribution",
235 "module",
236 "ambiguous",
237 "unknown",
238];
239
240impl JsonClass {
241 #[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
257pub fn classify_json_text(text: &str) -> JsonClass {
265 if super::xiidm::looks_like_jiidm(text) {
268 return JsonClass::Case(Detection::Known(SourceFormat::Transmission(
269 TransmissionFormat::Jiidm,
270 )));
271 }
272 let Ok(header) = serde_json::from_str::<JsonHeader>(text.trim_start_matches('\u{feff}')) else {
275 return JsonClass::Case(Detection::Unknown);
276 };
277 if header.schema.as_deref() == Some(POWERIO_IR_SCHEMA) {
279 return JsonClass::Module;
280 }
281 header.classify()
282}
283
284#[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
304fn 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
318fn 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
331fn 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#[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#[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 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 #[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 #[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}