Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`) |
Expand Down Expand Up @@ -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 |
Expand Down
90 changes: 90 additions & 0 deletions docs/features/sld-diagram-feeder-labels.md
Original file line number Diff line number Diff line change
@@ -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 `<text>` 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`.
7 changes: 6 additions & 1 deletion expert_backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
150 changes: 150 additions & 0 deletions expert_backend/services/diagram/sld_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 /
Expand Down Expand Up @@ -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.

Expand Down
Loading
Loading