diff --git a/.github/workflows/pre_commit.yaml b/.github/workflows/pre_commit.yaml index 60a00792..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 + - 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 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