1use std::collections::{BTreeMap, BTreeSet, HashMap};
4
5use serde::{Deserialize, Serialize};
6
7use powerio::{
8 BalancedNetwork, BusId, NORMALIZED_SOLVER_TABLES_PASS, NormalizedSolverTables,
9 SolverTableUnits, SourceDocument, SourceFormat,
10};
11use powerio_dist::{DistSourceFormat, MulticonductorNetwork};
12
13use crate::diagnostics::{DiagnosticSeverity, DiagnosticStage, StructuredDiagnostic};
14use crate::lowering::{
15 LoweringRecord, MulticonductorToBalancedError, MulticonductorToBalancedOptions,
16 MulticonductorToBalancedReadiness, check_multiconductor_to_balanced_lowering,
17 lower_multiconductor_to_balanced,
18};
19use crate::model::{ModelKind, ModelPayload};
20use crate::operating::{
21 OperatingPointSeries, apply_operating_point_to_model, check_series_identities,
22 operating_points_drop_code, operating_points_from_document,
23};
24use crate::provenance::{
25 Confidence, MappingKind, Origin, Producer, SourceDescriptor, SourceMapEntry, SourceRef,
26};
27use crate::study::{StudyBlock, apply_study_to_model, check_study_identities};
28use crate::summary::{ObjectSummary, ObjectTopology, ObjectUnits};
29use crate::validation::{ValidationPass, ValidationStatus, ValidationSummary};
30
31pub const PIO_PACKAGE_SCHEMA_VERSION: &str = "0.2.0";
43
44pub const READ_TRANSMISSION_PARSE_WARNING: &str = "READ.TRANSMISSION.PARSE_WARNING";
45pub const READ_GRIDFM_FIDELITY_WARNING: &str = "READ.GRIDFM.FIDELITY_WARNING";
46
47fn default_schema_version() -> String {
48 PIO_PACKAGE_SCHEMA_VERSION.to_owned()
49}
50
51#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
55#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
56pub struct DerivedMetadata {
57 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub matrix_stats: Option<serde_json::Value>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub normalized_solver_tables: Option<NormalizedSolverTableMetadata>,
61 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
62 pub cache_keys: BTreeMap<String, String>,
63}
64
65impl DerivedMetadata {
66 fn is_empty(&self) -> bool {
67 self.matrix_stats.is_none()
68 && self.normalized_solver_tables.is_none()
69 && self.cache_keys.is_empty()
70 }
71}
72
73#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
75#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
76#[non_exhaustive]
77pub struct NormalizedSolverTableMetadata {
78 pub pass: String,
79 pub units: SolverTableUnits,
80 pub row_counts: NormalizedSolverTableRowCounts,
81 pub bus_ids: Vec<BusId>,
82 pub reference_bus_indices: Vec<usize>,
83 pub component_labels: Vec<usize>,
84 pub branch_from_arc_indices: Vec<usize>,
85 pub branch_to_arc_indices: Vec<usize>,
86 pub source_rows: NormalizedSolverTableSourceRows,
87}
88
89#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
91#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
92#[non_exhaustive]
93pub struct NormalizedSolverTableRowCounts {
94 pub buses: usize,
95 pub loads: usize,
96 pub shunts: usize,
97 pub branches: usize,
98 pub switches: usize,
99 pub arcs: usize,
100 pub generators: usize,
101 pub storage: usize,
102 pub hvdc: usize,
103}
104
105#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
107#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
108#[non_exhaustive]
109pub struct NormalizedSolverTableSourceRows {
110 pub buses: Vec<Option<usize>>,
111 pub loads: Vec<Option<usize>>,
112 pub shunts: Vec<Option<usize>>,
113 pub branches: Vec<Option<usize>>,
114 pub switches: Vec<Option<usize>>,
115 pub generators: Vec<Option<usize>>,
116 pub storage: Vec<Option<usize>>,
117 pub hvdc: Vec<Option<usize>>,
118}
119
120impl From<&NormalizedSolverTables> for NormalizedSolverTableMetadata {
121 fn from(tables: &NormalizedSolverTables) -> Self {
122 Self {
123 pass: NORMALIZED_SOLVER_TABLES_PASS.to_owned(),
124 units: tables.units.clone(),
125 row_counts: NormalizedSolverTableRowCounts {
126 buses: tables.buses.len(),
127 loads: tables.loads.len(),
128 shunts: tables.shunts.len(),
129 branches: tables.branches.len(),
130 switches: tables.switches.len(),
131 arcs: tables.arcs.len(),
132 generators: tables.generators.len(),
133 storage: tables.storage.len(),
134 hvdc: tables.hvdc.len(),
135 },
136 bus_ids: tables.index.bus_ids.clone(),
137 reference_bus_indices: tables.index.reference_bus_indices.clone(),
138 component_labels: tables.index.component_labels.clone(),
139 branch_from_arc_indices: tables.index.branch_from_arc_indices.clone(),
140 branch_to_arc_indices: tables.index.branch_to_arc_indices.clone(),
141 source_rows: NormalizedSolverTableSourceRows {
142 buses: tables.index.bus_source_rows.clone(),
143 loads: tables.index.load_source_rows.clone(),
144 shunts: tables.index.shunt_source_rows.clone(),
145 branches: tables.index.branch_source_rows.clone(),
146 switches: tables.index.switch_source_rows.clone(),
147 generators: tables.index.generator_source_rows.clone(),
148 storage: tables.index.storage_source_rows.clone(),
149 hvdc: tables.index.hvdc_source_rows.clone(),
150 },
151 }
152 }
153}
154
155#[derive(Clone, Debug, Serialize, Deserialize)]
163#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
164#[non_exhaustive]
165pub struct NetworkPackage {
166 pub schema_version: String,
176 pub producer: Producer,
177 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub package_id: Option<String>,
180 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub created_at: Option<String>,
184 pub model_kind: ModelKind,
186 pub model: ModelPayload,
187 #[serde(default, skip_serializing_if = "Option::is_none")]
190 pub operating_points: Option<OperatingPointSeries>,
191 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub study: Option<StudyBlock>,
194 pub origin: Origin,
195 #[serde(default, skip_serializing_if = "Vec::is_empty")]
196 pub sources: Vec<SourceDescriptor>,
197 #[serde(default, skip_serializing_if = "Vec::is_empty")]
198 pub source_maps: Vec<SourceMapEntry>,
199 #[serde(default, skip_serializing_if = "Vec::is_empty")]
200 pub diagnostics: Vec<StructuredDiagnostic>,
201 pub validation: ValidationSummary,
202 #[serde(default)]
203 pub summary: ObjectSummary,
204 #[serde(default, skip_serializing_if = "Vec::is_empty")]
205 pub lowering_history: Vec<LoweringRecord>,
206 #[serde(default, skip_serializing_if = "DerivedMetadata::is_empty")]
207 pub derived: DerivedMetadata,
208}
209
210impl NetworkPackage {
211 pub fn from_balanced(net: BalancedNetwork) -> Self {
215 let mut net = net;
216 ensure_payload_uids(&mut net);
217 let origin = balanced_origin(&net);
218 let summary = balanced_summary(&net);
219 let sources = balanced_sources(&net);
220 let source_id = sources.first().map(|s| s.id.clone());
221 let source_maps = balanced_source_maps(&net, source_id.as_deref());
222 let diagnostics = Vec::new();
223 let validation = ValidationSummary::from_diagnostics(&diagnostics);
224 Self {
225 schema_version: default_schema_version(),
226 producer: Producer::powerio(),
227 package_id: None,
228 created_at: None,
229 model_kind: ModelKind::Balanced,
230 model: ModelPayload::balanced(net),
231 operating_points: None,
232 study: None,
233 origin,
234 sources,
235 source_maps,
236 diagnostics,
237 validation,
238 summary,
239 lowering_history: Vec::new(),
240 derived: DerivedMetadata::default(),
241 }
242 }
243
244 pub fn from_parsed_balanced(parsed: powerio::Parsed) -> Self {
249 let mut package = Self::from_balanced_with_read_warnings(
250 parsed.network,
251 READ_TRANSMISSION_PARSE_WARNING,
252 parsed.warnings,
253 );
254 if let Some(document) = &parsed.document {
255 package.attach_operating_points(document);
256 }
257 package
258 }
259
260 fn attach_operating_points(&mut self, document: &SourceDocument) {
261 match operating_points_from_document(document) {
262 Ok(series) => self.operating_points = series,
263 Err(error) => {
264 self.diagnostics.push(StructuredDiagnostic::new(
265 operating_points_drop_code(document),
266 DiagnosticSeverity::Warning,
267 DiagnosticStage::Read,
268 format!(
269 "time series could not be lifted into operating points; \
270 the package is static only: {error}"
271 ),
272 ));
273 self.validation = ValidationSummary::from_diagnostics(&self.diagnostics);
274 }
275 }
276 }
277
278 pub fn from_balanced_with_read_warnings<I, S>(
281 net: BalancedNetwork,
282 code: &str,
283 warnings: I,
284 ) -> Self
285 where
286 I: IntoIterator<Item = S>,
287 S: Into<String>,
288 {
289 let mut package = Self::from_balanced(net);
290 package.record_read_warnings(code, warnings);
291 package
292 }
293
294 pub fn record_read_warnings<I, S>(&mut self, code: &str, warnings: I)
296 where
297 I: IntoIterator<Item = S>,
298 S: Into<String>,
299 {
300 let diagnostics: Vec<StructuredDiagnostic> = warnings
301 .into_iter()
302 .map(|w| {
303 StructuredDiagnostic::new(
304 code,
305 DiagnosticSeverity::Warning,
306 DiagnosticStage::Read,
307 w.into(),
308 )
309 })
310 .collect();
311 if diagnostics.is_empty() {
312 return;
313 }
314 self.diagnostics.extend(diagnostics);
315 self.validation = ValidationSummary::from_diagnostics(&self.diagnostics);
316 }
317
318 pub fn from_multiconductor(net: MulticonductorNetwork) -> Self {
323 let summary = multiconductor_summary(&net);
324 let sources = multiconductor_sources(&net);
325 let source_id = sources.first().map(|s| s.id.clone());
326 let source_maps = multiconductor_source_maps(&net, source_id.as_deref());
327 let origin = multiconductor_origin(&net);
328
329 let diagnostics: Vec<StructuredDiagnostic> = net
330 .warnings
331 .iter()
332 .map(|w| {
333 StructuredDiagnostic::new(
334 "READ.DIST.PARSE_WARNING",
335 DiagnosticSeverity::Warning,
336 DiagnosticStage::Read,
337 w.clone(),
338 )
339 })
340 .collect();
341 let validation = ValidationSummary::from_diagnostics(&diagnostics);
342
343 Self {
344 schema_version: default_schema_version(),
345 producer: Producer::powerio(),
346 package_id: None,
347 created_at: None,
348 model_kind: ModelKind::Multiconductor,
349 model: ModelPayload::multiconductor(net),
350 operating_points: None,
351 study: None,
352 origin,
353 sources,
354 source_maps,
355 diagnostics,
356 validation,
357 summary,
358 lowering_history: Vec::new(),
359 derived: DerivedMetadata::default(),
360 }
361 }
362
363 pub fn model_kind(&self) -> ModelKind {
365 self.model_kind
366 }
367
368 pub fn kind_is_consistent(&self) -> bool {
371 self.model_kind == self.model.kind()
372 }
373
374 pub fn as_balanced(&self) -> Option<&BalancedNetwork> {
376 self.model.as_balanced()
377 }
378
379 pub fn as_multiconductor(&self) -> Option<&MulticonductorNetwork> {
381 self.model.as_multiconductor()
382 }
383
384 #[must_use]
386 pub fn operating_points(&self) -> Option<&OperatingPointSeries> {
387 self.operating_points.as_ref()
388 }
389
390 #[must_use]
392 pub fn with_operating_points(mut self, operating_points: OperatingPointSeries) -> Self {
393 self.set_operating_points(operating_points);
394 self
395 }
396
397 pub fn set_operating_points(&mut self, operating_points: OperatingPointSeries) {
399 self.operating_points = (!operating_points.is_empty()).then_some(operating_points);
400 }
401
402 pub fn clear_operating_points(&mut self) {
404 self.operating_points = None;
405 }
406
407 #[must_use]
409 pub fn study(&self) -> Option<&StudyBlock> {
410 self.study.as_ref()
411 }
412
413 #[must_use]
415 pub fn with_study(mut self, study: StudyBlock) -> Self {
416 self.set_study(study);
417 self
418 }
419
420 pub fn set_study(&mut self, study: StudyBlock) {
422 self.study = (!study.is_empty()).then_some(study);
423 }
424
425 pub fn clear_study(&mut self) {
427 self.study = None;
428 }
429
430 pub fn materialize_operating_point(&self, index: usize) -> serde_json::Result<Self> {
436 let series = self.operating_points.as_ref().ok_or_else(|| {
437 <serde_json::Error as serde::de::Error>::custom("package has no operating points")
438 })?;
439 let point = series.unique_point(index)?.ok_or_else(|| {
440 <serde_json::Error as serde::de::Error>::custom(format!(
441 "package has no operating point {index}"
442 ))
443 })?;
444 let (updated_model, updated_paths) = apply_operating_point_to_model(&self.model, point)?;
448 let had_normalized_solver_tables = self.derived.normalized_solver_tables.is_some();
449 let options = materialize_operating_point_options(index);
450 let mut package = Self {
455 schema_version: self.schema_version.clone(),
456 producer: self.producer.clone(),
457 package_id: None,
461 created_at: self.created_at.clone(),
462 model_kind: self.model_kind,
463 model: updated_model,
464 operating_points: None,
465 study: None,
466 origin: Origin::Derived {
467 parent_package_id: self.package_id.clone(),
468 pass: "materialize-operating-point".to_owned(),
469 options: options.clone(),
470 },
471 sources: self.sources.clone(),
472 source_maps: self
473 .source_maps
474 .iter()
475 .filter(|entry| !updated_paths.contains(entry.element_path.as_str()))
476 .cloned()
477 .collect(),
478 diagnostics: self
479 .diagnostics
480 .iter()
481 .filter(|diagnostic| {
482 diagnostic
483 .element_path
484 .as_deref()
485 .is_none_or(|path| !updated_paths.contains(path))
486 })
487 .cloned()
488 .collect(),
489 validation: self.validation.clone(),
491 summary: self.summary.clone(),
492 lowering_history: self.lowering_history.clone(),
493 derived: DerivedMetadata::default(),
496 };
497 let mut record = LoweringRecord::new(
498 "materialize-operating-point",
499 self.model_kind,
500 self.model_kind,
501 );
502 record.options = options;
503 package.run_sane_validation();
504 record.validation_status = package.validation.status;
505 package.push_lowering(record);
506 if had_normalized_solver_tables {
507 package
508 .attach_normalized_solver_table_metadata()
509 .map_err(|err| {
510 <serde_json::Error as serde::de::Error>::custom(format!(
511 "failed to recompute normalized solver table metadata: {err}"
512 ))
513 })?;
514 }
515 Ok(package)
516 }
517
518 pub fn materialize_balanced_operating_point(
521 &self,
522 index: usize,
523 ) -> serde_json::Result<Option<BalancedNetwork>> {
524 Ok(self
525 .materialize_operating_point(index)?
526 .model
527 .as_balanced()
528 .cloned())
529 }
530
531 pub fn materialize_multiconductor_operating_point(
534 &self,
535 index: usize,
536 ) -> serde_json::Result<Option<MulticonductorNetwork>> {
537 Ok(self
538 .materialize_operating_point(index)?
539 .model
540 .as_multiconductor()
541 .cloned())
542 }
543
544 pub fn materialize_study_commit(&self, commit_index: usize) -> serde_json::Result<Self> {
550 let study = self.study.as_ref().ok_or_else(|| {
551 <serde_json::Error as serde::de::Error>::custom("package has no study block")
552 })?;
553 let base = if let Some(index) = study.base_operating_point {
554 self.materialize_operating_point(index)?
555 } else {
556 self.clone()
557 };
558 let (updated_model, updated_paths) =
559 apply_study_to_model(&base.model, study, commit_index)?;
560 let had_normalized_solver_tables = base.derived.normalized_solver_tables.is_some();
561 let options = materialize_study_commit_options(study, commit_index);
562
563 let mut package = Self {
564 schema_version: base.schema_version.clone(),
565 producer: base.producer.clone(),
566 package_id: None,
567 created_at: base.created_at.clone(),
568 model_kind: base.model_kind,
569 model: updated_model,
570 operating_points: None,
571 study: None,
572 origin: Origin::Derived {
573 parent_package_id: self.package_id.clone(),
574 pass: "materialize-study-commit".to_owned(),
575 options: options.clone(),
576 },
577 sources: base.sources.clone(),
578 source_maps: base
579 .source_maps
580 .iter()
581 .filter(|entry| !updated_paths.contains(entry.element_path.as_str()))
582 .cloned()
583 .collect(),
584 diagnostics: base
585 .diagnostics
586 .iter()
587 .filter(|diagnostic| {
588 diagnostic
589 .element_path
590 .as_deref()
591 .is_none_or(|path| !updated_paths.contains(path))
592 })
593 .cloned()
594 .collect(),
595 validation: base.validation.clone(),
596 summary: base.summary.clone(),
597 lowering_history: base.lowering_history.clone(),
598 derived: DerivedMetadata::default(),
599 };
600 let mut record =
601 LoweringRecord::new("materialize-study-commit", base.model_kind, base.model_kind);
602 record.options = options;
603 record
604 .assumptions
605 .push(format!("applied study commits 0..={commit_index}"));
606 package.run_sane_validation();
607 record.validation_status = package.validation.status;
608 package.push_lowering(record);
609 if had_normalized_solver_tables {
610 package
611 .attach_normalized_solver_table_metadata()
612 .map_err(|err| {
613 <serde_json::Error as serde::de::Error>::custom(format!(
614 "failed to recompute normalized solver table metadata: {err}"
615 ))
616 })?;
617 }
618 Ok(package)
619 }
620
621 pub fn materialize_balanced_study_commit(
623 &self,
624 commit_index: usize,
625 ) -> serde_json::Result<Option<BalancedNetwork>> {
626 Ok(self
627 .materialize_study_commit(commit_index)?
628 .model
629 .as_balanced()
630 .cloned())
631 }
632
633 pub fn to_json(&self) -> serde_json::Result<String> {
635 serde_json::to_string(self)
636 }
637
638 pub fn to_json_pretty(&self) -> serde_json::Result<String> {
640 serde_json::to_string_pretty(self)
641 }
642
643 pub fn from_json(text: &str) -> serde_json::Result<Self> {
645 let pkg: Self = serde_json::from_str(text.trim_start_matches('\u{feff}')).map_err(|e| {
651 <serde_json::Error as serde::de::Error>::custom(format!(
652 "invalid .pio.json package envelope: {e}"
653 ))
654 })?;
655 if !Self::supports_schema_version(&pkg.schema_version) {
656 return Err(<serde_json::Error as serde::de::Error>::custom(format!(
657 "unsupported .pio.json schema_version {}; this reader supports {}; \
658 regenerate the package from its source case",
659 pkg.schema_version,
660 supported_lineage_label()
661 )));
662 }
663 if !pkg.kind_is_consistent() {
664 return Err(<serde_json::Error as serde::de::Error>::custom(
665 "model_kind does not match model.kind",
666 ));
667 }
668 Ok(pkg)
669 }
670
671 pub fn supports_schema_version(version: &str) -> bool {
679 let Some((major, minor)) = schema_lineage(version) else {
680 return false;
681 };
682 let (current_major, current_minor) = supported_lineage();
683 major == current_major && (major != 0 || minor == current_minor)
684 }
685
686 #[must_use]
687 pub fn with_origin(mut self, origin: Origin) -> Self {
688 self.origin = origin;
689 self
690 }
691
692 #[must_use]
693 pub fn with_package_id(mut self, id: impl Into<String>) -> Self {
694 self.package_id = Some(id.into());
695 self
696 }
697
698 #[must_use]
699 pub fn with_created_at(mut self, created_at: impl Into<String>) -> Self {
700 self.created_at = Some(created_at.into());
701 self
702 }
703
704 #[must_use]
705 pub fn with_sources(mut self, sources: Vec<SourceDescriptor>) -> Self {
706 self.sources = sources;
707 self
708 }
709
710 #[must_use]
711 pub fn with_source_maps(mut self, source_maps: Vec<SourceMapEntry>) -> Self {
712 self.source_maps = source_maps;
713 self
714 }
715
716 pub fn push_lowering(&mut self, record: LoweringRecord) {
718 self.lowering_history.push(record);
719 }
720
721 pub fn attach_normalized_solver_table_metadata(
728 &mut self,
729 ) -> std::result::Result<bool, powerio::Error> {
730 let Some(net) = self.as_balanced() else {
731 return Ok(false);
732 };
733 let tables = net.to_normalized_solver_tables()?;
734 self.derived.normalized_solver_tables = Some(NormalizedSolverTableMetadata::from(&tables));
735 Ok(true)
736 }
737
738 pub fn with_normalized_solver_table_metadata(
740 mut self,
741 ) -> std::result::Result<Self, powerio::Error> {
742 self.attach_normalized_solver_table_metadata()?;
743 Ok(self)
744 }
745
746 #[must_use]
749 pub fn check_multiconductor_to_balanced_lowering(
750 &self,
751 ) -> Option<MulticonductorToBalancedReadiness> {
752 self.as_multiconductor().map(|net| {
753 check_multiconductor_to_balanced_lowering(
754 net,
755 MulticonductorToBalancedOptions::default(),
756 )
757 })
758 }
759
760 pub fn lower_multiconductor_to_balanced(
765 &self,
766 options: MulticonductorToBalancedOptions,
767 ) -> Result<Self, MulticonductorToBalancedError> {
768 let Some(net) = self.as_multiconductor() else {
769 let diagnostic = StructuredDiagnostic::new(
770 "LOWER.MULTI_TO_BALANCED.WRONG_MODEL_KIND",
771 DiagnosticSeverity::Error,
772 DiagnosticStage::Lower,
773 format!(
774 "multiconductor to balanced lowering requires a multiconductor package, got {:?}",
775 self.model_kind
776 ),
777 );
778 return Err(MulticonductorToBalancedError::new(
779 options,
780 vec![diagnostic],
781 ));
782 };
783
784 let lowered = lower_multiconductor_to_balanced(net, options)?;
785 let mut record = lowered.record;
786 let mut output = NetworkPackage::from_balanced(lowered.network);
787 output.origin = Origin::Derived {
788 parent_package_id: self.package_id.clone(),
789 pass: "multiconductor-to-balanced".to_owned(),
790 options: record.options.clone(),
791 };
792 output.sources = derived_sources(self);
793 let source_id = output.sources.first().map(|source| source.id.as_str());
794 output.source_maps = match output.as_balanced() {
795 Some(balanced) => lowered_balanced_source_maps(net, balanced, source_id),
796 None => Vec::new(),
797 };
798 output.diagnostics.clone_from(&record.diagnostics);
799 output.lowering_history.clone_from(&self.lowering_history);
800 output.run_sane_validation();
801 record.validation_status = output.validation.status;
802 output.push_lowering(record);
803 Ok(output)
804 }
805
806 pub fn run_sane_validation(&mut self) {
812 self.diagnostics
813 .retain(|d| !is_sane_validation_code(d.code.as_str()));
814
815 let (mut diagnostics, mut passes) = match &self.model {
816 ModelPayload::Balanced { balanced_network } => sane_validate_balanced(balanced_network),
817 ModelPayload::Multiconductor {
818 multiconductor_network,
819 } => sane_validate_multiconductor(multiconductor_network),
820 };
821
822 if let Some(series) = &self.operating_points {
823 let (identity_diagnostics, identity_pass) =
824 validate_operating_identity(&self.model, series);
825 diagnostics.extend(identity_diagnostics);
826 passes.push(identity_pass);
827 }
828 if let Some(study) = &self.study {
829 let (study_diagnostics, study_pass) = validate_study(&self.model, study);
830 diagnostics.extend(study_diagnostics);
831 passes.push(study_pass);
832 }
833
834 attach_source_refs(&mut diagnostics, &self.source_maps);
835 self.diagnostics.extend(diagnostics);
836 self.validation =
837 ValidationSummary::from_diagnostics(&self.diagnostics).with_passes(passes);
838 }
839}
840
841fn materialize_operating_point_options(index: usize) -> serde_json::Map<String, serde_json::Value> {
842 let mut options = serde_json::Map::new();
843 options.insert("index".to_owned(), serde_json::json!(index));
844 options
845}
846
847fn materialize_study_commit_options(
848 study: &StudyBlock,
849 commit_index: usize,
850) -> serde_json::Map<String, serde_json::Value> {
851 let mut options = serde_json::Map::new();
852 options.insert("commit_index".to_owned(), serde_json::json!(commit_index));
853 if let Some(index) = study.base_operating_point {
854 options.insert("base_operating_point".to_owned(), serde_json::json!(index));
855 }
856 options
857}
858
859fn schema_lineage(version: &str) -> Option<(u64, u64)> {
860 let (rest, build) = match version.split_once('+') {
867 Some((rest, build)) => (rest, Some(build)),
868 None => (version, None),
869 };
870 let (core, pre) = match rest.split_once('-') {
871 Some((core, pre)) => (core, Some(pre)),
872 None => (rest, None),
873 };
874 if pre.is_some_and(|s| !valid_semver_suffix(s))
875 || build.is_some_and(|s| !valid_semver_suffix(s))
876 {
877 return None;
878 }
879 let mut parts = core.split('.');
880 let major = parts.next()?;
881 let minor = parts.next()?;
882 let patch = parts.next()?;
883 if parts.next().is_some() {
884 return None;
885 }
886 let major = parse_semver_number(major)?;
887 let minor = parse_semver_number(minor)?;
888 parse_semver_number(patch)?;
889 Some((major, minor))
890}
891
892fn parse_semver_number(s: &str) -> Option<u64> {
893 if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) || (s.len() > 1 && s.starts_with('0'))
894 {
895 return None;
896 }
897 s.parse().ok()
898}
899
900fn valid_semver_suffix(s: &str) -> bool {
901 !s.is_empty()
902 && s.split('.').all(|part| {
903 !part.is_empty() && part.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
904 })
905}
906
907fn supported_lineage() -> (u64, u64) {
908 schema_lineage(PIO_PACKAGE_SCHEMA_VERSION).expect("package schema version is valid semver")
909}
910
911fn supported_lineage_label() -> String {
914 match supported_lineage() {
915 (0, minor) => format!("0.{minor}.x"),
916 (major, _) => format!("major version {major}"),
917 }
918}
919
920pub fn ensure_payload_uids(net: &mut BalancedNetwork) {
926 macro_rules! fill {
927 ($table:ident) => {
928 for (row, element) in net.$table.iter_mut().enumerate() {
929 if element.uid.is_none() {
930 element.uid = Some(format!(concat!(stringify!($table), ":{}"), row));
931 }
932 }
933 };
934 }
935 fill!(buses);
936 fill!(loads);
937 fill!(shunts);
938 fill!(branches);
939 fill!(switches);
940 fill!(generators);
941 fill!(storage);
942 fill!(hvdc);
943 fill!(transformers_3w);
944}
945
946const SANE_VALIDATION_CODES: [&str; 10] = [
947 "VALIDATE.BALANCED.STRUCTURE",
948 "VALIDATE.BALANCED.VALUE_DOMAIN",
949 "VALIDATE.BALANCED.PAYLOAD_IDENTITY",
950 "VALIDATE.MULTI.STRUCTURE",
951 "VALIDATE.MULTI.TERMINAL_MAP",
952 "VALIDATE.MULTI.UNTYPED_OBJECT",
953 "VALIDATE.MULTI.NO_VOLTAGE_SOURCE",
954 "VALIDATE.PACKAGE.OPERATING_IDENTITY",
955 "VALIDATE.PACKAGE.STUDY_MODEL_KIND",
956 "VALIDATE.PACKAGE.STUDY_IDENTITY",
957];
958
959fn validate_operating_identity(
965 model: &ModelPayload,
966 series: &OperatingPointSeries,
967) -> (Vec<StructuredDiagnostic>, ValidationPass) {
968 let diagnostics: Vec<StructuredDiagnostic> = check_series_identities(model, series)
969 .into_iter()
970 .map(|(point_pos, update_pos, message)| {
971 StructuredDiagnostic::new(
972 "VALIDATE.PACKAGE.OPERATING_IDENTITY",
973 DiagnosticSeverity::Error,
974 DiagnosticStage::Validate,
975 message,
976 )
977 .with_element_path(format!(
978 "/operating_points/points/{point_pos}/updates/{update_pos}"
979 ))
980 })
981 .collect();
982 let status = validation_status(&diagnostics);
983 (
984 diagnostics,
985 ValidationPass::new("package.operating_identity", status),
986 )
987}
988
989fn validate_study(
990 model: &ModelPayload,
991 study: &StudyBlock,
992) -> (Vec<StructuredDiagnostic>, ValidationPass) {
993 if !matches!(model, ModelPayload::Balanced { .. }) {
994 let diagnostics = vec![
995 StructuredDiagnostic::new(
996 "VALIDATE.PACKAGE.STUDY_MODEL_KIND",
997 DiagnosticSeverity::Error,
998 DiagnosticStage::Validate,
999 "study blocks are only defined for balanced packages",
1000 )
1001 .with_element_path("/study"),
1002 ];
1003 return (
1004 diagnostics,
1005 ValidationPass::new("package.study", ValidationStatus::Error),
1006 );
1007 }
1008
1009 let diagnostics: Vec<StructuredDiagnostic> = check_study_identities(model, study)
1010 .into_iter()
1011 .map(|(commit_pos, edit_pos, message)| {
1012 StructuredDiagnostic::new(
1013 "VALIDATE.PACKAGE.STUDY_IDENTITY",
1014 DiagnosticSeverity::Error,
1015 DiagnosticStage::Validate,
1016 message,
1017 )
1018 .with_element_path(format!("/study/commits/{commit_pos}/edits/{edit_pos}"))
1019 })
1020 .collect();
1021 let status = validation_status(&diagnostics);
1022 (
1023 diagnostics,
1024 ValidationPass::new("package.study_identity", status),
1025 )
1026}
1027
1028fn is_sane_validation_code(code: &str) -> bool {
1029 SANE_VALIDATION_CODES.contains(&code)
1030}
1031
1032fn validation_status(diagnostics: &[StructuredDiagnostic]) -> ValidationStatus {
1033 diagnostics
1034 .iter()
1035 .map(|d| match d.severity {
1036 DiagnosticSeverity::Debug => ValidationStatus::Ok,
1037 DiagnosticSeverity::Info => ValidationStatus::Info,
1038 DiagnosticSeverity::Warning => ValidationStatus::Warning,
1039 DiagnosticSeverity::Error => ValidationStatus::Error,
1040 DiagnosticSeverity::Fatal => ValidationStatus::Fatal,
1041 })
1042 .max()
1043 .unwrap_or(ValidationStatus::Ok)
1044}
1045
1046fn sane_validate_balanced(
1047 net: &BalancedNetwork,
1048) -> (Vec<StructuredDiagnostic>, Vec<ValidationPass>) {
1049 let mut structure = Vec::new();
1050 if let Err(err) = net.validate() {
1051 structure.push(StructuredDiagnostic::new(
1052 "VALIDATE.BALANCED.STRUCTURE",
1053 DiagnosticSeverity::Error,
1054 DiagnosticStage::Validate,
1055 err.to_string(),
1056 ));
1057 }
1058
1059 let bus_index: HashMap<usize, usize> = net
1060 .buses
1061 .iter()
1062 .enumerate()
1063 .map(|(idx, b)| (b.id.0, idx))
1064 .collect();
1065 let mut value_domain = Vec::new();
1066 for finding in net.validate_values() {
1067 let element_path =
1068 balanced_value_finding_path(net, &bus_index, &finding).unwrap_or_else(|| {
1069 format!(
1070 "/model/balanced_network/{}#{}",
1071 finding.element.replace(' ', "_"),
1072 finding.field
1073 )
1074 });
1075 let mut d = StructuredDiagnostic::new(
1076 "VALIDATE.BALANCED.VALUE_DOMAIN",
1077 DiagnosticSeverity::Warning,
1078 DiagnosticStage::Validate,
1079 format!(
1080 "{} field `{}` is outside its value domain; suggested value is {}",
1081 finding.element, finding.field, finding.new
1082 ),
1083 )
1084 .with_element_path(element_path)
1085 .with_suggested_action("Run the explicit repair pass if these defaults are desired.");
1086 d.details
1087 .insert("element".to_owned(), serde_json::json!(finding.element));
1088 d.details
1089 .insert("field".to_owned(), serde_json::json!(finding.field));
1090 d.details
1091 .insert("old".to_owned(), serde_json::json!(finding.old));
1092 d.details
1093 .insert("new".to_owned(), serde_json::json!(finding.new));
1094 d.details
1095 .insert("reason".to_owned(), serde_json::json!(finding.reason));
1096 value_domain.push(d);
1097 }
1098
1099 let mut identity = Vec::new();
1103 macro_rules! check_uids {
1104 ($table:ident) => {
1105 table_uid_duplicates(
1106 stringify!($table),
1107 net.$table.iter().map(|e| e.uid.as_deref()),
1108 &mut identity,
1109 )
1110 };
1111 }
1112 check_uids!(buses);
1113 check_uids!(loads);
1114 check_uids!(shunts);
1115 check_uids!(branches);
1116 check_uids!(switches);
1117 check_uids!(generators);
1118 check_uids!(storage);
1119 check_uids!(hvdc);
1120 check_uids!(transformers_3w);
1121
1122 let passes = vec![
1123 ValidationPass::new("balanced.structure", validation_status(&structure)),
1124 ValidationPass::new("balanced.value_domain", validation_status(&value_domain)),
1125 ValidationPass::new("balanced.payload_identity", validation_status(&identity)),
1126 ];
1127 structure.extend(value_domain);
1128 structure.extend(identity);
1129 (structure, passes)
1130}
1131
1132fn table_uid_duplicates<'a>(
1136 table: &str,
1137 uids: impl Iterator<Item = Option<&'a str>>,
1138 diagnostics: &mut Vec<StructuredDiagnostic>,
1139) {
1140 let mut first_row: HashMap<&str, usize> = HashMap::new();
1141 for (row, uid) in uids.enumerate() {
1142 let Some(uid) = uid else { continue };
1143 if let Some(&first) = first_row.get(uid) {
1144 diagnostics.push(
1145 StructuredDiagnostic::new(
1146 "VALIDATE.BALANCED.PAYLOAD_IDENTITY",
1147 DiagnosticSeverity::Error,
1148 DiagnosticStage::Validate,
1149 format!(
1150 "payload table `{table}` carries uid `{uid}` on rows {first} and {row}; \
1151 identity resolution is ambiguous"
1152 ),
1153 )
1154 .with_element_path(format!("/model/balanced_network/{table}/{row}/uid")),
1155 );
1156 } else {
1157 first_row.insert(uid, row);
1158 }
1159 }
1160}
1161
1162fn attach_source_refs(diagnostics: &mut [StructuredDiagnostic], source_maps: &[SourceMapEntry]) {
1163 let mut by_path: HashMap<&str, &SourceRef> = HashMap::with_capacity(source_maps.len());
1167 for map in source_maps {
1168 by_path
1169 .entry(map.element_path.as_str())
1170 .or_insert(&map.source_ref);
1171 }
1172 for diagnostic in diagnostics {
1173 if diagnostic.source_ref.is_some() {
1174 continue;
1175 }
1176 let Some(path) = diagnostic.element_path.as_deref() else {
1177 continue;
1178 };
1179 if let Some(source_ref) = by_path.get(path) {
1180 diagnostic.source_ref = Some((*source_ref).clone());
1181 }
1182 }
1183}
1184
1185fn balanced_value_finding_path(
1186 net: &BalancedNetwork,
1187 bus_index: &HashMap<usize, usize>,
1188 finding: &powerio::Diagnostic,
1189) -> Option<String> {
1190 if let Some(id) = finding
1191 .element
1192 .strip_prefix("bus ")
1193 .and_then(|s| s.parse::<usize>().ok())
1194 {
1195 let idx = *bus_index.get(&id)?;
1196 return Some(format!(
1197 "/model/balanced_network/buses/{idx}/{}",
1198 finding.field
1199 ));
1200 }
1201
1202 if let Some(id) = finding
1203 .element
1204 .strip_prefix("generator at bus ")
1205 .and_then(|s| s.parse::<usize>().ok())
1206 {
1207 let mut matches = net
1211 .generators
1212 .iter()
1213 .enumerate()
1214 .filter(|(_, g)| {
1215 g.bus.0 == id
1216 && generator_field(g, finding.field)
1217 .is_some_and(|v| v.to_bits() == finding.old.to_bits())
1218 })
1219 .map(|(idx, _)| idx);
1220 let idx = matches.next()?;
1221 if matches.next().is_some() {
1222 return None;
1223 }
1224 return Some(format!(
1225 "/model/balanced_network/generators/{idx}/{}",
1226 finding.field
1227 ));
1228 }
1229
1230 None
1231}
1232
1233fn generator_field(generator: &powerio::Generator, field: &str) -> Option<f64> {
1234 Some(match field {
1235 "mbase" => generator.mbase,
1236 "vg" => generator.vg,
1237 _ => return None,
1238 })
1239}
1240
1241fn sane_validate_multiconductor(
1242 net: &MulticonductorNetwork,
1243) -> (Vec<StructuredDiagnostic>, Vec<ValidationPass>) {
1244 let mut structure = Vec::new();
1245 let mut terminal_maps = Vec::new();
1246 let mut untyped = Vec::new();
1247 let mut sources = Vec::new();
1248
1249 let (bus_ids, bus_terminals) = multiconductor_bus_index(net, &mut structure);
1250
1251 validate_multiconductor_lines(
1252 net,
1253 &bus_ids,
1254 &bus_terminals,
1255 &mut structure,
1256 &mut terminal_maps,
1257 );
1258 validate_multiconductor_switches(
1259 net,
1260 &bus_ids,
1261 &bus_terminals,
1262 &mut structure,
1263 &mut terminal_maps,
1264 );
1265 validate_multiconductor_transformers(
1266 net,
1267 &bus_ids,
1268 &bus_terminals,
1269 &mut structure,
1270 &mut terminal_maps,
1271 );
1272 validate_multiconductor_injections(
1273 net,
1274 &bus_ids,
1275 &bus_terminals,
1276 &mut structure,
1277 &mut terminal_maps,
1278 );
1279
1280 for (i, obj) in net.untyped.iter().enumerate() {
1281 untyped.push(
1282 StructuredDiagnostic::new(
1283 "VALIDATE.MULTI.UNTYPED_OBJECT",
1284 DiagnosticSeverity::Warning,
1285 DiagnosticStage::Validate,
1286 format!(
1287 "{} {} is preserved as an untyped object",
1288 obj.class, obj.name
1289 ),
1290 )
1291 .with_element_path(format!("/model/multiconductor_network/untyped/{i}")),
1292 );
1293 }
1294
1295 if net.sources.is_empty() {
1296 sources.push(StructuredDiagnostic::new(
1297 "VALIDATE.MULTI.NO_VOLTAGE_SOURCE",
1298 DiagnosticSeverity::Warning,
1299 DiagnosticStage::Validate,
1300 "multiconductor package has no voltage source",
1301 ));
1302 }
1303
1304 let passes = vec![
1305 ValidationPass::new("multiconductor.structure", validation_status(&structure)),
1306 ValidationPass::new(
1307 "multiconductor.terminal_map",
1308 validation_status(&terminal_maps),
1309 ),
1310 ValidationPass::new("multiconductor.untyped_object", validation_status(&untyped)),
1311 ValidationPass::new("multiconductor.voltage_source", validation_status(&sources)),
1312 ];
1313
1314 let mut diagnostics = structure;
1315 diagnostics.extend(terminal_maps);
1316 diagnostics.extend(untyped);
1317 diagnostics.extend(sources);
1318 (diagnostics, passes)
1319}
1320
1321fn validate_multiconductor_lines(
1322 net: &MulticonductorNetwork,
1323 bus_ids: &BTreeSet<String>,
1324 bus_terminals: &BTreeMap<String, BTreeSet<String>>,
1325 structure: &mut Vec<StructuredDiagnostic>,
1326 terminal_maps: &mut Vec<StructuredDiagnostic>,
1327) {
1328 for (i, line) in net.lines.iter().enumerate() {
1329 check_bus_ref(
1330 &line.bus_from,
1331 &format!("line {} from bus", line.name),
1332 &format!("/model/multiconductor_network/lines/{i}/bus_from"),
1333 bus_ids,
1334 structure,
1335 );
1336 check_bus_ref(
1337 &line.bus_to,
1338 &format!("line {} to bus", line.name),
1339 &format!("/model/multiconductor_network/lines/{i}/bus_to"),
1340 bus_ids,
1341 structure,
1342 );
1343 if !net
1344 .linecodes
1345 .iter()
1346 .any(|c| c.name.eq_ignore_ascii_case(&line.linecode))
1347 {
1348 structure.push(
1349 StructuredDiagnostic::new(
1350 "VALIDATE.MULTI.STRUCTURE",
1351 DiagnosticSeverity::Error,
1352 DiagnosticStage::Validate,
1353 format!(
1354 "line {} references unknown linecode `{}`",
1355 line.name, line.linecode
1356 ),
1357 )
1358 .with_element_path(format!("/model/multiconductor_network/lines/{i}/linecode")),
1359 );
1360 }
1361 check_terminal_map(
1362 &line.bus_from,
1363 &line.terminal_map_from,
1364 &format!("line {} from terminals", line.name),
1365 &format!("/model/multiconductor_network/lines/{i}/terminal_map_from"),
1366 bus_terminals,
1367 terminal_maps,
1368 );
1369 check_terminal_map(
1370 &line.bus_to,
1371 &line.terminal_map_to,
1372 &format!("line {} to terminals", line.name),
1373 &format!("/model/multiconductor_network/lines/{i}/terminal_map_to"),
1374 bus_terminals,
1375 terminal_maps,
1376 );
1377 }
1378}
1379
1380fn validate_multiconductor_switches(
1381 net: &MulticonductorNetwork,
1382 bus_ids: &BTreeSet<String>,
1383 bus_terminals: &BTreeMap<String, BTreeSet<String>>,
1384 structure: &mut Vec<StructuredDiagnostic>,
1385 terminal_maps: &mut Vec<StructuredDiagnostic>,
1386) {
1387 for (i, sw) in net.switches.iter().enumerate() {
1388 check_bus_ref(
1389 &sw.bus_from,
1390 &format!("switch {} from bus", sw.name),
1391 &format!("/model/multiconductor_network/switches/{i}/bus_from"),
1392 bus_ids,
1393 structure,
1394 );
1395 check_bus_ref(
1396 &sw.bus_to,
1397 &format!("switch {} to bus", sw.name),
1398 &format!("/model/multiconductor_network/switches/{i}/bus_to"),
1399 bus_ids,
1400 structure,
1401 );
1402 check_terminal_map(
1403 &sw.bus_from,
1404 &sw.terminal_map_from,
1405 &format!("switch {} from terminals", sw.name),
1406 &format!("/model/multiconductor_network/switches/{i}/terminal_map_from"),
1407 bus_terminals,
1408 terminal_maps,
1409 );
1410 check_terminal_map(
1411 &sw.bus_to,
1412 &sw.terminal_map_to,
1413 &format!("switch {} to terminals", sw.name),
1414 &format!("/model/multiconductor_network/switches/{i}/terminal_map_to"),
1415 bus_terminals,
1416 terminal_maps,
1417 );
1418 }
1419}
1420
1421fn validate_multiconductor_transformers(
1422 net: &MulticonductorNetwork,
1423 bus_ids: &BTreeSet<String>,
1424 bus_terminals: &BTreeMap<String, BTreeSet<String>>,
1425 structure: &mut Vec<StructuredDiagnostic>,
1426 terminal_maps: &mut Vec<StructuredDiagnostic>,
1427) {
1428 for (i, tx) in net.transformers.iter().enumerate() {
1429 for (j, winding) in tx.windings.iter().enumerate() {
1430 check_bus_ref(
1431 &winding.bus,
1432 &format!("transformer {} winding {j} bus", tx.name),
1433 &format!("/model/multiconductor_network/transformers/{i}/windings/{j}/bus"),
1434 bus_ids,
1435 structure,
1436 );
1437 check_terminal_map(
1438 &winding.bus,
1439 &winding.terminal_map,
1440 &format!("transformer {} winding {j} terminals", tx.name),
1441 &format!(
1442 "/model/multiconductor_network/transformers/{i}/windings/{j}/terminal_map"
1443 ),
1444 bus_terminals,
1445 terminal_maps,
1446 );
1447 }
1448 }
1449}
1450
1451fn validate_multiconductor_injections(
1452 net: &MulticonductorNetwork,
1453 bus_ids: &BTreeSet<String>,
1454 bus_terminals: &BTreeMap<String, BTreeSet<String>>,
1455 structure: &mut Vec<StructuredDiagnostic>,
1456 terminal_maps: &mut Vec<StructuredDiagnostic>,
1457) {
1458 let mut ctx = MultiValidationContext {
1459 bus_ids,
1460 bus_terminals,
1461 structure,
1462 terminal_maps,
1463 };
1464 for (i, load) in net.loads.iter().enumerate() {
1465 check_one_bus_element(
1466 &load.bus,
1467 &load.terminal_map,
1468 &format!("load {}", load.name),
1469 &format!("/model/multiconductor_network/loads/{i}"),
1470 &mut ctx,
1471 );
1472 }
1473 for (i, generator) in net.generators.iter().enumerate() {
1474 check_one_bus_element(
1475 &generator.bus,
1476 &generator.terminal_map,
1477 &format!("generator {}", generator.name),
1478 &format!("/model/multiconductor_network/generators/{i}"),
1479 &mut ctx,
1480 );
1481 }
1482 for (i, shunt) in net.shunts.iter().enumerate() {
1483 check_one_bus_element(
1484 &shunt.bus,
1485 &shunt.terminal_map,
1486 &format!("shunt {}", shunt.name),
1487 &format!("/model/multiconductor_network/shunts/{i}"),
1488 &mut ctx,
1489 );
1490 }
1491 for (i, capacitor) in net.capacitors.iter().enumerate() {
1492 check_one_bus_element(
1493 &capacitor.bus,
1494 &capacitor.terminal_map,
1495 &format!("capacitor {}", capacitor.name),
1496 &format!("/model/multiconductor_network/capacitors/{i}"),
1497 &mut ctx,
1498 );
1499 }
1500 for (i, source) in net.sources.iter().enumerate() {
1501 check_one_bus_element(
1502 &source.bus,
1503 &source.terminal_map,
1504 &format!("voltage source {}", source.name),
1505 &format!("/model/multiconductor_network/sources/{i}"),
1506 &mut ctx,
1507 );
1508 }
1509}
1510
1511struct MultiValidationContext<'a> {
1512 bus_ids: &'a BTreeSet<String>,
1513 bus_terminals: &'a BTreeMap<String, BTreeSet<String>>,
1514 structure: &'a mut Vec<StructuredDiagnostic>,
1515 terminal_maps: &'a mut Vec<StructuredDiagnostic>,
1516}
1517
1518fn check_one_bus_element(
1519 bus: &str,
1520 terminal_map: &[String],
1521 label: &str,
1522 path: &str,
1523 ctx: &mut MultiValidationContext<'_>,
1524) {
1525 check_bus_ref(
1526 bus,
1527 &format!("{label} bus"),
1528 &format!("{path}/bus"),
1529 ctx.bus_ids,
1530 ctx.structure,
1531 );
1532 check_terminal_map(
1533 bus,
1534 terminal_map,
1535 &format!("{label} terminals"),
1536 &format!("{path}/terminal_map"),
1537 ctx.bus_terminals,
1538 ctx.terminal_maps,
1539 );
1540}
1541
1542fn multiconductor_bus_index(
1543 net: &MulticonductorNetwork,
1544 diagnostics: &mut Vec<StructuredDiagnostic>,
1545) -> (BTreeSet<String>, BTreeMap<String, BTreeSet<String>>) {
1546 let mut ids = BTreeSet::new();
1547 let mut terminals = BTreeMap::new();
1548 let mut first_seen = BTreeMap::<String, String>::new();
1549 for (i, bus) in net.buses.iter().enumerate() {
1550 let key = bus.id.to_ascii_lowercase();
1551 if let Some(first) = first_seen.insert(key.clone(), bus.id.clone()) {
1552 diagnostics.push(
1553 StructuredDiagnostic::new(
1554 "VALIDATE.MULTI.STRUCTURE",
1555 DiagnosticSeverity::Error,
1556 DiagnosticStage::Validate,
1557 format!("duplicate bus id `{}` conflicts with `{first}`", bus.id),
1558 )
1559 .with_element_path(format!("/model/multiconductor_network/buses/{i}/id")),
1560 );
1561 }
1562 ids.insert(key.clone());
1563 terminals.insert(key, bus.terminals.iter().cloned().collect());
1564 }
1565 (ids, terminals)
1566}
1567
1568fn check_bus_ref(
1569 bus: &str,
1570 what: &str,
1571 path: &str,
1572 bus_ids: &BTreeSet<String>,
1573 diagnostics: &mut Vec<StructuredDiagnostic>,
1574) {
1575 if !bus_ids.contains(&bus.to_ascii_lowercase()) {
1576 diagnostics.push(
1577 StructuredDiagnostic::new(
1578 "VALIDATE.MULTI.STRUCTURE",
1579 DiagnosticSeverity::Error,
1580 DiagnosticStage::Validate,
1581 format!("{what} references unknown bus `{bus}`"),
1582 )
1583 .with_element_path(path),
1584 );
1585 }
1586}
1587
1588fn check_terminal_map(
1589 bus: &str,
1590 terminal_map: &[String],
1591 what: &str,
1592 path: &str,
1593 bus_terminals: &BTreeMap<String, BTreeSet<String>>,
1594 diagnostics: &mut Vec<StructuredDiagnostic>,
1595) {
1596 if terminal_map.is_empty() {
1597 diagnostics.push(
1598 StructuredDiagnostic::new(
1599 "VALIDATE.MULTI.TERMINAL_MAP",
1600 DiagnosticSeverity::Error,
1601 DiagnosticStage::Validate,
1602 format!("{what} has an empty terminal map"),
1603 )
1604 .with_element_path(path),
1605 );
1606 return;
1607 }
1608
1609 let Some(known) = bus_terminals.get(&bus.to_ascii_lowercase()) else {
1610 return;
1611 };
1612 for terminal in terminal_map {
1613 if !known.contains(terminal) {
1614 diagnostics.push(
1615 StructuredDiagnostic::new(
1616 "VALIDATE.MULTI.TERMINAL_MAP",
1617 DiagnosticSeverity::Error,
1618 DiagnosticStage::Validate,
1619 format!("{what} references unknown terminal `{terminal}` on bus `{bus}`"),
1620 )
1621 .with_element_path(path),
1622 );
1623 }
1624 }
1625}
1626
1627fn balanced_origin(net: &BalancedNetwork) -> Origin {
1629 match net.source_format {
1630 SourceFormat::InMemory => Origin::InMemory,
1631 SourceFormat::Normalized => Origin::Derived {
1632 parent_package_id: None,
1633 pass: "normalize-balanced".to_owned(),
1634 options: serde_json::Map::new(),
1635 },
1636 SourceFormat::Gridfm | SourceFormat::PypsaCsv => Origin::Folder {
1637 path: String::new(),
1638 format: net.source_format.name().to_owned(),
1639 file_hashes: BTreeMap::new(),
1640 },
1641 SourceFormat::PowerWorldBinary => Origin::BinaryFile {
1642 path: String::new(),
1643 format: net.source_format.name().to_owned(),
1644 hash: None,
1645 decoded_sections: Vec::new(),
1646 },
1647 other => Origin::File {
1648 path: String::new(),
1649 format: other.name().to_owned(),
1650 hash: None,
1651 retained_source: net.source.is_some(),
1652 },
1653 }
1654}
1655
1656fn balanced_sources(net: &BalancedNetwork) -> Vec<SourceDescriptor> {
1657 let Some(kind) = balanced_source_kind(net.source_format) else {
1658 return Vec::new();
1659 };
1660 vec![SourceDescriptor {
1661 id: "src0".to_owned(),
1662 kind: kind.to_owned(),
1663 path: None,
1664 format: Some(net.source_format.name().to_owned()),
1665 hash: None,
1666 }]
1667}
1668
1669fn balanced_source_kind(f: SourceFormat) -> Option<&'static str> {
1670 match f {
1671 SourceFormat::InMemory | SourceFormat::Normalized => None,
1672 SourceFormat::Gridfm | SourceFormat::PypsaCsv => Some("folder"),
1673 SourceFormat::PowerWorldBinary => Some("binary_file"),
1674 _ => Some("file"),
1675 }
1676}
1677
1678fn balanced_summary(net: &BalancedNetwork) -> ObjectSummary {
1679 let mut elements = BTreeMap::new();
1680 elements.insert("buses".to_owned(), net.buses.len() as u64);
1681 elements.insert("loads".to_owned(), net.loads.len() as u64);
1682 elements.insert("shunts".to_owned(), net.shunts.len() as u64);
1683 elements.insert("branches".to_owned(), net.branches.len() as u64);
1684 elements.insert("generators".to_owned(), net.generators.len() as u64);
1685 elements.insert("storage".to_owned(), net.storage.len() as u64);
1686 elements.insert("hvdc".to_owned(), net.hvdc.len() as u64);
1687 elements.insert(
1688 "transformers_3w".to_owned(),
1689 net.transformers_3w.len() as u64,
1690 );
1691
1692 let reference_buses: Vec<String> = net
1693 .buses
1694 .iter()
1695 .filter(|b| b.kind == powerio::BusType::Ref)
1696 .map(|b| b.id.0.to_string())
1697 .collect();
1698
1699 ObjectSummary {
1700 elements,
1701 topology: Some(ObjectTopology {
1702 connected_components: None,
1703 reference_buses,
1704 }),
1705 units: Some(ObjectUnits {
1706 power: Some("MW/MVAr".to_owned()),
1707 angle: Some("degrees".to_owned()),
1708 base_mva: Some(net.base_mva),
1709 }),
1710 }
1711}
1712
1713fn balanced_source_maps(net: &BalancedNetwork, source_id: Option<&str>) -> Vec<SourceMapEntry> {
1714 let Some(source_id) = source_id else {
1715 return Vec::new();
1716 };
1717 let mut entries = Vec::new();
1718 push_balanced_network_maps(&mut entries, source_id, net.source_format);
1719 push_balanced_bus_maps(&mut entries, source_id, net.buses.len());
1720 push_balanced_injection_maps(&mut entries, source_id, net);
1721 push_balanced_branch_maps(&mut entries, source_id, net);
1722 push_balanced_generator_maps(&mut entries, source_id, net.generators.len());
1723 entries
1724}
1725
1726fn push_balanced_network_maps(
1727 entries: &mut Vec<SourceMapEntry>,
1728 source_id: &str,
1729 source_format: SourceFormat,
1730) {
1731 push_balanced_map(
1732 entries,
1733 source_id,
1734 "/model/balanced_network/base_mva",
1735 "case",
1736 "base_mva",
1737 MappingKind::Exact,
1738 );
1739 if balanced_has_frequency_source(source_format) {
1740 push_balanced_map(
1741 entries,
1742 source_id,
1743 "/model/balanced_network/base_frequency",
1744 "case",
1745 "base_frequency",
1746 MappingKind::Exact,
1747 );
1748 }
1749}
1750
1751fn push_balanced_bus_maps(entries: &mut Vec<SourceMapEntry>, source_id: &str, len: usize) {
1752 push_balanced_record_maps(
1753 entries,
1754 source_id,
1755 "buses",
1756 len,
1757 "bus",
1758 &[
1759 "id", "kind", "vm", "va", "base_kv", "vmax", "vmin", "area", "zone",
1760 ],
1761 MappingKind::Exact,
1762 );
1763}
1764
1765fn push_balanced_injection_maps(
1766 entries: &mut Vec<SourceMapEntry>,
1767 source_id: &str,
1768 net: &BalancedNetwork,
1769) {
1770 if net.source_format == SourceFormat::Matpower {
1771 push_matpower_injection_maps(entries, source_id, net);
1772 } else {
1773 push_balanced_record_maps(
1774 entries,
1775 source_id,
1776 "loads",
1777 net.loads.len(),
1778 "load",
1779 &["bus", "p", "q", "in_service"],
1780 MappingKind::Exact,
1781 );
1782 push_balanced_record_maps(
1783 entries,
1784 source_id,
1785 "shunts",
1786 net.shunts.len(),
1787 "shunt",
1788 &["bus", "g", "b", "in_service"],
1789 MappingKind::Exact,
1790 );
1791 }
1792}
1793
1794fn push_balanced_branch_maps(
1795 entries: &mut Vec<SourceMapEntry>,
1796 source_id: &str,
1797 net: &BalancedNetwork,
1798) {
1799 for (i, branch) in net.branches.iter().enumerate() {
1800 push_balanced_record_map(
1801 entries,
1802 source_id,
1803 "branches",
1804 i,
1805 "branch",
1806 &[
1807 "from",
1808 "to",
1809 "r",
1810 "x",
1811 "b",
1812 "rate_a",
1813 "rate_b",
1814 "rate_c",
1815 "tap",
1816 "shift",
1817 "in_service",
1818 "angmin",
1819 "angmax",
1820 ],
1821 MappingKind::Exact,
1822 );
1823 if branch.charging.is_some() {
1824 for field in ["g_fr", "b_fr", "g_to", "b_to"] {
1825 push_balanced_map(
1826 entries,
1827 source_id,
1828 &format!("/model/balanced_network/branches/{i}/charging/{field}"),
1829 "branch",
1830 field,
1831 MappingKind::Exact,
1832 );
1833 }
1834 }
1835 }
1836}
1837
1838fn push_balanced_generator_maps(entries: &mut Vec<SourceMapEntry>, source_id: &str, len: usize) {
1839 push_balanced_record_maps(
1840 entries,
1841 source_id,
1842 "generators",
1843 len,
1844 "generator",
1845 &[
1846 "bus",
1847 "pg",
1848 "qg",
1849 "pmax",
1850 "pmin",
1851 "qmax",
1852 "qmin",
1853 "vg",
1854 "mbase",
1855 "in_service",
1856 ],
1857 MappingKind::Exact,
1858 );
1859}
1860
1861fn balanced_has_frequency_source(source_format: SourceFormat) -> bool {
1862 matches!(
1863 source_format,
1864 SourceFormat::Psse | SourceFormat::PandapowerJson
1865 )
1866}
1867
1868fn push_matpower_injection_maps(
1869 entries: &mut Vec<SourceMapEntry>,
1870 source_id: &str,
1871 net: &BalancedNetwork,
1872) {
1873 push_balanced_record_maps(
1877 entries,
1878 source_id,
1879 "loads",
1880 net.loads.len(),
1881 "bus",
1882 &["bus", "p", "q", "in_service"],
1883 MappingKind::Split,
1884 );
1885 push_balanced_record_maps(
1886 entries,
1887 source_id,
1888 "shunts",
1889 net.shunts.len(),
1890 "bus",
1891 &["bus", "g", "b", "in_service"],
1892 MappingKind::Split,
1893 );
1894}
1895
1896fn push_balanced_record_maps(
1897 entries: &mut Vec<SourceMapEntry>,
1898 source_id: &str,
1899 collection: &str,
1900 len: usize,
1901 record: &str,
1902 fields: &[&str],
1903 mapping_kind: MappingKind,
1904) {
1905 for i in 0..len {
1906 push_balanced_record_map(
1907 entries,
1908 source_id,
1909 collection,
1910 i,
1911 record,
1912 fields,
1913 mapping_kind,
1914 );
1915 }
1916}
1917
1918fn push_balanced_record_map(
1919 entries: &mut Vec<SourceMapEntry>,
1920 source_id: &str,
1921 collection: &str,
1922 i: usize,
1923 record: &str,
1924 fields: &[&str],
1925 mapping_kind: MappingKind,
1926) {
1927 for &field in fields {
1928 push_balanced_map(
1929 entries,
1930 source_id,
1931 &format!("/model/balanced_network/{collection}/{i}/{field}"),
1932 record,
1933 field,
1934 mapping_kind,
1935 );
1936 }
1937}
1938
1939fn push_balanced_map(
1940 entries: &mut Vec<SourceMapEntry>,
1941 source_id: &str,
1942 element_path: &str,
1943 record: &str,
1944 field: &str,
1945 mapping_kind: MappingKind,
1946) {
1947 entries.push(SourceMapEntry {
1948 element_path: element_path.to_owned(),
1949 source_ref: SourceRef::new(source_id)
1950 .with_record(record)
1951 .with_field(field),
1952 mapping_kind,
1953 confidence: Confidence::High,
1954 });
1955}
1956
1957fn multiconductor_summary(net: &MulticonductorNetwork) -> ObjectSummary {
1958 let mut elements = BTreeMap::new();
1959 elements.insert("buses".to_owned(), net.buses.len() as u64);
1960 elements.insert("linecodes".to_owned(), net.linecodes.len() as u64);
1961 elements.insert("lines".to_owned(), net.lines.len() as u64);
1962 elements.insert("switches".to_owned(), net.switches.len() as u64);
1963 elements.insert("transformers".to_owned(), net.transformers.len() as u64);
1964 elements.insert("loads".to_owned(), net.loads.len() as u64);
1965 elements.insert("generators".to_owned(), net.generators.len() as u64);
1966 elements.insert("shunts".to_owned(), net.shunts.len() as u64);
1967 elements.insert("capacitors".to_owned(), net.capacitors.len() as u64);
1968 elements.insert("voltage_sources".to_owned(), net.sources.len() as u64);
1969
1970 ObjectSummary {
1971 elements,
1972 topology: None,
1973 units: Some(ObjectUnits {
1974 power: Some("W/var".to_owned()),
1975 angle: Some("radians".to_owned()),
1976 base_mva: None,
1977 }),
1978 }
1979}
1980
1981fn multiconductor_sources(net: &MulticonductorNetwork) -> Vec<SourceDescriptor> {
1982 match net.source_format {
1983 Some(sf) => vec![SourceDescriptor {
1984 id: "src0".to_owned(),
1985 kind: "file".to_owned(),
1986 path: None,
1987 format: Some(dist_format_name(sf).to_owned()),
1988 hash: None,
1989 }],
1990 None => Vec::new(),
1991 }
1992}
1993
1994fn dist_format_name(f: DistSourceFormat) -> &'static str {
1995 f.name()
1996}
1997
1998fn multiconductor_origin(net: &MulticonductorNetwork) -> Origin {
1999 match net.source_format {
2000 Some(sf) => Origin::File {
2001 path: String::new(),
2002 format: dist_format_name(sf).to_owned(),
2003 hash: None,
2004 retained_source: net.source.is_some(),
2005 },
2006 None => Origin::InMemory,
2007 }
2008}
2009
2010fn derived_sources(parent: &NetworkPackage) -> Vec<SourceDescriptor> {
2011 if !parent.sources.is_empty() {
2012 return parent.sources.clone();
2013 }
2014 vec![SourceDescriptor {
2015 id: "parent".to_owned(),
2016 kind: "package".to_owned(),
2017 path: None,
2018 format: Some("pio-json".to_owned()),
2019 hash: parent.package_id.clone(),
2020 }]
2021}
2022
2023fn lowered_balanced_source_maps(
2024 input: &MulticonductorNetwork,
2025 balanced: &BalancedNetwork,
2026 source_id: Option<&str>,
2027) -> Vec<SourceMapEntry> {
2028 let Some(source_id) = source_id else {
2029 return Vec::new();
2030 };
2031 let mut entries = Vec::new();
2032 push_lowered_bus_maps(&mut entries, source_id, input);
2033 push_lowered_branch_maps(&mut entries, source_id, input, balanced);
2034 push_lowered_load_maps(&mut entries, source_id, input, balanced);
2035 push_lowered_shunt_maps(&mut entries, source_id, input, balanced);
2036 push_lowered_generator_maps(&mut entries, source_id, input, balanced);
2037 entries
2038}
2039
2040fn push_lowered_bus_maps(
2041 entries: &mut Vec<SourceMapEntry>,
2042 source_id: &str,
2043 input: &MulticonductorNetwork,
2044) {
2045 for (idx, bus) in input.buses.iter().enumerate() {
2046 for (field, mapping_kind) in [
2047 ("id", MappingKind::Synthetic),
2048 ("kind", MappingKind::Lowered),
2049 ("vm", MappingKind::ConvertedUnits),
2050 ("va", MappingKind::ConvertedUnits),
2051 ("base_kv", MappingKind::ConvertedUnits),
2052 ("area", MappingKind::Defaulted),
2053 ("zone", MappingKind::Defaulted),
2054 ("name", MappingKind::Lowered),
2055 ] {
2056 push_lowered_map(
2057 entries,
2058 source_id,
2059 &format!("/model/balanced_network/buses/{idx}/{field}"),
2060 "multiconductor_bus",
2061 field,
2062 mapping_kind,
2063 );
2064 }
2065 for field in ["vmin", "vmax"] {
2066 let mapping_kind = if bus.v_min.is_some() && bus.v_max.is_some() {
2067 MappingKind::ConvertedUnits
2068 } else {
2069 MappingKind::Defaulted
2070 };
2071 push_lowered_map(
2072 entries,
2073 source_id,
2074 &format!("/model/balanced_network/buses/{idx}/{field}"),
2075 "multiconductor_bus",
2076 field,
2077 mapping_kind,
2078 );
2079 }
2080 }
2081}
2082
2083fn push_lowered_branch_maps(
2084 entries: &mut Vec<SourceMapEntry>,
2085 source_id: &str,
2086 input: &MulticonductorNetwork,
2087 balanced: &BalancedNetwork,
2088) {
2089 for (idx, branch) in balanced.branches.iter().enumerate() {
2090 let record = "multiconductor_line";
2091 for (field, mapping_kind) in [
2092 ("from", MappingKind::Lowered),
2093 ("to", MappingKind::Lowered),
2094 ("r", MappingKind::ConvertedUnits),
2095 ("x", MappingKind::ConvertedUnits),
2096 ("b", MappingKind::ConvertedUnits),
2097 ("in_service", MappingKind::Lowered),
2098 ("tap", MappingKind::Defaulted),
2099 ("shift", MappingKind::Defaulted),
2100 ("angmin", MappingKind::Defaulted),
2101 ("angmax", MappingKind::Defaulted),
2102 ] {
2103 push_lowered_map(
2104 entries,
2105 source_id,
2106 &format!("/model/balanced_network/branches/{idx}/{field}"),
2107 record,
2108 field,
2109 mapping_kind,
2110 );
2111 }
2112 let has_rating = input
2113 .lines
2114 .get(idx)
2115 .and_then(|line| input.linecode(&line.linecode))
2116 .is_some_and(|code| code.i_max.is_some() || code.s_max.is_some());
2117 let rate_kind = if has_rating {
2118 MappingKind::ConvertedUnits
2119 } else {
2120 MappingKind::Defaulted
2121 };
2122 for field in ["rate_a", "rate_b", "rate_c"] {
2123 push_lowered_map(
2124 entries,
2125 source_id,
2126 &format!("/model/balanced_network/branches/{idx}/{field}"),
2127 record,
2128 field,
2129 rate_kind,
2130 );
2131 }
2132 if branch.charging.is_some() {
2133 for field in ["g_fr", "b_fr", "g_to", "b_to"] {
2134 push_lowered_map(
2135 entries,
2136 source_id,
2137 &format!("/model/balanced_network/branches/{idx}/charging/{field}"),
2138 record,
2139 field,
2140 MappingKind::ConvertedUnits,
2141 );
2142 }
2143 }
2144 }
2145}
2146
2147fn push_lowered_load_maps(
2148 entries: &mut Vec<SourceMapEntry>,
2149 source_id: &str,
2150 input: &MulticonductorNetwork,
2151 balanced: &BalancedNetwork,
2152) {
2153 for idx in 0..balanced.loads.len().min(input.loads.len()) {
2154 for (field, mapping_kind) in [
2155 ("bus", MappingKind::Lowered),
2156 ("p", MappingKind::Aggregated),
2157 ("q", MappingKind::Aggregated),
2158 ("in_service", MappingKind::Lowered),
2159 ] {
2160 push_lowered_map(
2161 entries,
2162 source_id,
2163 &format!("/model/balanced_network/loads/{idx}/{field}"),
2164 "multiconductor_load",
2165 field,
2166 mapping_kind,
2167 );
2168 }
2169 }
2170}
2171
2172fn push_lowered_shunt_maps(
2173 entries: &mut Vec<SourceMapEntry>,
2174 source_id: &str,
2175 input: &MulticonductorNetwork,
2176 balanced: &BalancedNetwork,
2177) {
2178 for idx in 0..balanced.shunts.len().min(input.shunts.len()) {
2179 for (field, mapping_kind) in [
2180 ("bus", MappingKind::Lowered),
2181 ("g", MappingKind::Aggregated),
2182 ("b", MappingKind::Aggregated),
2183 ("in_service", MappingKind::Lowered),
2184 ] {
2185 push_lowered_map(
2186 entries,
2187 source_id,
2188 &format!("/model/balanced_network/shunts/{idx}/{field}"),
2189 "multiconductor_shunt",
2190 field,
2191 mapping_kind,
2192 );
2193 }
2194 }
2195}
2196
2197fn push_lowered_generator_maps(
2198 entries: &mut Vec<SourceMapEntry>,
2199 source_id: &str,
2200 input: &MulticonductorNetwork,
2201 balanced: &BalancedNetwork,
2202) {
2203 for idx in 0..balanced.generators.len().min(input.generators.len()) {
2204 let generator = &input.generators[idx];
2205 for (field, mapping_kind) in [
2206 ("bus", MappingKind::Lowered),
2207 ("pg", MappingKind::Aggregated),
2208 ("qg", MappingKind::Aggregated),
2209 ("vg", MappingKind::Defaulted),
2210 ("mbase", MappingKind::Synthetic),
2211 ("in_service", MappingKind::Lowered),
2212 ] {
2213 push_lowered_map(
2214 entries,
2215 source_id,
2216 &format!("/model/balanced_network/generators/{idx}/{field}"),
2217 "multiconductor_generator",
2218 field,
2219 mapping_kind,
2220 );
2221 }
2222 for (field, present) in [
2223 ("pmin", generator.p_min.is_some()),
2224 ("pmax", generator.p_max.is_some()),
2225 ("qmin", generator.q_min.is_some()),
2226 ("qmax", generator.q_max.is_some()),
2227 ] {
2228 push_lowered_map(
2229 entries,
2230 source_id,
2231 &format!("/model/balanced_network/generators/{idx}/{field}"),
2232 "multiconductor_generator",
2233 field,
2234 if present {
2235 MappingKind::Aggregated
2236 } else {
2237 MappingKind::Defaulted
2238 },
2239 );
2240 }
2241 }
2242}
2243
2244fn push_lowered_map(
2245 entries: &mut Vec<SourceMapEntry>,
2246 source_id: &str,
2247 element_path: &str,
2248 record: &str,
2249 field: &str,
2250 mapping_kind: MappingKind,
2251) {
2252 entries.push(SourceMapEntry {
2253 element_path: element_path.to_owned(),
2254 source_ref: SourceRef::new(source_id)
2255 .with_record(record)
2256 .with_field(field),
2257 mapping_kind,
2258 confidence: Confidence::High,
2259 });
2260}
2261
2262fn multiconductor_source_maps(
2267 net: &MulticonductorNetwork,
2268 source_id: Option<&str>,
2269) -> Vec<SourceMapEntry> {
2270 let Some(source_id) = source_id else {
2271 return Vec::new();
2272 };
2273 let mut entries = Vec::new();
2274 for (element, fields) in &net.defaulted {
2275 for field in fields {
2276 entries.push(SourceMapEntry {
2277 element_path: format!("/model/multiconductor_network/{element}#{field}"),
2278 source_ref: SourceRef::new(source_id).with_field((*field).to_owned()),
2279 mapping_kind: MappingKind::Defaulted,
2280 confidence: Confidence::High,
2281 });
2282 }
2283 }
2284 entries
2285}
2286
2287#[cfg(test)]
2288mod tests {
2289 #[test]
2290 fn schema_lineage_parses_semver_suffixes() {
2291 assert_eq!(super::schema_lineage("1.2.3"), Some((1, 2)));
2292 assert_eq!(super::schema_lineage("1.0.0-rc.1"), Some((1, 0)));
2293 assert_eq!(super::schema_lineage("1.0.0+build-x"), Some((1, 0)));
2296 assert_eq!(super::schema_lineage("0.2.0+2026-07-21"), Some((0, 2)));
2297 assert_eq!(super::schema_lineage("1.0.0-rc-1+b-2"), Some((1, 0)));
2298 assert_eq!(super::schema_lineage("1.0"), None);
2299 assert_eq!(super::schema_lineage("1.0.0-"), None);
2300 assert_eq!(super::schema_lineage("01.0.0"), None);
2301 }
2302
2303 #[test]
2304 fn version_gate_is_exact_minor_while_major_is_zero() {
2305 use super::NetworkPackage;
2306 assert!(NetworkPackage::supports_schema_version("0.2.0"));
2307 assert!(NetworkPackage::supports_schema_version("0.2.7"));
2308 assert!(!NetworkPackage::supports_schema_version("0.1.1"));
2309 assert!(!NetworkPackage::supports_schema_version("0.3.0"));
2310 assert!(!NetworkPackage::supports_schema_version("1.0.0"));
2311 assert!(!NetworkPackage::supports_schema_version("garbage"));
2312 }
2313
2314 #[test]
2315 fn envelope_shaped_rejection_names_the_format() {
2316 let err = super::NetworkPackage::from_json(
2319 r#"{"model_kind":"balanced","model":{"kind":"balanced"}}"#,
2320 )
2321 .unwrap_err();
2322 assert!(err.to_string().contains(".pio.json"), "got: {err}");
2323 }
2324}