#![expect(
rustdoc::broken_intra_doc_links,
reason = "the frozen 0.9 schema records these doc strings byte for byte"
)]
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::geo::{GeoMeta, Location};
use crate::{Error, Result};
pub type Extras = BTreeMap<String, Value>;
pub const DEFAULT_BASE_FREQUENCY: f64 = 60.0;
fn default_base_frequency() -> f64 {
DEFAULT_BASE_FREQUENCY
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(transparent)]
pub struct BusId(pub usize);
impl BusId {
pub const MAX: Self = Self(i64::MAX as usize);
#[must_use]
pub const fn new(id: usize) -> Self {
Self(id)
}
}
impl std::fmt::Display for BusId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "UPPERCASE")]
#[repr(u8)]
#[non_exhaustive]
pub enum BusType {
Pq = 1,
Pv = 2,
Ref = 3,
Isolated = 4,
}
impl BusType {
pub(crate) fn from_f64(v: f64) -> Self {
match v as i32 {
2 => Self::Pv,
3 => Self::Ref,
4 => Self::Isolated,
_ => Self::Pq,
}
}
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Pq => "PQ",
Self::Pv => "PV",
Self::Ref => "REF",
Self::Isolated => "ISOLATED",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct GenCost {
pub model: u8,
pub startup: f64,
pub shutdown: f64,
pub ncost: usize,
pub coeffs: Vec<f64>,
}
impl GenCost {
#[must_use]
pub fn new(model: u8, startup: f64, shutdown: f64, coeffs: Vec<f64>) -> Self {
let ncost = if model == 1 {
coeffs.len() / 2
} else {
coeffs.len()
};
Self {
model,
startup,
shutdown,
ncost,
coeffs,
}
}
#[must_use]
pub fn with_ncost(
model: u8,
startup: f64,
shutdown: f64,
ncost: usize,
coeffs: Vec<f64>,
) -> Self {
Self {
model,
startup,
shutdown,
ncost,
coeffs,
}
}
pub fn quadratic(&self) -> Option<(f64, f64)> {
self.quadratic_with_constant().map(|(q, c, _)| (q, c))
}
pub fn quadratic_with_constant(&self) -> Option<(f64, f64, f64)> {
if self.model != 2 {
return None;
}
if self.coeffs.len() < self.ncost {
return None;
}
match self.ncost {
3 => Some((2.0 * self.coeffs[0], self.coeffs[1], self.coeffs[2])),
2 => Some((0.0, self.coeffs[0], self.coeffs[1])),
1 => Some((0.0, 0.0, self.coeffs[0])),
_ => None,
}
}
pub const LEADING_COEFF_TOL: f64 = 1e-12;
pub fn quadratic_with_constant_tol(&self, tol: f64) -> Option<(f64, f64, f64)> {
if self.model != 2 {
return None;
}
if self.coeffs.len() < self.ncost {
return None;
}
let row = &self.coeffs[..self.ncost];
let mut first = 0;
while first + 1 < row.len() && row[first].abs() <= tol {
first += 1;
}
match row.len() - first {
3 => Some((2.0 * row[first], row[first + 1], row[first + 2])),
2 => Some((0.0, row[first], row[first + 1])),
1 => Some((0.0, 0.0, row[first])),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub enum SourceFormat {
#[serde(rename = "matpower", alias = "Matpower")]
Matpower,
#[serde(rename = "powermodels-json", alias = "PowerModelsJson")]
PowerModelsJson,
#[serde(rename = "egret-json", alias = "EgretJson")]
EgretJson,
#[serde(rename = "psse", alias = "Psse")]
Psse,
#[serde(rename = "powerworld", alias = "PowerWorld")]
PowerWorld,
#[serde(rename = "pandapower-json", alias = "PandapowerJson")]
PandapowerJson,
#[serde(rename = "pslf", alias = "Pslf")]
Pslf,
#[serde(rename = "powerworld-pwb", alias = "PowerWorldBinary")]
PowerWorldBinary,
#[serde(rename = "in-memory", alias = "InMemory")]
InMemory,
#[serde(rename = "normalized", alias = "Normalized")]
Normalized,
#[serde(rename = "gridfm", alias = "Gridfm")]
Gridfm,
#[serde(rename = "pypsa-csv", alias = "PypsaCsv")]
PypsaCsv,
#[serde(rename = "goc3-json", alias = "Goc3Json")]
Goc3Json,
#[serde(rename = "surge-json", alias = "SurgeJson")]
SurgeJson,
#[serde(rename = "opfdata-json", alias = "DeepMindOpfDataJson")]
DeepMindOpfDataJson,
}
impl SourceFormat {
#[must_use]
pub fn name(self) -> &'static str {
match self {
SourceFormat::Matpower => "matpower",
SourceFormat::PowerModelsJson => "powermodels-json",
SourceFormat::EgretJson => "egret-json",
SourceFormat::Psse => "psse",
SourceFormat::PowerWorld => "powerworld",
SourceFormat::PandapowerJson => "pandapower-json",
SourceFormat::Pslf => "pslf",
SourceFormat::PowerWorldBinary => "powerworld-pwb",
SourceFormat::InMemory => "in-memory",
SourceFormat::Normalized => "normalized",
SourceFormat::Gridfm => "gridfm",
SourceFormat::PypsaCsv => "pypsa-csv",
SourceFormat::Goc3Json => "goc3-json",
SourceFormat::SurgeJson => "surge-json",
SourceFormat::DeepMindOpfDataJson => "opfdata-json",
}
}
}
#[derive(Debug, Clone)]
pub struct BalancedNetwork {
tables: std::sync::Arc<BalancedNetworkTables>,
}
impl BalancedNetwork {
pub(crate) fn from_tables(tables: BalancedNetworkTables) -> Self {
Self {
tables: std::sync::Arc::new(tables),
}
}
pub(crate) fn tables_mut(&mut self) -> &mut BalancedNetworkTables {
std::sync::Arc::make_mut(&mut self.tables)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schema", schemars(rename = "BalancedNetwork"))]
#[serde(remote = "Self")]
pub(crate) struct BalancedNetworkTables {
pub name: String,
pub base_mva: f64,
#[serde(default = "default_base_frequency")]
pub base_frequency: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub geo: Option<GeoMeta>,
pub buses: std::sync::Arc<Vec<Bus>>,
pub loads: std::sync::Arc<Vec<Load>>,
pub shunts: std::sync::Arc<Vec<Shunt>>,
pub branches: std::sync::Arc<Vec<Branch>>,
#[serde(default)]
pub switches: std::sync::Arc<Vec<Switch>>,
pub generators: std::sync::Arc<Vec<Generator>>,
pub storage: std::sync::Arc<Vec<Storage>>,
pub hvdc: std::sync::Arc<Vec<Hvdc>>,
#[serde(default)]
pub transformers_3w: std::sync::Arc<Vec<Transformer3W>>,
#[serde(default)]
pub areas: std::sync::Arc<Vec<Area>>,
#[serde(default)]
pub solver: Option<SolverParams>,
pub source_format: SourceFormat,
}
impl Serialize for BalancedNetwork {
fn serialize<S: serde::Serializer>(
&self,
serializer: S,
) -> std::result::Result<S::Ok, S::Error> {
BalancedNetworkTables::serialize(
&self.tables,
powerio_core::__implementation::nonfinite::NonFiniteSer(serializer),
)
}
}
impl<'de> Deserialize<'de> for BalancedNetwork {
fn deserialize<D: serde::Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Self, D::Error> {
BalancedNetworkTables::deserialize(powerio_core::__implementation::nonfinite::NonFiniteDe(
deserializer,
))
.map(BalancedNetwork::from_tables)
}
}
#[cfg(feature = "schema")]
impl schemars::JsonSchema for BalancedNetwork {
fn schema_name() -> std::borrow::Cow<'static, str> {
<BalancedNetworkTables as schemars::JsonSchema>::schema_name()
}
fn schema_id() -> std::borrow::Cow<'static, str> {
<BalancedNetworkTables as schemars::JsonSchema>::schema_id()
}
fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
<BalancedNetworkTables as schemars::JsonSchema>::json_schema(generator)
}
}
macro_rules! table_accessors {
($($(#[$doc:meta])* $field:ident, $field_mut:ident: $ty:ty;)+) => {
impl BalancedNetwork {
$(
$(#[$doc])*
#[must_use]
pub fn $field(&self) -> &$ty {
&self.tables.$field
}
#[must_use]
pub fn $field_mut(&mut self) -> &mut $ty {
&mut self.tables_mut().$field
}
)+
}
};
}
table_accessors! {
name, name_mut: String;
geo, geo_mut: Option<GeoMeta>;
solver, solver_mut: Option<SolverParams>;
}
macro_rules! shared_table_accessors {
($($(#[$doc:meta])* $field:ident, $field_mut:ident: $ty:ty;)+) => {
impl BalancedNetwork {
$(
$(#[$doc])*
#[must_use]
pub fn $field(&self) -> &$ty {
&self.tables.$field
}
#[must_use]
pub fn $field_mut(&mut self) -> &mut $ty {
std::sync::Arc::make_mut(&mut self.tables_mut().$field)
}
)+
}
};
}
impl BalancedNetwork {
pub fn share_equal_tables(&mut self, donor: &Self) {
macro_rules! share {
($($field:ident),+) => {
$(
if !std::sync::Arc::ptr_eq(&self.tables.$field, &donor.tables.$field)
&& self.tables.$field == donor.tables.$field
{
self.tables_mut().$field = donor.tables.$field.clone();
}
)+
};
}
share!(
buses,
loads,
shunts,
branches,
switches,
generators,
storage,
hvdc,
transformers_3w,
areas
);
}
}
shared_table_accessors! {
buses, buses_mut: Vec<Bus>;
loads, loads_mut: Vec<Load>;
shunts, shunts_mut: Vec<Shunt>;
branches, branches_mut: Vec<Branch>;
switches, switches_mut: Vec<Switch>;
generators, generators_mut: Vec<Generator>;
storage, storage_mut: Vec<Storage>;
hvdc, hvdc_mut: Vec<Hvdc>;
transformers_3w, transformers_3w_mut: Vec<Transformer3W>;
areas, areas_mut: Vec<Area>;
}
impl BalancedNetwork {
#[must_use]
pub fn base_mva(&self) -> f64 {
self.tables.base_mva
}
#[must_use]
pub fn base_mva_mut(&mut self) -> &mut f64 {
&mut self.tables_mut().base_mva
}
#[must_use]
pub fn base_frequency(&self) -> f64 {
self.tables.base_frequency
}
#[must_use]
pub fn base_frequency_mut(&mut self) -> &mut f64 {
&mut self.tables_mut().base_frequency
}
#[must_use]
pub fn source_format(&self) -> SourceFormat {
self.tables.source_format
}
#[must_use]
pub fn source_format_mut(&mut self) -> &mut SourceFormat {
&mut self.tables_mut().source_format
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct Bus {
pub id: BusId,
pub kind: BusType,
pub vm: f64,
pub va: f64,
pub base_kv: f64,
pub vmax: f64,
pub vmin: f64,
#[serde(default)]
pub evhi: Option<f64>,
#[serde(default)]
pub evlo: Option<f64>,
pub area: usize,
pub zone: usize,
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub uid: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub location: Option<Location>,
pub extras: Extras,
}
impl Bus {
#[must_use]
pub fn new(id: BusId, kind: BusType, base_kv: f64) -> Self {
Self {
id,
kind,
vm: 1.0,
va: 0.0,
base_kv,
vmax: 1.1,
vmin: 0.9,
evhi: None,
evlo: None,
area: 1,
zone: 1,
name: None,
uid: None,
location: None,
extras: Extras::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct Load {
pub bus: BusId,
pub p: f64,
pub q: f64,
#[serde(default)]
pub voltage_model: Option<LoadVoltageModel>,
pub in_service: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub uid: Option<String>,
pub extras: Extras,
}
impl Load {
#[must_use]
pub fn new(bus: BusId, p: f64, q: f64) -> Self {
Self {
bus,
p,
q,
voltage_model: None,
in_service: true,
uid: None,
extras: Extras::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(tag = "kind", rename_all = "snake_case")]
#[non_exhaustive]
pub enum LoadVoltageModel {
ConstantPower,
Zip {
p_constant_power: f64,
q_constant_power: f64,
p_constant_current: f64,
q_constant_current: f64,
p_constant_impedance: f64,
q_constant_impedance: f64,
#[serde(default)]
v_nom: Option<f64>,
#[serde(default)]
load_type: Option<i32>,
#[serde(default)]
scaling: Option<f64>,
},
Exponential {
p: f64,
q: f64,
#[serde(default)]
v_nom: Option<f64>,
gamma_p: f64,
gamma_q: f64,
},
}
impl LoadVoltageModel {
#[must_use]
pub fn has_non_matpower_fields(&self) -> bool {
match self {
Self::ConstantPower => false,
Self::Zip {
p_constant_current,
q_constant_current,
p_constant_impedance,
q_constant_impedance,
v_nom,
load_type,
scaling,
..
} => {
*p_constant_current != 0.0
|| *q_constant_current != 0.0
|| *p_constant_impedance != 0.0
|| *q_constant_impedance != 0.0
|| v_nom.is_some()
|| load_type.is_some()
|| scaling.is_some()
}
Self::Exponential { .. } => true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct Shunt {
pub bus: BusId,
pub g: f64,
pub b: f64,
pub in_service: bool,
#[serde(default)]
pub control: Option<SwitchedShuntControl>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub uid: Option<String>,
pub extras: Extras,
}
impl Shunt {
#[must_use]
pub fn new(bus: BusId, g: f64, b: f64) -> Self {
Self {
bus,
g,
b,
in_service: true,
control: None,
uid: None,
extras: Extras::new(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum SwitchedShuntMode {
Locked,
Continuous,
Discrete,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct ShuntBlock {
pub steps: u32,
pub b: f64,
}
impl ShuntBlock {
#[must_use]
pub const fn new(steps: u32, b: f64) -> Self {
Self { steps, b }
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct SwitchedShuntControl {
pub mode: SwitchedShuntMode,
pub vhigh: f64,
pub vlow: f64,
pub control_bus: Option<BusId>,
pub rmpct: f64,
pub blocks: Vec<ShuntBlock>,
}
impl SwitchedShuntControl {
#[must_use]
pub fn new(mode: SwitchedShuntMode, vhigh: f64, vlow: f64, blocks: Vec<ShuntBlock>) -> Self {
Self {
mode,
vhigh,
vlow,
control_bus: None,
rmpct: 100.0,
blocks,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct Branch {
pub from: BusId,
pub to: BusId,
pub r: f64,
pub x: f64,
pub b: f64,
#[serde(default)]
pub charging: Option<BranchCharging>,
pub rate_a: f64,
pub rate_b: f64,
pub rate_c: f64,
#[serde(default)]
pub rating_sets: Vec<BranchRatingSet>,
#[serde(default)]
pub current_ratings: Option<BranchCurrentRatings>,
pub tap: f64,
pub shift: f64,
pub in_service: bool,
pub angmin: f64,
pub angmax: f64,
#[serde(default)]
pub control: Option<TransformerControl>,
#[serde(default)]
pub solution: Option<BranchSolution>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub uid: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub route: Option<Vec<Location>>,
pub extras: Extras,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct BranchRatingSet {
pub name: String,
pub rate_mva: f64,
}
impl BranchRatingSet {
#[must_use]
pub fn new(name: impl Into<String>, rate_mva: f64) -> Self {
Self {
name: name.into(),
rate_mva,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct BranchCharging {
pub g_fr: f64,
pub b_fr: f64,
pub g_to: f64,
pub b_to: f64,
}
impl BranchCharging {
#[must_use]
pub const fn new(g_fr: f64, b_fr: f64, g_to: f64, b_to: f64) -> Self {
Self {
g_fr,
b_fr,
g_to,
b_to,
}
}
#[must_use]
pub fn from_total_b(b: f64) -> Self {
Self {
g_fr: 0.0,
b_fr: b / 2.0,
g_to: 0.0,
b_to: b / 2.0,
}
}
#[must_use]
pub fn total_b(self) -> f64 {
self.b_fr + self.b_to
}
#[must_use]
pub fn total_g(self) -> f64 {
self.g_fr + self.g_to
}
#[must_use]
pub fn is_matpower_symmetric(self) -> bool {
self.g_fr.abs() <= f64::EPSILON
&& self.g_to.abs() <= f64::EPSILON
&& (self.b_fr - self.b_to).abs() <= f64::EPSILON
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct BranchCurrentRatings {
pub c_rating_a: f64,
pub c_rating_b: f64,
pub c_rating_c: f64,
}
impl BranchCurrentRatings {
#[must_use]
pub const fn new(c_rating_a: f64, c_rating_b: f64, c_rating_c: f64) -> Self {
Self {
c_rating_a,
c_rating_b,
c_rating_c,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct BranchSolution {
pub pf: f64,
pub qf: f64,
pub pt: f64,
pub qt: f64,
}
impl BranchSolution {
#[must_use]
pub const fn new(pf: f64, qf: f64, pt: f64, qt: f64) -> Self {
Self { pf, qf, pt, qt }
}
}
impl Branch {
#[must_use]
pub fn new(from: BusId, to: BusId, r: f64, x: f64) -> Self {
Self {
from,
to,
r,
x,
b: 0.0,
charging: None,
rate_a: 0.0,
rate_b: 0.0,
rate_c: 0.0,
rating_sets: Vec::new(),
current_ratings: None,
tap: 0.0,
shift: 0.0,
in_service: true,
angmin: -360.0,
angmax: 360.0,
control: None,
solution: None,
uid: None,
route: None,
extras: Extras::new(),
}
}
#[must_use]
pub fn effective_tap(&self) -> f64 {
if self.tap == 0.0 { 1.0 } else { self.tap }
}
pub fn divisible_tap(&self, row: usize) -> Result<f64> {
let tap = self.effective_tap();
if !tap.is_finite() || tap.abs() < crate::dc::MIN_DIVISIBLE_MAGNITUDE {
return Err(Error::DegenerateTap { row, tap });
}
Ok(tap)
}
#[must_use]
pub fn terminal_charging(&self) -> BranchCharging {
self.charging
.unwrap_or_else(|| BranchCharging::from_total_b(self.b))
}
pub fn series_admittance(&self, row: usize) -> Result<Option<(f64, f64)>> {
series_admittance_of(self.r, self.x, row)
}
#[must_use]
pub fn synthesize_rate_a(
&self,
angle_window_rad: f64,
(fr_vmin, fr_vmax): (f64, f64),
(to_vmin, to_vmax): (f64, f64),
) -> f64 {
let zmag = self.r.hypot(self.x);
if zmag < crate::dc::MIN_DIVISIBLE_MAGNITUDE {
return 0.0;
}
let window = angle_window_rad.abs().min(std::f64::consts::PI);
let cos_window = window.cos();
let separation = |vf: f64, vt: f64| {
(vf * vf + vt * vt - 2.0 * vf * vt * cos_window)
.max(0.0)
.sqrt()
};
let widest = separation(fr_vmax, to_vmax)
.max(separation(fr_vmax, to_vmin))
.max(separation(fr_vmin, to_vmax))
.max(separation(fr_vmin, to_vmin));
fr_vmax.max(to_vmax) * widest / zmag
}
#[must_use]
pub fn total_charging_b(&self) -> f64 {
self.terminal_charging().total_b()
}
#[must_use]
pub fn has_non_matpower_charging(&self) -> bool {
self.charging
.is_some_and(|charging| !charging.is_matpower_symmetric())
}
#[must_use]
pub fn is_transformer(&self) -> bool {
self.tap != 0.0 || self.shift != 0.0
}
#[must_use]
pub fn has_angle_limits(&self) -> bool {
self.angmin > -360.0 || self.angmax < 360.0
}
}
pub fn series_admittance_of(r: f64, x: f64, row: usize) -> Result<Option<(f64, f64)>> {
let magnitude = r.hypot(x);
if magnitude < crate::dc::MIN_DIVISIBLE_MAGNITUDE {
return Ok(None);
}
if !magnitude.is_finite() {
return Err(Error::NonFiniteSusceptance { row });
}
Ok(Some(crate::dc::series_admittance_parts(r, x)))
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct Switch {
pub from: BusId,
pub to: BusId,
pub closed: bool,
#[serde(default)]
pub thermal_rating: Option<f64>,
#[serde(default)]
pub current_rating: Option<f64>,
#[serde(default)]
pub pf: Option<f64>,
#[serde(default)]
pub qf: Option<f64>,
#[serde(default)]
pub pt: Option<f64>,
#[serde(default)]
pub qt: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub uid: Option<String>,
pub extras: Extras,
}
impl Switch {
#[must_use]
pub fn new(from: BusId, to: BusId, closed: bool) -> Self {
Self {
from,
to,
closed,
thermal_rating: None,
current_rating: None,
pf: None,
qf: None,
pt: None,
qt: None,
uid: None,
extras: Extras::new(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum TransformerControlMode {
Fixed,
Voltage,
ReactiveFlow,
ActiveFlow,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct TransformerControl {
pub mode: TransformerControlMode,
pub controlled_bus: Option<BusId>,
pub tap_min: f64,
pub tap_max: f64,
pub band_min: f64,
pub band_max: f64,
pub ntp: u32,
pub mva_base: f64,
}
impl Default for TransformerControl {
fn default() -> Self {
TransformerControl {
mode: TransformerControlMode::Fixed,
controlled_bus: None,
tap_min: 0.9,
tap_max: 1.1,
band_min: 0.9,
band_max: 1.1,
ntp: 33,
mva_base: 0.0,
}
}
}
impl TransformerControl {
#[must_use]
pub fn new(mode: TransformerControlMode) -> Self {
Self {
mode,
..Self::default()
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct Generator {
pub bus: BusId,
pub pg: f64,
pub qg: f64,
pub pmax: f64,
pub pmin: f64,
pub qmax: f64,
pub qmin: f64,
pub vg: f64,
pub mbase: f64,
pub in_service: bool,
pub cost: Option<GenCost>,
#[serde(default = "default_caps", with = "caps_serde")]
#[cfg_attr(feature = "schema", schemars(with = "BTreeMap<String, f64>"))]
pub caps: GenCaps,
#[serde(default)]
pub regulated_bus: Option<BusId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub uid: Option<String>,
}
impl Generator {
#[must_use]
pub fn new(bus: BusId) -> Self {
Self {
bus,
pg: 0.0,
qg: 0.0,
pmax: 0.0,
pmin: 0.0,
qmax: 0.0,
qmin: 0.0,
vg: 1.0,
mbase: 0.0,
in_service: true,
cost: None,
caps: default_caps(),
regulated_bus: None,
uid: None,
}
}
#[must_use]
pub fn has_caps(&self) -> bool {
self.caps.iter().any(Option::is_some)
}
}
pub type GenCaps = [Option<f64>; GEN_EXTRA_KEYS.len()];
fn default_caps() -> GenCaps {
[None; GEN_EXTRA_KEYS.len()]
}
mod caps_serde {
use super::{GEN_EXTRA_KEYS, GenCaps};
use serde::de::{Deserialize, Deserializer};
use serde::ser::{SerializeMap, Serializer};
use std::collections::BTreeMap;
pub(super) fn serialize<S: Serializer>(caps: &GenCaps, s: S) -> Result<S::Ok, S::Error> {
let present = caps.iter().filter(|v| v.is_some()).count();
let mut map = s.serialize_map(Some(present))?;
for (key, slot) in GEN_EXTRA_KEYS.iter().zip(caps.iter()) {
if let Some(value) = slot {
map.serialize_entry(key, value)?;
}
}
map.end()
}
pub(super) fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<GenCaps, D::Error> {
let named = Option::<BTreeMap<String, f64>>::deserialize(d)?.unwrap_or_default();
let mut caps: GenCaps = [None; GEN_EXTRA_KEYS.len()];
for (slot, key) in caps.iter_mut().zip(GEN_EXTRA_KEYS.iter()) {
*slot = named.get(*key).copied();
}
Ok(caps)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct Storage {
pub bus: BusId,
pub ps: f64,
pub qs: f64,
pub energy: f64,
pub energy_rating: f64,
pub charge_rating: f64,
pub discharge_rating: f64,
pub charge_efficiency: f64,
pub discharge_efficiency: f64,
pub thermal_rating: f64,
#[serde(default)]
pub current_rating: Option<f64>,
pub qmin: f64,
pub qmax: f64,
pub r: f64,
pub x: f64,
pub p_loss: f64,
pub q_loss: f64,
pub in_service: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub uid: Option<String>,
pub extras: Extras,
}
impl Storage {
#[must_use]
pub fn new(bus: BusId) -> Self {
Self {
bus,
ps: 0.0,
qs: 0.0,
energy: 0.0,
energy_rating: 0.0,
charge_rating: 0.0,
discharge_rating: 0.0,
charge_efficiency: 1.0,
discharge_efficiency: 1.0,
thermal_rating: 0.0,
current_rating: None,
qmin: 0.0,
qmax: 0.0,
r: 0.0,
x: 0.0,
p_loss: 0.0,
q_loss: 0.0,
in_service: true,
uid: None,
extras: Extras::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct Hvdc {
pub from: BusId,
pub to: BusId,
pub in_service: bool,
pub pf: f64,
pub pt: f64,
pub qf: f64,
pub qt: f64,
pub vf: f64,
pub vt: f64,
pub pmin: f64,
pub pmax: f64,
pub qminf: f64,
pub qmaxf: f64,
pub qmint: f64,
pub qmaxt: f64,
pub loss0: f64,
pub loss1: f64,
#[serde(default)]
pub cost: Option<GenCost>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub uid: Option<String>,
pub extras: Extras,
}
impl Hvdc {
#[must_use]
pub fn delivered_power(pf: f64, loss0: f64, loss1: f64) -> f64 {
pf - loss0 - loss1 * pf
}
#[must_use]
pub fn pt_matches_loss_model(&self, tol: f64) -> bool {
(self.pt - Self::delivered_power(self.pf, self.loss0, self.loss1)).abs() <= tol
}
#[must_use]
pub fn new(from: BusId, to: BusId) -> Self {
Self {
from,
to,
in_service: true,
pf: 0.0,
pt: 0.0,
qf: 0.0,
qt: 0.0,
vf: 1.0,
vt: 1.0,
pmin: 0.0,
pmax: 0.0,
qminf: 0.0,
qmaxf: 0.0,
qmint: 0.0,
qmaxt: 0.0,
loss0: 0.0,
loss1: 0.0,
cost: None,
uid: None,
extras: Extras::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct Area {
pub number: usize,
pub slack_bus: Option<BusId>,
pub net_interchange: f64,
pub tolerance: f64,
pub name: Option<String>,
}
impl Area {
#[must_use]
pub fn new(number: usize) -> Self {
Self {
number,
slack_bus: None,
net_interchange: 0.0,
tolerance: 0.0,
name: None,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct SolverParams {
pub newton_tolerance: Option<f64>,
pub max_iterations: Option<u32>,
pub zero_impedance_threshold: Option<f64>,
pub adjust_taps: Option<bool>,
pub adjust_area_interchange: Option<bool>,
pub adjust_phase_shift: Option<bool>,
pub adjust_dc_taps: Option<bool>,
pub adjust_switched_shunt: Option<bool>,
}
impl SolverParams {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn is_empty(&self) -> bool {
*self == SolverParams::default()
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct Impedance {
pub r: f64,
pub x: f64,
pub base_mva: f64,
}
impl Impedance {
#[must_use]
pub const fn new(r: f64, x: f64, base_mva: f64) -> Self {
Self { r, x, base_mva }
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct Winding {
pub bus: BusId,
pub tap: f64,
pub shift: f64,
pub nominal_kv: f64,
pub rate_a: f64,
pub rate_b: f64,
pub rate_c: f64,
}
impl Winding {
#[must_use]
pub fn new(bus: BusId) -> Self {
Self {
bus,
tap: 1.0,
shift: 0.0,
nominal_kv: 0.0,
rate_a: 0.0,
rate_b: 0.0,
rate_c: 0.0,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct Transformer3W {
pub windings: [Winding; 3],
pub z: [Impedance; 3],
pub star_vm: f64,
pub star_va: f64,
pub mag_g: f64,
pub mag_b: f64,
pub in_service: bool,
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub uid: Option<String>,
pub extras: Extras,
}
impl Transformer3W {
#[must_use]
pub fn new(windings: [Winding; 3], z: [Impedance; 3]) -> Self {
Self {
windings,
z,
star_vm: 1.0,
star_va: 0.0,
mag_g: 0.0,
mag_b: 0.0,
in_service: true,
name: None,
uid: None,
extras: Extras::new(),
}
}
#[must_use]
pub fn star_impedances(&self) -> [(f64, f64); 3] {
let [z12, z23, z31] = self.z;
let half = |a: f64, b: f64, c: f64| (a + b - c) / 2.0;
[
(half(z12.r, z31.r, z23.r), half(z12.x, z31.x, z23.x)),
(half(z12.r, z23.r, z31.r), half(z12.x, z23.x, z31.x)),
(half(z23.r, z31.r, z12.r), half(z23.x, z31.x, z12.x)),
]
}
#[must_use]
pub fn star_expansion(&self, star_id: BusId) -> (Bus, [Branch; 3]) {
let star = Bus {
id: star_id,
kind: BusType::Pq,
vm: self.star_vm,
va: self.star_va,
base_kv: self.windings[0].nominal_kv,
vmax: 1.1,
vmin: 0.9,
evhi: None,
evlo: None,
area: 0,
zone: 0,
name: self.name.clone(),
uid: self.uid.clone(),
location: None,
extras: Extras::new(),
};
let zs = self.star_impedances();
let branch = |w: &Winding, (r, x): (f64, f64)| Branch {
from: w.bus,
to: star_id,
r,
x,
b: 0.0,
charging: None,
rate_a: w.rate_a,
rate_b: w.rate_b,
rate_c: w.rate_c,
rating_sets: Vec::new(),
current_ratings: None,
tap: w.tap,
shift: w.shift,
in_service: self.in_service,
angmin: -360.0,
angmax: 360.0,
control: None,
solution: None,
uid: None,
route: None,
extras: Extras::new(),
};
let branches = [
branch(&self.windings[0], zs[0]),
branch(&self.windings[1], zs[1]),
branch(&self.windings[2], zs[2]),
];
(star, branches)
}
}
pub(crate) const GEN_EXTRA_KEYS: [&str; 11] = [
"pc1", "pc2", "qc1min", "qc1max", "qc2min", "qc2max", "ramp_agc", "ramp_10", "ramp_30",
"ramp_q", "apf",
];
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ValueFinding {
pub element: String,
pub table: &'static str,
pub index: usize,
pub field: &'static str,
pub old: f64,
pub new: f64,
pub reason: &'static str,
}
impl ValueFinding {
pub(crate) fn into_diagnostic(self) -> crate::Diagnostic {
let mut details = serde_json::Map::new();
details.insert("element".to_owned(), serde_json::json!(self.element));
details.insert("field".to_owned(), serde_json::json!(self.field));
details.insert("value".to_owned(), serde_json::json!(self.old));
details.insert("repaired_value".to_owned(), serde_json::json!(self.new));
details.insert("reason".to_owned(), serde_json::json!(self.reason));
crate::Diagnostic::of(
&crate::diagnostics::codes::VALIDATE_BALANCED_VALUE_DOMAIN,
format!(
"{}: `{}` is {} ({}); the repair sets {}",
self.element, self.field, self.old, self.reason, self.new
),
)
.with_target(format!("/{}/{}/{}", self.table, self.index, self.field))
.expect("scan-built targets are nonempty and bounded")
.with_details(details)
.expect("scan-built details stay within the record bounds")
}
}
pub fn repair_values(
module: powerio_core::PioModule<BalancedNetwork>,
) -> std::result::Result<powerio_core::PioModule<BalancedNetwork>, powerio_core::Error> {
let repair_ordinal = module
.history()
.iter()
.filter(|entry| entry.kind() == powerio_core::HistoryKind::Repair)
.count();
let mut network_findings = Vec::new();
let mut module = module.map_value(|mut network| {
network_findings = network.repair_in_place();
network
});
if network_findings.is_empty() {
return Ok(module);
}
let mut parameters = std::collections::BTreeMap::new();
parameters.insert(
"repairs".to_owned(),
serde_json::json!(
network_findings
.iter()
.map(|finding| {
serde_json::json!({
"element": finding.element,
"field": finding.field,
"value": finding.old,
"repaired_value": finding.new,
})
})
.collect::<Vec<_>>()
),
);
let entry = powerio_core::HistoryEntry::new(
powerio_core::HistoryId::new(format!("repair{repair_ordinal}"))?,
powerio_core::HistoryKind::Repair,
"value_domain_repair",
)?
.with_parameters(parameters)?;
module.add_history_entry(entry)?;
for finding in network_findings {
module.add_diagnostic(finding.into_diagnostic())?;
}
module = module.sever_source();
Ok(module)
}
fn repair_vm(vm: f64) -> Option<f64> {
(!vm.is_finite() || vm <= 0.0 || vm > 2.0).then_some(1.0)
}
fn repair_va(va: f64) -> Option<f64> {
(!va.is_finite() || va.abs() > 2000.0).then_some(0.0)
}
fn repair_mbase(mbase: f64, sbase: f64) -> Option<f64> {
(!mbase.is_finite() || mbase <= 0.0).then_some(sbase)
}
fn repair_vg(vg: f64) -> Option<f64> {
(!vg.is_finite() || vg <= 0.0).then_some(1.0)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct LoweredLengths {
pub(crate) buses: usize,
pub(crate) branches: usize,
pub(crate) shunts: usize,
}
impl BalancedNetwork {
#[must_use]
pub fn new(name: impl Into<String>, base_mva: f64) -> BalancedNetwork {
BalancedNetwork::from_tables(BalancedNetworkTables {
name: name.into(),
base_mva,
base_frequency: DEFAULT_BASE_FREQUENCY,
geo: None,
buses: Vec::new().into(),
loads: Vec::new().into(),
shunts: Vec::new().into(),
branches: Vec::new().into(),
switches: Vec::new().into(),
generators: Vec::new().into(),
storage: Vec::new().into(),
hvdc: Vec::new().into(),
transformers_3w: Vec::new().into(),
areas: Vec::new().into(),
solver: None,
source_format: SourceFormat::InMemory,
})
}
#[must_use]
pub fn in_memory(
name: impl Into<String>,
base_mva: f64,
buses: Vec<Bus>,
branches: Vec<Branch>,
) -> BalancedNetwork {
let mut net = Self::new(name, base_mva);
*net.buses_mut() = buses;
*net.branches_mut() = branches;
net
}
pub fn to_json(&self) -> crate::Result<String> {
serde_json::to_string(self).map_err(|e| Error::FormatRead {
format: "JSON",
message: e.to_string(),
})
}
pub fn to_json_with_diagnostics(
&self,
) -> crate::Result<(String, Vec<crate::diagnostics::Diagnostic>)> {
let text = self.to_json()?;
Ok((text, Vec::new()))
}
pub fn to_format(&self, format: crate::TargetFormat) -> crate::Result<crate::Conversion> {
crate::format::write_conversion(self, format)
}
pub fn to_canonical_format(
&self,
format: crate::TargetFormat,
) -> crate::Result<crate::Conversion> {
crate::format::write_conversion(self, format)
}
pub fn to_format_with_options(
&self,
format: crate::TargetFormat,
options: &crate::WriteOptions,
) -> crate::Result<crate::Conversion> {
if options.is_default() {
return self.to_format(format);
}
let (working, policy_warnings) = crate::format::apply_write_cost_policy(self, options)?;
let mut conv = crate::format::write_conversion(&working, format)?;
conv.prepend(policy_warnings);
Ok(conv)
}
#[must_use]
pub fn to_matpower(&self) -> String {
crate::write_matpower(self)
}
pub fn from_json(text: &str) -> crate::Result<BalancedNetwork> {
let text = text.trim_start_matches('\u{feff}');
let net: BalancedNetwork = serde_json::from_str(text).map_err(|e| Error::FormatRead {
format: "JSON",
message: e.to_string(),
})?;
net.check_references("JSON")?;
if net.buses().is_empty() {
return Err(Error::FormatRead {
format: "JSON",
message: "case has no buses".into(),
});
}
Ok(net)
}
pub fn from_json_bytes(bytes: &[u8]) -> crate::Result<BalancedNetwork> {
let text = std::str::from_utf8(bytes).map_err(|error| Error::FormatRead {
format: "JSON",
message: format!("input is not valid UTF-8: {error}"),
})?;
Self::from_json(text)
}
#[must_use]
pub fn is_normalized(&self) -> bool {
self.source_format() == SourceFormat::Normalized
}
pub fn check_base_mva(&self) -> crate::Result<()> {
if self.base_mva().is_finite() && self.base_mva() > 0.0 {
Ok(())
} else {
Err(crate::Error::InvalidBaseMva {
base: self.base_mva(),
})
}
}
#[must_use]
pub fn validate_values(&self) -> Vec<crate::Diagnostic> {
self.value_findings()
.into_iter()
.map(ValueFinding::into_diagnostic)
.collect()
}
pub(crate) fn value_findings(&self) -> Vec<ValueFinding> {
let mut out = Vec::new();
for (index, b) in self.buses().iter().enumerate() {
if let Some(new) = repair_vm(b.vm) {
out.push(ValueFinding {
element: format!("bus {}", b.id),
table: "buses",
index,
field: "vm",
old: b.vm,
new,
reason: "voltage magnitude outside [0, 2] p.u.",
});
}
if let Some(new) = repair_va(b.va) {
out.push(ValueFinding {
element: format!("bus {}", b.id),
table: "buses",
index,
field: "va",
old: b.va,
new,
reason: "voltage angle outside ±2000°",
});
}
}
for (index, g) in self.generators().iter().enumerate() {
if let Some(new) = repair_mbase(g.mbase, self.base_mva()) {
out.push(ValueFinding {
element: format!("generator at bus {}", g.bus),
table: "generators",
index,
field: "mbase",
old: g.mbase,
new,
reason: "non-positive generator MVA base",
});
}
if let Some(new) = repair_vg(g.vg) {
out.push(ValueFinding {
element: format!("generator at bus {}", g.bus),
table: "generators",
index,
field: "vg",
old: g.vg,
new,
reason: "non-positive voltage setpoint",
});
}
}
out
}
pub(crate) fn repair_in_place(&mut self) -> Vec<ValueFinding> {
let findings = self.value_findings();
let sbase = self.base_mva();
for b in self.buses_mut() {
if let Some(new) = repair_vm(b.vm) {
b.vm = new;
}
if let Some(new) = repair_va(b.va) {
b.va = new;
}
}
for g in self.generators_mut() {
if let Some(new) = repair_mbase(g.mbase, sbase) {
g.mbase = new;
}
if let Some(new) = repair_vg(g.vg) {
g.vg = new;
}
}
findings
}
pub(crate) fn lowered_lengths(&self) -> LoweredLengths {
let mut lengths = LoweredLengths {
buses: self.buses().len(),
branches: self.branches().len(),
shunts: self.shunts().len(),
};
for t in self.transformers_3w().iter().filter(|t| t.in_service) {
lengths.buses += 1;
lengths.branches += 3;
if t.mag_g != 0.0 || t.mag_b != 0.0 {
lengths.shunts += 1;
}
}
lengths
}
pub(crate) fn expand_transformers_3w(&self) -> std::borrow::Cow<'_, BalancedNetwork> {
if self.transformers_3w().is_empty() {
return std::borrow::Cow::Borrowed(self);
}
let mut net = self.clone();
let scale = if net.is_normalized() {
1.0
} else {
net.base_mva()
};
let base_id = net
.buses()
.iter()
.map(|b| b.id.0)
.max()
.unwrap_or(0)
.checked_add(1)
.expect("bus id space exhausted for star expansion");
for (k, t) in self
.transformers_3w()
.iter()
.filter(|t| t.in_service)
.enumerate()
{
let star_id = BusId(
base_id
.checked_add(k)
.expect("bus id space exhausted for star expansion"),
);
let (star, branches) = t.star_expansion(star_id);
net.buses_mut().push(star);
net.branches_mut().extend(branches);
if t.mag_g != 0.0 || t.mag_b != 0.0 {
net.shunts_mut().push(Shunt {
bus: star_id,
g: t.mag_g * scale,
b: t.mag_b * scale,
in_service: true,
control: None,
uid: None,
extras: Extras::new(),
});
}
}
net.transformers_3w_mut().clear();
std::borrow::Cow::Owned(net)
}
pub fn validate(&self) -> crate::Result<()> {
self.check_references("network")
}
pub(crate) fn check_references(&self, format: &'static str) -> crate::Result<()> {
let mut ids = std::collections::HashSet::with_capacity(self.buses().len());
for b in self.buses() {
if b.id > BusId::MAX {
return Err(Error::FormatRead {
format,
message: format!("bus id {} is outside the int64 id space", b.id),
});
}
if !ids.insert(b.id) {
return Err(Error::FormatRead {
format,
message: format!("duplicate bus id {}", b.id),
});
}
}
let check = |bus: BusId, what: &str| -> crate::Result<()> {
if ids.contains(&bus) {
Ok(())
} else {
Err(Error::FormatRead {
format,
message: format!("{what} references unknown bus {bus}"),
})
}
};
for (i, br) in self.branches().iter().enumerate() {
for bus in [br.from, br.to] {
if !ids.contains(&bus) {
return Err(Error::FormatRead {
format,
message: format!("branch {i} references unknown bus {bus}"),
});
}
}
if let Some(bus) = br.control.as_ref().and_then(|c| c.controlled_bus) {
check(bus, "transformer control")?;
}
}
for (i, sw) in self.switches().iter().enumerate() {
for bus in [sw.from, sw.to] {
if !ids.contains(&bus) {
return Err(Error::FormatRead {
format,
message: format!("switch {i} references unknown bus {bus}"),
});
}
}
}
for l in self.loads() {
check(l.bus, "load")?;
}
for s in self.shunts() {
check(s.bus, "shunt")?;
if let Some(bus) = s.control.as_ref().and_then(|c| c.control_bus) {
check(bus, "switched-shunt control")?;
}
}
for g in self.generators() {
check(g.bus, "generator")?;
if let Some(bus) = g.regulated_bus {
check(bus, "generator voltage control")?;
}
}
for d in self.hvdc() {
check(d.from, "dcline")?;
check(d.to, "dcline")?;
}
for s in self.storage() {
check(s.bus, "storage")?;
}
for a in self.areas() {
if let Some(slack) = a.slack_bus {
check(slack, "area swing")?;
}
}
for t in self.transformers_3w() {
for w in &t.windings {
check(w.bus, "3-winding transformer")?;
}
}
self.check_star_expansion_headroom(format)
}
fn check_star_expansion_headroom(&self, format: &'static str) -> crate::Result<()> {
if self.transformers_3w().is_empty() {
return Ok(());
}
let Some(max_id) = self.buses().iter().map(|b| b.id.0).max() else {
return Ok(());
};
let needed = self
.transformers_3w()
.iter()
.filter(|t| t.in_service)
.count()
.max(1);
if max_id
.checked_add(needed)
.is_none_or(|top| top > BusId::MAX.0)
{
return Err(Error::FormatRead {
format,
message: format!(
"bus id {max_id} leaves no room to allocate synthetic star bus ids \
for 3-winding transformers"
),
});
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn close(actual: f64, expected: f64) {
assert!((actual - expected).abs() < 1e-12, "{actual} != {expected}");
}
#[test]
fn source_format_serializes_as_its_name_token_and_reads_the_legacy_spelling() {
let all = [
SourceFormat::Matpower,
SourceFormat::PowerModelsJson,
SourceFormat::EgretJson,
SourceFormat::Psse,
SourceFormat::PowerWorld,
SourceFormat::PandapowerJson,
SourceFormat::Pslf,
SourceFormat::PowerWorldBinary,
SourceFormat::InMemory,
SourceFormat::Normalized,
SourceFormat::Gridfm,
SourceFormat::PypsaCsv,
SourceFormat::Goc3Json,
SourceFormat::SurgeJson,
SourceFormat::DeepMindOpfDataJson,
];
for f in all {
match f {
SourceFormat::Matpower
| SourceFormat::PowerModelsJson
| SourceFormat::EgretJson
| SourceFormat::Psse
| SourceFormat::PowerWorld
| SourceFormat::PandapowerJson
| SourceFormat::Pslf
| SourceFormat::PowerWorldBinary
| SourceFormat::InMemory
| SourceFormat::Normalized
| SourceFormat::Gridfm
| SourceFormat::PypsaCsv
| SourceFormat::Goc3Json
| SourceFormat::SurgeJson
| SourceFormat::DeepMindOpfDataJson => {}
}
let token = serde_json::to_value(f).unwrap();
assert_eq!(token, serde_json::Value::String(f.name().to_owned()));
let back: SourceFormat = serde_json::from_value(token).unwrap();
assert_eq!(back, f);
let legacy = serde_json::Value::String(format!("{f:?}"));
let from_legacy: SourceFormat = serde_json::from_value(legacy).unwrap();
assert_eq!(from_legacy, f);
}
}
#[test]
fn quadratic_with_constant_keeps_c0_across_ncost() {
let full = GenCost::new(2, 0.0, 0.0, vec![1.5, 2.0, 5.0]);
assert_eq!(full.quadratic_with_constant(), Some((3.0, 2.0, 5.0)));
assert_eq!(full.quadratic(), Some((3.0, 2.0)));
let linear = GenCost::new(2, 0.0, 0.0, vec![2.0, 5.0]);
assert_eq!(linear.quadratic_with_constant(), Some((0.0, 2.0, 5.0)));
let constant = GenCost::new(2, 0.0, 0.0, vec![5.0]);
assert_eq!(constant.quadratic_with_constant(), Some((0.0, 0.0, 5.0)));
let piecewise = GenCost::new(1, 0.0, 0.0, vec![0.0, 0.0, 1.0, 1.0]);
assert_eq!(piecewise.quadratic_with_constant(), None);
let cubic = GenCost::new(2, 0.0, 0.0, vec![1.0, 1.0, 1.0, 1.0]);
assert_eq!(cubic.quadratic_with_constant(), None);
let truncated = GenCost::with_ncost(2, 0.0, 0.0, 3, vec![1.0]);
assert_eq!(truncated.quadratic_with_constant(), None);
}
#[test]
fn a_leading_coefficient_below_the_tolerance_comes_off_the_row() {
let artifact = GenCost::new(2, 0.0, 0.0, vec![1e-17, 2.0, 5.0]);
assert_eq!(
artifact.quadratic_with_constant(),
Some((2e-17, 2.0, 5.0)),
"the untouched reader keeps the artifact"
);
assert_eq!(
artifact.quadratic_with_constant_tol(GenCost::LEADING_COEFF_TOL),
Some((0.0, 2.0, 5.0))
);
assert_eq!(
artifact.quadratic_with_constant_tol(0.0),
Some((2e-17, 2.0, 5.0)),
"a zero tolerance strips an exact zero alone"
);
let padded = GenCost::new(2, 0.0, 0.0, vec![0.0, 1.5, 2.0, 5.0]);
assert_eq!(padded.quadratic_with_constant(), None);
assert_eq!(
padded.quadratic_with_constant_tol(0.0),
Some((3.0, 2.0, 5.0))
);
let flat = GenCost::new(2, 0.0, 0.0, vec![1e-17, 1e-17, 1e-17]);
assert_eq!(
flat.quadratic_with_constant_tol(GenCost::LEADING_COEFF_TOL),
Some((0.0, 0.0, 1e-17)),
"the last coefficient stays, whatever its magnitude"
);
let piecewise = GenCost::new(1, 0.0, 0.0, vec![0.0, 0.0, 1.0, 1.0]);
assert_eq!(
piecewise.quadratic_with_constant_tol(GenCost::LEADING_COEFF_TOL),
None
);
let truncated = GenCost::with_ncost(2, 0.0, 0.0, 3, vec![1.0]);
assert_eq!(
truncated.quadratic_with_constant_tol(GenCost::LEADING_COEFF_TOL),
None
);
let quartic = GenCost::new(2, 0.0, 0.0, vec![1.0, 1.0, 1.0, 1.0, 1.0]);
assert_eq!(
quartic.quadratic_with_constant_tol(GenCost::LEADING_COEFF_TOL),
None
);
}
fn expected_rate(window: f64, fr: f64, to: f64, zmag: f64) -> f64 {
let separation = (fr * fr + to * to - 2.0 * fr * to * window.cos()).sqrt();
fr.max(to) * separation / zmag
}
#[test]
fn synthesized_rate_follows_the_angle_window_and_the_voltage_bands() {
let br = Branch::new(BusId(1), BusId(2), 0.03, 0.04);
let expected = |window: f64, fr: f64, to: f64| expected_rate(window, fr, to, 0.05);
let at = |v: f64| (v, v);
close(
br.synthesize_rate_a(0.5, at(1.1), at(1.06)),
expected(0.5, 1.1, 1.06),
);
assert!(
br.synthesize_rate_a(0.8, at(1.1), at(1.06))
> br.synthesize_rate_a(0.5, at(1.1), at(1.06))
);
close(
br.synthesize_rate_a(-0.5, at(1.1), at(1.06)),
expected(0.5, 1.1, 1.06),
);
for window in [6.0, 2.0 * std::f64::consts::PI, -360.0] {
close(
br.synthesize_rate_a(window, at(1.1), at(1.06)),
expected(std::f64::consts::PI, 1.1, 1.06),
);
}
let ideal = Branch::new(BusId(1), BusId(2), 0.0, 0.0);
close(ideal.synthesize_rate_a(0.5, at(1.1), at(1.1)), 0.0);
}
#[test]
fn a_narrow_window_bounds_at_the_mixed_voltage_corner() {
let br = Branch::new(BusId(1), BusId(2), 0.0, 0.01);
let (vmin, vmax) = (0.9, 1.1);
let corner = |window: f64, fr: f64, to: f64| expected_rate(window, fr, to, 0.01);
let narrow = 2.0_f64.to_radians();
let bound = br.synthesize_rate_a(narrow, (vmin, vmax), (vmin, vmax));
close(bound, corner(narrow, vmax, vmin));
assert!(
bound > 5.0 * corner(narrow, vmax, vmax),
"the mixed corner dominates here: {bound} vs {}",
corner(narrow, vmax, vmax)
);
let wide = 30.0_f64.to_radians();
close(
br.synthesize_rate_a(wide, (vmin, vmax), (vmin, vmax)),
corner(wide, vmax, vmax),
);
}
fn bus(id: usize) -> Bus {
Bus {
id: BusId(id),
kind: BusType::Pq,
vm: 1.0,
va: 0.0,
base_kv: 230.0,
vmax: 1.1,
vmin: 0.9,
evhi: None,
evlo: None,
area: 1,
zone: 1,
name: None,
uid: None,
location: None,
extras: Extras::new(),
}
}
#[test]
fn model_json_bytes_are_strict_utf8_and_keep_model_validation() {
let net = BalancedNetwork::in_memory("bytes", 100.0, vec![bus(1)], Vec::new());
let json = net.to_json().expect("serialize model JSON");
let mut with_bom = b"\xef\xbb\xbf".to_vec();
with_bom.extend_from_slice(json.as_bytes());
let back = BalancedNetwork::from_json_bytes(&with_bom).expect("read BOM prefixed JSON");
assert_eq!(back.name(), "bytes");
assert_eq!(back.buses().len(), 1);
let error = BalancedNetwork::from_json_bytes(b"{\"buses\":[]\xff}")
.expect_err("invalid UTF-8 must not be replaced");
assert!(
matches!(&error, crate::Error::FormatRead { format: "JSON", message } if message.starts_with("input is not valid UTF-8:")),
"{error}"
);
assert_eq!(error.code().code, "PARSE.SOURCE.MALFORMED");
let empty = net
.to_json()
.expect("serialize model JSON")
.replace(&serde_json::to_string(&net.buses()).unwrap(), "[]");
let error = BalancedNetwork::from_json_bytes(empty.as_bytes())
.expect_err("the byte API must keep no-bus validation");
assert!(error.to_string().contains("case has no buses"), "{error}");
}
fn winding(b: usize) -> Winding {
Winding {
bus: BusId(b),
tap: 1.0,
shift: 0.0,
nominal_kv: 230.0,
rate_a: 100.0,
rate_b: 0.0,
rate_c: 0.0,
}
}
fn transformer_3w() -> Transformer3W {
let z = |r, x| Impedance {
r,
x,
base_mva: 100.0,
};
Transformer3W {
windings: [winding(1), winding(2), winding(3)],
z: [z(0.01, 0.10), z(0.02, 0.20), z(0.03, 0.30)],
star_vm: 0.98,
star_va: -1.5,
mag_g: 0.0,
mag_b: 0.0,
in_service: true,
name: Some("T1".into()),
uid: None,
extras: Extras::new(),
}
}
#[test]
fn star_impedances_split_the_pairwise_values() {
let [(r1, x1), (r2, x2), (r3, x3)] = transformer_3w().star_impedances();
close(r1, 0.01);
close(x1, 0.10);
close(r2, 0.0);
close(x2, 0.0);
close(r3, 0.02);
close(x3, 0.20);
}
#[test]
fn star_expansion_builds_a_star_bus_and_three_branches() {
let t = transformer_3w();
let (star, branches) = t.star_expansion(BusId(99));
assert_eq!(star.id, BusId(99));
close(star.vm, 0.98);
close(star.va, -1.5);
for (i, br) in branches.iter().enumerate() {
assert_eq!(br.from, t.windings[i].bus);
assert_eq!(br.to, BusId(99));
close(br.tap, 1.0);
close(br.rate_a, 100.0);
}
close(branches[2].r, 0.02);
close(branches[2].x, 0.20);
}
#[test]
fn three_winding_transformer_survives_json_transport() {
let mut net =
BalancedNetwork::in_memory("t", 100.0, vec![bus(1), bus(2), bus(3)], Vec::new());
net.transformers_3w_mut().push(transformer_3w());
net.validate().unwrap();
let back = BalancedNetwork::from_json(&net.to_json().unwrap()).unwrap();
assert_eq!(back.transformers_3w().len(), 1);
close(back.transformers_3w()[0].z[1].x, 0.20);
assert_eq!(back.transformers_3w()[0].windings[2].bus, BusId(3));
}
#[test]
fn lowered_lengths_match_the_expansion() {
let mut magnetizing = transformer_3w();
magnetizing.mag_b = 0.02;
let mut out_of_service = transformer_3w();
out_of_service.in_service = false;
out_of_service.mag_g = 0.01;
for units in [
vec![],
vec![transformer_3w()],
vec![magnetizing.clone()],
vec![out_of_service.clone()],
vec![transformer_3w(), magnetizing, out_of_service],
] {
let mut net =
BalancedNetwork::in_memory("t", 100.0, vec![bus(1), bus(2), bus(3)], Vec::new());
net.shunts_mut().push(Shunt::new(BusId(1), 0.0, 0.5));
*net.transformers_3w_mut() = units;
let counted = net.lowered_lengths();
let built = net.expand_transformers_3w();
assert_eq!(counted.buses, built.buses().len());
assert_eq!(counted.branches, built.branches().len());
assert_eq!(counted.shunts, built.shunts().len());
}
}
#[test]
fn check_references_rejects_bus_ids_without_star_expansion_headroom() {
let mut net = BalancedNetwork::in_memory(
"t",
100.0,
vec![bus(1), bus(2), bus(3), bus(i64::MAX as usize)],
Vec::new(),
);
net.transformers_3w_mut().push(transformer_3w());
let err = net.validate().unwrap_err().to_string();
assert!(
err.contains("no room to allocate synthetic star bus ids"),
"got {err}"
);
}
#[test]
fn star_expansion_headroom_counts_only_in_service_transformers() {
let mut net = BalancedNetwork::in_memory(
"t",
100.0,
vec![bus(1), bus(2), bus(3), bus(i64::MAX as usize - 1)],
Vec::new(),
);
net.transformers_3w_mut().push(transformer_3w());
let mut out_of_service = transformer_3w();
out_of_service.in_service = false;
net.transformers_3w_mut().push(out_of_service);
net.validate()
.expect("in-service count fits; must not be rejected");
}
#[test]
fn check_references_rejects_a_bus_id_past_the_int64_ceiling() {
let mut net = BalancedNetwork::in_memory(
"t",
100.0,
vec![bus(1), bus(i64::MAX as usize + 1)],
Vec::new(),
);
let err = net.validate().unwrap_err().to_string();
assert!(err.contains("outside the int64 id space"), "got {err}");
net.buses_mut()[1].id = BusId(i64::MAX as usize);
net.validate().expect("the ceiling itself is representable");
}
#[test]
fn check_references_rejects_a_dangling_winding_bus() {
let mut net = BalancedNetwork::in_memory("t", 100.0, vec![bus(1), bus(2)], Vec::new());
net.transformers_3w_mut().push(transformer_3w()); let err = net.validate().unwrap_err().to_string();
assert!(
err.contains("3-winding transformer references unknown bus 3"),
"got {err}"
);
}
fn regulating_branch(reg: usize) -> Branch {
Branch {
from: BusId(1),
to: BusId(2),
r: 0.0,
x: 0.1,
b: 0.0,
charging: None,
rate_a: 0.0,
rate_b: 0.0,
rate_c: 0.0,
rating_sets: Vec::new(),
current_ratings: None,
tap: 1.0,
shift: 0.0,
in_service: true,
angmin: -360.0,
angmax: 360.0,
control: Some(TransformerControl {
mode: TransformerControlMode::Voltage,
controlled_bus: Some(BusId(reg)),
tap_min: 0.95,
tap_max: 1.05,
band_min: 1.0,
band_max: 1.02,
ntp: 17,
mva_base: 100.0,
}),
solution: None,
uid: None,
route: None,
extras: Extras::new(),
}
}
#[test]
fn transformer_control_survives_json_transport() {
let mut net =
BalancedNetwork::in_memory("t", 100.0, vec![bus(1), bus(2), bus(3)], Vec::new());
net.branches_mut().push(regulating_branch(3));
net.validate().unwrap();
let back = BalancedNetwork::from_json(&net.to_json().unwrap()).unwrap();
let c = back.branches()[0].control.as_ref().unwrap();
assert_eq!(c.mode, TransformerControlMode::Voltage);
assert_eq!(c.controlled_bus, Some(BusId(3)));
close(c.tap_max, 1.05);
assert_eq!(c.ntp, 17);
}
#[test]
fn gen_caps_serialize_as_a_named_map_that_grows_additively() {
let mut caps: GenCaps = [None; GEN_EXTRA_KEYS.len()];
caps[8] = Some(1.5); caps[10] = Some(0.5); let g = Generator {
bus: BusId(1),
pg: 10.0,
qg: 0.0,
pmax: 100.0,
pmin: 0.0,
qmax: 50.0,
qmin: -50.0,
vg: 1.0,
mbase: 100.0,
in_service: true,
cost: None,
caps,
regulated_bus: None,
uid: None,
};
let json = serde_json::to_string(&g).unwrap();
assert!(json.contains(r#""caps":{"#), "caps is an object: {json}");
assert!(json.contains(r#""ramp_30":1.5"#) && json.contains(r#""apf":0.5"#));
let back: Generator = serde_json::from_str(&json).unwrap();
assert_eq!(back.caps, g.caps);
let with_future = r#"{"bus":1,"pg":10,"qg":0,"pmax":100,"pmin":0,"qmax":50,"qmin":-50,
"vg":1,"mbase":100,"in_service":true,"cost":null,
"caps":{"ramp_30":1.5,"future_ramp":9.9}}"#;
let g2: Generator = serde_json::from_str(with_future).unwrap();
assert_eq!(g2.caps[8], Some(1.5));
assert_eq!(g2.caps.iter().filter(|v| v.is_some()).count(), 1);
let no_caps = r#"{"bus":1,"pg":10,"qg":0,"pmax":100,"pmin":0,"qmax":50,"qmin":-50,
"vg":1,"mbase":100,"in_service":true,"cost":null}"#;
let g3: Generator = serde_json::from_str(no_caps).unwrap();
assert!(!g3.has_caps());
let null_caps = r#"{"bus":1,"pg":10,"qg":0,"pmax":100,"pmin":0,"qmax":50,"qmin":-50,
"vg":1,"mbase":100,"in_service":true,"cost":null,"caps":null}"#;
let g4: Generator = serde_json::from_str(null_caps).unwrap();
assert!(!g4.has_caps());
}
#[test]
#[allow(clippy::float_cmp)]
fn nonfinite_values_round_trip_through_model_json() {
let bus = |id, vm| Bus {
id: BusId(id),
kind: BusType::Pq,
vm,
va: 0.0,
base_kv: 230.0,
vmax: 1.1,
vmin: 0.9,
evhi: None,
evlo: None,
area: 1,
zone: 1,
name: None,
uid: None,
location: None,
extras: Extras::new(),
};
let branch = Branch {
from: BusId(1),
to: BusId(2),
r: 0.0,
x: f64::INFINITY,
b: 0.0,
charging: None,
rate_a: 0.0,
rate_b: 0.0,
rate_c: 0.0,
rating_sets: Vec::new(),
current_ratings: None,
tap: 0.0,
shift: 0.0,
in_service: true,
angmin: -360.0,
angmax: 360.0,
control: None,
solution: None,
uid: None,
route: None,
extras: Extras::new(),
};
let mut g = Generator {
bus: BusId(1),
pg: 0.0,
qg: 0.0,
pmax: 0.0,
pmin: 0.0,
qmax: 0.0,
qmin: 0.0,
vg: 1.0,
mbase: 100.0,
in_service: true,
cost: None,
caps: GenCaps::default(),
regulated_bus: None,
uid: None,
};
g.caps[8] = Some(f64::INFINITY); let mut net = BalancedNetwork::in_memory(
"nf",
100.0,
vec![bus(1, f64::NAN), bus(2, 1.0)],
vec![branch],
);
net.generators_mut().push(g);
let text = net.to_json().unwrap();
assert!(text.contains(r#""vm":"NaN""#), "{text}");
assert!(text.contains(r#""x":"Infinity""#), "{text}");
assert!(text.contains(r#""ramp_30":"Infinity""#), "{text}");
let back = BalancedNetwork::from_json(&text).unwrap();
assert!(back.buses()[0].vm.is_nan());
assert_eq!(back.branches()[0].x, f64::INFINITY);
assert_eq!(back.generators()[0].caps[8], Some(f64::INFINITY));
assert_eq!(back.to_json().unwrap(), text);
let (_, diagnostics) = net.to_json_with_diagnostics().unwrap();
assert!(diagnostics.is_empty());
}
#[test]
fn a_null_at_a_float_position_names_the_pre_090_spelling() {
let net = BalancedNetwork::in_memory("nf", 100.0, vec![bus(1), bus(2)], Vec::new());
let text = net
.to_json()
.unwrap()
.replacen("\"vm\":1.0", "\"vm\":null", 1);
assert!(text.contains("\"vm\":null"), "fixture edit failed: {text}");
let err = BalancedNetwork::from_json(&text).unwrap_err().to_string();
assert!(err.contains("before 0.9.0"), "{err}");
}
#[test]
fn check_references_rejects_a_dangling_controlled_bus() {
let mut net = BalancedNetwork::in_memory("t", 100.0, vec![bus(1), bus(2)], Vec::new());
net.branches_mut().push(regulating_branch(9)); let err = net.validate().unwrap_err().to_string();
assert!(
err.contains("transformer control references unknown bus 9"),
"got {err}"
);
}
fn switched_shunt(reg: usize) -> Shunt {
Shunt {
bus: BusId(1),
g: 0.0,
b: 19.0,
in_service: true,
control: Some(SwitchedShuntControl {
mode: SwitchedShuntMode::Discrete,
vhigh: 1.05,
vlow: 0.95,
control_bus: Some(BusId(reg)),
rmpct: 100.0,
blocks: vec![
ShuntBlock { steps: 2, b: 25.0 },
ShuntBlock { steps: 1, b: 50.0 },
],
}),
uid: None,
extras: Extras::new(),
}
}
#[test]
fn switched_shunt_control_survives_json_transport() {
let mut net =
BalancedNetwork::in_memory("t", 100.0, vec![bus(1), bus(2), bus(3)], Vec::new());
net.shunts_mut().push(switched_shunt(3));
net.validate().unwrap();
let back = BalancedNetwork::from_json(&net.to_json().unwrap()).unwrap();
let c = back.shunts()[0].control.as_ref().unwrap();
assert_eq!(c.mode, SwitchedShuntMode::Discrete);
assert_eq!(c.control_bus, Some(BusId(3)));
assert_eq!(c.blocks.len(), 2);
close(c.blocks[1].b, 50.0);
}
#[test]
fn check_references_rejects_a_dangling_switched_shunt_control_bus() {
let mut net = BalancedNetwork::in_memory("t", 100.0, vec![bus(1), bus(2)], Vec::new());
net.shunts_mut().push(switched_shunt(9)); let err = net.validate().unwrap_err().to_string();
assert!(
err.contains("switched-shunt control references unknown bus 9"),
"got {err}"
);
}
#[test]
fn validate_values_flags_and_repair_clamps_out_of_domain_values() {
let mut net = BalancedNetwork::in_memory("t", 100.0, vec![bus(1), bus(2)], Vec::new());
net.buses_mut()[0].vm = 0.0; net.buses_mut()[1].va = 9000.0; net.generators_mut().push(Generator {
bus: BusId(1),
pg: 10.0,
qg: 0.0,
pmax: 100.0,
pmin: 0.0,
qmax: 50.0,
qmin: -50.0,
vg: 0.0, mbase: 0.0, in_service: true,
cost: None,
caps: Default::default(),
regulated_bus: None,
uid: None,
});
let diags = net.validate_values();
let fields: std::collections::BTreeSet<_> = diags
.iter()
.map(|d| d.details()["field"].as_str().unwrap().to_owned())
.collect();
assert_eq!(
fields,
["mbase", "va", "vg", "vm"]
.into_iter()
.map(str::to_owned)
.collect(),
"all four out-of-domain fields reported"
);
assert!(
diags
.iter()
.all(|d| d.code() == "VALIDATE.BALANCED.VALUE_DOMAIN" && d.target().is_some())
);
close(net.buses()[0].vm, 0.0);
let module = powerio_core::PioModule::new(net);
let module = repair_values(module).unwrap();
let net = module.value();
close(net.buses()[0].vm, 1.0);
close(net.buses()[1].va, 0.0);
close(net.generators()[0].mbase, 100.0); close(net.generators()[0].vg, 1.0);
assert!(net.validate_values().is_empty());
let entries = module.history();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].kind(), powerio_core::HistoryKind::Repair);
assert_eq!(
entries[0].parameters()["repairs"].as_array().unwrap().len(),
diags.len()
);
assert_eq!(module.diagnostics().len(), diags.len());
let module = repair_values(module).unwrap();
assert_eq!(module.history().len(), 1);
}
#[test]
fn validate_values_is_empty_for_a_clean_network() {
let net = BalancedNetwork::in_memory("t", 100.0, vec![bus(1), bus(2)], Vec::new());
assert!(net.validate_values().is_empty());
}
}