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