Skip to content

Commit b3dab6f

Browse files
Add CVODE-style initial step size algorithm for BDF solvers
The Hairer-Wanner `ode_determine_initdt` algorithm produces catastrophically small initial step sizes for stiff multi-scale problems where some state variables start at zero with tiny absolute tolerances. The d₂ term in the Hairer formula blows up when f₀/abstol is large, producing initial dt values that are orders of magnitude too small (e.g., 5.48e-25 vs CVODE's 3.8e-13). This PR implements the CVODE CVHin algorithm from SUNDIALS (Hindmarsh et al., 2005) as an alternative initial step size selection method. Key features: - Component-wise max-norm upper bound (most restrictive component constrains, rather than getting diluted in the RMS norm) - Iterative refinement (up to 4 iterations) with convergence detection - Geometric mean initialization from lower/upper bounds - Cancellation detection in finite-difference second derivative estimates - 0.5 safety bias factor The new algorithm is selected via the `initdt_alg` trait: - `DefaultInitDt()` - existing Hairer-Wanner algorithm (default for all solvers) - `SundialsInitDt()` - CVODE CVHin algorithm (default for BDF solvers) BDF methods (FBDF, QNDF, QNDF1, QNDF2, ABDF2, DFBDF) now use SundialsInitDt by default. All other solvers continue to use the Hairer algorithm unchanged. On a multi-scale test problem with zero states and abstol=1e-15, the new algorithm produces initial step sizes 27-2186x larger than Hairer, reducing total steps from 84335 to 369. Fixes part of #1496. Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com> Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6f57229 commit b3dab6f

7 files changed

Lines changed: 619 additions & 1 deletion

File tree

lib/OrdinaryDiffEqBDF/src/OrdinaryDiffEqBDF.jl

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ import OrdinaryDiffEqCore: alg_order, calculate_residuals!,
2323
get_fsalfirstlast, generic_solver_docstring, _bool_to_ADType,
2424
_process_AD_choice,
2525
_ode_interpolant, _ode_interpolant!, has_stiff_interpolation,
26-
_ode_addsteps!, DerivativeOrderNotPossibleError
26+
_ode_addsteps!, DerivativeOrderNotPossibleError,
27+
initdt_alg, SundialsInitDt
2728
using OrdinaryDiffEqSDIRK: ImplicitEulerConstantCache, ImplicitEulerCache
2829

2930
using TruncatedStacktraces: @truncate_stacktrace

lib/OrdinaryDiffEqBDF/src/alg_utils.jl

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,7 @@ alg_order(alg::DFBDF) = 1 #dummy value
4141
isfsal(alg::DImplicitEuler) = false
4242

4343
has_stiff_interpolation(::Union{QNDF, FBDF, DFBDF}) = true
44+
45+
# BDF methods use the CVODE-style initial step size algorithm which is more robust
46+
# for stiff multi-scale problems where some state variables start at zero with tiny abstol.
47+
initdt_alg(::Union{FBDF, QNDF, QNDF1, QNDF2, ABDF2, DFBDF}) = SundialsInitDt()

lib/OrdinaryDiffEqBDF/test/runtests.jl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ if TEST_GROUP != "QA"
1313
@time @safetestset "BDF Inference Tests" include("inference_tests.jl")
1414
@time @safetestset "BDF Convergence Tests" include("bdf_convergence_tests.jl")
1515
@time @safetestset "BDF Regression Tests" include("bdf_regression_tests.jl")
16+
@time @safetestset "SundialsInitDt Tests" include("sundials_initdt_tests.jl")
1617
end
1718

1819
# Run QA tests (JET, Aqua, AllocCheck) - skip on pre-release Julia
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
using Test
2+
using OrdinaryDiffEqBDF
3+
using OrdinaryDiffEqSDIRK
4+
using OrdinaryDiffEqCore: initdt_alg, DefaultInitDt, SundialsInitDt
5+
6+
@testset "SundialsInitDt Algorithm" begin
7+
8+
@testset "Trait dispatch" begin
9+
# BDF methods should use SundialsInitDt
10+
@test initdt_alg(FBDF()) isa SundialsInitDt
11+
@test initdt_alg(QNDF()) isa SundialsInitDt
12+
@test initdt_alg(QNDF1()) isa SundialsInitDt
13+
@test initdt_alg(QNDF2()) isa SundialsInitDt
14+
@test initdt_alg(ABDF2()) isa SundialsInitDt
15+
16+
# Non-BDF implicit methods should use DefaultInitDt
17+
@test initdt_alg(ImplicitEuler()) isa DefaultInitDt
18+
end
19+
20+
@testset "Simple exponential decay (in-place)" begin
21+
function f_exp!(du, u, p, t)
22+
du[1] = -u[1]
23+
end
24+
prob = ODEProblem(f_exp!, [1.0], (0.0, 10.0))
25+
sol = solve(prob, FBDF())
26+
@test sol.retcode == ReturnCode.Success
27+
@test isapprox(sol.u[end][1], exp(-10.0), rtol=1e-2)
28+
end
29+
30+
@testset "Simple exponential decay (out-of-place)" begin
31+
f_exp(u, p, t) = [-u[1]]
32+
prob = ODEProblem(f_exp, [1.0], (0.0, 10.0))
33+
sol = solve(prob, FBDF())
34+
@test sol.retcode == ReturnCode.Success
35+
@test isapprox(sol.u[end][1], exp(-10.0), rtol=1e-2)
36+
end
37+
38+
@testset "Multi-scale with zero states and tiny abstol (in-place)" begin
39+
# This is the pathological case from issue #1496:
40+
# Some species start at 0 with tiny abstol, causing the Hairer algorithm
41+
# to produce catastrophically small initial dt.
42+
function f_ms!(du, u, p, t)
43+
du[1] = -100.0 * u[1] + 1.0
44+
du[2] = 0.1 * u[1]
45+
du[3] = 1.84 # mimics TH2S
46+
end
47+
u0 = [1.0, 0.0, 0.0]
48+
prob = ODEProblem(f_ms!, u0, (0.0, 100.0))
49+
50+
# With FBDF (SundialsInitDt) - should succeed efficiently
51+
sol_fbdf = solve(prob, FBDF(), abstol=1e-15, reltol=1e-8)
52+
@test sol_fbdf.retcode == ReturnCode.Success
53+
@test sol_fbdf.t[end] == 100.0
54+
55+
# With ImplicitEuler (DefaultInitDt/Hairer) for comparison
56+
sol_ie = solve(prob, ImplicitEuler(), abstol=1e-15, reltol=1e-8)
57+
@test sol_ie.retcode == ReturnCode.Success
58+
59+
# The SundialsInitDt should produce a MUCH larger initial dt
60+
dt_fbdf = sol_fbdf.t[2] - sol_fbdf.t[1]
61+
dt_ie = sol_ie.t[2] - sol_ie.t[1]
62+
@test dt_fbdf > dt_ie # SundialsInitDt is larger
63+
@test dt_fbdf / dt_ie > 10 # At least 10x larger
64+
65+
# FBDF should use far fewer steps (more efficient)
66+
@test length(sol_fbdf.t) < length(sol_ie.t)
67+
end
68+
69+
@testset "Multi-scale with zero states and tiny abstol (out-of-place)" begin
70+
function f_ms(u, p, t)
71+
return [-100.0 * u[1] + 1.0, 0.1 * u[1], 1.84]
72+
end
73+
u0 = [1.0, 0.0, 0.0]
74+
prob = ODEProblem(f_ms, u0, (0.0, 100.0))
75+
76+
sol = solve(prob, FBDF(), abstol=1e-15, reltol=1e-8)
77+
@test sol.retcode == ReturnCode.Success
78+
@test sol.t[end] == 100.0
79+
end
80+
81+
@testset "Stiff Robertson problem" begin
82+
# Classic stiff test problem
83+
function robertson!(du, u, p, t)
84+
du[1] = -0.04 * u[1] + 1e4 * u[2] * u[3]
85+
du[2] = 0.04 * u[1] - 1e4 * u[2] * u[3] - 3e7 * u[2]^2
86+
du[3] = 3e7 * u[2]^2
87+
end
88+
u0 = [1.0, 0.0, 0.0]
89+
prob = ODEProblem(robertson!, u0, (0.0, 1e5))
90+
91+
sol = solve(prob, FBDF(), abstol=1e-8, reltol=1e-8)
92+
@test sol.retcode == ReturnCode.Success
93+
@test sol.t[end] == 1e5
94+
95+
# Conservation law: u1 + u2 + u3 = 1
96+
@test isapprox(sum(sol.u[end]), 1.0, atol=1e-6)
97+
end
98+
99+
@testset "Backward integration" begin
100+
function f_back!(du, u, p, t)
101+
du[1] = -u[1]
102+
end
103+
prob = ODEProblem(f_back!, [exp(-10.0)], (10.0, 0.0))
104+
sol = solve(prob, FBDF())
105+
@test sol.retcode == ReturnCode.Success
106+
@test isapprox(sol.u[end][1], 1.0, rtol=2e-2)
107+
end
108+
109+
@testset "User-specified dt still works" begin
110+
function f_dt!(du, u, p, t)
111+
du[1] = -u[1]
112+
end
113+
prob = ODEProblem(f_dt!, [1.0], (0.0, 1.0))
114+
# When user specifies dt, SundialsInitDt should not be called
115+
sol = solve(prob, FBDF(), dt=0.01)
116+
@test sol.retcode == ReturnCode.Success
117+
end
118+
119+
@testset "QNDF with multi-scale problem" begin
120+
function f_qndf!(du, u, p, t)
121+
du[1] = -100.0 * u[1] + 1.0
122+
du[2] = 0.0 # starts at 0, stays at 0
123+
end
124+
u0 = [1.0, 0.0]
125+
prob = ODEProblem(f_qndf!, u0, (0.0, 1.0))
126+
127+
sol = solve(prob, QNDF(), abstol=1e-12, reltol=1e-8)
128+
@test sol.retcode == ReturnCode.Success
129+
@test sol.t[end] == 1.0
130+
end
131+
end

lib/OrdinaryDiffEqCore/src/alg_utils.jl

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,13 @@ isimplicit(alg::OrdinaryDiffEqAdaptiveImplicitAlgorithm) = true
176176
isimplicit(alg::OrdinaryDiffEqImplicitAlgorithm) = true
177177
isimplicit(alg::CompositeAlgorithm) = any(isimplicit.(alg.algs))
178178

179+
# Initial dt selection algorithm trait.
180+
# DefaultInitDt uses the Hairer-Wanner algorithm (suitable for non-stiff and general problems).
181+
# SundialsInitDt uses the CVODE CVHin-style algorithm (more robust for stiff multi-scale problems).
182+
# Subpackages can override this for specific algorithms (e.g., BDF methods).
183+
initdt_alg(alg::Union{OrdinaryDiffEqAlgorithm, DAEAlgorithm}) = DefaultInitDt()
184+
initdt_alg(alg::CompositeAlgorithm) = initdt_alg(alg.algs[1])
185+
179186
isdtchangeable(alg::Union{OrdinaryDiffEqAlgorithm, DAEAlgorithm}) = true
180187
isdtchangeable(alg::CompositeAlgorithm) = all(isdtchangeable.(alg.algs))
181188

lib/OrdinaryDiffEqCore/src/algorithms.jl

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
# Initial dt selection algorithms
2+
abstract type InitDtAlg end
3+
struct DefaultInitDt <: InitDtAlg end
4+
struct SundialsInitDt <: InitDtAlg end
5+
16
abstract type OrdinaryDiffEqAlgorithm <: SciMLBase.AbstractODEAlgorithm end
27
abstract type OrdinaryDiffEqAdaptiveAlgorithm <: OrdinaryDiffEqAlgorithm end
38
abstract type OrdinaryDiffEqCompositeAlgorithm <: OrdinaryDiffEqAlgorithm end

0 commit comments

Comments
 (0)