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