Skip to content

Commit 2cd9890

Browse files
authored
stop clipping rejected steps to t1 (#758)
1 parent 9094d03 commit 2cd9890

2 files changed

Lines changed: 63 additions & 5 deletions

File tree

diffrax/_integrate.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -275,13 +275,13 @@ def _save(
275275
)
276276

277277

278-
def _clip_to_end(tprev, tnext, t1, t1_clip_floor, keep_step):
278+
def _clip_to_end(tnext, t1, t1_clip_floor, keep_step):
279279
# The tolerance of ~100 ULP's means that we don't end up with too-small intervals
280280
# for dense output, which then gives numerically unstable answers due to floating
281281
# point errors.
282-
clip = tnext > t1_clip_floor
283-
tclip = jnp.where(keep_step, t1, tprev + 0.5 * (t1 - tprev))
284-
return jnp.where(clip, tclip, tnext)
282+
# Only clip on accepted steps: on a rejection the controller has just shrunk its
283+
# proposal, so overriding it can cause an infinite rejection loop (see #756).
284+
return jnp.where(keep_step & (tnext > t1_clip_floor), t1, tnext)
285285

286286

287287
def _maybe_static(static_x: ArrayLike | None, x: ArrayLike) -> ArrayLike:
@@ -410,7 +410,7 @@ def body_fun_aux(state):
410410
#
411411

412412
tprev = jnp.minimum(tprev, t1)
413-
tnext = _clip_to_end(tprev, tnext, t1, t1_clip_floor, keep_step)
413+
tnext = _clip_to_end(tnext, t1, t1_clip_floor, keep_step)
414414

415415
progress_meter_state = progress_meter.step(
416416
state.progress_meter_state, linear_rescale(t0, tprev, t1)

test/test_adaptive_stepsize_controller.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,64 @@ def test_jump_at_t1_with_large_t1_in_float32():
364364
assert sol.ts == jnp.array([t1])
365365

366366

367+
# https://github.com/patrick-kidger/diffrax/issues/756
368+
# In float32 with a large t1, the 100-ULP clip window is wide enough to contain
369+
# the PID controller's desired step. If tprev sits just outside the clip window
370+
# but the controller's proposed tnext is inside it, the rejected step must not
371+
# be overridden with a fixed midpoint between tprev and t1: tprev does not move
372+
# on rejection, so a fixed midpoint would be retried forever.
373+
def test_clip_to_end_does_not_override_rejected_step():
374+
from diffrax._integrate import _clip_to_end
375+
376+
t1 = jnp.array(600.0, dtype=jnp.float32)
377+
t1_clip_floor = t1
378+
for _ in range(100):
379+
t1_clip_floor = eqxi.prevbefore(t1_clip_floor)
380+
# tnext lies inside the clip window; the controller has just rejected the
381+
# previous step, so keep_step is False. Expect the controller's tnext to
382+
# pass through unchanged (so the next iteration sees a strictly shrunken
383+
# proposal rather than the same midpoint).
384+
tnext = jnp.array(599.996, dtype=jnp.float32)
385+
assert tnext > t1_clip_floor
386+
out = _clip_to_end(tnext, t1, t1_clip_floor, jnp.array(False))
387+
assert out == tnext
388+
# On an accepted step we still snap to t1.
389+
out = _clip_to_end(tnext, t1, t1_clip_floor, jnp.array(True))
390+
assert out == t1
391+
# And outside the clip window we never touch tnext.
392+
tnext_far = jnp.array(599.0, dtype=jnp.float32)
393+
for keep in (jnp.array(True), jnp.array(False)):
394+
assert _clip_to_end(tnext_far, t1, t1_clip_floor, keep) == tnext_far
395+
396+
397+
# End-to-end version of the above: a harmonic oscillator in float32 with t1=600
398+
# and a tolerance that drives the PID controller's desired step into the 100-ULP
399+
# clip window. Before the #756 fix this hung at max_steps with tprev pinned to
400+
# the clip floor; after the fix it completes in ~30 steps.
401+
def test_no_infinite_reject_loop_at_t1_in_float32():
402+
t1 = jnp.float32(600.0)
403+
omega = jnp.float32(1000.0)
404+
405+
def vf(t, y, args):
406+
y0, y1 = y
407+
return jnp.stack([y1, -omega * omega * y0])
408+
409+
sol = diffrax.diffeqsolve(
410+
diffrax.ODETerm(vf),
411+
diffrax.Tsit5(),
412+
t0=jnp.float32(599.99),
413+
t1=t1,
414+
dt0=None,
415+
y0=jnp.array([1.0, 0.0], dtype=jnp.float32),
416+
stepsize_controller=diffrax.PIDController(rtol=1e-4, atol=1e-4, safety=0.9),
417+
saveat=diffrax.SaveAt(t1=True),
418+
max_steps=1000,
419+
)
420+
assert sol.result == diffrax.RESULTS.successful
421+
assert sol.ts is not None
422+
assert sol.ts[-1] == t1
423+
424+
367425
# https://github.com/patrick-kidger/diffrax/issues/713
368426
def test_t0_at_jump_time():
369427
jump_time = 0.98

0 commit comments

Comments
 (0)