Skip to content

Commit c1879d3

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 and used to produce a spurious trailing step at `1 - eps` followed by `1.0`. PR #2869 removed the `100eps(tstop)` snap hack that previously masked this in `fixed_t_for_floatingpoint_error!`. Two coordinated changes restore the expected 11-step result without reintroducing the old hack: 1. `_ode_init` expands `tstops` to `tspan[1]:dt:tspan[end]` when the user specifies `dt` for a fixed-time method and did not supply their own `tstops`/`d_discontinuities`. 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. The expansion is skipped whenever the user supplied any tstops, which preserves the existing "continue at dtcache between user tstops" behavior that tests like `sol.t == [0, 1/3, 1/2, 5/6, 1]` depend on. 2. `modify_dt_for_tstops!` compares `dt` against `distance_to_tstop` with a small floating-point tolerance (`100 * eps(max(t, tstop))`). Without this tolerance, `dtcache = 0.1` reads as strictly less than a `distance_to_tstop` of `0.10000000000000009` and the integrator takes a full step that overshoots the tstop by one ulp, then takes a matching tiny corrective step. The tolerance guard is gated on `isfinite(tdir_tstop) && isfinite(integrator.t)` so that semi-infinite `tspan = (0.0, Inf)` integrations still work. 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 where `solve(prob, Euler(); dt = 0.1)` began returning 12 steps instead of 11. Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
1 parent ba0dfa2 commit c1879d3

3 files changed

Lines changed: 52 additions & 2 deletions

File tree

lib/OrdinaryDiffEqCore/src/integrators/integrator_utils.jl

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,11 +178,22 @@ function modify_dt_for_tstops!(integrator)
178178
tdir_t = integrator.tdir * integrator.t
179179
tdir_tstop = first_tstop(integrator)
180180
distance_to_tstop = abs(tdir_tstop - tdir_t)
181+
# Floating-point tolerance for the dt-vs-distance comparison so that a
182+
# `dt` whose nominal value matches `distance_to_tstop` to within a few
183+
# ulps still snaps to the tstop. Without this a fixed-dt run that should
184+
# land exactly on a tstop drifts past it and takes a spurious tiny step.
185+
tstop_tol = if integrator.t isa AbstractFloat && isfinite(tdir_tstop) &&
186+
isfinite(integrator.t)
187+
100 * eps(float(max(abs(integrator.t), abs(tdir_tstop)) /
188+
oneunit(integrator.t))) * oneunit(integrator.t)
189+
else
190+
zero(distance_to_tstop)
191+
end
181192

182193
if integrator.opts.adaptive
183194
original_dt = abs(integrator.dt)
184195
integrator.dtpropose = integrator.tdir * original_dt
185-
if original_dt < distance_to_tstop
196+
if original_dt + tstop_tol < distance_to_tstop
186197
_set_tstop_flag!(integrator, false)
187198
else
188199
_set_tstop_flag!(
@@ -198,7 +209,7 @@ function modify_dt_for_tstops!(integrator)
198209
elseif integrator.dtchangeable && !integrator.force_stepfail
199210
# always try to step! with dtcache, but lower if a tstop
200211
# however, if force_stepfail then don't set to dtcache, and no tstop worry
201-
if abs(integrator.dtcache) < distance_to_tstop
212+
if abs(integrator.dtcache) + tstop_tol < distance_to_tstop
202213
_set_tstop_flag!(integrator, false)
203214
else
204215
_set_tstop_flag!(

lib/OrdinaryDiffEqCore/src/solve.jl

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,19 @@ 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, expand tstops to the range tspan[1]:dt:tspan[end]. Julia's range
407+
# semantics use TwicePrecision to avoid the floating-point drift of repeated
408+
# `t + dt + dt + ...`, so the integrator hits each step exactly and lands on
409+
# tspan[end] without a spurious tiny final step. Skipped when the user
410+
# supplied tstops to preserve existing "step at dtcache between tstops"
411+
# semantics.
412+
if !isnothing(dt) && !iszero(dt) && (!isadaptive(_alg) || !adaptive) &&
413+
sign(dt) == tdir && isempty(tstops) && isempty(d_discontinuities)
414+
tstops = ((tspan[1] + dt):dt:tspan[end]...,)
415+
end
416+
404417
tstops_internal = initialize_tstops(tType, tstops, d_discontinuities, tspan)
405418
saveat_internal = initialize_saveat(tType, saveat, tspan)
406419
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)