Skip to content

Match OpenLoadFlow on hvdc limits, VSC curves, SVC standby b0 and remote voltage control - #222

Merged
BDonnot merged 34 commits into
dev_1.1.1from
hvdc_operator_range
Oct 4, 2026
Merged

BDonnot merged 34 commits into
dev_1.1.1from
hvdc_operator_range

Conversation

@BDonnot

@BDonnot BDonnot commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator
added removed
C++ (src/core, src/bindings) +797 -54
Python (package) +378 -40
Tests +498 -9
Docs + changelog +32 -7
total +1705 -110

Summary

This PR brings lightsim2grid's outer-loop-free solve (and the physical-limit checks that stand in for OpenLoadFlow's outer loops) closer to OpenLoadFlow on hvdc lines, VSC stations, SVCs and remote voltage control. Each case below was a contingency where OLF switched or capped an element and lightsim2grid either modelled it differently or reported nothing.

HVDC lines and VSC converter stations

  • Operator active power range: init_from_pypowsybl now reads hvdcOperatorActivePowerRange per direction as the line's limit, like OLF does, and only falls back on max_p when that range is missing.
  • AC-emulation limits: bake_outer_loops(bake_hvdc_ac_emulation_limits=True) (on by default) freezes an angle-droop line that OLF saturated at its limit to a fixed setpoint.
  • Release check: the new LSGrid.set_hvdc_ac_emulation_frozen reports HVDC_AC_EMULATION_RELEASE when the droop asks for less than the limit the line is frozen at.
  • Capability curves: a VSC station with a capability curve gets its reactive limits from the curve at its target P. Before, it had no reactive limit.

Generators, SVCs and the bake

  • New remote voltage check: LSGrid.set_remote_voltage_control_vm_range adds a physical check (LOW_/HIGH_VOLTAGE_REMOTE_CONTROL) for a generator that holds a remote bus from an unrealistic voltage on its own bus. This is OLF's robust remote voltage control. init_from_pypowsybl(remote_voltage_control_vm_range="olf") sets OLF's range.
  • Reactive sharing key: a controller bus's share now counts all the generators on that bus, as OLF does, not only the regulating ones.
  • SVC standby automaton: the reactive range of an SVC with a standby automaton is shifted by the automaton's b0, both in init_from_pypowsybl and in the bake.
  • Freezing rules in the bake:
    • A unit OLF held with some reactive headroom left is no longer frozen. Only a unit at its limit is.
    • A unit clamped at its limit on a bus that still regulates is not frozen, because OLF switches whole buses, not units.
    • A unit whose reactive range is too small, on a bus where another controller keeps regulating, is now frozen.
  • Slack overshoot: a unit OLF capped well beyond its active limit stays capped in the slack pre-pass until the shift has used that overshoot up (set_gen_can_participate_slack_overshoot, filled by the bake).

Breaking

  • BINARY_FORMAT_VERSION goes from 15 to 17: the slack overshoot and the hvdc AC-emulation frozen flag are now serialized. The test fixture is regenerated as case14_sandbox_format17.lsb.

Merge with dev_1.1.1

origin/dev_1.1.1 is merged into the branch. The only conflict was in CHANGELOG.rst, where both sides had added entries at the top of the 1.1.1 section. Both sets are kept and the section is sorted by kind.

Testing

New or extended tests: test_hvdc_pypowsybl, test_olf_bake, test_remote_voltage_control, test_svc_standby, test_can_participate_slack, test_voltage_control_pypowsybl and test_binary_serialization.

After the merge, LSGrid.cpp, help_fun_msg.cpp and binding_lsgrid.cpp pass a C++14 syntax check. The Python tests were not re-run on the merged tree; CI covers that.

🤖 Generated with Claude Code

BDonnot added 20 commits October 1, 2026 18:39
OpenLoadFlow saturates an angle-droop ("AC emulation") hvdc line at the
limit of its hvdcOperatorActivePowerRange extension, one value per
direction (opr_from_cs1_to_cs2 / opr_from_cs2_to_cs1), and only falls
back on the line's max_p when the extension is missing.
init_from_pypowsybl passed max_p in both directions, so on a line whose
operator range is narrower than max_p the hvdc active-power check of
get_physical_violations compared the flow with the wrong limit: a line
OLF saturates read as well within its limit, and the contingency
showed no physical violation at all.

_hvdc_pmax_per_direction now builds pmax_1to2 / pmax_2to1 from the
extension when present. gpusim2grid reads these limits off the LSGrid,
so it gets them too.

Tested in test_hvdc_pypowsybl.py: the limits with and without the
extension, and a HIGH_P violation against the operator range.

Assisted-by: Claude Code (claude-opus-5-5)
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
OpenLoadFlow's AcHvdcAcEmulationLimits outer loop caps the active power
the sending converter of an angle-droop hvdc line takes from the AC grid
at the line's limit in that direction (the operator active power range,
max_p otherwise). bake_outer_loops did not bake that loop: the baked
network kept the droop, and the loop-free solve -- OLF baked as well as
lightsim2grid -- let the line transmit beyond its limit, the flows of
the AC lines in parallel off by as much.

The new step _bake_hvdc_ac_emulation_limits (bake_outer_loops'
bake_hvdc_ac_emulation_limits, on by default) finds the lines whose
sending converter realized its limit (within _HVDC_P_LIMIT_TOL_MW: OLF
imposes the limit as a setpoint, so a saturated line reproduces it to
float rounding) and bakes them as OLF solved them: droop off, target_p
at the limit, converters_mode with the sending side as rectifier. A line
in its linear regime keeps its droop.

Not covered: a line frozen at its limit cannot come back below it on a
grid that asks it for less, and nothing reports that release yet.

Tested in test_olf_bake.py: saturation in both directions, the loop-free
re-solve reproducing the with-loops converter flows, a line in its
linear regime left alone.

Assisted-by: Claude Code (claude-opus-5-5)
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
With bake_saturated_voltage_control, the bake also froze a generator (or
a voltage-mode SVC) whose target OLF held, as soon as its reactive output
sat within the relative tolerance of _q_limit_tol (a fraction of its Q
range). That tolerance exists for a unit OLF switched and that settled a
hair inside its limit, whose voltage is free; on a held unit it also
froze one OLF still regulated with some headroom left. A contingency that
relieves such a unit makes OLF move its reactive output while the frozen
copy cannot follow, and on a stiff bus the voltage deviation that would
report the release (LOW_VOLTAGE_AT_MIN_Q / HIGH_VOLTAGE_AT_MAX_Q) is far
too small to show, although the reactive power moved is not.

A held unit is now frozen only at its limit to the new
_Q_SATURATED_HELD_TOL_MVAR; one with more headroom keeps regulating, as
OLF does. Units OLF did not hold, and members switched out of a shared
group, are frozen as before.

test_svc_near_its_limit_follows_the_generator_rule now expects a held SVC
with headroom left to stay regulating under the flag;
test_olf_held_unit_with_headroom_not_frozen and
test_saturated_held_svc_frozen_on_request check both sides of the
tolerance.

Assisted-by: Claude Code (claude-opus-5-5)
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
pypowsybl leaves min_q / max_q NaN for a station whose
reactive_limits_kind is CURVE; its limits are only exposed through
min_q_at_target_p / max_q_at_target_p, which are not default columns.
init_from_pypowsybl read min_q / max_q alone, so such a station was
unlimited: a contingency asking it for more than its curve allows was
neither modelled nor reported by the reactive-limit physical check,
while OpenLoadFlow switches it to PQ at that limit.

The stations are now fetched with all their attributes and read like the
generators: the curve at the target P, the fixed box otherwise, min and
max swapped back when malformed curve data inverts them.

Tested in test_hvdc_pypowsybl.py: a station with a capability curve gets
the curve's limits.

Assisted-by: Claude Code (claude-opus-5-5)
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
A generator can hold the voltage of a bus other than its own. Holding a
remote target can take its own bus far from anything realistic while its
reactive output stays inside its limits, so the reactive-limit check has
nothing to say. OpenLoadFlow's ReactiveLimits loop, in its default
"robust" remote voltage control mode (voltageRemoteControlRobustMode),
switches such a controller bus to PQ at its target reactive power as soon
as its voltage leaves [minRealisticVoltage * 1.02,
maxRealisticVoltage / 1.02] pu of its nominal voltage (the margin checked
against OpenLoadFlow: it switches between the two sides of the bound). An
outer-loop-free solve keeps it regulating; on a real grid snapshot a
contingency left a generator holding its remote target from well above
that bound, with a large voltage gap around it and no physical violation
reported.

It is now a physical check:
- LSGrid.set_remote_voltage_control_vm_range(min_vm_pu, max_vm_pu) stores
  the range (NaN by default: no check, one NaN side checks the other).
  Never read by a powerflow; copied with the grid, not part of get_state
  or the binary format, like set_keep_vinit_at_group_controlled_buses.
- RemoteVoltageControlCheck.hpp: the generator controllers of the
  voltage-control plan the solve used whose own bus is not the bus their
  group regulates (held ones skipped) are checked on their own bus, and
  reported as LOW_VOLTAGE_REMOTE_CONTROL (13) / HIGH_VOLTAGE_REMOTE_CONTROL
  (14) on the GENERATOR, value and limit in kV, by
  LSGrid.get_physical_violations and every batch's
  compute_physical_violations (a generator contingency, or a masked own or
  regulated bus, skips it). Category PHYSICAL.
- init_from_pypowsybl(remote_voltage_control_vm_range="olf") sets
  OpenLoadFlow's default range (None: no check, or a pair).

Tested in test_remote_voltage_control.py, against OpenLoadFlow itself on
IEEE 14 with a generator regulating a remote bus: reported exactly where
OpenLoadFlow switches, on both bounds, and the same in a contingency
analysis.

Assisted-by: Claude Code (claude-opus-5-5)
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
OpenLoadFlow discards from voltage control a generator whose reactive
range is below 1 MVAr, and injects its target_q instead. The bake freezes
such a unit, except when its regulated bus held its target in the
reference solve. But "held" is a property of the bus: when the unit shares
its regulated bus with a controller OLF keeps, that other unit holds the
bus, and the small one read as held and stayed regulating. lightsim2grid
then counted its reactive range in the bus' capability, larger than OLF's,
and missed the reactive-limit switch OLF makes after a contingency (on a
real grid snapshot, a bus OLF switches to PQ while lightsim2grid saw it
well within its capability).

A unit with too small a range is now frozen at its realized q even when
its bus is held, as soon as another regulating generator with a large
enough range (and a plausible target) regulates the same bus. A small
unit alone on its bus keeps the held exemption.

Tested in test_olf_bake.py: a sub-1 MVAr unit added on the bus of an
IEEE 14 generator is frozen at its target_q, the other one keeps
regulating, and the loop-free re-solve reproduces the reference.

Assisted-by: Claude Code (claude-opus-5-5)
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
OpenLoadFlow models an SVC carrying a standbyAutomaton extension -- in
standby or not -- as the automaton's b0, a fixed susceptance, plus the SVC
itself, and holds the SVC's own susceptance in [b_min, b_max]: its
ReactiveLimits loop compares what the SVC part produces with those
limits. The total output, the one pypowsybl reports and lightsim2grid
models, can therefore only range over [b_min + b0, b_max + b0].
lightsim2grid and the bake used [b_min, b_max] for that total: on a real
grid snapshot a contingency asked an SVC with a capacitive b0 for an
output OLF's loop found beyond its limit by the b0 share and switched to
PQ, while lightsim2grid, holding the same voltage, saw it well within its
range and reported nothing (the remaining gap was that b0 share, to the
digit).

_svc_standby_b0 reads the automaton's b0 (0 without the extension);
init_from_pypowsybl shifts the SVC's b_min / b_max by it, so the
reactive-limit physical check (and gpusim2grid, which reads the limits off
the LSGrid) compares the total output with OLF's range, and the bake's
SVC saturation step does the same before freezing an SVC at its limit.

Tested in test_svc_standby.py on the four substations network: reported
exactly when OLF switches (an inductive b0 pushes the SVC part past
b_max, a capacitive one leaves it room), and the bake freezes an SVC OLF
switched at its shifted limit, the loop-free re-solve reproducing the
reference.

Assisted-by: Claude Code (claude-opus-5-5)
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
OpenLoadFlow shares the distributed slack from the raw set-points:
p = clamp(target_p + lambda * weight, [min_p, max_p]), one common lambda.
A unit it capped at max_p therefore had target_p + lambda * weight above
max_p, possibly by a lot when the reference mismatch was large. After a
contingency it shares the new total from the raw set-points again: the
capped unit only leaves max_p once lambda has dropped by enough to use up
that overshoot. The pre-pass (consider_only_main_component(True), the
batches' redistribute_slack) let a flagged "can participate in the slack"
unit move as soon as the contingency's own imbalance was of the other
sign. On a real grid snapshot whose reference distribution had a large
positive mismatch, a contingency then moved a unit OLF kept capped by a
few MW, and every flow with it.

- SlackParticipation carries, next to the "can participate" weight, each
  unit's overshoot beyond the limit it sits at (MW, 0 by default):
  LSGrid.set_gen_can_participate_slack_overshoot /
  set_storage_can_participate_slack_overshoot, GenInfo / StorageInfo
  can_participate_slack_overshoot_mw; binary format 15 -> 16, fixture
  regenerated (LS2G_HAS_CAN_PARTICIPATE_SLACK_OVERSHOOT for gpusim2grid).
- slack_redistribution::distribute, when a unit carries an overshoot, solves
  OLF's rule as it is: one common shift with p_k = clamp(v_k + shift * w_k),
  v_k the injection plus the overshoot on the side of its limit, by
  bisection (the total is monotone in the shift). Without any overshoot the
  round-based algorithm runs unchanged.
- bake_outer_loops(..., return_details=True) returns
  can_participate_slack_overshoot: lambda is read off the units OLF neither
  excluded nor capped, and each capped unit's overshoot is
  target_p + lambda * weight beyond its limit;
  init_from_pypowsybl(can_participate_slack_overshoot=...) sets it.

Tested in test_can_participate_slack.py: an overshoot is used up first
(the capped unit gives part of an islanded load, or none when the overshoot
is larger than the shift), the batch matches the single solve, the value
survives copy, pickle and the binary format, and the bake returns it for
init to set.

Assisted-by: Claude Code (claude-opus-5-5)
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
…sks for less

LSGrid.set_hvdc_ac_emulation_frozen flags the lines bake_outer_loops froze at
their limit (droop off, parameters kept); the physical check reports
HVDC_AC_EMULATION_RELEASE when the droop flow falls below that limit.
init_from_pypowsybl(hvdc_ac_emulation_frozen=...), binary format 17.

Assisted-by: Claude Code (claude-opus-5-5)
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
…ring key

OpenLoadFlow gives each controller bus of a remote voltage control group a
share keyed on ALL its connected generators (q_percent, or reactive range),
regulating or not; lightsim2grid only summed the controllers.

Assisted-by: Claude Code (claude-opus-5-5)
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
…ulates

OpenLoadFlow switches a whole bus PV -> PQ; a unit at its limit while another
unit of its bus keeps headroom was only clamped when the bus' reactive power
was split among its units. Neither freezing rule of the bake freezes it now.

Assisted-by: Claude Code (claude-opus-5-5)
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
Both sides added entries at the top of the 1.1.1 changelog section. Keep
both and sort the section by kind (BREAKING, FIXED, ADDED, IMPROVED), as
dev_1.1.1 already does.

Assisted-by: Claude Code (claude-opus-5-5)
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
distribute_with_overshoot only reported "all saturated" when every
participant ran out of room. A unit only flagged "can participate" with
room left kept that false, every unit of the Newton solve's slack was
marked saturated, and the caller took them all out of it: the next solve
had an empty distributed slack. Apply the rule `distribute` already
applies: when every in_slack unit hit its bound, clear the mask and set
all_saturated.

Assisted-by: Claude Code
Claude-Session: https://claude.ai/code/session_01GbSAUSwx459CCq4pn9oTkD
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
The overshoot was a magnitude, and distribute_with_overshoot guessed its
side from where the unit sits. A charging storage unit a positive
mismatch capped at 0 MW sits at 0, which the sign rule reads as the
LOWER bound of an injecting unit: the overshoot was put below 0, a later
negative mismatch could not move the unit at all, and a positive one
moved it above 0 once a phantom overshoot was used up.

The overshoot is now signed: > 0 beyond the upper limit, < 0 beyond the
lower one. The bake returns it so, the setter accepts any finite value,
and at 0 MW the sign tells which side of 0 the unit is on.

Assisted-by: Claude Code
Claude-Session: https://claude.ai/code/session_01GbSAUSwx459CCq4pn9oTkD
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
The swap of inverted min_q / max_q writes into the arrays
`Series.to_numpy(float)` returns, which pandas copy-on-write (the
default from pandas 3) hands out read-only: init_from_pypowsybl raised
"assignment destination is read-only" on every network with an HVDC
line, swapped limits or not (an empty boolean mask still writes).

Assisted-by: Claude Code
Claude-Session: https://claude.ai/code/session_01GbSAUSwx459CCq4pn9oTkD
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
`can_participate_slack_overshoot` was only read inside the branch that
applies `can_participate_slack`: given alone, or with an explicit slack,
it was dropped silently and every capped unit left its limit at once.
Raise, as `can_participate_slack` itself does in the same situation.

Assisted-by: Claude Code
Claude-Session: https://claude.ai/code/session_01GbSAUSwx459CCq4pn9oTkD
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
The other bake flags (can_be_pv, the "can participate in the slack"
weight and overshoot) are compared; this one was not, so two grids
differing only by which hvdc lines are frozen compared equal and a
regression in its binary round trip or its propagation went unseen.

Assisted-by: Claude Code
Claude-Session: https://claude.ai/code/session_01GbSAUSwx459CCq4pn9oTkD
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
The VSC converter read duplicated the generators' one (the curve at the
target P when the flat box is NaN, the sort of an inverted pair), and
the two copies had already drifted apart: one wrote into a read-only
array under pandas copy-on-write. Both now go through
_aux_reactive_limits_at_target_p; each keeps its own bound for NaN.

No behaviour change: the limits init_from_pypowsybl reads are
bit-identical before and after on ieee14, ieee118, the four-substations
network and on CURVE / swapped-curve / HVDC variants.

Assisted-by: Claude Code
Claude-Session: https://claude.ai/code/session_01GbSAUSwx459CCq4pn9oTkD
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
The bake rebuilt the main-component filter by hand (connected and
synchronous component 0 on both stations) next to the module's own
_keep_only_main_comp: two definitions that would drift apart the day
the rule changes. Filter the stations through it instead.

No behaviour change: the lines baked, their target_p and converters
mode are identical before and after on the hvdc test networks
(saturated, linear, operator-range, a station disconnected), with and
without keep_only_main_comp.

Assisted-by: Claude Code
Claude-Session: https://claude.ai/code/session_01GbSAUSwx459CCq4pn9oTkD
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
sharing_weight recomputed, for every controller, the share of the bus
of each of its peers, each by a pass over all the members of the group:
cubic in the size of a group, at every rebuild of the plan. What it
sums does not depend on the controller beyond its own membership, so
the per-bus shares, whether the buses of the active members are all
keyed, and the sums inside each bus are computed once per group; a held
controller adds its own bus and terms on top, as its peer list did.

The sums are accumulated in the same order: the weights are
bit-identical before and after (checked on 1335 controllers over mixed
keyed / unkeyed buses, held members and passive units), and a group of
2000 controllers on 400 buses builds in about a millisecond instead of
several seconds.

Assisted-by: Claude Code
Claude-Session: https://claude.ai/code/session_01GbSAUSwx459CCq4pn9oTkD
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
BDonnot and others added 9 commits October 3, 2026 16:28
init_from_pypowsybl(remote_voltage_control_vm_range="olf") used a
hard-coded minRealisticVoltage / maxRealisticVoltage of 0.8 / 1.2, but
OpenLoadFlow's defaults differ between versions: the one pypowsybl
1.16.1 ships uses 0.5 / 2.0. There, lightsim2grid reported remote
controllers OpenLoadFlow never switches, and
test_reported_where_olf_switches failed in CI.

"olf" now reads both defaults off the installed OpenLoadFlow
(get_provider_parameters), with the margin of its loop; the constants
are only the fallback when pypowsybl does not expose them. The two
tests that check the reporting itself, not the default, pass an
explicit range.

Assisted-by: Claude Code
Claude-Session: https://claude.ai/code/session_01GbSAUSwx459CCq4pn9oTkD
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
Review fixes for #222: slack overshoot, VSC limits, OLF voltage range, sharing-key cost
OpenLoadFlow reads the standby automaton's b0 in
LfStaticVarCompensatorImpl.setupVoltageControl, i.e. only for an SVC
regulating in VOLTAGE mode. init_from_pypowsybl shifted [b_min, b_max] by
b0 for every SVC carrying the extension, so a REACTIVE_POWER or OFF SVC got
a reactive envelope OpenLoadFlow never uses. The bake already filtered on
voltage mode; the loader now does the same, and also keeps the shift for an
SVC the bake froze out of voltage control (can_be_pv), whose release brings
it back.

Assisted-by: Claude Code
Claude-Session: https://claude.ai/code/session_013nEtWC1QUbwyMjfDJ8JGnA
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
OpenLoadFlow switches a whole controller bus, not one of its units: an SVC
it clamped at its limit while splitting a bus' reactive power with a
generator that still has room keeps regulating. The generator rules
already exclude that case (_bus_still_regulating); the SVC rules did not,
so the bake froze such an SVC to REACTIVE_POWER -- through the "switched
out of a shared group" rule by default, and through the saturated rule
with bake_saturated_voltage_control. Both now skip an SVC whose own bus
has another controller inside its range.

Assisted-by: Claude Code
Claude-Session: https://claude.ai/code/session_013nEtWC1QUbwyMjfDJ8JGnA
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
The flag says an outer loop froze the line at its active power limit, and
the release check re-derives the direction it is frozen in from the
current converters_mode. Nothing cleared it when a caller (the grid2op
backend, through change_p_dcline) later moved or reversed the set-point,
so a line no longer at that limit kept being checked -- against the limit
of whatever direction the new set-point pointed to, which reported a
spurious HVDC_AC_EMULATION_RELEASE. change_p now clears the flag when the
set-point or the direction actually changes; writing the same set-point
again keeps it.

Assisted-by: Claude Code
Claude-Session: https://claude.ai/code/session_013nEtWC1QUbwyMjfDJ8JGnA
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
distribute_with_overshoot returned early with not_distributed_mw = 0 when
the participants' weights summed to zero or less, while distribute, in the
same case, reports the whole mismatch as not distributed. Nothing moved,
so a caller reading the report was told everything had been placed. Both
paths now agree.

Assisted-by: Claude Code
Claude-Session: https://claude.ai/code/session_013nEtWC1QUbwyMjfDJ8JGnA
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
A unit OpenLoadFlow capped beyond its active limit carries that overshoot
into the redistribution pre-pass, but nothing ever reduced or cleared it:
redistribute_active_power applied the full reference overshoot again on
every call, and a unit that rejoined the slack and was later saturated
brought the old value back. Two redistributions in a row therefore did not
land where one redistribution of their sum does.

distribute (both paths) now reports, per unit, how far beyond its bound the
common shift leaves it; redistribute_active_power stores that on every
unit out of the slack afterwards, and a unit joining (or removed from) the
slack drops its overshoot. The batch pre-pass, which starts each row from
the base case, is unchanged.

Assisted-by: Claude Code
Claude-Session: https://claude.ai/code/session_013nEtWC1QUbwyMjfDJ8JGnA
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
The range of the LOW_/HIGH_VOLTAGE_REMOTE_CONTROL check was copied with
the grid but left out of get_state, and init_from_pypowsybl turns it on by
default. A grid pickled to worker processes, deep-copied (pybind falls
back on the pickle state) or saved with save_binary therefore came back
with the check silently off. The two bounds are appended to
LSGrid::StateRes and restored through the setter, which also validates
what a file holds.

BINARY_FORMAT_VERSION 17 -> 18; the reference fixture is regenerated as
case14_sandbox_format18.lsb.

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

OpenLoadFlow builds the reactive keys of a shared voltage control from
every LfGenerator of each controller bus (Control.createReactiveKeys):
generators, but also batteries, VSC converter stations and SVCs, none of
which can carry a key -- so one of them on a controller bus makes the whole
group fall back on the reactive ranges. _collect_passive_gens only looked
at generators, so a battery next to a keyed generator left lightsim2grid
sharing by keys where OpenLoadFlow shares by ranges.

The passive units of a controller bus now include the connected storage
units, VSC stations (LCC stations are loads in OpenLoadFlow) and SVCs that
control nothing; an SVC's susceptance range is converted to MVAr with the
grid's sn_mva, which build_solver_side now takes.

Assisted-by: Claude Code
Claude-Session: https://claude.ai/code/session_013nEtWC1QUbwyMjfDJ8JGnA
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
BDonnot and others added 4 commits October 4, 2026 05:34
LSGrid::set_gen_can_participate_slack_overshoot said the values are >= 0,
but they are signed -- negative for a unit capped below its lower limit --
and distribute_with_overshoot relies on that sign to tell, for a unit at
0 MW, which side of 0 it was capped from. The header comment and the
GenInfo / StorageInfo attribute doc now say so; the python docstring also
names the right init_from_pypowsybl argument (can_participate_slack_overshoot).

Assisted-by: Claude Code
Claude-Session: https://claude.ai/code/session_013nEtWC1QUbwyMjfDJ8JGnA
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
HVDC_AC_EMULATION_RELEASE and LOW_/HIGH_VOLTAGE_REMOTE_CONTROL are reported
by LSGrid.get_physical_violations and by every batch's
compute_physical_violations, but most of the documentation of those
entry points still described the earlier set of checks: the LSGrid
docstring listed only HIGH_P for an hvdc line, the batch and
ContingencyAnalysis docs counted "five checks" and "five shapes", and the
LimitViolation value / limit / side docs did not say what they hold for
the release. Each place now lists both checks, with the meaning of side,
value and limit, the tolerance each is compared with, and that the release
check works in DC while the remote one is AC only.

Assisted-by: Claude Code
Claude-Session: https://claude.ai/code/session_013nEtWC1QUbwyMjfDJ8JGnA
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
The previous commit had redistribute_active_power give a unit it
saturated a fresh overshoot (how far the common shift would have taken it
past its bound). That holds the unit at its limit on the next mismatch of
the other sign, where OpenLoadFlow's distribution step lets a unit it
capped move at once -- test_main_component_slack's
test_redistribute_active_power_standalone checks exactly that and failed in
CI. A redistribution now only uses up an overshoot a unit already carried
(from the reference solve), never makes one and never flips its sign.

Assisted-by: Claude Code
Claude-Session: https://claude.ai/code/session_013nEtWC1QUbwyMjfDJ8JGnA
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
add_pickle handed the whole LSGrid::StateRes (a tuple of the nested states of every
container) to pybind11 in one go. That instantiates a single enormous tuple_caster, which
- overflows MSVC's 64K type-record limit (C1067 in pybind11/detail/descr.h) on
  binding_lsgrid.cpp, so the Windows editable install failed and the tests then could not
  import lightsim2grid;
- needs several GB of compiler memory, which OOM-killed cc1plus of gcc 8 even on the
  large resource class.

The state is now converted recursively one tuple element at a time (C++14, no fold
expressions), which gives the very same python tuple, so existing pickles stay readable.
Peak compiler memory on binding_lsgrid.cpp drops from about 3.1 GB to about 1.1 GB.

Assisted-by: Claude Code (claude-sonnet-5-5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ToXXBvRV1cdc9sM3PE4AZF
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
(cherry picked from commit 7b2dcca)
Fix the review findings on #222 (slack overshoot, SVC b0, hvdc frozen flag, reactive keys)
@BDonnot
BDonnot merged commit 62e0a36 into dev_1.1.1 Oct 4, 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