1use crate::model::{DistNetwork, DistSourceFormat};
4
5#[derive(Debug, Clone, PartialEq, Eq)]
7#[non_exhaustive]
8pub struct ConversionSidecar {
9 pub path: String,
11 pub text: String,
13}
14
15impl ConversionSidecar {
16 #[must_use]
20 pub fn dropped_warning(&self, reason: &str) -> String {
21 format!(
22 "fidelity: sidecar `{}` was not written: {reason}",
23 self.path
24 )
25 }
26}
27
28#[derive(Debug, Clone)]
32#[non_exhaustive]
33pub struct Conversion {
34 pub text: String,
35 pub sidecars: Vec<ConversionSidecar>,
37 pub warnings: Vec<String>,
38 pub diagnostics: Vec<crate::diagnostics::StructuredDiagnostic>,
44}
45
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
48#[non_exhaustive]
49pub enum DistTargetFormat {
50 Dss,
51 BmopfJson,
52 PmdJson,
53}
54
55pub fn dist_target_from_name(name: &str) -> Option<DistTargetFormat> {
57 let key = canonical_key(name);
58 match key.as_str() {
59 "dss" | "opendss" => Some(DistTargetFormat::Dss),
60 "pmd" | "pmdjson" | "engineering" => Some(DistTargetFormat::PmdJson),
61 "bmopf" | "bmopfjson" => Some(DistTargetFormat::BmopfJson),
62 _ => None,
63 }
64}
65
66impl std::str::FromStr for DistTargetFormat {
67 type Err = crate::Error;
68
69 fn from_str(s: &str) -> crate::Result<Self> {
72 dist_target_from_name(s).ok_or_else(|| crate::Error::UnknownFormat(s.to_string()))
73 }
74}
75
76impl DistTargetFormat {
77 pub fn name(self) -> &'static str {
80 match self {
81 DistTargetFormat::Dss => "dss",
82 DistTargetFormat::PmdJson => "pmd-json",
83 DistTargetFormat::BmopfJson => "bmopf-json",
84 }
85 }
86}
87
88fn read(path: &std::path::Path) -> crate::Result<String> {
89 std::fs::read_to_string(path).map_err(|source| crate::Error::Io {
90 path: path.display().to_string(),
91 source,
92 })
93}
94
95fn canonical_key(name: &str) -> String {
96 name.to_ascii_lowercase()
97 .chars()
98 .filter(|c| *c != '-' && *c != '_')
99 .collect()
100}
101
102const DISTRIBUTION_ELEMENT_TABLES: &[&str] = &[
115 "capacitor",
116 "control_profile",
121 "generator",
122 "ibr",
123 "line",
124 "linecode",
125 "load",
126 "meta",
127 "shunt",
128 "switch",
129 "terminal_conventions",
130 "transformer",
131 "voltage_source",
132];
133
134const NOT_BMOPF_KEYS: &[&str] = &[
139 "baseMVA",
140 "branch",
141 "dcline",
142 "gen",
143 "per_unit",
144 "source_type",
145 "source_version",
146 "storage",
147];
148
149const PMD_MARKER: &str = "data_model";
153
154#[allow(clippy::struct_excessive_bools)]
166#[derive(Default)]
167struct TopLevel {
168 is_object: bool,
170 pmd_marker: bool,
171 bus: bool,
172 dist_table: bool,
174 not_bmopf: bool,
176}
177
178impl<'de> serde::Deserialize<'de> for TopLevel {
179 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
180 struct Probe;
181
182 impl<'de> serde::de::Visitor<'de> for Probe {
183 type Value = TopLevel;
184
185 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186 f.write_str("a JSON document")
187 }
188
189 fn visit_map<A: serde::de::MapAccess<'de>>(
190 self,
191 mut map: A,
192 ) -> Result<TopLevel, A::Error> {
193 let mut out = TopLevel {
194 is_object: true,
195 ..TopLevel::default()
196 };
197 while let Some(key) = map.next_key::<std::borrow::Cow<'_, str>>()? {
202 let key = key.as_ref();
203 out.pmd_marker |= key == PMD_MARKER;
204 out.bus |= key == "bus";
205 out.dist_table |= DISTRIBUTION_ELEMENT_TABLES.contains(&key);
206 out.not_bmopf |= NOT_BMOPF_KEYS.contains(&key);
207 map.next_value::<serde::de::IgnoredAny>()?;
208 }
209 Ok(out)
210 }
211
212 fn visit_bool<E>(self, _: bool) -> Result<TopLevel, E> {
217 Ok(TopLevel::default())
218 }
219 fn visit_i64<E>(self, _: i64) -> Result<TopLevel, E> {
220 Ok(TopLevel::default())
221 }
222 fn visit_u64<E>(self, _: u64) -> Result<TopLevel, E> {
223 Ok(TopLevel::default())
224 }
225 fn visit_f64<E>(self, _: f64) -> Result<TopLevel, E> {
226 Ok(TopLevel::default())
227 }
228 fn visit_str<E>(self, _: &str) -> Result<TopLevel, E> {
229 Ok(TopLevel::default())
230 }
231 fn visit_unit<E>(self) -> Result<TopLevel, E> {
232 Ok(TopLevel::default())
233 }
234 fn visit_none<E>(self) -> Result<TopLevel, E> {
235 Ok(TopLevel::default())
236 }
237 fn visit_seq<A: serde::de::SeqAccess<'de>>(
238 self,
239 mut seq: A,
240 ) -> Result<TopLevel, A::Error> {
241 while seq.next_element::<serde::de::IgnoredAny>()?.is_some() {}
242 Ok(TopLevel::default())
243 }
244 }
245
246 deserializer.deserialize_any(Probe)
247 }
248}
249
250pub fn classify_distribution_json(text: &str) -> crate::Result<DistTargetFormat> {
268 let text = text.trim_start_matches('\u{feff}');
271 let unrecognized = |detail: &str| crate::Error::Json {
272 format: "distribution",
273 message: format!(
274 "not a recognized distribution document: {detail}. PMD ENGINEERING JSON \
275 carries `data_model`; BMOPF JSON carries a `bus` table beside one of \
276 {DISTRIBUTION_ELEMENT_TABLES:?}. Pass the format explicitly to override."
277 ),
278 };
279
280 let Ok(top) = serde_json::from_str::<TopLevel>(text) else {
281 return Ok(DistTargetFormat::BmopfJson);
282 };
283 if !top.is_object {
284 return Err(unrecognized("the top level is not an object"));
285 }
286
287 if top.pmd_marker {
293 return Ok(DistTargetFormat::PmdJson);
294 }
295 if top.bus && top.dist_table && !top.not_bmopf {
296 return Ok(DistTargetFormat::BmopfJson);
297 }
298 Err(if top.bus && top.not_bmopf {
299 unrecognized(
300 "it carries a `bus` table with PowerModels keys beside it, so it is a \
301 transmission document; read it through the transmission hub",
302 )
303 } else if top.bus {
304 unrecognized("its `bus` table has no distribution element table beside it")
305 } else {
306 unrecognized("it carries no marker of either format")
307 })
308}
309
310pub(crate) const BOM_WARNING: &str =
312 "leading UTF-8 byte order mark removed; a same-format write returns the text without it";
313
314fn parse_text(text: &str, format: DistTargetFormat) -> crate::Result<DistNetwork> {
320 let stripped = text.trim_start_matches('\u{feff}');
321 let mut net = match format {
322 DistTargetFormat::Dss => crate::dss::parse_dss_str(stripped),
323 DistTargetFormat::BmopfJson => crate::bmopf::parse_bmopf_str(stripped)?,
324 DistTargetFormat::PmdJson => crate::pmd::parse_pmd_str(stripped)?,
325 };
326 if stripped.len() != text.len() {
327 net.warnings.push(BOM_WARNING.to_owned());
328 }
329 Ok(net)
330}
331
332pub fn parse_str(text: &str, format: &str) -> crate::Result<DistNetwork> {
334 parse_text(text, format.parse::<DistTargetFormat>()?)
335}
336
337pub fn parse_file(
340 path: impl AsRef<std::path::Path>,
341 from: Option<&str>,
342) -> crate::Result<DistNetwork> {
343 let path = path.as_ref();
344 let format = if let Some(from) = from {
347 from.parse::<DistTargetFormat>()?
348 } else {
349 let ext = path
350 .extension()
351 .and_then(|e| e.to_str())
352 .unwrap_or_default()
353 .to_ascii_lowercase();
354 match ext.as_str() {
355 "dss" => DistTargetFormat::Dss,
356 "json" => {
357 let text = read(path)?;
358 return parse_text(&text, classify_distribution_json(&text)?);
359 }
360 other => return Err(crate::Error::UnknownFormat(other.to_string())),
361 }
362 };
363 match format {
364 DistTargetFormat::Dss => crate::dss::parse_dss_file(path),
365 DistTargetFormat::BmopfJson | DistTargetFormat::PmdJson => parse_text(&read(path)?, format),
366 }
367}
368
369fn convert(net: &DistNetwork, target: DistTargetFormat) -> Conversion {
373 let conv = net.to_format(target);
374 let mut warnings = net.warnings.clone();
375 warnings.extend(conv.warnings);
376 let mut diagnostics = net.parse_diagnostics.clone();
377 diagnostics.extend(conv.diagnostics);
378 Conversion {
379 text: conv.text,
380 sidecars: conv.sidecars,
381 warnings,
382 diagnostics,
383 }
384}
385
386pub fn convert_str(text: &str, to: DistTargetFormat, format: &str) -> crate::Result<Conversion> {
389 Ok(convert(&parse_str(text, format)?, to))
390}
391
392pub fn convert_file(
396 path: impl AsRef<std::path::Path>,
397 to: DistTargetFormat,
398 from: Option<&str>,
399) -> crate::Result<Conversion> {
400 Ok(convert(&parse_file(path, from)?, to))
401}
402
403impl DistTargetFormat {
404 fn matches(self, source: DistSourceFormat) -> bool {
405 matches!(
406 (self, source),
407 (DistTargetFormat::Dss, DistSourceFormat::Dss)
408 | (DistTargetFormat::BmopfJson, DistSourceFormat::BmopfJson)
409 | (DistTargetFormat::PmdJson, DistSourceFormat::PmdJson)
410 )
411 }
412}
413
414impl DistNetwork {
415 pub fn to_canonical_format(&self, format: DistTargetFormat) -> Conversion {
417 let mut conv = match format {
418 DistTargetFormat::Dss => crate::dss::write_dss(self),
419 DistTargetFormat::BmopfJson => crate::bmopf::write_bmopf_json(self),
420 DistTargetFormat::PmdJson => crate::pmd::write_pmd_json(self),
421 };
422 let routed = self
425 .lines
426 .iter()
427 .filter(|line| line.route.is_some())
428 .count();
429 if routed > 0 {
430 conv.warnings.push(format!(
431 "{routed} line route(s) dropped: {} has no polyline field",
432 format.name()
433 ));
434 }
435 conv
436 }
437
438 pub fn to_format(&self, format: DistTargetFormat) -> Conversion {
449 if let (Some(source), Some(source_format)) = (&self.source, self.source_format) {
450 if format.matches(source_format) {
451 return Conversion {
452 text: source.as_ref().clone(),
453 sidecars: Vec::new(),
454 warnings: Vec::new(),
455 diagnostics: Vec::new(),
456 };
457 }
458 }
459 self.to_canonical_format(format)
460 }
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466
467 #[test]
468 fn distribution_json_classifier_preserves_pmd_marker_and_bmopf_fallback() {
469 for doc in [
470 r#"{"data_model": "ENGINEERING"}"#,
471 r#"{"data_model": "MATHEMATICAL"}"#,
472 r#"{"data_model": 7}"#,
475 r#"{"data_model": null}"#,
476 ] {
477 assert_eq!(
478 classify_distribution_json(doc).unwrap(),
479 DistTargetFormat::PmdJson,
480 "{doc}"
481 );
482 }
483 for doc in [
484 r#"{"bus": {}, "voltage_source": {}}"#,
485 r#"{"bus": {}, "line": {}, "linecode": {}}"#,
488 r#"{"bus": {}, "transformer": {}}"#,
489 r#"{"bus": {}, "capacitor": {}}"#,
490 r#"{"bus": {}, "generator": {}}"#,
491 r#"{"bus": {}, "ibr": {}}"#,
494 r#"{"bus": {}, "control_profile": {}}"#,
495 ] {
496 assert_eq!(
497 classify_distribution_json(doc).unwrap(),
498 DistTargetFormat::BmopfJson,
499 "{doc}"
500 );
501 }
502 assert_eq!(
505 classify_distribution_json("{not json").unwrap(),
506 DistTargetFormat::BmopfJson
507 );
508 assert_eq!(
511 classify_distribution_json("\u{feff}{\"data_model\": \"ENGINEERING\"}").unwrap(),
512 DistTargetFormat::PmdJson
513 );
514 }
515
516 #[test]
521 fn a_powermodels_document_never_classifies_as_bmopf() {
522 let powermodels = r#"{"baseMVA": 100.0, "branch": {}, "bus": {}, "dcline": {},
524 "gen": {}, "load": {}, "name": "case14", "per_unit": true, "shunt": {},
525 "source_type": "matpower", "source_version": "2", "storage": {},
526 "switch": {}}"#;
527 assert!(classify_distribution_json(powermodels).is_err());
528
529 for marker in NOT_BMOPF_KEYS {
533 let doc = format!("{{\"bus\": {{}}, \"linecode\": {{}}, \"{marker}\": 1}}");
534 assert!(
535 classify_distribution_json(&doc).is_err(),
536 "`{marker}` must refuse the BMOPF reading: {doc}"
537 );
538 }
539 }
540
541 #[test]
546 fn shared_table_names_classify_as_bmopf_and_the_veto_still_refuses_powermodels() {
547 for doc in [
550 r#"{"bus": {}, "load": {}}"#,
551 r#"{"bus": {}, "shunt": {}}"#,
552 r#"{"bus": {}, "switch": {}}"#,
553 r#"{"bus": {}, "meta": {"frequency": 60}}"#,
554 ] {
555 assert_eq!(
556 classify_distribution_json(doc).unwrap(),
557 DistTargetFormat::BmopfJson,
558 "{doc}"
559 );
560 }
561 for doc in [
563 r#"{"bus": {}, "load": {}, "baseMVA": 100.0}"#,
564 r#"{"bus": {}, "shunt": {}, "branch": {}}"#,
565 r#"{"bus": {}, "switch": {}, "per_unit": true}"#,
566 ] {
567 assert!(classify_distribution_json(doc).is_err(), "{doc}");
568 }
569 }
570
571 #[test]
575 fn the_pmd_marker_wins_over_shared_element_tables() {
576 let both = r#"{"data_model": "ENGINEERING", "bus": {}, "line": {}, "linecode": {}}"#;
577 assert_eq!(
578 classify_distribution_json(both).unwrap(),
579 DistTargetFormat::PmdJson
580 );
581 }
582
583 #[test]
584 fn unclassifiable_documents_are_refused_with_a_reason() {
585 for (doc, needle) in [
586 (
587 r#"{"bus": {"data_model": {}}}"#,
588 "no distribution element table",
589 ),
590 (r#"{"name": "data_model"}"#, "no marker of either format"),
591 ("{}", "no marker of either format"),
592 ("[]", "not an object"),
593 ("null", "not an object"),
594 ("3", "not an object"),
595 (r#""a string""#, "not an object"),
596 ("true", "not an object"),
597 ] {
598 let err = classify_distribution_json(doc).unwrap_err().to_string();
599 assert!(err.contains(needle), "{doc}: got {err}");
600 }
601 }
602
603 #[test]
608 fn the_probe_is_bounded_on_adversarial_shapes() {
609 let big = format!(
611 r#"{{"bus": {{}}, "linecode": {{}}, "junk": [{}]}}"#,
612 "0,".repeat(200_000) + "0"
613 );
614 assert_eq!(
615 classify_distribution_json(&big).unwrap(),
616 DistTargetFormat::BmopfJson
617 );
618
619 let mut keys = String::new();
621 for i in 0..50_000 {
622 use std::fmt::Write as _;
623 let _ = write!(keys, "\"k{i}\":0,");
624 }
625 let many = format!(r#"{{{keys}"bus":{{}},"linecode":{{}}}}"#);
626 assert_eq!(
627 classify_distribution_json(&many).unwrap(),
628 DistTargetFormat::BmopfJson
629 );
630
631 let deep = format!(
637 r#"{{"bus":{{}},"linecode":{{}},"junk":{}{}}}"#,
638 "[".repeat(20_000),
639 "]".repeat(20_000)
640 );
641 assert_eq!(
642 classify_distribution_json(&deep).unwrap(),
643 DistTargetFormat::BmopfJson
644 );
645
646 assert_eq!(
648 classify_distribution_json(
649 r#"{"data_model":"ENGINEERING","data_model":"ENGINEERING"}"#
650 )
651 .unwrap(),
652 DistTargetFormat::PmdJson
653 );
654 }
655
656 #[test]
661 fn a_document_the_probe_accepts_is_refused_by_the_reader_not_a_crash() {
662 for depth in [200usize, 20_000, 500_000] {
663 let doc = format!(
664 "{{\"bus\":{{}},\"linecode\":{{}},\"junk\":{}{}}}",
665 "[".repeat(depth),
666 "]".repeat(depth)
667 );
668 let format = classify_distribution_json(&doc).expect("markers are present");
669 assert_eq!(format, DistTargetFormat::BmopfJson);
670 let err = crate::parse_str(&doc, format.name())
671 .expect_err("the reader refuses past its recursion limit");
672 assert!(
673 err.to_string().contains("recursion limit"),
674 "depth {depth}: {err}"
675 );
676 }
677 }
678
679 #[test]
683 fn marker_matching_is_case_sensitive() {
684 for doc in [
685 r#"{"Data_Model": "ENGINEERING"}"#,
686 r#"{"DATA_MODEL": "ENGINEERING"}"#,
687 r#"{"Bus": {}, "Linecode": {}}"#,
688 ] {
689 assert!(classify_distribution_json(doc).is_err(), "{doc}");
690 }
691 }
692
693 #[test]
694 fn byte_order_mark_is_stripped_and_warned() {
695 let dss = "\u{feff}clear\nnew circuit.c basekv=12.47 bus1=src\n";
696 let net = parse_str(dss, "dss").unwrap();
697 assert!(
698 net.warnings.iter().any(|w| w.contains("byte order mark")),
699 "warnings: {:?}",
700 net.warnings
701 );
702 assert!(
703 net.source
704 .as_ref()
705 .is_some_and(|s| !s.starts_with('\u{feff}'))
706 );
707 }
708
709 #[test]
710 fn parse_file_rejects_unclassifiable_json() {
711 let dir = tempfile::tempdir().unwrap();
714 let path = dir.path().join("case.json");
715 std::fs::write(
716 &path,
717 r#"{"bus": {}, "branch": {}, "gen": {}, "baseMVA": 100.0}"#,
718 )
719 .unwrap();
720 let err = parse_file(&path, None).unwrap_err();
721 assert!(
722 err.to_string()
723 .contains("not a recognized distribution document"),
724 "{err}"
725 );
726 assert!(parse_file(&path, Some("bmopf-json")).is_ok());
728 }
729
730 #[test]
731 fn unknown_format_names_fail_before_any_work() {
732 assert!(matches!(
733 parse_str("", "matpower"),
734 Err(crate::Error::UnknownFormat(_))
735 ));
736 assert!(matches!(
737 "matpower".parse::<DistTargetFormat>(),
738 Err(crate::Error::UnknownFormat(_))
739 ));
740 assert!(matches!(
741 parse_file("missing.dss", Some("matpower")),
742 Err(crate::Error::UnknownFormat(_))
743 ));
744 }
745
746 #[test]
747 fn one_shot_convert_carries_parse_warnings() {
748 let dss = "clear\nnew circuit.w basekv=12.47 bus1=src\n\
749 new line.l1 bus1=src bus2=b2 length=1 units=furlong\n";
750 let conv = convert_str(dss, DistTargetFormat::BmopfJson, "dss").unwrap();
751 assert!(
752 conv.warnings.iter().any(|w| w.contains("furlong")),
753 "parse warnings must surface through the one-shot converter: {:?}",
754 conv.warnings
755 );
756 }
757
758 #[test]
759 fn canonical_format_bypasses_same_format_dss_echo() {
760 let src = "Clear\n\
761 New Circuit.c basekv=12.47 bus1=sourcebus\n\
762 New Load.l1 bus1=sourcebus.1 phases=1 conn=wye kv=7.2 kw=10 kvar=2\n";
763 let net = parse_str(src, "dss").unwrap();
764 assert_eq!(net.to_format(DistTargetFormat::Dss).text, src);
765
766 let canonical = net.to_canonical_format(DistTargetFormat::Dss);
767 assert_ne!(canonical.text, src);
768 assert!(
769 canonical
770 .text
771 .lines()
772 .any(|l| l.contains("Load.l1") && l.contains("vminpu=0")),
773 "{}",
774 canonical.text
775 );
776 }
777}