use std::collections::BTreeMap;
use powerio_core::{
Diagnostic, HistoryEntry, HistoryId, HistoryKind, PioModule, Producer, TimePoint,
};
use crate::codes;
use crate::stored::legacy09::{ModelPayload, NetworkPackage, OperatingPointSeries};
use crate::value::PioValue;
type Result<T> = std::result::Result<T, powerio_core::Error>;
fn refused(code: &'static powerio_core::DiagnosticInfo, message: String) -> powerio_core::Error {
powerio_core::Error::new(code, message)
}
pub(super) fn upgrade_legacy(
text: &str,
powerio_version: Option<&str>,
) -> Result<PioModule<PioValue>> {
let identified_09 = powerio_version.is_some_and(version_is_09);
if !identified_09 {
return Err(refused(
&codes::READ_MODULE_UNSUPPORTED,
format!(
"not a stored powerio module: no `schema` header and `powerio_version` {} does \
not identify a released 0.9 package; the pre 0.9 lineage must be regenerated",
powerio_version.map_or_else(|| "<absent>".to_string(), |v| format!("`{v}`"))
),
));
}
let legacy = NetworkPackage::from_json(text)
.map_err(|error| refused(&codes::READ_MODULE_INVALID, error.to_string()))?;
if legacy.study.as_ref().is_some_and(|study| !study.is_empty()) {
return Err(refused(
&codes::READ_MODULE_LEGACY_STUDY,
"this 0.9 package carries a nonempty `study` block, whose unapplied cumulative \
commits and selectable base state cannot be upgraded without choosing a revision; \
materialize the selected commit on the 0.9 surface \
(`NetworkPackage::materialize_study_commit`, or \
`pio_package_materialize_study_commit` over the 0.9 C ABI) and \
upgrade that static package"
.to_string(),
));
}
let value = upgrade_value(&legacy)?;
crate::stored::convert::validate_decoded_networks(&value)
.map_err(|error| refused(&codes::READ_MODULE_INVALID, error.to_string()))?;
let producer = Producer::new(
legacy.producer.tool.clone(),
legacy.producer.version.clone(),
)
.unwrap_or_else(|_| {
Producer::new("powerio", crate::VERSION).expect("static producer is valid")
});
let legacy_kind = match &legacy.model {
ModelPayload::Balanced { .. } => "balanced_network",
ModelPayload::Multiconductor { .. } => "multiconductor_network",
};
let rebase = match &value {
PioValue::BalancedOperatingPointTimeSeries(_) => "/network",
_ => "",
};
let mut module = PioModule::new(value).with_producer(producer);
for diagnostic in &legacy.diagnostics {
module
.add_diagnostic(upgrade_diagnostic(diagnostic, legacy_kind, rebase))
.map_err(|error| refused(&codes::READ_MODULE_INVALID, error.to_string()))?;
}
let upgrade_note = HistoryEntry::new(
HistoryId::new("upgrade-0.9").expect("static id is valid"),
HistoryKind::Upgrade,
"upgrade_network_package_0_9",
)
.expect("static name is valid")
.with_assumption(
"legacy summaries, validation counts, origin, and derived metadata were \
nonauthoritative and were recomputed or dropped",
)
.expect("static assumption is valid");
module
.add_history_entry(upgrade_note)
.map_err(|error| refused(&codes::READ_MODULE_INVALID, error.to_string()))?;
module
.add_diagnostic(Diagnostic::of(
&codes::READ_MODULE_UPGRADED,
format!(
"upgraded a powerio {} package: legacy summaries, validation counts, and derived \
metadata are nonauthoritative and were not carried",
legacy.powerio_version
),
))
.map_err(|error| refused(&codes::READ_MODULE_INVALID, error.to_string()))?;
Ok(module)
}
fn upgrade_value(legacy: &NetworkPackage) -> Result<PioValue> {
let series = legacy
.operating_points
.clone()
.filter(|series| !series.points.is_empty());
match (&legacy.model, series) {
(ModelPayload::Balanced { balanced_network }, Some(series)) => {
upgrade_series(balanced_network, &series)
}
(ModelPayload::Balanced { balanced_network }, None) => {
Ok(PioValue::BalancedNetwork((**balanced_network).clone()))
}
(
ModelPayload::Multiconductor {
multiconductor_network,
},
Some(_),
) => Err(refused(
&codes::READ_MODULE_INVALID,
format!(
"a 0.9 multiconductor package with operating points has no upgrade; the \
0.9 shape never produced one ({} buses)",
multiconductor_network.buses().len()
),
)),
(
ModelPayload::Multiconductor {
multiconductor_network,
},
None,
) => Ok(PioValue::MulticonductorNetwork(
(**multiconductor_network).clone(),
)),
}
}
fn version_is_09(version: &str) -> bool {
version
.split('.')
.take(2)
.collect::<Vec<_>>()
.as_slice()
.first()
.zip(version.split('.').nth(1))
.is_some_and(|(major, minor)| *major == "0" && minor == "9")
}
const MAX_UPGRADE_PERIODS: usize = 131_072;
fn upgrade_time_points(series: &OperatingPointSeries, periods: usize) -> Result<Vec<TimePoint>> {
let mut time_points = Vec::new();
time_points.try_reserve(periods).map_err(|_| {
refused(
&codes::READ_MODULE_INVALID,
format!("cannot reserve {periods} upgraded time points"),
)
})?;
for index in 0..periods {
let label = series
.time_axis
.labels
.get(index)
.cloned()
.unwrap_or_else(|| format!("period {index}"));
let duration = match series.time_axis.duration_hours.get(index) {
None => None,
Some(hours) => {
let seconds = hours * 3600.0;
Some(
std::time::Duration::try_from_secs_f64(seconds).map_err(|_| {
refused(
&codes::READ_MODULE_INVALID,
format!(
"period {index} states {hours} hours, which is not a finite \
nonnegative duration"
),
)
})?,
)
}
};
time_points.push(
TimePoint::new(label, duration)
.map_err(|error| refused(&codes::READ_MODULE_INVALID, error.to_string()))?,
);
}
Ok(time_points)
}
fn upgrade_series(
network: &crate::BalancedNetwork,
series: &OperatingPointSeries,
) -> Result<PioValue> {
let periods = series
.points
.len()
.max(series.time_axis.labels.len())
.max(series.time_axis.duration_hours.len());
if periods > MAX_UPGRADE_PERIODS {
return Err(refused(
&codes::READ_MODULE_INVALID,
format!(
"the time axis carries {periods} periods across its labels, durations, and \
operating points; at most {MAX_UPGRADE_PERIODS} periods upgrade"
),
));
}
let time_points = upgrade_time_points(series, periods)?;
let mut overrides: BTreeMap<&'static str, Vec<Vec<(String, f64)>>> = BTreeMap::new();
for point in &series.points {
if point.index >= periods {
return Err(refused(
&codes::READ_MODULE_INVALID,
format!(
"operating point index {} is outside the {periods} period time axis",
point.index
),
));
}
for update in &point.updates {
let identity = legacy_identity(network, &update.element);
for (field, value) in &update.fields {
let Some(quantity) = legacy_quantity(&update.element.table, field) else {
return Err(refused(
&codes::READ_MODULE_LEGACY_FIELD,
format!(
"operating point update field `{}.{field}` has no state quantity; \
this 0.9 series cannot upgrade losslessly",
update.element.table
),
));
};
let value = value.as_f64().or_else(|| match value {
serde_json::Value::Bool(flag) => Some(f64::from(u8::from(*flag))),
_ => None,
});
let Some(value) = value.map(|value| {
if quantity == "bus_voltage_angle" {
value.to_radians()
} else {
value
}
}) else {
return Err(refused(
&codes::READ_MODULE_INVALID,
format!(
"operating point update `{}.{field}` is not numeric",
update.element.table
),
));
};
let column = match overrides.entry(quantity) {
std::collections::btree_map::Entry::Occupied(entry) => entry.into_mut(),
std::collections::btree_map::Entry::Vacant(entry) => {
let mut columns = Vec::new();
columns.try_reserve(periods).map_err(|_| {
refused(
&codes::READ_MODULE_INVALID,
format!("cannot reserve {periods} override slots for `{quantity}`"),
)
})?;
columns.resize_with(periods, Vec::new);
entry.insert(columns)
}
};
column[point.index].push((identity.clone(), value));
}
}
}
let mut builder = powerio_prob::BalancedStateBuilder::new(network.clone(), time_points);
for (quantity, changes) in overrides {
let base = base_row(network, quantity)?;
builder = builder
.sparse_by_name(quantity, base, changes)
.map_err(|error| refused(&codes::READ_MODULE_INVALID, error.to_string()))?;
}
Ok(PioValue::BalancedOperatingPointTimeSeries(
builder
.build()
.map_err(|error| refused(&codes::READ_MODULE_INVALID, error.to_string()))?,
))
}
fn legacy_identity(
network: &crate::BalancedNetwork,
element: &crate::stored::legacy09::ElementRef,
) -> String {
if element.table == "buses" {
let by_row = element
.row
.and_then(|row| network.buses().get(row))
.map(|bus| bus.id.0.to_string());
let by_uid = element.source_uid.as_deref().and_then(|uid| {
network
.buses()
.iter()
.find(|bus| bus.uid.as_deref() == Some(uid))
.map(|bus| bus.id.0.to_string())
});
return by_row.or(by_uid).unwrap_or_default();
}
element
.source_uid
.clone()
.or_else(|| element.row.map(|row| format!("{}:{row}", element.table)))
.unwrap_or_default()
}
fn legacy_quantity(table: &str, field: &str) -> Option<&'static str> {
Some(match (table, field) {
("loads", "p") => "load_active_power",
("loads", "q") => "load_reactive_power",
("generators", "pg") => "generator_active_power",
("generators", "qg") => "generator_reactive_power",
("generators", "vg") => "generator_voltage_setpoint",
("generators", "in_service") => "generator_in_service",
("buses", "vm") => "bus_voltage_magnitude",
("buses", "va") => "bus_voltage_angle",
("branches", "in_service") => "branch_in_service",
("branches", "tap") => "branch_tap_ratio",
("branches", "shift") => "branch_phase_shift",
_ => return None,
})
}
fn base_row(network: &crate::BalancedNetwork, quantity: &str) -> Result<Vec<f64>> {
let row = match quantity {
"load_active_power" => network.loads().iter().map(|l| l.p).collect(),
"load_reactive_power" => network.loads().iter().map(|l| l.q).collect(),
"generator_active_power" => network.generators().iter().map(|g| g.pg).collect(),
"generator_reactive_power" => network.generators().iter().map(|g| g.qg).collect(),
"generator_voltage_setpoint" => network.generators().iter().map(|g| g.vg).collect(),
"generator_in_service" => network
.generators()
.iter()
.map(|g| f64::from(u8::from(g.in_service)))
.collect(),
"bus_voltage_magnitude" => network.buses().iter().map(|b| b.vm).collect(),
"bus_voltage_angle" => network.buses().iter().map(|b| b.va.to_radians()).collect(),
"branch_in_service" => network
.branches()
.iter()
.map(|b| f64::from(u8::from(b.in_service)))
.collect(),
"branch_tap_ratio" => network.branches().iter().map(|b| b.tap).collect(),
"branch_phase_shift" => network.branches().iter().map(|b| b.shift).collect(),
other => {
return Err(refused(
&codes::READ_MODULE_INVALID,
format!("no base row for quantity `{other}`"),
));
}
};
Ok(row)
}
fn upgrade_diagnostic(
legacy: &crate::stored::legacy09::StructuredDiagnostic,
kind: &str,
rebase: &str,
) -> Diagnostic {
let target = crate::stored::legacy09::diagnostics::translate_legacy_target(
legacy.element_path.as_deref(),
kind,
)
.map(|rest| format!("{rebase}{rest}"));
crate::stored::legacy09::diagnostics::to_module_diagnostic(legacy, target)
}
#[cfg(test)]
mod upgrade_tests {
use std::collections::BTreeMap;
use crate::stored::{read_module, write_module};
use crate::value::PioValue;
use powerio_core::HistoryKind;
use powerio_tx::{BalancedNetwork, Bus, BusId, BusType, Load};
fn small_network() -> BalancedNetwork {
let mut bus1 = Bus::new(BusId(1), BusType::Ref, 345.0);
bus1.vm = 1.02;
let mut bus2 = Bus::new(BusId(2), BusType::Pq, 345.0);
bus2.vmax = f64::INFINITY;
bus2.va = 12.0;
let mut network = BalancedNetwork::in_memory(
"stored-roundtrip",
100.0,
vec![bus1, bus2],
vec![powerio_tx::Branch::new(BusId(1), BusId(2), 0.01, 0.1)],
);
network.loads_mut().push(Load::new(BusId(2), 40.0, 10.0));
network
}
fn legacy_package_text(with_series: bool, with_study: bool) -> String {
use crate::stored::legacy09::{
ElementRef, ElementUpdate, NetworkPackage, OperatingPoint, OperatingPointSeries,
TimeAxis,
};
let mut package = NetworkPackage::from_balanced(small_network());
if with_series {
let mut update_fields = BTreeMap::new();
update_fields.insert("p".to_string(), serde_json::json!(75.0));
let mut axis = TimeAxis::new(2);
axis.duration_hours = vec![1.0, 1.0];
axis.labels = vec!["h0".into(), "h1".into()];
let mut angle_fields = BTreeMap::new();
angle_fields.insert("va".to_string(), serde_json::json!(30.0));
let mut second = OperatingPoint::new(1);
second.updates = vec![
ElementUpdate::new(ElementRef::new("loads", 0), update_fields),
ElementUpdate::new(ElementRef::new("buses", 1), angle_fields),
];
package.operating_points = Some(OperatingPointSeries::new(
axis,
vec![OperatingPoint::new(0), second],
));
}
let mut text = package.to_json().unwrap();
if with_study {
let mut raw: serde_json::Value = serde_json::from_str(&text).unwrap();
raw["study"] = serde_json::json!({
"label": "scenario a",
"commits": [{"id": "c1"}]
});
text = raw.to_string();
}
text
}
#[test]
fn a_released_09_package_upgrades_one_way() {
let text = legacy_package_text(false, false);
let module = read_module(&text).unwrap();
assert!(matches!(module.value(), PioValue::BalancedNetwork(_)));
assert!(
module
.diagnostics()
.iter()
.any(|d| d.code() == "READ.MODULE.UPGRADED"),
"{:?}",
module.diagnostics()
);
assert!(
module
.history()
.iter()
.any(|entry| entry.kind() == HistoryKind::Upgrade)
);
}
#[test]
fn a_malformed_legacy_diagnostic_code_upgrades_to_the_fallback_instead_of_panicking() {
for bad_code in ["bad", "", "TWO.SEGMENTS", "READ..EMPTY", "1LEADING.DIGIT.X"] {
let mut raw: serde_json::Value =
serde_json::from_str(&legacy_package_text(false, false)).unwrap();
raw["diagnostics"] = serde_json::json!([
{"code": bad_code, "severity": "warning", "message": "malformed code"}
]);
let module = read_module(&raw.to_string())
.unwrap_or_else(|e| panic!("code {bad_code:?} refused the whole document: {e}"));
assert!(
module
.diagnostics()
.iter()
.any(|d| d.code() == "PARTNER.LEGACY.UNCODED"),
"code {bad_code:?}: {:?}",
module.diagnostics()
);
}
}
#[test]
fn legacy_operating_points_become_the_primary_series_value() {
let text = legacy_package_text(true, false);
let module = read_module(&text).unwrap();
let PioValue::BalancedOperatingPointTimeSeries(series) = module.value() else {
panic!("expected the series value, got {:?}", module.value().kind());
};
assert_eq!(series.len(), 2);
assert_eq!(series.values()[0].load_active_power("loads:0"), Some(40.0));
assert_eq!(series.values()[1].load_active_power("loads:0"), Some(75.0));
let base = series.values()[0].bus_voltage_angle(BusId(2)).unwrap();
assert!((base - 12.0_f64.to_radians()).abs() < 1e-12, "{base}");
let updated = series.values()[1].bus_voltage_angle(BusId(2)).unwrap();
assert!((updated - 30.0_f64.to_radians()).abs() < 1e-12, "{updated}");
}
#[test]
fn a_nonempty_legacy_study_is_refused_with_direction() {
let text = legacy_package_text(false, true);
let error = read_module(&text).unwrap_err().to_string();
assert!(error.contains("materialize"), "{error}");
}
#[test]
#[ignore = "fixture generator"]
fn generate_frozen_fixtures() {
let base = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/package");
std::fs::write(
format!("{base}/frozen-0.9-series.pio.json"),
legacy_package_text(true, false),
)
.unwrap();
std::fs::write(
format!("{base}/frozen-0.9-balanced.pio.json"),
legacy_package_text(false, false),
)
.unwrap();
let mut network = powerio_dist::MulticonductorNetwork::named("frozen-mc");
network.buses_mut().push(powerio_dist::DistBus::new(
"b1",
vec!["1".into(), "2".into()],
));
let package = crate::stored::legacy09::NetworkPackage::from_multiconductor(network);
std::fs::write(
format!("{base}/frozen-0.9-multiconductor.pio.json"),
package.to_json().unwrap(),
)
.unwrap();
}
#[test]
fn legacy_diagnostic_targets_translate_and_write() {
let mut raw: serde_json::Value =
serde_json::from_str(&legacy_package_text(true, false)).unwrap();
raw["diagnostics"] = serde_json::json!([{
"code": "VALIDATE.BALANCED.VALUE_DOMAIN",
"severity": "warning",
"message": "bus 2 voltage magnitude is outside its domain",
"element_path": "/model/balanced_network/buses/1/vm"
}]);
let module = read_module(&raw.to_string()).unwrap();
let upgraded = module
.diagnostics()
.iter()
.find(|d| d.code() == "VALIDATE.BALANCED.VALUE_DOMAIN")
.expect("the legacy finding survives");
assert_eq!(upgraded.target(), Some("/network/buses/1/vm"));
write_module(&module).unwrap();
}
}
#[cfg(test)]
mod limit_upgrade_tests {
use crate::stored::read_module;
use crate::value::PioValue;
use powerio_tx::{BalancedNetwork, Bus, BusId, BusType, Generator, Load};
fn small_network() -> BalancedNetwork {
let mut network = BalancedNetwork::in_memory(
"stored-roundtrip",
100.0,
vec![
Bus::new(BusId(1), BusType::Ref, 345.0),
Bus::new(BusId(2), BusType::Pq, 345.0),
],
vec![powerio_tx::Branch::new(BusId(1), BusId(2), 0.01, 0.1)],
);
network.loads_mut().push(Load::new(BusId(2), 40.0, 10.0));
network.generators_mut().push(Generator::new(BusId(1)));
network
}
fn legacy_series_text() -> serde_json::Value {
use std::collections::BTreeMap;
use crate::stored::legacy09::{
ElementRef, ElementUpdate, NetworkPackage, OperatingPoint, OperatingPointSeries,
TimeAxis,
};
let mut package = NetworkPackage::from_balanced(small_network());
let mut update_fields = BTreeMap::new();
update_fields.insert("p".to_string(), serde_json::json!(75.0));
let mut axis = TimeAxis::new(2);
axis.duration_hours = vec![1.0, 1.0];
axis.labels = vec!["h0".into(), "h1".into()];
let mut second = OperatingPoint::new(1);
second.updates = vec![ElementUpdate::new(
ElementRef::new("loads", 0),
update_fields,
)];
package.operating_points = Some(OperatingPointSeries::new(
axis,
vec![OperatingPoint::new(0), second],
));
serde_json::from_str(&package.to_json().unwrap()).unwrap()
}
#[test]
fn a_declared_period_count_no_longer_sizes_the_series() {
for declared in [3_usize, 10_000_000] {
let mut raw = legacy_series_text();
raw["operating_points"]["time_axis"]["periods"] = serde_json::json!(declared);
let module = read_module(&raw.to_string()).unwrap();
let PioValue::BalancedOperatingPointTimeSeries(series) = module.value() else {
panic!("wrong kind");
};
assert_eq!(series.len(), 2, "declared {declared}");
assert_eq!(series.values()[1].load_active_power("loads:0"), Some(75.0));
}
let mut raw = legacy_series_text();
let labels: Vec<String> = (0..=131_072).map(|i| format!("h{i}")).collect();
raw["operating_points"]["time_axis"]["labels"] = serde_json::json!(labels);
let error = read_module(&raw.to_string()).unwrap_err();
assert_eq!(
error.info().map(|info| info.code),
Some("READ.MODULE.INVALID")
);
assert!(error.to_string().contains("131072"), "{error}");
}
#[test]
fn a_stated_duration_outside_the_finite_range_is_refused() {
for hours in ["1.0e300", "1.7e308"] {
let mut raw = legacy_series_text();
raw["operating_points"]["time_axis"]["duration_hours"] =
serde_json::from_str(&format!("[{hours}, 1.0]")).unwrap();
let error = read_module(&raw.to_string()).unwrap_err();
assert_eq!(
error.info().map(|info| info.code),
Some("READ.MODULE.INVALID"),
"hours {hours}"
);
assert!(error.to_string().contains("period 0"), "{error}");
}
let module = read_module(&legacy_series_text().to_string()).unwrap();
let PioValue::BalancedOperatingPointTimeSeries(series) = module.value() else {
panic!("wrong kind");
};
assert_eq!(
series.time_points()[0].duration(),
Some(std::time::Duration::from_secs(3600))
);
}
}