diff --git a/.gitignore b/.gitignore index 286604b7..617c9465 100644 --- a/.gitignore +++ b/.gitignore @@ -198,4 +198,6 @@ go.sh !frontend/.vscode !frontend/env/ !frontend/env/.env -hmisurveys/ \ No newline at end of file +/config/dev/cab-standalone/.secrets.pre-simplify + +hmisurveys/ diff --git a/README.md b/README.md index 3b4fb7a1..33d32a2e 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,8 @@ git clone [repo-url] InteractiveAI offers versatile deployment options, leveraging either Docker or Kubernetes. The primary method entails initiating InteractiveAI via Docker to launch all services concurrently. However, recognizing potential resource strain in this mode, we've introduced alternative configurations. These configurations enable selective startup of essential services with minimal dependencies, catering to streamlined versions of certain APIs. Below are the steps to start all services. For other methods, please consult the developer guide. +> **_NOTE:_** `./local_setup.sh` runs all the steps below (`./local_stop.sh` stops everything). With `--a3s`, recommendations come from a running [A3S]() instead of the expert agent. + ### Running All Services (Dev Mode) 1. **Set-up environment variables** diff --git a/backend/cab_common/cab_common_auth/settings.py b/backend/cab_common/cab_common_auth/settings.py index 5595cd35..be9997aa 100644 --- a/backend/cab_common/cab_common_auth/settings.py +++ b/backend/cab_common/cab_common_auth/settings.py @@ -30,6 +30,8 @@ } }, "root": {"level": "DEBUG", "handlers": ["wsgi"]}, + # Avoid silently disabling loggers already set up by the host service. + "disable_existing_loggers": False, } logging.config.dictConfig(DEFAULT_LOGGING) diff --git a/backend/context-service/resources/PowerGrid/schemas.py b/backend/context-service/resources/PowerGrid/schemas.py index 54645552..2d29d1bc 100644 --- a/backend/context-service/resources/PowerGrid/schemas.py +++ b/backend/context-service/resources/PowerGrid/schemas.py @@ -5,3 +5,5 @@ class MetadataSchemaPowerGrid(MetadataSchema): topology = String(allow_none=False) observation = Dict(allow_none=False) + # Serialized environment state, so agents can rebuild it by replay. Optional. + environment_state = Dict(allow_none=True, required=False) diff --git a/backend/recommendation-service/api/exceptions.py b/backend/recommendation-service/api/exceptions.py index 4e675ef5..5e3fe080 100644 --- a/backend/recommendation-service/api/exceptions.py +++ b/backend/recommendation-service/api/exceptions.py @@ -4,3 +4,16 @@ class InvalidUseCase(HTTPError): status_code = 400 message = 'Use case invalid' + + +class UpstreamAgentError(HTTPError): + """The RL agent API could not be reached, or answered with an error. + + Distinct from "the agent ran and had nothing to recommend", which is an + empty list and a success. Collapsing the two into an empty 200 makes an + outage indistinguishable from a quiet grid: the caller waits on a spinner + with nothing to retry and nothing in the UI to say why. + """ + + status_code = 502 + message = 'The recommendation agent is unavailable' diff --git a/backend/recommendation-service/api/schemas.py b/backend/recommendation-service/api/schemas.py index 7c76633f..7a403a2e 100644 --- a/backend/recommendation-service/api/schemas.py +++ b/backend/recommendation-service/api/schemas.py @@ -10,6 +10,8 @@ class RecommendationAsk(Schema): # only when the operator has consented. Declared so it survives schema # validation and is forwarded verbatim to the RL agent. cognitive_snapshot = Dict() + # Optional per-request tuning, e.g. kpi_prediction_steps for A3S. + options = Dict() class RecommendationOut(Schema): @@ -20,6 +22,9 @@ class RecommendationOut(Schema): agent_type = String() actions = List(Dict()) kpis = Dict(allow_none=True) + # A3S multi-step rollouts: candidate index and rollout timestep. + branch_index = Integer(allow_none=True) + step = Integer(allow_none=True) class ProcedureOut(Schema): diff --git a/backend/recommendation-service/resources/PowerGrid/manager.py b/backend/recommendation-service/resources/PowerGrid/manager.py index aefea2cb..ca52e053 100644 --- a/backend/recommendation-service/resources/PowerGrid/manager.py +++ b/backend/recommendation-service/resources/PowerGrid/manager.py @@ -1,7 +1,9 @@ import os +import time import requests import urllib3 +from api.exceptions import UpstreamAgentError from api.manager.base_manager import BaseRecommendationManager from settings import logger @@ -24,6 +26,10 @@ def __init__(self): "http://frontend:80/rl-api/recommendation", ) self.rl_agent_api_token = os.environ.get("RL_AGENT_API_TOKEN", "") + # A3S rollouts can take tens of seconds; 30s was too tight. + self.rl_agent_api_timeout = float( + os.environ.get("RL_AGENT_API_TIMEOUT", "120") + ) super().__init__() def get_recommendation(self, request_data): @@ -45,35 +51,76 @@ def _get_rl_parades(self, request_data): request_data (dict): Full request payload with keys "event" and "context" Returns: - list[dict]: List of parade recommendations, empty on failure + list[dict]: List of parade recommendations. Empty only when the + agent ran and had nothing to propose. + + Raises: + UpstreamAgentError: If the agent API could not be reached or + answered with an error. Deliberately not swallowed into an + empty list — see the exception's docstring. """ try: headers = {} if self.rl_agent_api_token: headers["Authorization"] = f"Bearer {self.rl_agent_api_token}" + started = time.monotonic() response = requests.post( self.rl_agent_api_url, json=request_data, headers=headers, - timeout=30, + timeout=self.rl_agent_api_timeout, verify=False, # SSL cert may not be trusted inside the container ) response.raise_for_status() data = response.json() + elapsed = time.monotonic() - started + if elapsed > self.rl_agent_api_timeout / 2: + logger.warning( + "RL agent call took %.1fs of a %.0fs budget", + elapsed, + self.rl_agent_api_timeout, + ) + else: + logger.info("RL agent call took %.1fs", elapsed) logger.info(f"RL agent returned {len(data)} recommendation(s)") return data except requests.exceptions.SSLError as e: logger.error(f"SSL error calling RL agent API: {e}") - return [] + raise UpstreamAgentError( + message="Could not establish a secure connection to the " + "recommendation agent" + ) from e except requests.exceptions.HTTPError as e: - logger.error(f"HTTP error calling RL agent API: {e} — response body: {e.response.text[:500] if e.response is not None else 'N/A'}") - return [] + body = e.response.text[:500] if e.response is not None else 'N/A' + logger.error(f"HTTP error calling RL agent API: {e} — response body: {body}") + status = e.response.status_code if e.response is not None else None + raise UpstreamAgentError( + message=f"The recommendation agent returned an error " + f"({status})" if status else + "The recommendation agent returned an error", + # The agent's own message goes in the detail rather than the + # user-facing message: it is a Python traceback summary, not + # something an operator can act on. + detail={"upstream_status": status, "upstream_body": body}, + ) from e + except requests.exceptions.Timeout as e: + # Before ConnectionError: requests' Timeout subclasses it for the + # connect-timeout case, so the narrower except has to come first. + logger.error( + "Timeout calling RL agent API (%s) after %.0fs", + self.rl_agent_api_url, + self.rl_agent_api_timeout, + ) + raise UpstreamAgentError( + message="The recommendation agent did not answer in time" + ) from e except requests.exceptions.ConnectionError as e: logger.error(f"Connection error calling RL agent API ({self.rl_agent_api_url}): {e}") - return [] - except requests.exceptions.Timeout: - logger.error(f"Timeout calling RL agent API ({self.rl_agent_api_url}) after 30s") - return [] + raise UpstreamAgentError( + message="Could not reach the recommendation agent" + ) from e except Exception as e: logger.error(f"Unexpected error calling RL agent API: {type(e).__name__}: {e}") - return [] + raise UpstreamAgentError( + detail={"error": f"{type(e).__name__}: {e}"} + ) from e diff --git a/backend/recommendation-service/settings.py b/backend/recommendation-service/settings.py index b69f904d..399a68a1 100644 --- a/backend/recommendation-service/settings.py +++ b/backend/recommendation-service/settings.py @@ -14,7 +14,9 @@ 'root': { 'level': 'INFO', 'handlers': ['wsgi'] - } + }, + # Avoid silently disabling loggers already set up elsewhere. + 'disable_existing_loggers': False, } logging.config.dictConfig(DEFAULT_LOGGING) diff --git a/backend/recommendation-service/tests/test_smoke_pipeline.py b/backend/recommendation-service/tests/test_smoke_pipeline.py new file mode 100644 index 00000000..f38d54e0 --- /dev/null +++ b/backend/recommendation-service/tests/test_smoke_pipeline.py @@ -0,0 +1,39 @@ +"""Smoke test: post a context, stub out the RL agent, expect an ontology recommendation back.""" +import json + +POWERGRID_BEARER_TOKEN = "dummy-token-see-PowerGrid_auth_mocker-fixture" + + +def test_pipeline_smoke_context_to_recommendation( + client, create_usecases, PowerGrid_auth_mocker, mocker +): + mocker.patch( + "resources.PowerGrid.manager.PowerGridManager._get_rl_parades", + return_value=[], + ) + + with open("tests/tests_resources/rte_recommendation.json") as json_file: + payload = json.load(json_file) + + headers = {"Authorization": f"Bearer {POWERGRID_BEARER_TOKEN}"} + response = client.post( + "/api/v1/recommendation?use_case=PowerGrid", + headers=headers, + json=payload, + ) + + assert response.status_code == 200 + recommendations = response.get_json() + assert isinstance(recommendations, list) and len(recommendations) >= 1 + + for reco in recommendations: + assert reco["use_case"] == "PowerGrid" + assert reco["agent_type"] in {"IA", "onto"} + assert reco["title"] + assert "kpis" in reco + + onto_recos = [r for r in recommendations if r["agent_type"] == "onto"] + assert onto_recos + assert any( + "efficiency_of_the_reco" in (r["kpis"] or {}) for r in onto_recos + ) diff --git a/config/dev/cab-standalone/.secrets.example b/config/dev/cab-standalone/.secrets.example index 3ca7c1dc..a8da8ece 100644 --- a/config/dev/cab-standalone/.secrets.example +++ b/config/dev/cab-standalone/.secrets.example @@ -1,6 +1,7 @@ # Copy to .secrets and fill in your values — this file is gitignored # # RL agent (T2.1_deep_expert) — pick ONE RL_AGENT_API_URL for your environment: +# To use a local A3S instead, do not set this: run ../../../local_setup.sh --a3s. # Local dev : RL agent running on THIS host on port 5123, reached via host.docker.internal # (cab_recommendation has the host-gateway mapping) # Server : RL agent on the LAN host diff --git a/config/dev/cab-standalone/docker-compose.sh b/config/dev/cab-standalone/docker-compose.sh index 2826d66a..1157bf8e 100755 --- a/config/dev/cab-standalone/docker-compose.sh +++ b/config/dev/cab-standalone/docker-compose.sh @@ -45,9 +45,14 @@ echo "HOST_IP=${HOST_IP}" >> .env # Secrets — sourced from .secrets if present (gitignored), otherwise from shell env # In CI these are injected by GitHub Actions as environment variables. +# An RL_AGENT_API_URL already in the environment wins over the file: that is how +# `local_setup.sh --a3s` points this stack at a running A3S without editing +# .secrets, and it would otherwise be silently overwritten here. +_ENV_RL_AGENT_API_URL="${RL_AGENT_API_URL:-}" if [[ -f .secrets ]]; then source .secrets fi +RL_AGENT_API_URL="${_ENV_RL_AGENT_API_URL:-${RL_AGENT_API_URL:-}}" 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 diff --git a/config/dev/cab-standalone/docker-compose.yml b/config/dev/cab-standalone/docker-compose.yml index 077f4b77..cbfa1b6a 100644 --- a/config/dev/cab-standalone/docker-compose.yml +++ b/config/dev/cab-standalone/docker-compose.yml @@ -189,6 +189,8 @@ services: - TZ=UTC # Set timezone to UTC - RL_AGENT_API_URL=${RL_AGENT_API_URL} - RL_AGENT_API_TOKEN=${RL_AGENT_API_TOKEN} + # A3S rollouts can take tens of seconds to answer. + - RL_AGENT_API_TIMEOUT=${RL_AGENT_API_TIMEOUT:-120} command: sh -c "./entrypoint.sh" ports: - 5400:5000 diff --git a/config/dev/cab-standalone/nginx-cors-permissive.conf b/config/dev/cab-standalone/nginx-cors-permissive.conf index f99158f4..de68ddc4 100644 --- a/config/dev/cab-standalone/nginx-cors-permissive.conf +++ b/config/dev/cab-standalone/nginx-cors-permissive.conf @@ -3,6 +3,9 @@ resolver 127.0.0.11 ipv6=off; server { listen 80; server_name localhost; + + # PowerGrid environment_state payloads exceed the 1 MB nginx default. + client_max_body_size 64m; ### CUSTOMIZATION - BEGIN # Url of the Authentication provider set $KeycloakBaseUrl "http://keycloak:8080"; @@ -357,7 +360,7 @@ server { proxy_pass __POWERGRID_SIMU_UPSTREAM__; } - location /cognitive-api/ { + location ~ ^/cognitive-api/(?.*)$ { # Proxy for the INESCTEC cognitive API (avoids browser CORS restrictions) if ($request_method = 'OPTIONS') { @@ -379,7 +382,12 @@ server { proxy_set_header Authorization "Bearer __COGNITIVE_TOKEN__"; proxy_ssl_server_name on; proxy_ssl_verify off; - proxy_pass https://wesenss.inesctec.pt/api/v1/; + # The host is kept in a variable on purpose: a literal name here is resolved when + # nginx loads its config, so this third-party host being unreachable would stop the + # whole frontend from starting. As a variable it is resolved per request instead, + # and only this endpoint fails. The location captures the path so no rewrite is needed. + set $cognitive_host https://wesenss.inesctec.pt; + proxy_pass $cognitive_host/api/v1/$cognitive_path$is_args$args; } location /rl-api/ { diff --git a/config/dev/cab-standalone/nginx-kubernetes.conf b/config/dev/cab-standalone/nginx-kubernetes.conf index 11f75cc3..a7e61651 100644 --- a/config/dev/cab-standalone/nginx-kubernetes.conf +++ b/config/dev/cab-standalone/nginx-kubernetes.conf @@ -13,6 +13,9 @@ log_format upstreamlog '[$time_local] $remote_addr - $remote_user - $server_name server { listen 80; server_name localhost cab-dev.irtsystemx.org; + + # PowerGrid environment_state payloads exceed the 1 MB nginx default. + client_max_body_size 64m; error_log /var/log/nginx/error.log debug; access_log /var/log/nginx/access.log opfab-log; diff --git a/config/dev/cab-standalone/nginx.conf b/config/dev/cab-standalone/nginx.conf index 75078c35..ee2ecec7 100644 --- a/config/dev/cab-standalone/nginx.conf +++ b/config/dev/cab-standalone/nginx.conf @@ -11,6 +11,9 @@ log_format opfab-log '$remote_addr - $time_local_ms' server { listen 80; server_name localhost; + + # PowerGrid environment_state payloads exceed the 1 MB nginx default. + client_max_body_size 64m; access_log /var/log/nginx/access.log opfab-log; ### CUSTOMIZATION - BEGIN diff --git a/config/dev/recommendation-service/nginx.conf b/config/dev/recommendation-service/nginx.conf index b9ad9014..eca4b28c 100644 --- a/config/dev/recommendation-service/nginx.conf +++ b/config/dev/recommendation-service/nginx.conf @@ -3,6 +3,9 @@ resolver 127.0.0.11 ipv6=off; server { listen 80; server_name localhost; + + # PowerGrid environment_state payloads exceed the 1 MB nginx default. + client_max_body_size 64m; ### CUSTOMIZATION - BEGIN # Url of the Authentication provider set $KeycloakBaseUrl "http://keycloak:8080"; diff --git a/frontend/default.conf b/frontend/default.conf index e76df39a..8c95ebe8 100644 --- a/frontend/default.conf +++ b/frontend/default.conf @@ -5,6 +5,9 @@ server { listen 80; server_name localhost; + # PowerGrid environment_state payloads exceed the 1 MB nginx default. + client_max_body_size 64m; + gzip on; gzip_types application/javascript text/css; diff --git a/frontend/src/api/services.ts b/frontend/src/api/services.ts index 5892be71..300fbac8 100644 --- a/frontend/src/api/services.ts +++ b/frontend/src/api/services.ts @@ -13,6 +13,7 @@ export function getRecommendation(payload: { event: Card['data']['metadata'] context: Context cognitive_snapshot?: CognitiveSnapshot + options?: { kpi_prediction_steps?: number } }) { return http.post[]>('/cab_recommendation/api/v1/recommendation', payload) } diff --git a/frontend/src/entities/PowerGrid/CAB/Assistant.vue b/frontend/src/entities/PowerGrid/CAB/Assistant.vue index b1fef2d3..e9073f86 100644 --- a/frontend/src/entities/PowerGrid/CAB/Assistant.vue +++ b/frontend/src/entities/PowerGrid/CAB/Assistant.vue @@ -30,6 +30,9 @@ + diff --git a/frontend/src/entities/PowerGrid/CAB/kpiProjection.ts b/frontend/src/entities/PowerGrid/CAB/kpiProjection.ts new file mode 100644 index 00000000..cc5d026d --- /dev/null +++ b/frontend/src/entities/PowerGrid/CAB/kpiProjection.ts @@ -0,0 +1,39 @@ +import type { Entity } from '@/types/entities' +import type { Recommendation } from '@/types/services' + +export type KpiProjectionGroup = { + branchIndex: number + title: string + values: (number | undefined)[] +} + +// Groups rollout steps of the same candidate action together. +export function branchKey(recommendation: Recommendation) { + return recommendation.branch_index ?? recommendation.title +} + +// Regroups the flat API list (one item per rollout step) into one KPI series per candidate. +export function groupKpiProjection( + recommendations: Recommendation[], + kpiKey: string +): { steps: number[]; groups: KpiProjectionGroup[] } { + const steps = [...new Set(recommendations.map((r) => r.step ?? 1))].sort((a, b) => a - b) + const groups = new Map() + + recommendations.forEach((recommendation, index) => { + const key = branchKey(recommendation) + let group = groups.get(key) + if (!group) { + group = { + branchIndex: recommendation.branch_index ?? index, + title: recommendation.title.replace(/_step_\d+$/, ''), + values: steps.map(() => undefined) + } + groups.set(key, group) + } + const value = recommendation.kpis?.[kpiKey] + if (isFinite(value)) group.values[steps.indexOf(recommendation.step ?? 1)] = Number(value) + }) + + return { steps, groups: [...groups.values()].sort((a, b) => a.branchIndex - b.branchIndex) } +} diff --git a/frontend/src/entities/PowerGrid/locales/en.json b/frontend/src/entities/PowerGrid/locales/en.json index e11cecd4..0abcf683 100644 --- a/frontend/src/entities/PowerGrid/locales/en.json +++ b/frontend/src/entities/PowerGrid/locales/en.json @@ -17,5 +17,13 @@ "PowerGrid.kpis.redispatching_volume": "Redispatch volume", "PowerGrid.kpis.renewable_energy_share": "Proportion of renewable", "PowerGrid.kpis.total_consumption": "Total consumption", - "PowerGrid.kpis.type_of_the_reco": "Type of recommendation" + "PowerGrid.kpis.type_of_the_reco": "Type of recommendation", + "kpiProjection.show": "Show KPI projections", + "kpiProjection.close": "Close", + "kpiProjection.title": "{kpi} — projection over the next timesteps", + "kpiProjection.description": "For each candidate recommendation, projected value of the selected KPI if the action is applied and the grid then evolves under the agent's own policy for the next timesteps.", + "kpiProjection.xAxis": "Timesteps ahead", + "kpiProjection.tableCaption": "Projected {kpi} value per candidate recommendation and timestep ahead", + "kpiProjection.tableRecommendation": "Recommendation", + "kpiProjection.step": "t+{step}" } diff --git a/frontend/src/entities/PowerGrid/locales/fr.json b/frontend/src/entities/PowerGrid/locales/fr.json index 4c1666cb..64593b72 100644 --- a/frontend/src/entities/PowerGrid/locales/fr.json +++ b/frontend/src/entities/PowerGrid/locales/fr.json @@ -17,5 +17,13 @@ "PowerGrid.kpis.distance_from_reference_topology": "Distance à la topologie de référence", "PowerGrid.kpis.curtailment_volume": "Volume de curtailment", "PowerGrid.kpis.redispatching_volume": "Volume de redispatching", - "PowerGrid.kpis.type_of_the_reco": "Type de la parade" + "PowerGrid.kpis.type_of_the_reco": "Type de la parade", + "kpiProjection.show": "Afficher les projections de KPI", + "kpiProjection.close": "Fermer", + "kpiProjection.title": "{kpi} — projection sur les prochains pas de temps", + "kpiProjection.description": "Pour chaque parade candidate, valeur projetée du KPI sélectionné si l'action est appliquée puis que le réseau évolue selon la politique de l'agent sur les prochains pas de temps.", + "kpiProjection.xAxis": "Pas de temps à venir", + "kpiProjection.tableCaption": "Valeur projetée du KPI {kpi} par parade candidate et pas de temps à venir", + "kpiProjection.tableRecommendation": "Parade", + "kpiProjection.step": "t+{step}" } diff --git a/frontend/src/entities/PowerGrid/types.ts b/frontend/src/entities/PowerGrid/types.ts index d963a438..4ad4ce40 100644 --- a/frontend/src/entities/PowerGrid/types.ts +++ b/frontend/src/entities/PowerGrid/types.ts @@ -61,6 +61,12 @@ export type PowerGrid = { year: [number] } topology: string + // Opaque state envelope, forwarded to the recommendation service so A3S can rebuild the environment. + environment_state?: { + serializer: string + state: Record + metadata?: Record + } } Metadata: { event_type: 'KPI' | 'anticipation' | 'agent' | 'consignation' diff --git a/frontend/src/stores/services.ts b/frontend/src/stores/services.ts index 582f5c0a..fad01b3e 100644 --- a/frontend/src/stores/services.ts +++ b/frontend/src/stores/services.ts @@ -1,8 +1,8 @@ import { defineStore } from 'pinia' import { ref } from 'vue' -import { fetchCognitiveSnapshot } from '@/api/cognitive' import type { CognitiveSnapshot } from '@/api/cognitive' +import { fetchCognitiveSnapshot } from '@/api/cognitive' import * as servicesApi from '@/api/services' import i18n from '@/plugins/i18n' import type { Card } from '@/types/cards' @@ -132,9 +132,12 @@ export const useServicesStore = defineStore('services', () => { event: Card['data']['metadata'] context: Context cognitive_snapshot?: CognitiveSnapshot + options?: { kpi_prediction_steps?: number } } = { event: getRootCard(event).data.metadata, - context: contextForAgent + context: contextForAgent, + // A3S rolls this out; other managers ignore it. + options: { kpi_prediction_steps: 6 } } if (hasCognitiveConsent()) { payload.cognitive_snapshot = await fetchCognitiveSnapshot() diff --git a/frontend/src/types/services.ts b/frontend/src/types/services.ts index 75756293..1fc30c0d 100644 --- a/frontend/src/types/services.ts +++ b/frontend/src/types/services.ts @@ -8,6 +8,13 @@ export type Recommendation = { title: string actions: Action[] kpis?: { [key: string]: any } + // A3S multi-step rollouts: candidate index and rollout timestep. + branch_index?: number + step?: number + // Whether this rollout step ended the episode (terminal-state KPIs). + done?: boolean + // Environment's absolute clock, vs `step`'s rollout-relative count. + env_timestep?: number } export type FullContext = { diff --git a/local_setup.sh b/local_setup.sh new file mode 100755 index 00000000..3f134797 --- /dev/null +++ b/local_setup.sh @@ -0,0 +1,403 @@ +#!/usr/bin/env bash +# +# local_setup.sh — one-shot local setup for the InteractiveAI backend + PowerGrid simulator. +# +# Steps: start the backend, wait for Keycloak + frontend, configure Keycloak via +# the admin REST API (manual prompt as fallback), load OperatorFabric resources, +# build and start the PowerGrid simulator, reload the frontend nginx and verify +# the recommendation path. +# +# Usage: +# ./local_setup.sh # full setup (prompts if containers already run) +# ./local_setup.sh --clean # tear down existing containers first, no prompt +# ./local_setup.sh --wipe # tear down existing containers AND volumes, no prompt +# ./local_setup.sh --a3s [URL] # take recommendations from an already-running A3S +# # (default URL http://host.docker.internal:5010/api/v1/recommendation) +# +# This script never starts A3S. Start it first from a3s-service/ with +# ./docker/local_setup.sh, then pass --a3s here. +# +# Overridable via environment: +# KC_ADMIN (admin) KC_PW (admin) FRONTEND_URL (http://localhost:3200) +# +# Secrets (RL_AGENT_API_URL / RL_AGENT_API_TOKEN / COGNITIVE_TOKEN) are read from +# config/dev/cab-standalone/.secrets if present (see .secrets.example). + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BACKEND_DIR="$REPO_ROOT/config/dev/cab-standalone" +RESOURCES_DIR="$REPO_ROOT/resources" +SIM_DIR="$REPO_ROOT/usecases_examples/PowerGrid" +SIM_COMPOSE="docker-compose.local.yml" # server config lives in docker-compose.yml +SIM_PORT=5122 # must match POWERGRID_SIMU_UPSTREAM in .env +# Where --a3s points the recommendation service when no URL is given. host.docker.internal +# is how the containers reach a service published on the host. +A3S_DEFAULT_URL="http://host.docker.internal:5010/api/v1/recommendation" +# Serializer name the simulator stamps into environment_state; must match what A3S accepts. +SIM_SERIALIZER_SRC="$REPO_ROOT/usecases_examples/PowerGrid/app/models/env_serialization.py" + +KC_BASE="http://localhost:89/auth" # Keycloak 16.x (legacy /auth base path) +KC_REALM="dev" +KC_CLIENT="opfab-client" +KC_ADMIN="${KC_ADMIN:-admin}" +KC_PW="${KC_PW:-admin}" + +FRONTEND_URL="${FRONTEND_URL:-http://localhost:3200}" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +log() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; } +ok() { printf '\033[1;32m ✓ %s\033[0m\n' "$*"; } +warn() { printf '\033[1;33m ! %s\033[0m\n' "$*"; } +die() { printf '\033[1;31m ✗ %s\033[0m\n' "$*" >&2; exit 1; } + +# Emit an OSC 8 terminal hyperlink; degrades to plain text if unsupported. +link() { printf '\033]8;;%s\033\\%s\033]8;;\033\\' "$1" "$1"; } + +# Block until an HTTP endpoint answers with the wanted status, or time out. +wait_for_http() { + local url="$1" want="${2:-200}" tries="${3:-90}" i=1 code + while (( i <= tries )); do + code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 "$url" || true)" + [[ "$code" == "$want" ]] && return 0 + printf ' waiting for %s (%s/%s, last=%s)\r' "$url" "$i" "$tries" "$code" + sleep 2; (( i++ )) + done + printf '\n'; return 1 +} + +require() { command -v "$1" >/dev/null 2>&1 || die "'$1' is required but not installed."; } + +# True if $1 is the name of a currently running container. +container_running() { docker ps --format '{{.Names}}' | grep -qx "$1"; } + +# --------------------------------------------------------------------------- +# Gateway / A3S robustness +# --------------------------------------------------------------------------- +# nginx caches upstream IPs at config load, so a container recreated afterwards +# 502s until reloaded. Reload against the substituted config (-c), not the raw +# templated one nginx -s reload would otherwise validate. +frontend_nginx_reload() { + container_running frontend || { warn "frontend not running — skipping nginx reload"; return 0; } + if docker exec frontend nginx -c /personal-conf/nginx.conf -s reload >/dev/null 2>&1; then + ok "frontend nginx reloaded (upstream IPs re-resolved)" + elif docker exec frontend nginx -s reload >/dev/null 2>&1; then + ok "frontend nginx reloaded" + else + warn "nginx reload failed — restarting the frontend container instead" + docker restart frontend >/dev/null 2>&1 || { warn "frontend restart failed"; return 0; } + wait_for_http "$FRONTEND_URL/" 200 >/dev/null || warn "frontend not answering 200 yet" + ok "frontend restarted" + fi +} + +# --a3s: A3S runs on its own (a3s-service/docker/local_setup.sh), so all this +# does is point the recommendation service at it — after confirming it answers, +# because otherwise the only symptom is an empty recommendation panel at the end +# of a ten-minute setup. +require_a3s() { + local health="${RL_AGENT_API_URL%/recommendation}/health" + # The containers reach it via host.docker.internal; from here it is localhost. + local host_health="${health/host.docker.internal/localhost}" + if wait_for_http "$host_health" 200 3 >/dev/null; then + ok "A3S is answering at $host_health" + return 0 + fi + warn "no A3S answering at $host_health" + warn " start it first: cd $REPO_ROOT/a3s-service && ./docker/local_setup.sh" + warn " or pass the URL: ./local_setup.sh --a3s http://host.docker.internal:/api/v1/recommendation" + die "A3S is not running" +} + +# Checks the gateway route, the RL agent, and the simulator/A3S serializer contract. +verify_recommendation_path() { + local failures=0 + + if wait_for_http "$FRONTEND_URL/cab_recommendation/api/v1/health" 200 15 >/dev/null; then + ok "gateway route $FRONTEND_URL/cab_recommendation/ reaches the recommendation service" + else + warn "gateway route to the recommendation service is not answering 200" + warn " the browser calls it at /cab_recommendation/ — a 502 here means a stale nginx upstream" + failures=$(( failures + 1 )) + fi + + if [[ -n "${RL_AGENT_API_URL:-}" ]]; then + # Resolve from inside the recommendation container, whose network namespace RL_AGENT_API_URL targets. + local health_url="${RL_AGENT_API_URL%/recommendation}/health" + if container_running cab_recommendation && docker exec cab_recommendation python -c " +import sys, urllib.request, ssl +ctx = ssl._create_unverified_context() +try: + urllib.request.urlopen('$health_url', timeout=8, context=ctx) +except Exception as e: + sys.exit(str(e) or 'unreachable') +" >/dev/null 2>&1; then + ok "RL agent reachable from cab_recommendation at $health_url" + # May point at a standalone A3S on the host; find it by its published port. + local port a3s_container + port="$(sed -E 's#.*://[^/:]+:([0-9]+).*#\1#' <<<"$RL_AGENT_API_URL")" + if [[ "$port" =~ ^[0-9]+$ ]]; then + a3s_container="$(docker ps --format '{{.Names}}\t{{.Ports}}' \ + | awk -F'\t' -v p=":$port->" 'index($2, p) {print $1; exit}')" + if [[ -n "$a3s_container" ]]; then + verify_serializer_contract "$a3s_container" || failures=$(( failures + 1 )) + fi + fi + else + warn "RL agent at \$RL_AGENT_API_URL is NOT reachable from inside cab_recommendation" + warn " URL: $RL_AGENT_API_URL" + warn " a container on another compose network is not reachable by name —" + warn " use http://host.docker.internal:/api/v1/recommendation" + failures=$(( failures + 1 )) + fi + else + warn "RL_AGENT_API_URL is not set — only the ontology recommender will run" + fi + + return "$failures" +} + +# Compares the simulator's serializer name against what A3S accepts. +# $1 = name of the container running A3S. +verify_serializer_contract() { + local a3s="$1" emitted accepted + emitted="$(grep -oE '"serializer": [A-Z0-9_]+' "$SIM_SERIALIZER_SRC" 2>/dev/null | awk '{print $2}' | head -1)" + [[ -n "$emitted" ]] || { warn "could not determine the simulator's serializer from $SIM_SERIALIZER_SRC"; return 0; } + emitted="$(grep -oE "^${emitted} = \"[a-z0-9_]+\"" "$SIM_SERIALIZER_SRC" | sed 's/.*"\(.*\)"/\1/' | head -1)" + [[ -n "$emitted" ]] || { warn "could not resolve the simulator's serializer constant"; return 0; } + + accepted="$(docker exec "$a3s" sh -c 'grep -hoE "grid2op_observation_v[0-9]+" /my_app/integrations/powergrid/serialization.py 2>/dev/null | sort -u' 2>/dev/null || true)" + if [[ -z "$accepted" ]]; then + warn "could not read the serializers '$a3s' accepts — skipping the contract check" + return 0 + fi + if grep -qx "$emitted" <<<"$accepted"; then + ok "serializer contract OK — simulator emits '$emitted', '$a3s' accepts it" + return 0 + fi + warn "SERIALIZER MISMATCH — recommendations will silently come back empty" + warn " simulator emits: $emitted" + warn " '$a3s' accepts: $(tr '\n' ' ' <<<"$accepted")" + warn " that A3S image predates the simulator's payload format — rebuild it:" + warn " cd $REPO_ROOT/a3s-service && ./docker/local_setup.sh --rebuild" + return 1 +} + +# --------------------------------------------------------------------------- +# Keycloak configuration via the admin REST API +# --------------------------------------------------------------------------- +kc_admin_token() { + curl -s --max-time 10 -X POST \ + "$KC_BASE/realms/master/protocol/openid-connect/token" \ + -d client_id=admin-cli -d "username=$KC_ADMIN" -d "password=$KC_PW" \ + -d grant_type=password \ + | python3 -c 'import sys,json; print(json.load(sys.stdin).get("access_token",""))' 2>/dev/null +} + +# Returns 0 on success, non-zero if anything went wrong (caller then prompts). +kc_configure() { + local token realm updated code + token="$(kc_admin_token)" + [[ -n "$token" ]] || { warn "could not obtain a Keycloak admin token"; return 1; } + + # Without a matching Frontend URL, token issuer URLs mismatch and auth returns 401. + realm="$(curl -s --max-time 10 -H "Authorization: Bearer $token" "$KC_BASE/admin/realms/$KC_REALM")" + updated="$(printf '%s' "$realm" | FRONTEND_URL="$FRONTEND_URL" python3 -c ' +import sys, json, os +d = json.load(sys.stdin) +attrs = d.get("attributes") or {} +attrs["frontendUrl"] = os.environ["FRONTEND_URL"] +d["attributes"] = attrs +print(json.dumps(d))' 2>/dev/null)" + [[ -n "$updated" ]] || { warn "could not read/patch the '$KC_REALM' realm"; return 1; } + code="$(curl -s -o /dev/null -w '%{http_code}' -X PUT \ + "$KC_BASE/admin/realms/$KC_REALM" \ + -H "Authorization: Bearer $token" -H "Content-Type: application/json" \ + -d "$updated")" + [[ "$code" == 2* ]] || { warn "realm update returned HTTP $code"; return 1; } + ok "realm '$KC_REALM' Frontend URL set to $FRONTEND_URL" + return 0 +} + +# Manual fallback: pause and let the operator configure Keycloak by hand. +kc_manual_prompt() { + cat < General -> Frontend URL = $FRONTEND_URL -> Save + 4. Clients -> '$KC_CLIENT' -> Valid Redirect URIs must include + ${FRONTEND_URL%/}/* (and Web Origins ${FRONTEND_URL}) -> Save + ------------------------------------------------------------------ +EOF + read -r -p " Press ENTER once Keycloak is configured to continue... " _ +} + +# --------------------------------------------------------------------------- +# Existing-container detection / teardown +# --------------------------------------------------------------------------- +# Lists running containers of the compose project rooted at $1. $2 = optional compose file. +compose_running() { + ( cd "$1" && docker compose ${2:+-f "$2"} ps --format ' {{.Name}} ({{.Status}})' 2>/dev/null ) || true +} + +# Remove both compose stacks. $1 = extra `down` args (e.g. "-v" to drop volumes). +teardown_stacks() { + local extra="${1:-}" + log "Tearing down existing containers${extra:+ and volumes} for a clean rebuild" + ( cd "$SIM_DIR" && docker compose -f "$SIM_COMPOSE" down $extra 2>/dev/null ) || true + ( cd "$BACKEND_DIR" && docker compose down $extra 2>/dev/null ) || true + ok "existing containers removed" +} + +# Ask what to do if our containers are already running. $1: "" ask, "clean" down, "wipe" down -v. +handle_existing_containers() { + local mode="${1:-}" running + running="$(compose_running "$BACKEND_DIR"; compose_running "$SIM_DIR" "$SIM_COMPOSE")" + + if [[ -z "$running" ]]; then + ok "no existing project containers running" + return 0 + fi + + warn "Found running containers from this setup:" + printf '%s\n' "$running" + + case "$mode" in + clean) teardown_stacks "" ; return 0 ;; + wipe) teardown_stacks "-v" ; return 0 ;; + esac + + if [[ ! -t 0 ]]; then + warn "non-interactive shell and no --clean/--wipe flag: leaving containers as-is" + return 0 + fi + + local reply + read -r -p " Kill them and rebuild clean? [y]es / [w]ipe data too / [N]o, keep running: " reply + case "${reply,,}" in + y|yes) teardown_stacks "" ;; + w|wipe) teardown_stacks "-v" ;; + *) warn "leaving existing containers in place (continuing)" ;; + esac +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +main() { + # Deliberately not named USE_A3S: .secrets is sourced into this scope below, + # and an old `export USE_A3S=1` there would silently turn this flag on. + local CLEAN_MODE="" A3S_MODE=0 A3S_URL="" + while (( $# )); do + case "$1" in + --clean) CLEAN_MODE="clean" ;; + --wipe) CLEAN_MODE="wipe" ;; + # --a3s takes an optional URL: anything that is not another flag. + --a3s) A3S_MODE=1 + [[ "${2:-}" == -* || -z "${2:-}" ]] || { A3S_URL="$2"; shift; } ;; + -h|--help) sed -n '3,24p' "${BASH_SOURCE[0]}"; exit 0 ;; + *) die "unknown argument: $1 (see --help)" ;; + esac + shift + done + + log "Checking prerequisites" + require docker; require curl; require python3 + docker compose version >/dev/null 2>&1 || die "'docker compose' (v2) is required." + ok "docker, docker compose, curl, python3 present" + + # Source .secrets in THIS shell too (docker-compose.sh sources it in a + # subshell), so the verification step below knows which agent is configured. + if [[ -f "$BACKEND_DIR/.secrets" ]]; then + # shellcheck disable=SC1091 # path is runtime-resolved, gitignored + source "$BACKEND_DIR/.secrets" + else + warn "no config/dev/cab-standalone/.secrets file — using default RL agent API and no cognitive token" + warn "copy .secrets.example to .secrets to override (see docker-compose.sh)" + fi + + # --a3s wins over whatever .secrets says: it is the more explicit statement of + # where recommendations come from for this run. docker-compose.sh sources + # .secrets in its own shell, so export it rather than just setting it. + if (( A3S_MODE )); then + log "Using A3S for recommendations" + export RL_AGENT_API_URL="${A3S_URL:-$A3S_DEFAULT_URL}" + require_a3s + ok "recommendation service will call $RL_AGENT_API_URL" + fi + + log "Checking for existing containers" + handle_existing_containers "$CLEAN_MODE" + + log "Step 1/6 — Starting the InteractiveAI backend" + ( cd "$BACKEND_DIR" && ./docker-compose.sh ) + ok "backend compose brought up" + + # Force a rebuild from this repo's source: docker-compose.sh's `up -d` reuses + # existing images as-is, so edits here would otherwise not reach the containers. + log "Rebuilding frontend and cabrecommendation from this repo's source" + ( cd "$BACKEND_DIR" && docker compose up -d --build --force-recreate frontend cabrecommendation ) + ok "frontend, cabrecommendation rebuilt from source" + + log "Step 2/6 — Waiting for Keycloak" + wait_for_http "$KC_BASE/realms/master" 200 || die "Keycloak did not come up on :89" + ok "Keycloak is up" + + log "Step 3/6 — Configuring Keycloak" + if kc_configure; then + ok "Keycloak configured automatically" + else + kc_manual_prompt + fi + log "Restarting the frontend to pick up the Keycloak change" + docker restart frontend >/dev/null && ok "frontend restarted" + wait_for_http "$FRONTEND_URL/" 200 || warn "frontend not answering 200 yet (continuing)" + + log "Step 4/6 — Loading resources and registering use cases" + # Wait until auth actually works end-to-end before loading (avoids 401s). + local i=1 + while true; do + unset token + source "$RESOURCES_DIR/getToken.sh" admin "${FRONTEND_URL%:*}" >/dev/null 2>&1 || true + [[ -n "${token:-}" ]] && break + (( i > 24 )) && die "auth never became ready ($FRONTEND_URL/auth/token)" + printf ' waiting for auth to be ready (%s/24)\r' "$i"; sleep 5; (( i++ )) + done + printf '\n'; ok "auth is ready" + ( cd "$RESOURCES_DIR" && ./loadTestConf.sh ) + ok "resources loaded, use cases registered" + + log "Step 5/6 — Building and starting the PowerGrid simulator" + # The default docker-compose.yml is the server config (wrong port); use the local one. + ( cd "$SIM_DIR" && docker compose -f "$SIM_COMPOSE" up -d --build --force-recreate app ) + ok "PowerGrid simulator started" + + # Last, once nothing else will be recreated, so the re-resolved upstream IPs stick. + log "Step 6/6 — Re-pointing the gateway and verifying the recommendation path" + frontend_nginx_reload + if verify_recommendation_path; then + ok "recommendation path verified end to end" + else + warn "the recommendation path is NOT fully working — see the warnings above" + warn "the UI will come up, but the PowerGrid recommendation panel may stay empty" + fi + + printf '\n\033[1;32mSetup complete.\033[0m\n\n' + # powergrid_user is provisioned with the PowerGrid entity; publisher_test isn't. + printf ' InteractiveAI UI %s (powergrid_user / test)\n' "$(link "$FRONTEND_URL")" + printf ' PowerGrid simulator %s (powergrid_user / test) (also proxied same-origin at %s/powergrid-simu/)\n' "$(link "http://localhost:$SIM_PORT")" "$FRONTEND_URL" + printf ' Keycloak admin %s (admin / admin)\n' "$(link "$KC_BASE/admin")" + printf '\n In the simulator, pick server %s and log in.\n' "$(link "http://host.docker.internal:3200/")" +} + +main "$@" diff --git a/local_stop.sh b/local_stop.sh new file mode 100755 index 00000000..f576d8fe --- /dev/null +++ b/local_stop.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# +# local_stop.sh — tear down the local InteractiveAI backend + PowerGrid simulator. +# +# Usage: +# ./local_stop.sh # stop & remove containers, KEEP data volumes (default) +# ./local_stop.sh --wipe # also delete data volumes (Postgres/Mongo/Keycloak) — fresh start next time +# ./local_stop.sh --pause # just stop containers, keep them (fastest; `./local_setup.sh` or `docker compose start` to resume) +# ./local_stop.sh --help +# +# Both Docker Compose projects are handled: the backend (config/dev/cab-standalone) +# and the PowerGrid simulator (usecases_examples/PowerGrid). + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BACKEND_DIR="$REPO_ROOT/config/dev/cab-standalone" +SIM_DIR="$REPO_ROOT/usecases_examples/PowerGrid" +# Local dev uses docker-compose.local.yml, not the default (server) compose file. +SIM_COMPOSE="docker-compose.local.yml" + +log() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; } +ok() { printf '\033[1;32m ✓ %s\033[0m\n' "$*"; } +die() { printf '\033[1;31m ✗ %s\033[0m\n' "$*" >&2; exit 1; } + +case "${1:-}" in + "") MSG="Stopping everything (containers removed, data volumes kept)" + COMPOSE_CMD="down" ;; + --wipe|--volumes) MSG="Stopping everything and DELETING data volumes (fresh start next time)" + COMPOSE_CMD="down -v" ;; + --pause|--stop) MSG="Pausing everything (containers kept, resume later)" + COMPOSE_CMD="stop" ;; + -h|--help) sed -n '3,13p' "${BASH_SOURCE[0]}"; exit 0 ;; + *) die "unknown argument: ${1} (see --help)" ;; +esac + +# Run the chosen teardown command in a compose project directory. +# $2 = optional compose file. +run() { + ( cd "$1" && docker compose ${2:+-f "$2"} $COMPOSE_CMD 2>/dev/null ) || true +} + +log "$MSG" + +# Simulator first, then backend it depends on. +run "$SIM_DIR" "$SIM_COMPOSE" +run "$BACKEND_DIR" + +ok "done" diff --git a/usecases_examples/PowerGrid/Dockerfile.app b/usecases_examples/PowerGrid/Dockerfile.app index 782ea872..8f9d46ba 100644 --- a/usecases_examples/PowerGrid/Dockerfile.app +++ b/usecases_examples/PowerGrid/Dockerfile.app @@ -7,7 +7,6 @@ RUN mkdir /code COPY . /code/ WORKDIR /code -RUN apt-get update && apt-get install ffmpeg libsm6 libxext6 -y RUN pip3 install -r requirements-app.txt CMD ["python3", "PowerGrid_poc_simulator_app.py"] \ No newline at end of file diff --git a/usecases_examples/PowerGrid/app/models/Communicate.py b/usecases_examples/PowerGrid/app/models/Communicate.py index 9e779eb8..28793a58 100644 --- a/usecases_examples/PowerGrid/app/models/Communicate.py +++ b/usecases_examples/PowerGrid/app/models/Communicate.py @@ -7,6 +7,7 @@ import numpy as np from config.config import logging, set_pause, get_pause_status from app.models.recommendation_store import store as recommendation_store +from app.models.env_serialization import build_environment_state class Communicate: """ @@ -227,7 +228,22 @@ def login(self, username, password): authorization = True return url, authorization - def send_context_online(self, obs, scn_first_step, context_date, img_b64): + def environment_state_payload(self, obs, env_identity, recorder): + """Builds the `environment_state` envelope, or None if disabled/unavailable.""" + if not env_identity or recorder is None or not len(recorder): + return None + + context_config = self.outputs_config['Outputs']['Context'] + if str(context_config.get('serialize_env_state', 'yes')).lower() != 'yes': + return None + + return build_environment_state( + obs, env_identity, recorder, + float(context_config.get('max_state_mb', 32)) + ) + + def send_context_online(self, obs, scn_first_step, context_date, img_b64, + env_identity=None, recorder=None): """ Sends the context online with the observation image. @@ -236,6 +252,8 @@ def send_context_online(self, obs, scn_first_step, context_date, img_b64): scn_first_step: First step of the scenario. context_date: Date of the context. img_b64: Base64 encoded image. + env_identity: Environment identity from `build_env_identity`. + recorder: ReplayRecorder of actions since reset; published as `environment_state`. """ if obs.current_step < scn_first_step or not self.cab_api_on: return @@ -243,12 +261,17 @@ def send_context_online(self, obs, scn_first_step, context_date, img_b64): try: url = self.cab_url + \ self.outputs_config['Outputs']['Context']['context_port'] + data = { + "observation": obs.to_json(), + "topology": img_b64 + } + environment_state = self.environment_state_payload( + obs, env_identity or {}, recorder) + if environment_state is not None: + data["environment_state"] = environment_state payload = json.dumps({ "date": f"{context_date}", - "data": { - "observation": obs.to_json(), - "topology": img_b64 - }, + "data": data, "use_case": "PowerGrid" }) headers = { diff --git a/usecases_examples/PowerGrid/app/models/Simulator.py b/usecases_examples/PowerGrid/app/models/Simulator.py index fd1bb734..17fa4075 100644 --- a/usecases_examples/PowerGrid/app/models/Simulator.py +++ b/usecases_examples/PowerGrid/app/models/Simulator.py @@ -11,6 +11,7 @@ import matplotlib matplotlib.use('agg') from app.models.Listener import Listener +from app.models.env_serialization import ReplayRecorder, build_env_identity from config.config import logging, set_pause, get_pause_status 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, @@ -35,6 +36,9 @@ def __init__(self, socketio): self.env = None self.obs = None self.act = None + # Replay history published with the context, and the env it replays against. + self.replay_recorder = ReplayRecorder() + self.env_identity = {} self.listen = None self.local_assistant = None self.com = None @@ -91,6 +95,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() + # Reset the replay history for this new episode. + self.replay_recorder.reset() + self.env_identity = build_env_identity(self.config) logging.info("Loaded scenario: %s \n", self.env.chronics_handler.get_name()) session['message'].append(f"Loaded scenario: {self.env.chronics_handler.get_name()}") @@ -173,6 +180,9 @@ def run_simulator(self, com): topo_before = self.obs.topo_vect.copy() line_status_before = self.obs.line_status.copy() + # Record before applying, so the replay history stays gap-free. + self.replay_recorder.record(act) + # Beginning of step: observation update self.obs, _, done, info = self.env.step(act) @@ -284,7 +294,9 @@ def run_simulator(self, com): com.send_context_online(self.obs, self.config['scenario_first_step'], context_date, - img_b64_current) + img_b64_current, + self.env_identity, + self.replay_recorder) event_resolved_trigger = False context_just_sent = True @@ -307,7 +319,9 @@ def run_simulator(self, com): com.send_context_online(self.obs, self.config['scenario_first_step'], context_date, - img_b64_current) + img_b64_current, + self.env_identity, + self.replay_recorder) context_just_sent = True logging.info("Status: Overload detected on the network") @@ -384,7 +398,9 @@ def run_simulator(self, com): com.send_context_online(self.obs, self.config['scenario_first_step'], context_date, - img_b64_current) + img_b64_current, + self.env_identity, + self.replay_recorder) context_just_sent = True logging.info("Status: AI agent raised an alarm") @@ -420,7 +436,9 @@ def run_simulator(self, com): com.send_context_online(self.obs, self.config['scenario_first_step'], context_date, - img_b64_current) + img_b64_current, + self.env_identity, + self.replay_recorder) context_just_sent = True logging.info("Status: AI agent raised an alert") @@ -454,7 +472,9 @@ def run_simulator(self, com): com.send_context_online(self.obs, self.config['scenario_first_step'], context_date, - img_b64_current) + img_b64_current, + self.env_identity, + self.replay_recorder) context_just_sent = True logging.info( @@ -505,7 +525,9 @@ def run_simulator(self, com): com.send_context_online(self.obs, self.config['scenario_first_step'], context_date, - img_b64_current) + img_b64_current, + self.env_identity, + self.replay_recorder) context_just_sent = True logging.info("Status: Line loss detected: %s", diff --git a/usecases_examples/PowerGrid/app/models/env_serialization.py b/usecases_examples/PowerGrid/app/models/env_serialization.py new file mode 100644 index 00000000..f754e094 --- /dev/null +++ b/usecases_examples/PowerGrid/app/models/env_serialization.py @@ -0,0 +1,99 @@ +# Serializes the simulator's environment state (identity + action history) so +# consumers like A3S can rebuild it exactly by replay instead of approximating it. +import base64 +import gzip +import json +import logging + +# Must stay in sync with GRID2OP_OBSERVATION_V2 in the A3S service +# (a3s-service/agent_as_a_service/powergrid/serialization.py). +GRID2OP_OBSERVATION_V2 = "grid2op_observation_v2" + +# Framing of replay_actions, stamped in metadata. +REPLAY_COMPRESSION = "gzip+base64" + + +class ReplayRecorder: + """Records actions applied since the last reset; the history must be gap-free to replay.""" + + def __init__(self): + self._actions = [] + self._broken = False + + def reset(self) -> None: + self._actions = [] + self._broken = False + + def record(self, action) -> None: + """Appends an action; marks the recorder broken (for the rest of the episode) on failure.""" + if self._broken: + return + try: + self._actions.append({"vect": action.to_vect().tolist()}) + except Exception as e: + logging.error( + "Failed to record an action for replay; no environment state " + "will be published for the rest of this episode: %s", e) + self._actions = [] + self._broken = True + + @property + def actions(self) -> list: + return self._actions + + @property + def broken(self) -> bool: + return self._broken + + def __len__(self) -> int: + return len(self._actions) + + +def build_env_identity(config: dict) -> dict: + """Captures which environment (seed, scenario, library versions) to replay against.""" + identity = { + "seed": int(config["env_seed"]), + "scenario_name": str(config["scenario_name"]), + } + for name, package in (("grid2op_version", "grid2op"), + ("lightsim2grid_version", "lightsim2grid")): + try: + identity[name] = __import__(package).__version__ + except Exception as e: + logging.warning("Could not read the %s version: %s", package, e) + return identity + + +def encode_replay_actions(replay_actions: list) -> str: + """Compresses the action history to gzipped, base64-encoded JSON.""" + raw = json.dumps(replay_actions, separators=(",", ":")).encode("utf-8") + return base64.b64encode(gzip.compress(raw, 6)).decode("ascii") + + +def build_environment_state(obs, env_identity: dict, recorder: ReplayRecorder, + max_state_mb: float) -> dict: + """Builds the environment_state envelope, or None if there's nothing replayable or it's too large.""" + if recorder.broken or not len(recorder): + return None + + encoded = encode_replay_actions(recorder.actions) + size_mb = len(encoded) / (1024 * 1024) + if size_mb > max_state_mb: + logging.warning( + "Replay history dropped: %.1f MB over the %.1f MB cap (%d actions).", + size_mb, max_state_mb, len(recorder)) + return None + + return { + "serializer": GRID2OP_OBSERVATION_V2, + "state": { + "observation": obs.to_json(), + "replay_actions": encoded, + **env_identity, + }, + "metadata": { + "current_step": int(obs.current_step), + "replayed_actions": len(recorder), + "compression": REPLAY_COMPRESSION, + }, + } diff --git a/usecases_examples/PowerGrid/config/API_POWERGRID_CAB.toml b/usecases_examples/PowerGrid/config/API_POWERGRID_CAB.toml index 6fe1a593..ee008a94 100644 --- a/usecases_examples/PowerGrid/config/API_POWERGRID_CAB.toml +++ b/usecases_examples/PowerGrid/config/API_POWERGRID_CAB.toml @@ -19,6 +19,10 @@ event_port = "cab_event/api/v1/events" [Outputs.Context] context_port = "cabcontext/api/v1/contexts" tempo = 30 +# Publish environment_state (identity + replay history) alongside the observation. +serialize_env_state = "yes" +# Cap on the published (compressed) history size, in MB. +max_state_mb = 32 [Inputs.Act] url = "http://127.0.0.1:5000/api/v1/recommendations" diff --git a/usecases_examples/PowerGrid/tests/test_env_serialization.py b/usecases_examples/PowerGrid/tests/test_env_serialization.py new file mode 100644 index 00000000..75047c95 --- /dev/null +++ b/usecases_examples/PowerGrid/tests/test_env_serialization.py @@ -0,0 +1,125 @@ +# Tests the producer side of environment_state, without the Grid2Op stack. +# Run from usecases_examples/PowerGrid: python -m pytest tests -q +import base64 +import gzip +import json + +from app.models.env_serialization import ( + GRID2OP_OBSERVATION_V2, + REPLAY_COMPRESSION, + ReplayRecorder, + build_env_identity, + build_environment_state, +) + +CONFIG = {"env_seed": "2118338672", "scenario_name": "jan_28_1"} + + +class StubAction: + """Stands in for a Grid2Op action, vectorizable or not.""" + + def __init__(self, vector: list, vectorizable: bool): + self._vector = vector + self._vectorizable = vectorizable + + def to_vect(self): + if not self._vectorizable: + raise RuntimeError("cannot vectorize this action") + return _Vector(self._vector) + + +class _Vector: + """Minimal stand-in for the numpy array to_vect() returns.""" + + def __init__(self, values: list): + self._values = values + + def tolist(self) -> list: + return list(self._values) + + +class StubObservation: + """Stands in for the Grid2Op observation being published.""" + + current_step = 12 + + def to_json(self) -> dict: + return {"current_step": [self.current_step]} + + +def _decode(encoded: str) -> list: + """Decodes a published history back into a list of actions.""" + return json.loads(gzip.decompress(base64.b64decode(encoded)).decode("utf-8")) + + +def test_the_published_state_carries_the_history_and_the_identity(): + recorder = ReplayRecorder() + for step in range(3): + recorder.record(StubAction([float(step), 0.0], vectorizable=True)) + + state = build_environment_state( + StubObservation(), build_env_identity(CONFIG), recorder, 32 + ) + + assert state["serializer"] == GRID2OP_OBSERVATION_V2 + assert state["metadata"]["compression"] == REPLAY_COMPRESSION + assert state["metadata"]["replayed_actions"] == 3 + assert state["state"]["seed"] == 2118338672 + assert state["state"]["scenario_name"] == "jan_28_1" + assert _decode(state["state"]["replay_actions"]) == [ + {"vect": [0.0, 0.0]}, + {"vect": [1.0, 0.0]}, + {"vect": [2.0, 0.0]}, + ] + + +def test_a_failed_recording_publishes_nothing_for_the_rest_of_the_episode(): + recorder = ReplayRecorder() + recorder.record(StubAction([1.0], vectorizable=True)) + recorder.record(StubAction([2.0], vectorizable=False)) + recorder.record(StubAction([3.0], vectorizable=True)) + + assert recorder.broken + assert len(recorder) == 0 + assert ( + build_environment_state( + StubObservation(), build_env_identity(CONFIG), recorder, 32 + ) + is None + ) + + # A new episode records again. + recorder.reset() + recorder.record(StubAction([4.0], vectorizable=True)) + + assert not recorder.broken + assert len(recorder) == 1 + + +def test_an_empty_history_publishes_nothing(): + assert ( + build_environment_state( + StubObservation(), build_env_identity(CONFIG), ReplayRecorder(), 32 + ) + is None + ) + + +def test_an_oversized_history_is_dropped(): + recorder = ReplayRecorder() + for step in range(50): + recorder.record(StubAction([float(step)], vectorizable=True)) + + assert ( + build_environment_state( + StubObservation(), build_env_identity(CONFIG), recorder, 0.0 + ) + is None + ) + + +def test_the_identity_reports_the_libraries_it_can_read(): + identity = build_env_identity(CONFIG) + + assert identity["seed"] == 2118338672 + assert identity["scenario_name"] == "jan_28_1"