Skip to content

Commit 48a4bc9

Browse files
Fix spurious tiny final step for fixed-dt methods
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 #2869 removed the `100eps(tstop)` snap hack that previously masked this. Fix: for non-adaptive, dtchangeable algorithms with no user-supplied tstops / d_discontinuities / callbacks, expand `tstops` to the range `tspan[1]:dt:tspan[end]` whose TwicePrecision arithmetic gives exact floating-point tstops, and inflate `dt` by 10 ulps so that `modify_dt_for_tstops!` always takes the tstop branch and snaps `t` exactly via `fixed_t_for_tstop_error!`. The expansion is intentionally skipped for adaptive algorithms (even when used with `adaptive=false`), `CompositeAlgorithm`, non-finite tspans, and any solve with callbacks — preserving all existing stepping semantics in those cases. 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>
1 parent ba0dfa2 commit 48a4bc9

2 files changed

Lines changed: 53 additions & 0 deletions

File tree

lib/OrdinaryDiffEqCore/src/solve.jl

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,33 @@ function _ode_init(
401401
_tstops_cache = tstops
402402
tstops = ()
403403
end
404+
405+
# For fixed-timestep methods with a user-supplied dt and no user-supplied
406+
# tstops/d_discontinuities/callbacks, expand tstops to the range
407+
# tspan[1]:dt:tspan[end]. Julia's range semantics use TwicePrecision to
408+
# avoid the floating-point drift of repeated `t + dt + dt + ...`, so the
409+
# integrator hits each step exactly and lands on tspan[end] without a
410+
# spurious tiny final step. Skipped whenever the user supplied tstops,
411+
# d_discontinuities, or callbacks (which may mutate dt at runtime via
412+
# `set_proposed_dt!`) to preserve existing "step at dtcache" semantics,
413+
# and when tspan has non-finite endpoints.
414+
if !isnothing(dt) && !iszero(dt) && !isadaptive(_alg) &&
415+
isdtchangeable(_alg) && !(_alg isa CompositeAlgorithm) &&
416+
sign(dt) == tdir &&
417+
isempty(tstops) && isempty(d_discontinuities) &&
418+
callback === nothing && all(isfinite, tspan)
419+
tstops = ((tspan[1] + dt):dt:tspan[end]...,)
420+
# Inflate dt so that dtcache is strictly larger than every range
421+
# spacing. Julia's TwicePrecision range arithmetic can produce
422+
# spacings up to ~6 ulps above dt; inflating by 10 ulps ensures
423+
# modify_dt_for_tstops! always takes the "hit tstop" branch,
424+
# clipping dt to the exact range value and snapping t via
425+
# fixed_t_for_tstop_error!.
426+
if dt isa AbstractFloat
427+
dt = tdir * nextfloat(abs(dt), 10)
428+
end
429+
end
430+
404431
tstops_internal = initialize_tstops(tType, tstops, d_discontinuities, tspan)
405432
saveat_internal = initialize_saveat(tType, saveat, tspan)
406433
d_discontinuities_internal = initialize_d_discontinuities(

test/interface/ode_tstops_tests.jl

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,32 @@ end
272272
@test sol.u[end] 0.0 atol = 1.0e-10
273273
end
274274

275+
@testset "Fixed-dt: no spurious tiny final step from accumulated drift" begin
276+
# Regression test: with fixed dt = 0.1 and tspan = (0.0, 1.0) the
277+
# accumulated `t + dt + dt + ...` drifts and used to produce a spurious
278+
# 12th step at 0.9999999999999999 followed by 1.0. The fix expands
279+
# tstops to tspan[1]:dt:tspan[end], whose Julia range semantics land
280+
# exactly on tspan[end].
281+
f!(du, u, p, t) = (du .= u; nothing)
282+
u0 = [1.0]
283+
prob = ODEProblem(f!, u0, (0.0, 1.0))
284+
sol = solve(prob, Euler(); dt = 0.1)
285+
@test length(sol.t) == 11
286+
@test sol.t[end] == 1.0
287+
@test sol.t == collect(0.0:0.1:1.0)
288+
289+
# Reverse direction
290+
prob_rev = ODEProblem(f!, u0, (1.0, 0.0))
291+
sol_rev = solve(prob_rev, Euler(); dt = -0.1)
292+
@test length(sol_rev.t) == 11
293+
@test sol_rev.t[end] == 0.0
294+
@test sol_rev.t == collect(1.0:-0.1:0.0)
295+
296+
# dt that does not evenly divide tspan still hits tspan[end] cleanly
297+
sol_uneven = solve(prob, Euler(); dt = 0.3)
298+
@test sol_uneven.t[end] == 1.0
299+
end
300+
275301
@testset "d_discontinuities: tprev advanced past discontinuity" begin
276302
# Directly verify the shift-past invariant using the integrator interface:
277303
# after stepping past the discontinuity, integrator.tprev should be

0 commit comments

Comments
 (0)