github-actions[bot]
Deploy f7b8bf6
1c730d1
Raw
History Blame Contribute Delete
10.2 kB
# Copyright (c) 2025-2026, RTE (https://www.rte-france.com)
# SPDX-License-Identifier: MPL-2.0
"""Stage 2 of the France RTE Matpower game dataset: difficulty-grade the
non-antenna constraining N-1 contingencies of a built grid, driving the
Co-Study4Grid recommender offline.
RESUMABLE: each graded scenario is appended to ``graded.jsonl`` as it is
computed; a re-run skips contingencies already present, so a machine restart
continues where it stopped.
Difficulty (mirrors RTE7000 THT):
easy - a suggested unitary action resolves every contingency-attributable
overload;
medium - no unitary resolves, but a first-identified superposition pair does;
hard - neither.
Per the Co-Study4Grid N-overload rule, base overloads NOT worsened by the
contingency are not counted for resolution (base-relative).
Run: python scripts/game_mode/matpower/grade.py <gridId> [--limit N] [--time]
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
from expert_backend.main import ConfigRequest # noqa: E402
from expert_backend.services.network_service import network_service # noqa: E402
from expert_backend.services.recommender_service import recommender_service # noqa: E402
REPO = Path(__file__).resolve().parents[3]
DATA = REPO / "data" / "rte_matpower"
# Matpower networks are bus-branch: no couplers/node topology, so the action
# space is line disconnection + injection actions. Coupling minima -> 0.
CONFIG_MINIMA = dict(
min_line_reconnections=0.0, min_close_coupling=0.0, min_open_coupling=0.0,
min_line_disconnections=3.0, min_pst=1.0, min_load_shedding=2.0,
min_renewable_curtailment_actions=2, min_redispatch=2,
n_prioritized_actions=15, monitoring_factor=0.95,
pre_existing_overload_threshold=0.02, ignore_reconnections=False,
pypowsybl_fast_mode=True,
)
def configure(grid_dir: Path):
# `update_config` auto-generates a disco_ action per line and WRITES THEM
# BACK into the action file (recommender_service ~L592). Pointing it at the
# committed actions.json would bloat that source ~8x (curated open_coupler
# ~450 KB -> ~3.7 MB of derived disco_) and dirty the tree on every grade.
# So the backend writes to a throwaway runtime copy instead, seeded from
# the curated file; the committed actions.json stays pristine. The runtime
# copy lives under data/ (gitignored) and is reused across contingencies.
actions = grid_dir / "actions.json"
runtime = grid_dir / "actions.runtime.json"
runtime.write_text(actions.read_text() if actions.exists() else "{}")
cfg = ConfigRequest(
network_path=str(grid_dir / "network.xiidm"),
action_file_path=str(runtime),
layout_path=str(grid_dir / "grid_layout.json"),
**CONFIG_MINIMA,
)
recommender_service.reset()
network_service.load_network(cfg.network_path)
recommender_service.update_config(cfg)
def _step2_result(events):
"""Drain the step2 iterator; return the 'result' event payload."""
out = {}
for ev in events:
if isinstance(ev, dict) and ev.get("type") == "result":
out = ev
return out
def _resolves(after_lines, new_overloads: set) -> bool:
"""An action resolves iff no contingency-attributable overload remains
(base overloads it doesn't touch are ignored — the N-overload rule)."""
return len(set(after_lines or []) & new_overloads) == 0
def _classify(step2: dict, new_overloads: set) -> tuple[str, dict]:
actions = step2.get("actions") or {}
combined = step2.get("combined_actions") or {}
best_unitary = None
for aid, a in actions.items():
if a.get("non_convergence"):
continue
if _resolves(a.get("lines_overloaded_after"), new_overloads):
return "easy", {"kind": "unitary", "action": aid,
"max_rho": a.get("max_rho")}
if best_unitary is None or (a.get("max_rho", 9e9) < best_unitary[1]):
best_unitary = (aid, a.get("max_rho", 9e9))
for pid, p in combined.items():
if p.get("is_islanded") or p.get("non_convergence"):
continue
if _resolves(p.get("lines_overloaded_after"), new_overloads) or \
(p.get("is_rho_reduction") and float(p.get("max_rho", 9e9)) <= 1.0):
return "medium", {"kind": "pair", "action": pid,
"max_rho": p.get("max_rho")}
return "hard", {"kind": "none", "best_unitary": best_unitary,
"n_actions": len(actions), "n_pairs": len(combined)}
def grade_contingency(cont: dict) -> dict:
cid = cont.get("tripped_line") or cont.get("contingency_id")
rec = dict(contingency_id=cid)
t0 = time.time()
try:
step1 = recommender_service.run_analysis_step1([cid])
except Exception as ex: # noqa: BLE001
rec.update(difficulty="non_converged", error=f"step1:{type(ex).__name__}",
t_step1=round(time.time() - t0, 2))
return rec
t_s1 = time.time() - t0
overloads = step1.get("lines_overloaded", []) or []
can_proceed = bool(step1.get("can_proceed"))
# The lines themselves, not just their count: the scenario database shows
# the player which lines to bring back under the limit, and it must be the
# RECOMMENDER's view (monitoring_factor 0.95, base-relative) rather than
# the screening's, since that is what the session will be judged against.
rec.update(n_overloads=len(overloads), overloaded_lines=list(overloads),
can_proceed=can_proceed, t_step1=round(t_s1, 2))
if not can_proceed or not overloads:
rec["difficulty"] = "trivial"
return rec
t1 = time.time()
try:
res = _step2_result(recommender_service.run_analysis_step2(overloads, overloads))
except Exception as ex: # noqa: BLE001
rec.update(difficulty="non_converged", error=f"step2:{type(ex).__name__}",
t_step2=round(time.time() - t1, 2))
return rec
rec["t_step2"] = round(time.time() - t1, 2)
rec["n_actions"] = len(res.get("actions") or {})
difficulty, solution = _classify(res, set(overloads))
rec["difficulty"] = difficulty
rec["solution"] = solution
return rec
def grade_all(conts, grid_dir: Path, done: set, sink=None, reset_each: bool = True):
"""Grade `conts`, yielding ``(index, contingency_id, record)``.
``reset_each`` re-runs :func:`configure` before EVERY contingency, and is
the default because it is a correctness requirement, not a tuning knob:
``run_analysis_step2`` mutates the shared network state, and grading in a
plain loop poisons every subsequent contingency. Measured on
``grid_6be3a179`` (case6515rte), 12 contingencies both ways: the first
three agree, then every remaining case collapses to ``trivial`` with zero
overloads — 9/12 verdicts wrong, and wrong in the silent direction (a real
``hard`` scenario is dropped from the database as "nothing to solve").
Reloading the 20 MB network each time costs ~4x (2.4 s -> 10.6 s per
contingency on that grid). That is the price of a correct database.
"""
configure(grid_dir)
for i, cont in enumerate(conts):
cid = cont.get("tripped_line") or cont.get("contingency_id")
if cid in done:
continue
if reset_each and i:
configure(grid_dir)
rec = grade_contingency(cont)
if sink is not None:
sink(rec)
yield i, cid, rec
def grids_to_grade(names) -> list[str]:
"""``all`` expands to every built grid, in a stable order."""
if list(names) == ["all"]:
return sorted(d.name for d in (DATA / "grids").iterdir() if d.is_dir())
return list(names)
def grade_grid(grid: str, limit: int = 0, persist: bool = True,
reset_each: bool = True) -> int:
grid_dir = DATA / "grids" / grid
n1 = json.loads((grid_dir / "n1_contingencies.json").read_text())
conts = [c for c in n1.get("contingencies", []) if not c.get("antenna")]
if limit:
conts = conts[:limit]
graded_path = grid_dir / "graded.jsonl"
done = set()
if graded_path.exists() and persist:
for line in graded_path.read_text().splitlines():
if line.strip():
done.add(json.loads(line)["contingency_id"])
todo = len(conts) - len(done & {c.get("tripped_line") for c in conts})
print(f"[grade] {grid}: {len(conts)} non-antenna constraining "
f"contingencies, {todo} à faire ({len(done)} déjà gradées)")
def sink(rec):
if persist:
with graded_path.open("a") as f:
f.write(json.dumps(rec) + "\n")
t0 = time.time()
n = 0
for i, cid, rec in grade_all(conts, grid_dir, done, sink, reset_each):
n += 1
print(f" [{i+1}/{len(conts)}] {cid}: {rec.get('difficulty')} "
f"({rec.get('n_overloads')} surcharges, "
f"{rec.get('t_step1', 0) + rec.get('t_step2', 0):.1f}s)")
dt = time.time() - t0
print(f"[grade] {grid}: {n} gradées en {dt:.0f}s ({dt/max(1, n):.1f}s each)")
return n
def main():
ap = argparse.ArgumentParser()
ap.add_argument("grid", nargs="+",
help="grid id(s), ou 'all' pour tous les grids construits")
ap.add_argument("--limit", type=int, default=0)
ap.add_argument("--time", action="store_true", help="timing dump, don't persist")
ap.add_argument("--no-reset", action="store_true",
help="NE PAS re-configurer le recommender entre les "
"contingences. Produit des verdicts FAUX (mesuré : "
"9/12 divergents, tout s'effondre en 'trivial') — "
"réservé au chronométrage brut.")
args = ap.parse_args()
total = 0
for grid in grids_to_grade(args.grid):
total += grade_grid(grid, args.limit, not args.time, not args.no_reset)
print(f"[grade] total {total} contingences gradées")
if __name__ == "__main__":
main()