1#[derive(Clone, Copy, Debug, PartialEq, Eq)]
7pub enum Detection<T> {
8 Known(T),
9 Unknown,
10 Ambiguous,
11}
12
13impl<T> Detection<T> {
14 pub fn known(self) -> Option<T> {
15 match self {
16 Self::Known(value) => Some(value),
17 Self::Unknown | Self::Ambiguous => None,
18 }
19 }
20}
21
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23#[non_exhaustive]
24pub enum Domain {
25 Transmission,
26 Distribution,
27}
28
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30#[non_exhaustive]
31pub enum TransmissionFormat {
32 Matpower,
33 PowerModelsJson,
34 EgretJson,
35 Psse,
36 Psse34,
37 Psse35,
38 PowerWorld,
39 PandapowerJson,
40 #[doc(hidden)]
41 PowerioJson,
42 PypsaCsv,
43 Pslf,
44 Pwb,
45 Gridfm,
46 Goc3Json,
47 SurgeJson,
48 DeepMindOpfDataJson,
49}
50
51impl TransmissionFormat {
52 pub fn name(self) -> &'static str {
53 match self {
54 Self::Matpower => "matpower",
55 Self::PowerModelsJson => "powermodels-json",
56 Self::EgretJson => "egret-json",
57 Self::Psse => "psse",
58 Self::Psse34 => "psse34",
59 Self::Psse35 => "psse35",
60 Self::PowerWorld => "powerworld",
61 Self::PandapowerJson => "pandapower-json",
62 Self::PowerioJson => "powerio-json",
63 Self::PypsaCsv => "pypsa-csv",
64 Self::Pslf => "pslf",
65 Self::Pwb => "pwb",
66 Self::Gridfm => "gridfm",
67 Self::Goc3Json => "goc3-json",
68 Self::SurgeJson => "surge-json",
69 Self::DeepMindOpfDataJson => "opfdata-json",
70 }
71 }
72}
73
74#[derive(Clone, Copy, Debug, PartialEq, Eq)]
75#[non_exhaustive]
76pub enum DistributionFormat {
77 Dss,
78 PmdJson,
79 BmopfJson,
80}
81
82impl DistributionFormat {
83 pub fn name(self) -> &'static str {
84 match self {
85 Self::Dss => "dss",
86 Self::PmdJson => "pmd-json",
87 Self::BmopfJson => "bmopf-json",
88 }
89 }
90}
91
92#[derive(Clone, Copy, Debug, PartialEq, Eq)]
93#[non_exhaustive]
94pub enum SourceFormat {
95 Transmission(TransmissionFormat),
96 Distribution(DistributionFormat),
97}
98
99impl SourceFormat {
100 pub fn domain(self) -> Domain {
101 match self {
102 Self::Transmission(_) => Domain::Transmission,
103 Self::Distribution(_) => Domain::Distribution,
104 }
105 }
106
107 pub fn name(self) -> &'static str {
108 match self {
109 Self::Transmission(format) => format.name(),
110 Self::Distribution(format) => format.name(),
111 }
112 }
113}
114
115pub type JsonFormat = SourceFormat;
116
117pub fn classify_format_name(name: &str) -> Detection<SourceFormat> {
119 if let Some(format) = transmission_format_from_name(name) {
120 return Detection::Known(SourceFormat::Transmission(format));
121 }
122 if let Some(format) = distribution_format_from_name(name) {
123 return Detection::Known(SourceFormat::Distribution(format));
124 }
125 Detection::Unknown
126}
127
128pub fn transmission_format_from_name(name: &str) -> Option<TransmissionFormat> {
129 let key = canonical_key(name);
130 match key.as_str() {
131 "matpower" | "m" => Some(TransmissionFormat::Matpower),
132 "powermodelsjson" | "powermodels" | "pm" => Some(TransmissionFormat::PowerModelsJson),
133 "egretjson" | "egret" => Some(TransmissionFormat::EgretJson),
134 "psse" | "psse33" | "raw" | "raw33" => Some(TransmissionFormat::Psse),
135 "psse34" | "raw34" => Some(TransmissionFormat::Psse34),
136 "psse35" | "raw35" => Some(TransmissionFormat::Psse35),
137 "powerworld" | "aux" => Some(TransmissionFormat::PowerWorld),
138 "pandapowerjson" | "pandapower" | "pp" => Some(TransmissionFormat::PandapowerJson),
139 "poweriojson" | "powerio" | "json" => Some(TransmissionFormat::PowerioJson),
140 "pypsacsv" | "pypsa" => Some(TransmissionFormat::PypsaCsv),
141 "pslf" | "epc" | "pslfepc" => Some(TransmissionFormat::Pslf),
142 "pwb" => Some(TransmissionFormat::Pwb),
143 "gridfm" => Some(TransmissionFormat::Gridfm),
144 "goc3" | "goc3json" | "go3" | "gochallenge3" | "c3" => Some(TransmissionFormat::Goc3Json),
145 "surge" | "surgejson" => Some(TransmissionFormat::SurgeJson),
146 "opfdata"
147 | "opfdatajson"
148 | "deepmindopfdata"
149 | "deepmindopfdatajson"
150 | "gridopt"
151 | "gridoptjson" => Some(TransmissionFormat::DeepMindOpfDataJson),
152 _ => None,
153 }
154}
155
156pub fn distribution_format_from_name(name: &str) -> Option<DistributionFormat> {
157 let key = canonical_key(name);
158 match key.as_str() {
159 "dss" | "opendss" => Some(DistributionFormat::Dss),
160 "pmd" | "pmdjson" | "engineering" => Some(DistributionFormat::PmdJson),
161 "bmopf" | "bmopfjson" => Some(DistributionFormat::BmopfJson),
162 _ => None,
163 }
164}
165
166#[derive(Clone, Copy, Debug, PartialEq, Eq)]
171pub enum JsonClass {
172 Package,
176 Case(Detection<JsonFormat>),
178}
179
180pub fn classify_json_text(text: &str) -> JsonClass {
190 let Ok(shape) = JsonShape::try_from(text.trim_start_matches('\u{feff}')) else {
193 return JsonClass::Case(Detection::Unknown);
194 };
195 if matches!(
196 shape.string("model_kind"),
197 Some("balanced" | "multiconductor")
198 ) && shape.has("model")
199 {
200 return JsonClass::Package;
201 }
202 JsonClass::Case(shape.classify())
203}
204
205fn canonical_key(name: &str) -> String {
206 name.to_ascii_lowercase()
207 .chars()
208 .filter(|c| *c != '-' && *c != '_')
209 .collect()
210}
211
212struct JsonShape {
213 object: serde_json::Map<String, serde_json::Value>,
214}
215
216impl TryFrom<&str> for JsonShape {
217 type Error = ();
218
219 fn try_from(text: &str) -> Result<Self, Self::Error> {
220 let value = serde_json::from_str::<serde_json::Value>(text).map_err(|_| ())?;
221 let serde_json::Value::Object(object) = value else {
222 return Err(());
223 };
224 Ok(Self { object })
225 }
226}
227
228impl JsonShape {
229 fn has(&self, key: &str) -> bool {
230 self.object.contains_key(key)
231 }
232
233 fn string(&self, key: &str) -> Option<&str> {
234 self.object.get(key).and_then(serde_json::Value::as_str)
235 }
236
237 fn classify(&self) -> Detection<JsonFormat> {
238 let is_pandapower = self.string("_class") == Some("pandapowerNet");
239 let is_egret = self.has("elements") && self.has("system");
240 let is_goc3 = self.has("network")
241 && (self.has("time_series_input") || self.has("reliability"))
242 && self.object.get("network").is_some_and(|network| {
243 network.as_object().is_some_and(|obj| {
244 obj.contains_key("simple_dispatchable_device")
245 || obj.contains_key("ac_line")
246 || obj.contains_key("two_winding_transformer")
247 })
248 });
249 let is_surge = self.string("format") == Some("surge-json")
250 && self.has("schema_version")
251 && self.has("network");
252 let is_opfdata = self
253 .object
254 .get("grid")
255 .and_then(serde_json::Value::as_object)
256 .is_some_and(|grid| {
257 grid.contains_key("nodes")
258 && grid.contains_key("edges")
259 && grid.contains_key("context")
260 })
261 && self
262 .object
263 .get("solution")
264 .and_then(serde_json::Value::as_object)
265 .is_some_and(|solution| {
266 solution.contains_key("nodes") && solution.contains_key("edges")
267 })
268 && self
269 .object
270 .get("metadata")
271 .and_then(serde_json::Value::as_object)
272 .is_some_and(|metadata| metadata.contains_key("objective"));
273 let is_powerio = self.has("buses")
274 && (self.has("branches")
275 || self.has("base_mva")
276 || self.has("loads")
277 || self.has("generators"));
278 let is_power_models =
279 self.has("baseMVA") || self.has("branch") || self.has("gen") || self.has("gencost");
280 let transmission = is_pandapower
281 || is_egret
282 || is_goc3
283 || is_surge
284 || is_opfdata
285 || is_powerio
286 || is_power_models;
287
288 let is_pmd = self.has("data_model");
289 let strong_bmopf = self.has("line")
290 || self.has("linecode")
291 || self.has("transformer")
292 || self.has("voltage_source");
293 let weak_bmopf = self.has("bus")
294 || self.has("load")
295 || self.has("generator")
296 || self.has("shunt")
297 || self.has("switch");
298 let distribution = is_pmd || strong_bmopf || (weak_bmopf && !transmission);
299
300 match (transmission, distribution) {
301 (true, true) => Detection::Ambiguous,
302 (true, false) => Detection::Known(SourceFormat::Transmission(if is_pandapower {
303 TransmissionFormat::PandapowerJson
304 } else if is_egret {
305 TransmissionFormat::EgretJson
306 } else if is_goc3 {
307 TransmissionFormat::Goc3Json
308 } else if is_surge {
309 TransmissionFormat::SurgeJson
310 } else if is_opfdata {
311 TransmissionFormat::DeepMindOpfDataJson
312 } else if is_powerio {
313 TransmissionFormat::PowerioJson
314 } else {
315 TransmissionFormat::PowerModelsJson
316 })),
317 (false, true) => Detection::Known(SourceFormat::Distribution(if is_pmd {
318 DistributionFormat::PmdJson
319 } else {
320 DistributionFormat::BmopfJson
321 })),
322 (false, false) => Detection::Unknown,
323 }
324 }
325}
326
327#[cfg(test)]
328mod tests {
329 use super::{
330 Detection, DistributionFormat, JsonClass, SourceFormat, TransmissionFormat,
331 classify_json_text,
332 };
333
334 #[test]
335 fn classifies_package() {
336 assert_eq!(
337 classify_json_text(
338 r#"{"model_kind":"multiconductor","model":{"kind":"multiconductor"}}"#
339 ),
340 JsonClass::Package
341 );
342 assert_eq!(
343 classify_json_text(r#"{"model_kind":"balanced","model":{}}"#),
344 JsonClass::Package
345 );
346 assert_eq!(
349 classify_json_text(r#"{"buses":[],"linecodes":[]}"#),
350 JsonClass::Case(Detection::Unknown)
351 );
352 assert_eq!(
353 classify_json_text(r#"{"baseMVA":100.0,"bus":{},"model":"ACP","model_kind":"opf"}"#),
354 JsonClass::Case(Detection::Known(SourceFormat::Transmission(
355 TransmissionFormat::PowerModelsJson
356 )))
357 );
358 assert_eq!(
359 classify_json_text("not json"),
360 JsonClass::Case(Detection::Unknown)
361 );
362 }
363
364 #[test]
365 fn classifies_pmd_json() {
366 assert_eq!(
367 classify_json_text(r#"{"data_model":"ENGINEERING","bus":{}}"#),
368 JsonClass::Case(Detection::Known(SourceFormat::Distribution(
369 DistributionFormat::PmdJson
370 )))
371 );
372 }
373
374 #[test]
375 fn classifies_full_bmopf_json() {
376 assert_eq!(
377 classify_json_text(r#"{"bus":{},"linecode":{},"voltage_source":{}}"#),
378 JsonClass::Case(Detection::Known(SourceFormat::Distribution(
379 DistributionFormat::BmopfJson
380 )))
381 );
382 }
383
384 #[test]
385 fn classifies_minimal_bmopf_json() {
386 assert_eq!(
387 classify_json_text(r#"{"bus":{"a":{"terminal_names":["1"]}}}"#),
388 JsonClass::Case(Detection::Known(SourceFormat::Distribution(
389 DistributionFormat::BmopfJson
390 )))
391 );
392 }
393
394 #[test]
395 fn classifies_power_models_with_bus_and_base_mva_as_transmission() {
396 assert_eq!(
397 classify_json_text(
398 r#"{"baseMVA":100.0,"bus":{},"branch":{},"gen":{},"load":{},"switch":{}}"#
399 ),
400 JsonClass::Case(Detection::Known(SourceFormat::Transmission(
401 TransmissionFormat::PowerModelsJson
402 )))
403 );
404 }
405
406 #[test]
407 fn classifies_powerio_json() {
408 assert_eq!(
409 classify_json_text(r#"{"base_mva":100.0,"buses":[],"branches":[]}"#),
410 JsonClass::Case(Detection::Known(SourceFormat::Transmission(
411 TransmissionFormat::PowerioJson
412 )))
413 );
414 }
415
416 #[test]
417 fn classifies_pandapower_json() {
418 assert_eq!(
419 classify_json_text(r#"{"_class":"pandapowerNet","_object":{}}"#),
420 JsonClass::Case(Detection::Known(SourceFormat::Transmission(
421 TransmissionFormat::PandapowerJson
422 )))
423 );
424 }
425
426 #[test]
427 fn classifies_egret_json() {
428 assert_eq!(
429 classify_json_text(r#"{"elements":{},"system":{}}"#),
430 JsonClass::Case(Detection::Known(SourceFormat::Transmission(
431 TransmissionFormat::EgretJson
432 )))
433 );
434 }
435
436 #[test]
437 fn classifies_goc3_json() {
438 assert_eq!(
439 classify_json_text(
440 r#"{"network":{"bus":[],"simple_dispatchable_device":[]},"time_series_input":{}}"#
441 ),
442 JsonClass::Case(Detection::Known(SourceFormat::Transmission(
443 TransmissionFormat::Goc3Json
444 )))
445 );
446 }
447
448 #[test]
449 fn resolves_goc3_aliases() {
450 for alias in ["goc3-json", "goc3", "go3", "go-challenge-3", "c3"] {
451 assert_eq!(
452 super::transmission_format_from_name(alias),
453 Some(TransmissionFormat::Goc3Json),
454 "{alias}"
455 );
456 }
457 }
458
459 #[test]
460 fn classifies_surge_json() {
461 assert_eq!(
462 classify_json_text(
463 r#"{"format":"surge-json","schema_version":"0.1.0","network":{"buses":[]}}"#
464 ),
465 JsonClass::Case(Detection::Known(SourceFormat::Transmission(
466 TransmissionFormat::SurgeJson
467 )))
468 );
469 }
470
471 #[test]
472 fn resolves_surge_aliases() {
473 for alias in ["surge-json", "surge", "surgejson"] {
474 assert_eq!(
475 super::transmission_format_from_name(alias),
476 Some(TransmissionFormat::SurgeJson),
477 "{alias}"
478 );
479 }
480 }
481
482 #[test]
483 fn classifies_opfdata_json() {
484 assert_eq!(
485 classify_json_text(
486 r#"{
487 "grid":{"nodes":{},"edges":{},"context":[]},
488 "solution":{"nodes":{},"edges":{}},
489 "metadata":{"objective":0.0}
490 }"#
491 ),
492 JsonClass::Case(Detection::Known(SourceFormat::Transmission(
493 TransmissionFormat::DeepMindOpfDataJson
494 )))
495 );
496 assert_eq!(
497 classify_json_text(r#"{"grid":{},"solution":{},"metadata":{}}"#),
498 JsonClass::Case(Detection::Unknown)
499 );
500 }
501
502 #[test]
503 fn resolves_opfdata_aliases() {
504 for alias in [
505 "opfdata-json",
506 "opfdata",
507 "OPFData",
508 "deepmind-opfdata-json",
509 "deepmind-opfdata",
510 "gridopt-json",
511 "gridopt",
512 ] {
513 assert_eq!(
514 super::transmission_format_from_name(alias),
515 Some(TransmissionFormat::DeepMindOpfDataJson),
516 "{alias}"
517 );
518 }
519 }
520
521 #[test]
522 fn classifies_json_with_leading_byte_order_mark() {
523 assert_eq!(
524 classify_json_text("\u{feff}{\"baseMVA\":100.0,\"bus\":{},\"branch\":{}}"),
525 JsonClass::Case(Detection::Known(SourceFormat::Transmission(
526 TransmissionFormat::PowerModelsJson
527 )))
528 );
529 }
530
531 #[test]
532 fn unknown_json_has_no_signal() {
533 assert_eq!(
534 classify_json_text(r#"{"name":"case"}"#),
535 JsonClass::Case(Detection::Unknown)
536 );
537 }
538
539 #[test]
540 fn mixed_transmission_and_distribution_markers_are_ambiguous() {
541 assert_eq!(
542 classify_json_text(r#"{"baseMVA":100.0,"voltage_source":{}}"#),
543 JsonClass::Case(Detection::Ambiguous)
544 );
545 }
546}