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