Usage
Minimal Example
For example, we can solve a simple split problem using the Euler() algorithm for each subproblem with the LieTrotterGodunov algorithm, by defining a problem tree and an analogue solver tree via tuples:
using OrdinaryDiffEqLowOrderRK, OrdinaryDiffEqOperatorSplitting
# This is the true, full ODE.
function ode_true(du, u, p, t)
du .-= 0.1u
du[1] -= 0.01u[3]
du[3] -= 0.01u[1]
end
# This is the first operator of the ODE.
function ode1(du, u, p, t)
@. du = -0.1u
end
f1 = ODEFunction(ode1)
f1dofs = [1, 2, 3]
# This is the second operator of the ODE.
function ode2(du, u, p, t)
du[1] = -0.01u[2]
du[2] = -0.01u[1]
end
f2 = ODEFunction(ode2)
f2dofs = [1, 3]
# This defines the split of the ODE.
f = GenericSplitFunction((f1, f2), (f1dofs, f2dofs))
# Next we can define the split problem.
u0 = [-1.0, 1.0, 0.0]
tspan = (0.0, 1.0)
prob = OperatorSplittingProblem(f, u0, tspan)
# And the time integration algorithm.
alg = LieTrotterGodunov(
(Euler(), Euler())
)
# Right now OrdinaryDiffEqOperatorSplitting.jl does not implement the SciML solution interface,
# but we can obtain intermediate solutions via the iterator interface.
integrator = init(prob, alg, dt = 0.1)
for (u, t) in TimeChoiceIterator(integrator, 0.0:0.5:1.0)
@show t, u
endFor second-order accuracy, use the StrangMarchuk algorithm instead. It performs the symmetric palindromic splitting A₁(Δt/2) → … → Aₙ(Δt) → … → A₁(Δt/2):
alg = StrangMarchuk(
(Euler(), Euler())
)
integrator = init(prob, alg, dt = 0.1)
for (u, t) in TimeChoiceIterator(integrator, 0.0:0.5:1.0)
@show t, u
endConfiguring individual subintegrators
init takes one value per keyword, which is not enough when the operators want different treatment – a stiff reaction term needs small steps while a diffusion term is happy with large ones, or one operator should be integrated adaptively and another at a fixed step size.
A TreeOption carries one value per node of the splitting tree instead. It is built for a given splitting function, so it knows the shape of the tree and rejects addresses that do not exist:
f_reaction = GenericSplitFunction((f_r1, f_r2), (r1dofs, r2dofs))
f = GenericSplitFunction((f_diffusion, f_reaction), (ddofs, rdofs))
dt = TreeOption(f, 1.0e-2) # every node starts with the same valueNodes are addressed either by their path or by a SplitNode minted from the splitting function, where f[2, 1] is the first operator of the second operator of f. Plain assignment sets a single node, broadcast assignment sets a node and everything below it:
dt[2] = 1.0e-4 # the reaction split node alone
dt[2] .= 1.0e-4 # the reaction split node and both of its operators
dt[f[2]] .= 1.0e-4 # the same, addressed by node
dt[f[2, 1]] = 1.0e-5 # just the first reaction operator
integrator = init(prob, alg; dt)Assignments are applied in order, so a broadcast over a subtree overwrites anything more specific written before it.
Multi-rate integration
A node's dt is the step size it uses to traverse the interval its parent hands it. Giving the reaction subtree a smaller dt therefore makes it subcycle: with an outer step of 1.0e-2 and a reaction step of 1.0e-4, the reaction operators take a hundred steps per splitting step and still land exactly on the synchronization point.
Two things to keep in mind:
- Under
StrangMarchuka child is handed intervals ofΔt/2,ΔtandΔt/2. A step size that does not divide them leaves a short final sub-step in each interval, so the sub-steps are not all the same length. Step sizes that are exactly representable (powers of two, say) avoid this. - A node's
dtlarger than the interval it is handed is clipped to that interval.
For an adaptive node the configured dt is only the initial step size.
Mixing adaptive and fixed-step operators
Adaptivity is configured the same way:
adaptive = TreeOption(f, false)
adaptive[f[2, 1]] = true
adaptive[f[2, 2]] = true
integrator = init(prob, alg; dt = 1.0e-2, adaptive)Note that the splitting nodes themselves stay non-adaptive here. Broadcasting adaptive[2] .= true would also mark the reaction split node as adaptive, and since LieTrotterGodunov is not an adaptive algorithm that produces a warning.
Any other keyword accepted by the inner integrators can be given per node as well and is passed down to the leaves, while keywords a splitting node understands (dtmin, dtmax, failfactor) are applied at every level:
reltol = TreeOption(f, 1.0e-3)
reltol[f[2, 1]] = 1.0e-9
integrator = init(prob, alg; dt = 1.0e-2, adaptive, reltol)Reading the tree back
A SplitNode addresses the same position in every tree that mirrors the splitting function, so it resolves against the algorithm and the integrator too:
f[f[2, 1]] # the sub function
alg[f[2, 1]] # the inner algorithm
integrator[f[2, 1]] # the sub integratorintegrator[i] is not available for this: SciMLBase already gives integer indexing of an integrator the meaning "the i-th state component".
Calling reinit! without a dt restores every node to its configured step size, so a multi-rate setup survives. Passing a dt reconfigures the tree exactly as at init: a single value applies to every node, a TreeOption node by node.