1use crate::model::{DistSourceFormat, MulticonductorNetwork};
4
5#[derive(Debug, Clone, PartialEq, Eq)]
7pub(crate) struct TextSidecar {
8 pub(crate) path: String,
10 pub(crate) text: String,
12}
13
14#[derive(Debug, Clone)]
16pub(crate) struct TextEmission {
17 pub(crate) text: String,
18 pub(crate) sidecars: Vec<TextSidecar>,
19 pub(crate) diagnostics: Vec<crate::diagnostics::Diagnostic>,
20 pub(crate) fidelity: powerio_core::Fidelity,
21}
22
23impl TextEmission {
24 pub(crate) fn new(
25 text: String,
26 sidecars: Vec<TextSidecar>,
27 diagnostics: crate::diagnostics::Diagnostics,
28 ) -> Self {
29 Self {
30 text,
31 sidecars,
32 diagnostics: diagnostics.into_records(),
33 fidelity: powerio_core::Fidelity::Canonical,
34 }
35 }
36
37 pub(crate) fn faithful(text: String) -> Self {
39 let mut emission = Self::new(text, Vec::new(), crate::diagnostics::Diagnostics::new());
40 emission.fidelity = powerio_core::Fidelity::ExactSameFormat;
41 emission
42 }
43
44 pub(crate) fn push(
46 &mut self,
47 info: &'static crate::diagnostics::DiagnosticInfo,
48 message: impl Into<String>,
49 ) {
50 self.diagnostics
51 .push(crate::diagnostics::Diagnostic::of(info, message));
52 }
53
54 #[cfg(test)]
55 pub(crate) fn render_diagnostics(&self) -> Vec<String> {
56 crate::diagnostics::render_diagnostics(&self.diagnostics)
57 }
58}
59
60#[derive(Clone, Copy, Debug, PartialEq, Eq)]
62#[non_exhaustive]
63pub enum DistTargetFormat {
64 Dss,
65 BmopfJson,
66 PmdJson,
67 CimDist,
70 Gridlabd,
71 GridlabdArras,
72}
73
74#[derive(Clone, Debug, Default, PartialEq)]
76#[non_exhaustive]
77pub struct EmitOptions {
78 pub dss: crate::dss::DssEmitOptions,
79 pub bmopf: crate::bmopf::BmopfEmitOptions,
80}
81
82impl EmitOptions {
83 fn is_default_for(&self, format: DistTargetFormat) -> bool {
84 match format {
85 DistTargetFormat::Dss => self.dss == crate::dss::DssEmitOptions::default(),
86 DistTargetFormat::BmopfJson => self.bmopf == crate::bmopf::BmopfEmitOptions::default(),
87 DistTargetFormat::PmdJson
88 | DistTargetFormat::CimDist
89 | DistTargetFormat::Gridlabd
90 | DistTargetFormat::GridlabdArras => true,
91 }
92 }
93}
94
95pub fn parse_dist_target_format(name: &str) -> Option<DistTargetFormat> {
97 let key = canonical_key(name);
98 match key.as_str() {
99 "dss" | "opendss" => Some(DistTargetFormat::Dss),
100 "glm" | "gridlabd" | "gridlabdpnnl" => Some(DistTargetFormat::Gridlabd),
101 "gridlabdarras" => Some(DistTargetFormat::GridlabdArras),
102 "pmd" | "pmdjson" | "engineering" => Some(DistTargetFormat::PmdJson),
103 "bmopf" | "bmopfjson" => Some(DistTargetFormat::BmopfJson),
104 "cimdist" => Some(DistTargetFormat::CimDist),
105 _ => None,
106 }
107}
108
109impl std::str::FromStr for DistTargetFormat {
110 type Err = crate::Error;
111
112 fn from_str(s: &str) -> crate::Result<Self> {
115 parse_dist_target_format(s).ok_or_else(|| crate::Error::UnknownFormat(s.to_string()))
116 }
117}
118
119impl DistTargetFormat {
120 pub fn name(self) -> &'static str {
123 match self {
124 DistTargetFormat::Dss => "dss",
125 DistTargetFormat::Gridlabd => "gridlabd",
126 DistTargetFormat::GridlabdArras => "gridlabd-arras",
127 DistTargetFormat::PmdJson => "pmd-json",
128 DistTargetFormat::BmopfJson => "bmopf-json",
129 DistTargetFormat::CimDist => "cim-dist",
130 }
131 }
132}
133
134fn canonical_key(name: &str) -> String {
135 name.to_ascii_lowercase()
136 .chars()
137 .filter(|c| *c != '-' && *c != '_')
138 .collect()
139}
140
141const DISTRIBUTION_ELEMENT_TABLES: &[&str] = &[
154 "capacitor",
155 "control_profile",
160 "generator",
161 "ibr",
162 "line",
163 "linecode",
164 "load",
165 "meta",
166 "shunt",
167 "switch",
168 "terminal_conventions",
169 "transformer",
170 "voltage_source",
171];
172
173const NOT_BMOPF_KEYS: &[&str] = &[
178 "baseMVA",
179 "branch",
180 "dcline",
181 "gen",
182 "per_unit",
183 "source_type",
184 "source_version",
185 "storage",
186];
187
188const PMD_MARKER: &str = "data_model";
192
193#[allow(clippy::struct_excessive_bools)]
205#[derive(Default)]
206struct TopLevel {
207 is_object: bool,
209 pmd_marker: bool,
210 bus: bool,
211 dist_table: bool,
213 not_bmopf: bool,
215}
216
217impl<'de> serde::Deserialize<'de> for TopLevel {
218 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
219 struct Probe;
220
221 impl<'de> serde::de::Visitor<'de> for Probe {
222 type Value = TopLevel;
223
224 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225 f.write_str("a JSON document")
226 }
227
228 fn visit_map<A: serde::de::MapAccess<'de>>(
229 self,
230 mut map: A,
231 ) -> Result<TopLevel, A::Error> {
232 let mut out = TopLevel {
233 is_object: true,
234 ..TopLevel::default()
235 };
236 while let Some(key) = map.next_key::<std::borrow::Cow<'_, str>>()? {
241 let key = key.as_ref();
242 out.pmd_marker |= key == PMD_MARKER;
243 out.bus |= key == "bus";
244 out.dist_table |= DISTRIBUTION_ELEMENT_TABLES.contains(&key);
245 out.not_bmopf |= NOT_BMOPF_KEYS.contains(&key);
246 map.next_value::<serde::de::IgnoredAny>()?;
247 }
248 Ok(out)
249 }
250
251 fn visit_bool<E>(self, _: bool) -> Result<TopLevel, E> {
256 Ok(TopLevel::default())
257 }
258 fn visit_i64<E>(self, _: i64) -> Result<TopLevel, E> {
259 Ok(TopLevel::default())
260 }
261 fn visit_u64<E>(self, _: u64) -> Result<TopLevel, E> {
262 Ok(TopLevel::default())
263 }
264 fn visit_f64<E>(self, _: f64) -> Result<TopLevel, E> {
265 Ok(TopLevel::default())
266 }
267 fn visit_str<E>(self, _: &str) -> Result<TopLevel, E> {
268 Ok(TopLevel::default())
269 }
270 fn visit_unit<E>(self) -> Result<TopLevel, E> {
271 Ok(TopLevel::default())
272 }
273 fn visit_none<E>(self) -> Result<TopLevel, E> {
274 Ok(TopLevel::default())
275 }
276 fn visit_seq<A: serde::de::SeqAccess<'de>>(
277 self,
278 mut seq: A,
279 ) -> Result<TopLevel, A::Error> {
280 while seq.next_element::<serde::de::IgnoredAny>()?.is_some() {}
281 Ok(TopLevel::default())
282 }
283 }
284
285 deserializer.deserialize_any(Probe)
286 }
287}
288
289pub fn classify_distribution_json(text: &str) -> crate::Result<DistTargetFormat> {
307 let text = text.trim_start_matches('\u{feff}');
310 let unrecognized = |detail: &str| crate::Error::Json {
311 format: "distribution",
312 message: format!(
313 "not a recognized distribution document: {detail}. PMD ENGINEERING JSON \
314 carries `data_model`; BMOPF JSON carries a `bus` table beside one of \
315 {DISTRIBUTION_ELEMENT_TABLES:?}. Pass the format explicitly to override."
316 ),
317 };
318
319 let Ok(top) = serde_json::from_str::<TopLevel>(text) else {
320 return Ok(DistTargetFormat::BmopfJson);
321 };
322 if !top.is_object {
323 return Err(unrecognized("the top level is not an object"));
324 }
325
326 if top.pmd_marker {
332 return Ok(DistTargetFormat::PmdJson);
333 }
334 if top.bus && top.dist_table && !top.not_bmopf {
335 return Ok(DistTargetFormat::BmopfJson);
336 }
337 Err(if top.bus && top.not_bmopf {
338 unrecognized(
339 "it carries a `bus` table with PowerModels keys beside it, so it is a \
340 transmission document; read it through the transmission hub",
341 )
342 } else if top.bus {
343 unrecognized("its `bus` table has no distribution element table beside it")
344 } else {
345 unrecognized("it carries no marker of either format")
346 })
347}
348
349pub fn parse(
367 source: powerio_core::Source,
368) -> std::result::Result<powerio_core::PioModule<MulticonductorNetwork>, powerio_core::Error> {
369 if source
370 .format()
371 .is_some_and(|f| is_sincal_multiconductor_token(f.as_str()))
372 {
373 return Err(sincal_feature_disabled(&source));
374 }
375 let selected = source
376 .format()
377 .and_then(|id| parse_dist_target_format(id.as_str()));
378 if matches!(
379 selected,
380 Some(DistTargetFormat::Gridlabd | DistTargetFormat::GridlabdArras)
381 ) || (source.format().is_none()
382 && std::path::Path::new(source.name())
383 .extension()
384 .is_some_and(|ext| ext.eq_ignore_ascii_case("glm")))
385 {
386 let dialect = if selected == Some(DistTargetFormat::GridlabdArras) {
387 crate::glm::GlmDialect::Arras4_3
388 } else {
389 crate::glm::GlmDialect::Pnnl5_3
390 };
391 return crate::glm::parse(
392 source,
393 crate::glm::PreprocessOptions {
394 dialect,
395 ..Default::default()
396 },
397 );
398 }
399 let mut warnings = crate::collect::Diagnostics::new();
400 let mut dss_replay = None;
401 match parse_to_network(&source, &mut warnings, &mut dss_replay) {
402 Ok((network, format)) => {
403 let source = match powerio_core::FormatId::new(format.name()) {
406 Ok(format) => source.with_format(format),
407 Err(_) => source,
408 };
409 let mut module =
410 powerio_core::PioModule::parsed(network, source, warnings.into_records())?;
411 if let Some(replay) = dss_replay {
412 module.insert_extension(
413 "org.powerio.opendss",
414 serde_json::json!({
415 "version": 1, "absolute_dependency": replay.absolute_dependency,
416 "dependencies": replay.dependencies.iter().map(powerio_core::SourceId::as_str).collect::<Vec<_>>(),
417 }),
418 )?;
419 }
420 Ok(module)
421 }
422 Err(error) => {
423 let core = powerio_core::Error::new(error.code(), error.to_string());
424 Err(core
425 .with_diagnostics(warnings.into_records())
426 .with_cause(error)
427 .with_source(source))
428 }
429 }
430}
431
432fn parse_to_network(
435 source: &powerio_core::Source,
436 warnings: &mut crate::collect::Diagnostics,
437 dss_replay: &mut Option<crate::dss::read::ReplayContext>,
438) -> crate::Result<(MulticonductorNetwork, DistTargetFormat)> {
439 let declared = source
440 .format()
441 .map(powerio_core::FormatId::as_str)
442 .map(|name| {
443 parse_dist_target_format(name)
444 .ok_or_else(|| crate::Error::UnknownFormat(name.to_string()))
445 })
446 .transpose()?;
447 if source.is_directory() {
448 let cim = declared.map_or_else(
449 || crate::cim::source_looks_like_cim_dist(source),
450 |format| format == DistTargetFormat::CimDist,
451 );
452 if !cim {
453 return Err(crate::Error::UnknownFormat(format!(
454 "{} is a directory, and the only distribution format read from a directory is \
455 a cim-dist document set; pass a case file",
456 source.name()
457 )));
458 }
459 }
460 let format = if let Some(format) = declared {
461 format
462 } else if source.is_directory() {
463 DistTargetFormat::CimDist
464 } else {
465 let ext = std::path::Path::new(source.name())
466 .extension()
467 .and_then(|e| e.to_str())
468 .unwrap_or_default()
469 .to_ascii_lowercase();
470 match ext.as_str() {
471 "dss" => DistTargetFormat::Dss,
472 "json" => {
473 let buffer = primary(source)?;
474 classify_distribution_json(source_text(&buffer)?)?
475 }
476 "xml" if crate::cim::looks_like_cim_dist(primary(source)?.content_bytes()) => {
477 DistTargetFormat::CimDist
478 }
479 other => return Err(crate::Error::UnknownFormat(other.to_string())),
480 }
481 };
482 let network = match format {
483 DistTargetFormat::Gridlabd | DistTargetFormat::GridlabdArras => Err(
484 crate::Error::UnknownFormat("GLM project requires the project parser".into()),
485 ),
486 DistTargetFormat::Dss => {
487 crate::dss::read::parse_dss_collecting(source, warnings, dss_replay)
488 }
489 DistTargetFormat::BmopfJson => {
490 let buffer = primary(source)?;
491 crate::bmopf::read::parse_bmopf_collecting(source_text(&buffer)?, warnings)
492 }
493 DistTargetFormat::PmdJson => {
494 let buffer = primary(source)?;
495 crate::pmd::read::parse_pmd_collecting(source_text(&buffer)?, warnings)
496 }
497 DistTargetFormat::CimDist => crate::cim::parse_cim_collecting(source, warnings),
498 }?;
499 Ok((network, format))
500}
501
502pub(crate) fn primary(source: &powerio_core::Source) -> crate::Result<powerio_core::SourceBuffer> {
504 source
505 .primary_buffer()
506 .map_err(|error| crate::Error::FormatRead {
507 format: "case text",
508 message: error.to_string(),
509 })
510}
511
512pub(crate) fn source_text(buffer: &powerio_core::SourceBuffer) -> crate::Result<&str> {
516 std::str::from_utf8(buffer.content_bytes()).map_err(|e| crate::Error::FormatRead {
517 format: "case text",
518 message: format!("not valid UTF-8: {e}"),
519 })
520}
521
522impl DistTargetFormat {
523 fn matches(self, source: Option<DistSourceFormat>) -> bool {
524 matches!(
525 (self, source),
526 (DistTargetFormat::Dss, Some(DistSourceFormat::Dss))
527 | (
528 DistTargetFormat::BmopfJson,
529 Some(DistSourceFormat::BmopfJson)
530 )
531 | (DistTargetFormat::PmdJson, Some(DistSourceFormat::PmdJson))
532 | (DistTargetFormat::CimDist, Some(DistSourceFormat::CimDist))
533 )
534 }
535}
536
537pub(crate) fn is_sincal_multiconductor_token(name: &str) -> bool {
539 name.chars()
540 .filter(|c| !matches!(c, '-' | '_' | ' '))
541 .collect::<String>()
542 .eq_ignore_ascii_case("sincalmulticonductor")
543}
544
545fn sincal_feature_disabled(source: &powerio_core::Source) -> powerio_core::Error {
547 powerio_core::Error::new(
548 &crate::diagnostics::codes::REQUEST_SINCAL_MULTICONDUCTOR_FEATURE_DISABLED,
549 "SINCAL adapters live in powerio-tnd; use powerio with its `sincal` feature or powerio_tnd::formats::sincal",
550 )
551 .with_source(source.clone())
552}
553
554pub(crate) fn emit_text_with_options(
556 module: &powerio_core::PioModule<MulticonductorNetwork>,
557 format: DistTargetFormat,
558 options: &EmitOptions,
559) -> Result<TextEmission, powerio_core::Error> {
560 let mut output = if matches!(
561 format,
562 DistTargetFormat::Gridlabd | DistTargetFormat::GridlabdArras
563 ) {
564 crate::glm::emit_glm_text(module.value())?
565 } else if options.is_default_for(format)
566 && let Some(text) = echo_text(module, format)
567 {
568 TextEmission::faithful(text)
569 } else {
570 crate::readiness::require_resolved_geometry(module.value())?;
571 if format != DistTargetFormat::Dss {
572 crate::readiness::require_earth_referenced_sources(module.value())?;
573 }
574 emit_value_text_with_options(module.value(), format, options)?
575 };
576 powerio_core::attach_retention_details(
577 module.diagnostics(),
578 &mut output.diagnostics,
579 "EMIT.DIST.SINCAL_RETAINED_SOURCE_OMITTED",
580 )?;
581 Ok(output)
582}
583
584fn echo_text(
588 module: &powerio_core::PioModule<MulticonductorNetwork>,
589 target: DistTargetFormat,
590) -> Option<String> {
591 let source = module.source()?;
592 let buffer = source.primary_buffer().ok()?;
593 let declared = source
597 .format()
598 .and_then(|format| parse_dist_target_format(format.as_str()))?;
599 if declared != target || !target.matches(*module.value().source_format()) {
600 return None;
601 }
602 let text = std::str::from_utf8(buffer.bytes()).ok()?;
603 Some(text.to_owned())
604}
605
606pub(crate) fn emit_value_text_with_options(
608 net: &MulticonductorNetwork,
609 format: DistTargetFormat,
610 options: &EmitOptions,
611) -> Result<TextEmission, powerio_core::Error> {
612 net.validate_component_locations()?;
613 let mut conv = match format {
614 DistTargetFormat::Gridlabd | DistTargetFormat::GridlabdArras => {
615 return crate::glm::emit_glm_text(net);
616 }
617 DistTargetFormat::Dss => {
618 crate::dss::validate_emission(net)?;
619 crate::dss::emit_dss_text_with_options(net, &options.dss)
620 }
621 DistTargetFormat::BmopfJson => {
622 crate::bmopf::emit_bmopf_json_text_with_options(net, options.bmopf)
623 }
624 DistTargetFormat::PmdJson => crate::pmd::emit_pmd_json_text(net),
625 DistTargetFormat::CimDist => crate::cim::emit_cim_dist_text(net),
626 };
627 if !net.component_locations().is_empty() {
628 conv.push(&crate::diagnostics::codes::EMIT_DIST_COMPONENT_COORDINATES_DROPPED,
629 format!("{} component point(s) dropped: {} cannot carry their attachment, partial-axis and datum semantics",
630 net.component_locations().len(), format.name()));
631 }
632 if *net.source_format() == Some(DistSourceFormat::Sincal) {
633 conv.push(
634 &crate::diagnostics::codes::EMIT_SINCAL_MULTICONDUCTOR_RETAINED_SOURCE_OMITTED,
635 "Native SINCAL source-only data are omitted from the typed electrical conversion.",
636 );
637 }
638 let routed = net
641 .lines()
642 .iter()
643 .filter(|line| line.route.is_some())
644 .count();
645 if routed > 0 {
646 conv.push(
647 &crate::diagnostics::codes::EMIT_MULTICONDUCTOR_ROUTE_DROPPED,
648 format!(
649 "{routed} line route(s) dropped: {} has no polyline field",
650 format.name()
651 ),
652 );
653 }
654 Ok(conv)
655}
656
657#[cfg(test)]
658pub(crate) fn emit_value_text(
659 net: &MulticonductorNetwork,
660 format: DistTargetFormat,
661) -> TextEmission {
662 emit_value_text_with_options(net, format, &EmitOptions::default())
663 .expect("test network is emittable")
664}
665
666pub fn emit(
694 module: &powerio_core::PioModule<MulticonductorNetwork>,
695 format: DistTargetFormat,
696 destination: powerio_core::Destination,
697) -> std::result::Result<powerio_core::EmitResult, powerio_core::Error> {
698 emit_with_options(module, format, &EmitOptions::default(), destination)
699}
700
701pub fn emit_with_options(
710 module: &powerio_core::PioModule<MulticonductorNetwork>,
711 format: DistTargetFormat,
712 options: &EmitOptions,
713 destination: powerio_core::Destination,
714) -> std::result::Result<powerio_core::EmitResult, powerio_core::Error> {
715 if matches!(
716 format,
717 DistTargetFormat::Gridlabd | DistTargetFormat::GridlabdArras
718 ) && let Some(project) = crate::glm::retained_project(module, format)?
719 {
720 return destination.__commit_artifacts(
721 true,
722 powerio_core::Fidelity::ExactSameFormat,
723 project.artifacts,
724 project.diagnostics,
725 );
726 }
727 let conv = emit_text_with_options(module, format, options)?;
728 if format == DistTargetFormat::Dss
729 && options.is_default_for(format)
730 && let Some(project) = crate::dss::retained_project(module)?
731 {
732 let mut diagnostics = conv.diagnostics;
733 diagnostics.extend(project.diagnostics);
734 return destination.__commit_artifacts(true, conv.fidelity, project.artifacts, diagnostics);
735 }
736 match format {
737 DistTargetFormat::Gridlabd | DistTargetFormat::GridlabdArras => {
738 let artifact = powerio_core::MemoryArtifact::new(
739 powerio_core::ArtifactPath::new("case.glm")?,
740 conv.text.into_bytes(),
741 );
742 destination.__commit_artifacts(true, conv.fidelity, vec![artifact], conv.diagnostics)
743 }
744 DistTargetFormat::Dss => {
745 let mut artifacts = vec![powerio_core::MemoryArtifact::new(
746 powerio_core::ArtifactPath::new("case.dss")
747 .expect("static name is a valid artifact path"),
748 conv.text.into_bytes(),
749 )];
750 for sidecar in conv.sidecars {
751 artifacts.push(powerio_core::MemoryArtifact::new(
752 powerio_core::ArtifactPath::new(sidecar.path)?,
753 sidecar.text.into_bytes(),
754 ));
755 }
756 destination.__commit_artifacts(true, conv.fidelity, artifacts, conv.diagnostics)
757 }
758 DistTargetFormat::BmopfJson | DistTargetFormat::PmdJson | DistTargetFormat::CimDist => {
759 let artifact = powerio_core::MemoryArtifact::new(
760 powerio_core::ArtifactPath::new("case")
761 .expect("static name is a valid artifact path"),
762 conv.text.into_bytes(),
763 );
764 destination.__commit_artifacts(false, conv.fidelity, vec![artifact], conv.diagnostics)
765 }
766 }
767}
768
769#[cfg(test)]
770mod tests {
771 use super::*;
772
773 #[test]
774 fn distribution_json_classifier_preserves_pmd_marker_and_bmopf_fallback() {
775 for doc in [
776 r#"{"data_model": "ENGINEERING"}"#,
777 r#"{"data_model": "MATHEMATICAL"}"#,
778 r#"{"data_model": 7}"#,
781 r#"{"data_model": null}"#,
782 ] {
783 assert_eq!(
784 classify_distribution_json(doc).unwrap(),
785 DistTargetFormat::PmdJson,
786 "{doc}"
787 );
788 }
789 for doc in [
790 r#"{"bus": {}, "voltage_source": {}}"#,
791 r#"{"bus": {}, "line": {}, "linecode": {}}"#,
794 r#"{"bus": {}, "transformer": {}}"#,
795 r#"{"bus": {}, "capacitor": {}}"#,
796 r#"{"bus": {}, "generator": {}}"#,
797 r#"{"bus": {}, "ibr": {}}"#,
800 r#"{"bus": {}, "control_profile": {}}"#,
801 ] {
802 assert_eq!(
803 classify_distribution_json(doc).unwrap(),
804 DistTargetFormat::BmopfJson,
805 "{doc}"
806 );
807 }
808 assert_eq!(
811 classify_distribution_json("{not json").unwrap(),
812 DistTargetFormat::BmopfJson
813 );
814 assert_eq!(
817 classify_distribution_json("\u{feff}{\"data_model\": \"ENGINEERING\"}").unwrap(),
818 DistTargetFormat::PmdJson
819 );
820 }
821
822 #[test]
827 fn a_powermodels_document_never_classifies_as_bmopf() {
828 let powermodels = r#"{"baseMVA": 100.0, "branch": {}, "bus": {}, "dcline": {},
830 "gen": {}, "load": {}, "name": "case14", "per_unit": true, "shunt": {},
831 "source_type": "matpower", "source_version": "2", "storage": {},
832 "switch": {}}"#;
833 assert!(classify_distribution_json(powermodels).is_err());
834
835 for marker in NOT_BMOPF_KEYS {
839 let doc = format!("{{\"bus\": {{}}, \"linecode\": {{}}, \"{marker}\": 1}}");
840 assert!(
841 classify_distribution_json(&doc).is_err(),
842 "`{marker}` must refuse the BMOPF reading: {doc}"
843 );
844 }
845 }
846
847 #[test]
852 fn shared_table_names_classify_as_bmopf_and_the_veto_still_refuses_powermodels() {
853 for doc in [
856 r#"{"bus": {}, "load": {}}"#,
857 r#"{"bus": {}, "shunt": {}}"#,
858 r#"{"bus": {}, "switch": {}}"#,
859 r#"{"bus": {}, "meta": {"frequency": 60}}"#,
860 ] {
861 assert_eq!(
862 classify_distribution_json(doc).unwrap(),
863 DistTargetFormat::BmopfJson,
864 "{doc}"
865 );
866 }
867 for doc in [
869 r#"{"bus": {}, "load": {}, "baseMVA": 100.0}"#,
870 r#"{"bus": {}, "shunt": {}, "branch": {}}"#,
871 r#"{"bus": {}, "switch": {}, "per_unit": true}"#,
872 ] {
873 assert!(classify_distribution_json(doc).is_err(), "{doc}");
874 }
875 }
876
877 #[test]
881 fn the_pmd_marker_wins_over_shared_element_tables() {
882 let both = r#"{"data_model": "ENGINEERING", "bus": {}, "line": {}, "linecode": {}}"#;
883 assert_eq!(
884 classify_distribution_json(both).unwrap(),
885 DistTargetFormat::PmdJson
886 );
887 }
888
889 #[test]
890 fn unclassifiable_documents_are_refused_with_a_reason() {
891 for (doc, needle) in [
892 (
893 r#"{"bus": {"data_model": {}}}"#,
894 "no distribution element table",
895 ),
896 (r#"{"name": "data_model"}"#, "no marker of either format"),
897 ("{}", "no marker of either format"),
898 ("[]", "not an object"),
899 ("null", "not an object"),
900 ("3", "not an object"),
901 (r#""a string""#, "not an object"),
902 ("true", "not an object"),
903 ] {
904 let err = classify_distribution_json(doc).unwrap_err().to_string();
905 assert!(err.contains(needle), "{doc}: got {err}");
906 }
907 }
908
909 #[test]
914 fn the_probe_is_bounded_on_adversarial_shapes() {
915 let big = format!(
917 r#"{{"bus": {{}}, "linecode": {{}}, "junk": [{}]}}"#,
918 "0,".repeat(200_000) + "0"
919 );
920 assert_eq!(
921 classify_distribution_json(&big).unwrap(),
922 DistTargetFormat::BmopfJson
923 );
924
925 let mut keys = String::new();
927 for i in 0..50_000 {
928 use std::fmt::Write as _;
929 let _ = write!(keys, "\"k{i}\":0,");
930 }
931 let many = format!(r#"{{{keys}"bus":{{}},"linecode":{{}}}}"#);
932 assert_eq!(
933 classify_distribution_json(&many).unwrap(),
934 DistTargetFormat::BmopfJson
935 );
936
937 let deep = format!(
943 r#"{{"bus":{{}},"linecode":{{}},"junk":{}{}}}"#,
944 "[".repeat(20_000),
945 "]".repeat(20_000)
946 );
947 assert_eq!(
948 classify_distribution_json(&deep).unwrap(),
949 DistTargetFormat::BmopfJson
950 );
951
952 assert_eq!(
954 classify_distribution_json(
955 r#"{"data_model":"ENGINEERING","data_model":"ENGINEERING"}"#
956 )
957 .unwrap(),
958 DistTargetFormat::PmdJson
959 );
960 }
961
962 #[test]
967 fn a_document_the_probe_accepts_is_refused_by_the_reader_not_a_crash() {
968 for depth in [200usize, 20_000, 500_000] {
969 let doc = format!(
970 "{{\"bus\":{{}},\"linecode\":{{}},\"junk\":{}{}}}",
971 "[".repeat(depth),
972 "]".repeat(depth)
973 );
974 let format = classify_distribution_json(&doc).expect("markers are present");
975 assert_eq!(format, DistTargetFormat::BmopfJson);
976 let err = crate::testkit::parse_str(&doc, format.name())
977 .expect_err("the reader refuses past its recursion limit");
978 assert!(
979 err.to_string().contains("recursion limit"),
980 "depth {depth}: {err}"
981 );
982 }
983 }
984
985 #[test]
989 fn marker_matching_is_case_sensitive() {
990 for doc in [
991 r#"{"Data_Model": "ENGINEERING"}"#,
992 r#"{"DATA_MODEL": "ENGINEERING"}"#,
993 r#"{"Bus": {}, "Linecode": {}}"#,
994 ] {
995 assert!(classify_distribution_json(doc).is_err(), "{doc}");
996 }
997 }
998
999 #[test]
1000 fn byte_order_mark_is_retained_and_echoed() {
1001 let dss = "\u{feff}clear\nnew circuit.c basekv=12.47 bus1=src\n";
1002 let net = crate::testkit::parse_dss_str(dss);
1003 assert_eq!(net.name().as_deref(), Some("c"));
1004 assert!(
1005 !net.warnings.iter().any(|w| w.contains("byte order mark")),
1006 "retaining the mark is not a loss: {:?}",
1007 net.warnings
1008 );
1009 assert_eq!(net.emit(DistTargetFormat::Dss).text, dss);
1012 assert!(
1013 net.source
1014 .as_ref()
1015 .is_some_and(|s| !s.starts_with('\u{feff}'))
1016 );
1017 }
1018
1019 #[test]
1020 fn memory_and_file_sources_parse_the_same_fixture_alike() {
1021 let path = concat!(
1022 env!("CARGO_MANIFEST_DIR"),
1023 "/../tests/data/dist/micro/onephase_zip_load.dss"
1024 );
1025 let bytes = std::fs::read(path).unwrap();
1026 let from_bytes =
1027 crate::testkit::parse_str(std::str::from_utf8(&bytes).unwrap(), "dss").unwrap();
1028 let from_file = crate::testkit::parse_file(path, None).unwrap();
1029 assert_eq!(from_bytes.buses().len(), from_file.buses().len());
1030 assert_eq!(from_bytes.loads().len(), from_file.loads().len());
1031 assert_eq!(from_bytes.source, from_file.source);
1032 }
1033
1034 #[test]
1035 fn non_utf8_bytes_are_refused_and_name_the_encoding() {
1036 let bytes: &[u8] = b"clear\nnew circuit.caf\xE9 basekv=12.47 bus1=src\n";
1038 let source = powerio_core::Source::from_memory("<memory>", bytes.to_vec())
1039 .unwrap()
1040 .with_format(powerio_core::FormatId::new("dss").unwrap());
1041 let err = parse(source).unwrap_err();
1042 assert!(err.to_string().contains("UTF-8"), "{err}");
1043 }
1044
1045 #[test]
1046 fn parse_rejects_unclassifiable_json() {
1047 let dir = tempfile::tempdir().unwrap();
1050 let path = dir.path().join("case.json");
1051 std::fs::write(
1052 &path,
1053 r#"{"bus": {}, "branch": {}, "gen": {}, "baseMVA": 100.0}"#,
1054 )
1055 .unwrap();
1056 let err = crate::testkit::parse_file(&path, None).unwrap_err();
1057 assert!(
1058 err.to_string()
1059 .contains("not a recognized distribution document"),
1060 "{err}"
1061 );
1062 assert!(crate::testkit::parse_file(&path, Some("bmopf-json")).is_ok());
1064 }
1065
1066 #[test]
1067 fn unknown_format_names_fail_before_any_work() {
1068 assert!(matches!(
1069 crate::testkit::parse_str("", "matpower"),
1070 Err(crate::Error::UnknownFormat(_))
1071 ));
1072 assert!(matches!(
1073 "matpower".parse::<DistTargetFormat>(),
1074 Err(crate::Error::UnknownFormat(_))
1075 ));
1076 assert!(matches!(
1077 crate::testkit::parse_file("missing.dss", Some("matpower")),
1078 Err(crate::Error::UnknownFormat(_))
1079 ));
1080 }
1081
1082 #[test]
1083 fn parse_diagnostics_remain_on_the_module_when_it_is_emitted() {
1084 let dss = "clear\nnew circuit.w basekv=12.47 bus1=src\n\
1085 new line.l1 bus1=src bus2=b2 length=1 units=furlong\n";
1086 let source = powerio_core::Source::from_memory("<memory>", dss.as_bytes().to_vec())
1087 .unwrap()
1088 .with_format(powerio_core::FormatId::new("dss").unwrap());
1089 let module = parse(source).unwrap();
1090 let lines = crate::diagnostics::render_diagnostics(module.diagnostics());
1091 assert!(
1092 lines.iter().any(|w| w.contains("furlong")),
1093 "parse diagnostics stay on PioModule: {lines:?}"
1094 );
1095 emit(
1096 &module,
1097 DistTargetFormat::BmopfJson,
1098 powerio_core::Destination::memory("case.json").unwrap(),
1099 )
1100 .unwrap();
1101 }
1102
1103 #[test]
1104 fn canonical_format_bypasses_same_format_dss_echo() {
1105 let src = "Clear\n\
1106 New Circuit.c basekv=12.47 bus1=sourcebus\n\
1107 New Load.l1 bus1=sourcebus.1 phases=1 conn=wye kv=7.2 kw=10 kvar=2\n";
1108 let net = crate::testkit::parse_dss_str(src);
1109 assert_eq!(net.emit(DistTargetFormat::Dss).text, src);
1110
1111 let canonical = net.emit_value(DistTargetFormat::Dss);
1112 assert_ne!(canonical.text, src);
1113 assert!(
1114 canonical
1115 .text
1116 .lines()
1117 .any(|l| l.contains("Load.l1") && l.contains("vminpu=0")),
1118 "{}",
1119 canonical.text
1120 );
1121 }
1122}