use pyo3::exceptions::{PyRuntimeError, PyValueError};
use pyo3::prelude::*;
use crate::batch::{uniform_load_scaling, BatchSolver, BusOverride, Scenario};
use crate::network::{build_ybus, linear_initial_guess, stamp_shunts, YBusSparse};
use crate::pgm::PgmInput;
use crate::solver::{IslandStatus, JacobianBackend, PersistentSolver, SolveStatus};
use crate::types::Bus;
#[cfg(feature = "cgmes")]
use crate::cgmes::{
cgmes_resolve_dc_converters, cgmes_to_buses_and_branches, cgmes_topological_node_bus_index, load_profiles,
};
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'"
))),
}
}
#[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,
solver: PersistentSolver,
backend: JacobianBackend,
batch: Option<(usize, BatchSolver)>,
tol: f64,
max_iter: usize,
tn_bus_index: std::collections::HashMap<String, usize>,
}
#[pymethods]
impl PowerFlowModel {
#[staticmethod]
#[pyo3(signature = (path, backend="scalar", tol=1e-6, max_iter=20, s_base_va=1e6, freq_hz=50.0))]
fn from_pgm_json(
path: &str,
backend: &str,
tol: f64,
max_iter: usize,
s_base_va: f64,
freq_hz: f64,
) -> 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,
solver: PersistentSolver::new(backend),
backend,
batch: None,
tol,
max_iter,
tn_bus_index: std::collections::HashMap::new(),
})
}
#[cfg(feature = "cgmes")]
#[staticmethod]
#[pyo3(signature = (paths, backend="scalar", tol=1e-6, max_iter=20, s_base_va=100e6))]
fn from_cgmes(
paths: Vec<String>,
backend: &str,
tol: f64,
max_iter: usize,
s_base_va: f64,
) -> 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 buses_template, lines, transformers, shunts) = cgmes_to_buses_and_branches(&ds, s_base_va)
.map_err(|e| PyRuntimeError::new_err(format!("converting CGMES model: {e}")))?;
cgmes_resolve_dc_converters(&ds, &mut buses_template, s_base_va)
.map_err(|e| PyRuntimeError::new_err(format!("resolving CGMES HVDC converters: {e}")))?;
let tn_bus_index = cgmes_topological_node_bus_index(&ds)
.map_err(|e| PyRuntimeError::new_err(format!("resolving CGMES bus index: {e}")))?;
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,
solver: PersistentSolver::new(backend),
backend,
batch: None,
tol,
max_iter,
tn_bus_index,
})
}
#[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()
}
fn solve(&mut self) -> PyResult<()> {
self.buses = self.buses_template.clone();
linear_initial_guess(&mut self.buses, &self.ybus);
let islands = 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 reset(&mut self) {
self.solver.reset();
}
#[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 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]
struct StateEstimationModel {
buses: Vec<Bus>,
net: crate::pgm::PgmNetwork,
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="scalar", method="newton_raphson", tol=1e-8, max_iter=20, s_base_va=1e6, freq_hz=50.0))]
fn from_pgm_json(
path: &str,
backend: &str,
method: &str,
tol: f64,
max_iter: usize,
s_base_va: f64,
freq_hz: f64,
) -> 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 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 net = crate::pgm::pgm_to_network(
serde_json::from_str(&raw)
.map_err(|e| PyValueError::new_err(format!("parsing {path} as PGM JSON: {e}")))?,
s_base_va,
freq_hz,
);
let measurements = crate::measurement::measurements_from_pgm(&input, &net, s_base_va)
.map_err(|e| PyValueError::new_err(format!("{path}: {e}")))?;
if measurements.is_empty() {
return Err(PyValueError::new_err(format!(
"{path} contains no usable sensors, so there is nothing to estimate"
)));
}
let mut ybus = build_ybus(net.buses.len(), &net.lines, &net.transformers);
stamp_shunts(&mut ybus, &shunts);
let se_net = crate::se::SeNetwork::new(&net, ybus.finish(), &shunts);
let buses = net.buses.clone();
let options = crate::se::nr::SeOptions {
tol,
max_iter,
backend: parse_backend(backend)?,
method,
};
Ok(Self {
buses,
net,
se_net,
measurements,
options,
report: None,
estimator: crate::se::nr::PersistentEstimator::new(options),
})
}
#[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.net.buses.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.net.buses, &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,
);
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(),
))
}
}
#[pymodule]
fn _gridoxide(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PowerFlowModel>()?;
m.add_class::<BatchResult>()?;
m.add_class::<StateEstimationModel>()?;
m.add_class::<SeBatchOutcome>()?;
Ok(())
}