Co-Study4Grid / scripts /test_estimation_vs_simulation_small_grid.py
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
15.1 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
"""Diagnose the combined-pair estimation vs. simulation discrepancy.
Targets the Co-Study4Grid backend running on the small test grid with
contingency P.SAOL31RONCI, reproducing the gap observed in the
"Combine Actions → Computed Pairs" modal between the library's
superposition estimate (``estimated_max_rho`` / ``target_max_rho``)
and the backend's post-action simulation (``max_rho`` returned by
``/api/simulate-manual-action``).
Runs step1 → step2 → per-pair simulate and prints:
• per-pair breakdown:
- library estimate (from step2 `combined_actions`)
- library's OWN internal simulation (embedded in step2 payload as
``max_rho_simulated`` when ``VERIFY_SUPERPOSITION_MAX_RHO`` is on
— this is the <2% "ground-truth" reference the user's library
report was computed from)
- backend's simulation (what the UI "Re-Simulate" button calls)
• variant-bug flag (simulated line == contingency?)
• monitoring-scope mismatch flag (est line absent from sim overloads)
• aggregate stats: two gap columns — library_est vs library_sim, and
library_sim vs backend_sim (the real discrepancy to explain)
Usage:
# 1. Start the backend (from project root):
python -m expert_backend.main
# 2. Run this script:
python scripts/test_estimation_vs_simulation_small_grid.py
Env:
BACKEND_URL - backend base URL (default http://127.0.0.1:8000)
TOP_N_PAIRS - how many pairs to diagnose, by estimated_max_rho (default 15)
"""
from __future__ import annotations
import json
import os
import sys
from statistics import median
import numpy as np
import requests
BACKEND_URL = os.environ.get("BACKEND_URL", "http://127.0.0.1:8000")
TOP_N_PAIRS = int(os.environ.get("TOP_N_PAIRS", "15"))
ONLY_PAIR = os.environ.get("DIAGNOSE_ONLY_PAIR", "").strip() # exact combined_actions key
NETWORK_PATH = "/home/marotant/dev/Expert_op4grid_recommender/data/bare_env_small_grid_test/grid.xiidm"
ACTION_FILE_PATH = "/home/marotant/dev/Expert_op4grid_recommender/data/action_space/reduced_model_actions_test.json"
CONTINGENCY = "P.SAOL31RONCI"
# ---------------------------------------------------------------------------
# HTTP helpers
# ---------------------------------------------------------------------------
def api_post(path, payload, *, timeout=300):
resp = requests.post(f"{BACKEND_URL}{path}", json=payload, timeout=timeout)
resp.raise_for_status()
return resp.json()
def api_get(path, *, timeout=60):
resp = requests.get(f"{BACKEND_URL}{path}", timeout=timeout)
resp.raise_for_status()
return resp.json()
def api_post_ndjson(path, payload, *, timeout=600):
resp = requests.post(f"{BACKEND_URL}{path}", json=payload, stream=True, timeout=timeout)
resp.raise_for_status()
events = []
for line in resp.iter_lines():
if line:
events.append(json.loads(line))
return events
# ---------------------------------------------------------------------------
# Pipeline steps
# ---------------------------------------------------------------------------
def load_config():
print(f"[CONFIG] network: {NETWORK_PATH}")
print(f"[CONFIG] actions: {ACTION_FILE_PATH}")
payload = {
"network_path": NETWORK_PATH,
"action_file_path": ACTION_FILE_PATH,
"min_line_reconnections": 2,
"min_close_coupling": 3,
"min_open_coupling": 2,
"min_line_disconnections": 3,
"min_pst": 1,
"min_load_shedding": 2,
"min_renewable_curtailment_actions": 0,
"n_prioritized_actions": 15,
"monitoring_factor": 0.95,
"pre_existing_overload_threshold": 0.02,
"ignore_reconnections": False,
"pypowsybl_fast_mode": True,
}
api_post("/api/config", payload)
print("[CONFIG] applied\n")
def run_step1():
print(f"[STEP1] contingency = {CONTINGENCY}")
result = api_post("/api/run-analysis-step1", {"disconnected_element": CONTINGENCY})
overloads = result.get("lines_overloaded", []) or []
print(f"[STEP1] overloads: {overloads}")
if not overloads:
raise SystemExit("[STEP1] no overloads detected — contingency mislabeled or monitoring path off")
print()
return overloads
def run_step2(overloads):
print(f"[STEP2] resolving {len(overloads)} overloads (streaming NDJSON)")
events = api_post_ndjson(
"/api/run-analysis-step2",
{
"selected_overloads": overloads,
"all_overloads": overloads,
"monitor_deselected": False,
},
)
result_event = next((e for e in events if e.get("type") == "result"), None)
if not result_event:
raise SystemExit("[STEP2] no result event received")
combined = result_event.get("combined_actions", {}) or {}
prioritized = result_event.get("actions", {}) or {}
lwca = result_event.get("lines_we_care_about")
print(f"[STEP2] prioritized_actions: {len(prioritized)} | combined_pairs: {len(combined)}")
if lwca is not None:
print(f"[STEP2] lines_we_care_about: {len(lwca)} lines")
print()
return prioritized, combined, lwca
def simulate_pair(pair_id):
return api_post(
"/api/simulate-manual-action",
{"action_id": pair_id, "disconnected_element": CONTINGENCY},
)
def recompute_superposition(action1_id, action2_id):
try:
return api_post(
"/api/compute-superposition",
{
"action1_id": action1_id,
"action2_id": action2_id,
"disconnected_element": CONTINGENCY,
},
)
except requests.HTTPError as e:
return {"error": str(e)}
# ---------------------------------------------------------------------------
# Diagnostic
# ---------------------------------------------------------------------------
def _fmt_rho(v):
return f"{v:.4f}" if isinstance(v, (int, float)) and v is not None else str(v)
def _betas_close(b1, b2, tol=1e-3):
if not b1 or not b2 or len(b1) != len(b2):
return False
return all(abs(a - b) <= tol for a, b in zip(b1, b2))
def diagnose_pair(pair_key, pair_data, sim_result):
print("=" * 80)
print(f"PAIR: {pair_key}")
print("=" * 80)
est_rho = pair_data.get("max_rho")
est_line = pair_data.get("max_rho_line")
target_rho = pair_data.get("target_max_rho")
target_line = pair_data.get("target_max_rho_line")
betas = pair_data.get("betas") or []
# Library's OWN internal simulation, embedded in step2 payload when
# VERIFY_SUPERPOSITION_MAX_RHO is on. This is the "ground-truth"
# reference that the user's previous <2% gap report came from.
lib_sim_rho = pair_data.get("max_rho_simulated")
lib_sim_line = pair_data.get("max_rho_line_simulated")
lib_sim_gap = pair_data.get("max_rho_gap")
lib_sim_match = pair_data.get("max_rho_line_match")
sim_rho = sim_result.get("max_rho")
sim_line = sim_result.get("max_rho_line")
sim_overloaded = sim_result.get("lines_overloaded_after") or []
sim_nc = sim_result.get("non_convergence")
sim_islanded = sim_result.get("is_islanded")
print(" [LIBRARY ESTIMATE (step2 superposition formula)]")
print(f" max_rho (global): {_fmt_rho(est_rho)} on {est_line}")
print(f" target_max_rho (overload set): {_fmt_rho(target_rho)} on {target_line}")
print(f" betas: {betas}")
if lib_sim_rho is not None:
print(" [LIBRARY INTERNAL SIMULATION (_verify_pair_max_rho_by_simulation)]")
print(f" max_rho_simulated: {_fmt_rho(lib_sim_rho)} on {lib_sim_line}")
print(f" max_rho_gap (est - lib_sim): {_fmt_rho(lib_sim_gap)} "
f"(line_match={lib_sim_match})")
else:
print(" [LIBRARY INTERNAL SIMULATION] not present in step2 payload "
"(VERIFY_SUPERPOSITION_MAX_RHO disabled?)")
print(" [BACKEND SIMULATION (/api/simulate-manual-action — what the UI calls)]")
print(f" max_rho: {_fmt_rho(sim_rho)} on {sim_line}")
print(f" overloaded_after: {sim_overloaded}")
print(f" non_convergence: {sim_nc}")
print(f" is_islanded: {sim_islanded}")
flags = []
# Variant-bug flag
if sim_line == CONTINGENCY:
flags.append(
f"VARIANT-BUG? backend sim max_rho_line IS the contingency ({CONTINGENCY})"
)
# Estimation vs backend simulation line mismatch
line_match_est_bsim = est_line == sim_line
if not line_match_est_bsim:
note = "est line != backend_sim line"
if est_line and sim_overloaded and est_line not in sim_overloaded:
note += f" — '{est_line}' absent from backend's overloaded_after"
flags.append(note)
# Library_sim vs backend_sim line mismatch — these should match since
# both are AC simulations of the same combined action on N-1.
if lib_sim_line is not None and sim_line is not None and lib_sim_line != sim_line:
flags.append(
f"SIM-PATH DIVERGENCE: library_sim line '{lib_sim_line}' "
f"!= backend_sim line '{sim_line}'"
)
# Gaps
def _gap(a, b):
if isinstance(a, (int, float)) and isinstance(b, (int, float)):
return a - b
return None
gap_est_libsim = _gap(est_rho, lib_sim_rho) # library estimate vs library simulation (expected <2%)
gap_est_bsim = _gap(est_rho, sim_rho) # library estimate vs backend sim (UI-visible gap)
gap_libsim_bsim = _gap(lib_sim_rho, sim_rho) # library sim vs backend sim (same formula, different path)
print(" [GAPS]")
print(f" est - lib_sim = {_fmt_rho(gap_est_libsim)} "
"(library internal; expected <2%)")
print(f" est - backend_sim = {_fmt_rho(gap_est_bsim)} "
"(UI-visible 'Max Loading (Est.)' vs 'Simulated Max Rho')")
print(f" lib_sim - backend_sim = {_fmt_rho(gap_libsim_bsim)} "
"(two sims of the same action — should be ~0; non-zero = backend sim-path bug)")
if flags:
print(" [FLAGS]")
for f in flags:
print(f" ⚠ {f}")
print()
return {
"pair_key": pair_key,
"est_rho": est_rho,
"est_line": est_line,
"lib_sim_rho": lib_sim_rho,
"lib_sim_line": lib_sim_line,
"sim_rho": sim_rho,
"sim_line": sim_line,
"gap_est_libsim": gap_est_libsim,
"gap_est_bsim": gap_est_bsim,
"gap_libsim_bsim": gap_libsim_bsim,
"line_match_est_bsim": line_match_est_bsim,
"line_match_libsim_bsim": (
lib_sim_line is not None and sim_line is not None and lib_sim_line == sim_line
),
"is_variant_bug": sim_line == CONTINGENCY,
}
def aggregate(rows):
def _clean(vals):
return [v for v in vals if isinstance(v, (int, float))]
gaps_est_libsim = _clean([r["gap_est_libsim"] for r in rows])
gaps_est_bsim = _clean([r["gap_est_bsim"] for r in rows])
gaps_libsim_bsim = _clean([r["gap_libsim_bsim"] for r in rows])
n = len(rows)
line_match_est_bsim_n = sum(1 for r in rows if r["line_match_est_bsim"])
line_match_libsim_bsim_n = sum(1 for r in rows if r["line_match_libsim_bsim"])
variant_n = sum(1 for r in rows if r["is_variant_bug"])
def _stats(vals, label):
if not vals:
print(f" {label}: n=0")
return
arr = np.asarray(vals, dtype=float)
print(
f" {label}: n={len(vals)} "
f"mean_signed={arr.mean():+.4f} "
f"mean_abs={np.abs(arr).mean():.4f} "
f"median_abs={median(np.abs(arr)):.4f} "
f"max_abs={np.abs(arr).max():.4f} "
f"rmse={float(np.sqrt((arr ** 2).mean())):.4f}"
)
print("=" * 80)
print(f"AGGREGATE over {n} pairs")
print("=" * 80)
_stats(gaps_est_libsim, "est - lib_sim ")
_stats(gaps_est_bsim, "est - backend_sim ")
_stats(gaps_libsim_bsim, "lib_sim - backend_sim ")
print(f" line match est vs backend_sim: {line_match_est_bsim_n}/{n}")
print(f" line match lib_sim vs backend_sim: {line_match_libsim_bsim_n}/{n}")
print(f" variant-bug flags: {variant_n}/{n}")
print()
print("INTERPRETATION:")
print(" • est - lib_sim should be <~2% (the user's known-good reference).")
print(" • lib_sim - backend_sim is the real discrepancy: both are AC")
print(" simulations of the SAME combined action on N-1, so any gap here")
print(" points to a divergence in how the backend's simulate_manual_action")
print(" constructs the simulation (different obs_start, different rebuilt")
print(" action object, or different simulate() parameters).")
print(" • est - backend_sim = (est - lib_sim) + (lib_sim - backend_sim),")
print(" and the bulk is in the second term.")
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
load_config()
overloads = run_step1()
prioritized, combined, _ = run_step2(overloads)
if not combined:
print("[DONE] No combined pairs produced by step2 — nothing to diagnose.")
return 0
# Rank pairs by estimated_max_rho desc; narrow to a single pair if asked.
if ONLY_PAIR:
if ONLY_PAIR not in combined:
print(f"[FATAL] DIAGNOSE_ONLY_PAIR={ONLY_PAIR!r} not in combined_actions")
return 1
pairs_ranked = [(ONLY_PAIR, combined[ONLY_PAIR])]
else:
pairs_ranked = sorted(
combined.items(),
key=lambda kv: (kv[1].get("max_rho") or 0.0),
reverse=True,
)[:TOP_N_PAIRS]
print(f"[DIAGNOSE] Top {len(pairs_ranked)} pairs by estimated_max_rho\n")
rows = []
for pair_key, pair_data in pairs_ranked:
try:
sim_result = simulate_pair(pair_key)
except requests.HTTPError as e:
print(f"[{pair_key}] simulate failed: {e}")
continue
row = diagnose_pair(pair_key, pair_data, sim_result)
rows.append(row)
if rows:
aggregate(rows)
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except requests.ConnectionError:
print(f"[FATAL] backend not reachable at {BACKEND_URL} — start it first.")
sys.exit(1)
except Exception as e:
print(f"[FATAL] {type(e).__name__}: {e}")
sys.exit(1)