Files
SkillCompiler/data/skills-bench/tasks/energy-ac-optimal-power-flow/verifier/test_outputs.py
T
2026-09-04 14:58:42 +08:00

832 lines
31 KiBLFS
Python

"""
Test cases for AC Optimal Power Flow task.
Validates the agent's solution using:
1. Schema validation (report structure is correct)
2. Feasibility verification (all AC constraints satisfied)
3. Cost consistency checks
Since ACOPF could have multiple locally optimal solutions, we verify:
- The solution is FEASIBLE (satisfies all AC power flow constraints)
- The reported cost matches the MATPOWER polynomial cost function evaluated at the reported dispatch
We do NOT compare individual dispatch values, as these may differ
between valid solutions due to non-convexity.
"""
import json
import math
import os
import numpy as np
import pytest
OUTPUT_FILE = "/root/report.json"
NETWORK_FILE = "/root/network.json"
# Tolerances for numerical comparisons
TOL_POWER_BALANCE = 1.0 # Power balance tolerance (MW/MVAr)
TOL_VOLTAGE = 0.01 # Voltage bound tolerance (pu)
TOL_BRANCH_FLOW = 5.0 # Branch flow limit tolerance (MVA)
TOL_GEN_BOUND = 1.0 # Generator bound tolerance (MW/MVAr)
TOL_ANGLE_DEG = 0.1 # Angle tolerance (deg)
TOL_COST = 1e-2 # Cost self-consistency tolerance ($/hr)
TOL_OPTIMALITY_GAP_REL = 0.10 # +/- 10% objective gap vs solver benchmark (relative)
def _deg2rad(deg: float) -> float:
return deg * math.pi / 180.0
def _compute_branch_flows_pu(Vm, Va, br, bus_id_to_idx):
"""
Compute branch flows in per-unit (P_ij, Q_ij, P_ji, Q_ji) using the exact
pi-model equations in acopf-math-model.md (matching MATPOWER branch fields).
"""
# MATPOWER: [fbus, tbus, r, x, b, rateA, rateB, rateC, ratio, angle, status, angmin, angmax]
r = br[2]
x = br[3]
bc = br[4]
# Match solve.sh: treat very small ratios as "no transformer" (ratio=1)
ratio = br[8] if abs(br[8]) >= 1e-12 else 1.0
shift = _deg2rad(br[9])
# Series admittance y = 1/(r + jx) = g + jb
if abs(r) < 1e-12 and abs(x) < 1e-12:
g = 0.0
b = 0.0
else:
denom = r * r + x * x
g = r / denom
b = -x / denom
f = bus_id_to_idx[int(br[0])]
t = bus_id_to_idx[int(br[1])]
Vm_i = Vm[f]
Vm_j = Vm[t]
Va_i = Va[f]
Va_j = Va[t]
inv_t = 1.0 / ratio
inv_t2 = inv_t * inv_t
# i -> j
delta = Va_i - Va_j - shift
c = math.cos(delta)
s = math.sin(delta)
P_ij = g * Vm_i * Vm_i * inv_t2 - Vm_i * Vm_j * inv_t * (g * c + b * s)
Q_ij = -(b + bc / 2.0) * Vm_i * Vm_i * inv_t2 - Vm_i * Vm_j * inv_t * (g * s - b * c)
# j -> i
delta2 = Va_j - Va_i + shift
c2 = math.cos(delta2)
s2 = math.sin(delta2)
P_ji = g * Vm_j * Vm_j - Vm_i * Vm_j * inv_t * (g * c2 + b * s2)
Q_ji = -(b + bc / 2.0) * Vm_j * Vm_j - Vm_i * Vm_j * inv_t * (g * s2 - b * c2)
return P_ij, Q_ij, P_ji, Q_ji
def _extract_solution_arrays(report, network):
"""
Returns (Vm, Va, Pg, Qg) aligned to network ordering.
Vm in pu, Va in radians, Pg/Qg in MW/MVAr.
"""
buses = network["bus"]
gens = network["gen"]
n_bus = len(buses)
n_gen = len(gens)
bus_ids = [int(b[0]) for b in buses]
bus_id_to_idx = {bid: i for i, bid in enumerate(bus_ids)}
Vm = np.zeros(n_bus, dtype=float)
Va = np.zeros(n_bus, dtype=float)
for b in report["buses"]:
i = bus_id_to_idx[int(b["id"])]
Vm[i] = float(b["vm_pu"])
Va[i] = _deg2rad(float(b["va_deg"]))
# Generators are expected to be 1..n_gen ids
Pg = np.zeros(n_gen, dtype=float)
Qg = np.zeros(n_gen, dtype=float)
for g in report["generators"]:
idx = int(g["id"]) - 1
Pg[idx] = float(g["pg_MW"])
Qg[idx] = float(g["qg_MVAr"])
return Vm, Va, Pg, Qg, bus_id_to_idx
def _solve_acopf_optimal_cost(network: dict) -> float:
"""
Solve the ACOPF (same formulation as acopf-math-model.md) to obtain a
benchmark objective value.
Uses CasADi + IPOPT (installed by tests/test.sh).
"""
try:
import casadi as ca # type: ignore
except Exception as e: # pragma: no cover
raise RuntimeError(
"CasADi is required for the optimality-gap test. "
"Ensure tests install casadi (e.g., via tests/test.sh)."
) from e
baseMVA = float(network["baseMVA"])
bus = np.array(network["bus"], dtype=float)
gen = np.array(network["gen"], dtype=float)
branch = np.array(network["branch"], dtype=float)
gencost = np.array(network["gencost"], dtype=float)
n_bus = int(bus.shape[0])
n_gen = int(gen.shape[0])
n_branch = int(branch.shape[0])
bus_ids = bus[:, 0].astype(int)
bus_type = bus[:, 1].astype(int)
bus_id_to_idx = {int(bus_ids[i]): i for i in range(n_bus)}
Pd = bus[:, 2] / baseMVA
Qd = bus[:, 3] / baseMVA
Gs = bus[:, 4] / baseMVA
Bs = bus[:, 5] / baseMVA
Vmax = bus[:, 11]
Vmin = bus[:, 12]
# Initial bus voltage guess (MATPOWER VM/VA columns)
Vm0_bus = np.clip(bus[:, 7], Vmin, Vmax)
Va0_bus = np.array([_deg2rad(float(a)) for a in bus[:, 8]], dtype=float)
gen_bus = np.array([bus_id_to_idx[int(b)] for b in gen[:, 0]], dtype=int)
Pg0 = gen[:, 1] / baseMVA
Qg0 = gen[:, 2] / baseMVA
Qmax = gen[:, 3] / baseMVA
Qmin = gen[:, 4] / baseMVA
Pmax = gen[:, 8] / baseMVA
Pmin = gen[:, 9] / baseMVA
# gencost: [model, startup, shutdown, n, c2, c1, c0]
c2 = gencost[:, 4]
c1 = gencost[:, 5]
c0 = gencost[:, 6]
# Branch parameters
f = np.array([bus_id_to_idx[int(x)] for x in branch[:, 0]], dtype=int)
t = np.array([bus_id_to_idx[int(x)] for x in branch[:, 1]], dtype=int)
r = branch[:, 2]
x = branch[:, 3]
bc = branch[:, 4]
rate_pu = branch[:, 5] / baseMVA
tap = np.where(np.abs(branch[:, 8]) < 1e-12, 1.0, branch[:, 8])
shift = np.array([_deg2rad(float(a)) for a in branch[:, 9]], dtype=float)
angmin = np.array([_deg2rad(float(a)) for a in branch[:, 11]], dtype=float)
angmax = np.array([_deg2rad(float(a)) for a in branch[:, 12]], dtype=float)
# Series admittance y = 1/(r+jx) = g + jb
g = np.zeros(n_branch, dtype=float)
bser = np.zeros(n_branch, dtype=float)
for br_idx in range(n_branch):
if abs(r[br_idx]) < 1e-12 and abs(x[br_idx]) < 1e-12:
g[br_idx] = 0.0
bser[br_idx] = 0.0
else:
denom = r[br_idx] * r[br_idx] + x[br_idx] * x[br_idx]
g[br_idx] = r[br_idx] / denom
bser[br_idx] = -x[br_idx] / denom
# Decision variables
Vm = ca.MX.sym("Vm", n_bus)
Va = ca.MX.sym("Va", n_bus)
Pg = ca.MX.sym("Pg", n_gen)
Qg = ca.MX.sym("Qg", n_gen)
# Objective (in $/hr, using Pg in MW)
Pg_MW = Pg * baseMVA
obj = ca.sum1(ca.DM(c2) * (Pg_MW**2) + ca.DM(c1) * Pg_MW + ca.DM(c0))
# Branch flows (per unit), aggregated per bus
P_out = [ca.MX(0) for _ in range(n_bus)]
Q_out = [ca.MX(0) for _ in range(n_bus)]
Pij = [None] * n_branch
Qij = [None] * n_branch
Pji = [None] * n_branch
Qji = [None] * n_branch
for br_idx in range(n_branch):
i = int(f[br_idx])
j = int(t[br_idx])
inv_t = 1.0 / float(tap[br_idx])
inv_t2 = inv_t * inv_t
delta_ij = Va[i] - Va[j] - float(shift[br_idx])
cth = ca.cos(delta_ij)
sth = ca.sin(delta_ij)
P_ij = g[br_idx] * Vm[i] ** 2 * inv_t2 - Vm[i] * Vm[j] * inv_t * (
g[br_idx] * cth + bser[br_idx] * sth
)
Q_ij = -(bser[br_idx] + bc[br_idx] / 2.0) * Vm[i] ** 2 * inv_t2 - Vm[i] * Vm[j] * inv_t * (
g[br_idx] * sth - bser[br_idx] * cth
)
delta_ji = Va[j] - Va[i] + float(shift[br_idx])
c2th = ca.cos(delta_ji)
s2th = ca.sin(delta_ji)
P_ji = g[br_idx] * Vm[j] ** 2 - Vm[i] * Vm[j] * inv_t * (
g[br_idx] * c2th + bser[br_idx] * s2th
)
Q_ji = -(bser[br_idx] + bc[br_idx] / 2.0) * Vm[j] ** 2 - Vm[i] * Vm[j] * inv_t * (
g[br_idx] * s2th - bser[br_idx] * c2th
)
Pij[br_idx], Qij[br_idx], Pji[br_idx], Qji[br_idx] = P_ij, Q_ij, P_ji, Q_ji
P_out[i] += P_ij
Q_out[i] += Q_ij
P_out[j] += P_ji
Q_out[j] += Q_ji
# Generator aggregation per bus
Pg_bus = [ca.MX(0) for _ in range(n_bus)]
Qg_bus = [ca.MX(0) for _ in range(n_bus)]
for k in range(n_gen):
i = int(gen_bus[k])
Pg_bus[i] += Pg[k]
Qg_bus[i] += Qg[k]
g_expr = []
lbg = []
ubg = []
# Power balance equalities at each bus
for i in range(n_bus):
g_expr.append(Pg_bus[i] - Pd[i] - Gs[i] * (Vm[i] ** 2) - P_out[i])
lbg.append(0.0)
ubg.append(0.0)
for i in range(n_bus):
g_expr.append(Qg_bus[i] - Qd[i] + Bs[i] * (Vm[i] ** 2) - Q_out[i])
lbg.append(0.0)
ubg.append(0.0)
# Reference bus angle(s): Va = 0 for all BUS_TYPE==3 buses
ref_idxs = np.where(bus_type == 3)[0]
if len(ref_idxs) == 0:
raise RuntimeError("Network has no reference (slack) bus (BUS_TYPE==3)")
for idx in ref_idxs:
g_expr.append(Va[int(idx)])
lbg.append(0.0)
ubg.append(0.0)
# Branch apparent power constraints (both directions), only if rateA>0
for br_idx in range(n_branch):
if float(rate_pu[br_idx]) > 0:
limit2 = float(rate_pu[br_idx] ** 2)
g_expr.append(Pij[br_idx] ** 2 + Qij[br_idx] ** 2)
lbg.append(0.0)
ubg.append(limit2)
g_expr.append(Pji[br_idx] ** 2 + Qji[br_idx] ** 2)
lbg.append(0.0)
ubg.append(limit2)
# Angle difference bounds: angmin <= Va_i - Va_j <= angmax
for br_idx in range(n_branch):
g_expr.append(Va[int(f[br_idx])] - Va[int(t[br_idx])])
lbg.append(float(angmin[br_idx]))
ubg.append(float(angmax[br_idx]))
x = ca.vertcat(Vm, Va, Pg, Qg)
gvec = ca.vertcat(*g_expr)
# Variable bounds
lbx = np.concatenate([Vmin, -math.pi * np.ones(n_bus), Pmin, Qmin]).tolist()
ubx = np.concatenate([Vmax, math.pi * np.ones(n_bus), Pmax, Qmax]).tolist()
# Candidate starting points (take best objective)
x0_flat = np.concatenate(
[
np.ones(n_bus),
np.zeros(n_bus),
np.clip(Pg0, Pmin, Pmax),
np.clip(Qg0, Qmin, Qmax),
]
)
x0_bus = np.concatenate(
[
Vm0_bus,
Va0_bus,
np.clip(Pg0, Pmin, Pmax),
np.clip(Qg0, Qmin, Qmax),
]
)
for idx in ref_idxs:
x0_flat[n_bus + int(idx)] = 0.0
x0_bus[n_bus + int(idx)] = 0.0
nlp = {"x": x, "f": obj, "g": gvec}
opts = {
"ipopt.print_level": 0,
"ipopt.sb": "yes",
"ipopt.max_iter": 2000,
"ipopt.tol": 1e-7,
"ipopt.acceptable_tol": 1e-5,
"ipopt.mu_strategy": "adaptive",
"print_time": False,
}
solver = ca.nlpsol("solver", "ipopt", nlp, opts)
best_obj = None
errors = []
for x0 in (x0_bus, x0_flat):
try:
sol = solver(x0=x0.tolist(), lbx=lbx, ubx=ubx, lbg=lbg, ubg=ubg)
obj_val = float(sol["f"])
if best_obj is None or obj_val < best_obj:
best_obj = obj_val
except Exception as e: # pragma: no cover
errors.append(repr(e))
continue
if best_obj is None:
raise RuntimeError("Failed to solve ACOPF with IPOPT. Errors:\n" + "\n".join(errors[:5]))
return best_obj
# =============================================================================
# Fixtures
# =============================================================================
@pytest.fixture(scope="module")
def report():
"""Load the agent's report.json."""
assert os.path.exists(OUTPUT_FILE), f"Output file {OUTPUT_FILE} does not exist"
with open(OUTPUT_FILE, encoding="utf-8") as f:
return json.load(f)
@pytest.fixture(scope="module")
def network():
"""Load the network data."""
with open(NETWORK_FILE, encoding="utf-8") as f:
return json.load(f)
@pytest.fixture(scope="module")
def parsed_network(network):
"""Parse network into convenient data structures."""
baseMVA = network["baseMVA"]
buses = network["bus"]
gens = network["gen"]
branches = network["branch"]
gencost = network["gencost"]
n_bus = len(buses)
n_gen = len(gens)
n_branch = len(branches)
# Bus number to index mapping
bus_num_to_idx = {int(buses[i][0]): i for i in range(n_bus)}
return {
"baseMVA": baseMVA,
"buses": buses,
"gens": gens,
"branches": branches,
"gencost": gencost,
"n_bus": n_bus,
"n_gen": n_gen,
"n_branch": n_branch,
"bus_num_to_idx": bus_num_to_idx
}
@pytest.fixture(scope="module")
def acopf_benchmark_cost(network) -> float:
"""Solve ACOPF in-test and return the benchmark optimal objective value."""
return _solve_acopf_optimal_cost(network)
# =============================================================================
# Schema Validation - Verify report structure
# =============================================================================
class TestSchema:
"""Verify report has correct structure and all required fields."""
def test_top_level_fields(self, report):
"""Check all required top-level fields exist."""
required = ["summary", "generators", "buses", "most_loaded_branches", "feasibility_check"]
for field in required:
assert field in report, f"Missing top-level field: {field}"
def test_summary_fields(self, report):
"""Check summary has all required fields."""
summary = report["summary"]
required = [
"total_cost_per_hour",
"total_load_MW",
"total_load_MVAr",
"total_generation_MW",
"total_generation_MVAr",
"total_losses_MW",
"solver_status"
]
for field in required:
assert field in summary, f"Missing summary field: {field}"
def test_generator_count(self, report, parsed_network):
"""Verify all generators are reported."""
n_gen = parsed_network["n_gen"]
assert len(report["generators"]) == n_gen, \
f"Expected {n_gen} generators, got {len(report['generators'])}"
def test_generator_fields(self, report):
"""Verify each generator has required fields."""
required = ["id", "bus", "pg_MW", "qg_MVAr", "pmin_MW", "pmax_MW", "qmin_MVAr", "qmax_MVAr"]
for gen in report["generators"]:
for field in required:
assert field in gen, f"Generator {gen.get('id', '?')} missing field: {field}"
def test_bus_count(self, report, parsed_network):
"""Verify all buses are reported."""
n_bus = parsed_network["n_bus"]
assert len(report["buses"]) == n_bus, \
f"Expected {n_bus} buses, got {len(report['buses'])}"
def test_bus_fields(self, report):
"""Verify each bus has required fields."""
required = ["id", "vm_pu", "va_deg", "vmin_pu", "vmax_pu"]
for bus in report["buses"]:
for field in required:
assert field in bus, f"Bus {bus.get('id', '?')} missing field: {field}"
def test_most_loaded_branches(self, report):
"""Verify most_loaded_branches has correct structure."""
branches = report["most_loaded_branches"]
assert isinstance(branches, list), "most_loaded_branches must be a list"
assert len(branches) == 10, f"Expected 10 most loaded branches, got {len(branches)}"
required = ["from_bus", "to_bus", "loading_pct", "flow_from_MVA", "flow_to_MVA", "limit_MVA"]
for br in branches:
for field in required:
assert field in br, f"Branch missing field: {field}"
# Should be in descending order
loadings = [br["loading_pct"] for br in branches]
assert loadings == sorted(loadings, reverse=True), \
"most_loaded_branches not in descending order by loading_pct"
def test_feasibility_check_fields(self, report):
"""Verify feasibility_check has required fields."""
fc = report["feasibility_check"]
required = ["max_p_mismatch_MW", "max_q_mismatch_MVAr", "max_voltage_violation_pu", "max_branch_overload_MVA"]
for field in required:
assert field in fc, f"Missing feasibility_check field: {field}"
# =============================================================================
# Feasibility Tests - Solution must satisfy all AC constraints
# =============================================================================
class TestFeasibility:
"""Verify the solution satisfies all physical constraints."""
def test_solver_status(self, report):
"""Solver should report optimal or feasible status."""
status = report["summary"]["solver_status"].lower()
assert "optimal" in status or "acceptable" in status or "feasible" in status, \
f"Solver did not find optimal/feasible solution: {status}"
def test_voltage_bounds(self, report, parsed_network):
"""All bus voltages must be within bounds."""
buses = parsed_network["buses"]
bus_results = {b["id"]: b for b in report["buses"]}
violations = []
for bus in buses:
bus_id = int(bus[0])
vmin, vmax = bus[12], bus[11]
if bus_id not in bus_results:
violations.append(f"Bus {bus_id} not in results")
continue
vm = bus_results[bus_id]["vm_pu"]
if vm < vmin - TOL_VOLTAGE:
violations.append(f"Bus {bus_id}: Vm={vm:.4f} < Vmin={vmin}")
if vm > vmax + TOL_VOLTAGE:
violations.append(f"Bus {bus_id}: Vm={vm:.4f} > Vmax={vmax}")
assert len(violations) == 0, "Voltage violations:\n" + "\n".join(violations[:10])
def test_generator_p_bounds(self, report, parsed_network):
"""All generator active power outputs must be within bounds."""
gens = parsed_network["gens"]
violations = []
for k, gen_data in enumerate(report["generators"]):
pmin = gens[k][9]
pmax = gens[k][8]
pg = gen_data["pg_MW"]
if pg < pmin - TOL_GEN_BOUND:
violations.append(f"Gen {gen_data['id']}: Pg={pg:.2f} < Pmin={pmin:.2f}")
if pg > pmax + TOL_GEN_BOUND:
violations.append(f"Gen {gen_data['id']}: Pg={pg:.2f} > Pmax={pmax:.2f}")
assert len(violations) == 0, "Generator P violations:\n" + "\n".join(violations[:10])
def test_generator_q_bounds(self, report, parsed_network):
"""All generator reactive power outputs must be within bounds."""
gens = parsed_network["gens"]
violations = []
for k, gen_data in enumerate(report["generators"]):
qmin = gens[k][4]
qmax = gens[k][3]
qg = gen_data["qg_MVAr"]
if qg < qmin - TOL_GEN_BOUND:
violations.append(f"Gen {gen_data['id']}: Qg={qg:.2f} < Qmin={qmin:.2f}")
if qg > qmax + TOL_GEN_BOUND:
violations.append(f"Gen {gen_data['id']}: Qg={qg:.2f} > Qmax={qmax:.2f}")
assert len(violations) == 0, "Generator Q violations:\n" + "\n".join(violations[:10])
def test_reference_angle(self, report, network):
"""Reference (slack) bus angle(s) must be 0 degrees."""
buses = network["bus"]
ref_bus_ids = [int(b[0]) for b in buses if int(b[1]) == 3]
assert ref_bus_ids, "Network has no reference (slack) bus (BUS_TYPE==3)"
bus_map = {b["id"]: b for b in report["buses"]}
violations = []
for ref_bus_id in ref_bus_ids:
if ref_bus_id not in bus_map:
violations.append(f"Reference bus {ref_bus_id} missing from report")
continue
va_deg = float(bus_map[ref_bus_id]["va_deg"])
if abs(va_deg) > TOL_ANGLE_DEG:
violations.append(f"Reference bus {ref_bus_id}: Va={va_deg:.6f} deg (expected 0)")
assert not violations, "Reference angle violations:\n" + "\n".join(violations)
def test_power_balance_acopf(self, report, network):
"""
Check AC power balance using the formulation in acopf-math-model.md:
sum_g Sg - sum_d Sd - (Ys)^*|V|^2 = sum_branch_out S_ij
"""
baseMVA = float(network["baseMVA"])
buses = network["bus"]
gens = network["gen"]
branches = network["branch"]
Vm, Va, Pg, Qg, bus_id_to_idx = _extract_solution_arrays(report, network)
n_bus = len(buses)
n_gen = len(gens)
Pd_pu = np.array([b[2] for b in buses]) / baseMVA
Qd_pu = np.array([b[3] for b in buses]) / baseMVA
Gs_pu = np.array([b[4] for b in buses]) / baseMVA
Bs_pu = np.array([b[5] for b in buses]) / baseMVA
# Generator aggregation per bus (per unit)
Pg_bus = np.zeros(n_bus, dtype=float)
Qg_bus = np.zeros(n_bus, dtype=float)
for k in range(n_gen):
bus_id = int(gens[k][0])
i = bus_id_to_idx[bus_id]
Pg_bus[i] += Pg[k] / baseMVA
Qg_bus[i] += Qg[k] / baseMVA
# Branch flow aggregation per bus (per unit)
P_out = np.zeros(n_bus, dtype=float)
Q_out = np.zeros(n_bus, dtype=float)
for br in branches:
fbus = int(br[0])
tbus = int(br[1])
f = bus_id_to_idx[fbus]
t = bus_id_to_idx[tbus]
P_ij, Q_ij, P_ji, Q_ji = _compute_branch_flows_pu(Vm, Va, br, bus_id_to_idx)
P_out[f] += P_ij
Q_out[f] += Q_ij
P_out[t] += P_ji
Q_out[t] += Q_ji
P_mis = Pg_bus - Pd_pu - Gs_pu * (Vm**2) - P_out
Q_mis = Qg_bus - Qd_pu + Bs_pu * (Vm**2) - Q_out
max_p_mis = float(np.max(np.abs(P_mis)) * baseMVA)
max_q_mis = float(np.max(np.abs(Q_mis)) * baseMVA)
assert max_p_mis <= TOL_POWER_BALANCE, f"Max P mismatch {max_p_mis:.6f} MW"
assert max_q_mis <= TOL_POWER_BALANCE, f"Max Q mismatch {max_q_mis:.6f} MVAr"
def test_branch_limits_and_angle_bounds(self, report, network):
"""Verify branch |S_ij|<=rateA and angmin<=Va_i-Va_j<=angmax for all branches."""
baseMVA = float(network["baseMVA"])
branches = network["branch"]
Vm, Va, _, _, bus_id_to_idx = _extract_solution_arrays(report, network)
violations = []
for br in branches:
rateA = float(br[5])
angmin = float(br[11])
angmax = float(br[12])
fbus = int(br[0])
tbus = int(br[1])
f = bus_id_to_idx[fbus]
t = bus_id_to_idx[tbus]
delta_deg = (Va[f] - Va[t]) * 180.0 / math.pi
if delta_deg < angmin - TOL_ANGLE_DEG or delta_deg > angmax + TOL_ANGLE_DEG:
violations.append(
f"Branch {fbus}->{tbus} angle {delta_deg:.3f} not in [{angmin},{angmax}]"
)
if rateA > 0:
P_ij, Q_ij, P_ji, Q_ji = _compute_branch_flows_pu(Vm, Va, br, bus_id_to_idx)
S_ij = math.sqrt(P_ij * P_ij + Q_ij * Q_ij) * baseMVA
S_ji = math.sqrt(P_ji * P_ji + Q_ji * Q_ji) * baseMVA
if S_ij > rateA + TOL_BRANCH_FLOW or S_ji > rateA + TOL_BRANCH_FLOW:
violations.append(
f"Branch {fbus}<->{tbus} overload: "
f"|S_ij|={S_ij:.2f}, |S_ji|={S_ji:.2f} > rateA={rateA:.2f}"
)
assert not violations, "Branch violations:\n" + "\n".join(violations[:20])
def test_branch_current_limits(self, report, network):
"""
Verify branch current constraint from acopf-math-model.md:
|S_ij| <= |V_i| * i^u_ij for all (i,j) in E U E^R
This test runs only when the network JSON provides a branch current limit
field (e.g., an extra branch column described as a current limit in
network["column_info"]["branch"]).
"""
# Try to find a branch column that represents a current limit.
col_info = (network.get("column_info") or {}).get("branch") or {}
current_limit_col = None
current_limit_desc = None
for k, desc in col_info.items():
if not isinstance(desc, str):
continue
d = desc.lower()
if "current" in d or "i_max" in d or "imax" in d:
try:
current_limit_col = int(k)
current_limit_desc = desc
break
except (TypeError, ValueError):
continue
if current_limit_col is None:
pytest.skip(
"Network does not provide branch current limits; "
"skipping |S_ij| <= |V_i| * i^u_ij feasibility check."
)
# Only support per-unit current limits explicitly labeled as such.
if current_limit_desc is not None:
desc_l = current_limit_desc.lower()
is_pu = ("pu" in desc_l) or ("p.u" in desc_l) or ("per unit" in desc_l)
if not is_pu:
pytest.skip(
f"Branch current limit column found but units are unclear ({current_limit_desc!r}); "
"skipping current-limit feasibility check."
)
branches = network["branch"]
Vm, Va, _, _, bus_id_to_idx = _extract_solution_arrays(report, network)
violations = []
for br in branches:
if current_limit_col >= len(br):
violations.append(
f"Branch {int(br[0])}->{int(br[1])} missing current limit column {current_limit_col}"
)
continue
i_max_pu = float(br[current_limit_col])
if i_max_pu <= 0:
# No current limit specified for this branch.
continue
fbus = int(br[0])
tbus = int(br[1])
f = bus_id_to_idx[fbus]
t = bus_id_to_idx[tbus]
P_ij, Q_ij, P_ji, Q_ji = _compute_branch_flows_pu(Vm, Va, br, bus_id_to_idx)
S_ij_pu = math.sqrt(P_ij * P_ij + Q_ij * Q_ij)
S_ji_pu = math.sqrt(P_ji * P_ji + Q_ji * Q_ji)
# Convert |S| <= |V|*Imax into a current magnitude check: |I| = |S|/|V|.
Vm_i = float(Vm[f])
Vm_j = float(Vm[t])
if Vm_i > 1e-12:
I_ij_pu = S_ij_pu / Vm_i
if I_ij_pu > i_max_pu + 1e-3:
violations.append(
f"Branch {fbus}->{tbus} current {I_ij_pu:.6f} pu > limit {i_max_pu:.6f} pu"
)
if Vm_j > 1e-12:
I_ji_pu = S_ji_pu / Vm_j
if I_ji_pu > i_max_pu + 1e-3:
violations.append(
f"Branch {tbus}->{fbus} current {I_ji_pu:.6f} pu > limit {i_max_pu:.6f} pu"
)
assert not violations, "Branch current-limit violations:\n" + "\n".join(violations[:20])
def test_internal_consistency_generation(self, report):
"""Total generation should match sum of individual generators."""
gen_sum_p = sum(g["pg_MW"] for g in report["generators"])
gen_sum_q = sum(g["qg_MVAr"] for g in report["generators"])
reported_p = report["summary"]["total_generation_MW"]
reported_q = report["summary"]["total_generation_MVAr"]
assert abs(gen_sum_p - reported_p) < TOL_POWER_BALANCE, \
f"Generation sum {gen_sum_p:.2f} != reported {reported_p:.2f}"
assert abs(gen_sum_q - reported_q) < TOL_POWER_BALANCE, \
f"Generation sum {gen_sum_q:.2f} != reported {reported_q:.2f}"
def test_internal_consistency_losses(self, report):
"""Losses should equal generation minus load."""
summary = report["summary"]
computed_losses = summary["total_generation_MW"] - summary["total_load_MW"]
reported_losses = summary["total_losses_MW"]
assert abs(computed_losses - reported_losses) < TOL_POWER_BALANCE, \
f"Computed losses {computed_losses:.2f} != reported {reported_losses:.2f}"
def test_losses_reasonable(self, report):
"""Losses should be positive and reasonable (typically 1-5% of load)."""
summary = report["summary"]
losses = summary["total_losses_MW"]
load = summary["total_load_MW"]
assert losses > 0, f"Losses {losses:.2f} should be positive"
loss_pct = losses / load * 100
assert loss_pct < 10, f"Losses {loss_pct:.2f}% unreasonably high (>10%)"
def test_no_voltage_violations(self, report):
"""Reported voltage violation should be zero or near-zero."""
fc = report["feasibility_check"]
assert fc["max_voltage_violation_pu"] < TOL_VOLTAGE, \
f"Voltage violation {fc['max_voltage_violation_pu']:.4f} pu exceeds tolerance"
def test_no_branch_overloads(self, report):
"""Reported branch overload should be zero or near-zero."""
fc = report["feasibility_check"]
assert fc["max_branch_overload_MVA"] < TOL_BRANCH_FLOW, \
f"Branch overload {fc['max_branch_overload_MVA']:.2f} MVA exceeds tolerance"
# =============================================================================
# Optimality Test - Solution should achieve near-minimum cost
# =============================================================================
class TestOptimality:
"""Verify cost reporting is consistent and near-optimal."""
def test_cost_is_positive(self, report):
"""Generation cost should be positive."""
cost = report["summary"]["total_cost_per_hour"]
assert cost > 0, f"Cost {cost} should be positive"
def test_cost_matches_gencost(self, report, network):
"""Reported cost must match the cost computed from gencost and Pg."""
gencost = network["gencost"]
# gencost: [model, startup, shutdown, n, c2, c1, c0]
c2 = np.array([row[4] for row in gencost], dtype=float)
c1 = np.array([row[5] for row in gencost], dtype=float)
c0 = np.array([row[6] for row in gencost], dtype=float)
Pg = np.array([float(g["pg_MW"]) for g in report["generators"]], dtype=float)
computed = float(np.sum(c2 * Pg * Pg + c1 * Pg + c0))
reported = float(report["summary"]["total_cost_per_hour"])
assert abs(computed - reported) <= max(TOL_COST, 1e-6 * max(1.0, reported)), \
f"Cost mismatch: computed={computed:.6f}, reported={reported:.6f}"
def test_cost_within_10pct_of_solved_acopf(self, report, acopf_benchmark_cost):
"""
Solve the same ACOPF in-test and require the reported objective to be
within +/-10% of the benchmark objective.
"""
benchmark = float(acopf_benchmark_cost)
reported = float(report["summary"]["total_cost_per_hour"])
lower = (1.0 - TOL_OPTIMALITY_GAP_REL) * benchmark
upper = (1.0 + TOL_OPTIMALITY_GAP_REL) * benchmark
assert lower <= reported <= upper, (
f"Objective gap too large: reported={reported:.6f}, benchmark={benchmark:.6f}, "
f"allowed=[{lower:.6f}, {upper:.6f}]"
)