Co-Study4Grid / expert_backend /services /simulation_mixin.py
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
50.1 kB
# Copyright (c) 2025-2026, RTE (https://www.rte-france.com)
# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0.
# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file,
# you can obtain one at http://mozilla.org/MPL/2.0/.
# SPDX-License-Identifier: MPL-2.0
# This file is part of Co-Study4Grid a Power Grid Study tool Assistant Interface to help solve contigencies for a grid state under study.
"""Simulation mixin for RecommenderService.
Contains manual action simulation, superposition computation,
and action dictionary management methods.
"""
import logging
import time
import numpy as np
from expert_op4grid_recommender import config
from expert_op4grid_recommender.action_evaluation.classifier import ActionClassifier
from expert_op4grid_recommender.utils.superposition import (
compute_combined_pair_superposition,
_identify_action_elements,
)
from typing import TYPE_CHECKING
from expert_backend.services.sanitize import sanitize_for_json
from expert_backend.services.service_lock import with_network_lock
from expert_backend.services.simulation_helpers import (
build_combined_description,
build_manual_action_description,
canonicalize_action_id,
classify_action_content,
clamp_tap,
compute_action_metrics,
compute_combined_rho,
compute_reduction_setpoint,
compute_redispatch_setpoint,
compute_target_max_rho,
extract_action_topology,
half_open_overload_notes,
is_injection_action,
is_pst_action,
is_switch_only_content,
normalise_non_convergence,
parse_pst_tap_id,
pst_fallback_line_idxs,
resolve_lines_overloaded,
serialize_action_result,
)
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from expert_backend.services._recommender_state import RecommenderState
_Base = RecommenderState
else:
_Base = object
class SimulationMixin(_Base):
"""Mixin providing action simulation and superposition methods."""
def get_all_action_ids(self):
"""Return a list of {id, description, type} for every action in the loaded dictionary."""
if not self._dict_action:
raise ValueError("No action dictionary loaded. Load a config first.")
from expert_op4grid_recommender.action_evaluation.classifier import ActionClassifier
classifier = ActionClassifier()
result = []
for action_id, action_desc in self._dict_action.items():
result.append({
"id": action_id,
"description": action_desc.get("description_unitaire",
action_desc.get("description", "")),
"type": classifier.identify_action_type(action_desc)
})
return result
@staticmethod
def _canonicalize_id(action_id: str) -> str:
return canonicalize_action_id(action_id)
@staticmethod
def _build_action_entry_from_topology(action_id, topo):
"""Build an action dict entry from saved topology fields.
Converts action_topology (lines_ex_bus, lines_or_bus, gens_bus,
loads_bus, substations) back into a content dict that
env.action_space(content) can parse.
"""
entry = {"description_unitaire": f"Restored action: {action_id}"}
content = {}
# Build set_bus from element-level topology (dict format, matching raw action files)
set_bus = {}
topo_to_content = {
"lines_ex_bus": "lines_ex_id",
"lines_or_bus": "lines_or_id",
"gens_bus": "generators_id",
"loads_bus": "loads_id",
}
for topo_field, content_field in topo_to_content.items():
vals = topo.get(topo_field)
if vals and isinstance(vals, dict):
set_bus[content_field] = {name: int(bus) for name, bus in vals.items()}
# Include substations (critical for node_merging_* actions)
subs = topo.get("substations") or {}
if subs:
set_bus["substations_id"] = [
(int(sub_id), [int(b) for b in bus_array])
for sub_id, bus_array in subs.items()
]
if set_bus:
content["set_bus"] = set_bus
# Include switches if present
switches = topo.get("switches") or {}
if switches:
content["switches"] = switches
# Include PST tap if present
pst_tap = topo.get("pst_tap") or {}
if pst_tap:
content["pst_tap"] = pst_tap
# Power reduction actions: set_load_p / set_gen_p (new format)
loads_p = topo.get("loads_p") or {}
if loads_p and isinstance(loads_p, dict):
content["set_load_p"] = {name: float(p) for name, p in loads_p.items()}
gens_p = topo.get("gens_p") or {}
if gens_p and isinstance(gens_p, dict):
content["set_gen_p"] = {name: float(p) for name, p in gens_p.items()}
entry["content"] = content if content else {}
return entry
@with_network_lock
def simulate_manual_action(
self,
raw_action_id: str,
disconnected_elements,
action_content=None,
lines_overloaded=None,
target_mw=None,
target_tap=None,
voltage_level_id=None,
):
"""Simulate a single or combined action and return its impact.
Orchestrator — delegates each phase to a private helper so the
flow stays readable. See module docstring + `simulation_helpers`
for per-step detail.
raw_action_id may combine multiple IDs with `+` (e.g. "act1+act2").
action_content is an optional topology dict (or per-action map)
for actions not in the dictionary — used by session reload.
target_mw / target_tap reduce a load shedding / curtailment /
PST action to a specific setpoint instead of full reduction.
"""
if not self._dict_action:
raise ValueError("No action dictionary loaded. Load a config first.")
norm_contingency = self._normalize_contingency_elements(disconnected_elements)
# Variant-state guard — drains the NAD prefetch and pins the
# working variant on the contingency variant before reading obs.
# See docs/performance/history/grid2op-shared-network.md.
self._ensure_contingency_state_ready(norm_contingency)
action_id = self._canonicalize_id(raw_action_id.strip())
if lines_overloaded is None:
lines_overloaded = []
action_ids = action_id.split("+")
recent_actions = (
self._last_result.get("prioritized_actions", {}) if self._last_result else {}
)
self._inject_action_content_entries(
action_ids, action_content, recent_actions, voltage_level_id=voltage_level_id
)
env = self._get_simulation_env()
nm = env.network_manager
n = nm.network
original_variant = n.get_working_variant_id()
# Prefer the (obs, obs_simu_defaut) pair captured by step1 and
# stored on ``_analysis_context``. The grid2op ↔ pypowsybl env
# bridge does not re-sync ``env.get_obs()`` to
# ``n.set_working_variant(...)``, so a fresh fetch here can
# return an N-state observation even after pinning the N-1
# variant — the downstream ``obs.simulate(..., keep_variant=True)``
# would then run against the wrong baseline and the backend's
# max_rho drifts from the library's own simulation. Reusing the
# step1 obs keeps this path numerically aligned with step2 and
# with ``compute_superposition`` (which uses the same pattern
# via ``_obs_n1_from_context``).
ctx = self._analysis_context or {}
ctx_obs_n1 = self._obs_n1_from_context()
ctx_obs_n = ctx.get("obs")
used_context_obs = ctx_obs_n1 is not None and ctx_obs_n is not None
if used_context_obs:
obs, obs_simu_defaut = ctx_obs_n, ctx_obs_n1
# NOTE: do NOT overwrite ``obs_simu_defaut._variant_id``. The
# library stamped it at step1 with its own kept variant id,
# and ``pypowsybl_backend.observation.simulate`` clones from
# ``self._variant_id`` at simulate time — rewriting it to a
# backend-scoped variant that doesn't exist in the library's
# ``NetworkManager`` would raise ``Variant ... not found``.
else:
# Fallback: no step1 context (direct simulate without prior
# step1, or session reload — ``restore_analysis_context``
# doesn't serialize obs objects). The stale-obs desync still
# applies on this path; tracked as a follow-up.
obs, obs_simu_defaut = self._fetch_n_and_contingency_observations(env, n, norm_contingency)
obs_n1 = obs_simu_defaut
self._create_dynamic_actions_if_needed(
action_ids, recent_actions, obs_n1, nm, target_mw
)
for aid in action_ids:
if aid not in self._dict_action and aid not in recent_actions:
raise ValueError(
f"Action '{aid}' not found in the loaded action dictionary or recent analysis."
)
self._last_disconnected_elements = list(norm_contingency)
lines_we_care_about, branches_with_limits = self._get_monitoring_parameters(obs_simu_defaut)
monitoring_factor = getattr(config, "MONITORING_FACTOR_THERMAL_LIMITS", 0.95)
worsening_threshold = getattr(config, "PRE_EXISTING_OVERLOAD_WORSENING_THRESHOLD", 0.02)
# Step1 stores the resolved friendly names under
# ``lines_overloaded_names``; session reload writes them
# under ``lines_overloaded`` (see
# ``RecommenderService.restore_analysis_context``). Check both
# so manual simulations triggered after step1 reuse the same
# pypowsybl-style identifiers the rest of the UI is wired
# against — the previous single-key read silently fell through
# to the vectorised obs-based path and re-emitted grid2op's
# synthetic ``line_<i>`` names, which the frontend's
# ``displayName`` resolver has no mapping for. Same lookup
# order as ``compute_superposition`` (see ``simulation_mixin``
# bottom-half). ``ctx`` is the same dict captured earlier in
# the function for ``ctx_obs_n1`` / ``ctx_obs_n``.
ctx_overloaded = ctx.get("lines_overloaded_names") or ctx.get("lines_overloaded")
lines_overloaded_ids, lines_overloaded_names = resolve_lines_overloaded(
obs_simu_defaut,
obs,
ctx_overloaded,
lines_overloaded,
lines_we_care_about,
branches_with_limits,
monitoring_factor,
worsening_threshold,
)
self._promote_recent_actions_to_dict(action_ids, recent_actions)
self._apply_target_mw_updates(action_ids, target_mw, obs_n1)
self._apply_target_tap_updates(action_ids, target_tap, nm)
action = self._build_combined_action_object(action_ids, env, recent_actions)
# Re-pin the working variant to the backend's N-1 only on the
# fallback path. `_fetch_n_and_n1_observations` can return a
# cached obs whose associated working variant has drifted (its
# cache-hit branches don't touch the variant), so an
# ``obs.simulate(..., keep_variant=True)`` on that obs can run
# against the wrong variant. The context path is already safe:
# the library stamped ``obs_simu_defaut._variant_id`` to its own
# kept N-1 variant, which ``pypowsybl_backend.observation.simulate``
# clones from directly (independent of the working variant).
if not used_context_obs:
n.set_working_variant(self._get_contingency_variant(norm_contingency))
actual_fast_mode = getattr(config, "PYPOWSYBL_FAST_MODE", True)
obs_simu_action, _, _, info_action = obs_simu_defaut.simulate(
action,
time_step=0,
keep_variant=True,
fast_mode=actual_fast_mode,
)
n.set_working_variant(original_variant)
# Tracker kept for future perf instrumentation — cheap to keep.
_ = time.perf_counter()
description_unitaire = build_combined_description(
action_ids, self._dict_action, recent_actions
)
metrics = compute_action_metrics(
obs,
obs_simu_defaut,
obs_simu_action,
info_action,
lines_overloaded_ids,
lines_we_care_about,
branches_with_limits,
monitoring_factor,
worsening_threshold,
)
non_convergence = normalise_non_convergence(info_action.get("exception"))
# When the action leaves a still-"overloaded" line open at ONE end, its
# loading is real but is pure capacitive CHARGING current (the line is
# energised from the live end only) — the SLD / NAD show p = 0 while the
# card shows e.g. 33 %. Capture the live-end reactive power for any such
# line whose loading stays above ~1 % so the UI can annotate the value
# instead of it reading as a residual overload.
half_open_overloads = half_open_overload_notes(
obs_simu_action, lines_overloaded_names, metrics.get("rho_after") or []
)
topo = extract_action_topology(action, action_id, self._dict_action)
description, content = self._resolve_action_description_and_content(
action_id, description_unitaire, topo
)
action_data = {
"content": content,
"observation": obs_simu_action,
"description": description or description_unitaire or "",
"description_unitaire": description_unitaire or "",
"action": action,
"action_topology": topo,
"rho_before": metrics["rho_before"],
"rho_after": metrics["rho_after"],
"max_rho": metrics["max_rho"],
"max_rho_line": metrics["max_rho_line"],
"is_rho_reduction": metrics["is_rho_reduction"],
"is_islanded": metrics["is_islanded"],
"disconnected_mw": metrics["disconnected_mw"],
"n_components": metrics["n_components_after"],
"non_convergence": non_convergence,
"lines_overloaded_after": sanitize_for_json(metrics["lines_overloaded_after"]),
"half_open_overloads": sanitize_for_json(half_open_overloads),
"is_estimated": False,
}
action_data["curtailment_details"] = self._compute_curtailment_details(
action_data, obs_n1=obs_n1
)
action_data["load_shedding_details"] = self._compute_load_shedding_details(
action_data, obs_n1=obs_n1
)
if action_id.startswith("redispatch_"):
action_data["redispatch_details"] = self._compute_redispatch_details(
action_data, obs_n1=obs_n1
)
action_data["pst_details"] = self._compute_pst_details(action_data)
self._register_action_result(action_id, action_data, info_action, obs_simu_action)
return serialize_action_result(action_id, action_data)
# ------------------------------------------------------------------
# Private helpers — each owns one phase of simulate_manual_action.
# Keeping them on the class (not the helpers module) because they
# read/mutate self state (caches, `_dict_action`, `_last_result`).
# ------------------------------------------------------------------
def _inject_action_content_entries(
self, action_ids, action_content, recent_actions, voltage_level_id=None
) -> None:
"""Inject caller-provided topology dicts into `_dict_action`.
Used on session reload AND on user-built SLD topology edits:
actions whose ID was minted at the call site (e.g.
``user_topo_<vl>_<ts>``) aren't in the action dictionary, and
their content has to be available before
``env.action_space(content)`` is called.
When the action is a user-built SLD edit (a ``user_topo_*`` id) or a
switch-only content, the placeholder ``Restored action`` description
is replaced with a human-readable one so the resulting card in the
frontend feed reads "Manoeuvre manuelle sur <vl>: …" instead of the
synthetic id. The description covers both breaker / disconnector
toggles AND load / generator active-power retunes staged from the
same diagram (see ``build_manual_action_description``).
"""
if not action_content:
return
per_action = classify_action_content(action_content, action_ids)
for aid in action_ids:
if aid in self._dict_action or aid in recent_actions:
continue
topo = per_action.get(aid)
if not topo:
continue
entry = self._build_action_entry_from_topology(aid, topo)
content = entry.get("content") or {}
if aid.startswith("user_topo_") or is_switch_only_content(content):
desc = build_manual_action_description(
content, voltage_level_id=voltage_level_id
)
entry["description_unitaire"] = desc
entry["description"] = desc
self._dict_action[aid] = entry
logger.info(
"[simulate_manual_action] Injected restored action '%s' into dict", aid
)
def _fetch_n_and_contingency_observations(self, env, n, disconnected_elements) -> tuple:
"""Return ``(obs_n, obs_contingency)`` for the current simulation.
Maintains the CALL ORDER (N first, then contingency) that legacy
tests assert on ``env.get_obs.call_count == 2``. Also tags
``obs_contingency._variant_id`` explicitly so downstream diagram
code knows which state to compare against.
"""
# Call 1: N state
n_variant_id = self._get_n_variant()
if self._cached_obs_n is not None and self._cached_obs_n_id == n_variant_id:
obs = self._cached_obs_n
else:
n.set_working_variant(n_variant_id)
obs = env.get_obs()
self._cached_obs_n = obs
self._cached_obs_n_id = n_variant_id
# Call 2: contingency state (N-1, N-2, ..., N-K)
cont_variant_id = self._get_contingency_variant(disconnected_elements)
if self._cached_obs_n1 is not None and self._cached_obs_n1_id == cont_variant_id:
obs_simu_defaut = self._cached_obs_n1
else:
n.set_working_variant(cont_variant_id)
obs_simu_defaut = env.get_obs()
self._cached_obs_n1 = obs_simu_defaut
self._cached_obs_n1_id = cont_variant_id
# Explicitly tag the variant so the action-variant diagram code
# knows what to compare against downstream.
obs_simu_defaut._variant_id = cont_variant_id
return obs, obs_simu_defaut
def _create_dynamic_actions_if_needed(
self, action_ids, recent_actions, obs_n1, nm, target_mw
) -> None:
"""Auto-create heuristic actions for redispatch_ / curtail_ / load_shedding_ / pst_tap_ / reco_ prefixes."""
for aid in action_ids:
if aid in self._dict_action or aid in recent_actions:
continue
if aid.startswith("redispatch_"):
self._create_dynamic_redispatch(aid, target_mw, obs_n1)
elif aid.startswith("curtail_"):
self._create_dynamic_curtailment(aid, target_mw, obs_n1)
elif aid.startswith("load_shedding_"):
self._create_dynamic_load_shedding(aid, target_mw, obs_n1)
elif aid.startswith("pst_tap_") or aid.startswith("pst_"):
self._create_dynamic_pst(aid, nm)
elif aid.startswith("reco_"):
self._create_dynamic_reconnection(aid)
def _create_dynamic_redispatch(self, aid, target_mw, obs_n1) -> None:
gen_name = aid[len("redispatch_"):]
default_delta = getattr(config, "REDISPATCH_DEFAULT_DELTA_MW", 10.0)
# For redispatch, ``target_mw`` is the SIGNED delta (raise > 0 / lower
# < 0); the resulting absolute setpoint is current production + delta.
setpoint = compute_redispatch_setpoint(gen_name, target_mw, obs_n1, default_delta)
topo = {"gens_p": {gen_name: setpoint}}
entry = self._build_action_entry_from_topology(aid, topo)
vl_id = None
try:
from expert_backend.services.network_service import network_service as ns
vl_id = ns.get_generator_voltage_level(gen_name)
except Exception as e:
logger.debug("Suppressed exception: %s", e)
delta = float(target_mw) if target_mw is not None else float(default_delta)
verb = "hausse" if delta >= 0 else "baisse"
if vl_id:
entry["description"] = (
f"Redispatch on generator '{gen_name}' at voltage level '{vl_id}'"
)
entry["description_unitaire"] = f"Redispatch {verb} '{gen_name}' ('{vl_id}')"
else:
entry["description"] = f"Redispatch on generator '{gen_name}'"
entry["description_unitaire"] = f"Redispatch {verb} '{gen_name}'"
self._dict_action[aid] = entry
logger.info(
"[simulate_manual_action] Created dynamic redispatch action '%s' (setpoint=%s MW)",
aid, setpoint,
)
def _create_dynamic_curtailment(self, aid, target_mw, obs_n1) -> None:
gen_name = aid[len("curtail_"):]
setpoint = compute_reduction_setpoint(gen_name, "gen", target_mw, obs_n1)
topo = {"gens_p": {gen_name: setpoint}}
entry = self._build_action_entry_from_topology(aid, topo)
vl_id = None
try:
from expert_backend.services.network_service import network_service as ns
vl_id = ns.get_generator_voltage_level(gen_name)
except Exception as e:
logger.debug("Suppressed exception: %s", e)
if vl_id:
entry["description"] = (
f"Renewable curtailment on generator '{gen_name}' at voltage level '{vl_id}'"
)
entry["description_unitaire"] = f"Effacement '{gen_name}' ('{vl_id}')"
else:
entry["description"] = f"Renewable curtailment on generator '{gen_name}'"
entry["description_unitaire"] = f"Effacement '{gen_name}'"
self._dict_action[aid] = entry
logger.info(
"[simulate_manual_action] Created dynamic curtailment action '%s' (setpoint=%s MW)",
aid, setpoint,
)
def _create_dynamic_load_shedding(self, aid, target_mw, obs_n1) -> None:
load_name = aid[len("load_shedding_"):]
setpoint = compute_reduction_setpoint(load_name, "load", target_mw, obs_n1)
topo = {"loads_p": {load_name: setpoint}}
entry = self._build_action_entry_from_topology(aid, topo)
vl_id = None
try:
from expert_backend.services.network_service import network_service as ns
vl_id = ns.get_load_voltage_level(load_name)
except Exception as e:
logger.debug("Suppressed exception: %s", e)
if vl_id:
entry["description"] = f"Load shedding on '{load_name}' at voltage level '{vl_id}'"
entry["description_unitaire"] = f"Effacement '{load_name}' ('{vl_id}')"
else:
entry["description"] = f"Load shedding on '{load_name}'"
entry["description_unitaire"] = f"Effacement '{load_name}'"
self._dict_action[aid] = entry
logger.info(
"[simulate_manual_action] Created dynamic load shedding action '%s' (setpoint=%s MW)",
aid, setpoint,
)
def _create_dynamic_pst(self, aid, nm) -> None:
parsed = parse_pst_tap_id(aid)
if not parsed:
return
pst_id, variation = parsed
pst_info = nm.get_pst_tap_info(pst_id)
if not pst_info:
return
current_tap = pst_info["tap"]
new_tap = clamp_tap(current_tap + variation, pst_info)
topo = {"pst_tap": {pst_id: new_tap}}
entry = self._build_action_entry_from_topology(aid, topo)
entry["description"] = f"PST tap change for {pst_id} (tap: {current_tap} -> {new_tap})"
entry["description_unitaire"] = f"Variation PST {pst_id}"
self._dict_action[aid] = entry
logger.info("[simulate_manual_action] Created dynamic PST action '%s'", aid)
def _create_dynamic_reconnection(self, aid) -> None:
line_name = aid[len("reco_"):]
# Reconnect both ends of the line to bus 1.
topo = {
"lines_or_bus": {line_name: 1},
"lines_ex_bus": {line_name: 1},
}
entry = self._build_action_entry_from_topology(aid, topo)
entry["description"] = f"Line reconnection: '{line_name}'"
entry["description_unitaire"] = f"Reconnexion '{line_name}'"
self._dict_action[aid] = entry
logger.info("[simulate_manual_action] Created dynamic reconnection action '%s'", aid)
def _promote_recent_actions_to_dict(self, action_ids, recent_actions) -> None:
"""Promote heuristic actions found on `_last_result.prioritized_actions`
into `_dict_action` so that target_mw updates can mutate their content.
"""
for aid in action_ids:
if aid in self._dict_action or aid not in recent_actions:
continue
a_obj = recent_actions[aid]["action"]
topo = {}
for field in (
"lines_ex_bus",
"lines_or_bus",
"gens_bus",
"loads_bus",
"pst_tap",
"substations",
"switches",
"loads_p",
"gens_p",
):
val = getattr(a_obj, field, None)
if val:
topo[field] = val # _build_action_entry_from_topology sanitises
entry = self._build_action_entry_from_topology(aid, topo)
if recent_actions[aid].get("description_unitaire"):
entry["description_unitaire"] = recent_actions[aid]["description_unitaire"]
if recent_actions[aid].get("description"):
entry["description"] = recent_actions[aid]["description"]
self._dict_action[aid] = entry
logger.info(
"[simulate_manual_action] Promoted heuristic action '%s' to registry for target_mw update",
aid,
)
def _apply_target_mw_updates(self, action_ids, target_mw, obs_n1) -> None:
if target_mw is None:
return
for aid in action_ids:
entry = self._dict_action.get(aid)
if not entry:
continue
content = entry.get("content", {})
is_redispatch = aid.startswith("redispatch_")
if "set_load_p" in content:
for load_name in content["set_load_p"]:
sp = compute_reduction_setpoint(load_name, "load", target_mw, obs_n1)
content["set_load_p"][load_name] = sp
logger.info(
"[simulate_manual_action] Updated set_load_p[%s] = %s MW",
load_name, sp,
)
if "set_gen_p" in content:
for gen_name in content["set_gen_p"]:
if is_redispatch:
# target_mw is the signed delta; setpoint = current + delta.
default_delta = getattr(config, "REDISPATCH_DEFAULT_DELTA_MW", 10.0)
sp = compute_redispatch_setpoint(gen_name, target_mw, obs_n1, default_delta)
else:
sp = compute_reduction_setpoint(gen_name, "gen", target_mw, obs_n1)
content["set_gen_p"][gen_name] = sp
logger.info(
"[simulate_manual_action] Updated set_gen_p[%s] = %s MW",
gen_name, sp,
)
def _apply_target_tap_updates(self, action_ids, target_tap, nm) -> None:
if target_tap is None:
return
for aid in action_ids:
entry = self._dict_action.get(aid)
if not entry:
continue
content = entry.get("content", {})
if "pst_tap" not in content:
continue
for pst_id in content["pst_tap"]:
pst_info = nm.get_pst_tap_info(pst_id)
clamped = clamp_tap(target_tap, pst_info)
content["pst_tap"][pst_id] = clamped
if pst_info:
logger.info(
"[simulate_manual_action] Updated pst_tap[%s] = %s",
pst_id, clamped,
)
else:
logger.info(
"[simulate_manual_action] Updated pst_tap[%s] = %s (no bounds info)",
pst_id, target_tap,
)
def _build_combined_action_object(self, action_ids, env, recent_actions):
"""Concatenate Grid2Op action objects for each ID into one combined action."""
try:
action = None
for aid in action_ids:
if aid in self._dict_action:
a_obj = env.action_space(self._dict_action[aid]["content"])
else:
a_obj = recent_actions[aid]["action"]
action = a_obj if action is None else action + a_obj
return action
except Exception as e:
raise ValueError(f"Could not create action from description: {e}")
def _resolve_action_description_and_content(self, action_id, description_unitaire, topo) -> tuple:
"""Pull description + content from `_dict_action`, reconstructing from
topology as a fallback. Guarantees `content` is never None — the
library's rule validator crashes on `content.get("set_bus", {})`.
"""
description = description_unitaire
content = None
if self._dict_action:
entry = self._dict_action.get(action_id)
if entry:
if "description" in entry:
description = entry["description"]
if "content" in entry:
content = entry["content"]
if content is None and topo:
try:
restored = self._build_action_entry_from_topology(action_id, topo)
content = restored.get("content")
except Exception as e:
logger.debug("Suppressed exception: %s", e)
if content is None:
content = {}
return description, content
def _register_action_result(self, action_id, action_data, info_action, obs_simu_action) -> None:
"""Persist the simulated action to `_last_result` and merge into `_dict_action`.
Uses merge (not replace) on `_dict_action` so the library's
`_identify_action_elements` can still find the original structure.
"""
if not info_action.get("exception") and obs_simu_action is not None:
if self._last_result is None:
self._last_result = {"prioritized_actions": {}}
if "prioritized_actions" not in self._last_result:
self._last_result["prioritized_actions"] = {}
self._last_result["prioritized_actions"][action_id] = action_data
if self._dict_action is None:
self._dict_action = {}
if action_id in self._dict_action:
existing = self._dict_action[action_id]
logger.info(
"[simulate_manual_action] Merging into existing _dict_action['%s']", action_id
)
existing["observation"] = action_data.get("observation")
existing["action"] = action_data.get("action")
existing["action_topology"] = action_data.get("action_topology")
# Always update content — even empty {} is valid and must replace
# a stale None to prevent content.get() crashes.
if action_data.get("content") is not None:
existing["content"] = action_data["content"]
else:
logger.info(
"[simulate_manual_action] NEW _dict_action['%s'] (no existing entry)", action_id
)
self._dict_action[action_id] = action_data
@with_network_lock
def compute_superposition(self, action1_id: str, action2_id: str, disconnected_elements):
"""Compute the combined effect of two actions via the superposition theorem.
Orchestrator — delegates to private helpers so the flow stays
readable. Used when a pair was NOT part of the initial analysis
(e.g. two manually-simulated actions). Always re-runs
simulations for any missing action before computing betas.
"""
norm_contingency = self._normalize_contingency_elements(disconnected_elements)
# Same contingency variant guard as simulate_manual_action.
self._ensure_contingency_state_ready(norm_contingency)
all_actions = self._ensure_pair_simulated(action1_id, action2_id, norm_contingency)
env = self._get_simulation_env()
classifier = ActionClassifier()
self._log_dict_action_snapshot(action1_id, action2_id, all_actions)
line_idxs1, sub_idxs1 = self._identify_elements_with_pst_fallback(
action1_id, all_actions, classifier, env
)
line_idxs2, sub_idxs2 = self._identify_elements_with_pst_fallback(
action2_id, all_actions, classifier, env
)
# Injection actions (load shedding / curtailment / redispatch) carry no
# topology element — they are combined via the Generalized Superposition
# Theorem. Only topology actions must resolve to a switched element.
act1_is_injection = is_injection_action(action1_id, self._dict_action, classifier)
act2_is_injection = is_injection_action(action2_id, self._dict_action, classifier)
if (not act1_is_injection and not line_idxs1 and not sub_idxs1) or \
(not act2_is_injection and not line_idxs2 and not sub_idxs2):
return {
"error": (
f"Cannot identify elements for one or both actions "
f"(Act1: {len(line_idxs1)} lines, {len(sub_idxs1)} subs; "
f"Act2: {len(line_idxs2)} lines, {len(sub_idxs2)} subs)"
)
}
n = env.network_manager.network
original_variant = n.get_working_variant_id()
# Fetch N-1 and N observations (order matters for test mocks).
# Prefer the N-1 observation captured at step1 when available —
# grid2op's ``obs.simulate(action, keep_variant=True)`` used by
# ``simulate_manual_action`` can mutate the shared N-1 variant,
# so a fresh ``env.get_obs()`` here would drift away from the
# baseline step2 used to pre-compute ``combined_actions`` betas.
# Reusing the context obs keeps the on-demand re-estimation
# numerically consistent with the "Computed Pairs" view.
ctx_obs_n1 = self._obs_n1_from_context()
if ctx_obs_n1 is not None:
obs_start = ctx_obs_n1
else:
n.set_working_variant(self._get_contingency_variant(norm_contingency))
obs_start = env.get_obs()
self._log_per_line_rho(action1_id, action2_id, line_idxs1, line_idxs2, obs_start, env, all_actions)
monitoring_factor = getattr(config, "MONITORING_FACTOR_THERMAL_LIMITS", 0.95)
worsening_threshold = getattr(config, "PRE_EXISTING_OVERLOAD_WORSENING_THRESHOLD", 0.02)
name_line_list = list(env.name_line)
name_to_idx_map = {l: i for i, l in enumerate(name_line_list)}
num_lines = len(name_line_list)
n.set_working_variant(self._get_n_variant())
obs_n = env.get_obs()
# obs.rho is already pre-scaled by monitoring_factor (grid2op
# divides current by ``limit * mf``), so the overload threshold
# on obs.rho is 1.0 — not mf. Using mf here would double-apply
# the factor and treat lines at ~90 % of the permanent limit as
# "already overloaded". See simulation_helpers.build_care_mask.
pre_existing_rho = {
i: obs_n.rho[i]
for i in range(len(obs_n.rho))
if obs_n.rho[i] >= 1.0
}
lines_we_care_about, branches_with_limits = self._get_monitoring_parameters(obs_start)
lines_overloaded_ids = self._superposition_lines_overloaded(
obs_start,
name_line_list,
name_to_idx_map,
pre_existing_rho,
lines_we_care_about,
branches_with_limits,
monitoring_factor,
worsening_threshold,
)
act1_is_pst = is_pst_action(action1_id, self._dict_action, classifier)
act2_is_pst = is_pst_action(action2_id, self._dict_action, classifier)
logger.info("[compute_superposition] Calling compute_combined_pair_superposition with:")
logger.info(
" act1_line_idxs=%s, act1_sub_idxs=%s, act1_is_pst=%s, act1_is_injection=%s",
line_idxs1, sub_idxs1, act1_is_pst, act1_is_injection,
)
logger.info(
" act2_line_idxs=%s, act2_sub_idxs=%s, act2_is_pst=%s, act2_is_injection=%s",
line_idxs2, sub_idxs2, act2_is_pst, act2_is_injection,
)
combined_id = f"{action1_id}+{action2_id}"
result = compute_combined_pair_superposition(
obs_start=obs_start,
obs_act1=all_actions[action1_id]["observation"],
obs_act2=all_actions[action2_id]["observation"],
act1_line_idxs=line_idxs1,
act1_sub_idxs=sub_idxs1,
act2_line_idxs=line_idxs2,
act2_sub_idxs=sub_idxs2,
obs_combined=all_actions.get(combined_id, {}).get("observation"),
act1_is_pst=act1_is_pst,
act2_is_pst=act2_is_pst,
act1_is_injection=act1_is_injection,
act2_is_injection=act2_is_injection,
)
if "error" not in result:
self._augment_superposition_result(
result,
obs_start,
obs_n,
all_actions,
action1_id,
action2_id,
name_line_list,
lines_we_care_about,
branches_with_limits,
lines_overloaded_ids,
monitoring_factor,
worsening_threshold,
num_lines,
)
n.set_working_variant(original_variant)
return sanitize_for_json(result)
# ------------------------------------------------------------------
# Private helpers for compute_superposition
# ------------------------------------------------------------------
def _ensure_pair_simulated(self, action1_id, action2_id, disconnected_elements):
"""Re-run ``simulate_manual_action`` for any pair member missing from
``_last_result.prioritized_actions``. Returns the up-to-date
``prioritized_actions`` dict.
"""
all_actions = (
self._last_result.get("prioritized_actions", {}) if self._last_result else {}
)
if action1_id not in all_actions:
self.simulate_manual_action(action1_id, disconnected_elements)
all_actions = self._last_result["prioritized_actions"]
if action2_id not in all_actions:
self.simulate_manual_action(action2_id, disconnected_elements)
all_actions = self._last_result["prioritized_actions"]
return all_actions
def _identify_elements_with_pst_fallback(self, action_id, all_actions, classifier, env) -> tuple:
"""Run `_identify_action_elements` with a PST-content-based fallback
when it returns empty (PST tap changes don't appear as topology
switches).
"""
act_obj = all_actions[action_id]["action"]
line_idxs, sub_idxs = _identify_action_elements(
act_obj, action_id, self._dict_action, classifier, env
)
logger.info(
"[compute_superposition] _identify_action_elements: '%s' line_idxs=%s, sub_idxs=%s",
action_id, line_idxs, sub_idxs,
)
if not line_idxs and not sub_idxs:
fallback = pst_fallback_line_idxs(
action_id, self._dict_action, all_actions, list(env.name_line)
)
if fallback:
logger.info(
"[compute_superposition] PST fallback for '%s': line_idxs=%s",
action_id, fallback,
)
return fallback, sub_idxs
return line_idxs, sub_idxs
def _superposition_lines_overloaded(
self,
obs_start,
name_line_list,
name_to_idx_map,
pre_existing_rho,
lines_we_care_about,
branches_with_limits,
monitoring_factor,
worsening_threshold,
):
"""Determine the active monitoring set for the superposition result.
Prefers the analysis context's overload set when available (keeps
the pair result aligned with the step2 "Computed Pairs" view);
otherwise recomputes from `obs_start` with the same
pre-existing-worsening rule as `simulate_manual_action`.
Context lookup order:
1. ``lines_overloaded_ids`` — indices resolved by step1 against
the same ``name_line`` ordering (used by step2 discovery).
2. ``lines_overloaded_names`` — step1 populates this key.
3. ``lines_overloaded`` — written by session reload
(``restore_analysis_context``).
"""
ctx = self._analysis_context or {}
ctx_ids = ctx.get("lines_overloaded_ids")
if ctx_ids:
ids = [int(i) for i in ctx_ids if 0 <= int(i) < len(name_line_list)]
logger.info(
"[compute_superposition] Using analysis context lines_overloaded_ids: %d lines",
len(ids),
)
return ids
ctx_overloaded = ctx.get("lines_overloaded_names") or ctx.get("lines_overloaded")
if ctx_overloaded:
ids = [name_to_idx_map[l] for l in ctx_overloaded if l in name_to_idx_map]
logger.info(
"[compute_superposition] Using analysis context lines_overloaded names: %d lines",
len(ids),
)
return ids
wt = float(worsening_threshold)
lwca_set = set(lines_we_care_about) if lines_we_care_about else set(name_line_list)
bwl_set = set(branches_with_limits)
# obs.rho is already pre-scaled by monitoring_factor — threshold
# is 1.0, not mf. See simulation_helpers.build_care_mask docstring.
ids = []
for i in range(len(obs_start.rho)):
ln = name_line_list[i]
if obs_start.rho[i] >= 1.0 and ln in lwca_set and ln in bwl_set:
# Symmetric impact rule — see ``simulation_helpers.build_care_mask``.
if i in pre_existing_rho:
lower = pre_existing_rho[i] * (1 - wt)
upper = pre_existing_rho[i] * (1 + wt)
if lower <= obs_start.rho[i] <= upper:
continue
ids.append(i)
logger.info(
"[compute_superposition] Computed lines_overloaded from N-1 state: %d lines "
"(filtered by %d care + %d with-limits)",
len(ids), len(lwca_set), len(bwl_set),
)
return ids
def _augment_superposition_result(
self,
result,
obs_start,
obs_n,
all_actions,
action1_id,
action2_id,
name_line_list,
lines_we_care_about,
branches_with_limits,
lines_overloaded_ids,
monitoring_factor,
worsening_threshold,
num_lines,
) -> None:
"""Post-process the library result into scalar max_rho + rho_before/after.
Mirrors the care_mask logic in `compute_action_metrics` so the
pair view matches single-action displays.
"""
mf = float(monitoring_factor)
wt = float(worsening_threshold)
if lines_we_care_about is not None and len(lines_we_care_about) > 0:
care_mask = np.isin(name_line_list, list(lines_we_care_about))
else:
care_mask = np.ones(num_lines, dtype=bool)
limits_mask = np.isin(name_line_list, list(branches_with_limits))
care_mask &= limits_mask
rho_combined = compute_combined_rho(
obs_start,
all_actions[action1_id]["observation"],
all_actions[action2_id]["observation"],
result["betas"],
)
base_rho_n = (
np.array(obs_n.rho[:num_lines])
if len(obs_n.rho) >= num_lines
else np.array(obs_n.rho)
)
# obs.rho is already pre-scaled by monitoring_factor — threshold
# is 1.0, not mf. See simulation_helpers.build_care_mask docstring.
# ``rho_combined`` is in the same scale (linear combination of
# obs.rho values).
pre_existing = base_rho_n >= 1.0
# Symmetric impact rule — see ``simulation_helpers.build_care_mask``.
rho_c = rho_combined[:num_lines]
not_impacted = (rho_c >= base_rho_n * (1 - wt)) & (rho_c <= base_rho_n * (1 + wt))
care_mask &= ~(pre_existing & not_impacted)
for idx in lines_overloaded_ids:
if idx < len(care_mask):
care_mask[idx] = True
max_rho = 0.0
max_rho_line = "N/A"
if np.any(care_mask):
masked_rho = rho_combined[care_mask]
masked_names = np.array(name_line_list)[care_mask]
max_idx = int(np.argmax(masked_rho))
max_rho = float(masked_rho[max_idx])
max_rho_line = masked_names[max_idx]
target_max_rho, target_max_rho_line = compute_target_max_rho(
rho_combined, name_line_list, lines_overloaded_ids,
)
logger.info(
"[compute_superposition] monitored lines: %d/%d, lines_overloaded force-included: %d",
int(np.sum(care_mask)), num_lines, len(lines_overloaded_ids),
)
logger.info(
"[compute_superposition] RESULT: max_rho_line=%s, max_rho_raw=%.6f, max_rho_scaled=%.4f",
max_rho_line, max_rho, max_rho * mf,
)
rho_after_raw = rho_combined[lines_overloaded_ids]
baseline_rho = obs_start.rho[lines_overloaded_ids]
is_rho_reduction = bool(np.all(rho_after_raw + 0.01 < baseline_rho))
result.update({
"max_rho": max_rho * monitoring_factor,
"max_rho_line": max_rho_line,
# Max computed over the USER-SELECTED overloaded lines — the
# ones the pair is meant to resolve. Lets the UI show the
# effect on the target contingency alongside the global
# `max_rho`, which may land on an off-target line due to
# linearisation error on lines far from either action.
"target_max_rho": target_max_rho * monitoring_factor if target_max_rho else 0.0,
"target_max_rho_line": target_max_rho_line,
"is_rho_reduction": is_rho_reduction,
"rho_after": (rho_combined[lines_overloaded_ids] * monitoring_factor).tolist(),
"rho_before": (obs_start.rho[lines_overloaded_ids] * monitoring_factor).tolist(),
"is_estimated": True,
})
def _log_dict_action_snapshot(self, action1_id, action2_id, all_actions) -> None:
"""Debug-only: log _dict_action entry keys for a pair (silent in prod)."""
for aid in (action1_id, action2_id):
entry = self._dict_action.get(aid) if self._dict_action else None
if entry:
logger.debug(
"[compute_superposition] _dict_action['%s'] keys: %s",
aid, list(entry.keys()),
)
else:
logger.debug("[compute_superposition] _dict_action['%s'] = NOT FOUND", aid)
if not all_actions.get(aid):
logger.debug("[compute_superposition] all_actions['%s'] = NOT FOUND", aid)
def _log_per_line_rho(
self, action1_id, action2_id, line_idxs1, line_idxs2, obs_start, env, all_actions
) -> None:
"""Debug-only: log rho + p_or deltas per identified line index."""
name_line = list(env.name_line)
for aid, lidxs in [(action1_id, line_idxs1), (action2_id, line_idxs2)]:
obs_act = all_actions[aid]["observation"]
try:
for li in lidxs:
ln = name_line[li] if li < len(name_line) else f"idx_{li}"
logger.debug(
"[compute_superposition] rho at %s(idx=%d): "
"obs_start=%.6f, obs_act(%s)=%.6f, delta=%.6f",
ln, li,
float(obs_start.rho[li]), aid,
float(obs_act.rho[li]),
float(obs_act.rho[li] - obs_start.rho[li]),
)
except (TypeError, ValueError, IndexError):
logger.warning(
"[compute_superposition] Could not log rho for %s (mock or missing data)", aid
)