Add ToOp topology optimizer integration as combined-action recommender#159
Open
marota wants to merge 14 commits into
Open
Add ToOp topology optimizer integration as combined-action recommender#159marota wants to merge 14 commits into
marota wants to merge 14 commits into
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)ToOpRecommenderclass: wraps ToOp'srun_pipelinewith lazy imports (optional install on Python 3.11 + GPU deps)set_bus+switchesdict)n_prioritized_actions,include_busbar_splits,runtime_seconds,n_worst_contingenciesdict_actionentries for service integrationrecommenders/__init__.py(modified)ToOpRecommenderin the model registryrecommenders/_service_integration.py(modified)is_toop_topology,constituent_ids,constituent_count) into enriched actionsdict_actionentries so cards remain re-simulatable and session-saveablecombined_actionschanneltests/test_toop_recommender.py(373 lines, new)_is_line_openhelper tests (terminal connection logic)_merge_topology_contenttests (line toggles, VL splits, enrich integration, precedence)_build_topology_actionstests (one action per topology, diff logic, capping, action-space rejection)Frontend (
frontend/src/)types.ts(modified)ActionDetailinterface: addis_toop_topology,constituent_ids,constituent_countfieldscomponents/ActionCard.tsx(modified)is_toop_topologyis truecomponents/ActionCard.test.tsx(modified)Implementation Details
Lazy imports:
_import_run_pipeline(),_import_dictconfig(),_import_enrich()returnNoneon import failure, allowing graceful degradation (empty recommendation + log line) instead of crashing the step-2 NDJSON stream.Network diff: Compares original XIIDM against each
modified_network.xiidmalong two axes:connected1/connected2columns)opencolumn, grouped byvoltage_level_id)Content merge: Folds line toggles and per-VL busbar splits into one action via
enrich_actions_lazy(resolves switch sets to per-connectableset_busassignments), then unions allset_bus/switchesdicts. Line toggles applied last so explicit open/close wins over a split's bus assignment for the same branch.Service integration: Stashes
_last_topology_groupsand_last_topology_dict_entrieson the recommender instance; the service integration reads themhttps://claude.ai/code/session_01Eih4rx5r2rh3K6DJDwhtY6