Co-Study4Grid / scripts /gst_estimation_vs_simulation_small_grid.py
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
9.98 kB
#!/usr/bin/env python3
# Copyright (c) 2025-2026, RTE (https://www.rte-france.com)
# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0.
# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file,
# you can obtain one at http://mozilla.org/MPL/2.0/.
# SPDX-License-Identifier: MPL-2.0
"""Characterise the Generalized Superposition Theorem (GST) estimate vs. full
simulation on the small test grid, **at the library level** (pure pypowsybl
backend — no running FastAPI backend required).
Unlike ``test_estimation_vs_simulation_small_grid.py`` (which drives the HTTP
backend), this script loads ``SimulationEnvironment`` directly, applies the
contingency, simulates each remedial action and the combined pair, runs the
library's ``compute_combined_pair_superposition`` (which routes injection pairs
to the GST), and prints a per-pair accuracy breakdown restricted to the
monitored lines.
It reproduces the two findings discussed in the GST review:
1. **GST pairs (topology + injection) estimate as accurately as the
pre-existing EST pairs (topology + topology).** The injection term
(beta = 1.0) adds no error beyond the inherent AC-vs-DC linearisation
limit. The per-line rho gap (mean ~1-2 pts, occasional ~15 pts on
parallel corridors) is the same for both — it flips the *global* max-rho
line between near-equally-loaded monitored lines but the on-target
overload is predicted correctly.
2. **Injection + injection pairs are weaker** (two large injections compound
the AC nonlinearity), so they over-predict combined relief.
Run:
# default paths resolve to <repo>/data/...
python scripts/gst_estimation_vs_simulation_small_grid.py
Env:
GST_NETWORK_PATH - override the .xiidm path
GST_MONITORED_CSV - override the monitored-lines CSV path
GST_CONTINGENCY - override the contingency element (default P.SAOL31RONCI)
"""
from __future__ import annotations
import csv
import os
import sys
import warnings
from pathlib import Path
import numpy as np
warnings.filterwarnings("ignore")
REPO_ROOT = Path(__file__).resolve().parent.parent
NETWORK_PATH = os.environ.get(
"GST_NETWORK_PATH", str(REPO_ROOT / "data/bare_env_small_grid_test/grid.xiidm"))
MONITORED_CSV = os.environ.get(
"GST_MONITORED_CSV", str(REPO_ROOT / "data/lignes_a_monitorer.csv"))
CONTINGENCY = os.environ.get("GST_CONTINGENCY", "P.SAOL31RONCI")
WORSENING_THRESHOLD = 0.02 # pre-existing-overload symmetric band
def _load_monitored(path):
if not os.path.exists(path):
return set()
with open(path) as f:
return {row["branches"] for row in csv.DictReader(f)}
def main():
try:
from expert_op4grid_recommender.pypowsybl_backend.simulation_env import (
SimulationEnvironment,
)
from expert_op4grid_recommender.utils.superposition import (
compute_combined_pair_superposition,
)
except Exception as e: # pragma: no cover - environment guard
print(f"[SKIP] pypowsybl / expert_op4grid_recommender unavailable: {e}")
return 0
if not os.path.exists(NETWORK_PATH):
print(f"[SKIP] network not found: {NETWORK_PATH}")
return 0
monitored = _load_monitored(MONITORED_CSV)
env = SimulationEnvironment(network_path=NETWORK_PATH)
obs_n = env.get_obs()
name_line = list(obs_n.name_line)
n = len(name_line)
idx = {nm: i for i, nm in enumerate(name_line)}
rho_n = np.asarray(obs_n.rho)
obs_start = obs_n.simulate(env.action_space({"set_line_status": [(CONTINGENCY, -1)]}))[0]
rho0 = np.asarray(obs_start.rho)
# Monitored, non-contingency, not pre-existing-overloaded lines (the same
# scope the backend uses to pick the reported max_rho).
mon = np.array([
(name_line[i] in monitored and name_line[i] != CONTINGENCY and rho_n[i] < 1.0)
for i in range(n)
]) if monitored else (rho_n < 1.0)
pre = rho_n >= 1.0
def masked_max(rho):
not_impacted = (rho >= rho_n * (1 - WORSENING_THRESHOLD)) & (rho <= rho_n * (1 + WORSENING_THRESHOLD))
elig = mon & ~(pre & not_impacted)
if not elig.any():
return "N/A", 0.0
j = int(np.argmax(np.where(elig, rho, -1.0)))
return name_line[j], float(rho[j])
def build(spec):
kind, name = spec
if kind == "disco":
return env.action_space({"set_line_status": [(name, -1)]})
if kind == "reco":
return env.action_space({"set_line_status": [(name, +1)]})
if kind == "ls":
return env.action_space({"set_load_p": {name: 0.0}})
raise ValueError(spec)
def elem(spec):
kind, name = spec
if kind in ("disco", "reco"):
return [idx[name]], [], False
return [], [], True # injection: no topology element
def diagnose(kind_tag, label, s1, s2):
a1, a2 = build(s1), build(s2)
o1 = obs_start.simulate(a1)[0]
o2 = obs_start.simulate(a2)[0]
otgt = obs_start.simulate(a1 + a2)[0]
li1, si1, inj1 = elem(s1)
li2, si2, inj2 = elem(s2)
res = compute_combined_pair_superposition(
obs_start, o1, o2, li1, si1, li2, si2,
act1_is_injection=inj1, act2_is_injection=inj2)
print("=" * 78)
print(f"[{kind_tag}] {label}")
if "error" in res:
print(f" ERROR: {res['error']}")
return
b = np.asarray(res["betas"])
rho_sim = np.asarray(otgt.rho)
# default direct-rho superposition (matches compute_combined_rho)
rho_est = np.abs((1 - b.sum()) * rho0 + b[0] * np.asarray(o1.rho) + b[1] * np.asarray(o2.rho))
p_gst = np.asarray(res["p_or_combined"])
p_tgt = np.asarray(otgt.p_or)
gap = np.abs(rho_est - rho_sim)[mon] * 100
fgap = np.abs(p_gst - p_tgt)[mon]
el, ev = masked_max(rho_est)
sl, sv = masked_max(rho_sim)
print(f" betas={np.round(b, 3).tolist()}")
print(f" monitored-line rho gap: mean={gap.mean():.1f} max={gap.max():.1f} pts "
f"| flow gap: mean={fgap.mean():.2f} max={fgap.max():.2f} MW")
print(f" EST max(monitored) = {ev * 100:5.1f}% on {el}")
print(f" SIM max(monitored) = {sv * 100:5.1f}% on {sl}"
f" {'[max line MATCH]' if el == sl else '[max line FLIPPED]'}")
print(f"network : {NETWORK_PATH}")
print(f"contingency: {CONTINGENCY}")
print(f"monitored : {len(monitored)} lines\n")
print("EST = topology+topology (no injection) ; GST = with injection\n")
# Topology-only EST pairs (baseline accuracy).
diagnose("EST", "reco_GEN.PY762 + disco_BEON L31CPVAN",
("reco", "GEN.PY762"), ("disco", "BEON L31CPVAN"))
diagnose("EST", "disco_BEON L31CPVAN + reco_BOISSL61GEN.P",
("disco", "BEON L31CPVAN"), ("reco", "BOISSL61GEN.P"))
# GST topology + injection pairs (should match EST accuracy).
diagnose("GST", "disco_BEON L31CPVAN + load_shedding_P.SAO3TR312",
("disco", "BEON L31CPVAN"), ("ls", "P.SAO3TR312"))
diagnose("GST", "disco_BEON L31CPVAN + load_shedding_BEON3 TR311",
("disco", "BEON L31CPVAN"), ("ls", "BEON3 TR311"))
# GST injection + injection pair (weaker: AC nonlinearity compounds).
diagnose("GST", "load_shedding_P.SAO3TR312 + load_shedding_BEON3 TR311",
("ls", "P.SAO3TR312"), ("ls", "BEON3 TR311"))
dc_exactness_check()
return 0
def dc_exactness_check():
"""Prove the GST formula is EXACT in true DC for the flagged pair
(disco_BEON L31CPVAN + load_shedding_P.SAO3TR312), via a direct pypowsybl
DC load flow on the 4 states. If the GST reconstructs the DC combined flows
to ~0 MW, the est-vs-sim gap seen on the AC grid is AC-nonlinearity, not a
formula error.
"""
try:
import pypowsybl.network as pn
import pypowsybl.loadflow as lf
except Exception as e: # pragma: no cover
print(f"\n[DC-exactness check skipped: {e}]")
return
cont, beon, cfou, pymon, load = (
CONTINGENCY, "BEON L31CPVAN", "C.FOUL31MERVA", "PYMONL31SAISS", "P.SAO3TR312")
base = pn.load(NETWORK_PATH)
lines, loads = base.get_lines(), base.get_loads()
def rid(idx_obj, nm):
return nm if nm in idx_obj.index else next(
(i for i in idx_obj.index if i.strip() == nm.strip()), None)
lid = {k: rid(lines, k) for k in (cont, beon, cfou, pymon)}
load_id = rid(loads, load)
if any(v is None for v in lid.values()) or load_id is None:
print("\n[DC-exactness check skipped: element name mapping failed]")
return
def dc(disco_beon=False, shed=False):
net = pn.load(NETWORK_PATH)
net.update_lines(id=lid[cont], connected1=False, connected2=False)
if disco_beon:
net.update_lines(id=lid[beon], connected1=False, connected2=False)
if shed:
net.update_loads(id=load_id, p0=0.0)
lf.run_dc(net, parameters=lf.Parameters(distributed_slack=True))
flows = net.get_lines()
return {nm: float(flows.loc[lid[nm], "p1"]) for nm in (beon, cfou, pymon)}
ref, disco, shed, both = dc(), dc(True), dc(False, True), dc(True, True)
beta = shed[beon] / ref[beon] # GST topology beta = f_BEON(shed)/f_BEON(ref)
print("\n" + "=" * 78)
print("[DC-EXACTNESS] disco_BEON L31CPVAN + load_shedding_P.SAO3TR312 (true DC)")
print(f" GST beta(disco) = {beta:.4f}")
for nm in (cfou, pymon):
gst = (1 - beta) * ref[nm] + beta * disco[nm] + (shed[nm] - ref[nm])
print(f" {nm:14s}: GST={gst:8.3f} DC_sim={both[nm]:8.3f} err={gst - both[nm]:+.4f} MW")
print(" -> ~0 MW error confirms the GST is exact in DC; the AC est-vs-sim")
print(" gap is AC-nonlinearity on low-flow coupled lines, not a bug.")
if __name__ == "__main__":
sys.exit(main())