diff --git a/.circleci/config.yml b/.circleci/config.yml index ab723ee9..d3bd393b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -10,6 +10,9 @@ jobs: - image: cimg/python:3.10 steps: - checkout + - run: + name: Install Graphviz (provides ``dot``) + command: sudo apt-get update && sudo apt-get install -y graphviz - run: name: Install Dependencies command: | diff --git a/.github/workflows/parity.yml b/.github/workflows/parity.yml index 4004bc98..ee38400b 100644 --- a/.github/workflows/parity.yml +++ b/.github/workflows/parity.yml @@ -39,6 +39,21 @@ jobs: - uses: actions/setup-python@v5 with: python-version: '3.11' + # The parity check needs an up-to-date standalone bundle to + # diff against. `frontend/dist-standalone/` is gitignored, so + # in CI we have to (re)build it from source — otherwise the + # script falls back to the frozen `standalone_interface_legacy.html` + # which lags behind the React source by design. + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + - name: Build standalone bundle + working-directory: frontend + run: | + npm ci + npm run build:standalone - name: Run parity check run: python scripts/check_standalone_parity.py - name: Emit Markdown summary @@ -58,6 +73,16 @@ jobs: - uses: actions/setup-python@v5 with: python-version: '3.11' + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + - name: Build standalone bundle + working-directory: frontend + run: | + npm ci + npm run build:standalone - name: Run session-fidelity check run: python scripts/check_session_fidelity.py @@ -73,6 +98,16 @@ jobs: - uses: actions/setup-python@v5 with: python-version: '3.11' + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + - name: Build standalone bundle + working-directory: frontend + run: | + npm ci + npm run build:standalone - name: Run gesture-sequence check run: python scripts/check_gesture_sequence.py @@ -90,6 +125,16 @@ jobs: - uses: actions/setup-python@v5 with: python-version: '3.11' + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + - name: Build standalone bundle + working-directory: frontend + run: | + npm ci + npm run build:standalone - name: Run invariants check run: python scripts/check_invariants.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3d1eabab..a2fa01bb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,7 +7,14 @@ on: branches: [ main ] jobs: + # ------------------------------------------------------------------ + # Fast lane: every backend test that does NOT need the graphviz + # `dot` binary. Skips the system-package install entirely (which + # used to hang for ~8 minutes on the Azure apt mirror) and runs the + # ~720 pytest items in ~15 seconds. + # ------------------------------------------------------------------ test-backend: + name: Backend tests (no graphviz) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -15,13 +22,14 @@ jobs: uses: actions/setup-python@v5 with: python-version: "3.10" + cache: 'pip' - name: Install dependencies run: | python -m pip install --upgrade pip pip install ".[test]" pip install --no-deps expert_op4grid_recommender - - name: Run Pytest - run: pytest + - name: Run Pytest (excluding graphviz-dependent tests) + run: pytest --ignore=expert_backend/tests/test_overflow_html_dim_logic.py test-frontend: runs-on: ubuntu-latest @@ -45,3 +53,39 @@ jobs: run: | cd frontend npm run lint + + # ------------------------------------------------------------------ + # Slow lane: the small subset of backend tests that exercise the + # upstream interactive-HTML viewer through pydot → graphviz `dot`. + # Gated behind the fast lanes so a regression in non-graphviz code + # is reported in ~1 minute, and only the prod/load-layer fixtures + # pay the (cached) apt install cost. + # + # `awalsh128/cache-apt-pkgs-action` caches the resolved .deb files + # across runs — first run is ~30 s, subsequent runs restore from + # cache in ~5 s, vs. the ~8 minutes the bare `apt-get update` was + # taking on the Azure mirror. + # ------------------------------------------------------------------ + test-backend-graphviz: + name: Backend tests requiring graphviz + needs: [test-backend, test-frontend] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: "3.10" + cache: 'pip' + - name: Install Graphviz (cached apt) + uses: awalsh128/cache-apt-pkgs-action@v1 + with: + packages: graphviz + version: 1.0 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install ".[test]" + pip install --no-deps expert_op4grid_recommender + - name: Run graphviz-dependent Pytest subset + run: pytest expert_backend/tests/test_overflow_html_dim_logic.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eac36153..09a5568b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,6 +15,36 @@ pip install --no-deps expert_op4grid_recommender uvicorn expert_backend.main:app --host 0.0.0.0 --port 8000 ``` +#### System prerequisite — Graphviz (`dot`) + +The overflow-graph rendering pipeline shells out to Graphviz's +`dot` binary. `pip install` attempts a best-effort auto-install via +the platform's package manager (apt / dnf / pacman / apk on Linux, +Homebrew or MacPorts on macOS, Chocolatey / winget / Scoop on +Windows); set `COSTUDY4GRID_SKIP_GRAPHVIZ_INSTALL=1` to opt out. +Modern wheel-based installs may skip the `setup.py` post-install +hook — re-run it manually with the bundled console script if +`dot -V` fails: + +```bash +costudy4grid-install-graphviz +``` + +If the auto-install can't elevate (no `sudo`, locked package DB, +unsupported package manager), install Graphviz by hand: + +| Platform | Command | +|-------------------------|---------------------------------------------------| +| Debian / Ubuntu | `sudo apt-get install graphviz` | +| RHEL / Fedora | `sudo dnf install graphviz` (or `yum`) | +| Arch | `sudo pacman -S graphviz` | +| Alpine | `sudo apk add graphviz` | +| macOS (Homebrew) | `brew install graphviz` | +| macOS (MacPorts) | `sudo port install graphviz` | +| Windows (Chocolatey) | `choco install graphviz` | +| Windows (winget) | `winget install Graphviz.Graphviz` | +| Windows (Scoop) | `scoop install graphviz` | + ### Frontend ```bash diff --git a/Overflow_Graph/Overflow_Graph_P.SAOL31RONCI_chronic_grid.xiidm_timestep_9_hierarchi_only_signif_edges_no_consoli.html b/Overflow_Graph/Overflow_Graph_P.SAOL31RONCI_chronic_grid.xiidm_timestep_9_hierarchi_only_signif_edges_no_consoli.html new file mode 100644 index 00000000..99706547 --- /dev/null +++ b/Overflow_Graph/Overflow_Graph_P.SAOL31RONCI_chronic_grid.xiidm_timestep_9_hierarchi_only_signif_edges_no_consoli.html @@ -0,0 +1,1104 @@ + + + + +g_overflow_print_geo_2026-05-05_13-05.html + + + +
+ +
+
+ + + +
+ + + + + + +%3 + + +1GEN.P7 + +1GEN.P7 + + +GEN.PP6 + +GEN.PP6 + + +1GEN.P7->GEN.PP6 + + +29 + + +GEN.PP7 + +GEN.PP7 + + +GEN.PP6->GEN.PP7 + +-0 + + +IZERNP6 + + + +GEN.PP6->IZERNP6 + + +9 + + +VOUGLP6 + +VOUGLP6 + + +GEN.PP6->VOUGLP6 + + +20 + + +GEN.PP7->1GEN.P7 + + +29 + + +1GROSP7 + +1GROSP7 + + +GROSNP6 + +GROSNP6 + + +1GROSP7->GROSNP6 + + +-4 + + +CHALOP6 + +CHALOP6 + + +GROSNP6->CHALOP6 + + +-4 + + +GROSNP6->CHALOP6 + + +-4 + + +MACONP6 + + + +GROSNP6->MACONP6 + +0 + + +GROSNP7 + +GROSNP7 + + +GROSNP7->1GROSP7 + + +-4 + + +GROSNP7->GROSNP6 + + +-3 + + +1VIELP7 + +1VIELP7 + + +VIELMP6 + +VIELMP6 + + +1VIELP7->VIELMP6 + + +-5 + + +C.REGP6 + +C.REGP6 + + +VIELMP6->C.REGP6 + + +-5 + + +VIELMP6->C.REGP6 + + +-5 + + +COMMUP6 + + + +VIELMP6->COMMUP6 + + +2 + + +COUCHP6 + +COUCHP6 + + +VIELMP6->COUCHP6 + + +-7 + + +VIELMP7 + +VIELMP7 + + +VIELMP7->GEN.PP7 + + +8 + + +VIELMP7->GEN.PP7 + + +8 + + +VIELMP7->1VIELP7 + + +-5 + + +VIELMP7->VIELMP6 + + +-7 + + +VIELMP7->VIELMP6 + + +-4 + + +H.PAUP7 + +H.PAUP7 + + +VIELMP7->H.PAUP7 + + +2 + + +2H.PAP7 + +2H.PAP7 + + +H.PAUP6 + +H.PAUP6 + + +2H.PAP7->H.PAUP6 + + +-4 + + +GUEUGP6 + + + +H.PAUP6->GUEUGP6 + +-0 + + +H.PAUP7->2H.PAP7 + + +-4 + + +SSV.OP7 + +SSV.OP7 + + +H.PAUP7->SSV.OP7 + + +6 + + +AUXONP3 + +AUXONP3 + + +RIBAUP3 + +RIBAUP3 + + +AUXONP3->RIBAUP3 + + +-4 + + +BEON P3 + +BEON P3 + + +P.SAOP3 + +P.SAOP3 + + +BEON P3->P.SAOP3 + + +-26 + + +BOISSP6 + +BOISSP6 + + +BOISSP6->GEN.PP6 + +0 + + +C.FOUP3 + +C.FOUP3 + + +MERVAP3 + +MERVAP3 + + +C.FOUP3->MERVAP3 + + +-25 + + +C.REGP3 + +C.REGP3 + + +ZCRIMP3 + +ZCRIMP3 + + +C.REGP3->ZCRIMP3 + + +-2 + + +ZMAGNP6 + +ZMAGNP6 + + +C.REGP6->ZMAGNP6 + + +-9 + + +C.SAUP3 + +C.SAUP3 + + +CHALOP3 + +CHALOP3 + + +LOUHAP3 + +LOUHAP3 + + +CHALOP3->LOUHAP3 + +-0 + + +CHALOP6->CHALOP3 + +-0 + + +CHALOP6->CHALOP3 + +-0 + + +CHALOP6->CHALOP3 + +-0 + + +CPVANP6 + +CPVANP6 + + +CHALOP6->CPVANP6 + + +-8 + + +CIZE P6 + + + +FLEYRP6 + + + +CIZE P6->FLEYRP6 + + +9 + + +COLLOP3 + +COLLOP3 + + +COLLOP3->AUXONP3 + + +-4 + + +COMMUP6->H.PAUP6 + + +2 + + +COUCHP6->CPVANP6 + + +-8 + + +CPVANP3 + +CPVANP3 + + +CPVANP3->BEON P3 + + + + + -26 +   +97% +  → 0% + + +CPVANP3->RIBAUP3 + + +4 + + +CPVANP6->CPVANP3 + + +-11 + + +CPVANP6->CPVANP3 + +0 + + +CPVANP6->CPVANP3 + + +-11 + + +PYMONP6 + +PYMONP6 + + +CPVANP6->PYMONP6 + + +-0 + + +CREYSP7 + + + +CREYSP7->GEN.PP7 + + +7 + + +CREYSP7->GEN.PP7 + + +7 + + +CUISEP3 + + + +G.CHEP3 + + + +CUISEP3->G.CHEP3 + + +14 + + +FLEYRP6->VOUGLP6 + + +9 + + +FRON5P3 + + + +FRON5P3->LOUHAP3 + + +14 + + +G.CHEP3->FRON5P3 + + +14 + + +GENLIP3 + +GENLIP3 + + +GENLIP3->COLLOP3 + + +-4 + + +GUEUGP6->GROSNP6 + +0 + + +IZERNP6->CIZE P6 + + +9 + + +ZJOUXP6 + + + +MACONP6->ZJOUXP6 + +0 + + +MAGNYP3 + +MAGNYP3 + + +MAGNYP3->C.SAUP3 + + +2 + + +MAGNYP3->GENLIP3 + + +-4 + + +MAGNYP6 + +MAGNYP6 + + +MAGNYP6->MAGNYP3 + + +-2 + + +SSUSUP3 + +SSUSUP3 + + +MERVAP3->SSUSUP3 + + +-25 + + +NAVILP3 + +NAVILP3 + + +NAVILP3->C.FOUP3 + + +-25 + + +P.SAOP3->NAVILP3 + + +-25 + + +PYMONP3 + + +PYMONP3 + + +PYMONP3->LOUHAP3 + + +12 + + +PYMONP6->PYMONP3 + + +-0 + + +PYMONP6->PYMONP3 + +0 + + +PYMONP6->VOUGLP6 + +0 + + +SAISSP3 + + + +SAISSP3->PYMONP3 + + +12 + + +SSUSUP3->LOUHAP3 + + +-26 + + +SSV.OP7->GROSNP7 + + +-7 + + +SSV.OP7->CREYSP7 + + +7 + + +SSV.OP7->CREYSP7 + + +7 + + +VOUGLP3 + + + +VOUGLP3->CUISEP3 + + +14 + + +VOUGLP3->SAISSP3 + + +13 + + +VOUGLP6->VOUGLP3 + + +15 + + +VOUGLP6->VOUGLP3 + + +14 + + +ZCRIMP3->C.SAUP3 + + +-2 + + +ZJOUXP6->BOISSP6 + +0 + + +ZMAGNP6->CPVANP6 + + +-7 + + +ZMAGNP6->MAGNYP6 + + +-2 + + + + +
+
+
+ + + diff --git a/docs/features/interaction-logging.md b/docs/features/interaction-logging.md index b7e3446f..c1b593ca 100644 --- a/docs/features/interaction-logging.md +++ b/docs/features/interaction-logging.md @@ -83,6 +83,7 @@ type InteractionType = | 'zoom_out' // User clicked zoom-out button | 'zoom_reset' // User clicked zoom reset button | 'inspect_query_changed' // User typed in search/inspect box + | 'vl_names_toggled' // User toggled the 🏷 VL labels visibility on an NAD tab // === Action Overview Diagram === | 'overview_shown' // Overview view became visible (no card selected) | 'overview_hidden' // Overview view hidden (card selected / tab switched) @@ -93,6 +94,17 @@ type InteractionType = | 'overview_zoom_out' // User clicked overview zoom-out button | 'overview_zoom_fit' // User clicked overview "Fit" button | 'overview_inspect_changed' // User focused or cleared an asset in the overview inspect search + | 'overview_filter_changed' // User changed a category / threshold / action-type chip (overview OR overflow iframe) + | 'overview_unsimulated_toggled' // User toggled the "Show unsimulated" checkbox + | 'overview_unsimulated_pin_simulated' // User double-clicked an unsimulated pin to kick off its manual simulation + // === Overflow Analysis Tab === + | 'overflow_layout_mode_toggled' // User flipped the Hierarchical / Geo layout switch + | 'overflow_pins_toggled' // User flipped the 📌 Pins toolbar button + | 'overflow_pin_clicked' // Single-click on an action pin inside the overflow iframe + | 'overflow_pin_double_clicked' // Double-click on an action pin → SLD drill-down on the action sub-tab + | 'overflow_layer_toggled' // Layer-toggle gesture inside the overflow iframe sidebar + | 'overflow_select_all_layers' // Select-all / Unselect-all link in the overflow iframe sidebar + | 'overflow_node_double_clicked' // Double-click on an overflow-graph node → SLD drill-down on its VL // === SLD Overlay === | 'sld_overlay_opened' // User double-clicked VL to open SLD | 'sld_overlay_tab_changed' // User switched SLD tab (n / n-1 / action) @@ -180,6 +192,7 @@ Each event's `details` field contains **all parameters needed to replay** the us | `zoom_out` | `{ tab: TabId }` | Click - button | | `zoom_reset` | `{ tab: TabId }` | Click reset button | | `inspect_query_changed` | `{ query: string, target_tab?: TabId }` — `target_tab` is only present when the inspect field was triggered from a detached-tab overlay (per-tab inspect routing). Absent = main-window active tab. | Type in search box | +| `vl_names_toggled` | `{ show: boolean }` — new visibility state of the `🏷 VL` toggle (default ON). Toggles the `nad-hide-vl-labels` CSS class on every NAD tab; the labels remain reachable via the per-bus `` tooltip injected by `applyVlTitles`. | Click `🏷 VL` next to the bottom-left Inspect field. | ### Action Overview Diagram @@ -194,6 +207,23 @@ Each event's `details` field contains **all parameters needed to replay** the us | `overview_zoom_out` | `{}` | Click the overview `-` zoom button. | | `overview_zoom_fit` | `{}` — resets the viewBox to the auto-fit rectangle (contingency + overloads + pins). | Click the overview `Fit` button. | | `overview_inspect_changed` | `{ query: string, action: 'focus' \| 'cleared' }` — `focus` means the typed query matched an exact equipment id and the view zoomed onto it. `cleared` means the query was emptied and the view returned to the fit rectangle. Intermediate keystrokes that don't match are not logged. | Type in the overview inspect field or clear it. | +| `overview_filter_changed` | `{ kind: 'category' \| 'categories_bulk' \| 'threshold' \| 'action_type' \| 'overflow_iframe', category?: ActionSeverityCategory, enabled?: boolean, threshold?: number, action_type?: ActionTypeFilterToken }` — the discriminator `kind` says which chip / control changed: a single severity chip (`category` + `enabled`), the All / None bulk-select pills (`categories_bulk` + `enabled`), the Max-loading numeric input (`threshold`), the action-type chip row (`action_type`), or a chip change inside the Overflow Analysis iframe sidebar (`overflow_iframe` — the parent re-broadcasts the new filters via `cs4g:filters`). Logged for every change of `ActionOverviewFilters`, regardless of the surface that triggered it, so the Action Feed / Action Overview pins / overflow pins always reach the same filter state on replay. | Click a category chip, the All / None pill, edit the Max-loading input, click an action-type chip, OR change any of those in the Overflow Analysis iframe sidebar. | +| `overview_unsimulated_toggled` | `{ enabled: boolean }` — new state of the **Show unsimulated** checkbox. Drives the dashed grey "?" pin layer on both the Action Overview NAD and (gated on the same flag) the Overflow Analysis iframe. | Click the **Show unsimulated** checkbox in the Action Overview filters or the iframe filters panel. | +| `overview_unsimulated_pin_simulated` | `{ action_id: string }` — the action a double-click kicked off a manual simulation for. Emitted by both the Action Overview NAD and the Overflow Analysis iframe (the latter forwards the gesture via `cs4g:overflow-unsimulated-pin-double-clicked`). Wait-point: the simulated action eventually appears in the Action Feed and replaces the dashed pin with a coloured one. | Double-click an unsimulated pin on either surface. | + +### Overflow Analysis Tab + +The Overflow Analysis iframe forwards user gestures to the parent React app via `postMessage`, where they are recorded as the events below. See `docs/features/interactive-overflow-analysis.md` §8 for the full message-routing contract. + +| Event | Details | Replay Action | +|-------|---------|---------------| +| `overflow_layout_mode_toggled` | `{ to: 'hierarchical' \| 'geo' }` on start; the corresponding `_completed` event carries `{ to, cached: boolean }` (or `{ to, error: string }` on failure). Wait-point: the iframe URL refreshes once the backend regenerates / serves the requested layout. | Click the Hierarchical / Geo layout switch on the Overflow Analysis tab. | +| `overflow_pins_toggled` | `{ enabled: boolean }` — new state of the toolbar `📌 Pins` button. Disabled until step-2 has streamed actions; default OFF. When `enabled === true`, the parent posts the `cs4g:pins` payload to the iframe and the action pin layer becomes visible. | Click the `📌 Pins` toolbar toggle on the Overflow Analysis tab. | +| `overflow_pin_clicked` | `{ actionId: string }` — single click on an action pin inside the iframe. The Action Feed scrolls to the matching card AND a floating `ActionCardPopover` opens anchored on the pin. Does NOT switch the active main tab. | Click a pin once on the Overflow Analysis tab. | +| `overflow_pin_double_clicked` | `{ actionId: string, substation: string }` — double-click on an action pin. Closes any open popover and opens the SLD overlay scoped to that substation with `forceTab='action'`. Logged ONLY when `result.actions[actionId]` exists — stale double-clicks (action evicted by re-analysis) are silently dropped. | Double-click an action pin. | +| `overflow_layer_toggled` | `{ key: string, label: string, visible: boolean }` — `key` is the canonical layer key (e.g. `"semantic:is_hub"`, `"color:coral"`); `label` is the human-readable sidebar label; `visible` is the new checkbox state (dim-instead-of-hide membership recomputed from this). | Tick / untick a layer checkbox in the iframe sidebar. | +| `overflow_select_all_layers` | `{ visible: boolean }` — bulk-flip of every layer checkbox via the **Select all · Unselect all** link row above the layer list. | Click **Select all** or **Unselect all** in the iframe sidebar. | +| `overflow_node_double_clicked` | `{ name: string }` — substation / VL name of the double-clicked overflow-graph node. The parent's `onVlOpen` handler routes to the SLD overlay using the same path as a NAD VL double-click. | Double-click a node in the overflow graph. | ### SLD Overlay @@ -248,6 +278,8 @@ These events trigger async API calls. The replay agent must wait for the operati | `settings_applied` | Network reloaded, branches refreshed | | `tab_detached` | Popup opened, React portal target mounted — or the event is skipped if the runner can't open real popups | | `tab_reattached` | Popup closed, content re-rendered in the main window | +| `overflow_layout_mode_toggled` | Overflow iframe URL refreshes after the backend regenerates / serves the requested layout (`POST /api/regenerate-overflow-graph`); the matching `_completed` event carries `cached: bool` or `error: string`. | +| `overview_unsimulated_pin_simulated` | The dashed `?` pin is replaced by a coloured pin once the manual simulation lands in `result.actions`. | ### Handling `*_completed` Events @@ -308,6 +340,7 @@ If a replay agent depends on any of those post-load side effects, the reload pat - **Per-card edit state** (`cardEditMw`, `cardEditTap`): these are the raw, uncommitted values in the action card inputs. Only the committed, re-simulated result survives — persisted as `pst_details.tap_position` / `load_shedding_details[].shedded_mw` / `curtailment_details[].curtailed_mw`. A replay agent that wants to reproduce edit keystrokes must consume the `action_mw_resimulated` / `pst_tap_resimulated` events from `interaction_log.json` instead. - **Detached / tied tab state** (PR #84/#85): `detachedTabs` and the tied-tab registry are deliberately ephemeral. Detaching a tab does not change any analysis result, so reload intentionally starts with all tabs attached to the main window. A replay that needs to reproduce detach / reattach / tie / untie must stream the matching events from `interaction_log.json`. +- **Overflow Analysis tab UI state**: `overflowPinsEnabled`, `overflowLayoutMode`, the iframe layer-toggle checkboxes and the shared `overviewFilters` (category chips / threshold / action-type / Show unsimulated) are intentionally not in `session.json` — they reset on reload. A replay must stream `overflow_pins_toggled` / `overflow_layout_mode_toggled` / `overflow_layer_toggled` / `overflow_select_all_layers` / `overview_filter_changed` / `overview_unsimulated_toggled` from `interaction_log.json` to reach the same operator state. --- diff --git a/docs/features/interactive-overflow-analysis.md b/docs/features/interactive-overflow-analysis.md new file mode 100644 index 00000000..f3df6435 --- /dev/null +++ b/docs/features/interactive-overflow-analysis.md @@ -0,0 +1,721 @@ +# Interactive Overflow Analysis + +The **Overflow Analysis** tab embeds the upstream alphaDeesp HTML viewer +in an iframe and augments it with Co-Study4Grid–specific behaviour: +semantic layer toggles, action pins synchronised with the Action +Overview NAD, drill-down to the SLD on double-click, edge / line +re-routing in geo mode, and a per-pin filter widget that mirrors the +Action Overview filters. + +This document is the contract for that feature. It collects every +moving part — upstream tags, geo-transform behaviour, overlay +wire-format, click semantics, filter sync — so the next person +debugging or extending the tab does not have to reverse-engineer it +from the code. + +--- + +## 1. Architecture overview + +``` +┌─────────────────── Co-Study4Grid React frontend ─────────────────────┐ +│ VisualizationPanel: toolbar gets a 📌 Pins toggle. Disabled until │ +│ Step 2 has streamed actions; default OFF. When ON, builds │ +│ OverflowPin[] from result.actions + n1MetaIndex + vlToSubstation │ +│ + overviewFilters and posts them to the iframe. │ +└─────────────────────┬─────────────────────────────────────────────┬──┘ + │ window.postMessage │ + cs4g:pins ▼ │ ▲ cs4g:pin-clicked │ + cs4g:filters ▼│ ▲ cs4g:pin-double-clicked │ + │ ▲ cs4g:overflow-filter-changed │ + │ ▲ cs4g:overflow-layer-toggled │ + │ ▲ cs4g:overflow-node-double-clicked │ + │ ▲ cs4g:overlay-ready │ +┌─────────────────────▼──────────────────────────────────────────────┐ +│ Backend dynamic GET /results/pdf/{filename}: replaces the static │ +│ mount, reads the upstream HTML produced by the recommender, and │ +│ splices a <style>+<script> block before </body> via │ +│ ``expert_backend.services.overflow_overlay.inject_overlay``. │ +└─────────────────────┬──────────────────────────────────────────────┘ + │ HTML + injected overlay JS + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Upstream alphaDeesp ``interactive_html.py`` viewer: │ +│ • ``data-attr-*`` flags on nodes / edges (sourced from explicit │ +│ attributes set by the recommender) drive the LAYER toggles. │ +│ • Layers grouped into 3 sections (Structural Paths / Individual │ +│ entities properties / Flow redispatch values), with a │ +│ Select-all / Unselect-all row, and a *dim*-instead-of-hide │ +│ toggle behaviour (`opacity 0.12`). │ +│ Co-Study4Grid overlay JS (the inlined block): │ +│ • Renders action pins on top of the SVG inside ``g.graph``. │ +│ • Adds an "Action pins filters" section to the sidebar. │ +│ • Forwards click / double-click / layer-toggle events back to │ +│ the parent. │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +Phase split: +- **Phase A (upstream)** — `expert_op4grid_recommender` / + `alphaDeesp` get the source-of-truth tagging. +- **Phase B (Co-Study4Grid backend)** — overflow HTML serving, the + overlay injector, the geo transform. +- **Phase C (Co-Study4Grid frontend)** — toolbar toggle, pin + payload, popover, message routing, filter sync. + +--- + +## 2. Source-of-truth attribute tagging (upstream) + +The recommender sets these explicit boolean flags on the overflow +graph it produces. The interactive viewer simply scans them — no +heuristic re-derivation from colours / shapes. + +### 2.1 Node attributes + +| Attribute | Set by | Drives | +|------------------------|------------------------------------------|------------------------------| +| `is_hub=True` | `set_hubs_shape(...)` (auto) | `Hubs` layer | +| `in_red_loop=True` | `tag_red_loops(lines, nodes)` | `Red-loop paths` layer | +| `on_constrained_path=True` | `tag_constrained_path(lines, nodes)` | `Constrained path` layer | +| `prod_or_load="prod"` + `value` | `build_nodes` (auto) | `Production nodes` layer (≥ 1 MW floor) | +| `prod_or_load="load"` + `value` | `build_nodes` (auto) | `Consumption nodes` layer (≥ 1 MW floor) | + +Hubs are auto-tagged with `in_red_loop` AND `on_constrained_path` +so they always show up under those layers regardless of how the +recommender's lists are built. + +`prod_or_load` is written on **every** node by upstream `build_nodes` +(see `alphaDeesp/core/graphs/power_flow_graph.py` and the +simulator-specific `build_nodes_v2` helpers in +`alphaDeesp/core/{grid2op,powsybl,pypownet}/*Simulation.py`). The +sign of `prod_minus_load` decides the kind: + +* `prod_minus_load > 0` → `prod_or_load="prod"`, fillcolor `coral` +* `prod_minus_load < 0` → `prod_or_load="load"`, fillcolor `lightblue` +* `prod_minus_load == 0` → `prod_or_load="load"`, fillcolor `#ffffed` + (the white "passive substation" placeholder — every node carries + this even when it has neither prod nor load) + +The viewer therefore filters on `abs(value) >= _PROD_LOAD_VALUE_FLOOR_MW` +(1 MW by default) when populating the Production / Consumption layers +so the placeholder zero-balance nodes don't flood the Consumption +toggle. Bumping the floor is an +`alphaDeesp/core/interactive_html.py` knob. + +### 2.2 Edge attributes + +| Attribute | Set by | Drives | +|------------------------|------------------------------------------|------------------------------| +| `is_overload=True` | `highlight_significant_line_loading` | `Overloads` layer | +| `is_monitored=True` | `highlight_significant_line_loading` | `Low margin lines` layer | +| `in_red_loop=True` | `tag_red_loops(lines, nodes)` | `Red-loop paths` layer | +| `on_constrained_path=True` | `tag_constrained_path(lines, nodes)` | `Constrained path` layer | + +`is_overload` is a strict subset of `is_monitored` — every overload +is also a low-margin line; the inverse is not true. + +### 2.3 Recommender wiring + +`make_overflow_graph_visualization` (in +`expert_op4grid_recommender.graph_analysis.visualization`) accepts +the four lists (constrained-path lines/nodes, red-loop lines/nodes) +and the overloads list, then calls `tag_constrained_path`, +`tag_red_loops`, and `highlight_significant_line_loading` on the +graph before serialising. + +--- + +## 3. Layer-toggle UI (upstream `interactive_html.py`) + +The viewer's sidebar groups layers into three labelled sections: + +| Section | Layers | +|----------------------------------|--------------------------------------------------------------------------------------------------------------| +| **Structural Paths** | Constrained path, Red-loop paths | +| **Individual entities properties** | Reconnectable, Non-reconnectable, Swapped flow, Overloads, Low margin lines, Hubs, Production nodes, Consumption nodes | +| **Flow redispatch values** | Positive, Negative, Null | + +Production / Consumption nodes carry no edges — they are pure +node-only filters driven by `prod_or_load` + `value`. The sidebar +swatch is a small filled circle in the matching node fillcolor +(coral for prod, lightblue for load) so the visual mapping +between the swatch and the actual nodes on the canvas is direct. + +### Section ordering + +`_LAYER_SECTIONS` (Python dict in `interactive_html.py`) maps each +canonical layer key to its section. `_SECTION_ORDER` is the +top-to-bottom render order. Layers without a section assignment are +dropped entirely (e.g. the historical `color:black`, `color:gray`, +`color:darkred` buckets — replaced by the explicit `is_overload` / +`is_monitored` flags). + +Each layer in the JSON model carries a `section` field; the JS +template emits one `<h3 class="layer-section-header">` whenever the +section name changes between consecutive layers. + +### Dim-instead-of-hide + +Toggling a layer off applies a `.layer-off` class to its claimed +nodes AND edges. The CSS rule is: + +```css +.graph .layer-off { opacity: 0.12; } +``` + +(*not* `display: none` — pointer-events stay live, and the operator +can still see the topology). + +### Membership-based dim model + +Each node/edge knows the set of layers that claim it. The JS +recomputes `.layer-off` membership-wise: an element is visible iff +**at least one of its claiming layers is currently checked**. When +the operator unticks every layer, elements with no membership are +dimmed too (so a single-layer focus shows ONLY that layer). + +### Endpoint-node inclusion + +For colour layers, style layers, and the edge-only semantic layers +(Overloads, Low margin lines), the layer's `nodes` list is augmented +with the bbox-endpoint nodes of every claimed edge. Toggling +"Overloads" alone keeps the substations the overload connects +visible — no floating edges in dimmed space. + +### Select-all / Unselect-all + +A "Select all · Unselect all" link row sits above the layer +checkboxes; both flip every layer at once and dispatch the same +`change` event the individual checkboxes do. + +### Edge-id alignment regression guard + +`_align_edge_ids_with_svg()` walks the SVG `<title>` strings and +re-keys the JSON edge model so SVG `id="edgeN"` and JSON edge index +agree on `(src, dst)` endpoints. Without it, graphviz's two output +streams (SVG vs JSON) drift in independent orderings and the +`data-source` / `data-target` attributes on `<g class="edge">` +collide with their `<title>` content. This was a critical bug that +made the layer membership for some edges flat-out wrong. + +--- + +## 4. Geo-mode SVG transform + +The geo toggle calls `transform_html()` in +`expert_backend/services/analysis/overflow_geo_transform.py` to +re-place every node at its geographic coordinate (from +`grid_layout.json`) and redraw edges as straight lines. + +### Edge rewriting + +For each edge group in the SVG: +- Compute the new straight segment from source-node centre to + target-node centre (with a small `node_gap` pull-back so the + arrowhead tip lands on the node outline). +- For a regular edge (`<path>` + `<polygon>` arrowhead): + - Rewrite the path's `d` to a single `M src L tgt`. + - Rewrite the arrowhead polygon's `points` via + `_arrowhead_points` (proportional to the edge stroke width). + - Zero its `stroke-width` (graphviz inherits a heavy stroke onto + the polygon that turns the triangle into a chunky blob). +- For a **tapered** edge (`data-attr-style="tapered"` = + swapped-flow line): + - Detected upfront — graphviz emits TWO polygons (long ~21-vertex + body + 4-vertex arrowhead) and **no** path. + - `_rewrite_tapered_edge` rewrites the body polygon to a 4-vertex + tapered strip via `_tapered_strip_points` (wide at source, + narrow at target — same visual cue as the hierarchical layout). + - The second polygon stays the arrowhead. + - Without this special case the body polygon was overwritten with + a 3-vertex triangle and the swapped-flow line had no visible + body. + +--- + +## 5. Overlay injection + +`expert_backend/services/overflow_overlay.py:inject_overlay(html)` +splices a single `<style>` + `<script>` block (with deterministic +`id` attributes) before the closing `</body>` tag. Idempotent — a +re-injection on the same file replaces the previous block. + +The `<script>` body is built from a Python f-string that interleaves: +1. The full content of the **shared module** + `frontend/src/utils/svg/pinGlyph.js` (read at request time, with + ESM `export` keywords stripped via regex). Both this script + AND the React Action Overview pin renderer (`actionPinRender.ts`) + call into the same `createPinGlyph` factory — visual contract, + colour palette, status symbols all in one place. +2. The Co-Study4Grid–specific overlay code (filters panel, pin + layer, click handlers). + +A regression test (`test_injected_script_parses_as_valid_js`) asserts +the resulting script parses cleanly. A duplicate `const SVG_NS` +between `pinGlyph.js` and the overlay IIFE silently disabled every +listener once — guarded since. + +--- + +## 6. Action-pin overlay + +### 6.1 Pin payload (parent → iframe) + +```ts +interface OverflowPin { + actionId: string; + /** Primary anchor: substation id (overflow node `data-name`). */ + substation: string; + /** Fallback node anchors when the graph is keyed by VL ids. */ + nodeCandidates?: string[]; + /** Edge anchors — pin lands at the midpoint of the matching edge. */ + lineNames?: string[]; + label: string; // e.g. "92%", "DIV", "ISL" + severity: 'green' | 'orange' | 'red' | 'grey'; + isSelected: boolean; + isRejected: boolean; +} +``` + +Posted as `{ type: 'cs4g:pins', visible, pins: OverflowPin[] }`. + +### 6.2 Anchor priority (mirrors `actionPinData.resolveActionAnchor`) + +`buildOverflowPinPayload` in +`frontend/src/utils/svg/overflowPinPayload.ts` resolves each action +to an anchor in this priority order: + +1. **Load-shedding / curtailment** → VL node directly (early + return; never carries `lineNames`). +2. **Primary line targets** (`getActionTargetLines`) → emits + `lineNames=[…]`; overlay anchors at edge midpoint. +3. **Voltage-level targets** (`getActionTargetVoltageLevels`) → VL + node anchor. +4. **`max_rho_line` fallback** — only used when neither (2) nor (3) + produced anything; emitted as the single `lineNames` entry. + +This mimics `resolveActionAnchor` for the Action Overview NAD. +Coupler / node-merging / generic VL actions therefore land on their +substation node, NOT on their incidental `max_rho_line`. + +### 6.3 Pin rendering — shared glyph factory + +`frontend/src/utils/svg/pinGlyph.js` is plain ES JavaScript with +two consumers: + +- **React/TS bundle**: imported by `actionPinRender.ts` for the + Action Overview NAD pin layer. +- **Iframe overlay**: read as text by the Python backend, ESM + `export` keywords stripped, body inlined into the overlay + `<script>` block. + +The factory returns an `<g class="cs4g-pin">` element with: +- Teardrop bubble (half-circle on top, tip at the anchor (0, 0)) + drawn by a single `<path>` whose fill follows the severity + palette (`SEVERITY_FILL` / `_DIMMED` / `_HIGHLIGHTED`). +- White chrome circle behind the loading-rate label. +- Status symbol above the bubble: gold star when selected, red cross + when rejected. +- Same colour palette as the React `pinColors*` design tokens (a + `pinGlyph.test.ts` assertion enforces parity). + +### 6.4 Anchoring math (overlay-side) + +`overflow_overlay.py` provides three helpers: + +- `projectToLayer(node, layer, lx, ly)` — composes + `layer.getCTM().inverse() × node.getCTM()` to map a point from a + node's local coordinate space into the pin layer's space. The pin + layer is appended INSIDE `<g class="graph">` so it inherits + graphviz's translate / scale; without the CTM math, `getBBox()` + returned coordinates that don't include the node's own + `translate(...)` and every pin landed at (0, 0) of `g.graph`. +- `nodeCentre(names, layer)` — walks the candidate list against + `[data-name="<name>"]`; when a match exists, returns the node's + *top-edge* centre (so the pin tip touches the node and the body + floats above the label). +- `edgeCentre(lineNames, layer)` — looks up + `[data-attr-name="<line>"]`, reads `data-source` / `data-target`, + finds those two nodes and returns the midpoint of their + projected centres. Tried BEFORE `nodeCentre` when the pin + payload carries `lineNames`. + +### 6.5 Pin radius + +`basePinRadius(layer)` samples the first `.node` group's bbox and +multiplies by 0.55, then scales the result through the layer CTM so +the radius is in the same coordinate space as the centres. Cached +per render pass (`cachedR`) — every node in a graphviz dot output +shares the same shape size class. + +### 6.6 Click semantics + +The pin's `<g>` listens for both `click` and `dblclick`: + +- **Single click** — debounced via 250 ms `setTimeout`. If the + timer fires (no double click follows) the overlay posts + `cs4g:pin-clicked` with the pin's + `getBoundingClientRect()` (in iframe-screen pixels). +- **Double click** — clears the pending single-click timer and + posts `cs4g:pin-double-clicked` with `{ actionId, substation }`. + +The debounce is a structural requirement: without it the first click +fires `onActionSelect` (which switches the active main tab), and the +follow-up `dblclick` can no longer drill into the SLD. + +### 6.7 Parent-side handlers + +| Message | Parent handler | Effect | +|---|---|---| +| `cs4g:pin-clicked` | `onOverflowPinPreview(actionId)` + opens `ActionCardPopover` anchored on the pin | Feed scrolls to the matching card AND a floating popover (the SAME `ActionCardPopover` the Action Overview uses) opens around the pin. NO tab switch. | +| `cs4g:pin-double-clicked` | `handleOverflowPinDoubleClick(actionId, substation)` | Closes the popover, opens the SLD overlay with `forceTab='action'` for that substation. | + +The popover translates iframe-screen coordinates to parent-screen +coordinates via `iframe.getBoundingClientRect()` and uses the same +`decidePopoverPlacement` / `computePopoverStyle` helpers as the +Action Overview pin. + +### 6.8 SLD overlay action sub-tab + +`SldOverlay`'s tab filter previously gated the action sub-tab on +`actionDiagram` (the *main-window* NAD action variant). When opened +via overflow-pin double-click, that's null. The filter is now: + +```ts +if (tabMode === 'action') return !!actionDiagram || !!vlOverlay.actionId; +``` + +— so the action sub-tab is offered whenever the SLD overlay is +scoped to an action, regardless of whether the main NAD is on the +action variant. + +### 6.9 Pins toggle UI + +The toolbar exposes a single `📌 Pins` button (matching the `🏷 VL` +toggle visual) with `aria-pressed`, brand-soft fill when active, +`disabled` until step-2 actions exist. + +State + posting: +- `App.tsx` owns `overflowPinsEnabled: boolean` (default `false`), + `overflowPinsAvailable: !!result.actions`, `overflowPins: + OverflowPin[]` (memoised; rebuilt when actions / metaIndex / + vlToSubstation / monitoringFactor / selectedIds / rejectedIds / + overviewFilters change), `overflowUnsimulatedPins: OverflowPin[]` + (memoised; gated on `overviewFilters?.showUnsimulated`), and + `allOverflowPins = [...overflowPins, ...overflowUnsimulatedPins]` + which is the actual payload posted to the iframe. +- `VisualizationPanel` posts `cs4g:pins` on every relevant change + (after the `cs4g:overlay-ready` handshake). + +### 6.10 Un-simulated pin layer + +Mirrors the Action Overview's "Show unsimulated" behaviour: when +the operator ticks **Show unsimulated** (parent OR iframe sidebar), +every action that has a recommender score but no simulation result +yet is rendered as a dashed grey "?" pin so it can be discovered and +double-clicked into a manual simulation. + +#### Payload builder + +`buildOverflowUnsimulatedPinPayload` in `overflowPinPayload.ts` mirrors +the simulated-pin builder but: + +- Walks `actionsScoreInfo` (the score table) instead of + `result.actions`. +- Skips any action already present in the simulated set + (`simulatedIds`). +- Sets `unsimulated: true` on the resulting `OverflowPin`. +- Resolves the same anchor priority (load-shed / curtail → VL, + primary lines → edge midpoint, VL targets → VL node, + `max_rho_line` last resort). +- Populates the `title` field with `buildUnsimulatedPinTitle(id, + scoreInfo)` — the SAME helper consumed by the Action Overview pin + layer (exported from `actionPinData.ts` for cross-module reuse). + Multi-line content: `"<id> — not yet simulated (double-click to + run)\nType: …\nScore: … — rank N of M (max …)\nMW start: …\n…"`. + +#### Wire-format addition + +The `OverflowPin` interface gains two optional fields: + +```ts +interface OverflowPin { + /* …existing fields… */ + /** True for score-only pins from buildOverflowUnsimulatedPinPayload. */ + unsimulated?: boolean; + /** Multi-line tooltip; falls back to actionId in the overlay. */ + title?: string; +} +``` + +#### Overlay rendering (`overflow_overlay.py`) + +When `pin.unsimulated === true` the overlay: + +- Calls `createPinGlyph({ ..., dimmed: true })` from the shared + factory so the body fill follows the dimmed severity palette. +- Sets `data-unsimulated="true"` on the pin `<g>` (CSS hook + test + selector). +- Applies a dashed stroke (`stroke-dasharray="3,3"`) AND + `opacity: 0.5` to the bubble — visual parity with Action Overview. +- Reads `pin.title` (with fallback to `pin.actionId`) and emits a + `<title>` child on the pin so the browser's native hover tooltip + shows the multi-line score / rank / MW-start string. + +#### Distinct double-click route + +A double-click on an unsimulated pin posts +`cs4g:overflow-unsimulated-pin-double-clicked` (NOT the regular +`cs4g:pin-double-clicked`). The parent's `VisualizationPanel` +message router calls `onSimulateUnsimulatedAction(actionId)` — +the same `handleSimulateUnsimulatedAction` already used by the +Action Overview unsimulated-pin path. The popover is closed first +to mirror the simulated-pin double-click cleanup. + +Single click is unchanged: `cs4g:pin-clicked` still triggers the +feed-focus + popover behaviour, which works for unsimulated pins +because `ActionCardPopover` accepts a "score-only" descriptor. + +--- + +## 7. Action-pin filter sync + +### 7.1 Filter contract + +The same `ActionOverviewFilters` object drives: +- the Action Feed's card visibility, +- the Action Overview NAD's pin filtering, +- the overflow graph's pin filtering. + +```ts +interface ActionOverviewFilters { + categories: { green: boolean; orange: boolean; red: boolean; grey: boolean }; + threshold: number; // 0–3 (max-loading rate) + showUnsimulated: boolean; + actionType: 'all' | 'disco' | 'reco' | 'ls' | 'rc' | 'open' | 'close' | 'pst'; +} +``` + +The default is `DEFAULT_ACTION_OVERVIEW_FILTERS` in +`frontend/src/utils/actionTypes.ts`. + +### 7.2 Filter application (parent → pins) + +`buildOverflowPinPayload` accepts an optional `overviewFilters` +parameter. When passed, each action is dropped if either: +- `actionPassesOverviewFilter(details, monitoringFactor, categories, + threshold)` returns false, OR +- `matchesActionTypeFilter(actionType, actionId, description, null)` + returns false. + +`App.tsx` always passes `overviewFilters` to the builder, so the +overflow pin set is filtered identically to the Action Overview pin +set. + +The `showUnsimulated` field is the **gate** for the un-simulated +pin layer (§6.10): when `true`, `App.tsx` invokes +`buildOverflowUnsimulatedPinPayload` and concatenates its result with +the simulated payload before posting; when `false`, only the +simulated pins are posted. Toggling the field from EITHER side +(parent overview chip or iframe sidebar checkbox) drives the same +re-broadcast through `cs4g:filters` / `cs4g:overflow-filter-changed`, +keeping the two surfaces in lockstep. + +### 7.3 Iframe sidebar filter panel + +The overlay injects an **Action pins filters** section appended at +the BOTTOM of the upstream sidebar (separated from the layer +toggles by a top border). The section is hidden by default and +becomes visible only when the parent posts `visible: true` +together with the pin payload. + +The panel mirrors every Action Overview filter chip: +- `Solves overload` / `Low margin` / `Still overloaded` / + `Divergent / islanded` category chips with colour swatches. +- `All` / `None` bulk-select pills. +- `Max loading %` numeric input (0–300, stored × 100 internally). +- `Show unsimulated` checkbox. +- `ALL DISCO RECO LS RC OPEN CLOSE PST` action-type chip row + (single-select). +- A `📌 N` pin counter in the section header showing the number of + pins currently *rendered* (post-anchor-resolution count, mirroring + `overview-pin-counter`). + +### 7.4 Bidirectional sync + +| Direction | Wire format | Purpose | +|---|---|---| +| Parent → iframe | `{ type: 'cs4g:filters', filters: ActionOverviewFilters }` | Re-emit on every change to `overviewFilters` so the iframe panel reflects external changes (e.g. operator changed a chip on the Action Overview). | +| Iframe → parent | `{ type: 'cs4g:overflow-filter-changed', filters: ActionOverviewFilters }` | Operator changed a chip in the iframe sidebar; parent calls `onOverviewFiltersChange(msg.filters)` so the Action Feed and the Action Overview NAD pins follow suit. | + +Both ends are eventually consistent — the parent is the single +source of truth; the iframe just renders chips and posts changes. + +--- + +## 8. Forwarded interaction events + +The overlay forwards these gestures from inside the iframe to the +parent React app, where they're recorded by `interactionLogger` +(replay-ready event log): + +| Wire-format type | Interaction event | Payload fields | +|------------------------------------|----------------------------------|-----------------------------| +| `cs4g:overlay-ready` | (handshake — not logged) | — | +| `cs4g:pin-clicked` | `overflow_pin_clicked` | `actionId` | +| `cs4g:pin-double-clicked` | `overflow_pin_double_clicked` | `actionId`, `substation` | +| `cs4g:overflow-unsimulated-pin-double-clicked` | `overview_unsimulated_pin_simulated` | `action_id` | +| `cs4g:overflow-layer-toggled` | `overflow_layer_toggled` | `key`, `label`, `visible` | +| `cs4g:overflow-select-all-layers` | `overflow_select_all_layers` | `visible` | +| `cs4g:overflow-node-double-clicked`| `overflow_node_double_clicked` | `name` | +| `cs4g:overflow-filter-changed` | `overview_filter_changed` | `kind: 'overflow_iframe'` | +| (parent toolbar) | `overflow_pins_toggled` | `enabled` | +| (parent toolbar) | `overflow_layout_mode_toggled` | `to` | + +Parent-only emitter: +- `overflow_pin_double_clicked` is logged only when + `result.actions[actionId]` exists — stale double-clicks are + silently dropped. + +The `specConformance.test.ts` and +`scripts/check_standalone_parity.py` `_SPEC_DETAILS` table both +enforce these field sets. + +--- + +## 9. File map + +| File | Role | +|---|---| +| `expert_op4grid_recommender/graph_analysis/visualization.py` | Calls the four taggers on the graph before serialising. | +| `alphaDeesp/core/graphs/overflow_graph.py` | `set_hubs_shape`, `tag_red_loops`, `tag_constrained_path`, `highlight_significant_line_loading`. | +| `alphaDeesp/core/interactive_html.py` | Layer-index builder, section grouping, JS template, CTM-safe SVG-edge-id alignment. | +| `expert_backend/main.py` | Dynamic `GET /results/pdf/{filename}` route + path-traversal guard. | +| `expert_backend/services/overflow_overlay.py` | `inject_overlay` + the entire overlay JS payload (filters panel, pin layer, click handlers, message router). | +| `expert_backend/services/analysis/overflow_geo_transform.py` | Hierarchical → geo SVG rewrite, including tapered-edge body preservation. | +| `frontend/src/utils/svg/pinGlyph.js` | **SHARED** plain-JS pin-glyph factory, consumed by both `actionPinRender.ts` and the iframe overlay. | +| `frontend/src/utils/svg/pinGlyph.d.ts` | TypeScript declarations for the shared module. | +| `frontend/src/utils/svg/actionPinRender.ts` | Action Overview NAD pin renderer; calls the shared `createPinGlyph`. | +| `frontend/src/utils/svg/overflowPinPayload.ts` | `buildOverflowPinPayload` + `buildOverflowUnsimulatedPinPayload` — anchor priority, filter application, and unsimulated-pin payload (with multi-line `title`). | +| `frontend/src/utils/svg/actionPinData.ts` | Exports the shared `buildUnsimulatedPinTitle(id, scoreInfo)` helper consumed by both the Action Overview and the overflow unsimulated payload builder. | +| `frontend/src/components/VisualizationPanel.tsx` | Toolbar `📌 Pins` toggle, iframe ref + message router, popover state, filter rebroadcast. | +| `frontend/src/components/SldOverlay.tsx` | Sub-tab visibility logic (`actionDiagram` OR `vlOverlay.actionId`). | +| `frontend/src/hooks/useSldOverlay.ts` | `handleVlDoubleClick(actionId, vlName, forceTab?)` — forceTab path used by the pin double-click. | +| `frontend/src/App.tsx` | Wires `overflowPinsEnabled`, `overflowPins`, `handlePinPreview`, `handleOverflowPinDoubleClick`, `overviewFilters` flow. | + +--- + +## 10. Tests + +### Backend (`expert_backend/tests/`) + +- `test_overflow_overlay.py` — overlay injection idempotency, pin + click/dblclick listener presence, single-click debounce sentinel, + shared-glyph inlining, no duplicate `const SVG_NS`, pin layer + parented inside `g.graph`, action pins filter section appended + at the END of the sidebar, pin counter API present, **unsimulated + pins get dashed stroke + dim opacity**, **unsimulated dblclick + routes through `cs4g:overflow-unsimulated-pin-double-clicked`**, + **`pin.title` is consumed when present (fallback to actionId + otherwise)**. +- `test_overflow_geo_transform.py` — geo-mode rewrites including + the tapered-edge body / arrowhead split. +- `test_overflow_html_dim_logic.py` — section grouping, member- + ship-based dim model, edge-id alignment guard, `is_overload ⊂ + is_monitored` semantics. + +### Frontend (`frontend/src`) + +- `utils/svg/pinGlyph.test.ts` — palette parity vs design tokens, + star / cross paths, glyph variants. +- `utils/svg/overflowPinPayload.test.ts` — anchor resolution, + load-shed / curtail early-return, line-name vs VL fallback, + `nodeCandidates` carry-through, plus **`buildOverflowUnsimulatedPinPayload` + coverage**: simulated-id skip, null `metaIndex` short-circuit, + empty score table, dedupe by `actionId`, anchor-priority parity + with the simulated builder, multi-line `title` populated from + `buildUnsimulatedPinTitle`, fallback to `actionId` when no score + info is present. +- `components/VisualizationPanel.test.tsx` — toolbar toggle gating, + message-router behaviour, popover state. +- `hooks/useSldOverlay.test.ts` — `forceTab` override. +- `utils/specConformance.test.ts` — every wire-format event has its + required fields documented. +- `scripts/check_standalone_parity.py` — auto-generated standalone + inherits the same gestures. + +--- + +## 11. Reset / lifecycle invariants + +- **Apply Settings / Load Study** clears `result`, which clears + `overflowPinsAvailable`; an effect auto-disables + `overflowPinsEnabled` when availability goes false. +- **URL change on the iframe** (regenerate-overflow-graph or fresh + Step-2 PDF event) resets `overlayReady` (re-armed by the + iframe's `cs4g:overlay-ready` handshake) AND drops any open + popover. +- **Overflow layout cache** — backend + `_overflow_layout_cache` and `_overflow_layout_mode` are cleared + on `reset()` and on each fresh `run_analysis_step2`. +- **Filter state survives** the iframe re-load (it's owned by the + React parent); the parent re-broadcasts `cs4g:filters` after + every `overlay-ready`. + +--- + +## 12. Verification + +After any change in this feature: + +```bash +# Backend +pytest expert_backend/tests/test_overflow_overlay.py +pytest expert_backend/tests/test_overflow_geo_transform.py +pytest expert_backend/tests/test_overflow_html_dim_logic.py + +# Frontend +cd frontend +npm run test +npm run build:standalone + +# Parity gates +python scripts/check_standalone_parity.py +python scripts/check_invariants.py +python scripts/check_session_fidelity.py +python scripts/check_gesture_sequence.py + +# Sanity-check the injected JS still parses (no duplicate `const`, +# no f-string brace mishap). +python -c "from expert_backend.services.overflow_overlay import inject_overlay; print(len(inject_overlay('<html><body></body></html>')))" +``` + +Manual smoke (small grid): +1. Load `data/bare_env_small_grid_test`, pick contingency + `P.SAOL31RONCI`, run analysis to completion. +2. Open Overflow Analysis tab — verify three layer sections plus a + Select-all / Unselect-all row above them, dim-instead-of-hide + behaviour. +3. Toggle `📌 Pins` → action pins appear: + - line actions at edge midpoints, VL actions on substations, + - severity colours match the Action Overview palette, + - `📌 N` counter in the new "Action pins filters" section reflects + the count. +4. Click a pin once → feed scrolls to the card AND a popover opens + around the pin (no tab switch). +5. Double-click the same pin → popover closes, SLD overlay opens + with the action sub-tab selected and showing the post-action + variant for that substation. +6. Toggle a category chip in the iframe sidebar → Action Overview + pins and Action Feed cards filter in lock-step. +7. Tick **Show unsimulated** (parent overview OR iframe sidebar) → + dashed grey "?" pins appear on top of the simulated set; hovering + one reveals the multi-line tooltip (`<id> — not yet simulated …`, + `Type:`, `Score: … rank …`, `MW start: …`); double-clicking kicks + off the manual simulation flow (`overview_unsimulated_pin_simulated` + appears in the interaction log). +8. Untick **Show unsimulated** → the dashed pins disappear immediately + on both surfaces. +9. Switch to Geo layout → swapped-flow lines keep their tapered + body AND arrowhead. diff --git a/docs/features/save-results.md b/docs/features/save-results.md index 5bed5847..a010949d 100644 --- a/docs/features/save-results.md +++ b/docs/features/save-results.md @@ -91,6 +91,7 @@ Some pieces of in-memory state are deliberately ephemeral and will reset on relo - **Per-card edit state** (`cardEditMw`, `cardEditTap`): the raw, uncommitted values in the action card inputs. Only the committed, re-simulated result survives — persisted as `pst_details.tap_position` / `load_shedding_details[].shedded_mw` / `curtailment_details[].curtailed_mw`. A replay agent that wants to reproduce exact keystrokes must consume the `action_mw_resimulated` / `pst_tap_resimulated` events from `interaction_log.json` instead. - **Detached / tied visualization tab state** (PRs #84/#85): `detachedTabs` and the tied-tab registry are not in `session.json`. Detaching a tab does not change any analysis result, so reload intentionally starts with all tabs attached to the main window. A replay that needs to reproduce detach / reattach / tie / untie must stream the matching events from `interaction_log.json`. +- **Overflow Analysis tab UI state**: `overflowPinsEnabled` (📌 Pins toggle), `overflowLayoutMode` (Hierarchical / Geo), the iframe layer-toggle checkboxes, and the shared `overviewFilters` (category chips / threshold / action-type / Show unsimulated) are deliberately ephemeral. On reload the toggle starts OFF (auto-disabled while `result.actions` is empty during the brief restore window), the layout mode resets to the backend default, and the filters reset to `DEFAULT_ACTION_OVERVIEW_FILTERS`. A replay that needs to reproduce the operator's exact overflow-tab navigation must stream `overflow_pins_toggled` / `overflow_layout_mode_toggled` / `overflow_layer_toggled` / `overflow_select_all_layers` / `overflow_pin_clicked` / `overflow_pin_double_clicked` / `overflow_node_double_clicked` / `overview_filter_changed` / `overview_unsimulated_toggled` events from `interaction_log.json`. - **Live diagram rho ratios** are not threaded back from `session.overloads.n1_overloads_rho` into the `n1Diagram` object — they come from the re-fetched N-1 payload after `setSelectedBranch` fires. The persisted rho arrays are primarily useful for inspection of standalone `session.json` dumps and for replay agents that don't re-run the backend. --- @@ -135,8 +136,8 @@ Some pieces of in-memory state are deliberately ephemeral and will reset on relo }, "overflow_graph": { - "pdf_url": "/results/pdf/overflow_abc123.pdf", - "pdf_path": "/home/.../Overflow_Graph/overflow_abc123.pdf" + "pdf_url": "/results/pdf/overflow_abc123.html", + "pdf_path": "/home/.../Overflow_Graph/overflow_abc123.html" }, "analysis": { @@ -245,7 +246,7 @@ All settings active when the analysis was run. Matches the fields sent to `POST ### `overflow_graph` -URL and file path to the overflow graph PDF generated by `expert_op4grid_recommender`. `null` if analysis was not run. +URL and file path to the overflow graph artifact generated by `expert_op4grid_recommender`. With the default `VISUALIZATION_FORMAT="html"` this is the interactive alphaDeesp viewer (`<overflow>.html`) embedded by the Overflow Analysis tab via the `inject_overlay` middleware (see [`docs/features/interactive-overflow-analysis.md`](interactive-overflow-analysis.md)). Legacy sessions saved before the HTML switch carry `<overflow>.pdf`; reload globs both extensions and resolves the basename stored in `pdf_url`. The field names (`pdf_url` / `pdf_path`) are kept for backwards compatibility — they hold the actual file URL / absolute path regardless of extension. `null` if analysis was not run. ### `analysis` diff --git a/expert_backend/install_graphviz.py b/expert_backend/install_graphviz.py new file mode 100644 index 00000000..70b19ddb --- /dev/null +++ b/expert_backend/install_graphviz.py @@ -0,0 +1,139 @@ +"""Best-effort cross-platform installer for the Graphviz ``dot`` binary. + +Co-Study4Grid renders the overflow graph through ``pydot`` / +``networkx.drawing.nx_pydot``, which shells out to Graphviz's ``dot`` +executable. ``dot`` is a system-level binary and cannot be shipped as a +pure-Python wheel, so we provide this helper which is invoked from +``setup.py`` after a regular ``pip install``. + +The script is intentionally best-effort: if it can't find a supported +package manager or the install fails (no sudo, locked package DB, etc.) +it prints a clear, actionable message rather than aborting the whole +``pip install``. Users can also run it manually: + + python -m scripts.install_graphviz +""" +from __future__ import annotations + +import os +import platform +import shutil +import subprocess +import sys + +MANUAL_HINT = """\ +Could not auto-install Graphviz. Please install it manually so the +``dot`` binary is on PATH: + + Linux (Debian/Ubuntu): sudo apt-get install graphviz + Linux (RHEL/Fedora): sudo dnf install graphviz (or yum) + Linux (Arch): sudo pacman -S graphviz + Linux (Alpine): sudo apk add graphviz + macOS (Homebrew): brew install graphviz + macOS (MacPorts): sudo port install graphviz + Windows (Chocolatey): choco install graphviz + Windows (winget): winget install Graphviz.Graphviz + Windows (Scoop): scoop install graphviz + +After installation, verify with: dot -V +""" + + +def _run(cmd: list[str]) -> int: + print("+ " + " ".join(cmd), flush=True) + try: + return subprocess.call(cmd) + except FileNotFoundError: + return 127 + + +def _maybe_sudo(cmd: list[str]) -> list[str]: + if os.name == "nt": + return cmd + if os.geteuid() == 0: # type: ignore[attr-defined] + return cmd + if shutil.which("sudo"): + return ["sudo", "-n", *cmd] + return cmd + + +def _install_linux() -> bool: + candidates = [ + ("apt-get", ["apt-get", "install", "-y", "graphviz"]), + ("dnf", ["dnf", "install", "-y", "graphviz"]), + ("yum", ["yum", "install", "-y", "graphviz"]), + ("pacman", ["pacman", "-S", "--noconfirm", "graphviz"]), + ("apk", ["apk", "add", "--no-cache", "graphviz"]), + ("zypper", ["zypper", "install", "-y", "graphviz"]), + ] + for tool, cmd in candidates: + if shutil.which(tool): + if _run(_maybe_sudo(cmd)) == 0: + return True + return False + + +def _install_macos() -> bool: + if shutil.which("brew"): + if _run(["brew", "install", "graphviz"]) == 0: + return True + if shutil.which("port"): + if _run(_maybe_sudo(["port", "install", "graphviz"])) == 0: + return True + return False + + +def _install_windows() -> bool: + candidates = [ + ("choco", ["choco", "install", "graphviz", "-y", "--no-progress"]), + ("winget", ["winget", "install", "--id", "Graphviz.Graphviz", + "-e", "--accept-source-agreements", + "--accept-package-agreements"]), + ("scoop", ["scoop", "install", "graphviz"]), + ] + for tool, cmd in candidates: + if shutil.which(tool): + if _run(cmd) == 0: + return True + return False + + +def ensure_dot() -> bool: + """Ensure ``dot`` is on PATH; return True if it is (after install).""" + if shutil.which("dot"): + print("Graphviz 'dot' already installed; skipping.", flush=True) + return True + + system = platform.system() + print(f"Graphviz 'dot' not found on PATH; attempting install on {system}...", + flush=True) + + ok = False + if system == "Linux": + ok = _install_linux() + elif system == "Darwin": + ok = _install_macos() + elif system == "Windows": + ok = _install_windows() + else: + print(f"Unsupported platform: {system}", flush=True) + + # Re-check PATH (some installers update PATH only in new shells). + if shutil.which("dot"): + return True + + if not ok: + print(MANUAL_HINT, file=sys.stderr, flush=True) + else: + # The package manager succeeded but PATH may not be refreshed + # in this shell. Don't fail loudly. + print( + "Graphviz install command completed but 'dot' is not yet on PATH " + "in this shell. Open a new terminal and run `dot -V` to verify.", + flush=True, + ) + return False + + +if __name__ == "__main__": + sys.exit(0 if ensure_dot() else 1) diff --git a/expert_backend/main.py b/expert_backend/main.py index b29c3754..39a925b7 100644 --- a/expert_backend/main.py +++ b/expert_backend/main.py @@ -17,11 +17,11 @@ from fastapi import Body, FastAPI, HTTPException, Query, Request from fastapi.encoders import jsonable_encoder from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import Response -from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse, Response from pydantic import BaseModel from expert_backend.services.network_service import network_service +from expert_backend.services.overflow_overlay import inject_overlay from expert_backend.services.recommender_service import recommender_service logger = logging.getLogger(__name__) @@ -187,11 +187,52 @@ def _save_user_config(data: dict) -> None: allow_headers=["*"], ) -# Serve generated PDFs. -# We mount the directory where PDFs are generated. -# Since the directory name is 'Overflow_Graph', we ensure it exists. -os.makedirs("Overflow_Graph", exist_ok=True) -app.mount("/results/pdf", StaticFiles(directory="Overflow_Graph"), name="pdfs") +# Serve generated overflow-graph artifacts. The directory hosts a mix of +# legacy *.pdf files and the current default *.html interactive viewer +# (`config.VISUALIZATION_FORMAT="html"`). HTML responses are +# post-processed on the fly to splice the Co-Study4Grid action-pin +# overlay (`overflow_overlay.inject_overlay`) before `</body>` so the +# upstream alphaDeesp viewer stays a pure dependency we can upgrade. +# The route name `/results/pdf/...` is kept for backward compatibility +# with sessions saved under the legacy PDF era — see the field +# `pdf_url` in `session.json`. +_OVERFLOW_DIR = (Path(__file__).resolve().parent.parent / "Overflow_Graph").resolve() +_OVERFLOW_DIR.mkdir(parents=True, exist_ok=True) + + +@app.get("/results/pdf/{filename:path}") +def serve_overflow_artifact(filename: str) -> Response: + """Serve a generated overflow-graph artifact, post-processing HTML + to inject the action-pin overlay. + + Path-traversal guard: the resolved file must be inside + `_OVERFLOW_DIR`. Anything outside returns 404 (kept indistinct + from "not found" so the response shape doesn't leak info). + """ + candidate = (_OVERFLOW_DIR / filename).resolve() + try: + candidate.relative_to(_OVERFLOW_DIR) + except ValueError: + raise HTTPException(status_code=404, detail="Not found") + if not candidate.is_file(): + raise HTTPException(status_code=404, detail="Not found") + + suffix = candidate.suffix.lower() + if suffix == ".html": + try: + html = candidate.read_text(encoding="utf-8") + except OSError: + raise HTTPException(status_code=500, detail="Cannot read overflow file") + try: + html = inject_overlay(html) + except ValueError: + # Upstream HTML lacks </body> — serve raw so behaviour stays + # functional (the overlay is additive, not a hard requirement). + logger.warning("inject_overlay skipped: no </body> in %s", filename) + return Response(content=html, media_type="text/html; charset=utf-8") + + # PDF (legacy) and any other file type: stream as-is. + return FileResponse(str(candidate)) @app.get("/api/user-config") def get_user_config(): @@ -395,6 +436,20 @@ def get_nominal_voltages(): except Exception as e: raise HTTPException(status_code=400, detail=str(e)) + +@app.get("/api/voltage-level-substations") +def get_voltage_level_substations(): + """Return ``{vl_id: substation_id}`` for all voltage levels. + + Frontend uses this to anchor action-overview pins on the overflow + graph: pins reference voltage-level IDs (action targets) but the + overflow graph nodes are substation IDs — this map is the bridge. + """ + try: + return {"mapping": network_service.get_voltage_level_substations()} + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + @app.get("/api/pick-path") def pick_path(type: str = Query("file", enum=["file", "dir"])): """ diff --git a/expert_backend/services/analysis/overflow_geo_transform.py b/expert_backend/services/analysis/overflow_geo_transform.py index bb5bf7f4..dc28058b 100644 --- a/expert_backend/services/analysis/overflow_geo_transform.py +++ b/expert_backend/services/analysis/overflow_geo_transform.py @@ -336,6 +336,34 @@ def project(lx: float, ly: float) -> tuple[float, float]: # node outline rather than the node centre. ex, ey = _pull_back(sxn, syn, txn, tyn, node_gap) + # Tapered (style="tapered") edges represent SWAPPED-FLOW lines + # in the upstream graph builder. Graphviz emits them as TWO + # ``<polygon>`` children (a long ~21-point body + a 4-point + # arrowhead) and NO ``<path>``. The plain loop below would + # overwrite BOTH polygons with arrowhead shapes, leaving the + # edge with no visible body and no clearly directed arrow. + # Special-case the rewrite so the body becomes a tapered + # 4-vertex strip (wide at source, narrow at target — same + # visual cue as the hierarchical layout) and the second + # polygon stays the arrowhead. + is_tapered = g.get("data-attr-style") == "tapered" + polygons = [c for c in g.iter() + if _local_tag(c) == "polygon"] + if is_tapered and polygons: + _rewrite_tapered_edge( + polygons, sxn, syn, ex, ey, + stroke_w=path_stroke, + arrow_len=arrow_len, + arrow_half=arrow_half, + ) + # Edge labels still need to move to the midpoint of the + # new straight line. + for child in g.iter(): + if _local_tag(child) == "text": + child.set("x", _fmt((sxn + ex) / 2)) + child.set("y", _fmt((syn + ey) / 2)) + continue + for child in g.iter(): tag = _local_tag(child) if tag == "path": @@ -433,6 +461,74 @@ def _reanchor_graph_transform(svg_root, new_h: float) -> None: return +def _tapered_strip_points(sx: float, sy: float, ex: float, ey: float, + width_src: float, width_tgt: float) -> str: + """Return the ``points`` attribute for a 4-vertex polygon shaped + like a tapered strip — wide at (sx, sy), narrow at (ex, ey). + + Graphviz uses ``style="tapered"`` to mark **swapped-flow** edges + in the upstream overflow graph: the line itself is a filled + polygon whose width tapers from source to target. The + hierarchical layout's tapered polygon has ~20 points hugging the + spline; for the geo redraw a simple 4-vertex strip along the + straight (sx,sy)→(ex,ey) segment carries the same visual + semantic without any spline math. + """ + dx, dy = ex - sx, ey - sy + length = math.hypot(dx, dy) + if length == 0: + return f"{_fmt(sx)},{_fmt(sy)} {_fmt(ex)},{_fmt(ey)}" + ux, uy = dx / length, dy / length + # Perpendicular unit vector (rotated 90° CCW). + px, py = -uy, ux + hs = width_src / 2.0 + ht = width_tgt / 2.0 + return ( + f"{_fmt(sx + px * hs)},{_fmt(sy + py * hs)} " + f"{_fmt(ex + px * ht)},{_fmt(ey + py * ht)} " + f"{_fmt(ex - px * ht)},{_fmt(ey - py * ht)} " + f"{_fmt(sx - px * hs)},{_fmt(sy - py * hs)}" + ) + + +def _rewrite_tapered_edge(polygons: list, sx: float, sy: float, + ex: float, ey: float, + stroke_w: float, + arrow_len: float, + arrow_half: float) -> None: + """Rewrite a tapered edge's polygons in place. + + The hierarchical SVG emits two polygons per tapered edge: + 1. a long filled body (the tapered strip itself); + 2. a short triangular arrowhead at the target. + + Both are rewritten — the body becomes a 4-vertex tapered strip + along the new straight segment, and the arrowhead retains its + triangle shape pointing at the target node. ``stroke-width`` is + zeroed because graphviz inherits a heavy stroke onto these + polygons that would otherwise turn the tapered fill into a chunky + blob in the geo viewBox. + """ + body = polygons[0] + body.set( + "points", + _tapered_strip_points( + sx, sy, ex, ey, + width_src=max(stroke_w, 4.0), + width_tgt=max(stroke_w * 0.25, 1.0), + ), + ) + body.set("stroke-width", "0") + if len(polygons) >= 2: + arrow = polygons[1] + arrow.set( + "points", + _arrowhead_points(sx, sy, ex, ey, + arrow_len=arrow_len, arrow_half=arrow_half), + ) + arrow.set("stroke-width", "0") + + def _arrowhead_points(sx: float, sy: float, ex: float, ey: float, arrow_len: float = _ARROW_LEN, arrow_half: float = _ARROW_HALF) -> str: diff --git a/expert_backend/services/network_service.py b/expert_backend/services/network_service.py index 69395f04..5a97d950 100644 --- a/expert_backend/services/network_service.py +++ b/expert_backend/services/network_service.py @@ -171,6 +171,34 @@ def get_voltage_levels(self): return sorted(voltage_levels.index.tolist()) return [] + def get_voltage_level_substations(self): + """Return ``{vl_id: substation_id}`` for every voltage level. + + Used by the frontend to anchor action-overview pins on the + overflow graph: the overflow graph nodes are pypowsybl + substation IDs, while action data references voltage-level IDs + — this map closes that gap. Returns an empty dict if the + ``substation_id`` column is missing (pure-VL networks without + substations). + """ + if not self.network: + raise ValueError("Network not loaded") + + # `substation_id` ships in the default attribute set so a narrow + # query is enough; we don't pull `name` / `nominal_v` here. + voltage_levels = self.network.get_voltage_levels(attributes=['substation_id']) + if voltage_levels is None or voltage_levels.empty: + return {} + if 'substation_id' not in voltage_levels.columns: + return {} + sub_ids = voltage_levels['substation_id'].tolist() + idx = voltage_levels.index.tolist() + return { + vl_id: str(sub_id) + for vl_id, sub_id in zip(idx, sub_ids) + if sub_id is not None and str(sub_id) != 'nan' + } + def get_voltage_level_names(self): """Return {vl_id: display_name} for all voltage levels.""" if not self.network: diff --git a/expert_backend/services/overflow_overlay.py b/expert_backend/services/overflow_overlay.py new file mode 100644 index 00000000..27e87c50 --- /dev/null +++ b/expert_backend/services/overflow_overlay.py @@ -0,0 +1,711 @@ +# 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. + +"""Co-Study4Grid–specific overlay injected into the upstream alphaDeesp +interactive overflow-graph HTML. + +The overlay adds **action overview pins** on top of the overflow graph. +The upstream HTML viewer (rendered by `alphaDeesp.core.interactive_html`) +already provides pan/zoom, layer toggles and click-to-highlight; this +module is a thin add-on that lives ONLY in Co-Study4Grid because it +depends on action-recommendation data that the upstream library has no +knowledge of. + +Wire-format (parent React app → iframe via `postMessage`):: + + { + "type": "cs4g:pins", + "visible": true | false, + "pins": [ + { + "actionId": "f344b…", + "substation": "BEON", # primary anchor + "nodeCandidates": ["VL_1", …], # ordered fallback anchors + "label": "90.1%", + "severity": "green" | "orange" | "red" | "grey", + "isSelected": true, + "isRejected": false + }, … + ] + } + +The iframe answers back with:: + + { "type": "cs4g:pin-clicked", "actionId": "<id>" } + { "type": "cs4g:pin-double-clicked", "actionId": "<id>", "substation": "<name>" } + +The pin glyph itself is rendered by the SHARED ``pinGlyph.js`` module +(``frontend/src/utils/svg/pinGlyph.js``), inlined verbatim into the +overlay so the iframe's pins are visually identical to the React +Action Overview pins. Single-source palette + single-source SVG path. + +The injection is a pure string substitution: we splice ``<style>`` + +``<script>`` blocks immediately before the closing ``</body>`` tag. +Keeping the contract pure-text rather than DOM-aware lets us +unit-test the injector without an HTML parser dependency. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +# Path to the shared JS pin-glyph module. Same file is consumed by the +# React/TS bundle (`actionPinRender.ts`) via a normal ES import; here +# we read it as text and inline the body into the iframe's <script>. +_PIN_GLYPH_JS_PATH = ( + Path(__file__).resolve().parent.parent.parent + / "frontend" / "src" / "utils" / "svg" / "pinGlyph.js" +) + + +def _load_pin_glyph_js() -> str: + """Return the contents of ``pinGlyph.js`` with ESM ``export`` + keywords stripped so the body works as a plain inline script. + + The file is intentionally written without ``import`` statements + (see its module-level comment) so the only ESM artefact is + ``export const`` / ``export function``. We strip those prefixes + at runtime — the resulting code is plain ES2015 that runs both + inside a Vite bundle and inside the iframe overlay. + """ + text = _PIN_GLYPH_JS_PATH.read_text(encoding="utf-8") + # ``\bexport\s+(const|function|let|var)\s+`` keeps the keyword + # after ``export`` intact; the module-scope binding is preserved. + return re.sub(r"\bexport\s+(const|function|let|var)\s+", r"\1 ", text) + + +def _build_overlay_block() -> str: + """Return the literal ``<style>…</style><script>…</script>`` block to + inject before the iframe's ``</body>``. + + Single function (rather than a top-level constant) so we can + re-read ``pinGlyph.js`` on every call — useful during dev when the + shared module is being iterated on. In production the FastAPI + process is restarted between deployments anyway. + """ + pin_glyph_js = _load_pin_glyph_js() + + # Note the doubled ``{{ }}`` braces inside the f-string — they + # encode literal ``{`` / ``}`` so the JS object literals survive + # f-string formatting unchanged. + return f""" +<style id="cs4g-overlay-style"> + /* Pin overlay — visual contract is owned by pinGlyph.js below. + This file only adds interactive affordances (cursor / opacity). */ + .cs4g-pin {{ pointer-events: auto; cursor: pointer; }} + + /* ``ACTION PINS FILTERS`` section appended at the BOTTOM of the + iframe's sidebar when the pin overlay is visible. Mirrors the + chip-row filters of the React Action Overview tab so operators + get identical filtering on both surfaces. The whole panel is + wrapped in its own bordered container so it visually reads as + a distinct, action-pin-scoped widget separate from the + graph-layer toggles above. */ + #cs4g-filters {{ + display: none; margin: 14px 0 0; padding: 8px 0 0; + border-top: 1px solid var(--border, #d1d5db); + }} + #cs4g-filters.visible {{ display: block; }} + #cs4g-filters .filters-header {{ + display: flex; align-items: center; gap: 6px; + font-size: 12px; text-transform: uppercase; + color: var(--muted, #6b7280); margin: 0 0 6px; + letter-spacing: 0.04em; + }} + #cs4g-filters .filters-counter {{ + margin-left: auto; display: inline-flex; align-items: center; + gap: 3px; font-weight: 600; color: #111; + font-variant-numeric: tabular-nums; text-transform: none; + letter-spacing: 0; + }} + #cs4g-filters .row {{ + display: flex; align-items: center; gap: 6px; + margin-bottom: 4px; font-size: 12px; flex-wrap: wrap; + }} + #cs4g-filters .swatch {{ + width: 10px; height: 10px; border-radius: 2px; + border: 1px solid #ccc; display: inline-block; flex-shrink: 0; + }} + #cs4g-filters .chip {{ + padding: 2px 6px; border-radius: 4px; + border: 1px solid #ccc; cursor: pointer; user-select: none; + background: #fff; font-size: 11px; + }} + #cs4g-filters .chip[aria-pressed="true"] {{ + background: #1d4ed8; color: #fff; border-color: #1d4ed8; + }} + #cs4g-filters .threshold input {{ + width: 48px; padding: 1px 4px; font-size: 11px; + border: 1px solid #ccc; border-radius: 3px; + text-align: right; font-variant-numeric: tabular-nums; + }} + #cs4g-filters .pill {{ + padding: 1px 6px; border-radius: 3px; + background: transparent; border: 1px solid transparent; + cursor: pointer; user-select: none; font-size: 11px; + color: #1d4ed8; text-decoration: underline; + }} + #cs4g-filters label.toggle {{ + display: inline-flex; align-items: center; gap: 4px; + cursor: pointer; user-select: none; + }} +</style> +<script id="cs4g-overlay-script"> +(function() {{ + // ---- Shared glyph builder (inlined from frontend/src/utils/svg/pinGlyph.js) + // The shared module defines its own private ``const SVG_NS`` at the + // top of its body — we re-use that here rather than re-declaring it + // (a duplicate ``const`` would be a parse error and silently disable + // the entire overlay script). +{pin_glyph_js} + // ---- /shared + + // Last-known pin payload — re-rendered whenever pin state changes. + let lastPins = []; + let lastVisible = false; + + // Last-known Action-Overview filter state (sent by the parent + // every time it changes). The iframe never reads filter state + // anywhere else — it just renders chips and posts user changes + // back. The actual pin filtering happens in the React parent + // (``buildOverflowPinPayload``). + const PIN_COLORS = {{ + green: '#28a745', orange: '#f0ad4e', + red: '#dc3545', grey: '#9ca3af', + }}; + const ACTION_TYPE_TOKENS = [ + 'all', 'disco', 'reco', 'ls', 'rc', 'open', 'close', 'pst', + ]; + let filterState = {{ + categories: {{ green: true, orange: true, red: true, grey: true }}, + threshold: 1.5, + showUnsimulated: false, + actionType: 'all', + }}; + + function postFilters() {{ + window.parent.postMessage({{ + type: 'cs4g:overflow-filter-changed', + filters: filterState, + }}, '*'); + }} + + function buildFiltersPanel() {{ + const sidebar = document.getElementById('sidebar'); + if (!sidebar) return null; + let panel = document.getElementById('cs4g-filters'); + if (panel) return panel; + panel = document.createElement('section'); + panel.id = 'cs4g-filters'; + panel.innerHTML = + '<div class="filters-header">' + + '<span aria-hidden>\U0001F4CC</span>' + + '<span>Action pins filters</span>' + + '<span class="filters-counter" data-counter title="Pins currently rendered on the overflow graph">' + + '<span aria-hidden>\U0001F4CC</span>' + + '<span data-counter-value>0</span>' + + '</span>' + + '</div>' + + '<div class="row" data-filter-row="categories"></div>' + + '<div class="row" data-filter-row="category-bulk">' + + '<button type="button" class="pill" data-action="select-all">All</button>' + + '<button type="button" class="pill" data-action="select-none">None</button>' + + '</div>' + + '<div class="row threshold">' + + '<span style="color:#6b7280">Max loading</span>' + + '<input type="number" min="0" max="300" step="1" data-filter="threshold" />' + + '<span style="color:#6b7280">%</span>' + + '</div>' + + '<div class="row">' + + '<label class="toggle">' + + '<input type="checkbox" data-filter="show-unsimulated" />' + + '<span style="color:#6b7280">Show unsimulated</span>' + + '</label>' + + '</div>' + + '<div class="row" data-filter-row="action-types"></div>'; + // Append to the END of the sidebar so the filter chips read as + // a distinct "pin-scoped" widget that follows the existing + // graph-layer toggles, rather than competing for top-of-list + // attention before the operator has even enabled pins. + sidebar.appendChild(panel); + // Categories row (Solves overload / Low margin / Still over… / Div). + const catRow = panel.querySelector('[data-filter-row="categories"]'); + const catSpecs = [ + {{ key: 'green', label: 'Solves overload' }}, + {{ key: 'orange', label: 'Low margin' }}, + {{ key: 'red', label: 'Still overloaded' }}, + {{ key: 'grey', label: 'Divergent / islanded' }}, + ]; + for (const spec of catSpecs) {{ + const chip = document.createElement('span'); + chip.className = 'chip'; + chip.setAttribute('data-category', spec.key); + chip.setAttribute('role', 'button'); + chip.setAttribute('tabindex', '0'); + chip.innerHTML = + '<span class="swatch" style="background:' + PIN_COLORS[spec.key] + '"></span> ' + + spec.label; + chip.addEventListener('click', function() {{ + filterState = {{ + ...filterState, + categories: {{ + ...filterState.categories, + [spec.key]: !filterState.categories[spec.key], + }}, + }}; + renderFilterState(); + postFilters(); + }}); + catRow.appendChild(chip); + }} + // Bulk select-all / select-none. + const bulkRow = panel.querySelector('[data-filter-row="category-bulk"]'); + bulkRow.querySelector('[data-action="select-all"]').addEventListener('click', function() {{ + filterState = {{ + ...filterState, + categories: {{ green: true, orange: true, red: true, grey: true }}, + }}; + renderFilterState(); postFilters(); + }}); + bulkRow.querySelector('[data-action="select-none"]').addEventListener('click', function() {{ + filterState = {{ + ...filterState, + categories: {{ green: false, orange: false, red: false, grey: false }}, + }}; + renderFilterState(); postFilters(); + }}); + // Threshold spinner. + const thr = panel.querySelector('input[data-filter="threshold"]'); + thr.addEventListener('change', function(ev) {{ + const raw = parseInt(ev.target.value, 10); + if (!Number.isFinite(raw)) return; + const clamped = Math.max(0, Math.min(300, raw)); + filterState = {{ ...filterState, threshold: clamped / 100 }}; + renderFilterState(); postFilters(); + }}); + // Show-unsimulated checkbox. + const showU = panel.querySelector('input[data-filter="show-unsimulated"]'); + showU.addEventListener('change', function(ev) {{ + filterState = {{ ...filterState, showUnsimulated: !!ev.target.checked }}; + renderFilterState(); postFilters(); + }}); + // Action-type chips. + const typeRow = panel.querySelector('[data-filter-row="action-types"]'); + for (const tok of ACTION_TYPE_TOKENS) {{ + const chip = document.createElement('span'); + chip.className = 'chip'; + chip.setAttribute('data-action-type', tok); + chip.setAttribute('role', 'button'); + chip.setAttribute('tabindex', '0'); + chip.textContent = tok.toUpperCase(); + chip.addEventListener('click', function() {{ + filterState = {{ ...filterState, actionType: tok }}; + renderFilterState(); postFilters(); + }}); + typeRow.appendChild(chip); + }} + return panel; + }} + + function renderFilterState() {{ + const panel = document.getElementById('cs4g-filters'); + if (!panel) return; + panel.querySelectorAll('[data-category]').forEach(function(el) {{ + const k = el.getAttribute('data-category'); + el.setAttribute('aria-pressed', + filterState.categories[k] ? 'true' : 'false'); + }}); + panel.querySelectorAll('[data-action-type]').forEach(function(el) {{ + const k = el.getAttribute('data-action-type'); + el.setAttribute('aria-pressed', + filterState.actionType === k ? 'true' : 'false'); + }}); + const thr = panel.querySelector('input[data-filter="threshold"]'); + if (thr) thr.value = String(Math.round(filterState.threshold * 100)); + const showU = panel.querySelector('input[data-filter="show-unsimulated"]'); + if (showU) showU.checked = !!filterState.showUnsimulated; + }} + + function updatePinCounter(count) {{ + const panel = document.getElementById('cs4g-filters'); + if (!panel) return; + const v = panel.querySelector('[data-counter-value]'); + if (v) v.textContent = String(count); + }} + + function getSvg() {{ + return document.querySelector('#stage svg'); + }} + function getRoot(svg) {{ + if (!svg) return null; + return svg.querySelector('g.graph') || svg.querySelector('g') || svg; + }} + + // Pin layer lives INSIDE the ``g.graph`` group so it inherits the + // graphviz transform (graphviz emits ``transform="scale(…) translate(…)"`` + // on ``g.graph``). Without this, ``getBBox()`` coordinates from a + // ``.node`` would be in the graph's local space but pin coordinates + // would render against the outer SVG's viewport, leaving pins + // floating above / outside the actual graph. + function ensureLayer() {{ + const root = getRoot(getSvg()); + if (!root) return null; + let layer = root.querySelector('g.cs4g-pin-layer'); + if (!layer) {{ + layer = document.createElementNS(SVG_NS, 'g'); + layer.setAttribute('class', 'cs4g-pin-layer'); + // Append last so pins draw on top of nodes / edges. + root.appendChild(layer); + }} + return layer; + }} + + // Project a point in a node's local space into the layer's space + // by composing CTMs. Returns null if either CTM is unavailable. + function projectToLayer(node, layer, lx, ly) {{ + if (!node.getCTM || !layer.getCTM) return null; + const nodeCTM = node.getCTM(); + const layerCTM = layer.getCTM(); + if (!nodeCTM || !layerCTM) return null; + const m = layerCTM.inverse().multiply(nodeCTM); + return {{ + x: m.a * lx + m.c * ly + m.e, + y: m.b * lx + m.d * ly + m.f, + }}; + }} + + // Anchor a pin at the midpoint of the edge whose ``data-attr-name`` + // matches one of the candidate line names. This mirrors the Action + // Overview NAD pin's edge-midpoint anchor for branch actions — + // disco / reco / max_rho_line all land on the line's middle, not + // on either endpoint. + function edgeCentre(lineNames, layer) {{ + const root = getRoot(getSvg()); + if (!root || !lineNames || !lineNames.length) return null; + for (const name of lineNames) {{ + if (typeof name !== 'string' || !name) continue; + const safe = (window.CSS && CSS.escape) ? CSS.escape(name) : name.replace(/(["\\\\])/g, '\\\\$1'); + const edge = root.querySelector('.edge[data-attr-name="' + safe + '"]'); + if (!edge) continue; + const src = edge.getAttribute('data-source'); + const tgt = edge.getAttribute('data-target'); + if (!src || !tgt) continue; + const safeSrc = (window.CSS && CSS.escape) ? CSS.escape(src) : src; + const safeTgt = (window.CSS && CSS.escape) ? CSS.escape(tgt) : tgt; + const srcNode = root.querySelector('.node[data-name="' + safeSrc + '"]'); + const tgtNode = root.querySelector('.node[data-name="' + safeTgt + '"]'); + if (!srcNode || !tgtNode) continue; + try {{ + const sb = srcNode.getBBox(); + const tb = tgtNode.getBBox(); + const sp = projectToLayer(srcNode, layer, + sb.x + sb.width / 2, sb.y + sb.height / 2); + const tp = projectToLayer(tgtNode, layer, + tb.x + tb.width / 2, tb.y + tb.height / 2); + if (!sp || !tp) continue; + return {{ + x: (sp.x + tp.x) / 2, + y: (sp.y + tp.y) / 2, + ref: sb, + }}; + }} catch (e) {{ + continue; + }} + }} + return null; + }} + + // Anchor a pin on the centre of the matching node group. ``names`` + // is an ordered list of candidate ``data-name`` values — the + // substation first, then any voltage-level ids the action targets, + // so we still find an anchor when the recommender emits a VL-keyed + // overflow graph rather than a substation-keyed one. + // + // ``getBBox()`` returns the node's local-space bounding box — + // BEFORE the node's own ``transform="translate(...)"`` is applied. + // Graphviz emits per-node translate transforms, so we must compose + // the node's CTM with the inverse of the layer's CTM to project + // the bbox centre into the layer's coordinate system. Otherwise + // every pin would land at (0, 0) of the graph and stack on top of + // each other behind the background polygon. + function nodeCentre(names, layer) {{ + const root = getRoot(getSvg()); + if (!root || !names || !names.length) return null; + for (const name of names) {{ + if (typeof name !== 'string' || !name) continue; + const safe = (window.CSS && CSS.escape) ? CSS.escape(name) : name.replace(/(["\\\\])/g, '\\\\$1'); + const node = root.querySelector('.node[data-name="' + safe + '"]'); + if (!node) continue; + try {{ + const bb = node.getBBox(); + // Pin tip lands at the TOP of the node so the body floats + // above instead of overlapping the label. + const projected = projectToLayer( + node, layer, + bb.x + bb.width / 2, bb.y, + ); + if (projected) {{ + return {{ x: projected.x, y: projected.y, ref: bb }}; + }} + return {{ x: bb.x + bb.width / 2, y: bb.y, ref: bb }}; + }} catch (e) {{ + continue; + }} + }} + return null; + }} + + // Pick a global pin radius by sampling the FIRST visible node. + // The bbox is in node-local space; we scale it through the layer's + // CTM so the radius is expressed in the same coordinate system as + // the centre we just computed. Cached because every node in a + // graphviz dot output uses the same shape size class. + let cachedR = null; + function basePinRadius(layer) {{ + if (cachedR !== null) return cachedR; + const root = getRoot(getSvg()); + if (!root) return 14; + const node = root.querySelector('.node'); + if (!node) return 14; + try {{ + const bb = node.getBBox(); + let r = Math.min(bb.width, bb.height) * 0.55; + if (layer && node.getCTM && layer.getCTM) {{ + const nodeCTM = node.getCTM(); + const layerCTM = layer.getCTM(); + if (nodeCTM && layerCTM) {{ + const m = layerCTM.inverse().multiply(nodeCTM); + // Uniform scale factor (graphviz transforms are + // axis-aligned scale + translate, so |a| ~= |d|). + const scale = Math.sqrt(Math.abs(m.a * m.d - m.b * m.c)) || 1; + r = r * scale; + }} + }} + cachedR = Math.max(8, r); + }} catch (e) {{ + cachedR = 14; + }} + return cachedR; + }} + + // Delay used to distinguish a single click from the first click of + // a double click. Mirrors PIN_SINGLE_CLICK_DELAY_MS in + // ``actionPinRender.ts`` so both pin layers feel identical. + const SINGLE_CLICK_DELAY_MS = 250; + + function buildPin(pin, centre, layer) {{ + const r = basePinRadius(layer); + const isUnsim = !!pin.unsimulated; + // Reuse the SHARED action-overview glyph builder — same SVG + // shape, palette, chrome and status symbols. Un-simulated pins + // ride the ``dimmed`` flag so the body uses the dimmed palette, + // matching the Action Overview's renderUnsimulatedPin. + const g = createPinGlyph(document, {{ + severity: pin.severity, + label: pin.label, + // Use the explicit ``title`` field when the parent provided + // one (e.g. multi-line score / rank / MW-start summary for + // un-simulated pins) so the native <title> tooltip carries + // the same content as the Action Overview NAD pin. Falls + // back to the action id for simulated pins. + title: (typeof pin.title === 'string' && pin.title) + ? pin.title + : pin.actionId, + actionId: pin.actionId, + isSelected: !!pin.isSelected, + isRejected: !!pin.isRejected, + dimmed: isUnsim, + r: r, + }}); + g.setAttribute('transform', 'translate(' + centre.x + ' ' + centre.y + ')'); + if (isUnsim) {{ + // Match the Action-Overview unsimulated pin's visual: + // dashed outline + reduced opacity + ``data-unsimulated`` + // sentinel so unit tests can disambiguate the two layers. + g.setAttribute('data-unsimulated', 'true'); + g.setAttribute('opacity', '0.5'); + const path = g.querySelector('path'); + if (path) {{ + path.setAttribute('stroke', '#1f2937'); + path.setAttribute('stroke-width', String(r * 0.08)); + path.setAttribute('stroke-dasharray', + String(r * 0.35) + ' ' + String(r * 0.2)); + }} + }} + + // Single click vs double click are debounced — exactly the same + // pattern used by the Action Overview pins. Single click posts + // ``cs4g:pin-clicked`` (parent focuses the feed card) AFTER the + // 250 ms timer; double click cancels the timer and posts + // ``cs4g:pin-double-clicked`` (parent opens the SLD on its + // action sub-tab). Without this, the click handler would always + // run before dblclick, switching the React tab and preventing + // the SLD overlay from materialising on the right tab. + let clickTimer = null; + g.addEventListener('click', function(ev) {{ + ev.stopPropagation(); + if (clickTimer !== null) return; + // Capture the pin's screen-pixel rect at click time so the + // parent React app can position the floating action-card + // popover next to the pin (same UX as the Action Overview + // pin). Coordinates are iframe-screen pixels; the parent + // adds the iframe's own offset to convert to parent-screen. + const rect = g.getBoundingClientRect(); + const rectPayload = {{ + left: rect.left, top: rect.top, + width: rect.width, height: rect.height, + }}; + clickTimer = setTimeout(function() {{ + clickTimer = null; + window.parent.postMessage({{ + type: 'cs4g:pin-clicked', + actionId: pin.actionId, + screenRect: rectPayload, + }}, '*'); + }}, SINGLE_CLICK_DELAY_MS); + }}); + g.addEventListener('dblclick', function(ev) {{ + ev.stopPropagation(); + ev.preventDefault(); + if (clickTimer !== null) {{ + clearTimeout(clickTimer); + clickTimer = null; + }} + // Un-simulated pins kick off a manual simulation rather than + // open the SLD overlay. Mirrors the Action Overview's + // ``onUnsimulatedPinDoubleClick`` path. + if (isUnsim) {{ + window.parent.postMessage({{ + type: 'cs4g:overflow-unsimulated-pin-double-clicked', + actionId: pin.actionId, + }}, '*'); + }} else {{ + window.parent.postMessage({{ + type: 'cs4g:pin-double-clicked', + actionId: pin.actionId, + substation: pin.substation + }}, '*'); + }} + }}); + return g; + }} + + function render() {{ + const layer = ensureLayer(); + if (!layer) return; + while (layer.firstChild) layer.removeChild(layer.firstChild); + cachedR = null; // Re-sample radius after every render. + let drawn = 0; + if (lastVisible && lastPins.length) {{ + for (const pin of lastPins) {{ + // Branch actions (disco / reco / max_rho_line) anchor at + // the midpoint of the matching edge — same rule the Action + // Overview pins follow. Falls back to a single-node anchor + // when the edge isn't drawn (e.g. a line filtered out by + // the recommender's keep_overloads_components). + let centre = null; + if (Array.isArray(pin.lineNames) && pin.lineNames.length) {{ + centre = edgeCentre(pin.lineNames, layer); + }} + if (!centre) {{ + const candidates = [pin.substation].concat( + Array.isArray(pin.nodeCandidates) ? pin.nodeCandidates : [] + ); + centre = nodeCentre(candidates, layer); + }} + if (!centre) continue; + layer.appendChild(buildPin(pin, centre, layer)); + drawn += 1; + }} + }} + updatePinCounter(drawn); + }} + + function syncFilterPanelVisibility() {{ + const panel = buildFiltersPanel(); + if (!panel) return; + if (lastVisible) panel.classList.add('visible'); + else panel.classList.remove('visible'); + }} + + window.addEventListener('message', function(ev) {{ + const msg = ev && ev.data; + if (!msg || typeof msg !== 'object') return; + if (msg.type === 'cs4g:filters' && msg.filters && typeof msg.filters === 'object') {{ + // Parent broadcasts the live overviewFilters value. We only + // overwrite our local copy — the parent is the single source + // of truth and re-emits this whenever its state changes. + filterState = {{ + categories: {{ + green: !!msg.filters.categories?.green, + orange: !!msg.filters.categories?.orange, + red: !!msg.filters.categories?.red, + grey: !!msg.filters.categories?.grey, + }}, + threshold: typeof msg.filters.threshold === 'number' + ? msg.filters.threshold : 1.5, + showUnsimulated: !!msg.filters.showUnsimulated, + actionType: typeof msg.filters.actionType === 'string' + ? msg.filters.actionType : 'all', + }}; + buildFiltersPanel(); + renderFilterState(); + return; + }} + if (msg.type !== 'cs4g:pins') return; + lastVisible = !!msg.visible; + if (Array.isArray(msg.pins)) lastPins = msg.pins; + syncFilterPanelVisibility(); + render(); + }}); + + // Notify the parent that the overlay is wired up so it can post + // pins immediately rather than waiting for a poll. + window.addEventListener('load', function() {{ + window.parent.postMessage({{ type: 'cs4g:overlay-ready' }}, '*'); + }}); +}})(); +</script> +""" + + +def inject_overlay(html: str) -> str: + """Splice the Co-Study4Grid overlay block before the closing + ``</body>`` of an upstream alphaDeesp overflow-graph HTML. + + Idempotent: re-injection on an already-augmented file replaces the + previous block (matched by its ``id`` attributes) so successive + cache fetches do not accumulate duplicate listeners. + + Raises ``ValueError`` if the input lacks a ``</body>`` tag. + """ + closing = "</body>" + if closing not in html: + raise ValueError( + "Overflow HTML has no </body> tag — refusing to inject overlay." + ) + + # Strip any prior injection so a re-fetch of the same file doesn't + # accumulate duplicate event listeners (cheap string match — both + # blocks carry deterministic ids). + for marker in ('<style id="cs4g-overlay-style">', '<script id="cs4g-overlay-script">'): + start = html.find(marker) + if start == -1: + continue + # Find the closing tag that matches the marker's element. + end_tag = "</style>" if marker.startswith("<style") else "</script>" + end = html.find(end_tag, start) + if end == -1: + continue + html = html[:start] + html[end + len(end_tag):] + + block = _build_overlay_block() + return html.replace(closing, block + closing, 1) + + +__all__ = ["inject_overlay"] diff --git a/expert_backend/tests/test_overflow_geo_transform.py b/expert_backend/tests/test_overflow_geo_transform.py index 06d9744d..88cd6e29 100644 --- a/expert_backend/tests/test_overflow_geo_transform.py +++ b/expert_backend/tests/test_overflow_geo_transform.py @@ -419,3 +419,156 @@ def test_drops_graphviz_background_polygon(): assert 'fill="white" stroke="transparent"' in html out = transform_html(html, {"A": (0, 0), "B": (100, 100)}) assert 'fill="white" stroke="transparent"' not in out + + +# --------------------------------------------------------------------- +# Tapered (swapped-flow) edges — must keep both body and arrowhead +# visible after the geo transform. +# --------------------------------------------------------------------- + +_TAPERED_HTML = """\ +<!doctype html> +<html><head></head><body> +<svg width="400pt" height="400pt" viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg"> + <g class="graph"> + <g id="node1" class="node" data-name="A" data-attr-pos="10,20"> + <title>A + + + + B + + + + A->B + + + -25 + + + + +""" + + +def test_tapered_edge_keeps_arrowhead_polygon(): + """Tapered edges (swapped-flow) must still have a recognisable + arrowhead at the target end after the geo redraw. Before the + fix the body polygon and the arrowhead polygon were both + rewritten to a 3-vertex triangle, leaving the edge with no + visible body and a stacked arrowhead.""" + out = transform_html(_TAPERED_HTML, {"A": (0, 0), "B": (100, 100)}) + polygons = re.findall( + r']*points="([^"]+)"', out, + ) + assert len(polygons) >= 2, ( + "tapered edge must retain BOTH polygons (body + arrowhead)" + ) + # The first polygon (body) must be a tapered strip with 4 + # vertices; the second (arrowhead) must be the 3-point triangle + # produced by ``_arrowhead_points``. + body_pts = polygons[0].split() + arrow_pts = polygons[1].split() + assert len(body_pts) == 4, ( + f"tapered body should have 4 vertices, got {len(body_pts)}: {body_pts}" + ) + assert len(arrow_pts) == 3, ( + f"arrowhead should have 3 vertices, got {len(arrow_pts)}: {arrow_pts}" + ) + + +def test_tapered_edge_body_does_not_collapse_to_a_triangle(): + """Regression: before the fix, the long ~21-vertex tapered body + was overwritten by ``_arrowhead_points`` and became a 3-vertex + triangle stacked on top of the actual arrowhead — visually the + line was gone. Asserting the body stays at 4 vertices guards + that path.""" + out = transform_html(_TAPERED_HTML, {"A": (0, 0), "B": (100, 100)}) + # Pull the body polygon (the one with stroke="transparent" or 0). + m = re.search( + r']*stroke="transparent"[^>]*points="([^"]+)"', + out, + ) + assert m, "tapered body polygon (stroke=transparent) lost" + pts = m.group(1).split() + assert len(pts) == 4 + + +def test_mixed_tapered_and_regular_edges_in_same_svg(): + """A graph with BOTH a regular path-based edge AND a tapered + polygon-only edge must transform each correctly: + - the regular edge keeps a single 3-vertex arrowhead polygon + AND a path with two end-points. + - the tapered edge keeps two polygons: a 4-vertex strip body + and a 3-vertex arrowhead. + Mixed-mode coverage is the only way to spot a regression where + a future refactor accidentally applies the tapered branch to a + regular edge or vice-versa.""" + html = HIERARCHICAL_HTML.replace( + '', + '', + ).replace('', _TAPERED_HTML.split('')[1].split('')[0]) + # The replacement above shoehorns the tapered edge from the + # _TAPERED_HTML fixture INTO the regular fixture; ensure the + # underlying SVG still contains BOTH edges before transforming. + layout = {"A": (0, 0), "B": (100, 100)} + # We can't simply concat both; build a proper mixed SVG instead. + mixed = ( + '' + '' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + '' + '' + ) + out = transform_html(mixed, layout) + # Regular edge: count its polygons (must still be 1) and verify + # path is rewritten to a straight line. + reg_m = re.search( + r']*>(.*?)', + out, re.DOTALL, + ) + assert reg_m, "regular edge group missing" + regular_block = reg_m.group(1) + regular_polys = re.findall(r']*points="([^"]+)"', regular_block) + assert len(regular_polys) == 1, ( + f"regular edge should have exactly 1 arrowhead polygon, got {len(regular_polys)}" + ) + assert len(regular_polys[0].split()) == 3, "regular arrowhead must be a 3-vertex triangle" + assert re.search(r']*d="M[^"]+L[^"]+"', regular_block), ( + "regular edge path must be rewritten as a single ``M src L tgt`` segment" + ) + # Tapered edge: must still have 2 polygons (body 4-vertex, + # arrowhead 3-vertex), no path. + tap_m = re.search( + r']*>(.*?)', + out, re.DOTALL, + ) + assert tap_m, "tapered edge group missing" + tapered_block = tap_m.group(1) + tapered_polys = re.findall(r']*points="([^"]+)"', tapered_block) + assert len(tapered_polys) == 2, ( + f"tapered edge should keep 2 polygons (body + arrowhead), got {len(tapered_polys)}" + ) + assert len(tapered_polys[0].split()) == 4, "tapered body must be a 4-vertex strip" + assert len(tapered_polys[1].split()) == 3, "tapered arrowhead must be a 3-vertex triangle" + assert ' child" diff --git a/expert_backend/tests/test_overflow_html_dim_logic.py b/expert_backend/tests/test_overflow_html_dim_logic.py new file mode 100644 index 00000000..1d1e69cb --- /dev/null +++ b/expert_backend/tests/test_overflow_html_dim_logic.py @@ -0,0 +1,716 @@ +# 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 + +"""End-to-end tests for the overflow-graph viewer layer-toggle bug +fixes. The tests build a small handcrafted overflow graph that +exercises every category of edge / node the layer toggles classify +(hub, on_constrained_path, in_red_loop, is_overload, is_monitored, +plus colour and style discriminators), render it through the upstream +``build_interactive_html`` viewer, and assert the resulting MODEL JSON ++ injected SVG carry the right layer membership. The HTML output is +also re-injected through the Co-Study4Grid overlay so the dynamic +``/results/pdf/{filename}`` route is covered. + +The dim semantics of the JS template are verified via a small jsdom +simulation: we re-implement the recompute rule (``shouldDim``) in +Python — byte-equivalent to the JS — and assert it against the model +membership map. This avoids spinning up Node just to run a few cases +and keeps the contract easy to read. +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any, Dict, List, Set + +import networkx as nx +import pytest + +pydot = pytest.importorskip("pydot") + +from alphaDeesp.core.graphsAndPaths import OverFlowGraph # noqa: E402 +from alphaDeesp.core.interactive_html import build_interactive_html # noqa: E402 +from alphaDeesp.tests.graphs_test_helpers import make_ofg_with_graph # noqa: E402 + +from expert_backend.services.overflow_overlay import inject_overlay + + +# --------------------------------------------------------------------- +# Fixture: a graph that touches every layer the viewer surfaces +# --------------------------------------------------------------------- + +def _build_full_layer_graph() -> OverFlowGraph: + """Return an OverFlowGraph stub carrying: + + * one overload edge (black, also tagged is_overload) + * one constrained-path edge (blue, on_constrained_path) + * one red-loop edge (coral, in_red_loop, in a coral component) + * one positive-overflow-only edge (coral, NOT in any red loop because + its endpoint has a non-coral neighbour — actually for simplicity + we use a dedicated dyad) + * one monitored line (compound color + is_monitored) + * one reconnectable (dashed) edge + * one non-reconnectable (dotted) edge + * a hub node (is_hub) which by definition picks up + on_constrained_path + in_red_loop + """ + g = nx.MultiDiGraph() + # Nodes + g.add_node("HUB", shape="oval") + g.add_node("OVL_A", shape="oval") # constrained / overload endpoint + g.add_node("OVL_B", shape="oval") + g.add_node("RL_X", shape="oval") # red-loop interior (will collapse) + g.add_node("RL_Y", shape="oval") + g.add_node("MON_A", shape="oval") # monitored line endpoint + g.add_node("MON_B", shape="oval") + g.add_node("RC_A", shape="oval") # reconnectable edge endpoint + g.add_node("RC_B", shape="oval") + g.add_node("NR_A", shape="oval") # non-reconnectable + g.add_node("NR_B", shape="oval") + # Prod / load / quiet nodes — carry the same prod_or_load + value + # attributes upstream `build_nodes` assigns. The viewer layers + # filter on these. + g.add_node("PROD_BIG", shape="oval", prod_or_load="prod", value="42.0", + style="filled", fillcolor="coral") + g.add_node("LOAD_BIG", shape="oval", prod_or_load="load", value="-30.0", + style="filled", fillcolor="lightblue") + g.add_node("LOAD_TINY", shape="oval", prod_or_load="load", value="0.4", + style="filled", fillcolor="#ffffed") # below 1 MW floor + g.add_node("LOAD_ZERO", shape="oval", prod_or_load="load", value="0.0", + style="filled", fillcolor="#ffffed") # exact zero balance + + # Overload (black) — also part of constrained path + g.add_edge("OVL_A", "OVL_B", name="L_OVL", color="black", label="100") + # Constrained-path blue edge + g.add_edge("OVL_B", "HUB", name="L_BLUE", color="blue", label="-30") + # Pure red-loop component (RL_X — RL_Y, both coral) + g.add_edge("RL_X", "RL_Y", name="L_CORAL_RL", color="coral", label="5") + g.add_edge("RL_Y", "RL_X", name="L_CORAL_RL2", color="coral", label="5") + # Monitored coral line (will get is_monitored) + g.add_edge("MON_A", "MON_B", name="L_MON", color="coral", label="50") + # Reconnectable (dashed) edge — gray-style + g.add_edge("RC_A", "RC_B", name="L_RECO", color="gray", style="dashed", label="0") + # Non-reconnectable (dotted) edge — gray-style + g.add_edge("NR_A", "NR_B", name="L_NRECO", color="gray", style="dotted", label="0") + + ofg = make_ofg_with_graph(g) + # Tag pipeline (mirrors visualization.py order) + ofg.set_hubs_shape(["HUB"], shape_hub="diamond") + ofg.highlight_significant_line_loading({ + "L_OVL": {"before": 95, "after": 110}, + "L_MON": {"before": 80, "after": 92}, + }) + ofg.tag_constrained_path( + lines_constrained_path=["L_OVL", "L_BLUE"], + nodes_constrained_path=["OVL_A", "OVL_B"], + ) + ofg.collapse_red_loops() + # Source-of-truth red-loop tagging — simulates what the + # recommender's ``get_dispatch_edges_nodes(only_loop_paths=True)`` + # would return for this fixture: only the RL_X-RL_Y dyad + # participates in a cycle path. MON_A/MON_B is intentionally NOT + # tagged (no cycle). + ofg.tag_red_loops( + lines_red_loops=["L_CORAL_RL", "L_CORAL_RL2"], + nodes_red_loops=["RL_X", "RL_Y"], + ) + return ofg + + +def _build_html_and_model() -> tuple[str, Dict[str, Any]]: + ofg = _build_full_layer_graph() + pg = nx.drawing.nx_pydot.to_pydot(ofg.g) + html = build_interactive_html(pg, title="layer-coverage") + m = re.search(r"const MODEL = (\{.*?\});\n\(function", html, re.S) + assert m, "Embedded MODEL JSON not found" + return html, json.loads(m.group(1)) + + +def _layers_by_key(model: Dict[str, Any]) -> Dict[str, Dict[str, Any]]: + return {layer["key"]: layer for layer in model["layers"]} + + +# --------------------------------------------------------------------- +# Layer-membership assertions (source-truth, no symbol reinterpretation) +# --------------------------------------------------------------------- + +class TestLayerMembershipsFromSourceFlags: + def test_hubs_layer_includes_only_hub_node(self): + _, model = _build_html_and_model() + hubs = _layers_by_key(model)["semantic:is_hub"] + assert hubs["nodes"] == ["HUB"] + assert hubs["edges"] == [] + + def test_hub_is_also_in_red_loop_and_constrained_path(self): + _, model = _build_html_and_model() + layers = _layers_by_key(model) + assert "HUB" in set(layers["semantic:in_red_loop"]["nodes"]) + assert "HUB" in set(layers["semantic:on_constrained_path"]["nodes"]) + + def test_constrained_path_excludes_coral_edges(self): + _, model = _build_html_and_model() + cp = _layers_by_key(model)["semantic:on_constrained_path"] + # Match by edge-id → look up edge color via model.edges. + edge_colors = {e["id"]: e["attrs"].get("color", "") for e in model["edges"]} + for eid in cp["edges"]: + color = edge_colors[eid] + base = color.split(":", 1)[0].strip().strip('"').lower() + assert base != "coral", ( + f"edge {eid} (color={color!r}) leaked into constrained-path" + ) + + def test_constrained_path_includes_blue_and_black(self): + _, model = _build_html_and_model() + cp = _layers_by_key(model)["semantic:on_constrained_path"] + edges_by_id = {e["id"]: e for e in model["edges"]} + names = {edges_by_id[eid]["attrs"].get("name") for eid in cp["edges"]} + assert "L_OVL" in names + assert "L_BLUE" in names + + def test_red_loop_layer_matches_explicit_source_of_truth(self): + """in_red_loop tagging is now driven by the explicit list passed + from the recommender's ``get_dispatch_edges_nodes(only_loop_paths + =True)`` — which itself iterates ``red_loops.Path`` (actual + cycle paths). The viewer no longer derives membership from + heuristics over the local graph, so a coral edge can be in or + out of the layer regardless of its endpoints' shape.""" + _, model = _build_html_and_model() + rl = _layers_by_key(model)["semantic:in_red_loop"] + edges_by_id = {e["id"]: e for e in model["edges"]} + red_loop_names = {edges_by_id[eid]["attrs"].get("name") for eid in rl["edges"]} + # Fixture explicitly tagged the RL_X-RL_Y dyad as the loop. + assert "L_CORAL_RL" in red_loop_names + assert "L_CORAL_RL2" in red_loop_names + # The monitored coral line MON_A-MON_B is NOT in the cycle. + assert "L_MON" not in red_loop_names + rl_node_names = set(rl["nodes"]) + # HUB is auto-tagged by `set_hubs_shape` (hubs are by + # definition in red loops). RL_X / RL_Y come from the + # explicit ``tag_red_loops`` call. + assert rl_node_names == {"HUB", "RL_X", "RL_Y"} + + def test_red_loop_excludes_blue_and_black_edges(self): + _, model = _build_html_and_model() + rl = _layers_by_key(model)["semantic:in_red_loop"] + edges_by_id = {e["id"]: e for e in model["edges"]} + for eid in rl["edges"]: + base = edges_by_id[eid]["attrs"].get("color", "").split(":", 1)[0] + base = base.strip().strip('"').lower() + assert base == "coral", ( + f"edge {eid} (color={base!r}) leaked into red-loop layer" + ) + + def test_overload_layer_only_contains_black_edges(self): + _, model = _build_html_and_model() + layer = _layers_by_key(model)["semantic:is_overload"] + edges_by_id = {e["id"]: e for e in model["edges"]} + names = {edges_by_id[eid]["attrs"].get("name") for eid in layer["edges"]} + assert names == {"L_OVL"} + + def test_monitored_layer_includes_overloads_as_subset(self): + _, model = _build_html_and_model() + mon = _layers_by_key(model)["semantic:is_monitored"] + edges_by_id = {e["id"]: e for e in model["edges"]} + names = {edges_by_id[eid]["attrs"].get("name") for eid in mon["edges"]} + # Every entry in dict_significant_change is a low-margin / + # monitored line. The overload subset is also tagged as + # overload — they are NOT mutually exclusive layers. + assert names == {"L_MON", "L_OVL"} + + def test_reconnectable_layer_only_contains_dashed_edges(self): + _, model = _build_html_and_model() + layer = _layers_by_key(model).get("style:dashed") + assert layer is not None + edges_by_id = {e["id"]: e for e in model["edges"]} + for eid in layer["edges"]: + assert edges_by_id[eid]["attrs"].get("style", "").lower() == "dashed" + names = {edges_by_id[eid]["attrs"].get("name") for eid in layer["edges"]} + assert names == {"L_RECO"} + + def test_non_reconnectable_layer_only_contains_dotted_edges(self): + _, model = _build_html_and_model() + layer = _layers_by_key(model).get("style:dotted") + assert layer is not None + edges_by_id = {e["id"]: e for e in model["edges"]} + for eid in layer["edges"]: + assert edges_by_id[eid]["attrs"].get("style", "").lower() == "dotted" + names = {edges_by_id[eid]["attrs"].get("name") for eid in layer["edges"]} + assert names == {"L_NRECO"} + + +class TestProdLoadValueLayers: + """Coverage for the value-based ``node:prod`` / ``node:load`` layers + introduced alongside the ``prod_or_load`` attribute upstream + ``build_nodes`` writes on every node. + + Contract: + + * Only nodes whose ``prod_or_load`` matches the layer kind AND + whose ``abs(value)`` is at least 1 MW count — the white-coloured + zero-balance nodes (``prod_or_load='load'`` with ``value='0.0'``) + must NOT leak into the Consumption layer. + * Both layers live in the *Individual entities properties* section + so they group with Hubs / Overloads / Reconnectable in the + viewer's sidebar. + """ + + def test_production_layer_contains_only_prod_nodes_above_floor(self): + _, model = _build_html_and_model() + layer = _layers_by_key(model).get("node:prod") + assert layer is not None, "node:prod layer missing" + assert set(layer["nodes"]) == {"PROD_BIG"} + assert layer["edges"] == [] + + def test_consumption_layer_excludes_zero_balance_and_subfloor_nodes(self): + _, model = _build_html_and_model() + layer = _layers_by_key(model).get("node:load") + assert layer is not None, "node:load layer missing" + # LOAD_BIG passes the floor; LOAD_TINY (0.4 MW) and LOAD_ZERO + # (0.0 MW) are filtered out by the 1 MW threshold. + node_set = set(layer["nodes"]) + assert "LOAD_BIG" in node_set + assert "LOAD_TINY" not in node_set + assert "LOAD_ZERO" not in node_set + # Prod nodes never bleed into the load layer. + assert "PROD_BIG" not in node_set + # No edges on a value-based node layer. + assert layer["edges"] == [] + + def test_value_layers_group_under_individual_entities_section(self): + _, model = _build_html_and_model() + layers = _layers_by_key(model) + for key in ("node:prod", "node:load"): + assert layers[key]["section"] == "Individual entities properties" + + +# --------------------------------------------------------------------- +# Dim semantics (Python twin of the JS `shouldDim`) +# --------------------------------------------------------------------- + +def _should_dim(memberships: List[int], checked_set: Set[int], total: int) -> bool: + """Byte-equivalent of the JS rule in interactive_html.py: + + * `allChecked` (every layer is on) → never dim. + * Element with no memberships → dim whenever `allChecked` is False. + * Else: dim iff none of its memberships is in `checked_set`. + """ + all_checked = len(checked_set) == total + if all_checked: + return False + if not memberships: + return True + return not any(idx in checked_set for idx in memberships) + + +def _node_memberships(model: Dict[str, Any]) -> Dict[str, List[int]]: + out: Dict[str, List[int]] = {} + for i, layer in enumerate(model["layers"]): + for n in layer.get("nodes", []) or []: + out.setdefault(n, []).append(i) + return out + + +def _edge_memberships(model: Dict[str, Any]) -> Dict[str, List[int]]: + out: Dict[str, List[int]] = {} + for i, layer in enumerate(model["layers"]): + for e in layer.get("edges", []) or []: + out.setdefault(e, []).append(i) + return out + + +class TestDimSemantics: + """Confirms the bug fixes the user flagged on 2026-05-04 — the + must-have invariants of the layer-toggle UX.""" + + def test_unselect_all_dims_every_node(self): + _, model = _build_html_and_model() + node_mem = _node_memberships(model) + # Empty checked set = "Unselect all" + for name in {n["name"] for n in model["nodes"]}: + assert _should_dim( + node_mem.get(name, []), set(), len(model["layers"]) + ), f"node {name} stayed visible after unselect-all" + + def test_unselect_all_dims_every_edge(self): + _, model = _build_html_and_model() + edge_mem = _edge_memberships(model) + for e in model["edges"]: + assert _should_dim( + edge_mem.get(e["id"], []), set(), len(model["layers"]) + ), f"edge {e['id']} stayed visible after unselect-all" + + def test_select_all_keeps_every_element_visible(self): + _, model = _build_html_and_model() + node_mem = _node_memberships(model) + edge_mem = _edge_memberships(model) + all_idx = set(range(len(model["layers"]))) + for name in {n["name"] for n in model["nodes"]}: + assert not _should_dim( + node_mem.get(name, []), all_idx, len(model["layers"]) + ) + for e in model["edges"]: + assert not _should_dim( + edge_mem.get(e["id"], []), all_idx, len(model["layers"]) + ) + + def test_constrained_path_only_visible_with_only_that_layer(self): + _, model = _build_html_and_model() + layer_keys = [layer["key"] for layer in model["layers"]] + cp_idx = layer_keys.index("semantic:on_constrained_path") + checked = {cp_idx} + + node_mem = _node_memberships(model) + edge_mem = _edge_memberships(model) + cp_layer = _layers_by_key(model)["semantic:on_constrained_path"] + cp_node_set = set(cp_layer["nodes"]) + cp_edge_set = set(cp_layer["edges"]) + + # Every node IN the constrained-path layer is visible. + for n in cp_node_set: + assert not _should_dim( + node_mem.get(n, []), checked, len(model["layers"]) + ), f"constrained-path node {n} was wrongly dimmed" + # Every node NOT in any layer claimed by the checked set is + # dimmed — including all nodes whose only memberships were + # color/style/other semantic layers. + for n in {n["name"] for n in model["nodes"]} - cp_node_set: + assert _should_dim( + node_mem.get(n, []), checked, len(model["layers"]) + ), f"non-constrained-path node {n} stayed visible" + # Edge mirror. + for eid in cp_edge_set: + assert not _should_dim( + edge_mem.get(eid, []), checked, len(model["layers"]) + ) + for e in model["edges"]: + if e["id"] in cp_edge_set: + continue + assert _should_dim( + edge_mem.get(e["id"], []), checked, len(model["layers"]) + ), f"non-constrained edge {e['id']} stayed visible" + + def test_red_loop_only_visible_with_only_that_layer(self): + _, model = _build_html_and_model() + layer_keys = [layer["key"] for layer in model["layers"]] + rl_idx = layer_keys.index("semantic:in_red_loop") + checked = {rl_idx} + + edge_mem = _edge_memberships(model) + rl_layer = _layers_by_key(model)["semantic:in_red_loop"] + rl_edge_set = set(rl_layer["edges"]) + + # Hub belongs to the red-loop layer (definition-level). + node_mem = _node_memberships(model) + assert not _should_dim( + node_mem.get("HUB", []), checked, len(model["layers"]) + ) + + # No black/blue edge survives in red-loop-only view. + edges_by_id = {e["id"]: e for e in model["edges"]} + for e in model["edges"]: + if e["id"] in rl_edge_set: + continue + assert _should_dim( + edge_mem.get(e["id"], []), checked, len(model["layers"]) + ), ( + f"non-red-loop edge {e['id']} " + f"(color={edges_by_id[e['id']]['attrs'].get('color')!r}) " + f"stayed visible" + ) + + def test_reconnectable_only_visible_with_only_that_layer(self): + _, model = _build_html_and_model() + layer_keys = [layer["key"] for layer in model["layers"]] + rec_idx = layer_keys.index("style:dashed") + checked = {rec_idx} + + edge_mem = _edge_memberships(model) + rec_layer = _layers_by_key(model)["style:dashed"] + rec_edge_set = set(rec_layer["edges"]) + + # Dashed edges visible. + for eid in rec_edge_set: + assert not _should_dim( + edge_mem.get(eid, []), checked, len(model["layers"]) + ) + # Coloured non-dashed edges (e.g. blue, coral) must NOT be + # visible — that was the explicit bug the user reported. + for e in model["edges"]: + if e["id"] in rec_edge_set: + continue + assert _should_dim( + edge_mem.get(e["id"], []), checked, len(model["layers"]) + ), f"non-dashed edge {e['id']} stayed visible" + + def test_non_reconnectable_only_visible_with_only_that_layer(self): + _, model = _build_html_and_model() + layer_keys = [layer["key"] for layer in model["layers"]] + nr_idx = layer_keys.index("style:dotted") + checked = {nr_idx} + + edge_mem = _edge_memberships(model) + nr_layer = _layers_by_key(model)["style:dotted"] + nr_edge_set = set(nr_layer["edges"]) + + for eid in nr_edge_set: + assert not _should_dim( + edge_mem.get(eid, []), checked, len(model["layers"]) + ) + # Coloured non-dotted edges must NOT survive. + for e in model["edges"]: + if e["id"] in nr_edge_set: + continue + assert _should_dim( + edge_mem.get(e["id"], []), checked, len(model["layers"]) + ) + + +# --------------------------------------------------------------------- +# Co-Study4Grid overlay carries the dblclick→SLD wiring +# --------------------------------------------------------------------- + +class TestOverlayDoubleClickWiring: + def test_overflow_html_includes_dblclick_postmessage(self): + html, _ = _build_html_and_model() + # Upstream JS forwards dblclick to the parent window. + assert "cs4g:overflow-node-double-clicked" in html + + def test_inject_overlay_does_not_strip_dblclick_wiring(self): + html, _ = _build_html_and_model() + injected = inject_overlay(html) + assert "cs4g:overflow-node-double-clicked" in injected + # Overlay-side script also present. + assert "cs4g-overlay-script" in injected + + +# --------------------------------------------------------------------- +# End-to-end against the user's small-grid config (P.SAOL31RONCI) +# --------------------------------------------------------------------- + +class TestSmallGridOverflowGraphLayers: + """Regression test against the actual ``Overflow_Graph_P.SAOL31RONCI*.html`` + produced by the recommender on the bare_env_small_grid_test fixture. + + Skipped if the HTML hasn't been generated yet (e.g. a fresh + checkout running tests before any analysis run). The asserts + capture the user-reported bug class — extras nodes leaking into + the constrained-path layer and missing hub auto-flags.""" + + # Resolve relative to the project root so the test works on any + # checkout (CI, dev machine, container) — not just the original + # author's home dir. Test file lives at + # ``/expert_backend/tests/test_overflow_html_dim_logic.py``, + # so the project root is two parents above this file. + PROJECT_ROOT = Path(__file__).resolve().parents[2] + HTML_PATH = str( + PROJECT_ROOT + / "Overflow_Graph" + / ( + "Overflow_Graph_P.SAOL31RONCI_chronic_grid.xiidm_" + "timestep_9_hierarchi_only_signif_edges_no_consoli.html" + ) + ) + + def _load_model(self): + import os + if not os.path.isfile(self.HTML_PATH): + pytest.skip(f"Generated HTML not present: {self.HTML_PATH}") + with open(self.HTML_PATH, "r", encoding="utf-8") as fh: + html = fh.read() + import re + m = re.search(r"const MODEL = (\{.*?\});\n\(function", html, re.S) + return json.loads(m.group(1)) + + def _layers(self, model): + return {layer["key"]: layer for layer in model["layers"]} + + def test_constrained_path_does_not_include_side_branch_nodes(self): + """Side-branch nodes (e.g. MAGNYP3, MAGNYP6, ZCRIMP3) live in + ``other_blue_nodes`` upstream — they must NOT appear on the + strict constrained path.""" + model = self._load_model() + cp = self._layers(model).get("semantic:on_constrained_path") + assert cp is not None + cp_nodes = set(cp["nodes"]) + forbidden = {"MAGNYP3", "MAGNYP6", "ZCRIMP3"} + leak = cp_nodes & forbidden + assert not leak, f"Side-branch nodes leaked into constrained path: {leak}" + + def test_constrained_path_excludes_coral_edges(self): + model = self._load_model() + cp = self._layers(model).get("semantic:on_constrained_path") + edges_by_id = {e["id"]: e for e in model["edges"]} + for eid in cp["edges"]: + color = edges_by_id[eid]["attrs"].get("color", "") + base = ( + color.split(":", 1)[0].strip().strip('"').lower() + if isinstance(color, str) + else "" + ) + assert base != "coral", ( + f"coral edge {eid} (color={color!r}) on constrained path" + ) + + def test_every_hub_is_in_red_loop_and_on_constrained_path(self): + """Hubs are by definition in both layers — verify on real data.""" + model = self._load_model() + layers = self._layers(model) + hubs = layers.get("semantic:is_hub") + rl = layers.get("semantic:in_red_loop") + cp = layers.get("semantic:on_constrained_path") + assert hubs and rl and cp + rl_set, cp_set = set(rl["nodes"]), set(cp["nodes"]) + for h in hubs["nodes"]: + assert h in rl_set, f"hub {h} missing from red-loop layer" + assert h in cp_set, f"hub {h} missing from constrained-path layer" + + def test_overload_layer_has_exactly_the_overload(self): + """Only the BEON-CPVAN overloaded line (1 edge) should be + flagged ``is_overload`` for this scenario.""" + model = self._load_model() + ovl = self._layers(model).get("semantic:is_overload") + assert ovl is not None + assert len(ovl["edges"]) == 1, ( + f"expected exactly 1 overload edge, got {len(ovl['edges'])}" + ) + + def test_every_red_loop_edge_has_endpoints_among_red_loop_nodes(self): + """Source-of-truth invariant: every in_red_loop edge connects + two nodes that are themselves in_red_loop. Both come from the + recommender's ``get_dispatch_edges_nodes(only_loop_paths=True)`` + — the line filter keeps only edges whose endpoints are in the + node list, so this invariant is symmetric by construction.""" + model = self._load_model() + rl = self._layers(model).get("semantic:in_red_loop") + edges_by_id = {e["id"]: e for e in model["edges"]} + rl_node_set = set(rl["nodes"]) + for eid in rl["edges"]: + e = edges_by_id[eid] + assert e["source"] in rl_node_set, ( + f"red-loop edge {eid} source {e['source']!r} not in red-loop nodes" + ) + assert e["target"] in rl_node_set, ( + f"red-loop edge {eid} target {e['target']!r} not in red-loop nodes" + ) + + def test_user_listed_edges_ARE_on_constrained_path(self): + """Direct twin of the user's complaint: the four edges they + called out as missing must be on the constrained-path layer.""" + model = self._load_model() + cp = self._layers(model).get("semantic:on_constrained_path") + edges_by_id = {e["id"]: e for e in model["edges"]} + cp_set = set(cp["edges"]) + # (source, target, expected line names — BLUE only; the dimgray + # CPVANY632 is NOT on CP because it's null-flow) + wanted = { + ("SSV.OP7", "GROSNP7"): {"GROSNL71SSV.O"}, + ("CHALOP6", "CPVANP6"): {"CHALOL61CPVAN"}, + ("CPVANP6", "CPVANP3"): {"CPVANY631", "CPVANY633"}, + ("VIELMP7", "VIELMP6"): {"VIELMY762", "VIELMY763"}, + } + for (s, t), expected_names in wanted.items(): + on_cp = set() + for e in model["edges"]: + if (e["source"] == s and e["target"] == t) or ( + e["source"] == t and e["target"] == s + ): + if e["id"] in cp_set: + on_cp.add(e["attrs"].get("name")) + assert expected_names <= on_cp, ( + f"{s}↔{t}: expected {expected_names} on CP, got {on_cp}" + ) + + def test_svg_data_attrs_consistent_with_titles(self): + """Regression for the user-reported edge-id misalignment: + graphviz emits SVG and JSON edge IDs in independent orders, so + before the alignment pass the SVG element ``edgeN`` could carry + ``data-source`` / ``data-target`` referring to a different edge + than its own ```` says. After the fix, every SVG edge's + title and data-* attributes must agree.""" + import html as _html_mod + import os + if not os.path.isfile(self.HTML_PATH): + pytest.skip(f"Generated HTML not present: {self.HTML_PATH}") + with open(self.HTML_PATH, "r", encoding="utf-8") as f: + html = f.read() + svg_block = re.search(r"<svg[^>]*>.*?</svg>", html, re.S).group(0) + edge_blocks = re.findall( + r'<g id="(edge\d+)" class="edge"[^>]*' + r'data-source="([^"]*)"[^>]*data-target="([^"]*)"[^>]*>' + r'\s*<title>([^<]*)', + svg_block, + ) + assert edge_blocks, "no edge blocks parsed" + mismatches = [] + for gid, src, tgt, title in edge_blocks: + t = _html_mod.unescape(title) + for sep in ("->", "--"): + if sep in t: + a, b = t.split(sep, 1) + if (a.strip(), b.strip()) != (src, tgt): + mismatches.append( + (gid, (a.strip(), b.strip()), (src, tgt)) + ) + break + assert not mismatches, ( + f"{len(mismatches)} edges have title ≠ data-source/data-target: " + + "; ".join( + f"{gid}: title{tt} ≠ data{dd}" + for gid, tt, dd in mismatches[:5] + ) + ) + + def test_constrained_path_only_blue_or_black_edges(self): + """Direct twin of the user's complaint: NO non-blue/black edges + from VIELMP7, SSV.OP7, CPVANP6, CHALOP6 (or anywhere else) + should be on the constrained-path layer.""" + model = self._load_model() + cp = self._layers(model).get("semantic:on_constrained_path") + edges_by_id = {e["id"]: e for e in model["edges"]} + leaks = [] + for eid in cp["edges"]: + e = edges_by_id[eid] + color = e["attrs"].get("color", "") + base = ( + color.split(":", 1)[0].strip().strip('"').lower() + if isinstance(color, str) + else "" + ) + if base not in ("blue", "black"): + leaks.append( + f"{e['attrs'].get('name')} ({e['source']}→{e['target']}," + f" color={color!r})" + ) + assert not leaks, ( + "Non-blue/black edges leaked into constrained path: " + + ", ".join(leaks) + ) + + def test_red_loop_is_consistent_with_recommender_cycle_paths(self): + """For the small-grid scenario, the recommender's + ``red_loops.Path`` includes the cycle ``[CHALOP6, CHALOP3, + LOUHAP3]``. Therefore the CHALOY63x transformers AND the + dashed CHALOL31LOUHA edge are part of a red loop. + + This documents the source-of-truth contract: the viewer + propagates whatever the recommender's structured analysis + returned. Any disagreement with the operator's mental model + should be raised against the recommender's ``find_loops`` + algorithm — not the viewer.""" + model = self._load_model() + rl = self._layers(model).get("semantic:in_red_loop") + edges_by_id = {e["id"]: e for e in model["edges"]} + rl_names = {edges_by_id[eid]["attrs"].get("name") for eid in rl["edges"]} + # The cycle CHALOP6→CHALOP3→LOUHAP3→...→CHALOP6 is in the + # recommender's red_loops.Path — so the parallel transformers + # belong to it. (See the dump in test data setup.) + assert "CHALOY631" in rl_names + assert "CHALOY632" in rl_names + assert "CHALOY633" in rl_names + rl_node_set = set(rl["nodes"]) + assert {"CHALOP6", "CHALOP3", "LOUHAP3"} <= rl_node_set diff --git a/expert_backend/tests/test_overflow_overlay.py b/expert_backend/tests/test_overflow_overlay.py new file mode 100644 index 00000000..10c227d9 --- /dev/null +++ b/expert_backend/tests/test_overflow_overlay.py @@ -0,0 +1,412 @@ +# 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 + +"""Unit tests for the Co-Study4Grid overflow-graph overlay injector and +the dynamic ``/results/pdf/{filename}`` route that serves it. + +The overlay is plain string substitution by design (no HTML parser +dependency) — exercising it is therefore deterministic and fast. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from expert_backend.main import app +from expert_backend.services.overflow_overlay import inject_overlay + + +_BASE_HTML = ( + 'x' + '
' +) + + +# --------------------------------------------------------------------- +# inject_overlay — pure-string injector +# --------------------------------------------------------------------- + +class TestInjectOverlay: + def test_injects_style_and_script_before_closing_body(self) -> None: + out = inject_overlay(_BASE_HTML) + # The original body content must still be there. + assert '
' in out + # Both blocks must appear AND be positioned before . + style_at = out.find(' - - -
- - diff --git a/frontend/src/App.contingency.test.tsx b/frontend/src/App.contingency.test.tsx index 425197b8..26ae55d4 100644 --- a/frontend/src/App.contingency.test.tsx +++ b/frontend/src/App.contingency.test.tsx @@ -113,6 +113,7 @@ const mockApi = vi.hoisted(() => ({ getBranches: vi.fn().mockResolvedValue({ branches: ['BRANCH_A', 'BRANCH_B', 'BRANCH_C'], name_map: {} }), getVoltageLevels: vi.fn().mockResolvedValue({ voltage_levels: ['VL1', 'VL2'], name_map: {} }), getNominalVoltages: vi.fn().mockResolvedValue({ mapping: {}, unique_kv: [63, 225] }), + getVoltageLevelSubstations: vi.fn().mockResolvedValue({ mapping: {} }), getNetworkDiagram: vi.fn().mockResolvedValue({ svg: '', metadata: null }), getN1Diagram: vi.fn().mockResolvedValue({ svg: '', metadata: null, lines_overloaded: [] }), pickPath: vi.fn(), @@ -307,6 +308,7 @@ describe('Overload Clearing Logic', () => { mockApi.getBranches.mockResolvedValue({ branches: ['BRANCH_A', 'BRANCH_B', 'BRANCH_C'], name_map: {} }); mockApi.getVoltageLevels.mockResolvedValue({ voltage_levels: ['VL1', 'VL2'], name_map: {} }); mockApi.getNominalVoltages.mockResolvedValue({ mapping: {}, unique_kv: [63, 225] }); + mockApi.getVoltageLevelSubstations.mockResolvedValue({ mapping: {} }); mockApi.getNetworkDiagram.mockResolvedValue({ svg: '', metadata: null }); mockApi.getN1Diagram.mockResolvedValue({ svg: '', metadata: null, lines_overloaded: [] }); mockApi.runAnalysisStep1.mockResolvedValue({ can_proceed: true, lines_overloaded: ['LINE_OL1'] }); @@ -495,6 +497,7 @@ describe('N-1 overload state is populated before action analysis', () => { mockApi.getBranches.mockResolvedValue({ branches: ['BRANCH_A', 'BRANCH_B', 'BRANCH_C'], name_map: {} }); mockApi.getVoltageLevels.mockResolvedValue({ voltage_levels: ['VL1', 'VL2'], name_map: {} }); mockApi.getNominalVoltages.mockResolvedValue({ mapping: {}, unique_kv: [63, 225] }); + mockApi.getVoltageLevelSubstations.mockResolvedValue({ mapping: {} }); mockApi.getNetworkDiagram.mockResolvedValue({ svg: '', metadata: null }); mockApi.getN1Diagram.mockResolvedValue({ svg: '', metadata: null, lines_overloaded: [] }); localStorage.clear(); diff --git a/frontend/src/App.datalist.test.tsx b/frontend/src/App.datalist.test.tsx index 53c499bd..7fa8c4bc 100644 --- a/frontend/src/App.datalist.test.tsx +++ b/frontend/src/App.datalist.test.tsx @@ -18,6 +18,7 @@ const mockApi = vi.hoisted(() => ({ getBranches: vi.fn().mockResolvedValue({ branches: [], name_map: {} }), getVoltageLevels: vi.fn().mockResolvedValue({ voltage_levels: ['VL1'], name_map: {} }), getNominalVoltages: vi.fn().mockResolvedValue({ mapping: {}, unique_kv: [63, 225] }), + getVoltageLevelSubstations: vi.fn().mockResolvedValue({ mapping: {} }), getNetworkDiagram: vi.fn().mockResolvedValue({ svg: '', metadata: null }), getUserConfig: vi.fn().mockResolvedValue({ network_path: '/path', action_file_path: '/path' }), getConfigFilePath: vi.fn().mockResolvedValue('/config'), diff --git a/frontend/src/App.session.test.tsx b/frontend/src/App.session.test.tsx index c6f5cfe3..48541588 100644 --- a/frontend/src/App.session.test.tsx +++ b/frontend/src/App.session.test.tsx @@ -103,6 +103,7 @@ const mockApi = vi.hoisted(() => ({ getBranches: vi.fn().mockResolvedValue({ branches: ['BRANCH_A', 'BRANCH_B', 'BRANCH_C'], name_map: {} }), getVoltageLevels: vi.fn().mockResolvedValue({ voltage_levels: ['VL1', 'VL2'], name_map: {} }), getNominalVoltages: vi.fn().mockResolvedValue({ mapping: {}, unique_kv: [63, 225] }), + getVoltageLevelSubstations: vi.fn().mockResolvedValue({ mapping: {} }), getNetworkDiagram: vi.fn().mockResolvedValue({ svg: '', metadata: null }), getN1Diagram: vi.fn().mockResolvedValue({ svg: '', metadata: null, lines_overloaded: [] }), pickPath: vi.fn(), diff --git a/frontend/src/App.settings.test.tsx b/frontend/src/App.settings.test.tsx index 01e822f4..7eb5c136 100644 --- a/frontend/src/App.settings.test.tsx +++ b/frontend/src/App.settings.test.tsx @@ -123,6 +123,7 @@ const mockApi = vi.hoisted(() => ({ getBranches: vi.fn().mockResolvedValue({ branches: ['BRANCH_A', 'BRANCH_B', 'BRANCH_C'], name_map: {} }), getVoltageLevels: vi.fn().mockResolvedValue({ voltage_levels: ['VL1', 'VL2'], name_map: {} }), getNominalVoltages: vi.fn().mockResolvedValue({ mapping: {}, unique_kv: [63, 225] }), + getVoltageLevelSubstations: vi.fn().mockResolvedValue({ mapping: {} }), getNetworkDiagram: vi.fn().mockResolvedValue({ svg: '', metadata: null }), getN1Diagram: vi.fn().mockResolvedValue({ svg: '', metadata: null, lines_overloaded: [] }), pickPath: vi.fn(), diff --git a/frontend/src/App.stateManagement.test.tsx b/frontend/src/App.stateManagement.test.tsx index ea468b98..14401e05 100644 --- a/frontend/src/App.stateManagement.test.tsx +++ b/frontend/src/App.stateManagement.test.tsx @@ -117,6 +117,7 @@ const mockApi = vi.hoisted(() => ({ getBranches: vi.fn().mockResolvedValue({ branches: ['BRANCH_A', 'BRANCH_B', 'BRANCH_C'], name_map: {} }), getVoltageLevels: vi.fn().mockResolvedValue({ voltage_levels: ['VL1', 'VL2'], name_map: {} }), getNominalVoltages: vi.fn().mockResolvedValue({ mapping: {}, unique_kv: [63, 225] }), + getVoltageLevelSubstations: vi.fn().mockResolvedValue({ mapping: {} }), getNetworkDiagram: vi.fn().mockResolvedValue({ svg: '', metadata: null }), getN1Diagram: vi.fn().mockResolvedValue({ svg: '', metadata: null, lines_overloaded: [] }), pickPath: vi.fn(), diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 91c9d767..bb9d07da 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -32,6 +32,10 @@ import { useDiagramHighlights } from './hooks/useDiagramHighlights'; import { interactionLogger } from './utils/interactionLogger'; import { DEFAULT_ACTION_OVERVIEW_FILTERS } from './utils/actionTypes'; import { applyVlTitles } from './utils/svgUtils'; +import { + buildOverflowPinPayload, + buildOverflowUnsimulatedPinPayload, +} from './utils/svg/overflowPinPayload'; function App() { // ===== Settings Hook ===== @@ -68,6 +72,14 @@ function App() { const [voltageLevels, setVoltageLevels] = useState([]); /** ID → human-readable name for branches (lines + transformers) and VLs. */ const [nameMap, setNameMap] = useState>({}); + /** VL id → substation id. Loaded once after config-load and used to + * anchor the action-overview pins on the overflow graph (whose + * nodes are pypowsybl substation ids). */ + const [vlToSubstation, setVlToSubstation] = useState>({}); + /** Whether the user has switched ON the pin overlay on the overflow + * graph. Default OFF; toggle is disabled until Step 2 has produced + * a non-empty `result.actions` map. */ + const [overflowPinsEnabled, setOverflowPinsEnabled] = useState(false); const [configLoading, setConfigLoading] = useState(false); const [error, setError] = useState(''); @@ -383,8 +395,76 @@ function App() { diagrams.lastZoomState.current = { query: '', branch: '' }; setSelectedBranch(''); setShowMonitoringWarning(false); + setVlToSubstation({}); + setOverflowPinsEnabled(false); }, [clearContingencyState, diagrams, setShowMonitoringWarning]); + // Pre-compute the pin descriptors posted to the overflow-graph + // iframe. Memoised so unrelated re-renders don't churn the iframe + // postMessage. The toggle is gated on Step 2 having delivered at + // least one action — the iframe overlay is otherwise useless. + const overflowPinsAvailable = useMemo( + () => !analysisLoading && !!result?.actions && Object.keys(result.actions).length > 0, + [analysisLoading, result?.actions], + ); + const overflowPins = useMemo( + () => overflowPinsAvailable + ? buildOverflowPinPayload( + result?.actions ?? null, + diagrams.n1MetaIndex ?? null, + vlToSubstation, + monitoringFactor, + selectedActionIds, + rejectedActionIds, + undefined, + overviewFilters, + ) + : [], + [overflowPinsAvailable, result?.actions, diagrams.n1MetaIndex, vlToSubstation, + monitoringFactor, selectedActionIds, rejectedActionIds, overviewFilters], + ); + + // Un-simulated overflow pins. Built only when the operator has + // ticked ``Show unsimulated`` in the Action-Overview filter row + // (which is mirrored in the iframe sidebar's filter panel). + // Identical contract to the Action Overview pin layer: + // - dimmed grey pin with '?' label, + // - dblclick triggers a manual simulation rather than the SLD + // drill-down, + // - skipped when the id is already in ``result.actions``. + const overflowUnsimulatedPins = useMemo( + () => (overflowPinsAvailable && overviewFilters?.showUnsimulated) + ? buildOverflowUnsimulatedPinPayload( + unsimulatedActionIds, + new Set(Object.keys(result?.actions ?? {})), + diagrams.n1MetaIndex ?? null, + vlToSubstation, + unsimulatedActionInfo, + ) + : [], + [overflowPinsAvailable, overviewFilters?.showUnsimulated, + unsimulatedActionIds, unsimulatedActionInfo, + result?.actions, diagrams.n1MetaIndex, vlToSubstation], + ); + + // Pin payload posted to the iframe is the union of simulated + + // un-simulated pins. The overlay differentiates them via the + // ``unsimulated`` flag on each pin. + const allOverflowPins = useMemo( + () => [...overflowPins, ...overflowUnsimulatedPins], + [overflowPins, overflowUnsimulatedPins], + ); + + // Auto-disable the toggle when the gate goes away (e.g. user + // applied new settings, which clears the result). Without this, + // the toggle would stay ON but the toolbar would show 'OFF' style + // because `overflowPinsAvailable` is false. + useEffect(() => { + if (!overflowPinsAvailable && overflowPinsEnabled) { + setOverflowPinsEnabled(false); + } + }, [overflowPinsAvailable, overflowPinsEnabled]); + const wrappedActionSelect = useCallback( (actionId: string | null) => diagrams.handleActionSelect(actionId, result, selectedBranch, voltageLevels.length, setResult, setError), @@ -746,11 +826,15 @@ function App() { // only started after branches resolved — wasting the ~0.8s branches // gap off the critical path of the initial load. // See docs/performance/history/loading-parallel.md. - const [branchRes, vlRes, nomVRes, diagramRaw] = await Promise.all([ + const [branchRes, vlRes, nomVRes, diagramRaw, vlSubRes] = await Promise.all([ api.getBranches(), api.getVoltageLevels(), api.getNominalVoltages(), api.getNetworkDiagram(), + // Cheap query (~1 ms even on PyPSA-EUR France); pulled in + // parallel so it never extends the critical path. Used to + // anchor overflow-graph action pins on substations. + api.getVoltageLevelSubstations().catch(() => ({ mapping: {} as Record })), ]); setBranches(branchRes.branches); @@ -766,6 +850,7 @@ function App() { } diagrams.ingestBaseDiagram(diagramRaw, vlRes.voltage_levels.length); + setVlToSubstation(vlSubRes.mapping || {}); committedNetworkPathRef.current = networkPath; interactionLogger.record('config_loaded', buildConfigInteractionDetails()); @@ -808,11 +893,15 @@ function App() { // See the sibling call site in `applySettingsImmediate` for context: // fire 4 XHRs in parallel so the slow base-diagram call overlaps // with branches/voltage-levels/nominal-voltages. - const [branchRes, vlRes, nomVRes, diagramRaw] = await Promise.all([ + const [branchRes, vlRes, nomVRes, diagramRaw, vlSubRes] = await Promise.all([ api.getBranches(), api.getVoltageLevels(), api.getNominalVoltages(), api.getNetworkDiagram(), + // Cheap query (~1 ms even on PyPSA-EUR France); pulled in + // parallel so it never extends the critical path. Used to + // anchor overflow-graph action pins on substations. + api.getVoltageLevelSubstations().catch(() => ({ mapping: {} as Record })), ]); setBranches(branchRes.branches); @@ -827,6 +916,7 @@ function App() { } diagrams.ingestBaseDiagram(diagramRaw, vlRes.voltage_levels.length); + setVlToSubstation(vlSubRes.mapping || {}); committedNetworkPathRef.current = networkPath; interactionLogger.record('config_loaded', buildConfigInteractionDetails()); } catch (err: unknown) { @@ -1001,6 +1091,20 @@ function App() { handleVlDoubleClick(selectedActionId || '', vlName); }, [handleVlDoubleClick, selectedActionId]); + // Double-click on an action pin in the overflow graph drills into + // that substation's SLD on the post-action ('action') sub-tab. + // Guarded on the action being known to the analysis result — + // double-clicks on stale or unknown pins are silently ignored. + const handleOverflowPinDoubleClick = useCallback((actionId: string, substation: string) => { + if (!actionId || !substation) return; + const knownAction = !!result?.actions?.[actionId]; + if (!knownAction) return; + interactionLogger.record('overflow_pin_double_clicked', { + actionId, substation, + }); + handleVlDoubleClick(actionId, substation, 'action'); + }, [handleVlDoubleClick, result?.actions]); + const handleCancelDialog = useCallback(() => { // Cancelling a "Change Network?" dialog must roll back the // optimistic networkPath update done by requestNetworkPathChange, @@ -1244,6 +1348,8 @@ function App() { onOverlaySldTabChange={handleOverlaySldTabChange} voltageLevels={voltageLevels} onVlOpen={handleVlOpen} + onOverflowPinPreview={handlePinPreview} + onOverflowPinDoubleClick={handleOverflowPinDoubleClick} networkPath={networkPath} layoutPath={layoutPath} onOpenSettings={handleOpenSettings} @@ -1270,6 +1376,10 @@ function App() { onSimulateUnsimulatedAction={handleSimulateUnsimulatedAction} showVoltageLevelNames={showVoltageLevelNames} onToggleVoltageLevelNames={handleToggleVoltageLevelNames} + overflowPins={allOverflowPins} + overflowPinsEnabled={overflowPinsEnabled} + overflowPinsAvailable={overflowPinsAvailable} + onOverflowPinsToggle={setOverflowPinsEnabled} /> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 637e34e7..5ff5b2e6 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -66,6 +66,17 @@ export const api = { ); return response.data; }, + /** + * Return ``{vl_id: substation_id}`` for every voltage level. + * Used to anchor action-overview pins on the overflow graph + * (which uses substation names as node identifiers). + */ + getVoltageLevelSubstations: async (): Promise<{ mapping: Record }> => { + const response = await axios.get<{ mapping: Record }>( + `${API_BASE_URL}/api/voltage-level-substations` + ); + return response.data; + }, // NOTE: the FastAPI backend always serialises the SVG as a raw // XML string, so these axios methods narrow `DiagramData.svg` to // `string` at the boundary. The wider `string | SVGSVGElement` diff --git a/frontend/src/components/DetachedPlaceholder.tsx b/frontend/src/components/DetachedPlaceholder.tsx new file mode 100644 index 00000000..8a776d07 --- /dev/null +++ b/frontend/src/components/DetachedPlaceholder.tsx @@ -0,0 +1,57 @@ +// 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 React from 'react'; +import type { TabId } from '../types'; +import { colors } from '../styles/tokens'; + +/** + * In-place stand-in shown inside `VisualizationPanel` when a tab has + * been detached into a separate browser window. Surfaces the tab + * label, a "Focus window" shortcut and a "Reattach" action so the + * operator can recover from a hidden popup or pull the tab back into + * the main window. + */ +const DetachedPlaceholder: React.FC<{ + tabId: TabId; + label: string; + accentColor: string; + onFocusDetachedTab: (tab: TabId) => void; + onReattachTab: (tab: TabId) => void; +}> = ({ tabId, label, accentColor, onFocusDetachedTab, onReattachTab }) => ( +
+
{'\u21D7'}
+
"{label}" is open in a separate window
+
+ + +
+
+); + +export default DetachedPlaceholder; diff --git a/frontend/src/components/InspectSearchField.tsx b/frontend/src/components/InspectSearchField.tsx new file mode 100644 index 00000000..8f3db98c --- /dev/null +++ b/frontend/src/components/InspectSearchField.tsx @@ -0,0 +1,127 @@ +// 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 React, { useState, useRef } from 'react'; +import type { TabId } from '../types'; +import { colors } from '../styles/tokens'; + +/** + * Inspect text field + custom suggestions dropdown. + * + * This replaces a native + pair. The + * native datalist is unreliable when its owning subtree is physically + * relocated between documents via DetachableTabHost — Chromium in + * particular has been observed to show the dropdown in the wrong + * window (or not at all in the window being typed into) when a tied + * detached tab shares the same `inspectQuery` state with the main + * window's active tab overlay. + * + * By rendering the suggestion list ourselves, as a plain + * absolutely-positioned div sibling of the input, the dropdown is + * guaranteed to live in the same DOM subtree as the input it belongs + * to, in whichever window that subtree happens to be. It therefore + * renders reliably whether the overlay sits in the main window or in + * a detached popup, regardless of the tied state. + */ +const InspectSearchField: React.FC<{ + tabId: TabId; + inspectQuery: string; + onChangeQuery: (tab: TabId, q: string) => void; + filteredInspectables: string[]; +}> = ({ tabId, inspectQuery, onChangeQuery, filteredInspectables }) => { + const [focused, setFocused] = useState(false); + // Keep the dropdown visible long enough for an option click to + // register before onBlur hides it (click fires after blur). + const closeTimer = useRef(null); + + // Hide the dropdown after a matched commit so the user isn't left + // with a hovering suggestion panel while zoomed in on the asset. + const exactMatch = inspectQuery.length > 0 + && filteredInspectables.some(v => v.toUpperCase() === inspectQuery.toUpperCase()); + const showDropdown = focused && inspectQuery.length > 0 && filteredInspectables.length > 0 && !exactMatch; + + return ( +
+ onChangeQuery(tabId, e.target.value)} + onFocus={() => { + if (closeTimer.current !== null) { + window.clearTimeout(closeTimer.current); + closeTimer.current = null; + } + setFocused(true); + }} + onBlur={() => { + // Defer the hide so onMouseDown on an option can + // complete and fire its onClick before the + // dropdown unmounts. + closeTimer.current = window.setTimeout(() => { + setFocused(false); + closeTimer.current = null; + }, 150); + }} + placeholder="🔍 Inspect..." + style={{ + padding: '5px 10px', + border: inspectQuery ? `2px solid ${colors.brand}` : `1px solid ${colors.border}`, + borderRadius: '4px', + fontSize: '12px', + width: '180px', + boxShadow: '0 2px 5px rgba(0,0,0,0.15)', + background: 'white', + }} + /> + {showDropdown && ( +
+ {filteredInspectables.map(item => ( +
{ + e.preventDefault(); + onChangeQuery(tabId, item); + }} + style={{ + padding: '5px 10px', + cursor: 'pointer', + borderBottom: `1px solid ${colors.borderSubtle}`, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }} + onMouseEnter={e => { (e.currentTarget as HTMLDivElement).style.backgroundColor = colors.brandSoft; }} + onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.backgroundColor = 'white'; }} + > + {item} +
+ ))} +
+ )} +
+ ); +}; + +export default InspectSearchField; diff --git a/frontend/src/components/SldOverlay.tsx b/frontend/src/components/SldOverlay.tsx index 8fdfc0a5..d0ddc6c0 100644 --- a/frontend/src/components/SldOverlay.tsx +++ b/frontend/src/components/SldOverlay.tsx @@ -825,7 +825,18 @@ const SldOverlay: React.FC = ({
{(['n', 'n-1', 'action'] as SldTab[]).filter(tabMode => { if (tabMode === 'n-1') return !!n1Diagram; - if (tabMode === 'action') return !!actionDiagram; + // The SLD's "action" sub-tab depends on the + // overlay being scoped to an action — NOT on + // the main-window NAD already being on the + // action variant. The overflow-graph pin + // double-click opens the SLD overlay with + // ``vlOverlay.actionId`` set BEFORE any + // top-level action diagram has been loaded; + // hiding the tab in that case would strand + // the operator on a card-less variant view. + if (tabMode === 'action') { + return !!actionDiagram || !!vlOverlay.actionId; + } return true; // always show N }).map(tabMode => ( - -
- + ); return ( @@ -988,17 +915,106 @@ const VisualizationPanel: React.FC = ({ Geo + {/* Pin overlay toggle. Only enabled once + the action analysis is complete (the + gate is owned by App.tsx through + `overflowPinsAvailable`). */} + {onOverflowPinsToggle && (() => { + const pinsDisabled = !overflowPinsAvailable; + const tip = pinsDisabled + ? 'Run "Analyze & Suggest" to compute action pins' + : (overflowPinsEnabled + ? 'Hide action pins on the overflow graph' + : 'Show action pins on the overflow graph'); + return ( + + ); + })()} ); })()} {result?.pdf_url ? (