import json
from pathlib import Path
import pytest
import gridoxide
FIXTURES = Path(__file__).resolve().parent.parent.parent / "tests/data/pgm/state_estimation"
def expected_nodes(name):
data = json.loads((FIXTURES / name / "sym_output.json").read_text())["data"]
return {n["id"]: (n["u_pu"], n["u_angle"]) for n in data["node"]}
def model(name, **kwargs):
return gridoxide.StateEstimationModel.from_pgm_json(
str(FIXTURES / name / "input.json"), **kwargs
)
def test_estimate_matches_pgm():
m = model("1os2msr")
assert m.n_measurements == 20
m.solve()
vm, va = m.voltage_mag(), m.voltage_ang()
for idx, (u_pu, u_angle) in enumerate(expected_nodes("1os2msr").values()):
assert vm[idx] == pytest.approx(u_pu, abs=1e-6)
assert va[idx] == pytest.approx(u_angle, abs=1e-6)
def test_residuals_and_objective_are_available_after_solve():
m = model("1os2msr")
m.solve()
assert len(m.residuals()) == m.n_measurements
assert m.objective == pytest.approx(0.0, abs=1e-12)
def test_backends_agree():
answers = []
for backend in ("scalar", "block", "klu_native"):
m = model("1os2msr", backend=backend)
m.solve()
answers.append(m.voltage_mag())
for other in answers[1:]:
assert other == pytest.approx(answers[0], abs=1e-9)
def test_iterative_linear_method_agrees():
newton = model("transmission-case")
newton.solve()
linear = model("transmission-case", method="iterative_linear", max_iter=100)
linear.solve()
assert linear.voltage_mag() == pytest.approx(newton.voltage_mag(), abs=1e-6)
def test_unknown_method_is_rejected():
with pytest.raises(ValueError, match="unknown method"):
model("1os2msr", method="bogus")
def test_bad_data_rejects_the_conflicting_sensor():
m = model("node-injection-sensor-and-zero-injection")
m.solve()
chi_squared, dof, p_value, suspects = m.bad_data()
assert chi_squared > 1e3, chi_squared
assert dof > 0
assert p_value < 0.05, "a 100-sigma conflict must be rejected"
assert suspects, "the culprit should be identified"
_, normalized = suspects[0]
assert normalized > 3.0
def test_bad_data_is_quiet_on_consistent_data():
m = model("1os2msr")
m.solve()
_, _, p_value, _ = m.bad_data()
assert p_value > 0.05, "consistent data must not be flagged"
def test_bad_data_requires_solve_first():
m = model("1os2msr")
with pytest.raises(RuntimeError):
m.bad_data()
def test_observability_reports_only_synthesized_buses():
m = model("transmission-case")
m.solve()
n_physical = len(expected_nodes("transmission-case"))
for bus, quantity in m.observability():
assert bus >= n_physical, f"physical bus {bus} ({quantity}) is unobservable"
def test_document_without_sensors_is_rejected():
power_flow_input = (
Path(__file__).resolve().parent.parent.parent
/ "tests/data/pgm/powerflow/symmetric/line/input.json"
)
with pytest.raises(ValueError, match="no usable sensors"):
gridoxide.StateEstimationModel.from_pgm_json(str(power_flow_input))
def test_solve_batch_is_order_and_thread_invariant():
m = model("transmission-case")
m.solve()
single = list(m.voltage_mag())
scenarios = [[], [(0, m.measurement_value(0), 0.05)], [(0, m.measurement_value(0), 0.5)]]
reference = m.solve_batch(scenarios, 1)
assert [r.converged for r in reference] == [True, True, True]
assert reference[0].voltage_mag == pytest.approx(single, abs=0.0)
for threads in (2, 4):
out = m.solve_batch(scenarios, threads)
assert len(out) == len(scenarios)
for got, want in zip(out, reference):
assert got.voltage_mag == pytest.approx(want.voltage_mag, abs=0.0)
assert got.voltage_ang == pytest.approx(want.voltage_ang, abs=0.0)
assert got.iterations == want.iterations
def test_solve_batch_rejects_an_out_of_range_measurement():
m = model("transmission-case")
n = m.n_measurements
with pytest.raises(ValueError, match="only .* measurement"):
m.solve_batch([[(n, 1.0, 0.1)]])