Co-Study4Grid / expert_backend /services /simulation_helpers.py
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
33 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.
"""Stateless helpers for simulation_mixin.
These are the small, self-contained pieces that used to live as inline
blocks inside `simulate_manual_action` and `compute_superposition`.
Extracting them:
- Shrinks the orchestrator methods (they were 599 / 285 lines),
- Lets each concern be unit-tested in isolation, and
- Makes the data flow explicit (arguments vs. closures).
"""
from __future__ import annotations
import logging
import math
import re
from typing import Any
import numpy as np
from expert_backend.services.sanitize import sanitize_for_json
logger = logging.getLogger(__name__)
# Topology keys recognised by `_build_action_entry_from_topology` — also
# used to distinguish "single topology dict" from "dict of per-action
# topologies" in the caller-provided `action_content`.
TOPO_KEYS: frozenset[str] = frozenset({
"lines_ex_bus",
"lines_or_bus",
"gens_bus",
"loads_bus",
"substations",
"switches",
"loads_p",
"gens_p",
"pst_tap",
})
# Minimum disconnected load (MW) required to flag an islanding event.
# Below this, an extra connected component is treated as an AC load-flow
# numerical artefact (typically fast-mode local non-convergence on
# extreme PST taps) rather than a real outage.
ISLANDING_MW_THRESHOLD: float = 1.0
def canonicalize_action_id(action_id: str) -> str:
"""Return a canonical "+"-joined ID (components sorted alphabetically)."""
if not action_id or "+" not in action_id:
return action_id
return "+".join(sorted(p.strip() for p in action_id.split("+")))
def is_switch_only_content(content: Any) -> bool:
"""True if ``content`` carries only a non-empty ``switches`` dict.
Used by ``simulate_manual_action`` to decide whether to auto-build a
human-readable description for user-built SLD-edit actions, instead
of falling back to the raw ``action_id`` (which is just a generated
placeholder like ``user_topo_<vl>_<ts>``).
"""
if not isinstance(content, dict):
return False
switches = content.get("switches")
if not isinstance(switches, dict) or not switches:
return False
other_keys = [k for k in content.keys() if k != "switches"]
return len(other_keys) == 0
def build_switch_action_description(
switches: dict[str, bool],
voltage_level_id: str | None = None,
) -> str:
"""Return ``"Manoeuvre manuelle sur <vl>: A ouvert, B fermé"`` ."""
if not switches:
return "Manoeuvre manuelle (aucun switch)"
parts: list[str] = []
for sw_id, is_open in switches.items():
verb = "ouvert" if is_open else "fermé"
parts.append(f"{sw_id} {verb}")
body = ", ".join(parts)
if voltage_level_id:
return f"Manoeuvre manuelle sur {voltage_level_id}: {body}"
return f"Manoeuvre manuelle: {body}"
def _fmt_setpoint_mw(value: Any) -> str:
"""Format an active-power setpoint for an action description (1 decimal)."""
try:
return f"{float(value):.1f}"
except (TypeError, ValueError):
return str(value)
def build_manual_action_description(
content: dict[str, Any],
voltage_level_id: str | None = None,
) -> str:
"""Describe a user-built SLD-edit action (switch toggles AND/OR injection
setpoint changes) in one human-readable French clause.
Generalises :func:`build_switch_action_description` to the combined
interactive-SLD gesture: the operator can, from the same diagram, flip
breakers / disconnectors **and** retune the active power of loads /
generators before simulating one manual action. For a switch-only
``content`` the output is byte-identical to
:func:`build_switch_action_description`, so the existing card / filter
contract is preserved.
``content`` is the post-``_build_action_entry_from_topology`` form, i.e.
``switches`` plus ``set_gen_p`` / ``set_load_p`` (absolute MW setpoints).
"""
parts: list[str] = []
for sw_id, is_open in (content.get("switches") or {}).items():
parts.append(f"{sw_id} {'ouvert' if is_open else 'fermé'}")
for gen_id, p in (content.get("set_gen_p") or {}).items():
parts.append(f"{gen_id} P={_fmt_setpoint_mw(p)} MW")
for load_id, p in (content.get("set_load_p") or {}).items():
parts.append(f"{load_id} P={_fmt_setpoint_mw(p)} MW")
if not parts:
return "Manoeuvre manuelle (vide)"
body = ", ".join(parts)
if voltage_level_id:
return f"Manoeuvre manuelle sur {voltage_level_id}: {body}"
return f"Manoeuvre manuelle: {body}"
def compute_reduction_setpoint(
element_name: str,
element_type: str,
target_mw: float | None,
obs_n1: Any,
) -> float:
"""Compute the remaining MW setpoint after a target reduction.
``element_type`` is 'load' or 'gen'. Returns ``max(0, |current| - target)``.
Falls back to 0.0 if ``target_mw`` is ``None``, ``obs_n1`` is missing, or
the element cannot be found on the observation — i.e. the function
degrades to "full reduction" rather than raising.
"""
if target_mw is None or obs_n1 is None:
return 0.0
try:
if element_type == "load":
idx = list(obs_n1.name_load).index(element_name)
current_mw = float(obs_n1.load_p[idx])
else:
idx = list(obs_n1.name_gen).index(element_name)
current_mw = float(obs_n1.gen_p[idx])
remaining = max(0.0, abs(current_mw) - float(target_mw))
return round(remaining, 2)
except Exception as e:
logger.warning(
"[compute_reduction_setpoint] could not compute setpoint for %s: %s — falling back to 0.0",
element_name, e,
)
return 0.0
def compute_redispatch_setpoint(
gen_name: str,
delta_mw: float | None,
obs_n1: Any,
default_delta_mw: float = 10.0,
) -> float:
"""Compute the new generator setpoint for a redispatch (current + signed delta).
Unlike :func:`compute_reduction_setpoint` (which *subtracts* a reduction
from the current output), redispatch *adds* a signed delta: a positive
``delta_mw`` raises production, a negative one lowers it. Works in
production-positive magnitude (pypowsybl ``target_p`` convention) and is
floored at 0. Falls back to ``default_delta_mw`` (raise) when no delta is
supplied, and to ``default_delta_mw`` itself when the generator cannot be
located on the observation.
"""
delta = float(delta_mw) if delta_mw is not None else float(default_delta_mw)
if obs_n1 is None:
return round(max(0.0, delta), 2)
try:
idx = list(obs_n1.name_gen).index(gen_name)
current_mw = abs(float(obs_n1.gen_p[idx]))
return round(max(0.0, current_mw + delta), 2)
except Exception as e:
logger.warning(
"[compute_redispatch_setpoint] could not compute setpoint for %s: %s — using delta only",
gen_name, e,
)
return round(max(0.0, delta), 2)
_PST_TAP_PATTERN = re.compile(r"(pst(?:_tap)?_(.+))_(inc|dec)(\d+)$")
def parse_pst_tap_id(action_id: str) -> tuple[str, int] | None:
"""Parse a dynamic PST action ID like ``pst_tap_<id>_inc2``.
Returns ``(pst_id, signed_variation)`` or ``None`` if the ID doesn't
match the expected shape. ``signed_variation`` is positive for ``inc``
and negative for ``dec``.
"""
match = _PST_TAP_PATTERN.match(action_id)
if not match:
return None
_, pst_id, direction, val_str = match.groups()
val = int(val_str)
return pst_id, val if direction == "inc" else -val
def clamp_tap(target_tap: int, pst_info: dict[str, int] | None) -> int:
"""Clamp a requested tap position to the PST's ``[low_tap, high_tap]`` bounds.
``pst_info`` can be ``None`` (unknown bounds) — in that case the input
is returned as-is.
"""
if not pst_info:
return int(target_tap)
return max(int(pst_info["low_tap"]), min(int(pst_info["high_tap"]), int(target_tap)))
def classify_action_content(action_content: Any, action_ids: list[str]) -> dict[str, Any]:
"""Normalise a caller-provided ``action_content`` into ``{aid: topology}``.
``action_content`` may be:
- A single topology dict (any of the keys in ``TOPO_KEYS``) — broadcast
to every ``aid`` in ``action_ids``.
- A dict mapping ``aid -> topology`` — returned as-is.
"""
if not action_content:
return {}
if any(k in action_content for k in TOPO_KEYS):
return {aid: action_content for aid in action_ids}
return action_content
def is_pst_action(action_id: str, dict_action: dict | None, classifier: Any) -> bool:
"""Detect PST actions — mirrors the logic used by the library's
``compute_all_pairs_superposition``.
"""
desc = (dict_action or {}).get(action_id, {})
action_type = classifier.identify_action_type(desc, by_description=True)
return (
action_type in {"pst", "pst_tap"}
or "pst_tap" in action_id
or "pst_" in action_id
)
# Injection-action detection — kept in sync with the recommender library's
# ``superposition.is_injection_action`` (id prefix + classifier action type).
# Replicated here (rather than imported) so it stays correct when the library
# module is stubbed by the test mock layer, exactly like ``is_pst_action``.
_INJECTION_ID_PREFIXES: tuple[str, ...] = ("load_shedding_", "curtail_", "redispatch_")
_INJECTION_ACTION_TYPES: frozenset[str] = frozenset({
"load_power_reduction", "gen_power_reduction", "gen_redispatch",
"open_load", "open_gen",
})
def is_injection_action(action_id: str, dict_action: dict | None, classifier: Any) -> bool:
"""Detect injection actions (load shedding / curtailment / redispatch).
These change only nodal injections (``set_load_p`` / ``set_gen_p``), not the
topology, and are combined with topology actions through the Generalized
Superposition Theorem (GST). Detection is by action-id prefix and, when the
classifier resolves a type, by the injection action types — mirroring the
library's ``superposition.is_injection_action``.
"""
if action_id and action_id.startswith(_INJECTION_ID_PREFIXES):
return True
desc = (dict_action or {}).get(action_id, {})
action_type = classifier.identify_action_type(desc, by_description=True)
return action_type in _INJECTION_ACTION_TYPES
def pst_fallback_line_idxs(
action_id: str,
dict_action: dict | None,
all_actions: dict | None,
name_line: list[str],
) -> list[int]:
"""Locate the PST transformer line index from an action's ``pst_tap`` content.
Used when ``_identify_action_elements`` returns empty for a PST action
(PST tap changes are not topology switches — they don't appear in
``lines_ex_bus`` / ``lines_or_bus``).
"""
entry = (dict_action or {}).get(action_id) or (all_actions or {}).get(action_id, {})
pst_tap = entry.get("content", {}).get("pst_tap", {})
if not pst_tap:
pst_tap = entry.get("action_topology", {}).get("pst_tap", {})
if not pst_tap:
return []
idxs: list[int] = []
for pst_name in pst_tap:
if pst_name in name_line:
idxs.append(name_line.index(pst_name))
return idxs
def _to_1d(arr: Any) -> np.ndarray:
"""Coerce an array-like (including MagicMock-friendly lists) to 1D numpy."""
return np.atleast_1d(arr)
def build_half_open_reactive(network: Any) -> dict[str, float]:
"""Return ``{branch_id_or_name: live_end_reactive_mvar}`` for lines + 2-winding
transformers that are open at EXACTLY ONE terminal in the current variant.
A branch open at one end is out of service for active-power transfer, but its
line capacitance stays energised from the live end, so pypowsybl reports a
real REACTIVE charging current there (e.g. a 225 kV line opened at one end
shows ~16 MVAr at the connected terminal while p ~ 0). The current-based
loading ``rho`` then reads a small non-zero value — physically correct, but
easy to misread as a residual overload when the operator opened the line to
relieve one. Returning the live-end reactive power lets the UI explain that
an "after" loading on such a branch is capacitive charging current, not flow.
The value is ``abs(q)`` at the still-connected terminal. Keys cover BOTH the
IIDM id and the friendly ``name`` so a caller holding a grid2op / operator
name (``MARSIL61PRAGN``) can look the branch up. Returns ``{}`` on any
pypowsybl failure — the annotation is additive and must not break a run.
"""
out: dict[str, float] = {}
for getter in ("get_lines", "get_2_windings_transformers"):
try:
df = getattr(network, getter)(
attributes=["name", "connected1", "connected2", "q1", "q2"]
)
except Exception as e:
logger.debug("build_half_open_reactive: %s(attrs) failed: %s", getter, e)
try:
df = getattr(network, getter)()
except Exception as e2:
logger.debug("build_half_open_reactive: %s fallback failed: %s", getter, e2)
continue
try:
cols = list(getattr(df, "columns", []))
if "connected1" not in cols or "connected2" not in cols:
continue
has_name = "name" in cols
for eid, row in df.iterrows():
c1, c2 = bool(row["connected1"]), bool(row["connected2"])
if c1 == c2:
continue # both connected, or both open — not "half open"
q_live = row.get("q1") if c1 else row.get("q2")
try:
reactive = abs(float(q_live))
except (TypeError, ValueError):
reactive = 0.0
if not math.isfinite(reactive):
reactive = 0.0
out[str(eid)] = reactive
if has_name:
nm = row.get("name")
if nm is not None and str(nm) != "nan":
out[str(nm)] = reactive
except Exception as e:
logger.debug("build_half_open_reactive: scan failed for %s: %s", getter, e)
continue
return out
def build_care_mask(
action_names: np.ndarray,
action_rho: np.ndarray,
base_rho: np.ndarray,
lines_we_care_about: Any,
branches_with_limits: Any,
lines_overloaded_ids: list[int],
monitoring_factor: float,
worsening_threshold: float,
) -> np.ndarray:
"""Build a boolean mask over ``action_names`` selecting "monitored" lines.
Rules:
1. Must be in ``lines_we_care_about`` AND ``branches_with_limits``.
2. Exclude pre-existing N-state overloads UNLESS the action moved
the line outside the symmetric ``±worsening_threshold`` band
around its N value (``not_impacted``). Lines whose rho barely
changes belong to "other issues" and should not pollute the
action's max_rho; lines whose rho moved significantly — in
either direction — belong to the action's sensitive area and
must remain monitored, even when they were already overloaded.
3. Always force-include lines at ``lines_overloaded_ids`` (active monitoring).
NOTE: ``action_rho`` and ``base_rho`` come from grid2op observations,
where ``obs.rho`` is already pre-scaled by the monitoring factor:
``obs.rho = current / (permanent_limit * monitoring_factor)``. So
``obs.rho >= 1.0`` already means "at or above the monitoring
threshold", and the comparison MUST use 1.0 — not
``monitoring_factor`` — otherwise we double-apply the factor and
flag lines as overloaded at ``mf**2 ≈ 90.25 %`` of the permanent
limit instead of 95 %. The ``monitoring_factor`` parameter is kept
only because it sets the symmetric-impact band scale via
``worsening_threshold`` and for parity with the diagram-side
``get_overloaded_lines``, which compares pypowsybl currents (NOT
pre-scaled) against ``limit * monitoring_factor``.
Falls back to an all-False mask if numpy comparisons fail (legacy tests
pass MagicMocks for observations).
"""
mask = np.isin(action_names, list(lines_we_care_about))
mask &= np.isin(action_names, list(branches_with_limits))
wt = float(worsening_threshold)
OBS_RHO_OVERLOAD_THRESHOLD = 1.0 # see docstring — obs.rho is mf-scaled
try:
pre_existing = base_rho >= OBS_RHO_OVERLOAD_THRESHOLD
not_impacted = (action_rho >= base_rho * (1 - wt)) & (action_rho <= base_rho * (1 + wt))
mask &= ~(pre_existing & not_impacted)
except Exception as e:
logger.warning("build_care_mask: vectorised comparison failed (mock context?): %s", e)
mask = np.zeros(len(action_names), dtype=bool)
for idx in lines_overloaded_ids:
if idx < len(mask):
mask[idx] = True
return mask
def resolve_lines_overloaded(
obs_simu_defaut: Any,
obs_n: Any,
analysis_context_overloaded: list[str] | None,
caller_overloaded: list[str] | None,
lines_we_care_about: Any,
branches_with_limits: Any,
monitoring_factor: float,
worsening_threshold: float,
) -> tuple[list[int], list[str]]:
"""Determine which lines are treated as "overloaded" for reporting.
Priority: analysis context > caller-provided list > vectorised recomputation.
Returns ``(ids, names)`` matching ``obs_simu_defaut.name_line``.
"""
name_line = obs_simu_defaut.name_line
name_to_idx = {l: i for i, l in enumerate(name_line)}
if analysis_context_overloaded:
ids = [name_to_idx[l] for l in analysis_context_overloaded if l in name_to_idx]
return ids, [name_line[i] for i in ids]
if caller_overloaded:
ids = [name_to_idx[l] for l in caller_overloaded if l in name_to_idx]
return ids, [name_line[i] for i in ids]
action_names = _to_1d(obs_simu_defaut.name_line)
action_rho = _to_1d(obs_simu_defaut.rho)
base_rho = _to_1d(obs_n.rho)
wt = float(worsening_threshold)
# obs.rho is already pre-scaled by monitoring_factor — see
# ``build_care_mask`` docstring. Threshold is 1.0, not mf.
OBS_RHO_OVERLOAD_THRESHOLD = 1.0
mask = np.isin(action_names, list(lines_we_care_about))
mask &= np.isin(action_names, list(branches_with_limits))
try:
rho_mask = action_rho >= OBS_RHO_OVERLOAD_THRESHOLD
pre_existing = base_rho >= OBS_RHO_OVERLOAD_THRESHOLD
# Symmetric impact rule — see ``build_care_mask`` for rationale.
not_impacted = (action_rho >= base_rho * (1 - wt)) & (action_rho <= base_rho * (1 + wt))
mask &= rho_mask & ~(pre_existing & not_impacted)
except Exception as e:
logger.warning("resolve_lines_overloaded: vectorised comparison failed: %s", e)
mask = np.zeros(len(action_names), dtype=bool)
ids = np.where(mask)[0].tolist()
names = action_names[mask].tolist()
return ids, names
def half_open_overload_notes(
obs: Any, lines_overloaded_names: list[str], rho_after: list[float]
) -> dict[str, float]:
"""Return ``{line_name: live_end_reactive_mvar}`` for still-"overloaded" lines
the action leaves open at ONE end with a loading above ~1 %.
Such a line carries no real flow (the diagrams show p = 0) but its capacitance
draws reactive charging current from the live end, so its current-based
loading stays non-zero (the reported ~33 %). Surfacing the live-end reactive
power lets the ActionCard annotate the value as capacitive charging current
rather than it reading as a residual overload. Reads the post-action variant
via :func:`half_open_branch_reactive_from_obs`.
"""
if not lines_overloaded_names:
return {}
half_open = half_open_branch_reactive_from_obs(obs)
if not half_open:
return {}
notes: dict[str, float] = {}
for i, name in enumerate(lines_overloaded_names):
try:
rho = float(rho_after[i]) if i < len(rho_after) else 0.0
except (TypeError, ValueError):
rho = 0.0
if name in half_open and rho > 0.01:
notes[name] = half_open[name]
return notes
def half_open_branch_reactive_from_obs(obs: Any) -> dict[str, float]:
"""Return ``{branch_id_or_name: live_end_reactive_mvar}`` for branches open at
exactly one terminal in ``obs``'s post-action pypowsybl variant (see
:func:`build_half_open_reactive`).
Reads the SAME variant the SLD / NAD diagrams render
(``obs._network_manager`` on ``obs._variant_id``). Best-effort — returns
``{}`` on any failure and always restores the network manager's working
variant so the shared network is never left mutated.
"""
nm = getattr(obs, "_network_manager", None)
variant_id = getattr(obs, "_variant_id", None)
network = getattr(nm, "network", None) if nm is not None else None
if nm is None or network is None or variant_id is None:
return {}
try:
original = network.get_working_variant_id()
except Exception as e:
logger.debug("half_open_branch_reactive_from_obs: cannot read working variant: %s", e)
return {}
try:
nm.set_working_variant(variant_id)
return build_half_open_reactive(network)
except Exception as e:
logger.debug("half_open_branch_reactive_from_obs: read failed: %s", e)
return {}
finally:
try:
nm.set_working_variant(original)
except Exception as e:
logger.debug("half_open_branch_reactive_from_obs: variant restore failed: %s", e)
def compute_action_metrics(
obs: Any,
obs_simu_defaut: Any,
obs_simu_action: Any,
info_action: dict,
lines_overloaded_ids: list[int],
lines_we_care_about: Any,
branches_with_limits: Any,
monitoring_factor: float,
worsening_threshold: float,
) -> dict[str, Any]:
"""Post-process a single-action simulation result into a scalar summary.
Returns a dict with: ``rho_before``, ``rho_after``, ``max_rho``,
``max_rho_line``, ``is_rho_reduction``, ``is_islanded``,
``n_components_after``, ``disconnected_mw``, ``lines_overloaded_after``.
Handles the non-convergence case by zeroing action-side fields.
"""
mf = float(monitoring_factor)
rho_before = (
(_to_1d(obs_simu_defaut.rho)[lines_overloaded_ids] * mf).tolist()
if lines_overloaded_ids
else []
)
result = {
"rho_before": rho_before,
"rho_after": None,
"max_rho": 0.0,
"max_rho_line": "N/A",
"is_rho_reduction": False,
"is_islanded": False,
"n_components_after": 1,
"disconnected_mw": 0.0,
"lines_overloaded_after": [],
}
if info_action.get("exception"):
return result
n_components_after = obs_simu_action.n_components
result["n_components_after"] = n_components_after
if (
n_components_after > obs.n_components
or n_components_after > obs_simu_defaut.n_components
):
# Topology shows more components, but only flag a real islanding
# when actual load left the main component. Sub-threshold deltas
# are almost always AC-LF numerical artefacts (extreme PST taps,
# fast-mode local non-convergence) rather than physical outages.
disconnected_mw = float(
max(0.0, obs_simu_defaut.main_component_load_mw - obs_simu_action.main_component_load_mw)
)
if disconnected_mw >= ISLANDING_MW_THRESHOLD:
result["is_islanded"] = True
result["disconnected_mw"] = disconnected_mw
rho_after = (_to_1d(obs_simu_action.rho)[lines_overloaded_ids] * mf).tolist()
result["rho_after"] = rho_after
if rho_before:
try:
result["is_rho_reduction"] = bool(
np.all(np.array(rho_after) + 0.01 < np.array(rho_before))
)
except Exception as e:
logger.debug("compute_action_metrics: rho reduction check failed: %s", e)
action_names = _to_1d(obs_simu_action.name_line)
action_rho = _to_1d(obs_simu_action.rho)
base_rho = _to_1d(obs.rho)
care_mask = build_care_mask(
action_names,
action_rho,
base_rho,
lines_we_care_about,
branches_with_limits,
lines_overloaded_ids,
monitoring_factor,
worsening_threshold,
)
try:
monitored_rho = action_rho[care_mask]
monitored_names = action_names[care_mask]
# obs.rho is mf-scaled — see ``build_care_mask`` docstring — so
# the "overloaded" boundary on ``monitored_rho`` is 1.0, not mf.
overload_mask = monitored_rho >= 1.0
result["lines_overloaded_after"] = monitored_names[overload_mask].tolist()
if len(monitored_rho) > 0:
result["max_rho"] = float(np.max(monitored_rho)) * mf
result["max_rho_line"] = monitored_names[int(np.argmax(monitored_rho))]
except Exception as e:
logger.warning("compute_action_metrics: max_rho / overload calc failed: %s", e)
return result
def extract_action_topology(action: Any, action_id: str, dict_action: dict | None) -> dict:
"""Read topology fields off a Grid2Op action and sanitise them for JSON.
Supplements switches + heuristic (curtail / load_shedding) power
setpoints that don't appear as public attributes on standard actions.
"""
topo: dict[str, Any] = {}
for field in (
"lines_ex_bus",
"lines_or_bus",
"gens_bus",
"loads_bus",
"pst_tap",
"substations",
"switches",
"loads_p",
"gens_p",
):
val = getattr(action, field, None)
if val:
topo[field] = sanitize_for_json(val)
if not topo.get("switches") and dict_action:
entry = dict_action.get(action_id)
if entry:
sw = entry.get("switches")
if not sw:
content_in_dict = entry.get("content")
if isinstance(content_in_dict, dict):
sw = content_in_dict.get("switches")
if sw:
topo["switches"] = sanitize_for_json(sw)
# Back-fill injection setpoints from the action content so a user-built
# combined injection action (``set_gen_p`` AND ``set_load_p``) reports
# BOTH the generator and the load in its topology — the SLD / NAD
# highlight reads ``gens_p`` / ``loads_p`` to mark the affected feeders,
# and the grid2op action object doesn't always expose them as public
# attributes. Covers every action, not just the ``curtail_`` /
# ``load_shedding_`` prefixes handled below.
if dict_action:
content = (dict_action.get(action_id) or {}).get("content")
if isinstance(content, dict):
if not topo.get("gens_p") and content.get("set_gen_p"):
topo["gens_p"] = sanitize_for_json(content["set_gen_p"])
if not topo.get("loads_p") and content.get("set_load_p"):
topo["loads_p"] = sanitize_for_json(content["set_load_p"])
if action_id.startswith("curtail_") and not topo.get("gens_p"):
gen_name = action_id.replace("curtail_", "")
reg = (dict_action or {}).get(action_id, {}).get("content", {}).get("set_gen_p", {})
topo["gens_p"] = {gen_name: reg.get(gen_name, 0.0)}
elif action_id.startswith("load_shedding_") and not topo.get("loads_p"):
load_name = action_id.replace("load_shedding_", "")
reg = (dict_action or {}).get(action_id, {}).get("content", {}).get("set_load_p", {})
topo["loads_p"] = {load_name: reg.get(load_name, 0.0)}
return topo
def serialize_action_result(action_id: str, action_data: dict) -> dict:
"""Build the JSON-serialisable payload returned by ``simulate_manual_action``."""
return {
"action_id": action_id,
"description_unitaire": action_data.get("description_unitaire") or "No description available",
"rho_before": sanitize_for_json(action_data.get("rho_before")),
"rho_after": sanitize_for_json(action_data.get("rho_after")),
"max_rho": sanitize_for_json(action_data.get("max_rho")),
"max_rho_line": action_data.get("max_rho_line", ""),
"is_rho_reduction": bool(action_data.get("is_rho_reduction", False)),
"is_islanded": bool(action_data.get("is_islanded", False)),
"disconnected_mw": sanitize_for_json(action_data.get("disconnected_mw", 0.0)),
"n_components": int(action_data.get("n_components", 1)),
"non_convergence": action_data.get("non_convergence"),
"lines_overloaded": sanitize_for_json(action_data.get("lines_overloaded_after", [])),
"lines_overloaded_after": sanitize_for_json(action_data.get("lines_overloaded_after", [])),
"half_open_overloads": sanitize_for_json(action_data.get("half_open_overloads", {})),
"is_estimated": False,
"action_topology": action_data.get("action_topology"),
"curtailment_details": action_data.get("curtailment_details"),
"load_shedding_details": action_data.get("load_shedding_details"),
"redispatch_details": action_data.get("redispatch_details"),
"pst_details": action_data.get("pst_details"),
"content": action_data.get("content"),
}
def normalise_non_convergence(exception: Any) -> str | None:
"""Convert a list or scalar simulation exception into a user-facing message."""
if not exception:
return None
if isinstance(exception, list):
return "; ".join(str(e) for e in exception)
return str(exception)
def build_combined_description(
action_ids: list[str],
dict_action: dict | None,
recent_actions: dict | None,
) -> str:
"""Build a ``"[COMBINED] desc1 + desc2"`` string for multi-action IDs.
For single IDs, returns the single description (falling back through the
same resolution chain the caller would use).
"""
def _get_desc(aid: str) -> str:
entry = (dict_action or {}).get(aid)
if entry:
return (
entry.get("description_unitaire")
or entry.get("description")
or aid
)
recent = (recent_actions or {}).get(aid, {})
return (
recent.get("description_unitaire")
or recent.get("description")
or aid
)
if len(action_ids) == 1:
return str(_get_desc(action_ids[0]))
return "[COMBINED] " + " + ".join(str(_get_desc(aid)) for aid in action_ids)
def compute_combined_rho(
obs_start: Any,
obs_act1: Any,
obs_act2: Any,
betas: list[float],
) -> np.ndarray:
"""Compute the superposed rho vector: (1 - Σβ)·ρ_start + β₁·ρ₁ + β₂·ρ₂.
Absolute value — betas can be negative and the library convention is
magnitude-only downstream.
"""
return np.abs(
(1.0 - sum(betas)) * obs_start.rho
+ betas[0] * obs_act1.rho
+ betas[1] * obs_act2.rho
)
def compute_target_max_rho(
rho_combined: np.ndarray,
name_line_list: Any,
lines_overloaded_ids: list[int],
) -> tuple[float, str]:
"""Pick max rho / line over the user-selected overloaded lines only.
Rationale: the global ``max_rho`` scan across every monitored line
has to stay broad to catch NEW overloads that the action pair may
introduce (see ``test_superposition_max_rho_filtering_regression``
which pins that behaviour). But on lines far from either action,
linearisation error can put an arbitrary high-loaded line at the
top of the scan — a line with no relation to the contingency the
user is resolving. The "target" max reports the effect ON THE
LINES THE USER CARES ABOUT — the contingency's actual overloads —
so the UI can surface it alongside the global max and give a
direct estimated-vs-simulated comparison on the same line set.
Returns ``(0.0, "N/A")`` when no overload ids are available or all
are out of range (caller should treat that as "no target info").
"""
if not lines_overloaded_ids:
return 0.0, "N/A"
n_lines = len(rho_combined)
focus_ids = [int(i) for i in lines_overloaded_ids if 0 <= int(i) < n_lines]
if not focus_ids:
return 0.0, "N/A"
focus_rho = rho_combined[focus_ids]
argmax = int(np.argmax(focus_rho))
names = list(name_line_list)
return float(focus_rho[argmax]), str(names[focus_ids[argmax]])