import argparse
import sys
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parent))
from cases import CASE_NAMES, matpower_filename
from matpower_to_pgm import load_mpc
MATPOWER_DIR = (
Path(__file__).resolve().parents[2] / "tests/data/benchmark-grids/matpower"
)
CACHE_DIR = Path(__file__).resolve().parent / ".case-cache"
BUS_I, BUS_TYPE, PD, GS, VA = 0, 1, 2, 4, 8
GEN_BUS, PG, GEN_STATUS = 0, 1, 7
F_BUS, T_BUS, BR_X, RATIO, ANGLE, BR_STATUS = 0, 1, 3, 8, 9, 10
REF = 3
def build_bdc(mpc, zero_phase_shifts=False):
bus = np.asarray(mpc["bus"], dtype=float)
branch = np.asarray(mpc["branch"], dtype=float)
ids = bus[:, BUS_I].astype(int)
pos = {b: i for i, b in enumerate(ids)}
nb, nl = len(ids), len(branch)
bf = np.zeros((nl, nb))
cft = np.zeros((nl, nb))
pfinj = np.zeros(nl)
for l, br in enumerate(branch):
status = br[BR_STATUS] if br.shape[0] > BR_STATUS else 1.0
f, t = pos[int(br[F_BUS])], pos[int(br[T_BUS])]
x = br[BR_X]
if status == 0 or x == 0.0:
continue
tap = br[RATIO] if br[RATIO] != 0 else 1.0
b = status / (x * tap)
shift = 0.0 if zero_phase_shifts else np.deg2rad(br[ANGLE])
bf[l, f], bf[l, t] = b, -b
cft[l, f], cft[l, t] = 1.0, -1.0
pfinj[l] = b * (-shift)
return ids, cft.T @ bf, bf, cft.T @ pfinj, pfinj
def specified_p(mpc, ids, ignore_gs=False):
bus = np.asarray(mpc["bus"], dtype=float)
gen = np.asarray(mpc["gen"], dtype=float)
base_mva = float(np.asarray(mpc["baseMVA"]).ravel()[0])
pos = {b: i for i, b in enumerate(ids)}
p = -bus[:, PD] / base_mva
if not ignore_gs:
p -= bus[:, GS] / base_mva
for g in gen:
if g[GEN_STATUS] > 0:
p[pos[int(g[GEN_BUS])]] += g[PG] / base_mva
return p, bus[:, BUS_TYPE].astype(int), base_mva, np.deg2rad(bus[:, VA])
def reference_angles(bbus, pbus, pbusinj, bus_type, va0):
ref = np.flatnonzero(bus_type == REF)
pvpq = np.flatnonzero(bus_type != REF)
va = va0.copy()
rhs = (pbus - pbusinj)[pvpq] - bbus[np.ix_(pvpq, ref)] @ va0[ref]
try:
va[pvpq] = np.linalg.solve(bbus[np.ix_(pvpq, pvpq)], rhs)
except np.linalg.LinAlgError:
return None
return va
def gridoxide_angles(json_path, approximation="ignore_r"):
import json
import gridoxide
with open(json_path) as f:
node_ids = [n["id"] for n in json.load(f)["data"]["node"]]
model = gridoxide.PowerFlowModel.from_pgm_json(
str(json_path), method="dc", dc_approximation=approximation
)
model.solve()
return dict(zip(node_ids, model.voltage_ang()[: len(node_ids)]))
def check(case, zero_phase_shifts, ignore_gs, approximation):
from matpower_to_pgm import convert
mpc = load_mpc(MATPOWER_DIR / matpower_filename(case))
ids, bbus, bf, pbusinj, pfinj = build_bdc(mpc, zero_phase_shifts)
pbus, bus_type, base_mva, va0 = specified_p(mpc, ids, ignore_gs)
va_ref = reference_angles(bbus, pbus, pbusinj, bus_type, va0)
if va_ref is None:
return None
CACHE_DIR.mkdir(exist_ok=True)
json_path = CACHE_DIR / f"{case}.json"
if not json_path.exists():
convert(MATPOWER_DIR / matpower_filename(case), json_path)
va_go_by_id = gridoxide_angles(json_path, approximation)
va_go = np.array([va_go_by_id[int(b)] for b in ids])
ref = int(np.flatnonzero(bus_type == REF)[0])
va_ref = va_ref - va_ref[ref]
va_go = va_go - va_go[ref]
pf_ref = (bf @ va_ref + pfinj) * base_mva
pf_go = (bf @ va_go + pfinj) * base_mva
d_ang = np.abs(va_go - va_ref)
d_flow = np.abs(pf_go - pf_ref)
return {
"buses": len(ids),
"branches": bf.shape[0],
"max_angle_deg": np.rad2deg(d_ang.max()),
"worst_bus": int(ids[int(np.argmax(d_ang))]),
"max_flow_mw": d_flow.max() if d_flow.size else 0.0,
"peak_flow_mw": np.abs(pf_ref).max() if pf_ref.size else 0.0,
"shifters": int(np.count_nonzero(np.asarray(mpc["branch"], dtype=float)[:, ANGLE])),
"gs_mw": float(np.asarray(mpc["bus"], dtype=float)[:, GS].sum()),
}
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("cases", nargs="*")
ap.add_argument("--all", action="store_true")
ap.add_argument("--zero-phase-shifts", action="store_true")
ap.add_argument("--ignore-gs", action="store_true")
ap.add_argument("--approximation", default="ignore_r",
choices=["ignore_r", "ignore_g"])
args = ap.parse_args()
cases = CASE_NAMES if args.all else args.cases
if not cases:
ap.error("name at least one case, or pass --all")
print(f"{'case':<20}{'buses':>7}{'branch':>8}{'max Δangle':>12}"
f"{'max Δflow':>12}{'peak flow':>12}{'PSTs':>6}{'ΣGs':>9}")
worst = 0.0
for case in cases:
r = check(case, args.zero_phase_shifts, args.ignore_gs, args.approximation)
if r is None:
print(f"{case:<20}{'singular reference system':>50}")
continue
worst = max(worst, r["max_flow_mw"])
print(f"{case:<20}{r['buses']:>7}{r['branches']:>8}"
f"{r['max_angle_deg']:>11.2e}°{r['max_flow_mw']:>11.2e} "
f"{r['peak_flow_mw']:>11.1f}{r['shifters']:>6}{r['gs_mw']:>9.1f}")
print(f"\nworst branch-flow disagreement across all cases: {worst:.3e} MW")
if __name__ == "__main__":
main()