From 60f32fc1045a20a46b3f8a2003fb705899a9b7f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 13:19:51 +0000 Subject: [PATCH 1/2] Make PyPSA SLD readable: feeder labels, overload halo, loading coherence Three related fixes for the SLD overlay on the PyPSA-EUR grids, where equipment carries both a raw IIDM id (relation_8423569-225) and a friendly operator name (MARSIL61PRAGN): 1. Feeders labelled by the far-end voltage level. Every SLD endpoint now returns a `feeder_labels` map (build_feeder_labels) and the frontend relabels each branch feeder with the name of the VL at the OTHER end of the line (e.g. "MARSILLON 225kV") instead of the raw IIDM branch id, keeping a 1/2 index for parallel circuits. Falls back to the branch's own name, then the raw id, so already-readable grids are untouched. 2. Overload halo on the extremity SLD. The N-1 overload halo was missing because the overload list uses grid2op friendly names while the SLD cells are keyed by IIDM id; the feeder_labels `name` now bridges the two so the halo lands on the right feeder. 3. Disconnected-overload loading coherence. When an action disconnects the overloaded line itself, the card showed a stale grid2op loading (e.g. 33 %) while the SLD/NAD correctly drew zero flow. compute_action_metrics now reads connectivity from the post-action pypowsybl variant (build_branch_connectivity / disconnected_branch_names_from_obs) and reports 0 % for disconnected branches, so card and diagrams agree. The two large SLD label passes (feeder relabel + injection name buttons) were extracted into hooks/utils to keep SldOverlay under the LoC ceiling. Tests: feeder labels, branch connectivity, disconnected-overload zeroing (backend); feeder relabel + friendly-name overload halo (frontend). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TXqGodvLG69petEgm4GPrR --- CHANGELOG.md | 29 ++++ CLAUDE.md | 7 +- docs/features/sld-diagram-feeder-labels.md | 77 +++++++++ expert_backend/CLAUDE.md | 7 +- expert_backend/services/diagram/sld_render.py | 150 ++++++++++++++++++ expert_backend/services/diagram_mixin.py | 9 ++ expert_backend/services/simulation_helpers.py | 123 +++++++++++++- expert_backend/services/simulation_mixin.py | 9 ++ .../test_disconnected_overload_loading.py | 86 ++++++++++ expert_backend/tests/test_feeder_labels.py | 133 ++++++++++++++++ .../tests/test_simulation_helpers.py | 111 +++++++++++++ frontend/CLAUDE.md | 18 ++- frontend/src/api.ts | 18 +-- frontend/src/components/SldOverlay.test.tsx | 141 ++++++++++++++++ frontend/src/components/SldOverlay.tsx | 96 +++-------- frontend/src/hooks/useSldFeederRelabel.ts | 45 ++++++ .../src/hooks/useSldInjectionNameButtons.ts | 98 ++++++++++++ frontend/src/hooks/useSldOverlay.ts | 7 +- frontend/src/types.ts | 27 ++++ frontend/src/utils/svg/feederLabels.test.ts | 90 +++++++++++ frontend/src/utils/svg/feederLabels.ts | 87 ++++++++++ 21 files changed, 1271 insertions(+), 97 deletions(-) create mode 100644 docs/features/sld-diagram-feeder-labels.md create mode 100644 expert_backend/tests/test_disconnected_overload_loading.py create mode 100644 expert_backend/tests/test_feeder_labels.py create mode 100644 frontend/src/hooks/useSldFeederRelabel.ts create mode 100644 frontend/src/hooks/useSldInjectionNameButtons.ts create mode 100644 frontend/src/utils/svg/feederLabels.test.ts create mode 100644 frontend/src/utils/svg/feederLabels.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index dc378b1..fd976fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,35 @@ and the project (informally) follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### SLD readability & loading coherence on the PyPSA grids + +Three related fixes for the Single Line Diagram on the PyPSA-EUR grids, +where equipment carries both a raw IIDM id (`relation_8423569-225`) and a +friendly operator name (`MARSIL61PRAGN`): + +- **Feeders labelled by the far-end voltage level.** Branch feeders now + show the name of the voltage level at the OTHER end of the line (e.g. + `MARSILLON 225kV`) instead of pypowsybl's raw IIDM branch id, with a + `1`/`2` index kept when several parallel circuits reach the same far-end + VL. Falls back to the branch's own name, then to the raw id, so + already-readable grids are untouched. +- **Overload halo now shows on the extremity SLD.** The N-1 overload halo + was missing on the constrained feeder because the overload list uses + grid2op friendly names while the SLD cells are keyed by IIDM id; the two + are now bridged so the halo lands on the right feeder. +- **A disconnected overloaded line reports 0 % loading.** When an action + disconnects the overloaded line itself, the card showed a stale grid2op + loading (e.g. 33 %) while the SLD / NAD correctly drew zero flow. The + card now reads connectivity from the post-action variant — the same + state the diagrams read — and reports 0 %, so all three agree. + +Implementation: every SLD endpoint now returns a `feeder_labels` map +(`build_feeder_labels`); the frontend relabel + overload-bridge live in +`utils/svg/feederLabels.ts` + `hooks/useSldFeederRelabel.ts`; the loading +fix is `build_branch_connectivity` / `disconnected_branch_names_from_obs` +feeding `compute_action_metrics`. See +[`docs/features/sld-diagram-feeder-labels.md`](docs/features/sld-diagram-feeder-labels.md). + ### Direct SLD editing — switches & injections without a mode toggle The interactive Single Line Diagram editor is now reachable straight diff --git a/CLAUDE.md b/CLAUDE.md index f200661..3c25ab2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -171,6 +171,7 @@ Co-Study4Grid/ | `docs/features/adding-action-type.md` | Cross-cutting checklist for adding/upgrading a remedial-action type (lib → backend → frontend → save/log/reload triad → regression specs) | | `docs/features/interaction-logging.md` | Replay-ready event log contract | | `docs/features/sld-topology-edit.md` | Interactive SLD topology edit → manual action card | +| `docs/features/sld-diagram-feeder-labels.md` | SLD feeders relabelled by far-end VL name (+ parallel index), overload-halo friendly-name↔IIDM-id bridge, and disconnected-overload loading coherence with the diagrams | | `docs/features/vl-disk-interactions.md` | Interactive VL disks on the NAD (hover name / click → Inspect / double-click → SLD) + delegation performance contract | | `docs/features/game-mode-codabench.md` | Timed, scored Game Mode (`?game=1`) + Codabench benchmark bundle | | `deploy/huggingface/` | HuggingFace Docker Space deployment (Space README + `SETUP.md`) | @@ -297,9 +298,9 @@ Both scripts run in CI (`.github/workflows/code-quality.yml` and | POST | `/api/action-variant-diagram-patch` | Per-branch delta + VL-subtree splice for action DOM recycling | | POST | `/api/focused-diagram` | Generate NAD sub-diagram focused on a specific element | | POST | `/api/action-variant-focused-diagram` | Focused NAD for specific VL in post-action state | -| POST | `/api/n-sld` | Single Line Diagram for voltage level in N state. Response includes `switch_states` (per-switch open/closed map) **and** `injections` (per-load/generator active-power baseline) used by the interactive SLD-edit feature. | -| POST | `/api/contingency-sld` | Single Line Diagram in N-1 state (with flow deltas + `switch_states` + `injections`). | -| POST | `/api/action-variant-sld` | SLD in post-action state (with flow deltas, `changed_switches`, `switch_states`, `injections`). | +| POST | `/api/n-sld` | Single Line Diagram for voltage level in N state. Response includes `switch_states` (per-switch open/closed map), `injections` (per-load/generator active-power baseline) used by the interactive SLD-edit feature, **and** `feeder_labels` (per-branch `{name, other_vl, label}` — `label` = far-end VL name + parallel index, used to relabel feeders and bridge friendly-named overloads to the SLD cell). | +| POST | `/api/contingency-sld` | Single Line Diagram in N-1 state (with flow deltas + `switch_states` + `injections` + `feeder_labels`). | +| POST | `/api/action-variant-sld` | SLD in post-action state (with flow deltas, `changed_switches`, `switch_states`, `injections`, `feeder_labels`). | | POST | `/api/sld-topology-preview` | Target-topology preview SLD for the interactive SLD-edit feature: applies staged switch overrides on a throwaway variant and re-renders with topological colouring (no load flow; `stale_flows: true`). | | GET | `/api/actions` | Return all available action IDs and descriptions | | POST | `/api/regenerate-overflow-graph` | Regenerate (or serve from cache) the overflow graph in hierarchical / geo layout — drives the toggle on the Overflow Analysis tab | diff --git a/docs/features/sld-diagram-feeder-labels.md b/docs/features/sld-diagram-feeder-labels.md new file mode 100644 index 0000000..bfb1ab4 --- /dev/null +++ b/docs/features/sld-diagram-feeder-labels.md @@ -0,0 +1,77 @@ +# SLD readability & loading coherence (PyPSA grids) + +Three related fixes that make the Single Line Diagram (SLD) overlay +honest and readable on the PyPSA-EUR grids, where equipment carries two +identities: a raw IIDM id from the OSM-based conversion +(`relation_8423569-225`, `VL_way_207479669-225`) and a friendly operator +name (`MARSIL61PRAGN`, `MARSILLON 225kV`). + +## 1. Feeders labelled by the far-end voltage level + +By default pypowsybl draws each branch feeder with its raw IIDM id, which +is meaningless to an operator. Every SLD endpoint now also returns a +`feeder_labels` map and the frontend swaps the displayed label for the +**name of the voltage level at the OTHER end** of the branch (so the +operator reads *where the line goes*, e.g. `MARSILLON 225kV`). + +- Backend `services/diagram/sld_render.py::build_feeder_labels(network, + vl_id)` returns `{equipment_id: {name, other_vl, label}}` for every + line / 2-winding transformer touching the VL. + - `label` = far-end VL friendly name, **disambiguated with a 1-based + index** when several branches of this VL reach the same far-end VL + (parallel circuits) → `LANNEMEZAN 225kV 1` / `… 2`. + - Fall-backs: branch's own friendly name when the far-end VL is unnamed; + `None` (keep the raw id) when neither is available — so non-PyPSA + grids with already-readable ids are untouched. + - `name` carries the branch's friendly/operator name — see fix 2. +- Frontend `hooks/useSldFeederRelabel.ts` + `utils/svg/feederLabels.ts` + (`applyFeederRelabels`) swap the matching `` content, idempotently + (original stashed in `data-feeder-orig`, restored on tab/VL switch, + highlight clones skipped) — the same render-every-time + self-gate + pattern as the other SLD label passes. + +## 2. Overload halo visible on the extremity SLD + +The N-1 overload halo never showed on a feeder because the overloads come +from the analysis result as grid2op **friendly names** (`MARSIL61PRAGN`) +while the SLD cells are keyed by **IIDM id** (`relation_8423569-225`) — a +direct lookup missed. (The contingency halo worked because +`selectedContingency` is already IIDM-id-keyed.) + +`utils/svg/feederLabels.ts::buildFriendlyToEquip` / `overloadCandidates` +bridge friendly name → IIDM id via the `feeder_labels` map (each entry's +`name`), with the raw value as a fallback (covers id-keyed overload lists). +The overload-highlight pass in `SldOverlay.tsx` now resolves through that +bridge before `findCellForEquipment`. + +## 3. Disconnected-overload loading matches the diagrams + +When an action **disconnects the overloaded line itself**, the line +carries no flow — the SLD / NAD correctly draw it dashed with zero flow. +But the action card reported e.g. `33 %` for it, because grid2op's +forecast `obs.rho` can stay non-zero on a line opened by a switch action +(a backend obs-vs-variant desync). + +The fix reads connectivity from the **post-action pypowsybl variant** — +the exact state the diagrams read — and zeroes those loadings so the card +agrees with the diagrams: + +- `simulation_helpers.build_branch_connectivity(network)` → + `{branch_id_or_name: is_disconnected}` (a branch is disconnected when + either terminal is open), keyed by both IIDM id and friendly name. +- `simulation_helpers.disconnected_branch_names_from_obs(obs)` switches to + `obs._variant_id` on `obs._network_manager` (restoring the working + variant in a `finally`), reads connectivity, and returns the + disconnected set. +- `compute_action_metrics(..., disconnected_line_names=…)` forces those + branches' post-action rho to 0 before computing `rho_after`, `max_rho` + and `lines_overloaded_after`, so the card, the SLD and the NAD all agree. + +## Tests + +- Backend: `test_feeder_labels.py`, `test_disconnected_overload_loading.py`, + and the `compute_action_metrics` / `build_branch_connectivity` cases in + `test_simulation_helpers.py`. +- Frontend: `utils/svg/feederLabels.test.ts` and the + "feeder relabelling" / "overload halo via friendly name" suites in + `components/SldOverlay.test.tsx`. diff --git a/expert_backend/CLAUDE.md b/expert_backend/CLAUDE.md index 77a3078..c1d8abd 100644 --- a/expert_backend/CLAUDE.md +++ b/expert_backend/CLAUDE.md @@ -203,7 +203,12 @@ Diagram & topology: — each response now carries a `switch_states` map (per-VL operable-switch booleans) AND an `injections` map (per-VL load / generator active-power baseline: `kind`, `p`, gen `min_p` / `max_p` / - `energy_source`), driving the interactive SLD-edit baseline. + `energy_source`), driving the interactive SLD-edit baseline, AND a + `feeder_labels` map (`build_feeder_labels` — per-branch + `{name, other_vl, label}`: `label` = far-end VL name + parallel index + for feeder relabelling, `name` = friendly/operator name bridging + friendly-named overloads to the IIDM-id-keyed SLD cell). See + `docs/features/sld-diagram-feeder-labels.md`. - `POST /api/sld-topology-preview` — re-renders the VL SLD with the user's staged switch overrides applied on a throwaway variant (topological-colouring, NO load flow). Response carries diff --git a/expert_backend/services/diagram/sld_render.py b/expert_backend/services/diagram/sld_render.py index 508aa90..3c66193 100644 --- a/expert_backend/services/diagram/sld_render.py +++ b/expert_backend/services/diagram/sld_render.py @@ -9,10 +9,29 @@ import logging import math +import re +from collections import defaultdict from typing import Any logger = logging.getLogger(__name__) +# A pypowsybl ``name`` field is "not a real name" when it is missing, equal to +# the element id, or still a raw OSM identifier (``way/...`` / ``relation_...``) +# the PyPSA→IIDM conversion left in place. Mirrors ``network_service`` so the +# feeder relabelling and the NAD composite-name logic agree on what counts as a +# human-readable name. +_RAW_OSM_RE = re.compile(r"^(way|relation)[/_]") + + +def _is_real_name(name: Any, element_id: Any) -> bool: + """True when ``name`` is a human-readable label (not blank / id / raw OSM).""" + if name is None: + return False + s = str(name).strip() + if s == "" or s == "nan" or s == str(element_id): + return False + return not _RAW_OSM_RE.match(s) + def _finite_float(value: Any) -> float | None: """Coerce ``value`` to a finite float, or ``None`` when it is missing / @@ -161,6 +180,137 @@ def extract_vl_injections(network: Any, voltage_level_id: str) -> dict[str, dict return injections +def _vl_name_map(network: Any) -> dict[str, str]: + """Return ``{vl_id: friendly_name}`` for every voltage level with a real name. + + Best-effort: returns ``{}`` on any pypowsybl failure (feeder relabelling + is additive and must never break SLD rendering). + """ + try: + vls = network.get_voltage_levels(attributes=["name"]) + except Exception as e: + logger.debug("get_voltage_levels(name) failed: %s", e) + try: + vls = network.get_voltage_levels() + except Exception as e2: + logger.debug("get_voltage_levels fallback failed: %s", e2) + return {} + out: dict[str, str] = {} + try: + if vls is None or "name" not in getattr(vls, "columns", []): + return out + for vl_id, row in vls.iterrows(): + nm = row.get("name") + if _is_real_name(nm, vl_id): + out[str(vl_id)] = str(nm) + except Exception as e: + logger.debug("VL name map build failed: %s", e) + return out + + +def _collect_vl_branches(network: Any, voltage_level_id: str) -> list[dict]: + """Return ``[{eid, name, other_vl}]`` for every line / 2-winding transformer + that has a terminal in ``voltage_level_id``. + + ``other_vl`` is the voltage-level id at the FAR end of the branch (the same + VL for a self-loop). ``name`` is the branch's friendly name when it has one, + else ``None``. + """ + branches: list[dict] = [] + for getter in ("get_lines", "get_2_windings_transformers"): + try: + df = getattr(network, getter)( + attributes=["name", "voltage_level1_id", "voltage_level2_id"] + ) + except Exception as e: + logger.debug("%s(attrs) failed: %s", getter, e) + try: + df = getattr(network, getter)() + except Exception as e2: + logger.debug("%s fallback failed: %s", getter, e2) + continue + try: + cols = list(getattr(df, "columns", [])) + if "voltage_level1_id" not in cols or "voltage_level2_id" not in cols: + continue + mask = (df["voltage_level1_id"] == voltage_level_id) | ( + df["voltage_level2_id"] == voltage_level_id + ) + sub = df[mask] + has_name = "name" in cols + for eid, row in sub.iterrows(): + vl1 = str(row["voltage_level1_id"]) + vl2 = str(row["voltage_level2_id"]) + other = vl2 if vl1 == voltage_level_id else vl1 + line_name = row.get("name") if has_name else None + branches.append( + { + "eid": str(eid), + "name": str(line_name) if _is_real_name(line_name, eid) else None, + "other_vl": other, + } + ) + except Exception as e: + logger.debug("branch collection failed for %s: %s", getter, e) + continue + return branches + + +def build_feeder_labels(network: Any, voltage_level_id: str) -> dict[str, dict]: + """Return ``{equipment_id: {name, other_vl, label}}`` for the branch feeders + of ``voltage_level_id``. + + ``label`` is the **name of the voltage level at the OTHER end** of the + branch — far more interpretable than the raw IIDM branch id pypowsybl draws + by default (e.g. ``relation_8423569-225``). When several branches of this VL + terminate at the SAME far-end VL (parallel circuits) the label is + disambiguated with a 1-based index (``"MARSILLON 225kV 1"`` / ``" 2"``). + + Resolution / fall-backs for ``label``: + 1. far-end VL friendly name (+ parallel index when needed); + 2. the branch's own friendly name (already unique → no index) when the + far-end VL has no human-readable name; + 3. ``None`` — the frontend then keeps pypowsybl's default id label. + + ``name`` carries the branch's friendly name (the grid2op / operator name + such as ``MARSIL61PRAGN``) so the frontend can map an overloaded line — + reported by friendly name — back to the IIDM-id-keyed SLD cell and draw its + overload halo. + + Returns ``{}`` on any pypowsybl failure: relabelling is additive, the SLD + must still render. + """ + if not voltage_level_id: + return {} + vl_names = _vl_name_map(network) + branches = _collect_vl_branches(network, voltage_level_id) + + by_other: dict[str, list[dict]] = defaultdict(list) + for b in branches: + by_other[b["other_vl"]].append(b) + + result: dict[str, dict] = {} + for other_vl, members in by_other.items(): + members.sort(key=lambda b: b["eid"]) + multiple = len(members) > 1 + other_name = vl_names.get(other_vl) + for idx, b in enumerate(members, start=1): + if other_name: + label = f"{other_name} {idx}" if multiple else other_name + elif b["name"]: + # Far-end VL is unnamed — fall back to the branch's own name. + # Parallel branches already carry distinct names, so no index. + label = b["name"] + else: + label = None + result[b["eid"]] = { + "name": b["name"], + "other_vl": other_vl or None, + "label": label, + } + return result + + def extract_sld_svg_and_metadata(sld: Any) -> tuple: """Extract ``(svg, metadata)`` from a pypowsybl SLD diagram object. diff --git a/expert_backend/services/diagram_mixin.py b/expert_backend/services/diagram_mixin.py index 13fe9a8..129140a 100644 --- a/expert_backend/services/diagram_mixin.py +++ b/expert_backend/services/diagram_mixin.py @@ -56,6 +56,7 @@ get_overloaded_lines, ) from expert_backend.services.diagram.sld_render import ( + build_feeder_labels, extract_sld_svg_and_metadata, extract_vl_injections, extract_vl_switch_states, @@ -498,6 +499,7 @@ def get_n_sld(self, voltage_level_id: str) -> dict: svg, sld_metadata = extract_sld_svg_and_metadata(sld) switch_states = extract_vl_switch_states(n, voltage_level_id) injections = extract_vl_injections(n, voltage_level_id) + feeder_labels = build_feeder_labels(n, voltage_level_id) finally: n.set_working_variant(original_variant) return { @@ -506,6 +508,7 @@ def get_n_sld(self, voltage_level_id: str) -> dict: "voltage_level_id": voltage_level_id, "switch_states": switch_states, "injections": injections, + "feeder_labels": feeder_labels, } def get_contingency_sld(self, disconnected_elements, voltage_level_id: str) -> dict: @@ -520,6 +523,7 @@ def get_contingency_sld(self, disconnected_elements, voltage_level_id: str) -> d svg, sld_metadata = extract_sld_svg_and_metadata(sld) switch_states = extract_vl_switch_states(n, voltage_level_id) injections = extract_vl_injections(n, voltage_level_id) + feeder_labels = build_feeder_labels(n, voltage_level_id) result = { "svg": svg, "sld_metadata": sld_metadata, @@ -527,6 +531,7 @@ def get_contingency_sld(self, disconnected_elements, voltage_level_id: str) -> d "disconnected_elements": list(norm), "switch_states": switch_states, "injections": injections, + "feeder_labels": feeder_labels, } self._attach_flow_deltas_vs_base(result, n, voltage_level_ids=[voltage_level_id]) return result @@ -556,6 +561,7 @@ def get_action_variant_sld(self, action_id: str, voltage_level_id: str) -> dict: svg, sld_metadata = extract_sld_svg_and_metadata(sld) switch_states = extract_vl_switch_states(network, voltage_level_id) injections = extract_vl_injections(network, voltage_level_id) + feeder_labels = build_feeder_labels(network, voltage_level_id) result = { "svg": svg, @@ -564,6 +570,7 @@ def get_action_variant_sld(self, action_id: str, voltage_level_id: str) -> dict: "voltage_level_id": voltage_level_id, "switch_states": switch_states, "injections": injections, + "feeder_labels": feeder_labels, } self._attach_convergence_from_obs(result, obs) @@ -711,12 +718,14 @@ def get_topology_preview_sld( svg, sld_metadata = extract_sld_svg_and_metadata(sld) switch_states = extract_vl_switch_states(network, voltage_level_id) injections = extract_vl_injections(network, voltage_level_id) + feeder_labels = build_feeder_labels(network, voltage_level_id) return { "svg": svg, "sld_metadata": sld_metadata, "voltage_level_id": voltage_level_id, "switch_states": switch_states, "injections": injections, + "feeder_labels": feeder_labels, "stale_flows": True, } finally: diff --git a/expert_backend/services/simulation_helpers.py b/expert_backend/services/simulation_helpers.py index 900205f..2834092 100644 --- a/expert_backend/services/simulation_helpers.py +++ b/expert_backend/services/simulation_helpers.py @@ -307,6 +307,30 @@ def _to_1d(arr: Any) -> np.ndarray: return np.atleast_1d(arr) +def _zero_disconnected_rho( + action_rho: np.ndarray, + action_names: np.ndarray, + disconnected_line_names: set[str] | None, +) -> np.ndarray: + """Return ``action_rho`` with disconnected branches' entries forced to 0. + + Returns the input untouched (no copy) when there is nothing to zero, so the + common no-disconnection path stays allocation-free. + """ + if not disconnected_line_names: + return action_rho + try: + disc_mask = np.isin(action_names, list(disconnected_line_names)) + if not disc_mask.any(): + return action_rho + zeroed = np.array(action_rho, dtype=float, copy=True) + zeroed[disc_mask] = 0.0 + return zeroed + except Exception as e: + logger.debug("_zero_disconnected_rho: skipped (%s)", e) + return action_rho + + def build_care_mask( action_names: np.ndarray, action_rho: np.ndarray, @@ -414,6 +438,83 @@ def resolve_lines_overloaded( return ids, names +def build_branch_connectivity(network: Any) -> dict[str, bool]: + """Return ``{branch_id_or_name: is_disconnected}`` for lines + 2-winding + transformers of ``network`` (in its currently-active variant). + + A branch is *disconnected* when either terminal is open + (``connected1 AND connected2`` is False) — the same rule the action-patch + pipeline uses to mark dashed branches. Keys cover BOTH the IIDM id and the + friendly ``name`` so a caller holding a grid2op / operator name (e.g. + ``MARSIL61PRAGN``) can look the branch up directly. + + Returns ``{}`` on any pypowsybl failure — the loading-coherence fix this + drives is additive and must never break a simulation. + """ + out: dict[str, bool] = {} + for getter in ("get_lines", "get_2_windings_transformers"): + try: + df = getattr(network, getter)(attributes=["name", "connected1", "connected2"]) + except Exception as e: + logger.debug("build_branch_connectivity: %s(attrs) failed: %s", getter, e) + try: + df = getattr(network, getter)() + except Exception as e2: + logger.debug("build_branch_connectivity: %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(): + disconnected = not (bool(row["connected1"]) and bool(row["connected2"])) + out[str(eid)] = disconnected + if has_name: + nm = row.get("name") + if nm is not None and str(nm) != "nan": + out[str(nm)] = disconnected + except Exception as e: + logger.debug("build_branch_connectivity: scan failed for %s: %s", getter, e) + continue + return out + + +def disconnected_branch_names_from_obs(obs: Any) -> set: + """Return the set of branch ids / names disconnected in ``obs``'s + post-action pypowsybl variant. + + Mirrors exactly what ``get_action_variant_diagram`` / + ``get_action_variant_sld`` read (``obs._network_manager`` on + ``obs._variant_id``), so the action card's loadings can be re-aligned with + the diagrams for any line the action physically disconnects. Best-effort — + returns an empty set 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 network is None or variant_id is None: + return set() + try: + original = network.get_working_variant_id() + except Exception as e: + logger.debug("disconnected_branch_names_from_obs: cannot read working variant: %s", e) + return set() + try: + nm.set_working_variant(variant_id) + conn = build_branch_connectivity(network) + except Exception as e: + logger.debug("disconnected_branch_names_from_obs: connectivity read failed: %s", e) + return set() + finally: + try: + nm.set_working_variant(original) + except Exception as e: + logger.debug("disconnected_branch_names_from_obs: variant restore failed: %s", e) + return {key for key, disconnected in conn.items() if disconnected} + + def compute_action_metrics( obs: Any, obs_simu_defaut: Any, @@ -424,6 +525,7 @@ def compute_action_metrics( branches_with_limits: Any, monitoring_factor: float, worsening_threshold: float, + disconnected_line_names: set[str] | None = None, ) -> dict[str, Any]: """Post-process a single-action simulation result into a scalar summary. @@ -431,6 +533,16 @@ def compute_action_metrics( ``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. + + ``disconnected_line_names`` — when given, the post-action ``obs.rho`` of + any branch in this set is forced to 0 before every downstream statistic + (``rho_after`` / ``max_rho`` / ``lines_overloaded_after``). A branch the + action physically disconnects carries no flow, but grid2op's forecast + ``obs.rho`` can stay non-zero (a backend obs-vs-variant desync) — which is + why the card used to report e.g. 33 % on a line the SLD / NAD correctly + draw with zero flow. Reading connectivity from the post-action variant + (see :func:`build_branch_connectivity`) and zeroing here re-aligns the + card's loadings with the diagrams. """ mf = float(monitoring_factor) rho_before = ( @@ -471,7 +583,14 @@ def compute_action_metrics( result["is_islanded"] = True result["disconnected_mw"] = disconnected_mw - rho_after = (_to_1d(obs_simu_action.rho)[lines_overloaded_ids] * mf).tolist() + action_names = _to_1d(obs_simu_action.name_line) + # Single source of truth for the post-action loading vector. Force every + # branch the action disconnects to 0 so the card matches the diagrams. + action_rho = _zero_disconnected_rho( + _to_1d(obs_simu_action.rho), action_names, disconnected_line_names + ) + + rho_after = (action_rho[lines_overloaded_ids] * mf).tolist() if lines_overloaded_ids else [] result["rho_after"] = rho_after if rho_before: try: @@ -481,8 +600,6 @@ def compute_action_metrics( 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, diff --git a/expert_backend/services/simulation_mixin.py b/expert_backend/services/simulation_mixin.py index 4ce6d15..b4fbcaa 100644 --- a/expert_backend/services/simulation_mixin.py +++ b/expert_backend/services/simulation_mixin.py @@ -33,6 +33,7 @@ classify_action_content, clamp_tap, compute_action_metrics, + disconnected_branch_names_from_obs, compute_combined_rho, compute_reduction_setpoint, compute_redispatch_setpoint, @@ -295,6 +296,13 @@ def simulate_manual_action( action_ids, self._dict_action, recent_actions ) + # A line the action physically disconnects carries no flow, but + # grid2op's forecast ``obs.rho`` can stay non-zero (backend obs-vs- + # variant desync). Read connectivity from the post-action variant — + # the same state the SLD / NAD draw — and zero those loadings so the + # card agrees with the diagrams (0 %, not e.g. 33 %). + disconnected_line_names = disconnected_branch_names_from_obs(obs_simu_action) + metrics = compute_action_metrics( obs, obs_simu_defaut, @@ -305,6 +313,7 @@ def simulate_manual_action( branches_with_limits, monitoring_factor, worsening_threshold, + disconnected_line_names=disconnected_line_names, ) non_convergence = normalise_non_convergence(info_action.get("exception")) diff --git a/expert_backend/tests/test_disconnected_overload_loading.py b/expert_backend/tests/test_disconnected_overload_loading.py new file mode 100644 index 0000000..2619bf2 --- /dev/null +++ b/expert_backend/tests/test_disconnected_overload_loading.py @@ -0,0 +1,86 @@ +# 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. + +"""Issue 3 — action card loading coherence with the SLD / NAD diagrams. + +When an action disconnects an overloaded line, grid2op's forecast ``obs.rho`` +can stay non-zero (a backend obs-vs-variant desync) so the card showed e.g. +33 % on a line the diagrams correctly draw with zero flow. The fix reads the +post-action variant connectivity (the same state the diagrams read) and zeros +those loadings. + +These tests cover the variant-reading seam ``disconnected_branch_names_from_obs``; +the pure ``compute_action_metrics`` / ``build_branch_connectivity`` zeroing is +covered in ``test_simulation_helpers.py``. +""" + +from unittest.mock import MagicMock + +import pandas as pd + +from expert_backend.services.simulation_helpers import disconnected_branch_names_from_obs + + +def _lines_df(rows: dict) -> pd.DataFrame: + return pd.DataFrame.from_dict(rows, orient="index") + + +def _empty_trafos() -> pd.DataFrame: + return pd.DataFrame(columns=["name", "connected1", "connected2"]) + + +def _obs_with_variant(lines_df): + obs = MagicMock() + obs._variant_id = "action_var" + network = MagicMock() + network.get_working_variant_id.return_value = "orig" + network.get_lines.return_value = lines_df + network.get_2_windings_transformers.return_value = _empty_trafos() + nm = MagicMock() + nm.network = network + obs._network_manager = nm + return obs, nm + + +class TestDisconnectedBranchNamesFromObs: + def test_reads_disconnected_branches_by_id_and_name(self): + obs, nm = _obs_with_variant( + _lines_df( + { + "relation_8423569-225": { + "name": "MARSIL61PRAGN", "connected1": True, "connected2": False, + }, + "L2": {"name": "L2NAME", "connected1": True, "connected2": True}, + } + ) + ) + + names = disconnected_branch_names_from_obs(obs) + + # Disconnected line reachable by both IIDM id and grid2op/operator name. + assert "MARSIL61PRAGN" in names + assert "relation_8423569-225" in names + # Connected line absent. + assert "L2" not in names + assert "L2NAME" not in names + + def test_switches_to_action_variant_then_restores(self): + obs, nm = _obs_with_variant( + _lines_df({"L1": {"name": "L1", "connected1": True, "connected2": True}}) + ) + + disconnected_branch_names_from_obs(obs) + + nm.set_working_variant.assert_any_call("action_var") + # The shared network is always restored to its prior working variant. + assert nm.set_working_variant.call_args_list[-1].args == ("orig",) + + def test_missing_network_manager_returns_empty(self): + obs = MagicMock() + obs._network_manager = None + obs._variant_id = "action_var" + assert disconnected_branch_names_from_obs(obs) == set() diff --git a/expert_backend/tests/test_feeder_labels.py b/expert_backend/tests/test_feeder_labels.py new file mode 100644 index 0000000..7243534 --- /dev/null +++ b/expert_backend/tests/test_feeder_labels.py @@ -0,0 +1,133 @@ +# 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. + +"""Tests for the SLD feeder-label helper (other-end VL name + parallel index). + +See docs/features/sld-diagram-feeder-labels.md. +""" + +from unittest.mock import MagicMock + +import pandas as pd + +from expert_backend.services.diagram.sld_render import build_feeder_labels + + +def _df(rows: dict) -> pd.DataFrame: + """Build a DataFrame indexed by equipment id from ``{eid: {col: val}}``.""" + return pd.DataFrame.from_dict(rows, orient="index") + + +def _make_network(vls: dict, lines: dict, trafos: dict | None = None) -> MagicMock: + net = MagicMock() + net.get_voltage_levels.return_value = pd.DataFrame.from_dict(vls, orient="index") + net.get_lines.return_value = _df(lines) + trafo_df = ( + _df(trafos) + if trafos + else pd.DataFrame(columns=["name", "voltage_level1_id", "voltage_level2_id"]) + ) + net.get_2_windings_transformers.return_value = trafo_df + return net + + +class TestBuildFeederLabels: + def _network(self): + vls = { + "VL_PRAGN": {"name": "PRAGNERES 225kV"}, + "VL_MARSIL": {"name": "MARSILLON 225kV"}, + "VL_LANNE": {"name": "LANNEMEZAN 225kV"}, + "VL_NONAME": {"name": "VL_NONAME"}, # name == id → not a real name + "VL_TRAFOEND": {"name": "TRAFOSUB 400kV"}, + } + lines = { + # overloaded line, far end MARSILLON → single + "relation_8423569-225": { + "name": "MARSIL61PRAGN", + "voltage_level1_id": "VL_MARSIL", + "voltage_level2_id": "VL_PRAGN", + }, + # parallel pair PRAGN↔LANNE (with line_par1) + "relation_8423570-225": { + "name": "LANNEL61PRAGN", + "voltage_level1_id": "VL_PRAGN", + "voltage_level2_id": "VL_LANNE", + }, + "line_par1": { + "name": "LANNEL62PRAGN", + "voltage_level1_id": "VL_PRAGN", + "voltage_level2_id": "VL_LANNE", + }, + # far-end VL unnamed → fall back to the branch's own name + "line_to_noname": { + "name": "X.LINE", + "voltage_level1_id": "VL_PRAGN", + "voltage_level2_id": "VL_NONAME", + }, + # far-end VL unnamed AND branch name == id → no label + "line_nn2": { + "name": "line_nn2", + "voltage_level1_id": "VL_PRAGN", + "voltage_level2_id": "VL_NONAME", + }, + # does not touch VL_PRAGN → excluded + "other_line": { + "name": "MARSIL61LANNE", + "voltage_level1_id": "VL_MARSIL", + "voltage_level2_id": "VL_LANNE", + }, + } + trafos = { + "trafo1": { + "name": "TR1", + "voltage_level1_id": "VL_PRAGN", + "voltage_level2_id": "VL_TRAFOEND", + }, + } + return _make_network(vls, lines, trafos) + + def test_single_branch_labelled_with_far_end_vl_name(self): + result = build_feeder_labels(self._network(), "VL_PRAGN") + assert result["relation_8423569-225"]["label"] == "MARSILLON 225kV" + # The branch's own friendly (operator) name is carried for overload matching. + assert result["relation_8423569-225"]["name"] == "MARSIL61PRAGN" + assert result["relation_8423569-225"]["other_vl"] == "VL_MARSIL" + + def test_parallel_branches_get_indices(self): + result = build_feeder_labels(self._network(), "VL_PRAGN") + # Sorted by equipment id: "line_par1" < "relation_8423570-225". + assert result["line_par1"]["label"] == "LANNEMEZAN 225kV 1" + assert result["relation_8423570-225"]["label"] == "LANNEMEZAN 225kV 2" + + def test_unnamed_far_end_falls_back_to_branch_name(self): + result = build_feeder_labels(self._network(), "VL_PRAGN") + assert result["line_to_noname"]["label"] == "X.LINE" + + def test_no_label_when_neither_far_end_nor_branch_named(self): + result = build_feeder_labels(self._network(), "VL_PRAGN") + assert result["line_nn2"]["label"] is None + assert result["line_nn2"]["name"] is None + + def test_branch_not_touching_vl_is_excluded(self): + result = build_feeder_labels(self._network(), "VL_PRAGN") + assert "other_line" not in result + + def test_transformer_feeder_labelled(self): + result = build_feeder_labels(self._network(), "VL_PRAGN") + assert result["trafo1"]["label"] == "TRAFOSUB 400kV" + assert result["trafo1"]["other_vl"] == "VL_TRAFOEND" + + def test_empty_vl_id_returns_empty(self): + assert build_feeder_labels(self._network(), "") == {} + + def test_pypowsybl_failure_degrades_to_empty(self): + net = MagicMock() + net.get_voltage_levels.side_effect = RuntimeError("boom") + net.get_lines.side_effect = RuntimeError("boom") + net.get_2_windings_transformers.side_effect = RuntimeError("boom") + # Must not raise — relabelling is additive. + assert build_feeder_labels(net, "VL_PRAGN") == {} diff --git a/expert_backend/tests/test_simulation_helpers.py b/expert_backend/tests/test_simulation_helpers.py index 63f802a..b0ebb17 100644 --- a/expert_backend/tests/test_simulation_helpers.py +++ b/expert_backend/tests/test_simulation_helpers.py @@ -12,11 +12,13 @@ behaviour the orchestrator methods relied on. """ import numpy as np +import pandas as pd import pytest from unittest.mock import MagicMock from expert_backend.services.simulation_helpers import ( TOPO_KEYS, + build_branch_connectivity, build_care_mask, build_combined_description, canonicalize_action_id, @@ -538,6 +540,115 @@ def test_max_rho_picks_highest_monitored_line(self): assert metrics["max_rho_line"] == "L2" assert metrics["max_rho"] == pytest.approx(1.3 * 0.95) + # -- disconnected-line loading coherence (Issue 3) -- + + def test_disconnected_overloaded_line_reports_zero_loading(self): + """A line the action disconnects shows 0 % loading even when grid2op's + forecast rho stays non-zero — the card-vs-diagram coherence fix. + + Reproduces the reported case: MARSIL61PRAGN overloaded at 106 % in N-1, + disconnected by the action, yet grid2op's obs.rho stays at 0.35 (→ 33 %). + """ + names = ["MARSIL61PRAGN", "L2", "L3"] + metrics = compute_action_metrics( + obs=self._obs([0.1, 0.1, 0.1], names=names), + obs_simu_defaut=self._obs([1.06, 0.5, 0.5], names=names), + obs_simu_action=self._obs([0.35, 0.5, 0.5], names=names), # stale grid2op rho + info_action={"exception": None}, + lines_overloaded_ids=[0], + lines_we_care_about=set(names), + branches_with_limits=set(names), + monitoring_factor=0.95, + worsening_threshold=0.02, + disconnected_line_names={"MARSIL61PRAGN"}, + ) + assert metrics["rho_after"] == pytest.approx([0.0]) + assert "MARSIL61PRAGN" not in metrics["lines_overloaded_after"] + + def test_loading_unchanged_without_disconnect_set(self): + """Without the disconnect set the (stale) grid2op loading is reported + as-is — the fix is opt-in and changes nothing for non-disconnections.""" + names = ["MARSIL61PRAGN", "L2", "L3"] + metrics = compute_action_metrics( + obs=self._obs([0.1, 0.1, 0.1], names=names), + obs_simu_defaut=self._obs([1.06, 0.5, 0.5], names=names), + obs_simu_action=self._obs([0.35, 0.5, 0.5], names=names), + info_action={"exception": None}, + lines_overloaded_ids=[0], + lines_we_care_about=set(names), + branches_with_limits=set(names), + monitoring_factor=0.95, + worsening_threshold=0.02, + ) + assert metrics["rho_after"] == pytest.approx([0.35 * 0.95]) + + def test_disconnected_line_excluded_from_max_rho(self): + """When the action disconnects the worst line, max_rho drops to the + next-highest connected line instead of reporting the stale value.""" + names = ["A", "B", "C"] + metrics = compute_action_metrics( + obs=self._obs([0.1, 0.1, 0.1], names=names), + obs_simu_defaut=self._obs([0.1, 0.1, 0.1], names=names), + obs_simu_action=self._obs([1.4, 0.9, 0.5], names=names), # A highest but disconnected + info_action={"exception": None}, + lines_overloaded_ids=[], + lines_we_care_about=set(names), + branches_with_limits=set(names), + monitoring_factor=0.95, + worsening_threshold=0.02, + disconnected_line_names={"A"}, + ) + assert metrics["max_rho_line"] == "B" + assert metrics["max_rho"] == pytest.approx(0.9 * 0.95) + + +# ---------------------------------------------------------------------- +# build_branch_connectivity +# ---------------------------------------------------------------------- + +class TestBuildBranchConnectivity: + def _net(self, lines, trafos=None): + net = MagicMock() + net.get_lines.return_value = pd.DataFrame.from_dict(lines, orient="index") + net.get_2_windings_transformers.return_value = ( + pd.DataFrame.from_dict(trafos, orient="index") + if trafos + else pd.DataFrame(columns=["name", "connected1", "connected2"]) + ) + return net + + def test_keys_by_id_and_friendly_name(self): + net = self._net( + { + "relation_8423569-225": { + "name": "MARSIL61PRAGN", "connected1": True, "connected2": False, + }, + "L2": {"name": "L2NAME", "connected1": True, "connected2": True}, + } + ) + conn = build_branch_connectivity(net) + # Disconnected (one terminal open) — reachable by IIDM id AND friendly name. + assert conn["relation_8423569-225"] is True + assert conn["MARSIL61PRAGN"] is True + # Fully connected. + assert conn["L2"] is False + assert conn["L2NAME"] is False + + def test_transformers_included(self): + net = self._net( + {"L1": {"name": "L1", "connected1": True, "connected2": True}}, + trafos={"T1": {"name": "TR1", "connected1": False, "connected2": True}}, + ) + conn = build_branch_connectivity(net) + assert conn["T1"] is True + assert conn["TR1"] is True + + def test_pypowsybl_failure_degrades_to_empty(self): + net = MagicMock() + net.get_lines.side_effect = RuntimeError("boom") + net.get_2_windings_transformers.side_effect = RuntimeError("boom") + assert build_branch_connectivity(net) == {} + # ---------------------------------------------------------------------- # extract_action_topology diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index d395915..211b969 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -70,8 +70,15 @@ frontend/ │ ├── useOverflowIframe.ts # Interactive overflow viewer: iframe │ │ # lifecycle, layer toggles, hierarchical ↔ │ │ # geo switch, postMessage bridge, pin overlay - │ └── useTheme.ts # Light/dark theme toggle + persistence - │ # (0.8.0; see docs/features/dark-mode.md) + │ ├── useTheme.ts # Light/dark theme toggle + persistence + │ │ # (0.8.0; see docs/features/dark-mode.md) + │ ├── useSldFeederRelabel.ts # Relabel SLD branch feeders with the far- + │ │ # end VL name (Issue 1; render-every-time + │ │ # self-gate, delegates the DOM swap to + │ │ # utils/svg/feederLabels.applyFeederRelabels) + │ └── useSldInjectionNameButtons.ts # Render editable-injection NAME + │ # buttons on the SLD (extracted from + │ # SldOverlay to keep it under the LoC ceiling) ├── components/ # Presentational components (no API calls) │ ├── Header.tsx, ActionFeed.tsx, OverloadPanel.tsx, │ ├── VisualizationPanel.tsx, ActionCard.tsx, ActionCardPopover.tsx, @@ -153,8 +160,11 @@ frontend/ │ ├── actionPinData.ts # - deltaVisuals: flow-delta colouring │ ├── actionPinRender.ts # - actionPin{Data,Render}: overview pin layer │ ├── highlights.ts # - highlights: contingency / overload halos - │ └── edgeInfoDeclutter.ts # - flow-value de-collision (slide along edge; - │ # load-time pass invoked by svgBoost §6) + │ ├── edgeInfoDeclutter.ts # - flow-value de-collision (slide along edge; + │ │ # load-time pass invoked by svgBoost §6) + │ └── feederLabels.ts # - SLD feeder relabel + friendly-name↔IIDM-id + │ # overload bridge (see useSldFeederRelabel + + │ # docs/features/sld-diagram-feeder-labels.md) ├── svgPatch.ts # SVG DOM recycling: clone N-state SVG │ # and patch per-branch deltas on N-1 / action │ # tab switches (PR #108). Used by useContingencyFetch. diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 81da7af..1a25048 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -6,7 +6,7 @@ // This file is part of Co-Study4Grid a Power Grid Study tool Assistant Interface to help solve contigencies for a grid state under study. import axios from 'axios'; -import type { ConfigRequest, BranchResponse, DiagramData, DiagramPatch, FlowDelta, AssetDelta, AvailableAction, SessionResult, VlInjection } from './types'; +import type { ConfigRequest, BranchResponse, DiagramData, DiagramPatch, FlowDelta, AssetDelta, AvailableAction, SessionResult, VlInjection, FeederLabel } from './types'; // Same-origin by default when built with `VITE_API_BASE_URL=''` (the // frontend is served by the backend, e.g. on a HuggingFace Docker Space): @@ -239,15 +239,15 @@ export const api = { ); return response.data; }, - getNSld: async (voltageLevelId: string): Promise<{ svg: string; sld_metadata: string | null; voltage_level_id: string; switch_states?: Record; injections?: Record }> => { - const response = await axios.post<{ svg: string; sld_metadata: string | null; voltage_level_id: string; switch_states?: Record; injections?: Record }>( + getNSld: async (voltageLevelId: string): Promise<{ svg: string; sld_metadata: string | null; voltage_level_id: string; switch_states?: Record; injections?: Record; feeder_labels?: Record }> => { + const response = await axios.post<{ svg: string; sld_metadata: string | null; voltage_level_id: string; switch_states?: Record; injections?: Record; feeder_labels?: Record }>( `${API_BASE_URL}/api/n-sld`, { voltage_level_id: voltageLevelId } ); return response.data; }, - getContingencySld: async (disconnectedElements: string[], voltageLevelId: string): Promise<{ svg: string; sld_metadata: string | null; voltage_level_id: string; flow_deltas?: Record; reactive_flow_deltas?: Record; asset_deltas?: Record; switch_states?: Record; injections?: Record }> => { - const response = await axios.post<{ svg: string; sld_metadata: string | null; voltage_level_id: string; flow_deltas?: Record; reactive_flow_deltas?: Record; asset_deltas?: Record; switch_states?: Record; injections?: Record }>( + getContingencySld: async (disconnectedElements: string[], voltageLevelId: string): Promise<{ svg: string; sld_metadata: string | null; voltage_level_id: string; flow_deltas?: Record; reactive_flow_deltas?: Record; asset_deltas?: Record; switch_states?: Record; injections?: Record; feeder_labels?: Record }> => { + const response = await axios.post<{ svg: string; sld_metadata: string | null; voltage_level_id: string; flow_deltas?: Record; reactive_flow_deltas?: Record; asset_deltas?: Record; switch_states?: Record; injections?: Record; feeder_labels?: Record }>( `${API_BASE_URL}/api/contingency-sld`, { disconnected_elements: disconnectedElements, voltage_level_id: voltageLevelId } ); @@ -263,8 +263,8 @@ export const api = { disconnectedElements: string[]; switches: Record; baseActionId?: string | null; - }): Promise<{ svg: string; sld_metadata: string | null; voltage_level_id: string; switch_states?: Record; stale_flows?: boolean }> => { - const response = await axios.post<{ svg: string; sld_metadata: string | null; voltage_level_id: string; switch_states?: Record; stale_flows?: boolean }>( + }): Promise<{ svg: string; sld_metadata: string | null; voltage_level_id: string; switch_states?: Record; stale_flows?: boolean; feeder_labels?: Record }> => { + const response = await axios.post<{ svg: string; sld_metadata: string | null; voltage_level_id: string; switch_states?: Record; stale_flows?: boolean; feeder_labels?: Record }>( `${API_BASE_URL}/api/sld-topology-preview`, { voltage_level_id: params.voltageLevelId, @@ -275,8 +275,8 @@ export const api = { ); return response.data; }, - getActionVariantSld: async (actionId: string, voltageLevelId: string): Promise<{ svg: string; sld_metadata: string | null; action_id: string; voltage_level_id: string; flow_deltas?: Record; reactive_flow_deltas?: Record; asset_deltas?: Record; changed_switches?: Record; switch_states?: Record; injections?: Record }> => { - const response = await axios.post<{ svg: string; sld_metadata: string | null; action_id: string; voltage_level_id: string; flow_deltas?: Record; reactive_flow_deltas?: Record; asset_deltas?: Record; changed_switches?: Record; switch_states?: Record; injections?: Record }>( + getActionVariantSld: async (actionId: string, voltageLevelId: string): Promise<{ svg: string; sld_metadata: string | null; action_id: string; voltage_level_id: string; flow_deltas?: Record; reactive_flow_deltas?: Record; asset_deltas?: Record; changed_switches?: Record; switch_states?: Record; injections?: Record; feeder_labels?: Record }> => { + const response = await axios.post<{ svg: string; sld_metadata: string | null; action_id: string; voltage_level_id: string; flow_deltas?: Record; reactive_flow_deltas?: Record; asset_deltas?: Record; changed_switches?: Record; switch_states?: Record; injections?: Record; feeder_labels?: Record }>( `${API_BASE_URL}/api/action-variant-sld`, { action_id: actionId, voltage_level_id: voltageLevelId } ); diff --git a/frontend/src/components/SldOverlay.test.tsx b/frontend/src/components/SldOverlay.test.tsx index 030c88f..bb985bb 100644 --- a/frontend/src/components/SldOverlay.test.tsx +++ b/frontend/src/components/SldOverlay.test.tsx @@ -1320,4 +1320,145 @@ describe('SldOverlay', () => { } }); }); + + describe('feeder relabelling — other-end VL name (Issue 1)', () => { + const buildRelabelOverlay = ( + feederLabels: VlOverlay['feeder_labels'], + tab: SldTab = 'n', + ): VlOverlay => ({ + vlName: 'VL_PRAGN', + actionId: null, + svg: + '' + + 'relation_8423569-225' + + 'L_way_207479669-225' + + '', + sldMetadata: null, + loading: false, + error: null, + tab, + feeder_labels: feederLabels, + }); + + it('replaces the raw branch id with the far-end VL name', () => { + const overlay = buildRelabelOverlay({ + 'relation_8423569-225': { + name: 'MARSIL61PRAGN', other_vl: 'VL_MARSIL', label: 'MARSILLON 225kV', + }, + }); + const { container } = render(); + const lbl = container.querySelector('#lbl_marsil'); + expect(lbl?.textContent).toBe('MARSILLON 225kV'); + expect(lbl?.getAttribute('data-feeder-orig')).toBe('relation_8423569-225'); + }); + + it('preserves a parallel-circuit index in the label', () => { + const overlay = buildRelabelOverlay({ + 'relation_8423569-225': { + name: 'LANNEL61PRAGN', other_vl: 'VL_LANNE', label: 'LANNEMEZAN 225kV 2', + }, + }); + const { container } = render(); + expect(container.querySelector('#lbl_marsil')?.textContent).toBe('LANNEMEZAN 225kV 2'); + }); + + it('leaves the id untouched when the label is null', () => { + const overlay = buildRelabelOverlay({ + 'relation_8423569-225': { name: null, other_vl: null, label: null }, + }); + const { container } = render(); + expect(container.querySelector('#lbl_marsil')?.textContent).toBe('relation_8423569-225'); + }); + + it('leaves feeders without an entry untouched', () => { + const overlay = buildRelabelOverlay({ + 'relation_8423569-225': { + name: 'MARSIL61PRAGN', other_vl: 'VL_MARSIL', label: 'MARSILLON 225kV', + }, + }); + const { container } = render(); + // The load feeder has no feeder_labels entry → keep its own id label. + expect(container.querySelector('#lbl_load')?.textContent).toBe('L_way_207479669-225'); + }); + + it('does nothing when feeder_labels is absent', () => { + const overlay = buildRelabelOverlay(undefined); + const { container } = render(); + expect(container.querySelector('#lbl_marsil')?.textContent).toBe('relation_8423569-225'); + expect(container.querySelector('[data-feeder-relabel]')).toBeNull(); + }); + }); + + describe('overload halo via friendly name (Issue 2)', () => { + // SLD cell keyed by IIDM id; the overload is reported by the grid2op / + // operator FRIENDLY name. Without the feeder_labels bridge the halo + // never lands (the friendly name does not match the metadata id). + const buildOverlay = (withFeederLabels: boolean): VlOverlay => ({ + vlName: 'VL_PRAGN', + actionId: null, + svg: + '' + + '' + + '', + sldMetadata: JSON.stringify({ + nodes: [{ id: 'cell_marsil', equipmentId: 'relation_8423569-225' }], + }), + loading: false, + error: null, + tab: 'contingency', + feeder_labels: withFeederLabels + ? { + 'relation_8423569-225': { + name: 'MARSIL61PRAGN', other_vl: 'VL_MARSIL', label: 'MARSILLON 225kV', + }, + } + : undefined, + }); + + const resultWith = (overloaded: string[]) => + ({ + actions: {}, + lines_overloaded: overloaded, + pdf_path: null, + pdf_url: null, + message: '', + dc_fallback: false, + } as unknown as AnalysisResult); + + it('highlights the feeder when the overload is reported by friendly name', () => { + const { container } = render( + , + ); + expect(container.querySelector('#cell_marsil.sld-highlight-overloaded-original')).toBeTruthy(); + }); + + it('does NOT highlight without the bridge (reproduces the bug)', () => { + const { container } = render( + , + ); + expect(container.querySelector('#cell_marsil.sld-highlight-overloaded-original')).toBeNull(); + }); + + it('still highlights when the overload is reported by raw IIDM id', () => { + const { container } = render( + , + ); + expect(container.querySelector('#cell_marsil.sld-highlight-overloaded-original')).toBeTruthy(); + }); + }); }); diff --git a/frontend/src/components/SldOverlay.tsx b/frontend/src/components/SldOverlay.tsx index d80f1ef..cc33208 100644 --- a/frontend/src/components/SldOverlay.tsx +++ b/frontend/src/components/SldOverlay.tsx @@ -8,6 +8,9 @@ import React, { useState, useEffect, useLayoutEffect, useRef } from 'react'; import type { DiagramData, AnalysisResult, ActionDetail, VlOverlay, SldTab, SldFeederNode } from '../types'; import { isCouplingAction } from '../utils/svgUtils'; +import { buildFriendlyToEquip, overloadCandidates } from '../utils/svg/feederLabels'; +import { useSldFeederRelabel } from '../hooks/useSldFeederRelabel'; +import { useSldInjectionNameButtons } from '../hooks/useSldInjectionNameButtons'; import { colors } from '../styles/tokens'; import SldEditPanel from './SldEditPanel'; import SldInjectionPopover from './SldInjectionPopover'; @@ -904,8 +907,18 @@ const SldOverlay: React.FC = ({ overloadedLinesToShow = isSolved ? [] : actionDetail.lines_overloaded_after; } if (overloadedLinesToShow && overloadedLinesToShow.length > 0) { + // Overload names arrive from the analysis result as grid2op / + // operator FRIENDLY names (e.g. "MARSIL61PRAGN"), but the SLD cells + // are keyed by IIDM id (e.g. "relation_8423569-225") — a direct + // `findCellForEquipment` misses, so the halo never showed (Issue 2). + // Bridge friendly name → IIDM id via the feeder_labels map. + const friendlyToEquip = buildFriendlyToEquip(vlOverlay.feeder_labels); for (const lineId of overloadedLinesToShow) { - const cell = findCellForEquipment(lineId); + let cell: Element | null = null; + for (const cand of overloadCandidates(lineId, friendlyToEquip)) { + cell = findCellForEquipment(cand); + if (cell) break; + } if (cell && !highlightedCells.has(cell)) { cloneHighlight(cell, 'sld-highlight-overloaded'); highlightedCells.add(cell); @@ -1011,82 +1024,13 @@ const SldOverlay: React.FC = ({ } }); - // Render every editable injection's NAME as a dark-blue button: inject a - // rounded behind the matching name and recolour the label - // on top (CSS). The whole thing is the click target that opens the - // active-power editor (data-injection-equip, read by the click delegate). - // - // Runs every render and self-gates via signature + presence (same pattern - // as the delta / highlight passes above): the action / contingency - // highlight effect CLONES cells (the clone is inserted before the - // original and later removed), so (a) we must skip clones when matching - // the name text — otherwise the button lands on the soon-to-be-removed - // clone and the action target's name loses its button — and (b) we must - // re-apply if a reconciliation / tab switch drops the buttons. - const appliedNameBtnSigRef = useRef(''); - useLayoutEffect(() => { - const container = overlayBodyRef.current; - if (!container) return; - const injections = editMode ? vlOverlay.injections : undefined; - const ids = injections ? Object.keys(injections) : []; - const sig = JSON.stringify({ - edit: editMode, - ids: [...ids].sort(), - svgLen: activeSvg?.length ?? 0, - tab: vlOverlay.tab, - action: vlOverlay.actionId, - preview: previewActive, - }); - const btnsPresent = container.querySelector('.sld-injection-name-btn') !== null; - const expectBtns = ids.length > 0 && !!activeSvg; - if (sig === appliedNameBtnSigRef.current && (expectBtns ? btnsPresent : !btnsPresent)) { - return; - } - appliedNameBtnSigRef.current = sig; + // Render every editable injection's NAME as a clickable button (opens the + // active-power editor) — pass + its self-gate live in the hook. + useSldInjectionNameButtons(overlayBodyRef, vlOverlay.injections, editMode, activeSvg, vlOverlay.tab, vlOverlay.actionId, previewActive); - container.querySelectorAll('.sld-injection-name-btn').forEach(el => el.remove()); - container.querySelectorAll('.sld-injection-name-label').forEach(el => { - el.classList.remove('sld-injection-name-label'); - el.removeAttribute('data-injection-equip'); - }); - if (!injections || ids.length === 0) return; - - const SVGNS = 'http://www.w3.org/2000/svg'; - // Skip highlight clones — they carry a copy of the name text but are - // removed on the next render, so a button placed on a clone vanishes. - const texts = Array.from(container.querySelectorAll('text')) - .filter(t => !t.closest('.sld-highlight-clone')); - for (const equipmentId of ids) { - const variants = new Set([ - equipmentId, - equipmentId.replace(/\./g, '_'), - equipmentId.replace(/_/g, '.'), - ]); - const textEl = texts.find(t => variants.has((t.textContent ?? '').trim())); - if (!textEl || !textEl.parentNode) continue; - let bbox: DOMRect; - try { - bbox = textEl.getBBox(); - } catch { - continue; // getBBox unavailable (jsdom) / not laid out - } - if (!(bbox.width > 0 && bbox.height > 0)) continue; - const padX = 4, padY = 2.5; - const rect = document.createElementNS(SVGNS, 'rect'); - rect.setAttribute('x', String(bbox.x - padX)); - rect.setAttribute('y', String(bbox.y - padY)); - rect.setAttribute('width', String(bbox.width + padX * 2)); - rect.setAttribute('height', String(bbox.height + padY * 2)); - rect.setAttribute('rx', '3'); - rect.setAttribute('class', 'sld-injection-name-btn'); - rect.setAttribute('data-injection-equip', equipmentId); - textEl.parentNode.insertBefore(rect, textEl); - textEl.classList.add('sld-injection-name-label'); - textEl.setAttribute('data-injection-equip', equipmentId); - } - // No deps on purpose — runs every render, self-gates via the - // signature + presence probe above (catches clone churn + drops). - }); + // Relabel branch feeders with the far-end VL name (Issue 1) — pass + its + // self-gate live in the hook (see utils/svg/feederLabels.ts for the swap). + useSldFeederRelabel(overlayBodyRef, vlOverlay.feeder_labels, activeSvg, vlOverlay.tab, vlOverlay.actionId, previewActive); // Delegate switch clicks on the SVG body when edit mode is on. // We attach to the body container (capture phase) so taps on the diff --git a/frontend/src/hooks/useSldFeederRelabel.ts b/frontend/src/hooks/useSldFeederRelabel.ts new file mode 100644 index 0000000..f9cbb0a --- /dev/null +++ b/frontend/src/hooks/useSldFeederRelabel.ts @@ -0,0 +1,45 @@ +// 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. + +import { useLayoutEffect, useRef, type RefObject } from 'react'; +import type { FeederLabel, SldTab } from '../types'; +import { applyFeederRelabels } from '../utils/svg/feederLabels'; + +/** + * Relabel SLD branch feeders with the far-end VL name (Issue 1). Owns the + * render-every-time + signature self-gate so a pan reconciliation that drops + * the relabel re-applies it, mirroring the other SldOverlay label passes. + */ +export function useSldFeederRelabel( + bodyRef: RefObject, + feederLabels: Record | undefined, + activeSvg: string | null, + tab: SldTab, + actionId: string | null, + preview: boolean, +): void { + const sigRef = useRef(''); + useLayoutEffect(() => { + const container = bodyRef.current; + if (!container) return; + const entries = feederLabels + ? Object.entries(feederLabels).filter(([, info]) => !!info && !!info.label) + : []; + const sig = JSON.stringify({ + svgLen: activeSvg?.length ?? 0, + tab, + action: actionId, + preview, + labels: entries.map(([eid, info]) => `${eid}=${info.label}`).sort(), + }); + const applied = container.querySelector('[data-feeder-relabel]') !== null; + const expect = entries.length > 0 && !!activeSvg; + if (sig === sigRef.current && (expect ? applied : !applied)) return; + sigRef.current = sig; + applyFeederRelabels(container, feederLabels, activeSvg); + }); +} diff --git a/frontend/src/hooks/useSldInjectionNameButtons.ts b/frontend/src/hooks/useSldInjectionNameButtons.ts new file mode 100644 index 0000000..efb0fa6 --- /dev/null +++ b/frontend/src/hooks/useSldInjectionNameButtons.ts @@ -0,0 +1,98 @@ +// 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. + +import { useLayoutEffect, useRef, type RefObject } from 'react'; +import type { SldTab, VlInjection } from '../types'; + +/** + * Render every editable injection's NAME as a dark-blue button: inject a + * rounded ```` behind the matching name ```` and recolour the label + * on top (CSS). The whole thing is the click target that opens the active-power + * editor (``data-injection-equip``, read by the click delegate). + * + * Runs every render and self-gates via signature + presence (same pattern as + * the delta / highlight / feeder-relabel passes): the action / contingency + * highlight effect CLONES cells (the clone is inserted before the original and + * later removed), so (a) we must skip clones when matching the name text — + * otherwise the button lands on the soon-to-be-removed clone and the action + * target's name loses its button — and (b) we must re-apply if a reconciliation + * / tab switch drops the buttons. + */ +export function useSldInjectionNameButtons( + bodyRef: RefObject, + injectionsBaseline: Record | undefined, + editMode: boolean, + activeSvg: string | null, + tab: SldTab, + actionId: string | null, + preview: boolean, +): void { + const sigRef = useRef(''); + useLayoutEffect(() => { + const container = bodyRef.current; + if (!container) return; + const injections = editMode ? injectionsBaseline : undefined; + const ids = injections ? Object.keys(injections) : []; + const sig = JSON.stringify({ + edit: editMode, + ids: [...ids].sort(), + svgLen: activeSvg?.length ?? 0, + tab, + action: actionId, + preview, + }); + const btnsPresent = container.querySelector('.sld-injection-name-btn') !== null; + const expectBtns = ids.length > 0 && !!activeSvg; + if (sig === sigRef.current && (expectBtns ? btnsPresent : !btnsPresent)) { + return; + } + sigRef.current = sig; + + container.querySelectorAll('.sld-injection-name-btn').forEach(el => el.remove()); + container.querySelectorAll('.sld-injection-name-label').forEach(el => { + el.classList.remove('sld-injection-name-label'); + el.removeAttribute('data-injection-equip'); + }); + if (!injections || ids.length === 0) return; + + const SVGNS = 'http://www.w3.org/2000/svg'; + // Skip highlight clones — they carry a copy of the name text but are + // removed on the next render, so a button placed on a clone vanishes. + const texts = Array.from(container.querySelectorAll('text')) + .filter(t => !t.closest('.sld-highlight-clone')); + for (const equipmentId of ids) { + const variants = new Set([ + equipmentId, + equipmentId.replace(/\./g, '_'), + equipmentId.replace(/_/g, '.'), + ]); + const textEl = texts.find(t => variants.has((t.textContent ?? '').trim())); + if (!textEl || !textEl.parentNode) continue; + let bbox: DOMRect; + try { + bbox = textEl.getBBox(); + } catch { + continue; // getBBox unavailable (jsdom) / not laid out + } + if (!(bbox.width > 0 && bbox.height > 0)) continue; + const padX = 4, padY = 2.5; + const rect = document.createElementNS(SVGNS, 'rect'); + rect.setAttribute('x', String(bbox.x - padX)); + rect.setAttribute('y', String(bbox.y - padY)); + rect.setAttribute('width', String(bbox.width + padX * 2)); + rect.setAttribute('height', String(bbox.height + padY * 2)); + rect.setAttribute('rx', '3'); + rect.setAttribute('class', 'sld-injection-name-btn'); + rect.setAttribute('data-injection-equip', equipmentId); + textEl.parentNode.insertBefore(rect, textEl); + textEl.classList.add('sld-injection-name-label'); + textEl.setAttribute('data-injection-equip', equipmentId); + } + // No deps on purpose — runs every render, self-gates via the + // signature + presence probe above (catches clone churn + drops). + }); +} diff --git a/frontend/src/hooks/useSldOverlay.ts b/frontend/src/hooks/useSldOverlay.ts index 84729c9..3240989 100644 --- a/frontend/src/hooks/useSldOverlay.ts +++ b/frontend/src/hooks/useSldOverlay.ts @@ -7,7 +7,7 @@ import { useState, useCallback, useRef, useEffect, type MutableRefObject } from 'react'; import { api } from '../api'; -import type { VlOverlay, SldTab, FlowDelta, AssetDelta, TabId, VlInjection } from '../types'; +import type { VlOverlay, SldTab, FlowDelta, AssetDelta, TabId, VlInjection, FeederLabel } from '../types'; import { interactionLogger } from '../utils/interactionLogger'; export interface SldOverlayState { @@ -64,6 +64,7 @@ export function useSldOverlay(activeTab: TabId, liveSelectedActionId?: string | let changedSwitches: Record | undefined; let switchStates: Record | undefined; let injections: Record | undefined; + let feederLabels: Record | undefined; if (sldTab === 'n') { const res = await api.getNSld(vlName); @@ -71,6 +72,7 @@ export function useSldOverlay(activeTab: TabId, liveSelectedActionId?: string | metaData = res.sld_metadata ?? null; switchStates = res.switch_states; injections = res.injections; + feederLabels = res.feeder_labels; } else if (sldTab === 'contingency') { const res = await api.getContingencySld(contingencyElements, vlName); svgData = res.svg; @@ -80,6 +82,7 @@ export function useSldOverlay(activeTab: TabId, liveSelectedActionId?: string | assetDeltas = res.asset_deltas; switchStates = res.switch_states; injections = res.injections; + feederLabels = res.feeder_labels; } else { // Fallback to the live selectedActionId if the // overlay was opened from a tab where no action @@ -107,6 +110,7 @@ export function useSldOverlay(activeTab: TabId, liveSelectedActionId?: string | changedSwitches = res.changed_switches; switchStates = res.switch_states; injections = res.injections; + feederLabels = res.feeder_labels; // Persist the resolved actionId back onto the // overlay so subsequent re-renders and highlight // passes can find it on `vlOverlay.actionId`. @@ -124,6 +128,7 @@ export function useSldOverlay(activeTab: TabId, liveSelectedActionId?: string | changed_switches: changedSwitches, switch_states: switchStates, injections, + feeder_labels: feederLabels, } : prev ); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 8d1fa31..319e79a 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -403,6 +403,26 @@ export interface VlInjection { energy_source?: string; } +/** + * Identity + display label for one branch feeder on the displayed SLD. + * Backend populates this on every SLD endpoint via ``build_feeder_labels``. + * + * ``label`` is the **name of the voltage level at the OTHER end** of the + * branch (+ a parallel-circuit index when several branches of this VL reach + * the same far-end VL) — far more interpretable than pypowsybl's default raw + * IIDM branch id. ``name`` carries the branch's friendly / operator name + * (e.g. ``MARSIL61PRAGN``) so an overloaded line reported by friendly name + * can be mapped back to its IIDM-id-keyed SLD cell for the overload halo. + */ +export interface FeederLabel { + /** Branch friendly (operator / grid2op) name; ``null`` when none. */ + name: string | null; + /** Voltage-level id at the far end of the branch; ``null`` when unknown. */ + other_vl: string | null; + /** Display label (far-end VL name + index); ``null`` → keep the default id. */ + label: string | null; +} + export interface VlOverlay { vlName: string; actionId: string | null; @@ -415,6 +435,13 @@ export interface VlOverlay { reactive_flow_deltas?: Record; asset_deltas?: Record; changed_switches?: Record; + /** + * Branch feeder identities + relabel targets for the displayed VL, keyed + * by IIDM equipment id. Drives the feeder relabelling (other-end VL name) + * and the friendly-name → IIDM-id resolution that lets the overload halo + * land on the right cell. Backend populates via ``build_feeder_labels``. + */ + feeder_labels?: Record; /** * Baseline switch states for the displayed VL (``{switch_id: * is_open}``). Drives the interactive SLD-edit feature in diff --git a/frontend/src/utils/svg/feederLabels.test.ts b/frontend/src/utils/svg/feederLabels.test.ts new file mode 100644 index 0000000..2e759aa --- /dev/null +++ b/frontend/src/utils/svg/feederLabels.test.ts @@ -0,0 +1,90 @@ +// 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 + +import { describe, it, expect } from 'vitest'; +import { buildFriendlyToEquip, overloadCandidates, applyFeederRelabels } from './feederLabels'; +import type { FeederLabel } from '../../types'; + +const fl = (over: Partial> = {}): Record => ({ + 'relation_8423569-225': { name: 'MARSIL61PRAGN', other_vl: 'VL_MARSIL', label: 'MARSILLON 225kV' }, + ...over, +}); + +describe('buildFriendlyToEquip', () => { + it('maps friendly name → equipment id', () => { + const map = buildFriendlyToEquip(fl()); + expect(map.get('MARSIL61PRAGN')).toEqual(['relation_8423569-225']); + }); + + it('groups multiple ids under a shared friendly name', () => { + const map = buildFriendlyToEquip({ + a: { name: 'DOUBLE', other_vl: 'X', label: 'X 1' }, + b: { name: 'DOUBLE', other_vl: 'X', label: 'X 2' }, + }); + expect(map.get('DOUBLE')?.sort()).toEqual(['a', 'b']); + }); + + it('returns an empty map for undefined input', () => { + expect(buildFriendlyToEquip(undefined).size).toBe(0); + }); +}); + +describe('overloadCandidates', () => { + it('prepends the raw value then the mapped ids', () => { + const map = buildFriendlyToEquip(fl()); + expect(overloadCandidates('MARSIL61PRAGN', map)).toEqual([ + 'MARSIL61PRAGN', 'relation_8423569-225', + ]); + }); + + it('falls back to the raw value when unmapped (already id-keyed)', () => { + const map = buildFriendlyToEquip(fl()); + expect(overloadCandidates('relation_8423569-225', map)).toEqual(['relation_8423569-225']); + }); +}); + +describe('applyFeederRelabels', () => { + const mount = (svg: string): HTMLElement => { + const div = document.createElement('div'); + div.innerHTML = svg; + return div; + }; + + it('swaps a matching feeder text to the far-end VL label', () => { + const container = mount('relation_8423569-225'); + applyFeederRelabels(container, fl(), ''); + const t = container.querySelector('#t')!; + expect(t.textContent).toBe('MARSILLON 225kV'); + expect(t.getAttribute('data-feeder-orig')).toBe('relation_8423569-225'); + }); + + it('restores the original on a second call with no labels', () => { + const container = mount('relation_8423569-225'); + applyFeederRelabels(container, fl(), ''); + applyFeederRelabels(container, {}, ''); + expect(container.querySelector('#t')!.textContent).toBe('relation_8423569-225'); + }); + + it('skips highlight clones when matching', () => { + const container = mount( + '' + + 'relation_8423569-225' + + 'relation_8423569-225' + + '', + ); + applyFeederRelabels(container, fl(), ''); + // The original (non-clone) text is relabelled; the clone's copy is left alone. + expect(container.querySelector('#orig')!.textContent).toBe('MARSILLON 225kV'); + expect(container.querySelector('.sld-highlight-clone text')!.textContent) + .toBe('relation_8423569-225'); + }); + + it('does nothing when there is no active svg', () => { + const container = mount('relation_8423569-225'); + applyFeederRelabels(container, fl(), null); + expect(container.querySelector('#t')!.textContent).toBe('relation_8423569-225'); + }); +}); diff --git a/frontend/src/utils/svg/feederLabels.ts b/frontend/src/utils/svg/feederLabels.ts new file mode 100644 index 0000000..a95efbe --- /dev/null +++ b/frontend/src/utils/svg/feederLabels.ts @@ -0,0 +1,87 @@ +// 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. + +import type { FeederLabel } from '../../types'; + +/** + * Build ``{friendlyName: [equipmentId, ...]}`` from the SLD feeder-label map so + * an overloaded line reported by its grid2op / operator FRIENDLY name (e.g. + * "MARSIL61PRAGN") can be resolved to the IIDM-id-keyed SLD cell (e.g. + * "relation_8423569-225") and get its overload halo (Issue 2). + */ +export function buildFriendlyToEquip( + feederLabels: Record | undefined, +): Map { + const map = new Map(); + if (!feederLabels) return map; + for (const [eid, info] of Object.entries(feederLabels)) { + if (info?.name) { + const arr = map.get(info.name) ?? []; + arr.push(eid); + map.set(info.name, arr); + } + } + return map; +} + +/** + * Candidate equipment ids for an overloaded line: the friendly→id mapping (if + * any) plus the raw value as a fallback — covers networks whose overload list + * is already IIDM-id-keyed. + */ +export function overloadCandidates( + lineId: string, + friendlyToEquip: Map, +): string[] { + const mapped = friendlyToEquip.get(lineId); + return mapped && mapped.length > 0 ? [lineId, ...mapped] : [lineId]; +} + +/** + * Relabel branch feeders with the NAME of the voltage level at the OTHER end of + * the branch (e.g. "MARSILLON 225kV") instead of pypowsybl's default raw IIDM + * branch id (e.g. "relation_8423569-225"), which is hard to interpret on the + * PyPSA grids (Issue 1). The backend (``build_feeder_labels``) resolves the + * far-end VL name + parallel-circuit index; here we just swap the matching + * ```` content. + * + * Idempotent: the original text is stashed in ``data-feeder-orig`` so a tab / + * VL / preview switch restores cleanly, and highlight clones (which carry a + * copy of the label but are removed on the next render) are skipped. + */ +export function applyFeederRelabels( + container: HTMLElement, + feederLabels: Record | undefined, + activeSvg: string | null, +): void { + // Restore any previous relabel first, then re-apply against the current SVG. + container.querySelectorAll('[data-feeder-relabel]').forEach(el => { + const orig = el.getAttribute('data-feeder-orig'); + if (orig !== null) el.textContent = orig; + el.removeAttribute('data-feeder-relabel'); + el.removeAttribute('data-feeder-orig'); + }); + const entries = feederLabels + ? Object.entries(feederLabels).filter(([, info]) => !!info && !!info.label) + : []; + if (entries.length === 0 || !activeSvg) return; + + const texts = Array.from(container.querySelectorAll('text')) + .filter(t => !t.closest('.sld-highlight-clone')); + for (const [eid, info] of entries) { + const label = info.label as string; + const variants = new Set([eid, eid.replace(/\./g, '_'), eid.replace(/_/g, '.')]); + const textEl = texts.find(t => + !t.hasAttribute('data-feeder-relabel') + && variants.has((t.textContent ?? '').trim()), + ); + if (!textEl) continue; + textEl.setAttribute('data-feeder-orig', textEl.textContent ?? ''); + textEl.setAttribute('data-feeder-relabel', '1'); + textEl.textContent = label; + } +} From e0314af518b4426eecca734532f1327c4001e7eb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 19:41:45 +0000 Subject: [PATCH 2/2] Explain charging-current loading instead of zeroing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the SLD readability work. The "33% loading after disconnecting the overloaded line" turned out NOT to be a bug: reproducing the exact scenario against expert_op4grid_recommender's PypowsyblObservation on the real grid showed the line, once opened at one end, carries i1≈43A with q1≈-16.8 MVAr and p1=0 — i.e. real reactive CHARGING current of a line whose capacitance stays energised from the live end. rho = 43/130 ≈ 33% is therefore physically correct; the diagrams just show active power (p=0). So instead of suppressing the value we keep it and explain it: - Revert the compute_action_metrics zeroing and its build_branch_connectivity / disconnected_branch_names_from_obs helpers. - Add build_half_open_reactive / half_open_branch_reactive_from_obs / half_open_overload_notes: for a still-"overloaded" line left open at one end with loading >1%, capture the live-end reactive power (MVAr). - simulate_manual_action attaches it as `half_open_overloads` on the result (serialized + carried through the save/reload triad). - ActionCard annotates the loading: "open one end · 16.8 MVAr capacitive", with a tooltip explaining it is charging current, not real flow. The recommender library is correct and was left unchanged. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TXqGodvLG69petEgm4GPrR --- CHANGELOG.md | 20 +- CLAUDE.md | 2 +- docs/features/sld-diagram-feeder-labels.md | 63 +++--- expert_backend/services/simulation_helpers.py | 197 +++++++++--------- expert_backend/services/simulation_mixin.py | 21 +- .../test_disconnected_overload_loading.py | 86 -------- .../tests/test_half_open_overload.py | 134 ++++++++++++ .../tests/test_simulation_helpers.py | 119 ++++------- frontend/src/App.tsx | 2 + frontend/src/api.ts | 1 + frontend/src/components/ActionCard.test.tsx | 40 ++++ frontend/src/components/ActionCard.tsx | 14 ++ frontend/src/hooks/useSession.ts | 1 + frontend/src/types.ts | 20 ++ frontend/src/utils/sessionUtils.ts | 1 + 15 files changed, 415 insertions(+), 306 deletions(-) delete mode 100644 expert_backend/tests/test_disconnected_overload_loading.py create mode 100644 expert_backend/tests/test_half_open_overload.py diff --git a/CHANGELOG.md b/CHANGELOG.md index fd976fd..69d1a7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,17 +23,21 @@ friendly operator name (`MARSIL61PRAGN`): was missing on the constrained feeder because the overload list uses grid2op friendly names while the SLD cells are keyed by IIDM id; the two are now bridged so the halo lands on the right feeder. -- **A disconnected overloaded line reports 0 % loading.** When an action - disconnects the overloaded line itself, the card showed a stale grid2op - loading (e.g. 33 %) while the SLD / NAD correctly drew zero flow. The - card now reads connectivity from the post-action variant — the same - state the diagrams read — and reports 0 %, so all three agree. +- **The "after" loading of a line opened at one end is now explained.** + When an action opens the overloaded line at one end, the card showed e.g. + 33 % while the SLD / NAD drew zero flow. That is not a bug — a line open + at one end carries no active power (what the diagrams draw) but its + capacitance still draws real reactive charging current at the live end, + which the current-based loading reflects. The value is kept and annotated: + the card now adds *"open one end · 16.8 MVAr capacitive"* so it reads as + charging current, not a residual overload. Implementation: every SLD endpoint now returns a `feeder_labels` map (`build_feeder_labels`); the frontend relabel + overload-bridge live in -`utils/svg/feederLabels.ts` + `hooks/useSldFeederRelabel.ts`; the loading -fix is `build_branch_connectivity` / `disconnected_branch_names_from_obs` -feeding `compute_action_metrics`. See +`utils/svg/feederLabels.ts` + `hooks/useSldFeederRelabel.ts`; the charging- +current annotation is `build_half_open_reactive` / `half_open_overload_notes` +surfaced as `half_open_overloads` on the action result and rendered by +`ActionCard`. See [`docs/features/sld-diagram-feeder-labels.md`](docs/features/sld-diagram-feeder-labels.md). ### Direct SLD editing — switches & injections without a mode toggle diff --git a/CLAUDE.md b/CLAUDE.md index 3c25ab2..3565587 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -171,7 +171,7 @@ Co-Study4Grid/ | `docs/features/adding-action-type.md` | Cross-cutting checklist for adding/upgrading a remedial-action type (lib → backend → frontend → save/log/reload triad → regression specs) | | `docs/features/interaction-logging.md` | Replay-ready event log contract | | `docs/features/sld-topology-edit.md` | Interactive SLD topology edit → manual action card | -| `docs/features/sld-diagram-feeder-labels.md` | SLD feeders relabelled by far-end VL name (+ parallel index), overload-halo friendly-name↔IIDM-id bridge, and disconnected-overload loading coherence with the diagrams | +| `docs/features/sld-diagram-feeder-labels.md` | SLD feeders relabelled by far-end VL name (+ parallel index), overload-halo friendly-name↔IIDM-id bridge, and the charging-current annotation explaining the "after" loading of a line opened at one end | | `docs/features/vl-disk-interactions.md` | Interactive VL disks on the NAD (hover name / click → Inspect / double-click → SLD) + delegation performance contract | | `docs/features/game-mode-codabench.md` | Timed, scored Game Mode (`?game=1`) + Codabench benchmark bundle | | `deploy/huggingface/` | HuggingFace Docker Space deployment (Space README + `SETUP.md`) | diff --git a/docs/features/sld-diagram-feeder-labels.md b/docs/features/sld-diagram-feeder-labels.md index bfb1ab4..f7c74a2 100644 --- a/docs/features/sld-diagram-feeder-labels.md +++ b/docs/features/sld-diagram-feeder-labels.md @@ -44,34 +44,47 @@ bridge friendly name → IIDM id via the `feeder_labels` map (each entry's The overload-highlight pass in `SldOverlay.tsx` now resolves through that bridge before `findCellForEquipment`. -## 3. Disconnected-overload loading matches the diagrams +## 3. Explaining the "after" loading of a line opened at one end -When an action **disconnects the overloaded line itself**, the line -carries no flow — the SLD / NAD correctly draw it dashed with zero flow. -But the action card reported e.g. `33 %` for it, because grid2op's -forecast `obs.rho` can stay non-zero on a line opened by a switch action -(a backend obs-vs-variant desync). +When an action **opens the overloaded line at one end** (a breaker +manoeuvre), the action card reported e.g. `33 %` for it while the SLD / NAD +showed it dashed with **zero flow** — which read as a contradiction. -The fix reads connectivity from the **post-action pypowsybl variant** — -the exact state the diagrams read — and zeroes those loadings so the card -agrees with the diagrams: +It is **not** a bug: it is real physics. A line open at one end is out of +service for *active*-power transfer (`p ≈ 0`, which is what the diagrams +draw) but its line capacitance stays energised from the live end, so +pypowsybl reports a genuine **reactive charging current** there. On the +reported case that current is `i1 ≈ 43 A` with `q1 ≈ -16.8 MVAr` and +`p1 = 0`, and the current-based loading `rho = max(|i1|,|i2|)/limit = +43/130 ≈ 33 %`. (Confirmed by reproducing the scenario against +`expert_op4grid_recommender`'s `PypowsyblObservation` on the real grid; the +library is correct and was left unchanged.) -- `simulation_helpers.build_branch_connectivity(network)` → - `{branch_id_or_name: is_disconnected}` (a branch is disconnected when - either terminal is open), keyed by both IIDM id and friendly name. -- `simulation_helpers.disconnected_branch_names_from_obs(obs)` switches to - `obs._variant_id` on `obs._network_manager` (restoring the working - variant in a `finally`), reads connectivity, and returns the - disconnected set. -- `compute_action_metrics(..., disconnected_line_names=…)` forces those - branches' post-action rho to 0 before computing `rho_after`, `max_rho` - and `lines_overloaded_after`, so the card, the SLD and the NAD all agree. +So the value is kept and **explained** rather than suppressed: when a +still-"overloaded" line ends up open at one end with a loading above ~1 %, +the card annotates it with the live-end reactive power. + +- `simulation_helpers.build_half_open_reactive(network)` → + `{branch_id_or_name: live_end_reactive_mvar}` for lines / transformers + open at EXACTLY one terminal (`abs(q)` at the connected end), keyed by + both IIDM id and friendly name. +- `simulation_helpers.half_open_branch_reactive_from_obs(obs)` reads it from + `obs._variant_id` on `obs._network_manager` (restoring the working variant + in a `finally`). +- `simulation_helpers.half_open_overload_notes(obs, names, rho_after)` keeps + only the overloaded lines that are half-open with `rho_after > 0.01`, and + `simulate_manual_action` attaches the result as `half_open_overloads` on + the action result. +- The frontend `ActionCard.renderRho` appends an italic note — *"open one + end · 16.8 MVAr capacitive"* — next to the loading, with a tooltip + explaining it is charging current, not real flow. The field rides the + save / reload triad (`sessionUtils` + `useSession`) so it survives a + session reload. ## Tests -- Backend: `test_feeder_labels.py`, `test_disconnected_overload_loading.py`, - and the `compute_action_metrics` / `build_branch_connectivity` cases in - `test_simulation_helpers.py`. -- Frontend: `utils/svg/feederLabels.test.ts` and the - "feeder relabelling" / "overload halo via friendly name" suites in - `components/SldOverlay.test.tsx`. +- Backend: `test_feeder_labels.py`, `test_half_open_overload.py`, and the + `build_half_open_reactive` cases in `test_simulation_helpers.py`. +- Frontend: `utils/svg/feederLabels.test.ts`, the "feeder relabelling" / + "overload halo via friendly name" suites in `components/SldOverlay.test.tsx`, + and the "half-open overload annotation" suite in `components/ActionCard.test.tsx`. diff --git a/expert_backend/services/simulation_helpers.py b/expert_backend/services/simulation_helpers.py index 2834092..a8388b2 100644 --- a/expert_backend/services/simulation_helpers.py +++ b/expert_backend/services/simulation_helpers.py @@ -17,6 +17,7 @@ from __future__ import annotations import logging +import math import re from typing import Any @@ -307,28 +308,62 @@ def _to_1d(arr: Any) -> np.ndarray: return np.atleast_1d(arr) -def _zero_disconnected_rho( - action_rho: np.ndarray, - action_names: np.ndarray, - disconnected_line_names: set[str] | None, -) -> np.ndarray: - """Return ``action_rho`` with disconnected branches' entries forced to 0. +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. - Returns the input untouched (no copy) when there is nothing to zero, so the - common no-disconnection path stays allocation-free. + 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. """ - if not disconnected_line_names: - return action_rho - try: - disc_mask = np.isin(action_names, list(disconnected_line_names)) - if not disc_mask.any(): - return action_rho - zeroed = np.array(action_rho, dtype=float, copy=True) - zeroed[disc_mask] = 0.0 - return zeroed - except Exception as e: - logger.debug("_zero_disconnected_rho: skipped (%s)", e) - return action_rho + 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( @@ -438,81 +473,66 @@ def resolve_lines_overloaded( return ids, names -def build_branch_connectivity(network: Any) -> dict[str, bool]: - """Return ``{branch_id_or_name: is_disconnected}`` for lines + 2-winding - transformers of ``network`` (in its currently-active variant). +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 %. - A branch is *disconnected* when either terminal is open - (``connected1 AND connected2`` is False) — the same rule the action-patch - pipeline uses to mark dashed branches. Keys cover BOTH the IIDM id and the - friendly ``name`` so a caller holding a grid2op / operator name (e.g. - ``MARSIL61PRAGN``) can look the branch up directly. - - Returns ``{}`` on any pypowsybl failure — the loading-coherence fix this - drives is additive and must never break a simulation. + 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`. """ - out: dict[str, bool] = {} - for getter in ("get_lines", "get_2_windings_transformers"): - try: - df = getattr(network, getter)(attributes=["name", "connected1", "connected2"]) - except Exception as e: - logger.debug("build_branch_connectivity: %s(attrs) failed: %s", getter, e) - try: - df = getattr(network, getter)() - except Exception as e2: - logger.debug("build_branch_connectivity: %s fallback failed: %s", getter, e2) - continue + 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: - 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(): - disconnected = not (bool(row["connected1"]) and bool(row["connected2"])) - out[str(eid)] = disconnected - if has_name: - nm = row.get("name") - if nm is not None and str(nm) != "nan": - out[str(nm)] = disconnected - except Exception as e: - logger.debug("build_branch_connectivity: scan failed for %s: %s", getter, e) - continue - return out - - -def disconnected_branch_names_from_obs(obs: Any) -> set: - """Return the set of branch ids / names disconnected in ``obs``'s - post-action pypowsybl variant. - - Mirrors exactly what ``get_action_variant_diagram`` / - ``get_action_variant_sld`` read (``obs._network_manager`` on - ``obs._variant_id``), so the action card's loadings can be re-aligned with - the diagrams for any line the action physically disconnects. Best-effort — - returns an empty set on any failure, and always restores the network - manager's working variant so the shared network is never left mutated. + 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 network is None or variant_id is None: - return set() + return {} try: original = network.get_working_variant_id() except Exception as e: - logger.debug("disconnected_branch_names_from_obs: cannot read working variant: %s", e) - return set() + logger.debug("half_open_branch_reactive_from_obs: cannot read working variant: %s", e) + return {} try: nm.set_working_variant(variant_id) - conn = build_branch_connectivity(network) + return build_half_open_reactive(network) except Exception as e: - logger.debug("disconnected_branch_names_from_obs: connectivity read failed: %s", e) - return set() + 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("disconnected_branch_names_from_obs: variant restore failed: %s", e) - return {key for key, disconnected in conn.items() if disconnected} + logger.debug("half_open_branch_reactive_from_obs: variant restore failed: %s", e) def compute_action_metrics( @@ -525,7 +545,6 @@ def compute_action_metrics( branches_with_limits: Any, monitoring_factor: float, worsening_threshold: float, - disconnected_line_names: set[str] | None = None, ) -> dict[str, Any]: """Post-process a single-action simulation result into a scalar summary. @@ -533,16 +552,6 @@ def compute_action_metrics( ``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. - - ``disconnected_line_names`` — when given, the post-action ``obs.rho`` of - any branch in this set is forced to 0 before every downstream statistic - (``rho_after`` / ``max_rho`` / ``lines_overloaded_after``). A branch the - action physically disconnects carries no flow, but grid2op's forecast - ``obs.rho`` can stay non-zero (a backend obs-vs-variant desync) — which is - why the card used to report e.g. 33 % on a line the SLD / NAD correctly - draw with zero flow. Reading connectivity from the post-action variant - (see :func:`build_branch_connectivity`) and zeroing here re-aligns the - card's loadings with the diagrams. """ mf = float(monitoring_factor) rho_before = ( @@ -583,14 +592,7 @@ def compute_action_metrics( result["is_islanded"] = True result["disconnected_mw"] = disconnected_mw - action_names = _to_1d(obs_simu_action.name_line) - # Single source of truth for the post-action loading vector. Force every - # branch the action disconnects to 0 so the card matches the diagrams. - action_rho = _zero_disconnected_rho( - _to_1d(obs_simu_action.rho), action_names, disconnected_line_names - ) - - rho_after = (action_rho[lines_overloaded_ids] * mf).tolist() if lines_overloaded_ids else [] + rho_after = (_to_1d(obs_simu_action.rho)[lines_overloaded_ids] * mf).tolist() result["rho_after"] = rho_after if rho_before: try: @@ -600,6 +602,8 @@ def compute_action_metrics( 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, @@ -703,6 +707,7 @@ def serialize_action_result(action_id: str, action_data: dict) -> dict: "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"), diff --git a/expert_backend/services/simulation_mixin.py b/expert_backend/services/simulation_mixin.py index b4fbcaa..ac25b7f 100644 --- a/expert_backend/services/simulation_mixin.py +++ b/expert_backend/services/simulation_mixin.py @@ -33,12 +33,12 @@ classify_action_content, clamp_tap, compute_action_metrics, - disconnected_branch_names_from_obs, 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, @@ -296,13 +296,6 @@ def simulate_manual_action( action_ids, self._dict_action, recent_actions ) - # A line the action physically disconnects carries no flow, but - # grid2op's forecast ``obs.rho`` can stay non-zero (backend obs-vs- - # variant desync). Read connectivity from the post-action variant — - # the same state the SLD / NAD draw — and zero those loadings so the - # card agrees with the diagrams (0 %, not e.g. 33 %). - disconnected_line_names = disconnected_branch_names_from_obs(obs_simu_action) - metrics = compute_action_metrics( obs, obs_simu_defaut, @@ -313,10 +306,19 @@ def simulate_manual_action( branches_with_limits, monitoring_factor, worsening_threshold, - disconnected_line_names=disconnected_line_names, ) 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 @@ -339,6 +341,7 @@ def simulate_manual_action( "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, } diff --git a/expert_backend/tests/test_disconnected_overload_loading.py b/expert_backend/tests/test_disconnected_overload_loading.py deleted file mode 100644 index 2619bf2..0000000 --- a/expert_backend/tests/test_disconnected_overload_loading.py +++ /dev/null @@ -1,86 +0,0 @@ -# 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. - -"""Issue 3 — action card loading coherence with the SLD / NAD diagrams. - -When an action disconnects an overloaded line, grid2op's forecast ``obs.rho`` -can stay non-zero (a backend obs-vs-variant desync) so the card showed e.g. -33 % on a line the diagrams correctly draw with zero flow. The fix reads the -post-action variant connectivity (the same state the diagrams read) and zeros -those loadings. - -These tests cover the variant-reading seam ``disconnected_branch_names_from_obs``; -the pure ``compute_action_metrics`` / ``build_branch_connectivity`` zeroing is -covered in ``test_simulation_helpers.py``. -""" - -from unittest.mock import MagicMock - -import pandas as pd - -from expert_backend.services.simulation_helpers import disconnected_branch_names_from_obs - - -def _lines_df(rows: dict) -> pd.DataFrame: - return pd.DataFrame.from_dict(rows, orient="index") - - -def _empty_trafos() -> pd.DataFrame: - return pd.DataFrame(columns=["name", "connected1", "connected2"]) - - -def _obs_with_variant(lines_df): - obs = MagicMock() - obs._variant_id = "action_var" - network = MagicMock() - network.get_working_variant_id.return_value = "orig" - network.get_lines.return_value = lines_df - network.get_2_windings_transformers.return_value = _empty_trafos() - nm = MagicMock() - nm.network = network - obs._network_manager = nm - return obs, nm - - -class TestDisconnectedBranchNamesFromObs: - def test_reads_disconnected_branches_by_id_and_name(self): - obs, nm = _obs_with_variant( - _lines_df( - { - "relation_8423569-225": { - "name": "MARSIL61PRAGN", "connected1": True, "connected2": False, - }, - "L2": {"name": "L2NAME", "connected1": True, "connected2": True}, - } - ) - ) - - names = disconnected_branch_names_from_obs(obs) - - # Disconnected line reachable by both IIDM id and grid2op/operator name. - assert "MARSIL61PRAGN" in names - assert "relation_8423569-225" in names - # Connected line absent. - assert "L2" not in names - assert "L2NAME" not in names - - def test_switches_to_action_variant_then_restores(self): - obs, nm = _obs_with_variant( - _lines_df({"L1": {"name": "L1", "connected1": True, "connected2": True}}) - ) - - disconnected_branch_names_from_obs(obs) - - nm.set_working_variant.assert_any_call("action_var") - # The shared network is always restored to its prior working variant. - assert nm.set_working_variant.call_args_list[-1].args == ("orig",) - - def test_missing_network_manager_returns_empty(self): - obs = MagicMock() - obs._network_manager = None - obs._variant_id = "action_var" - assert disconnected_branch_names_from_obs(obs) == set() diff --git a/expert_backend/tests/test_half_open_overload.py b/expert_backend/tests/test_half_open_overload.py new file mode 100644 index 0000000..2cd16c3 --- /dev/null +++ b/expert_backend/tests/test_half_open_overload.py @@ -0,0 +1,134 @@ +# 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. + +"""Issue 3 — annotate the charging-current loading of a half-open overloaded line. + +When an action leaves a still-"overloaded" line open at ONE end, the line is out +of service for active power (the SLD / NAD show p = 0) but its capacitance keeps +drawing reactive charging current from the live end, so its current-based loading +stays non-zero (the reported ~33 %). This is physically correct, not a bug — so +we keep the value and surface the live-end reactive power, letting the ActionCard +explain it instead of suppressing it. + +Covers the variant-reading seam ``half_open_branch_reactive_from_obs`` and the +gating in ``half_open_overload_notes``. The pure ``build_half_open_reactive`` +df→dict is covered in ``test_simulation_helpers.py``. +""" + +from unittest.mock import MagicMock + +import pandas as pd + +from expert_backend.services.simulation_helpers import ( + half_open_branch_reactive_from_obs, + half_open_overload_notes, +) + + +def _lines_df(rows: dict) -> pd.DataFrame: + return pd.DataFrame.from_dict(rows, orient="index") + + +def _empty_trafos() -> pd.DataFrame: + return pd.DataFrame(columns=["name", "connected1", "connected2", "q1", "q2"]) + + +def _obs_with_variant(lines_df): + obs = MagicMock() + obs._variant_id = "action_var" + network = MagicMock() + network.get_working_variant_id.return_value = "orig" + network.get_lines.return_value = lines_df + network.get_2_windings_transformers.return_value = _empty_trafos() + nm = MagicMock() + nm.network = network + obs._network_manager = nm + return obs, nm + + +class TestHalfOpenBranchReactiveFromObs: + def test_reads_live_end_reactive_by_id_and_name(self): + obs, nm = _obs_with_variant( + _lines_df( + { + "relation_8423569-225": { + "name": "MARSIL61PRAGN", + "connected1": True, "connected2": False, + "q1": -16.8, "q2": 0.0, + }, + "L2": {"name": "L2NAME", "connected1": True, "connected2": True, + "q1": 1.0, "q2": -1.0}, + } + ) + ) + out = half_open_branch_reactive_from_obs(obs) + assert out["relation_8423569-225"] == 16.8 + assert out["MARSIL61PRAGN"] == 16.8 + assert "L2" not in out # fully connected → not half open + + def test_switches_to_action_variant_then_restores(self): + obs, nm = _obs_with_variant( + _lines_df({"L1": {"name": "L1", "connected1": True, "connected2": True, + "q1": 0.0, "q2": 0.0}}) + ) + half_open_branch_reactive_from_obs(obs) + nm.set_working_variant.assert_any_call("action_var") + # Shared network always restored to its prior working variant. + assert nm.set_working_variant.call_args_list[-1].args == ("orig",) + + def test_missing_network_manager_returns_empty(self): + obs = MagicMock() + obs._network_manager = None + obs._variant_id = "action_var" + assert half_open_branch_reactive_from_obs(obs) == {} + + +class TestHalfOpenOverloadNotes: + def test_annotates_half_open_overloaded_line_above_1pct(self): + obs, _ = _obs_with_variant( + _lines_df( + { + "MARSIL61PRAGN": { + "name": "MARSIL61PRAGN", + "connected1": True, "connected2": False, + "q1": -16.8, "q2": 0.0, + }, + } + ) + ) + # MARSIL61PRAGN overloaded, now at 33 % (charging current) → annotate. + notes = half_open_overload_notes( + obs, ["MARSIL61PRAGN", "L2"], rho_after=[0.333, 0.4] + ) + assert notes == {"MARSIL61PRAGN": 16.8} + + def test_loading_at_or_below_1pct_is_not_annotated(self): + obs, _ = _obs_with_variant( + _lines_df( + { + "L": {"name": "L", "connected1": True, "connected2": False, + "q1": -0.2, "q2": 0.0}, + } + ) + ) + # Half open but loading is only 0.5 % → below the 1 % info threshold. + notes = half_open_overload_notes(obs, ["L"], rho_after=[0.005]) + assert notes == {} + + def test_fully_connected_overloaded_line_is_not_annotated(self): + obs, _ = _obs_with_variant( + _lines_df( + {"L": {"name": "L", "connected1": True, "connected2": True, + "q1": 5.0, "q2": -5.0}} + ) + ) + notes = half_open_overload_notes(obs, ["L"], rho_after=[1.2]) + assert notes == {} + + def test_no_overloads_returns_empty(self): + obs, _ = _obs_with_variant(_empty_trafos()) + assert half_open_overload_notes(obs, [], rho_after=[]) == {} diff --git a/expert_backend/tests/test_simulation_helpers.py b/expert_backend/tests/test_simulation_helpers.py index b0ebb17..1d104ab 100644 --- a/expert_backend/tests/test_simulation_helpers.py +++ b/expert_backend/tests/test_simulation_helpers.py @@ -18,7 +18,7 @@ from expert_backend.services.simulation_helpers import ( TOPO_KEYS, - build_branch_connectivity, + build_half_open_reactive, build_care_mask, build_combined_description, canonicalize_action_id, @@ -540,114 +540,71 @@ def test_max_rho_picks_highest_monitored_line(self): assert metrics["max_rho_line"] == "L2" assert metrics["max_rho"] == pytest.approx(1.3 * 0.95) - # -- disconnected-line loading coherence (Issue 3) -- - - def test_disconnected_overloaded_line_reports_zero_loading(self): - """A line the action disconnects shows 0 % loading even when grid2op's - forecast rho stays non-zero — the card-vs-diagram coherence fix. - - Reproduces the reported case: MARSIL61PRAGN overloaded at 106 % in N-1, - disconnected by the action, yet grid2op's obs.rho stays at 0.35 (→ 33 %). - """ - names = ["MARSIL61PRAGN", "L2", "L3"] - metrics = compute_action_metrics( - obs=self._obs([0.1, 0.1, 0.1], names=names), - obs_simu_defaut=self._obs([1.06, 0.5, 0.5], names=names), - obs_simu_action=self._obs([0.35, 0.5, 0.5], names=names), # stale grid2op rho - info_action={"exception": None}, - lines_overloaded_ids=[0], - lines_we_care_about=set(names), - branches_with_limits=set(names), - monitoring_factor=0.95, - worsening_threshold=0.02, - disconnected_line_names={"MARSIL61PRAGN"}, - ) - assert metrics["rho_after"] == pytest.approx([0.0]) - assert "MARSIL61PRAGN" not in metrics["lines_overloaded_after"] - - def test_loading_unchanged_without_disconnect_set(self): - """Without the disconnect set the (stale) grid2op loading is reported - as-is — the fix is opt-in and changes nothing for non-disconnections.""" - names = ["MARSIL61PRAGN", "L2", "L3"] - metrics = compute_action_metrics( - obs=self._obs([0.1, 0.1, 0.1], names=names), - obs_simu_defaut=self._obs([1.06, 0.5, 0.5], names=names), - obs_simu_action=self._obs([0.35, 0.5, 0.5], names=names), - info_action={"exception": None}, - lines_overloaded_ids=[0], - lines_we_care_about=set(names), - branches_with_limits=set(names), - monitoring_factor=0.95, - worsening_threshold=0.02, - ) - assert metrics["rho_after"] == pytest.approx([0.35 * 0.95]) - - def test_disconnected_line_excluded_from_max_rho(self): - """When the action disconnects the worst line, max_rho drops to the - next-highest connected line instead of reporting the stale value.""" - names = ["A", "B", "C"] - metrics = compute_action_metrics( - obs=self._obs([0.1, 0.1, 0.1], names=names), - obs_simu_defaut=self._obs([0.1, 0.1, 0.1], names=names), - obs_simu_action=self._obs([1.4, 0.9, 0.5], names=names), # A highest but disconnected - info_action={"exception": None}, - lines_overloaded_ids=[], - lines_we_care_about=set(names), - branches_with_limits=set(names), - monitoring_factor=0.95, - worsening_threshold=0.02, - disconnected_line_names={"A"}, - ) - assert metrics["max_rho_line"] == "B" - assert metrics["max_rho"] == pytest.approx(0.9 * 0.95) - # ---------------------------------------------------------------------- -# build_branch_connectivity +# build_half_open_reactive (Issue 3 — charging-current annotation) # ---------------------------------------------------------------------- -class TestBuildBranchConnectivity: +class TestBuildHalfOpenReactive: def _net(self, lines, trafos=None): net = MagicMock() net.get_lines.return_value = pd.DataFrame.from_dict(lines, orient="index") net.get_2_windings_transformers.return_value = ( pd.DataFrame.from_dict(trafos, orient="index") if trafos - else pd.DataFrame(columns=["name", "connected1", "connected2"]) + else pd.DataFrame(columns=["name", "connected1", "connected2", "q1", "q2"]) ) return net - def test_keys_by_id_and_friendly_name(self): + def test_half_open_line_returns_live_end_reactive(self): + # Reproduces the reported case: MARSIL61PRAGN open at s2 (connected2=False), + # live end s1 draws q1 = -16.8 MVAr of capacitive charging current. net = self._net( { "relation_8423569-225": { - "name": "MARSIL61PRAGN", "connected1": True, "connected2": False, + "name": "MARSIL61PRAGN", + "connected1": True, "connected2": False, + "q1": -16.8, "q2": 0.0, }, - "L2": {"name": "L2NAME", "connected1": True, "connected2": True}, } ) - conn = build_branch_connectivity(net) - # Disconnected (one terminal open) — reachable by IIDM id AND friendly name. - assert conn["relation_8423569-225"] is True - assert conn["MARSIL61PRAGN"] is True - # Fully connected. - assert conn["L2"] is False - assert conn["L2NAME"] is False + out = build_half_open_reactive(net) + # abs(live-end q), reachable by both IIDM id and friendly name. + assert out["relation_8423569-225"] == pytest.approx(16.8) + assert out["MARSIL61PRAGN"] == pytest.approx(16.8) + + def test_picks_the_connected_terminal_reactive(self): + net = self._net( + {"L": {"name": "L", "connected1": False, "connected2": True, "q1": 0.0, "q2": -9.5}} + ) + out = build_half_open_reactive(net) + assert out["L"] == pytest.approx(9.5) # live end is s2 → |q2| + + def test_fully_connected_and_fully_open_are_excluded(self): + net = self._net( + { + "connected": {"name": "C", "connected1": True, "connected2": True, "q1": 5.0, "q2": -5.0}, + "fully_open": {"name": "O", "connected1": False, "connected2": False, "q1": 0.0, "q2": 0.0}, + } + ) + out = build_half_open_reactive(net) + assert "connected" not in out and "C" not in out + assert "fully_open" not in out and "O" not in out def test_transformers_included(self): net = self._net( - {"L1": {"name": "L1", "connected1": True, "connected2": True}}, - trafos={"T1": {"name": "TR1", "connected1": False, "connected2": True}}, + {"L1": {"name": "L1", "connected1": True, "connected2": True, "q1": 1.0, "q2": 1.0}}, + trafos={"T1": {"name": "TR1", "connected1": True, "connected2": False, "q1": 4.2, "q2": 0.0}}, ) - conn = build_branch_connectivity(net) - assert conn["T1"] is True - assert conn["TR1"] is True + out = build_half_open_reactive(net) + assert out["T1"] == pytest.approx(4.2) + assert out["TR1"] == pytest.approx(4.2) def test_pypowsybl_failure_degrades_to_empty(self): net = MagicMock() net.get_lines.side_effect = RuntimeError("boom") net.get_2_windings_transformers.side_effect = RuntimeError("boom") - assert build_branch_connectivity(net) == {} + assert build_half_open_reactive(net) == {} # ---------------------------------------------------------------------- diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 78b2451..117ef8b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -648,6 +648,7 @@ function App() { disconnected_mw: metrics.disconnected_mw, non_convergence: metrics.non_convergence, lines_overloaded_after: metrics.lines_overloaded_after, + half_open_overloads: metrics.half_open_overloads, action_topology: metrics.action_topology, load_shedding_details: metrics.load_shedding_details, curtailment_details: metrics.curtailment_details, @@ -855,6 +856,7 @@ function App() { disconnected_mw: m.disconnected_mw, non_convergence: m.non_convergence, lines_overloaded_after: m.lines_overloaded_after, + half_open_overloads: m.half_open_overloads, // Carry the topology so the SLD/NAD highlight marks EVERY affected // feeder of a combined manual action (e.g. a generator redispatch // AND a load shedding at the same VL get highlighted, not just one). diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 1a25048..a212355 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -183,6 +183,7 @@ export const api = { non_convergence: string | null; lines_overloaded: string[]; lines_overloaded_after?: string[]; + half_open_overloads?: Record; action_topology?: import('./types').ActionTopology; load_shedding_details?: import('./types').LoadSheddingDetail[]; curtailment_details?: import('./types').CurtailmentDetail[]; diff --git a/frontend/src/components/ActionCard.test.tsx b/frontend/src/components/ActionCard.test.tsx index 94f6fb9..6aab2d2 100644 --- a/frontend/src/components/ActionCard.test.tsx +++ b/frontend/src/components/ActionCard.test.tsx @@ -556,4 +556,44 @@ describe('ActionCard', () => { expect(screen.queryByTestId('action-card-act_1-origin')).not.toBeInTheDocument(); }); }); + + describe('half-open overload annotation (charging current)', () => { + it('annotates the loading with reactive power when a line is open one end', () => { + const details: ActionDetail = { + ...baseDetails, + rho_after: [0.333], + half_open_overloads: { LINE_A: 16.8 }, + }; + render( + , + ); + // The 33.3 % is kept (it is physically real) AND explained. + expect(screen.getByText(/33\.3%/)).toBeInTheDocument(); + expect(screen.getByText(/open one end/i)).toBeInTheDocument(); + expect(screen.getByText(/16\.8 MVAr/)).toBeInTheDocument(); + }); + + it('does not annotate a normally-loaded line', () => { + const details: ActionDetail = { + ...baseDetails, + rho_after: [0.85], + // no half_open_overloads + }; + render( + , + ); + expect(screen.queryByText(/open one end/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/MVAr/i)).not.toBeInTheDocument(); + }); + }); }); diff --git a/frontend/src/components/ActionCard.tsx b/frontend/src/components/ActionCard.tsx index 01b7e3a..a96afd1 100644 --- a/frontend/src/components/ActionCard.tsx +++ b/frontend/src/components/ActionCard.tsx @@ -108,8 +108,14 @@ const ActionCard: React.FC = ({ const renderRho = (arr: number[] | null, actionId: string, tab: 'action' | 'contingency' = 'action'): React.ReactNode => { if (!arr || arr.length === 0) return '—'; + const halfOpen = details.half_open_overloads; return arr.map((v, i) => { const lineName = linesOverloaded[i] || `line ${i}`; + // A line the action left open at one end shows a non-zero loading + // that is pure capacitive charging current (the diagrams show p = 0). + // Annotate it with the live-end reactive power so the operator reads + // it as charging current, not a residual overload. + const reactiveMvar = halfOpen ? halfOpen[lineName] : undefined; return ( {i > 0 && ', '} @@ -119,6 +125,14 @@ const ActionCard: React.FC = ({ onClick={(e) => { e.stopPropagation(); onAssetClick(actionId, lineName, tab); }} >{displayName(lineName)} {`: ${(v * 100).toFixed(1)}%`} + {reactiveMvar != null && ( + + {` — open one end · ${reactiveMvar.toFixed(1)} MVAr capacitive`} + + )} ); }); diff --git a/frontend/src/hooks/useSession.ts b/frontend/src/hooks/useSession.ts index 97d434c..26e5dd5 100644 --- a/frontend/src/hooks/useSession.ts +++ b/frontend/src/hooks/useSession.ts @@ -403,6 +403,7 @@ export function useSession(): SessionState { // rendered empty and the SLD/NAD tab lost its post-action // overload halos until the user re-ran analysis. lines_overloaded_after: entry.lines_overloaded_after, + half_open_overloads: entry.half_open_overloads, load_shedding_details: entry.load_shedding_details, curtailment_details: entry.curtailment_details, redispatch_details: entry.redispatch_details, diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 319e79a..f50f881 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -108,6 +108,16 @@ export interface ActionDetail { non_convergence?: string | null; action_topology?: ActionTopology; lines_overloaded_after?: string[]; + /** + * ``{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 is + * out of service for active power (the SLD / NAD 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 %). The ActionCard + * annotates the loading with the reactive amount so it reads as charging + * current, not a residual overload. Backend: ``half_open_branch_reactive_from_obs``. + */ + half_open_overloads?: Record; load_shedding_details?: LoadSheddingDetail[]; curtailment_details?: CurtailmentDetail[]; redispatch_details?: RedispatchDetail[]; @@ -487,6 +497,16 @@ export interface SavedActionEntry { non_convergence?: string | null; action_topology?: ActionTopology; lines_overloaded_after?: string[]; + /** + * ``{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 is + * out of service for active power (the SLD / NAD 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 %). The ActionCard + * annotates the loading with the reactive amount so it reads as charging + * current, not a residual overload. Backend: ``half_open_branch_reactive_from_obs``. + */ + half_open_overloads?: Record; load_shedding_details?: LoadSheddingDetail[]; curtailment_details?: CurtailmentDetail[]; redispatch_details?: RedispatchDetail[]; diff --git a/frontend/src/utils/sessionUtils.ts b/frontend/src/utils/sessionUtils.ts index 59cab08..a79726c 100644 --- a/frontend/src/utils/sessionUtils.ts +++ b/frontend/src/utils/sessionUtils.ts @@ -158,6 +158,7 @@ export function buildSessionResult(input: SessionInput): SessionResult { n_components: detail.n_components, disconnected_mw: detail.disconnected_mw, lines_overloaded_after: detail.lines_overloaded_after, + half_open_overloads: detail.half_open_overloads, load_shedding_details: detail.load_shedding_details, curtailment_details: detail.curtailment_details, pst_details: detail.pst_details,