Implementation
How the package fits into GeometricIntegratorsBase, and what to write to add a method of your own.
The step
GeometricIntegratorsBase drives the integration. Per step it does roughly
reset!(solstep, t) # shift history, advance t
initial_guess!(current, history, params, int) # framework hook, unused here
integrate_step!(current, history, params, int) # the method's own work
copy_internal_variables!(solstep, cache(int))
enforce_periodicity!(solstep)
compute_vectorfields!(current(solstep), problem(int))so a method contributes an integrate_step! and the framework supplies everything else — the loop, the solution storage, the NaN and non-convergence handling, ensembles.
Two conventions matter when reading the step implementations:
- On entry
sol.talready holds $t_{n+1}$ whilesol.qstill holds $q_n$. Sosol.qis the previous solution, and the previous time ist̄ = sol.t - timestep(int). sol.qis aStateWithError, so the increment goes in withGeometricBase.add!, which is the operation that performs the compensated summation.sol.q .+= Δqdoes not: broadcasting falls through to the genericAbstractArraypath, writes throughsetindex!and never touches the error field. Each mathematical increment is therefore accumulated in full into a scratch vector before it is added, rather than added term by term; splitting one increment over severaladd!calls would compensate each piece separately for no benefit. Where a step does make more than oneadd!, that is not what is happening: the strong update is applied twice, once with the weightsband once with the correction weightsb̂thatRungeKutta.Tableaucarries — two separate increments, not one broken up. The weak update takes onlyb, so aWERKstep makes exactly one.
A stochastic step adds one thing at the front: drawing the increments for the step.
StochasticIntegrators.sample_noise! — Method
sample_noise!(int, sol)Draw the increments for the step that int is about to take and store them in its cache.
Called at the top of every integrate_step!. The increments go into the cache belonging to the problem's own data type — cache(int), never cache(int, ST) — because they are ordinary real numbers rather than the dual numbers a Newton solve propagates, and because every arithmetic path within one step must see the same draw. A residual! evaluated in dual arithmetic therefore reads its increments from cache(int) too.
The step index handed to the process is recovered from the current time. On entry sol.t already holds $t_{n+1}$, so with a constant time step $n = (t_{n+1} - t_0) / \Delta t$ is the one-based index of the step being taken.
Where the increments live
The increments are stored in the integrator's cache, and always in the cache belonging to the problem's own data type — cache(int), never cache(int, ST).
This matters because a Newton solve propagates dual numbers, so CacheDict holds a separate cache per element type, and a residual! evaluated in dual arithmetic sees a different cache from the one integrate_step! sampled into. The increments are ordinary real numbers that multiply dual quantities, and every arithmetic path within one step must see the same draw, so they are read from the real-valued cache throughout:
function residual!(b, sol, params, int::GeometricIntegrator{<:SIRK})
c = cache(int, ST) # ST-typed working arrays
ΔW = cache(int).ΔW # the increments, shared and real
...
endThe random number generator lives on the method, since the cache is per-element-type and a per-cache generator would split the stream.
History and extrapolation
A SolutionStep keeps states at $t_{-1}, t_{-2}, \ldots$ that no initial condition provides, and the framework fills them by running the problem backwards from $t_0$. For a deterministic problem that is well defined; for a stochastic one it is not, because the past of a sample path is not a function of its present.
Stochastic methods therefore declare
GeometricIntegratorsBase.default_extrapolation(::StochasticMethod) = NoExtrapolation()which copies the initial state into the history slots instead. This is harmless because every method here is a one-step method and none reads the history.
Caches
StochasticIntegrators.StochasticIntegratorCache — Type
StochasticIntegratorCache{DT} <: IntegratorCache{DT}Cache of a stochastic integrator. Beyond the usual internal-stage storage it holds the increments ΔW and ΔZ of the current time step, drawn once per step by sample_noise!.
StochasticIntegrators.SDEIntegratorCache — Type
Cache of an integrator for a SDEProblem.
StochasticIntegrators.PSDEIntegratorCache — Type
Cache of an integrator for a PSDEProblem or SPSDEProblem.
Each family's cache holds ΔW and ΔZ for the current step, scratch for the final update, and its internal stage storage. Helpers:
StochasticIntegrators.create_internal_stage_vector — Function
S internal stage vectors of length D.
StochasticIntegrators.create_internal_stage_matrix — Function
S internal stage matrices of size D × M.
Adding a method
To add a family for, say, an SDEProblem:
Define the tableau and the method. The method subtypes
SDEMethod,PSDEMethodorSPSDEMethod, and carries its tableau and a generator:struct MyMethod{TT, RNG} <: SDEMethod tableau::TableauMine{TT} rng::RNG end convergence(::MyMethod) = :strongDefine a cache subtyping
SDEIntegratorCachewith at leastΔWandΔZfields, and theCache/CacheTypeconstructors:function GeometricIntegratorsBase.Cache{ST}(problem::AbstractProblemSDE, method::MyMethod; kwargs...) where {ST} MyCache{ST, length(vec(initial_conditions(problem).q)), noisedims(problem), nstages(method)}(; kwargs...) endNote
noisedims(problem): the noise dimension comes from the problem, via its noise process.For an implicit method, add
nlsolution(cache) = cache.x,default_solver,solversize, and thecomponents!/residual!pair.Define
integrate_step!, beginning withsample_noise!(int, sol):function GeometricIntegratorsBase.integrate_step!(sol, history, params, int::GeometricIntegrator{<:MyMethod, <:AbstractProblemSDE}) sample_noise!(int, sol) ... update_solution!(sol.q, ...) end
If a method needs an initial guess that depends on the increments — as SIRK and SIPRK do — it must not use the framework's initial_guess! hook, which runs before integrate_step! and therefore before the increments exist. Call a private predictor from inside integrate_step! instead; stage_initial_guess! is the name used here.
Final updates
StochasticIntegrators.update_solution! — Function
Final update of a stochastic Runge-Kutta method (SIRK, WIRK):
\[q_{n+1} = q_{n} + \Delta t \sum_{i} b^{v}_{i} V_{i} + \sum_{r} \Delta W^r \sum_{i} b^{B}_{i} B_{i}^{\cdot r} .\]
V: drift $v$ evaluated at the internal stagesB: diffusion matrix $B$ evaluated at the internal stagesbdrift,bdiff: weights of the drift and diffusion tableausΔW: increments of the driving processΔq,Δy: scratch of length $D$ and $M$
Final update of a stochastic explicit Runge-Kutta method (SERK), which carries a second diffusion tableau against the iterated integrals $\Delta Z$:
\[q_{n+1} = q_{n} + \Delta t \sum_{i} b^{v}_{i} V_{i} + \sum_{r} \Delta W^r \sum_{i} b^{B}_{i} B_{i}^{\cdot r} + \frac{1}{\Delta t} \sum_{r} \Delta Z^r \sum_{i} b^{B_2}_{i} B_{i}^{\cdot r} .\]
Final update of a stochastic partitioned Runge-Kutta method (SIPRK):
\[\begin{aligned} q_{n+1} &= q_{n} + \Delta t \sum_{i} b^{v}_{i} V_{i} + \sum_{r} \Delta W^r \sum_{i} b^{B}_{i} B_{i}^{\cdot r} , \\ p_{n+1} &= p_{n} + \Delta t \sum_{i} b^{f}_{i} F_{i} + \sum_{r} \Delta W^r \sum_{i} b^{G}_{i} G_{i}^{\cdot r} . \end{aligned}\]
Final update of a stochastic split partitioned Runge-Kutta method (SISPRK). The $p$ equation carries two drift and two diffusion tableaus, applied to the Hamiltonian part $F_1$, $G_1$ and the forcing part $F_2$, $G_2$ respectively:
\[p_{n+1} = p_{n} + \Delta t \sum_{i} \left( b^{f_1}_{i} F^{1}_{i} + b^{f_2}_{i} F^{2}_{i} \right) + \sum_{r} \Delta W^r \sum_{i} \left( b^{G_1}_{i} G^{1 \cdot r}_{i} + b^{G_2}_{i} G^{2 \cdot r}_{i} \right) .\]
StochasticIntegrators.update_solution_weak! — Function
Final update of a weak explicit Runge-Kutta method (WERK):
\[q_{n+1} = q_{n} + \Delta t \sum_{i} \alpha_{i} V_{i} + \sum_{r} \hat I^r \sum_{i} \beta^{1}_{i} B_{1,i}^{\cdot r} + \sqrt{\Delta t} \sum_{i} \beta^{2}_{i} \sum_{r} B_{2,i}^{\cdot r} .\]
B1: diffusion evaluated at the internal stages $H^{(r)}_i$B2: diffusion evaluated at the internal stages $\hat H^{(r)}_i$
This carries its own name rather than being another update_solution! method. It takes two families of diffusion stages and a $\sqrt{\Delta t}$ term that no strong update has, so it is a different formula rather than an overload of the same one — and as an overload it would be ambiguous with the SERK update above, which has the same arity and differs only in which positional slot holds the time step.
These carry the numerical core of every scheme and are shared between families — WIRK uses the same final update as SIRK, for instance. The weak update carries its own name because it is a different formula rather than an overload: it takes two families of diffusion stages and a √Δt term that no strong update has. update_solution! is called twice by its caller, once with the tableau weights b and once with the correction weights b̂ that RungeKutta.Tableau carries, and keeping those two additions separate is deliberate. update_solution_weak! is called once, with b only: WERK has no b̂ pass.
Upstream requirements
The package needs three things of its dependencies that a purely deterministic integrator would not, all of them added for this purpose:
GeometricBasedeclares the interface stubsnoiseandnoisedims.GeometricEquationsprovides the concreteWienerProcessandGridProcess, and the accessors on the stochastic equations and problems.GeometricIntegratorsBasedispatchesdefault_extrapolationon the method, so that a family for which running the problem backwards is meaningless can decline it.