mod common;
use std::path::PathBuf;
use gridoxide::measurement::{Measurement, MeasurementKind, Target};
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::bad_data;
use gridoxide::se::constraints::Constraints;
use gridoxide::se::jacobian::StateLayout;
use gridoxide::se::measurement_functions;
use gridoxide::se::nr::{SeMethod, SeOptions, SeStatus, estimate, linear_start};
use gridoxide::se::observability::analyze;
use gridoxide::solver::JacobianBackend;
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)
}
fn assert_estimate_matches(name: &str, backend: JacobianBackend, tol: f64) {
assert_estimate_matches_with(name, backend, SeMethod::NewtonRaphson, tol, 20);
}
fn assert_estimate_matches_with(
name: &str,
backend: JacobianBackend,
method: SeMethod,
tol: f64,
max_iter: usize,
) {
let dir = fixture_dir(name);
let input = common::load_pgm_input(&dir.join("input.json"));
let expected = common::load_json(&dir.join("sym_output.json"));
let id_to_idx = node_id_to_idx(&input);
let shunts = pgm_shunts_1ph(&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 measurements = measurements_from_pgm(&input, &net, S_BASE_VA).expect("measurements");
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 mut buses = net.buses.clone();
linear_start(&mut buses, &se_net, &measurements);
let options = SeOptions {
backend,
method,
max_iter,
..SeOptions::default()
};
let report = estimate(&measurements, &mut buses, &se_net, &options);
assert_eq!(
report.status,
SeStatus::Converged,
"{name} [{backend:?}/{method:?}]: {report:?}"
);
let phase_is_measured = measurements
.iter()
.any(|m| m.kind == gridoxide::measurement::MeasurementKind::VoltageAngle);
let mut offsets = Vec::new();
let mut checked = 0usize;
for node in expected["data"]["node"].as_array().expect("node output") {
let id = node["id"].as_u64().expect("node id");
let idx = net.node_idx[&id];
let u_pu = node["u_pu"]
.as_f64()
.or_else(|| node["u"].as_f64().map(|u| u / net.buses[idx].u_rated));
if let Some(u_pu) = u_pu {
assert!(
(buses[idx].voltage_mag - u_pu).abs() < tol,
"{name} [{backend:?}/{method:?}] node {id}: |V| = {}, PGM says {u_pu}",
buses[idx].voltage_mag
);
checked += 1;
}
let Some(u_angle) = node["u_angle"].as_f64() else {
continue;
};
if phase_is_measured {
assert!(
(buses[idx].voltage_ang - u_angle).abs() < tol,
"{name} [{backend:?}/{method:?}] node {id}: angle = {}, PGM says {u_angle}",
buses[idx].voltage_ang
);
checked += 1;
} else {
offsets.push((id, buses[idx].voltage_ang - u_angle));
}
}
assert!(
checked + offsets.len() > 0,
"{name} [{backend:?}/{method:?}]: the fixture published no node state to compare against, \
so this test asserted nothing but convergence"
);
if let Some(&(ref_id, reference)) = offsets.first() {
for &(id, offset) in &offsets {
assert!(
(offset - reference).abs() < tol,
"{name} [{backend:?}/{method:?}] node {id}: angle offset {offset} differs from node \
{ref_id}'s {reference} — a uniform offset is a reference convention, a \
varying one is a wrong estimate"
);
}
}
}
#[test]
fn estimates_1os2msr() {
assert_estimate_matches("1os2msr", JacobianBackend::Scalar, 1e-6);
}
#[test]
fn estimates_1os2msr_without_angle_measurements() {
assert_estimate_matches("1os2msr-no-angle", JacobianBackend::Scalar, 1e-6);
}
#[test]
fn estimates_with_an_infinite_sigma_measurement() {
assert_estimate_matches(
"inf-measurement-with-injection",
JacobianBackend::Scalar,
1e-6,
);
}
#[test]
fn estimates_transmission_case() {
assert_estimate_matches("transmission-case", JacobianBackend::Scalar, 1e-6);
}
#[test]
fn estimates_three_winding_transformer_side_sensors() {
assert_estimate_matches("three_winding_transformer", JacobianBackend::Scalar, 1e-6);
}
#[test]
fn estimates_from_an_asymmetric_voltage_phasor() {
assert_estimate_matches(
"single-node-source-asym-voltage-sensor",
JacobianBackend::Scalar,
1e-6,
);
}
#[test]
fn estimates_from_an_asymmetric_voltage_magnitude() {
assert_estimate_matches(
"single-node-source-asym-voltage-sensor-no-angle",
JacobianBackend::Scalar,
1e-6,
);
}
#[test]
fn estimates_with_mixed_symmetric_and_asymmetric_sensors() {
assert_estimate_matches("dummy-test-sym", JacobianBackend::Scalar, 1e-5);
}
#[test]
fn estimates_from_a_global_angle_current_sensor() {
assert_estimate_matches("global-current-sensor", JacobianBackend::Scalar, 1e-6);
}
#[test]
fn estimates_from_a_local_angle_current_sensor() {
assert_estimate_matches("local-current-sensor", JacobianBackend::Scalar, 1e-6);
}
#[test]
fn both_methods_agree_on_current_sensors() {
for name in ["global-current-sensor", "local-current-sensor"] {
assert_estimate_matches_with(
name,
JacobianBackend::Scalar,
SeMethod::IterativeLinear,
1e-6,
100,
);
}
}
#[test]
fn a_flat_start_finds_the_wrong_basin_through_a_phase_shifting_transformer() {
let dir = fixture_dir("three_winding_transformer");
let input = common::load_pgm_input(&dir.join("input.json"));
let id_to_idx = node_id_to_idx(&input);
let shunts = pgm_shunts_1ph(&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 measurements = measurements_from_pgm(&input, &net, S_BASE_VA).expect("measurements");
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 run = |seed: fn(&mut [gridoxide::types::Bus], &SeNetwork, &[Measurement])| {
let mut buses = net.buses.clone();
seed(&mut buses, &se_net, &measurements);
let report = estimate(&measurements, &mut buses, &se_net, &SeOptions::default());
(report, buses)
};
let (flat, flat_buses) = run(|b, _, m| gridoxide::se::nr::flat_start(b, m));
let (linear, linear_buses) = run(gridoxide::se::nr::linear_start);
assert_eq!(
flat.status,
SeStatus::Converged,
"the flat start does converge — that is the problem"
);
assert_eq!(linear.status, SeStatus::Converged);
assert!(
linear.objective < 1e-6,
"linear start should reach the true optimum, got J = {:.3e}",
linear.objective
);
assert!(
flat.objective > 1.0,
"the flat start's spurious minimum should be far worse, got J = {:.3e}",
flat.objective
);
let source_node = net.node_idx[&1];
assert!((linear_buses[source_node].voltage_mag - 0.9999926174270017).abs() < 1e-6);
assert!(
flat_buses[source_node].voltage_mag < 0.5,
"expected the spurious basin to sit far from 1.0 p.u., got {}",
flat_buses[source_node].voltage_mag
);
}
#[test]
fn zero_injection_constraint_overrides_a_conflicting_sensor() {
assert_estimate_matches(
"node-injection-sensor-and-zero-injection",
JacobianBackend::Scalar,
1e-6,
);
}
#[test]
fn fixtures_are_observable_in_their_physical_unknowns() {
for name in [
"1os2msr",
"1os2msr-no-angle",
"inf-measurement-with-injection",
"transmission-case",
"node-injection-sensor-and-zero-injection",
] {
let dir = fixture_dir(name);
let input = common::load_pgm_input(&dir.join("input.json"));
let id_to_idx = node_id_to_idx(&input);
let shunts = pgm_shunts_1ph(&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 measurements = measurements_from_pgm(&input, &net, S_BASE_VA).expect("measurements");
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 mut buses = net.buses.clone();
linear_start(&mut buses, &se_net, &measurements);
let layout = StateLayout::new(&buses, &measurements, &se_net);
let report = analyze(
&measurements,
&buses,
&se_net,
&layout,
&Constraints::new(&se_net),
);
assert!(
!report.skipped_numerical,
"{name}: fixture should be small enough to analyze"
);
let n_physical = id_to_idx.len();
for unknown in report
.unobservable
.iter()
.chain(&report.structurally_unmeasured)
{
assert!(
unknown.bus >= n_physical,
"{name}: physical bus {} ({:?}) is unobservable — report {report:?}",
unknown.bus,
unknown.quantity
);
}
}
}
#[test]
fn bad_data_analysis_flags_only_the_inconsistent_fixture() {
for (name, should_reject) in [
("1os2msr", false),
("1os2msr-no-angle", false),
("inf-measurement-with-injection", false),
("transmission-case", false),
("node-injection-sensor-and-zero-injection", true),
] {
let dir = fixture_dir(name);
let input = common::load_pgm_input(&dir.join("input.json"));
let id_to_idx = node_id_to_idx(&input);
let shunts = pgm_shunts_1ph(&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 measurements = measurements_from_pgm(&input, &net, S_BASE_VA).expect("measurements");
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 mut buses = net.buses.clone();
linear_start(&mut buses, &se_net, &measurements);
let report = estimate(&measurements, &mut buses, &se_net, &SeOptions::default());
let layout = StateLayout::new(&buses, &measurements, &se_net);
let constraints = Constraints::new(&se_net);
let bad = bad_data::analyze(
&measurements,
&report.residuals,
&buses,
&se_net,
&layout,
&constraints,
bad_data::Candidates::default(),
);
assert_eq!(
bad.rejects_at(0.05),
should_reject,
"{name}: chi-squared {:.3e} on {} dof, p = {:.3e}",
bad.chi_squared,
bad.degrees_of_freedom,
bad.p_value
);
if should_reject {
let worst = bad.worst().expect("a suspect should be identified");
assert!(
matches!(
measurements[worst.measurement].target,
gridoxide::measurement::Target::NodeInjection(_)
),
"{name}: expected the injection sensor to be worst, got {:?}",
measurements[worst.measurement]
);
assert!(
worst.normalized_residual > 3.0,
"{name}: normalized residual {} should exceed the conventional threshold",
worst.normalized_residual
);
}
}
}
#[test]
fn iterative_linear_matches_pgm() {
for name in [
"1os2msr",
"1os2msr-no-angle",
"inf-measurement-with-injection",
"transmission-case",
"node-injection-sensor-and-zero-injection",
] {
assert_estimate_matches_with(
name,
JacobianBackend::Scalar,
SeMethod::IterativeLinear,
1e-6,
100,
);
}
}
#[test]
fn the_two_methods_agree() {
for name in ["1os2msr", "transmission-case"] {
let dir = fixture_dir(name);
let input = common::load_pgm_input(&dir.join("input.json"));
let id_to_idx = node_id_to_idx(&input);
let shunts = pgm_shunts_1ph(&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 measurements = measurements_from_pgm(&input, &net, S_BASE_VA).expect("measurements");
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 solve = |method| {
let mut buses = net.buses.clone();
linear_start(&mut buses, &se_net, &measurements);
let options = SeOptions {
method,
max_iter: 100,
..SeOptions::default()
};
let report = estimate(&measurements, &mut buses, &se_net, &options);
assert_eq!(
report.status,
SeStatus::Converged,
"{name} [{method:?}]: {report:?}"
);
buses
};
let newton = solve(SeMethod::NewtonRaphson);
let linear = solve(SeMethod::IterativeLinear);
for (i, (a, b)) in newton.iter().zip(&linear).enumerate() {
assert!(
(a.voltage_mag - b.voltage_mag).abs() < 1e-6,
"{name} bus {i}: Newton {} vs iterative-linear {}",
a.voltage_mag,
b.voltage_mag
);
}
}
}
#[test]
fn per_node_injections_survive_across_a_link() {
for name in [
"node-injection-with-injection-sensor-sym-sensors",
"node-injection-wo-injection-sensor-sym-sensors",
] {
let dir = fixture_dir(name);
let input = common::load_pgm_input(&dir.join("input.json"));
let expected = common::load_json(&dir.join("sym_output.json"));
let id_to_idx = node_id_to_idx(&input);
let shunts = pgm_shunts_1ph(&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 measurements = measurements_from_pgm(&input, &net, S_BASE_VA).expect("measurements");
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 mut buses = net.buses.clone();
linear_start(&mut buses, &se_net, &measurements);
let report = estimate(&measurements, &mut buses, &se_net, &SeOptions::default());
assert_eq!(report.status, SeStatus::Converged, "{name}: {report:?}");
let nodes = expected["data"]["node"].as_array().expect("node output");
let mut checked = 0;
for node in nodes {
let id = node["id"].as_u64().expect("node id");
let idx = net.node_idx[&id];
let (Some(p), Some(q)) = (node["p"].as_f64(), node["q"].as_f64()) else {
continue;
};
let probe = |kind| Measurement {
kind,
target: Target::NodeInjection(idx),
value: 0.0,
sigma: 1.0,
};
let rows = vec![
probe(MeasurementKind::ActivePower),
probe(MeasurementKind::ReactivePower),
];
let h = measurement_functions(&rows, &buses, &se_net);
assert!(
(h[0] * S_BASE_VA - p).abs() < 1e-3,
"{name} node {id}: p = {} W, PGM says {p}",
h[0] * S_BASE_VA
);
assert!(
(h[1] * S_BASE_VA - q).abs() < 1e-3,
"{name} node {id}: q = {} W, PGM says {q}",
h[1] * S_BASE_VA
);
checked += 1;
}
assert!(
checked >= 2,
"{name}: expected both nodes of the link to be checked"
);
let raw = common::load_json(&dir.join("input.json"));
let link = &raw["data"]["link"].as_array().expect("link")[0];
assert_ne!(
net.node_idx[&link["from_node"].as_u64().unwrap()],
net.node_idx[&link["to_node"].as_u64().unwrap()],
"{name}: a merge here would erase the injections just checked"
);
}
}
#[test]
fn every_backend_agrees() {
for backend in [
JacobianBackend::Scalar,
JacobianBackend::Block,
JacobianBackend::KluNative,
] {
assert_estimate_matches("1os2msr", backend, 1e-6);
assert_estimate_matches("transmission-case", backend, 1e-6);
}
}