use std::collections::{BTreeMap, BTreeSet, HashMap};
use serde::{Deserialize, Serialize};
use crate::{
BalancedNetwork, BusId, NORMALIZED_SOLVER_TABLES_PASS, NormalizedSolverTables,
SolverTableUnits, SourceFormat,
};
use powerio_dist::{DistSourceFormat, MulticonductorNetwork};
use powerio_core::DiagnosticInfo;
use crate::stored::legacy09::diagnostics::{DiagnosticSeverity, StructuredDiagnostic, codes};
use crate::stored::legacy09::error::Error;
use crate::stored::legacy09::model::{ModelKind, ModelPayload};
use crate::stored::legacy09::operating::{
OperatingPointSeries, apply_operating_point_to_model, check_series_identities,
};
use crate::stored::legacy09::provenance::{
Confidence, MappingKind, Origin, Producer, SourceDescriptor, SourceMapEntry, SourceRef,
};
use crate::stored::legacy09::study::{StudyBlock, apply_study_to_model, check_study_identities};
use crate::stored::legacy09::summary::{ObjectSummary, ObjectTopology, ObjectUnits};
use crate::stored::legacy09::validation::{ValidationPass, ValidationStatus, ValidationSummary};
use crate::transform::{
LoweringRecord, MulticonductorToBalancedError, MulticonductorToBalancedOptions,
MulticonductorToBalancedReadiness, check_multiconductor_to_balanced_lowering,
lower_multiconductor_to_balanced,
};
fn version_is_frozen_lineage(version: &str) -> bool {
let mut parts = version.split('.');
let (major, minor, patch) = (parts.next(), parts.next(), parts.next());
major == Some("0")
&& minor == Some("9")
&& patch.is_some_and(|p| p.as_bytes().first().is_some_and(u8::is_ascii_digit))
}
fn default_powerio_version() -> String {
"0.9.0".to_owned()
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct DerivedMetadata {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub matrix_stats: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub normalized_solver_tables: Option<NormalizedSolverTableMetadata>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub cache_keys: BTreeMap<String, String>,
}
impl DerivedMetadata {
fn is_empty(&self) -> bool {
self.matrix_stats.is_none()
&& self.normalized_solver_tables.is_none()
&& self.cache_keys.is_empty()
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct NormalizedSolverTableMetadata {
pub pass: String,
pub units: SolverTableUnits,
pub row_counts: NormalizedSolverTableRowCounts,
pub bus_ids: Vec<BusId>,
pub reference_bus_indices: Vec<usize>,
pub component_labels: Vec<usize>,
pub branch_from_arc_indices: Vec<usize>,
pub branch_to_arc_indices: Vec<usize>,
pub source_rows: NormalizedSolverTableSourceRows,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct NormalizedSolverTableRowCounts {
pub buses: usize,
pub loads: usize,
pub shunts: usize,
pub branches: usize,
pub switches: usize,
pub arcs: usize,
pub generators: usize,
pub storage: usize,
pub hvdc: usize,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct NormalizedSolverTableSourceRows {
pub buses: Vec<Option<usize>>,
pub loads: Vec<Option<usize>>,
pub shunts: Vec<Option<usize>>,
pub branches: Vec<Option<usize>>,
pub switches: Vec<Option<usize>>,
pub generators: Vec<Option<usize>>,
pub storage: Vec<Option<usize>>,
pub hvdc: Vec<Option<usize>>,
}
impl From<&NormalizedSolverTables> for NormalizedSolverTableMetadata {
fn from(tables: &NormalizedSolverTables) -> Self {
Self {
pass: NORMALIZED_SOLVER_TABLES_PASS.to_owned(),
units: tables.units.clone(),
row_counts: NormalizedSolverTableRowCounts {
buses: tables.buses.len(),
loads: tables.loads.len(),
shunts: tables.shunts.len(),
branches: tables.branches.len(),
switches: tables.switches.len(),
arcs: tables.arcs.len(),
generators: tables.generators.len(),
storage: tables.storage.len(),
hvdc: tables.hvdc.len(),
},
bus_ids: tables.index.bus_ids.clone(),
reference_bus_indices: tables.index.reference_bus_indices.clone(),
component_labels: tables.index.component_labels.clone(),
branch_from_arc_indices: tables.index.branch_from_arc_indices.clone(),
branch_to_arc_indices: tables.index.branch_to_arc_indices.clone(),
source_rows: NormalizedSolverTableSourceRows {
buses: tables.index.bus_source_rows.clone(),
loads: tables.index.load_source_rows.clone(),
shunts: tables.index.shunt_source_rows.clone(),
branches: tables.index.branch_source_rows.clone(),
switches: tables.index.switch_source_rows.clone(),
generators: tables.index.generator_source_rows.clone(),
storage: tables.index.storage_source_rows.clone(),
hvdc: tables.index.hvdc_source_rows.clone(),
},
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(remote = "Self")]
#[non_exhaustive]
pub struct NetworkPackage {
#[serde(default)]
#[allow(rustdoc::broken_intra_doc_links)]
pub powerio_version: String,
pub producer: Producer,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub package_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<String>,
pub model_kind: ModelKind,
pub model: ModelPayload,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub operating_points: Option<OperatingPointSeries>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub study: Option<StudyBlock>,
pub origin: Origin,
#[serde(
default,
skip_serializing_if = "Vec::is_empty",
deserialize_with = "bounded_legacy_sources"
)]
pub sources: Vec<SourceDescriptor>,
#[serde(
default,
skip_serializing_if = "Vec::is_empty",
deserialize_with = "bounded_legacy_source_maps"
)]
pub source_maps: Vec<SourceMapEntry>,
#[serde(
default,
skip_serializing_if = "Vec::is_empty",
deserialize_with = "bounded_legacy_diagnostics"
)]
pub diagnostics: Vec<StructuredDiagnostic>,
pub validation: ValidationSummary,
#[serde(default)]
pub summary: ObjectSummary,
#[serde(
default,
skip_serializing_if = "Vec::is_empty",
deserialize_with = "bounded_legacy_history"
)]
pub lowering_history: Vec<LoweringRecord>,
#[serde(default, skip_serializing_if = "DerivedMetadata::is_empty")]
pub derived: DerivedMetadata,
}
impl serde::Serialize for NetworkPackage {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
NetworkPackage::serialize(
self,
powerio_core::__implementation::nonfinite::NonFiniteSer(serializer),
)
}
}
impl<'de> serde::Deserialize<'de> for NetworkPackage {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
NetworkPackage::deserialize(powerio_core::__implementation::nonfinite::NonFiniteDe(
deserializer,
))
}
}
impl NetworkPackage {
pub fn from_balanced(net: BalancedNetwork) -> Self {
let mut net = net;
ensure_payload_uids(&mut net);
let origin = balanced_origin(&net);
let summary = balanced_summary(&net);
let sources = balanced_sources(&net);
let source_id = sources.first().map(|s| s.id.clone());
let source_maps = balanced_source_maps(&net, source_id.as_deref());
let diagnostics = Vec::new();
let validation = ValidationSummary::from_diagnostics(&diagnostics);
Self {
powerio_version: default_powerio_version(),
producer: Producer::powerio(),
package_id: None,
created_at: None,
model_kind: ModelKind::Balanced,
model: ModelPayload::balanced(net),
operating_points: None,
study: None,
origin,
sources,
source_maps,
diagnostics,
validation,
summary,
lowering_history: Vec::new(),
derived: DerivedMetadata::default(),
}
}
pub fn from_balanced_module(module: powerio_core::PioModule<crate::BalancedNetwork>) -> Self {
let diagnostics = module.diagnostics().to_vec();
Self::from_balanced_with_read_diagnostics(
module.into_value(),
diagnostics.into_iter().map(Into::into),
)
}
pub fn attach_goc3_operating_points(&mut self, document: &crate::format::goc3::Goc3Document) {
match crate::stored::legacy09::operating::goc3_operating_points(document) {
Ok(series) => self.operating_points = series,
Err(error) => {
self.diagnostics.push(StructuredDiagnostic::of(
&crate::codes::READ_PACKAGE_OPERATING_POINTS_DROPPED,
format!(
"time series could not be lifted into operating points; \
the package is static only: {error}"
),
));
self.validation = ValidationSummary::from_diagnostics(&self.diagnostics);
}
}
}
pub fn from_balanced_with_read_diagnostics<I>(net: BalancedNetwork, diagnostics: I) -> Self
where
I: IntoIterator<Item = StructuredDiagnostic>,
{
let mut package = Self::from_balanced(net);
package.record_read_diagnostics(diagnostics);
package
}
pub fn record_read_diagnostics<I>(&mut self, diagnostics: I)
where
I: IntoIterator<Item = StructuredDiagnostic>,
{
let before = self.diagnostics.len();
self.diagnostics.extend(diagnostics);
if self.diagnostics.len() == before {
return;
}
self.validation = ValidationSummary::from_diagnostics(&self.diagnostics);
}
pub fn from_multiconductor_module(
module: powerio_core::PioModule<MulticonductorNetwork>,
) -> Self {
let diagnostics = module.diagnostics().to_vec();
let retained = module.source().is_some();
let mut package = Self::from_multiconductor(module.into_value());
package.record_read_diagnostics(diagnostics.into_iter().map(Into::into));
if retained
&& let Origin::File {
retained_source, ..
} = &mut package.origin
{
*retained_source = true;
}
package
}
pub fn from_multiconductor_with_read_diagnostics<I>(
net: MulticonductorNetwork,
diagnostics: I,
) -> Self
where
I: IntoIterator<Item = StructuredDiagnostic>,
{
let mut package = Self::from_multiconductor(net);
package.record_read_diagnostics(diagnostics);
package
}
pub fn from_multiconductor(net: MulticonductorNetwork) -> Self {
let summary = multiconductor_summary(&net);
let sources = multiconductor_sources(&net);
let source_id = sources.first().map(|s| s.id.clone());
let source_maps = multiconductor_source_maps(&net, source_id.as_deref());
let origin = multiconductor_origin(&net);
let diagnostics: Vec<StructuredDiagnostic> = Vec::new();
let validation = ValidationSummary::from_diagnostics(&diagnostics);
Self {
powerio_version: default_powerio_version(),
producer: Producer::powerio(),
package_id: None,
created_at: None,
model_kind: ModelKind::Multiconductor,
model: ModelPayload::multiconductor(net),
operating_points: None,
study: None,
origin,
sources,
source_maps,
diagnostics,
validation,
summary,
lowering_history: Vec::new(),
derived: DerivedMetadata::default(),
}
}
pub fn model_kind(&self) -> ModelKind {
self.model_kind
}
pub fn kind_is_consistent(&self) -> bool {
self.model_kind == self.model.kind()
}
pub fn as_balanced(&self) -> Option<&BalancedNetwork> {
self.model.as_balanced()
}
pub fn as_multiconductor(&self) -> Option<&MulticonductorNetwork> {
self.model.as_multiconductor()
}
#[must_use]
pub fn operating_points(&self) -> Option<&OperatingPointSeries> {
self.operating_points.as_ref()
}
#[must_use]
pub fn with_operating_points(mut self, operating_points: OperatingPointSeries) -> Self {
self.set_operating_points(operating_points);
self
}
pub fn set_operating_points(&mut self, operating_points: OperatingPointSeries) {
self.operating_points = (!operating_points.is_empty()).then_some(operating_points);
}
pub fn clear_operating_points(&mut self) {
self.operating_points = None;
}
#[must_use]
pub fn study(&self) -> Option<&StudyBlock> {
self.study.as_ref()
}
#[must_use]
pub fn with_study(mut self, study: StudyBlock) -> Self {
self.set_study(study);
self
}
pub fn set_study(&mut self, study: StudyBlock) {
self.study = (!study.is_empty()).then_some(study);
}
pub fn clear_study(&mut self) {
self.study = None;
}
pub fn materialize_operating_point(
&self,
index: usize,
) -> crate::stored::legacy09::Result<Self> {
let series = self.operating_points.as_ref().ok_or_else(|| {
crate::stored::legacy09::Error::Payload("package has no operating points".to_owned())
})?;
let point = series
.unique_point(index)?
.ok_or_else(|| Error::NoSuchIndex(format!("package has no operating point {index}")))?;
let (updated_model, updated_paths) = apply_operating_point_to_model(&self.model, point)?;
let had_normalized_solver_tables = self.derived.normalized_solver_tables.is_some();
let options = materialize_operating_point_options(index);
let mut package = Self {
powerio_version: self.powerio_version.clone(),
producer: self.producer.clone(),
package_id: None,
created_at: self.created_at.clone(),
model_kind: self.model_kind,
model: updated_model,
operating_points: None,
study: None,
origin: Origin::Derived {
parent_package_id: self.package_id.clone(),
pass: "materialize-operating-point".to_owned(),
options: options.clone(),
},
sources: self.sources.clone(),
source_maps: self
.source_maps
.iter()
.filter(|entry| !updated_paths.contains(entry.element_path.as_str()))
.cloned()
.collect(),
diagnostics: self
.diagnostics
.iter()
.filter(|diagnostic| {
diagnostic
.element_path
.as_deref()
.is_none_or(|path| !updated_paths.contains(path))
})
.cloned()
.collect(),
validation: self.validation.clone(),
summary: self.summary.clone(),
lowering_history: self.lowering_history.clone(),
derived: DerivedMetadata::default(),
};
let mut record = LoweringRecord::new(
"materialize-operating-point",
self.model_kind,
self.model_kind,
);
record.options = options;
package.run_sane_validation();
record.validation_status = package.validation.status;
package.push_lowering(record);
if had_normalized_solver_tables {
package
.attach_normalized_solver_table_metadata()
.map_err(|err| {
Error::Payload(format!(
"failed to recompute normalized solver table metadata: {err}"
))
})?;
}
Ok(package)
}
pub fn materialize_balanced_operating_point(
&self,
index: usize,
) -> crate::stored::legacy09::Result<Option<BalancedNetwork>> {
Ok(self
.materialize_operating_point(index)?
.model
.as_balanced()
.cloned())
}
pub fn materialize_multiconductor_operating_point(
&self,
index: usize,
) -> crate::stored::legacy09::Result<Option<MulticonductorNetwork>> {
Ok(self
.materialize_operating_point(index)?
.model
.as_multiconductor()
.cloned())
}
pub fn materialize_study_commit(
&self,
commit_index: usize,
) -> crate::stored::legacy09::Result<Self> {
let study = self.study.as_ref().ok_or_else(|| {
crate::stored::legacy09::Error::Payload("package has no study block".to_owned())
})?;
let base = if let Some(index) = study.base_operating_point {
self.materialize_operating_point(index)?
} else {
self.clone()
};
let (updated_model, updated_paths) =
apply_study_to_model(&base.model, study, commit_index)?;
let had_normalized_solver_tables = base.derived.normalized_solver_tables.is_some();
let options = materialize_study_commit_options(study, commit_index);
let mut package = Self {
powerio_version: base.powerio_version.clone(),
producer: base.producer.clone(),
package_id: None,
created_at: base.created_at.clone(),
model_kind: base.model_kind,
model: updated_model,
operating_points: None,
study: None,
origin: Origin::Derived {
parent_package_id: self.package_id.clone(),
pass: "materialize-study-commit".to_owned(),
options: options.clone(),
},
sources: base.sources.clone(),
source_maps: base
.source_maps
.iter()
.filter(|entry| !updated_paths.contains(entry.element_path.as_str()))
.cloned()
.collect(),
diagnostics: base
.diagnostics
.iter()
.filter(|diagnostic| {
diagnostic
.element_path
.as_deref()
.is_none_or(|path| !updated_paths.contains(path))
})
.cloned()
.collect(),
validation: base.validation.clone(),
summary: base.summary.clone(),
lowering_history: base.lowering_history.clone(),
derived: DerivedMetadata::default(),
};
let mut record =
LoweringRecord::new("materialize-study-commit", base.model_kind, base.model_kind);
record.options = options;
record
.assumptions
.push(format!("applied study commits 0..={commit_index}"));
package.run_sane_validation();
record.validation_status = package.validation.status;
package.push_lowering(record);
if had_normalized_solver_tables {
package
.attach_normalized_solver_table_metadata()
.map_err(|err| {
Error::Payload(format!(
"failed to recompute normalized solver table metadata: {err}"
))
})?;
}
Ok(package)
}
pub fn materialize_balanced_study_commit(
&self,
commit_index: usize,
) -> crate::stored::legacy09::Result<Option<BalancedNetwork>> {
Ok(self
.materialize_study_commit(commit_index)?
.model
.as_balanced()
.cloned())
}
pub fn to_json(&self) -> crate::stored::legacy09::Result<String> {
serde_json::to_string(self).map_err(Error::Serialize)
}
pub fn to_json_pretty(&self) -> crate::stored::legacy09::Result<String> {
serde_json::to_string_pretty(self).map_err(Error::Serialize)
}
pub fn from_json(text: &str) -> crate::stored::legacy09::Result<Self> {
let pkg: Self =
serde_json::from_str(text.trim_start_matches('\u{feff}')).map_err(Error::Malformed)?;
if !version_is_frozen_lineage(&pkg.powerio_version) {
return Err(Error::UnsupportedVersion(crate::version::reject_as(
".pio.json",
&pkg.powerio_version,
"0.9.x",
)));
}
if !pkg.kind_is_consistent() {
return Err(Error::ModelKindMismatch);
}
Ok(pkg)
}
pub fn from_json_bytes(bytes: &[u8]) -> crate::stored::legacy09::Result<Self> {
let text = std::str::from_utf8(bytes).map_err(|e| {
Error::Malformed(serde::de::Error::custom(format!("not valid UTF-8: {e}")))
})?;
Self::from_json(text)
}
#[must_use]
pub fn with_origin(mut self, origin: Origin) -> Self {
self.origin = origin;
self
}
#[must_use]
pub fn with_package_id(mut self, id: impl Into<String>) -> Self {
self.package_id = Some(id.into());
self
}
#[must_use]
pub fn with_created_at(mut self, created_at: impl Into<String>) -> Self {
self.created_at = Some(created_at.into());
self
}
#[must_use]
pub fn with_sources(mut self, sources: Vec<SourceDescriptor>) -> Self {
self.sources = sources;
self
}
#[must_use]
pub fn with_source_maps(mut self, source_maps: Vec<SourceMapEntry>) -> Self {
self.source_maps = source_maps;
self
}
pub fn push_lowering(&mut self, record: LoweringRecord) {
self.lowering_history.push(record);
}
pub fn attach_normalized_solver_table_metadata(
&mut self,
) -> std::result::Result<bool, powerio_tx::Error> {
let Some(net) = self.as_balanced() else {
return Ok(false);
};
let tables = net.to_normalized_solver_tables()?;
self.derived.normalized_solver_tables = Some(NormalizedSolverTableMetadata::from(&tables));
Ok(true)
}
pub fn with_normalized_solver_table_metadata(
mut self,
) -> std::result::Result<Self, powerio_tx::Error> {
self.attach_normalized_solver_table_metadata()?;
Ok(self)
}
#[must_use]
pub fn check_multiconductor_to_balanced_lowering(
&self,
) -> Option<MulticonductorToBalancedReadiness> {
self.as_multiconductor().map(|net| {
check_multiconductor_to_balanced_lowering(
net,
MulticonductorToBalancedOptions::default(),
)
})
}
pub fn lower_multiconductor_to_balanced(
&self,
options: MulticonductorToBalancedOptions,
) -> Result<Self, MulticonductorToBalancedError> {
let Some(net) = self.as_multiconductor() else {
let diagnostic = StructuredDiagnostic::of(
&codes::TRANSFORM_MULTI_TO_BALANCED_WRONG_MODEL_KIND,
format!(
"multiconductor to balanced lowering requires a multiconductor package, got {:?}",
self.model_kind
),
);
return Err(MulticonductorToBalancedError::new(options, &[diagnostic]));
};
let lowered = lower_multiconductor_to_balanced(net, options)?;
let mut record = lowered.record;
let mut output = NetworkPackage::from_balanced(lowered.network);
output.origin = Origin::Derived {
parent_package_id: self.package_id.clone(),
pass: "multiconductor-to-balanced".to_owned(),
options: record.options.clone(),
};
output.sources = derived_sources(self);
let source_id = output.sources.first().map(|source| source.id.as_str());
output.source_maps = match output.as_balanced() {
Some(balanced) => lowered_balanced_source_maps(net, balanced, source_id),
None => Vec::new(),
};
output.diagnostics.clone_from(&record.diagnostics);
output.lowering_history.clone_from(&self.lowering_history);
output.run_sane_validation();
record.validation_status = output.validation.status;
output.push_lowering(record);
Ok(output)
}
pub fn run_sane_validation(&mut self) {
self.diagnostics
.retain(|d| !is_sane_validation_code(d.code.as_str()));
let (mut diagnostics, mut passes) = match &self.model {
ModelPayload::Balanced { balanced_network } => sane_validate_balanced(balanced_network),
ModelPayload::Multiconductor {
multiconductor_network,
} => sane_validate_multiconductor(multiconductor_network),
};
if let Some(series) = &self.operating_points {
let (identity_diagnostics, identity_pass) =
validate_operating_identity(&self.model, series);
diagnostics.extend(identity_diagnostics);
passes.push(identity_pass);
}
if let Some(study) = &self.study {
let (study_diagnostics, study_pass) = validate_study(&self.model, study);
diagnostics.extend(study_diagnostics);
passes.push(study_pass);
}
attach_source_refs(&mut diagnostics, &self.source_maps);
self.diagnostics.extend(diagnostics);
self.validation =
ValidationSummary::from_diagnostics(&self.diagnostics).with_passes(passes);
}
}
fn materialize_operating_point_options(index: usize) -> serde_json::Map<String, serde_json::Value> {
let mut options = serde_json::Map::new();
options.insert("index".to_owned(), serde_json::json!(index));
options
}
fn materialize_study_commit_options(
study: &StudyBlock,
commit_index: usize,
) -> serde_json::Map<String, serde_json::Value> {
let mut options = serde_json::Map::new();
options.insert("commit_index".to_owned(), serde_json::json!(commit_index));
if let Some(index) = study.base_operating_point {
options.insert("base_operating_point".to_owned(), serde_json::json!(index));
}
options
}
pub fn ensure_payload_uids(net: &mut BalancedNetwork) {
macro_rules! fill {
($table:ident, $table_mut:ident) => {
for (row, element) in net.$table_mut().iter_mut().enumerate() {
if element.uid.is_none() {
element.uid = Some(format!(concat!(stringify!($table), ":{}"), row));
}
}
};
}
fill!(buses, buses_mut);
fill!(loads, loads_mut);
fill!(shunts, shunts_mut);
fill!(branches, branches_mut);
fill!(switches, switches_mut);
fill!(generators, generators_mut);
fill!(storage, storage_mut);
fill!(hvdc, hvdc_mut);
fill!(transformers_3w, transformers_3w_mut);
}
const SANE_VALIDATION_CODES: [&DiagnosticInfo; 10] = [
&powerio_tx::diagnostics::codes::VALIDATE_BALANCED_STRUCTURE,
&powerio_tx::diagnostics::codes::VALIDATE_BALANCED_VALUE_DOMAIN,
&powerio_tx::diagnostics::codes::VALIDATE_BALANCED_PAYLOAD_IDENTITY,
&powerio_dist::diagnostics::codes::VALIDATE_MULTI_STRUCTURE,
&powerio_dist::diagnostics::codes::VALIDATE_MULTI_TERMINAL_MAP,
&powerio_dist::diagnostics::codes::VALIDATE_MULTI_UNTYPED_OBJECT,
&powerio_dist::diagnostics::codes::VALIDATE_MULTI_NO_VOLTAGE_SOURCE,
&codes::VALIDATE_PACKAGE_OPERATING_IDENTITY,
&codes::VALIDATE_PACKAGE_STUDY_MODEL_KIND,
&codes::VALIDATE_PACKAGE_STUDY_IDENTITY,
];
fn validate_operating_identity(
model: &ModelPayload,
series: &OperatingPointSeries,
) -> (Vec<StructuredDiagnostic>, ValidationPass) {
let diagnostics: Vec<StructuredDiagnostic> = check_series_identities(model, series)
.into_iter()
.map(|(point_pos, update_pos, message)| {
StructuredDiagnostic::of(&codes::VALIDATE_PACKAGE_OPERATING_IDENTITY, message)
.with_element_path(format!(
"/operating_points/points/{point_pos}/updates/{update_pos}"
))
})
.collect();
let status = validation_status(&diagnostics);
(
diagnostics,
ValidationPass::new("package.operating_identity", status),
)
}
fn validate_study(
model: &ModelPayload,
study: &StudyBlock,
) -> (Vec<StructuredDiagnostic>, ValidationPass) {
if !matches!(model, ModelPayload::Balanced { .. }) {
let diagnostics = vec![
StructuredDiagnostic::of(
&codes::VALIDATE_PACKAGE_STUDY_MODEL_KIND,
"study blocks are only defined for balanced packages",
)
.with_element_path("/study"),
];
return (
diagnostics,
ValidationPass::new("package.study", ValidationStatus::Error),
);
}
let diagnostics: Vec<StructuredDiagnostic> = check_study_identities(model, study)
.into_iter()
.map(|(commit_pos, edit_pos, message)| {
StructuredDiagnostic::of(&codes::VALIDATE_PACKAGE_STUDY_IDENTITY, message)
.with_element_path(format!("/study/commits/{commit_pos}/edits/{edit_pos}"))
})
.collect();
let status = validation_status(&diagnostics);
(
diagnostics,
ValidationPass::new("package.study_identity", status),
)
}
fn is_sane_validation_code(code: &str) -> bool {
SANE_VALIDATION_CODES.iter().any(|entry| entry.code == code)
}
fn validation_status(diagnostics: &[StructuredDiagnostic]) -> ValidationStatus {
diagnostics
.iter()
.map(|d| match d.severity {
DiagnosticSeverity::Debug => ValidationStatus::Ok,
DiagnosticSeverity::Info => ValidationStatus::Info,
DiagnosticSeverity::Warning => ValidationStatus::Warning,
DiagnosticSeverity::Error => ValidationStatus::Error,
DiagnosticSeverity::Fatal => ValidationStatus::Fatal,
})
.max()
.unwrap_or(ValidationStatus::Ok)
}
fn sane_validate_balanced(
net: &BalancedNetwork,
) -> (Vec<StructuredDiagnostic>, Vec<ValidationPass>) {
let mut structure = Vec::new();
if let Err(err) = net.validate() {
structure.push(StructuredDiagnostic::of(
&powerio_tx::diagnostics::codes::VALIDATE_BALANCED_STRUCTURE,
err.to_string(),
));
}
let bus_index: HashMap<usize, usize> = net
.buses()
.iter()
.enumerate()
.map(|(idx, b)| (b.id.0, idx))
.collect();
let mut value_domain = Vec::new();
for finding in net.validate_values() {
let details = finding.details();
let element = details["element"].as_str().unwrap_or_default().to_owned();
let field = details["field"].as_str().unwrap_or_default().to_owned();
let old = details["value"].as_f64().unwrap_or(f64::NAN);
let new = details["repaired_value"].clone();
let element_path = balanced_value_finding_path(net, &bus_index, &element, &field, old)
.unwrap_or_else(|| {
format!(
"/model/balanced_network/{}#{}",
element.replace(' ', "_"),
field
)
});
let mut d = StructuredDiagnostic::of(
&powerio_tx::diagnostics::codes::VALIDATE_BALANCED_VALUE_DOMAIN,
format!(
"{element} field `{field}` is outside its value domain; suggested value is {new}"
),
)
.with_element_path(element_path)
.with_suggested_action("Run the explicit repair pass if these defaults are desired.");
d.details
.insert("element".to_owned(), serde_json::json!(element));
d.details
.insert("field".to_owned(), serde_json::json!(field));
d.details.insert("old".to_owned(), details["value"].clone());
d.details.insert("new".to_owned(), new);
d.details
.insert("reason".to_owned(), details["reason"].clone());
value_domain.push(d);
}
let mut identity = Vec::new();
macro_rules! check_uids {
($table:ident) => {
table_uid_duplicates(
stringify!($table),
net.$table().iter().map(|e| e.uid.as_deref()),
&mut identity,
)
};
}
check_uids!(buses);
check_uids!(loads);
check_uids!(shunts);
check_uids!(branches);
check_uids!(switches);
check_uids!(generators);
check_uids!(storage);
check_uids!(hvdc);
check_uids!(transformers_3w);
let passes = vec![
ValidationPass::new("balanced.structure", validation_status(&structure)),
ValidationPass::new("balanced.value_domain", validation_status(&value_domain)),
ValidationPass::new("balanced.payload_identity", validation_status(&identity)),
];
structure.extend(value_domain);
structure.extend(identity);
(structure, passes)
}
fn table_uid_duplicates<'a>(
table: &str,
uids: impl Iterator<Item = Option<&'a str>>,
diagnostics: &mut Vec<StructuredDiagnostic>,
) {
let mut first_row: HashMap<&str, usize> = HashMap::new();
for (row, uid) in uids.enumerate() {
let Some(uid) = uid else { continue };
if let Some(&first) = first_row.get(uid) {
diagnostics.push(
StructuredDiagnostic::of(
&powerio_tx::diagnostics::codes::VALIDATE_BALANCED_PAYLOAD_IDENTITY,
format!(
"payload table `{table}` carries uid `{uid}` on rows {first} and {row}; \
identity resolution is ambiguous"
),
)
.with_element_path(format!("/model/balanced_network/{table}/{row}/uid")),
);
} else {
first_row.insert(uid, row);
}
}
}
fn attach_source_refs(diagnostics: &mut [StructuredDiagnostic], source_maps: &[SourceMapEntry]) {
let mut by_path: HashMap<&str, &SourceRef> = HashMap::with_capacity(source_maps.len());
for map in source_maps {
by_path
.entry(map.element_path.as_str())
.or_insert(&map.source_ref);
}
for diagnostic in diagnostics {
if diagnostic.source_ref.is_some() {
continue;
}
let Some(path) = diagnostic.element_path.as_deref() else {
continue;
};
if let Some(source_ref) = by_path.get(path) {
diagnostic.source_ref = Some((*source_ref).clone());
}
}
}
fn balanced_value_finding_path(
net: &BalancedNetwork,
bus_index: &HashMap<usize, usize>,
element: &str,
field: &str,
old: f64,
) -> Option<String> {
if let Some(id) = element
.strip_prefix("bus ")
.and_then(|s| s.parse::<usize>().ok())
{
let idx = *bus_index.get(&id)?;
return Some(format!("/model/balanced_network/buses/{idx}/{field}"));
}
if let Some(id) = element
.strip_prefix("generator at bus ")
.and_then(|s| s.parse::<usize>().ok())
{
let mut matches = net
.generators()
.iter()
.enumerate()
.filter(|(_, g)| {
g.bus.0 == id
&& generator_field(g, field).is_some_and(|v| v.to_bits() == old.to_bits())
})
.map(|(idx, _)| idx);
let idx = matches.next()?;
if matches.next().is_some() {
return None;
}
return Some(format!("/model/balanced_network/generators/{idx}/{field}"));
}
None
}
fn generator_field(generator: &crate::Generator, field: &str) -> Option<f64> {
Some(match field {
"mbase" => generator.mbase,
"vg" => generator.vg,
_ => return None,
})
}
fn sane_validate_multiconductor(
net: &MulticonductorNetwork,
) -> (Vec<StructuredDiagnostic>, Vec<ValidationPass>) {
let mut structure = Vec::new();
let mut terminal_maps = Vec::new();
let mut untyped = Vec::new();
let mut sources = Vec::new();
let (bus_ids, bus_terminals) = multiconductor_bus_index(net, &mut structure);
validate_multiconductor_lines(
net,
&bus_ids,
&bus_terminals,
&mut structure,
&mut terminal_maps,
);
validate_multiconductor_switches(
net,
&bus_ids,
&bus_terminals,
&mut structure,
&mut terminal_maps,
);
validate_multiconductor_transformers(
net,
&bus_ids,
&bus_terminals,
&mut structure,
&mut terminal_maps,
);
validate_multiconductor_injections(
net,
&bus_ids,
&bus_terminals,
&mut structure,
&mut terminal_maps,
);
for (i, obj) in net.untyped().iter().enumerate() {
untyped.push(
StructuredDiagnostic::of(
&powerio_dist::diagnostics::codes::VALIDATE_MULTI_UNTYPED_OBJECT,
format!(
"{} {} is preserved as an untyped object",
obj.class, obj.name
),
)
.with_element_path(format!("/model/multiconductor_network/untyped/{i}")),
);
}
if net.sources().is_empty() {
sources.push(StructuredDiagnostic::of(
&powerio_dist::diagnostics::codes::VALIDATE_MULTI_NO_VOLTAGE_SOURCE,
"multiconductor package has no voltage source",
));
}
let passes = vec![
ValidationPass::new("multiconductor.structure", validation_status(&structure)),
ValidationPass::new(
"multiconductor.terminal_map",
validation_status(&terminal_maps),
),
ValidationPass::new("multiconductor.untyped_object", validation_status(&untyped)),
ValidationPass::new("multiconductor.voltage_source", validation_status(&sources)),
];
let mut diagnostics = structure;
diagnostics.extend(terminal_maps);
diagnostics.extend(untyped);
diagnostics.extend(sources);
(diagnostics, passes)
}
fn validate_multiconductor_lines(
net: &MulticonductorNetwork,
bus_ids: &BTreeSet<String>,
bus_terminals: &BTreeMap<String, BTreeSet<String>>,
structure: &mut Vec<StructuredDiagnostic>,
terminal_maps: &mut Vec<StructuredDiagnostic>,
) {
for (i, line) in net.lines().iter().enumerate() {
check_bus_ref(
&line.bus_from,
&format!("line {} from bus", line.name),
&format!("/model/multiconductor_network/lines/{i}/bus_from"),
bus_ids,
structure,
);
check_bus_ref(
&line.bus_to,
&format!("line {} to bus", line.name),
&format!("/model/multiconductor_network/lines/{i}/bus_to"),
bus_ids,
structure,
);
if !net
.linecodes()
.iter()
.any(|c| c.name.eq_ignore_ascii_case(&line.linecode))
{
structure.push(
StructuredDiagnostic::of(
&powerio_dist::diagnostics::codes::VALIDATE_MULTI_STRUCTURE,
format!(
"line {} references unknown linecode `{}`",
line.name, line.linecode
),
)
.with_element_path(format!("/model/multiconductor_network/lines/{i}/linecode")),
);
}
check_terminal_map(
&line.bus_from,
&line.terminal_map_from,
&format!("line {} from terminals", line.name),
&format!("/model/multiconductor_network/lines/{i}/terminal_map_from"),
bus_terminals,
terminal_maps,
);
check_terminal_map(
&line.bus_to,
&line.terminal_map_to,
&format!("line {} to terminals", line.name),
&format!("/model/multiconductor_network/lines/{i}/terminal_map_to"),
bus_terminals,
terminal_maps,
);
}
}
fn validate_multiconductor_switches(
net: &MulticonductorNetwork,
bus_ids: &BTreeSet<String>,
bus_terminals: &BTreeMap<String, BTreeSet<String>>,
structure: &mut Vec<StructuredDiagnostic>,
terminal_maps: &mut Vec<StructuredDiagnostic>,
) {
for (i, sw) in net.switches().iter().enumerate() {
check_bus_ref(
&sw.bus_from,
&format!("switch {} from bus", sw.name),
&format!("/model/multiconductor_network/switches/{i}/bus_from"),
bus_ids,
structure,
);
check_bus_ref(
&sw.bus_to,
&format!("switch {} to bus", sw.name),
&format!("/model/multiconductor_network/switches/{i}/bus_to"),
bus_ids,
structure,
);
check_terminal_map(
&sw.bus_from,
&sw.terminal_map_from,
&format!("switch {} from terminals", sw.name),
&format!("/model/multiconductor_network/switches/{i}/terminal_map_from"),
bus_terminals,
terminal_maps,
);
check_terminal_map(
&sw.bus_to,
&sw.terminal_map_to,
&format!("switch {} to terminals", sw.name),
&format!("/model/multiconductor_network/switches/{i}/terminal_map_to"),
bus_terminals,
terminal_maps,
);
}
}
fn validate_multiconductor_transformers(
net: &MulticonductorNetwork,
bus_ids: &BTreeSet<String>,
bus_terminals: &BTreeMap<String, BTreeSet<String>>,
structure: &mut Vec<StructuredDiagnostic>,
terminal_maps: &mut Vec<StructuredDiagnostic>,
) {
for (i, tx) in net.transformers().iter().enumerate() {
for (j, winding) in tx.windings.iter().enumerate() {
check_bus_ref(
&winding.bus,
&format!("transformer {} winding {j} bus", tx.name),
&format!("/model/multiconductor_network/transformers/{i}/windings/{j}/bus"),
bus_ids,
structure,
);
check_terminal_map(
&winding.bus,
&winding.terminal_map,
&format!("transformer {} winding {j} terminals", tx.name),
&format!(
"/model/multiconductor_network/transformers/{i}/windings/{j}/terminal_map"
),
bus_terminals,
terminal_maps,
);
}
}
}
fn validate_multiconductor_injections(
net: &MulticonductorNetwork,
bus_ids: &BTreeSet<String>,
bus_terminals: &BTreeMap<String, BTreeSet<String>>,
structure: &mut Vec<StructuredDiagnostic>,
terminal_maps: &mut Vec<StructuredDiagnostic>,
) {
let mut ctx = MultiValidationContext {
bus_ids,
bus_terminals,
structure,
terminal_maps,
};
for (i, load) in net.loads().iter().enumerate() {
check_one_bus_element(
&load.bus,
&load.terminal_map,
&format!("load {}", load.name),
&format!("/model/multiconductor_network/loads/{i}"),
&mut ctx,
);
}
for (i, generator) in net.generators().iter().enumerate() {
check_one_bus_element(
&generator.bus,
&generator.terminal_map,
&format!("generator {}", generator.name),
&format!("/model/multiconductor_network/generators/{i}"),
&mut ctx,
);
}
for (i, shunt) in net.shunts().iter().enumerate() {
check_one_bus_element(
&shunt.bus,
&shunt.terminal_map,
&format!("shunt {}", shunt.name),
&format!("/model/multiconductor_network/shunts/{i}"),
&mut ctx,
);
}
for (i, capacitor) in net.capacitors().iter().enumerate() {
check_one_bus_element(
&capacitor.bus,
&capacitor.terminal_map,
&format!("capacitor {}", capacitor.name),
&format!("/model/multiconductor_network/capacitors/{i}"),
&mut ctx,
);
}
for (i, source) in net.sources().iter().enumerate() {
check_one_bus_element(
&source.bus,
&source.terminal_map,
&format!("voltage source {}", source.name),
&format!("/model/multiconductor_network/sources/{i}"),
&mut ctx,
);
}
}
struct MultiValidationContext<'a> {
bus_ids: &'a BTreeSet<String>,
bus_terminals: &'a BTreeMap<String, BTreeSet<String>>,
structure: &'a mut Vec<StructuredDiagnostic>,
terminal_maps: &'a mut Vec<StructuredDiagnostic>,
}
fn check_one_bus_element(
bus: &str,
terminal_map: &[String],
label: &str,
path: &str,
ctx: &mut MultiValidationContext<'_>,
) {
check_bus_ref(
bus,
&format!("{label} bus"),
&format!("{path}/bus"),
ctx.bus_ids,
ctx.structure,
);
check_terminal_map(
bus,
terminal_map,
&format!("{label} terminals"),
&format!("{path}/terminal_map"),
ctx.bus_terminals,
ctx.terminal_maps,
);
}
fn multiconductor_bus_index(
net: &MulticonductorNetwork,
diagnostics: &mut Vec<StructuredDiagnostic>,
) -> (BTreeSet<String>, BTreeMap<String, BTreeSet<String>>) {
let mut ids = BTreeSet::new();
let mut terminals = BTreeMap::new();
let mut first_seen = BTreeMap::<String, String>::new();
for (i, bus) in net.buses().iter().enumerate() {
let key = bus.id.to_ascii_lowercase();
if let Some(first) = first_seen.insert(key.clone(), bus.id.clone()) {
diagnostics.push(
StructuredDiagnostic::of(
&powerio_dist::diagnostics::codes::VALIDATE_MULTI_STRUCTURE,
format!("duplicate bus id `{}` conflicts with `{first}`", bus.id),
)
.with_element_path(format!("/model/multiconductor_network/buses/{i}/id")),
);
}
ids.insert(key.clone());
terminals.insert(key, bus.terminals.iter().cloned().collect());
}
(ids, terminals)
}
fn check_bus_ref(
bus: &str,
what: &str,
path: &str,
bus_ids: &BTreeSet<String>,
diagnostics: &mut Vec<StructuredDiagnostic>,
) {
if !bus_ids.contains(&bus.to_ascii_lowercase()) {
diagnostics.push(
StructuredDiagnostic::of(
&powerio_dist::diagnostics::codes::VALIDATE_MULTI_STRUCTURE,
format!("{what} references unknown bus `{bus}`"),
)
.with_element_path(path),
);
}
}
fn check_terminal_map(
bus: &str,
terminal_map: &[String],
what: &str,
path: &str,
bus_terminals: &BTreeMap<String, BTreeSet<String>>,
diagnostics: &mut Vec<StructuredDiagnostic>,
) {
if terminal_map.is_empty() {
diagnostics.push(
StructuredDiagnostic::of(
&powerio_dist::diagnostics::codes::VALIDATE_MULTI_TERMINAL_MAP,
format!("{what} has an empty terminal map"),
)
.with_element_path(path),
);
return;
}
let Some(known) = bus_terminals.get(&bus.to_ascii_lowercase()) else {
return;
};
for terminal in terminal_map {
if !known.contains(terminal) {
diagnostics.push(
StructuredDiagnostic::of(
&powerio_dist::diagnostics::codes::VALIDATE_MULTI_TERMINAL_MAP,
format!("{what} references unknown terminal `{terminal}` on bus `{bus}`"),
)
.with_element_path(path),
);
}
}
}
fn balanced_origin(net: &BalancedNetwork) -> Origin {
match net.source_format() {
SourceFormat::InMemory => Origin::InMemory,
SourceFormat::Normalized => Origin::Derived {
parent_package_id: None,
pass: "normalize-balanced".to_owned(),
options: serde_json::Map::new(),
},
SourceFormat::Gridfm | SourceFormat::PypsaCsv => Origin::Folder {
path: String::new(),
format: net.source_format().name().to_owned(),
file_hashes: BTreeMap::new(),
},
SourceFormat::PowerWorldBinary => Origin::BinaryFile {
path: String::new(),
format: net.source_format().name().to_owned(),
hash: None,
decoded_sections: Vec::new(),
},
other => Origin::File {
path: String::new(),
format: other.name().to_owned(),
hash: None,
retained_source: false,
},
}
}
fn balanced_sources(net: &BalancedNetwork) -> Vec<SourceDescriptor> {
let Some(kind) = balanced_source_kind(net.source_format()) else {
return Vec::new();
};
vec![SourceDescriptor {
id: "src0".to_owned(),
kind: kind.to_owned(),
path: None,
format: Some(net.source_format().name().to_owned()),
hash: None,
}]
}
fn balanced_source_kind(f: SourceFormat) -> Option<&'static str> {
match f {
SourceFormat::InMemory | SourceFormat::Normalized => None,
SourceFormat::Gridfm | SourceFormat::PypsaCsv => Some("folder"),
SourceFormat::PowerWorldBinary => Some("binary_file"),
_ => Some("file"),
}
}
fn balanced_summary(net: &BalancedNetwork) -> ObjectSummary {
let mut elements = BTreeMap::new();
elements.insert("buses".to_owned(), net.buses().len() as u64);
elements.insert("loads".to_owned(), net.loads().len() as u64);
elements.insert("shunts".to_owned(), net.shunts().len() as u64);
elements.insert("branches".to_owned(), net.branches().len() as u64);
elements.insert("generators".to_owned(), net.generators().len() as u64);
elements.insert("storage".to_owned(), net.storage().len() as u64);
elements.insert("hvdc".to_owned(), net.hvdc().len() as u64);
elements.insert(
"transformers_3w".to_owned(),
net.transformers_3w().len() as u64,
);
let reference_buses: Vec<String> = net
.buses()
.iter()
.filter(|b| b.kind == crate::BusType::Ref)
.map(|b| b.id.0.to_string())
.collect();
ObjectSummary {
elements,
topology: Some(ObjectTopology {
connected_components: None,
reference_buses,
}),
units: Some(ObjectUnits {
power: Some("MW/MVAr".to_owned()),
angle: Some("degrees".to_owned()),
base_mva: Some(net.base_mva()),
}),
}
}
fn balanced_source_maps(net: &BalancedNetwork, source_id: Option<&str>) -> Vec<SourceMapEntry> {
let Some(source_id) = source_id else {
return Vec::new();
};
let mut entries = Vec::new();
push_balanced_network_maps(&mut entries, source_id, net.source_format());
push_balanced_bus_maps(&mut entries, source_id, net.buses().len());
push_balanced_injection_maps(&mut entries, source_id, net);
push_balanced_branch_maps(&mut entries, source_id, net);
push_balanced_generator_maps(&mut entries, source_id, net.generators().len());
entries
}
fn push_balanced_network_maps(
entries: &mut Vec<SourceMapEntry>,
source_id: &str,
source_format: SourceFormat,
) {
push_balanced_map(
entries,
source_id,
"/model/balanced_network/base_mva",
"case",
"base_mva",
MappingKind::Exact,
);
if balanced_has_frequency_source(source_format) {
push_balanced_map(
entries,
source_id,
"/model/balanced_network/base_frequency",
"case",
"base_frequency",
MappingKind::Exact,
);
}
}
fn push_balanced_bus_maps(entries: &mut Vec<SourceMapEntry>, source_id: &str, len: usize) {
push_balanced_record_maps(
entries,
source_id,
"buses",
len,
"bus",
&[
"id", "kind", "vm", "va", "base_kv", "vmax", "vmin", "area", "zone",
],
MappingKind::Exact,
);
}
fn push_balanced_injection_maps(
entries: &mut Vec<SourceMapEntry>,
source_id: &str,
net: &BalancedNetwork,
) {
if net.source_format() == SourceFormat::Matpower {
push_matpower_injection_maps(entries, source_id, net);
} else {
push_balanced_record_maps(
entries,
source_id,
"loads",
net.loads().len(),
"load",
&["bus", "p", "q", "in_service"],
MappingKind::Exact,
);
push_balanced_record_maps(
entries,
source_id,
"shunts",
net.shunts().len(),
"shunt",
&["bus", "g", "b", "in_service"],
MappingKind::Exact,
);
}
}
fn push_balanced_branch_maps(
entries: &mut Vec<SourceMapEntry>,
source_id: &str,
net: &BalancedNetwork,
) {
for (i, branch) in net.branches().iter().enumerate() {
push_balanced_record_map(
entries,
source_id,
"branches",
i,
"branch",
&[
"from",
"to",
"r",
"x",
"b",
"rate_a",
"rate_b",
"rate_c",
"tap",
"shift",
"in_service",
"angmin",
"angmax",
],
MappingKind::Exact,
);
if branch.charging.is_some() {
for field in ["g_fr", "b_fr", "g_to", "b_to"] {
push_balanced_map(
entries,
source_id,
&format!("/model/balanced_network/branches/{i}/charging/{field}"),
"branch",
field,
MappingKind::Exact,
);
}
}
}
}
fn push_balanced_generator_maps(entries: &mut Vec<SourceMapEntry>, source_id: &str, len: usize) {
push_balanced_record_maps(
entries,
source_id,
"generators",
len,
"generator",
&[
"bus",
"pg",
"qg",
"pmax",
"pmin",
"qmax",
"qmin",
"vg",
"mbase",
"in_service",
],
MappingKind::Exact,
);
}
fn balanced_has_frequency_source(source_format: SourceFormat) -> bool {
matches!(
source_format,
SourceFormat::Psse | SourceFormat::PandapowerJson
)
}
fn push_matpower_injection_maps(
entries: &mut Vec<SourceMapEntry>,
source_id: &str,
net: &BalancedNetwork,
) {
push_balanced_record_maps(
entries,
source_id,
"loads",
net.loads().len(),
"bus",
&["bus", "p", "q", "in_service"],
MappingKind::Split,
);
push_balanced_record_maps(
entries,
source_id,
"shunts",
net.shunts().len(),
"bus",
&["bus", "g", "b", "in_service"],
MappingKind::Split,
);
}
fn push_balanced_record_maps(
entries: &mut Vec<SourceMapEntry>,
source_id: &str,
collection: &str,
len: usize,
record: &str,
fields: &[&str],
mapping_kind: MappingKind,
) {
for i in 0..len {
push_balanced_record_map(
entries,
source_id,
collection,
i,
record,
fields,
mapping_kind,
);
}
}
fn push_balanced_record_map(
entries: &mut Vec<SourceMapEntry>,
source_id: &str,
collection: &str,
i: usize,
record: &str,
fields: &[&str],
mapping_kind: MappingKind,
) {
for &field in fields {
push_balanced_map(
entries,
source_id,
&format!("/model/balanced_network/{collection}/{i}/{field}"),
record,
field,
mapping_kind,
);
}
}
fn push_balanced_map(
entries: &mut Vec<SourceMapEntry>,
source_id: &str,
element_path: &str,
record: &str,
field: &str,
mapping_kind: MappingKind,
) {
entries.push(SourceMapEntry {
element_path: element_path.to_owned(),
source_ref: SourceRef::new(source_id)
.with_record(record)
.with_field(field),
mapping_kind,
confidence: Confidence::High,
});
}
fn multiconductor_summary(net: &MulticonductorNetwork) -> ObjectSummary {
let mut elements = BTreeMap::new();
elements.insert("buses".to_owned(), net.buses().len() as u64);
elements.insert("linecodes".to_owned(), net.linecodes().len() as u64);
elements.insert("lines".to_owned(), net.lines().len() as u64);
elements.insert("switches".to_owned(), net.switches().len() as u64);
elements.insert("transformers".to_owned(), net.transformers().len() as u64);
elements.insert("loads".to_owned(), net.loads().len() as u64);
elements.insert("generators".to_owned(), net.generators().len() as u64);
elements.insert("shunts".to_owned(), net.shunts().len() as u64);
elements.insert("capacitors".to_owned(), net.capacitors().len() as u64);
elements.insert("voltage_sources".to_owned(), net.sources().len() as u64);
ObjectSummary {
elements,
topology: None,
units: Some(ObjectUnits {
power: Some("W/var".to_owned()),
angle: Some("radians".to_owned()),
base_mva: None,
}),
}
}
fn multiconductor_sources(net: &MulticonductorNetwork) -> Vec<SourceDescriptor> {
match net.source_format() {
Some(sf) => vec![SourceDescriptor {
id: "src0".to_owned(),
kind: "file".to_owned(),
path: None,
format: Some(dist_format_name(*sf).to_owned()),
hash: None,
}],
None => Vec::new(),
}
}
fn dist_format_name(f: DistSourceFormat) -> &'static str {
f.name()
}
fn multiconductor_origin(net: &MulticonductorNetwork) -> Origin {
match net.source_format() {
Some(sf) => Origin::File {
path: String::new(),
format: dist_format_name(*sf).to_owned(),
hash: None,
retained_source: false,
},
None => Origin::InMemory,
}
}
fn derived_sources(parent: &NetworkPackage) -> Vec<SourceDescriptor> {
if !parent.sources.is_empty() {
return parent.sources.clone();
}
vec![SourceDescriptor {
id: "parent".to_owned(),
kind: "package".to_owned(),
path: None,
format: Some("pio-json".to_owned()),
hash: parent.package_id.clone(),
}]
}
fn lowered_balanced_source_maps(
input: &MulticonductorNetwork,
balanced: &BalancedNetwork,
source_id: Option<&str>,
) -> Vec<SourceMapEntry> {
let Some(source_id) = source_id else {
return Vec::new();
};
let mut entries = Vec::new();
push_lowered_bus_maps(&mut entries, source_id, input);
push_lowered_branch_maps(&mut entries, source_id, input, balanced);
push_lowered_load_maps(&mut entries, source_id, input, balanced);
push_lowered_shunt_maps(&mut entries, source_id, input, balanced);
push_lowered_generator_maps(&mut entries, source_id, input, balanced);
entries
}
fn push_lowered_bus_maps(
entries: &mut Vec<SourceMapEntry>,
source_id: &str,
input: &MulticonductorNetwork,
) {
for (idx, bus) in input.buses().iter().enumerate() {
for (field, mapping_kind) in [
("id", MappingKind::Synthetic),
("kind", MappingKind::Lowered),
("vm", MappingKind::ConvertedUnits),
("va", MappingKind::ConvertedUnits),
("base_kv", MappingKind::ConvertedUnits),
("area", MappingKind::Defaulted),
("zone", MappingKind::Defaulted),
("name", MappingKind::Lowered),
] {
push_lowered_map(
entries,
source_id,
&format!("/model/balanced_network/buses/{idx}/{field}"),
"multiconductor_bus",
field,
mapping_kind,
);
}
for field in ["vmin", "vmax"] {
let mapping_kind = if bus.v_min.is_some() && bus.v_max.is_some() {
MappingKind::ConvertedUnits
} else {
MappingKind::Defaulted
};
push_lowered_map(
entries,
source_id,
&format!("/model/balanced_network/buses/{idx}/{field}"),
"multiconductor_bus",
field,
mapping_kind,
);
}
}
}
fn push_lowered_branch_maps(
entries: &mut Vec<SourceMapEntry>,
source_id: &str,
input: &MulticonductorNetwork,
balanced: &BalancedNetwork,
) {
for (idx, branch) in balanced.branches().iter().enumerate() {
let record = "multiconductor_line";
for (field, mapping_kind) in [
("from", MappingKind::Lowered),
("to", MappingKind::Lowered),
("r", MappingKind::ConvertedUnits),
("x", MappingKind::ConvertedUnits),
("b", MappingKind::ConvertedUnits),
("in_service", MappingKind::Lowered),
("tap", MappingKind::Defaulted),
("shift", MappingKind::Defaulted),
("angmin", MappingKind::Defaulted),
("angmax", MappingKind::Defaulted),
] {
push_lowered_map(
entries,
source_id,
&format!("/model/balanced_network/branches/{idx}/{field}"),
record,
field,
mapping_kind,
);
}
let has_rating = input
.lines()
.get(idx)
.and_then(|line| input.linecode(&line.linecode))
.is_some_and(|code| code.i_max.is_some() || code.s_max.is_some());
let rate_kind = if has_rating {
MappingKind::ConvertedUnits
} else {
MappingKind::Defaulted
};
for field in ["rate_a", "rate_b", "rate_c"] {
push_lowered_map(
entries,
source_id,
&format!("/model/balanced_network/branches/{idx}/{field}"),
record,
field,
rate_kind,
);
}
if branch.charging.is_some() {
for field in ["g_fr", "b_fr", "g_to", "b_to"] {
push_lowered_map(
entries,
source_id,
&format!("/model/balanced_network/branches/{idx}/charging/{field}"),
record,
field,
MappingKind::ConvertedUnits,
);
}
}
}
}
fn push_lowered_load_maps(
entries: &mut Vec<SourceMapEntry>,
source_id: &str,
input: &MulticonductorNetwork,
balanced: &BalancedNetwork,
) {
for idx in 0..balanced.loads().len().min(input.loads().len()) {
for (field, mapping_kind) in [
("bus", MappingKind::Lowered),
("p", MappingKind::Aggregated),
("q", MappingKind::Aggregated),
("in_service", MappingKind::Lowered),
] {
push_lowered_map(
entries,
source_id,
&format!("/model/balanced_network/loads/{idx}/{field}"),
"multiconductor_load",
field,
mapping_kind,
);
}
}
}
fn push_lowered_shunt_maps(
entries: &mut Vec<SourceMapEntry>,
source_id: &str,
input: &MulticonductorNetwork,
balanced: &BalancedNetwork,
) {
for idx in 0..balanced.shunts().len().min(input.shunts().len()) {
for (field, mapping_kind) in [
("bus", MappingKind::Lowered),
("g", MappingKind::Aggregated),
("b", MappingKind::Aggregated),
("in_service", MappingKind::Lowered),
] {
push_lowered_map(
entries,
source_id,
&format!("/model/balanced_network/shunts/{idx}/{field}"),
"multiconductor_shunt",
field,
mapping_kind,
);
}
}
}
fn push_lowered_generator_maps(
entries: &mut Vec<SourceMapEntry>,
source_id: &str,
input: &MulticonductorNetwork,
balanced: &BalancedNetwork,
) {
for idx in 0..balanced.generators().len().min(input.generators().len()) {
let generator = &input.generators()[idx];
for (field, mapping_kind) in [
("bus", MappingKind::Lowered),
("pg", MappingKind::Aggregated),
("qg", MappingKind::Aggregated),
("vg", MappingKind::Defaulted),
("mbase", MappingKind::Synthetic),
("in_service", MappingKind::Lowered),
] {
push_lowered_map(
entries,
source_id,
&format!("/model/balanced_network/generators/{idx}/{field}"),
"multiconductor_generator",
field,
mapping_kind,
);
}
for (field, present) in [
("pmin", generator.p_min.is_some()),
("pmax", generator.p_max.is_some()),
("qmin", generator.q_min.is_some()),
("qmax", generator.q_max.is_some()),
] {
push_lowered_map(
entries,
source_id,
&format!("/model/balanced_network/generators/{idx}/{field}"),
"multiconductor_generator",
field,
if present {
MappingKind::Aggregated
} else {
MappingKind::Defaulted
},
);
}
}
}
fn push_lowered_map(
entries: &mut Vec<SourceMapEntry>,
source_id: &str,
element_path: &str,
record: &str,
field: &str,
mapping_kind: MappingKind,
) {
entries.push(SourceMapEntry {
element_path: element_path.to_owned(),
source_ref: SourceRef::new(source_id)
.with_record(record)
.with_field(field),
mapping_kind,
confidence: Confidence::High,
});
}
fn multiconductor_source_maps(
net: &MulticonductorNetwork,
source_id: Option<&str>,
) -> Vec<SourceMapEntry> {
let Some(source_id) = source_id else {
return Vec::new();
};
let mut entries = Vec::new();
for (element, fields) in net.defaulted() {
for field in fields {
entries.push(SourceMapEntry {
element_path: format!("/model/multiconductor_network/{element}#{field}"),
source_ref: SourceRef::new(source_id).with_field((*field).to_owned()),
mapping_kind: MappingKind::Defaulted,
confidence: Confidence::High,
});
}
}
entries
}
fn bounded_legacy_sources<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Vec<SourceDescriptor>, D::Error> {
powerio_core::limits::bounded_vec(
deserializer,
"sources",
powerio_core::limits::MAX_MODULE_SOURCES,
)
}
fn bounded_legacy_source_maps<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Vec<SourceMapEntry>, D::Error> {
powerio_core::limits::bounded_vec(
deserializer,
"source map entries",
powerio_core::limits::MAX_MODULE_SOURCE_MAP_ENTRIES,
)
}
fn bounded_legacy_diagnostics<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Vec<StructuredDiagnostic>, D::Error> {
powerio_core::limits::bounded_vec(
deserializer,
"diagnostics",
powerio_core::limits::MAX_MODULE_DIAGNOSTICS,
)
}
fn bounded_legacy_history<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Vec<LoweringRecord>, D::Error> {
powerio_core::limits::bounded_vec(
deserializer,
"lowering history entries",
powerio_core::limits::MAX_MODULE_HISTORY_ENTRIES,
)
}
#[cfg(test)]
mod tests {
#[test]
fn a_stored_version_never_bloats_the_rejection() {
let net = crate::BalancedNetwork::in_memory("demo", 100.0, vec![], vec![]);
let mut pkg = super::NetworkPackage::from_balanced(net);
pkg.powerio_version = "9".repeat(1 << 20);
let text = pkg.to_json().unwrap();
let error = super::NetworkPackage::from_json(&text).unwrap_err();
let rendered = error.to_string();
assert!(
rendered.len() < crate::version::MAX_REJECTED_VERSION_BYTES + 200,
"{} bytes",
rendered.len()
);
assert!(!rendered.contains('\n'), "{rendered}");
}
#[test]
fn a_package_states_the_powerio_version_that_wrote_it() {
let net = crate::BalancedNetwork::in_memory("demo", 100.0, vec![], vec![]);
let pkg = super::NetworkPackage::from_balanced(net);
assert_eq!(pkg.powerio_version, "0.9.0");
let text = pkg.to_json().unwrap();
assert!(text.contains("\"powerio_version\":\"0.9.0\""), "{text}");
assert!(
!text.contains("schema_version"),
"the per document schema number is gone: {text}"
);
}
#[test]
fn a_package_from_an_older_lineage_is_refused_by_name() {
let net = crate::BalancedNetwork::in_memory("demo", 100.0, vec![], vec![]);
let text = super::NetworkPackage::from_balanced(net)
.to_json()
.unwrap()
.replacen("\"powerio_version\"", "\"schema_version\"", 1);
let err = super::NetworkPackage::from_json(&text)
.unwrap_err()
.to_string();
assert!(err.contains("before powerio 0.9.0"), "{err}");
assert!(!err.contains("regenerate"), "{err}");
assert!(err.contains("this build reads"), "{err}");
}
#[test]
fn a_package_from_a_future_lineage_is_refused_with_both_versions() {
let net = crate::BalancedNetwork::in_memory("demo", 100.0, vec![], vec![]);
let mut pkg = super::NetworkPackage::from_balanced(net);
pkg.powerio_version = "9.9.9".to_owned();
let text = pkg.to_json().unwrap();
let err = super::NetworkPackage::from_json(&text)
.unwrap_err()
.to_string();
assert!(err.contains("9.9.9"), "{err}");
assert!(err.contains("0.9.x"), "{err}");
assert!(!err.contains("regenerate"), "{err}");
}
#[test]
fn package_shaped_rejection_names_the_format() {
let err = super::NetworkPackage::from_json(
r#"{"model_kind":"balanced","model":{"kind":"balanced"}}"#,
)
.unwrap_err();
assert!(err.to_string().contains(".pio.json"), "got: {err}");
}
}