Skip to content

Add ToOp topology optimizer integration as combined-action recommender#159

Open
marota wants to merge 14 commits into
mainfrom
claude/integrate-toop-recommender-FJOto
Open

Add ToOp topology optimizer integration as combined-action recommender#159
marota wants to merge 14 commits into
mainfrom
claude/integrate-toop-recommender-FJOto

Conversation

@marota

@marota marota commented Jun 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

Integrates Elia Group's ToOp (topology optimizer) as an optional recommender model that surfaces N-1 congestion-relieving topologies as single combined actions. Each ToOp candidate topology is treated as one merged grid2op action (all line toggles + busbar splits folded together), simulated as a whole by the assessment phase to yield the true combined loading.

Key Changes

Backend (expert_backend/)

  • recommenders/toop.py (696 lines, new)

    • ToOpRecommender class: wraps ToOp's run_pipeline with lazy imports (optional install on Python 3.11 + GPU deps)
    • Network export to XIIDM, ToOp invocation with configurable runtime budget and contingency count
    • Per-topology diff against original grid (line connection flags + per-VL switch states)
    • Merge all changes into one combined action content (set_bus + switches dict)
    • Four tunable parameters: n_prioritized_actions, include_busbar_splits, runtime_seconds, n_worst_contingencies
    • Stashes topology groupings and synthesised dict_action entries for service integration
  • recommenders/__init__.py (modified)

    • Register ToOpRecommender in the model registry
  • recommenders/_service_integration.py (modified)

    • Post-assessment decoration: inject ToOp topology metadata (is_toop_topology, constituent_ids, constituent_count) into enriched actions
    • Inject synthesised dict_action entries so cards remain re-simulatable and session-saveable
    • Decorate each topology in the combined_actions channel
  • tests/test_toop_recommender.py (373 lines, new)

    • Registry wiring tests (model listed, params declared)
    • Optional-install degradation (returns empty output when ToOp/omegaconf unavailable)
    • _is_line_open helper tests (terminal connection logic)
    • _merge_topology_content tests (line toggles, VL splits, enrich integration, precedence)
    • _build_topology_actions tests (one action per topology, diff logic, capping, action-space rejection)

Frontend (frontend/src/)

  • types.ts (modified)

    • ActionDetail interface: add is_toop_topology, constituent_ids, constituent_count fields
  • components/ActionCard.tsx (modified)

    • Render constituent-move chips (one per line toggle / busbar split) when is_toop_topology is true
    • Styled as small badges with muted background, wrapped with gap
  • components/ActionCard.test.tsx (modified)

    • Test rendering of constituent chips on ToOp topology cards
    • Test that non-ToOp cards do not render the constituent strip

Implementation Details

  1. Lazy imports: _import_run_pipeline(), _import_dictconfig(), _import_enrich() return None on import failure, allowing graceful degradation (empty recommendation + log line) instead of crashing the step-2 NDJSON stream.

  2. Network diff: Compares original XIIDM against each modified_network.xiidm along two axes:

    • Line connection flags (connected1 / connected2 columns)
    • Per-VL internal switch states (open column, grouped by voltage_level_id)
  3. Content merge: Folds line toggles and per-VL busbar splits into one action via enrich_actions_lazy (resolves switch sets to per-connectable set_bus assignments), then unions all set_bus / switches dicts. Line toggles applied last so explicit open/close wins over a split's bus assignment for the same branch.

  4. Service integration: Stashes _last_topology_groups and _last_topology_dict_entries on the recommender instance; the service integration reads them

https://claude.ai/code/session_01Eih4rx5r2rh3K6DJDwhtY6

claude added 14 commits May 19, 2026 14:43
…ine-switching MVP

ToOp is an open-source topology optimization engine from Elia Group
(https://github.com/eliagroup/ToOp). This first integration surfaces
its line-switching suggestions through the existing pluggable
recommender contract; busbar splits / reassignments are deferred to a
follow-up once the line-switch path is exercised on real grids.

ToOp pins Python 3.11 and pulls in heavy GPU dependencies (JAX, qdax,
Ray, …), so the package is **not** added to requirements — the
ToOpRecommender lazy-imports `toop_engine_topology_optimizer` inside
`recommend()` and degrades to an empty output with a single log line
when ToOp isn't installed, rather than crashing the step-2 NDJSON
stream.

Pipeline inside `recommend()`:

1. Export the live pypowsybl Network to a temporary CGMES bundle
   (ToOp's importer ingests CGMES / UCT, not XIIDM).
2. Build the DictConfig quadruple matching example2 from the upstream
   notebooks, restricted to a single `branch_switches` descriptor so
   Map Elites prioritises the line-switching axis only.
3. Run `run_pipeline` synchronously, bounded by a tunable
   `runtime_seconds` parameter.
4. Parse the Pareto front for line-switching decisions (tolerant
   parser accepts dict / object / iterable shapes — extends cleanly
   once the upstream return shape is finalised).
5. Translate each switch to the Co-Study4Grid action format
   `{"set_bus": {"lines_or_id": {line: ±1}, "lines_ex_id": {line: ±1}}}`
   via `env.action_space`, preferring an existing `disco_<line>` /
   `reco_<line>` entry from the operator's `dict_action` when one is
   available so suggestions match the vocabulary the user already
   uses.
6. Rank by ToOp's `overload_energy_n_1` metric (negated so the UI's
   "higher is better" sort agrees) and return the top-N.

The new model auto-surfaces in the React Settings → Recommender
dropdown via `GET /api/models`; no frontend change required.
…ible

The first real-grid run reported a model that returned 0 actions in
milliseconds with NO log line from the recommender — uvicorn's default
level suppresses our INFO logs. That made it impossible to tell which
of the 6 early-exit paths in `recommend()` was being taken (ToOp not
importable / omegaconf missing / env None / CGMES export failed /
run_pipeline raised / empty Pareto front).

Bump every early-exit log line from INFO to WARNING and add entry /
mid-flight / exit log points so each `recommend()` invocation now
emits an observable trail at the default backend log level:

- entry: params, env / network types, dict_action size
- post-import: whether ToOp + omegaconf imported successfully
- pre-pipeline: CGMES export path + run_pipeline kwargs
- post-pipeline: pareto return type, count of extracted switches
- exit: list of prioritized action ids returned

Operator impact: a re-run with the ToOp model now produces enough
backend log output to pinpoint the failure without bumping the log
level or attaching a debugger.
…chema

First real-grid run revealed that my pipeline_cfg / dc_optim_config
were wrong — ToOp's `run_pipeline` failed immediately with
`ConfigAttributeError: Missing key root_path`. Source inspection of
the installed package + cross-check with
`notebooks/example2_small_grid_toop.ipynb` exposed two structural
mistakes:

1. **`pipeline_cfg` is a typed `PipelineConfig`**, not a free-form
   DictConfig. It needs `root_path`, `iteration_name`, `file_name`
   and `grid_type` — and the referenced grid file must exist on
   disk before `get_paths()` is called (it raises FileNotFoundError
   otherwise).
2. **`dc_optim_config` has a nested `ga_config` sub-key** where my
   parameters (`runtime_seconds`, `me_descriptors`,
   `observed_metrics`, `n_worst_contingencies`) actually live.
   The top level wants the orchestration knobs
   (`task_name`, `tensorboard_dir`, `stats_dir`, `output_json`,
   `lf_config`, `num_cuda_devices`, …).
3. **ToOp accepts XIIDM natively** — the example notebook loads
   `grid.xiidm` directly. Saved one CGMES round-trip and several
   header-mapping failure modes.
4. **`importer_parameters` must come from
   `prepare_importer_parameters(file_path, data_folder)`**, not
   from a hand-built DictConfig. Same for
   `preprocessing_parameters` (typed `PreprocessParameters` with
   `action_set_clip` / `enable_bb_outage` / `bb_outage_as_nminus1`).
5. **The Pareto front lives in `output.json`**, written under
   `iteration_path / results / output.json` by the DC-optimisation
   stage. `run_pipeline` itself returns `topology_paths` — the
   per-topology files written by AC validation, not the elite map.

Resulting changes:

- `_export_to_cgmes` → `_export_network`: writes
  `<work_dir>/iter/grid.xiidm` via `network.save(..., "XIIDM")`,
  with fallback to other extensions when pypowsybl appends one.
- `_run_toop`: lazy-imports `PipelineConfig`, `PreprocessParameters`,
  `get_paths`, `prepare_importer_parameters`; builds the
  PipelineConfig + DictConfig pair matching `example2_small_grid_toop`
  with a single `branch_switches` MapElites descriptor; passes
  through the user's `runtime_seconds` / `n_worst_contingencies` /
  `n_prioritized_actions` via `ga_config`.
- `_load_output_json`: reads the optimisation output JSON and logs
  its top-level keys / first-entry keys before handing it to
  `_extract_line_switches` (the parser is already shape-tolerant).
- Tests: rename `_export_to_cgmes` mocks, patch `_run_toop` in the
  happy-path test so it bypasses the real ToOp config builders
  (which only exist when the package is installed).
ToOp's BatchedMEParameters pins observed_metrics and the descriptor
metric to a closed pydantic Literal enum. The line-switching name I
picked (`branch_switches`) isn't in it — `disconnected_branches` is.
This was visible in the preprocessing log line `n_disc_branches: 8`
and is confirmed by the pydantic ValidationError listing every valid
value.

This switch keeps the Map Elites search axis aligned with line
toggling (cells distinguish topologies by the number of branches
they open), and surfaces the same metric alongside
`overload_energy_n_1` so we can rank elites by congestion reduction.
…f5 path

DC optimisation aborted on `verify_static_information` →
`next(iter(static_informations))` → StopIteration because
`fixed_files=()` was empty. The notebook example threads
`static_information_file` through that list; in our case the file is
`data_folder / pipeline_cfg.static_info_relpath`. Preprocessing
writes it before the optimiser runs, so we just need to declare the
future path.
…tworks

ToOp ran end-to-end successfully on the first integration test:
preprocessing → 2375 epochs of DC optimisation → AC validation,
producing one topology with a modified_network.xiidm that opened
zero lines (the small-grid resolution didn't need line switching).

Two changes were needed to surface its results:

1. `run_pipeline` does NOT write to the `output_json` config field I
   wired earlier — it writes to
   `<snapshot_dir>/run_*/topology_*/modified_network.xiidm` and
   returns the list of topology directory paths from the AC
   validation stage. My `output.json` parser is now dead code; left
   for future use when ToOp's Pareto schema gets documented.

2. Extracting line-switches by reading ToOp's internal Pareto
   genome (in `res.json`) requires reverse-engineering an
   undocumented format. Much more robust: load each topology's
   `modified_network.xiidm` back through pypowsybl, compare per-line
   `connected1`/`connected2` flags against the input grid, and
   surface the differences as `(line_id, ±1, rank-as-score)` tuples.
   ToOp returns its topology list best-first, so rank is a faithful
   proxy for elite quality.

New module-level helper `_is_line_open` consolidates the
per-terminal flag check (with a tolerant fallback to older
`connected` column names). New instance method
`_extract_switches_from_topology_paths` orchestrates the diff and
de-duplicates `(line, status)` pairs across topologies keeping the
best rank.

If a future ToOp release does write the `output_json`, the
fast-path still works because the file-exists check still runs
first — but the diff path is the canonical extractor going forward.
…tion match

ToOp's first real run resolved an overload by splitting a substation
(VLevel2) rather than opening a line — visible in the log
("Saving SLDs of split stations..."). The line-switching MVP
correctly returned 0 actions for that case, but the operator saw an
empty feed and no signal that ToOp had actually proposed something
useful.

This commit extends the recommender to detect busbar splits and
surface matching `dict_action` entries:

- New `_extract_busbar_splits_from_topology_paths`: per topology,
  diff `Network.get_switches()` between the input grid and
  `modified_network.xiidm`. Voltage levels whose internal switches
  changed open/closed state — but whose line terminals did NOT —
  are flagged as busbar splits. De-duplicated across topologies
  keeping the best (lowest) ToOp rank as score.

- New `_materialise_busbar_actions`: for each VL ToOp split, surface
  every `dict_action` entry whose `VoltageLevelId` (or alias
  `voltage_level_id`) matches. We piggy-back on the operator's
  curated substation-action vocabulary rather than synthesising one
  from ToOp's raw switch list — that translation requires
  substation-specific knowledge the operator already encoded.
  When a split VL has no matching entry, log a clear "consider
  adding split_<vl> to your action library" warning so the gap is
  actionable.

- `recommend()` calls both extractors and merges, capping the
  surfaced list at `n_prioritized_actions`. Line-switch matches win
  on key collisions (their action ids never overlap with substation
  ids in practice, but be safe).

- New `include_busbar_splits` boolean parameter (default `True`)
  surfaces in the Settings → Recommender params dropdown. Disable
  to restrict ToOp's output to line switches only.

- Model label tightened from "ToOp (Elia Group — line switching)"
  to just "ToOp (Elia Group)" now that both action surfaces are
  covered.

The busbar path is intentionally conservative: ToOp tells us *which*
substation to reconfigure, the operator's library tells us *how*.
If a richer integration is needed later (e.g. surfacing ToOp's
exact switch list as a synthetic action), the seam is the
`_materialise_busbar_actions` method.
…bar splits

Previous busbar implementation piggy-backed on the operator's
dict_action vocabulary (matching by VoltageLevelId), which left
empty-handed any grid whose action library lacked split_<vl>
entries. The pypowsybl backend in expert_op4grid_recommender
actually accepts switch-action content directly — same shape as the
operator-curated coupling actions in
data/action_space/reduced_model_actions_test.json:

    {
      "description": ...,
      "VoltageLevelId": "<vl_id>",
      "switches": {"<vl_id>_<switch_id>": true|false, ...},
      "content": None,  # populated by enrich_actions_lazy
    }

(`true` = open, `false` = closed; switch ids are VL-prefixed.)

Rewrite extracts and surface accordingly:

- `_extract_busbar_splits_from_topology_paths` now returns
  `[(vl_id, {switch_id: new_open_state}, rank)]` triples — capturing
  *which* switches flipped and to *what* state, not just the
  affected VL. De-duplicated by VL keeping the best-rank topology's
  switch set.

- `_materialise_busbar_actions` synthesises one action per VL ToOp
  split. For each, it builds a raw dict_action entry with the
  required `VoltageLevelId` + `switches` fields, runs the entry
  through `enrich_actions_lazy(raw, network)` to populate `content`
  (per-connectable bus assignments) from the live network, then
  hands the populated content to `env.action_space`. The result is
  a fully materialised pypowsybl-backend action that shows up in
  the React feed as `toop_split_<vl_id>` with a description listing
  the switch operations.

- Defensive VL-prefix handling: switch ids already prefixed with
  `<vl_id>_` aren't double-prefixed (covers the case where
  pypowsybl exposes switches with VL-qualified ids on some grids).

- The whole synth pipeline is no-op when `enrich_actions_lazy`
  isn't importable, or when it raises, or when it doesn't populate
  `content`. Each branch logs a clear, operator-actionable warning;
  no silent empty surface.

Tests updated for the new signature and behaviour. Six fresh cases
cover: synthesis via mocked enrich, already-prefixed switch ids,
empty switches skip, enrich failure → empty, unpopulated content
warned + skipped, and env-rejection skipped.
Operator feedback: ToOp optimises *whole topologies*, and its
elementary moves are only meaningful together — the prior approach
(flattening each topology into independent disco_*/split cards)
surfaced suggestions that look neutral or worse in isolation, hiding
the real combined congestion relief (e.g. f0=-61.4 → f=-42.3 on the
test grid). The feed showed ten weak singles instead of the handful
of strong topologies ToOp actually found.

Rework so each ToOp candidate topology becomes ONE combined action:

- `_build_topology_actions`: per topology directory, diff
  modified_network.xiidm against the input grid along BOTH axes
  (line connection flags + per-VL internal switch states) and fold
  every change into one merged action content + one grid2op action
  object. Returned as a single prioritized action
  (`toop_topology_<rank>`), so the step-2 assessment phase really
  simulates the whole combination → the card's max_rho is the true
  combined loading ToOp optimised, not its parts in isolation.

- `_merge_topology_content`: enriches each VL switch set via
  `enrich_actions_lazy` (per-connectable set_bus resolution), unions
  all set_bus + switches across VLs, then applies line toggles last
  (explicit open/close wins over a split's bus assignment for the
  same branch). Returns the merged content + human-readable
  constituent labels for the card.

- `_service_integration`: after assessment, reformat each topology
  into one `combined_actions` entry (the channel selected by the
  operator) carrying the real simulated max_rho (is_simulated=True),
  the full `constituent_ids` list (+ is_toop_topology flag), and
  legacy action1_id/action2_id (first two constituents) so the
  existing 2-up pairs table still renders before the N-way UI lands.
  Injects the merged contents into `_dict_action` so each card stays
  re-simulatable / session-saveable. Topology entries are popped from
  the main feed → topologies-only, in the combined channel.

Removed the now-dead per-line / per-VL flattening extractors and the
undocumented Pareto-JSON parsers. Tests rewritten around the
per-topology builder + content merge (21 cases); the step-2 /
combined-action integration suite (46 cases) stays green.

Frontend N-way combined-action rendering is the follow-up commit.
Backend now emits each ToOp candidate topology as one combined_actions
entry (is_toop_topology=true) carrying a REAL simulated max_rho and the
full constituent_ids list. The combined-actions UI assumed exactly
2-action superposition pairs; relax it for the N-way topology case:

- types.ts: extend CombinedAction with is_toop_topology / constituent_ids
  / constituent_count / is_simulated / simulated_max_rho(_line) / rank.

- CombinedActionsModal: seed a ToOp entry's simData from its carried rho
  (it is already really-simulated by the step-2 assessment), so the row
  shows the true combined loading without a manual Simulate click. ToOp
  rows bypass the per-constituent action-type ring (a topology is its own
  category) and are governed by severity / max-loading only. Pass
  is_toop_topology + constituent_ids through to the table row.

- ComputedPairsTable: render a ToOp row as a single "🧩 ToOp topology #N"
  summary cell (colSpan 5) with the constituent moves as chips, the real
  simulated max-loading badge, and a Re-Simulate button that replays the
  whole combination by its topology id (which the backend registered in
  _dict_action). Legacy 2-up pair rows are untouched.

Tests: 3 new cases for the topology row (title + chips, simulated badge,
re-simulate-by-id). Full Vitest suite stays green (1448 passed).
Operator feedback: routing ToOp candidate topologies into the Combine
Actions modal is wrong — they should land directly in the Suggested
Actions list, as one card per topology, alongside any other recommender
output. Flip the routing:

- `_service_integration`: don't move topology entries out of
  `enriched_actions` and into `combined_actions`. Instead, decorate
  each enriched topology entry IN PLACE with N-way metadata
  (`is_toop_topology`, `constituent_ids`, `constituent_count`,
  `rank`) so the frontend ActionCard can render the constituents.
  Still inject the synthesised merged contents into `_dict_action`
  so the card stays re-simulatable / session-saveable.

- types.ts: move the N-way metadata fields from `CombinedAction` to
  `ActionDetail` (where action-card data lives). `CombinedAction`
  reverts to its previous 2-pair shape.

- ActionCard: render a constituent-chip strip under the title when
  `is_toop_topology` is set. Native title tooltip explains the row
  ("really simulated as one combined action"). data-testid
  `action-card-<id>-constituents` for the regression test.

- Revert the Combine-modal / ComputedPairsTable additions (no
  separate ToOp render path needed there anymore).

- ActionCard tests: 3 new cases — chips render when is_toop_topology,
  no strip on non-ToOp cards, no strip when constituent_ids is empty.

Full Vitest suite stays green (1448 passed). Backend lint + code-
quality gate + step-2 integration tests (46) all pass.

Resulting UX: a ToOp run on the same contingency now shows 4 cards
in Suggested Actions named `toop_topology_1`…`toop_topology_4`,
each with the elementary moves as chips and the real combined
max_rho on the badge — instead of the 10 misleading single-move
cards or a separate modal.
Regression after the per-topology rework: on the same grid the prior
synth path resolved (`synthesized busbar action toop_split_<vl>` for
seven VLs), the new merge path fails on every VL ("could not enrich
VL <vl> split"). Two diffs caused it:

1. The internal raw-action key was `_toop_vl_<vl>` instead of the
   `toop_split_<vl>` pattern the earlier run used. The library's
   enrich proxy keys off the action-id shape; a leading underscore
   + unfamiliar id silently fails to attach `content`.

2. The raw entries lost their `description` / `description_unitaire`
   fields — present in the per-VL synth code that previously worked,
   absent in the new merge code. Some lazy enrich paths require them
   to populate the content slot.

Restore both:
- Use the `toop_split_<vl>` id pattern for the temporary enrichment
  entries (purely internal to the merge — no conflict with the
  outer `toop_topology_<rank>` ids surfaced to the UI).
- Populate `description` and `description_unitaire` on each raw
  entry before calling `enrich_actions_lazy`.

Also harden the access pattern: the enriched value is a
`LazyActionDict`-style proxy whose entries don't satisfy
`isinstance(entry, dict)` even though they behave like one — so the
old `entry.get("content") if isinstance(entry, dict) else None`
guard sent everything to the silent-skip branch. Two new helpers,
`_resolve_lazy_entry` and `_resolve_lazy_content`, try mapping
access, attribute access, and ``.get`` in turn so populated content
is found whichever proxy shape the library returns.

Diagnostic logging upgraded: the skip-warning now prints
`entry=<type>, content=<type>` so a future failure tells us exactly
which proxy shape we got back, instead of just "could not enrich".
…pology actions

- Export inputs.network_defaut (N-K state) instead of inputs.network (N),
  and explicitly disconnect inputs.lines_defaut on the exported copy, so
  ToOp's N-0 == the operator-selected contingency. Fixes overload_energy_n0=0.0
  -> "No topologies found" -> 0 actions.
- Port stash pipeline config: max_num_disconnections=4 (re-enables line
  switching), overload_energy_n_0 targeting via optimize_current_state_only,
  and _deflate_thermal_limits (align ToOp threshold with monitoring factor).
- _merge_topology_content: fall back to raw switch flips when enrich_actions_lazy
  returns no content, instead of dropping the VL split.
- Wrap action_scores in the category-keyed shape ({category:{scores,params}})
  via _nest_scores; fixes AttributeError 'float' has no 'get' in
  propagate_non_convergence_to_scores.
- Tests: params_spec knob, _run_toop config (n0/n1), _deflate_thermal_limits,
  _merge fallback, _apply_contingency_state, _nest_scores.
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