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