Skip to content

Commit 63c813f

Browse files
Fix the solution bookkeeping for save_everystep=false (#428)
With `save_everystep=false`, `savevalues!` skips all of its ProbNumDiffEq-specific saving, so `sol.x_filt` was left with only the initial state and `sol.diffusions` was left empty. Two things broke as a result: - `calibrate_solution!` zips `sol.pu` (2 entries) against `sol.x_filt` (1 entry), so with a calibrated static diffusion the final `sol.pu[end].Σ` never got rescaled by the quasi-MLE sigma^2, and sigma^2 was not recoverable from the solution either. - Interpolating such a solution threw a `BoundsError`, since the interpolation indexes into the empty `sol.diffusions`. This is what the `WorkPrecisionSet` test with a dense `TestSolution` was marked broken for. `pn_solution_endpoint_match_cur_integrator!` now always saves the endpoint into `sol.x_filt`, and saves the last step's diffusion when nothing was saved during the solve. The endpoint used `integ.saveiter_dense` for `x_filt`, which stays at 1 whenever `dense=false`; it now uses `integ.saveiter` like `savevalues!` does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 4c6cf29 commit 63c813f

3 files changed

Lines changed: 52 additions & 17 deletions

File tree

src/integrator_utils.jl

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ function OrdinaryDiffEqCore.postamble!(
3030
smooth_solution!(integ)
3131
end
3232

33-
@assert (length(integ.sol.u) == length(integ.sol.pu))
33+
@assert (length(integ.sol.u) == length(integ.sol.pu) == length(integ.sol.x_filt))
3434

3535
return nothing
3636
end
@@ -58,8 +58,9 @@ function calibrate_solution!(integ, mle_diffusion)
5858
end
5959

6060
# Re-write into the solution estimates
61-
for (pu, x) in zip(integ.sol.pu, integ.sol.x_filt)
62-
_gaussian_mul!(pu, integ.cache.SolProj, x)
61+
# two-argument `eachindex` errors on a length mismatch instead of truncating
62+
for i in eachindex(integ.sol.pu, integ.sol.x_filt)
63+
_gaussian_mul!(integ.sol.pu[i], integ.cache.SolProj, integ.sol.x_filt[i])
6364
end
6465
# [(su[:] .= pu) for (su, pu) in zip(integ.sol.u, integ.sol.pu.μ)]
6566
end
@@ -123,22 +124,33 @@ end
123124
"Inspired by `OrdinaryDiffEqCore.solution_match_cur_integrator!`"
124125
function pn_solution_endpoint_match_cur_integrator!(integ)
125126
if integ.opts.save_end
126-
if integ.alg.smooth
127-
copyat_or_push!(
128-
integ.sol.x_filt,
129-
integ.saveiter_dense,
130-
integ.cache.x,
131-
)
127+
i = integ.saveiter
128+
129+
# `savevalues!` saved nothing here, so the last step's diffusion is still missing
130+
if !integ.opts.save_everystep && i > 1
131+
save_diffusion!(integ.sol, i, integ.cache.local_diffusion)
132132
end
133133

134+
copyat_or_push!(integ.sol.x_filt, i, integ.cache.x)
135+
134136
copyat_or_push!(
135137
integ.sol.pu,
136-
integ.saveiter,
138+
i,
137139
_gaussian_mul!(integ.cache.pu_tmp, integ.cache.SolProj, integ.cache.x),
138140
)
139141
end
140142
end
141143

144+
"Save `diffusion` as the `i`-th entry of `sol.diffusions`, appending if necessary."
145+
function save_diffusion!(sol, i, diffusion)
146+
if i <= length(sol.diffusions)
147+
sol.diffusions[i] = copy(diffusion)
148+
else
149+
push!(sol.diffusions, copy(diffusion))
150+
end
151+
return nothing
152+
end
153+
142154
"Extends `OrdinaryDiffEqCore._savevalues!` to save ProbNumDiffEq.jl-specific things."
143155
function DiffEqBase.savevalues!(
144156
integ::OrdinaryDiffEqCore.ODEIntegrator{<:AbstractEK},
@@ -152,11 +164,7 @@ function DiffEqBase.savevalues!(
152164
# Save our custom stuff that we need for the posterior
153165
if integ.opts.save_everystep
154166
i = integ.saveiter
155-
if i <= length(integ.sol.diffusions)
156-
integ.sol.diffusions[i] = copy(integ.cache.local_diffusion)
157-
else
158-
push!(integ.sol.diffusions, copy(integ.cache.local_diffusion))
159-
end
167+
save_diffusion!(integ.sol, i, integ.cache.local_diffusion)
160168
copyat_or_push!(integ.sol.x_filt, i, integ.cache.x)
161169
_gaussian_mul!(integ.cache.pu_tmp, integ.cache.SolProj, integ.cache.x)
162170
copyat_or_push!(integ.sol.pu, i, integ.cache.pu_tmp)

test/diffeqdevtools.jl

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,14 +76,16 @@ end
7676
@test plot(wps) isa AbstractPlot
7777
end
7878

79-
@testset "WorkPrecisionSet with TestSolution is broken" begin
79+
@testset "WorkPrecisionSet with TestSolution" begin
8080
abstols = 1.0 ./ 10.0 .^ (6:7)
8181
reltols = 1.0 ./ 10.0 .^ (3:4)
8282
setups = [
8383
Dict(:alg => EK0(smooth=false))
8484
Dict(:alg => EK1(smooth=false))
8585
]
86-
@test_broken wp = WorkPrecisionSet(
86+
# This used to throw a `BoundsError`: with `save_everystep=false` the solution was
87+
# left without any diffusions, which the interpolation needs; see issue #428
88+
wp = WorkPrecisionSet(
8789
prob, abstols, reltols, setups;
8890
appxsol=test_sol,
8991
dense=false,
@@ -92,6 +94,7 @@ end
9294
maxiters=Int(1e7),
9395
timeseries_errors=false,
9496
)
97+
@test wp isa WorkPrecisionSet
9598
@test_nowarn WorkPrecisionSet(
9699
prob, abstols, reltols, setups;
97100
appxsol=appxsol_nondense,

test/diffusions.jl

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,4 +81,28 @@ import ODEProblemLibrary: prob_ode_fitzhughnagumo
8181
appxsol = appxtrue(sol, true_sol, dense_errors=false)
8282
@test appxsol.errors[:final] < 1e-5
8383
end
84+
85+
# Fixes https://github.com/nathanaelbosch/ProbNumDiffEq.jl/issues/428
86+
@testset "`save_everystep=false` returns the same endpoint: $D" for D in (
87+
FixedDiffusion(),
88+
FixedMVDiffusion(),
89+
FixedDiffusion(1e3, false),
90+
DynamicDiffusion(),
91+
DynamicMVDiffusion(),
92+
)
93+
alg = EK0(diffusionmodel=D, smooth=false)
94+
kwargs = (dense=false, adaptive=false, dt=1e-2)
95+
sol_all = solve(prob, alg; save_everystep=true, kwargs...)
96+
sol_end = solve(prob, alg; save_everystep=false, kwargs...)
97+
98+
@test length(sol_end.u) == length(sol_end.pu) == length(sol_end.x_filt) == 2
99+
@test length(sol_end.diffusions) == 1
100+
101+
@test sol_end.pu[end].μ sol_all.pu[end].μ
102+
@test Matrix(sol_end.pu[end].Σ) Matrix(sol_all.pu[end].Σ)
103+
@test sol_end.diffusions[end] sol_all.diffusions[end]
104+
105+
# the interpolation reads `sol.diffusions`, which used to be empty here
106+
@test length(sol_end(0.5).μ) == length(prob.u0)
107+
end
84108
end

0 commit comments

Comments
 (0)