use pyo3::exceptions::{PyRuntimeError, PyValueError};
use pyo3::prelude::*;
use crate::batch::{BatchSolver, BusOverride, Scenario, uniform_load_scaling};
#[cfg(feature = "cgmes")]
use crate::cgmes::{
cgmes_resolve_dc_converters, cgmes_to_buses_and_branches, cgmes_topological_node_bus_index,
load_profiles,
};
use crate::linear::{
DcApproximation, DcIslandStatus, DcOptions, DcSensitivity, DcSolution, LinearIslandStatus,
dc_branches, dc_power_flow, linear_power_flow,
};
use crate::network::{ShuntAdm, YBusSparse, build_ybus, linear_initial_guess, stamp_shunts};
use crate::pgm::PgmInput;
use crate::solver::{IslandStatus, JacobianBackend, PersistentSolver, SolveStatus};
use crate::types::{Bus, Line, Transformer};
use crate::{PowerFlowInit, PowerFlowMethod};
fn parse_backend(name: &str) -> PyResult<JacobianBackend> {
match name {
"scalar" => Ok(JacobianBackend::Scalar),
"block" => Ok(JacobianBackend::Block),
#[cfg(feature = "klu")]
"klu" => Ok(JacobianBackend::Klu),
#[cfg(not(feature = "klu"))]
"klu" => Err(PyValueError::new_err(
"the 'klu' backend needs the crate's `klu` Cargo feature enabled too \
(maturin develop --features python,klu)",
)),
"klu_native" => Ok(JacobianBackend::KluNative),
#[cfg(feature = "pardiso")]
"pardiso" => Ok(JacobianBackend::Pardiso),
#[cfg(not(feature = "pardiso"))]
"pardiso" => Err(PyValueError::new_err(
"the 'pardiso' backend needs the crate's `pardiso` Cargo feature enabled too \
(maturin develop --features python,pardiso, with MKLROOT set)",
)),
other => Err(PyValueError::new_err(format!(
"unknown backend '{other}', expected 'scalar', 'block', 'klu', 'klu_native', or 'pardiso'"
))),
}
}
fn parse_method(name: &str) -> PyResult<PowerFlowMethod> {
match name {
"newton_raphson" => Ok(PowerFlowMethod::NewtonRaphson),
"dc" => Ok(PowerFlowMethod::Dc),
"linear_impedance" => Ok(PowerFlowMethod::LinearImpedance),
other => Err(PyValueError::new_err(format!(
"unknown method '{other}', expected 'newton_raphson', 'dc', or 'linear_impedance'"
))),
}
}
fn parse_init(name: &str) -> PyResult<PowerFlowInit> {
match name {
"flat" => Ok(PowerFlowInit::Flat),
"linear_impedance" => Ok(PowerFlowInit::LinearImpedance),
"dc" => Ok(PowerFlowInit::Dc),
other => Err(PyValueError::new_err(format!(
"unknown init '{other}', expected 'flat', 'linear_impedance', or 'dc'"
))),
}
}
#[cfg(feature = "cgmes")]
fn parse_retention(name: &str) -> PyResult<crate::topology::RetentionPolicy> {
use crate::topology::RetentionPolicy;
match name {
"none" => Ok(RetentionPolicy::MergeAll),
"all" => Ok(RetentionPolicy::RetainAll),
"busbar_adjacent" => Ok(RetentionPolicy::RetainAdjacentToBusbar),
other => Err(PyValueError::new_err(format!(
"unknown retain '{other}', expected 'none', 'busbar_adjacent' or 'all'"
))),
}
}
fn parse_dc_approximation(name: &str) -> PyResult<DcApproximation> {
match name {
"ignore_r" => Ok(DcApproximation::IgnoreR),
"ignore_g" => Ok(DcApproximation::IgnoreG),
other => Err(PyValueError::new_err(format!(
"unknown dc_approximation '{other}', expected 'ignore_r' (b = 1/x) or \
'ignore_g' (b = x/(r²+x²))"
))),
}
}
#[pyclass]
struct BatchResult {
#[pyo3(get)]
voltage_mag: Vec<f64>,
#[pyo3(get)]
voltage_ang: Vec<f64>,
#[pyo3(get)]
iterations: usize,
#[pyo3(get)]
converged: bool,
#[pyo3(get)]
max_mismatch: f64,
}
#[pyclass]
struct SeBatchOutcome {
#[pyo3(get)]
voltage_mag: Vec<f64>,
#[pyo3(get)]
voltage_ang: Vec<f64>,
#[pyo3(get)]
iterations: usize,
#[pyo3(get)]
objective: f64,
#[pyo3(get)]
converged: bool,
}
#[pyclass(unsendable)]
struct PowerFlowModel {
buses_template: Vec<Bus>,
buses: Vec<Bus>,
ybus: YBusSparse,
lines: Vec<Line>,
transformers: Vec<Transformer>,
shunts: Vec<ShuntAdm>,
solver: PersistentSolver,
backend: JacobianBackend,
method: PowerFlowMethod,
dc_opts: DcOptions,
dc_solution: Option<DcSolution>,
sensitivity: Option<DcSensitivity>,
batch: Option<(usize, BatchSolver)>,
tap_changers: Vec<Option<crate::types::TapChanger>>,
regulation: Vec<crate::outerloop::TapRegulation>,
machines: Vec<crate::types::RegulatingMachine>,
nonregulating_q: Vec<f64>,
outer: Option<crate::OuterLoopOutcome>,
tol: f64,
max_iter: usize,
#[cfg(feature = "cgmes")]
node_breaker: Option<crate::switches::NodeBreakerNetwork>,
tn_bus_index: std::collections::HashMap<String, usize>,
bus_numbers: Vec<u64>,
}
#[pymethods]
impl PowerFlowModel {
#[staticmethod]
#[pyo3(signature = (
path,
backend="klu_native",
tol=1e-6,
max_iter=20,
s_base_va=1e6,
freq_hz=50.0,
method="newton_raphson",
dc_approximation="ignore_r",
))]
#[allow(clippy::too_many_arguments)]
fn from_pgm_json(
path: &str,
backend: &str,
tol: f64,
max_iter: usize,
s_base_va: f64,
freq_hz: f64,
method: &str,
dc_approximation: &str,
) -> PyResult<Self> {
let raw = std::fs::read_to_string(path)
.map_err(|e| PyRuntimeError::new_err(format!("reading {path}: {e}")))?;
let input: PgmInput = serde_json::from_str(&raw)
.map_err(|e| PyValueError::new_err(format!("parsing {path} as PGM JSON: {e}")))?;
let id_to_idx = crate::pgm::node_id_to_idx(&input);
let shunts = crate::pgm::pgm_shunts_1ph(&input, &id_to_idx, s_base_va);
let (buses_template, lines, transformers) =
crate::pgm::pgm_to_buses_and_branches(input, s_base_va, freq_hz);
let mut ybus = build_ybus(buses_template.len(), &lines, &transformers);
stamp_shunts(&mut ybus, &shunts);
let ybus = ybus.finish();
let backend = parse_backend(backend)?;
Ok(Self {
buses: buses_template.clone(),
buses_template,
ybus,
lines,
transformers,
shunts,
solver: PersistentSolver::new(backend),
backend,
machines: Vec::new(),
nonregulating_q: Vec::new(),
method: parse_method(method)?,
dc_opts: DcOptions {
approximation: parse_dc_approximation(dc_approximation)?,
..DcOptions::default()
},
dc_solution: None,
sensitivity: None,
#[cfg(feature = "cgmes")]
node_breaker: None,
batch: None,
tap_changers: Vec::new(),
regulation: Vec::new(),
outer: None,
tol,
max_iter,
tn_bus_index: std::collections::HashMap::new(),
bus_numbers: Vec::new(),
})
}
#[staticmethod]
#[pyo3(signature = (
path,
backend="klu_native",
tol=1e-6,
max_iter=20,
method="newton_raphson",
dc_approximation="ignore_r",
))]
fn from_matpower(
path: &str,
backend: &str,
tol: f64,
max_iter: usize,
method: &str,
dc_approximation: &str,
) -> PyResult<Self> {
let net = crate::matpower::read(path).map_err(|e| match e {
crate::matpower::MatpowerError::Io(e) => {
PyRuntimeError::new_err(format!("reading {path}: {e}"))
}
e => PyValueError::new_err(format!("{path}: {e}")),
})?;
let mut ybus = build_ybus(net.buses.len(), &net.lines, &net.transformers);
stamp_shunts(&mut ybus, &net.shunts);
let ybus = ybus.finish();
let backend = parse_backend(backend)?;
Ok(Self {
buses: net.buses.clone(),
buses_template: net.buses,
ybus,
lines: net.lines,
transformers: net.transformers,
shunts: net.shunts,
solver: PersistentSolver::new(backend),
backend,
machines: Vec::new(),
nonregulating_q: Vec::new(),
method: parse_method(method)?,
dc_opts: DcOptions {
approximation: parse_dc_approximation(dc_approximation)?,
..DcOptions::default()
},
dc_solution: None,
sensitivity: None,
#[cfg(feature = "cgmes")]
node_breaker: None,
batch: None,
tap_changers: Vec::new(),
regulation: Vec::new(),
outer: None,
tol,
max_iter,
tn_bus_index: std::collections::HashMap::new(),
bus_numbers: net.bus_numbers,
})
}
fn bus_numbers(&self) -> Vec<u64> {
self.bus_numbers.clone()
}
#[cfg(feature = "cgmes")]
#[staticmethod]
#[pyo3(signature = (
paths,
backend="klu_native",
tol=1e-6,
max_iter=20,
s_base_va=100e6,
method="newton_raphson",
dc_approximation="ignore_r",
topology="bus_branch",
retain="none",
))]
#[allow(clippy::too_many_arguments)]
fn from_cgmes(
paths: Vec<String>,
backend: &str,
tol: f64,
max_iter: usize,
s_base_va: f64,
method: &str,
dc_approximation: &str,
topology: &str,
retain: &str,
) -> PyResult<Self> {
let path_refs: Vec<&std::path::Path> = paths.iter().map(std::path::Path::new).collect();
let ds = load_profiles(&path_refs)
.map_err(|e| PyRuntimeError::new_err(format!("decoding CGMES profiles: {e}")))?;
let mut taps: (
Vec<Option<crate::types::TapChanger>>,
Vec<crate::outerloop::TapRegulation>,
) = (Vec::new(), Vec::new());
let mut dispatch_data: (Vec<crate::types::RegulatingMachine>, Vec<f64>) =
(Vec::new(), Vec::new());
let (buses_template, lines, transformers, shunts, tn_bus_index, node_breaker) =
match topology {
"bus_branch" => {
if retain != "none" {
return Err(PyValueError::new_err(
"retain= only applies to topology=\"node_breaker\"; the bus-branch \
view has already merged every switch away",
));
}
let net = crate::cgmes::cgmes_to_network(&ds, s_base_va).map_err(|e| {
PyRuntimeError::new_err(format!("converting CGMES model: {e}"))
})?;
let (mut buses, lines, transformers, shunts) =
(net.buses, net.lines, net.transformers, net.shunts);
taps = (net.tap_changers, net.regulation);
dispatch_data = (
net.voltage_control.machines,
net.voltage_control.nonregulating_q,
);
cgmes_resolve_dc_converters(&ds, &mut buses, s_base_va).map_err(|e| {
PyRuntimeError::new_err(format!("resolving CGMES HVDC converters: {e}"))
})?;
let tn = cgmes_topological_node_bus_index(&ds).map_err(|e| {
PyRuntimeError::new_err(format!("resolving CGMES bus index: {e}"))
})?;
(buses, lines, transformers, shunts, tn, None)
}
"node_breaker" => {
let policy = parse_retention(retain)?;
let net = crate::cgmes::cgmes_node_breaker_to_buses_and_branches(
&ds,
s_base_va,
&policy,
crate::switches::SwitchTreatment::Regularize,
)
.map_err(|e| PyRuntimeError::new_err(format!("converting CGMES model: {e}")))?;
(
net.buses.clone(),
net.lines.clone(),
net.transformers.clone(),
net.shunts.clone(),
std::collections::HashMap::new(),
Some(net),
)
}
other => {
return Err(PyValueError::new_err(format!(
"unknown topology '{other}', expected 'bus_branch' or 'node_breaker'"
)));
}
};
let mut ybus = build_ybus(buses_template.len(), &lines, &transformers);
stamp_shunts(&mut ybus, &shunts);
let ybus = ybus.finish();
let backend = parse_backend(backend)?;
Ok(Self {
buses: buses_template.clone(),
buses_template,
ybus,
lines,
transformers,
shunts,
machines: dispatch_data.0,
nonregulating_q: dispatch_data.1,
solver: PersistentSolver::new(backend),
backend,
method: parse_method(method)?,
dc_opts: DcOptions {
approximation: parse_dc_approximation(dc_approximation)?,
..DcOptions::default()
},
dc_solution: None,
sensitivity: None,
node_breaker,
batch: None,
tap_changers: taps.0,
regulation: taps.1,
outer: None,
tol,
max_iter,
tn_bus_index,
bus_numbers: Vec::new(),
})
}
#[getter]
fn n_nodes(&self) -> usize {
self.buses.len()
}
fn bus_index_for_mrid(&self, mrid: &str) -> Option<usize> {
self.tn_bus_index.get(mrid).copied()
}
#[pyo3(signature = (
control_taps = false,
enforce_q_limits = false,
control_remote_voltage = false,
distribute_slack = false,
max_outer = 40,
max_tap_shift = 3,
init = "linear_impedance",
))]
#[allow(clippy::too_many_arguments)]
fn solve(
&mut self,
control_taps: bool,
enforce_q_limits: bool,
control_remote_voltage: bool,
distribute_slack: bool,
max_outer: usize,
max_tap_shift: i32,
init: &str,
) -> PyResult<()> {
let init = parse_init(init)?;
match self.method {
PowerFlowMethod::Dc => return self.solve_dc(),
PowerFlowMethod::LinearImpedance => return self.solve_linear_impedance(),
PowerFlowMethod::NewtonRaphson => {}
}
self.dc_solution = None;
self.outer = None;
self.buses = self.buses_template.clone();
crate::initialize_newton(
&mut self.buses,
&self.lines,
&self.transformers,
&self.ybus,
init,
self.dc_opts,
);
let wants_taps = control_taps && !self.regulation.is_empty();
let wants_remote =
control_remote_voltage && self.machines.iter().any(|m| m.at_bus != m.controls_bus);
let islands = if enforce_q_limits || distribute_slack || wants_taps || wants_remote {
let distribution =
distribute_slack.then(|| crate::outerloop::SlackDistribution::uniform(&self.buses));
let report = crate::run_power_flow_with_remote(
std::mem::take(&mut self.buses),
&self.lines,
&self.transformers,
&self.shunts,
crate::TapData {
changers: &self.tap_changers,
regulation: &self.regulation,
},
crate::RemoteControlData {
machines: &self.machines,
},
crate::PowerFlowOptions {
control_taps,
control_remote_voltage,
tap_max_shift: max_tap_shift,
enforce_q_limits,
distribute_slack: distribution,
max_outer_iter: max_outer,
init: PowerFlowInit::Flat,
tol: self.tol,
max_iter: self.max_iter,
backend: self.backend,
..Default::default()
},
);
self.buses = report.buses;
self.outer = report.outer;
self.solver.reset();
self.sensitivity = None;
report.islands
} else {
self.solver
.solve(&mut self.buses, &self.ybus, self.tol, self.max_iter)
};
for island in &islands {
match island.status {
IslandStatus::Converged
| IslandStatus::NoReferenceBus
| IslandStatus::AmbiguousReferenceBus => {}
IslandStatus::MaxIterationsReached => {
return Err(PyRuntimeError::new_err(format!(
"power flow did not converge within {} iterations (component with buses {:?})",
self.max_iter, island.bus_indices
)));
}
IslandStatus::Singular => {
return Err(PyRuntimeError::new_err(format!(
"Jacobian is singular (component with buses {:?})",
island.bus_indices
)));
}
}
}
Ok(())
}
fn tap_positions(&self) -> Vec<Option<i32>> {
let moved = self
.outer
.as_ref()
.map(|o| &o.changers)
.filter(|c| !c.is_empty());
moved
.unwrap_or(&self.tap_changers)
.iter()
.map(|c| c.as_ref().map(|c| c.position))
.collect()
}
fn tap_controllers<'py>(
&self,
py: Python<'py>,
) -> PyResult<Vec<pyo3::Bound<'py, pyo3::types::PyDict>>> {
use crate::outerloop::ControllerOutcome;
let Some(outer) = self.outer.as_ref() else {
return Ok(Vec::new());
};
outer
.taps
.iter()
.map(|c| {
let d = pyo3::types::PyDict::new(py);
d.set_item("id", c.id.clone())?;
d.set_item("transformer", c.transformer)?;
d.set_item("controlled_bus", c.controlled_bus)?;
d.set_item("initial_position", c.initial_position)?;
d.set_item("final_position", c.final_position)?;
d.set_item("steps_moved", c.steps_moved)?;
d.set_item("direction_changes", c.direction_changes)?;
d.set_item(
"outcome",
match c.outcome {
ControllerOutcome::InDeadband => "in_deadband",
ControllerOutcome::Closest => "closest",
ControllerOutcome::AtLimit => "at_limit",
ControllerOutcome::Insensitive => "insensitive",
ControllerOutcome::Hunting => "hunting",
ControllerOutcome::Unfinished => "unfinished",
},
)?;
Ok(d)
})
.collect()
}
fn remote_voltage_control(&self, py: Python<'_>) -> PyResult<Vec<Py<pyo3::types::PyDict>>> {
let Some(outer) = self.outer.as_ref() else {
return Ok(Vec::new());
};
outer
.remote
.iter()
.map(|r| {
let d = pyo3::types::PyDict::new(py);
d.set_item("machine", r.machine.clone())?;
d.set_item("controller_bus", r.controller_bus)?;
d.set_item("controlled_bus", r.controlled_bus)?;
d.set_item("target", r.target)?;
d.set_item("reached", r.reached)?;
d.set_item("setpoint", r.setpoint)?;
d.set_item("moves", r.moves)?;
d.set_item(
"outcome",
match r.outcome {
crate::outerloop::RemoteOutcome::Held => "held",
crate::outerloop::RemoteOutcome::Insensitive => "insensitive",
crate::outerloop::RemoteOutcome::Unfinished => "unfinished",
crate::outerloop::RemoteOutcome::AtReactiveLimit => "at_reactive_limit",
},
)?;
Ok(d.unbind())
})
.collect()
}
#[pyo3(signature = (bus, v_max = 1.10, v_min = 0.40, step = 0.01, refine = true,
enforce_q_limits = false))]
#[allow(clippy::too_many_arguments)]
fn qv_curve(
&self,
py: Python<'_>,
bus: usize,
v_max: f64,
v_min: f64,
step: f64,
refine: bool,
enforce_q_limits: bool,
) -> PyResult<Py<pyo3::types::PyDict>> {
use crate::qv::{QvOptions, QvStatus, qv_curve};
let curve = qv_curve(
&self.buses_template,
&self.lines,
&self.transformers,
&self.shunts,
bus,
QvOptions {
pf: crate::PowerFlowOptions {
enforce_q_limits,
tol: self.tol,
max_iter: self.max_iter.max(60),
backend: self.backend,
..Default::default()
},
v_max,
v_min,
step,
refine,
},
);
if let QvStatus::Rejected(why) = &curve.status {
return Err(PyValueError::new_err(format!("bus {bus}: {why:?}")));
}
let d = pyo3::types::PyDict::new(py);
d.set_item("bus", curve.bus)?;
d.set_item("base_voltage", curve.base_voltage)?;
d.set_item("already_controlled", curve.already_controlled)?;
d.set_item(
"status",
match curve.status {
QvStatus::NoseFound => "nose_found",
QvStatus::NoseNotReached => "nose_not_reached",
QvStatus::Failed => "failed",
QvStatus::Rejected(_) => "rejected",
},
)?;
let points: Vec<Py<pyo3::types::PyDict>> = curve
.points
.iter()
.map(|p| {
let e = pyo3::types::PyDict::new(py);
e.set_item("voltage", p.voltage)?;
e.set_item("q", p.q)?;
e.set_item("iterations", p.iterations)?;
Ok(e.unbind())
})
.collect::<PyResult<_>>()?;
d.set_item("points", points)?;
d.set_item("failed", curve.failed.clone())?;
match &curve.nose {
None => d.set_item("nose", py.None())?,
Some(n) => {
let e = pyo3::types::PyDict::new(py);
e.set_item("voltage", n.voltage)?;
e.set_item("q", n.q)?;
e.set_item("margin_pu", n.margin_pu)?;
e.set_item("refined", n.refined)?;
d.set_item("nose", e)?;
}
}
Ok(d.unbind())
}
fn machine_dispatch(&self, py: Python<'_>) -> PyResult<Vec<Py<pyo3::types::PyDict>>> {
let split = crate::dispatch::allocate(
&self.machines,
&self.nonregulating_q,
&self.buses,
&self.ybus,
);
split
.into_iter()
.map(|bd| {
let d = pyo3::types::PyDict::new(py);
d.set_item("bus", bd.bus)?;
d.set_item("required", bd.required)?;
d.set_item("attributed", bd.attributed)?;
d.set_item("unattributed", bd.unattributed)?;
d.set_item(
"basis",
match bd.basis {
crate::dispatch::KeyBasis::Explicit => "explicit",
crate::dispatch::KeyBasis::Capability => "capability",
crate::dispatch::KeyBasis::Uniform => "uniform",
},
)?;
let machines: Vec<Py<pyo3::types::PyDict>> = bd
.machines
.into_iter()
.map(|m| {
let e = pyo3::types::PyDict::new(py);
e.set_item("id", m.id)?;
e.set_item("at_bus", m.at_bus)?;
e.set_item("q", m.q)?;
e.set_item("q_min", m.q_min)?;
e.set_item("q_max", m.q_max)?;
e.set_item("at_limit", m.at_limit)?;
e.set_item("share", m.share)?;
Ok(e.unbind())
})
.collect::<PyResult<_>>()?;
d.set_item("machines", machines)?;
Ok(d.unbind())
})
.collect()
}
fn q_limit_switches(&self) -> Vec<usize> {
self.outer
.as_ref()
.map(|o| o.q_limit_switches.clone())
.unwrap_or_default()
}
fn slack_shift(&self) -> Vec<f64> {
self.outer
.as_ref()
.and_then(|o| o.slack.as_ref())
.map(|s| s.shift.clone())
.unwrap_or_default()
}
fn outer_loops(&self) -> Vec<(String, usize, bool)> {
self.outer
.as_ref()
.and_then(|o| o.report.as_ref())
.map(|r| {
r.loops
.iter()
.map(|l| {
(
l.name.to_string(),
l.iterations,
l.status == crate::outerloop::OuterLoopStatus::Stable,
)
})
.collect()
})
.unwrap_or_default()
}
#[getter]
fn tap_control_count(&self) -> usize {
self.regulation.len()
}
fn reset(&mut self) {
self.solver.reset();
self.sensitivity = None;
}
fn solve_dc(&mut self) -> PyResult<()> {
self.buses = self.buses_template.clone();
let solution = dc_power_flow(
&mut self.buses,
&self.lines,
&self.transformers,
self.dc_opts,
);
let singular: Vec<&[usize]> = solution
.islands
.iter()
.filter(|i| i.status == DcIslandStatus::Singular)
.map(|i| i.bus_indices.as_slice())
.collect();
if !singular.is_empty() {
return Err(PyRuntimeError::new_err(format!(
"DC susceptance matrix is singular (component(s) with buses {singular:?})"
)));
}
self.dc_solution = Some(solution);
Ok(())
}
fn solve_linear_impedance(&mut self) -> PyResult<()> {
self.dc_solution = None;
self.buses = self.buses_template.clone();
let report = linear_power_flow(&mut self.buses, &self.ybus);
let singular: Vec<&[usize]> = report
.islands
.iter()
.filter(|i| i.status == LinearIslandStatus::Singular)
.map(|i| i.bus_indices.as_slice())
.collect();
if !singular.is_empty() {
return Err(PyRuntimeError::new_err(format!(
"linearized system is singular (component(s) with buses {singular:?})"
)));
}
Ok(())
}
fn branch_flow_p(&self) -> PyResult<Vec<f64>> {
Ok(self.require_dc()?.branch_p.clone())
}
fn dc_slack_pickup(&self) -> PyResult<Vec<(Vec<usize>, f64)>> {
Ok(self
.require_dc()?
.islands
.iter()
.map(|i| (i.bus_indices.clone(), i.slack_pickup))
.collect())
}
fn dc_max_residual(&self) -> PyResult<f64> {
Ok(self.require_dc()?.max_residual)
}
fn ptdf_column(&mut self, bus: usize) -> PyResult<Option<Vec<f64>>> {
if bus >= self.buses.len() {
return Err(PyValueError::new_err(format!(
"bus {bus} is out of range (0..{})",
self.buses.len()
)));
}
Ok(self.require_sensitivity()?.ptdf_column(bus))
}
fn ptdf_row(&mut self, branch: usize) -> PyResult<Option<Vec<f64>>> {
self.check_branch(branch)?;
Ok(self.require_sensitivity()?.ptdf_row(branch))
}
fn lodf_column(&mut self, branch: usize) -> PyResult<Option<Vec<f64>>> {
self.check_branch(branch)?;
Ok(self.require_sensitivity()?.lodf_column(branch))
}
fn is_radial(&mut self, branch: usize) -> PyResult<bool> {
self.check_branch(branch)?;
Ok(self.require_sensitivity()?.is_radial(branch))
}
#[pyo3(signature = (branch, base_flows=None))]
fn outage_flows(
&mut self,
branch: usize,
base_flows: Option<Vec<f64>>,
) -> PyResult<Option<Vec<f64>>> {
self.check_branch(branch)?;
let base = match base_flows {
Some(f) => f,
None => self.require_dc()?.branch_p.clone(),
};
let n = self.lines.len() + self.transformers.len();
if base.len() != n {
return Err(PyValueError::new_err(format!(
"base_flows has {} entries, expected one per branch ({n})",
base.len()
)));
}
Ok(self.require_sensitivity()?.outage_flows(&base, branch))
}
#[pyo3(signature = (branches, base_flows=None))]
fn multi_outage_flows(
&mut self,
branches: Vec<usize>,
base_flows: Option<Vec<f64>>,
) -> PyResult<Option<Vec<f64>>> {
for &b in &branches {
self.check_branch(b)?;
}
let base = match base_flows {
Some(f) => f,
None => self.require_dc()?.branch_p.clone(),
};
let n = self.lines.len() + self.transformers.len();
if base.len() != n {
return Err(PyValueError::new_err(format!(
"base_flows has {} entries, expected one per branch ({n})",
base.len()
)));
}
Ok(self
.require_sensitivity()?
.multi_outage_flows(&base, &branches))
}
#[pyo3(signature = (contingencies, threads=None))]
fn solve_contingencies(
&mut self,
contingencies: Vec<Vec<usize>>,
threads: Option<usize>,
) -> PyResult<Vec<(String, Vec<f64>, Vec<f64>)>> {
let scenarios: Vec<Scenario> = contingencies
.into_iter()
.map(|branch_outages| Scenario {
bus_overrides: Vec::new(),
branch_outages,
})
.collect();
let batch = match threads {
Some(t) => BatchSolver::with_threads(self.backend, t.max(1))
.map_err(|e| PyRuntimeError::new_err(e.to_string()))?,
None => BatchSolver::new(self.backend),
};
let reports = batch
.solve_contingencies(
&self.buses_template,
&self.lines,
&self.transformers,
&self.shunts,
&scenarios,
self.tol,
self.max_iter,
)
.map_err(|e| PyValueError::new_err(e.to_string()))?;
Ok(reports
.into_iter()
.map(|r| {
let status = match r.stats.status {
SolveStatus::Converged => "converged",
SolveStatus::MaxIterationsReached => "max_iterations",
SolveStatus::Singular => "singular",
};
let vm = r.buses.iter().map(|b| b.voltage_mag).collect();
let va = r.buses.iter().map(|b| b.voltage_ang).collect();
(status.to_string(), vm, va)
})
.collect())
}
fn is_breaking_set(&mut self, branches: Vec<usize>) -> PyResult<bool> {
for &b in &branches {
self.check_branch(b)?;
}
Ok(self.require_sensitivity()?.is_breaking_set(&branches))
}
fn transfer_factors(&mut self, injections: Vec<f64>) -> PyResult<Vec<f64>> {
if injections.len() != self.buses.len() {
return Err(PyValueError::new_err(format!(
"injections has {} entries, expected one per bus ({})",
injections.len(),
self.buses.len()
)));
}
self.require_sensitivity()?
.transfer_factors(&injections)
.ok_or_else(|| PyRuntimeError::new_err("the reduced susceptance matrix is singular"))
}
#[cfg(feature = "cgmes")]
fn switches(&self) -> Vec<(usize, String, String, usize, usize, bool, usize)> {
let Some(net) = self.node_breaker.as_ref() else {
return Vec::new();
};
net.view
.retained()
.iter()
.enumerate()
.filter_map(|(n, r)| {
let branch = net.branch_of_retained(n)?;
let kind = net
.switch_kind(r.switch)
.map(|k| format!("{k:?}"))
.unwrap_or_else(|| "Unknown".to_string());
Some((
r.switch.0,
net.switch_label(r.switch),
kind,
r.buses[0].0,
r.buses[1].0,
net.is_switch_open(r.switch).unwrap_or(r.open),
branch,
))
})
.collect()
}
#[cfg(feature = "cgmes")]
fn set_switch(&mut self, switch: usize, open: bool) -> PyResult<()> {
let Some(net) = self.node_breaker.as_mut() else {
return Err(PyRuntimeError::new_err(
"this model is not node-breaker; build it with topology=\"node_breaker\"",
));
};
if !net.set_switch_open(crate::topology::SwitchIdx(switch), open) {
return Err(PyValueError::new_err(format!(
"switch {switch} is not retained in this view, or is degenerate"
)));
}
self.transformers = net.transformers.clone();
let mut ybus = build_ybus(self.buses_template.len(), &self.lines, &self.transformers);
stamp_shunts(&mut ybus, &self.shunts);
self.ybus = ybus.finish();
self.solver.reset();
self.sensitivity = None;
self.dc_solution = None;
Ok(())
}
#[cfg(feature = "cgmes")]
fn switch_flow_p(&self) -> PyResult<Vec<f64>> {
let Some(net) = self.node_breaker.as_ref() else {
return Err(PyRuntimeError::new_err(
"this model is not node-breaker; build it with topology=\"node_breaker\"",
));
};
let v = crate::branch_flow::bus_voltages(&self.buses);
Ok(net
.switch_branches()
.into_iter()
.map(|(switch, _)| net.switch_flow(switch, &v).map(|(p, _)| p).unwrap_or(0.0))
.collect())
}
#[getter]
fn n_branches(&self) -> usize {
self.lines.len() + self.transformers.len()
}
#[pyo3(signature = (scenarios, threads=None))]
fn solve_batch(
&mut self,
scenarios: Vec<Vec<(usize, f64, f64)>>,
threads: Option<usize>,
) -> PyResult<Vec<BatchResult>> {
let scenarios: Vec<Scenario> = scenarios
.into_iter()
.map(|overrides| {
Scenario::new(
overrides
.into_iter()
.map(|(bus, p, q)| BusOverride::new(bus).p(p).q(q))
.collect(),
)
})
.collect();
self.run_batch(scenarios, threads)
}
#[pyo3(signature = (scales, threads=None))]
fn solve_batch_scaled(
&mut self,
scales: Vec<f64>,
threads: Option<usize>,
) -> PyResult<Vec<BatchResult>> {
let scenarios: Vec<Scenario> = scales
.into_iter()
.map(|f| uniform_load_scaling(&self.buses_template, f))
.collect();
self.run_batch(scenarios, threads)
}
#[staticmethod]
fn default_threads() -> usize {
rayon::current_num_threads()
}
fn ybus_triplets(&self) -> (Vec<usize>, Vec<usize>, Vec<f64>, Vec<f64>) {
let n = self.ybus.n();
let mut rows = Vec::new();
let mut cols = Vec::new();
let mut g = Vec::new();
let mut b = Vec::new();
for i in 0..n {
for &(j, y) in self.ybus.row(i) {
rows.push(i);
cols.push(j);
g.push(y.re);
b.push(y.im);
}
}
(rows, cols, g, b)
}
fn bus_spec(&self) -> (Vec<u32>, Vec<f64>, Vec<f64>) {
let mut kinds = Vec::with_capacity(self.buses_template.len());
let mut p = Vec::with_capacity(self.buses_template.len());
let mut q = Vec::with_capacity(self.buses_template.len());
for bus in &self.buses_template {
kinds.push(match bus.bus_type {
crate::types::BusType::Slack => 0u32,
crate::types::BusType::PV => 1,
crate::types::BusType::PQ => 2,
});
p.push(bus.p_spec);
q.push(bus.q_spec);
}
(kinds, p, q)
}
fn initial_guess(&self) -> (Vec<f64>, Vec<f64>) {
let mut buses = self.buses_template.clone();
linear_initial_guess(&mut buses, &self.ybus);
(
buses.iter().map(|b| b.voltage_mag).collect(),
buses.iter().map(|b| b.voltage_ang).collect(),
)
}
fn zip_term_counts(&self) -> Vec<usize> {
self.buses_template
.iter()
.map(|b| b.zip_terms.len())
.collect()
}
fn voltage_mag(&self) -> Vec<f64> {
self.buses.iter().map(|b| b.voltage_mag).collect()
}
fn voltage_ang(&self) -> Vec<f64> {
self.buses.iter().map(|b| b.voltage_ang).collect()
}
fn voltage_kv(&self) -> Vec<f64> {
self.buses
.iter()
.map(|b| b.voltage_mag * b.u_rated / 1e3)
.collect()
}
}
impl PowerFlowModel {
fn require_dc(&self) -> PyResult<&DcSolution> {
self.dc_solution.as_ref().ok_or_else(|| {
PyRuntimeError::new_err(
"no DC result available — build the model with method=\"dc\" and call solve(), \
or call solve_dc() directly",
)
})
}
fn require_sensitivity(&mut self) -> PyResult<&DcSensitivity> {
if self.sensitivity.is_none() {
let branches = dc_branches(&self.lines, &self.transformers, self.dc_opts);
let n_branches = self.lines.len() + self.transformers.len();
self.sensitivity = Some(
DcSensitivity::new(&self.buses_template, &branches, n_branches).ok_or_else(
|| {
PyRuntimeError::new_err(
"the reduced susceptance matrix is singular, so no sensitivity \
factors exist for this network",
)
},
)?,
);
}
Ok(self.sensitivity.as_ref().expect("just populated"))
}
fn check_branch(&self, branch: usize) -> PyResult<()> {
let n = self.lines.len() + self.transformers.len();
if branch >= n {
return Err(PyValueError::new_err(format!(
"branch {branch} is out of range (0..{n})"
)));
}
Ok(())
}
fn run_batch(
&mut self,
scenarios: Vec<Scenario>,
threads: Option<usize>,
) -> PyResult<Vec<BatchResult>> {
let want = threads.unwrap_or_else(rayon::current_num_threads).max(1);
if self.batch.as_ref().map(|(n, _)| *n) != Some(want) {
let solver = BatchSolver::with_threads(self.backend, want)
.map_err(|e| PyRuntimeError::new_err(e.to_string()))?;
self.batch = Some((want, solver));
}
let (_, batch) = self.batch.as_ref().expect("just populated above");
let reports = batch
.solve(
&self.buses_template,
&self.ybus,
&scenarios,
self.tol,
self.max_iter,
)
.map_err(|e| PyRuntimeError::new_err(e.to_string()))?;
Ok(reports
.into_iter()
.map(|r| BatchResult {
voltage_mag: r.buses.iter().map(|b| b.voltage_mag).collect(),
voltage_ang: r.buses.iter().map(|b| b.voltage_ang).collect(),
iterations: r.stats.iterations(),
converged: r.stats.status == SolveStatus::Converged,
max_mismatch: r.stats.final_mismatch(),
})
.collect())
}
}
#[pyclass(unsendable)]
struct StateEstimationModel {
buses: Vec<Bus>,
buses_template: Vec<Bus>,
phases: usize,
node_idx: std::collections::HashMap<u64, usize>,
se_net: crate::se::SeNetwork,
measurements: Vec<crate::measurement::Measurement>,
options: crate::se::nr::SeOptions,
report: Option<crate::se::nr::SeReport>,
estimator: crate::se::nr::PersistentEstimator,
}
#[pymethods]
impl StateEstimationModel {
#[staticmethod]
#[pyo3(signature = (path, backend="klu_native", method="newton_raphson", tol=1e-8, max_iter=20, s_base_va=1e6, freq_hz=50.0, asymmetric=false))]
#[allow(clippy::too_many_arguments)]
fn from_pgm_json(
path: &str,
backend: &str,
method: &str,
tol: f64,
max_iter: usize,
s_base_va: f64,
freq_hz: f64,
asymmetric: bool,
) -> PyResult<Self> {
let method = match method {
"newton_raphson" => crate::se::nr::SeMethod::NewtonRaphson,
"iterative_linear" => crate::se::nr::SeMethod::IterativeLinear,
other => {
return Err(PyValueError::new_err(format!(
"unknown method {other:?}, expected 'newton_raphson' or 'iterative_linear'"
)));
}
};
let raw = std::fs::read_to_string(path)
.map_err(|e| PyRuntimeError::new_err(format!("reading {path}: {e}")))?;
let input: PgmInput = serde_json::from_str(&raw)
.map_err(|e| PyValueError::new_err(format!("parsing {path} as PGM JSON: {e}")))?;
let case = if asymmetric {
crate::pgm::se::se_case_from_pgm_3ph(&input, s_base_va, freq_hz)
} else {
crate::pgm::se::se_case_from_pgm(&input, s_base_va, freq_hz)
}
.map_err(|e| PyValueError::new_err(format!("{path}: {e}")))?;
let options = crate::se::nr::SeOptions {
tol,
max_iter,
backend: parse_backend(backend)?,
method,
};
Ok(Self {
buses: case.buses.clone(),
buses_template: case.buses,
phases: case.phases,
node_idx: case.node_idx,
se_net: case.network,
measurements: case.measurements,
options,
report: None,
estimator: crate::se::nr::PersistentEstimator::new(options),
})
}
#[getter]
fn phases(&self) -> usize {
self.phases
}
fn nodes(&self) -> Vec<(u64, usize)> {
let mut out: Vec<(u64, usize)> = self
.node_idx
.iter()
.map(|(&id, &i)| (id, self.phases * i))
.collect();
out.sort_unstable();
out
}
#[getter]
fn n_nodes(&self) -> usize {
self.buses.len()
}
fn measurement_value(&self, i: usize) -> PyResult<f64> {
self.measurements
.get(i)
.map(|m| m.value)
.ok_or_else(|| PyValueError::new_err(format!("no measurement {i}")))
}
fn measurement_sigma(&self, i: usize) -> PyResult<f64> {
self.measurements
.get(i)
.map(|m| m.sigma)
.ok_or_else(|| PyValueError::new_err(format!("no measurement {i}")))
}
#[getter]
fn n_measurements(&self) -> usize {
self.measurements.len()
}
fn solve(&mut self) -> PyResult<()> {
self.buses = self.buses_template.clone();
crate::se::nr::linear_start(&mut self.buses, &self.se_net, &self.measurements);
let report = self
.estimator
.estimate(&self.measurements, &mut self.buses, &self.se_net);
let status = report.status;
self.report = Some(report);
match status {
crate::se::nr::SeStatus::Converged => Ok(()),
crate::se::nr::SeStatus::MaxIterations => Err(PyRuntimeError::new_err(
"state estimation did not converge within max_iter",
)),
crate::se::nr::SeStatus::Singular => Err(PyRuntimeError::new_err(
"the gain matrix is singular; the measurements likely leave part of \
the state unobservable — call observability() for the detail",
)),
}
}
#[pyo3(signature = (scenarios, threads=0))]
fn solve_batch(
&mut self,
scenarios: Vec<Vec<(usize, f64, f64)>>,
threads: usize,
) -> PyResult<Vec<SeBatchOutcome>> {
use crate::se::batch::{MeasurementOverride, SeBatchSolver, SeScenario};
let scenarios: Vec<SeScenario> = scenarios
.into_iter()
.map(|rows| {
SeScenario::new(
rows.into_iter()
.map(|(i, value, sigma)| {
MeasurementOverride::new(i).value(value).sigma(sigma)
})
.collect(),
)
})
.collect();
let solver = if threads == 0 {
SeBatchSolver::new(self.options)
} else {
SeBatchSolver::with_threads(self.options, threads)
.map_err(|e| PyRuntimeError::new_err(e.to_string()))?
};
let results = solver
.estimate(
&self.buses_template,
&self.se_net,
&self.measurements,
&scenarios,
)
.map_err(|e| PyValueError::new_err(e.to_string()))?;
Ok(results
.into_iter()
.map(|r| SeBatchOutcome {
voltage_mag: r.buses.iter().map(|b| b.voltage_mag).collect(),
voltage_ang: r.buses.iter().map(|b| b.voltage_ang).collect(),
iterations: r.report.iterations,
objective: r.report.objective,
converged: matches!(r.report.status, crate::se::nr::SeStatus::Converged),
})
.collect())
}
fn voltage_mag(&self) -> Vec<f64> {
self.buses.iter().map(|b| b.voltage_mag).collect()
}
fn voltage_ang(&self) -> Vec<f64> {
self.buses.iter().map(|b| b.voltage_ang).collect()
}
fn residuals(&self) -> Vec<f64> {
self.report
.as_ref()
.map(|r| r.residuals.clone())
.unwrap_or_default()
}
#[getter]
fn objective(&self) -> f64 {
self.report
.as_ref()
.map(|r| r.objective)
.unwrap_or(f64::NAN)
}
fn observability(&self) -> Vec<(usize, String)> {
let layout =
crate::se::jacobian::StateLayout::new(&self.buses, &self.measurements, &self.se_net);
let report = crate::se::observability::analyze(
&self.measurements,
&self.buses,
&self.se_net,
&layout,
&crate::se::constraints::Constraints::new(&self.se_net),
);
report
.unobservable
.iter()
.chain(&report.structurally_unmeasured)
.map(|u| {
let quantity = match u.quantity {
crate::se::observability::Quantity::Angle => "angle",
crate::se::observability::Quantity::Magnitude => "magnitude",
};
(u.bus, quantity.to_string())
})
.collect()
}
#[pyo3(signature = (candidates=20))]
fn bad_data(&self, candidates: usize) -> PyResult<(f64, usize, f64, Vec<(usize, f64)>)> {
let Some(report) = self.report.as_ref() else {
return Err(PyRuntimeError::new_err("call solve() before bad_data()"));
};
let layout =
crate::se::jacobian::StateLayout::new(&self.buses, &self.measurements, &self.se_net);
let constraints = crate::se::constraints::Constraints::new(&self.se_net);
let bad = crate::se::bad_data::analyze(
&self.measurements,
&report.residuals,
&self.buses,
&self.se_net,
&layout,
&constraints,
crate::se::bad_data::Candidates { limit: candidates },
);
Ok((
bad.chi_squared,
bad.degrees_of_freedom,
bad.p_value,
bad.suspects
.iter()
.map(|s| (s.measurement, s.normalized_residual))
.collect(),
))
}
}
#[pyclass]
struct ShortCircuitNode {
#[pyo3(get)]
id: u64,
#[pyo3(get)]
u_pu: [f64; 3],
#[pyo3(get)]
u_angle: [f64; 3],
#[pyo3(get)]
u: [f64; 3],
#[pyo3(get)]
energized: bool,
#[pyo3(get)]
sequence: (f64, f64, f64),
}
#[pyclass]
struct ShortCircuitFault {
#[pyo3(get)]
id: u64,
#[pyo3(get)]
i_f: [f64; 3],
#[pyo3(get)]
i_f_angle: [f64; 3],
}
#[pyclass]
struct ShortCircuitSource {
#[pyo3(get)]
id: u64,
#[pyo3(get)]
i: [f64; 3],
#[pyo3(get)]
i_angle: [f64; 3],
}
#[pyclass]
struct ShortCircuitBranch {
#[pyo3(get)]
id: u64,
#[pyo3(get)]
energized: bool,
#[pyo3(get)]
i_from: [f64; 3],
#[pyo3(get)]
i_from_angle: [f64; 3],
#[pyo3(get)]
i_to: [f64; 3],
#[pyo3(get)]
i_to_angle: [f64; 3],
}
#[pyclass]
struct ShortCircuitShunt {
#[pyo3(get)]
id: u64,
#[pyo3(get)]
i: [f64; 3],
#[pyo3(get)]
i_angle: [f64; 3],
}
#[pyclass]
struct ShortCircuitResult {
#[pyo3(get)]
nodes: Vec<Py<ShortCircuitNode>>,
#[pyo3(get)]
faults: Vec<Py<ShortCircuitFault>>,
#[pyo3(get)]
sources: Vec<Py<ShortCircuitSource>>,
#[pyo3(get)]
branches: Vec<Py<ShortCircuitBranch>>,
#[pyo3(get)]
shunts: Vec<Py<ShortCircuitShunt>>,
}
#[pyclass]
struct ContinuationEvent {
#[pyo3(get)]
bus: usize,
#[pyo3(get)]
limit: String,
#[pyo3(name = "lambda_", get)]
lambda: f64,
}
#[pyclass]
struct ContinuationCurve {
#[pyo3(get)]
lambdas: Vec<f64>,
#[pyo3(get)]
arclengths: Vec<f64>,
#[pyo3(get)]
voltage_mag: Vec<Vec<f64>>,
#[pyo3(get)]
branch: Vec<String>,
#[pyo3(get)]
events: Vec<Py<ContinuationEvent>>,
#[pyo3(get)]
lambda_max: Option<f64>,
#[pyo3(get)]
critical_kind: Option<String>,
#[pyo3(get)]
critical_bus: Option<usize>,
#[pyo3(get)]
weakest: Vec<(usize, f64)>,
#[pyo3(get)]
margin_mw: Option<f64>,
#[pyo3(get)]
margin_pu: Option<f64>,
#[pyo3(get)]
status: String,
#[pyo3(get)]
warnings: Vec<String>,
}
#[cfg(feature = "dynamics")]
#[pyclass]
pub struct DynamicsResult {
#[pyo3(get)]
status: String,
#[pyo3(get)]
steps: usize,
#[pyo3(get)]
newton_iterations: usize,
#[pyo3(get)]
events_applied: usize,
#[pyo3(get)]
warnings: Vec<String>,
#[pyo3(get)]
relay_actions: Vec<(String, f64, f64)>,
#[pyo3(get)]
time: Vec<f64>,
#[pyo3(get)]
names: Vec<String>,
rows: Vec<Vec<f64>>,
}
#[cfg(feature = "dynamics")]
#[pymethods]
impl DynamicsResult {
fn series(&self, name: &str) -> PyResult<Vec<f64>> {
let column = self
.names
.iter()
.position(|n| n == name)
.ok_or_else(|| PyValueError::new_err(format!("no observable named {name:?}")))?;
Ok(self.rows.iter().map(|r| r[column]).collect())
}
fn values(&self) -> Vec<Vec<f64>> {
self.rows.clone()
}
fn completed(&self) -> bool {
self.status == "completed"
}
fn __repr__(&self) -> String {
format!(
"DynamicsResult(status={:?}, steps={}, points={}, observables={})",
self.status,
self.steps,
self.time.len(),
self.names.len()
)
}
}
#[cfg(feature = "dynamics")]
#[pyfunction]
#[pyo3(signature = (
path, stop = 10.0, step = 0.005, tol = 1e-9, max_newton = 20,
damping_steps = 2, backend = None, events = None, speed_voltages = None,
relays = None,
))]
#[allow(clippy::too_many_arguments)]
fn dynamics(
py: Python<'_>,
path: &str,
stop: f64,
step: f64,
tol: f64,
max_newton: usize,
damping_steps: usize,
backend: Option<&str>,
events: Option<Vec<Bound<'_, pyo3::types::PyAny>>>,
speed_voltages: Option<bool>,
relays: Option<Vec<Bound<'_, pyo3::types::PyAny>>>,
) -> PyResult<DynamicsResult> {
use crate::dynamics::{DynamicsOptions, DynamicsStatus, json, run_dynamics};
use crate::solver::JacobianBackend;
let backend = match backend {
None | Some("scalar") => JacobianBackend::Scalar,
Some("klu_native") => JacobianBackend::KluNative,
Some(other) => {
return Err(PyValueError::new_err(format!(
"backend must be \"scalar\" or \"klu_native\", got {other:?}"
)));
}
};
let mut document = json::read(path).map_err(|e| PyValueError::new_err(e.to_string()))?;
if let Some(on) = speed_voltages {
document.dynamics.speed_voltages = on;
}
let (mut system, mut schedule, mut protection) = document
.build_with_relays()
.map_err(|e| PyValueError::new_err(e.to_string()))?;
if let Some(given) = events {
let mut parsed = Vec::with_capacity(given.len());
for item in given {
let text: String = py
.import("json")?
.call_method1("dumps", (item,))?
.extract()?;
let spec: json::EventSpec = serde_json::from_str(&text)
.map_err(|e| PyValueError::new_err(format!("event: {e}")))?;
parsed.push(crate::dynamics::Event::from(spec));
}
schedule = parsed;
}
if let Some(given) = relays {
let mut parsed = Vec::with_capacity(given.len());
for item in given {
let text: String = py
.import("json")?
.call_method1("dumps", (item,))?
.extract()?;
parsed.push(
serde_json::from_str(&text)
.map_err(|e| PyValueError::new_err(format!("relay: {e}")))?,
);
}
protection = parsed;
}
let report = run_dynamics(
&mut system,
&DynamicsOptions {
end_time: stop,
step,
tol,
max_newton,
damping_steps,
events: schedule,
relays: protection,
backend,
},
);
let status = match report.status {
DynamicsStatus::Completed => "completed".to_string(),
DynamicsStatus::NewtonFailed { time } => format!("newton_failed at {time}"),
DynamicsStatus::Singular { time } => format!("singular at {time}"),
};
Ok(DynamicsResult {
status,
steps: report.steps,
newton_iterations: report.newton_iterations,
events_applied: report.events_applied,
warnings: report.warnings.iter().map(|w| w.to_string()).collect(),
relay_actions: report
.relay_actions
.iter()
.map(|a| (a.id.clone(), a.crossed_at, a.fired_at))
.collect(),
time: report.trajectory.time,
names: report.trajectory.names,
rows: report.trajectory.rows,
})
}
#[cfg(feature = "dynamics")]
#[pyclass]
pub struct DynamicsMode {
#[pyo3(get)]
sigma: f64,
#[pyo3(get)]
omega: f64,
#[pyo3(get)]
damping: f64,
#[pyo3(get)]
frequency: f64,
#[pyo3(get)]
time_constant: f64,
#[pyo3(get)]
participation: Vec<(String, f64)>,
#[pyo3(get)]
shape: Vec<(String, f64, f64)>,
#[pyo3(get)]
residual: f64,
}
#[cfg(feature = "dynamics")]
#[pymethods]
impl DynamicsMode {
fn __repr__(&self) -> String {
format!(
"DynamicsMode({:+.5}{:+.5}j, damping={:.4}, f={:.4} Hz)",
self.sigma, self.omega, self.damping, self.frequency
)
}
}
#[cfg(feature = "dynamics")]
#[pyclass]
pub struct ModeSensitivity {
#[pyo3(get)]
device: usize,
#[pyo3(get)]
unit: String,
#[pyo3(get)]
name: String,
#[pyo3(get)]
value: f64,
#[pyo3(get)]
d_sigma: f64,
#[pyo3(get)]
d_omega: f64,
#[pyo3(get)]
d_damping: f64,
#[pyo3(get)]
d_frequency: f64,
}
#[cfg(feature = "dynamics")]
#[pymethods]
impl ModeSensitivity {
fn __repr__(&self) -> String {
format!(
"ModeSensitivity({}.{} = {:.4}, dzeta/dp={:+.3e})",
self.unit, self.name, self.value, self.d_damping
)
}
}
#[cfg(feature = "dynamics")]
#[pyclass]
pub struct SmallSignalResult {
#[pyo3(get)]
modes: Vec<Py<DynamicsMode>>,
#[pyo3(get)]
method: String,
#[pyo3(get)]
shift: Option<(f64, f64)>,
#[pyo3(get)]
converged: bool,
#[pyo3(get)]
complete: bool,
#[pyo3(get)]
sensitivities: Vec<Py<ModeSensitivity>>,
}
#[cfg(feature = "dynamics")]
#[pymethods]
impl SmallSignalResult {
fn __repr__(&self) -> String {
match self.shift {
None => format!(
"SmallSignalResult({} modes, dense, complete)",
self.modes.len()
),
Some((re, im)) => format!(
"SmallSignalResult({} modes, sparse near {re:+.4}{im:+.4}j, partial)",
self.modes.len()
),
}
}
fn __len__(&self) -> usize {
self.modes.len()
}
fn __getitem__(&self, py: Python<'_>, index: isize) -> PyResult<Py<DynamicsMode>> {
let len = self.modes.len() as isize;
let index = if index < 0 { index + len } else { index };
if index < 0 || index >= len {
return Err(pyo3::exceptions::PyIndexError::new_err(
"mode index out of range",
));
}
Ok(self.modes[index as usize].clone_ref(py))
}
}
#[cfg(feature = "dynamics")]
#[pyfunction]
#[pyo3(signature = (
path,
speed_voltages = None,
freq = None,
damping = None,
near = None,
count = None,
tol = None,
sensitivities = false,
))]
#[allow(clippy::too_many_arguments)]
fn small_signal(
py: Python<'_>,
path: &str,
speed_voltages: Option<bool>,
freq: Option<f64>,
damping: Option<f64>,
near: Option<(f64, f64)>,
count: Option<usize>,
tol: Option<f64>,
sensitivities: bool,
) -> PyResult<SmallSignalResult> {
use crate::dynamics::{json, smallsignal};
use smallsignal::{Method, SmallSignalOptions};
let mut document = json::read(path).map_err(|e| PyValueError::new_err(e.to_string()))?;
if let Some(on) = speed_voltages {
document.dynamics.speed_voltages = on;
}
let (mut system, _) = document
.build()
.map_err(|e| PyValueError::new_err(e.to_string()))?;
if near.is_some() && freq.is_some() {
return Err(PyValueError::new_err(
"near names the shift outright; do not also give freq",
));
}
if near.is_none() && freq.is_none() && damping.is_some() {
return Err(PyValueError::new_err(
"damping only says how far off the axis to aim; give freq to say where",
));
}
let aimed = match (near, freq) {
(Some((re, im)), _) => Some(num_complex::Complex::new(re, im)),
(None, Some(hz)) => {
if hz <= 0.0 {
return Err(PyValueError::new_err("freq must be positive"));
}
Some(SmallSignalOptions::near_frequency(hz, damping.unwrap_or(0.05)).shift)
}
(None, None) => None,
};
let result = match aimed {
Some(shift) => {
let mut opts = SmallSignalOptions {
shift,
..SmallSignalOptions::default()
};
if let Some(count) = count {
opts.count = count;
}
if let Some(tol) = tol {
opts.tol = tol;
}
smallsignal::analyze_near(&system, &opts)
}
None => smallsignal::analyze(&system),
}
.map_err(|e| PyValueError::new_err(e.to_string()))?;
let sensitivities = if sensitivities {
mode_sensitivities(py, &mut system, &result)?
} else {
Vec::new()
};
let modes = result
.modes
.iter()
.map(|mode| {
Py::new(
py,
DynamicsMode {
sigma: mode.eigenvalue.re,
omega: mode.eigenvalue.im,
damping: mode.damping,
frequency: mode.frequency,
time_constant: mode.time_constant,
participation: result.participants(mode),
shape: result.shape(mode),
residual: mode.residual,
},
)
})
.collect::<PyResult<Vec<_>>>()?;
let (method, shift, complete) = match result.method {
Method::Dense => ("dense".to_string(), None, true),
Method::Sparse { shift, .. } => ("sparse".to_string(), Some((shift.re, shift.im)), false),
};
Ok(SmallSignalResult {
modes,
method,
shift,
converged: result.converged,
complete,
sensitivities,
})
}
#[cfg(feature = "dynamics")]
fn mode_sensitivities(
py: Python<'_>,
system: &mut crate::dynamics::DynamicSystem,
result: &crate::dynamics::smallsignal::SmallSignal,
) -> PyResult<Vec<Py<ModeSensitivity>>> {
use crate::dynamics::smallsignal;
let Some(mode) = result.critical().cloned() else {
return Ok(Vec::new());
};
let parameters = smallsignal::tunable_parameters(system);
if parameters.is_empty() {
return Ok(Vec::new());
}
let mut computed = smallsignal::sensitivities(system, &mode, ¶meters)
.map_err(|e| PyValueError::new_err(e.to_string()))?;
computed.sort_by(|a, b| {
let (x, y) = (a.d_damping * a.value, b.d_damping * b.value);
y.abs()
.partial_cmp(&x.abs())
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut units: Vec<&str> = Vec::new();
for name in &result.state_names {
let unit = name.split('.').next().unwrap_or(name);
if !units.contains(&unit) {
units.push(unit);
}
}
computed
.into_iter()
.map(|s| {
Py::new(
py,
ModeSensitivity {
device: s.parameter.device,
unit: units
.get(s.parameter.device)
.map(|u| (*u).to_string())
.unwrap_or_default(),
name: s.parameter.name,
value: s.value,
d_sigma: s.d_eigenvalue.re,
d_omega: s.d_eigenvalue.im,
d_damping: s.d_damping,
d_frequency: s.d_frequency,
},
)
})
.collect()
}
#[pyfunction]
#[pyo3(signature = (
path, enforce_q_limits = false, target_lambda = None, lower_branch = false,
step = 0.1, max_steps = 200, parametrization = None,
buses = None, pickup = None, base_mva = 100.0, s_base_va = None, freq_hz = 50.0,
))]
#[allow(clippy::too_many_arguments)]
fn continuation(
py: Python<'_>,
path: &str,
enforce_q_limits: bool,
target_lambda: Option<f64>,
lower_branch: bool,
step: f64,
max_steps: usize,
parametrization: Option<&str>,
buses: Option<Vec<usize>>,
pickup: Option<Vec<usize>>,
base_mva: f64,
s_base_va: Option<f64>,
freq_hz: f64,
) -> PyResult<ContinuationCurve> {
use crate::PowerFlowOptions;
use crate::continuation::augmented::Parametrization;
use crate::continuation::{
ContinuationOptions, ContinuationStatus, CriticalPointKind, CurveBranch, LoadingDirection,
QLimitKind, StopCriterion, run_continuation,
};
use crate::pgm::pgm_to_buses_and_branches;
let parametrization = match parametrization {
None => Parametrization::default(),
Some("local") => Parametrization::Local,
Some("natural") => Parametrization::Natural,
Some("arclength") => Parametrization::PseudoArcLength,
Some(other) => {
return Err(PyValueError::new_err(format!(
"parametrization must be \"local\", \"natural\" or \"arclength\", got {other:?}"
)));
}
};
if target_lambda.is_some() && lower_branch {
return Err(PyValueError::new_err(
"target_lambda and lower_branch ask for different walks",
));
}
let raw = std::fs::read_to_string(path)
.map_err(|e| PyRuntimeError::new_err(format!("reading {path}: {e}")))?;
let input: PgmInput = serde_json::from_str(&raw)
.map_err(|e| PyValueError::new_err(format!("parsing {path}: {e}")))?;
let (net_buses, lines, transformers) =
pgm_to_buses_and_branches(input, s_base_va.unwrap_or(base_mva * 1e6), freq_hz);
let n = net_buses.len();
let check = |list: &[usize], what: &str| -> PyResult<()> {
match list.iter().find(|i| **i >= n) {
Some(i) => Err(PyValueError::new_err(format!(
"{what}: bus {i} is out of range for a {n}-bus network"
))),
None => Ok(()),
}
};
let direction = match &buses {
Some(list) => {
check(list, "buses")?;
LoadingDirection::scale_buses(&net_buses, list)
}
None => LoadingDirection::scale_loads(&net_buses),
};
let direction = match &pickup {
None => direction,
Some(list) => {
check(list, "pickup")?;
let mut weights = vec![0.0; n];
for &i in list {
weights[i] = 1.0;
}
LoadingDirection::scale_loads_with_pickup(&net_buses, &weights)
}
};
let curve = run_continuation(
net_buses,
&lines,
&transformers,
&[],
ContinuationOptions {
direction,
parametrization,
stop: StopCriterion {
target_lambda,
trace_lower_branch: lower_branch,
},
step,
max_steps,
power_flow: PowerFlowOptions {
enforce_q_limits,
..Default::default()
},
base_mva,
..Default::default()
},
);
if let ContinuationStatus::Rejected(why) = &curve.status {
return Err(PyValueError::new_err(format!(
"continuation was not attempted: {why:?}"
)));
}
if let ContinuationStatus::BaseCaseFailed(status) = &curve.status {
return Err(PyRuntimeError::new_err(format!(
"the base case did not converge ({status:?}), so there is no curve to trace from"
)));
}
let events = curve
.q_limit_events()
.into_iter()
.map(|(bus, limit, lambda)| {
Py::new(
py,
ContinuationEvent {
bus,
limit: match limit {
QLimitKind::Max => "max".to_string(),
QLimitKind::Min => "min".to_string(),
},
lambda,
},
)
})
.collect::<PyResult<Vec<_>>>()?;
let critical = curve.critical.as_ref();
Ok(ContinuationCurve {
lambdas: curve.lambdas(),
arclengths: curve.points.iter().map(|p| p.arclength).collect(),
voltage_mag: curve.points.iter().map(|p| p.voltage_mag.clone()).collect(),
branch: curve
.points
.iter()
.map(|p| match p.branch {
CurveBranch::Upper => "upper".to_string(),
CurveBranch::Lower => "lower".to_string(),
})
.collect(),
events,
lambda_max: critical.map(|c| c.lambda_max),
critical_kind: critical.map(|c| match c.kind {
CriticalPointKind::SaddleNode => "saddle_node".to_string(),
CriticalPointKind::LimitInduced { .. } => "limit_induced".to_string(),
}),
critical_bus: critical.and_then(|c| c.critical_bus()),
weakest: critical
.map(|c| c.weakest.iter().map(|w| (w.bus, w.participation)).collect())
.unwrap_or_default(),
margin_mw: critical.map(|c| c.margin_mw),
margin_pu: critical.map(|c| c.margin_pu),
status: format!("{:?}", curve.status),
warnings: curve.warnings.iter().map(|w| format!("{w:?}")).collect(),
})
}
#[pyfunction]
#[pyo3(signature = (path, scaling = "max", s_base_va = 1e6, freq_hz = 50.0))]
fn short_circuit(
py: Python<'_>,
path: &str,
scaling: &str,
s_base_va: f64,
freq_hz: f64,
) -> PyResult<ShortCircuitResult> {
use crate::pgm::shortcircuit::short_circuit_from_pgm;
use crate::shortcircuit::{SequenceValue, ShortCircuitOptions, VoltageScaling};
let scaling = match scaling {
"max" | "maximum" => VoltageScaling::Maximum,
"min" | "minimum" => VoltageScaling::Minimum,
other => {
return Err(PyValueError::new_err(format!(
"scaling must be \"max\" or \"min\", got {other:?}"
)));
}
};
let raw = std::fs::read_to_string(path)
.map_err(|e| PyRuntimeError::new_err(format!("reading {path}: {e}")))?;
let input: PgmInput = serde_json::from_str(&raw)
.map_err(|e| PyValueError::new_err(format!("parsing {path}: {e}")))?;
let (net, report) =
short_circuit_from_pgm(&input, s_base_va, freq_hz, ShortCircuitOptions { scaling })
.map_err(|e| PyRuntimeError::new_err(e.to_string()))?;
let mut id_of = vec![0u64; net.n_nodes];
for (&id, &idx) in &net.node_idx {
id_of[idx] = id;
}
let nodes = report
.nodes
.iter()
.enumerate()
.map(|(idx, n)| {
let s = SequenceValue::from_phase(&report.u_bus[idx]);
Py::new(
py,
ShortCircuitNode {
id: id_of[idx],
u_pu: n.u_pu,
u_angle: n.u_angle,
u: n.u,
energized: n.energized,
sequence: (s.zero.norm(), s.positive.norm(), s.negative.norm()),
},
)
})
.collect::<PyResult<Vec<_>>>()?;
let faults = report
.faults
.iter()
.map(|f| {
Py::new(
py,
ShortCircuitFault {
id: f.id,
i_f: f.i_f,
i_f_angle: f.i_f_angle,
},
)
})
.collect::<PyResult<Vec<_>>>()?;
let sources = report
.sources
.iter()
.map(|s| {
Py::new(
py,
ShortCircuitSource {
id: s.id,
i: s.i,
i_angle: s.i_angle,
},
)
})
.collect::<PyResult<Vec<_>>>()?;
let branches = report
.branches
.iter()
.map(|b| {
Py::new(
py,
ShortCircuitBranch {
id: b.id,
energized: b.energized,
i_from: b.i_from,
i_from_angle: b.i_from_angle,
i_to: b.i_to,
i_to_angle: b.i_to_angle,
},
)
})
.collect::<PyResult<Vec<_>>>()?;
let shunts = report
.shunts
.iter()
.map(|s| {
Py::new(
py,
ShortCircuitShunt {
id: s.id,
i: s.i,
i_angle: s.i_angle,
},
)
})
.collect::<PyResult<Vec<_>>>()?;
Ok(ShortCircuitResult {
nodes,
faults,
sources,
branches,
shunts,
})
}
#[pyclass]
struct SensitivityColumn {
#[pyo3(get)]
d_branch_active: Vec<f64>,
#[pyo3(get)]
d_branch_reactive: Vec<f64>,
#[pyo3(get)]
d_voltage_magnitude: Vec<f64>,
#[pyo3(get)]
d_voltage_angle: Vec<f64>,
}
#[pyclass]
struct SensitivityRow {
#[pyo3(get)]
d_active_injection: Vec<f64>,
#[pyo3(get)]
d_reactive_injection: Vec<f64>,
#[pyo3(get)]
d_transformer_ratio: Vec<f64>,
#[pyo3(get)]
d_phase_shift: Vec<f64>,
}
#[pyclass(unsendable)]
struct AcSensitivityModel {
inner: crate::ac_sensitivity::AcSensitivity,
n_buses: usize,
n_branches: usize,
}
fn parse_terminal(name: &str) -> PyResult<crate::branch_flow::Terminal> {
match name {
"from" => Ok(crate::branch_flow::Terminal::From),
"to" => Ok(crate::branch_flow::Terminal::To),
other => Err(PyValueError::new_err(format!(
"terminal must be \"from\" or \"to\", got {other:?}"
))),
}
}
#[pymethods]
impl AcSensitivityModel {
#[new]
#[pyo3(signature = (path, s_base_va = 1e6, freq_hz = 50.0, tol = 1e-8, max_iter = 50))]
fn new(path: &str, s_base_va: f64, freq_hz: f64, tol: f64, max_iter: usize) -> PyResult<Self> {
let raw = std::fs::read_to_string(path)
.map_err(|e| PyRuntimeError::new_err(format!("reading {path}: {e}")))?;
let input: PgmInput = serde_json::from_str(&raw)
.map_err(|e| PyValueError::new_err(format!("parsing {path}: {e}")))?;
let (buses, lines, transformers) =
crate::pgm::pgm_to_buses_and_branches(input, s_base_va, freq_hz);
let opts = crate::PowerFlowOptions {
tol,
max_iter,
..Default::default()
};
let report = crate::run_power_flow(
buses,
&lines,
&transformers,
&[],
crate::TapData::none(),
opts,
);
if report.stats.status != SolveStatus::Converged {
return Err(PyRuntimeError::new_err(format!(
"the power flow did not converge ({:?}); there is no operating point to \
differentiate",
report.stats.status
)));
}
let ybus = build_ybus(report.buses.len(), &lines, &transformers).finish();
let n_buses = report.buses.len();
let n_branches = lines.len() + transformers.len();
let inner =
crate::ac_sensitivity::AcSensitivity::new(&report.buses, &ybus, &lines, &transformers)
.ok_or_else(|| {
PyRuntimeError::new_err("the Jacobian is singular at the solved point")
})?;
Ok(Self {
inner,
n_buses,
n_branches,
})
}
#[getter]
fn n_buses(&self) -> usize {
self.n_buses
}
#[getter]
fn n_branches(&self) -> usize {
self.n_branches
}
#[pyo3(signature = (*, active_injection = None, reactive_injection = None,
transformer_ratio = None, phase_shift = None,
terminal = "from"))]
fn column(
&self,
active_injection: Option<usize>,
reactive_injection: Option<usize>,
transformer_ratio: Option<usize>,
phase_shift: Option<usize>,
terminal: &str,
) -> PyResult<SensitivityColumn> {
use crate::ac_sensitivity::Variable;
let chosen: Vec<Variable> = [
active_injection.map(Variable::ActiveInjection),
reactive_injection.map(Variable::ReactiveInjection),
transformer_ratio.map(Variable::TransformerRatio),
phase_shift.map(Variable::PhaseShift),
]
.into_iter()
.flatten()
.collect();
let [variable] = chosen[..] else {
return Err(PyValueError::new_err(
"pass exactly one of active_injection, reactive_injection, \
transformer_ratio or phase_shift",
));
};
let terminal = parse_terminal(terminal)?;
let flows = self
.inner
.branch_response(variable, terminal)
.ok_or_else(|| PyValueError::new_err("index out of range, or a singular solve"))?;
let state = self
.inner
.state_response(variable)
.ok_or_else(|| PyValueError::new_err("index out of range, or a singular solve"))?;
Ok(SensitivityColumn {
d_branch_active: flows.iter().map(|(p, _)| *p).collect(),
d_branch_reactive: flows.iter().map(|(_, q)| *q).collect(),
d_voltage_magnitude: state.d_vmag,
d_voltage_angle: state.d_theta,
})
}
#[pyo3(signature = (*, branch = None, bus = None, quantity = "active", terminal = "from"))]
fn row(
&self,
branch: Option<usize>,
bus: Option<usize>,
quantity: &str,
terminal: &str,
) -> PyResult<SensitivityRow> {
use crate::ac_sensitivity::Function;
let function = match (branch, bus) {
(Some(branch), None) => {
let terminal = parse_terminal(terminal)?;
match quantity {
"active" => Function::BranchActivePower { branch, terminal },
"reactive" => Function::BranchReactivePower { branch, terminal },
other => {
return Err(PyValueError::new_err(format!(
"for a branch, quantity must be \"active\" or \"reactive\", got {other:?}"
)));
}
}
}
(None, Some(bus)) => match quantity {
"magnitude" | "active" => Function::VoltageMagnitude(bus),
"angle" => Function::VoltageAngle(bus),
other => {
return Err(PyValueError::new_err(format!(
"for a bus, quantity must be \"magnitude\" or \"angle\", got {other:?}"
)));
}
},
_ => return Err(PyValueError::new_err("pass exactly one of branch or bus")),
};
let row = self
.inner
.function_row(function)
.ok_or_else(|| PyValueError::new_err("index out of range, or a singular solve"))?;
Ok(SensitivityRow {
d_active_injection: row.d_active,
d_reactive_injection: row.d_reactive,
d_transformer_ratio: row.d_ratio,
d_phase_shift: row.d_phase,
})
}
}
#[cfg(feature = "opf")]
#[pyclass]
struct DcOpfBinding {
#[pyo3(get)]
branch: usize,
#[pyo3(get)]
flow: f64,
#[pyo3(get)]
rate: f64,
#[pyo3(get)]
price: f64,
}
#[cfg(feature = "opf")]
#[pyclass]
struct AcOpfResult {
#[pyo3(get)]
objective: f64,
#[pyo3(get)]
p_gen: Vec<f64>,
#[pyo3(get)]
q_gen: Vec<f64>,
#[pyo3(get)]
generator_index: Vec<usize>,
#[pyo3(get)]
magnitudes: Vec<f64>,
#[pyo3(get)]
angles: Vec<f64>,
#[pyo3(get)]
flows: Vec<(f64, f64)>,
#[pyo3(get)]
lmp_p: Vec<f64>,
#[pyo3(get)]
lmp_q: Vec<f64>,
#[pyo3(get)]
iterations: usize,
#[pyo3(get)]
violation: f64,
}
#[cfg(feature = "opf")]
#[pyclass]
struct DcOpfResult {
#[pyo3(get)]
objective: f64,
#[pyo3(get)]
dispatch: Vec<f64>,
#[pyo3(get)]
generator_index: Vec<usize>,
#[pyo3(get)]
angles: Vec<f64>,
#[pyo3(get)]
flows: Vec<f64>,
#[pyo3(get)]
lmp: Vec<f64>,
#[pyo3(get)]
shed: Vec<f64>,
#[pyo3(get)]
binding: Vec<Py<DcOpfBinding>>,
}
#[cfg(feature = "opf")]
#[pyfunction]
#[pyo3(signature = (path, data_path = None, shed_price = 10_000.0,
allow_shedding = true, dc_approximation = "ignore_g",
solver = "ipm", freq_hz = 50.0))]
fn dc_opf(
py: Python<'_>,
path: &str,
data_path: Option<&str>,
shed_price: f64,
allow_shedding: bool,
dc_approximation: &str,
solver: &str,
freq_hz: f64,
) -> PyResult<DcOpfResult> {
use crate::opf::dc::{DcOpf, DcOpfOptions};
use crate::opf::ipm::IpmSolver;
use crate::opf::model::OpfData;
use crate::opf::{OptStatus, Solver};
let data_path = match data_path {
Some(p) => std::path::PathBuf::from(p),
None => std::path::PathBuf::from(path).with_extension("opf.json"),
};
let network_text = std::fs::read_to_string(path)
.map_err(|e| PyRuntimeError::new_err(format!("reading {path}: {e}")))?;
let data_text = std::fs::read_to_string(&data_path).map_err(|e| {
PyRuntimeError::new_err(format!(
"reading {}: {e} — pass data_path if the companion OPF document is elsewhere",
data_path.display()
))
})?;
let input: PgmInput = serde_json::from_str(&network_text)
.map_err(|e| PyValueError::new_err(format!("parsing {path}: {e}")))?;
let data = OpfData::from_json(&data_text)
.map_err(|e| PyValueError::new_err(format!("parsing {}: {e}", data_path.display())))?;
let options = DcOpfOptions {
shed_price,
allow_shedding,
};
let approximation = parse_dc_approximation(dc_approximation)?;
let network = crate::pgm::opf::dc_opf_network_from_pgm(input, &data, freq_hz, approximation)
.map_err(|e| PyValueError::new_err(e.to_string()))?;
let generator_index: Vec<usize> = network.generators.iter().map(|g| g.index).collect();
let opf = DcOpf::build(network, options).map_err(|e| PyValueError::new_err(e.to_string()))?;
let solution = match solver {
"ipm" => {
let mut s = IpmSolver::new();
s.solve(opf.problem())
.map_err(|e| PyRuntimeError::new_err(e.to_string()))?
}
"highs" => {
#[cfg(feature = "opf-highs")]
{
let mut s = crate::opf::highs::HighsSolver::new()
.map_err(|e| PyRuntimeError::new_err(e.to_string()))?;
s.solve(opf.problem())
.map_err(|e| PyRuntimeError::new_err(e.to_string()))?
}
#[cfg(not(feature = "opf-highs"))]
{
return Err(PyValueError::new_err(
"solver='highs' needs the extension built with the opf-highs \
feature, which links a local HiGHS install",
));
}
}
other => {
return Err(PyValueError::new_err(format!(
"unknown solver '{other}', expected 'ipm' or 'highs'"
)));
}
};
let result = opf.interpret(&solution);
if result.status != OptStatus::Optimal {
return Err(PyRuntimeError::new_err(format!(
"no optimal dispatch ({:?}); an infeasible case usually means demand cannot be \
served within the limits",
result.status
)));
}
let binding = result
.binding
.iter()
.map(|b| {
Py::new(
py,
DcOpfBinding {
branch: b.branch,
flow: b.flow,
rate: b.rate,
price: b.price,
},
)
})
.collect::<PyResult<Vec<_>>>()?;
Ok(DcOpfResult {
objective: result.objective,
dispatch: result.dispatch,
generator_index,
angles: result.angles,
flows: result.flows,
lmp: result.lmp,
shed: result.shed,
binding,
})
}
#[cfg(feature = "opf")]
#[pyfunction]
#[pyo3(signature = (path, data_path = None, enforce_limits = true,
max_iterations = 300, tolerance = 1e-8, freq_hz = 50.0))]
fn ac_opf(
py: Python<'_>,
path: &str,
data_path: Option<&str>,
enforce_limits: bool,
max_iterations: usize,
tolerance: f64,
freq_hz: f64,
) -> PyResult<AcOpfResult> {
use crate::opf::ac::{AcOpf, AcOpfOptions};
use crate::opf::model::OpfData;
let data_path = match data_path {
Some(p) => std::path::PathBuf::from(p),
None => std::path::PathBuf::from(path).with_extension("opf.json"),
};
let network_text = std::fs::read_to_string(path)
.map_err(|e| PyRuntimeError::new_err(format!("reading {path}: {e}")))?;
let data_text = std::fs::read_to_string(&data_path).map_err(|e| {
PyRuntimeError::new_err(format!(
"reading {}: {e} — pass data_path if the companion OPF document is elsewhere",
data_path.display()
))
})?;
let input: PgmInput = serde_json::from_str(&network_text)
.map_err(|e| PyValueError::new_err(format!("parsing {path}: {e}")))?;
let data = OpfData::from_json(&data_text)
.map_err(|e| PyValueError::new_err(format!("parsing {}: {e}", data_path.display())))?;
let mut options = AcOpfOptions {
enforce_limits,
..AcOpfOptions::default()
};
options.nlp.max_iterations = max_iterations;
options.nlp.tolerance = tolerance;
let result = py.detach(|| {
let network = crate::pgm::opf::ac_opf_network_from_pgm(input, &data, freq_hz, &options)?;
AcOpf::build(network, options)?.solve()
});
let result = result.map_err(|e| PyValueError::new_err(e.to_string()))?;
if result.status != crate::opf::OptStatus::Optimal {
return Err(PyRuntimeError::new_err(format!(
"no optimal dispatch ({:?}), largest constraint violation {:.3e} pu",
result.status, result.violation
)));
}
Ok(AcOpfResult {
objective: result.objective,
p_gen: result.p_gen,
q_gen: result.q_gen,
generator_index: result.generator_index,
magnitudes: result.magnitudes,
angles: result.angles,
flows: result.flows,
lmp_p: result.lmp_p,
lmp_q: result.lmp_q,
iterations: result.iterations,
violation: result.violation,
})
}
#[pymodule]
fn _gridoxide(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PowerFlowModel>()?;
m.add_class::<BatchResult>()?;
m.add_class::<StateEstimationModel>()?;
m.add_class::<SeBatchOutcome>()?;
m.add_class::<ShortCircuitResult>()?;
m.add_class::<ShortCircuitNode>()?;
m.add_class::<ShortCircuitFault>()?;
m.add_class::<ShortCircuitSource>()?;
m.add_class::<ShortCircuitBranch>()?;
m.add_class::<ShortCircuitShunt>()?;
m.add_function(wrap_pyfunction!(short_circuit, m)?)?;
m.add_class::<ContinuationCurve>()?;
m.add_class::<ContinuationEvent>()?;
m.add_function(wrap_pyfunction!(continuation, m)?)?;
m.add_class::<AcSensitivityModel>()?;
m.add_class::<SensitivityColumn>()?;
m.add_class::<SensitivityRow>()?;
#[cfg(feature = "dynamics")]
{
m.add_class::<DynamicsResult>()?;
m.add_class::<DynamicsMode>()?;
m.add_class::<SmallSignalResult>()?;
m.add_class::<ModeSensitivity>()?;
m.add_function(wrap_pyfunction!(dynamics, m)?)?;
m.add_function(wrap_pyfunction!(small_signal, m)?)?;
}
#[cfg(feature = "opf")]
{
m.add_class::<DcOpfResult>()?;
m.add_class::<DcOpfBinding>()?;
m.add_class::<AcOpfResult>()?;
m.add_function(wrap_pyfunction!(dc_opf, m)?)?;
m.add_function(wrap_pyfunction!(ac_opf, m)?)?;
}
Ok(())
}