Skip to content

Fixes from the review of #226: topological actions in ScenarioSweep, LightEnv - #228

Merged
BDonnot merged 13 commits into
fast-envfrom
ccr-9bf14bd7-jobq34
Oct 8, 2026
Merged

BDonnot merged 13 commits into
fast-envfrom
ccr-9bf14bd7-jobq34

Conversation

@BDonnot

@BDonnot BDonnot commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator
added removed
C++ (src/core, src/bindings) +377 -84
Python (package) +6 -23
Tests +349 -18
Docs + changelog +44 -4
Build / other +145 -8
total +921 -137

Fixes for the problems a review of #226 found, on top of fast-env. One commit per fix. Each bug fix comes with a test that failed before it; the commit message says how it failed.

ScenarioSweep with topological actions

  • clear() kept the branch placements (0e52bcb). A batch computed after clear() without actions replayed the old placements, with solver ids of the old layout. The process aborted on a corrupted heap (munmap_chunk(): invalid pointer).
  • run() raised "call compute first" when the "n" case diverged (c7f951f). The guard now checks that the actions are resolved, not that the base case converged.
  • Masked extra busbars were voltage-checked in the "n" case (c62e621). get_violations_n() could report a bus the base grid does not have.
  • Half-open branches (53cd131). A row putting such a branch on was solved on a guess: moving its closed end closed the open end too, and set_line_status +1 was read as a no-op while apply_to_gridmodel closes the open end. Such a row is now refused by name. Disconnecting a half-open branch was already correct and stays allowed.
  • A busbar a row leaves empty was treated as an island (1b8b366). A merge was NOT_SIMULATED without handle_disconnected_grid. test_row_splitting_the_grid has been reworked: its row emptied the bus instead of stranding it, so it now covers both cases.
  • gen_v groups used the base placement (e6cfcd8). A generator moved off a shared bus was compared with the one left there (row skipped), and set_vm could seed that bus with the set-point of the generator that left.
  • Regulating storage units were not controllers of their bus (6d3c3b3). Taking one out left its bus PV. Masking every generator of a bus a storage unit also holds turned that bus PQ. The bus reactive check also leaves a disconnected unit out now.
  • Storage units in the distributed slack (5f1e29b). Disconnecting one re-derives the row's weights. Moving one is refused, as for a generator.
  • Bus-graph fast path kept when an action creates a bus (4c3ff77). Performance only. BusGraph leaves the known-isolated extra busbars out of its tree. Measured with callgrind through two new ScenarioSweep phases of benchmarks/cache_profiling/profile_batch.cpp (two-busbar grids from make_grids.py --two-busbars). Instructions per row, before → after: 1,154,578 → 1,120,767 (−2.9%) on the synthetic 200-bus Illinois grid, 10,291,717 → 10,071,328 (−2.1%) on case1354pegase. Every row's voltages are bit-identical; table and command in the cache-profiling README.
  • Comments and refusal lists brought up to date (157afa9).
  • Dead mask cache removed from the Python wrapper (30bb8ca).

LightEnv

  • Moved-from env (3b05d2f). nb_actions(), get_actions() and init_actions() now throw std::logic_error instead of dereferencing null pointers.
  • update_rho_time (2d3ae4a). It is now cumulated over the episode and reset with the other timers.

Changelog

Two [FIXED] entries under 1.1.1, for behaviour that shipped in 1.1.0 with set_contingency_gens: a regulating storage unit on a masked generator's bus (6d3c3b3), and modify_gen_v with a disconnected generator (e6cfcd8). The half-open refusal is added to the [TODO] list.

Testing

  • C++ (Catch2): 399/399 with ctest, the new BusGraph case included.
  • Python (unittest, grid2op from source), 620 tests, OK: test_ScenarioSweep_topology, test_batch_redistribute_slack, test_ScenarioSweep, test_ScenarioSweep_gen_contingency, test_ContingencyAnalysis_split, test_ContingencyAnalysis_limit_violations, test_physical_violations, test_lsgrid_violations, test_main_component_slack, test_can_participate_slack, test_cap_slack_at_active_limits, test_binary_serialization, test_storage_pypowsybl, test_voltage_control_batch, test_hvdc_batch, test_fdpf, test_solver_control, test_SecurityAnlysis, test_LightEnv, test_storage_voltage_control, test_storage_distributed_slack, test_InjectionSweep, test_timeseries_sbus.
  • 90 skips, from conditions that already existed: 60 DC variants, 25 needing pypowsybl (not installed in the environment that ran them), 5 algorithms without a distributed slack.

🤖 Generated with Claude Code

https://claude.ai/code/session_014PrXNr1rwq4PxLAZ9pL3yu


Generated by Claude Code

BDonnot added 13 commits October 8, 2026 05:14
`_line_mask`, `_trafo_mask` and `_has_topo_actions` were written and reset
in five places and read nowhere: run() takes each row's disconnected
branches from C++ (get_row_disconnected_branches), which merges the masks
and the topological action itself. The comments saying run() needs the
cache went with it, and run()'s docstring now says where element_ids come
from.

Assisted-by: Claude Code
Claude-Session: https://claude.ai/code/session_014PrXNr1rwq4PxLAZ9pL3yu
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
_reset_topo_policy_state() cleared the branches a row's action takes out
but not the ones it places (topo_branches_moved). A batch computed after
clear() with no action registered returns from _maybe_resolve_topology
before anything is resolved, so init_li_coeffs_from_masks and
_prepare_connectivity replayed the old batch's placements, with solver
ids of the old union layout.

Reproduced with a branch reconnected by an action, then clear() and a
plain batch on the same grid: the second compute writes admittance
entries the new pattern does not hold, a row fails, and the process
aborts on a corrupted heap (munmap_chunk(): invalid pointer). The
placements are now cleared with the rest; the new test compares that
batch with a fresh sweep.

Assisted-by: Claude Code
Claude-Session: https://claude.ai/code/session_014PrXNr1rwq4PxLAZ9pL3yu
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
get_row_disconnected_branches refused to answer while the batch inputs
were not valid, and that flag is only raised once the "n" powerflow has
converged. ScenarioSweep.run() calls it for every row after
compute(ignore_errors=True), so with topological actions registered a
diverging "n" case raised "call compute first" instead of being reported
through its sentinel violations, which is what run() documents.

The actions are resolved before the "n" solve. A flag of their own
(_topo_resolved_) says so: raised at the end of _maybe_resolve_topology,
dropped with the batch inputs, unconditionally (a batch whose "n" case
diverged was never valid, its resolution still stands until the inputs
change).

Assisted-by: Claude Code
Claude-Session: https://claude.ai/code/session_014PrXNr1rwq4PxLAZ9pL3yu
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
…n" case

_record_n_case_violations passed no masked list to the bus voltage check.
With topological actions, the union layout holds busbars no element of
the base grid stands on; the "n" solve masks them, so they read the seed
they were given (busbar 1's starting voltage), not a solved one, and
get_violations_n() could report a violation on a bus the base grid does
not have. A row that does not use them already skips them.

The base mask (_base_masked_) is now passed, as the rows pass theirs. The
physical checks of the "n" case need nothing: their plans only list buses
a controller of the base grid stands on.

Assisted-by: Claude Code
Claude-Session: https://claude.ai/code/session_014PrXNr1rwq4PxLAZ9pL3yu
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
A move leaves actions_ and init_grid_ null, and nb_actions(),
get_actions() and init_actions() dereferenced them unchecked, where the
class documents that a moved-from env throws std::logic_error on
anything but destruction or assignment. They now run the same check as
reset() and step().

The moved-from helper of the C++ tests asks the three of them; before
this change nb_actions() returned instead of throwing.

Assisted-by: Claude Code
Claude-Session: https://claude.ai/code/session_014PrXNr1rwq4PxLAZ9pL3yu
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
Protections::update_rho assigned its timer (=) where the other timers
add up (+=), and reset_timers() left it out, so update_rho_time held the
duration of the last call only, while the binding documents it as
cumulated over an episode. It now adds up and is reset with the others.

The test steps the env twenty times: the readings must never decrease,
and a reset must bring the value back down. Before the change they went
up and down from one step to the next.

Assisted-by: Claude Code
Claude-Session: https://claude.ai/code/session_014PrXNr1rwq4PxLAZ9pL3yu
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
A branch on in the base grid with one end open was handled as if both
ends were closed. A row moving its closed end put the closed-branch
block on both buses, the open end included. A row reconnecting it with
set_line_status was read as a no-op (same global status, same buses),
while TopoAction::apply_to_gridmodel closes the open end on the bus it
was last on. Reproduced on case14 with one end of line 5 opened: the
reconnecting row came out with voltages about 0.02 pu away from the
one-off powerflow, and both rows were solved without a word.

What a row should do with that open end is not settled, so a row that
puts such a branch on is now refused, by name. resolve_row_topo lists
it even when the buses do not change, so that the refusal sees it.
Disconnecting one stays allowed: the coefficients taken out are the
branch's effective block, and the rows match the reference. A branch
reconnected to an end with no bus recorded is refused as well, where
its -1 used to reach the union layout and the admittance edits.

Assisted-by: Claude Code
Claude-Session: https://claude.ai/code/session_014PrXNr1rwq4PxLAZ9pL3yu
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
Without handle_disconnected_grid, a row is skipped when it masks a bus
other than the union layout's extra busbars. A busbar the row's action
leaves with no element at all fell on the wrong side of that rule: a
merge (every element of busbar 2 moved back to busbar 1, on a grid that
splits the substation) was NOT_SIMULATED, though nothing is islanded
and nothing lost, while a load left alone on an extra busbar, which does
lose its injection, was solved.

_maybe_resolve_topology now lists, per row, the base buses its action
empties (the base grid's per-bus element counts, with each element the
plan takes off a bus or puts on one moved accordingly; a branch end
counts where it is closed), and the connectivity verdict and the skip
mask accept those as masked by design. Only the action's own moves
count: a row without one keeps the answer the masks always gave.

test_row_splitting_the_grid took generator 4 out with trafo 3, so its
row emptied the bus rather than stranding it. It now has both rows:
trafo 3 alone (generator 4 islanded: NOT_SIMULATED by default) and the
two together (solved in either mode). The slack pre-pass tests no
longer turn handle_disconnected_grid on: they needed it for that rule
only.

Assisted-by: Claude Code
Claude-Session: https://claude.ai/code/session_014PrXNr1rwq4PxLAZ9pL3yu
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
With modify_gen_v, the buses several generators regulate are found once
per compute(), from the base placement. A row that moves a generator off
such a bus was then held to its old group. Reproduced on case14 with
generator 3 moved off the bus it shares with generator 2, its own
set-point raised by 0.02 pu. _row_gen_v_conflicts compared it with
generator 2 and skipped the row (NOT_SIMULATED). With the comparison
fixed alone, set_vm (base placement, last writer wins) still seeded the
shared bus with the set-point of the generator that left, and the row
came out about 0.02 pu off the one-off powerflow.

A row's generators that are off their base bus (moved, or disconnected:
the gen_off it already carries) are now left out of the comparison, and
after set_vm a shared bus a row takes a generator off is set back to the
set-point of what stays there: the generators left, or the element no
row moves. Each constraint keeps its bus for that. The new test fails
without either half.

Assisted-by: Claude Code
Claude-Session: https://claude.ai/code/session_014PrXNr1rwq4PxLAZ9pL3yu
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
The PV -> PQ handling of a row only counted generators as the
controllers of a bus. A row taking out a storage unit that regulates its
bus left that bus PV, held at the set-point of a unit that is gone; a row
taking out every generator of a bus a storage unit also regulates turned
it PQ while the unit still held it. Reproduced on educ_case14_storage
with two regulating storage units: about 0.03 and 0.05 pu off the one-off
powerflow, both rows reported converged.

_maybe_prepare_gen_contingency now lists the regulating storage units
per bus next to the generators, and a bus flips once a row takes out all
of them. A row only ever takes such a unit out: placing one is refused.
The bus reactive check (BusQCheck) leaves out a storage unit the row
disconnects, as it does a generator; the solves that disconnect none
keep the previous signature. Each half has a test that fails without it.

Assisted-by: Claude Code
Claude-Session: https://claude.ai/code/session_014PrXNr1rwq4PxLAZ9pL3yu
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
A row's distributed-slack weights were only re-derived for the
generators it disconnects: a storage unit with a slack weight kept its
share on its base bus whatever the row's action did with it. Reproduced
on educ_case14_storage with storage 0 in the slack: the row disconnecting
it put part of the imbalance on a bus with nothing to absorb it, the row
moving it put its share on the bus it left (voltages up to 0.03 pu off
the one-off powerflow).

The two cases are now handled as for a generator. Taken out, the unit is
left out of the row's weights (_row_slack_storages_off_, read by
_row_slack_weights through get_slack_weights_solver_without, with the
refactorization fallback that goes with weights that vary per row).
Moved or reactivated, it would take its share on another bus and change
the slack set with the row: refused, by name.

Assisted-by: Claude Code
Claude-Session: https://claude.ai/code/session_014PrXNr1rwq4PxLAZ9pL3yu
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
_prepare_connectivity answers each row from one DFS of the base graph
(BusGraph) and falls back to a breadth-first search of a patched matrix
copy when the tree cannot settle it, a base graph that is not connected
included. The union layout of a batch with topological actions holds
busbars no element of the base grid stands on, isolated in the base
matrix: once a single action created a bus, every row went to the
search, plain rows included, which on a large grid turns the per-row
check from constant time into a pass over the whole matrix.

BusGraph::build takes the buses known to be isolated and leaves them out
of the tree (rooted at the first bus left in, sizes counted without
them); a left-out bus that has an edge, or a tree too small to rule out
a tie with one, settles nothing. _prepare_connectivity passes the base
mask and adds it back to each row the tree settles, which is what the
search listed for them. Rows that place a branch are still searched.

Measured in instructions (callgrind) with the batch driver of
benchmarks/cache_profiling, which gains two ScenarioSweep phases --
ss_topo_ac, where one row creates a bus and every other row is plain,
and ss_ac, the same rows without actions -- and two-busbar grids
(make_grids.py --two-busbars). Per row of ss_topo_ac, before -> after:
1,154,578 -> 1,120,767 on the synthetic 200-bus Illinois grid (-2.9%),
10,291,717 -> 10,071,328 on case1354pegase (-2.1%). The difference is
the connectivity pre-pass, for the whole batch 6,979,995 -> 216,579 and
11,966,529 -> 946,436. ss_ac does not move, and every row's voltages are
bit-identical before and after.

The new BusGraph test checks the tree against the search on random
graphs with isolated buses (bus 0 among them).

Assisted-by: Claude Code
Claude-Session: https://claude.ai/code/session_014PrXNr1rwq4PxLAZ9pL3yu
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
The C++ docstring of set_topo_actions and the header comment of
_maybe_resolve_topology still described the first version: disconnections
only, moves and reconnections refused. Both now say what a row plays
(reconnections and moves between busbars included, on one symbolic
analysis) and list what is still refused and where.

The refusal lists that users read (the binding, the Python wrapper,
docs/scenario_sweep.rst and the changelog TODO) gain the half-open
branch put back on, refused since the half-open fix; a storage unit in
the slack is already covered there by "a slack participant".

Assisted-by: Claude Code
Claude-Session: https://claude.ai/code/session_014PrXNr1rwq4PxLAZ9pL3yu
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>

@BDonnot BDonnot left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok

@BDonnot
BDonnot merged commit edab507 into fast-env Oct 8, 2026
97 checks passed
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.

1 participant