1#[derive(Clone, Copy, Debug, PartialEq, Eq)]
8pub enum Detection<T> {
9 Known(T),
10 Unknown,
11 Ambiguous,
12}
13
14impl<T> Detection<T> {
15 pub fn known(self) -> Option<T> {
16 match self {
17 Self::Known(value) => Some(value),
18 Self::Unknown | Self::Ambiguous => None,
19 }
20 }
21}
22
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24#[non_exhaustive]
25pub enum Domain {
26 Transmission,
27 Distribution,
28}
29
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31#[non_exhaustive]
32pub enum TransmissionFormat {
33 Matpower,
34 PowerModelsJson,
35 EgretJson,
36 Psse,
37 Psse34,
38 Psse35,
39 PowerWorld,
40 PandapowerJson,
41 PowerioJson,
42 PypsaCsv,
43 Pslf,
44 Pwb,
45 Gridfm,
46 Goc3Json,
47 SurgeJson,
48}
49
50impl TransmissionFormat {
51 pub fn name(self) -> &'static str {
52 match self {
53 Self::Matpower => "matpower",
54 Self::PowerModelsJson => "powermodels-json",
55 Self::EgretJson => "egret-json",
56 Self::Psse => "psse",
57 Self::Psse34 => "psse34",
58 Self::Psse35 => "psse35",
59 Self::PowerWorld => "powerworld",
60 Self::PandapowerJson => "pandapower-json",
61 Self::PowerioJson => "powerio-json",
62 Self::PypsaCsv => "pypsa-csv",
63 Self::Pslf => "pslf",
64 Self::Pwb => "pwb",
65 Self::Gridfm => "gridfm",
66 Self::Goc3Json => "goc3-json",
67 Self::SurgeJson => "surge-json",
68 }
69 }
70}
71
72#[derive(Clone, Copy, Debug, PartialEq, Eq)]
73#[non_exhaustive]
74pub enum DistributionFormat {
75 Dss,
76 PmdJson,
77 BmopfJson,
78}
79
80impl DistributionFormat {
81 pub fn name(self) -> &'static str {
82 match self {
83 Self::Dss => "dss",
84 Self::PmdJson => "pmd-json",
85 Self::BmopfJson => "bmopf-json",
86 }
87 }
88}
89
90#[derive(Clone, Copy, Debug, PartialEq, Eq)]
91#[non_exhaustive]
92pub enum SourceFormat {
93 Transmission(TransmissionFormat),
94 Distribution(DistributionFormat),
95}
96
97impl SourceFormat {
98 pub fn domain(self) -> Domain {
99 match self {
100 Self::Transmission(_) => Domain::Transmission,
101 Self::Distribution(_) => Domain::Distribution,
102 }
103 }
104
105 pub fn name(self) -> &'static str {
106 match self {
107 Self::Transmission(format) => format.name(),
108 Self::Distribution(format) => format.name(),
109 }
110 }
111}
112
113pub type JsonFormat = SourceFormat;
114
115pub fn classify_format_name(name: &str) -> Detection<SourceFormat> {
117 if let Some(format) = transmission_format_from_name(name) {
118 return Detection::Known(SourceFormat::Transmission(format));
119 }
120 if let Some(format) = distribution_format_from_name(name) {
121 return Detection::Known(SourceFormat::Distribution(format));
122 }
123 Detection::Unknown
124}
125
126pub fn transmission_format_from_name(name: &str) -> Option<TransmissionFormat> {
127 let key = canonical_key(name);
128 match key.as_str() {
129 "matpower" | "m" => Some(TransmissionFormat::Matpower),
130 "powermodelsjson" | "powermodels" | "pm" => Some(TransmissionFormat::PowerModelsJson),
131 "egretjson" | "egret" => Some(TransmissionFormat::EgretJson),
132 "psse" | "psse33" | "raw" | "raw33" => Some(TransmissionFormat::Psse),
133 "psse34" | "raw34" => Some(TransmissionFormat::Psse34),
134 "psse35" | "raw35" => Some(TransmissionFormat::Psse35),
135 "powerworld" | "aux" => Some(TransmissionFormat::PowerWorld),
136 "pandapowerjson" | "pandapower" | "pp" => Some(TransmissionFormat::PandapowerJson),
137 "poweriojson" | "powerio" | "json" => Some(TransmissionFormat::PowerioJson),
138 "pypsacsv" | "pypsa" => Some(TransmissionFormat::PypsaCsv),
139 "pslf" | "epc" | "pslfepc" => Some(TransmissionFormat::Pslf),
140 "pwb" => Some(TransmissionFormat::Pwb),
141 "gridfm" => Some(TransmissionFormat::Gridfm),
142 "goc3" | "goc3json" | "go3" | "gochallenge3" | "c3" => Some(TransmissionFormat::Goc3Json),
143 "surge" | "surgejson" => Some(TransmissionFormat::SurgeJson),
144 _ => None,
145 }
146}
147
148pub fn distribution_format_from_name(name: &str) -> Option<DistributionFormat> {
149 let key = canonical_key(name);
150 match key.as_str() {
151 "dss" | "opendss" => Some(DistributionFormat::Dss),
152 "pmd" | "pmdjson" | "engineering" => Some(DistributionFormat::PmdJson),
153 "bmopf" | "bmopfjson" => Some(DistributionFormat::BmopfJson),
154 _ => None,
155 }
156}
157
158pub fn classify_json_text(text: &str) -> Detection<JsonFormat> {
164 let Ok(shape) = JsonShape::try_from(text) else {
165 return Detection::Unknown;
166 };
167 shape.classify()
168}
169
170fn canonical_key(name: &str) -> String {
171 name.to_ascii_lowercase()
172 .chars()
173 .filter(|c| *c != '-' && *c != '_')
174 .collect()
175}
176
177struct JsonShape {
178 object: serde_json::Map<String, serde_json::Value>,
179}
180
181impl TryFrom<&str> for JsonShape {
182 type Error = ();
183
184 fn try_from(text: &str) -> Result<Self, Self::Error> {
185 let value = serde_json::from_str::<serde_json::Value>(text).map_err(|_| ())?;
186 let serde_json::Value::Object(object) = value else {
187 return Err(());
188 };
189 Ok(Self { object })
190 }
191}
192
193impl JsonShape {
194 fn has(&self, key: &str) -> bool {
195 self.object.contains_key(key)
196 }
197
198 fn string(&self, key: &str) -> Option<&str> {
199 self.object.get(key).and_then(serde_json::Value::as_str)
200 }
201
202 fn classify(&self) -> Detection<JsonFormat> {
203 let is_pandapower = self.string("_class") == Some("pandapowerNet");
204 let is_egret = self.has("elements") && self.has("system");
205 let is_goc3 = self.has("network")
206 && (self.has("time_series_input") || self.has("reliability"))
207 && self.object.get("network").is_some_and(|network| {
208 network.as_object().is_some_and(|obj| {
209 obj.contains_key("simple_dispatchable_device")
210 || obj.contains_key("ac_line")
211 || obj.contains_key("two_winding_transformer")
212 })
213 });
214 let is_surge = self.string("format") == Some("surge-json")
215 && self.has("schema_version")
216 && self.has("network");
217 let is_powerio = self.has("buses")
218 && (self.has("branches")
219 || self.has("base_mva")
220 || self.has("loads")
221 || self.has("generators"));
222 let is_power_models =
223 self.has("baseMVA") || self.has("branch") || self.has("gen") || self.has("gencost");
224 let transmission =
225 is_pandapower || is_egret || is_goc3 || is_surge || is_powerio || is_power_models;
226
227 let is_pmd = self.has("data_model");
228 let strong_bmopf = self.has("line")
229 || self.has("linecode")
230 || self.has("transformer")
231 || self.has("voltage_source");
232 let weak_bmopf = self.has("bus")
233 || self.has("load")
234 || self.has("generator")
235 || self.has("shunt")
236 || self.has("switch");
237 let distribution = is_pmd || strong_bmopf || (weak_bmopf && !transmission);
238
239 match (transmission, distribution) {
240 (true, true) => Detection::Ambiguous,
241 (true, false) => Detection::Known(SourceFormat::Transmission(if is_pandapower {
242 TransmissionFormat::PandapowerJson
243 } else if is_egret {
244 TransmissionFormat::EgretJson
245 } else if is_goc3 {
246 TransmissionFormat::Goc3Json
247 } else if is_surge {
248 TransmissionFormat::SurgeJson
249 } else if is_powerio {
250 TransmissionFormat::PowerioJson
251 } else {
252 TransmissionFormat::PowerModelsJson
253 })),
254 (false, true) => Detection::Known(SourceFormat::Distribution(if is_pmd {
255 DistributionFormat::PmdJson
256 } else {
257 DistributionFormat::BmopfJson
258 })),
259 (false, false) => Detection::Unknown,
260 }
261 }
262}
263
264#[cfg(test)]
265mod tests {
266 use super::{
267 Detection, DistributionFormat, SourceFormat, TransmissionFormat, classify_json_text,
268 };
269
270 #[test]
271 fn classifies_pmd_json() {
272 assert_eq!(
273 classify_json_text(r#"{"data_model":"ENGINEERING","bus":{}}"#),
274 Detection::Known(SourceFormat::Distribution(DistributionFormat::PmdJson))
275 );
276 }
277
278 #[test]
279 fn classifies_full_bmopf_json() {
280 assert_eq!(
281 classify_json_text(r#"{"bus":{},"linecode":{},"voltage_source":{}}"#),
282 Detection::Known(SourceFormat::Distribution(DistributionFormat::BmopfJson))
283 );
284 }
285
286 #[test]
287 fn classifies_minimal_bmopf_json() {
288 assert_eq!(
289 classify_json_text(r#"{"bus":{"a":{"terminal_names":["1"]}}}"#),
290 Detection::Known(SourceFormat::Distribution(DistributionFormat::BmopfJson))
291 );
292 }
293
294 #[test]
295 fn classifies_power_models_with_bus_and_base_mva_as_transmission() {
296 assert_eq!(
297 classify_json_text(
298 r#"{"baseMVA":100.0,"bus":{},"branch":{},"gen":{},"load":{},"switch":{}}"#
299 ),
300 Detection::Known(SourceFormat::Transmission(
301 TransmissionFormat::PowerModelsJson
302 ))
303 );
304 }
305
306 #[test]
307 fn classifies_powerio_json() {
308 assert_eq!(
309 classify_json_text(r#"{"base_mva":100.0,"buses":[],"branches":[]}"#),
310 Detection::Known(SourceFormat::Transmission(TransmissionFormat::PowerioJson))
311 );
312 }
313
314 #[test]
315 fn classifies_pandapower_json() {
316 assert_eq!(
317 classify_json_text(r#"{"_class":"pandapowerNet","_object":{}}"#),
318 Detection::Known(SourceFormat::Transmission(
319 TransmissionFormat::PandapowerJson
320 ))
321 );
322 }
323
324 #[test]
325 fn classifies_egret_json() {
326 assert_eq!(
327 classify_json_text(r#"{"elements":{},"system":{}}"#),
328 Detection::Known(SourceFormat::Transmission(TransmissionFormat::EgretJson))
329 );
330 }
331
332 #[test]
333 fn classifies_goc3_json() {
334 assert_eq!(
335 classify_json_text(
336 r#"{"network":{"bus":[],"simple_dispatchable_device":[]},"time_series_input":{}}"#
337 ),
338 Detection::Known(SourceFormat::Transmission(TransmissionFormat::Goc3Json))
339 );
340 }
341
342 #[test]
343 fn resolves_goc3_aliases() {
344 for alias in ["goc3-json", "goc3", "go3", "go-challenge-3", "c3"] {
345 assert_eq!(
346 super::transmission_format_from_name(alias),
347 Some(TransmissionFormat::Goc3Json),
348 "{alias}"
349 );
350 }
351 }
352
353 #[test]
354 fn classifies_surge_json() {
355 assert_eq!(
356 classify_json_text(
357 r#"{"format":"surge-json","schema_version":"0.1.0","network":{"buses":[]}}"#
358 ),
359 Detection::Known(SourceFormat::Transmission(TransmissionFormat::SurgeJson))
360 );
361 }
362
363 #[test]
364 fn resolves_surge_aliases() {
365 for alias in ["surge-json", "surge", "surgejson"] {
366 assert_eq!(
367 super::transmission_format_from_name(alias),
368 Some(TransmissionFormat::SurgeJson),
369 "{alias}"
370 );
371 }
372 }
373
374 #[test]
375 fn unknown_json_has_no_signal() {
376 assert_eq!(classify_json_text(r#"{"name":"case"}"#), Detection::Unknown);
377 }
378
379 #[test]
380 fn mixed_transmission_and_distribution_markers_are_ambiguous() {
381 assert_eq!(
382 classify_json_text(r#"{"baseMVA":100.0,"voltage_source":{}}"#),
383 Detection::Ambiguous
384 );
385 }
386}