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