Transition Path Theory for a 2D double well

In this example we apply Transition Path Theory (TPT) to a two-dimensional overdamped double-well system. The full theoretical background and the layered API used below are documented in the Transition Path Theory manual. Here we focus on putting the pieces together end-to-end: discretise the diffusion on a Cartesian grid, solve for the committor, and visualise the reactive density and current.

using CriticalTransitions
using LinearAlgebra: norm
using CairoMakie

System

We consider an overdamped 2D Itô diffusion

\[\mathrm{d}X_t = -\nabla U(X_t)\,\mathrm{d}t + \sigma\,\mathrm{d}W_t\]

with the separable potential

\[U(x, y) = \tfrac{1}{4}(x^2 - 1)^2 + \tfrac{1}{2}\,y^2 ,\]

whose drift is

\[b(x, y) = -\nabla U = \bigl(x - x^3,\; -y\bigr) .\]

The deterministic dynamics has stable fixed points at $(\pm 1, 0)$ and a saddle at the origin. The noise is isotropic with diffusion coefficient $\sigma^2$ along each axis. Because the drift is the gradient of a potential and the noise is isotropic, this is a reversible system — the backward committor satisfies $q^{-} = 1 - q^{+}$ exactly.

drift(u, p, t) = SA[u[1] - u[1]^3, -u[2]]
sys = CoupledSDEs(drift, [0.0, 0.0]; noise_strength = 0.4)
2-dimensional CoupledSDEs
 deterministic: false
 discrete time: false
 in-place:      false
 dynamic rule:  drift
 SDE solver:    SOSRA
 SDE kwargs:    (abstol = 0.01, reltol = 0.01, dt = 0.1)
 Noise type:    (additive = true, autonomous = true, linear = true, invertible = true)
 parameters:    SciMLBase.NullParameters()
 time:          0.0
 state:         [0.0, 0.0]

Visualise the drift field:

xs = range(-1.8, 1.8; length = 40)
ys = range(-1.0, 1.0; length = 24)
fig = Figure(; size = (640, 360))
ax = Axis(fig[1, 1]; xlabel = "x", ylabel = "y", aspect = DataAspect())
streamplot!(
    ax,
    p -> Point2f(p[1] - p[1]^3, -p[2]),
    -1.8 .. 1.8,
    -1.0 .. 1.0;
    linewidth = 0.6,
    colormap = [:gray40, :gray40],
    arrow_size = 8,
    gridsize = (28, 16),
)
scatter!(ax, [-1.0, 1.0], [0.0, 0.0]; color = :black, markersize = 12)
text!(ax, [-1.0, 1.0], [0.0, 0.0]; text = ["A", "B"], offset = (8, 8))
fig
Example block output

Discretisation

We discretise on a uniform Cartesian grid covering the relevant transition region. The grid does not have to be square — here we use a finer resolution along $x$ since that is the reaction coordinate.

grid = CartesianGrid((-1.8, 1.8, 121), (-1.0, 1.0, 81))
CartesianGrid{2, Float64}((121, 81), (0.02975206611570248, 0.024691358024691357), (LinRange{Float64}(-1.7851239669421488, 1.7851239669421488, 121), LinRange{Float64}(-0.9876543209876543, 0.9876543209876543, 81)), (LinRange{Float64}(-1.8, 1.8, 122), LinRange{Float64}(-1.0, 1.0, 82)))

The metastable sets $A$ and $B$ are passed as predicates evaluated at each cell center; alternatively a BitVector or a vector of linear cell indices is accepted.

A = x -> norm(x .- SA[-1.0, 0.0]) < 0.25
B = x -> norm(x .- SA[1.0, 0.0]) < 0.25
#5 (generic function with 1 method)

Building the DiffusionGenerator is the expensive step (it sweeps the grid once and assembles the sparse rate matrix via the Scharfetter–Gummel scheme). Once it exists you can run any number of analyses on the same operator.

gen = DiffusionGenerator(sys, grid)
DiffusionGenerator{2, Tuple{Reflecting, Reflecting}, Float64}(CartesianGrid{2, Float64}((121, 81), (0.02975206611570248, 0.024691358024691357), (LinRange{Float64}(-1.7851239669421488, 1.7851239669421488, 121), LinRange{Float64}(-0.9876543209876543, 0.9876543209876543, 81)), (LinRange{Float64}(-1.8, 1.8, 122), LinRange{Float64}(-1.0, 1.0, 82))), sparse([1, 2, 122, 1, 2, 3, 123, 2, 3, 4  …  9798, 9799, 9800, 9679, 9799, 9800, 9801, 9680, 9800, 9801], [1, 1, 1, 2, 2, 2, 2, 3, 3, 3  …  9799, 9799, 9799, 9800, 9800, 9800, 9800, 9801, 9801, 9801], [-320.23990123366013, 41.28125566531496, 112.45936819658093, 168.28053303707918, -355.6384835772311, 43.64345905964226, 112.45936819658093, 162.3978597153352, -352.43952881665894, 46.01647753202241  …  46.01647753202241, -352.43952881665894, 162.3978597153352, 112.45936819658093, 43.64345905964226, -355.6384835772311, 168.28053303707918, 112.45936819658093, 41.28125566531496, -320.23990123366013], 9801, 9801), (Reflecting(), Reflecting()))

Sanity-check the discrete generator: rows of a CTMC rate matrix sum to zero and the off-diagonals are non-negative.

@show size(gen.Q)
@show maximum(abs, vec(sum(gen.Q; dims = 2)))
9.947598300641403e-14

Bundled TPT solve

ReactiveTransition computes the invariant density, both committors and the reactive rate in a single call:

result = ReactiveTransition(gen, A, B)

@show reactive_rate(result)
@show probability_reactive(result)
@show probability_last_A(result)
0.5000000000001869

forward_committor, backward_committor, and stationary_distribution are constant-time accessors on a ReactiveTransition:

qplus = forward_committor(result)
qminus = backward_committor(result)
ρ = stationary_distribution(result)
9801-element Vector{Float64}:
 9.344396397653801e-10
 3.809102529032179e-9
 1.41736665893555e-8
 4.8307685145477995e-8
 1.513182076584429e-7
 4.370678876504544e-7
 1.1679003477038924e-6
 2.896361672876803e-6
 6.6873697149326855e-6
 1.4419603434469343e-5
 ⋮
 6.687369708907789e-6
 2.8963616662867228e-6
 1.1679003404160465e-6
 4.370678794737022e-7
 1.5131819830186214e-7
 4.83076741368945e-8
 1.4173653101777777e-8
 3.80908498398515e-9
 9.34414743267754e-10

Reshape from linear indexing into the grid shape for plotting:

reshape_grid(v) = reshape(v, grid.nbox)
reshape_grid (generic function with 1 method)

Forward committor

\[q^{+}(x, y)\]

is the probability that a trajectory started at $(x, y)$ reaches $B$ before $A$. It interpolates smoothly between $0$ on $A$ and $1$ on $B$, with the $0.5$-isoline running through the saddle.

x_centers = collect(grid.centers[1])
y_centers = collect(grid.centers[2])

fig = Figure(; size = (640, 360))
ax = Axis(fig[1, 1]; xlabel = "x", ylabel = "y", aspect = DataAspect(), title = "Forward committor q⁺")
hm = heatmap!(ax, x_centers, y_centers, reshape_grid(qplus); colormap = :viridis)
contour!(
    ax, x_centers, y_centers, reshape_grid(qplus); levels = [0.5], color = :white, linewidth = 2
)
Colorbar(fig[1, 2], hm)
fig
Example block output

Because the system is reversible, $q^{-} = 1 - q^{+}$ — let's verify:

@show maximum(abs.(qminus .- (1 .- qplus)))
3.581602792124272e-10

Reactive density

The probability density of being on a reactive piece (one that just left $A$ and will reach $B$ before returning) is $\rho_R = \rho \cdot q^{+} \cdot q^{-}$. It concentrates around the most likely transition channels.

ρR = reactive_density(result)

fig = Figure(; size = (640, 360))
ax = Axis(fig[1, 1]; xlabel = "x", ylabel = "y", aspect = DataAspect(), title = "Reactive density ρ·q⁺·q⁻")
hm = heatmap!(ax, x_centers, y_centers, reshape_grid(ρR); colormap = :magma)
Colorbar(fig[1, 2], hm)
fig
Example block output

Reactive current

reactive_current returns two NTuple{D} outputs: a per-axis array of face fluxes (one per axis-aligned face) and a per-axis array of node-centered fluxes (averaged onto cell centers). The node-centered version is convenient for plotting an arrow field.

J_nodes, _ = reactive_current(result)
Jx, Jy = J_nodes      # each is a 121×81 array
([1.3890365872893588e-13 1.8597554719091396e-13 … 1.8583119033009442e-13 1.3854077999795363e-13; 4.2308513715833865e-13 5.672943806826181e-13 … 5.671398219655175e-13 4.227839197646304e-13; … ; 4.227839197646287e-13 5.671398219655192e-13 … 5.67139821965492e-13 4.227839197646151e-13; 1.3854077999795022e-13 1.8583119033009783e-13 … 1.8583119033008424e-13 1.3854077999795022e-13], [-1.6662856730112306e-13 -2.786749920136583e-13 … 2.788953677583897e-13 1.669359150719205e-13; -6.848538769422468e-13 -1.1443273143231843e-12 … 1.1444636202347307e-12 6.85002475669062e-13; … ; 6.850024756690538e-13 1.144463620234743e-12 … -1.1444636202347266e-12 -6.850024756690866e-13; 1.669359150719205e-13 2.7889536775839786e-13 … -2.788953677583856e-13 -1.669359150719164e-13])

Subsample for a clean arrow plot:

step = 6
Is = 1:step:grid.nbox[1]
Js = 1:step:grid.nbox[2]
xq = [x_centers[i] for i in Is, _ in Js]
yq = [y_centers[j] for _ in Is, j in Js]
ux = [Jx[i, j] for i in Is, j in Js]
uy = [Jy[i, j] for i in Is, j in Js]
mag = sqrt.(ux .^ 2 .+ uy .^ 2)
mmax = maximum(mag)

fig = Figure(; size = (640, 360))
ax = Axis(fig[1, 1]; xlabel = "x", ylabel = "y", aspect = DataAspect(), title = "Reactive current")
heatmap!(
    ax, x_centers, y_centers, reshape_grid(ρR); colormap = :magma, alpha = 0.6,
)
arrows2d!(
    ax,
    vec(xq), vec(yq),
    vec(ux ./ mmax), vec(uy ./ mmax);
    color = :white,
    lengthscale = 0.08,
)
fig
Example block output

The reactive current points consistently from $A$ toward $B$ and is strongest near the saddle, as expected.

Mean first-passage time

The same DiffusionGenerator supports other backward-Kolmogorov analyses without rebuilding. For instance, the mean first-passage time to $B$ as a function of starting point:

τ = mean_first_passage_time(gen, B)

fig = Figure(; size = (640, 360))
ax = Axis(fig[1, 1]; xlabel = "x", ylabel = "y", aspect = DataAspect(), title = "Mean first-passage time to B")
hm = heatmap!(ax, x_centers, y_centers, reshape_grid(τ); colormap = :plasma)
Colorbar(fig[1, 2], hm)
fig
Example block output

Reusing the generator

Because the generator is decoupled from the metastable sets, we can sweep over different choices of $A$ and $B$ at the cost of a single sparse solve each:

for r in (0.2, 0.25, 0.3, 0.35)
    Ar = x -> norm(x .- SA[-1.0, 0.0]) < r
    Br = x -> norm(x .- SA[1.0, 0.0]) < r
    k = reactive_rate(ReactiveTransition(gen, Ar, Br))
    println("r = $r → k_AB = $(round(k; sigdigits = 4))")
end
r = 0.2 → k_AB = 0.004229
r = 0.25 → k_AB = 0.004265
r = 0.3 → k_AB = 0.004311
r = 0.35 → k_AB = 0.004368

Authored by O. Ameye and R. Börner


This page was generated using Literate.jl.