Rates, distributions & the generator

This page covers the quantities you can compute from the infinitesimal generator of an SDE: stationary and quasi-stationary distributions, mean first-passage times, metastable timescales, and the time evolution of densities. All of them are linear-algebra problems on a sparse discretisation of the generator on a Cartesian grid (the engine, described in the second half of this page). It supports any state-space dimension and any CoupledSDEs with diagonal additive noise.

What you can compute

Every observable below is solved on a built DiffusionGenerator (see The generator). Building the generator is the expensive step (one sweep of the grid); reuse it across analyses.

grid = CartesianGrid((-2.0, 2.0, 81), (-2.0, 2.0, 81))
sys  = CoupledSDEs((u, p, t) -> SA[u[2], -u[1] + u[1]^3 - 0.5*u[2]],
                   [0.0, 0.0]; noise_strength = 0.4)
gen  = DiffusionGenerator(sys, grid)

The A, B, target arguments below accept a predicate x -> Bool, a BitVector of length ncells(grid), or a vector of linear cell indices; the predicate builders ball, cuboid, sublevel construct common shapes.

By default the analyses use a sparse direct LU solve. For large grids where direct factorisation runs out of memory, pass an alg keyword with any LinearSolve.jl algorithm, typically a Krylov method:

using LinearSolve: KrylovJL_GMRES
ρ = stationary_distribution(gen, KrylovJL_GMRES())
τ = mean_first_passage_time(gen, x -> x[1] > 1; alg = KrylovJL_GMRES())

Stationary and quasi-stationary distributions

The invariant density $\rho$ is the stationary solution of the Fokker-Planck equation ($\mathcal{L}^{*}\rho = 0$ with $\int\rho = 1$); it is the long-time distribution of the system. The quasi-stationary distribution (QSD) is the analogous long-lived distribution conditioned on not having left a chosen metastable basin, obtained from the leading eigenmode of the generator restricted to that basin with absorbing boundary.

ρ   = stationary_distribution(gen)
qsd = quasi_stationary_distribution(gen, x -> x[1] < 0)   # QSD of the left basin
CriticalTransitions.stationary_distributionFunction
stationary_distribution(
    generator::DiffusionGenerator{D, BC, T};
    ...
) -> Any
stationary_distribution(
    generator::DiffusionGenerator{D, BC, T},
    alg;
    verbose,
    multimodal_tol,
    kwargs...
) -> Any

Invariant probability density of the discretised process generated by generator: solves the discrete adjoint stationary equation Qᵀ ρ = 0 augmented with the normalisation ∫ ρ dV = 1, where Q = generator.Q is the rate matrix.

Returns a length-ncells(generator) vector.

Algorithm (alg)

  • A LinearSolve.SciMLLinearSolveAlgorithm — pinned linear solve with the given algorithm. Default is UMFPACKFactorization() (sparse direct LU). Pass e.g. KrylovJL_GMRES() for an iterative solver. Extra kwargs flow to LinearSolve.solve.
  • DenseEigen()LinearAlgebra.eigen on Matrix(Qᵀ), picking the eigenvector at λ ≈ 0. Bounded by O(N²) storage.
  • KrylovKitSolver() — shift-invert Arnoldi via KrylovKit.eigsolve near σ = 0. Use for large sparse problems; pass inner_alg = … to control the LinearSolve algorithm used for the inner shift-invert solve. Extra kwargs flow to KrylovKit.eigsolve.

Diagnostic

The result is always validated post-hoc:

  1. Sign check: entries below -16ε · max|ρ| are flagged.
  2. Residual check: ‖Qᵀ ρ‖ / ‖ρ‖ should be near machine ε.

Pass verbose = true to additionally run the multi-modality probe:

  • Linear-solve path: re-pins at several spread-out cells and checks the answer is invariant. Adds three full LU solves.
  • Eigensolver path (DenseEigen, KrylovKitSolver): inspects the spectral gap above the kernel by computing λ₂, λ₃ of Qᵀ and flagging |λ₂| / |λ₃| < multimodal_tol. Adds one extra eigenmodes call (a single LU factorisation reused across Arnoldi iterations).

In both cases a positive result reveals ≥ 2 near-zero eigenvalues within machine precision (multi-basin metastability): the returned vector is one quasi-stationary mode rather than the global invariant, and you should switch to quasi_stationary_distribution per basin. The probe is off by default.

A single combined warning is emitted with concrete suggestions if any check fires.

source
CriticalTransitions.quasi_stationary_distributionFunction
quasi_stationary_distribution(
    generator::DiffusionGenerator,
    basin;
    ...
) -> Tuple{Vector{T} where T<:AbstractFloat, Any}
quasi_stationary_distribution(
    generator::DiffusionGenerator,
    basin,
    alg::Union{DenseEigen, KrylovKitSolver};
    kwargs...
) -> Tuple{Vector{T} where T<:AbstractFloat, Any}

Quasi-stationary distribution (QSD) of a metastable basin.

Returns (ρ_QSD, λ_exit) where ρ_QSD is the length-ncells(gen) QSD vector (with the generator's float element type, zero on cells outside basin, normalised to ∫ ρ_QSD dV = 1 on basin) and λ_exit > 0 is the corresponding exit rate. The mean lifetime in the basin, conditional on staying in it long enough to reach quasi-equilibrium, is 1 / λ_exit.

basin accepts a predicate x -> Bool, a BitVector of length ncells(gen), or a vector of linear cell indices.

Theory

The QSD is the principal left eigenvector of the basin sub-generator Q_S (the rows and columns of Q indexed by the basin S):

\[Q_S^\top \rho_S = -\lambda_{\text{exit}}\, \rho_S ,\qquad \rho_S \ge 0 ,\qquad \int \rho_S\, dV = 1 ,\]

with -λ_exit the eigenvalue of Q_S^T closest to zero (it is real and strictly negative because mass leaks across the basin boundary). Equivalently, λ_exit is the smallest-magnitude eigenvalue of -Q_S. For a single metastable set inside an otherwise-reflecting domain, the QSD is the long-time limit of the distribution conditioned on not yet having left S, and -D log ρ_QSD is the local quasipotential of S at noise strength D << 1.

This routine is the standard tool for visualising the quasipotential well of a metastable basin at small noise, where the global stationary_distribution becomes ill-conditioned.

Algorithm (alg)

  • KrylovKitSolver() (default) — shift-invert Arnoldi via KrylovKit.eigsolve. Kwargs flow to KrylovKit.eigsolve; pass inner_alg = … to control the LinearSolve algorithm used for the inner shift-invert solve.
  • DenseEigen()LinearAlgebra.eigen on Matrix(Q_Sᵀ). Use on small basins where the dense factorisation fits in memory.

Passing a LinearSolve.SciMLLinearSolveAlgorithm is rejected: the QSD eigenvalue −λ_exit is unknown a priori, so a single linear solve is insufficient.

source

Mean first-passage time

The mean first-passage time $\tau(x)$ to a target set is the expected time until a trajectory started at $x$ first hits the target. Its variance is also available.

using CriticalTransitions: ball, reshape_to_grid

τ = mean_first_passage_time(gen, x -> x[1] > 1)
τ = mean_first_passage_time(gen, ball((1.0, 0.0), 0.25))

τ_grid = reshape_to_grid(τ, gen)            # vector → grid shape for plotting
heatmap(grid.centers[1], grid.centers[2], τ_grid')
CriticalTransitions.mean_first_passage_timeFunction
mean_first_passage_time(
    generator::DiffusionGenerator{D, BC, T},
    target;
    alg,
    kwargs...
) -> Any

Mean first-passage time τ[i] from cell i to the target set: the expected time for a trajectory of the process generated by generator to first hit target starting at i. Solves Q τ = -1 on the free cells with Dirichlet condition τ = 0 on target.

target accepts a predicate, a BitVector, or a vector of linear cell indices.

Default alg is UMFPACKFactorization() (sparse direct LU). Pass any SciMLLinearSolveAlgorithm for an iterative solver.

See also first_passage_variance.

source
CriticalTransitions.first_passage_varianceFunction
first_passage_variance(
    generator::DiffusionGenerator{D, BC, T},
    target;
    alg,
    kwargs...
) -> Any

Variance Var[τ | X_0 = i] = E[τ²] − E[τ]² of the first-passage time to target, as a function of starting cell i.

The first moment τ_1 = E[τ] solves Q τ_1 = -1 with τ_1|_target = 0 (mean_first_passage_time); the second moment τ_2 = E[τ²] solves Q τ_2 = -2 τ_1 with τ_2|_target = 0. The variance follows from Var[τ] = τ_2 − τ_1².

Returns a length-ncells(generator) vector of variances, all ≥ 0.

Default alg is UMFPACKFactorization() (sparse direct LU). Pass any SciMLLinearSolveAlgorithm for an iterative solver.

See also mean_first_passage_time.

source

Metastable timescales

The slowest-decaying eigenmodes of the generator encode metastable timescales (one over the spectral gap is the mixing time); the corresponding eigenvectors are the canonical reaction coordinates / metastable-state indicators used in Markov state model decomposition.

λ, V = eigenmodes(gen, 5)        # 5 slowest eigenpairs
@show λ                          # λ[1] = 0; λ[2:end] ≤ 0
metastable_timescale = -1 / real(λ[2])
CriticalTransitions.eigenmodesFunction
eigenmodes(gen::DiffusionGenerator; ...) -> Tuple{Any, Any}
eigenmodes(
    gen::DiffusionGenerator,
    k::Integer;
    ...
) -> Tuple{Any, Any}
eigenmodes(
    gen::DiffusionGenerator,
    k::Integer,
    alg::Union{DenseEigen, KrylovKitSolver};
    kwargs...
) -> Tuple{Any, Any}

Slowest-decaying eigenmodes of the generator gen. Returns (λ, V) where λ is a vector of Complex{T} eigenvalues (with T = floattype(gen)) with largest real part (least negative — slowest decay), sorted descending, and V collects the corresponding right eigenvectors of Q columnwise (Q * V[:, i] = λ[i] * V[:, i]).

For mass-preserving boundary conditions (Reflecting / Periodic) the eigenvalue closest to zero is exactly 0, with eigenvector the constant function (since Q * 1 = 0); the corresponding left eigenvector is the invariant density. For Absorbing boundaries Q is a sub-generator and all eigenvalues lie strictly in the open left half-plane (no zero mode). The remaining eigenvalues lie in the open left half-plane, and -1 / Re(λ_k) gives the metastable timescale of the k-th mode. The slow non-trivial right eigenvectors are the canonical reaction coordinates / metastable-state indicators used in Markov state model decomposition (e.g. PCCA+).

Algorithm (alg)

  • KrylovKitSolver() (default) — shift-invert Arnoldi via KrylovKit.eigsolve near σ = 0. The right choice for large sparse generators. Kwargs flow to KrylovKit.eigsolve; pass inner_alg (a SciMLLinearSolveAlgorithm, defaults to UMFPACKFactorization()) to override the LinearSolve algorithm for the inner shift-invert solve.
  • DenseEigen()LinearAlgebra.eigen on Matrix(gen.Q), returning all eigenpairs sliced to the first k. Fine up to ~5000 cells.

Passing a LinearSolve.SciMLLinearSolveAlgorithm is rejected: the target eigenvalues are unknown a priori, so a single linear solve is insufficient.

source

Time evolution of densities

The discrete Fokker-Planck equation dρ/dt = Qᵀ ρ is propagated via the matrix-exponential action ρ(t) = exp(t·Qᵀ)·ρ₀. The signature mirrors DynamicalSystemsBase.trajectory: total time T, optional sampling interval Δt, optional transient Ttr.

using CriticalTransitions: ncells, cell_volume

ρ_0 = zeros(ncells(gen)); ρ_0[i_start] = 1 / cell_volume(gen)   # point mass

ρs, t = propagate_density(gen, 5.0, ρ_0)               # endpoints only: t = [0, 5]
ρs, t = propagate_density(gen, 5.0, ρ_0; Δt = 0.1)     # snapshots every 0.1
ρs, t = propagate_density(gen, 5.0, ρ_0; Δt = 0.1, Ttr = 2.0)   # skip transient

ρs[:, i] is the density at t[i]. Internal substepping is adaptive; the tol, m, and adaptive kwargs control the Krylov-subspace matrix-exponential algorithm.

CriticalTransitions.propagate_densityFunction
propagate_density(
    gen::DiffusionGenerator{D, BC, S},
    T::Real,
    ρ_0::AbstractVector;
    Δt,
    Ttr,
    tol,
    m,
    adaptive
) -> Tuple{Any, Any}

Time-evolved probability density. Solves the discrete Fokker–Planck equation dρ/dt = Qᵀ ρ (where Qᵀ =fokker_planck_operator(gen)) starting from ρ_0, and records ρ on a uniform time grid.

Returns (ρs, t) where t = Ttr:Δt:(Ttr + T) and ρs is a Matrix (with the generator's float element type) of size (ncells(gen), length(t)) whose i-th column is ρ(t[i]).

ρs, t = propagate_density(gen, T, ρ_0)                      # snapshot at 0 and T
ρs, t = propagate_density(gen, T, ρ_0; Δt = 0.1)            # snapshots every 0.1
ρs, t = propagate_density(gen, T, ρ_0; Δt = 0.1, Ttr = 5)   # skip transient

Arguments

  • gen — diffusion generator (provides Qᵀ).
  • T — total time over which the trajectory is recorded.
  • ρ_0 — initial density (length-ncells(gen) vector).

Keyword arguments

  • Δt = T — sampling interval. Default returns just the initial and final snapshots.
  • Ttr = 0 — transient time skipped before recording starts.
  • tol = 1e-7 — Krylov local-error tolerance.
  • m = 30 — maximum Krylov subspace dimension before restart.
  • adaptive = true — use adaptive Krylov substepping. Disable only for debugging or to force fixed-step Krylov of dimension m.

For mass-preserving boundary conditions (Reflecting / Periodic) the total mass is preserved across the trajectory; for Absorbing boundaries it decays as probability leaks through. Internal substepping is automatic — tol/m/adaptive control the Krylov-subspace matrix-exponential algorithm in ExponentialUtilities, which handles stiffness without ever forming exp(t·Qᵀ) explicitly.

source

The generator (engine)

Theory primer

We work with an Itô diffusion in $\mathbb{R}^D$,

\[\mathrm{d}X_t = b(X_t)\,\mathrm{d}t + \sigma(X_t)\,\mathrm{d}W_t .\]

The infinitesimal generator of the diffusion is the second-order differential operator

\[\mathcal{L}\,u = b\!\cdot\!\nabla u + \tfrac{1}{2}\,\mathrm{tr}(\sigma\sigma^{\!\top}\nabla^{\!2} u) .\]

It is the backward Kolmogorov operator, governing how expectations of observables evolve in time ($\partial_t \mathbb{E}[u(X_t)] = \mathcal{L}u$). Its $L^2$-adjoint $\mathcal{L}^{*}$ is the Fokker-Planck operator, governing the probability density ($\partial_t \rho = \mathcal{L}^{*}\rho$).

After discretisation, $\mathcal{L}$ becomes a sparse matrix $Q$; its transpose $Q^\top$ discretises $\mathcal{L}^{*}$. The same matrix wears different names depending on convention:

OperatorActs onAccessor
backward Kolmogorov / generator / rate matrix (= $Q$)observablesrate_matrix
(negative) M-matrix (= $-Q$)observables, PDE formm_matrix
forward Kolmogorov / Fokker-Planck (= $Q^\top$)probability densitiesfokker_planck_operator

rate_matrix and m_matrix differ only in sign convention; the Fokker-Planck operator is the matrix transpose, equivalent to the $L^2$-adjoint of the generator (for the uniform cell-volume inner product). The observables in the first half of this page are linear boundary-value problems on $\mathcal{L}$ or its adjoint: the invariant density solves $Q^\top\rho = 0$ with normalisation, and the mean first-passage time to a target $T$ solves $\mathcal{L}\tau = -1$ on $T^{\mathrm{c}}$ with $\tau|_T = 0$.

Discretisation

We approximate the diffusion by a continuous-time Markov chain on a uniform Cartesian grid: each cell becomes a state of the chain; the generator $Q$ collects transition rates between adjacent cells.

The face rates come from the Scharfetter-Gummel exponential-fitting stencil. It interpolates between centered differences (small Péclet) and upwind (large Péclet), and always preserves the M-matrix property, which is what guarantees solutions of $Qq = 0$ numerically stay in the right range even at large drift / small noise.

The outer faces of the grid can be treated three different ways, set via the bc keyword to DiffusionGenerator:

bcMeaning
Reflecting() (default)outer faces omitted, the chain bounces off (no-flux Neumann).
Periodic()outer faces wrap to the opposite end of the same axis. Use for systems on a torus / ring (angle variables).
Absorbing()boundary cells leak probability through the outer faces; Q becomes a sub-generator. Use for survival problems where trajectories that leave the grid are removed.

A single instance applies to every axis; pass a D-tuple for per-axis control:

gen = DiffusionGenerator(sys, grid; bc = Periodic())
gen = DiffusionGenerator(sys, grid; bc = (Reflecting(), Periodic()))

The boundary condition is part of the generator's type (a DiffusionGenerator{2, Tuple{Reflecting, Periodic}} is a different concrete type from DiffusionGenerator{2, Tuple{Periodic, Periodic}}), which lets the compiler specialize the assembly and analysis code to the specific BC. The covariance must be diagonal (axis-aligned noise); off-diagonal entries raise an ArgumentError at construction.

CriticalTransitions.AbsorbingType

Absorbing boundary: cells on the outer boundary leak probability through their outer faces (escape rate from the cell-center drift). Row sums on boundary cells become negative; the resulting Q is a sub-generator.

source

Building the generator

gen = DiffusionGenerator(sys, grid)
CriticalTransitions.DiffusionGeneratorType
struct DiffusionGenerator{D, BC<:Tuple, T<:AbstractFloat}

The discretised generator of an autonomous Itô diffusion on a CartesianGrid, built by the Scharfetter–Gummel exponential- fitting finite-volume scheme.

The matrix Q generates the transition semigroup exp(t Q) of the discretised process. With probability densities and observables represented as column vectors (the convention used throughout this module), it propagates observables forward (du/dt = Q u, the discrete backward Kolmogorov) and densities forward via the transpose (dρ/dt = Qᵀ ρ, the discrete Fokker–Planck, exposed as fokker_planck_operator). Equivalently, Q[i, j] for i ≠ j is the transition rate from cell i to cell j.

The same matrix has several traditional names:

NameWhere it comes fromSign convention of Q
rate matrix / Q-matrixprobability theory, Markov chainsoff-diagonals ≥ 0
infinitesimal generatorsemigroup theory, applied mathoff-diagonals ≥ 0
negative M-matrixnumerical PDE / finite-volume / FEM(the M-matrix is -Q, off-diag ≤ 0)

Use m_matrix for the M-matrix view (PDE convention), rate_matrix for the CTMC view, or fokker_planck_operator for the forward Kolmogorov / FP operator (Qᵀ).

Fields

  • grid::CartesianGrid: The Cartesian grid the generator lives on.

  • Q::SparseArrays.SparseMatrixCSC{T, Int64} where T<:AbstractFloat: Sparse rate matrix (off-diagonals = transition rates ≥ 0).

  • bc::Tuple: Per-axis boundary condition (one BoundaryCondition per axis).

Construction

DiffusionGenerator(sys::CoupledSDEs, grid::CartesianGrid; bc=Reflecting())

Discretises the SDE's backward Kolmogorov operator on grid. Drift b(x) is read via drift; diffusion is read once from covariance_matrix(sys) and must be diagonal (axis-aligned noise).

bc controls how the outer grid faces are treated and is a singleton BoundaryCondition instance applied to every axis, or a D-tuple for per-axis control:

  • Reflecting() (default) — outer faces are omitted; chain bounces off the edges.
  • Periodic() — outer faces wrap to the opposite end of the same axis.
  • Absorbing() — boundary cells leak probability through their outer faces. Q is a sub-generator (row sums on the boundary become negative).
source
CriticalTransitions.rate_matrixFunction
rate_matrix(
    generator::DiffusionGenerator
) -> SparseArrays.SparseMatrixCSC{T, Int64} where T<:AbstractFloat

Rate matrix of gen: returns gen.Q, also called the discrete backward Kolmogorov / generator.

The off-diagonals are transition rates between adjacent cells, the diagonal is minus the escape rate. For the PDE M-matrix sign convention see m_matrix; for the forward Kolmogorov / Fokker–Planck operator (acts on densities), see fokker_planck_operator.

source
CriticalTransitions.m_matrixFunction
m_matrix(
    generator::DiffusionGenerator
) -> SparseArrays.SparseMatrixCSC{Tv, Int64} where Tv

M-matrix of the generator. Equivalent to -rate_matrix(gen).

source
CriticalTransitions.fokker_planck_operatorFunction
fokker_planck_operator(
    generator::DiffusionGenerator
) -> SparseArrays.SparseMatrixCSC{Tv, Int64} where Tv

Discrete Fokker–Planck operator (forward Kolmogorov operator) of generator.

It acts on probability densities: the discrete Fokker–Planck equation is

dρ/dt = fokker_planck_operator(gen) * ρ ,

and the invariant density solves fokker_planck_operator(gen) * ρ = 0.

source

The grid

Each axis is (lo, hi, N); per-axis spacing may differ.

grid = CartesianGrid((-2.0, 2.0, 81), (-2.0, 2.0, 81))   # 2D, 81×81 cells
CriticalTransitions.CartesianGridType
struct CartesianGrid{D, T<:AbstractFloat}

A D-dimensional uniform axis-aligned Cartesian grid with coordinates stored in the floating-point type T (default Float64).

Fields

  • nbox::Tuple{Vararg{Int64, D}} where D: Number of cells along each axis.

  • h::Tuple{Vararg{T, D}} where {D, T<:AbstractFloat}: Cell width along each axis.

  • centers::Tuple{Vararg{LinRange{T, Int64}, D}} where {D, T<:AbstractFloat}: Cell-center coordinates along each axis (length N_k).

  • edges::Tuple{Vararg{LinRange{T, Int64}, D}} where {D, T<:AbstractFloat}: Cell-edge coordinates along each axis (length N_k + 1).

Construction

CartesianGrid((u_lo, u_hi, N_u), (v_lo, v_hi, N_v), ...)            # Float64
CartesianGrid{Double64}((u_lo, u_hi, N_u), (v_lo, v_hi, N_v), ...)  # extended

Each axis is (lo, hi, N) and creates N equal-width cells. The storage type T propagates into DiffusionGenerator and the resulting rate matrix.

source

A handful of small accessors are available (non-exported, use them via CriticalTransitions.ncells(grid) or import explicitly):

Predicate and reshape helpers

Predicate builders for the A / B / target arguments and a vector to grid-shape reshaper, kept non-exported to keep the top-level namespace tight. Import explicitly:

using CriticalTransitions: ball, cuboid, sublevel, reshape_to_grid
CriticalTransitions.ballFunction
ball(
    center,
    radius::Real
) -> CriticalTransitions.var"#187#188"{_A, <:Real} where _A

Return a predicate for the open ball with given center and radius.

source
CriticalTransitions.cuboidFunction
cuboid(lo, hi) -> CriticalTransitions.var"#189#191"

Predicate matching points inside an axis-aligned box: returns the closure x -> all(lo .≤ x .≤ hi). lo and hi may be tuples, SVectors, or any indexable types of the right length.

source
CriticalTransitions.sublevelFunction
sublevel(
    f,
    c::Real
) -> CriticalTransitions.var"#193#194"{_A, <:Real} where _A

Predicate for the sublevel set {x : f(x) < c}. Useful for masking by a potential, energy, or any scalar-valued function on state space.

using CriticalTransitions: sublevel
U(x) = 0.25 * (x[1]^2 - 1)^2 + 0.5 * x[2]^2
basin_low_energy = sublevel(U, 0.1)
source
CriticalTransitions.reshape_to_gridFunction
reshape_to_grid(
    v::AbstractVector,
    gen::DiffusionGenerator
) -> Any

Reshape a per-cell vector (length ncells(grid)) back to a grid-shaped Array of size grid.nbox. Useful for plotting and for spatial operations on grid quantities.

using CriticalTransitions: reshape_to_grid
ρ = stationary_distribution(gen)
ρ_grid = reshape_to_grid(ρ, gen)
heatmap(grid.centers[1], grid.centers[2], ρ_grid)

Both DiffusionGenerator and CartesianGrid are accepted as the second argument.

source

Limitations

The implementation targets one specific point in design space: uniform Cartesian grid, Scharfetter-Gummel finite volume, sparse direct LU.

Status
State-space dimensionworks for any $D$, but practical only for $D \leq 3$ ($\sim 10^6$ cells); marginal at $D = 4$; infeasible at $D \geq 5$ with uniform grids
Noise covariancemust be diagonal
Geometryuniform Cartesian only; curved boundaries are staircased, no AMR
Boundary conditionsreflecting (default), periodic, or absorbing, per-axis
Time-dependenceautonomous only
Linear solversparse direct LU by default; opt-in Krylov via the alg kwarg (any LinearSolve.jl algorithm)