From c42531d1a0248d7da0634170e60ba9f2d2dd001a Mon Sep 17 00:00:00 2001 From: Abderrahman AIT SAID Date: Mon, 6 Jul 2026 11:43:04 +0000 Subject: [PATCH 1/7] feat(railway): add KPI-based recommendation sorting modes Add sncf_recommender with four ordering strategies and wire them into RailwayManager via request_data.event.mode: - basic: best-first ordering (SNCF_RECO3) - deontic: filter by a KPI threshold, then sort ascending - risk: sort all recommendations by a KPI ascending - risk_tie_break: primary KPI with a secondary KPI as tie-breaker Supported KPIs: passengers, delay (parsed from strings like "1h30"), cost, total_cost. Catalog lookup errors are surfaced as an empty list instead of crashing the transform step. --- .../resources/Railway/manager.py | 74 ++++++-- .../resources/Railway/sncf_recommender.py | 158 ++++++++++++++++++ 2 files changed, 214 insertions(+), 18 deletions(-) create mode 100644 backend/recommendation-service/resources/Railway/sncf_recommender.py diff --git a/backend/recommendation-service/resources/Railway/manager.py b/backend/recommendation-service/resources/Railway/manager.py index cf44bc54..687472ec 100644 --- a/backend/recommendation-service/resources/Railway/manager.py +++ b/backend/recommendation-service/resources/Railway/manager.py @@ -2,17 +2,19 @@ import json from api.manager.base_manager import BaseRecommendationManager from .mockRecommendations.mockRecommendations import RECOMMENDATION_CATALOG +from .sncf_recommender import SNCF_RECO3, SNCF_deontic, SNCF_risk, SNCF_risk_tie_break import logging logger = logging.getLogger(__name__) + class RailwayManager(BaseRecommendationManager): def __init__(self): super().__init__() def _transform_recommendation(self, reco_json): - """Transform a recommendation from catalog to output format.""" + """Transform a recommendation from catalog format to API output format.""" reco = json.loads(reco_json) return { "title": reco["data"]["title"], @@ -25,23 +27,59 @@ def _transform_recommendation(self, reco_json): def get_recommendation(self, request_data): """ - Override to provide recommendations specific to the Railway use case. - - This method generates and returns recommendations tailored for Railway events. + Return recommendations for a Railway event. + + Supports four modes controlled by request_data["event"]["mode"]: + + - "basic" (default): best-first ordering via SNCF_RECO3 + - "deontic": filter by KPI threshold, then sort ascending via SNCF_deontic + requires: sort_type ("passengers"|"delay"|"cost"|"total_cost") + threshold_value (int, or delay string e.g. "1h30" for delay) + - "risk": sort all recommendations by KPI ascending via SNCF_risk + requires: sort_type + - "risk_tie_break": sort by primary KPI, break ties with secondary via SNCF_risk_tie_break + requires: sort_type + optional: tie_breaker (same values as sort_type) """ event_data = request_data.get("event", {}) - event_id = str(event_data.get("id_event", "1")) - - logger.info(f"Processing event_id: {event_id}") - - # Get recommendations from catalog - if event_id == "1": - recommendations = RECOMMENDATION_CATALOG.get(event_id, RECOMMENDATION_CATALOG["1"]) - elif event_id == "2": - recommendations = RECOMMENDATION_CATALOG.get(event_id, RECOMMENDATION_CATALOG["1"]) - elif event_id == "3": - recommendations = RECOMMENDATION_CATALOG.get(event_id, RECOMMENDATION_CATALOG["1"]) - # Transform each recommendation to output format - return [self._transform_recommendation(reco) for reco in recommendations] - + context_data = request_data.get("context", {}) + + # Ensure id_event has a fallback so catalog lookup always has a key + event_for_sncf = {**event_data, "id_event": str(event_data.get("id_event", "1"))} + # Wrap into the structure expected by SNCF functions + event_json = json.dumps({"data": event_for_sncf}) + context_json = json.dumps({"data": context_data}) + + mode = event_data.get("mode", "deontic") + logger.info(f"Railway recommendation — event_id: {event_for_sncf['id_event']}, mode: {mode}") + + if mode == "deontic": + sort_type = event_data.get("sort_type", "cost") + threshold_value = event_data.get("threshold_value") + recommendations = SNCF_deontic( + event_json, context_json, RECOMMENDATION_CATALOG, + type=sort_type, threshold_value=threshold_value, + ) + elif mode == "risk": + sort_type = event_data.get("sort_type", "cost") + recommendations = SNCF_risk( + event_json, context_json, RECOMMENDATION_CATALOG, + type=sort_type, + ) + elif mode == "risk_tie_break": + sort_type = event_data.get("sort_type", "cost") + tie_breaker = event_data.get("tie_breaker") + recommendations = SNCF_risk_tie_break( + event_json, context_json, RECOMMENDATION_CATALOG, + type=sort_type, tie_breaker=tie_breaker, + ) + else: # "basic" or any unrecognised mode + recommendations = SNCF_RECO3(event_json, context_json, RECOMMENDATION_CATALOG) + + # Surface catalog errors as an empty list rather than crashing downstream + if recommendations and isinstance(recommendations[0], dict) and "error" in recommendations[0]: + logger.error(f"Recommendation error: {recommendations[0]['error']}") + return [] + + return [self._transform_recommendation(reco) for reco in recommendations] diff --git a/backend/recommendation-service/resources/Railway/sncf_recommender.py b/backend/recommendation-service/resources/Railway/sncf_recommender.py new file mode 100644 index 00000000..bcb3f0b1 --- /dev/null +++ b/backend/recommendation-service/resources/Railway/sncf_recommender.py @@ -0,0 +1,158 @@ +import json +import re + + +def parse_delay_to_minutes(delay_str): + if delay_str is None: + return 0 + delay_str = delay_str.lower().strip() + hours = 0 + minutes = 0 + h_match = re.search(r'(\d+)h', delay_str) + m_match = re.search(r'(\d+)\s*min', delay_str) + if h_match: + hours = int(h_match.group(1)) + if m_match: + minutes = int(m_match.group(1)) + if "h" in delay_str and "min" not in delay_str: + parts = delay_str.split("h") + if len(parts) > 1 and parts[1].isdigit(): + minutes = int(parts[1]) + return hours * 60 + minutes + + +def SNCF_RECO3(event_json, context_json, recommendation_catalog): + """ + Selects recommendations using IF–THEN rules based on id_event. + Returns 4 recommendations ordered with best=True first. + """ + event = json.loads(event_json) + id_event = event["data"].get("id_event") + + if id_event not in recommendation_catalog: + return [json.dumps({"error": f"No recommendations defined for event_id {id_event}"})] + + recos = recommendation_catalog[id_event] + + def best_first(reco_json): + reco = json.loads(reco_json) + return 0 if reco["data"]["kpis"].get("best") == "True" else 1 + + return sorted(recos, key=best_first) + + +def SNCF_deontic(event_json, context_json, recommendation_catalog, type="passengers", threshold_value=200): + """ + Filters recommendations by a KPI threshold, then sorts by that KPI ascending. + + Parameters + ---------- + type : str + KPI to filter and sort on: "passengers", "delay", "cost", "total_cost" + threshold_value : int | str + Upper bound for the KPI (use a delay string like "1h30" for type="delay") + """ + event = json.loads(event_json) + id_event = event["data"].get("id_event") + + if id_event not in recommendation_catalog: + return [json.dumps({"error": f"No recommendations defined for event_id {id_event}"})] + + recos = recommendation_catalog[id_event] + + # When no threshold is provided, skip filtering and return all recommendations sorted + if threshold_value is None: + filtered = list(recos) + else: + filtered = [] + for reco_json in recos: + reco = json.loads(reco_json) + kpis = reco["data"]["kpis"] + passengers = int(kpis.get("nb_impacted_passengers", 0)) + cost = int(kpis.get("cost", 0)) + total_cost = int(kpis.get("total_cost", 0)) + delay_minutes = parse_delay_to_minutes(kpis.get("delay", "0min")) + + if type == "passengers" and passengers <= threshold_value: + filtered.append(reco_json) + elif type == "delay": + if delay_minutes <= parse_delay_to_minutes(threshold_value): + filtered.append(reco_json) + elif type == "cost" and cost <= threshold_value: + filtered.append(reco_json) + elif type == "total_cost" and total_cost <= threshold_value: + filtered.append(reco_json) + + def _key(reco_json): + reco = json.loads(reco_json) + kpis = reco["data"]["kpis"] + if type == "passengers": + return int(kpis.get("nb_impacted_passengers", 0)) + if type == "delay": + return parse_delay_to_minutes(kpis.get("delay", "0min")) + if type == "cost": + return int(kpis.get("cost", 0)) + if type == "total_cost": + return int(kpis.get("total_cost", 0)) + return 0 + + if type not in ("passengers", "delay", "cost", "total_cost"): + return [json.dumps({"error": "type must be 'passengers', 'delay', 'cost', or 'total_cost'"})] + + return sorted(filtered, key=_key) + + +def SNCF_risk(event_json, context_json, recommendation_catalog, type): + """ + Returns all recommendations sorted by a KPI ascending (no threshold filtering). + """ + event = json.loads(event_json) + id_event = event["data"].get("id_event") + + if id_event not in recommendation_catalog: + return [json.dumps({"error": f"No recommendations defined for event_id {id_event}"})] + + recos = recommendation_catalog[id_event] + + kpi_keys = { + "passengers": lambda r: int(json.loads(r)["data"]["kpis"].get("nb_impacted_passengers", 0)), + "delay": lambda r: parse_delay_to_minutes(json.loads(r)["data"]["kpis"].get("delay", "0min")), + "cost": lambda r: int(json.loads(r)["data"]["kpis"].get("cost", 0)), + "total_cost": lambda r: int(json.loads(r)["data"]["kpis"].get("total_cost", 0)), + } + + if type not in kpi_keys: + return [json.dumps({"error": "type must be 'passengers', 'delay', 'cost', or 'total_cost'"})] + + return sorted(recos, key=kpi_keys[type]) + + +def SNCF_risk_tie_break(event_json, context_json, recommendation_catalog, type, tie_breaker=None): + """ + Orders recommendations by a primary KPI, using a secondary KPI to break ties. + """ + event = json.loads(event_json) + id_event = event["data"].get("id_event") + + if id_event not in recommendation_catalog: + return [json.dumps({"error": f"No recommendations defined for event_id {id_event}"})] + + recos = recommendation_catalog[id_event] + + kpi_keys = { + "passengers": lambda r: int(json.loads(r)["data"]["kpis"].get("nb_impacted_passengers", 0)), + "delay": lambda r: parse_delay_to_minutes(json.loads(r)["data"]["kpis"].get("delay", "0min")), + "cost": lambda r: int(json.loads(r)["data"]["kpis"].get("cost", 0)), + "total_cost": lambda r: int(json.loads(r)["data"]["kpis"].get("total_cost", 0)), + } + + if type not in kpi_keys: + return [json.dumps({"error": "Invalid type"})] + + if tie_breaker is None: + return sorted(recos, key=kpi_keys[type]) + + if tie_breaker not in kpi_keys: + return [json.dumps({"error": "Invalid tie_breaker"})] + + return sorted(recos, key=lambda r: (kpi_keys[type](r), kpi_keys[tie_breaker](r))) From b08d9b2407eb80bbca4100ec186211df351d08e3 Mon Sep 17 00:00:00 2001 From: Abderrahman AIT SAID Date: Mon, 6 Jul 2026 11:44:12 +0000 Subject: [PATCH 2/7] fix(powergrid): send apply action to the simulator for real Replace the eval-demo hack that faked a success response with the real POST to VITE_POWERGRID_SIMU/api/v1/recommendations. - api.ts: await the real request, detect when the response is the SPA index.html (request fell through to the nginx SPA fallback because no /powergrid-simu/ proxy is present) and log request/response details. - Assistant.vue: make onSelection async; only mark the card resolved and close the assistant when the apply succeeds, and leave the card open on failure so the user can retry (the http plugin shows the error modal). --- .../src/entities/PowerGrid/CAB/Assistant.vue | 26 ++++++++--- frontend/src/entities/PowerGrid/api.ts | 43 ++++++++++++++----- 2 files changed, 53 insertions(+), 16 deletions(-) diff --git a/frontend/src/entities/PowerGrid/CAB/Assistant.vue b/frontend/src/entities/PowerGrid/CAB/Assistant.vue index c890de03..b1fef2d3 100644 --- a/frontend/src/entities/PowerGrid/CAB/Assistant.vue +++ b/frontend/src/entities/PowerGrid/CAB/Assistant.vue @@ -106,16 +106,32 @@ watch( } ) -function onSelection(selected: any) { +async function onSelection(selected: any) { + console.info( + '[PowerGrid][apply] Apply pressed — recommendation:', + selected?.title, + '| agent_type:', + selected?.agent_type + ) + console.info('[PowerGrid][apply] action to send (selected.actions[0]):', selected?.actions?.[0]) + console.info('[PowerGrid][apply] active card id:', appStore.card('PowerGrid')?.id) sendTrace({ data: selected, use_case: route.params.entity as Entity, step: 'AWARD' }) - applyRecommendation(selected.actions[0]) - const activeCard = appStore.card('PowerGrid') - if (activeCard) cardsStore.resolveCriticality(activeCard) - appStore.tab.assistant = 0 + try { + await applyRecommendation(selected.actions[0]) + console.info( + '[PowerGrid][apply] success — marking card resolved (criticality → ND) and closing assistant' + ) + const activeCard = appStore.card('PowerGrid') + if (activeCard) cardsStore.resolveCriticality(activeCard) + appStore.tab.assistant = 0 + } catch { + console.error('[PowerGrid][apply] failed — leaving card open for retry (error modal shown by http plugin)') + // http plugin already shows an error modal — leave the card open so the user can retry + } } function primaryAction() { diff --git a/frontend/src/entities/PowerGrid/api.ts b/frontend/src/entities/PowerGrid/api.ts index d0a56506..de4dd9e6 100644 --- a/frontend/src/entities/PowerGrid/api.ts +++ b/frontend/src/entities/PowerGrid/api.ts @@ -1,15 +1,36 @@ import http from '@/plugins/http' import type { Action } from '@/types/entities' -// TODO: TEMP HACK (eval-demo) — MUST BE REMOVED before next release -// The real PowerGrid simulator API call is disabled and replaced with a fake success response for demo purposes. -// To restore: uncomment the http.post block and delete the Promise.resolve line. -export function applyRecommendation(data: Action<'PowerGrid'>) { - // [DISABLED] Simulator API is inactive — returning fake success for demo - // To restore: uncomment the http.post and remove the Promise.resolve - // return http.post<{ message: string }>( - // import.meta.env.VITE_POWERGRID_SIMU + '/api/v1/recommendations', - // data - // ) - return Promise.resolve({ data: { message: 'ok (simulated)' } }) // TEMP HACK: remove this line +export async function applyRecommendation(data: Action<'PowerGrid'>) { + const base = import.meta.env.VITE_POWERGRID_SIMU + const url = base + '/api/v1/recommendations' + // Debug logging: confirm the apply action is actually sent to the simulator, and what is sent. + // NB: the axios instance prepends baseURL (VITE_API), which is empty in standalone => same-origin. + console.info( + `[PowerGrid][apply] POST ${url} (axios baseURL="${import.meta.env.VITE_API ?? ''}", VITE_POWERGRID_SIMU="${base ?? ''}")` + ) + console.info('[PowerGrid][apply] payload sent to simulator:', data) + try { + const res = await http.post<{ message: string }>(url, data) + const raw: unknown = res.data + const looksLikeHtml = typeof raw === 'string' && / 300 ? raw.slice(0, 300) + '… (truncated)' : res.data + console.info(`[PowerGrid][apply] simulator responded HTTP ${res.status}:`, body) + if (looksLikeHtml) + console.warn( + '[PowerGrid][apply] ⚠ Response is the SPA index.html, NOT a simulator reply. The request ' + + 'fell through to the nginx SPA fallback — there is no /powergrid-simu/ proxy in the running ' + + 'nginx config, so the action never reached the simulator.' + ) + return res + } catch (err) { + const e = err as { response?: { status?: number; data?: unknown }; message?: string } + console.error( + '[PowerGrid][apply] request FAILED — HTTP', + e.response?.status ?? '(no response / network error)', + e.response?.data ?? e.message ?? err + ) + throw err + } } From ab01434d746ba5187f05823e80bba3e116b781eb Mon Sep 17 00:00:00 2001 From: Abderrahman AIT SAID Date: Mon, 6 Jul 2026 11:44:54 +0000 Subject: [PATCH 3/7] feat(powergrid): add same-origin nginx proxy to the simulator Route the apply POST through a /powergrid-simu/ location so the browser stays same-origin and never triggers CORS against the grid2op simulator. - frontend/default.conf and cab-standalone nginx-cors-permissive.conf: add the /powergrid-simu/ proxy_pass (LAN upstream, adjust per env). - docker-compose.sh: default VITE_POWERGRID_SIMU=/powergrid-simu. - env.d.ts: type the VITE_* env vars, including VITE_POWERGRID_SIMU. --- config/dev/cab-standalone/docker-compose.sh | 1 + .../dev/cab-standalone/nginx-cors-permissive.conf | 6 ++++++ frontend/default.conf | 11 +++++++++++ frontend/env.d.ts | 14 ++++++++++++++ 4 files changed, 32 insertions(+) diff --git a/config/dev/cab-standalone/docker-compose.sh b/config/dev/cab-standalone/docker-compose.sh index 67eaa4cd..6bda7543 100755 --- a/config/dev/cab-standalone/docker-compose.sh +++ b/config/dev/cab-standalone/docker-compose.sh @@ -50,6 +50,7 @@ if [[ -f .secrets ]]; then fi echo "RL_AGENT_API_URL=${RL_AGENT_API_URL:-https://interactiveagent.passerelle.irt-systemx.fr/api/v1/recommendation}" >> .env echo "RL_AGENT_API_TOKEN=${RL_AGENT_API_TOKEN:-}" >> .env +echo "VITE_POWERGRID_SIMU=${VITE_POWERGRID_SIMU:-/powergrid-simu}" >> .env echo "VITE_COGNITIVE_TOKEN=${VITE_COGNITIVE_TOKEN:-}" >> .env cat .env diff --git a/config/dev/cab-standalone/nginx-cors-permissive.conf b/config/dev/cab-standalone/nginx-cors-permissive.conf index 2cf8aa8c..d7b19b5f 100644 --- a/config/dev/cab-standalone/nginx-cors-permissive.conf +++ b/config/dev/cab-standalone/nginx-cors-permissive.conf @@ -342,6 +342,12 @@ server { proxy_set_header X-Forwarded-For $remote_addr; } + location /powergrid-simu/ { + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_pass http://192.168.208.61:5100/; + } + location /cognitive-api/ { # Proxy for the INESCTEC cognitive API (avoids browser CORS restrictions) diff --git a/frontend/default.conf b/frontend/default.conf index 769e1649..1e81cc41 100644 --- a/frontend/default.conf +++ b/frontend/default.conf @@ -1,3 +1,4 @@ +# Resolver line is replaced at runtime by start-webui.sh resolver 127.0.0.11 ipv6=off; server { @@ -34,4 +35,14 @@ server { proxy_ssl_verify off; proxy_pass https://interactiveagent.passerelle.irt-systemx.fr/api/v1/; } + + # Proxy for the PowerGrid (grid2op) simulator — keeps the "apply" POST same-origin + # so the browser never does a cross-origin request (avoids CORS entirely). + # Frontend posts to /powergrid-simu/... with VITE_POWERGRID_SIMU=/powergrid-simu. + # NB: this upstream is a LAN address reachable from cab-standalone; adjust per environment. + location /powergrid-simu/ { + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_pass http://192.168.208.61:5100/; + } } diff --git a/frontend/env.d.ts b/frontend/env.d.ts index 11f02fe2..4c207149 100644 --- a/frontend/env.d.ts +++ b/frontend/env.d.ts @@ -1 +1,15 @@ /// + +interface ImportMetaEnv { + readonly VITE_API: string + readonly VITE_DEFAULT_LOCALE: string + readonly VITE_FALLBACK_LOCALE: string + readonly VITE_POWERGRID_SIMU: string + readonly VITE_RAILWAY_SIMU: string + readonly VITE_ATM_SIMU: string + readonly VITE_COGNITIVE_TOKEN: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} From 7aa2575b804e6f8084941af6e546b58b11e8922b Mon Sep 17 00:00:00 2001 From: Abderrahman AIT SAID Date: Mon, 6 Jul 2026 11:54:29 +0000 Subject: [PATCH 4/7] feat(powergrid-sim): overhaul simulator recommendation flow and UX Upgrade the PowerGrid grid2op simulator PoC with five related changes: - Recommendation store: add an in-process RecommendationStore shared by the POST /api/v1/recommendations endpoint and the simulation loop. The loop now reads the applied recommendation directly instead of issuing an HTTP request back to itself, which was fragile behind a reverse proxy and returned 404. - Action verification: add summarize_action / verify_action_applied / format_verdict_description in utils.py and wire them into Simulator so each InteractiveAI recommendation is checked against the grid state (APPLIED / NO_OP / NO_EFFECT / REJECTED / OVERRIDDEN) and reported. - Pause/Resume: replace the one-shot 'Continue' button with a Pause/ Resume toggle, add /pause_simulation, stream simulation-state and keepalive SSE events, and default IS_PAUSED to False. - Reverse-proxy / CORS readiness: add CORS headers, ProxyFix, SocketIO cors_allowed_origins and X-Accel-Buffering so the simulator works behind the frontend nginx proxy and for direct cross-origin calls. - Translate comments, log messages and dashboard UI from French to English; tune scenario timing and demo server config. --- .../PowerGrid/PowerGrid_poc_simulator_app.py | 91 ++++++---- .../PowerGrid/RecommendationAPI.py | 14 ++ .../PowerGrid/app/models/Communicate.py | 80 +++++---- .../PowerGrid/app/models/Simulator.py | 168 ++++++++++-------- .../app/models/recommendation_store.py | 37 ++++ .../PowerGrid/app/models/utils.py | 129 +++++++++++++- .../PowerGrid/app/templates/dashboard.html | 160 ++++++++++------- .../PowerGrid/config/API_POWERGRID_CAB.toml | 3 +- .../PowerGrid/config/CONFIG.toml | 4 +- usecases_examples/PowerGrid/config/config.py | 2 +- 10 files changed, 477 insertions(+), 211 deletions(-) create mode 100644 usecases_examples/PowerGrid/app/models/recommendation_store.py diff --git a/usecases_examples/PowerGrid/PowerGrid_poc_simulator_app.py b/usecases_examples/PowerGrid/PowerGrid_poc_simulator_app.py index 175609eb..0e778b04 100644 --- a/usecases_examples/PowerGrid/PowerGrid_poc_simulator_app.py +++ b/usecases_examples/PowerGrid/PowerGrid_poc_simulator_app.py @@ -1,27 +1,36 @@ +import json import urllib.parse from flask import Flask, jsonify, request from flask import render_template, redirect from flask import url_for, session, g from flask import Response, stream_with_context, flash from flask_socketio import SocketIO +from werkzeug.middleware.proxy_fix import ProxyFix from app.models.Communicate import Communicate from app.models.Simulator import Simulator -from config.config import set_pause +from app.models.recommendation_store import store as recommendation_store +from config.config import logging, set_pause -class Recommendation: - """Class to manage recommendations.""" - - def __init__(self): - """Initialize a new Recommendation instance.""" - self.data = {} - - -Recommend = Recommendation() - app = Flask(__name__, template_folder='app/templates') app.secret_key = 'votre_clé_secrète_ici' -socketio = SocketIO(app) +app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1) +socketio = SocketIO(app, cors_allowed_origins='*') + +@app.after_request +def add_cors_headers(response): + """Allow the CAB frontend to POST applied recommendations cross-origin. + + Runs for every response, including the automatic preflight OPTIONS — without + these headers the browser blocks the apply POST with a CORS error. + NB: prefer routing the apply through the frontend's nginx same-origin proxy + (/powergrid-simu/), which avoids CORS entirely; these headers only matter when + the frontend calls this simulator directly over plain HTTP. + """ + response.headers['Access-Control-Allow-Origin'] = '*' + response.headers['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS' + response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization' + return response com = Communicate() simu = Simulator(socketio) @@ -37,10 +46,10 @@ def index(): @app.route('/dashboard') def dashboard(): """Display the dashboard.""" - # Récupérer le nom d'utilisateur + # Get username username = session.get('username', 'Invité') server = session.get('server', 'Null') - # Récupérer tous les messages + # Get all messages messages = session.pop('message', []) return render_template('dashboard.html', username=username, @@ -58,7 +67,7 @@ def load_simulation(): simu.initialize_simulation(com, session) if 'message' not in session or isinstance(session['message'], str): session['message'] = [] - session['message'].append("La simulation est chargée.") + session['message'].append("Simulation loaded.") return redirect(url_for('dashboard')) return redirect(url_for('login')) @@ -72,7 +81,7 @@ def add_server(): """ new_server_url = request.json.get('url') if new_server_url: - # Ajouter le nouveau serveur à la configuration + # Add the new server to the configuration success = com.add_cab_server_url(new_server_url) if success: @@ -111,13 +120,13 @@ def login(): session['server'] = server return redirect(url_for('load_simulation')) else: - flash("Échec de la connexion. Veuillez réessayer.") + flash("Login failed. Please try again.") else: - flash("Serveur non disponible. Veuillez choisir un autre serveur.") + flash("Server unavailable. Please choose a different server.") except ConnectionRefusedError: - flash("Le serveur a refusé la connexion. Veuillez réessayer plus tard.") + flash("Server refused the connection. Please try again later.") except Exception as e: - flash("Une erreur s'est produite. La connexion n'a pas pu s'établir avec le serveur.") + flash("An error occurred. Could not establish a connection to the server.") return redirect(url_for('index')) @@ -135,7 +144,7 @@ def edit_config(): simu.initialize_simulation(com, session) if 'message' not in session or isinstance(session['message'], str): session['message'] = [] - session['message'].append("La simulation est chargée.") + session['message'].append("Simulation loaded.") return redirect(url_for('dashboard')) @@ -157,7 +166,7 @@ def edit_simulation_settings(): simu.initialize_simulation(com, session) if 'message' not in session or isinstance(session['message'], str): session['message'] = [] - session['message'].append("La simulation est chargée.") + session['message'].append("Simulation loaded.") return redirect(url_for('dashboard')) @@ -173,10 +182,18 @@ def start_simulation(): mimetype='text/event-stream') response.headers['Cache-Control'] = 'no-cache' response.headers['Connection'] = 'keep-alive' - return response + response.headers['X-Accel-Buffering'] = 'no' + return response return redirect(url_for('dashboard')) +@app.route('/pause_simulation', methods=['POST']) +def pause_simulation(): + """Pause the simulation.""" + set_pause(True) + return "Paused", 200 + + @app.route('/continue_simulation', methods=['POST']) def continue_simulation(): """Resume the simulation after a pause.""" @@ -187,12 +204,12 @@ def continue_simulation(): @app.route('/logout') def logout(): """Log out the user.""" - # Effacer les données de session + # Clear session data session.pop('username', None) session.pop('config', None) session.pop('act', None) session.pop('message', None) - # Rediriger vers la page de connexion + # Redirect to login page return redirect(url_for('index')) @@ -211,9 +228,15 @@ def get_last_payloads(): @app.route('/api/v1/recommendations', methods=['POST']) def receive_act(): - """Receive recommendations.""" - Recommend.data = request.get_json() - print(Recommend.data) + """Receive a recommendation from InteractiveAI and store it in memory. + + The simulation loop reads it back directly from the shared store, so this + endpoint is the single entry point for recommendations. + """ + data = request.get_json() + recommendation_store.set(data) + logging.info("Recommendation received via POST from InteractiveAI: %s", + json.dumps(data)) return jsonify({ "message": "OK" }) @@ -221,11 +244,13 @@ def receive_act(): @app.route('/api/v1/recommendations', methods=['GET']) def send_act(): - """Send recommendations.""" - act_dict = {} - act_dict = Recommend.data - Recommend.data = {} - print(act_dict) + """Return and clear the stored recommendation. + + Kept for external callers; the simulation loop no longer relies on this + endpoint and reads the shared store directly instead. + """ + act_dict = recommendation_store.pop() + logging.info("Recommendation served via GET: %s", json.dumps(act_dict)) return jsonify(act_dict) diff --git a/usecases_examples/PowerGrid/RecommendationAPI.py b/usecases_examples/PowerGrid/RecommendationAPI.py index a56e96cf..b0e93e15 100644 --- a/usecases_examples/PowerGrid/RecommendationAPI.py +++ b/usecases_examples/PowerGrid/RecommendationAPI.py @@ -8,6 +8,20 @@ def __init__(self): app = Flask(__name__) +@app.after_request + +def add_cors_headers(response): + """Allow the CAB frontend to POST applied recommendations cross-origin. + + Without these headers the browser's preflight OPTIONS succeeds but the actual + POST is blocked by CORS. Prefer the nginx same-origin proxy (/powergrid-simu/) + where possible; these headers only matter for direct plain-HTTP calls. + """ + response.headers['Access-Control-Allow-Origin'] = '*' + response.headers['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS' + response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization' + return response + @app.route("/") def home_page(): return "API for PowerGrid recommendations" diff --git a/usecases_examples/PowerGrid/app/models/Communicate.py b/usecases_examples/PowerGrid/app/models/Communicate.py index dd1ab6d4..9e779eb8 100644 --- a/usecases_examples/PowerGrid/app/models/Communicate.py +++ b/usecases_examples/PowerGrid/app/models/Communicate.py @@ -6,6 +6,7 @@ from datetime import datetime, timedelta import numpy as np from config.config import logging, set_pause, get_pause_status +from app.models.recommendation_store import store as recommendation_store class Communicate: """ @@ -25,7 +26,7 @@ def __init__(self): self.act_dict = {} self.payload = {} - # Charger les paramètres depuis le fichier de configuration + # Load parameters from configuration file self.load_config() def load_config(self): @@ -44,11 +45,11 @@ def load_config(self): "differently." ) except FileNotFoundError: - logging.error("Le fichier 'config/API_POWERGRID_CAB.toml' n'a pas été trouvé.") + logging.error("File 'config/API_POWERGRID_CAB.toml' not found.") except toml.TomlDecodeError: - logging.error("Erreur de décodage du fichier 'config/API_POWERGRID_CAB.toml'.") + logging.error("Failed to decode 'config/API_POWERGRID_CAB.toml'.") except Exception as e: - logging.error("Une erreur inattendue est survenue: %s", str(e)) + logging.error("An unexpected error occurred: %s", str(e)) def edit_parameters(self, parameter_name, new_value): """ @@ -58,10 +59,10 @@ def edit_parameters(self, parameter_name, new_value): parameter_name: Name of the parameter to modify. new_value: New value of the parameter. """ - # Recharger la configuration au cas où elle aurait été modifiée + # Reload config in case it was modified self.load_config() - # Modifier les paramètres dans self.outputs_config + # Update parameters in self.outputs_config keys = parameter_name.split('.') current_param = self.outputs_config for key in keys[:-1]: @@ -69,11 +70,11 @@ def edit_parameters(self, parameter_name, new_value): current_param = current_param[key] else: logging.error( - "Le paramètre %s n'existe pas dans les paramètres.", parameter_name) + "Parameter %s does not exist in the configuration.", parameter_name) return current_param[keys[-1]] = new_value - # Enregistrer les modifications dans le fichier de configuration + # Save changes to configuration file with open("config/API_POWERGRID_CAB.toml", "w", encoding="utf-8") as file: toml.dump(self.outputs_config, file) @@ -92,7 +93,7 @@ def add_cab_server_url(self, url): if 'Connexion' not in config: config['Connexion'] = {} - # Trouver le prochain numéro disponible pour le serveur + # Find next available server number server_count = sum(1 for key in config['Connexion'] if key.startswith('cab_server_url_')) new_key = f'cab_server_url_{server_count + 1}' @@ -103,7 +104,7 @@ def add_cab_server_url(self, url): return True except Exception as e: - print(f"Une erreur est survenue lors de l'ajout du serveur: {e}") + print(f"An error occurred while adding the server: {e}") return False def delete_cab_server_url(self, url): @@ -125,15 +126,15 @@ def delete_cab_server_url(self, url): del config['Connexion'][key] with open(config_file, "w") as configfile: toml.dump(config, configfile) - print(f"Serveur supprimé : clé={key}, url={url}") + print(f"Server removed: key={key}, url={url}") return True, url - print(f"URL non trouvée : {url}") + print(f"URL not found: {url}") return False, "" else: - print("Section 'Connexion' non trouvée dans le fichier de configuration") + print("Section 'Connexion' not found in configuration file") return False, "" except Exception as e: - print(f"Une erreur est survenue lors de la suppression du serveur: {e}") + print(f"An error occurred while deleting the server: {e}") return False, "" def get_cab_server_urls(self): @@ -145,7 +146,7 @@ def get_cab_server_urls(self): """ try: config = toml.load("config/API_POWERGRID_CAB.toml") - # Accéder à la section [Connexion] et récupérer toutes les URLs + # Access [Connexion] section and retrieve all URLs urls = { key: value for key, value in config['Connexion'].items() @@ -154,14 +155,14 @@ def get_cab_server_urls(self): return urls except KeyError: print( - "La section 'Connexion' est manquante dans le fichier de configuration.") + "Section 'Connexion' is missing from the configuration file.") return {} except FileNotFoundError: - print("Le fichier de configuration n'a pas été trouvé.") + print("Configuration file not found.") return {} except Exception as e: print( - f"Erreur lors de la lecture du fichier de configuration: {e}") + f"Error reading configuration file: {e}") return {} def choose_a_cab_application(self, server_choice): @@ -178,16 +179,16 @@ def choose_a_cab_application(self, server_choice): server_url = server_choice if server_url: self.cab_url = server_url - return server_url # Retourne l'URL du serveur choisi + return server_url return None except KeyError: - print("Le choix du serveur n'est pas valide.") + print("Invalid server choice.") return None except FileNotFoundError: - print("Le fichier de configuration n'a pas été trouvé.") + print("Configuration file not found.") return None except Exception as e: - print(f"Une erreur inattendue est survenue: {e}") + print(f"An unexpected error occurred: {e}") return None def login(self, username, password): @@ -257,11 +258,11 @@ def send_context_online(self, obs, scn_first_step, context_date, img_b64): response = requests.request( "POST", url, headers=headers, data=payload, timeout=15) response.raise_for_status() - logging.info("Contexte envoyé avec succès.") + logging.info("Context sent successfully.") except Exception as e: logging.error(e) logging.info( - "L'envoi du contexte a échoué : la connexion avec InteractiveAI a échoué.") + "Failed to send context: connection with InteractiveAI failed.") def send_payload_and_store_it(self, payload, obs, scn_first_step): """ @@ -574,12 +575,15 @@ def get_act_from_api(self): time.sleep(1) yield ( "data: {\"div\": \"status-div\", \"content\": " - "\"Click 'Continuer' after making your selection in InteractiveAI\"}\n\n" + "\"Click 'Resume' after making your selection in InteractiveAI\"}\n\n" ) time.sleep(1) + yield f"data: {json.dumps({'div': 'simulation-state', 'content': 'paused'})}\n\n" set_pause(True) while get_pause_status(): + yield ": keepalive\n\n" time.sleep(1) + yield f"data: {json.dumps({'div': 'simulation-state', 'content': 'running'})}\n\n" else: message = { "div": "message-container", @@ -589,23 +593,25 @@ def get_act_from_api(self): time.sleep(1) yield ( "data: {\"div\": \"status-div\", \"content\": " - "\"Repeat your selection in InteractiveAI, then click again on " - "'Continuer'\"}\n\n" + "\"Repeat your selection in InteractiveAI, then click 'Resume' again\"}\n\n" ) time.sleep(1) + yield f"data: {json.dumps({'div': 'simulation-state', 'content': 'paused'})}\n\n" set_pause(True) while get_pause_status(): + yield ": keepalive\n\n" time.sleep(1) - url = self.outputs_config['Inputs']['Act']['url'] - payload = {} - headers = {} - response = requests.request( - "GET", url, headers=headers, data=payload, timeout=15) - print(response.text) - response.raise_for_status() - # print(response.text) - # print(response.json()) - self.act_dict = response.json() + yield f"data: {json.dumps({'div': 'simulation-state', 'content': 'running'})}\n\n" + # The recommendation is delivered to this same process via the + # POST /api/v1/recommendations endpoint and held in a shared + # in-memory store. Read it directly instead of issuing an HTTP + # request back to ourselves (which is fragile behind a reverse + # proxy / loopback and was returning 404). + self.act_dict = recommendation_store.pop() + if bool(self.act_dict) is True: + logging.info( + "Recommendation payload received from InteractiveAI: %s", + json.dumps(self.act_dict)) if bool(self.act_dict) is False and get_act_counter >= 1: logging.info( "\n No recommendation has been received! \n" diff --git a/usecases_examples/PowerGrid/app/models/Simulator.py b/usecases_examples/PowerGrid/app/models/Simulator.py index 9d6683ad..fd1bb734 100644 --- a/usecases_examples/PowerGrid/app/models/Simulator.py +++ b/usecases_examples/PowerGrid/app/models/Simulator.py @@ -15,7 +15,8 @@ from app.models.utils import (create_observation_image, get_alert_lines, search_chronic_num_from_name, get_curent_lines_in_bad_kpi, get_curent_lines_lost, get_zone_where_alarm_occured, expand_act_from_cab, - load_assistant, local_xd_silly, targeted_scenario_act_fixed, generate_graph_html) + load_assistant, local_xd_silly, targeted_scenario_act_fixed, generate_graph_html, + summarize_action, verify_action_applied, format_verdict_description) BkClass = LightSimBackend @@ -50,7 +51,7 @@ def load_and_edit_config(self, params=None): config_path = "config/CONFIG.toml" self.config = toml.load(config_path) if params: - # Mettre à jour le fichier de configuration avec les nouveaux paramètres + # Update the configuration file with new parameters self.config.update(params) with open(config_path, 'w', encoding='utf-8') as config_file: toml.dump(self.config, config_file) @@ -90,9 +91,9 @@ def initialize_simulation(self, com, session): self.config['scenario_name'], self.env) self.env.set_id(id_scenario) # Scenario choice self.obs = self.env.reset() - logging.info("Le scénario chargé est : %s \n", + logging.info("Loaded scenario: %s \n", self.env.chronics_handler.get_name()) - session['message'].append(f"Le scénario chargé est : {self.env.chronics_handler.get_name()}") + session['message'].append(f"Loaded scenario: {self.env.chronics_handler.get_name()}") assistant_path = self.config['assistant_path'] assistant_seed = int(self.config['assistant_seed']) @@ -110,8 +111,8 @@ def initialize_simulation(self, com, session): except Exception as e: logging.error(e) - logging.info("Le scénario est chargé.\n") - session['message'].append("Le scénario est chargé.") + logging.info("Scenario loaded.\n") + session['message'].append("Scenario loaded.") self.listen = Listener(self.obs) return act @@ -128,6 +129,7 @@ def run_simulator(self, com): Yields: str: Status updates and messages for the simulation interface. """ + set_pause(False) paris_timezone = timezone(timedelta(hours=2)) date = datetime.now(paris_timezone) # date = datetime.now(timezone.utc) @@ -138,22 +140,63 @@ def run_simulator(self, com): silent_mode_msg_trigger = True step_counter = 0 clear_parade_flag = False + # Holds a recommendation received from InteractiveAI that is awaiting + # confirmation that it was actually applied on the next env.step. + pending_cab_act = None + + # Human-readable titles for each possible application verdict. + verdict_titles = { + "APPLIED": "Recommendation applied to the grid ✓", + "NO_OP": "Recommendation was a do-nothing action", + "NO_EFFECT": "Recommendation applied but grid unchanged", + "REJECTED": "Recommendation rejected by the grid ✗", + "OVERRIDDEN": "Recommendation overridden by scenario script ✗", + } while not done: context_date = date + timedelta(minutes=float(5))*step_counter img_b64_current = None img_b64_forecast = None - # Pour corriger la valeur de act à certain pas précis - # en vue d'avoir notre scénario cible + # Correct act value at specific steps + # to reach the target scenario act_fixed, _ = targeted_scenario_act_fixed(self.env, self.obs) + cab_act_overwritten = False if act_fixed is not None: + # A scripted scenario action takes precedence: if a recommendation + # was pending, it is being discarded before reaching the grid. + cab_act_overwritten = pending_cab_act is not None act = act_fixed - # Begining of steps : Observation updates - self.obs, _, done, _ = self.env.step(act) + # Snapshot the grid state so we can confirm whether the action that + # is about to be applied actually takes effect. + topo_before = self.obs.topo_vect.copy() + line_status_before = self.obs.line_status.copy() + + # Beginning of step: observation update + self.obs, _, done, info = self.env.step(act) + + # Confirm whether a recommendation received from InteractiveAI was + # actually applied to the simulation on this step. + if pending_cab_act is not None: + verdict = verify_action_applied(pending_cab_act, + topo_before, + line_status_before, + self.obs, + info, + overwritten=cab_act_overwritten) + logging.info("InteractiveAI action application check: %s", + json.dumps(verdict, default=str)) + verdict_msg = { + "title": verdict_titles.get(verdict["status"], + "Recommendation status"), + "description": format_verdict_description(verdict), + } + yield (f"data: {{\"div\": \"events-div\", \"content\": " + f"{json.dumps(verdict_msg)}}}\n\n") + pending_cab_act = None - # Vider le div des parades + # Clear the actions panel if self.obs.current_step >= self.config['scenario_first_step'] and clear_parade_flag: empty_message = { "title": "", @@ -163,7 +206,7 @@ def run_simulator(self, com): clear_parade_flag = False if self.obs.current_step >= self.config['scenario_first_step']: - # Pour l'affichage du graphique dans le simulateur + # Update the interactive graph graph_html = generate_graph_html(self.env, self.obs) # print("Contenu du graphique:", graph_html[:200]) @@ -174,30 +217,30 @@ def run_simulator(self, com): # To handle between "silent mode" and "stream simulation" (with or without InteractiveAI) # (The stream simulation starts at step scenario_first_step) if self.obs.current_step >= self.config['scenario_first_step']: - logging.info("Pas de simulation : %s", + logging.info("Simulation step: %s", self.obs.current_step) yield ( f"data: {{\"div\": \"status-div\", \"content\": " - f"\"Pas de simulation : {self.obs.current_step}\"}}\n\n" + f"\"Simulation step: {self.obs.current_step}\"}}\n\n" ) elif self.obs.current_step == self.config['scenario_first_step'] - 1: print("\n") - logging.info("Le simulateur est à présent connecté à InteractiveAI.\n") + logging.info("The simulator is now connected to InteractiveAI.\n") message = { "div": "message-container", - "content": "Le simulateur est à présent connecté à InteractiveAI." + "content": "The simulator is now connected to InteractiveAI." } yield f"data: {json.dumps(message)}\n\n" silent_mode_msg_trigger = False else: if silent_mode_msg_trigger: - logging.info('Status: Le scénario se déroule en arrière plan.\n' - 'Le simulateur va se connecter à InteractiveAI à partir du pas : %s', + logging.info('Status: Scenario running in background.\n' + 'The simulator will connect to InteractiveAI from step: %s', self.config['scenario_first_step']) message = ( - f"Status: Le scénario se déroule en arrière plan.\n" - f"Le simulateur va se connecter à InteractiveAI à partir du pas : " - f"{self.config['scenario_first_step']} (Voir les paramètres de configuration)" + f"Status: Scenario running in background.\n" + f"The simulator will connect to InteractiveAI from step: " + f"{self.config['scenario_first_step']} (See configuration parameters)" ) yield f"data: {{ \"div\": \"status-div\", \"content\": {json.dumps(message)} }}\n\n" silent_mode_msg_trigger = False @@ -267,17 +310,17 @@ def run_simulator(self, com): img_b64_current) context_just_sent = True - logging.info("Status: Il y a une surcharge sur le réseau") + logging.info("Status: Overload detected on the network") message = { "div": "message-container", - "content": "Status: Il y a une surcharge sur le réseau" + "content": "Status: Overload detected on the network" } yield f"data: {json.dumps(message)}\n\n" yield ( f"data: {{\"div\": \"events-div\", \"content\": {{ \"title\": " - f"\"Status: Il y a une surcharge sur la ligne " + f"\"Status: Overload on line " f"{get_curent_lines_in_bad_kpi(self.obs)}\" , " - f"\"description\": \"La surcharge est de " + f"\"description\": \"Overload at " f"{np.round(np.float64(self.obs.rho.max()*100),decimals=1,out=None)}%\"" f" }}}}\n\n" ) @@ -296,24 +339,32 @@ def run_simulator(self, com): case_overload=True) if (self.obs.current_step < self.config['scenario_first_step']) or (com.cab_api_on is False): - # Utiliser XD_Silly en cache (en local) + # Use cached XD_Silly (local) act = local_xd_silly(self.obs, self.local_assistant) if com.cab_api_on is False: - logging.info("Parade : %s", act) + logging.info("Action: %s", act) parade_message = { - "title": "Parade", + "title": "Action", "description": f"{str(act)}" } yield f"data: {{\"div\": \"actions-div\", \"content\": {json.dumps(parade_message)}}}\n\n" clear_parade_flag = True else: - # Récuperer les parades de InteractiveAI + # Retrieve actions from InteractiveAI yield from com.get_act_from_api() act = expand_act_from_cab(self.env, com.act_dict) - logging.info("Parade : %s", act) + act_summary = summarize_action(act) + logging.info("Action received from InteractiveAI: %s", + json.dumps(act_summary, default=str)) + # Flag this action so the next env.step can confirm it + # was actually applied to the grid. + pending_cab_act = { + "step_issued": int(self.obs.current_step), + "summary": act_summary, + } parade_message = { - "title": "Parade", + "title": "Action", "description": f"{str(act)}" } yield f"data: {{\"div\": \"actions-div\", \"content\": {json.dumps(parade_message)}}}\n\n" @@ -336,10 +387,10 @@ def run_simulator(self, com): img_b64_current) context_just_sent = True - logging.info("Status: Il y a une alarme de l'agent IA") + logging.info("Status: AI agent raised an alarm") yield ( "data: {\"div\": \"events-div\", \"content\": " - "{ \"title\": \"Status: Il y a une alerte de l'agent IA\", " + "{ \"title\": \"Status: AI agent raised an alert\", " "\"description\": \"\" } }\n\n" ) @@ -372,10 +423,10 @@ def run_simulator(self, com): img_b64_current) context_just_sent = True - logging.info("Status: Il y a une alerte de l'agent IA") + logging.info("Status: AI agent raised an alert") yield ( "data: {\"div\": \"events-div\", \"content\": " - "{ \"title\": \"Status: Il y a une alerte de l'agent IA\", " + "{ \"title\": \"Status: AI agent raised an alert\", " "\"description\": \"\" } }\n\n" ) @@ -407,19 +458,19 @@ def run_simulator(self, com): context_just_sent = True logging.info( - "Status: Il y a un événement de type 'anticipation N-1' ") + "Status: N-1 anticipation event detected") message = { "div": "message-container", - "content": "Status: Il y a un événement de type 'anticipation N-1' " + "content": "Status: N-1 anticipation event detected" } yield f"data: {json.dumps(message)}\n\n" for x in self.listen.anticipation: logging.info( - "Il y a un événement d'anticipation de perte de ligne %s", x) + "N-1 anticipation event for line loss: %s", x) yield ( f"data: {{\"div\": \"events-div\", \"content\": " - f"{{ \"title\": \"Il y a un événement d'anticipation de perte de ligne\", " + f"{{ \"title\": \"N-1 anticipation event: risk of line loss\", " f"\"description\": \"{x}\"}} }}\n\n" ) @@ -441,19 +492,6 @@ def run_simulator(self, com): case_anticip=True) event_resolved_trigger = True obs_forecast = None - if self.obs.current_step >= self.config['scenario_first_step']: - message = { - "div": "message-container", - "content": "La simulation est en pause." - } - yield f"data: {json.dumps(message)}\n\n" - yield ( - "data: {\"div\": \"status-div\", \"content\": " - "\"Cliquez sur 'Continuer' pour poursuivre la simulation.\"}\n\n" - ) - set_pause(True) - while get_pause_status(): - time.sleep(1) if "Line lost" in self.listen.current_issues: if self.obs.current_step >= self.config['scenario_first_step']: @@ -470,19 +508,19 @@ def run_simulator(self, com): img_b64_current) context_just_sent = True - logging.info("Status: Il y a une perte de ligne %s", + logging.info("Status: Line loss detected: %s", get_curent_lines_lost(self.obs)) - + logging.info( - "Status: Il y a un événement de type 'perte de ligne' ") + "Status: Line lost event detected") message = { "div": "message-container", - "content": "Status: Il y a une perte de ligne ' " + "content": "Status: Line loss detected" } yield f"data: {json.dumps(message)}\n\n" yield ( f"data: {{\"div\": \"events-div\", \"content\": " - f"{{ \"title\": \"Status: Il y a une perte de ligne \" , " + f"{{ \"title\": \"Status: Line loss detected\" , " f"\"description\": \"{get_curent_lines_lost(self.obs)}\" }}}}\n\n" ) @@ -500,19 +538,6 @@ def run_simulator(self, com): self.obs), case_line_lost=True) event_resolved_trigger = True - if self.obs.current_step >= self.config['scenario_first_step']: - message = { - "div": "message-container", - "content": "La simulation est en pause." - } - yield f"data: {json.dumps(message)}\n\n" - yield ( - "data: {\"div\": \"status-div\", \"content\": " - "\"Cliquez sur 'Continuer' pour poursuivre la simulation.\"}\n\n" - ) - set_pause(True) - while get_pause_status(): - time.sleep(1) # -------------------------------------------------------------------- # To reconnect lines in the grid any time this agent detect a line disconnection. @@ -525,3 +550,6 @@ def run_simulator(self, com): if self.obs.current_step >= self.config['scenario_first_step']: step_counter = step_counter + 1 time.sleep(self.config['stepDuration_s']) + while get_pause_status(): + yield ": keepalive\n\n" + time.sleep(1) diff --git a/usecases_examples/PowerGrid/app/models/recommendation_store.py b/usecases_examples/PowerGrid/app/models/recommendation_store.py new file mode 100644 index 00000000..2498f825 --- /dev/null +++ b/usecases_examples/PowerGrid/app/models/recommendation_store.py @@ -0,0 +1,37 @@ +"""Shared in-process store for the latest recommendation from InteractiveAI. + +The simulator app's ``POST /api/v1/recommendations`` endpoint writes the +recommendation here, and the simulation loop reads it back directly. Because +both live in the same process, this avoids any HTTP round-trip from the app to +itself (which is fragile behind a reverse proxy / loopback and was returning +404 Not Found). + +This module intentionally imports nothing from the rest of the application so +it can be shared by both the Flask routes and ``Communicate`` without creating +a circular import. +""" + + +class RecommendationStore: + """Holds the most recent recommendation payload in memory.""" + + def __init__(self): + self._data = {} + + def set(self, data): + """Store a recommendation payload received from InteractiveAI.""" + self._data = data or {} + + def pop(self): + """Return the stored recommendation and clear it (single consumption).""" + data = self._data + self._data = {} + return data + + def peek(self): + """Return the stored recommendation without clearing it.""" + return self._data + + +# Process-wide singleton shared by the API endpoints and the simulation loop. +store = RecommendationStore() diff --git a/usecases_examples/PowerGrid/app/models/utils.py b/usecases_examples/PowerGrid/app/models/utils.py index 4880c8be..f3069400 100644 --- a/usecases_examples/PowerGrid/app/models/utils.py +++ b/usecases_examples/PowerGrid/app/models/utils.py @@ -140,7 +140,7 @@ def expand_act_from_cab(env, act_dict): Action: An action compliant with the environment. """ act = env.action_space() - logging.info("act_dict {act_dict}") + logging.info("Expanding action received from InteractiveAI: %s", act_dict) act.from_json(act_dict) act_vect = act.to_vect() actnew = env.action_space() @@ -148,6 +148,133 @@ def expand_act_from_cab(env, act_dict): return actnew +def summarize_action(act): + """ + Build a log/UI friendly summary of the concrete impact of a grid2op action. + + Args: + act: A grid2op BaseAction. + + Returns: + dict: 'is_do_nothing' flag, the readable 'text' description and a + best-effort structured 'impact' (set_bus / set_line_status / ...). + """ + summary = {"is_do_nothing": True, "text": str(act), "impact": {}} + try: + summary["is_do_nothing"] = not act.can_affect_something() + except Exception as e: + logging.error(e) + try: + summary["impact"] = act.impact_on_objects() + except Exception as e: + logging.error(e) + return summary + + +def verify_action_applied(pending, topo_before, line_status_before, + obs_after, info, overwritten=False): + """ + Determine whether a recommendation was effectively applied to the grid. + + A grid2op action that is illegal or ambiguous is silently ignored by + ``env.step``: nothing changes on the grid. This compares the grid state + before and after the step and inspects the ``info`` returned by the step to + produce an honest verdict. + + Args: + pending: dict describing the action that was supposed to be applied + (keys: 'step_issued', 'summary'). + topo_before: obs.topo_vect captured just before env.step. + line_status_before: obs.line_status captured just before env.step. + obs_after: observation returned by env.step. + info: the info dict returned by env.step. + overwritten: True if a scripted scenario action overrode the recommendation. + + Returns: + dict: structured verification result, ready to log and to display. + """ + is_illegal = bool(info.get("is_illegal", False)) + is_ambiguous = bool(info.get("is_ambiguous", False)) + exceptions = [str(e) for e in info.get("exception", []) if e is not None] + + changes = {"changed": False, "bus_changes": [], "line_status_changes": []} + try: + diff_idx = np.where(topo_before != obs_after.topo_vect)[0] + for i in diff_idx.tolist(): + changes["bus_changes"].append({ + "topo_vect_id": int(i), + "from_bus": int(topo_before[i]), + "to_bus": int(obs_after.topo_vect[i]), + }) + ls_idx = np.where(line_status_before != obs_after.line_status)[0] + for i in ls_idx.tolist(): + changes["line_status_changes"].append({ + "line_id": int(i), + "line_name": str(obs_after.name_line[i]), + "connected": bool(obs_after.line_status[i]), + }) + changes["changed"] = bool(len(diff_idx) or len(ls_idx)) + except Exception as e: + logging.error(e) + + is_do_nothing = bool(pending.get("summary", {}).get("is_do_nothing", False)) + + if overwritten: + status = "OVERRIDDEN" + elif is_illegal or is_ambiguous: + status = "REJECTED" + elif changes["changed"]: + status = "APPLIED" + elif is_do_nothing: + status = "NO_OP" + else: + status = "NO_EFFECT" + + return { + "status": status, + "step_issued": pending.get("step_issued"), + "step_applied": int(obs_after.current_step), + "is_illegal": is_illegal, + "is_ambiguous": is_ambiguous, + "exceptions": exceptions, + "changes": changes, + } + + +def format_verdict_description(verdict): + """ + Build a short human-readable description of an action verification verdict. + + Args: + verdict: dict produced by :func:`verify_action_applied`. + + Returns: + str: one-line description suitable for the dashboard and the logs. + """ + parts = [] + bus = verdict["changes"]["bus_changes"] + lines = verdict["changes"]["line_status_changes"] + if bus: + parts.append(f"{len(bus)} bus assignment(s) changed") + if lines: + flips = ", ".join( + f"{c['line_name']} " + f"{'reconnected' if c['connected'] else 'disconnected'}" + for c in lines) + parts.append(f"line status: {flips}") + if verdict["is_illegal"]: + parts.append("action was illegal") + if verdict["is_ambiguous"]: + parts.append("action was ambiguous") + if verdict["exceptions"]: + parts.append("; ".join(verdict["exceptions"])) + if not parts: + parts.append("no observable change on the grid") + return (f"Issued at step {verdict['step_issued']}, " + f"checked at step {verdict['step_applied']}: " + + "; ".join(parts)) + + def load_assistant(assistant_path, assistant_seed, env): """ Loads the assistant agent. diff --git a/usecases_examples/PowerGrid/app/templates/dashboard.html b/usecases_examples/PowerGrid/app/templates/dashboard.html index 01f57a4b..d867da38 100644 --- a/usecases_examples/PowerGrid/app/templates/dashboard.html +++ b/usecases_examples/PowerGrid/app/templates/dashboard.html @@ -1,9 +1,9 @@ - + - Tableau de bord + Dashboard @@ -210,12 +210,12 @@

PowerGrid Simulator based on Grid2Op platform