ODE/DAE solvers¶
This page provides an overview of the time-stepping engines behind
ds.pdesolve: the native solvers, their options and presets, which
options are static under jit compilation, and the diffrax wrapper.
Recommended default¶
ds.JIDA() at default settings is the recommended solver. It handles
every problem form the Recipe API produces, from pure ODEs to fully
implicit index-1 DAEs. For a non-stiff problem whose rows are all
differential, DiffraxStepper opens the explicit Diffrax collection
(the diffrax wrapper).
The native solvers¶
Three native engines implement the variable-step, variable-order family of implicit multistep methods in pure JAX, GPU-resident end to end.
JIDA(recommended) solves the fully implicit form \(F(u, du/dt, t) = 0\) directly, the method of the SUNDIALS IDA lineage (Hindmarsh et al., 2005). It handles ODEs and fully implicit index-1 DAEs.JBDFimplements the classic backward differentiation formula (BDF) family with a fixed-leading-coefficient corrector, the coefficient family of IDA (Hindmarsh et al., 2005). It handles ODEs and mass-form DAEs \(M(t)\, du/dt = f\) with a constant or time-dependent mass. A state-dependent mass is rejected.JNDFimplements the Klopfenstein-Shampine numerical differentiation formula (NDF) family, the method of QNDF and MATLAB'sode15s(Shampine and Reichelt, 1997). The NDFs modify the BDFs with smaller error constants at the same orders. It handles ODEs and mass-form DAEs \(M(u, t)\, du/dt = f\), including a state-dependent mass.
Constructor options¶
The three constructors share their options. Each option states its default.
rtol=1e-6andatol=1e-6, the relative and absolute integration tolerances.linear="auto"names the linear backend,"auto","cudss","klu", or"dense"(linear solvers).newton_tol_factor=1.0scales the Newton corrector's acceptance threshold. The step error test keeps the unscaled tolerances.ic_max_newton_iters=10caps the consistent-initialization Newton onJNDFandJBDF(consistent initialization).JIDAruns its own initialization modes and refuses a non-default value.precision=andjacobian_values_dtype=are left unset: the solve inherits the problem's precision policy (precision). A value given here is used only when the problem declares none or the same one.adjoint=Nonecarries a default differentiation strategy on the solver, overridden per call (sensitivities and autodiff).
JIDA adds initialization and error-test options.
consistent_ic=Trueprojects the initial state before the first step and is a no-op for a pure ODE (consistent initialization).suppress_alg=Truekeeps algebraic rows out of the local error test. Differential globals stay in the error norm, andFalseincludes every algebraic row.id=Noneoverrides the differential-row mask used by the error test. The default derives the mask from the problem's mass structure.ic_mode=selects the consistent-initialization route (consistent initialization). Any value outside the four below is refused at construction."ya_ydp", the default, solves the algebraic values and the differential slopes with a relinearizing Newton projection, on the reduced algebraic block when the mass matrix is diagonal and on the joint system otherwise."ya_ydp_euler"solves the same unknowns with a fixed-Jacobian Euler-Newton kernel, factoring once and iterating against the frozen factor."y_fixed_ydot"solves the full state at a zero leading coefficient while holding the derivative at the engine's slope seed, zeros on the initial entry and the post-event slope on a restart."assume_consistent"skips the projection.
The Euler routes gate convergence on a weighted RMS norm
The Euler-Newton kernel accepts when the weighted root-mean-square norm of its Newton step falls below its gate, while the default route gates on the residual 2-norm. The RMS normalization divides by the square root of the row count, so on a large algebraic block the Euler gate is looser by roughly that factor.
Presets¶
<solver>.stock() and <solver>.flash() assemble the two execution
presets on each solver class. Both accept the constructor options as
keyword overrides.
.stock(), the default profile of every bare constructor: step-size control matching the engine's reference implementation, with Jacobian reuse across steps..flash(): a proportional-integral step-size controller with a fresh Jacobian each corrector iteration.
Solve options¶
solve() takes the per-run options pdesolve forwards
(running a simulation). The engine keywords
and their defaults:
dt0=0.0, the initial step size.0estimates it.hmin=0.0andhmax=0.0, the step-size bounds.0leaves each bound to the engine.max_order=5, the maximum integration order.max_steps=10_000, the step budget, which also sizes the fixed output and trace buffers (solutions and output).linear=overrides the configured backend for this call.trace=False.Trueattaches a per-step diagnostic payload to the returned solution.
The remaining call options (saveat=, theta=, event=,
control_state0=, consistent_ic=, lambda0=, adjoint=) are
covered in running a simulation.
Compilation¶
Each solve is jit compiled and cached on the solver configuration, the problem object's identity, and the argument shapes. The three native engines share one contract.
- Dynamic arguments reuse the compiled program: the time span, the
initial state, the parameter values (
theta=), the control values (control_state0=),dt0=, and the save-time values for a fixed number of save times. - Static options compile a fresh program: every constructor option
(tolerances, backend, precision, profile, the solver-level adjoint),
max_steps=,max_order=,hmin=,hmax=,trace=, the event declaration,consistent_ic=, and the output plan's shape (the number of save times, dense output on or off). - Rebuilding the problem also compiles fresh. Keep the problem object
stable across calls and vary values through
theta=.
The diffrax wrapper¶
DiffraxStepper provides a wrapper to the ODE solvers in the
Diffrax package (install the
optional dependency with pip install diffrax). Diffrax provides the
explicit Runge-Kutta solvers, among them Euler, Heun, Bosh3,
Tsit5, Dopri5, and Dopri8
(the Diffrax solver collection).
DiffraxStepper() defaults to the Tsit5 solver with rtol=1e-6,
atol=1e-6, and max_steps=10_000, and inner= takes any Diffrax
solver instance.
The eligibility contract, checked before integration:
- Every differential field and differential global carries constant identity mass. Non-unit, time-dependent, and state-dependent mass are refused.
- Dirichlet rows are eligible, integrated through the boundary value's time derivative (below).
- Algebraic fields, algebraic globals, and derived-control closing rows are refused.
- Events are terminate-only, and a continuous event must use
direction="any", because Diffrax does not represent directional crossings.
The per-call options narrow with the backend. tolerances=,
control_state0=, lambda0=, trace=True, linear=, and the native
step-bound options (hmin, hmax, max_order, max_events) are
refused, saveat= accepts steps=True in its boolean form only
(solutions and output), and dense output and the
"projected" sampling policy are refused.
A Dirichlet pin \(u_b(t) = c_b(t)\) is an algebraic row in the lowered
system (boundary conditions), so
the backend reduces it to the explicit equation \(du_b/dt = dc_b/dt\) and
seeds the pinned value consistently at t0. consistent_ic=False
asserts that the caller already supplied the intended start.
Boundary drift on long horizons
The differentiated constraint is exact from a consistent start in exact arithmetic, but integration error accumulates in the pinned rows, so loose tolerances can drift the boundary values over a long horizon. The native engines enforce the pin algebraically at every step.
The shared adjoints map as follows:
| diff_soup strategy | Diffrax strategy |
|---|---|
None or CheckpointAdjoint |
RecursiveCheckpointAdjoint |
ForwardAdjoint |
ForwardMode |
BacksolveAdjoint |
BacksolveAdjoint |
ImplicitAdjoint |
ImplicitAdjoint |
A CheckpointAdjoint(checkpoints=k) carries its count across.
BacksolveAdjoint and ImplicitAdjoint execute only on this backend,
and the native engines refuse both.
For example, to solve the nonlinear heat equation from the Diffrax examples,
the recipe declares the nonlinear equation and the wrapper drops into
the same pdesolve call:
import numpy as np
import diff_soup as ds
from diff_soup.domains import cartesian_grid_1d
n = 50
spec = ds.problem("nonlinear_heat")
spec.domain("rod", cartesian_grid_1d(n, -1.0, 1.0, method="fvm"))
spec.field("u", mass="differential", scale=1.0, init=lambda x: x**2)
@spec.equation(writes="u", reads=("u",))
def nonlinear_heat(q):
return (1.0 - q.u) * q.ops.laplacian(q.u)
spec.bc.dirichlet("u", node_indices=np.array([0, n - 1]), value=1.0)
problem = spec.build()
y0 = spec.assemble_state({})
sol = ds.pdesolve(problem, ds.DiffraxStepper(), (0.0, 1.0), y0)
The pinned end values hold at exactly 1 through the differentiated
constraint, and the solution matches a native ds.JIDA() solve of the
same problem to the requested tolerances.
References¶
- A. C. Hindmarsh, P. N. Brown, K. E. Grant, S. L. Lee, R. Serban, D. E. Shumaker, and C. S. Woodward. SUNDIALS: Suite of nonlinear and differential/algebraic equation solvers. ACM Transactions on Mathematical Software, 31(3):363–396, 2005.
- L. F. Shampine and M. W. Reichelt. The MATLAB ODE Suite. SIAM Journal on Scientific Computing, 18(1):1–22, 1997.