Fix derivatives - #207
Fix derivatives#207
Conversation
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>
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>
| *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, |
| """ | ||
|
|
||
| import io | ||
| import xml.etree.ElementTree as ET |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
| ATTR_STORAGES_INPUT = [ | ||
| "target_p_mw", | ||
| "target_q_mvar", | ||
| "is_slack", | ||
| "slack_weight", | ||
| ] |
There was a problem hiding this comment.
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 ?
There was a problem hiding this comment.
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.pyreadsmin_p_mw/max_p_mw. -
pypowsybl: yes,
_aux_add_generators.pyreadsmin_p/max_p(iidm always carries them). -
PowerModels / MATPOWER: no.
from_powermodels/_aux_add_gen.pyreadqmin/qmaxand stopped there, so a grid loaded from a.mfile could not report an active-power violation at all — even though_mpc_to_powermodels.pywas already carryingPMIN/PMAXthrough 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 intest_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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
Remove rte and use a more generic term "real grid snapshots" for example
| 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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 :
- this file should be split into comprehensible "sub files" to facilitate the debugging
- 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
There was a problem hiding this comment.
Both added at the top of the [TODO] section of CHANGELOG.rst (c16eb3f):
- 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. - 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
|
|
||
| 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 |
| // 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 }; |
There was a problem hiding this comment.
Are storage units properly added to the physical violation qmin / qmax check ?
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
SlackParticipationitself, 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>
Review worked through — on 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.pyhas no module-level constant left (that also answers "constants at the top of the file"), and the circular import between_olf_bakeand_aux_battery_apcgoes with them. xml— stdlib, not a new dependency, but now imported inside_apc_from_xiidmonly, behind atry/except ImportErrorfalling 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 (
BusQCheckcounts a regulating storage unit's range in its bus'). Active: not covered, and cannot be — a storage unit has nomin_p_mw/max_p_mwat all. TODO added rather than widening this PR. - Generator
pmin/pmaxby converter — pandapower yes, pypowsybl yes, PowerModels / MATPOWER no. Fixed: the PowerModels converter now callsset_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 inSlackParticipation.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>
Fix some issues by testing the batch algorithm against finite differences