Skip to content

Fix derivatives - #207

Merged
BDonnot merged 7 commits into
dev_1.0.1from
fix_derivatives
Sep 19, 2026
Merged

BDonnot merged 7 commits into
dev_1.0.1from
fix_derivatives

Conversation

@BDonnot

@BDonnot BDonnot commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

Fix some issues by testing the batch algorithm against finite differences

Assisted-by: Claude Code (Opus 5)
Signed-off-by: DONNOT Benjamin <benjamin.donnot@rte-france.com>
… choice on whether saturated gen are PV or PQ

Assisted-by: Claude Code (Opus 5)
Signed-off-by: DONNOT Benjamin <benjamin.donnot@rte-france.com>
Assisted-by: Claude Code (Opus 5)
Signed-off-by: DONNOT Benjamin <benjamin.donnot@rte-france.com>
…ing generators

Assisted-by: Claude Code (Fable 5.1)
Signed-off-by: DONNOT Benjamin <benjamin.donnot@rte-france.com>
dev_1.0.1 gained a generator active-power-limits / distributed-slack
physical check (GenPCheck.hpp, BINARY_FORMAT_VERSION 6 -> 7) while this
branch independently refactored generator/storage slack participation
into SlackParticipation and bumped the same format number twice more
(storage voltage regulation, then storage slack participation).

Conflict resolution:
- GeneratorContainer::StateRes/StateResIdx: keep both additions
  (p_min_mw_/p_max_mw_ then reactive_key_, appended in that order so a
  v7 file stays a strict prefix of the merged layout).
- BinaryArchive.hpp: renumber this branch's two bumps to sit after
  dev_1.0.1's already-independent v7 -- storage voltage regulation +
  generator reactive key become v8, storage slack participation
  becomes v9 (was v7/v8 here, now stacked on top of dev_1.0.1's v7
  instead of colliding with it).
- Regenerated the binary fixture at v9 (case14_sandbox_format9.lsb,
  replacing the v6/v7/v8 ones both sides tried to rename format6.lsb
  into) and updated test_binary_serialization.py's FIXTURE_PATH.
- binding_batch.cpp / CHANGELOG.rst: textual merges, kept both sides'
  content.
- All other conflicts (GenPCheck.hpp itself, BaseBatchSweep.*,
  LimitViolation.hpp, LSGrid.hpp, the two other binding files,
  test_batch_physical_violations.cpp) auto-merged cleanly since
  GenPCheck.hpp only calls GeneratorContainer's already-refactored
  public slack API (is_slack/get_gen_slack_weight/get_min_p/get_max_p).

Verified: full C++ Catch2 suite (343/343) and the Python
test_binary_serialization / test_physical_violations /
test_storage_distributed_slack suites all pass against a rebuilt
extension.

Assisted-by: Claude Code (claude-sonnet-5)
Signed-off-by: DONNOT Benjamin <benjamin.donnot@rte-france.com>
BDonnot added a commit that referenced this pull request Sep 18, 2026
Sync the fast-env branch with the head of the fix_derivatives branch.

The PR renamed GridModel to LSGrid, moved the core under src/core and
split the pybind11 module into src/bindings/python/binding_*.cpp, so the
light-env work carried on this branch is ported onto that layout:

- src/light_env/*.hpp move to src/core/light_env/, are wrapped in the
  ls2g namespace, and target LSGrid (change_bus_* now takes a
  GridModelBusId).
- the LightEnv / Protections / TopoAction / ElementType bindings that
  lived at the end of src/main.cpp become src/bindings/python/
  binding_light_env.cpp, registered through bind_light_env().
- test_light_env.py imports from lightsim2grid.lightsim2grid_cpp.

The three modify/delete conflicts (src/GridModel.hpp,
src/element_container/SGenContainer.cpp, src/main.cpp) are resolved by
taking the deletion: the fast-env edits there were a template parameter
rename, a blank line, and the bindings ported above.

Assisted-by: Claude Code (claude-fable-5-1)
Signed-off-by: DONNOT Benjamin <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.

Some changes

*charging* battery (``target_p < 0``) does participate;
* its key is ``max_p / droop`` (``max_p`` itself, not ``max_target_p``).

Measured against OpenLoadFlow on a real RTE snapshot, battery by battery (shares x12.4,

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.

remove RTE please

"""

import io
import xml.etree.ElementTree as ET

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.

xml becomes an hard dependency then ? From memory it's rather heavy :-/ Is there any solution outside of it ? Or maybe it's already a dependency of pypowsybl (it would surprise me)

Maybe lazy incluse it if needed only. With a try / catch failing back to a reasonable default behavior if not installed.

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.

Not a new dependency: xml.etree.ElementTree is in the Python standard library, so it is already there with the interpreter, and neither pypowsybl nor anything else has to ship it. Its C accelerator (_elementtree) is built into CPython.

That said, the concern about paying for it on every import is fair, so it is now imported inside _apc_from_xiidm — the only function that needs it, and only on the fallback path (pypowsybl <= 1.16.1, source="auto"). The import is wrapped in a try / except ImportError that warns and returns {}, which makes every battery fall back to OpenLoadFlow's defaults, so a Python built without _elementtree still loads the grid.

Done in c16eb3f (branch claude/pr-207-comments-defow7).


Generated by Claude Code

Comment on lines 67 to 72
ATTR_STORAGES_INPUT = [
"target_p_mw",
"target_q_mvar",
"is_slack",
"slack_weight",
]

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.

I put it here because I don't know where to have it otherwise:

  • in the previous merge, there was also capability to detect elements of the slack outside their pmin / pmax. Is this physical detection present for battery too ?
  • another question, still about the previous (already merged) feature, are the pmin / pmax of generator read when the grid is init from Matpower/ powermodels and pypowsybl ?

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.

Two questions, two different answers.

1. Is the pmin / pmax detection present for batteries too? No, and it cannot be yet.

GenPCheck (the active-power third of compute_physical_violations) only walks the generators, and the reason is one level down: StorageContainer carries no active power limits at all — there is no min_p_mw / max_p_mw on a storage unit, no set_storage_p_limits, nothing in StateRes. So a battery the distributed slack pushed past what it can deliver goes unreported, even though this PR does let it take a share of the slack.

Closing that gap is a real piece of work (container state + BINARY_FORMAT_VERSION bump + bindings + the converters + GenPCheck), so I have not widened this PR with it — it is written up as a TODO in the changelog instead, with what it takes. Say the word if you would rather have it here.

The reactive side is fine: a voltage-regulating storage unit's [min_q, max_q] does count towards its bus' capability (BusQCheck), tested both in test_physical_violations.py::TestStorageCapabilityFromPython and in test_batch_physical_violations.cpp.

2. Are the generators' pmin / pmax read by the other converters? pypowsybl yes, MATPOWER / PowerModels no — now fixed.

  • pandapower: yes, _aux_add_gen.py reads min_p_mw / max_p_mw.

  • pypowsybl: yes, _aux_add_generators.py reads min_p / max_p (iidm always carries them).

  • PowerModels / MATPOWER: no. from_powermodels/_aux_add_gen.py read qmin / qmax and stopped there, so a grid loaded from a .m file could not report an active-power violation at all — even though _mpc_to_powermodels.py was already carrying PMIN / PMAX through into the dict, unused.

    Fixed in c16eb3f: the PowerModels converter now calls set_gen_p_limits, which covers MATPOWER since it goes through the same dict. A missing or non-finite limit becomes NaN, i.e. "no limit for that one", same convention as the other two. Tested in test_init_from_matpower.py::test_generator_active_power_limits_are_read.


Generated by Claude Code


# OpenLoadFlow's hardcoded fallback droop (`AbstractLfGenerator.DEFAULT_DROOP`, "why not"),
# for a unit whose `activePowerControl` extension does not set its own
_OLF_DEFAULT_DROOP = 4.0

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.

Avoid redeclaring this type of constants. The day it changes it will be error prone (and hard to debug). Have them all in an util file that is imported when such constants are needed.

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.

Agreed — and worse than a duplicate, _aux_battery_apc.py was importing two of them back from _olf_bake.py, which is what forced the lazy-import dance between the two modules.

They now all live in one new module, lightsim2grid/network/from_pypowsybl/_olf_const.py, in two groups: OpenLoadFlow's own constants mirrored here (_ZERO_P_TOL, _MIN_REACTIVE_RANGE_MVAR, _MIN_PLAUSIBLE_TARGET_V_PU / _MAX_PLAUSIBLE_TARGET_V_PU, _MIN_NOMINAL_V_FOR_TARGET_V_CHECK_KV, _MAX_PLAUSIBLE_ACTIVE_POWER_MW, _OLF_DEFAULT_DROOP) and the tolerances used to read a reference solve back (_Q_LIMIT_TOL_ABS / _Q_LIMIT_TOL_REL, _TARGET_V_HELD_TOL_PU, _TARGET_Q_TOL_MVAR). _olf_bake.py has no module-level constant left at all, _aux_battery_apc.py and _aux_add_slack.py import from _olf_const directly, and the circular import is gone with them.

Done in c16eb3f (branch claude/pr-207-comments-defow7).


Generated by Claude Code

generator whose target was NOT held was not controlling (switched, not
started, discarded, merged, below OLF's ``generatorVoltageControlMinNominalVoltage``,
...) and is frozen to its realized Q in a final catch-all pass
(:func:`_bake_generator_voltage_control_not_held`). On a real 7k-bus RTE

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.

Remove rte and use a more generic term "real grid snapshots" for example

Comment on lines +1117 to +1118
OLF effectively used (on a real 7k-bus RTE snapshot the 41 units at their
``max_p`` carried 42 % of the raw key). This only holds for a mismatch of

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.

Mention of rte

And 42% should not happen here

0 MW) is left alone.
* PV -> PQ reactive-limit switches (generators, VSC converter stations): only
the units whose realized reactive power actually sits at a Q limit are frozen
to fixed-Q at that value, with voltage regulation switched off. Units still

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.

Putting it at the top of the changes here, but does not really concern it:

Add in the Todo, on the Changelog, the fact that :

  1. this file should be split into comprehensible "sub files" to facilitate the debugging
  2. there should be, opt-in options, off by default, the possibility to get the changes made to the grid. Like "gen xxx removed from slack", "batt yyy assigned PQ" etc in a json format for example

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.

Both added at the top of the [TODO] section of CHANGELOG.rst (c16eb3f):

  1. Split _olf_bake.py. Along what each rule bakes — taps and sections, generator voltage control, reactive-limit switches, SVCs, active power and slack participation — so a failing bake points at one readable module rather than at a thousand-line file.
  2. An opt-in report of what the bake changed, off by default: "gen xxx removed from the slack participants", "batt yyy frozen to PQ", "tap zzz fixed at position n", in json, each with the rule that decided it. Today the bake rewrites the grid silently and a caller cannot tell which decision produced the state it is handed.

Two more went in while going through the review, from the other threads: separating slack participation from the reference slack (here), and active-power limits for storage units (here).


Generated by Claude Code

Comment thread lightsim2grid/tests/test_olf_bake.py Outdated

def test_olf_switched_unit_baked_at_its_limit_not_reported_q(self):
"""A unit switched at its Q limit injects that limit, but OLF does not always
report it (on RTE snapshots it re-splits a bus' reactive target among the units

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.

Rte...

// STORAGE is never a controller of the plan (a storage unit only ever pins its
// own bus, see StorageContainer); the tag exists for LSGrid's reactive-residual
// bookkeeping (QShare), which uses the same kind space.
enum Kind { GEN = 0, SVC = 1, HVDC_SIDE_1 = 2, HVDC_SIDE_2 = 3, STORAGE = 4 };

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.

Are storage units properly added to the physical violation qmin / qmax check ?

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.

Yes — a voltage-regulating storage unit's reactive range does count towards its bus' capability.

The STORAGE tag not being a controller of the voltage-control plan is what made this look doubtful, and the two are independent: BusQCheck::build_bus_q_plan builds each bus' [q_min, q_max] from everything that holds that bus, whatever path it holds it through, and it walks the storage units explicitly (is_local_voltage_controller, get_min_q / get_max_q summed into the bus like a local generator's). Tested in test_physical_violations.py::TestStorageCapabilityFromPython::test_limits_of_a_regulating_storage_unit and in test_batch_physical_violations.cpp ("a voltage-regulating storage unit's reactive capability counts towards its bus'").

Since the question came up right here, the comment on Kind now says it rather than leaving it to be re-derived (c16eb3f).

The active side is the one that is genuinely missing — see my answer on the compare_lsgrid.py thread: a storage unit has no min_p_mw / max_p_mw at all, so GenPCheck has nothing to compare against. TODO added.


Generated by Claude Code

// This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform.

#ifndef LS2G_SLACK_PARTICIPATION_H
#define LS2G_SLACK_PARTICIPATION_H

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.

I put this comment here, why not...

Add a Todo in the changelog for the NR behavior when multiple slacks are present.
We should now make the difference between "participate in the distributed slack" which is pure active things and has nothing to do with voltages and "is reference slack" which is a much stronger say : theta = known, VM = known, p and q unknown.
Today an element participating to the distributed slack is necessarily PV which does not make any sense (a load could participate in the distributed slack...)

Let's not adress this yet, but have a todo around this.

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.

Agreed, and not addressed — TODO added, in two places (c16eb3f):

  • CHANGELOG.rst, top of [TODO]: "participates in the DISTRIBUTED SLACK" is a purely active statement — this element takes a share of the power imbalance, by a participation factor — and says nothing about voltage, unlike "is the REFERENCE slack" (theta known, |V| known, P and Q unknown). Today a participant is necessarily PV, which does not follow: a load could take a share without controlling any voltage. Separating the two roles is what would allow it.
  • a shorter note in the class docstring of SlackParticipation itself, pointing at the changelog — that is where someone adding a participant will be looking.

Generated by Claude Code

…lpers

Review comments on "Fix derivatives", worked through:

* Every mention of RTE, and every exact number measured on one snapshot ("30
  generators", "42 % of the raw key", "18.820306 MVAr", ...), is out of the
  comments and docstrings. They read as a note to oneself: they date, a reader
  cannot check them, and they describe one grid rather than the behaviour. What
  each rule does is said instead. The rule itself is now written down in
  CLAUDE.md so it does not have to be re-discovered on the next PR.

* The OLF-mirrored constants and the tolerances for reading a reference solve
  back move into one module, `from_pypowsybl/_olf_const.py`. They were declared
  in `_olf_bake.py` -- some of them mid-file -- and `_aux_battery_apc.py`
  imported them back lazily, which is also what forced the circular-import
  dance between the two. One writer now, for all of them.

* `xml.etree.ElementTree` is imported inside `_apc_from_xiidm`, the only
  function that needs it, behind a try/except that falls back to OpenLoadFlow's
  defaults. It is a standard-library module rather than a new dependency, but
  it is only ever needed on the XIIDM fallback path.

* `_bake_battery_voltage_control` and the generator catch-all now share what is
  genuinely common: `_resolve_regulated_bus` (which bus an element regulates)
  and `_target_v_held` (did the reference solve hold that bus at the target).
  The two entry points stay apart: a battery's regulation flag, target voltage
  and regulated element live in an extension frame, not in columns of
  `get_batteries()`, and switching it off is `update_extensions`.

* PowerModels' / MATPOWER's `pmin` / `pmax` reach `LSGrid.set_gen_p_limits`.
  They were read by the pandapower and pypowsybl converters only, so a grid
  loaded from a .m file could not report an active-power physical violation at
  all. Tested in `test_init_from_matpower.py`.

* A voltage-regulating storage unit's reactive range does already count towards
  its bus' capability in `compute_physical_violations` (`BusQCheck` builds the
  bus from whatever holds it, controller of the voltage-control plan or not).
  Said so where the question came up, in `VoltageControlData.hpp`.

* Four TODOs added to the CHANGELOG: separating "participates in the
  distributed slack" (purely active) from "is the reference slack"; splitting
  `_olf_bake.py` into sub-files; an opt-in json report of what the bake
  changed; and active-power limits for storage units, which have none, so a
  battery the slack pushed past what it can deliver goes unreported.

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

BDonnot commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator Author

Review worked through — on branch claude/pr-207-comments-defow7

All 20 threads are addressed in c16eb3f, pushed to claude/pr-207-comments-defow7 (branched off this PR's head, eccddec), so it merges into fix_derivatives cleanly. I did not push to your branch.

added removed
C++ (src/core) +11 -1
Python (package) +150 -122
Tests +10 -3
Docs + changelog +48 -1
total +296 -162

RTE names and measured numbers — 11 threads

Every RTE introduced by this PR is gone, replaced by "a real grid snapshot" / "real grid snapshots", and every number carried over from an investigation with it. What each rule does is said instead:

where was now
_aux_battery_apc.py:25 "a real RTE snapshot ... shares x12.4, x1, x0.144" which units the rule keeps, and that the droop = 0 ones take no part
_aux_add_slack.py:58 "7k-bus RTE snapshot ... to 1e-13" "real grid snapshots ... to solver precision"
_olf_bake.py:71 "30 generators ... and ~100 they left regulating" what the catch-all decides, in both directions
_olf_bake.py:214-215 "two units at 1.22366 and 1.22242 reported 1.22304 and 1.22242" two units of a bus reported a hair from what they injected, the bus no longer balancing
_olf_bake.py:229-232 "2.9e-4 and 6.2e-4 MVAr ... 0.18 MVAr inside it ... 0.11 MVAr beyond" the bus balance being off by the discrepancy is what identifies a misreport
_olf_bake.py:418 "~1e-16 pu ... at least ~1e-7 pu away" machine precision vs. orders of magnitude further; the threshold sits between them
_olf_bake.py:734-737 "RTE snapshots ... 7.6e-4 pu on the 6 kV side" the case it covers, and that the low-voltage side is visibly off without it
_olf_bake.py:825-826 "85.8 MW on a 217-947 MW curve ... 18.820306 / -13.686818 MVAr" the extrapolated limit sits far enough from the clamped one to bake the wrong value
_olf_bake.py:909 "2e-3 pu on an RTE snapshot" removed
_olf_bake.py:1117-1118 "the 41 units at their max_p carried 42 %" the capped units carry enough of the raw key that ignoring them skews the distribution
test_olf_bake.py:499 "on RTE snapshots" "on real grid snapshots"

The numbers that stay are the ones a reader can act on: the tolerances the code uses, the constants mirrored from OpenLoadFlow, the values the test fixtures set.

The rule itself is now written down in CLAUDE.md (Comments and docstrings: no RTE, no measured numbers), so it does not have to be re-discovered next time.

The rest

  • Constants — all of them, OLF-mirrored and reading-back tolerances alike, moved into one module from_pypowsybl/_olf_const.py. _olf_bake.py has no module-level constant left (that also answers "constants at the top of the file"), and the circular import between _olf_bake and _aux_battery_apc goes with them.
  • xml — stdlib, not a new dependency, but now imported inside _apc_from_xiidm only, behind a try / except ImportError falling back to OpenLoadFlow's defaults.
  • Merging the two voltage-control functions — the decision merged (_resolve_regulated_bus, _target_v_held), the read/write around it did not, because a battery's regulation lives in an extension frame. Reasons in the docstring and on the thread.
  • Storage units and the physical checks — reactive: already covered (BusQCheck counts a regulating storage unit's range in its bus'). Active: not covered, and cannot be — a storage unit has no min_p_mw / max_p_mw at all. TODO added rather than widening this PR.
  • Generator pmin / pmax by converter — pandapower yes, pypowsybl yes, PowerModels / MATPOWER no. Fixed: the PowerModels converter now calls set_gen_p_limits, which covers MATPOWER too, with a test.
  • TODOs — four in CHANGELOG.rst: separating distributed-slack participation from the reference slack (+ a note in SlackParticipation.hpp), splitting _olf_bake.py, the opt-in json report of what the bake changed, and storage active-power limits.

Tests

Built from source and run locally: test_olf_bake (27), test_storage_voltage_control (8), test_storage_distributed_slack (12), test_init_from_matpower (27), test_voltage_control_pypowsybl (20), test_physical_violations (26) — all pass, except 4 in test_physical_violations that error on import grid2op, which is not installed in this container and is unrelated to the change.


Generated by Claude Code

Only conflict was the top of CHANGELOG.rst's [TODO], where both sides added
entries: dev_1.0.1 the control-limits design note from #208, this branch the
four written up while going through the review of #207. Both kept, the base
branch's first.

No code came in with the merge -- dev_1.0.1 added a docs file and that
changelog entry, nothing else.

Assisted-by: Claude Code (claude-opus-5)
Claude-Session: https://claude.ai/code/session_01PC8RAAVDZEqq2UENu7jKNZ
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 d2c2be8 into dev_1.0.1 Sep 19, 2026
104 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