diff --git a/backend/recommendation-service/resources/PowerGrid/manager.py b/backend/recommendation-service/resources/PowerGrid/manager.py
index f2c30c49..bb4a12b8 100644
--- a/backend/recommendation-service/resources/PowerGrid/manager.py
+++ b/backend/recommendation-service/resources/PowerGrid/manager.py
@@ -25,6 +25,9 @@ def __init__(self):
self.owl_file_path = os.path.join(
script_dir, "ontology/Grid2onto_v2_3_1.owl"
)
+ # Runtime value comes from the RL_AGENT_API_URL env var (set via
+ # .secrets -> docker-compose.sh -> .env for local Docker, or extraEnv
+ # for k8s). The fallback is a safe in-cluster default only.
self.rl_agent_api_url = os.environ.get(
"RL_AGENT_API_URL",
"http://frontend:80/rl-api/recommendation",
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)))
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/deploy-chart/configmap-assistant-platform.yaml b/deploy-chart/configmap-assistant-platform.yaml
new file mode 100644
index 00000000..895d2bc5
--- /dev/null
+++ b/deploy-chart/configmap-assistant-platform.yaml
@@ -0,0 +1,730 @@
+apiVersion: v1
+data:
+ businessconfig.yml: |
+ spring:
+ application:
+ name: businessconfig
+ operatorfabric.businessconfig:
+ storage:
+ path: "/businessconfig-storage"
+ cards-consultation.yml: |
+ spring:
+ application:
+ name: cards-consultation
+ checkIfUserIsAlreadyConnected: true
+ cards-publication.yml: |
+ spring:
+ application:
+ name: cards-publication
+ deserializer:
+ value:
+ delegate:
+ class: io.confluent.kafka.serializers.KafkaAvroDeserializer
+ serializer:
+ value:
+ delegate:
+ class: io.confluent.kafka.serializers.KafkaAvroSerializer
+ opfab:
+ kafka:
+ topics:
+ card:
+ topicname: opfab
+ response-card:
+ topicname: opfab-response
+ schema:
+ registry:
+ url: http://localhost:8081
+ checkAuthenticationForCardSending: false
+ common.yml: |
+ management:
+ endpoints:
+ web:
+ exposure:
+ include: '*'
+ spring:
+ rabbitmq:
+ host: cab-rabbitmq.cab.svc.cluster.local
+ port: 5672
+ username: ${RABBITMQ_USERNAME}
+ password: ${RABBITMQ_PASSWORD}
+ security:
+ provider-url: https://keycloak.irtsysx.fr/auth
+ oauth2:
+ resourceserver:
+ jwt:
+ jwk-set-uri: ${spring.security.provider-url}/realms/interactiveai/protocol/openid-connect/certs
+ data:
+ mongodb:
+ database: ${MONGODB_DB}
+ uri: mongodb://${MONGODB_USERNAME}:${MONGODB_PASSWORD}@cab-mongodb.cab.svc.cluster.local:27017/${MONGODB_DB}?authSource=admin&authMode=scram-sha1
+ server:
+ forward-headers-strategy: framework
+ operatorfabric:
+ servicesUrls:
+ users: "http://cab-users.cab.svc.cluster.local:8080"
+ businessconfig: "http://cab-businessconfig.cab.svc.cluster.local:8080"
+ nginx.conf: |
+ # docker-compose DNS used to resolved users service
+ # resolver 127.0.0.11 ipv6=off;
+
+ # Log format to have msec in time + request processing time
+ map "$time_local:$msec" $time_local_ms {
+ ~(^\S+)(\s+\S+):\d+\.(\d+)$ $1.$3$2;
+ }
+ log_format opfab-log '$remote_addr - $time_local_ms'
+ '"$request" $status $request_time $body_bytes_sent ';
+
+ log_format upstreamlog '[$time_local] $remote_addr - $remote_user - $server_name $host to: $upstream_addr: $request $status upstream_response_time $upstream_response_time msec $msec request_time $request_time';
+
+ server {
+ listen 8080;
+ server_name localhost demo.interactiveai.irt-systemx.fr;
+ error_log /var/log/nginx/error.log debug;
+ access_log /var/log/nginx/access.log opfab-log;
+
+ ### CUSTOMIZATION - BEGIN
+ # Url of the Authentication provider
+ set $KeycloakBaseUrl "https://keycloak.irtsysx.fr";
+ # Realm associated to OperatorFabric within the Authentication provider
+ set $OperatorFabricRealm "interactiveai";
+ # base64 encoded pair of authentication in the form of 'client-id:secret-id'
+ set $ClientPairOFAuthentication "b3BmYWItY2xpZW50Oklxdkh4VzBEdENDNXVMVEVTcFhyN0sxdkhtSHViOGdE" ;
+
+ ### CUSTOMIZATION - END
+
+ ### OPFAB GENERIC CONFIGURATION ###
+ ### BE CAREFUL WHEN MODIFYING ###
+ set $BasicValue "Basic $ClientPairOFAuthentication";
+ set $KeycloakOpenIdConnect $KeycloakBaseUrl/auth/realms/$OperatorFabricRealm/protocol/openid-connect;
+ gzip on;
+ gzip_types application/javascript text/css;
+
+ location / {
+ add_header Cache-Control "no-cache";
+ alias /usr/share/nginx/html/;
+ try_files $uri $uri/ /index.html;
+ }
+ location = /external/ {
+ add_header Cache-Control "no-cache";
+ alias /usr/share/nginx/html/external/;
+ index index.html index.htm;
+ }
+ location /ui/ {
+ add_header Cache-Control "no-cache";
+ alias /usr/share/nginx/html/;
+ index index.html index.htm;
+ }
+ location /auth/check_token {
+ add_header Cache-Control "no-cache";
+ proxy_set_header Host keycloak.irtsysx.fr;
+ proxy_set_header Authorization $BasicValue ;
+ proxy_pass $KeycloakOpenIdConnect/token/introspect;
+ }
+ location /auth/token {
+ add_header Cache-Control "no-cache";
+ proxy_set_header Host keycloak.irtsysx.fr;
+ proxy_set_header Authorization $BasicValue ;
+ proxy_pass $KeycloakOpenIdConnect/token;
+ }
+ location /auth/code/ {
+ add_header Cache-Control "no-cache";
+ proxy_set_header Host keycloak.irtsysx.fr;
+ proxy_set_header Authorization $BasicValue ;
+ proxy_pass $KeycloakOpenIdConnect/auth?response_type=code&client_id=opfab-client&$args;
+ }
+
+ location /auth {
+ add_header Cache-Control "no-cache";
+ proxy_set_header X-Forwarded-For $proxy_protocol_addr;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Host keycloak.irtsysx.fr;
+ proxy_pass $KeycloakBaseUrl/auth;
+ }
+
+ # To be sure new files are downloaded when version change
+ # we set no-cache for json config files and for i18n files
+ location /config/web-ui.json {
+ add_header Cache-Control "no-cache";
+ alias /usr/share/nginx/html/opfab/web-ui.json;
+ }
+ location /config/ui-menu.json {
+ add_header Cache-Control "no-cache";
+ alias /usr/share/nginx/html/opfab/ui-menu.json;
+ }
+ location /ui/assets/i18n/ {
+ add_header Cache-Control "no-cache";
+ alias /usr/share/nginx/html/assets/i18n/;
+ }
+
+ location /businessconfig {
+ add_header Cache-Control "no-cache";
+ proxy_pass http://cab-businessconfig.cab.svc.cluster.local:8080;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ }
+
+ location ~ "^/users/internal/(.*)" {
+ return 404;
+ }
+
+ location ~ "^/users/(.*)" {
+ add_header Cache-Control "no-cache";
+ proxy_pass http://cab-users.cab.svc.cluster.local:8080/$1$is_args$args;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ }
+
+ location /users {
+ add_header Cache-Control "no-cache";
+ proxy_pass http://cab-users.cab.svc.cluster.local:8080/users;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ }
+
+ location /perimeters {
+ add_header Cache-Control "no-cache";
+ proxy_pass http://cab-users.cab.svc.cluster.local:8080/perimeters;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ }
+
+ location /cards/ {
+ add_header Cache-Control "no-cache";
+ proxy_pass http://cab-cardsconsultation.cab.svc.cluster.local:8080/;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ }
+ ### !!!! SECURITY WARNING !!!!
+ ### The following configuration is suitable only if you set checkAuthenticationForCardSending to true
+ ### which is the default configuration
+ ###
+
+ location /cardspub/cards {
+ add_header Cache-Control "no-cache";
+ proxy_pass http://cab-cardspublication.cab.svc.cluster.local:8080/cards;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ }
+
+ ### if you set checkAuthenticationForCardSending to false
+ ### you MUST not permit to access cards endpoint via nginx
+ ### and replace the previous configuration by the following conf
+
+ #location /cardspub/cards/user {
+ # proxy_pass http://cards-publication:8080/cards/user;
+ # proxy_set_header X-Forwarded-For $remote_addr;
+ #}
+ #location /cardspub/cards/translateCardField {
+ # proxy_pass http://cards-publication:8080/cards/translateCardField;
+ # proxy_set_header X-Forwarded-For $remote_addr;
+ #}
+ ###
+ ### !!! END SECURITY WARNING !!!
+ location /archives {
+ add_header Cache-Control "no-cache";
+ proxy_pass http://cab-cardsconsultation.cab.svc.cluster.local:8080;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ }
+ location /externaldevices {
+ add_header Cache-Control "no-cache";
+ set $externaldevices http://external-devices.cab.svc.cluster.local:8080;
+ proxy_pass $externaldevices;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ }
+ location ~ "^/externaldevices/(.*)" {
+ add_header Cache-Control "no-cache";
+ set $externaldevices http://external-devices.cab.svc.cluster.local:8080;
+ proxy_pass $externaldevices/$1;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ }
+
+ location /cabcontext/ {
+ add_header Cache-Control "no-cache";
+ proxy_pass http://cab-cabcontext.cab.svc.cluster.local:5000/;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ }
+
+ location /cab_event/ {
+ add_header Cache-Control "no-cache";
+ proxy_pass http://cab-cabevent.cab.svc.cluster.local:5000/;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ }
+
+ location /cab_recommendation/ {
+ add_header Cache-Control "no-cache";
+ proxy_pass http://cab-cabrecommendation.cab.svc.cluster.local:5000/;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ }
+
+ location /cabhistoric/ {
+ add_header Cache-Control "no-cache";
+ proxy_pass http://cab-cabhistoric.cab.svc.cluster.local:5000/;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ }
+
+ location /cab_capitalization/ {
+ add_header Cache-Control "no-cache";
+ proxy_pass http://cab-cabcapitalization.cab.svc.cluster.local:5000/;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ }
+
+ error_page 500 502 503 504 /50x.html;
+ location = /50x.html {
+ root /usr/share/nginx/html;
+ }
+ location /cognitive-api/ {
+ add_header Cache-Control "no-cache";
+ proxy_set_header Host wesenss.inesctec.pt;
+ proxy_set_header X-Fordwarded-For $remote_addr;
+ proxy_ssl_server_name on;
+ proxy_ssl_verify off;
+ proxy_pass https://wesenss.inesctec.pt/api/v1/;
+ }
+ }
+ ui-menu.json: |
+ {
+ "coreMenusConfiguration":
+ [
+ {
+ "id": "feed",
+ "visible": true
+ },
+ {
+ "id": "archives",
+ "visible": true
+ },
+ {
+ "id": "monitoring",
+ "visible": true
+ },
+ {
+ "id": "logging",
+ "visible": true
+ },
+ {
+ "id": "usercard",
+ "visible": true
+ },
+ {
+ "id": "calendar",
+ "visible": true
+ },
+ {
+ "id": "admin",
+ "visible": true,
+ "showOnlyForGroups": ["ADMIN"]
+ },
+ {
+ "id": "settings",
+ "visible": true
+ },
+ {
+ "id": "activityarea",
+ "visible": true
+ },
+ {
+ "id": "feedconfiguration",
+ "visible": true
+ },
+ {
+ "id": "realtimeusers",
+ "visible": true
+ },
+ {
+ "id": "externaldevicesconfiguration",
+ "visible": true,
+ "showOnlyForGroups": ["ADMIN"]
+ },
+ {
+ "id": "nightdaymode",
+ "visible": true
+ },
+ {
+ "id": "about",
+ "visible": true
+ },
+ {
+ "id": "changepassword",
+ "visible": true
+ },
+ {
+ "id": "logout",
+ "visible": true
+ }
+ ],
+ "menus": [
+ {
+ "id": "menu1",
+ "label": "title.single",
+ "entries": [
+ {
+ "id": "uid_test_0",
+ "url": "https://en.wikipedia.org/w/index.php",
+ "label": "entry.single",
+ "linkType": "BOTH"
+ }
+ ]
+ },
+ {
+ "id": "menu2",
+ "label": "title.multi",
+ "entries": [
+ {
+ "id": "uid_test_1",
+ "url": "https://opfab.github.io/",
+ "label": "entry.entry1",
+ "linkType": "BOTH",
+ "showOnlyForGroups": ["ReadOnly","Planner"]
+ },
+ {
+ "id": "uid_test_2",
+ "url": "https://www.wikipedia.org/",
+ "label": "entry.entry2",
+ "linkType": "BOTH",
+ "showOnlyForGroups": ["Dispatcher"]
+ },
+ {
+ "id": "uid_test_3",
+ "url": "http://localhost:2002/external/appExample/",
+ "label": "entry.entry3",
+ "linkType": "BOTH"
+ }
+ ]
+ },
+ {
+ "id": "adminmenu",
+ "label": "title.admin",
+ "entries": [
+ {
+ "id": "uid_test_3",
+ "url": "https://opfab.github.io/",
+ "label": "entry.admin",
+ "linkType": "BOTH",
+ "showOnlyForGroups": ["ADMIN"]
+ }
+ ]
+ }
+ ],
+ "locales": [
+ {
+ "language": "en",
+ "i18n": {
+ "menu1": {
+ "title": {
+ "single": "First menu"
+ },
+ "entry": {
+ "single": "Single menu entry"
+ }
+ },
+ "menu2": {
+ "title": {
+ "multi": "Second menu"
+ },
+ "entry": {
+ "entry1": "First menu entry",
+ "entry2": "Second menu entry",
+ "entry3": "External application"
+ }
+ },
+ "adminmenu": {
+ "title": {
+ "admin": "Admin menu"
+ },
+ "entry": {
+ "admin": "Admin menu entry"
+ }
+ }
+ }
+ },
+ {
+ "language": "fr",
+ "i18n": {
+ "menu1": {
+ "title": {
+ "single": "Premier menu"
+ },
+ "entry": {
+ "single": "Unique élément"
+ }
+ },
+ "menu2": {
+ "title": {
+ "multi": "Deuxième menu"
+ },
+ "entry": {
+ "entry1": "Premier élément",
+ "entry2": "Deuxième élément",
+ "entry3": "Application externe"
+ }
+ },
+ "adminmenu": {
+ "title": {
+ "admin": "Admin menu"
+ },
+ "entry": {
+ "admin": "Admin menu entry"
+ }
+ }
+ }
+ },
+ {
+ "language": "nl",
+ "i18n": {
+ "menu1": {
+ "title": {
+ "single": "Eerste menu"
+ },
+ "entry": {
+ "single": "Enkel menu-item"
+ }
+ },
+ "menu2": {
+ "title": {
+ "multi": "Tweede menu"
+ },
+ "entry": {
+ "entry1": "Eerste menu-item",
+ "entry2": "Tweede menu-item",
+ "entry3": "TExterne applicatie"
+ }
+ },
+ "adminmenu": {
+ "title": {
+ "admin": "Admin menu"
+ },
+ "entry": {
+ "admin": "Admin menu-item"
+ }
+ }
+ }
+ }
+ ]
+ }
+ users.yml: |2
+
+ # POPULATE THE USER DATABASE ON INIT
+ # !!!! WARNING: VALUES SHOULD BE CHANGED FOR PRODUCTION MODE !!!!
+ spring:
+ application:
+ name: users
+ operatorfabric.users.default:
+ users:
+ - login: admin
+ groups: ["ADMIN"]
+ entities: ["Railway", "ATM", "PowerGrid"]
+ - login: ilyes
+ firstname : Ilyes
+ lastname : KAANICH
+ groups: ["Dispatcher","ReadOnly"]
+ entities: ["IRT_MAIN"]
+ - login: railway_user
+ groups: ["Planner", "ReadOnly"]
+ entities: ["Railway"]
+ - login: orange_user
+ groups: [ "PowerGrid","ADMIN","ReadOnly","Dispatcher"]
+ entities: [ "ORANGE" ]
+ - login: atm_user
+ groups: ["ReadOnly","Dispatcher"]
+ entities: ["ATM"]
+ - login: PowerGrid_user
+ groups: [ "PowerGrid","ADMIN","ReadOnly","Dispatcher"]
+ entities: [ "PowerGrid" ]
+ groups:
+ - id: ADMIN
+ name: ADMINISTRATORS
+ description: The admin group
+ - id: PowerGrid
+ name: RTE France
+ description: RTE TSO Group
+ realtime: false
+ - id: Dispatcher
+ name: Dispatcher
+ description: Dispatcher Group
+ realtime: true
+ - id: Planner
+ name: Planner
+ description: Planner Group
+ realtime: true
+ - id: Supervisor
+ name: Supervisor
+ description: Supervisor Group
+ realtime: true
+ - id: Manager
+ name: Manager
+ description: Manager Group
+ realtime: false
+ - id: ReadOnly
+ name: ReadOnly
+ description: ReadOnly Group
+ realtime: false
+ entities:
+ - id: Railway
+ name: National society of French railroads
+ description: National society of French railroads
+ parents : ["IRT_MAIN"]
+ - id: ORANGE
+ name: Orange
+ description: Orange
+ parents : ["IRT_MAIN"]
+ - id: ATM
+ name: ATM usecase
+ description: ATM usecase
+ parents : ["IRT_MAIN"]
+ - id: PowerGrid
+ name: Electricity Transmission Network
+ description: Electricity Transmission Network
+ parents : ["IRT_MAIN"]
+ - id: IRT_MAIN
+ name: IRT Control Centers
+ description: IRT Control Centers
+ entityAllowedToSendCard: false
+ web-ui.json: |
+ {
+ "environmentName": "KUBERNETES ENV",
+ "environmentColor": "blue",
+ "checkIfUrlIsLocked": true,
+ "showUserEntitiesOnTopRightOfTheScreen": true,
+ "externalDevicesEnabled": true,
+ "selectActivityAreaOnLogin": false,
+ "archive": {
+ "filters": {
+ "page": {
+ "size": [
+ "10"
+ ]
+ },
+ "tags": {
+ "list": [
+ {
+ "label": "Label for tag 1",
+ "value": "tag1"
+ },
+ {
+ "label": "Label for tag 2",
+ "value": "tag2"
+ }
+ ]
+ }
+ }
+ },
+ "logging": {
+ "filters": {
+ "tags": {
+ "list": [
+ {
+ "label": "Label for tag 1",
+ "value": "tag1"
+ }
+ ]
+ }
+ }
+ },
+ "feed": {
+ "defaultSorting": "unread",
+ "defaultAcknowledgmentFilter": "notack",
+ "card": {
+ "hideTimeFilter": false,
+ "time": {
+ "display": "BUSINESS"
+ },
+ "hideResponseFilter": false,
+ "hideApplyFiltersToTimeLineChoice": false,
+ "secondsBeforeLttdForClockDisplay": 3700,
+ "hideAckAllCardsFeature": false,
+ "titleUpperCase": true
+ },
+ "timeline": {
+ "domains": [
+ "TR",
+ "J",
+ "7D",
+ "W",
+ "M",
+ "Y"
+ ]
+ },
+ "geomap": {
+ "enableMap": false,
+ "defaultDataProjection": "EPSG:4326",
+ "initialLongitude": 5.3255,
+ "initialLatitude": 52.1845,
+ "initialZoom": 6,
+ "zoomLevelWhenZoomToLocation": 14,
+ "maxZoom": 11,
+ "zoomDuration": 500
+ },
+ "enableGroupedCards": false
+ },
+ "i18n": {
+ "supported": {
+ "locales": [
+ "en",
+ "fr",
+ "nl"
+ ]
+ }
+ },
+ "security": {
+ "jwt": {
+ "expire-claim": "exp",
+ "login-claim": "preferred_username"
+ },
+ "logout-url": "https://keycloak.irtsysx.fr/auth/realms/interactiveai/protocol/openid-connect/logout?redirect_uri=https://demo.interactiveai.irt-systemx.fr/",
+ "oauth2": {
+ "client-id": "opfab-client",
+ "flow": {
+ "delegate-url": "https://keycloak.irtsysx.fr/auth/realms/interactiveai/protocol/openid-connect/auth?response_type=code&client_id=opfab-client",
+ "mode": "PASSWORD",
+ "provider": "Opfab Keycloak"
+ }
+ },
+ "provider-realm": "interactiveai",
+ "provider-url": "https://keycloak.irtsysx.fr/auth",
+ "changePasswordUrl": "https://keycloak.irtsysx.fr/auth/realms/interactiveai/account/#/security/signingin"
+ },
+ "settings": {
+ "locale": "en",
+ "dateTimeFormat": "HH:mm DD/MM/YYYY",
+ "dateFormat": "DD/MM/YYYY",
+ "styleWhenNightDayModeDesactivated": "NIGHT",
+ "replayInterval" : 10,
+ "replayEnabled" : true
+
+ },
+ "settingsScreen": {
+ "hiddenSettings": ["description"]
+ },
+ "about": {
+ "firstapplication": {
+ "name": "First application",
+ "rank": 1,
+ "version": "v12.34.56"
+ },
+ "keycloack": {
+ "name": "Keycloak",
+ "rank": 2,
+ "version": "6.0.1"
+ },
+ "lastapplication": {
+ "name": "Wonderful Solution",
+ "version": "0.1.2-RELEASE"
+ }
+ },
+ "usercard": {
+ "useDescriptionFieldForEntityList": false
+ }
+ }
+kind: ConfigMap
+metadata:
+ annotations:
+ argocd.argoproj.io/tracking-id: cab:/ConfigMap:cab/cab-assistant-platform-config
+ kubectl.kubernetes.io/last-applied-configuration: |
+ {"apiVersion":"v1","data":{"businessconfig.yml":"spring:\n application:\n name: businessconfig\noperatorfabric.businessconfig:\n storage:\n path: \"/businessconfig-storage\"\n","cards-consultation.yml":"spring:\n application:\n name: cards-consultation\ncheckIfUserIsAlreadyConnected: true\n","cards-publication.yml":"spring:\n application:\n name: cards-publication\n deserializer:\n value:\n delegate:\n class: io.confluent.kafka.serializers.KafkaAvroDeserializer\n serializer:\n value:\n delegate:\n class: io.confluent.kafka.serializers.KafkaAvroSerializer\nopfab:\n kafka:\n topics:\n card:\n topicname: opfab\n response-card:\n topicname: opfab-response\n schema:\n registry:\n url: http://localhost:8081\ncheckAuthenticationForCardSending: false\n","common.yml":"management:\n endpoints:\n web:\n exposure:\n include: '*'\nspring:\n rabbitmq:\n host: cab-rabbitmq.cab.svc.cluster.local\n port: 5672\n username: ${RABBITMQ_USERNAME}\n password: ${RABBITMQ_PASSWORD}\n security:\n provider-url: https://keycloak.irtsysx.fr/auth\n oauth2:\n resourceserver:\n jwt:\n jwk-set-uri: ${spring.security.provider-url}/realms/interactiveai/protocol/openid-connect/certs\n data:\n mongodb:\n database: ${MONGODB_DB}\n uri: mongodb://${MONGODB_USERNAME}:${MONGODB_PASSWORD}@cab-mongodb.cab.svc.cluster.local:27017/${MONGODB_DB}?authSource=admin\u0026authMode=scram-sha1\nserver:\n forward-headers-strategy: framework\noperatorfabric:\n servicesUrls:\n users: \"http://cab-users.cab.svc.cluster.local:8080\"\n businessconfig: \"http://cab-businessconfig.cab.svc.cluster.local:8080\"\n","nginx.conf":"# docker-compose DNS used to resolved users service\n# resolver 127.0.0.11 ipv6=off;\n\n# Log format to have msec in time + request processing time\nmap \"$time_local:$msec\" $time_local_ms {\n ~(^\\S+)(\\s+\\S+):\\d+\\.(\\d+)$ $1.$3$2;\n}\nlog_format opfab-log '$remote_addr - $time_local_ms'\n'\"$request\" $status $request_time $body_bytes_sent ';\n\nlog_format upstreamlog '[$time_local] $remote_addr - $remote_user - $server_name $host to: $upstream_addr: $request $status upstream_response_time $upstream_response_time msec $msec request_time $request_time';\n\nserver {\n listen 8080;\n server_name localhost demo.interactiveai.irt-systemx.fr;\n error_log /var/log/nginx/error.log debug;\n access_log /var/log/nginx/access.log opfab-log;\n\n ### CUSTOMIZATION - BEGIN\n # Url of the Authentication provider\n set $KeycloakBaseUrl \"https://keycloak.irtsysx.fr\";\n # Realm associated to OperatorFabric within the Authentication provider\n set $OperatorFabricRealm \"interactiveai\";\n # base64 encoded pair of authentication in the form of 'client-id:secret-id'\n set $ClientPairOFAuthentication \"b3BmYWItY2xpZW50Oklxdkh4VzBEdENDNXVMVEVTcFhyN0sxdkhtSHViOGdE\" ;\n\n ### CUSTOMIZATION - END\n\n ### OPFAB GENERIC CONFIGURATION ###\n ### BE CAREFUL WHEN MODIFYING ###\n set $BasicValue \"Basic $ClientPairOFAuthentication\";\n set $KeycloakOpenIdConnect $KeycloakBaseUrl/auth/realms/$OperatorFabricRealm/protocol/openid-connect;\n gzip on;\n gzip_types application/javascript text/css;\n\n location / {\n add_header Cache-Control \"no-cache\";\n alias /usr/share/nginx/html/;\n try_files $uri $uri/ /index.html;\n }\n location = /external/ {\n add_header Cache-Control \"no-cache\";\n alias /usr/share/nginx/html/external/;\n index index.html index.htm;\n }\n location /ui/ {\n add_header Cache-Control \"no-cache\";\n alias /usr/share/nginx/html/;\n index index.html index.htm;\n }\n location /auth/check_token {\n add_header Cache-Control \"no-cache\";\n proxy_set_header Host keycloak.irtsysx.fr;\n proxy_set_header Authorization $BasicValue ;\n proxy_pass $KeycloakOpenIdConnect/token/introspect;\n }\n location /auth/token {\n add_header Cache-Control \"no-cache\";\n proxy_set_header Host keycloak.irtsysx.fr;\n proxy_set_header Authorization $BasicValue ;\n proxy_pass $KeycloakOpenIdConnect/token;\n }\n location /auth/code/ {\n add_header Cache-Control \"no-cache\";\n proxy_set_header Host keycloak.irtsysx.fr;\n proxy_set_header Authorization $BasicValue ;\n proxy_pass $KeycloakOpenIdConnect/auth?response_type=code\u0026client_id=opfab-client\u0026$args;\n }\n\n location /auth {\n add_header Cache-Control \"no-cache\";\n proxy_set_header X-Forwarded-For $proxy_protocol_addr;\n proxy_set_header X-Forwarded-Proto $scheme;\n proxy_set_header Host keycloak.irtsysx.fr;\n proxy_pass $KeycloakBaseUrl/auth;\n }\n\n # To be sure new files are downloaded when version change\n # we set no-cache for json config files and for i18n files\n location /config/web-ui.json {\n add_header Cache-Control \"no-cache\";\n alias /usr/share/nginx/html/opfab/web-ui.json;\n }\n location /config/ui-menu.json {\n add_header Cache-Control \"no-cache\";\n alias /usr/share/nginx/html/opfab/ui-menu.json;\n }\n location /ui/assets/i18n/ {\n add_header Cache-Control \"no-cache\";\n alias /usr/share/nginx/html/assets/i18n/;\n }\n\n location /businessconfig {\n add_header Cache-Control \"no-cache\";\n proxy_pass http://cab-businessconfig.cab.svc.cluster.local:8080;\n proxy_set_header X-Forwarded-For $remote_addr;\n }\n\n location ~ \"^/users/internal/(.*)\" {\n return 404;\n }\n\n location ~ \"^/users/(.*)\" {\n add_header Cache-Control \"no-cache\";\n proxy_pass http://cab-users.cab.svc.cluster.local:8080/$1$is_args$args;\n proxy_set_header X-Forwarded-For $remote_addr;\n }\n\n location /users {\n add_header Cache-Control \"no-cache\";\n proxy_pass http://cab-users.cab.svc.cluster.local:8080/users;\n proxy_set_header X-Forwarded-For $remote_addr;\n }\n\n location /perimeters {\n add_header Cache-Control \"no-cache\";\n proxy_pass http://cab-users.cab.svc.cluster.local:8080/perimeters;\n proxy_set_header X-Forwarded-For $remote_addr;\n }\n\n location /cards/ {\n add_header Cache-Control \"no-cache\";\n proxy_pass http://cab-cardsconsultation.cab.svc.cluster.local:8080/;\n proxy_set_header X-Forwarded-For $remote_addr;\n }\n ### !!!! SECURITY WARNING !!!!\n ### The following configuration is suitable only if you set checkAuthenticationForCardSending to true\n ### which is the default configuration\n ###\n\n location /cardspub/cards {\n add_header Cache-Control \"no-cache\";\n proxy_pass http://cab-cardspublication.cab.svc.cluster.local:8080/cards;\n proxy_set_header X-Forwarded-For $remote_addr;\n }\n\n ### if you set checkAuthenticationForCardSending to false\n ### you MUST not permit to access cards endpoint via nginx\n ### and replace the previous configuration by the following conf\n\n #location /cardspub/cards/user {\n # proxy_pass http://cards-publication:8080/cards/user;\n # proxy_set_header X-Forwarded-For $remote_addr;\n #}\n #location /cardspub/cards/translateCardField {\n # proxy_pass http://cards-publication:8080/cards/translateCardField;\n # proxy_set_header X-Forwarded-For $remote_addr;\n #}\n ###\n ### !!! END SECURITY WARNING !!!\n location /archives {\n add_header Cache-Control \"no-cache\";\n proxy_pass http://cab-cardsconsultation.cab.svc.cluster.local:8080;\n proxy_set_header X-Forwarded-For $remote_addr;\n }\n location /externaldevices {\n add_header Cache-Control \"no-cache\";\n set $externaldevices http://external-devices.cab.svc.cluster.local:8080;\n proxy_pass $externaldevices;\n proxy_set_header X-Forwarded-For $remote_addr;\n }\n location ~ \"^/externaldevices/(.*)\" {\n add_header Cache-Control \"no-cache\";\n set $externaldevices http://external-devices.cab.svc.cluster.local:8080;\n proxy_pass $externaldevices/$1;\n proxy_set_header X-Forwarded-For $remote_addr;\n }\n\n location /cabcontext/ {\n add_header Cache-Control \"no-cache\";\n proxy_pass http://cab-cabcontext.cab.svc.cluster.local:5000/;\n proxy_set_header X-Forwarded-For $remote_addr;\n }\n\n location /cab_event/ {\n add_header Cache-Control \"no-cache\";\n proxy_pass http://cab-cabevent.cab.svc.cluster.local:5000/;\n proxy_set_header X-Forwarded-For $remote_addr;\n }\n\n location /cab_recommendation/ {\n add_header Cache-Control \"no-cache\";\n proxy_pass http://cab-cabrecommendation.cab.svc.cluster.local:5000/;\n proxy_set_header X-Forwarded-For $remote_addr;\n }\n\n location /cabhistoric/ {\n add_header Cache-Control \"no-cache\";\n proxy_pass http://cab-cabhistoric.cab.svc.cluster.local:5000/;\n proxy_set_header X-Forwarded-For $remote_addr;\n }\n\n location /cab_capitalization/ {\n add_header Cache-Control \"no-cache\";\n proxy_pass http://cab-cabcapitalization.cab.svc.cluster.local:5000/;\n proxy_set_header X-Forwarded-For $remote_addr;\n }\n\n error_page 500 502 503 504 /50x.html;\n location = /50x.html {\n root /usr/share/nginx/html;\n }\n}\n","ui-menu.json":"{\n \"coreMenusConfiguration\":\n [\n {\n \"id\": \"feed\",\n \"visible\": true\n },\n {\n \"id\": \"archives\",\n \"visible\": true\n },\n {\n \"id\": \"monitoring\",\n \"visible\": true\n },\n {\n \"id\": \"logging\",\n \"visible\": true\n },\n {\n \"id\": \"usercard\",\n \"visible\": true\n },\n {\n \"id\": \"calendar\",\n \"visible\": true\n },\n {\n \"id\": \"admin\",\n \"visible\": true,\n \"showOnlyForGroups\": [\"ADMIN\"]\n },\n {\n \"id\": \"settings\",\n \"visible\": true\n },\n {\n \"id\": \"activityarea\",\n \"visible\": true\n },\n {\n \"id\": \"feedconfiguration\",\n \"visible\": true\n },\n {\n \"id\": \"realtimeusers\",\n \"visible\": true\n },\n {\n \"id\": \"externaldevicesconfiguration\",\n \"visible\": true,\n \"showOnlyForGroups\": [\"ADMIN\"]\n },\n {\n \"id\": \"nightdaymode\",\n \"visible\": true\n },\n {\n \"id\": \"about\",\n \"visible\": true\n },\n {\n \"id\": \"changepassword\",\n \"visible\": true\n },\n {\n \"id\": \"logout\",\n \"visible\": true\n }\n ],\n \"menus\": [\n {\n \"id\": \"menu1\",\n \"label\": \"title.single\",\n \"entries\": [\n {\n \"id\": \"uid_test_0\",\n \"url\": \"https://en.wikipedia.org/w/index.php\",\n \"label\": \"entry.single\",\n \"linkType\": \"BOTH\"\n }\n ]\n },\n {\n \"id\": \"menu2\",\n \"label\": \"title.multi\",\n \"entries\": [\n {\n \"id\": \"uid_test_1\",\n \"url\": \"https://opfab.github.io/\",\n \"label\": \"entry.entry1\",\n \"linkType\": \"BOTH\",\n \"showOnlyForGroups\": [\"ReadOnly\",\"Planner\"]\n },\n {\n \"id\": \"uid_test_2\",\n \"url\": \"https://www.wikipedia.org/\",\n \"label\": \"entry.entry2\",\n \"linkType\": \"BOTH\",\n \"showOnlyForGroups\": [\"Dispatcher\"]\n },\n {\n \"id\": \"uid_test_3\",\n \"url\": \"http://localhost:2002/external/appExample/\",\n \"label\": \"entry.entry3\",\n \"linkType\": \"BOTH\"\n }\n ]\n },\n {\n \"id\": \"adminmenu\",\n \"label\": \"title.admin\",\n \"entries\": [\n {\n \"id\": \"uid_test_3\",\n \"url\": \"https://opfab.github.io/\",\n \"label\": \"entry.admin\",\n \"linkType\": \"BOTH\",\n \"showOnlyForGroups\": [\"ADMIN\"]\n }\n ]\n }\n ],\n \"locales\": [\n {\n \"language\": \"en\",\n \"i18n\": {\n \"menu1\": {\n \"title\": {\n \"single\": \"First menu\"\n },\n \"entry\": {\n \"single\": \"Single menu entry\"\n }\n },\n \"menu2\": {\n \"title\": {\n \"multi\": \"Second menu\"\n },\n \"entry\": {\n \"entry1\": \"First menu entry\",\n \"entry2\": \"Second menu entry\",\n \"entry3\": \"External application\"\n }\n },\n \"adminmenu\": {\n \"title\": {\n \"admin\": \"Admin menu\"\n },\n \"entry\": {\n \"admin\": \"Admin menu entry\"\n }\n }\n }\n },\n {\n \"language\": \"fr\",\n \"i18n\": {\n \"menu1\": {\n \"title\": {\n \"single\": \"Premier menu\"\n },\n \"entry\": {\n \"single\": \"Unique élément\"\n }\n },\n \"menu2\": {\n \"title\": {\n \"multi\": \"Deuxième menu\"\n },\n \"entry\": {\n \"entry1\": \"Premier élément\",\n \"entry2\": \"Deuxième élément\",\n \"entry3\": \"Application externe\"\n }\n },\n \"adminmenu\": {\n \"title\": {\n \"admin\": \"Admin menu\"\n },\n \"entry\": {\n \"admin\": \"Admin menu entry\"\n }\n }\n }\n },\n {\n \"language\": \"nl\",\n \"i18n\": {\n \"menu1\": {\n \"title\": {\n \"single\": \"Eerste menu\"\n },\n \"entry\": {\n \"single\": \"Enkel menu-item\"\n }\n },\n \"menu2\": {\n \"title\": {\n \"multi\": \"Tweede menu\"\n },\n \"entry\": {\n \"entry1\": \"Eerste menu-item\",\n \"entry2\": \"Tweede menu-item\",\n \"entry3\": \"TExterne applicatie\"\n }\n },\n \"adminmenu\": {\n \"title\": {\n \"admin\": \"Admin menu\"\n },\n \"entry\": {\n \"admin\": \"Admin menu-item\"\n }\n }\n }\n }\n ]\n}\n","users.yml":"\n# POPULATE THE USER DATABASE ON INIT\n# !!!! WARNING: VALUES SHOULD BE CHANGED FOR PRODUCTION MODE !!!!\nspring:\n application:\n name: users\noperatorfabric.users.default:\n users:\n - login: admin\n groups: [\"ADMIN\"]\n entities: [\"Railway\", \"ATM\", \"PowerGrid\"]\n - login: ilyes\n firstname : Ilyes\n lastname : KAANICH\n groups: [\"Dispatcher\",\"ReadOnly\"]\n entities: [\"IRT_MAIN\"]\n - login: railway_user\n groups: [\"Planner\", \"ReadOnly\"]\n entities: [\"Railway\"]\n - login: orange_user\n groups: [ \"PowerGrid\",\"ADMIN\",\"ReadOnly\",\"Dispatcher\"]\n entities: [ \"ORANGE\" ]\n - login: atm_user\n groups: [\"ReadOnly\",\"Dispatcher\"]\n entities: [\"ATM\"]\n - login: PowerGrid_user\n groups: [ \"PowerGrid\",\"ADMIN\",\"ReadOnly\",\"Dispatcher\"]\n entities: [ \"PowerGrid\" ]\n groups:\n - id: ADMIN\n name: ADMINISTRATORS\n description: The admin group\n - id: PowerGrid\n name: RTE France\n description: RTE TSO Group\n realtime: false\n - id: Dispatcher\n name: Dispatcher\n description: Dispatcher Group\n realtime: true\n - id: Planner\n name: Planner\n description: Planner Group\n realtime: true\n - id: Supervisor\n name: Supervisor\n description: Supervisor Group\n realtime: true\n - id: Manager\n name: Manager\n description: Manager Group\n realtime: false\n - id: ReadOnly\n name: ReadOnly\n description: ReadOnly Group\n realtime: false\n entities:\n - id: Railway\n name: National society of French railroads\n description: National society of French railroads\n parents : [\"IRT_MAIN\"]\n - id: ORANGE\n name: Orange\n description: Orange\n parents : [\"IRT_MAIN\"]\n - id: ATM\n name: ATM usecase\n description: ATM usecase\n parents : [\"IRT_MAIN\"]\n - id: PowerGrid\n name: Electricity Transmission Network\n description: Electricity Transmission Network\n parents : [\"IRT_MAIN\"]\n - id: IRT_MAIN\n name: IRT Control Centers\n description: IRT Control Centers\n entityAllowedToSendCard: false\n","web-ui.json":"{\n \"environmentName\": \"KUBERNETES ENV\",\n \"environmentColor\": \"blue\",\n \"checkIfUrlIsLocked\": true,\n \"showUserEntitiesOnTopRightOfTheScreen\": true,\n \"externalDevicesEnabled\": true,\n \"selectActivityAreaOnLogin\": false,\n \"archive\": {\n \"filters\": {\n \"page\": {\n \"size\": [\n \"10\"\n ]\n },\n \"tags\": {\n \"list\": [\n {\n \"label\": \"Label for tag 1\",\n \"value\": \"tag1\"\n },\n {\n \"label\": \"Label for tag 2\",\n \"value\": \"tag2\"\n }\n ]\n }\n }\n },\n \"logging\": {\n \"filters\": {\n \"tags\": {\n \"list\": [\n {\n \"label\": \"Label for tag 1\",\n \"value\": \"tag1\"\n }\n ]\n }\n }\n },\n \"feed\": {\n \"defaultSorting\": \"unread\",\n \"defaultAcknowledgmentFilter\": \"notack\",\n \"card\": {\n \"hideTimeFilter\": false,\n \"time\": {\n \"display\": \"BUSINESS\"\n },\n \"hideResponseFilter\": false,\n \"hideApplyFiltersToTimeLineChoice\": false,\n \"secondsBeforeLttdForClockDisplay\": 3700,\n \"hideAckAllCardsFeature\": false,\n \"titleUpperCase\": true\n },\n \"timeline\": {\n \"domains\": [\n \"TR\",\n \"J\",\n \"7D\",\n \"W\",\n \"M\",\n \"Y\"\n ]\n },\n \"geomap\": {\n \"enableMap\": false,\n \"defaultDataProjection\": \"EPSG:4326\",\n \"initialLongitude\": 5.3255,\n \"initialLatitude\": 52.1845,\n \"initialZoom\": 6,\n \"zoomLevelWhenZoomToLocation\": 14,\n \"maxZoom\": 11,\n \"zoomDuration\": 500\n },\n \"enableGroupedCards\": false\n },\n \"i18n\": {\n \"supported\": {\n \"locales\": [\n \"en\",\n \"fr\",\n \"nl\"\n ]\n }\n },\n \"security\": {\n \"jwt\": {\n \"expire-claim\": \"exp\",\n \"login-claim\": \"preferred_username\"\n },\n \"logout-url\": \"https://keycloak.irtsysx.fr/auth/realms/interactiveai/protocol/openid-connect/logout?redirect_uri=https://demo.interactiveai.irt-systemx.fr/\",\n \"oauth2\": {\n \"client-id\": \"opfab-client\",\n \"flow\": {\n \"delegate-url\": \"https://keycloak.irtsysx.fr/auth/realms/interactiveai/protocol/openid-connect/auth?response_type=code\u0026client_id=opfab-client\",\n \"mode\": \"PASSWORD\",\n \"provider\": \"Opfab Keycloak\"\n }\n },\n \"provider-realm\": \"interactiveai\",\n \"provider-url\": \"https://keycloak.irtsysx.fr/auth\",\n \"changePasswordUrl\": \"https://keycloak.irtsysx.fr/auth/realms/interactiveai/account/#/security/signingin\"\n },\n \"settings\": {\n \"locale\": \"en\",\n \"dateTimeFormat\": \"HH:mm DD/MM/YYYY\",\n \"dateFormat\": \"DD/MM/YYYY\",\n \"styleWhenNightDayModeDesactivated\": \"NIGHT\",\n \"replayInterval\" : 10,\n \"replayEnabled\" : true\n\n },\n \"settingsScreen\": {\n \"hiddenSettings\": [\"description\"]\n },\n \"about\": {\n \"firstapplication\": {\n \"name\": \"First application\",\n \"rank\": 1,\n \"version\": \"v12.34.56\"\n },\n \"keycloack\": {\n \"name\": \"Keycloak\",\n \"rank\": 2,\n \"version\": \"6.0.1\"\n },\n \"lastapplication\": {\n \"name\": \"Wonderful Solution\",\n \"version\": \"0.1.2-RELEASE\"\n }\n },\n \"usercard\": {\n \"useDescriptionFieldForEntityList\": false\n }\n}\n"},"kind":"ConfigMap","metadata":{"annotations":{"argocd.argoproj.io/tracking-id":"cab:/ConfigMap:cab/cab-assistant-platform-config"},"labels":{"app.kubernetes.io/instance":"cab","app.kubernetes.io/managed-by":"Helm","app.kubernetes.io/name":"interactiveai-platform","app.kubernetes.io/version":"0.0.1","helm.sh/chart":"interactiveai-platform-0.2.2"},"name":"cab-assistant-platform-config","namespace":"cab"}}
+ meta.helm.sh/release-name: cab
+ meta.helm.sh/release-namespace: cab
+ creationTimestamp: "2026-01-30T10:53:28Z"
+ labels:
+ app.kubernetes.io/instance: cab
+ app.kubernetes.io/managed-by: Helm
+ app.kubernetes.io/name: interactiveai-platform
+ app.kubernetes.io/version: 0.0.1
+ helm.sh/chart: interactiveai-platform-0.2.2
+ name: cab-assistant-platform-config
+ namespace: cab
+ resourceVersion: "21095247065"
+ uid: 96bf811c-fdd7-453e-a97e-4179f061c469
\ No newline at end of file
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
+}
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
+ }
}
diff --git a/frontend/src/plugins/i18n.ts b/frontend/src/plugins/i18n.ts
index bbc6c207..ea0bb348 100644
--- a/frontend/src/plugins/i18n.ts
+++ b/frontend/src/plugins/i18n.ts
@@ -8,7 +8,7 @@ import { ENTITIES } from '@/types/entities'
export const SUPPORT_LOCALES = ['en', 'fr'] as const
Object.freeze(SUPPORT_LOCALES)
-export default createI18n({
+const i18n = createI18n({
locale: window.navigator.language.split('-')[0] || import.meta.env.VITE_DEFAULT_LOCALE || 'en',
fallbackLocale: import.meta.env.VITE_FALLBACK_LOCALE || 'en',
legacy: false,
@@ -20,10 +20,12 @@ export default createI18n({
}
})
-export async function setupEntitiesLocales(i18n: ReturnType) {
+export default i18n
+
+export async function setupEntitiesLocales(instance: typeof i18n = i18n) {
for (const entity of ENTITIES)
for (const locale of SUPPORT_LOCALES) {
- i18n.global.setLocaleMessage(
+ instance.global.setLocaleMessage(
`${locale}-${entity}`,
(await import(`../entities/${entity}/locales/${locale}.json`)).default
)
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