mod common;
use std::path::PathBuf;
use gridoxide::measurement::Measurement;
use gridoxide::network::{build_ybus, stamp_shunts};
use gridoxide::pgm::measurement::measurements_from_pgm;
use gridoxide::pgm::{node_id_to_idx, pgm_shunts_1ph, pgm_to_network};
use gridoxide::se::SeNetwork;
use gridoxide::se::batch::{
MeasurementOverride, SeBatchError, SeBatchSolver, SeScenario, align_scenarios,
};
use gridoxide::se::nr::{PersistentEstimator, SeMethod, SeOptions, SeStatus, linear_start};
use gridoxide::types::Bus;
const S_BASE_VA: f64 = 1e6;
fn fixture_dir(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/data/pgm/state_estimation")
.join(name)
}
struct Loaded {
buses: Vec<Bus>,
se_net: SeNetwork,
template: Vec<Measurement>,
scenarios: Vec<SeScenario>,
node_idx: std::collections::HashMap<u64, usize>,
expected: serde_json::Value,
}
fn load(name: &str) -> Loaded {
let dir = fixture_dir(name);
let base = common::load_json(&dir.join("input.json"));
let batch = common::load_json(&dir.join("update_batch.json"));
let expected = common::load_json(&dir.join("sym_output_batch.json"));
let base_input = common::load_pgm_input(&dir.join("input.json"));
let id_to_idx = node_id_to_idx(&base_input);
let shunts = pgm_shunts_1ph(&base_input, &id_to_idx, S_BASE_VA);
let net = pgm_to_network(
common::load_pgm_input(&dir.join("input.json")),
S_BASE_VA,
50.0,
);
let mut ybus = build_ybus(net.buses.len(), &net.lines, &net.transformers);
stamp_shunts(&mut ybus, &shunts);
let se_net = net.se_network(ybus.finish(), &shunts);
let per_scenario: Vec<Vec<Measurement>> = batch["data"]
.as_array()
.expect("update_batch data is a list of scenarios")
.iter()
.map(|scenario| {
let merged = common::apply_batch_scenario(&base, scenario);
measurements_from_pgm(&merged, &net, S_BASE_VA).expect("scenario measurements")
})
.collect();
let (template, scenarios) = align_scenarios(&per_scenario).expect("scenarios align");
Loaded {
buses: net.buses.clone(),
se_net,
template,
scenarios,
node_idx: net.node_idx.clone(),
expected,
}
}
fn sequential(l: &Loaded, options: SeOptions) -> Vec<(Vec<Bus>, SeStatus)> {
let mut estimator = PersistentEstimator::new(options);
l.scenarios
.iter()
.map(|sc| {
let mut measurements = l.template.clone();
for ov in &sc.overrides {
if let Some(v) = ov.value {
measurements[ov.measurement].value = v;
}
if let Some(s) = ov.sigma {
measurements[ov.measurement].sigma = s;
}
}
let mut buses = l.buses.clone();
linear_start(&mut buses, &l.se_net, &measurements);
let report = estimator.estimate(&measurements, &mut buses, &l.se_net);
(buses, report.status)
})
.collect()
}
const BATCH_FIXTURES: &[(&str, SeMethod)] = &[
("sensor-update-nr", SeMethod::NewtonRaphson),
("sensor-update-il", SeMethod::IterativeLinear),
("sensor-update-initially-empty", SeMethod::NewtonRaphson),
(
"unbalanced-power-measurements-newton-raphson",
SeMethod::NewtonRaphson,
),
(
"unbalanced-power-measurements-iterative-linear",
SeMethod::IterativeLinear,
),
];
const SOLVABLE_FIXTURES: &[(&str, SeMethod, f64)] = &[
("sensor-update-nr", SeMethod::NewtonRaphson, 1e-6),
("sensor-update-il", SeMethod::IterativeLinear, 1e-3),
(
"sensor-update-initially-empty",
SeMethod::NewtonRaphson,
1e-6,
),
(
"unbalanced-power-measurements-newton-raphson",
SeMethod::NewtonRaphson,
1e-6,
),
(
"unbalanced-power-measurements-iterative-linear",
SeMethod::IterativeLinear,
1e-6,
),
];
#[test]
fn a_batch_reproduces_a_sequential_loop_exactly() {
for &(name, method) in BATCH_FIXTURES {
let l = load(name);
let options = SeOptions {
method,
max_iter: 100,
..SeOptions::default()
};
let reference = sequential(&l, options);
for threads in [1usize, 2, 4] {
let solver = SeBatchSolver::with_threads(options, threads).expect("pool");
let out = solver
.estimate(&l.buses, &l.se_net, &l.template, &l.scenarios)
.expect("batch estimates");
assert_eq!(out.len(), l.scenarios.len(), "{name}: wrong scenario count");
for (i, (result, (want_buses, want_status))) in out.iter().zip(&reference).enumerate() {
assert_eq!(
result.report.status, *want_status,
"{name} scenario {i} at {threads} thread(s): status differs"
);
for (got, want) in result.buses.iter().zip(want_buses) {
assert_eq!(
(got.voltage_mag, got.voltage_ang),
(want.voltage_mag, want.voltage_ang),
"{name} scenario {i} at {threads} thread(s): bus {} differs",
got.idx
);
}
}
}
}
}
#[test]
fn batch_scenarios_match_pgm_per_scenario() {
let mut checked = 0;
for &(name, method, tol) in SOLVABLE_FIXTURES {
let l = load(name);
let options = SeOptions {
method,
max_iter: 100,
..SeOptions::default()
};
let out = SeBatchSolver::new(options)
.estimate(&l.buses, &l.se_net, &l.template, &l.scenarios)
.expect("batch estimates");
let scenarios = l.expected["data"].as_array().expect("batch output");
assert_eq!(scenarios.len(), out.len(), "{name}: scenario count differs");
for (i, (result, expected)) in out.iter().zip(scenarios).enumerate() {
assert_eq!(
result.report.status,
SeStatus::Converged,
"{name} scenario {i}: {:?}",
result.report
);
for node in expected["node"].as_array().expect("node output") {
let id = node["id"].as_u64().expect("node id");
if node["energized"].as_u64() == Some(0) {
continue;
}
let idx = l.node_idx[&id];
let u_rated = l.buses[idx].u_rated;
let Some(u) = node["u"]
.as_f64()
.or_else(|| node["u_pu"].as_f64().map(|pu| pu * u_rated))
else {
continue;
};
let got = result.buses[idx].voltage_mag * u_rated;
assert!(
(got - u).abs() <= tol * u.abs().max(1.0),
"{name} scenario {i} node {id}: |V| = {got}, PGM says {u}"
);
checked += 1;
}
}
}
assert!(
checked >= 20,
"expected a meaningful number of node checks, got {checked}"
);
}
#[test]
fn a_base_document_with_no_readings_still_yields_a_template() {
let dir = fixture_dir("sensor-update-initially-empty");
let base_input = common::load_pgm_input(&dir.join("input.json"));
let net = pgm_to_network(
common::load_pgm_input(&dir.join("input.json")),
S_BASE_VA,
50.0,
);
let from_base = measurements_from_pgm(&base_input, &net, S_BASE_VA).expect("measurements");
assert!(
from_base.is_empty(),
"the base document is supposed to carry no readings"
);
let l = load("sensor-update-initially-empty");
assert!(
!l.template.is_empty(),
"the template must come from the scenarios, which do carry readings"
);
}
#[test]
fn changing_whether_an_angle_is_measured_is_rejected() {
let l = load("sensor-update-nr");
let angle_rows: Vec<usize> = l
.template
.iter()
.enumerate()
.filter(|(_, m)| m.kind == gridoxide::measurement::MeasurementKind::VoltageAngle)
.map(|(i, _)| i)
.collect();
assert!(
!angle_rows.is_empty(),
"fixture is supposed to carry voltage angles"
);
let mut scenarios = l.scenarios.clone();
scenarios[0] = SeScenario::new(
angle_rows
.iter()
.map(|&i| MeasurementOverride::new(i).absent())
.collect(),
);
let solver = SeBatchSolver::new(SeOptions::default());
assert_eq!(
solver
.estimate(&l.buses, &l.se_net, &l.template, &scenarios)
.unwrap_err(),
SeBatchError::AngleReferenceChanged { scenario: 0 }
);
}
#[test]
fn an_out_of_range_override_is_reported() {
let l = load("sensor-update-nr");
let n = l.template.len();
let scenarios = vec![SeScenario::new(vec![
MeasurementOverride::new(n).value(1.0),
])];
let solver = SeBatchSolver::new(SeOptions::default());
assert_eq!(
solver
.estimate(&l.buses, &l.se_net, &l.template, &scenarios)
.unwrap_err(),
SeBatchError::MeasurementOutOfRange {
scenario: 0,
measurement: n,
n
}
);
}
#[test]
fn a_de_energised_island_is_reported_at_zero() {
for name in ["sensor-update-nr", "sensor-update-il"] {
let l = load(name);
let components = gridoxide::network::connected_components(&l.se_net.ybus);
let live = components
.iter()
.filter(|c| c.iter().any(|&i| !l.se_net.source_branches[i].is_empty()))
.count();
assert!(
components.len() > 1 && live == 1,
"{name}: expected several components with exactly one energised"
);
let options = SeOptions {
method: SeMethod::NewtonRaphson,
max_iter: 100,
..SeOptions::default()
};
let out = SeBatchSolver::new(options)
.estimate(&l.buses, &l.se_net, &l.template, &l.scenarios)
.expect("batch estimates");
let expected = l.expected["data"].as_array().expect("batch output");
let mut zeroed = 0;
for (result, scenario) in out.iter().zip(expected) {
assert_eq!(
result.report.status,
SeStatus::Converged,
"{name}: {:?}",
result.report
);
for node in scenario["node"].as_array().expect("node output") {
if node["energized"].as_u64() != Some(0) {
continue;
}
let idx = l.node_idx[&node["id"].as_u64().expect("node id")];
assert_eq!(
(result.buses[idx].voltage_mag, result.buses[idx].voltage_ang),
(0.0, 0.0),
"{name}: node {} is de-energised and must be reported at zero",
node["id"]
);
zeroed += 1;
}
}
assert!(
zeroed >= 8,
"{name}: expected several de-energised nodes, got {zeroed}"
);
}
}
#[test]
fn every_batch_fixture_is_listed_here() {
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/data/pgm/state_estimation");
let mut unlisted = Vec::new();
for entry in std::fs::read_dir(&dir).expect("fixture dir") {
let entry = entry.expect("dir entry");
if !entry.path().join("update_batch.json").exists() {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
if !BATCH_FIXTURES.iter().any(|&(n, _)| n == name) {
unlisted.push(name);
}
}
assert!(
unlisted.is_empty(),
"these fixtures ship an update_batch.json but are not in BATCH_FIXTURES: {unlisted:?}"
);
assert_eq!(BATCH_FIXTURES.len(), 5, "batch fixtures present");
}