Derive the voltage-control plan once per powerflow, in one class - #191
Conversation
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
| * 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` |
There was a problem hiding this comment.
Still don't get why no HVDC here. An already present bug ?
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
Why ? Only value changed in this case. And value is already tagged above
There was a problem hiding this comment.
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
… 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
Review answered in 7ec0ba3You were right that moving The predicate. Two now, both on 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_;
}
The propagation: 15 call sites → 2. Three flags were wrong before this branch, and your comments found all three:
Are stations in Sbus? Yes — Why no HVDC in 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 Left open deliberately — four threads where I did not do what was asked and you should judge: the two 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 Generated by Claude Code |
BDonnot
left a comment
There was a problem hiding this comment.
A few tests to do
| */ | ||
| [[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_; |
There was a problem hiding this comment.
Disagree with slack_partcipate_changed, make another test
There was a problem hiding this comment.
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 PVand 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
…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
18988f9 to
eaf0f8e
Compare
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:
LSGrid::fillpv_pqBase::update_stateVoltageControl::update_state, which re-ran the free-Vm slack pass of its own firstFour 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 offLSGrid(fillpv_pqincluded, after review):group_controlled_buses()build_groupsbuild_pv_pqfree_vm_slack_buses()build_free_vm_slackcontrollers()build_controllersLSGrid::get_group_controlled_buses/get_free_vm_slack_solver_buses/fill_voltage_control_solver_datakeep 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 modifiersTwo predicates, both on
AlgoControland both read by_build_into_cache, so what the powerflow asks and what a test asks cannot drift: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_busraisingtell_recompute_sbus()(fillSbusnever reads that field) andtell_pv_changed()unconditionally, and the converter station's_deactivate/_reactivateraisingtell_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 noNRSystem, 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_pfnow 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 calledinit_fdpf_coeffs(), and the profiling driver'sMAX_ITER = 10diverges for FDPF (~50 needed) — it now derives the budget fromBaseAlgo::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.inj(an ordinary step)idem(the floor)topo(a line toggled)idemandinjsave the same number of instructions to the last one: what is removed is a fixed per-solve cost, before the Newton loop starts.injreads smaller only because it is a 1.5x–2.7x bigger solve. About 1.9k of the figure is an unrelated find:AlgorithmSelectortook itserror_msgbyconst 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
topocolumn is the term the review disputed.need_recompute_pv_pq()listedybus_change_sparsity_pattern_; the reviewer disagreed and asked for a test rather than an argument, so the[pv_pq]cases intest_cache_reuse.cppreach 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: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.pyalso writes_fancyvariants 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
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.Tests
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 oftest_cache_reuse.cpp.grid2opdata_testfixtures in this container and reproduce without the change.🤖 Generated with Claude Code
https://claude.ai/code/session_01Qz1LmyVeo1nnhrJvLJv9iY
Generated by Claude Code