use std::collections::{HashMap, HashSet};
use tracing::debug;
use crate::market::{
AmbientConditions, CombinedCyclePlant, DispatchableLoad, EmissionPolicy, MarketRules,
OutageEntry, PumpedHydroUnit, ReserveZone,
};
use crate::network::asset::AssetCatalog;
use crate::network::boundary::BoundaryData;
use crate::network::breaker::BreakerRating;
use crate::network::cgmes_roundtrip::CgmesRoundtripData;
use crate::network::grounding::GroundingEntry;
use crate::network::impedance_correction::ImpedanceCorrectionTable;
use crate::network::induction_machine::InductionMachine;
use crate::network::market_data::MarketData;
use crate::network::measurement::CimMeasurement;
use crate::network::multi_section_line::MultiSectionLineGroup;
use crate::network::net_ops::NetworkOperationsData;
use crate::network::op_limits::OperationalLimits;
use crate::network::protection::ProtectionData;
use crate::network::scheduled_area_transfer::ScheduledAreaTransfer;
use crate::network::switching_device_rating::SwitchingDeviceRatingSet;
use crate::network::types::DEFAULT_BASE_MVA;
use crate::network::voltage_droop_control::VoltageDroopControl;
use crate::network::{
AreaSchedule, Branch, Bus, BusType, FactsDevice, FixedShunt, Flowgate, Generator, HvdcModel,
Interface, Load, NodeBreakerTopology, OltcSpec, Owner, ParSpec, PowerInjection, Region,
SwitchedShunt, SwitchedShuntOpf, TopologyMappingState, generator::StorageValidationError,
};
use serde::{Deserialize, Serialize};
#[derive(Debug, thiserror::Error)]
pub enum NetworkError {
#[error("network has no buses")]
EmptyNetwork,
#[error("base_mva must be positive and finite, got {0}")]
InvalidBaseMva(f64),
#[error("duplicate bus number {0}")]
DuplicateBusNumber(u32),
#[error("network has no slack bus")]
NoSlackBus,
#[error("branch ({branch_from}-{branch_to}) references missing bus {missing_bus}")]
InvalidBranchEndpoint {
branch_from: u32,
branch_to: u32,
missing_bus: u32,
},
#[error("branch ({0}-{0}) is a self-loop")]
SelfLoopBranch(u32),
#[error("generator references missing bus {0}")]
InvalidGeneratorBus(u32),
#[error("generator at bus {bus} references missing regulated bus {reg_bus}")]
InvalidGeneratorRegulatedBus { bus: u32, reg_bus: u32 },
#[error("load references missing bus {0}")]
InvalidLoadBus(u32),
#[error("power injection references missing bus {0}")]
InvalidPowerInjectionBus(u32),
#[error("fixed shunt references missing bus {0}")]
InvalidFixedShuntBus(u32),
#[error("dispatchable load references missing bus {0}")]
InvalidDispatchableLoadBus(u32),
#[error("duplicate canonical generator id `{id}`")]
DuplicateGeneratorId { id: String },
#[error("switched shunt `{id}` references missing host bus {bus}")]
InvalidSwitchedShuntBus { id: String, bus: u32 },
#[error("switched shunt `{id}` references missing regulated bus {bus}")]
InvalidSwitchedShuntRegulatedBus { id: String, bus: u32 },
#[error("switched shunt OPF `{id}` references missing host bus {bus}")]
InvalidSwitchedShuntOpfBus { id: String, bus: u32 },
#[error("generator at bus {bus} has pmin > pmax")]
InvalidGeneratorLimits { bus: u32 },
#[error("generator at bus {bus} has qmin > qmax")]
InvalidGeneratorReactiveLimits { bus: u32 },
#[error("generator at bus {bus} has invalid storage parameters: {source}")]
InvalidStorageParameters {
bus: u32,
#[source]
source: StorageValidationError,
},
#[error("bus {bus} field `{field}` is invalid: {value}")]
InvalidBusField {
bus: u32,
field: &'static str,
value: f64,
},
#[error("load at bus {bus} field `{field}` is invalid: {value}")]
InvalidLoadField {
bus: u32,
field: &'static str,
value: f64,
},
#[error("fixed shunt at bus {bus} field `{field}` is invalid: {value}")]
InvalidFixedShuntField {
bus: u32,
field: &'static str,
value: f64,
},
#[error("power injection at bus {bus} field `{field}` is invalid: {value}")]
InvalidPowerInjectionField {
bus: u32,
field: &'static str,
value: f64,
},
#[error("generator at bus {bus} field `{field}` is invalid: {value}")]
InvalidGeneratorField {
bus: u32,
field: &'static str,
value: f64,
},
#[error("branch ({from_bus}-{to_bus}) field `{field}` is invalid: {value}")]
InvalidBranchField {
from_bus: u32,
to_bus: u32,
field: &'static str,
value: f64,
},
#[error(
"branch ({from_bus}-{to_bus}) has invalid angle bounds (min={min_rad:?}, max={max_rad:?})"
)]
InvalidBranchAngleBounds {
from_bus: u32,
to_bus: u32,
min_rad: Option<f64>,
max_rad: Option<f64>,
},
#[error(
"connected component with buses {buses:?} has slack buses {slack_buses:?}; expected exactly one slack bus"
)]
InvalidSlackPlacement {
buses: Vec<u32>,
slack_buses: Vec<u32>,
},
#[error("bus {bus} is marked isolated but still has in-service connectivity")]
InvalidIsolatedBusConnectivity { bus: u32 },
#[error("bus {0} has non-finite voltage initial condition (vm or va is NaN/Inf)")]
NonFiniteBusVoltage(u32),
#[error(
"bus {0} has invalid voltage bounds (vmin={1}, vmax={2}): must be finite with vmin <= vmax"
)]
InvalidBusVoltageBounds(u32, f64, f64),
#[error("branch ({0}-{1}) has non-finite impedance parameter (r, x, b, or tap is NaN/Inf)")]
NonFiniteBranchImpedance(u32, u32),
#[error("duplicate branch key ({from_bus}-{to_bus} ckt {circuit})")]
DuplicateBranchKey {
from_bus: u32,
to_bus: u32,
circuit: String,
},
#[error("network has node-breaker topology but no mapped bus-branch view yet")]
MissingTopologyMapping,
#[error("network node-breaker topology is stale; call rebuild_topology() before solving")]
StaleNodeBreakerTopology,
#[error("interface `{name}` is invalid: {detail}")]
InvalidInterfaceDefinition { name: String, detail: String },
#[error("flowgate `{name}` is invalid: {detail}")]
InvalidFlowgateDefinition { name: String, detail: String },
#[error("duplicate area schedule number {0}")]
DuplicateAreaScheduleNumber(u32),
#[error("area {area} references invalid slack bus {slack_bus}")]
InvalidAreaScheduleSlackBus { area: u32, slack_bus: u32 },
#[error("area {area} field `{field}` is invalid: {value}")]
InvalidAreaScheduleField {
area: u32,
field: &'static str,
value: f64,
},
#[error("duplicate HVDC link name `{name}`")]
DuplicateHvdcLinkName { name: String },
#[error("HVDC link `{name}` references missing AC bus {bus}")]
InvalidHvdcLinkEndpoint { name: String, bus: u32 },
#[error("duplicate explicit DC grid id {id}")]
DuplicateDcGridId { id: u32 },
#[error("explicit DC grid {grid_id} has duplicate DC bus id {bus_id}")]
DuplicateDcBusId { grid_id: u32, bus_id: u32 },
#[error("explicit DC grid {grid_id} converter references missing AC bus {ac_bus}")]
InvalidDcConverterAcBus { grid_id: u32, ac_bus: u32 },
#[error("explicit DC grid {grid_id} converter references missing DC bus {dc_bus}")]
InvalidDcConverterDcBus { grid_id: u32, dc_bus: u32 },
#[error(
"explicit DC grid {grid_id} branch ({from_bus}-{to_bus}) references missing DC bus {missing_bus}"
)]
InvalidDcBranchEndpoint {
grid_id: u32,
from_bus: u32,
to_bus: u32,
missing_bus: u32,
},
#[error(
"network mixes point-to-point HVDC links with explicit DC-network topology; choose one canonical HVDC representation per solve"
)]
MixedHvdcRepresentation,
}
fn is_valid_lower_bound(value: f64) -> bool {
value.is_finite() || value == f64::NEG_INFINITY
}
fn is_valid_upper_bound(value: f64) -> bool {
value.is_finite() || value == f64::INFINITY
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct BranchEquipmentKey {
pub bus_a: u32,
pub bus_b: u32,
pub circuit: String,
}
impl BranchEquipmentKey {
pub fn new(from_bus: u32, to_bus: u32, circuit: impl Into<String>) -> Self {
let circuit = circuit.into();
if from_bus <= to_bus {
Self {
bus_a: from_bus,
bus_b: to_bus,
circuit,
}
} else {
Self {
bus_a: to_bus,
bus_b: from_bus,
circuit,
}
}
}
pub fn from_branch(branch: &Branch) -> Self {
Self::new(branch.from_bus, branch.to_bus, branch.circuit.clone())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct BranchConditionalRatingEntry {
branch: BranchEquipmentKey,
ratings: Vec<ConditionalRating>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct BranchThermalRatingSnapshot {
branch: BranchEquipmentKey,
rating_a_mva: f64,
rating_c_mva: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct BranchConditionalRatings {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
entries: Vec<BranchConditionalRatingEntry>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
base_thermal_ratings: Vec<BranchThermalRatingSnapshot>,
}
impl BranchConditionalRatings {
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn iter(&self) -> impl Iterator<Item = (&BranchEquipmentKey, &Vec<ConditionalRating>)> {
self.entries
.iter()
.map(|entry| (&entry.branch, &entry.ratings))
}
pub fn values(&self) -> impl Iterator<Item = &Vec<ConditionalRating>> {
self.entries.iter().map(|entry| &entry.ratings)
}
pub fn get(&self, branch: &BranchEquipmentKey) -> Option<&[ConditionalRating]> {
self.entries
.iter()
.find(|entry| entry.branch == *branch)
.map(|entry| entry.ratings.as_slice())
}
pub fn get_for_branch(&self, branch: &Branch) -> Option<&[ConditionalRating]> {
self.get(&BranchEquipmentKey::from_branch(branch))
}
pub fn insert(&mut self, branch: BranchEquipmentKey, ratings: Vec<ConditionalRating>) {
if let Some(entry) = self.entries.iter_mut().find(|entry| entry.branch == branch) {
entry.ratings = ratings;
} else {
self.entries
.push(BranchConditionalRatingEntry { branch, ratings });
}
}
pub fn insert_for_branch(&mut self, branch: &Branch, ratings: Vec<ConditionalRating>) {
self.insert(BranchEquipmentKey::from_branch(branch), ratings);
}
pub fn clear(&mut self) {
self.entries.clear();
}
pub fn extend(&mut self, other: Self) {
for entry in other.entries {
self.insert(entry.branch, entry.ratings);
}
for snapshot in other.base_thermal_ratings {
if !self
.base_thermal_ratings
.iter()
.any(|existing| existing.branch == snapshot.branch)
{
self.base_thermal_ratings.push(snapshot);
}
}
}
fn apply_to(&mut self, branches: &mut [Branch], active_conditions: &[String]) {
if self.entries.is_empty() {
return;
}
let branch_positions: HashMap<BranchEquipmentKey, usize> = branches
.iter()
.enumerate()
.map(|(idx, branch)| (BranchEquipmentKey::from_branch(branch), idx))
.collect();
for entry in &self.entries {
if let Some(&br_idx) = branch_positions.get(&entry.branch)
&& let Some(branch) = branches.get(br_idx)
&& !self
.base_thermal_ratings
.iter()
.any(|snapshot| snapshot.branch == entry.branch)
{
self.base_thermal_ratings.push(BranchThermalRatingSnapshot {
branch: entry.branch.clone(),
rating_a_mva: branch.rating_a_mva,
rating_c_mva: branch.rating_c_mva,
});
}
}
for snapshot in &self.base_thermal_ratings {
if let Some(&br_idx) = branch_positions.get(&snapshot.branch)
&& let Some(branch) = branches.get_mut(br_idx)
{
branch.rating_a_mva = snapshot.rating_a_mva;
branch.rating_c_mva = snapshot.rating_c_mva;
}
}
for entry in &self.entries {
let Some(&br_idx) = branch_positions.get(&entry.branch) else {
continue;
};
let Some(branch) = branches.get_mut(br_idx) else {
continue;
};
let matching: Vec<&ConditionalRating> = entry
.ratings
.iter()
.filter(|cr| active_conditions.iter().any(|c| c == &cr.condition_id))
.collect();
if matching.is_empty() {
continue;
}
if let Some(min_a) = matching
.iter()
.filter(|cr| cr.rating_a_mva > 0.0)
.map(|cr| cr.rating_a_mva)
.reduce(f64::min)
{
branch.rating_a_mva = min_a;
}
if let Some(min_c) = matching
.iter()
.filter(|cr| cr.rating_c_mva > 0.0)
.map(|cr| cr.rating_c_mva)
.reduce(f64::min)
{
branch.rating_c_mva = min_c;
}
}
}
fn reset_on(&mut self, branches: &mut [Branch]) {
let branch_positions: HashMap<BranchEquipmentKey, usize> = branches
.iter()
.enumerate()
.map(|(idx, branch)| (BranchEquipmentKey::from_branch(branch), idx))
.collect();
for snapshot in &self.base_thermal_ratings {
if let Some(&br_idx) = branch_positions.get(&snapshot.branch)
&& let Some(branch) = branches.get_mut(br_idx)
{
branch.rating_a_mva = snapshot.rating_a_mva;
branch.rating_c_mva = snapshot.rating_c_mva;
}
}
self.base_thermal_ratings.clear();
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PhaseImpedanceEntry {
pub row: u8,
pub col: u8,
pub r: f64,
pub x: f64,
pub b: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MutualCoupling {
pub line1_id: String,
pub line2_id: String,
pub r: f64,
pub x: f64,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct GeoPoint {
pub x: f64,
pub y: f64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct NetworkCimData {
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub per_length_phase_impedances: HashMap<String, Vec<PhaseImpedanceEntry>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mutual_couplings: Vec<MutualCoupling>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub grounding_impedances: Vec<GroundingEntry>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub geo_locations: HashMap<String, Vec<GeoPoint>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub measurements: Vec<CimMeasurement>,
#[serde(default, skip_serializing_if = "AssetCatalog::is_empty")]
pub asset_catalog: AssetCatalog,
#[serde(default, skip_serializing_if = "OperationalLimits::is_empty")]
pub operational_limits: OperationalLimits,
#[serde(default, skip_serializing_if = "BoundaryData::is_empty")]
pub boundary_data: BoundaryData,
#[serde(default, skip_serializing_if = "CgmesRoundtripData::is_empty")]
pub cgmes_roundtrip: CgmesRoundtripData,
#[serde(default, skip_serializing_if = "ProtectionData::is_empty")]
pub protection_data: ProtectionData,
#[serde(default, skip_serializing_if = "MarketData::is_empty")]
pub market_data: MarketData,
#[serde(default, skip_serializing_if = "NetworkOperationsData::is_empty")]
pub network_operations: NetworkOperationsData,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct NetworkMetadata {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub regions: Vec<Region>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub owners: Vec<Owner>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub voltage_droop_controls: Vec<VoltageDroopControl>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub switching_device_rating_sets: Vec<SwitchingDeviceRatingSet>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub scheduled_area_transfers: Vec<ScheduledAreaTransfer>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub impedance_corrections: Vec<ImpedanceCorrectionTable>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub multi_section_line_groups: Vec<MultiSectionLineGroup>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct NetworkMarketData {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dispatchable_loads: Vec<DispatchableLoad>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub pumped_hydro_units: Vec<PumpedHydroUnit>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub combined_cycle_plants: Vec<CombinedCyclePlant>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub outage_schedule: Vec<OutageEntry>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub reserve_zones: Vec<ReserveZone>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ambient: Option<AmbientConditions>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub emission_policy: Option<EmissionPolicy>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub market_rules: Option<MarketRules>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct NetworkControlData {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub switched_shunts: Vec<SwitchedShunt>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub switched_shunts_opf: Vec<SwitchedShuntOpf>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub oltc_specs: Vec<OltcSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub par_specs: Vec<ParSpec>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Network {
pub name: String,
pub base_mva: f64,
#[serde(default = "Network::default_freq_hz")]
pub freq_hz: f64,
pub buses: Vec<Bus>,
pub branches: Vec<Branch>,
pub generators: Vec<Generator>,
pub loads: Vec<Load>,
#[serde(default)]
pub controls: NetworkControlData,
#[serde(default, skip_serializing_if = "HvdcModel::is_empty")]
pub hvdc: HvdcModel,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub area_schedules: Vec<AreaSchedule>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub facts_devices: Vec<FactsDevice>,
#[serde(default)]
pub metadata: NetworkMetadata,
#[serde(default)]
pub cim: NetworkCimData,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub interfaces: Vec<Interface>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub flowgates: Vec<Flowgate>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub nomograms: Vec<crate::network::flowgate::OperatingNomogram>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub topology: Option<NodeBreakerTopology>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub induction_machines: Vec<InductionMachine>,
#[serde(default, skip_serializing_if = "BranchConditionalRatings::is_empty")]
pub conditional_limits: BranchConditionalRatings,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub breaker_ratings: Vec<BreakerRating>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fixed_shunts: Vec<FixedShunt>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub power_injections: Vec<PowerInjection>,
#[serde(default)]
pub market_data: NetworkMarketData,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConditionalRating {
pub condition_id: String,
pub rating_a_mva: f64,
pub rating_c_mva: f64,
}
pub(crate) trait HasCanonicalId {
fn canonical_id(&self) -> &str;
fn set_canonical_id(&mut self, id: String);
fn bus_number(&self) -> u32;
}
impl HasCanonicalId for Generator {
fn canonical_id(&self) -> &str {
&self.id
}
fn set_canonical_id(&mut self, id: String) {
self.id = id;
}
fn bus_number(&self) -> u32 {
self.bus
}
}
impl HasCanonicalId for Load {
fn canonical_id(&self) -> &str {
&self.id
}
fn set_canonical_id(&mut self, id: String) {
self.id = id;
}
fn bus_number(&self) -> u32 {
self.bus
}
}
impl HasCanonicalId for FixedShunt {
fn canonical_id(&self) -> &str {
&self.id
}
fn set_canonical_id(&mut self, id: String) {
self.id = id;
}
fn bus_number(&self) -> u32 {
self.bus
}
}
impl HasCanonicalId for SwitchedShunt {
fn canonical_id(&self) -> &str {
&self.id
}
fn set_canonical_id(&mut self, id: String) {
self.id = id;
}
fn bus_number(&self) -> u32 {
self.bus
}
}
impl HasCanonicalId for SwitchedShuntOpf {
fn canonical_id(&self) -> &str {
&self.id
}
fn set_canonical_id(&mut self, id: String) {
self.id = id;
}
fn bus_number(&self) -> u32 {
self.bus
}
}
fn canonicalize_ids(items: &mut [impl HasCanonicalId], prefix: &str) {
let mut used_ids = HashSet::new();
for item in items.iter_mut() {
let trimmed = item.canonical_id().trim().to_string();
if trimmed.is_empty() {
continue;
}
if item.canonical_id() != trimmed {
item.set_canonical_id(trimmed.clone());
}
used_ids.insert(trimmed);
}
let mut ordinal_by_bus: HashMap<u32, usize> = HashMap::new();
for item in items.iter_mut() {
let bus = item.bus_number();
let ordinal = ordinal_by_bus.entry(bus).or_insert(0);
*ordinal += 1;
if !item.canonical_id().trim().is_empty() {
continue;
}
let base = format!("{prefix}_{bus}_{ordinal}");
let mut candidate = base.clone();
let mut collision = 2usize;
while used_ids.contains(&candidate) {
candidate = format!("{base}_{collision}");
collision += 1;
}
used_ids.insert(candidate.clone());
item.set_canonical_id(candidate);
}
}
impl Default for Network {
fn default() -> Self {
Self {
name: String::new(),
base_mva: DEFAULT_BASE_MVA,
freq_hz: 60.0,
buses: Vec::new(),
branches: Vec::new(),
generators: Vec::new(),
loads: Vec::new(),
controls: NetworkControlData::default(),
hvdc: HvdcModel::default(),
area_schedules: Vec::new(),
facts_devices: Vec::new(),
metadata: NetworkMetadata::default(),
cim: NetworkCimData::default(),
interfaces: Vec::new(),
flowgates: Vec::new(),
nomograms: Vec::new(),
topology: None,
induction_machines: Vec::new(),
conditional_limits: BranchConditionalRatings::default(),
breaker_ratings: Vec::new(),
fixed_shunts: Vec::new(),
power_injections: Vec::new(),
market_data: NetworkMarketData::default(),
}
}
}
impl Network {
fn default_freq_hz() -> f64 {
60.0
}
pub fn new(name: &str) -> Self {
debug!(name, "creating new network");
Self {
name: name.to_string(),
..Default::default()
}
}
pub fn apply_conditional_limits(&mut self, active_conditions: &[String]) {
self.conditional_limits
.apply_to(&mut self.branches, active_conditions);
}
pub fn reset_conditional_limits(&mut self) {
self.conditional_limits.reset_on(&mut self.branches);
}
pub fn storage_generators(&self) -> impl Iterator<Item = (usize, &Generator)> {
self.generators
.iter()
.enumerate()
.filter(|(_, g)| g.is_storage())
}
pub fn n_buses(&self) -> usize {
self.buses.len()
}
pub fn n_branches(&self) -> usize {
self.branches.len()
}
pub fn max_bus_number(&self) -> u32 {
self.buses.iter().map(|b| b.number).max().unwrap_or(0)
}
pub fn n_generators(&self) -> usize {
self.generators.len()
}
pub fn n_generators_in_service(&self) -> usize {
self.generators.iter().filter(|g| g.in_service).count()
}
pub fn validate_structure(&self) -> Result<(), NetworkError> {
if self.buses.is_empty() {
return Err(NetworkError::EmptyNetwork);
}
if !self.base_mva.is_finite() || self.base_mva <= 0.0 {
return Err(NetworkError::InvalidBaseMva(self.base_mva));
}
let mut bus_numbers = std::collections::HashSet::new();
for bus in &self.buses {
if !bus_numbers.insert(bus.number) {
return Err(NetworkError::DuplicateBusNumber(bus.number));
}
}
if let Some(topology) = &self.topology {
match topology.status() {
TopologyMappingState::Missing => return Err(NetworkError::MissingTopologyMapping),
TopologyMappingState::Stale => return Err(NetworkError::StaleNodeBreakerTopology),
TopologyMappingState::Current => {}
}
}
for branch in &self.branches {
if !bus_numbers.contains(&branch.from_bus) {
return Err(NetworkError::InvalidBranchEndpoint {
branch_from: branch.from_bus,
branch_to: branch.to_bus,
missing_bus: branch.from_bus,
});
}
if !bus_numbers.contains(&branch.to_bus) {
return Err(NetworkError::InvalidBranchEndpoint {
branch_from: branch.from_bus,
branch_to: branch.to_bus,
missing_bus: branch.to_bus,
});
}
if branch.from_bus == branch.to_bus {
return Err(NetworkError::SelfLoopBranch(branch.from_bus));
}
}
let mut branch_keys = std::collections::HashSet::new();
for branch in &self.branches {
let key = BranchEquipmentKey::from_branch(branch);
if !branch_keys.insert(key) {
return Err(NetworkError::DuplicateBranchKey {
from_bus: branch.from_bus.min(branch.to_bus),
to_bus: branch.from_bus.max(branch.to_bus),
circuit: branch.circuit.clone(),
});
}
}
self.validate_interface_definitions()?;
self.validate_internal_control_indices()?;
self.validate_hvdc_structure(&bus_numbers)?;
for load in &self.loads {
if !bus_numbers.contains(&load.bus) {
return Err(NetworkError::InvalidLoadBus(load.bus));
}
}
for injection in &self.power_injections {
if !bus_numbers.contains(&injection.bus) {
return Err(NetworkError::InvalidPowerInjectionBus(injection.bus));
}
}
for shunt in &self.fixed_shunts {
if !bus_numbers.contains(&shunt.bus) {
return Err(NetworkError::InvalidFixedShuntBus(shunt.bus));
}
}
for resource in &self.market_data.dispatchable_loads {
if !bus_numbers.contains(&resource.bus) {
return Err(NetworkError::InvalidDispatchableLoadBus(resource.bus));
}
}
let mut generator_ids = std::collections::HashSet::new();
for g in &self.generators {
if !bus_numbers.contains(&g.bus) {
return Err(NetworkError::InvalidGeneratorBus(g.bus));
}
if let Some(reg_bus) = g.reg_bus
&& !bus_numbers.contains(®_bus)
{
return Err(NetworkError::InvalidGeneratorRegulatedBus {
bus: g.bus,
reg_bus,
});
}
let canonical_id = g.id.trim();
if !canonical_id.is_empty() && !generator_ids.insert(canonical_id.to_string()) {
return Err(NetworkError::DuplicateGeneratorId {
id: canonical_id.to_string(),
});
}
if g.pmin > g.pmax {
return Err(NetworkError::InvalidGeneratorLimits { bus: g.bus });
}
}
for g in &self.generators {
if let Some(storage) = &g.storage {
storage
.validate()
.map_err(|source| NetworkError::InvalidStorageParameters {
bus: g.bus,
source,
})?;
}
}
Ok(())
}
pub fn validate_for_solve(&self) -> Result<(), NetworkError> {
self.validate_structure()?;
self.validate_hvdc_solve_contract()?;
self.validate_area_schedules()?;
self.validate_component_slack()?;
self.validate_numerics_for_solve()?;
Ok(())
}
pub fn validate_for_dc_solve(&self) -> Result<(), NetworkError> {
self.validate_structure()?;
self.validate_hvdc_solve_contract()?;
self.validate_area_schedules()?;
self.validate_component_slack()?;
self.validate_numerics_for_dc_solve()?;
Ok(())
}
pub fn validate(&self) -> Result<(), NetworkError> {
self.validate_for_solve()
}
fn validate_component_slack(&self) -> Result<(), NetworkError> {
let bus_index = self.bus_index_map();
let mut adjacency: Vec<Vec<usize>> = vec![Vec::new(); self.buses.len()];
let mut electrically_active = vec![false; self.buses.len()];
for (idx, bus) in self.buses.iter().enumerate() {
electrically_active[idx] =
bus.shunt_conductance_mw != 0.0 || bus.shunt_susceptance_mvar != 0.0;
}
let mark_active_bus_number = |bus_number: u32, active: &mut [bool]| {
if let Some(&idx) = bus_index.get(&bus_number) {
active[idx] = true;
}
};
for load in self.loads.iter().filter(|load| load.in_service) {
mark_active_bus_number(load.bus, &mut electrically_active);
}
for generator in self
.generators
.iter()
.filter(|generator| generator.in_service)
{
mark_active_bus_number(generator.bus, &mut electrically_active);
}
let regulating_targets: HashMap<u32, usize> = self
.generators
.iter()
.filter(|generator| generator.can_voltage_regulate())
.fold(HashMap::new(), |mut counts, generator| {
let target_bus = generator.reg_bus.unwrap_or(generator.bus);
*counts.entry(target_bus).or_insert(0) += 1;
counts
});
for injection in self
.power_injections
.iter()
.filter(|injection| injection.in_service)
{
mark_active_bus_number(injection.bus, &mut electrically_active);
}
for shunt in self.fixed_shunts.iter().filter(|shunt| shunt.in_service) {
mark_active_bus_number(shunt.bus, &mut electrically_active);
}
for shunt in &self.controls.switched_shunts {
if shunt.b_injected() != 0.0
&& let Some(&idx) = bus_index.get(&shunt.bus)
&& let Some(active) = electrically_active.get_mut(idx)
{
*active = true;
}
}
for shunt in &self.controls.switched_shunts_opf {
if shunt.b_init_pu != 0.0
&& let Some(&idx) = bus_index.get(&shunt.bus)
&& let Some(active) = electrically_active.get_mut(idx)
{
*active = true;
}
}
for branch in self.branches.iter().filter(|br| br.in_service) {
let Some(&from_idx) = bus_index.get(&branch.from_bus) else {
continue;
};
let Some(&to_idx) = bus_index.get(&branch.to_bus) else {
continue;
};
adjacency[from_idx].push(to_idx);
adjacency[to_idx].push(from_idx);
}
let mut visited = vec![false; self.buses.len()];
for start_idx in 0..self.buses.len() {
if visited[start_idx] {
continue;
}
if self.buses[start_idx].bus_type == BusType::Isolated {
visited[start_idx] = true;
let bus_number = self.buses[start_idx].number;
if !adjacency[start_idx].is_empty() || electrically_active[start_idx] {
return Err(NetworkError::InvalidIsolatedBusConnectivity { bus: bus_number });
}
continue;
}
let mut stack = vec![start_idx];
let mut component = Vec::new();
while let Some(idx) = stack.pop() {
if visited[idx] {
continue;
}
if self.buses[idx].bus_type == BusType::Isolated {
return Err(NetworkError::InvalidIsolatedBusConnectivity {
bus: self.buses[idx].number,
});
}
visited[idx] = true;
component.push(idx);
for &next in &adjacency[idx] {
if !visited[next] {
stack.push(next);
}
}
}
if component.is_empty() {
continue;
}
let buses: Vec<u32> = component
.iter()
.map(|&idx| self.buses[idx].number)
.collect();
let slack_buses: Vec<u32> = component
.iter()
.filter(|&&idx| self.buses[idx].bus_type == BusType::Slack)
.map(|&idx| self.buses[idx].number)
.collect();
if slack_buses.len() != 1 {
return Err(NetworkError::InvalidSlackPlacement { buses, slack_buses });
}
for &idx in &component {
let bus = &self.buses[idx];
let reg_count = regulating_targets.get(&bus.number).copied().unwrap_or(0);
match bus.bus_type {
BusType::Slack if reg_count == 0 => {
return Err(NetworkError::InvalidSlackPlacement { buses, slack_buses });
}
BusType::PV if reg_count == 0 => {
return Err(NetworkError::InvalidGeneratorField {
bus: bus.number,
field: "voltage_regulated",
value: 0.0,
});
}
_ => {}
}
}
}
Ok(())
}
fn validate_area_schedules(&self) -> Result<(), NetworkError> {
let bus_index = self.bus_index_map();
let mut seen_areas = HashSet::new();
for area in &self.area_schedules {
if !seen_areas.insert(area.number) {
return Err(NetworkError::DuplicateAreaScheduleNumber(area.number));
}
if area.slack_bus == 0 || !bus_index.contains_key(&area.slack_bus) {
return Err(NetworkError::InvalidAreaScheduleSlackBus {
area: area.number,
slack_bus: area.slack_bus,
});
}
if !area.p_desired_mw.is_finite() {
return Err(NetworkError::InvalidAreaScheduleField {
area: area.number,
field: "p_desired_mw",
value: area.p_desired_mw,
});
}
if !area.p_tolerance_mw.is_finite() || area.p_tolerance_mw < 0.0 {
return Err(NetworkError::InvalidAreaScheduleField {
area: area.number,
field: "p_tolerance_mw",
value: area.p_tolerance_mw,
});
}
}
Ok(())
}
fn validate_numerics_for_solve(&self) -> Result<(), NetworkError> {
for bus in &self.buses {
if !bus.shunt_conductance_mw.is_finite() {
return Err(NetworkError::InvalidBusField {
bus: bus.number,
field: "shunt_conductance_mw",
value: bus.shunt_conductance_mw,
});
}
if !bus.shunt_susceptance_mvar.is_finite() {
return Err(NetworkError::InvalidBusField {
bus: bus.number,
field: "shunt_susceptance_mvar",
value: bus.shunt_susceptance_mvar,
});
}
if !bus.voltage_magnitude_pu.is_finite() || !bus.voltage_angle_rad.is_finite() {
return Err(NetworkError::NonFiniteBusVoltage(bus.number));
}
if !bus.voltage_min_pu.is_finite()
|| !bus.voltage_max_pu.is_finite()
|| bus.voltage_min_pu > bus.voltage_max_pu
{
return Err(NetworkError::InvalidBusVoltageBounds(
bus.number,
bus.voltage_min_pu,
bus.voltage_max_pu,
));
}
}
for load in &self.loads {
for (field, value) in [
("active_power_demand_mw", load.active_power_demand_mw),
(
"reactive_power_demand_mvar",
load.reactive_power_demand_mvar,
),
("zip_p_impedance_frac", load.zip_p_impedance_frac),
("zip_p_current_frac", load.zip_p_current_frac),
("zip_p_power_frac", load.zip_p_power_frac),
("zip_q_impedance_frac", load.zip_q_impedance_frac),
("zip_q_current_frac", load.zip_q_current_frac),
("zip_q_power_frac", load.zip_q_power_frac),
(
"freq_sensitivity_p_pct_per_hz",
load.freq_sensitivity_p_pct_per_hz,
),
(
"freq_sensitivity_q_pct_per_hz",
load.freq_sensitivity_q_pct_per_hz,
),
("frac_motor_a", load.frac_motor_a),
("frac_motor_b", load.frac_motor_b),
("frac_motor_c", load.frac_motor_c),
("frac_motor_d", load.frac_motor_d),
("frac_electronic", load.frac_electronic),
("frac_static", load.frac_static),
] {
if !value.is_finite() || (field.ends_with("_frac") && !(0.0..=1.0).contains(&value))
{
return Err(NetworkError::InvalidLoadField {
bus: load.bus,
field,
value,
});
}
}
}
for injection in &self.power_injections {
for (field, value) in [
(
"active_power_injection_mw",
injection.active_power_injection_mw,
),
(
"reactive_power_injection_mvar",
injection.reactive_power_injection_mvar,
),
] {
if !value.is_finite() {
return Err(NetworkError::InvalidPowerInjectionField {
bus: injection.bus,
field,
value,
});
}
}
}
for shunt in &self.fixed_shunts {
for (field, value) in [("g_mw", shunt.g_mw), ("b_mvar", shunt.b_mvar)] {
if !value.is_finite() {
return Err(NetworkError::InvalidFixedShuntField {
bus: shunt.bus,
field,
value,
});
}
}
if let Some(rated_kv) = shunt.rated_kv {
if !rated_kv.is_finite() {
return Err(NetworkError::InvalidFixedShuntField {
bus: shunt.bus,
field: "rated_kv",
value: rated_kv,
});
}
}
if let Some(rated_mvar) = shunt.rated_mvar {
if !rated_mvar.is_finite() {
return Err(NetworkError::InvalidFixedShuntField {
bus: shunt.bus,
field: "rated_mvar",
value: rated_mvar,
});
}
}
}
for g in &self.generators {
for (field, value) in [
("p", g.p),
("q", g.q),
("voltage_setpoint_pu", g.voltage_setpoint_pu),
("machine_base_mva", g.machine_base_mva),
] {
if !value.is_finite() {
return Err(NetworkError::InvalidGeneratorField {
bus: g.bus,
field,
value,
});
}
}
if g.machine_base_mva <= 0.0 {
return Err(NetworkError::InvalidGeneratorField {
bus: g.bus,
field: "machine_base_mva",
value: g.machine_base_mva,
});
}
if g.voltage_setpoint_pu <= 0.0 {
return Err(NetworkError::InvalidGeneratorField {
bus: g.bus,
field: "voltage_setpoint_pu",
value: g.voltage_setpoint_pu,
});
}
for (field, value) in [("pmin", g.pmin), ("qmin", g.qmin)] {
if !is_valid_lower_bound(value) {
return Err(NetworkError::InvalidGeneratorField {
bus: g.bus,
field,
value,
});
}
}
for (field, value) in [("pmax", g.pmax), ("qmax", g.qmax)] {
if !is_valid_upper_bound(value) {
return Err(NetworkError::InvalidGeneratorField {
bus: g.bus,
field,
value,
});
}
}
if g.pmin > g.pmax {
return Err(NetworkError::InvalidGeneratorLimits { bus: g.bus });
}
if g.qmin > g.qmax {
return Err(NetworkError::InvalidGeneratorReactiveLimits { bus: g.bus });
}
if let Some(apf) = g.agc_participation_factor {
if !apf.is_finite() || apf < 0.0 {
return Err(NetworkError::InvalidGeneratorField {
bus: g.bus,
field: "agc_participation_factor",
value: apf,
});
}
}
if let Some(forced_outage_rate) = g.forced_outage_rate {
if !forced_outage_rate.is_finite() || !(0.0..=1.0).contains(&forced_outage_rate) {
return Err(NetworkError::InvalidGeneratorField {
bus: g.bus,
field: "forced_outage_rate",
value: forced_outage_rate,
});
}
}
}
for branch in &self.branches {
if branch.tap < 0.0 {
return Err(NetworkError::InvalidBranchField {
from_bus: branch.from_bus,
to_bus: branch.to_bus,
field: "tap",
value: branch.tap,
});
}
for (field, value) in [
("r", branch.r),
("x", branch.x),
("b", branch.b),
("g_pi", branch.g_pi),
("tap", branch.tap),
("phase_shift_rad", branch.phase_shift_rad),
("g_mag", branch.g_mag),
("b_mag", branch.b_mag),
] {
if !value.is_finite() {
return Err(NetworkError::InvalidBranchField {
from_bus: branch.from_bus,
to_bus: branch.to_bus,
field,
value,
});
}
}
for (field, value) in [
("rating_a_mva", branch.rating_a_mva),
("rating_b_mva", branch.rating_b_mva),
("rating_c_mva", branch.rating_c_mva),
] {
if !is_valid_upper_bound(value) {
return Err(NetworkError::InvalidBranchField {
from_bus: branch.from_bus,
to_bus: branch.to_bus,
field,
value,
});
}
}
if let (Some(min), Some(max)) = (branch.angle_diff_min_rad, branch.angle_diff_max_rad) {
if !is_valid_lower_bound(min) || !is_valid_upper_bound(max) || min > max {
return Err(NetworkError::InvalidBranchAngleBounds {
from_bus: branch.from_bus,
to_bus: branch.to_bus,
min_rad: branch.angle_diff_min_rad,
max_rad: branch.angle_diff_max_rad,
});
}
}
if let Some(min) = branch.angle_diff_min_rad {
if !is_valid_lower_bound(min) {
return Err(NetworkError::InvalidBranchField {
from_bus: branch.from_bus,
to_bus: branch.to_bus,
field: "angle_diff_min_rad",
value: min,
});
}
}
if let Some(max) = branch.angle_diff_max_rad {
if !is_valid_upper_bound(max) {
return Err(NetworkError::InvalidBranchField {
from_bus: branch.from_bus,
to_bus: branch.to_bus,
field: "angle_diff_max_rad",
value: max,
});
}
}
}
Ok(())
}
fn validate_numerics_for_dc_solve(&self) -> Result<(), NetworkError> {
for bus in &self.buses {
for (field, value) in [
("shunt_conductance_mw", bus.shunt_conductance_mw),
("shunt_susceptance_mvar", bus.shunt_susceptance_mvar),
] {
if !value.is_finite() {
return Err(NetworkError::InvalidBusField {
bus: bus.number,
field,
value,
});
}
}
}
for load in &self.loads {
for (field, value) in [
("active_power_demand_mw", load.active_power_demand_mw),
(
"reactive_power_demand_mvar",
load.reactive_power_demand_mvar,
),
] {
if !value.is_finite() {
return Err(NetworkError::InvalidLoadField {
bus: load.bus,
field,
value,
});
}
}
}
for injection in &self.power_injections {
for (field, value) in [
(
"active_power_injection_mw",
injection.active_power_injection_mw,
),
(
"reactive_power_injection_mvar",
injection.reactive_power_injection_mvar,
),
] {
if !value.is_finite() {
return Err(NetworkError::InvalidPowerInjectionField {
bus: injection.bus,
field,
value,
});
}
}
}
for g in &self.generators {
if !g.p.is_finite() {
return Err(NetworkError::InvalidGeneratorField {
bus: g.bus,
field: "p",
value: g.p,
});
}
for (field, value) in [("pmin", g.pmin)] {
if !is_valid_lower_bound(value) {
return Err(NetworkError::InvalidGeneratorField {
bus: g.bus,
field,
value,
});
}
}
for (field, value) in [("pmax", g.pmax)] {
if !is_valid_upper_bound(value) {
return Err(NetworkError::InvalidGeneratorField {
bus: g.bus,
field,
value,
});
}
}
if g.pmin > g.pmax {
return Err(NetworkError::InvalidGeneratorLimits { bus: g.bus });
}
if let Some(apf) = g.agc_participation_factor {
if !apf.is_finite() || apf < 0.0 {
return Err(NetworkError::InvalidGeneratorField {
bus: g.bus,
field: "agc_participation_factor",
value: apf,
});
}
}
}
for branch in &self.branches {
if branch.tap < 0.0 {
return Err(NetworkError::InvalidBranchField {
from_bus: branch.from_bus,
to_bus: branch.to_bus,
field: "tap",
value: branch.tap,
});
}
for (field, value) in [
("x", branch.x),
("tap", branch.tap),
("phase_shift_rad", branch.phase_shift_rad),
] {
if !value.is_finite() {
return Err(NetworkError::InvalidBranchField {
from_bus: branch.from_bus,
to_bus: branch.to_bus,
field,
value,
});
}
}
for (field, value) in [
("rating_a_mva", branch.rating_a_mva),
("rating_b_mva", branch.rating_b_mva),
("rating_c_mva", branch.rating_c_mva),
] {
if !is_valid_upper_bound(value) {
return Err(NetworkError::InvalidBranchField {
from_bus: branch.from_bus,
to_bus: branch.to_bus,
field,
value,
});
}
}
}
Ok(())
}
pub fn canonicalize_branch_circuit_ids(&mut self) {
let mut used = HashSet::new();
let mut next_suffix_by_key: HashMap<(u32, u32, String), usize> = HashMap::new();
for branch in &mut self.branches {
let base_circuit = {
let trimmed = branch.circuit.trim();
if trimmed.is_empty() {
"1".to_string()
} else {
trimmed.to_string()
}
};
let base_key =
BranchEquipmentKey::new(branch.from_bus, branch.to_bus, base_circuit.clone());
let counter_key = (base_key.bus_a, base_key.bus_b, base_circuit.clone());
if used.insert(base_key) {
branch.circuit = base_circuit;
next_suffix_by_key.entry(counter_key).or_insert(2);
continue;
}
let next_suffix = next_suffix_by_key.entry(counter_key).or_insert(2);
loop {
let candidate = format!("{base_circuit}#{}", *next_suffix);
*next_suffix += 1;
let candidate_key =
BranchEquipmentKey::new(branch.from_bus, branch.to_bus, candidate.clone());
if used.insert(candidate_key) {
branch.circuit = candidate;
break;
}
}
}
}
pub fn canonicalize_generator_ids(&mut self) {
canonicalize_ids(&mut self.generators, "gen");
}
pub fn canonicalize_load_ids(&mut self) {
canonicalize_ids(&mut self.loads, "load");
}
pub fn canonicalize_shunt_ids(&mut self) {
canonicalize_ids(&mut self.fixed_shunts, "shunt");
}
pub fn canonicalize_switched_shunt_ids(&mut self) {
canonicalize_ids(&mut self.controls.switched_shunts, "switched_shunt");
canonicalize_ids(&mut self.controls.switched_shunts_opf, "switched_shunt_opf");
}
pub fn canonicalize_hvdc_converter_ids(&mut self) {
self.hvdc.canonicalize_converter_ids();
}
pub fn canonicalize_dispatchable_load_ids(&mut self) {
struct DlWrapper<'a> {
inner: &'a mut DispatchableLoad,
}
impl HasCanonicalId for DlWrapper<'_> {
fn canonical_id(&self) -> &str {
&self.inner.resource_id
}
fn set_canonical_id(&mut self, id: String) {
self.inner.resource_id = id;
}
fn bus_number(&self) -> u32 {
self.inner.bus
}
}
let mut wrappers: Vec<DlWrapper> = self
.market_data
.dispatchable_loads
.iter_mut()
.map(|dl| DlWrapper { inner: dl })
.collect();
canonicalize_ids(&mut wrappers, "dispatchable_load");
}
pub fn canonicalize_runtime_identities(&mut self) {
self.canonicalize_runtime_bus_types();
self.canonicalize_branch_circuit_ids();
self.canonicalize_generator_ids();
self.canonicalize_switched_shunt_ids();
self.canonicalize_hvdc_converter_ids();
}
fn canonicalize_runtime_bus_types(&mut self) {
let regulating_targets: HashSet<u32> = self
.generators
.iter()
.filter(|generator| generator.can_voltage_regulate())
.map(|generator| generator.reg_bus.unwrap_or(generator.bus))
.collect();
for bus in &mut self.buses {
if bus.bus_type == BusType::PV && !regulating_targets.contains(&bus.number) {
bus.bus_type = BusType::PQ;
}
}
}
pub fn bus_index_map(&self) -> HashMap<u32, usize> {
self.buses
.iter()
.enumerate()
.map(|(i, b)| (b.number, i))
.collect()
}
pub fn gen_index_by_id(&self) -> HashMap<String, usize> {
let mut map = HashMap::new();
for (i, g) in self.generators.iter().enumerate() {
map.entry(g.id.trim().to_string()).or_insert(i);
}
map
}
pub fn find_gen_index_by_id(&self, id: &str) -> Option<usize> {
let canonical = id.trim();
self.generators
.iter()
.position(|g| g.id.trim() == canonical)
}
pub fn gen_index_map(&self) -> HashMap<(u32, Option<String>), usize> {
let mut map = HashMap::new();
for (i, g) in self.generators.iter().enumerate() {
map.entry((g.bus, g.machine_id.clone())).or_insert(i);
}
map
}
pub fn find_gen_index(&self, bus: u32, machine_id: Option<&str>) -> Option<usize> {
match machine_id {
Some(mid) => self
.generators
.iter()
.position(|g| g.bus == bus && g.machine_id.as_deref().unwrap_or("1") == mid),
None => self.generators.iter().position(|g| g.bus == bus),
}
}
pub fn branch_index_map(&self) -> HashMap<(u32, u32, String), usize> {
let mut map = HashMap::new();
for (i, b) in self.branches.iter().enumerate() {
map.entry((b.from_bus, b.to_bus, b.circuit.clone()))
.or_insert(i);
}
map
}
pub fn find_branch_index(&self, from_bus: u32, to_bus: u32, circuit: &str) -> Option<usize> {
self.branches.iter().position(|branch| {
let same_direction = branch.from_bus == from_bus && branch.to_bus == to_bus;
let reverse_direction = branch.from_bus == to_bus && branch.to_bus == from_bus;
(same_direction || reverse_direction) && branch.circuit == circuit
})
}
pub fn load_index_by_id(&self) -> HashMap<String, usize> {
let mut map = HashMap::new();
for (i, load) in self.loads.iter().enumerate() {
map.entry(load.id.clone()).or_insert(i);
}
map
}
pub fn find_load_index_by_id(&self, id: &str) -> Option<usize> {
self.loads.iter().position(|load| load.id == id)
}
pub fn find_load_index(&self, bus: u32, id: Option<&str>) -> Option<usize> {
match id {
Some(load_id) => self
.loads
.iter()
.position(|load| load.bus == bus && load.id == load_id),
None => self.loads.iter().position(|load| load.bus == bus),
}
}
pub fn shunt_index_by_id(&self) -> HashMap<String, usize> {
let mut map = HashMap::new();
for (i, shunt) in self.fixed_shunts.iter().enumerate() {
map.entry(shunt.id.clone()).or_insert(i);
}
map
}
pub fn find_shunt_index_by_id(&self, id: &str) -> Option<usize> {
self.fixed_shunts.iter().position(|shunt| shunt.id == id)
}
pub fn find_shunt_index(&self, bus: u32, id: Option<&str>) -> Option<usize> {
match id {
Some(shunt_id) => self
.fixed_shunts
.iter()
.position(|shunt| shunt.bus == bus && shunt.id == shunt_id),
None => self.fixed_shunts.iter().position(|shunt| shunt.bus == bus),
}
}
pub fn find_hvdc_link_index_by_name(&self, name: &str) -> Option<usize> {
self.hvdc.links.iter().position(|link| link.name() == name)
}
fn validate_interface_definitions(&self) -> Result<(), NetworkError> {
let existing_branches: HashSet<_> = self
.branches
.iter()
.map(crate::network::BranchRef::from)
.collect();
for interface in &self.interfaces {
if interface.members.is_empty() {
return Err(NetworkError::InvalidInterfaceDefinition {
name: interface.name.clone(),
detail: "interface has no weighted branch members".to_string(),
});
}
for member in &interface.members {
if !member.coefficient.is_finite() {
return Err(NetworkError::InvalidInterfaceDefinition {
name: interface.name.clone(),
detail: format!(
"interface member ({}, {}, {}) has non-finite coefficient {}",
member.branch.from_bus,
member.branch.to_bus,
member.branch.circuit,
member.coefficient
),
});
}
if !existing_branches.contains(&member.branch) {
return Err(NetworkError::InvalidInterfaceDefinition {
name: interface.name.clone(),
detail: format!(
"interface references missing branch ({}, {}, {})",
member.branch.from_bus, member.branch.to_bus, member.branch.circuit
),
});
}
}
}
for flowgate in &self.flowgates {
if flowgate.monitored.is_empty() && flowgate.contingency_branch.is_none() {
return Err(NetworkError::InvalidFlowgateDefinition {
name: flowgate.name.clone(),
detail: "flowgate has neither monitored members nor a contingency branch"
.to_string(),
});
}
for member in &flowgate.monitored {
if !member.coefficient.is_finite() {
return Err(NetworkError::InvalidFlowgateDefinition {
name: flowgate.name.clone(),
detail: format!(
"flowgate monitored branch ({}, {}, {}) has non-finite coefficient {}",
member.branch.from_bus,
member.branch.to_bus,
member.branch.circuit,
member.coefficient
),
});
}
if !existing_branches.contains(&member.branch) {
return Err(NetworkError::InvalidFlowgateDefinition {
name: flowgate.name.clone(),
detail: format!(
"flowgate references missing monitored branch ({}, {}, {})",
member.branch.from_bus, member.branch.to_bus, member.branch.circuit
),
});
}
}
if let Some(branch) = &flowgate.contingency_branch
&& !existing_branches.contains(branch)
{
return Err(NetworkError::InvalidFlowgateDefinition {
name: flowgate.name.clone(),
detail: format!(
"flowgate references missing contingency branch ({}, {}, {})",
branch.from_bus, branch.to_bus, branch.circuit
),
});
}
}
Ok(())
}
fn validate_internal_control_indices(&self) -> Result<(), NetworkError> {
let bus_numbers: HashSet<u32> = self.buses.iter().map(|bus| bus.number).collect();
for shunt in &self.controls.switched_shunts {
if !bus_numbers.contains(&shunt.bus) {
return Err(NetworkError::InvalidSwitchedShuntBus {
id: shunt.id.clone(),
bus: shunt.bus,
});
}
if !bus_numbers.contains(&shunt.bus_regulated) {
return Err(NetworkError::InvalidSwitchedShuntRegulatedBus {
id: shunt.id.clone(),
bus: shunt.bus_regulated,
});
}
}
for shunt in &self.controls.switched_shunts_opf {
if !bus_numbers.contains(&shunt.bus) {
return Err(NetworkError::InvalidSwitchedShuntOpfBus {
id: shunt.id.clone(),
bus: shunt.bus,
});
}
}
Ok(())
}
fn validate_hvdc_structure(&self, bus_numbers: &HashSet<u32>) -> Result<(), NetworkError> {
let mut link_names = HashSet::new();
for link in &self.hvdc.links {
let name = link.name().trim().to_string();
if !name.is_empty() && !link_names.insert(name.clone()) {
return Err(NetworkError::DuplicateHvdcLinkName { name });
}
match link {
crate::network::HvdcLink::Lcc(link) => {
for terminal in [&link.rectifier, &link.inverter] {
if !bus_numbers.contains(&terminal.bus) {
return Err(NetworkError::InvalidHvdcLinkEndpoint {
name: link.name.clone(),
bus: terminal.bus,
});
}
}
}
crate::network::HvdcLink::Vsc(link) => {
for terminal in [&link.converter1, &link.converter2] {
if !bus_numbers.contains(&terminal.bus) {
return Err(NetworkError::InvalidHvdcLinkEndpoint {
name: link.name.clone(),
bus: terminal.bus,
});
}
}
}
}
}
let mut grid_ids = HashSet::new();
for grid in self.hvdc.dc_grids.iter().filter(|grid| !grid.is_empty()) {
if !grid_ids.insert(grid.id) {
return Err(NetworkError::DuplicateDcGridId { id: grid.id });
}
let mut dc_bus_ids = HashSet::new();
for bus in &grid.buses {
if !dc_bus_ids.insert(bus.bus_id) {
return Err(NetworkError::DuplicateDcBusId {
grid_id: grid.id,
bus_id: bus.bus_id,
});
}
}
for converter in &grid.converters {
if !bus_numbers.contains(&converter.ac_bus()) {
return Err(NetworkError::InvalidDcConverterAcBus {
grid_id: grid.id,
ac_bus: converter.ac_bus(),
});
}
if !dc_bus_ids.contains(&converter.dc_bus()) {
return Err(NetworkError::InvalidDcConverterDcBus {
grid_id: grid.id,
dc_bus: converter.dc_bus(),
});
}
}
for branch in &grid.branches {
if !dc_bus_ids.contains(&branch.from_bus) {
return Err(NetworkError::InvalidDcBranchEndpoint {
grid_id: grid.id,
from_bus: branch.from_bus,
to_bus: branch.to_bus,
missing_bus: branch.from_bus,
});
}
if !dc_bus_ids.contains(&branch.to_bus) {
return Err(NetworkError::InvalidDcBranchEndpoint {
grid_id: grid.id,
from_bus: branch.from_bus,
to_bus: branch.to_bus,
missing_bus: branch.to_bus,
});
}
}
}
Ok(())
}
fn validate_hvdc_solve_contract(&self) -> Result<(), NetworkError> {
if self.hvdc.has_point_to_point_links() && self.hvdc.has_explicit_dc_topology() {
return Err(NetworkError::MixedHvdcRepresentation);
}
Ok(())
}
pub fn find_pumped_hydro_index_by_name(&self, name: &str) -> Option<usize> {
self.market_data
.pumped_hydro_units
.iter()
.position(|unit| unit.name == name)
}
pub fn find_dispatchable_load_index(
&self,
resource_id: &str,
bus: Option<u32>,
) -> Option<usize> {
self.market_data
.dispatchable_loads
.iter()
.enumerate()
.find_map(|(index, resource)| {
if resource.resource_id != resource_id {
return None;
}
if bus.is_none_or(|value| resource.bus == value) {
Some(index)
} else {
None
}
})
}
pub fn find_combined_cycle_index_by_name(&self, name: &str) -> Option<usize> {
self.market_data
.combined_cycle_plants
.iter()
.position(|plant| plant.name == name)
}
pub fn slack_bus_index(&self) -> Option<usize> {
self.buses.iter().position(|b| b.bus_type == BusType::Slack)
}
pub fn slack_buses(&self) -> Vec<&Bus> {
self.buses
.iter()
.filter(|b| b.bus_type == BusType::Slack)
.collect()
}
pub fn agc_participation_by_bus(&self) -> Vec<(usize, f64)> {
let bus_map = self.bus_index_map();
let mut by_bus = vec![0.0f64; self.n_buses()];
for g in self.generators.iter().filter(|g| g.in_service) {
if let Some(apf) = g.agc_participation_factor {
if apf > 0.0 && apf.is_finite() {
if let Some(&idx) = bus_map.get(&g.bus) {
by_bus[idx] += apf;
}
}
}
}
by_bus
.into_iter()
.enumerate()
.filter(|&(_, w)| w > 0.0)
.collect()
}
pub fn bus_load_p_mw(&self) -> Vec<f64> {
self.bus_load_p_mw_with_map(&self.bus_index_map())
}
pub fn bus_load_q_mvar(&self) -> Vec<f64> {
self.bus_load_q_mvar_with_map(&self.bus_index_map())
}
pub fn bus_load_p_mw_with_map(&self, bus_map: &HashMap<u32, usize>) -> Vec<f64> {
let mut demand = vec![0.0; self.buses.len()];
for load in &self.loads {
if load.in_service {
if let Some(&idx) = bus_map.get(&load.bus) {
demand[idx] += load.active_power_demand_mw;
}
}
}
for injection in &self.power_injections {
if injection.in_service {
if let Some(&idx) = bus_map.get(&injection.bus) {
demand[idx] -= injection.active_power_injection_mw;
}
}
}
demand
}
pub fn bus_load_q_mvar_with_map(&self, bus_map: &HashMap<u32, usize>) -> Vec<f64> {
let mut demand = vec![0.0; self.buses.len()];
for load in &self.loads {
if load.in_service {
if let Some(&idx) = bus_map.get(&load.bus) {
demand[idx] += load.reactive_power_demand_mvar;
}
}
}
for injection in &self.power_injections {
if injection.in_service {
if let Some(&idx) = bus_map.get(&injection.bus) {
demand[idx] -= injection.reactive_power_injection_mvar;
}
}
}
demand
}
pub fn bus_p_injection_pu(&self) -> Vec<f64> {
self.bus_p_injection_pu_with_map(&self.bus_index_map())
}
pub fn bus_p_injection_pu_with_map(&self, bus_map: &HashMap<u32, usize>) -> Vec<f64> {
let n = self.buses.len();
let demand = self.bus_load_p_mw_with_map(bus_map);
let mut p_inj = vec![0.0; n];
for (i, d) in demand.iter().enumerate() {
p_inj[i] -= d / self.base_mva;
}
for g in &self.generators {
if g.in_service
&& let Some(&idx) = bus_map.get(&g.bus)
{
p_inj[idx] += g.p / self.base_mva;
}
}
p_inj
}
pub fn bus_q_injection_pu(&self) -> Vec<f64> {
self.bus_q_injection_pu_with_map(&self.bus_index_map())
}
pub fn bus_q_injection_pu_with_map(&self, bus_map: &HashMap<u32, usize>) -> Vec<f64> {
let n = self.buses.len();
let demand = self.bus_load_q_mvar_with_map(bus_map);
let mut q_inj = vec![0.0; n];
for (i, d) in demand.iter().enumerate() {
q_inj[i] -= d / self.base_mva;
}
for g in &self.generators {
if g.in_service
&& let Some(&idx) = bus_map.get(&g.bus)
{
q_inj[idx] += g.q / self.base_mva;
}
}
q_inj
}
pub fn total_generation_mw(&self) -> f64 {
self.generators
.iter()
.filter(|g| g.in_service)
.map(|g| g.p)
.sum()
}
pub fn total_load_mw(&self) -> f64 {
self.loads
.iter()
.filter(|l| l.in_service)
.map(|l| l.active_power_demand_mw)
.sum()
}
pub fn rebuild_bus_state_from_explicit_equipment(&mut self) {
self.rebuild_bus_state_from_explicit_equipment_with_map(&self.bus_index_map());
}
pub fn rebuild_bus_state_from_explicit_equipment_with_map(
&mut self,
bus_map: &HashMap<u32, usize>,
) {
for bus in &mut self.buses {
bus.shunt_conductance_mw = 0.0;
bus.shunt_susceptance_mvar = 0.0;
}
for shunt in &self.fixed_shunts {
if shunt.in_service
&& let Some(&idx) = bus_map.get(&shunt.bus)
{
self.buses[idx].shunt_conductance_mw += shunt.g_mw;
self.buses[idx].shunt_susceptance_mvar += shunt.b_mvar;
}
}
}
pub fn scale_loads(&mut self, factor: f64, area: Option<u32>) {
self.scale_loads_with_map(factor, area, &self.bus_index_map());
}
pub fn scale_loads_with_map(
&mut self,
factor: f64,
area: Option<u32>,
bus_map: &HashMap<u32, usize>,
) {
let bus_area: Vec<u32> = self.buses.iter().map(|b| b.area).collect();
for load in &mut self.loads {
if let Some(a) = area {
if let Some(&idx) = bus_map.get(&load.bus) {
if bus_area.get(idx).copied() != Some(a) {
continue;
}
}
}
load.active_power_demand_mw *= factor;
load.reactive_power_demand_mvar *= factor;
}
}
pub fn scale_generation(&mut self, factor: f64, area: Option<u32>) {
self.scale_generation_with_map(factor, area, &self.bus_index_map());
}
pub fn scale_generation_with_map(
&mut self,
factor: f64,
area: Option<u32>,
bus_map: &HashMap<u32, usize>,
) {
let bus_area: Vec<u32> = self.buses.iter().map(|b| b.area).collect();
for g in &mut self.generators {
if !g.in_service {
continue;
}
if let Some(a) = area {
if let Some(&idx) = bus_map.get(&g.bus) {
if bus_area.get(idx).copied() != Some(a) {
continue;
}
}
}
g.p = (g.p * factor).clamp(g.pmin, g.pmax);
}
}
pub fn set_branch_sequence(
&mut self,
from_bus: u32,
to_bus: u32,
circuit: &str,
r0: f64,
x0: f64,
b0: f64,
) -> bool {
use crate::network::ZeroSeqData;
for br in &mut self.branches {
let matched = (br.from_bus == from_bus && br.to_bus == to_bus && br.circuit == circuit)
|| (br.from_bus == to_bus && br.to_bus == from_bus && br.circuit == circuit);
if matched {
let zs = br.zero_seq.get_or_insert_with(ZeroSeqData::default);
zs.r0 = r0;
zs.x0 = x0;
zs.b0 = b0;
return true;
}
}
false
}
pub fn get_branch_sequence(
&self,
from_bus: u32,
to_bus: u32,
circuit: &str,
) -> Option<(f64, f64, f64)> {
for br in &self.branches {
let matched = (br.from_bus == from_bus && br.to_bus == to_bus && br.circuit == circuit)
|| (br.from_bus == to_bus && br.to_bus == from_bus && br.circuit == circuit);
if matched {
return br.zero_seq.as_ref().map(|zs| (zs.r0, zs.x0, zs.b0));
}
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::network::{
Branch, Bus, BusType, FixedShunt, Generator, GeneratorRef, Load, StorageParams,
};
fn make_3bus_network() -> Network {
let mut net = Network::new("test-3bus");
let bus1 = Bus::new(1, BusType::Slack, 138.0);
let bus2 = Bus::new(2, BusType::PQ, 138.0);
let bus3 = Bus::new(3, BusType::PV, 138.0);
net.buses.push(bus1);
net.buses.push(bus2);
net.buses.push(bus3);
net.loads.push(Load::new(2, 50.0, 0.0));
net.loads.push(Load::new(3, 30.0, 0.0));
net.branches.push(Branch::new_line(1, 2, 0.01, 0.1, 0.02));
net.branches.push(Branch::new_line(2, 3, 0.02, 0.2, 0.04));
net.generators.push(Generator::new(1, 90.0, 1.0));
net.generators.push(Generator::new(3, 20.0, 1.0));
net.canonicalize_generator_ids();
net
}
#[test]
fn test_n_buses() {
let net = make_3bus_network();
assert_eq!(net.n_buses(), 3);
}
#[test]
fn test_n_branches() {
let net = make_3bus_network();
assert_eq!(net.n_branches(), 2);
}
#[test]
fn test_n_generators() {
let net = make_3bus_network();
assert_eq!(net.n_generators(), 2);
}
#[test]
fn test_n_generators_in_service_excludes_out_of_service() {
let mut net = make_3bus_network();
net.generators[1].in_service = false;
assert_eq!(net.n_generators(), 2, "n_generators returns total count");
assert_eq!(
net.n_generators_in_service(),
1,
"n_generators_in_service should only count in-service generators"
);
}
#[test]
fn test_bus_index_map_correctness() {
let net = make_3bus_network();
let map = net.bus_index_map();
assert_eq!(map.len(), 3);
assert_eq!(map[&1], 0);
assert_eq!(map[&2], 1);
assert_eq!(map[&3], 2);
}
#[test]
fn test_bus_index_map_non_contiguous_bus_numbers() {
let mut net = Network::new("non-contiguous");
net.buses.push(Bus::new(10, BusType::Slack, 138.0));
net.buses.push(Bus::new(50, BusType::PQ, 138.0));
net.buses.push(Bus::new(99, BusType::PQ, 138.0));
let map = net.bus_index_map();
assert_eq!(map.len(), 3);
assert_eq!(map[&10], 0);
assert_eq!(map[&50], 1);
assert_eq!(map[&99], 2);
}
#[test]
fn test_slack_bus_index() {
let net = make_3bus_network();
assert_eq!(
net.slack_bus_index(),
Some(0),
"Bus 1 is Slack and at index 0"
);
}
#[test]
fn test_slack_bus_index_no_slack() {
let mut net = Network::new("no-slack");
net.buses.push(Bus::new(1, BusType::PQ, 138.0));
net.buses.push(Bus::new(2, BusType::PV, 138.0));
assert_eq!(
net.slack_bus_index(),
None,
"No slack bus should return None"
);
}
#[test]
fn test_slack_bus_index_slack_not_first() {
let mut net = Network::new("slack-last");
net.buses.push(Bus::new(1, BusType::PQ, 138.0));
net.buses.push(Bus::new(2, BusType::PQ, 138.0));
net.buses.push(Bus::new(3, BusType::Slack, 138.0));
assert_eq!(
net.slack_bus_index(),
Some(2),
"Slack bus at the end should return index 2"
);
}
#[test]
fn test_total_generation_mw() {
let net = make_3bus_network();
assert!(
(net.total_generation_mw() - 110.0).abs() < 1e-10,
"total generation should be 110 MW; got {}",
net.total_generation_mw()
);
}
#[test]
fn test_total_generation_mw_excludes_offline() {
let mut net = make_3bus_network();
net.generators[0].in_service = false;
assert!(
(net.total_generation_mw() - 20.0).abs() < 1e-10,
"offline gen should not count; got {}",
net.total_generation_mw()
);
}
#[test]
fn test_total_load_mw() {
let net = make_3bus_network();
assert!(
(net.total_load_mw() - 80.0).abs() < 1e-10,
"total load should be 80 MW; got {}",
net.total_load_mw()
);
}
#[test]
fn test_empty_network() {
let net = Network::new("empty");
assert_eq!(net.n_buses(), 0);
assert_eq!(net.n_branches(), 0);
assert_eq!(net.n_generators(), 0);
assert_eq!(net.slack_bus_index(), None);
assert!((net.total_generation_mw()).abs() < 1e-10);
assert!((net.total_load_mw()).abs() < 1e-10);
assert!(net.bus_index_map().is_empty());
}
#[test]
fn test_empty_network_bus_p_injection() {
let net = Network::new("empty");
let p_inj = net.bus_p_injection_pu();
assert!(
p_inj.is_empty(),
"empty network should have empty injection vector"
);
}
#[test]
fn test_validate_rejects_invalid_storage_parameters() {
let mut net = make_3bus_network();
net.generators[0].storage = Some(StorageParams {
charge_efficiency: 1.2,
..StorageParams::with_energy_capacity_mwh(50.0)
});
let err = net.validate().unwrap_err();
assert!(matches!(
err,
NetworkError::InvalidStorageParameters { bus: 1, .. }
));
}
#[test]
fn test_validate_rejects_load_on_missing_bus() {
let mut net = make_3bus_network();
net.loads.push(Load::new(99, 10.0, 2.0));
let err = net.validate().unwrap_err();
assert!(matches!(err, NetworkError::InvalidLoadBus(99)));
}
#[test]
fn test_validate_rejects_power_injection_on_missing_bus() {
let mut net = make_3bus_network();
net.power_injections.push(PowerInjection {
bus: 99,
id: "inj_missing".into(),
kind: crate::network::power_injection::PowerInjectionKind::Other,
active_power_injection_mw: 5.0,
reactive_power_injection_mvar: 1.0,
in_service: true,
});
let err = net.validate().unwrap_err();
assert!(matches!(err, NetworkError::InvalidPowerInjectionBus(99)));
}
#[test]
fn test_validate_rejects_fixed_shunt_on_missing_bus() {
let mut net = make_3bus_network();
net.fixed_shunts.push(FixedShunt {
bus: 99,
id: "sh_missing".into(),
shunt_type: crate::network::ShuntType::Capacitor,
g_mw: 0.0,
b_mvar: 1.0,
in_service: true,
rated_kv: None,
rated_mvar: None,
});
let err = net.validate().unwrap_err();
assert!(matches!(err, NetworkError::InvalidFixedShuntBus(99)));
}
#[test]
fn test_validate_rejects_dispatchable_load_on_missing_bus() {
let mut net = make_3bus_network();
net.market_data
.dispatchable_loads
.push(DispatchableLoad::curtailable(
99,
10.0,
2.0,
0.0,
100.0,
net.base_mva,
));
let err = net.validate().unwrap_err();
assert!(matches!(err, NetworkError::InvalidDispatchableLoadBus(99)));
}
#[test]
fn test_bus_p_injection_pu() {
let net = make_3bus_network();
let p_inj = net.bus_p_injection_pu();
assert_eq!(p_inj.len(), 3);
assert!(
(p_inj[0] - 0.9).abs() < 1e-10,
"bus 1 p_inj: expected 0.9, got {}",
p_inj[0]
);
assert!(
(p_inj[1] - (-0.5)).abs() < 1e-10,
"bus 2 p_inj: expected -0.5, got {}",
p_inj[1]
);
assert!(
(p_inj[2] - (-0.1)).abs() < 1e-10,
"bus 3 p_inj: expected -0.1, got {}",
p_inj[2]
);
}
#[test]
fn test_network_new_defaults() {
let net = Network::new("test-defaults");
assert_eq!(net.name, "test-defaults");
assert!(
(net.base_mva - 100.0).abs() < 1e-10,
"default base_mva should be 100"
);
assert!(net.buses.is_empty());
assert!(net.branches.is_empty());
assert!(net.generators.is_empty());
assert!(net.loads.is_empty());
}
#[test]
fn test_scale_loads_all() {
let mut net = make_3bus_network();
net.loads.push(Load::new(2, 10.0, 5.0));
net.loads.push(Load::new(3, 20.0, 10.0));
let old_total: f64 = net
.loads
.iter()
.map(|l| l.active_power_demand_mw)
.sum::<f64>();
net.scale_loads(1.5, None);
let new_total: f64 = net
.loads
.iter()
.map(|l| l.active_power_demand_mw)
.sum::<f64>();
assert!(
(new_total - old_total * 1.5).abs() < 1e-10,
"total Pd should scale by 1.5: old={} new={} expected={}",
old_total,
new_total,
old_total * 1.5
);
}
#[test]
fn test_scale_generation_clamped() {
let mut net = make_3bus_network();
net.scale_generation(10.0, None);
for g in &net.generators {
assert!(
g.p <= g.pmax,
"Pg={} should be <= Pmax={} after 10x scaling",
g.p,
g.pmax
);
}
}
#[test]
fn test_set_branch_sequence_roundtrip() {
let mut net = make_3bus_network();
let (from, to, ckt) = {
let br = &net.branches[0];
(br.from_bus, br.to_bus, br.circuit.clone())
};
assert!(net.get_branch_sequence(from, to, &ckt).is_none());
assert!(net.set_branch_sequence(from, to, &ckt, 0.15, 0.45, 0.02));
let (r0, x0, b0) = net.get_branch_sequence(from, to, &ckt).unwrap();
assert!((r0 - 0.15).abs() < 1e-10);
assert!((x0 - 0.45).abs() < 1e-10);
assert!((b0 - 0.02).abs() < 1e-10);
let (r0r, x0r, b0r) = net.get_branch_sequence(to, from, &ckt).unwrap();
assert!((r0r - 0.15).abs() < 1e-10);
assert!((x0r - 0.45).abs() < 1e-10);
assert!((b0r - 0.02).abs() < 1e-10);
}
#[test]
fn test_set_branch_sequence_not_found() {
let mut net = make_3bus_network();
assert!(!net.set_branch_sequence(99, 100, "1", 0.1, 0.2, 0.0));
}
#[test]
fn test_negative_tap_ratio_rejected_by_validate_for_solve() {
let mut net = make_3bus_network();
net.branches[0].tap = -0.95;
let err = net.validate_for_solve().unwrap_err();
assert!(matches!(
err,
NetworkError::InvalidBranchField { field: "tap", .. }
));
}
#[test]
fn validate_for_solve_rejects_missing_slack_in_island() {
let mut net = Network::new("component-slack");
net.buses = vec![
Bus::new(1, BusType::Slack, 138.0),
Bus::new(2, BusType::PQ, 138.0),
Bus::new(3, BusType::PQ, 138.0),
];
net.branches.push(Branch::new_line(1, 2, 0.01, 0.1, 0.02));
net.generators.push(Generator::new(1, 50.0, 1.0));
net.canonicalize_generator_ids();
let err = net.validate_for_solve().unwrap_err();
assert!(matches!(
err,
NetworkError::InvalidSlackPlacement { buses, slack_buses }
if buses.contains(&3) && slack_buses.is_empty()
));
}
#[test]
fn validate_for_solve_rejects_isolated_bus_with_active_equipment() {
let mut net = Network::new("isolated-bus");
net.buses = vec![Bus::new(1, BusType::Isolated, 138.0)];
net.loads.push(Load::new(1, 5.0, 1.0));
let err = net.validate_for_solve().unwrap_err();
assert!(matches!(
err,
NetworkError::InvalidIsolatedBusConnectivity { bus: 1 }
));
}
#[test]
fn validate_for_solve_rejects_isolated_bus_with_bus_shunt() {
let mut net = Network::new("isolated-bus-shunt");
let mut bus = Bus::new(1, BusType::Isolated, 138.0);
bus.shunt_susceptance_mvar = 25.0;
net.buses = vec![bus];
let err = net.validate_for_solve().unwrap_err();
assert!(matches!(
err,
NetworkError::InvalidIsolatedBusConnectivity { bus: 1 }
));
}
#[test]
fn validate_for_solve_rejects_duplicate_area_schedule_numbers() {
let mut net = make_3bus_network();
net.area_schedules.push(AreaSchedule {
number: 1,
slack_bus: 1,
p_desired_mw: 10.0,
p_tolerance_mw: 5.0,
name: "A".to_string(),
});
net.area_schedules.push(AreaSchedule {
number: 1,
slack_bus: 1,
p_desired_mw: 20.0,
p_tolerance_mw: 5.0,
name: "B".to_string(),
});
let err = net.validate_for_solve().unwrap_err();
assert!(matches!(err, NetworkError::DuplicateAreaScheduleNumber(1)));
}
#[test]
fn validate_for_solve_rejects_invalid_area_schedule_slack_bus() {
let mut net = make_3bus_network();
net.area_schedules.push(AreaSchedule {
number: 7,
slack_bus: 99,
p_desired_mw: 10.0,
p_tolerance_mw: 5.0,
name: "bad".to_string(),
});
let err = net.validate_for_solve().unwrap_err();
assert!(matches!(
err,
NetworkError::InvalidAreaScheduleSlackBus {
area: 7,
slack_bus: 99
}
));
}
#[test]
fn validate_for_solve_allows_explicitly_unbounded_generator_limits() {
let mut net = make_3bus_network();
net.generators[0].qmin = f64::NEG_INFINITY;
net.generators[0].qmax = f64::INFINITY;
net.generators[0].pmax = f64::INFINITY;
net.validate_for_solve()
.expect("unbounded OPF-style generator limits should be allowed");
}
#[test]
fn validate_for_solve_allows_explicitly_unbounded_branch_limits() {
let mut net = make_3bus_network();
net.branches[0].rating_a_mva = f64::INFINITY;
net.branches[0].angle_diff_min_rad = Some(f64::NEG_INFINITY);
net.branches[0].angle_diff_max_rad = Some(f64::INFINITY);
net.validate_for_solve()
.expect("unbounded thermal and angle limits should be allowed");
}
#[test]
fn validate_for_solve_rejects_wrong_sided_infinite_limits() {
let mut net = make_3bus_network();
net.generators[0].qmax = f64::NEG_INFINITY;
let err = net.validate_for_solve().unwrap_err();
assert!(matches!(
err,
NetworkError::InvalidGeneratorField {
field: "qmax",
value,
..
} if value == f64::NEG_INFINITY
));
}
#[test]
fn validate_for_solve_allows_raw_agc_weights_above_one() {
let mut net = make_3bus_network();
net.generators[0].agc_participation_factor = Some(2.0);
net.validate_for_solve()
.expect("AGC participation factors are raw weights and may exceed 1.0");
}
#[test]
fn validate_accepts_network_after_generator_ids_are_canonicalized() {
let mut net = make_3bus_network();
net.generators[0].id.clear();
net.canonicalize_generator_ids();
net.validate()
.expect("validate should succeed after canonicalization");
assert!(
!net.generators[0].id.is_empty(),
"canonicalization should have auto-assigned a canonical id"
);
assert!(
net.generators[0].id.starts_with("gen_"),
"canonical id should follow gen_{{bus}}_{{ordinal}} format, got: {}",
net.generators[0].id
);
}
#[test]
fn canonicalize_runtime_identities_demotes_pv_bus_without_active_regulator() {
let mut net = Network::new("runtime-bus-type-normalization");
net.buses.push(Bus::new(1, BusType::Slack, 230.0));
net.buses.push(Bus::new(2, BusType::PV, 230.0));
net.branches.push(Branch::new_line(1, 2, 0.01, 0.1, 0.0));
let mut slack_gen = Generator::new(1, 50.0, 1.0);
slack_gen.id = "g1".into();
net.generators.push(slack_gen);
let mut offline_pv_gen = Generator::new(2, 10.0, 1.02);
offline_pv_gen.id = "g2".into();
offline_pv_gen.in_service = false;
net.generators.push(offline_pv_gen);
net.canonicalize_runtime_identities();
assert_eq!(net.buses[1].bus_type, BusType::PQ);
net.validate()
.expect("runtime canonicalization should produce a solve-ready network");
}
#[test]
fn validate_rejects_duplicate_generator_id() {
let mut net = make_3bus_network();
let duplicate_id = net.generators[0].id.clone();
net.generators[1].id = duplicate_id.clone();
let err = net.validate().unwrap_err();
assert!(matches!(
err,
NetworkError::DuplicateGeneratorId { id } if id == duplicate_id
));
}
#[test]
fn canonicalize_generator_ids_fills_missing_ids_deterministically() {
let mut net = Network::new("canonicalize-generator-ids");
net.generators
.push(Generator::with_id(" explicit-a ", 10, 0.0, 1.0));
net.generators.push(Generator::new(10, 0.0, 1.0));
net.generators.push(Generator::new(10, 0.0, 1.0));
net.generators
.push(Generator::with_id("gen_10_2", 10, 0.0, 1.0));
net.generators.push(Generator::new(20, 0.0, 1.0));
let mut clone = net.clone();
net.canonicalize_generator_ids();
clone.canonicalize_generator_ids();
let ids: Vec<&str> = net
.generators
.iter()
.map(|generator| generator.id.as_str())
.collect();
assert_eq!(
ids,
vec![
"explicit-a",
"gen_10_2_2",
"gen_10_3",
"gen_10_2",
"gen_20_1"
]
);
assert_eq!(
ids,
clone
.generators
.iter()
.map(|generator| generator.id.as_str())
.collect::<Vec<_>>()
);
let unique_ids: HashSet<&str> = ids.iter().copied().collect();
assert_eq!(unique_ids.len(), ids.len());
}
#[test]
fn canonicalize_branch_circuit_ids_disambiguates_reverse_duplicates() {
let mut net = Network::new("canonicalize-branch-circuits");
net.buses.push(Bus::new(1, BusType::Slack, 230.0));
net.buses.push(Bus::new(2, BusType::PQ, 230.0));
net.branches.push(Branch::new_line(1, 2, 0.0, 0.1, 0.0));
net.branches.push(Branch::new_line(2, 1, 0.0, 0.1, 0.0));
net.branches.push(Branch::new_line(1, 2, 0.0, 0.1, 0.0));
net.canonicalize_branch_circuit_ids();
let circuits: Vec<&str> = net
.branches
.iter()
.map(|branch| branch.circuit.as_str())
.collect();
assert_eq!(circuits, vec!["1", "1#2", "1#3"]);
net.validate_structure()
.expect("canonicalized branch circuits should be structurally valid");
}
#[test]
fn canonicalize_load_and_shunt_ids_fills_missing_ids_deterministically() {
let mut net = Network::new("canonicalize-load-shunt-ids");
net.loads.push(Load {
bus: 3,
id: String::new(),
..Default::default()
});
net.loads.push(Load {
bus: 3,
id: " load_existing ".into(),
..Default::default()
});
net.fixed_shunts.push(FixedShunt {
bus: 7,
id: String::new(),
shunt_type: crate::network::ShuntType::Capacitor,
g_mw: 0.0,
b_mvar: 0.0,
in_service: true,
rated_kv: None,
rated_mvar: None,
});
net.fixed_shunts.push(FixedShunt {
bus: 7,
id: " sh_existing ".into(),
shunt_type: crate::network::ShuntType::Reactor,
g_mw: 0.0,
b_mvar: 0.0,
in_service: true,
rated_kv: None,
rated_mvar: None,
});
net.canonicalize_load_ids();
net.canonicalize_shunt_ids();
assert_eq!(net.loads[0].id, "load_3_1");
assert_eq!(net.loads[1].id, "load_existing");
assert_eq!(net.fixed_shunts[0].id, "shunt_7_1");
assert_eq!(net.fixed_shunts[1].id, "sh_existing");
}
#[test]
fn canonicalize_dispatchable_load_ids_fills_missing_ids_deterministically() {
let mut net = make_3bus_network();
net.market_data
.dispatchable_loads
.push(DispatchableLoad::curtailable(
2, 150.0, 30.0, 50.0, 40.0, 100.0,
));
net.market_data
.dispatchable_loads
.push(DispatchableLoad::curtailable(
2, 100.0, 20.0, 0.0, 50.0, 100.0,
));
net.market_data.dispatchable_loads[1].resource_id = " dr_existing ".into();
net.canonicalize_dispatchable_load_ids();
assert_eq!(
net.market_data.dispatchable_loads[0].resource_id,
"dispatchable_load_2_1"
);
assert_eq!(
net.market_data.dispatchable_loads[1].resource_id,
"dr_existing"
);
}
#[test]
fn find_stable_asset_indices_by_identity() {
let mut net = make_3bus_network();
net.loads.push(Load {
bus: 2,
id: "load_2_a".into(),
..Default::default()
});
net.fixed_shunts.push(FixedShunt {
bus: 2,
id: "shunt_2_a".into(),
shunt_type: crate::network::ShuntType::Capacitor,
g_mw: 1.0,
b_mvar: 2.0,
in_service: true,
rated_kv: None,
rated_mvar: None,
});
net.hvdc
.links
.push(crate::network::HvdcLink::Vsc(crate::network::VscHvdcLink {
name: "HVDC_A".into(),
converter1: crate::network::VscConverterTerminal {
bus: 1,
..Default::default()
},
converter2: crate::network::VscConverterTerminal {
bus: 3,
..Default::default()
},
..Default::default()
}));
net.market_data
.pumped_hydro_units
.push(PumpedHydroUnit::new(
"PH_A".into(),
GeneratorRef {
bus: 1,
id: "GEN_A".into(),
},
100.0,
));
net.market_data
.dispatchable_loads
.push(DispatchableLoad::curtailable(
2, 20.0, 5.0, 0.0, 100.0, 100.0,
));
net.market_data.dispatchable_loads[0].resource_id = "dr_a".into();
net.market_data
.combined_cycle_plants
.push(CombinedCyclePlant {
id: String::new(),
name: "CC_A".into(),
configs: Vec::new(),
transitions: Vec::new(),
active_config: None,
hours_in_config: 0.0,
duct_firing_capable: false,
});
assert_eq!(net.find_branch_index(1, 2, "1"), Some(0));
assert_eq!(net.find_branch_index(2, 1, "1"), Some(0));
assert_eq!(net.find_load_index_by_id("load_2_a"), Some(2));
assert_eq!(net.find_load_index(2, Some("load_2_a")), Some(2));
assert_eq!(net.find_shunt_index_by_id("shunt_2_a"), Some(0));
assert_eq!(net.find_shunt_index(2, Some("shunt_2_a")), Some(0));
assert_eq!(net.find_hvdc_link_index_by_name("HVDC_A"), Some(0));
assert_eq!(net.find_pumped_hydro_index_by_name("PH_A"), Some(0));
assert_eq!(net.find_dispatchable_load_index("dr_a", Some(2)), Some(0));
assert_eq!(net.find_combined_cycle_index_by_name("CC_A"), Some(0));
}
#[test]
fn conditional_limits_apply_and_reset() {
let mut net = Network::new("test");
let mut br0 = Branch::new_line(1, 2, 0.01, 0.1, 0.0);
br0.rating_a_mva = 100.0;
br0.rating_c_mva = 150.0;
let mut br1 = Branch::new_line(2, 3, 0.01, 0.1, 0.0);
br1.rating_a_mva = 200.0;
br1.rating_c_mva = 250.0;
net.branches.push(br0);
net.branches.push(br1);
net.conditional_limits.insert_for_branch(
&net.branches[0],
vec![
ConditionalRating {
condition_id: "summer".to_string(),
rating_a_mva: 80.0,
rating_c_mva: 0.0,
},
ConditionalRating {
condition_id: "winter".to_string(),
rating_a_mva: 120.0,
rating_c_mva: 0.0,
},
],
);
net.apply_conditional_limits(&["summer".to_string()]);
assert!(
(net.branches[0].rating_a_mva - 80.0).abs() < 1e-6,
"Branch 0 rate_a should be 80 after summer, got {}",
net.branches[0].rating_a_mva
);
assert!(
(net.branches[1].rating_a_mva - 200.0).abs() < 1e-6,
"Branch 1 should be unchanged"
);
net.apply_conditional_limits(&["winter".to_string()]);
assert!(
(net.branches[0].rating_a_mva - 120.0).abs() < 1e-6,
"Branch 0 rate_a should be 120 after winter, got {}",
net.branches[0].rating_a_mva
);
net.reset_conditional_limits();
assert!(
(net.branches[0].rating_a_mva - 100.0).abs() < 1e-6,
"Branch 0 rate_a should be 100 after reset, got {}",
net.branches[0].rating_a_mva
);
}
#[test]
fn conditional_limits_clear_preserves_reset_state() {
let mut net = Network::new("test");
let mut branch = Branch::new_line(1, 2, 0.01, 0.1, 0.0);
branch.rating_a_mva = 100.0;
branch.rating_c_mva = 150.0;
net.branches.push(branch);
net.conditional_limits.insert_for_branch(
&net.branches[0],
vec![ConditionalRating {
condition_id: "summer".to_string(),
rating_a_mva: 80.0,
rating_c_mva: 120.0,
}],
);
net.apply_conditional_limits(&["summer".to_string()]);
assert!((net.branches[0].rating_a_mva - 80.0).abs() < 1e-6);
net.conditional_limits.clear();
net.reset_conditional_limits();
assert!((net.branches[0].rating_a_mva - 100.0).abs() < 1e-6);
assert!((net.branches[0].rating_c_mva - 150.0).abs() < 1e-6);
}
#[test]
fn conditional_limits_empty_conditions_reset_to_base() {
let mut net = Network::new("conditional-empty");
let mut branch = Branch::new_line(1, 2, 0.01, 0.1, 0.0);
branch.rating_a_mva = 100.0;
branch.rating_c_mva = 150.0;
net.branches.push(branch);
net.conditional_limits.insert_for_branch(
&net.branches[0],
vec![ConditionalRating {
condition_id: "summer".to_string(),
rating_a_mva: 80.0,
rating_c_mva: 120.0,
}],
);
net.apply_conditional_limits(&["summer".to_string()]);
assert!((net.branches[0].rating_a_mva - 80.0).abs() < 1e-6);
net.apply_conditional_limits(&[]);
assert!((net.branches[0].rating_a_mva - 100.0).abs() < 1e-6);
assert!((net.branches[0].rating_c_mva - 150.0).abs() < 1e-6);
}
#[test]
fn conditional_limits_serde_roundtrip_preserves_reset_state() {
let mut net = Network::new("test");
let mut branch = Branch::new_line(1, 2, 0.01, 0.1, 0.0);
branch.rating_a_mva = 100.0;
branch.rating_c_mva = 150.0;
net.branches.push(branch);
net.conditional_limits.insert_for_branch(
&net.branches[0],
vec![ConditionalRating {
condition_id: "summer".to_string(),
rating_a_mva: 80.0,
rating_c_mva: 120.0,
}],
);
net.apply_conditional_limits(&["summer".to_string()]);
let json = serde_json::to_string(&net).unwrap();
let mut roundtripped: Network = serde_json::from_str(&json).unwrap();
roundtripped.reset_conditional_limits();
assert!((roundtripped.branches[0].rating_a_mva - 100.0).abs() < 1e-6);
assert!((roundtripped.branches[0].rating_c_mva - 150.0).abs() < 1e-6);
}
#[test]
fn validate_structure_rejects_reverse_direction_duplicate_branch() {
let mut net = Network::new("dup-branch");
net.buses.push(Bus::new(1, BusType::Slack, 230.0));
net.buses.push(Bus::new(2, BusType::PQ, 230.0));
net.branches.push(Branch::new_line(1, 2, 0.01, 0.1, 0.0));
let mut reverse = Branch::new_line(2, 1, 0.01, 0.1, 0.0);
reverse.circuit = "1".to_string();
net.branches.push(reverse);
let err = net.validate_structure().unwrap_err();
assert!(matches!(
err,
NetworkError::DuplicateBranchKey {
from_bus: 1,
to_bus: 2,
..
}
));
}
#[test]
fn generator_lookup_uses_trimmed_canonical_id() {
let mut net = Network::new("trimmed-generator-id");
net.buses.push(Bus::new(1, BusType::Slack, 230.0));
let mut generator = Generator::new(1, 50.0, 1.0);
generator.id = " GEN_A ".to_string();
net.generators.push(generator);
assert_eq!(net.find_gen_index_by_id("GEN_A"), Some(0));
assert_eq!(net.find_gen_index_by_id(" GEN_A "), Some(0));
assert_eq!(net.gen_index_by_id().get("GEN_A"), Some(&0));
}
#[test]
fn validate_for_solve_rejects_invalid_switched_shunt_indices() {
let mut net = Network::new("switched-shunt-index");
net.buses.push(Bus::new(1, BusType::Slack, 230.0));
net.controls.switched_shunts.push(SwitchedShunt {
id: "ssh_1".into(),
bus: 1,
bus_regulated: 0,
b_step: 0.01,
n_steps_cap: 1,
n_steps_react: 0,
v_target: 1.0,
v_band: 0.02,
n_active_steps: 0,
});
let err = net.validate_for_solve().unwrap_err();
assert!(matches!(
err,
NetworkError::InvalidSwitchedShuntRegulatedBus { id, bus }
if id == "ssh_1" && bus == 0
));
}
#[test]
fn validate_for_solve_rejects_slack_without_regulating_generator() {
let mut net = Network::new("slack-no-reg");
net.buses.push(Bus::new(1, BusType::Slack, 230.0));
let mut generator = Generator::new(1, 50.0, 1.0);
generator.voltage_regulated = false;
net.generators.push(generator);
let err = net.validate_for_solve().unwrap_err();
assert!(matches!(
err,
NetworkError::InvalidSlackPlacement { slack_buses, .. } if slack_buses == vec![1]
));
}
#[test]
fn validate_for_solve_rejects_pv_without_regulating_generator() {
let mut net = Network::new("pv-no-reg");
net.buses.push(Bus::new(1, BusType::Slack, 230.0));
net.buses.push(Bus::new(2, BusType::PV, 230.0));
net.branches.push(Branch::new_line(1, 2, 0.01, 0.1, 0.0));
net.generators.push(Generator::new(1, 60.0, 1.0));
let mut generator = Generator::new(2, 40.0, 1.0);
generator.voltage_regulated = false;
net.generators.push(generator);
let err = net.validate_for_solve().unwrap_err();
assert!(matches!(
err,
NetworkError::InvalidGeneratorField {
bus,
field: "voltage_regulated",
value
} if bus == 2 && value == 0.0
));
}
#[test]
fn validate_for_solve_accepts_remote_regulated_pv_bus() {
let mut net = Network::new("pv-remote-reg");
net.buses.push(Bus::new(1, BusType::Slack, 230.0));
net.buses.push(Bus::new(2, BusType::PV, 230.0));
net.branches.push(Branch::new_line(1, 2, 0.01, 0.1, 0.0));
let mut slack_generator = Generator::new(1, 60.0, 1.0);
slack_generator.reg_bus = Some(1);
net.generators.push(slack_generator);
let mut remote_generator = Generator::new(1, 20.0, 1.0);
remote_generator.reg_bus = Some(2);
net.generators.push(remote_generator);
assert!(net.validate_for_solve().is_ok());
}
#[test]
fn validate_structure_rejects_missing_remote_regulated_bus() {
let mut net = Network::new("missing-reg-bus");
net.buses.push(Bus::new(1, BusType::Slack, 230.0));
let mut generator = Generator::new(1, 50.0, 1.0);
generator.reg_bus = Some(2);
net.generators.push(generator);
let err = net.validate_structure().unwrap_err();
assert!(matches!(
err,
NetworkError::InvalidGeneratorRegulatedBus { bus, reg_bus }
if bus == 1 && reg_bus == 2
));
}
#[test]
fn validate_structure_rejects_empty_interface_members() {
let mut net = Network::new("interface-mismatch");
net.buses.push(Bus::new(1, BusType::Slack, 230.0));
net.buses.push(Bus::new(2, BusType::PQ, 230.0));
net.interfaces.push(Interface {
name: "IF_A".to_string(),
members: vec![],
limit_forward_mw: 100.0,
limit_reverse_mw: 100.0,
in_service: true,
limit_forward_mw_schedule: vec![],
limit_reverse_mw_schedule: vec![],
});
let err = net.validate_structure().unwrap_err();
assert!(matches!(
err,
NetworkError::InvalidInterfaceDefinition { name, .. } if name == "IF_A"
));
}
#[test]
fn validate_structure_rejects_missing_interface_branch_reference() {
let mut net = Network::new("interface-missing-branch");
net.buses.push(Bus::new(1, BusType::Slack, 230.0));
net.buses.push(Bus::new(2, BusType::PQ, 230.0));
net.interfaces.push(Interface {
name: "IF_A".to_string(),
members: vec![crate::network::WeightedBranchRef::new(1, 2, "1", 1.0)],
limit_forward_mw: 100.0,
limit_reverse_mw: 100.0,
in_service: true,
limit_forward_mw_schedule: vec![],
limit_reverse_mw_schedule: vec![],
});
let err = net.validate_structure().unwrap_err();
assert!(matches!(
err,
NetworkError::InvalidInterfaceDefinition { name, .. } if name == "IF_A"
));
}
#[test]
fn validate_structure_rejects_non_finite_flowgate_coefficients() {
let mut net = Network::new("flowgate-nan");
net.buses.push(Bus::new(1, BusType::Slack, 230.0));
net.buses.push(Bus::new(2, BusType::PQ, 230.0));
net.branches.push(Branch::new_line(1, 2, 0.01, 0.1, 0.0));
net.flowgates.push(Flowgate {
name: "FG_A".to_string(),
monitored: vec![crate::network::WeightedBranchRef::new(1, 2, "1", f64::NAN)],
contingency_branch: None,
limit_mw: 100.0,
limit_reverse_mw: 100.0,
in_service: true,
limit_mw_schedule: vec![],
limit_reverse_mw_schedule: vec![],
hvdc_coefficients: vec![],
hvdc_band_coefficients: vec![],
ptdf_per_bus: vec![],
limit_mw_active_period: None,
breach_sides: crate::network::FlowgateBreachSides::Both,
});
let err = net.validate_structure().unwrap_err();
assert!(matches!(
err,
NetworkError::InvalidFlowgateDefinition { name, .. } if name == "FG_A"
));
}
#[test]
fn validate_structure_rejects_missing_flowgate_contingency_branch() {
let mut net = Network::new("flowgate-missing-ctg");
net.buses.push(Bus::new(1, BusType::Slack, 230.0));
net.buses.push(Bus::new(2, BusType::PQ, 230.0));
net.branches.push(Branch::new_line(1, 2, 0.01, 0.1, 0.0));
net.flowgates.push(Flowgate {
name: "FG_A".to_string(),
monitored: vec![crate::network::WeightedBranchRef::new(1, 2, "1", 1.0)],
contingency_branch: Some(crate::network::BranchRef::new(2, 1, "1")),
limit_mw: 100.0,
limit_reverse_mw: 100.0,
in_service: true,
limit_mw_schedule: vec![],
limit_reverse_mw_schedule: vec![],
hvdc_coefficients: vec![],
hvdc_band_coefficients: vec![],
ptdf_per_bus: vec![],
limit_mw_active_period: None,
breach_sides: crate::network::FlowgateBreachSides::Both,
});
let err = net.validate_structure().unwrap_err();
assert!(matches!(
err,
NetworkError::InvalidFlowgateDefinition { name, .. } if name == "FG_A"
));
}
#[test]
fn validate_structure_rejects_duplicate_hvdc_link_names() {
let mut net = Network::new("dup-hvdc-name");
net.buses.push(Bus::new(1, BusType::Slack, 230.0));
net.buses.push(Bus::new(2, BusType::PQ, 230.0));
let mut link_a = crate::network::LccHvdcLink {
name: "HVDC_A".to_string(),
..Default::default()
};
link_a.rectifier.bus = 1;
link_a.inverter.bus = 2;
let mut link_b = crate::network::LccHvdcLink {
name: "HVDC_A".to_string(),
..Default::default()
};
link_b.rectifier.bus = 1;
link_b.inverter.bus = 2;
net.hvdc.push_lcc_link(link_a);
net.hvdc.push_lcc_link(link_b);
let err = net.validate_structure().unwrap_err();
assert!(matches!(
err,
NetworkError::DuplicateHvdcLinkName { name } if name == "HVDC_A"
));
}
#[test]
fn validate_for_solve_rejects_mixed_hvdc_representations() {
let mut net = Network::new("mixed-hvdc");
net.buses.push(Bus::new(1, BusType::Slack, 230.0));
net.buses.push(Bus::new(2, BusType::PQ, 230.0));
let mut link = crate::network::LccHvdcLink {
name: "HVDC_A".to_string(),
..Default::default()
};
link.rectifier.bus = 1;
link.inverter.bus = 2;
net.hvdc.push_lcc_link(link);
let grid = net.hvdc.ensure_dc_grid(1, Some("grid".to_string()));
grid.buses.push(crate::network::DcBus {
bus_id: 101,
p_dc_mw: 0.0,
v_dc_pu: 1.0,
base_kv_dc: 320.0,
v_dc_max: 1.1,
v_dc_min: 0.9,
cost: 0.0,
g_shunt_siemens: 0.0,
r_ground_ohm: 0.0,
});
grid.converters.push(crate::network::DcConverter::Vsc(
crate::network::DcConverterStation {
id: String::new(),
dc_bus: 101,
ac_bus: 1,
control_type_dc: 2,
control_type_ac: 1,
active_power_mw: 0.0,
reactive_power_mvar: 0.0,
is_lcc: false,
voltage_setpoint_pu: 1.0,
transformer_r_pu: 0.0,
transformer_x_pu: 0.0,
transformer: false,
tap_ratio: 1.0,
filter_susceptance_pu: 0.0,
filter: false,
reactor_r_pu: 0.0,
reactor_x_pu: 0.0,
reactor: false,
base_kv_ac: 230.0,
voltage_max_pu: 1.1,
voltage_min_pu: 0.9,
current_max_pu: 2.0,
status: true,
loss_constant_mw: 0.0,
loss_linear: 0.0,
loss_quadratic_rectifier: 0.0,
loss_quadratic_inverter: 0.0,
droop: 0.0,
power_dc_setpoint_mw: 0.0,
voltage_dc_setpoint_pu: 1.0,
active_power_ac_max_mw: 10.0,
active_power_ac_min_mw: -10.0,
reactive_power_ac_max_mvar: 10.0,
reactive_power_ac_min_mvar: -10.0,
},
));
let err = net.validate_for_solve().unwrap_err();
assert!(matches!(err, NetworkError::MixedHvdcRepresentation));
}
}