import json
from pathlib import Path
import pytest
import gridoxide
FIXTURE_DIR = (
Path(__file__).resolve().parent.parent.parent / "tests/data/pgm/powerflow/symmetric"
)
TRANSMISSION = FIXTURE_DIR / "transmission-case/input.json"
DISTRIBUTION = FIXTURE_DIR / "distribution-case/input.json"
def dc_model(path=TRANSMISSION, **kwargs):
return gridoxide.PowerFlowModel.from_pgm_json(str(path), method="dc", **kwargs)
def test_dc_solves_and_conserves_power():
model = dc_model()
model.solve()
assert len(model.voltage_ang()) == model.n_nodes
assert len(model.branch_flow_p()) == model.n_branches
assert model.dc_max_residual() < 1e-9
pickup = model.dc_slack_pickup()
assert len(pickup) >= 1
total = sum(p for _, p in pickup)
assert abs(total - 62.0) < 1e-9, f"expected 62 p.u. of load, got {total}"
def test_the_two_dc_approximations_are_both_reachable():
angles = {}
for approximation in ("ignore_r", "ignore_g"):
model = dc_model(dc_approximation=approximation)
model.solve()
angles[approximation] = model.voltage_ang()
worst = max(abs(a - b) for a, b in zip(angles["ignore_r"], angles["ignore_g"]))
assert worst > 1e-9, "dc_approximation did not reach the solver"
with pytest.raises(ValueError, match="unknown dc_approximation"):
dc_model(dc_approximation="nonsense")
def test_method_selection_and_validation():
for method in ("newton_raphson", "dc", "linear_impedance"):
model = gridoxide.PowerFlowModel.from_pgm_json(str(TRANSMISSION), method=method)
model.solve()
assert len(model.voltage_mag()) == model.n_nodes
with pytest.raises(ValueError, match="unknown method"):
gridoxide.PowerFlowModel.from_pgm_json(str(TRANSMISSION), method="nonsense")
def test_branch_flows_need_a_dc_solve():
model = gridoxide.PowerFlowModel.from_pgm_json(str(TRANSMISSION))
model.solve() with pytest.raises(RuntimeError, match="no DC result"):
model.branch_flow_p()
model.solve_dc()
assert len(model.branch_flow_p()) == model.n_branches
def test_ptdf_matches_a_finite_difference_of_the_solve():
model = dc_model()
model.solve()
base = model.branch_flow_p()
eps = 1e-3
checked = 0
for bus in range(model.n_nodes):
column = model.ptdf_column(bus)
if column is None:
continue
injections = [0.0] * model.n_nodes
injections[bus] = eps
response = model.transfer_factors(injections)
for k, (predicted, measured) in enumerate(zip(column, response)):
assert abs(predicted * eps - measured) < 1e-9, (
f"PTDF[{k}, {bus}]: column says {predicted * eps}, "
f"transfer_factors says {measured}"
)
assert len(base) == len(column)
checked += 1
assert checked > 2, f"only {checked} buses had PTDF columns"
def test_ptdf_row_is_the_transpose_of_the_columns():
model = dc_model()
columns = [model.ptdf_column(bus) for bus in range(model.n_nodes)]
checked = 0
for branch in range(model.n_branches):
row = model.ptdf_row(branch)
if row is None:
continue
for bus, column in enumerate(columns):
if column is None:
continue
assert abs(row[bus] - column[branch]) < 1e-12
checked += 1
assert checked > 2, f"only {checked} branches had PTDF rows"
def test_lodf_and_radial_branches():
model = dc_model()
radial, meshed = 0, 0
for branch in range(model.n_branches):
column = model.lodf_column(branch)
if model.is_radial(branch):
assert column is None, f"branch {branch} is radial but returned a column"
radial += 1
else:
assert column is not None
assert column[branch] == -1.0
meshed += 1
assert radial > 0, "fixture exercises no radial branch"
assert meshed > 0, "fixture exercises no meshed branch"
def test_sensitivities_do_not_require_a_solve():
model = dc_model()
assert model.ptdf_column(0) is not None
assert model.n_branches > 0
def test_out_of_range_indices_are_rejected():
model = dc_model()
with pytest.raises(ValueError, match="out of range"):
model.ptdf_column(model.n_nodes)
with pytest.raises(ValueError, match="out of range"):
model.lodf_column(model.n_branches)
with pytest.raises(ValueError, match="one per bus"):
model.transfer_factors([0.0])
def test_dc_on_a_distribution_feeder():
model = dc_model(DISTRIBUTION)
model.solve()
assert model.dc_max_residual() < 1e-9
def test_outage_flows_match_an_actual_outage():
model = dc_model()
model.solve()
base = model.branch_flow_p()
checked = 0
for branch in range(model.n_branches):
after = model.outage_flows(branch)
if model.is_radial(branch):
assert after is None
continue
assert after is not None
assert after[branch] == 0.0
column = model.lodf_column(branch)
for k in range(model.n_branches):
assert abs(after[k] - (base[k] + column[k] * base[branch])) < 1e-9 or k == branch
checked += 1
assert checked > 0, "no non-radial branch was checked"
def test_outage_flows_validates_its_input():
model = dc_model()
model.solve()
with pytest.raises(ValueError, match="out of range"):
model.outage_flows(model.n_branches)
with pytest.raises(ValueError, match="one per branch"):
model.outage_flows(0, [0.0])
def test_multi_outage_flows_and_breaking_sets():
model = dc_model()
model.solve()
base = model.branch_flow_p()
assert model.multi_outage_flows([]) == base
assert model.multi_outage_flows([0, 0]) is None
checked, breaking = 0, 0
for a in range(model.n_branches):
for b in range(a + 1, model.n_branches):
after = model.multi_outage_flows([a, b])
if after is None:
assert model.is_breaking_set([a, b])
breaking += 1
continue
assert after[a] == 0.0 and after[b] == 0.0
assert model.multi_outage_flows([a]) == model.outage_flows(a)
checked += 1
assert checked > 0, "no solvable pair was screened"
assert breaking > 0, "no breaking pair was found, so is_breaking_set is untested"
def test_multi_outage_validates_its_input():
model = dc_model()
model.solve()
with pytest.raises(ValueError, match="out of range"):
model.multi_outage_flows([model.n_branches])
with pytest.raises(ValueError, match="one per branch"):
model.multi_outage_flows([0], [0.0])
with pytest.raises(ValueError, match="out of range"):
model.is_breaking_set([model.n_branches])
def test_ac_contingency_sweep():
model = gridoxide.PowerFlowModel.from_pgm_json(str(TRANSMISSION))
n = model.n_branches
results = model.solve_contingencies([[b] for b in range(n)])
assert len(results) == n
for b, (status, vm, va) in enumerate(results):
assert status in ("converged", "max_iterations", "singular")
assert len(vm) == model.n_nodes and len(va) == model.n_nodes
single = model.solve_contingencies([[b] for b in range(n)], threads=1)
assert [s for s, _, _ in single] == [s for s, _, _ in results]
for (_, a, _), (_, c, _) in zip(single, results):
assert max(abs(x - y) for x, y in zip(a, c)) < 1e-6
base_status, base_vm, _ = model.solve_contingencies([[]])[0]
assert base_status == "converged"
model.solve()
assert max(abs(x - y) for x, y in zip(base_vm, model.voltage_mag())) < 1e-6
def test_contingency_sweep_validates_branch_indices():
model = gridoxide.PowerFlowModel.from_pgm_json(str(TRANSMISSION))
with pytest.raises(ValueError):
model.solve_contingencies([[model.n_branches]])