Skip to content

Fix spurious tiny final step for fixed-dt methods#3441

Merged
ChrisRackauckas merged 1 commit intoSciML:masterfrom
ChrisRackauckas-Claude:fix-fixed-dt-spurious-final-step
Apr 16, 2026
Merged

Fix spurious tiny final step for fixed-dt methods#3441
ChrisRackauckas merged 1 commit intoSciML:masterfrom
ChrisRackauckas-Claude:fix-fixed-dt-spurious-final-step

Conversation

@ChrisRackauckas-Claude
Copy link
Copy Markdown
Contributor

Summary

Follow-up to #2869 addressing the regression surfaced in PositiveIntegrators.jl #192. With OrdinaryDiffEqCore v3.12+, solve(prob, Euler(); dt = 0.1) on tspan = (0.0, 1.0) started returning 12 steps instead of 11, with a spurious trailing step at 0.9999999999999999 followed by 1.0. PR #2869 removed the 100eps(tstop) snap hack that used to paper over this.

Two coordinated changes fix it without re-introducing the old hack:

  1. _ode_init: when the user specifies dt for a fixed-time method and did not supply their own tstops/d_discontinuities, expand tstops to tspan[1]:dt:tspan[end]. Julia's range semantics use TwicePrecision internally, so each range element is the exact floating-point representative of k*dt and lands on tspan[end] cleanly. Gated on empty user tstops so the existing sol.t == [0, 1/3, 1/2, 5/6, 1] behavior is preserved when users bring their own tstops.

  2. modify_dt_for_tstops!: compare dt against distance_to_tstop with a small floating-point tolerance (100 * eps(max(t, tstop))). Without this, dtcache = 0.1 reads as strictly less than a distance_to_tstop of 0.10000000000000009 and the integrator takes a full step that overshoots by one ulp, then a tiny corrective step. The tolerance guard skips infinite t/tstop so that tspan = (0.0, Inf) integrations are unaffected.

Design suggested by @ChrisRackauckas in the PositiveIntegrators.jl thread:

I think the best solution is to simply expand tstops to tspan[1]:dt:tspan[2] here, since Julia's range semantics has some complex logic that makes that take into account floating point error and give accurate tstops.

The tolerance change in modify_dt_for_tstops! is needed because denser tstops alone don't fix it — the strict dtcache < distance_to_tstop comparison still fails at one ulp mismatches and routes through the drift path.

Test plan

  • New regression test Fixed-dt: no spurious tiny final step from accumulated drift in test/interface/ode_tstops_tests.jl covers forward, reverse, and non-evenly-dividing dt on tspan = (0.0, 1.0).
  • Full test/interface/ode_tstops_tests.jl still passes (the dt = 1//3, tstops = [1/2] test that expects [0, 1/3, 1/2, 5/6, 1] is unchanged since the expansion is gated on empty user tstops).
  • test/integrators/ode_event_tests.jl including the tspan = (0.0, Inf) terminate-callback case still passes.
  • Full CI across the monorepo.

🤖 Generated with Claude Code

@ChrisRackauckas-Claude
Copy link
Copy Markdown
Contributor Author

Pushed follow-up commit narrowing the range-expansion gate.

The unconditional expansion tripped three additional cases that surfaced in CI:

  1. inf_handling.jltspan = (0.0, Inf) with adaptive=false, dt=0.1 tripped InexactError(Int64, Inf) when constructing (0.0 + 0.1):0.1:Inf. Gated on all(isfinite, tspan).
  2. integrator_interface_tests.jl (set_proposed_dt!) — a discrete callback changes dt from 0.1 to 0.5 mid-integration. Committing 0.1-spaced tstops at init clips every subsequent step back to 0.1. Gated on callback === nothing.
  3. linear_nonlinear_krylov_tests.jl (LawsonEuler, ETDRK*, HochOst4) — these are isdtchangeable(alg) == false. The range's sub-ulp gaps (diff(0.0:0.01:1.0) is not uniform) cause handle_tstop! to interpolate back when ttmp overshoots, which resets integrator.dt and trips apply_step!'s "setup does not allow for changing dt" guard. Gated on isdtchangeable(_alg).

Local runs now pass on ode_tstops_tests.jl, inf_handling.jl, integrator_interface_tests.jl, and the original PositiveIntegrators.jl Euler bug case.

The AD (autodiff_events.jl) and ODEInterfaceRegression failures also show up on the current master CI (run 24455830731), so they look unrelated. I'll keep an eye on whatever this run surfaces next.

@ChrisRackauckas-Claude ChrisRackauckas-Claude force-pushed the fix-fixed-dt-spurious-final-step branch from ed632e3 to 48a4bc9 Compare April 16, 2026 09:46
When solving with a fixed-dt method (e.g. `solve(prob, Euler(); dt = 0.1)`),
the accumulated `t + dt + dt + ...` drifts past `tspan[end]` by one ulp,
producing a spurious trailing micro-step.  PR SciML#2869 removed the old
`100eps(tstop)` snap in `fixed_t_for_floatingpoint_error!` that masked this.

Fix: add a floating-point tolerance (`100 * eps(max(|t|, |tstop|))`) to
the `dt < distance_to_tstop` comparison in `modify_dt_for_tstops!`.
When `dt ≈ distance` within rounding the integrator now takes the tstop
branch, and `fixed_t_for_tstop_error!` snaps `t` to the exact tstop
value — eliminating the extra step.

Adds a regression test covering forward, reverse, and non-evenly-
dividing `dt` on `tspan = (0.0, 1.0)`.

Fixes the PositiveIntegrators.jl CI failure noted in
NumericalMathematics/PositiveIntegrators.jl#192.

Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
@ChrisRackauckas-Claude ChrisRackauckas-Claude force-pushed the fix-fixed-dt-spurious-final-step branch from 48a4bc9 to 2ad6f5a Compare April 16, 2026 13:55
@ChrisRackauckas ChrisRackauckas merged commit 1864296 into SciML:master Apr 16, 2026
206 of 223 checks passed
ChrisRackauckas-Claude pushed a commit to ChrisRackauckas-Claude/OrdinaryDiffEq.jl that referenced this pull request Apr 16, 2026
Triggers a release so that Julia 1.10 CI (which lacks `[sources]`
support and pulls OrdinaryDiffEqCore from the registry) picks up the
tstop tolerance fix from SciML#3441.

Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
ChrisRackauckas added a commit that referenced this pull request Apr 16, 2026
- DiffEqBase         6.217.0 -> 6.218.0  (dt_epsilon default now WarnLevel in Minimal/Standard, #3445)
- OrdinaryDiffEqCore 3.32.0  -> 3.33.0   (fix spurious tiny final step for fixed-dt methods, #3441)

These are the only subpackages with src/ changes on master since their last registered versions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ChrisRackauckas added a commit that referenced this pull request Apr 16, 2026
…3456)

- DiffEqBase         6.217.0 -> 6.218.0  (dt_epsilon default now WarnLevel in Minimal/Standard, #3445)
- OrdinaryDiffEqCore 3.32.0  -> 3.33.0   (fix spurious tiny final step for fixed-dt methods, #3441)

These are the only subpackages with src/ changes on master since their last registered versions.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.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.

2 participants