Skip to content

[DOCUMENTED] design note: enforcing control limits inside the Newton-Raphson - #208

Merged
BDonnot merged 4 commits into
dev_1.0.1from
claude/slack-weights-newton-raphson-xlkzku
Sep 18, 2026
Merged

BDonnot merged 4 commits into
dev_1.0.1from
claude/slack-weights-newton-raphson-xlkzku

Conversation

@BDonnot

@BDonnot BDonnot commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator
added removed
Docs + changelog +597 -0
total +597 -0

Docs only. No code changes, nothing implemented. Two files touched versus dev_1.0.1:
CHANGELOG.rst (a [TODO] entry) and docs/dev_notes/nr_control_limits.md (new).

What this is

It is the companion to outer_loop_checks_missing_data.md. That note asks the detection
question — what a post-solve "would this outer loop have fired?" check needs — and ends
several sections on the same sentence in different words: "b_min / b_max are checked,
not enforced"
, "this only becomes meaningful the day the limit is enforced". This
note is a proposal for that day.

The argument

docs/comparison_with_pypowsybl.rst ends its "outer loops" section on an open question:
whether a given OLF outer loop could be folded into the inner NR formulation the way
distributed slack was, or fundamentally needs iteration around the solve. The note argues
it is largely not case-by-case — five controls are the same mathematical object:

control resource box regulated quantity
distributed slack generator P [get_min_p, get_max_p] system active power balance
PV ↔ PQ generator Q [min_q_, max_q_] bus voltage magnitude
ratio tap changer ratio ρ [rho_min, rho_max] bus voltage magnitude (deadband)
area interchange area generation per-machine limits area net export
secondary voltage control group Q per-machine limits pilot bus voltage

Each is a bounded control resource complementary to a regulated quantity, which is a
complementarity condition, which can be written as a projection (normal-map) row and
solved by a semismooth Newton. One mechanism, instantiated five times. Only tap
discreteness (as opposed to tap limits) genuinely needs iteration around the solve.

The note covers why the classical heuristics cycle — two tests evaluated on two different
iterates, and with two coupled controls no correct ordering exists, which is why OLF
exposes transformerVoltageControlMode — the NCP / semismooth machinery and its
convergence theorem, one instantiation per control, and two adjacent questions: OLF's
state-dependent slack weights (balanceType) and the hydro produce / absorb mode case.

Why it might matter here specifically

  • Every pin / release is a value inside a fixed sparsity pattern, so one analyze +
    refactorize survives a whole sweep. The pv/pq vector disappears entirely, which makes
    has_pv_changed() structurally unable to fire on a reactive-limit event —
    set_switchable_vm_buses / set_pv_pinned_buses stop being workarounds and become the
    formulation.
  • VoltageControl already reserves both Jacobian slots the three-way branch needs
    (h_vm_, h_slope_), and its stranded path is a hand-rolled instance of the same row
    swap — for a lone controller the reactive-limit case would cost zero new rows, columns
    or nonzeros.
  • Hvdc is the existing precedent for an extension owning a state-dependent contribution
    without touching Ybus, which is what a continuous tap ratio needs.

Costs, stated in the note and not benchmarked

Dimension growth on the common grid (free in the batch path where the union layout is
already reserved, real for a plain ac_pf); the per-control scaling constant, whose
omission is why textbook implementations fail on real grids; degeneracy exactly at a bound
and the smoothing homotopy that cures it; non-uniqueness of the AC solution set, which
this does not fix; and tap discreteness. Also a short section on why no production load
flow works this way.

History of this branch, for review

The commits are worth skipping — review the merged result. Two things went wrong and
were corrected:

  1. The first commit's note claimed generators have no active power limits. True at the
    branch point, false on current dev_1.0.1: set_p_limits / get_min_p /
    get_max_p exist, and GenPCheck / BusQCheck already detect violations. The
    second commit merges dev_1.0.1 and reframes the note around the
    detection → enforcement split — which improved the plan: step 1 is now "enforce the
    limits that exist", with GenPCheck as a ready-made oracle, since every row it flags
    today is a row enforcement should silence.
  2. That same commit put the note in docs/dev_notes/ (matching the convention that
    directory established: not built, not in the toctree, not cross-referenced from the
    built docs) but failed to remove the toctree entry and the :ref: the first commit
    had added — a broken docs build, caught in review. Fixed in c9d4389; see the two
    resolved threads for the mechanism.

Other content corrections from the sibling note's findings: tap control, area interchange
and secondary voltage control are blocked on the model, not the formulation (a
transformer stores no regulated bus / target / deadband; there is no Area concept, no
control zones or pilot points). And a section on how enforcement interacts with
BusQCheck's per-bus argument — the check is per bus because the per-machine split is a
convention, whereas enforcement is genuinely per machine, so the two should not be
expected to agree machine-by-machine until it exists.

Verification

  • git diff --stat origin/dev_1.0.1...HEAD touches exactly CHANGELOG.rst and
    docs/dev_notes/nr_control_limits.md; no docs/*.rst mentions nr_control_limits.
  • RST validated with docutils: CHANGELOG.rst produces the same 11 warnings as
    origin/dev_1.0.1, i.e. no new ones. The docs were not built (no Sphinx in the
    container), but nothing added here is built — Sphinx's default source_suffix does not
    pick up .md.
  • Re-verified against the merged base that the VoltageControl ledger slots, the
    stranded row swap, TrafoContainer's "no discrete tap", HvdcLineContainer's "does
    NOT stamp the AC active power in Sbus" and _masked_slack_weights all still read as the
    note describes.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CDrx7k9yW7ppP16bid8ZDN

docs/comparison_with_pypowsybl.rst ends its "outer loops" section on an open
question -- whether a given OLF outer loop could be folded into the inner NR
formulation the way distributed slack was, or fundamentally needs iteration
around the solve. This note argues it is largely NOT case-by-case.

Reactive limits, tap-ratio control, area interchange and secondary voltage
control are the same mathematical object: a bounded control resource
complementary to a regulated quantity. That is a complementarity condition,
expressible as a projection (normal-map) row and solved by a semismooth Newton,
which gives the switching logic a Jacobian instead of a heuristic. The note
covers why the classical heuristics cycle (two tests evaluated on two different
iterates; with two coupled controls, no correct ordering exists -- which is why
OLF exposes transformerVoltageControlMode), the NCP / semismooth machinery and
its convergence theorem, and one instantiation per control.

It also covers two adjacent questions: pmin / pmax on distributed-slack
participants (GeneratorContainer has no active limits at all today), and
state-dependent slack weights, where margin-proportional weighting turns out to
enforce pmax exactly without any complementarity -- provided the normalisation
is dropped inside the NR, since it is a gauge and carrying it in would make the
slack border dense.

The formulation fits the existing contracts unusually well: every pin / release
is a VALUE inside a fixed sparsity pattern, so one analyze + refactorize
survives a whole sweep, and the pv/pq vector disappears entirely, which makes
has_pv_changed() structurally unable to fire on a reactive-limit event.
VoltageControl already reserves both Jacobian slots the three-way branch needs,
and its "stranded" path is a hand-rolled instance of the same row swap; Hvdc is
the precedent for an extension owning a state-dependent contribution without
touching Ybus.

Costs are stated plainly and none of them is benchmarked: dimension growth on
the common grid (free in the batch path, real for a plain ac_pf), the scaling
constant, degeneracy at a bound, non-uniqueness, and tap discreteness -- the one
part that genuinely does need round-and-re-solve.

Nothing here is implemented. Added as a [TODO] entry with the smallest useful
first step named (pmin / pmax on generators plus clamp-and-renormalise on the
slack), a label + cross-reference on comparison_with_pypowsybl.rst, and the
page in the technical-documentation toctree.

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

Signed-off-by: Benjamin Donnot <benjamin.donnot@rte-france.com>
dev_1.0.1 has moved since this branch started, in two ways that both bear on
the note:

- docs/dev_notes/ now exists as the home for exactly this kind of content
  ("Status note, not documentation ... not part of the built documentation
  (docs/*.rst)"), so the note moves there as markdown and comes back out of
  the technical-documentation toctree. The label and cross-reference added to
  comparison_with_pypowsybl.rst are reverted with it: dev notes are not
  referenced from the built docs, and the sibling note is not either.

- generators DO now have active power limits -- GeneratorContainer::set_p_limits
  / get_min_p / get_max_p, optional and NaN where never given -- and
  compute_physical_violations already DETECTS a slack machine driven past them
  (GenPCheck) and a bus past its reactive capability (BusQCheck). The note
  claimed there were no active limits at all, which was true at its branch
  point and is not now.

Correcting that changes the note's spine for the better: it is no longer "add
limits", it is the enforcement half of a split the sibling note keeps naming --
"b_min / b_max are checked, not enforced", "this only becomes meaningful the
day the limit is enforced". Step 1 of the incremental path becomes enforcing
the limits that exist, with GenPCheck as a ready-made oracle: every row it
flags today is a row enforcement should silence.

Two further corrections from the sibling note's findings, both about
sequencing: tap voltage control, area interchange and secondary voltage control
are blocked on the MODEL, not the formulation (a transformer stores no regulated
bus, target or deadband; there is no Area concept and there are no control zones
or pilot points), so the note now says so rather than implying the Jacobian work
is the obstacle. Added a section on how enforcement interacts with BusQCheck's
per-bus argument: the check is per bus because the per-machine split is a
convention, while enforcement is genuinely per machine, so the two should not be
expected to agree machine-by-machine until it exists.

Re-verified against the merged base: the VoltageControl ledger slots and the
"stranded" row swap, TrafoContainer's "no discrete tap", HvdcLineContainer's
"does NOT stamp the AC active power in Sbus" and _masked_slack_weights all still
read as the note describes.

Assisted-by: Claude Code (claude-opus-5)
Claude-Session: https://claude.ai/code/session_01CDrx7k9yW7ppP16bid8ZDN
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.

check the ref in the docs

Comment thread docs/index.rst Outdated
Comment thread docs/comparison_with_pypowsybl.rst Outdated
…behind

Both are review findings, and both are correct: docs/nr_control_limits.rst was
deleted when the note moved to docs/dev_notes/, but docs/index.rst still listed
`nr_control_limits` in the toctree and comparison_with_pypowsybl.rst still had a
`:ref:`nr_control_limits``. That is a broken docs build -- a toctree entry for a
nonexisting document, and an undefined label -- not a cosmetic leftover.

The previous commit claimed to revert both. It did not: it ran
`git checkout HEAD -- <those two files>` while HEAD was the merge commit, which
already contained the very edits being reverted, so the checkout restored them
instead of removing them. `git status` then showed the files as unmodified,
which is what a checkout from HEAD guarantees whatever the content, so the check
proved nothing. Reverting against origin/dev_1.0.1 is what was needed.

Also drops the `.. _comparison_with_pypowsybl:` label added in the first commit:
its only consumer was the `:ref:` in the note, and the note is not part of the
built documentation any more.

Verified this time against the base rather than against HEAD: the branch now
touches exactly two files versus origin/dev_1.0.1 (CHANGELOG.rst and
docs/dev_notes/nr_control_limits.md), and no docs/*.rst mentions
nr_control_limits at all.

Assisted-by: Claude Code (claude-opus-5)
Claude-Session: https://claude.ai/code/session_01CDrx7k9yW7ppP16bid8ZDN
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 f4fe159 into dev_1.0.1 Sep 18, 2026
58 checks passed
BDonnot added a commit that referenced this pull request Sep 19, 2026
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>
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