File size: 10,157 Bytes
1c730d1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
# 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()