Compose step-size knobs into BasicController; replace DummyController for BDF/JVODE#3570
Conversation
2b6b3d7 to
7144773
Compare
|
The fact that this Pr adds ~200 loc and ~10 lines of tests suggests the new abstraction isn't carrying it's weight. |
|
What is your suggestion? The one the AI came up with was pretty bad, it just didn't have a way to change qmin at all for things like BDF. This at least is uniform, fixes a few bugs that the new controller interface introduced, and better declares what can be expected from the interface. |
|
I can't find that other PR on my phone right now that had the one claude came up with but review that, do you think that minimal change direction is actually better? I don't like magic numbers without knobs |
|
currious if @termi-official has thoughts here |
termi-official
left a comment
There was a problem hiding this comment.
The design makes sense to me. The only alternative I can think off is to introduce a macro to inject the common variables directly into the controllers, but that seems to be the worse decision.
I left some comments on the code which we might want to discuss.
| k = cache.order | ||
| prefer_const_step = cache.nconsteps < cache.order + 2 | ||
| zₛ = 1.2 # equivalent to integrator.opts.gamma | ||
| zₛ = 1.2 # equivalent to controller `gamma` |
There was a problem hiding this comment.
Shouldn't this be pulled from the controller instead of hard-coded?
There was a problem hiding this comment.
Yeah it should just be gamma.
There was a problem hiding this comment.
Done — zₛ = 1.2 is now zₛ = get_gamma(integrator) in both BDF stepsize bodies (the QNDF accept-step path and the shared bdf_step_reject_controller!). Added gamma_default(::Union{QNDF, FBDF, DFBDF}) = 12 // 10 so a default-constructed BDFController() lands on the same numeric behavior the magic 1.2 used to have.
| of the per-algorithm step-size knobs that used to live on the | ||
| `OrdinaryDiffEq` algorithm structs themselves. | ||
| """ | ||
| struct BasicController{T1, T2, T3, T4, T5, T6, T7} |
There was a problem hiding this comment.
I guess a better name would not suggest that this is a controller. Maybe we could name this CommonControllerOptions or similar?
Also, why should these all have fields different types?
There was a problem hiding this comment.
Renamed → CommonControllerOptions. Also collapsed the per-field type parameters into a single T: fields are now Union{Nothing, T} (so unresolved options can hold nothings, and resolve_basic produces a CommonControllerOptions{Float64} with concrete fields). Adding a new knob in the future is now a one-line struct field addition rather than a typevar bump everywhere it's referenced.
|
|
||
| mutable struct KantorovichTypeControllerCache{T, E} <: AbstractControllerCache | ||
| controller::KantorovichTypeController{T} | ||
| controller::KantorovichTypeController{OrdinaryDiffEqCore.BasicController{T, T, T, T, T, T, T}, T} |
There was a problem hiding this comment.
I think this is not really safe. If we add a field to BasicController in the future this isnt a concrete type anymore.
There was a problem hiding this comment.
Fixed by the rename + single-T-param refactor in the same comment thread above: the cache type signature is now KantorovichTypeController{CommonControllerOptions{T}, T}, so adding a new knob to CommonControllerOptions doesn't change this signature at all (no more 7 typevars to keep in sync).
| # it is computed by stepsize_controller_internal! (in perform_step!) resp. stepsize_predictor! | ||
| # (in step_accept_controller! and step_reject_controller!) | ||
| return zero(typeof(cache.controller.qmax)) | ||
| return zero(typeof(cache.controller.basic.qmax)) |
There was a problem hiding this comment.
I think we should use the new interface get_qmax over direct access. Same below.
There was a problem hiding this comment.
Done. All controller.basic.qmax / controller.basic.qmin reads in the Extrapolation stepsize bodies are now get_qmax(integrator) / get_qmin(integrator). Same swap was applied to the BDF and JVODE stepsize bodies (which also read these knobs).
7144773 to
76d326d
Compare
|
Force-pushed addressing the review:
21/21 local smoke tests pass (default solve for |
fe32378 to
6735196
Compare
|
CI iteration:
Fixes that landed during this iteration:
|
ranocha
left a comment
There was a problem hiding this comment.
Reported by @ranocha in NumericalMathematics/PositiveIntegrators.jl#194 (comment).
It was reported by @JoshuaLampert, not me.
| Composable holder for the standard step-size knobs every adaptive | ||
| controller uses: | ||
|
|
||
| - `qmin` / `qmax`: lower / upper bounds on the per-step shrink/grow factor. | ||
| - `qmax_first_step`: looser `qmax` applied to the very first accepted step | ||
| (mirrors Sundials CVODE — the initial dt from automatic step-size | ||
| selection is approximate, so a much larger growth is allowed once). | ||
| - `gamma`: safety factor applied to the controller's predicted dt change. |
There was a problem hiding this comment.
This is not true. The PIDController uses its step size limiter instead.
| """ | ||
| get_qmin(integrator) | ||
| get_qmax(integrator) | ||
| get_qmax_first_step(integrator) | ||
| get_gamma(integrator) | ||
| get_qsteady_min(integrator) | ||
| get_qsteady_max(integrator) | ||
| get_failfactor(integrator) | ||
|
|
||
| Read a step-size knob from the integrator's controller. Default | ||
| dispatch reads `integrator.controller_cache.controller.basic.X` — | ||
| i.e. it goes through the `CommonControllerOptions` embedded on every concrete | ||
| controller (`IController`/`PIController`/`PIDController`/ | ||
| `PredictiveController`/`BDFController`/`JVODEController`). | ||
|
|
||
| `CompositeControllerCache` overrides each accessor to delegate to the | ||
| currently active sub-cache (mirroring how `stepsize_controller!` and | ||
| friends dispatch). The transitional `DummyControllerCache` also | ||
| provides overrides for the BDF/Nordsieck cases that haven't been | ||
| migrated yet. | ||
|
|
||
| These accessors are what the integrator-level paths (e.g. | ||
| [`handle_step_rejection!`](@ref) for `qmin`, | ||
| [`post_newton_controller!`](@ref) for `failfactor`) call instead of | ||
| reading `integrator.opts.X` — the v7 controller refactor moved these | ||
| knobs off `DEOptions` and onto the controller object. | ||
| """ | ||
| @inline get_qmin(integrator::SciMLBase.DEIntegrator) = | ||
| get_qmin(integrator, integrator.controller_cache) |
There was a problem hiding this comment.
This attaches the documentation only to get_qmin, not the other accessor functions:
help?> OrdinaryDiffEqCore.get_qmax
No documentation found.
OrdinaryDiffEqCore.get_qmax is a Function.
# 4 methods for generic function "get_qmax" from OrdinaryDiffEqCore:
[1] get_qmax(integrator, cache::OrdinaryDiffEqCore.CompositeControllerCache)
@ ~/.julia/dev/OrdinaryDiffEq/lib/OrdinaryDiffEqCore/src/integrators/controllers.jl:1078
[2] get_qmax(integrator, ::OrdinaryDiffEqCore.DummyControllerCache)
@ ~/.julia/dev/OrdinaryDiffEq/lib/OrdinaryDiffEqCore/src/integrators/controllers.jl:395
[3] get_qmax(integrator, cache::OrdinaryDiffEqCore.AbstractControllerCache)
@ ~/.julia/dev/OrdinaryDiffEq/lib/OrdinaryDiffEqCore/src/integrators/controllers.jl:314
[4] get_qmax(integrator::SciMLBase.DEIntegrator)
@ ~/.julia/dev/OrdinaryDiffEq/lib/OrdinaryDiffEqCore/src/integrators/controllers.jl:298
help?> OrdinaryDiffEqCore.get_qmin
get_qmin(integrator)
get_qmax(integrator)
get_qmax_first_step(integrator)
get_gamma(integrator)
get_qsteady_min(integrator)
get_qsteady_max(integrator)
get_failfactor(integrator)
Read a step-size knob from the integrator's controller. Default dispatch reads
integrator.controller_cache.controller.basic.X — i.e. it goes through the
CommonControllerOptions embedded on every concrete controller
(IController/PIController/PIDController/ PredictiveController/BDFController/JVODEController).
CompositeControllerCache overrides each accessor to delegate to the currently active sub-cache
(mirroring how stepsize_controller! and friends dispatch). The transitional DummyControllerCache
also provides overrides for the BDF/Nordsieck cases that haven't been migrated yet.
These accessors are what the integrator-level paths (e.g. handle_step_rejection! for qmin,
post_newton_controller! for failfactor) call instead of reading integrator.opts.X — the v7
controller refactor moved these knobs off DEOptions and onto the controller object.|
Yes I still have a bit more to do here. This is passing but it's not ready to merge. |
982365b to
3696bd9
Compare
…ntroller for BDF/JVODE In v7, `qmin` (alongside `qmax`, `gamma`, `beta1/beta2`, `qsteady_*`, `qoldinit`) moved off `DEOptions` and onto the controller object. The out-of-domain rejection path in `handle_step_rejection!` was still reaching for the old `integrator.opts.qmin`, which throws on the v7 `DEOptions` struct — only the legacy DelayDiffEq constructor still mentions it. The same was true of `integrator.opts.failfactor` in `post_newton_controller!`. Reported in NumericalMathematics/PositiveIntegrators.jl#194 (comment) Rather than papering over with a one-off `hasfield` walk, this lifts the standard step-size knobs into a composable building block and retires the `DummyController` workaround that BDF / Nordsieck were using to keep the knobs on their algorithm structs. A new `CommonControllerOptions{T}` struct holds `qmin`, `qmax`, `qmax_first_step`, `gamma`, `qsteady_min`, `qsteady_max`, `failfactor` — the seven scalars the integrator-level paths actually read. A single type parameter `T` keeps the type signatures simple even if more knobs are added later. All fields default to `nothing`; algorithm-specific defaults are filled in by `resolve_basic` at `setup_controller_cache` time. Concrete controllers (`IController`, `PIController`, `PIDController`, `PredictiveController`, `ExtrapolationController`, `KantorovichTypeController`, plus the new `BDFController` and `JVODEController`) all embed a `CommonControllerOptions` as `controller.basic`. Seven generic accessors — `get_qmin`, `get_qmax`, `get_qmax_first_step`, `get_gamma`, `get_qsteady_min`, `get_qsteady_max`, `get_failfactor` — dispatch on `cache::AbstractControllerCache` and read through `cache.controller.basic`. `CompositeControllerCache` overrides each one to delegate to the active sub-cache. `DummyControllerCache` keeps an alg-field fallback for any SDE algorithm still using it. `integrator.opts.qmin` → `get_qmin(integrator)`, `integrator.opts.failfactor` → `get_failfactor(integrator)`. Same in the BDF post-Newton paths. QNDF/FBDF/DFBDF used to keep `qmax`, `qsteady_min`, `qsteady_max` as fields on the algorithm struct itself, with a `DummyController` hard-wired into `default_controller`. The stepsize logic read `alg.qmax` / `alg.qsteady_min` / `alg.qsteady_max` directly, plus a hard-coded `zₛ = 1.2` magic-number gamma, so the controller surface was unsettable. `BDFController` embeds `CommonControllerOptions` and has a cache that delegates back to alg-level dispatch (the existing BDF order-selection logic is left intact). The hard-coded `zₛ = 1.2` is now `get_gamma(integrator)`. `default_controller(QT, alg::Union{QNDF, FBDF, DFBDF})` threads `alg.qmax` / `alg.qsteady_min` / `alg.qsteady_max` through to the controller, so existing usage like `QNDF(qmax = 20)` keeps working. Users can now also pass `controller = BDFController(qmin = …, gamma = …)` to set knobs that previously had no surface (incl. `qmin` and `gamma`). BDF-tuned per-algorithm defaults (`qmax = 5//1`, `qsteady_min = 9//10`, `qsteady_max = 12//10`, `gamma = 12//10`) are encoded as `qmax_default(::QNDF)` / `gamma_default(::QNDF)` etc. Same pattern. `setη!` / `chooseη!` / `step_accept_controller!(::JVODE)` now read `get_qmin(integrator)` / `get_qmax(integrator)` / `get_qsteady_*(integrator)` instead of `alg.qmin` etc. - `IController` / `PIController` / `PredictiveController` / `PIDController` shed their flat `qmin/qmax/...` fields and embed `CommonControllerOptions` instead. PI-specific knobs (`beta1`, `beta2`, `qoldinit`) and PID-specific knobs (`beta`, `accept_safety`, `limiter`) stay on the controller alongside `basic`. - `ExtrapolationController` and `KantorovichTypeController` likewise embed `CommonControllerOptions`. Their stepsize logic reads `get_qmax(integrator)` / `get_qmin(integrator)` rather than direct field access. `test/runtests.jl` walks transitive `[sources]` dependencies and `Pkg.develop`s them. Pre-seed the `developed` set with the active project so a `[sources]` entry that points back to it (e.g. via the umbrella `OrdinaryDiffEq`'s transitive sources) is skipped — `Pkg.develop` cannot develop the active project itself, and that error was the "package X has the same name or UUID as the active project" failure across the sublibrary CI matrix. Reproducer (`isoutofdomain` predicate that fires once on the first proposed step) plus a smoke test of every controller path (default `solve`, user-supplied `BDFController`, `CommonControllerOptions` construction, controller-composition invariants) — 21/21 pass on Julia 1.12. - On master (without this fix): all algorithms error out — accessing `integrator.opts.qmin` throws because the v7 `DEOptions` struct doesn't have the field. - With this fix: `Tsit5` / `Vern7` / `Rosenbrock23` / `FBDF` / `QNDF` all complete the isout-rejection problem successfully, and `BDFController(qmax = 3)` is honored end-to-end. Co-Authored-By: Chris Rackauckas <[email protected]>
3696bd9 to
c443513
Compare
|
Final CI status: 215 pass / 10 fail / 2 skip — all 10 failures are pre-existing or infrastructure flakes. Breakdown of the 10 reds:
All controller-related jobs that exercise the refactor are green: every |
Summary
In v7,
qmin(alongsideqmax,gamma,beta1/beta2,qsteady_*,qoldinit) moved offDEOptionsand onto the controller object. The out-of-domain rejection path inhandle_step_rejection!was still reaching for the oldintegrator.opts.qmin, which throws on the v7DEOptionsstruct — only the legacy DelayDiffEq constructor still mentions it. The same was true ofintegrator.opts.failfactorinpost_newton_controller!.Reported by @ranocha in NumericalMathematics/PositiveIntegrators.jl#194 (comment).
Rather than papering over with a one-off
hasfieldwalk, this lifts the standard step-size knobs into a composable building block and retires theDummyControllerworkaround that BDF / Nordsieck were using to keep these knobs hard-wired on their algorithm structs.BasicController+ accessorsA new
BasicControllerstruct holds the seven scalars the integrator-level paths actually read:qmin/qmax(andqmax_first_stepfor the relaxed first-step bound),gamma(safety factor),qsteady_min/qsteady_max(deadband),failfactor(post-Newton-failure dt shrink).All fields default to
nothing; algorithm-specific defaults are filled in byresolve_basicatsetup_controller_cachetime. Concrete controllers (IController,PIController,PIDController,PredictiveController,ExtrapolationController,KantorovichTypeController, plus the newBDFControllerandJVODEController) all embed aBasicControllerascontroller.basic.Seven generic accessors —
get_qmin,get_qmax,get_qmax_first_step,get_gamma,get_qsteady_min,get_qsteady_max,get_failfactor— dispatch oncache::AbstractControllerCacheand read throughcache.controller.basic.CompositeControllerCacheoverrides each one to delegate to the currently active sub-cache (mirroring howstepsize_controller!already dispatched).DummyControllerCachekeeps an alg-field fallback for any SDE algorithm still using it.handle_step_rejection!/post_newton_controller!integrator.opts.qmin→get_qmin(integrator)integrator.opts.failfactor→get_failfactor(integrator)(also in the BDF post-Newton paths inlib/OrdinaryDiffEqBDF/src/controllers.jl)BDFController(replacesDummyControllerfor QNDF / FBDF / DFBDF)QNDF/FBDF/DFBDF used to keep
qmax,qsteady_min,qsteady_maxas fields on the algorithm struct itself, with aDummyControllerhard-wired intodefault_controller. The stepsize logic readalg.qmax/alg.qsteady_min/alg.qsteady_maxdirectly, so the controller surface was unsettable.BDFControllerembedsBasicControllerand has a cache that delegates back to alg-level dispatch (the existing BDF order-selection logic is left intact).default_controller(QT, alg::Union{QNDF, FBDF, DFBDF})threadsalg.qmax/alg.qsteady_min/alg.qsteady_maxthrough to the controller, so existing usage likeQNDF(qmax = 20)keeps working — but users can now also passcontroller = BDFController(qmin = …, gamma = …)to set knobs that previously had no surface. BDF-tuned per-algorithm defaults (qmax = 5//1,qsteady_min = 9//10,qsteady_max = 12//10) are encoded asqmax_default(::QNDF)etc.JVODEController(replacesDummyControllerfor Nordsieck JVODE)Same pattern.
setη!/chooseη!/step_accept_controller!(::JVODE)now readget_qmin(integrator)/get_qmax(integrator)/get_qsteady_*(integrator)instead ofalg.qminetc.Other refactors for consistency
IController/PIController/PredictiveController/PIDControllershed their flatqmin/qmax/...fields and embedBasicControllerinstead. PI-specific knobs (beta1,beta2,qoldinit) and PID-specific knobs (beta,accept_safety,limiter) stay on the controller alongsidebasic.ExtrapolationControllerandKantorovichTypeControllerlikewise embedBasicControllerso the seven accessors work uniformly.PredictiveControllerdocstring and two stale# equivalent to integrator.opts.gammacomments inlib/OrdinaryDiffEqBDF/src/controllers.jlwere updated to reflect the new interface.Verification
Reproducer (
isoutofdomainpredicate that fires once on the first proposed step) plus a smoke test covering every controller path:isoutofdomain— accessingintegrator.opts.qminthrows because the v7DEOptionsstruct doesn't have the field.