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