Skip to content

Outer detection - #15

Merged
BDonnot merged 23 commits into
mainfrom
outer_detection
Oct 13, 2026
Merged

BDonnot merged 23 commits into
mainfrom
outer_detection

Conversation

@BDonnot

@BDonnot BDonnot commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

No description provided.

…l ones

Assisted-by: Claude Code (Fable 5.1)

Signed-off-by: DONNOT Benjamin <benjamin.donnot@rte-france.com>
…rence and add storage unit as a voltage controler

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

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>
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>
set_gen_v (InjectionSweepGPU, ScenarioSweepGPU, BatchPowerFlow) now keys a
column on the bus its generator REGULATES (InjectionElements.gen_v_bus), not
on the bus it stands on: a Vm-fixed bus is reseeded as before, while a bus
held by a VoltageControl group gets that group's per-row v_set
(GenVsetSlots, vc_vset_stride), which is what a remote regulator's set-point
was silently dropped from. The Vm-fixed mask now excludes a slack bus that
carries a ledger Vm unknown; conflicting_gen_v_rows and the gradient gate
follow the same mapping and refuse a group holding an SVC / hvdc station
asked for anything but its pinned set-point. Tied set-points share one
derivative (get_gen_v_share parity).

BatchTimings also carries the cuDSS factor statistics (LU nnz, memory
estimates) read after every ANALYSIS, and benchmark_cudss_batch_raw runs the
uniform-batch solver on an arbitrary CSR matrix to size a Jacobian no session
builds yet.

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

lightsim2grid 1.1.0's GenPCheck, as the third member of
compute_physical_violations: the slack is solved inside the Jacobian by
participation factors that know nothing about limits, so a participating
machine's converged active power (its target plus its share of the
imbalance) can leave [min_p_mw, max_p_mw] -- what OpenLoadFlow's
DistributedSlack outer loop re-shares. Reported per machine (LOW_P / HIGH_P
on the new GENERATOR / STORAGE element types, container id), never enforced.

Generators and storage units are one check, as upstream: the share of a
machine is a fraction of the raw participation of its whole bus, both
families included, so the plan (GenPPlanData, built by lightsim2grid's own
build_gen_p_plan through the bridge, or handed in as arrays; optional --
unset means nothing has a limit) carries every participant next to the
reportable entries. The kernel reads no slack state: the live raw
participation of a bus is summed on the device from the plan (upstream's
w_norm(bus) * total_raw_w), and the share comes from the raw active residual
of the slot's patched Ybus plus the droop hvdc flows leaving the bus. A
generator's target is the row's own: the two sweep facades push gen_p
through set_gen_p_targets; a storage unit's is the grid's, negated into the
generator convention its limits and the reported value use.

Pinned against ContingencyAnalysisCPP, InjectionSweepCPP and
ScenarioSweepCPP, and against a single ac_pf's res_p_mw. Array mode is
tested on one slack bus holding two machines: the tuple path has no
distributed slack (uniform weights, trivial ledger), a limitation that
predates this change.

Assisted-by: Claude Code (Fable 5.1)
Signed-off-by: DONNOT Benjamin <benjamin.donnot@rte-france.com>
…nal voltage

The extremity-side current used the origin bus' kV base, so on a
transformer (or any branch joining two voltage levels) i_ex / ka_ex and the
CURRENT violations on side 2 were off by the voltage ratio. The GPU flow and
limit-violation kernels now take a second base (d_base_current_ex_A), and the
numpy (compute_branch_flows_cpu) and torch (compute_flows) helpers do the same.

compute_branch_flows_cpu also handles a -1 (Kron-reduced / half-open)
endpoint like the kernels: 0 A on that terminal, V=0 on that side, the other
end's amp base. Before, numpy read V[-1] / bus_vn_kv[-1] and reported a wrong
current on the live side. The new test checks both terminals against
lightsim2grid's res_a1_ka / res_a2_ka.

Assisted-by: Claude Code (claude-opus-5-5)
Signed-off-by: DONNOT Benjamin <benjamin.donnot@rte-france.com>
Assisted-by: Claude Code (claude-opus-5-5)
Signed-off-by: DONNOT Benjamin <benjamin.donnot@rte-france.com>
The batched violation kernels (operational and physical) used to keep the
first `capacity` records found per row, in scan order. They now keep, for
each violation type, the `capacity` most severe ones, sorted most severe
first, types one after the other in the row's output:

- CURRENT, LOW_VOLTAGE, HIGH_VOLTAGE ranked by |value/limit - 1|
- LOW_Q / HIGH_Q, HVDC HIGH_P, LOW_P / HIGH_P (generators and storage
  units ranked together) ranked by |value - limit|

A shared device helper (TopKByType) does a sorted insertion into each
type's own range of the row's slice, rejecting a less severe candidate in
O(1) once the range is full, then packs the ranges to the front. Output
buffers grow to n_types * capacity per row; the physical result structs
expose it as `stride`. `truncated` now means some type exceeded capacity.

Parity tests compare records with lightsim2grid as sets and check the
ranking separately; a new test pins the kept records against an uncapped
run. Also add AGENTS.md as a symlink to CLAUDE.md.

Assisted-by: Claude Code (claude-opus-5-5)
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
lightsim2grid PR #216 makes its batch contingency analysis match
OpenLoadFlow with outer loops on real RTE snapshots. Every batch-side
change of it is a parity gap here:

- redistribute_slack (ContingencyAnalysisGPU / ScenarioSweepGPU): what a
  row loses (a masked island, a disconnected generator) is shared on the
  remaining slack units before the solve, OLF's DistributedSlack way
  (verbatim port of upstream's distribute, slack_redistribution.hpp):
  bounded by [min_p, max_p], never crossing 0 MW, a saturated unit
  leaving that row's slack. Per-row weights and a per-chunk Sbus
  correction (SlotSlackRedistribution); ContingencyBatch gets a per-slot
  Sbus only when a correction exists, so the option off is bit-identical.
  The slack active-power check follows (moved set-points, no share for a
  saturated unit). BatchPowerFlow runs the pre-pass in the forward, with
  the exact piecewise-linear VJP, FD-checked.
- A VoltageControl group every controller of which is stranded is
  recovered at any size (Q_first = 0, a reserved slot per group); a row
  stranding a regulated bus whose controllers stay live is skipped.
- Automatic reference slack (reference_slack="auto"): the participant
  stranded by the fewest rows becomes the angle reference; move_reference
  reproduces lightsim2grid's own ledger for it and the base state is
  rebuilt (now possible on ContingencyAnalysisSession too).
- PQ -> PV release check (LOW_VOLTAGE_AT_MIN_Q / HIGH_VOLTAGE_AT_MAX_Q),
  built from lightsim2grid's own plan, with physical_violation_tol_vm_pu.
- Fix: a hot ScenarioSweep run wiped the row masks (disconnected /
  stranded groups), which also gave a stranded group a gen_v gradient.

The operational checks also take violation_rel_tol (default 1e-9, as
lightsim2grid): a value is reported only beyond its limit by more than
that relative margin, so a bus a regulator holds exactly at its vmax is
not reported or not depending on the last bit of the solve. Kernel,
numpy n-case, sessions, engines and facades; documented in CLAUDE.md and
docs/api.rst.

Assisted-by: Claude Code (claude-opus-5-5)
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
Port lightsim2grid's standby SVC check (SvcStandbyCheck.hpp) to the batch
sessions: an idle SVC flagged with LSGrid.set_svc_standby whose regulated
bus leaves its automaton's [low, high] thresholds, which OpenLoadFlow's
MonitoringVoltageOuterLoop would switch to voltage control, is reported as
LOW_VOLTAGE_SVC_STANDBY / HIGH_VOLTAGE_SVC_STANDBY (11 / 12) on the new
ViolationElementType.SVC (7), value and limit in kV.

It is routed through the PQ -> PV release plan rather than a plan of its
own: the test is the same (below a target at "min", above it at "max"), so
an SVC is two entries of the new GenPvReleasePlanData.el_type SVC, its low
and its high threshold, appended by the bridge from lightsim2grid's own
build_svc_standby_plan (behind __has_include). The kernel gives them two
groups of their own (N_GEN_PV_RELEASE_VIOLATION_GROUPS 2 -> 4), never masks
them by a generator contingency nor moves them by a row's targets (NaN
columns in _push_gen_pv_release_targets), and the Python decoding reads the
element type off the type code. An empty el_type keeps every entry a
generator, so array-mode callers are unchanged.

Adds a CHANGELOG.rst, which the repository did not have.

Assisted-by: Claude Code (Opus 5.5)
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
Port lightsim2grid's release check of the SVCs flagged as frozen at a
reactive limit (LSGrid.set_svc_can_be_pv): a fixed-Q SVC at the absorbing
(resp. producing) end of its range whose regulated bus is below (resp.
above) its target is reported as LOW_VOLTAGE_AT_MIN_Q /
HIGH_VOLTAGE_AT_MAX_Q on the SVC.

The release plan already carried SVC entries for the standby check; an SVC
entry is now either kind: GenPvReleasePlanData.el_type says the element
(5 GENERATOR / 7 SVC) and the new optional GenPvReleasePlanData.standby
marks the standby entries. The bridge takes the frozen SVCs from
lightsim2grid's own build_gen_pv_release_plan. As types 9 / 10 can now be
on a generator or an SVC, every record carries its element type
(GenPvReleaseViolationsResult.el_type), which the Python decoding reads
instead of inferring it from the type.

Also restores the standby SVC CHANGELOG entry: the previous commit picked
up another CHANGELOG.rst created at the same time, without it.

Assisted-by: Claude Code (Opus 5.5)
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
Port lightsim2grid's "can participate in the slack" flag to the
redistribute_slack pre-pass: a unit left out of the slack only because it
sat at an active limit (LSGrid.set_gen_can_participate_slack) takes a share
of what a row loses within its [min_p, max_p] -- only away from the limit it
sits at -- but never enters the solve's slack weights.

SlackRedistributionData gets an optional in_slack (one per unit, empty = all
in the slack): the bridge adds the flagged units with in_slack 0, in
upstream's append_participants order; row_slack_weights skips them and a row
never records one as saturated (including through
set_external_slack_saturation).

Assisted-by: Claude Code (Opus 5.5)
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
lightsim2grid's build_gen_pv_release_plan now pins a generator flagged
can_be_pv at the nearer of its reactive limits instead of requiring its
setpoint within the tolerance of one. The bridge calls it without the
tolerance (extract_gen_pv_release_plan_from_lsgrid keeps its tol_mva argument
for its callers, ignored), and the facades no longer rebuild a grid-built
release plan when physical_violation_tol_mva changes.

Assisted-by: Claude Code (Opus 5.5)
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
Port lightsim2grid's release check of the VSC converter stations flagged as
frozen at a reactive limit (LSGrid.set_hvdc_can_be_pv): reported on the HVDC
line, `side` the station's end.

The release plan takes HVDC entries (el_type 4, gen_id the hvdc line id) and
an optional GenPvReleasePlanData.side; the bridge copies lightsim2grid's
entry side (behind LS2G_HAS_RELEASE_ENTRY_SIDE). The kernel runs the
generators' test on them -- never masked by a generator contingency nor
moved by a row's targets -- and writes each record's side
(GenPvReleaseViolationsResult.side), which the Python decoding reads.

Assisted-by: Claude Code (Opus 5.5)
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
Follows lightsim2grid: redistribute_slack on a scenario sweep also pre-shares
the imbalance a row's injections create against the grid's set-points, which
went to the unbounded Newton slack before.

- SlackRedistributionData.base_p_mw: sn * sum Re(grid Sbus), filled by the
  bridge (NaN in array mode without it: no such term; optional last element
  of the tuple and of the binding's constructor).
- injection_change_lost_mw: base_p_mw minus the row's total active
  injection, the generators it disconnects put back at their row set-point
  (term (a) counts them) -- upstream's element-wise sum on per-bus data.
  prepass_all takes the row totals; the contingency session keeps the old
  overload, without the term.
- BatchPowerFlow's torch pre-pass adds the same term.
- Tests against lightsim2grid's batch (element-wise and per-bus injections,
  with an island and a generator off in one row, BatchPowerFlow, array mode
  without base_p_mw). The gradient check's row 2 now raises a load: at a row
  losing exactly nothing, the units at 0 MW leave the slack as soon as the
  lost power turns negative, a jump the central difference straddled.

Assisted-by: Claude Code (Opus 5.5)
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
Brings in the build CI (#16), the Modal GPU test job (#17) and the
scenario-sweep / adjoint fixes of #14.

Conflict resolutions:
- scenario_sweep_session.cu: keep main's warm path (rebuild the driver
  when the live chunk capacity would split the active rows into too many
  chunks, or break keep_final_jacobian's single chunk) and its reset_rows
  lambda; drop main's per-row slack weights from the ScenarioSweepBatch
  constructor, since this branch hands them over after the build through
  set_slack_redistribution.
- _batch_pf.py: take main's rule that _topology_mask only changes once
  the session accepted the topology; _row_masks (redistribute_slack's
  pre-pass, which runs before the op hands the topology over) now reads
  the queued topology and keys its cache on that tensor.
- _flows.py: per-terminal ampere bases (this branch) with main's
  half-open handling; a -1 side falls back to the live end's vn_kv, as
  set_branch_data does.
- scenario_sweep/gpu_facade.py: main's _push_gen_v helper, with this
  branch's gen_v_bus mapping.

Assisted-by: Claude Code
Claude-Session: https://claude.ai/code/session_01Afi372v35Q3tbaDtypd9Fa
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
Main keeps each batch session's driver across runs (BatchDriverConfig).
In contingency analysis, two things this branch adds change what that
driver is built from, so either now forces a new driver:
- the automatic reference slack rebuilds the base state
  (base_state_generation_ is part of the snapshot);
- the redistribute_slack pre-pass lives in the source (toggling it or
  handing in new participants bumps contingencies_generation_).
The injection sweep keeps main's hot path and passes the VoltageControl
group map to build_gen_v_override.

Assisted-by: Claude Code (claude-opus-5-5)
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
- Per-row targets of the physical checks: the sweep sessions clear their
  dirty flags only on a run with the checks on, so set-points changed
  while they were off reach the live driver once they are back on.
- v_base_dlpack(): base_state_ is a shared_ptr the capsule co-owns, so a
  view taken before a base-state rebuild (automatic reference slack,
  generator-contingency reserved set) no longer reads freed memory.
- ContingencyAnalysisSession refuses slack participants given with
  another sn_mva than the branch data's, as ScenarioSweepSession does.
- choose_reference_bus_from_ledger: the automatic-reference candidate
  rule, shared by both sessions instead of copied.
- ScenarioSweepGPU derives the gen_v skip mask again at compute() when
  set_gen_v() came with another row count than the injections, so the
  setters may come in any order.
- violation_tol / violation_rel_tol / violation_capacity live once in
  LimitViolations{Engine,Facade}Mixin.
- Physical checks on a reused driver: the setup (plan uploads, output
  allocation, n-case kernels) is redone only when its PhysicalSetupKey
  changes, otherwise the -1 sentinels are re-seeded; turning the checks
  off disarms the driver.
- Docs: api.rst lists the five physical checks; CLAUDE.md qualifies the
  bit-for-bit claim for a resistive droop HVDC line.

Assisted-by: Claude Code (claude-opus-5-5)
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
@BDonnot BDonnot added the gpu-ci Run the GPU test suite on Modal (build.yml gpu-tests job) label Oct 13, 2026
lightsim2grid master (1.2) adds LOW_VOLTAGE_REMOTE_CONTROL (13),
HIGH_VOLTAGE_REMOTE_CONTROL (14) and HVDC_AC_EMULATION_RELEASE (15), all
PHYSICAL. test_enum_parity_with_lightsim2grid walks every upstream member, so
the GPU CI job (which builds lightsim2grid master) failed with a KeyError.

Add the three codes to both mirrors (the Python IntEnum and the C++
limit_violation_types.hpp) and to the PHYSICAL set of violation_category().
No gpusim2grid kernel writes them yet; this only keeps the codes in sync.

Assisted-by: Claude Code (claude-opus-5-5)
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
test_context_init_is_negligible_after_warmup compared one wall-clock reading
against 5 ms on a shared Modal T4 and failed now and then (9.8 ms on main
after PR #14, 23 ms on this PR), unrelated to the code under test. A missed
warmup costs tens of ms (cuSPARSE/cuDSS dlopen + PTX JIT, context creation),
so 50 ms still catches the regression the test is there for.

Assisted-by: Claude Code (claude-opus-5-5)
Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
Comment thread docs/api.rst Outdated
``get_violations()``. A row that was never simulated or did not converge has
an **empty** entry (no sentinel), exactly as lightsim2grid reports it.

Today the category holds two checks:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A third one has been added: pmin / pmax for generator / battery


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 96d2ebd: api.rst now says the category holds five checks and lists them all: bus reactive capability, PQ → PV release, standby SVC switch-on, HVDC droop P saturation, and the slack machines' active power (LOW_P / HIGH_P against min_p / max_p, generators and storage units together). It also documents physical_violation_tol_vm_pu.


Generated by Claude Code

Comment thread src/_cpp/scenario_sweep_session.cu Outdated
if (phys_.compute_physical_violations)
solver_->upload_gen_p_no_share(rd_ns_gen, slack_rd_.n_gen, rd_ns_sto, slack_rd_.n_sto);
rd_gp_last_ = rd_gp;
gen_p_targets_dirty_ = false;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale per-row targets after turning compute_physical_violations off and on. gen_p_targets_dirty_ / gen_pv_release_targets_dirty_ are cleared on every run. But when the checks are off, physical_checks::before_solve returns immediately (physical_checks_impl.cuh:61) and uploads nothing. The flag isn't part of ScenarioSweepDriverConfig, so turning it back on reuses the same driver, which still holds the old targets. sync_targets then sees has && !dirty && driver_has and skips the upload.

Repro: checks on, set_injections_from_elements(A), compute() → checks off, set_injections_from_elements(B) (same row count), compute() (hot run: the flags are cleared, nothing is uploaded) → checks on, compute(). LOW_P/HIGH_P and PQ→PV release records are evaluated against A's set-points, not B's. The same applies to the set_gen_v release targets.

Suggested fix: clear the two flags only when the checks are on (i.e. when before_solve actually synced), or pass gen_p_targets_dirty_ || !phys_.compute_physical_violations-style state through.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 96d2ebd. Both ScenarioSweepSession::run() and InjectionSweepSession::run() (the injection sweep also reuses its driver since the merge) now clear gen_p_targets_dirty_ / gen_pv_release_targets_dirty_ only on a run with compute_physical_violations on, so targets changed while the checks were off still reach the driver. In addition, turning the checks off now disarms the live driver (BatchPfDriver::disable_physical_checks), so a reused driver no longer runs the four kernels. Pinned by test_gen_p_violations.py::test_targets_changed_while_the_checks_were_off[InjectionSweepGPU|ScenarioSweepGPU], your repro: A, off, B, on.


Generated by Claude Code

const int want_ref = _wanted_reference_bus();
if (want_ref != ref_bus_) {
ref_bus_ = want_ref;
_build_base_state();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

v_base_dlpack() can point at freed memory, now by default. run() rebuilds the base state here whenever _wanted_reference_bus() differs from ref_bus_, and reference_slack='auto' is the new default. export_v_base_dlpack (dlpack_export.cu:117) makes the session the capsule's owner, but base_state_ is a unique_ptr that this line replaces. A tensor taken before compute() (torch.from_dlpack(inner.v_base_dlpack())) then reads freed GPU memory, although the Python docstring says the view lives as long as the solver.

Before this PR, this could only happen through ScenarioSweep generator contingencies (and CLAUDE.md documents it there); now it happens by default in contingency analysis with handle_disconnected_grid=True whenever a contingency strands the grid's reference.

Options: make the capsule own a shared_ptr to the base state (and turn base_state_ into one), or at least document it in ContingencyAnalysis*.v_base_dlpack as it already is for ScenarioSweep.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 96d2ebd with the first option. base_state_ is now a std::shared_ptr<AcPfNrState> in both ContingencyAnalysisSession and ScenarioSweepSession, and export_v_base_dlpack / export_v_base_dlpack_ss make the capsule co-own it (std::static_pointer_cast<void>(self->base_state_)). A view taken before a rebuild keeps the previous base voltages alive instead of reading freed memory. The v_base_dlpack docstrings and CLAUDE.md say so. Pinned by test_scenario_sweep_gen_contingency.py::test_v_base_view_outlives_a_base_state_rebuild.


Generated by Claude Code

if (redistribute_slack_) {
_check_redistribute_slack();
const int n_bus = base_state_->n_bus;
const double sn = slack_rd_.sn_mva;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

slack_rd_.sn_mva is used without being checked. The pre-pass converts the per-unit h_Sbus_base_ to MW with slack_rd_.sn_mva. Neither set_slack_redistribution_data (L779) nor _check_redistribute_slack (L791) checks that it matches the base h_Sbus_base_ is expressed on. ScenarioSweepSession::_check_redistribute_slack does (the "given with another sn_mva" error).

Failure: in array mode, set_slack_redistribution_data(..., sn_mva=1.0) on a grid in per-unit on 100 MVA under-counts an island's lost power 100×, so units aren't clamped and the report's mismatch_mw is wrong, with no error.

Note: this session only learns its sn_mva_ from set_branch_data(), so the check may need that, or an explicit sn_mva at construction.


Generated by Claude Code

@BDonnot BDonnot Oct 13, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 96d2ebd. ContingencyAnalysisSession::_check_redistribute_slack now refuses participants whose sn_mva differs from the branch data's ("the slack participants were given with another sn_mva than the branch data's (set_branch_data)"), like the scenario sweep. As you noted, the session only learns sn_mva_ from set_branch_data(). But build_contingencies() already refuses to run without it, so by run() the branch data's sn_mva is always known, and the check runs there. Pinned by test_redistribute_slack.py::test_ca_refuses_participants_on_another_sn_mva.


Generated by Claude Code

mask_cfg_ = build_mask_config(*base_state_, ledger_ptr);
}

int ContingencyAnalysisSession::_wanted_reference_bus()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicated code (maintainability). This is a verbatim copy of ScenarioSweepSession::_wanted_reference_bus(): the same candidate rule (positive weight, has a P row), the same choose_reference_bus call and the same fallback. If the rule changes in one copy only (e.g. to exclude pre-pass-only units), contingency analysis and scenario sweep will quietly pick different references. A shared helper next to choose_reference_bus that takes the LedgerData would remove the duplication.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 96d2ebd. New choose_reference_bus_from_ledger(contingencies, Ybus_rm, ledger, forced, fallback) next to choose_reference_bus in contingency_analysis_helper.{hpp,cpp} holds the candidate rule (positive weight, has a P row), the call and the fallback. Both sessions' _wanted_reference_bus() now call it, so the two can no longer drift apart.


Generated by Claude Code

vc_group_of_bus=self._inner.vc_group_of_bus,
vc_pinned_v_set=self._inner.vc_pinned_v_set)
if bad.any():
self._inner.set_skipped_rows(bad)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling set_gen_v() / set_contingency_gens() before set_injections*() breaks the next compute(). The skip mask is pushed right away, from set_gen_v() and set_contingency_gens(). C++ set_skipped_rows throws when its row count differs from the injections the session currently holds. Nothing re-derives the mask when set_injections* later changes the row count.

Repro: the session holds 10-row injections.

  1. set_gen_v(<20 rows, some conflicting>) stores self._gen_v and pushes it (C++ set_gen_v accepts any row count), then raises row count must match set_injections() here. _push_gen_pv_release_targets() is never reached, so the release-check targets are stale too.
  2. After set_injections_from_elements(<20 rows>), compute() fails with "set_skipped_rows()'s row count no longer matches". That names an internal API the facade user never called, and the only way out is to call set_gen_v again.

Suggested fix: derive and push the skip mask lazily at compute() time, or also call _update_skipped_rows() from set_injections* / set_topology.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 96d2ebd for set_gen_v(). The facade now records the injections' row count (_n_injection_rows, set by set_injections / set_injections_from_elements), and set_gen_v pushes gen_v and the PQ → PV release targets before deriving the skip mask. If the row counts differ, it clears the skipped rows and marks them stale; compute() re-derives them (_update_skipped_rows) before run(). New injections also mark the mask stale. So set_gen_v(<20 rows>) followed by set_injections_from_elements(<20 rows>) then compute() works, and the release targets are no longer left stale. Pinned by test_gen_v.py::TestConflictingSetpoints::test_gen_v_may_change_row_count_before_the_injections.

Not changed: set_contingency_gens() with a row count different from the current injections is still refused straight away by the C++ session, with an explicit row-count error naming set_contingency_gens (not an internal API). It fails at the call, not at a later compute().


Generated by Claude Code

self._inner.compute_limit_violations = value

@property
def violation_rel_tol(self):

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicated code (maintainability). The violation_rel_tol property (docstring, getter, setter and the [0, 1) validation) is copy-pasted in both engines and both facades (8 def violation_rel_tol in src/gpusim2grid). The PR already moved the physical-check and slack-redistribution properties into shared mixins; a small limit-violations mixin would do the same here, and could also hold get_violations / violation_capacity.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 96d2ebd. _limit_violations.py now has LimitViolationsEngineMixin (violation_tol, violation_rel_tol with its [0, 1) validation, violation_capacity) and LimitViolationsFacadeMixin (violation_rel_tol forwarded to the engine). The two engines and ContingencyAnalysisGPU / ScenarioSweepGPU inherit them instead of carrying copies. def violation_rel_tol now appears only in the two mixins.


Generated by Claude Code

"set_bus_q_capability() first (the *GPU facades do it from a lightsim2grid grid "
"through set_bus_q_capability_from_grid(); an EMPTY plan is accepted when no "
"machine regulates a voltage).");
drv.set_bus_q_check(cfg.bus_q_plan, cfg.physical_violation_tol_mva,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hot runs redo work that could be skipped (perf). On the persistent ScenarioSweep driver, every compute() with physical checks on re-uploads all four plans, reallocates every output buffer with assign(), re-runs the four single-thread n-case kernels, and waits on several cs.synchronize() calls, even when only the injections changed.

The plans and the n-case report only change when the plan or the base state changes. A generation counter could skip the upload and the n-case work, keeping just a fill() to reset the -1 sentinels. This matters for the BatchPowerFlow training loop.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 96d2ebd. physical_checks::before_solve now builds a PhysicalSetupKey: a plan generation (PhysicalChecksConfig::plan_generation, bumped by every set_*_plan), the tolerances, residual tol, sn_mva, capacity, the generator-mask pointer, n_gen and the base case's convergence. When the key matches the driver's, the plan uploads, the assign() reallocations and the four n-case kernels (with their syncs) are skipped; only BatchPfDriver::reset_physical_outputs() runs (a thrust::fill re-seeding the -1 sentinels and truncated flags), plus the per-row target sync when they changed. The base-state generation is in the driver snapshot, so a base-state change is a cold driver and redoes the setup. With only the injections changing, a BatchPowerFlow training step pays a fill.


Generated by Claude Code

Comment thread CLAUDE.md
- **Structural supersets** (`ledger_extend.{hpp,cpp}`): the CA/SS sessions may solve a J whose pattern is a strict *superset* of lightsim2grid's — every extra entry is filled per slot by value, so one cuDSS analysis still serves the batch. Two extensions exist: `reserve_stranded_controller_slots` (one `(v_row, q_col)` zero per lone GEN controller, see `handle_disconnected_grid`) and `add_switchable_vm_buses` (one Vm column + Q equation per bus, with the full dS pattern derived from Ybus, **appended at the end** of the ledger so no existing index moves — which is why the VoltageControl custom rows are first frozen into explicit `LedgerData::vc_v_rows`/`vc_share_rows` by `materialize_vc_custom_rows`; an empty `vc_v_rows` still means lightsim2grid's own "last `n_controllers` rows" layout). `AcPfGPU`/`InjectionSweepGPU` ledgers are never extended. `drop_multislack_augmentation` must run *before* either extension.
- **Feature kernels** are small, conditionally-gated additions layered on the same driver: MultiSlack (`adjust_slack_mismatch`/`fill_slack_feature`/`update_slack_absorbed`, gated on `slack_col>=0`), HVDC droop (`hvdc_adjust_mismatch`/`hvdc_fill_feature`, additive J slopes — forces `d_J_values` to be zeroed before `fill_J` when active), and VoltageControl (`vc_adjust_mismatch`/`vc_vrow`/`vc_share`/`vc_apply_step`, a bordered formulation that adds custom rows). Shared inline helpers `nr_feature_{mismatch,zero_J,fill_J,update}` (`nr_iter_step.cuh`) are called from both the single-system step and the batched `driver.cuh` loop, so CA/InjectionSweep get the augmented system "for free" once the base session carries a `LedgerData*`.
- **Structural supersets** (`ledger_extend.{hpp,cpp}`): the CA/SS sessions may solve a J whose pattern is a strict *superset* of lightsim2grid's — every extra entry is filled per slot by value, so one cuDSS analysis still serves the batch. Two extensions exist: `reserve_stranded_controller_slots` (one `(v_row, q_col_first)` zero per VoltageControl group whose first controller is not an SVC, see `handle_disconnected_grid`) and `add_switchable_vm_buses` (one Vm column + Q equation per bus, with the full dS pattern derived from Ybus, **appended at the end** of the ledger so no existing index moves — which is why the VoltageControl custom rows are first frozen into explicit `LedgerData::vc_v_rows`/`vc_share_rows` by `materialize_vc_custom_rows`; an empty `vc_v_rows` still means lightsim2grid's own "last `n_controllers` rows" layout). `AcPfGPU`/`InjectionSweepGPU` ledgers are never extended. `drop_multislack_augmentation` must run *before* either extension.
- **Feature kernels** are small, conditionally-gated additions layered on the same driver: MultiSlack (`adjust_slack_mismatch`/`fill_slack_feature`/`update_slack_absorbed`, gated on `slack_col>=0`), HVDC droop (`hvdc_adjust_mismatch`/`hvdc_fill_feature`, additive J slopes — forces `d_J_values` to be zeroed before `fill_J` when active; the receiving-side slope is the *exact* derivative of the flow, `k·(1−lf1)(1−lf2)·(1−2·r·line_in)` — NR converges without the last factor, the adjoint does not: it was a ~1e-4 relative error on real-grid P gradients, see `test_hvdc_droop_resistive_loss_slope_is_exact`), and VoltageControl (`vc_adjust_mismatch`/`vc_vrow`/`vc_share`/`vc_apply_step`, a bordered formulation that adds custom rows). Shared inline helpers `nr_feature_{mismatch,zero_J,fill_J,update}` (`nr_iter_step.cuh`) are called from both the single-system step and the batched `driver.cuh` loop, so CA/InjectionSweep get the augmented system "for free" once the base session carries a `LedgerData*`.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docs now contradict each other. The exact receiving-side slope here is intentional and well justified: the adjoint needs it, and test_hvdc_droop_resistive_loss_slope_is_exact pins it. But the intro to this section (L140) still says results "match lightsim2grid bit-for-bit (to ~1e-15) on both the single solve and the batched … paths". As this note says, lightsim2grid's Jacobian lacks the (1−2·r·line_in) factor. So for a droop HVDC line with r != 0, the NR path differs: the converged state still matches to NR tolerance, but a fixed nb_iter / partially converged batch (e.g. direct_iter0_only) no longer matches to ~1e-15.

Suggest qualifying the L140 claim (e.g. "… except the Jacobian slope of a resistive droop HVDC line, see below, which changes the iterates but not the converged state").


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 96d2ebd. The L140 claim now reads "… match lightsim2grid bit-for-bit (to ~1e-15) … except on a grid with a droop HVDC line of non-zero resistance, whose receiving-side Jacobian slope gpusim2grid takes exactly (see Feature kernels below): that changes the NR iterates, not the converged state, so it still matches to the NR tolerance but a fixed nb_iter / partially converged batch (e.g. direct_iter0_only) no longer matches to ~1e-15."


Generated by Claude Code

@BDonnot
BDonnot merged commit 16ac333 into main Oct 13, 2026
18 of 19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gpu-ci Run the GPU test suite on Modal (build.yml gpu-tests job)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant