Skip to content

Add the limited(actual, limiter) operator: symbolic PCNR iterate limiting for nonlinear solves - #4774

Draft
ChrisRackauckas-Claude wants to merge 1 commit into
SciML:masterfrom
ChrisRackauckas-Claude:symbolic-limiters-pcnr
Draft

Add the limited(actual, limiter) operator: symbolic PCNR iterate limiting for nonlinear solves#4774
ChrisRackauckas-Claude wants to merge 1 commit into
SciML:masterfrom
ChrisRackauckas-Claude:symbolic-limiters-pcnr

Conversation

@ChrisRackauckas-Claude

Copy link
Copy Markdown
Member

Important

This PR should be ignored until reviewed by @ChrisRackauckas.

Note

Dependency chain: requires SciML/SciMLBase.jl#1449 (adds NonlinearFunction.precondition/postcondition, v3.37.0) and SciML/NonlinearSolve.jl#1084 (solvers apply the hooks). CI here cannot resolve until SciMLBase 3.37 is released; solver-behavior test assertions are additionally gated on isdefined(NonlinearSolveBase, :apply_postcondition!!) so they activate when NonlinearSolve releases. Everything below was verified locally against the companion branches.

Summary

Adds the limited(actual, limiter) operator: symbolic, component-level declaration of SPICE-style iterate limiting, lowered automatically to the Predictor/Corrector Newton-Raphson (PCNR) formulation of Aadithya, Keiter & Mei. A device author annotates a sensitive quantity once —

i ~ Is * (exp(limited(v, pnjlim(limitnew, limitold, Vt, vcrit)) / Vt) - 1)

— and every nonlinear solve built from any model containing that component gets predictor/corrector limiting fully automatically: mtkcompile performs the PCNR augmentation and NonlinearProblem construction compiles the limiters into the new NonlinearFunction.postcondition corrector hook.

The operator follows the architecture of the Modelica homotopy(actual, simplified) operator (#4601): the actual expression first, the helper second; limitnew/limitold are reserved placeholder symbols (fixed sentinels, precompile-safe like HOMOTOPY_LAMBDA) for the proposed and previously-accepted values inside the limiter expression.

What the lowering does

For time-independent systems (in both the ModelingToolkitBase light pipeline and the ModelingToolkit structural pipeline, inserted post-expand_connections):

  1. each unique limited(actual, limiter) node gets an auxiliary irreducible unknown limited_k; the node is replaced by it and the consistency equation limited_k ~ actual is appended (EquationSourceInformation is padded accordingly for TearingState);
  2. because limited_k is irreducible, alias elimination keeps the limited quantity as the surviving representative of its alias class — the PCNR augmentation is reduced symbolically, at no runtime cost;
  3. limited_k receives actual as both default and guess, so no user-provided initial value is needed;
  4. the limiter registry is stored in LimitedCtx system metadata; NonlinearFunction construction compiles each limiter via generate_custom_function (parameters, including bound parameters, resolve through standard codegen) into an iip/oop postcondition hook.

For time-dependent systems the operator is stripped to actual during mtkcompile, so component libraries carrying limiters compile unchanged for transient simulation (limiting inside implicit steppers' nonlinear solves is future work). Un-lowered limited nodes are rejected with clear errors at NonlinearFunction/ODEFunction construction; nested limited and limiters referencing other unknowns are rejected likewise.

Changes

  • ModelingToolkitBase (1.54.0 → 1.55.0): new src/systems/limited_operator.jl (operator, sentinels, detection/strip/lowering passes, generate_limited_postcondition); lowering invoked from __mtkcompile; postcondition wired into NonlinearFunction construction with guards; ODEFunction guard; exports limited, limitnew, limitold; SciMLBase compat 3.37; tests in test/limited_operator.jl (registered in the InterfaceII group).
  • ModelingToolkit (11.34.0 → 11.35.0): lowering invoked from the structural __mtkcompile (with source-info padding); SciMLBase compat 3.37; MTKBase compat 1.55.
  • Docs: new docs/src/basics/Limiting.md (registered in pages.jl) with a component-based diode-circuit walkthrough, lowering semantics, and contracts.

Verification (all local, Julia 1.12.4, dev'd SciMLBase#1449 + NonlinearSolve#1084)

  • New limited_operator.jl testset: 28/28 in the MTKBase light pipeline.
  • Neighboring homotopy_lowering.jl: 43/43 (no regression from the shared pipeline edits).
  • Full ModelingToolkit (structural) pipeline end-to-end:
    • scalar diode DC solve: plain NewtonRaphson 179 steps → with limited 11 steps (identical to the hand-written PCNR/NonlinearSolve tutorial results), root v = 0.6698509496766559 exact to 1e-6;
    • hierarchical two-component circuit (diode + resistor subsystems, namespaced parameters incl. a bound vcrit): 11 steps, correct v/i recovered through observed;
    • transient RC-diode System(eqs, t): operator stripped, ODEProblem compiles, simulates, and the generated RHS matches the analytic value at t=0.
  • MTKBase GROUP=InterfaceII suite (contains the new tests plus problem-construction/homotopy tests) run against the dev stack — result reported in a PR comment.

Design notes

  • Sentinels are parameters (not variables) so the time-dependent System(eqs, t) constructor's function-of-t validation accepts equations containing them; both lowering paths drop them from the discovered unknowns/parameters (namespaced occurrences — diode₊__limitnew_ₘₜₖ — are recognized by name suffix and canonicalized back to the toplevel sentinels).
  • The lowering keeps constructor-built caches coherent (var_to_name, and crucially the irreducibles field, which structural simplification consults rather than per-variable metadata — found the hard way).
  • Derivative rules register ∂₁ = 1, ∂₂ = 0: Jacobians treat the operator as actual, matching the hook semantics (the limiter is a corrector, not part of the residual).

Closes the ModelingToolkit third of the SciML/NonlinearSolve.jl#351 + PCNR feature arc.

🤖 Generated with Claude Code

https://claude.ai/code/session_01B9GHoo4pFa3DU9yhyTkevL

@ChrisRackauckas-Claude

Copy link
Copy Markdown
Member Author

Verification detail (local, Julia 1.12.4). The full GROUP=InterfaceII harness run is currently blocked by registered-ecosystem version skew, not by this PR: dev'd SciMLBase master requires FindFirstFunctions 3 while the MTKBase test env pins DataInterpolations 8 (needs FFF <2), and dev'd NonlinearSolve requires LinearSolve 5 while registered OrdinaryDiffEq pins <5. Any MTK branch tested against SciMLBase master hits the same wall until the compat sweep accompanying those releases.

What was run instead, in a coherent dev environment (SciMLBase#1449 + NonlinearSolve#1084 + this branch):

Testset Result
test/limited_operator.jl (new, MTKBase light pipeline) 28/28
test/homotopy_lowering.jl (shares the touched pipeline) 43/43
test/homotopy_problem.jl (exercises the edited problems/nonlinearproblem.jl) 29/29
test/nonlinearsystem.jl 23/24 — the 1 error is an using OrdinaryDiffEq precompile failure from the version skew above (old OrdinaryDiffEqDifferentiation vs SciMLBase master), unrelated to this diff
Full ModelingToolkit structural pipeline (root __mtkcompile) end-to-end flat diode: 179 → 11 steps; hierarchical diode+resistor components (namespaced + bound vcrit parameter): 11 steps, correct v/i via observed; transient System(eqs, t): stripped, compiles, integrates, RHS matches analytic value

Also verified the operator's derivative rules (∂₁=1, ∂₂=0, Jacobians see actual), nested-limited rejection, the un-lowered guard on NonlinearFunction/ODEFunction, and the limiter-references-unknowns error.

🤖 Generated with Claude Code

@ChrisRackauckas-Claude

Copy link
Copy Markdown
Member Author

CI triage (first full run): Runic Format Check and Spell Check pass. All 83 failing jobs (tests/sublibrary-ci/Downgrade/Documentation/Benchmarks/Catalyst downstream) fail at dependency resolution with Unsatisfiable requirements for SciMLBase — the documented state until SciML/SciMLBase.jl#1449 is merged and 3.37.0 is tagged (this PR's compat requires it). Verified by reading the InterfaceII and Downgrade logs directly; no non-resolution failure modes present.

Release order for green CI here: SciMLBase#1449 → tag 3.37.0 → re-run (tests pass with limiting inert) → NonlinearSolve#1084 + release → hook-gated solver-behavior assertions activate.

🤖 Generated with Claude Code

Return `true` iff `sys` contains a `limited(...)` node anywhere in its equations or its
observed equations.
"""
function has_any_limited(sys)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This should use https://github.com/JuliaSymbolics/SymbolicUtils.jl/blob/master/src/irstructure.jl#L659 to have any hope at scaling to Multibody models. Naive recursion on an expression is strictly off the table in such cases.

Recursively replace every `limited(actual, limiter)` node in the unwrapped expression `x`
with `actual`, discarding the limiter (and with it any `limitnew`/`limitold` sentinels).
"""
function _strip_limited(x)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

In a similar fashion, this should use IRStructure, though we don't have an inbuilt utility for this. An alternative formulation is to propagate a cache::Base.IdDict{SymbolicT, SymbolicT} throughout the recursion, populate it with the result of maketerm and check it at the beginning to avoid duplicating work.

return filter(v -> _sentinel_kind(v) === :none, vars)
end

function _canonicalize_sentinels(x)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Similar cache required.


# Replace every node in `x` that appears as a key of `repl` (an ordered
# node => variable map keyed by `isequal`) with its replacement.
function _replace_limited_nodes(x, repl)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Isn't this just substitute? It should use IRSubstituter.

return maketerm(typeof(x), operation(x), new_args, metadata(x))
end

function _collect_limited_nodes!(nodes, x)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is just search_variables!. It should use the IRStructureSearchBuffer version.

for v in Iterators.flatten((unknowns(sys), parameters(sys; initial_parameters = true)))
if hasname(v) && getname(v) in reserved
throw(
ArgumentError(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can't the lowering use Unicode or a name with # in it?

# parameters only; referencing other unknowns is not supported since the hook
# sees the limited entries, not the whole state.
bad = [
v for v in unwrap.(Symbolics.get_variables(lexpr))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Using IRStructureSearchBuffer and get_variables! here will be faster.

lims = Tuple(lims)
if iip
return let lims = lims
function limited_postcondition_iip(up, uprev, p)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can these closures be made callable structs so we don't accidentally forget to add something to the let block later?

)
)
end
fn = generate_custom_function(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Do we want to FunctionWrapper these somehow to cut down on compile times?

@ChrisRackauckas-Claude

Copy link
Copy Markdown
Member Author

Thanks @AayushSabharwal — all addressed in 204a5ce. Every traversal now goes through the IR machinery rather than naive recursion:

Comment Change
_has_limited naive recursion (scaling to Multibody) has_limited is now SU.query(IsLimitedNode(), expr) — DAG-memoized, plus a callable-struct predicate reused as the is_atomic below
_strip_limited needs IRStructure/cache strip is now node collection + one SU.IRSubstituter pass over equations and observed
_replace_limited_nodes "isn't this just substitute?" yes — replaced by IRSubstituter{false}(ir, rules); no hand-rolled maketerm rebuild left
_collect_limited_nodes! "just search_variables!" search_variables! with IRStructureSearchBuffer and is_atomic = IsLimitedNode(), so the limited nodes themselves land in the buffer. Kept an OrderedSet so insertion order stays deterministic — the generated names have to be a pure function of the system for RGF-cache/precompile reasons
sentinel canonicalization recursion same treatment: search_variables! with an IsSentinel atomic predicate → substitution rules → IRSubstituter
free-variable check → IRStructureSearchBuffer/get_variables! done, with the buffer reused across limiters
"Can't the lowering use Unicode or a name with #?" now Symbol("#limited_", k) — unwritable as a Julia identifier, so the name-collision guard is deleted outright
closures → callable structs LimitedPostcondition{iip, L} with an explicit limiters field

On the last one — FunctionWrapper for the compiled limiters: I left them unwrapped for now, because I don't think there's a signature that survives the use sites. The limiter is called as fn((u_new, u_old), p) where the value type must stay generic (ForwardDiff Duals flow through the same entries when the outer problem is differentiated, e.g. parameter estimation over a limited circuit) and p is an MTKParameters whose type varies per system, so a concrete FunctionWrapper{Float64, Tuple{Tuple{Float64,Float64}, P}} would either break AD or need one wrapper type per system anyway. The tuple-of-limiters does specialize on limiter count, so compile time grows with the number of distinct limited quantities in a model — if that bites on a large device library, the cheap fix is a length threshold above which we switch to a Vector of a single wrapper type with a per-eltype cache. Happy to add that upfront rather than on measurement if you'd prefer.

Re-verified after the rewrite: limited_operator.jl 28/28, and the full ModelingToolkit structural pipeline end-to-end — flat diode 179 → 11 steps, hierarchical component circuit 11 steps with identical root (0.6698509496766559), transient strip path unchanged.

🤖 Generated with Claude Code

@ChrisRackauckas-Claude

Copy link
Copy Markdown
Member Author

Updated for the SciMLBase/NonlinearSolve redesign (commit febcd0c82): precondition/postcondition are now solve keywords rather than NonlinearFunction fields — see SciML/SciMLBase.jl#1449 for the rationale.

For this PR the change is small and strictly an improvement: the corrector compiled from the limiter registry is merged into the problem's keywords by merge_limited_postcondition instead of being threaded through NonlinearFunction, so it rides to solve/init like any other option. Two consequences worth noting:

  • A corrector passed explicitly at solve time now takes precedence over the model-declared one, so a user can override a device library's limiting without rebuilding the model. merge_limited_postcondition deliberately leaves a user-supplied postcondition in place rather than clobbering it.
  • NonlinearFunction(sys) no longer needs the expression = Val{true} special case for limited systems at function-construction time; the guard moved to the problem-construction path where the closure is actually created.

Re-verified after the change: limited_operator.jl 29/29 (up from 28, with a new test asserting the solve-time override actually replaces the model's corrector), and the full ModelingToolkit structural pipeline still gives 179 → 11 Newton steps on the flat diode circuit and 11 steps through the hierarchical component circuit.

🤖 Generated with Claude Code

@ChrisRackauckas-Claude

Copy link
Copy Markdown
Member Author

limited now works inside transient solves, via NLStep

limited was inert in a transient simulation — stripped to actual because there was
nowhere to hang a corrector. There is now: src/systems/solver_nlprob.jl builds a genuine
time-independent System for the implicit stage equations and wraps its NonlinearProblem
in SciMLBase.ODENLStepData, and OrdinaryDiffEqNonlinearSolve solves that through
NonlinearSolve's own init cache path. So an ODEProblem built with nlstep = true now
carries the model's limiters into the stage Newton.

The obstacle that shaped the design

The obvious plan — re-attach the limited nodes to the stage system and let the existing
lower_limited PCNR augmentation run — does not work, for a reason outside this repo.
ODENLStepData requires the stage system's unknowns to be an index subset of the ODE
unknowns:

  • MTK: subsetidxs = [findfirst(isequal(y), unknowns(sys)) for y in unknowns(nlsys)]
  • OrdinaryDiffEqNonlinearSolve/src/newton.jl:87: nlstep_data.nlprob.u0 .= @view z[nlstep_data.u0perm]
  • newton.jl:183-195: atmp_sub = @view(atmp[u0perm]), and the Newton convergence norm is
    taken over that view.

The augmentation introduces an auxiliary irreducible unknown #limited_k per limited
quantity. That is not an ODE state: findfirst returns nothing, the u0 fill and the
residual index map both break, and there is no entry in z to seed it from. Making it work
would mean changing the ODENLStepData contract in SciMLBase and
OrdinaryDiffEqNonlinearSolve.

What is done instead

The augmentation is not needed in the stage system. The stage substitution
v -> γ₂*v + inner_tmp[i] already makes each limited quantity an affine function
q = a*z + b of a stage unknown, with a/b built from parameters (γ₂, inner_tmp[i])
only. Correcting q is correcting z, so the corrector is the model's limiter L
conjugated by that affine map:

φ(znew, zold) = (L(a*znew + b, a*zold + b) - b) / a

φ is registered against z in the same LimitedCtx metadata the time-independent
path uses, so merge_limited_postcondition / generate_limited_postcondition /
LimitedPostcondition are reused verbatim — no new codegen. For the RC-diode below the
registry that lands on the stage system is literally

v(t) => (-inner_tmpₘₜₖ[1] + pnjlim(inner_tmpₘₜₖ[1] + __limitnew_ₘₜₖ*γ₂ₘₜₖ,
                                   inner_tmpₘₜₖ[1] + __limitold_ₘₜₖ*γ₂ₘₜₖ, Vt, 0.71)) / γ₂ₘₜₖ

φ inherits L's fixed-point property, and the corrector runs before the residual is
evaluated at each iterate, so a converged stage still solves the unmodified stage equations
— limiting changes how the step is found, never what it is.

Restrictions, both errors rather than silently dropped limiting:

  • the limited quantity must resolve to an affine function of one stage unknown;
  • nlstep_scc = true is rejected — the SCC decomposition splits the problem the corrector
    is attached to.

A limited quantity that no stage unknown feeds is skipped: the solver never iterates on it.

Surviving mtkcompile

mtkcompile still strips limited to actual for time-dependent systems, byte for byte as
before; strip_limited_system additionally records actual => limiter in a new
StageLimitedCtx system metadata key.

I picked this over keeping the nodes through structural simplification (or making the strip
lazy) because a limited(v, ...) call node is opaque to the structural pass — carrying
it would change tearing and alias elimination for every time-dependent model bearing the
annotation, including ones that never build NLStep data. Metadata is inert, so nothing about
transient compilation changes; the test asserts the generated ODEFunction right-hand side
is still exactly actual.

The recorded actual never has to survive literally in the simplified equations.
attach_stage_limiters resolves it with substitute_observed(sys, ·) → the stage subrules
substitute_observed(stagesys, ·), walking it through both compilations' eliminated
variables into stage coordinates.

What limitold means in a stage solve

NonlinearSolve passes cache.u_cache — the previous accepted iterate — as u_prev to
apply_postcondition!!. So inside a stage solve, limitold is the previous Newton iterate
of the current implicit stage
, evaluated as the physical stage value a*z_prev + b. It is
not the previous time step. That is the SPICE reading of limiting: it damps the iteration,
not the trajectory; the first iterate of a stage compares against the step's predictor.
Documented in the limited docstring, the limitold docstring, and a new section of
docs/src/basics/Limiting.md.

Also in this push

  • Dropped the NLS_APPLIES_POSTCONDITION test gate now that NonlinearSolveBase v2.41.0 and
    NonlinearSolve v4.25.0 are registered with postcondition; those assertions run
    unconditionally. Compat bumped to match (NonlinearSolveBase = "2.41",
    NonlinearSolve = "4.25"), and NonlinearSolveBase added to the root test target.
  • Removed five stray [deps] entries (NonlinearSolve, NonlinearSolveBase,
    NonlinearSolveFirstOrder, NonlinearSolveQuasiNewton, NonlinearSolveSpectralMethods)
    that the first commit accidentally added to ModelingToolkitBase/Project.toml — its source
    never imports them and they were already [extras]. (Aqua would have flagged them.)
  • limited_operator.jl is now also run from the root suite (test/group_interfaceii.jl via
    @mtktestset), which is what makes the nlstep testsets execute — they are gated on
    @isdefined(ModelingToolkit) since generate_ODENLStepData lives in ModelingToolkit.jl.
    (This incidentally gives NLStep its first test in this repo; grep -rn nlstep test/ was
    previously empty.)
  • NonlinearSolveAlg is reached as OrdinaryDiffEqNonlinearSolve.NonlinearSolveAlg in both
    the test and the docs: OrdinaryDiffEq v7 no longer re-exports it and the owning package
    never exported it, so there is no public path to the one stepper option that consumes
    nlstep_data.

Local test results

Environment: this branch's MTK + MTKBase (dev), SciMLBase 3.43.0 (dev), registry
NonlinearSolve 4.25.0 / NonlinearSolveBase 2.41.0, OrdinaryDiffEqSDIRK +
OrdinaryDiffEqNonlinearSolve. Julia 1.12.4. Run after Runic.

Test Summary:                                    | Pass  Total     Time
with ModelingToolkit loaded (root package suite) |   53     53  1m49.1s
  Limited operator (PCNR iterate limiting)       |   53     53  1m49.0s

Test Summary:                                       | Pass  Total   Time
without ModelingToolkit (ModelingToolkitBase suite) |  127    127  51.9s
  Limited operator (PCNR iterate limiting)          |   42     42   3.1s
  Homotopy lowering                                 |   43     43   9.3s
  Homotopy problem construction & sweep             |   29     29  33.4s
  Homotopy OMC parity                               |   13     13   6.1s

limited_operator.jl went 29 → 42 assertions standalone, 53 with ModelingToolkit loaded
(the two nlstep testsets). New coverage:

  • the StageLimitedCtx registry survives mtkcompile and the ODEFunction right-hand side
    is unchanged;
  • attach_stage_limiters produces the conjugated limiter, checked numerically: with
    a = 0.5, b = 0.1, the corrector maps a proposed z = 3.0 to
    (pnjlim(0.5*3.0 + 0.1, 0.5*0.0 + 0.1, …) - 0.1) / 0.5, and is the identity at a fixed
    point;
  • rejection of a quantity spanning two stage unknowns, and of one nonlinear in its stage
    unknown;
  • ODEProblem(..., nlstep = true) puts a postcondition on nlstep_data.nlprob.kwargs,
    u0perm is unchanged ([1], no auxiliary unknown), and — the assertion that actually
    proves the wiring — init(nlstep.nlprob, NewtonRaphson()) gives a cache whose
    NonlinearSolveBase.get_postcondition is the generated LimitedPostcondition;
  • an ImplicitEuler(nlsolve = NonlinearSolveAlg()) transient solve succeeds and agrees with
    the unannotated model to rtol = 1e-6;
  • nlstep_scc = true throws.

Manual checks beyond the suite, on the RC-diode (Vsrc = 5V, R = 1kΩ, C = 1nF, Is = 1e-14, Vt = 25mV, vcrit = 0.71), ImplicitEuler + NonlinearSolveAlg, adaptive = false:

limited unannotated
dt = 1e-6, tspan = 20dt v = 0.0, ConvergenceFailure v = -5.1e16, "Success"
adaptive to t = 1e-5 nf = 652, v = 0.6698509496766557 nf = 661, v = 0.6698509496766557

The first row is the limiter doing its job — the unannotated stage Newton walks off into
exp(v/Vt) and returns garbage, the limited one refuses to accept the step. The second is
the honest caveat: on a smoothly stepped RC ramp the limiter almost never binds, so it buys
~1% of f-evaluations, not the 179 → 11 iteration collapse the DC operating point shows. The
value here is robustness on hard turn-ons, not speed on easy ones.

@ChrisRackauckas

Copy link
Copy Markdown
Member

The discussions from before miss that limiting is a function of operators, not variables or even equations. You don't want to limit a full equation, and the choice of limiter can be operator-specific. So doing this based on variable bounds is just a bad approach because you don't necessarily know the function form to do with that. Even if we do something more automatic, I think we want this manual form and the automatic form just lowers to this, so it still needs to be exposed.

@ChrisRackauckas-Claude

Copy link
Copy Markdown
Member Author

bounds metadata was leaking onto the stage problem as a wrong static box

While extending the nlstep stage-limiting path I found a pre-existing bug worth fixing here, since it is the same machinery.

NonlinearProblem(sys, op) auto-derives lb/ub from the unknowns' bounds metadata whenever they are not passed explicitly. inner_nlsystem builds the stage system with new_dvs = unknowns(sys) — the same symbolic variables, carrying the same metadata — so an ODE state's box silently landed on the stage problem. Measured on D(x) ~ -k*x^2*(1-x) with @variables x(t) = 0.5 [bounds = (0.0, 1.0)] and nlstep = true, before the fix:

stage unknowns : x(t)      stage u0 : [0.5]
stage lb : [0.0]           stage ub : [1.0]
NonlinearSolveBase.needs_bounds_transform(nlprob, NewtonRaphson()) = true
transform maps u0 [0.5] -> [0.0]

That is wrong twice:

  1. Wrong variable. The stage unknown is the increment z; the physical quantity is γ₂*z + inner_tmp[i]. lb ≤ u ≤ ub is not lb ≤ z ≤ ub. The correct stage box is (lb - b)/a ≤ z ≤ (ub - b)/a with a = γ₂, b = inner_tmp[i] — and the stepper rewrites a and b every stage, so the correct box is time-varying and cannot be a static vector.
  2. Coordinate corruption. Bounds trigger NonlinearSolve's reparametrization, but OrdinaryDiffEq keeps writing raw stage values into the problem each stage (OrdinaryDiffEqNonlinearSolve/src/newton.jl: nlstep_data.nlprob.u0 .= @view z[u0perm], then reinit!). Raw values into a transformed problem is silent corruption.

(A linear ODE cannot show this: mtkcompile solves the stage system exactly and tears it to zero unknowns, so there is no stage unknown for a box to land on. The tests use a nonlinear RHS for this reason.)

The fix

Stop the wrong static box. generate_ODENLStepData now builds the stage NonlinearProblem with lb = nothing, ub = nothing. That needed a sentinel default (DeriveBounds()) on the NonlinearProblem/NonlinearLeastSquaresProblem constructors, because previously an explicit lb = nothing, ub = nothing was indistinguishable from "not supplied" and still triggered derivation. Derivation behaviour is unchanged for every other caller — the existing Bounds metadata is forwarded to NonlinearProblem/NonlinearLeastSquaresProblem testset passes untouched (17/17).

Re-deliver the box correctly, through the machinery that is already there. A clamp is just a limiter, L(new, old) = clamp(new, lo, hi), so bounds on a state becomes a limiter of that state and goes through the existing _conjugate_limiter path, landing on the stage unknown as

φ(znew, zold) = (clamp(a*znew + b, lo, hi) - b) / a

The key property that makes this correct: a and b are symbolic expressions in the stage parameters (γ₂, inner_tmp), and the conjugated corrector is compiled by generate_custom_function against the system's parameters — so they are read live at call time. The box the corrector enforces on the increment moves with the stepper, for free. The tests assert exactly this by driving set_γ_c/set_inner_tmp the way the stepper does and checking that the same proposed z gets projected differently.

Only the finite sides are emitted (max(lo, min(limitnew, hi))), so a one-sided bound costs one comparison and an unbounded state costs nothing.

Composition. LimitedCtx is a Vector{Pair}, and LimitedPostcondition applies entries in order on the running value, so several correctors on one stage unknown compose rather than clobber. Bounds clamps are appended after the model's own limiters, giving clamp ∘ limiter: the limiter damps the proposed move, the clamp then projects whatever it produced, so the corrected iterate always lands in range. The test pins the order — with γ₂ = 0.5, b = 0.1, a halving limiter and a [0, 1] box, z = 6 gives physical 1.0 as clamp ∘ limiter and would give 0.55 the other way round.

Automatic, not opt-in

Bounds-derived clamping is on by default. Clamping an intermediate Newton iterate cannot change the root a stage solve converges to, only the path it takes there, and it keeps a right-hand side containing log/sqrt/exp from being evaluated outside its domain — which would NaN the whole solve rather than merely fail a step. That is a safety property, and safety properties belong on by default.

nlstep_limit_bounds = false remains as an escape hatch, kept for one concrete reason: a clamp is a projection, not just damping, so if a model's stage solutions legitimately leave the declared box (bounds used as an advisory range rather than an invariant) the projection would keep the stage solve from converging. One keyword lets such a model opt out without deleting its metadata.

Two consequences follow from "automatic" that would otherwise be regressions, and are handled explicitly:

  • A box that cannot be conjugated (a bounded state the stage system tears away as a nonlinear function of other unknowns) is dropped, not raised. A declared limited(...) quantity still errors in that situation — the user asked for limiting and cannot have it — but a model that integrates today must not stop integrating merely because a state carries a box.
  • nlstep_scc = true skips bounds clamping rather than rejecting the model, for the same reason. Declared limited quantities keep erroring there (the SCC decomposition splits the problem the postcondition is attached to).

Tests

lib/ModelingToolkitBase/test/limited_operator.jl, run in both modes it is run in CI.

With ModelingToolkit loaded (as test/group_interfaceii.jl runs it) — 96/96, up from 53:

Test Summary:                                                              | Pass  Total     Time
Limited operator (PCNR iterate limiting)                                   |   96     96  2m19.0s
  runtime numeric fallback is the actual branch                            |    1      1     0.0s
  derivative treats the operator as actual                                 |    3      3     2.3s
  lowering: augmentation, irreducibility, guesses, registry                |    7      7     0.5s
  nested limited operators are rejected                                    |    1      1     0.1s
  compiled system solves with automatic PCNR limiting                      |    6      6    39.5s
  hierarchical components: limiting composes through namespacing           |    5      5     5.0s
  time-dependent systems strip the operator but record it                  |    6      6     5.6s
  stage limiters: the limiter is conjugated onto the stage unknown         |    6      6     3.2s
  bounds become a clamping limiter of the physical quantity                |    8      8     7.0s
  stage limiters: bounds are conjugated into stage coordinates             |    7      7     0.4s
  stage limiters: a user limiter and bounds compose as clamp ∘ limiter     |    5      5     0.4s
  stage limiters: a box that cannot be conjugated is dropped, not an error |    1      1     0.1s
  explicit `lb`/`ub` of `nothing` suppress the metadata-derived box        |    3      3     0.4s
  stage limiters: quantities that cannot be conjugated are rejected        |    4      4     0.7s
  nlstep transient solve limits its stage Newton iterates                  |   10     10    37.5s
  nlstep rejects limiters it cannot attach                                 |    2      2     3.8s
  nlstep does not put the state's box on the stage increments              |    6      6     4.3s
  nlstep delivers the box as a per-stage clamp, automatically              |   12     12    22.9s
  guards                                                                   |    3      3     0.3s

Standalone (MTKBase only, the lib/ModelingToolkitBase/test/runtests.jl path) — 66/66, up from 42.

Existing bounds-derivation coverage, unaffected:

Test Summary:                                                                     | Pass  Total   Time
Bounds metadata is forwarded to `NonlinearProblem`/`NonlinearLeastSquaresProblem` |   17     17  16.2s

And the probe from the top of this comment, after the fix:

stage lb            : nothing
stage ub            : nothing
>>> bounds are NOT attached to the stage problem.

Runic clean on all five changed files. Documented in docs/src/basics/Limiting.md (new section, with a runnable @example block I verified prints (nothing, true)).

…ting

Adds symbolic, component-level declaration of SPICE-style iterate limiting,
lowered to the Predictor/Corrector Newton-Raphson (PCNR) formulation of
Aadithya, Keiter & Mei. Mirrors the Modelica homotopy(actual, simplified)
operator: limited(actual, limiter) stays opaque through System construction,
and mtkcompile lowers it.

- Time-independent systems: an auxiliary irreducible unknown per limited
  quantity, a consistency equation, and the limiter registry in LimitedCtx
  metadata. NonlinearProblem construction compiles the registry into a
  postcondition corrector attached to the problem's keywords, so it is
  forwarded to solve/init like any other solver option.
- Time-dependent systems: the operator is stripped, but the registry is kept
  in StageLimitedCtx so that nlstep problems can re-attach it. For those, the
  stage substitution makes each limited quantity affine in a stage unknown, so
  the corrector is the limiter conjugated by that map and no augmentation is
  needed. limitold is then the previous Newton iterate of the current stage.
- bounds metadata is delivered to nlstep stage solves the same way, as a
  clamping corrector conjugated into stage coordinates, automatically. The
  coefficients are stage-parameter expressions read live, so the enforced box
  tracks the stepper, which a static lb/ub vector cannot. The stage problem is
  built with explicit lb/ub of nothing so state boxes are never attached to
  stage increments, which required a DeriveBounds() sentinel to distinguish
  'not supplied' from an explicit nothing.

Measured on the classic Vs-R-diode DC circuit with pnjlim: plain
NewtonRaphson 179 steps, with limited 11 steps, both flat and through
hierarchical namespaced components.

Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
@ChrisRackauckas-Claude

Copy link
Copy Markdown
Member Author

Rebased onto current master (de05533509). The branch was 221 commits behind, and master had refactored the exact constructors this PR touches, so this was not a mechanical replay:

  • SciMLFunctionOptions now replaces the GeneratedFunctionOptions pattern, and ODEFunction/NonlinearFunction are each split into a kwargs... wrapper plus an opts-based inner method. The has_any_limited guards moved into the inner methods beside check_complete, and nlstep_limit_bounds is threaded through both signatures and the forwarding call.
  • Versions are master's, minor-bumped for the new public API: ModelingToolkit 11.39.0, ModelingToolkitBase 1.62.0.
  • The six commits were squashed into one before rebasing. That was deliberate rather than cosmetic: the intermediate commits contained designs that later commits reverse (the hooks began as NonlinearFunction fields and moved to solve keywords), so replaying them through the drift would have meant resolving conflicts in code the next commit deletes. The squashed message records the full design arc.

Dependencies are all released now, so this PR no longer depends on anything unmerged: SciMLBase 3.43 (#1449), NonlinearSolveBase 2.41 (SciML/NonlinearSolve.jl#1084, #1142), and SciML/OrdinaryDiffEq.jl#4158 for the stage-solve forwarding.

Verified locally after the rebase (Julia 1.12.4):

Run Result
limited_operator.jl with ModelingToolkit visible (CI's group_interfaceii.jl path) 96/96
limited_operator.jl standalone ModelingToolkitBase 66/66
homotopy_lowering.jl (shares the touched lowering pipeline) 43/43

The MTK-visible run is the one that matters for this rebase: the nlstep testsets are gated on @isdefined(ModelingToolkit), and they are what exercise the nlstep_limit_bounds plumbing I re-threaded by hand. Running the file under @safetestset hides that gate — its module isolation makes an outer using ModelingToolkit invisible, silently dropping those 30 assertions — so it was run without the wrapper.

🤖 Generated with Claude Code

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.

3 participants