#!/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)