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:

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

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:

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

Missing docstring for BoundaryCondition. Check Documenter's build log for details.

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

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 geometry

Layer 1 — geometry

grid = CartesianGrid((-2.0, 2.0, 81), (-2.0, 2.0, 81))   # 2D, 81×81 cells

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

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):

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

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

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_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

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

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.ballFunction
ball(
    center,
    radius::Real
) -> CriticalTransitions.var"#172#173"{_A, <:Real} where _A

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

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

source
CriticalTransitions.sublevelFunction
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)
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)