From 9e3b542ce9962deb961b33a2778e1bc82c540445 Mon Sep 17 00:00:00 2001 From: tsybinae <186317713+tsybinae@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:51:21 -0700 Subject: [PATCH 1/3] AgentiGrid: correct ExaGO -> GridKit conversion, full precision in GridKit checks - from_exago: ExaGO's -save_output writes some branches with fbus/tbus swapped but keeps the tap and phase shift of the original from bus (ACTIVSg10k: 3562 of 12706 branches, 476 off-nominal transformers), so GridKit had those taps on the wrong winding and the case did not start at ExaGO's operating point (Vm jump up to 0.15 pu, machines drifting). New exago_branch_directions() keeps, for each transformer, the direction whose computed flow matches ExaGO's reported Pf/Qf; called in build_from_exago after exago_shifts_to_degrees. - from_exago: ACTIVSg10k static_gen injections kept and ExaGO load rebuilt net of them; Pg within 0.01 MW of Pmin/Pmax set to the limit and units at a Tgov1 valve limit set just inside it; machines paired with MATPOWER rows in file order. - criteria/journal/agent_loop/validation: rounded numbers are followed by their full value in brackets (num()); every check keeps all its numbers unrounded in CheckResult.data, written to the journal (including the highest final Vm and its bus, and the base-case steady-state values). - matpower_parser: read .m files with errors="replace" so non-UTF-8 bytes in comments do not reject a case. - tests: converter tests for radian shifts, static_gen, machine order, Pmin/Pmax and valve-limit snapping. Co-Authored-By: Claude Opus 5.5 --- .../exago_parsers/matpower_parser.py | 4 +- .../agentigrid/gridkit_engine/agent_loop.py | 24 ++- .../agentigrid/gridkit_engine/criteria.py | 34 ++-- .../agentigrid/gridkit_engine/from_exago.py | 156 ++++++++++++++++-- .../agentigrid/gridkit_engine/journal.py | 3 +- .../agentigrid/gridkit_engine/validation.py | 4 +- agentigrid/tests/gridkit/test_from_exago.py | 94 +++++++++++ 7 files changed, 283 insertions(+), 36 deletions(-) diff --git a/agentigrid/agentigrid/exago_parsers/matpower_parser.py b/agentigrid/agentigrid/exago_parsers/matpower_parser.py index 49576d1d..78d48f9f 100644 --- a/agentigrid/agentigrid/exago_parsers/matpower_parser.py +++ b/agentigrid/agentigrid/exago_parsers/matpower_parser.py @@ -180,7 +180,9 @@ def parse_matpower(path: Path) -> MATNetwork: if not path.exists(): raise FileNotFoundError(f"MATPOWER file not found: {path}") - text = path.read_text(encoding="utf-8") + # Exported cases may carry non-UTF-8 bytes in comments (e.g. Windows curly + # quotes in citations); replace them rather than reject the file. + text = path.read_text(encoding="utf-8", errors="replace") # --- Function name --- m = _FUNC_RE.search(text) diff --git a/agentigrid/agentigrid/gridkit_engine/agent_loop.py b/agentigrid/agentigrid/gridkit_engine/agent_loop.py index 3b350d77..67062288 100644 --- a/agentigrid/agentigrid/gridkit_engine/agent_loop.py +++ b/agentigrid/agentigrid/gridkit_engine/agent_loop.py @@ -213,6 +213,7 @@ def _run_base_case(self, case: dict, emap: dict, run_dir: Path, dt_monitor_s=self._settings.dt_monitor_s) self._print(f"[Iter {iteration}] Base case: {what}, {self._settings.tmax_s:g} s ...") res = run_one(job, self._settings) + rows = [] if res.status.solved: series = read_results(res.csv_path, emap) steady = criteria.check_steady_state( @@ -220,6 +221,11 @@ def _run_base_case(self, case: dict, emap: dict, run_dir: Path, ) ok = steady.status == "pass" text = f"GridKit {res.status.reason}. Steady state: {steady.status} - {steady.detail}." + rows = [{"bus": None, "status": steady.status, "solved": True, "run": res.status.reason, + "checks": {steady.name: {"status": steady.status, "value": steady.value, + "limit": steady.limit, "detail": steady.detail, + "data": steady.data}}, + "run_dir": str(run_dir)}] else: ok = False text = f"GridKit base case did not solve: {res.status.reason}." @@ -227,7 +233,7 @@ def _run_base_case(self, case: dict, emap: dict, run_dir: Path, text += " Fault screens are not meaningful until the base case solves and stays steady." self._print(f"[Iter {iteration}] {text}") self._journal.add(JournalEntry( - iteration, "base_case", what, verdict=text, result_text=text, + iteration, "base_case", what, verdict=text, result_text=text, rows=rows, elapsed_s=res.elapsed_s, run_dir=str(run_dir), )) return text, ok @@ -347,7 +353,7 @@ def _handle_fault_screen(self, iteration: int, data: dict) -> tuple[Optional[str cycles = study.clearing_cycles + study.margin_cycles note = (f"bus short circuit to ground at {study.start_s:g} s, cleared after {cycles:g} cycles " - f"({study.duration_s():.3f} s), R = {study.R:g} pu, X = {study.X:g} pu, " + f"({criteria.num(study.duration_s(), '.3f', ' s')}), R = {study.R:g} pu, X = {study.X:g} pu, " f"{study.tmax_s:g} s simulated, GridKit {study.application}, " f"starting from {start.label}") text = screen_verdict(self._subject(study), outcomes, note) @@ -420,18 +426,18 @@ def _exago_label(source: SteadyStateSource) -> str: def _outcome_table(outcomes: list[FaultOutcome]) -> str: """Every fault, one row: result and the main check values.""" - def val(o: FaultOutcome, name: str, fmt: str) -> str: + def val(o: FaultOutcome, name: str, fmt: str, unit: str = "", scale: float = 1.0) -> str: c = next((c for c in o.checks if c.name == name), None) if c is None or c.value is None: return "-" if c is None else c.status - return fmt.format(c.value) + return criteria.num(scale * c.value, fmt, unit) - lines = ["ALL BUS FAULTS:", - f"{'Bus':>6} | {'Result':<10} | {'Angle spread':>12} | {'Vmin 2.5s':>9} | {'Damping':>8}", - "-" * 58] + lines = ["ALL BUS FAULTS (rounded value, full value in brackets):", + f"{'Bus':>6} | {'Result':<10} | {'Angle spread':>32} | {'Vmin 2.5s':>28} | {'Damping':>28}", + "-" * 116] for o in sorted(outcomes, key=lambda o: o.bus): lines.append( - f"{o.bus:>6} | {o.status:<10} | {val(o, 'angle_stability', '{:.1f} deg'):>12} | " - f"{val(o, 'voltage_recovery', '{:.3f}'):>9} | {val(o, 'damping', '{:.1%}'):>8}" + f"{o.bus:>6} | {o.status:<10} | {val(o, 'angle_stability', '.1f', ' deg'):>32} | " + f"{val(o, 'voltage_recovery', '.3f', ' pu'):>28} | {val(o, 'damping', '.1f', '%', 100):>28}" ) return "\n".join(lines) diff --git a/agentigrid/agentigrid/gridkit_engine/criteria.py b/agentigrid/agentigrid/gridkit_engine/criteria.py index 77b90564..2b69539b 100644 --- a/agentigrid/agentigrid/gridkit_engine/criteria.py +++ b/agentigrid/agentigrid/gridkit_engine/criteria.py @@ -28,7 +28,7 @@ from __future__ import annotations import math -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Mapping, Optional, Sequence # --------------------------------------------------------------------------- @@ -105,12 +105,19 @@ class CheckResult: limit: Optional[float] detail: str source: str + data: dict = field(default_factory=dict) # every number in *detail*, unrounded (journal) @property def passed(self) -> bool: return self.status == "pass" +def num(x: float, fmt: str, unit: str = "") -> str: + """*x* rounded with *fmt* for reading, then its full value in brackets, so a + rounded 1.100 that is really 1.10036 stays visible.""" + return f"{x:{fmt}}{unit} [{float(x)!r}{unit}]" + + def _series_after(t: Sequence[float], x: Sequence[float], t0: float) -> list[tuple[float, float]]: return [(ti, xi) for ti, xi in zip(t, x) if ti >= t0] @@ -135,9 +142,10 @@ def check_angle_stability( ok = worst <= limit_deg return CheckResult( "angle_stability", "pass" if ok else "fail", worst, limit_deg, - f"largest machine angle spread {worst:.1f} deg at t = {worst_t:g} s" + f"largest machine angle spread {num(worst, '.1f', ' deg')} at t = {worst_t:g} s" + ("" if ok else " (loss of synchronism)"), src, + {"spread_deg": worst, "t_s": worst_t}, ) @@ -165,9 +173,10 @@ def check_voltage_recovery( ok = worst_v >= min_pu return CheckResult( "voltage_recovery", "pass" if ok else "fail", worst_v, min_pu, - f"lowest voltage from {after_s:g} s after clearing: {worst_v:.3f} pu " + f"lowest voltage from {after_s:g} s after clearing: {num(worst_v, '.3f', ' pu')} " f"at bus {worst_bus}, t = {worst_t:g} s", src, + {"lowest_pu": worst_v, "bus": worst_bus, "t_s": worst_t}, ) @@ -196,8 +205,9 @@ def check_voltage_envelope( bus, ti, vi, vmin = worst return CheckResult( name, "pass" if ok else "fail", vi, vmin, - f"closest to envelope: bus {bus} at t = {ti:g} s, {vi:.3f} pu vs minimum {vmin:.2f} pu", + f"closest to envelope: bus {bus} at t = {ti:g} s, {num(vi, '.3f', ' pu')} vs minimum {vmin:.2f} pu", source, + {"v_pu": vi, "bus": bus, "t_s": ti, "envelope_min_pu": vmin, "margin_pu": worst_margin}, ) @@ -216,9 +226,11 @@ def check_final_voltage( ok = finals[lo_bus] >= vmin and finals[hi_bus] <= vmax return CheckResult( "final_voltage", "pass" if ok else "fail", finals[lo_bus], vmin, - f"end of run: lowest {finals[lo_bus]:.3f} pu (bus {lo_bus}), " - f"highest {finals[hi_bus]:.3f} pu (bus {hi_bus}), band {vmin}-{vmax} pu", + f"end of run: lowest {num(finals[lo_bus], '.3f', ' pu')} (bus {lo_bus}), " + f"highest {num(finals[hi_bus], '.3f', ' pu')} (bus {hi_bus}), band {vmin}-{vmax} pu", src, + {"lowest_pu": finals[lo_bus], "lowest_bus": lo_bus, + "highest_pu": finals[hi_bus], "highest_bus": hi_bus, "vmin_pu": vmin, "vmax_pu": vmax}, ) @@ -302,11 +314,12 @@ def check_damping( f"all swings below {min_amplitude_deg} deg after clearing", src) zeta, nm = worst ok = zeta >= min_ratio and not unmeasured - detail = f"lowest damping ratio {100 * zeta:.1f}% (machine {nm})" + detail = f"lowest damping ratio {num(100 * zeta, '.1f', '%')} (machine {nm})" if unmeasured: detail += f"; not measurable for {', '.join(unmeasured[:5])}" status = "pass" if ok else ("fail" if zeta < min_ratio else "not_tested") - return CheckResult("damping", status, zeta, min_ratio, detail, src) + return CheckResult("damping", status, zeta, min_ratio, detail, src, + {"ratio": zeta, "machine": nm, "unmeasured": unmeasured}) # Pre-fault steady state: the no-fault run of the case copy must stay still, @@ -341,9 +354,10 @@ def check_steady_state( ok = angle_move <= angle_tol_deg and v_move <= v_tol_pu return CheckResult( "steady_state", "pass" if ok else "fail", angle_move, angle_tol_deg, - f"without a fault: angles move {angle_move:.3f} deg (limit {angle_tol_deg}), " - f"voltages move {v_move:.4f} pu (limit {v_tol_pu})", + f"without a fault: angles move {num(angle_move, '.3f', ' deg')} (limit {angle_tol_deg}), " + f"voltages move {num(v_move, '.4f', ' pu')} (limit {v_tol_pu})", src, + {"angle_move_deg": angle_move, "v_move_pu": v_move}, ) # PJM tests the agent does not run, because GridKit lacks the element they diff --git a/agentigrid/agentigrid/gridkit_engine/from_exago.py b/agentigrid/agentigrid/gridkit_engine/from_exago.py index 246464b7..d0c5cd72 100644 --- a/agentigrid/agentigrid/gridkit_engine/from_exago.py +++ b/agentigrid/agentigrid/gridkit_engine/from_exago.py @@ -120,14 +120,47 @@ def _signal_ports(device: dict) -> set: return {v for k, v in device.get("ports", {}).items() if not _is_bus_port(k)} -def _machine_unit(device: dict) -> str: - """Machine id within its bus ('49_1_genrou' -> '1'), for ordering.""" - parts = str(device.get("id", "")).split("_") - return parts[1] if len(parts) > 2 else str(device.get("id", "")) +# GridKit cases whose renewables are "_static_gen_" LoadZIP injections +# (no dynamic model). The derived ExaGO case carries them as negative load. +STATIC_GEN_CASES = {"ACTIVSg10k"} +# A dispatch this close to Pmin/Pmax is taken as at the limit (ExaGO -save_output +# writes Pg with 2 decimals). +PG_LIMIT_SNAP_MW = 0.01 -def _unit_key(unit: str): - return (0, int(unit), "") if unit.isdigit() else (1, 0, unit) + +def static_gen_loads(case: dict, loads: list[dict]) -> list[dict]: + """The renewable injections to keep unchanged; empty unless *case* is in + STATIC_GEN_CASES. They are fixed at the case's own output, which the ExaGO + case netted from its load, so only the ordinary load is rebuilt from ExaGO + and each keeps its own load model (constant current vs. impedance).""" + if case.get("header", {}).get("case_name") not in STATIC_GEN_CASES: + return [] + return [d for d in loads if "_static_gen_" in d["id"]] + + +def _snap_to_valve_limits(devices: list[dict], base: float) -> list[str]: + """Machines at a Tgov1 valve limit, set exactly inside it. + + Tgov1 starts from valve position p0 * base / Trate and rejects it outside + [Pvmin, Pvmax] with a 4-epsilon tolerance. Its stored limits differ from the + MATPOWER Pmin/Pmax in the last digits, so a unit dispatched at Pmax can + fail; within PG_LIMIT_SNAP_MW of a limit it is set 1e-10 (relative) inside. + """ + machines = {d["ports"].get("pmech"): d for d in devices if d["class"] in MACHINE_CLASSES} + snapped = [] + for gov in devices: + if gov["class"] != "Tgov1" or gov["ports"].get("pmech") not in machines: + continue + m, p = machines[gov["ports"]["pmech"]], gov["params"] + pg = m["params"]["p0"] * base + for limit, inward in ((p["Pvmax"], -1e-10), (p["Pvmin"], 1e-10)): + mw = limit * p["Trate"] + if abs(pg - mw) < PG_LIMIT_SNAP_MW: + m["params"]["p0"] = mw * (1.0 + inward) / base + snapped.append(m["id"]) + break + return snapped def apply_steady_state(case: dict, net: MATNetwork) -> tuple[dict, list[str]]: @@ -169,11 +202,14 @@ def apply_steady_state(case: dict, net: MATNetwork) -> tuple[dict, list[str]]: for d in devices: if d["class"] in MACHINE_CLASSES: machines_at[d["ports"]["bus"]].append(d) - no_model, ambiguous, removed_machines = [], [], [] + no_model, ambiguous, removed_machines, at_limit = [], [], [], [] for bus in sorted(set(gens_at) | set(machines_at)): gens = gens_at.get(bus, []) online = [g for g in gens if g.status] - machines = sorted(machines_at.get(bus, []), key=lambda d: _unit_key(_machine_unit(d))) + # Machines pair with the MATPOWER rows in file order: both files list a + # bus's units in the source order (ACTIVSg10k: 1, 10, 2, ... 9), which a + # numeric sort of the unit ids would break at buses with 10+ units. + machines = machines_at.get(bus, []) if len(online) > len(machines): no_model.append(bus) continue @@ -190,13 +226,28 @@ def apply_steady_state(case: dict, net: MATNetwork) -> tuple[dict, list[str]]: if g is None or not g.status: removed_machines.append(m) else: - m["params"]["p0"] = g.Pg / base + # ExaGO writes Pg to 2 decimals; a unit dispatched at Pmin/Pmax + # can land just outside it, which GridKit's governors reject. + pg = g.Pg + for limit in (g.Pmax, g.Pmin): + if pg != limit and abs(pg - limit) < PG_LIMIT_SNAP_MW: + at_limit.append(m["id"]) + pg = limit + break + m["params"]["p0"] = pg / base m["params"]["q0"] = g.Qg / base if no_model: raise SteadyStateError( f"Online ExaGO generator(s) at bus(es) {no_model} have no dynamic model " "in the GridKit case; GridKit cannot simulate them." ) + if at_limit: + notes.append(f"{len(at_limit)} machine(s) within {PG_LIMIT_SNAP_MW} MW of Pmin/Pmax set " + f"to the limit (ExaGO's output rounding): {', '.join(at_limit[:10])}") + at_valve = _snap_to_valve_limits(devices, base) + if at_valve: + notes.append(f"{len(at_valve)} machine(s) within {PG_LIMIT_SNAP_MW} MW of their Tgov1 valve " + f"limit set just inside it: {', '.join(at_valve[:10])}") if ambiguous: raise SteadyStateError( f"Cannot match ExaGO generators to GridKit machines at bus(es) {ambiguous} " @@ -219,6 +270,16 @@ def apply_steady_state(case: dict, net: MATNetwork) -> tuple[dict, list[str]]: # --- loads and shunts -------------------------------------------------- old_loads = [d for d in devices if d["class"] == "LoadZIP"] + static = static_gen_loads(case, old_loads) + injection: dict[int, list[float]] = defaultdict(lambda: [0.0, 0.0]) # MW, MVAr + for d in static: + injection[d["ports"]["bus"]][0] += d["params"]["Pnom"] * base + injection[d["ports"]["bus"]][1] += d["params"]["Qnom"] * base + if static: + static_ids = {id(d) for d in static} + old_loads = [d for d in old_loads if id(d) not in static_ids] + notes.append(f"{len(static)} static_gen injection(s) at {len(injection)} bus(es) kept " + "unchanged; ExaGO load at those buses rebuilt net of them.") nonz = sorted({d["ports"]["bus"] for d in old_loads if d["params"].get("alphaI", 0) or d["params"].get("alphaP", 0)}) if nonz: @@ -228,14 +289,19 @@ def apply_steady_state(case: dict, net: MATNetwork) -> tuple[dict, list[str]]: alphas = {d["ports"]["bus"]: (d["params"].get("alphaI", 0.0), d["params"].get("alphaP", 0.0)) for d in old_loads} load_ids = {d["ports"]["bus"]: d["id"] for d in old_loads} - devices[:] = [d for d in devices if d["class"] != "LoadZIP"] + devices[:] = [d for d in devices if d["class"] != "LoadZIP"] + static n_shunts = 0 for n, b in sorted(buses.items()): - if b.Pd or b.Qd: + # ExaGO's Pd/Qd is net of any kept injection, so add it back. + pd = b.Pd - injection[n][0] if n in injection else b.Pd + qd = b.Qd - injection[n][1] if n in injection else b.Qd + if n in injection and n not in load_ids and abs(pd) < 0.05 and abs(qd) < 0.05: + pd = qd = 0.0 # rounding left over from netting; the bus has no load + if pd or qd: ai, ap = alphas.get(n, (0.0, 0.0)) devices.append({"class": "LoadZIP", "id": load_ids.get(n, f"load_{n}"), "ports": {"bus": n}, - "params": {"Pnom": b.Pd / base, "Qnom": b.Qd / base, + "params": {"Pnom": pd / base, "Qnom": qd / base, "alphaI": ai, "alphaP": ap}}) if b.Gs or b.Bs: vm2 = b.Vm * b.Vm # Pnom/Qnom are taken at the initial voltage @@ -282,7 +348,71 @@ def apply_steady_state(case: dict, net: MATNetwork) -> tuple[dict, list[str]]: return out, notes +def exago_shifts_to_degrees(net: MATNetwork) -> list[str]: + """Convert the branch phase shifts of an ExaGO saved solution to degrees. + + ExaGO reads MATPOWER shift angles in degrees and converts them to radians + (psreaddata.cpp), but -save_output writes them back still in radians + (psoutput.cpp), so opflowout.m/pflowout.m break the MATPOWER convention. + Bus angles (Va) are written in degrees and are left as they are. + """ + shifted = [br for br in net.branches if br.angle] + for br in shifted: + br.angle = math.degrees(br.angle) + if not shifted: + return [] + return [f"{len(shifted)} phase shift(s) in ExaGO's solution converted from radians to degrees: " + + ", ".join(f"{br.fbus}-{br.tbus} {br.angle:.3f} deg [{br.angle!r} deg]" for br in shifted[:10])] + + +def _end_flows(br, vf: complex, vt: complex, base: float) -> tuple[complex, complex]: + """MVA entering *br* at fbus with the tap and phase shift on the fbus side, + and at fbus if they were on the tbus side instead.""" + ys = 1.0 / complex(br.r, br.x) + ysh = 0.5j * br.b + tap = (br.ratio or 1.0) * complex(math.cos(math.radians(br.angle)), math.sin(math.radians(br.angle))) + i_tap_here = (ys + ysh) / abs(tap) ** 2 * vf - ys / tap.conjugate() * vt + i_tap_there = (ys + ysh) * vf - ys / tap * vt + return vf * i_tap_here.conjugate() * base, vf * i_tap_there.conjugate() * base + + +def exago_branch_directions(net: MATNetwork) -> list[str]: + """Put back the from/to buses of transformers that ExaGO wrote reversed. + + ExaGO's -save_output (psoutput.cpp) writes some branches with fbus and tbus + swapped but keeps the tap ratio and phase shift, which belong to the + original from bus, so a reader puts them on the wrong winding (ACTIVSg10k: + 3562 of 12706 branches, 476 of them off-nominal transformers). Its reported + flows are right for the real direction, so for each transformer the + direction whose computed flow matches the reported Pf/Qf at fbus is kept. + Run after exago_shifts_to_degrees. Lines (tap 1, no shift) are the same + either way and are left as written. + """ + buses = {b.bus_i: b.Vm * complex(math.cos(math.radians(b.Va)), math.sin(math.radians(b.Va))) + for b in net.buses} + base = net.baseMVA + flipped = [] + for br in net.branches: + if not br.status or ((br.ratio in (0.0, 1.0)) and not br.angle) or len(br.extra) < 2: + continue + reported = complex(br.extra[0], br.extra[1]) + as_written, reversed_ = (abs(s - reported) for s in + _end_flows(br, buses[br.fbus], buses[br.tbus], base)) + if reversed_ < as_written: + br.fbus, br.tbus = br.tbus, br.fbus + br.extra[:4] = br.extra[2:4] + br.extra[:2] + flipped.append(br) + if not flipped: + return [] + return [f"{len(flipped)} transformer(s) written by ExaGO with from/to reversed put back in " + "their real direction (tap on the right side): " + + ", ".join(f"{br.fbus}-{br.tbus} tap {br.ratio:g}" for br in flipped[:10])] + + def build_from_exago(case: dict, source: SteadyStateSource) -> tuple[dict, list[str]]: """Read *source*'s solved case and apply it to the GridKit *case*.""" net = parse_matpower(source.solved_file) - return apply_steady_state(case, net) + notes = exago_shifts_to_degrees(net) + notes += exago_branch_directions(net) + out, more = apply_steady_state(case, net) + return out, notes + more diff --git a/agentigrid/agentigrid/gridkit_engine/journal.py b/agentigrid/agentigrid/gridkit_engine/journal.py index fa0813b4..96b6d582 100644 --- a/agentigrid/agentigrid/gridkit_engine/journal.py +++ b/agentigrid/agentigrid/gridkit_engine/journal.py @@ -34,7 +34,8 @@ def outcome_row(o: FaultOutcome, run_dir: Path | None = None) -> dict: "solved": o.run.solved, "run": o.run.reason, "checks": { - c.name: {"status": c.status, "value": c.value, "limit": c.limit, "detail": c.detail} + c.name: {"status": c.status, "value": c.value, "limit": c.limit, "detail": c.detail, + "data": c.data} for c in o.checks }, "run_dir": str(run_dir) if run_dir else None, diff --git a/agentigrid/agentigrid/gridkit_engine/validation.py b/agentigrid/agentigrid/gridkit_engine/validation.py index 4b11e625..897c8f09 100644 --- a/agentigrid/agentigrid/gridkit_engine/validation.py +++ b/agentigrid/agentigrid/gridkit_engine/validation.py @@ -100,13 +100,13 @@ def validate_fault_study( if study.clearing_cycles <= 0 or study.margin_cycles < 0: errors.append("clearing_cycles must be positive and margin_cycles not negative") if clear >= study.tmax_s: - errors.append(f"Fault clears at {clear:.3f} s, not before tmax_s = {study.tmax_s} s") + errors.append(f"Fault clears at {criteria.num(clear, '.3f', ' s')}, not before tmax_s = {study.tmax_s} s") if study.dt_monitor_s <= 0: errors.append("dt_monitor_s must be positive") elif study.dt_monitor_s > duration / 2: warnings.append( f"dt_monitor_s = {study.dt_monitor_s} s records fewer than two points during the " - f"{duration:.3f} s fault" + f"{criteria.num(duration, '.3f', ' s')} fault" ) unknown = sorted(set(study.checks) - set(CHECKS)) diff --git a/agentigrid/tests/gridkit/test_from_exago.py b/agentigrid/tests/gridkit/test_from_exago.py index 74427fb5..e835230e 100644 --- a/agentigrid/tests/gridkit/test_from_exago.py +++ b/agentigrid/tests/gridkit/test_from_exago.py @@ -2,6 +2,7 @@ from __future__ import annotations +import copy import json import math from pathlib import Path @@ -13,6 +14,7 @@ from agentigrid.gridkit_engine.from_exago import ( SteadyStateError, apply_steady_state, + exago_shifts_to_degrees, find_latest_steady_state, goal_wants_steady_state, ) @@ -178,6 +180,98 @@ def test_shunts_and_branches_from_exago(pair): assert any("64-82" in n for n in notes) +@needs_cases +def test_exago_phase_shifts_read_as_radians(pair): + # ExaGO's saved solution writes shift angles in radians; GridKit wants radians. + case, net = pair + br = net.branches[0] + br.angle = math.radians(-12.0) + notes = exago_shifts_to_degrees(net) + assert br.angle == pytest.approx(-12.0) and "radians to degrees" in notes[0] + out, _ = apply_steady_state(case, net) + phases = [d["params"]["phase"] for d in _devices(out, "Branch") if d["params"]["phase"]] + assert phases == [pytest.approx(math.radians(-12.0))] + + +def _with_static_gen(case, net, name): + # A 5 MW renewable at bus 2, as GridKit's 10k case writes it; ExaGO nets it from Pd. + case["header"]["case_name"] = name + case["devices"].append({"class": "LoadZIP", "id": "load_2_static_gen_1", "ports": {"bus": 2}, + "params": {"Pnom": -0.05, "Qnom": -0.01, "alphaI": 1.0, "alphaP": 0.0}}) + b = next(b for b in net.buses if b.bus_i == 2) + b.Pd -= 5.0 + b.Qd -= 1.0 + return b + + +@needs_cases +def test_static_gen_kept_for_10k_case(pair): + case, net = pair + _with_static_gen(case, net, "ACTIVSg10k") + out, notes = apply_steady_state(case, net) + at2 = {d["id"]: d["params"] for d in _devices(out, "LoadZIP") if d["ports"]["bus"] == 2} + assert at2["load_2_static_gen_1"] == {"Pnom": -0.05, "Qnom": -0.01, "alphaI": 1.0, "alphaP": 0.0} + assert at2["load_2"]["Pnom"] == pytest.approx(7.39 / 100) and at2["load_2"]["alphaI"] == 0.0 + assert any("static_gen" in n for n in notes) + + +@needs_cases +def test_static_gen_rule_off_for_other_cases(pair): + case, net = pair + b = _with_static_gen(case, net, "ACTIVSg200") + out, notes = apply_steady_state(case, net) + at2 = [d for d in _devices(out, "LoadZIP") if d["ports"]["bus"] == 2] + assert [d["id"] for d in at2] == ["load_2"] or [d["id"] for d in at2] == ["load_2_static_gen_1"] + assert at2[0]["params"]["Pnom"] == pytest.approx(b.Pd / 100) + assert not any("static_gen" in n for n in notes) + + +@needs_cases +def test_machines_pair_in_file_order(pair): + # ACTIVSg10k lists a bus's units as 1, 10, 2, ...; pairing must not re-sort them. + case, net = pair + gen = next(g for g in net.generators if g.bus == 135 and g.status) + m1 = next(d for d in _devices(case, "Genrou") if d["ports"]["bus"] == 135) + m10 = copy.deepcopy(m1) + m10["id"] = m1["id"].replace("_1_", "_10_", 1) + case["devices"].insert(case["devices"].index(m1) + 1, m10) + second = copy.deepcopy(gen) + second.Pg = 12.34 + net.generators.insert(net.generators.index(gen) + 1, second) + out, _ = apply_steady_state(case, net) + p0 = {d["id"]: d["params"]["p0"] for d in _devices(out, "Genrou") if d["ports"]["bus"] == 135} + assert p0[m1["id"]] == pytest.approx(gen.Pg / 100) + assert p0[m10["id"]] == pytest.approx(12.34 / 100) + + +@needs_cases +@pytest.mark.parametrize("offset,snapped", [(0.005, True), (-0.005, True), (0.05, False)]) +def test_dispatch_at_limit_snapped(pair, offset, snapped): + case, net = pair + gen = next(g for g in net.generators if g.bus == 135 and g.status) + gen.Pg = gen.Pmax + offset + out, notes = apply_steady_state(case, net) + m = next(d for d in _devices(out, "Genrou") if d["ports"]["bus"] == 135) + assert m["params"]["p0"] == pytest.approx((gen.Pmax if snapped else gen.Pg) / 100) + assert any("set to the limit" in n for n in notes) == snapped + + +@needs_cases +def test_dispatch_at_valve_limit_set_inside(pair): + # Tgov1 rejects a start outside [Pvmin, Pvmax] with a 4-epsilon tolerance. + case, net = pair + gov = next(d for d in _devices(case, "Tgov1")) + m = next(d for d in case["devices"] if d["class"] in MACHINE_CLASSES + and d["ports"].get("pmech") == gov["ports"]["pmech"]) + gen = next(g for g in net.generators if g.bus == m["ports"]["bus"] and g.status) + gen.Pg = gov["params"]["Pvmax"] * gov["params"]["Trate"] + 0.004 + out, notes = apply_steady_state(case, net) + p0 = next(d for d in out["devices"] if d["id"] == m["id"])["params"]["p0"] + assert p0 * 100 / gov["params"]["Trate"] <= gov["params"]["Pvmax"] + assert p0 * 100 == pytest.approx(gov["params"]["Pvmax"] * gov["params"]["Trate"]) + assert any("valve limit" in n for n in notes) + + @needs_cases def test_generator_without_model_is_an_error(pair): case, net = pair From 0066e516743d84a49e3c741a65a6b054e2af7489 Mon Sep 17 00:00:00 2001 From: pelesh Date: Tue, 20 Oct 2026 22:23:32 -0400 Subject: [PATCH 2/3] Use python 3.13 This is quick and dirty fix, it seems `cmake-format` is not compatible with python 3.15. --- .github/workflows/pre_commit.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pre_commit.yaml b/.github/workflows/pre_commit.yaml index 60a00792..13ca5cb0 100644 --- a/.github/workflows/pre_commit.yaml +++ b/.github/workflows/pre_commit.yaml @@ -10,7 +10,7 @@ jobs: - uses: actions/checkout@v3 with: ref: ${{ github.event.pull_request.head.ref }} - - uses: actions/setup-python@v3 + - uses: actions/setup-python@v3.13 - uses: pre-commit/action@v3.0.0 - uses: EndBug/add-and-commit@v9.1.3 # Only need to try and commit if the action failed From 9d87f6c2f0550b404bab52e8ddb2a1e925e8d292 Mon Sep 17 00:00:00 2001 From: pelesh Date: Tue, 20 Oct 2026 22:55:55 -0400 Subject: [PATCH 3/3] Update pre_commit.yaml Try python 3.14 --- .github/workflows/pre_commit.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pre_commit.yaml b/.github/workflows/pre_commit.yaml index 13ca5cb0..1a19fdf0 100644 --- a/.github/workflows/pre_commit.yaml +++ b/.github/workflows/pre_commit.yaml @@ -10,7 +10,7 @@ jobs: - uses: actions/checkout@v3 with: ref: ${{ github.event.pull_request.head.ref }} - - uses: actions/setup-python@v3.13 + - uses: actions/setup-python@v3.14 - uses: pre-commit/action@v3.0.0 - uses: EndBug/add-and-commit@v9.1.3 # Only need to try and commit if the action failed