use std::collections::{BTreeSet, HashMap};
use powerio_core::{Diagnostic, Error};
use powerio_tx::{
BalancedNetwork, BusId, BusType, IslandPartition, correct_angle_difference_bounds,
};
use crate::diagnostics::codes;
use crate::instance::balanced::{AcOpfInstance, transform_assumption, transform_discarded};
use crate::instance::constraints::ActiveConstraints;
use crate::instance::objective::{Objective, ObjectiveTerm};
use crate::operating::{BalancedOperatingPointFlag, OperatingPoint, row_identity};
#[derive(Clone, Debug, Default, PartialEq)]
#[non_exhaustive]
pub enum BranchSelection {
#[default]
All,
None,
Only(Vec<String>),
}
impl BranchSelection {
#[must_use]
pub const fn kind_name(&self) -> &'static str {
match self {
Self::All => "all",
Self::None => "none",
Self::Only(_) => "only",
}
}
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub struct BranchSwitchingCost {
pub id: String,
pub open_cost: f64,
pub close_cost: f64,
}
impl BranchSwitchingCost {
#[must_use]
pub fn new(id: impl Into<String>, open_cost: f64, close_cost: f64) -> Self {
Self {
id: id.into(),
open_cost,
close_cost,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum IslandingPolicy {
#[default]
Forbidden,
Allowed,
}
impl IslandingPolicy {
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::Forbidden => "forbidden",
Self::Allowed => "allowed",
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum CurrentLimitPolicy {
#[default]
Skip,
Stated,
DeriveFromRateA,
}
impl CurrentLimitPolicy {
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::Skip => "skip",
Self::Stated => "stated",
Self::DeriveFromRateA => "derive_from_rate_a",
}
}
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub struct BranchCurrentLimit {
pub id: String,
pub from_pu: Option<f64>,
pub to_pu: Option<f64>,
}
impl BranchCurrentLimit {
#[must_use]
pub fn new(id: impl Into<String>, from_pu: Option<f64>, to_pu: Option<f64>) -> Self {
Self {
id: id.into(),
from_pu,
to_pu,
}
}
}
#[derive(Clone, Debug, Default, PartialEq)]
#[non_exhaustive]
pub struct AcOtsOptions {
pub switchable: BranchSelection,
pub switching_costs: Vec<BranchSwitchingCost>,
pub islanding: IslandingPolicy,
pub max_open: Option<usize>,
pub max_switched: Option<usize>,
pub current_limits: CurrentLimitPolicy,
pub stated_current_limits: Vec<BranchCurrentLimit>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
#[non_exhaustive]
pub struct TerminalCurrentBounds {
pub from_pu: Option<f64>,
pub to_pu: Option<f64>,
}
impl TerminalCurrentBounds {
fn is_bounded(self) -> bool {
self.from_pu.is_some() || self.to_pu.is_some()
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[non_exhaustive]
pub struct SwitchingCost {
pub open: f64,
pub close: f64,
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub struct AcOtsBranch {
pub id: String,
pub initial_closed: bool,
pub switchable: bool,
pub switching_cost: Option<SwitchingCost>,
pub stated_current: TerminalCurrentBounds,
pub current: TerminalCurrentBounds,
}
#[derive(Clone, Debug)]
pub struct AcOtsInstance {
base: AcOpfInstance,
options: AcOtsOptions,
branches: Vec<AcOtsBranch>,
initial_islands: IslandPartition,
}
impl AcOtsInstance {
pub fn new(base: AcOpfInstance, options: AcOtsOptions) -> Result<Self, Error> {
let branches = resolve_branches(&base, &options)?;
let initial_islands = base.network().calc_islands();
Ok(Self {
base,
options,
branches,
initial_islands,
})
}
pub fn from_ac_opf(base: AcOpfInstance) -> Result<Self, Error> {
let options = AcOtsOptions {
switchable: initially_closed_selection(base.network()),
..AcOtsOptions::default()
};
Ok(Self::new(base, options)?.with_switching_objective())
}
pub fn from_network(network: BalancedNetwork) -> Result<Self, Error> {
Self::from_ac_opf(AcOpfInstance::from_network(network)?)
}
pub fn with_options(self, options: AcOtsOptions) -> Result<Self, Error> {
Self::new(self.base, options)
}
#[must_use]
pub fn with_switching_objective(mut self) -> Self {
if !self
.base
.objective()
.contains(&ObjectiveTerm::BranchSwitchingCost)
{
self.base = self
.base
.with_objective_term(ObjectiveTerm::BranchSwitchingCost);
}
self
}
#[must_use]
pub const fn base(&self) -> &AcOpfInstance {
&self.base
}
#[must_use]
pub fn network(&self) -> &BalancedNetwork {
self.base.network()
}
#[must_use]
pub const fn objective(&self) -> &Objective {
self.base.objective()
}
#[must_use]
pub const fn constraints(&self) -> &ActiveConstraints {
self.base.constraints()
}
#[must_use]
pub const fn initial_point(&self) -> Option<&OperatingPoint<BalancedNetwork>> {
self.base.initial_point()
}
#[must_use]
pub const fn options(&self) -> &AcOtsOptions {
&self.options
}
#[must_use]
pub fn branches(&self) -> &[AcOtsBranch] {
&self.branches
}
pub fn branch_order(&self) -> impl ExactSizeIterator<Item = &str> {
self.branches.iter().map(|branch| branch.id.as_str())
}
#[must_use]
pub fn initial_closed(&self) -> Vec<bool> {
self.branches
.iter()
.map(|branch| branch.initial_closed)
.collect()
}
pub fn validate_topology(&self, branch_closed: &[bool]) -> Result<f64, Error> {
if branch_closed.len() != self.branches.len() {
return Err(topology_invalid(format!(
"the branch status vector carries {} values; the network has {} branches",
branch_closed.len(),
self.branches.len()
)));
}
let mut open = 0;
let mut switched = 0;
let mut cost = 0.0;
for (branch, &closed) in self.branches.iter().zip(branch_closed) {
if !closed {
open += 1;
}
if closed == branch.initial_closed {
continue;
}
if !branch.switchable {
return Err(topology_invalid(format!(
"branch `{}` cannot switch, but the final topology {} it",
branch.id,
if closed { "closes" } else { "opens" }
)));
}
switched += 1;
if let Some(costs) = branch.switching_cost {
cost += if closed { costs.close } else { costs.open };
}
}
if let Some(max_open) = self.options.max_open
&& open > max_open
{
return Err(topology_invalid(format!(
"the final topology opens {open} branches; at most {max_open} may be open"
)));
}
if let Some(max_switched) = self.options.max_switched
&& switched > max_switched
{
return Err(topology_invalid(format!(
"the final topology changes {switched} branch statuses; at most {max_switched} may change"
)));
}
self.check_islands(branch_closed)?;
Ok(cost)
}
pub fn fix_topology(
&self,
branch_closed: &[bool],
) -> Result<(AcOpfInstance, Vec<Diagnostic>), Error> {
self.validate_topology(branch_closed)?;
let mut network = self.network_with(branch_closed);
let mut diagnostics = vec![transform_discarded(
"the switchable branch selection, switching costs, switching budgets, and islanding policy",
)];
if self.options.islanding == IslandingPolicy::Allowed {
diagnostics.extend(assign_island_references(&mut network));
}
if self
.branches
.iter()
.any(|branch| branch.current.is_bounded())
{
diagnostics.push(transform_discarded("the branch current limits"));
}
let mut objective = self.base.objective().clone();
if objective.contains(&ObjectiveTerm::BranchSwitchingCost) {
objective = objective.without(&ObjectiveTerm::BranchSwitchingCost);
diagnostics.push(transform_discarded(
"the branch switching cost objective term",
));
}
let mut fixed = AcOpfInstance::from_network(network.clone())?
.with_objective(objective)
.with_constraints(self.base.constraints().clone());
if let Some(point) = self.base.initial_point() {
if point
.flags(BalancedOperatingPointFlag::BranchInService)
.is_some()
{
diagnostics.push(transform_discarded(
"the initial point, which states the initial branch statuses,",
));
} else {
fixed = fixed.with_initial_point(point.clone().rebind_network(network)?);
}
}
Ok((fixed, diagnostics))
}
fn check_islands(&self, branch_closed: &[bool]) -> Result<(), Error> {
let initial = &self.initial_islands;
let fin = self.network_with(branch_closed).calc_islands();
if self.options.islanding == IslandingPolicy::Forbidden {
for island in &initial.islands {
let pieces: BTreeSet<Option<usize>> =
island.buses.iter().map(|&bus| fin.island_of(bus)).collect();
if pieces.len() > 1 {
return Err(topology_invalid(format!(
"the final topology splits the initial island of {} buses holding bus {} into {} islands, and islanding is forbidden",
island.buses.len(),
island.buses[0],
pieces.len()
)));
}
}
}
for island in fin.islands.iter().filter(|island| !island.is_supplied()) {
let cut_off = island.buses.iter().find(|&&bus| {
initial
.island_of(bus)
.is_some_and(|position| initial.islands[position].is_supplied())
});
if let Some(bus) = cut_off {
return Err(topology_invalid(format!(
"the final topology leaves an island of {} buses holding bus {bus} with no in-service generator; opening branches does not shed load or de-energize buses",
island.buses.len()
)));
}
}
Ok(())
}
fn network_with(&self, branch_closed: &[bool]) -> BalancedNetwork {
let mut network = self.base.network().clone();
let changed = network
.branches()
.iter()
.zip(branch_closed)
.any(|(branch, &closed)| branch.in_service != closed);
if changed {
for (branch, &closed) in network.branches_mut().iter_mut().zip(branch_closed) {
branch.in_service = closed;
}
}
network
}
}
fn initially_closed_selection(network: &BalancedNetwork) -> BranchSelection {
BranchSelection::Only(
network
.branches()
.iter()
.enumerate()
.filter(|(_, branch)| branch.in_service)
.map(|(row, branch)| row_identity(branch.uid.as_deref(), "branches", row))
.collect(),
)
}
fn options_invalid(message: String) -> Error {
Error::new(&codes::BUILD_INSTANCE_SWITCHING_OPTIONS_INVALID, message)
}
fn topology_invalid(message: String) -> Error {
Error::new(&codes::BUILD_INSTANCE_TOPOLOGY_INVALID, message)
}
fn resolve_branches(
base: &AcOpfInstance,
options: &AcOtsOptions,
) -> Result<Vec<AcOtsBranch>, Error> {
let network = base.network();
let mut branches: Vec<AcOtsBranch> = network
.branches()
.iter()
.enumerate()
.map(|(row, branch)| AcOtsBranch {
id: row_identity(branch.uid.as_deref(), "branches", row),
initial_closed: branch.in_service,
switchable: matches!(options.switchable, BranchSelection::All),
switching_cost: None,
stated_current: TerminalCurrentBounds::default(),
current: TerminalCurrentBounds::default(),
})
.collect();
let mut position = HashMap::with_capacity(branches.len());
for (row, branch) in branches.iter().enumerate() {
if position.insert(branch.id.clone(), row).is_some() {
return Err(Error::new(
&codes::BUILD_INSTANCE_SHAPE_MISMATCH,
format!("two branches share the identity `{}`", branch.id),
));
}
}
let resolve = |what: &str, id: &str, seen: &mut BTreeSet<usize>| -> Result<usize, Error> {
let row = *position
.get(id)
.ok_or_else(|| options_invalid(format!("{what} names unknown branch `{id}`")))?;
if !seen.insert(row) {
return Err(options_invalid(format!(
"{what} names branch `{id}` more than once"
)));
}
Ok(row)
};
if let BranchSelection::Only(identities) = &options.switchable {
let mut seen = BTreeSet::new();
for id in identities {
let row = resolve("the switchable selection", id, &mut seen)?;
branches[row].switchable = true;
}
}
let mut seen = BTreeSet::new();
for cost in &options.switching_costs {
let row = resolve("the switching costs", &cost.id, &mut seen)?;
for (side, value) in [("opening", cost.open_cost), ("closing", cost.close_cost)] {
if !value.is_finite() || value < 0.0 {
return Err(options_invalid(format!(
"branch `{}` has {side} cost {value}; costs must be finite and nonnegative",
cost.id
)));
}
}
branches[row].switching_cost = Some(SwitchingCost {
open: cost.open_cost,
close: cost.close_cost,
});
}
let mut seen = BTreeSet::new();
for limit in &options.stated_current_limits {
let row = resolve("the stated current limits", &limit.id, &mut seen)?;
for (side, value) in [("from", limit.from_pu), ("to", limit.to_pu)] {
if let Some(value) = value
&& (!value.is_finite() || value <= 0.0)
{
return Err(options_invalid(format!(
"branch `{}` has {side} terminal current limit {value}; a limit must be finite and positive",
limit.id
)));
}
}
branches[row].stated_current = TerminalCurrentBounds {
from_pu: limit.from_pu,
to_pu: limit.to_pu,
};
}
check_budgets(&branches, options)?;
apply_current_policy(base, options.current_limits, &mut branches);
Ok(branches)
}
fn check_budgets(branches: &[AcOtsBranch], options: &AcOtsOptions) -> Result<(), Error> {
let switchable = branches.iter().filter(|branch| branch.switchable).count();
let fixed_open = branches
.iter()
.filter(|branch| !branch.switchable && !branch.initial_closed)
.count();
if let Some(max_switched) = options.max_switched
&& max_switched > switchable
{
return Err(options_invalid(format!(
"the switching budget {max_switched} exceeds the {switchable} switchable branches"
)));
}
if let Some(max_open) = options.max_open {
if max_open > branches.len() {
return Err(options_invalid(format!(
"the open budget {max_open} exceeds the {} branches",
branches.len()
)));
}
if max_open < fixed_open {
return Err(options_invalid(format!(
"the open budget {max_open} is below the {fixed_open} branches that are open and cannot switch"
)));
}
}
Ok(())
}
fn apply_current_policy(
base: &AcOpfInstance,
policy: CurrentLimitPolicy,
branches: &mut [AcOtsBranch],
) {
match policy {
CurrentLimitPolicy::Skip => {}
CurrentLimitPolicy::Stated => {
for branch in branches.iter_mut() {
branch.current = branch.stated_current;
}
}
CurrentLimitPolicy::DeriveFromRateA => {
let derived = derive_current_limits(base);
for (branch, limit) in branches.iter_mut().zip(derived) {
branch.current = TerminalCurrentBounds {
from_pu: limit,
to_pu: limit,
};
}
}
}
}
fn derive_current_limits(base: &AcOpfInstance) -> Vec<Option<f64>> {
let network = base.network();
let constraints = base.constraints();
let normalized = network.is_normalized();
let base_mva = network.base_mva();
let buses: HashMap<BusId, (f64, f64, bool)> = network
.buses()
.iter()
.map(|bus| {
let active = constraints.voltage_bounds.selects(&bus.id.to_string());
(bus.id, (bus.vmin, bus.vmax, active))
})
.collect();
network
.branches()
.iter()
.enumerate()
.map(|(row, branch)| {
let identity = row_identity(branch.uid.as_deref(), "branches", row);
let (&(vmin_f, vmax_f, active_f), &(vmin_t, vmax_t, active_t)) =
(buses.get(&branch.from)?, buses.get(&branch.to)?);
if !(active_f && active_t) {
return None;
}
let mut limit: Option<f64> = None;
let impedance = branch.r.hypot(branch.x);
if impedance > 0.0 {
let theta_max = if constraints.angle_bounds.selects(&identity) {
let (angmin, angmax) = if normalized {
(branch.angmin, branch.angmax)
} else {
(branch.angmin.to_radians(), branch.angmax.to_radians())
};
let (angmin, angmax) = correct_angle_difference_bounds(angmin, angmax);
angmin.abs().max(angmax.abs())
} else {
std::f64::consts::PI
};
let spread =
vmax_f * vmax_f + vmax_t * vmax_t - 2.0 * vmax_f * vmax_t * theta_max.cos();
limit = Some(spread.max(0.0).sqrt() / impedance);
}
if branch.rate_a > 0.0 && constraints.thermal_limits.selects(&identity) {
let rate = if normalized {
branch.rate_a
} else {
branch.rate_a / base_mva
};
let rated = rate / vmin_f.min(vmin_t);
limit = Some(limit.map_or(rated, |limit| limit.min(rated)));
}
limit.filter(|limit| limit.is_finite())
})
.collect()
}
fn assign_island_references(network: &mut BalancedNetwork) -> Vec<Diagnostic> {
let partition = network.calc_islands();
let hosts_generator: BTreeSet<BusId> = network
.generators()
.iter()
.filter(|generator| generator.in_service)
.map(|generator| generator.bus)
.collect();
let mut kinds: HashMap<BusId, BusType> = HashMap::new();
let mut diagnostics = Vec::new();
for island in &partition.islands {
let chosen = island
.calc_reference_choice(network.generators())
.unwrap_or(island.buses[0]);
if island.references.as_slice() == [chosen] {
continue;
}
kinds.insert(chosen, BusType::Ref);
for &bus in island.references.iter().filter(|&&bus| bus != chosen) {
let kind = if hosts_generator.contains(&bus) {
BusType::Pv
} else {
BusType::Pq
};
kinds.insert(bus, kind);
}
diagnostics.push(transform_assumption(&format!(
"the final island of {} bus(es) takes bus {chosen} as its one reference bus",
island.buses.len()
)));
}
if !kinds.is_empty() {
for bus in network.buses_mut() {
if let Some(&kind) = kinds.get(&bus.id) {
bus.kind = kind;
}
}
}
diagnostics
}