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