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.t already holds $t_{n+1}$ while sol.q still holds $q_n$. So sol.q is the previous solution, and the previous time is t̄ = sol.t - timestep(int).
  • sol.q is a StateWithError, so the increment goes in with GeometricBase.add!, which is the operation that performs the compensated summation. sol.q .+= Δq does not: broadcasting falls through to the generic AbstractArray path, writes through setindex! 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 several add! calls would compensate each piece separately for no benefit. Where a step does make more than one add!, that is not what is happening: the strong update is applied twice, once with the weights b and once with the correction weights b̂ that RungeKutta.Tableau carries — two separate increments, not one broken up. The weak update takes only b, so a WERK step 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.

source

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
    ...
end

The 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

Each family's cache holds ΔW and ΔZ for the current step, scratch for the final update, and its internal stage storage. Helpers:

Adding a method

To add a family for, say, an SDEProblem:

  1. Define the tableau and the method. The method subtypes SDEMethod, PSDEMethod or SPSDEMethod, and carries its tableau and a generator:

    struct MyMethod{TT, RNG} <: SDEMethod
        tableau::TableauMine{TT}
        rng::RNG
    end
    
    convergence(::MyMethod) = :strong
  2. Define a cache subtyping SDEIntegratorCache with at least ΔW and ΔZ fields, and the Cache/CacheType constructors:

    function GeometricIntegratorsBase.Cache{ST}(problem::AbstractProblemSDE,
            method::MyMethod; kwargs...) where {ST}
        MyCache{ST, length(vec(initial_conditions(problem).q)),
                noisedims(problem), nstages(method)}(; kwargs...)
    end

    Note noisedims(problem): the noise dimension comes from the problem, via its noise process.

  3. For an implicit method, add nlsolution(cache) = cache.x, default_solver, solversize, and the components! / residual! pair.

  4. Define integrate_step!, beginning with sample_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 stages
  • B: diffusion matrix $B$ evaluated at the internal stages
  • bdrift, bdiff: weights of the drift and diffusion tableaus
  • ΔW: increments of the driving process
  • Δq, Δy: scratch of length $D$ and $M$
source

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} .\]

source

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}\]

source

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) .\]

source
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.

source

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:

  • GeometricBase declares the interface stubs noise and noisedims.
  • GeometricEquations provides the concrete WienerProcess and GridProcess, and the accessors on the stochastic equations and problems.
  • GeometricIntegratorsBase dispatches default_extrapolation on the method, so that a family for which running the problem backwards is meaningless can decline it.