Skip to content

Surface target max rho on user-selected overloads in pair estimation - #114

Merged
marota merged 8 commits into
mainfrom
claude/combine-load-shedding-actions-xpifM
Apr 23, 2026
Merged

marota merged 8 commits into
mainfrom
claude/combine-load-shedding-actions-xpifM

Conversation

@marota

@marota marota commented Apr 23, 2026

Copy link
Copy Markdown
Collaborator

Summary

This PR adds "target max rho" metrics to combined action pair estimation, allowing the UI to surface the pair's effect on the user-selected overloaded lines alongside the global max rho. This addresses a regression where linearisation error could place the global max on an unrelated line, obscuring the pair's actual impact on the contingency being resolved.

Key Changes

Backend (expert_backend/)

  • simulation_mixin.py:

    • Modified compute_superposition() to reuse the N-1 observation from analysis context (obs_simu_defaut) instead of fetching a fresh one, ensuring numerical consistency with pre-computed pairs (Grid2Op's simulate() can mutate the shared N-1 variant)
    • Added explicit N-1 variant pinning in simulate_manual_action() immediately before obs.simulate() to prevent simulation against the N state when observation caches hit
    • Updated _superposition_lines_overloaded() to prefer step1-populated context keys (lines_overloaded_ids, lines_overloaded_names) over session-reload keys, keeping on-demand re-estimation aligned with pre-computed discovery
  • analysis_mixin.py:

    • Added _augment_combined_actions_with_target_max_rho() method to enrich pre-computed pairs with target metrics scoped to user-selected overloads
    • Integrated target augmentation into run_analysis_step2() after discovery completes
  • simulation_helpers.py:

    • Added compute_target_max_rho() helper to calculate max rho/line over a subset of lines, returning (0.0, "N/A") when no overloads are available

Frontend (frontend/src/)

  • ExplorePairsTab.tsx:

    • Restructured action bar to show "Simulate Combined" button alongside "Estimate" in no-preview state
    • Added separate "Simulate Combined" button on top of comparison card when preview exists but no simulation has run
    • Updated button text and disabled states to reflect load-shedding/curtailment restrictions
    • Hides action bar once simulation completes
  • ExplorePairsTab.test.tsx:

    • Added comprehensive test coverage for new button states and visibility logic
    • Tests verify target max rho display, comparison card rendering, and button enable/disable conditions
  • ComputedPairsTable.tsx/test.tsx:

    • Added target_max_rho and target_max_rho_line fields to ComputedPairEntry interface
    • Renders target subtitle when target line differs from estimated line
  • CombinedActionsModal.tsx:

    • Passes target_max_rho and target_max_rho_line from backend response to modal data
  • types.ts:

    • Extended CombinedAction interface with target max rho fields

Tests

  • test_superposition_service.py: Added 8 new regression tests covering context reuse, fallback behavior, target max rho emission, and augmentation logic
  • test_combined_actions_scenario.py: Added integration test verifying on-demand compute_superposition() matches pre-computed pair values
  • test_cache_synchronization.py: Updated to reflect additional _get_n1_variant call from explicit pinning

Notable Implementation Details

  • The fix preserves the global max_rho scan to catch newly-introduced overloads while adding a scoped "target" metric for the contingency's actual overloads
  • Context observation reuse ensures on-demand pair re-estimation produces identical betas to the pre-computed "Computed Pairs" view, preventing drift from Grid2Op's in-place variant mutations
  • Explicit N-1 variant pinning before simulate prevents physically impossible states (non-zero rho on disconnected contingency line) when observation caches hit
  • Target fields always present in payload with "N/A" sentinel when no target info available, simplifying frontend branching logic

https://claude.ai/code/session_01HBuTAKw7NKbfoRREE4XFU2

claude and others added 8 commits April 23, 2026 09:12
…imulate-only)

Load shedding and renewable-curtailment actions were blocked from
selection in the Explore Pairs combination picker because the
superposition-theorem estimator does not support them. Their
simulation, however, is still well-defined — the previous block
prevented a perfectly valid combined study.

- Drop the selection-time restriction in CombinedActionsModal so any
  action pair can be picked, including LS/RC.
- Surface a persistent "Simulate Combined" button in ExplorePairsTab
  (both the no-preview state and the comparison card), independent
  from the estimation flow. Estimate remains disabled when an LS/RC
  action is among the selected pair and explains why via tooltip
  and button label.
- Update the associated Vitest specs.

https://claude.ai/code/session_01HBuTAKw7NKbfoRREE4XFU2
…after simulate-only

Previously the Estimate + Simulate buttons sat inside the comparison-card
header, and the card was gated on a successful estimate. This made the
simulate-only path (required for load shedding / curtailment pairs) feel
bolted on — after Simulate Combined there was nowhere to read the result
unless a preview happened to exist.

- Remove both buttons from the card header.
- Render the card whenever there is something to show: an estimate, a
  running simulation, or a simulation result. The Estimated Effect
  column is skipped when no preview exists, so the simulate-only case
  shows a single "Simulation Result" column.
- Surface "Simulate Combined" as a full-width button above the card
  while an estimate is visible but no simulation has been run yet;
  hide it once the simulation feedback lands.
- Keep the two-button no-preview area (Estimate + Simulate Combined)
  below the action table for the initial state only.
- Tighten the title: "Explore Pairs Comparison" when an estimate is
  present, "Simulation Result" when only simulation exists.

https://claude.ai/code/session_01HBuTAKw7NKbfoRREE4XFU2
…ed betas

Estimating a pair via Explore Pairs (POST /api/compute-superposition)
could return very different betas than the same pair's entry in the
step2-computed "Computed Pairs" view (e.g. [3.193, 1.479] vs the
pre-computed [1.10, 0.92], sometimes flagged as "unreliable —
betas outside [-2.0, 3.0]"). Two causes, both corrected:

1. `obs_start` drift. The on-demand path fetched a fresh N-1
   observation via `env.get_obs()` after `_get_n1_variant`. Each
   preceding `simulate_manual_action` used grid2op's
   `obs.simulate(action, keep_variant=True)`, which can mutate the
   shared N-1 variant — so the fresh fetch drifted from the step1
   baseline that step2's discovery used. Prefer the N-1 observation
   captured in `_analysis_context.obs_simu_defaut` when available;
   fall back to `env.get_obs()` only when no context exists.

2. Monitoring-set mismatch. `_superposition_lines_overloaded` only
   read `_analysis_context["lines_overloaded"]`, a key populated
   exclusively by `restore_analysis_context` (session reload). After
   a fresh analysis the step1 context exposes the overload set as
   `lines_overloaded_ids` / `lines_overloaded_names`, so the lookup
   always missed and the code recomputed from `obs_start.rho` —
   producing a monitoring set diverging from what step2 used, and
   thus a different `max_rho_line` / `max_rho` for the same pair.
   Extend the lookup order to prefer `lines_overloaded_ids`, then
   `lines_overloaded_names`, and finally the legacy
   `lines_overloaded` key.

Also adds:
- Unit tests pinning context-obs reuse, fallback to fresh fetch, and
  the new lookup ordering in `_superposition_lines_overloaded`.
- A real-data regression test in `test_combined_actions_scenario.py`
  asserting on-demand betas / max_rho agree (within 1e-3) with the
  step2 pre-computed entry for an identical pair; the test module
  is already skipped when pypowsybl / expert_op4grid_recommender are
  not installed.
- Follow-up Vitest coverage for the prior UI refactor (top Simulate
  click routing, disabled state during simulation, card title flip
  between "Explore Pairs Comparison" and "Simulation Result").

https://claude.ai/code/session_01HBuTAKw7NKbfoRREE4XFU2
…load set

When Run Analysis pre-computes a combined pair, the library picks
max_rho_line as the globally highest linearised rho across every
monitored line — so it can warn on new overloads the pair might
introduce. But on lines far from either action the superposition
linearisation is inaccurate: it can put an arbitrary far-from-
contingency line (e.g. "LOUHAL31PYMON at 81.9%") at the top of the
scan even when the actual simulation finds the max on a different,
unrelated line (e.g. "BOCTOL71N.SE5 at 52%"). The estimate and the
simulation end up pointing at different lines at different values,
both below the monitoring factor — confusing operators who are
really asking "did the pair resolve my overloads?".

This change preserves the global-max warning semantics (required by
`test_superposition_max_rho_filtering_regression`) and adds a
side-channel target_max_rho / target_max_rho_line, scoped to the
user-selected `lines_overloaded_ids`. Both the on-demand Explore
Pairs re-estimation and the step2 Computed Pairs view get the new
fields.

- `simulation_helpers.compute_target_max_rho` — shared helper that
  picks (rho, line) over the overloaded-id set only.
- `SimulationMixin._augment_superposition_result` — emits target_*
  alongside max_*.
- `AnalysisMixin._augment_combined_actions_with_target_max_rho` —
  post-processes each library-populated pair with the same helper,
  leaving the library's max_rho_line untouched.
- Frontend `CombinedAction` type gains target_max_rho /
  target_max_rho_line (both optional).
- Explore Pairs comparison card shows a small "Target overload:
  X% on LINE" line under the estimated effect when the target line
  differs from the global estimated line.
- Computed Pairs table shows a "target: X% on LINE" subtitle under
  the Line (Est.) cell with the same condition.

Tests: 4 new backend specs (on-demand emits target fields, shape
stability without analysis context, step2 augment with target
fields, no-op without context) + 2 new frontend specs on each UI
surface. 37/37 backend superposition tests pass, 1156/1156
frontend Vitest specs pass, lint + typecheck clean.

https://claude.ai/code/session_01HBuTAKw7NKbfoRREE4XFU2
The previous commit that added `target_max_rho` augmentation
accidentally deleted the `enriched_actions` block in
`AnalysisMixin.run_analysis_step2`, producing a runtime
`NameError: name 'enriched_actions' is not defined` the moment the
final `{type: "result"}` event tried to include the enriched action
feed — surfaced to the frontend as "Backend Error in Analysis
Resolution".

Restored the `_enrich_actions` call and the combined-id filter.
Added a regression test that drives the real
`AnalysisMixin.run_analysis_step2` generator end-to-end (not through
the endpoint-level mock seam used by `test_split_analysis`) and
asserts a typed `pdf` + `result` event pair with no `error` event.
The previous split-analysis test mocked the whole
`recommender_service.run_analysis_step2` call so it could not have
caught a body-level NameError — the new test closes that gap.

https://claude.ai/code/session_01HBuTAKw7NKbfoRREE4XFU2
…pair path

Symptom: in the Computed Pairs table, the "Simulated Line" column can
land on the contingency line itself (e.g. P.SAOL31RONCI at 52.3%
after running analysis for that contingency) — physically impossible
in N-1 where that line is disconnected and rho must be 0.

Root cause: `simulate_manual_action` calls
`_fetch_n_and_n1_observations` which only invokes
`set_working_variant` when a cache MISSES (lines 314-330 of
simulation_mixin.py).  When both caches hit — the common case after
step1 has already primed them — the working variant is left on
whatever the previous caller positioned.  The N branch a few lines
above tends to set it to N when the N cache misses while the N-1
cache hits, so the net effect after the helper returns is "variant
= N".  The subsequent `obs_simu_defaut.simulate(action,
keep_variant=True, ...)` then applies the combined action ON TOP OF
the CURRENT working variant in-place — so the action runs against
the N state, not N-1, and the result includes a non-zero rho on the
contingency line.

Fix: re-pin the working variant to the N-1 id (`_get_n1_variant`)
immediately before the `.simulate()` call.  This is cheap (variant
lookup is cached) and defensive: it guarantees the simulation runs
against N-1 regardless of what the cache-hit path or any upstream
caller did with the variant.

Regression test (test_superposition_service.py) drives
`simulate_manual_action` with both N and N-1 caches primed and
tracks the order of `set_working_variant` calls vs the `.simulate`
call — asserts the last variant set BEFORE simulate is the N-1 id.
Also updates `test_cache_invalidation_on_contingency_switch` to
account for the extra `_get_n1_variant` lookup per call (was 2,
now 3).

Note: I was unable to reproduce the symptom locally — this sandbox
doesn't have pypowsybl / expert_op4grid_recommender installed.  The
fix is derived from reading the cache / variant flow and matches
the exact observed symptom (contingency line appearing as simulated
max).  Please re-run the Computed Pairs flow with
config_small_grid.json + P.SAOL31RONCI to confirm.

https://claude.ai/code/session_01HBuTAKw7NKbfoRREE4XFU2
The "Simulated Max Rho" column in the Combine Actions → Computed
Pairs modal diverged from "Max Loading (Est.)" by up to ~46 points
on the same line (e.g. 64.9% vs 52.0% on BOCTOL71N.SE5). The library
internal verify `_verify_pair_max_rho_by_simulation` reports mean
|gap| < 0.01, so the estimator is sound — the divergence is
entirely in the backend's `simulate_manual_action` path.

Root cause: `_fetch_n_and_n1_observations` calls `env.get_obs()`
after `n.set_working_variant(n1)`, but the grid2op ↔ pypowsybl env
bridge does not re-sync the returned observation to the current
working variant. On the small test grid with contingency
P.SAOL31RONCI, the backend's "N-1" obs carried rho=0.43 on the
contingency line itself (it should be ≈0 in N-1), i.e. it was
really the N state. The combined action then simulated against the
wrong baseline → large rho drift on every downstream line.

Fix: prefer the (obs, obs_simu_defaut) pair already captured by
step1 and stored on `_analysis_context` — exactly the pattern
`compute_superposition` already uses via `_obs_n1_from_context`.
Do NOT overwrite `obs_simu_defaut._variant_id`: the library stamped
it at step1 with its own kept variant, and
`pypowsybl_backend.observation.simulate` clones from
`self._variant_id` directly. The pre-simulate working-variant
re-pin is now gated on the fallback branch only — the context path
is variant-safe without it.

Limitation carried over: session reload
(`restore_analysis_context` doesn't serialize obs objects) and
direct `/api/simulate-manual-action` without a prior step1 still
exercise the stale-fetch fallback. Deeper fix requires repairing
the env re-sync on `set_working_variant` — tracked separately.

Validation on contingency P.SAOL31RONCI (top 15 pairs):

  est − backend_sim mean|gap|: 0.3461 → 0.0011
  est − backend_sim max |gap|: 0.4735 → 0.0102
  lib_sim − backend_sim mean|gap|: 0.3452 → 0.0000
  line match est vs backend_sim: 15/15

Tests: adds two regressions in `test_cache_synchronization.py`
covering the context-prefer path (get_obs never called, rho_before
propagated from the context obs) and the fallback path (two
get_obs calls + N-1 re-pin fired). Also ships the end-to-end
diagnostic script `scripts/test_estimation_vs_simulation_small_grid.py`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ombined pair

The Computed Pairs card for a combined action like
`load_shedding_BEON3 TR311+reco_GEN.PY762` rendered only the
load-shedding voltage level as a clickable badge; the reco'd line
was dropped. Same behaviour for curtailment + reco, or any
combination where one leg is a topology action and the other is
load-shedding / curtailment. Pairs of the same kind
(two load sheddings, two topology actions) rendered correctly.

Root cause: `renderBadges()` in ActionCard.tsx used an
`if (isLoadShedding) { … } else if (isRenewableCurtailment) { … }
else { topology extraction }` chain. The presence of a single
load-shedding or curtailment detail short-circuited to its own
branch, skipping the topology extraction that would have surfaced
the reco / disco / coupling sub-action's line or VL.

Fix: collect badges cumulatively. Load-shedding VLs, curtailment
VLs, topology-derived VLs, topology-derived lines, and the
single-equipment fallback each contribute to a shared `badges`
array with a shared de-dup `vlSet`, so a combined pair always
gets one badge per sub-action. This mirrors the split-and-evaluate-
per-part pattern commit 150fd2a applied to `getActionTargetLines`
in utils/svg/highlights.ts.

Tests: two new regressions in ActionCard.test.tsx cover
`load_shedding + reco` and `curtail + reco`, asserting both the
LS/RC voltage level and the reco line are rendered as clickable
`<button>` elements.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@marota
marota merged commit 07484fb into main Apr 23, 2026
8 checks passed
marota pushed a commit that referenced this pull request Apr 24, 2026
Follow-up to the root/frontend/backend CLAUDE.md + README refresh.
Audits the remaining project docs against PRs merged 2026-04-21 →
2026-04-23 and updates the eight that had actually drifted.

frontend/README.md (HIGH)
  Replaced the stock Vite-template boilerplate with a real frontend
  orientation: npm scripts, source-tree map (hooks, components,
  utils incl. the utils/svg/* split from PR #104), pointer to the
  auto-generated standalone bundle, testing + lint commands, and
  cross-refs to CLAUDE.md / PARITY_AUDIT.md / docs/README.md.

expert_backend/tests/CLAUDE.md (MEDIUM)
  Full rewrite of the test-file inventory: added the PR #104
  decomposition suites (test_simulation_helpers.py,
  test_analysis_helpers.py, test_diagram_helpers.py), the patch-
  endpoint coverage (test_diagram_patch_helpers.py,
  test_n1_diagram_fast_path.py) from PR #108, the regression
  guards (test_resimulate_regression.py,
  test_second_contingency_reset.py,
  test_get_n1_variant_clones_from_n_state.py,
  test_configurable_mw.py) that were added since 2026-04-11, and
  the dynamic reco_* reconnection path from PR #110. Replaced the
  frontend inventory with a structural summary matching the
  current ~60-file Vitest layout, listed the scripts/pypsa_eur
  pytest coverage, and documented the tests removed by PR #103 /
  #104 (test_ui_regressions.py, standaloneInterface.test.ts,
  cssRegression.test.ts).

docs/features/combined-actions.md (MEDIUM)
  Added a "Recent updates (PR #114, release 0.6.5)" section
  covering LS/curtailment in combined pairs, Simulate Combined
  moved out of the card + clickable sub-action badges,
  target_max_rho on the user-selected overload set, and Explore-
  Pairs re-estimation aligned with the pre-computed betas.
  Rewrote the "Standalone Interface" section to describe the
  auto-generated bundle, and dropped the checklist line that
  required manual standalone_interface.html mirroring. Added a
  row to the key-files table for the ActionCard sub-action badges
  and the ComputedPairsTable / ExplorePairsTab split.

docs/features/state-reset-and-confirmation-dialogs.md (MEDIUM)
  Replaced the standalone_interface.html section with a pointer
  to the auto-generated bundle, and expanded the "What reset()
  clears" list to match the current RecommenderService.reset()
  implementation (drain order + fast-path caches +
  _layout_cache + NAD-prefetch state, with the
  add-a-new-cache-goes-here guardrail).

docs/features/detachable-viz-tabs.md (MEDIUM)
  Removed the "standalone_interface.html mirror" row and the
  follow-up caveat that said the single-file interface doesn't
  support detaching — both are obsolete now that the bundle is
  auto-generated from the React tree.

docs/architecture/app-refactoring-plan.md (MEDIUM)
  Added a "Status: SHIPPED" banner at the top summarising both
  refactor waves (Phase 1 via PR #74, Phase 2 hook extraction
  via PR #109) and pointing readers at the current source of
  truth (frontend/CLAUDE.md + CHANGELOG.md 0.6.5). Preserved the
  original plan below the banner for historical value.

docs/architecture/phase2-state-management-optimization.md (LOW)
  Added a "Status: Partially shipped" banner — memoization +
  React.memo pass shipped under PR #75, superseded in part by
  PR #109 (useN1Fetch / useDiagramHighlights extractions),
  orchestrator hooks still deferred.

docs/features/frontend-ui-improvements.md (LOW)
  Scope header now names `frontend/dist-standalone/standalone.html`
  (auto-generated, PR #101) instead of the decommissioned
  hand-maintained file.

https://claude.ai/code/session_01RsvHjjFbAauF3NvD9BNT5V
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants