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