Diffusion operator
This page documents how CriticalTransitions discretises an SDE on a Cartesian grid into a sparse linear operator, and the analyses you can compute on that operator: invariant density, mean first-passage time, spectral decomposition, density propagation. It supports any state-space dimension and any CoupledSDEs with diagonal additive noise.
Theory primer
Setting
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 generator
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:
| Operator | Acts on | Accessor |
|---|---|---|
| backward Kolmogorov / generator / rate matrix (= $Q$) | observables | rate_matrix |
| (negative) M-matrix (= $-Q$) | observables, PDE form | m_matrix |
| forward Kolmogorov / Fokker–Planck (= $Q^\top$) | probability densities | fokker_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).
Boundary-value problems on the generator
A wide class of useful quantities solve linear BVPs on $\mathcal{L}$ or its adjoint. The ones implemented here:
- Invariant density $\rho$: stationary solution of $\mathcal{L}^{*}\rho = 0$ with $\int \rho = 1$. Discretely: $Q^\top \rho = 0$ with normalisation.
- Mean first-passage time $\tau(x)$ to a target $T$: expected time until $X_t$ first hits $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:
bc | Meaning |
|---|---|
Reflecting() (default) | outer faces omitted — 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.
Missing docstring for BoundaryCondition. Check Documenter's build log for details.
CriticalTransitions.Reflecting — Type
No-flux Neumann boundary: outer faces are omitted from the stencil. The chain bounces off the grid edges, mass is preserved.
CriticalTransitions.Periodic — Type
Periodic boundary: outer faces wrap to the opposite end of the same axis. Use for systems on a torus / ring (angle variables).
CriticalTransitions.Absorbing — Type
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.
The covariance must be diagonal (axis-aligned noise); off-diagonal entries raise an ArgumentError at construction.
The API
The API is layered. Stop at any level and use the output as you wish.
Layer 3: stationary_distribution, mean_first_passage_time,
first_passage_variance, eigenmodes, propagate_density
▲
Layer 2: DiffusionGenerator ← the discretised operator
▲
Layer 1: CartesianGrid ← the geometryLayer 1 — geometry
grid = CartesianGrid((-2.0, 2.0, 81), (-2.0, 2.0, 81)) # 2D, 81×81 cellsEach axis is (lo, hi, N). Per-axis spacing may differ.
CriticalTransitions.CartesianGrid — Type
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 (lengthN_k).edges::Tuple{Vararg{LinRange{T, Int64}, D}} where {D, T<:AbstractFloat}: Cell-edge coordinates along each axis (lengthN_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), ...) # extendedEach axis is (lo, hi, N) and creates N equal-width cells. The storage type T propagates into DiffusionGenerator and the resulting rate matrix.
A handful of small accessors are available (non-exported — use them via CriticalTransitions.ncells(grid) or import explicitly):
CriticalTransitions.ncells — Function
ncells(grid::CartesianGrid) -> Int64
Total number of cells in grid.
CriticalTransitions.cell_volume — Function
cell_volume(grid::CartesianGrid) -> Any
Volume of one cell (product of axis spacings).
CriticalTransitions.cell_center — Function
cell_center(
grid::CartesianGrid{D, T},
I::CartesianIndex{D}
) -> Any
Center coordinates of cell I of grid.
Layer 2 — the discretised operator
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)Building gen is the expensive step (one sweep of the grid). Reuse it across multiple analyses or sweeps.
CriticalTransitions.DiffusionGenerator — Type
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:
| Name | Where it comes from | Sign convention of Q |
|---|---|---|
| rate matrix / Q-matrix | probability theory, Markov chains | off-diagonals ≥ 0 |
| infinitesimal generator | semigroup theory, applied math | off-diagonals ≥ 0 |
| negative M-matrix | numerical 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 (oneBoundaryConditionper 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.Qis a sub-generator (row sums on the boundary become negative).
CriticalTransitions.rate_matrix — Function
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.
CriticalTransitions.m_matrix — Function
m_matrix(
generator::DiffusionGenerator
) -> SparseArrays.SparseMatrixCSC{Tv, Int64} where Tv
M-matrix of the generator. Equivalent to -rate_matrix(gen).
CriticalTransitions.fokker_planck_operator — Function
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.
Layer 3 — analyses
The A, B, target arguments accept a predicate x -> Bool, a BitVector of length ncells(grid), or a vector of linear cell indices.
ρ = stationary_distribution(gen)
τ = mean_first_passage_time(gen, x -> x[1] > 1)The analyses default to 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())CriticalTransitions.stationary_distribution — Function
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 isUMFPACKFactorization()(sparse direct LU). Pass e.g.KrylovJL_GMRES()for an iterative solver. Extra kwargs flow toLinearSolve.solve. DenseEigen()—LinearAlgebra.eigenonMatrix(Qᵀ), picking the eigenvector atλ ≈ 0. Bounded by O(N²) storage.KrylovKitSolver()— shift-invert Arnoldi viaKrylovKit.eigsolvenearσ = 0. Use for large sparse problems; passinner_alg = …to control the LinearSolve algorithm used for the inner shift-invert solve. Extra kwargs flow toKrylovKit.eigsolve.
Diagnostic
The result is always validated post-hoc:
- Sign check: entries below
-16ε · max|ρ|are flagged. - 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λ₂, λ₃ofQᵀand flagging|λ₂| / |λ₃| < multimodal_tol. Adds one extraeigenmodescall (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.
CriticalTransitions.mean_first_passage_time — Function
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.
CriticalTransitions.first_passage_variance — Function
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.
Time evolution
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; tol, m, and adaptive kwargs control the Krylov-subspace matrix-exponential algorithm.
CriticalTransitions.propagate_density — Function
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 transientArguments
gen— diffusion generator (providesQᵀ).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 dimensionm.
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.
Spectral analysis
The slowest-decaying eigenmodes of Q encode metastable timescales (one over the spectral gap is the mixing time) and 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.eigenmodes — Function
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 viaKrylovKit.eigsolvenearσ = 0. The right choice for large sparse generators. Kwargs flow toKrylovKit.eigsolve; passinner_alg(aSciMLLinearSolveAlgorithm, defaults toUMFPACKFactorization()) to override the LinearSolve algorithm for the inner shift-invert solve.DenseEigen()—LinearAlgebra.eigenonMatrix(gen.Q), returning all eigenpairs sliced to the firstk. 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.
Convenience helpers
Predicate builders for A / B / target arguments and a vector → grid-shape reshaper, kept non-exported to keep the top-level namespace tight. Import explicitly:
using CriticalTransitions: ball, cuboid, sublevel, reshape_to_grid
target = ball((1.0, 0.0), 0.25)
τ = mean_first_passage_time(gen, target)
τ_grid = reshape_to_grid(τ, gen)
heatmap(grid.centers[1], grid.centers[2], τ_grid')CriticalTransitions.ball — Function
ball(
center,
radius::Real
) -> CriticalTransitions.var"#172#173"{_A, <:Real} where _A
Return a predicate for the open ball with given center and radius.
CriticalTransitions.cuboid — Function
cuboid(lo, hi) -> CriticalTransitions.var"#174#176"
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.
CriticalTransitions.sublevel — Function
sublevel(
f,
c::Real
) -> CriticalTransitions.var"#178#179"{_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)CriticalTransitions.reshape_to_grid — Function
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.
Limitations
The implementation targets one specific point in design space — uniform Cartesian grid, Scharfetter–Gummel finite volume, sparse direct LU.
| Status | |
|---|---|
| State-space dimension | works 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 covariance | must be diagonal |
| Geometry | uniform Cartesian only — curved boundaries are staircased, no AMR |
| Boundary conditions | reflecting (default), periodic, or absorbing — per-axis |
| Time-dependence | autonomous only |
| Linear solver | sparse direct LU by default; opt-in Krylov via the alg kwarg (any LinearSolve.jl algorithm) |