import sys
import time
import jax
jax.config.update("jax_enable_x64", True)
import jax.numpy as jnp import numpy as np
import gridoxide
SLACK, PV, PQ = 0, 1, 2
def load_case(path):
model = gridoxide.PowerFlowModel.from_pgm_json(path, backend="klu")
zips = model.zip_term_counts()
if any(z != 0 for z in zips):
raise SystemExit(
f"{sum(1 for z in zips if z)} bus(es) carry ZIP terms; this oracle models "
"constant-power injections only and would silently give a wrong answer"
)
rows, cols, g, b = model.ybus_triplets()
n = model.n_nodes
ybus = np.zeros((n, n), dtype=np.complex128)
ybus[np.asarray(rows), np.asarray(cols)] = np.asarray(g) + 1j * np.asarray(b)
kinds, p_spec, q_spec = model.bus_spec()
kinds = np.asarray(kinds, dtype=np.int64)
if kinds.shape != (n,):
raise SystemExit(f"bus_spec returned kinds of shape {kinds.shape}, expected ({n},)")
if not set(np.unique(kinds)) <= {SLACK, PV, PQ}:
raise SystemExit(f"unexpected bus-type codes: {np.unique(kinds)}")
vm0, va0 = model.initial_guess()
return model, {
"ybus": jnp.asarray(ybus),
"kind": kinds,
"p_spec": jnp.asarray(p_spec),
"q_spec": jnp.asarray(q_spec),
"vm0": jnp.asarray(vm0),
"va0": jnp.asarray(va0),
}
def power_injections(vm, va, ybus):
v = vm * jnp.exp(1j * va)
s = v * jnp.conj(ybus @ v)
return jnp.real(s), jnp.imag(s)
def jacobian(vm, va, ybus, p_calc, q_calc, non_slack, pq):
g, b = jnp.real(ybus), jnp.imag(ybus)
ang = va[:, None] - va[None, :]
sin, cos = jnp.sin(ang), jnp.cos(ang)
vv = vm[:, None] * vm[None, :]
h = vv * (g * sin - b * cos)
n = vm[:, None] * (g * cos + b * sin)
m = -vv * (g * cos + b * sin)
ll = vm[:, None] * (g * sin - b * cos)
d = jnp.arange(vm.shape[0])
h = h.at[d, d].set(-q_calc - vm**2 * jnp.diag(b))
n = n.at[d, d].set(p_calc / vm + vm * jnp.diag(g))
m = m.at[d, d].set(p_calc - vm**2 * jnp.diag(g))
ll = ll.at[d, d].set(q_calc / vm - vm * jnp.diag(b))
return jnp.block(
[
[h[jnp.ix_(non_slack, non_slack)], n[jnp.ix_(non_slack, pq)]],
[m[jnp.ix_(pq, non_slack)], ll[jnp.ix_(pq, pq)]],
]
)
def mismatch(vm, va, ybus, p_spec, q_spec, non_slack, pq):
p_calc, q_calc = power_injections(vm, va, ybus)
return (
jnp.concatenate([(p_spec - p_calc)[non_slack], (q_spec - q_calc)[pq]]),
p_calc,
q_calc,
)
def newton(case, p_spec, q_spec, tol=1e-6, max_iter=20, solve_fn=None):
ybus, kind = case["ybus"], case["kind"]
non_slack = jnp.asarray(np.flatnonzero(kind != SLACK))
pq = jnp.asarray(np.flatnonzero(kind == PQ))
n_ang = int(non_slack.shape[0])
vm, va = case["vm0"], case["va0"]
solve_fn = solve_fn or (lambda j, f: jnp.linalg.solve(j, f))
for it in range(max_iter):
f, p_calc, q_calc = mismatch(vm, va, ybus, p_spec, q_spec, non_slack, pq)
if float(jnp.max(jnp.abs(f))) < tol:
return vm, va, it + 1, True
j = jacobian(vm, va, ybus, p_calc, q_calc, non_slack, pq)
dx = solve_fn(j, f)
va = va.at[non_slack].add(dx[:n_ang])
vm = vm.at[pq].add(dx[n_ang:])
return vm, va, max_iter, False
def solve_batch_vmap(case, scenarios, tol=1e-6, max_iter=20):
return [newton(case, p, q, tol, max_iter) for p, q in scenarios]
def solve_batch_bde(case, scenarios, tol=1e-6, max_iter=20):
ybus, kind = case["ybus"], case["kind"]
non_slack = jnp.asarray(np.flatnonzero(kind != SLACK))
pq = jnp.asarray(np.flatnonzero(kind == PQ))
n_ang = int(non_slack.shape[0])
nb = len(scenarios)
blk = n_ang + int(pq.shape[0])
vms = [case["vm0"]] * nb
vas = [case["va0"]] * nb
active = [True] * nb
iters = [max_iter] * nb
converged = [False] * nb
for it in range(max_iter):
blocks, rhs, live = [], [], []
for k in range(nb):
if not active[k]:
continue
p_spec, q_spec = scenarios[k]
f, p_calc, q_calc = mismatch(vms[k], vas[k], ybus, p_spec, q_spec, non_slack, pq)
if float(jnp.max(jnp.abs(f))) < tol:
active[k], converged[k], iters[k] = False, True, it + 1
continue
blocks.append(jacobian(vms[k], vas[k], ybus, p_calc, q_calc, non_slack, pq))
rhs.append(f)
live.append(k)
if not live:
break
big_j = jax.scipy.linalg.block_diag(*blocks)
big_f = jnp.concatenate(rhs)
big_dx = jnp.linalg.solve(big_j, big_f)
for slot, k in enumerate(live):
dx = big_dx[slot * blk : (slot + 1) * blk]
vas[k] = vas[k].at[non_slack].add(dx[:n_ang])
vms[k] = vms[k].at[pq].add(dx[n_ang:])
return [(vms[k], vas[k], iters[k], converged[k]) for k in range(nb)]
def cap_scenarios(case, n_scen, budget_gb=1.0):
kind = case["kind"]
blk = int(np.sum(kind != SLACK) + np.sum(kind == PQ))
per = (blk**2) * 8 / 1e9
if per <= 0:
return n_scen, blk
max_b = max(1, int((budget_gb / per) ** 0.5))
return min(n_scen, max_b), blk
def make_scenarios(case, n, seed=20260726):
rng = np.random.default_rng(seed)
return [
(case["p_spec"] * f, case["q_spec"] * f)
for f in rng.uniform(0.8, 1.2, size=n)
]
def main():
path = sys.argv[1]
n_scen = int(sys.argv[2]) if len(sys.argv) > 2 else 8
model, case = load_case(path)
n = model.n_nodes
print(f"case: {path}")
print(f"buses: {n} scenarios: {n_scen} dtype: {case['ybus'].dtype}")
assert case["ybus"].dtype == jnp.complex128, "x64 not enabled — results would be meaningless"
print()
model.solve()
ref_vm = np.asarray(model.voltage_mag())
ref_va = np.asarray(model.voltage_ang())
t0 = time.perf_counter()
vm, va, iters, ok = newton(case, case["p_spec"], case["q_spec"])
t1 = time.perf_counter()
if not ok:
raise SystemExit("oracle did not converge on the base case")
d_vm = float(np.max(np.abs(np.asarray(vm) - ref_vm)))
dva = np.asarray(va) - ref_va
d_va = float(np.max(np.abs(np.arctan2(np.sin(dva), np.cos(dva)))))
print("1. oracle vs gridoxide klu, base case")
print(f" oracle converged in {iters} iters ({(t1 - t0) * 1e3:.1f} ms)")
print(f" max |dVm| = {d_vm:.3e}")
print(f" max |dVa| = {d_va:.3e} rad")
base_ok = d_vm < 1e-8 and d_va < 1e-8
print(f" {'PASS' if base_ok else 'FAIL'} (tolerance 1e-8)")
print()
capped, blk = cap_scenarios(case, n_scen)
if capped < n_scen:
print(f" (block size {blk}; capping {n_scen} -> {capped} scenarios to bound the dense solve)")
n_scen = capped
scenarios = make_scenarios(case, n_scen)
t0 = time.perf_counter()
indep = solve_batch_vmap(case, scenarios)
t1 = time.perf_counter()
bde = solve_batch_bde(case, scenarios)
t2 = time.perf_counter()
worst_vm = worst_va = 0.0
iter_mismatch = 0
for (a_vm, a_va, a_it, a_ok), (b_vm, b_va, b_it, b_ok) in zip(indep, bde):
if not (a_ok and b_ok):
raise SystemExit("a scenario failed to converge in one of the two paths")
worst_vm = max(worst_vm, float(np.max(np.abs(np.asarray(a_vm) - np.asarray(b_vm)))))
worst_va = max(worst_va, float(np.max(np.abs(np.asarray(a_va) - np.asarray(b_va)))))
iter_mismatch += a_it != b_it
print("2. block-diagonal embedding vs independent solves")
print(f" independent: {(t1 - t0) * 1e3:.1f} ms embedded: {(t2 - t1) * 1e3:.1f} ms")
print(f" max |dVm| = {worst_vm:.3e}")
print(f" max |dVa| = {worst_va:.3e} rad")
print(f" iteration-count mismatches: {iter_mismatch}/{n_scen}")
bde_ok = worst_vm < 1e-12 and worst_va < 1e-12 and iter_mismatch == 0
print(f" {'PASS' if bde_ok else 'FAIL'} (tolerance 1e-12)")
print()
scales = list(np.random.default_rng(20260726).uniform(0.8, 1.2, size=n_scen))
got = model.solve_batch_scaled(scales)
worst = 0.0
for r, (o_vm, _, _, _) in zip(got, bde):
worst = max(worst, float(np.max(np.abs(np.asarray(r.voltage_mag) - np.asarray(o_vm)))))
print("3. oracle batch vs gridoxide BatchSolver")
print(f" max |dVm| = {worst:.3e}")
batch_ok = worst < 1e-8
print(f" {'PASS' if batch_ok else 'FAIL'} (tolerance 1e-8)")
print()
all_ok = base_ok and bde_ok and batch_ok
print("RESULT:", "PASS" if all_ok else "FAIL")
return 0 if all_ok else 1
if __name__ == "__main__":
sys.exit(main())