Skip to content

Derive the voltage-control plan once per powerflow, in one class - #191

Merged
BDonnot merged 4 commits into
dev_1.0.1from
claude/fancy-voltage-controller-cache-vdbggt
Sep 7, 2026
Merged

Derive the voltage-control plan once per powerflow, in one class#191
BDonnot merged 4 commits into
dev_1.0.1from
claude/fancy-voltage-controller-cache-vdbggt

Conversation

@BDonnot

@BDonnot BDonnot commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Follow-up to #188 / #189: the remaining piece of the cross-solve cache, the "fancy" voltage-controller features (remote voltage control, several machines regulating one bus, SVCs).

What was there

Three derived sets described those controllers, and each was re-derived where it happened to be needed:

set derived by
which buses a control GROUP regulates (grid ids) LSGrid::fillpv_pq
which slack buses keep a free Vm unknown (solver ids) Base::update_state
the controller list itself VoltageControl::update_state, which re-ran the free-Vm slack pass of its own first

Four walks of every generator of the grid per solve, each building std::sets as it went — and three independent chances for the layers to disagree, because each walk read the containers again and nothing said they had to agree.

They were never three answers: the controller list is derived from the free-Vm slack set, which is derived from the group layout.

1. A class

src/core/VoltageControlPlan.{hpp,cpp} — one object, four layers, ~250 lines moved off LSGrid (fillpv_pq included, after review):

layer entry point needs
1 — group_controlled_buses() build_groups generators + SVCs only
2 — the pv/pq split build_pv_pq layer 1 + labelling + slack
3 — free_vm_slack_buses() build_free_vm_slack layers 1–2
4 — controllers() build_controllers layers 1–3

LSGrid::get_group_controlled_buses / get_free_vm_slack_solver_buses / fill_voltage_control_solver_data keep their signatures and behaviour, error messages included; they now build a throw-away plan and are documented as the on-demand form, for callers outside a solve.

2. In the cache, once per powerflow

The plan is a member of SolverBusLayout — it is expressed in that cache's bus labelling and its pv-pq split, which is why it belongs next to them rather than in the extensions that read it. A plan carried across a relabelling is not stale data, it is a different grid.

A foreign build (the batch algorithms) now publishes the plan it just built alongside the labelling, instead of leaving the extensions to re-derive one from it.

3. AlgoControl + propagation in the element modifiers

Two predicates, both on AlgoControl and both read by _build_into_cache, so what the powerflow asks and what a test asks cannot drift:

bool need_recompute_pv_pq() const noexcept {
    return need_reset_solver_ || change_dimension_ ||
           slack_participate_changed_ || pv_changed_ || pq_changed_;
}
bool need_recompute_voltage_control() const noexcept {
    return need_recompute_pv_pq() || voltage_control_changed_;
}

Since the split is layer 2 of the plan, "the split must be rebuilt" and "the plan must be rebuilt" are the same question one layer apart: who is in a control group, and which bus it regulates, are exactly the inputs the split reads. So the new tell_voltage_control_changed() carries only the one input the split does not read — a voltage setpoint, which moves no bus's pv/pq class at all — and is raised from exactly two places (GeneratorContainer::change_v_nothrow, ConverterStationContainer::change_v), on the AC family only and only for an element that actually regulates.

The review also caught three pre-existing over-invalidations, all fixed here: set_regulated_bus raising tell_recompute_sbus() (fillSbus never reads that field) and tell_pv_changed() unconditionally, and the converter station's _deactivate / _reactivate raising tell_pv_changed() for non-regulating stations.

4. An algorithm that cannot do voltage control now says so

BaseAlgo::supports_remote_voltage_control() existed and had no caller. Fast-decoupled and Gauss-Seidel hold no NRSystem, so on a grid with control groups they converged 0.36 pu away from the Newton-Raphson answer, missing setpoints by 0.127 pu — silently. ac_pf now refuses such a grid with the full list of offending generators, SVCs and stations; for an algorithm that cannot, no plan is built at all (layer 2 produces the classical split, layers 1/3/4 are skipped), which also stops it paying for work it never reads.

Two smaller fixes found on the way: change_algorithm(const std::string&) never called init_fdpf_coeffs(), and the profiling driver's MAX_ITER = 10 diverges for FDPF (~50 needed) — it now derives the budget from BaseAlgo::is_fdpf().

Measurements

callgrind, KLU, -O3, tol = 1e-8; every solve's iteration count and full complex voltage vector compared between the two builds — all traces bit-identical, on every phase.

grid inj (an ordinary step) idem (the floor) topo (a line toggled)
case30 -4.9% -7.9% -2.3%
case118 -8.3% -16.9% -4.9%
case118_fancy -7.0% -18.6% -4.0%
case1354pegase -3.2% -7.3% -1.9%
case1354pegase_fancy -2.6% -3.9% -1.7%
case9241pegase -2.2% -5.4% -1.4%
case9241pegase_fancy -0.4% -0.8% -0.5%

idem and inj save the same number of instructions to the last one: what is removed is a fixed per-solve cost, before the Newton loop starts. inj reads smaller only because it is a 1.5x–2.7x bigger solve. About 1.9k of the figure is an unrelated find: AlgorithmSelector took its error_msg by const std::string &, and every call site passes a literal longer than libstdc++'s SSO buffer — a malloc and a free per solve for a string only the error path reads.

Part of the topo column is the term the review disputed. need_recompute_pv_pq() listed ybus_change_sparsity_pattern_; the reviewer disagreed and asked for a test rather than an argument, so the [pv_pq] cases in test_cache_reuse.cpp reach a state where one term is raised and the other five are not, solve warm, reset, solve cold and compare — and the predicate was then built both ways:

dropping ybus_change_sparsity_pattern_  ->  41 assertions, all pass   -> dropped
dropping slack_participate_changed_     ->  1 of 2 cases fails        -> kept

Worth -0.9% / -1.8% / -0.7% / -0.4% on its own (case30 / case118 / case1354pegase / case9241pegase, topo, same tree with the term restored).

make_grids.py also writes _fancy variants of case118 / case1354pegase / case9241pegase — pairs of generators re-pointed at a common neighbouring load bus plus voltage-mode SVCs. The plain pandapower cases have no remote control and no SVC at all, so nothing about the bordered block showed up in the #190 audit.

Two findings, neither in scope

  • On case9241pegase_fancy, 86% of a cached solve is klu_refactor (55% on the plain case). Forty two-member groups add 80 reactive-injection columns, each coupling a generator's bus to a regulated bus a branch away, and KLU's ordering pays the fill-in.
  • The singular configurations hit while writing the tests (a local PV regulator co-existing with a remote group) reproduce identically on the pre-change binary. That is the open TODO entry, not a regression.

Tests

  • C++: 249 test cases / 591,174 assertions green, including the new src/tests/test_voltage_control_plan_cache.cpp (every input of the plan mutated on a grid that has already solved, so a stale plan is available to be wrongly reused, and on a fresh grid built in the final state — a reused stale plan does not throw and does not look wrong, it converges on the previous scenario's controllers) and the new [pv_pq] section of test_cache_reuse.cpp.
  • Python: 188 passed / 65 skipped across the tests touching this area. The full suite's remaining failures are all missing grid2op data_test fixtures in this container and reproduce without the change.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Qz1LmyVeo1nnhrJvLJv9iY


Generated by Claude Code

The "fancy" voltage controllers -- remote-regulating generators, several
machines regulating one bus, voltage-mode SVCs -- were described by three
derived sets, each re-derived where it happened to be needed: by fillpv_pq,
by Base::update_state, and twice by VoltageControl::update_state, which
re-ran the free-Vm slack pass of its own before building the controller
list. Four walks of every generator of the grid per solve, each building
std::sets as it went, and three independent chances for the layers to
disagree -- because each walk read the containers again and nothing said
they had to agree.

They were never three answers. The controller list is derived from the
free-Vm slack set, which is derived from the group layout. VoltageControlPlan
makes that one object with three layers, and puts it where it belongs: in
SolverBusLayout, next to the labelling and the pv-pq split it is expressed
in. A plan carried across a relabelling is not stale data, it is a different
grid.

_build_into_cache builds it around fillpv_pq -- layer 1 is an input to the
split, layers 2 and 3 are expressed in it -- and the NR extensions read it
through get_ac_voltage_control_plan(). A solve that changed none of its
inputs keeps the one the previous solve built, which an ordinary grid2op
step does not: moving a load's P and Q raises need_recompute_sbus and
nothing else. AlgoControl gains its own flag for the rest, raised by every
element modifier that can move a plan input, conditionally where the caller
declares the same state on every step (update_slack_weights).

Measured with callgrind, KLU, answers compared bit for bit: -3.7% / -8.1% /
-3.1% / -2.2% of an ordinary cached powerflow on case30 / case118 /
case1354pegase / case9241pegase, and -6.1% / -16.4% / -7.3% / -5.4% of an
identical re-solve. make_grids.py now also writes _fancy variants carrying
real control groups and SVCs -- the plain pandapower cases have none at all
-- worth -6.8% / -2.6% / -0.4%.

Signed-off-by: Benjamin Donnot <benjamin.donnot@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qz1LmyVeo1nnhrJvLJv9iY

@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.

Lots of changes to make and things that are unclear

Also some tests might need to be added for the flag raised in the grid modifiers.

Comment thread src/core/Utils.hpp Outdated
Comment thread src/core/VoltageControlPlan.cpp
Comment thread src/core/VoltageControlPlan.hpp Outdated
* THE THREE LAYERS, AND WHAT EACH NEEDS
*
* 1. `group_controlled_buses()` -- GRID bus ids. Reads only input data
* (generators, SVCs), never the labelling, which is what lets `fillpv_pq`

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.

Still don't get why no HVDC here. An already present bug ?

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.

Same answer as on the .cpp — see that thread. Short version: not a bug. A station has no regulated bus of its own (the container stores none), so it can only ever regulate the bus it stands on, through the ordinary PV path — it can never create a group, only join one somebody else created, which build_controllers does. Both ends now say so explicitly in 7ec0ba3, and "remote regulation by a station is not modelled" is now a changelog TODO.


Generated by Claude Code

solver_control.dc_algo_controler().tell_v_changed();
// v_set of the group this station belongs to, when a group claims its bus.
// AC only: a DC solve has no voltage control, hence no plan to invalidate.
solver_control.ac_algo_controler().tell_voltage_control_changed();

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.

Why ? Only value changed in this case. And value is already tagged above

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.

This one I kept, but your objection made me narrow it and it is now the only reason the flag still exists.

tell_v_changed() above is not enough because v_changed_ is no longer part of need_recompute_voltage_control() — you were right that it did not belong in that union (see the Utils.hpp thread). And a setpoint really is the one input of the plan that nothing else carries: moving a remote regulator's target changes no bus' pv/pq class at all — that is the point of the bordered formulation, the regulated bus stays PQ — so pv_changed_ never fires and the plan would be reused with a stale v_set.

Narrowed to only fire when the station actually regulates, since target_vm_pu_ of one that does not is never read:

if(voltage_regulator_on_[station_id]) solver_control.ac_algo_controler().tell_voltage_control_changed();

There is a test for it, and it fails if the term is dropped from the predicate.


Generated by Claude Code

Comment thread src/core/element_container/ConverterStationContainer.cpp
Comment thread src/core/element_container/GeneratorContainer.hpp
Comment thread src/core/element_container/GeneratorContainer.hpp Outdated
Comment thread src/core/element_container/GeneratorContainer.hpp Outdated
Comment thread src/core/element_container/GeneratorContainer.hpp Outdated
Comment thread src/core/element_container/SvcContainer.cpp
BDonnot and others added 2 commits September 6, 2026 05:23
… for it

Deriving the voltage-control plan once per powerflow, into the cache, made a
pre-existing hole visible. Layers 3 and 4 of the plan are consumed by the Base
and VoltageControl components of NRSystem, i.e. by the Newton-Raphson
algorithms and nothing else. Fast-decoupled and Gauss-Seidel hold no NRSystem,
so they were paying for two container walks nobody reads -- and, far worse, the
pv/pq split was taking a group-regulated bus out of PV for a bordered block
they never build, leaving that bus' magnitude pinned by nothing at all. The
solve converged, looked plausible, and on a case118 with eight control groups
landed 0.36 pu away from the Newton answer, missing every setpoint by 0.127 pu.
BaseAlgo::supports_remote_voltage_control() existed to prevent exactly that,
and had no caller anywhere.

ac_pf now refuses such a grid, in the same place and the same shape as the
angle-droop guard right above it, naming every concerned generator, SVC and
converter station. It does not rewrite the grid on the caller's behalf:
turning the regulator off changes the reactive dispatch, and pointing it at
its own bus needs a setpoint nobody has -- the one it carries targets a
DIFFERENT bus -- so which to do is the caller's decision. And with that
settled, an algorithm without the bordered block builds no plan at all: the
FDPF rebuild path goes from +2.20% / +0.57% against the pre-change baseline
to -0.09% / -0.05% on case118 / case9241pegase.

fillpv_pq moves into VoltageControlPlan as layer 2, which is where it belonged:
its one subtlety is keyed on layer 1 and layer 4 is then keyed on it, so the
layers and their build order are one object rather than a call sequence that
has to be got right. LSGrid keeps only the list of containers to ask, which
also settles the standing TODO there -- the rule is "ask every container", not
"ask these eight in this order". The DC family's use of layer 1 is deliberately
left exactly as it was: BaseDCAlgo does read pv, so whether a group-regulated
bus should be reclassified in a solve that has no voltage is a question of its
own, and gating it here would have answered it by accident.

Two things found while measuring, both fixed. change_algorithm(const
std::string&) did not call init_fdpf_coeffs() where its enum overload does, so
selecting a fast-decoupled solver BY NAME threw on the first powerflow while
the same solver by enum worked; AlgorithmSelector gains the no-argument
is_fdpf() the type-keyed one cannot stand in for. And AlgorithmSelector took
its error_msg by const std::string&, with fifty-odd literal call sites longer
than the small-string buffer: get_V, get_Va, get_Vm, compute_pf and
tell_solver_control each did a malloc and a free per solve to build a string
only the error path reads. -1,855 to -2,078 instructions per solve, everywhere.

The headline numbers improve with it: -4.9% / -8.3% / -3.2% / -2.2% of an
ordinary cached powerflow on case30 / case118 / case1354pegase /
case9241pegase, answers still bit-identical on all fourteen (grid, phase)
traces.

Signed-off-by: Benjamin Donnot <benjamin.donnot@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qz1LmyVeo1nnhrJvLJv9iY
Nearly every objection on the flag propagation was right, and moving
fillpv_pq into VoltageControlPlan is what makes that visible. The pv/pq
split is now layer 2 of the plan, so "the split must be rebuilt" and "the
plan must be rebuilt" are the same question asked one layer apart -- and
whoever is in a control group, and which bus it regulates, are exactly the
inputs the split reads. There is no way to change one without changing the
other, so pv_changed_ and slack_participate_changed_ already carried all of
it.

need_recompute_voltage_control() is therefore need_recompute_pv_pq() plus one
term, and need_recompute_pv_pq() is a real, nameable thing whose every term
is a reason the split changes. Gone from it: slack_weight_changed_,
one_el_change_bus_, v_changed_ and the duplication of what the call site
already asked. Both predicates live on AlgoControl and are read by
_build_into_cache, so what the powerflow asks and what a test asks cannot
drift apart.

The one term the split does not read is a SETPOINT: moving a remote
regulator's target changes no bus' class at all, which is the point of the
bordered formulation. So tell_voltage_control_changed() survives in exactly
two places -- GeneratorContainer::change_v_nothrow and
ConverterStationContainer::change_v -- down from fifteen, and only for an
element that actually regulates, target_vm_pu_ of one that does not being
never read.

Two flags were wrong before this branch and are fixed rather than moved.
set_regulated_bus raised tell_recompute_sbus(): fillSbus never reads
regulated_bus_id_, and what it stamps for a regulating generator -- active
power only, the reactive being free -- is the same wherever that generator
regulates, so the injections cannot have moved. It also raised
tell_pv_changed() for a generator that does not regulate, for which the
field is inert. And ConverterStationContainer::_deactivate / _reactivate
raised tell_pv_changed() for every station, where fillpv pins a bus only for
a regulating one. tell_recompute_sbus() there stays: stations ARE in Sbus.

Why build_groups reads no hvdc station: a station has no regulated bus of
its own -- the container stores none, a regulating station always regulates
the bus it stands on -- so it can never be the remote controller that CREATES
a group. It pins its own bus through fillpv like a local generator and joins
a group only when one already claims that bus, which is build_controllers'
job and is keyed on the set build_groups returns. Spelled out at both ends.

Added to the changelog's TODO: the inputs of the plan that have no runtime
setter at all, so a caller cannot change them on a live grid -- a generator's
and a station's voltage_regulator_on_, an SVC's regulation_mode_ and
regulated_bus_id_, and remote regulation by a station, which is not modelled.

Answers unchanged: all fourteen (grid, phase) traces still bit-identical, and
the instruction counts do not move.

Signed-off-by: Benjamin Donnot <benjamin.donnot@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qz1LmyVeo1nnhrJvLJv9iY

BDonnot commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator Author

Review answered in 7ec0ba3

You were right that moving fillpv_pq into the plan changes the answers — but mostly by making your objections more right, not less. The pv/pq split is now layer 2 of the plan, so "the split must be rebuilt" and "the plan must be rebuilt" are the same question asked one layer apart, and the flags I had been listing were already carried by pv_changed_ / slack_participate_changed_.

The predicate. Two now, both on AlgoControl, and the second is visibly the first plus one term:

bool need_recompute_pv_pq() const noexcept {
    return need_reset_solver_ || change_dimension_ || ybus_change_sparsity_pattern_ ||
           slack_participate_changed_ || pv_changed_ || pq_changed_;
}
bool need_recompute_voltage_control() const noexcept {
    return need_recompute_pv_pq() || voltage_control_changed_;
}

slack_weight_changed_, one_el_change_bus_ and v_changed_ are gone. slack_participate_changed_ and ybus_change_sparsity_pattern_ stay, but as pv/pq terms — the slack is not PV, and the sparsity flag is what re-labels the buses — which is a reason you can check rather than a claim about voltage control. _build_into_cache reads those two and nothing else, so what the powerflow asks and what a test asks cannot drift.

The propagation: 15 call sites → 2. tell_voltage_control_changed() now means one thing only — a voltage setpoint moved — and lives in GeneratorContainer::change_v_nothrow and ConverterStationContainer::change_v, gated on voltage_regulator_on_. That is the one input the split does not read: moving a remote regulator's target changes no bus' class at all. Everywhere else you flagged (_deactivate, _reactivate, _change_bus, _change_p, add_slackbus, remove_slackbus, turnedoff_*, set_regulated_bus, the three SVC sites) the line is deleted, because the tell_pv_changed() / tell_slack_participate_changed() already there does the job.

Three flags were wrong before this branch, and your comments found all three:

where was now
GeneratorContainer::set_regulated_bus tell_recompute_sbus() removedfillSbus never reads regulated_bus_id_, and what it stamps for a regulating gen (active power only) is the same wherever it regulates
GeneratorContainer::set_regulated_bus tell_pv_changed() unconditional gated on voltage_regulator_on_ — the field is inert otherwise
ConverterStationContainer::_deactivate / _reactivate tell_pv_changed() for every station gated on voltage_regulator_on_fillpv skips the others

Are stations in Sbus? Yes — fillSbus_station stamps every connected one (active power always, reactive too when it does not regulate). So tell_recompute_sbus() there is right and stays; the line is now commented to say so.

Why no HVDC in build_groups? Not a bug. A station has no regulated bus of its own — the container stores none, a regulating station always regulates the bus it stands on — so it can never be the remote controller that creates a group. It pins its own bus through fillpv like a local generator, and joins a group only when one already claims that bus, which is build_controllers' job and is keyed on what build_groups returns. Spelled out at both ends now.

TODOs added (top of the changelog) for the four inputs with no runtime setter at all, so a caller cannot change them on a live grid: a generator's and a station's voltage_regulator_on_, an SVC's regulation_mode_ and regulated_bus_id_, and remote regulation by a station, which is not modelled. Each says what flag the setter must raise.

Left open deliberately — four threads where I did not do what was asked and you should judge: the two change_v sites (I kept the flag, narrowed; it is the only non-redundant use left) and the two HVDC-in-build_groups ones (I answered rather than changed code).

Checks. C++ 247 cases / 591,133 assertions green; targeted python 245 passed / 65 skipped; full python suite 1440 passed, the 99 failures all missing grid2op data_test fixtures in this container and identical on the base. Instruction counts unchanged and all 14 (grid, phase) traces still bit-identical to the pre-PR build. Both invalidation paths mutation-checked: dropping the setpoint term, and silencing set_regulated_bus, each make a specific test fail.


Generated by Claude Code

@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.

A few tests to do

Comment thread src/core/Utils.hpp Outdated
Comment thread src/core/Utils.hpp
*/
[[nodiscard]] bool need_recompute_pv_pq() const noexcept {
return need_reset_solver_ || change_dimension_ || ybus_change_sparsity_pattern_ ||
slack_participate_changed_ || pv_changed_ || pq_changed_;

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.

Disagree with slack_partcipate_changed, make another test

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.

Same experiment, opposite verdict: this one is load-bearing, and the test now proves it rather than asserting it. slack_participate_changed_ stays (18988f9).

TEST_CASE("slack_participate_changed, on its own, DOES move the split", "[LSGrid][cache_reuse][pv_pq]") — two sections, the slack moved to another generator (update_slack_weights_by_id) and a second generator joining it (add_gen_slackbus). Each asserts the term is raised and the other five are not, then solves warm, resets, solves cold and compares. With the term dropped from the predicate:

  the slack moves to another generator
  a second generator joins the slack
test cases:  2 |  1 passed | 1 failed
assertions: 37 | 35 passed | 2 failed

And the reason has nothing to do with voltage, which is where I think our disagreement was — you are right that a non-reference slack has nothing to do with voltage. It is that the split reads the slack set directly:

// GeneratorContainer::fillpv
if(is_in_vect(bus_id_solver.cast_int(), slack_bus_id_solver.to_int_vector())) continue;  // slack bus is not PV

and the PQ loop right after it skips slack buses too. So a bus joining the slack set must leave bus_pv, and one leaving it must join bus_pv or bus_pq. Move the slack set and the split moves with it, whatever the machine does to voltage.


Generated by Claude Code

Comment thread src/core/element_container/GeneratorContainer.hpp Outdated
Comment thread src/tests/test_cache_reuse.cpp
…dant one

The review disagreed with two terms of need_recompute_pv_pq() and asked for
a test rather than an argument. The only way to attribute a rebuild to ONE
term is to reach a state where that term is raised and the other five are
not, so that is what the new [pv_pq] cases in test_cache_reuse.cpp do: they
assert the whole vector of six after the action, solve on the cache the
action left behind, solve again cold, and compare.

Then the predicate was built both ways and the same cases run against each:

  dropping ybus_change_sparsity_pattern_  -> both cases pass  -> dropped
  dropping slack_participate_changed_     -> both cases fail  -> kept

The sparsity flag was subsumed. It is only ever raised by a branch-side
mutation, and every one of those goes through _apply_and_track_buses, which
raises change_dimension_ exactly when the mutation empties or fills a bus --
exactly when the labelling moves. When no bus crossed, id_me_to_solver comes
back identical and a branch is neither a voltage controller nor a slack, so
the old split still describes the grid. The slack flag is load-bearing for a
reason that has nothing to do with voltage: fillpv skips a bus that is in
the slack set and so does the PQ loop after it, so moving the slack set
moves the split. The add_slackbus comment now says that first, instead of
leading with the is_pseudo_off() link, which is real but secondary.

Worth -0.9% / -1.8% / -0.7% / -0.4% of a topology-changing cached powerflow
on case30 / case118 / case1354pegase / case9241pegase, measured on its own
against the same tree with the term restored, answers bit-identical on all
seven grids. The idem and inj phases are unaffected, as expected: neither
raises the flag.

Measuring it needed one precaution worth recording, and the benchmark README
now records it: the profiling driver links liblightsim2grid_core.so with a
RUNPATH into its own build directory, so two binaries copied out of the same
build tree load whatever library that tree holds at run time. The first
attempt compared a build against itself and read a flat 0.00%.

Signed-off-by: Benjamin Donnot <benjamin.donnot@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qz1LmyVeo1nnhrJvLJv9iY
@BDonnot
BDonnot force-pushed the claude/fancy-voltage-controller-cache-vdbggt branch from 18988f9 to eaf0f8e Compare September 6, 2026 19:41

@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 78e1d10 into dev_1.0.1 Sep 7, 2026
58 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