diff --git a/CHANGELOG.md b/CHANGELOG.md index dc378b1..69d1a7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,39 @@ 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. +- **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 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 The interactive Single Line Diagram editor is now reachable straight diff --git a/CLAUDE.md b/CLAUDE.md index f200661..3565587 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 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`) | @@ -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..f7c74a2 --- /dev/null +++ b/docs/features/sld-diagram-feeder-labels.md @@ -0,0 +1,90 @@ +# 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. Explaining the "after" loading of a line opened at one end + +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. + +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.) + +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_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/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..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,6 +308,64 @@ def _to_1d(arr: Any) -> np.ndarray: return np.atleast_1d(arr) +def build_half_open_reactive(network: Any) -> dict[str, float]: + """Return ``{branch_id_or_name: live_end_reactive_mvar}`` for lines + 2-winding + transformers that are open at EXACTLY ONE terminal in the current variant. + + A branch open at one end is out of service for active-power transfer, but its + line capacitance stays energised from the live end, so pypowsybl reports a + real REACTIVE charging current there (e.g. a 225 kV line opened at one end + shows ~16 MVAr at the connected terminal while p ~ 0). The current-based + loading ``rho`` then reads a small non-zero value — physically correct, but + easy to misread as a residual overload when the operator opened the line to + relieve one. Returning the live-end reactive power lets the UI explain that + an "after" loading on such a branch is capacitive charging current, not flow. + + The value is ``abs(q)`` at the still-connected terminal. Keys cover BOTH the + IIDM id and the friendly ``name`` so a caller holding a grid2op / operator + name (``MARSIL61PRAGN``) can look the branch up. Returns ``{}`` on any + pypowsybl failure — the annotation is additive and must not break a run. + """ + out: dict[str, float] = {} + for getter in ("get_lines", "get_2_windings_transformers"): + try: + df = getattr(network, getter)( + attributes=["name", "connected1", "connected2", "q1", "q2"] + ) + except Exception as e: + logger.debug("build_half_open_reactive: %s(attrs) failed: %s", getter, e) + try: + df = getattr(network, getter)() + except Exception as e2: + logger.debug("build_half_open_reactive: %s fallback failed: %s", getter, e2) + continue + try: + cols = list(getattr(df, "columns", [])) + if "connected1" not in cols or "connected2" not in cols: + continue + has_name = "name" in cols + for eid, row in df.iterrows(): + c1, c2 = bool(row["connected1"]), bool(row["connected2"]) + if c1 == c2: + continue # both connected, or both open — not "half open" + q_live = row.get("q1") if c1 else row.get("q2") + try: + reactive = abs(float(q_live)) + except (TypeError, ValueError): + reactive = 0.0 + if not math.isfinite(reactive): + reactive = 0.0 + out[str(eid)] = reactive + if has_name: + nm = row.get("name") + if nm is not None and str(nm) != "nan": + out[str(nm)] = reactive + except Exception as e: + logger.debug("build_half_open_reactive: scan failed for %s: %s", getter, e) + continue + return out + + def build_care_mask( action_names: np.ndarray, action_rho: np.ndarray, @@ -414,6 +473,68 @@ def resolve_lines_overloaded( return ids, names +def half_open_overload_notes( + obs: Any, lines_overloaded_names: list[str], rho_after: list[float] +) -> dict[str, float]: + """Return ``{line_name: live_end_reactive_mvar}`` for still-"overloaded" lines + the action leaves open at ONE end with a loading above ~1 %. + + Such a line carries no real flow (the diagrams show p = 0) but its capacitance + draws reactive charging current from the live end, so its current-based + loading stays non-zero (the reported ~33 %). Surfacing the live-end reactive + power lets the ActionCard annotate the value as capacitive charging current + rather than it reading as a residual overload. Reads the post-action variant + via :func:`half_open_branch_reactive_from_obs`. + """ + if not lines_overloaded_names: + return {} + half_open = half_open_branch_reactive_from_obs(obs) + if not half_open: + return {} + notes: dict[str, float] = {} + for i, name in enumerate(lines_overloaded_names): + try: + rho = float(rho_after[i]) if i < len(rho_after) else 0.0 + except (TypeError, ValueError): + rho = 0.0 + if name in half_open and rho > 0.01: + notes[name] = half_open[name] + return notes + + +def half_open_branch_reactive_from_obs(obs: Any) -> dict[str, float]: + """Return ``{branch_id_or_name: live_end_reactive_mvar}`` for branches open at + exactly one terminal in ``obs``'s post-action pypowsybl variant (see + :func:`build_half_open_reactive`). + + Reads the SAME variant the SLD / NAD diagrams render + (``obs._network_manager`` on ``obs._variant_id``). Best-effort — returns + ``{}`` on any failure and always restores the network manager's working + variant so the shared network is never left mutated. + """ + nm = getattr(obs, "_network_manager", None) + variant_id = getattr(obs, "_variant_id", None) + network = getattr(nm, "network", None) if nm is not None else None + if network is None or variant_id is None: + return {} + try: + original = network.get_working_variant_id() + except Exception as e: + logger.debug("half_open_branch_reactive_from_obs: cannot read working variant: %s", e) + return {} + try: + nm.set_working_variant(variant_id) + return build_half_open_reactive(network) + except Exception as e: + logger.debug("half_open_branch_reactive_from_obs: read failed: %s", e) + return {} + finally: + try: + nm.set_working_variant(original) + except Exception as e: + logger.debug("half_open_branch_reactive_from_obs: variant restore failed: %s", e) + + def compute_action_metrics( obs: Any, obs_simu_defaut: Any, @@ -586,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 4ce6d15..ac25b7f 100644 --- a/expert_backend/services/simulation_mixin.py +++ b/expert_backend/services/simulation_mixin.py @@ -38,6 +38,7 @@ compute_redispatch_setpoint, compute_target_max_rho, extract_action_topology, + half_open_overload_notes, is_injection_action, is_pst_action, is_switch_only_content, @@ -308,6 +309,16 @@ def simulate_manual_action( ) 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 @@ -330,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_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_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 63f802a..1d104ab 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_half_open_reactive, build_care_mask, build_combined_description, canonicalize_action_id, @@ -539,6 +541,72 @@ def test_max_rho_picks_highest_monitored_line(self): assert metrics["max_rho"] == pytest.approx(1.3 * 0.95) +# ---------------------------------------------------------------------- +# build_half_open_reactive (Issue 3 — charging-current annotation) +# ---------------------------------------------------------------------- + +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", "q1", "q2"]) + ) + return net + + 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, + "q1": -16.8, "q2": 0.0, + }, + } + ) + 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, "q1": 1.0, "q2": 1.0}}, + trafos={"T1": {"name": "TR1", "connected1": True, "connected2": False, "q1": 4.2, "q2": 0.0}}, + ) + 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_half_open_reactive(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/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 81da7af..a212355 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): @@ -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[]; @@ -239,15 +240,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 +264,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 +276,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/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/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/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/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..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[]; @@ -403,6 +413,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 +445,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 @@ -460,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, 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; + } +}