FlashBang.jl
Cardiac tissue electrophysiology on orthogonal, structured grids.
FlashBang solves the monodomain equation
\[\begin{aligned} \chi C_\mathrm{m} \partial_t \varphi_\mathrm{m} &= \nabla\cdot\kappa\nabla\varphi_\mathrm{m} + \chi\left(I_\mathrm{ion}(\varphi_\mathrm{m}, s, t) + I_\mathrm{stim}(x, t)\right) \\ \partial_t s &= f(\varphi_\mathrm{m}, s, t) \end{aligned}\]
by reaction-diffusion operator splitting. MatrixFreeOperators.jl supplies the spatial operators, CytoZoo.jl the cell kinetics, and OrdinaryDiffEqOperatorSplitting.jl the splitting.
Restricting the domain to orthogonal structured grids is the design, not a limitation waiting to be lifted: it is what makes the spatial operators matrix-free and what lets the same source run on a GPU. Unstructured meshes are Thunderbolt.jl's job. FlashBang's pipeline is nevertheless congruent with Thunderbolt's by convention — same names, same shapes — while depending on none of it.
The pipeline
Four objects, in order.
| Step | Type | What it says |
|---|---|---|
| 1 | MonodomainModel | the continuous problem: κ, χ, Cₘ, the cell model, the stimulus |
| 2 | ReactionDiffusionSplit | how to split it, and any spatial parameter heterogeneity |
| 3 | FiniteDifferenceDiscretization + a CartesianGrid | where to solve it |
| 4 | semidiscretize | assembles a GenericSplitFunction ready for OperatorSplittingProblem |
using FlashBang
using CytoZoo: FHNModel
using OrdinaryDiffEqLowOrderRK: Euler
grid = CartesianGrid(((0.0, 20.0),), (400,); bc = ((Neumann(), Neumann()),))
stim = TransmembraneStimulationProtocol(
(x, t) -> (t <= 2.0 && x[1] <= 1.0) ? 0.5 : 0.0;
nonzero_intervals = ((0.0, 2.0),),
)
model = MonodomainModel(; κ = 0.1, ion = FHNModel(), stim)
f = semidiscretize(ReactionDiffusionSplit(model), FiniteDifferenceDiscretization(), grid)
prob = OperatorSplittingProblem(f, create_initial_condition(f), (0.0, 400.0))
integrator = init(prob, LieTrotterGodunov((Euler(), Euler())); dt = 0.005)
while integrator.t < 400.0
step!(integrator, 0.005, true)
φ = getvariable(integrator.u, f, :φₘ)
endExamples
Run with julia --project=examples -t auto examples/<name>.jl.
| Example | What it shows |
|---|---|
cable_1d.jl | the snippet above with reporting and a figure — one FitzHugh–Nagumo cable, one propagating action potential |
spiral_2d.jl | a reentrant spiral from an S1–S2 cross-field protocol, Aliev–Panfilov kinetics on an 80×80 sheet |
niederer_benchmark.jl | the Niederer et al. (2011) N-version benchmark — a 3D anisotropic slab with ten Tusscher–Panfilov 2006 kinetics, activation times at nine reference points against an independent finite-difference solve |
The last two are ports of the corresponding MatrixFreeOperators.jl examples, which run the same physics on an adaptive block forest through a hand-rolled Godunov split. Reading a pair side by side shows what the pipeline buys and what the structured-grid restriction costs — FlashBang carries every cell at the finest spacing, where the forest carries roughly a fifth of them.
Their cell models live in examples/ rather than in CytoZoo because they exist to drive figures. examples/ten_tusscher_2006.jl doubles as its own acceptance test: run it directly to check the 19-state port against published single-cell targets.
The solution vector
State-blocked (structure of arrays), matching Thunderbolt's StateBlockedLayout:
\[u = \left[\varphi_\mathrm{m}(1{:}N);\; s_1(1{:}N);\; s_2(1{:}N);\; \ldots;\; s_{M-1}(1{:}N)\right]\]
Every state is contiguous, so the diffusion half acts on the leading block 1:N — no gather, no synchronizer object, and coalesced access on a GPU. Reach into it by name with getvariable, setvariable!, and variable_range rather than by index.
Things that will bite you
- The stimulus sign is inverted relative to a cell model. A protocol returns
Iₐₚₚin PDE form, where positive depolarizes; a CytoZoo cell model's internal stimulus is a membrane current, where negative depolarizes. SeeAbstractStimulationProtocol. Stimulation belongs to the diffusion half — the cell model's own stimulus must be off, andMonodomainModelwarns when it can tell that it is not. - The grid is cell-centered.
ncells across a lengthLput the first node atΔx/2, not at0. Comparisons against a vertex-centered reference differ atO(Δx). - Only zero-flux boundaries. Anything else raises an
ArgumentErrornaming the face. - One semidiscretization per concurrent solve. The prepared operator inside the diffusion half owns scratch buffers and is stateful and single-threaded.
- The splitting integrator has no SciML solution interface. There is no
solto hand back; step it, or sample it withSciMLIterators.TimeChoiceIterator. - Use
Float64.Float32runs, but the ArmyHeart reference measured non-finite results across 58.6% of the ToRORd parameter space in single precision.
GPU
Put the backend on the grid — CartesianGrid(…; device = CUDABackend()) — and field allocation, the prepared operator's scratch, node_coordinates, and create_initial_condition all follow. The reaction half then runs as one KernelAbstractions kernel over the nodes, which requires the cell model to be isbits.
API
FlashBang.FlashBangFlashBang.AbstractEPModelFlashBang.AbstractStimulationProtocolFlashBang.DiffusionFunctionFlashBang.FiniteDifferenceDiscretizationFlashBang.MonodomainModelFlashBang.NoStimulationProtocolFlashBang.PointwiseODEFunctionFlashBang.ReactionDiffusionSplitFlashBang.TransmembraneStimulationProtocolFlashBang.apply_stimulus!FlashBang.create_initial_conditionFlashBang.diffusion_operatorFlashBang.getvariableFlashBang.is_activeFlashBang.node_coordinatesFlashBang.num_nodesFlashBang.semidiscretizeFlashBang.setvariable!FlashBang.solution_sizeFlashBang.variable_range
FlashBang.FlashBang — Module
FlashBangCardiac tissue electrophysiology on orthogonal, structured grids.
The pipeline mirrors Thunderbolt's by convention — same names, same shapes, no dependency:
model = MonodomainModel(; κ, ion) # continuous problem
split = ReactionDiffusionSplit(model) # how to split it
f = semidiscretize(split, FiniteDifferenceDiscretization(), grid)
prob = OperatorSplittingProblem(f, create_initial_condition(f), tspan)
integrator = init(prob, LieTrotterGodunov((Euler(), Euler())); dt)Spatial operators come from MatrixFreeOperators.jl and cell kinetics from CytoZoo.jl. Restricting the domain to orthogonal structured grids is what buys the matrix-free operators; unstructured meshes are Thunderbolt's job, not this package's.
See also: MonodomainModel, semidiscretize, create_initial_condition.
FlashBang.AbstractEPModel — Type
AbstractEPModelSupertype for cardiac electrophysiology tissue models. A model is a declaration of the continuous problem — coefficients, cell model, stimulation — carrying no discretization. semidiscretize turns one into a right-hand side on a given grid.
FlashBang.AbstractStimulationProtocol — Type
AbstractStimulationProtocolSupertype for externally applied stimulation currents.
A protocol is a callable (x, t) -> Iₐₚₚ returning the applied current density at physical position x (an SVector of cell-center coordinates) and time t.
Sign convention
Iₐₚₚ is in the PDE-form convention: positive is depolarizing. It enters the monodomain equation as the χIₛₜᵢₘ term of
χCₘ∂ₜφₘ = ∇⋅κ∇φₘ + χ(Iᵢₒₙ + Iₛₜᵢₘ)so a positive value drives φₘ up.
Inside a cell model the stimulus is a membrane current and follows the negative-inward convention — CytoZoo's default Stimulus amplitude is -53 µA/µF, and its right-hand side subtracts Iₛₜᵢₘ from dv/dt. Here a stimulus is a source term on the tissue-scale PDE and is added. A protocol ported from a cell model needs its sign flipped.
Stimulation belongs to the diffusion half of the reaction-diffusion split, uniformly in every dimension. The cell model's own stimulus must therefore be off; MonodomainModel warns when it detects one that is not.
See also: NoStimulationProtocol, TransmembraneStimulationProtocol.
FlashBang.DiffusionFunction — Type
DiffusionFunction(prepared, op, grid, stim, Cₘ, xs)Diffusion half of the split: ∂ₜφₘ = ∇⋅(κ/(χCₘ))∇φₘ + Iₛₜᵢₘ/Cₘ on the contiguous voltage block of the state-blocked solution vector.
prepared is the MatrixFreeOperators PreparedOperator the right-hand side actually applies; op and grid are kept alongside it because a prepared operator cannot be rebuilt from itself — Adapt needs them to re-prepare on the target device, and tests need them to inspect what was assembled.
prepared owns halo-padded scratch fields and is stateful and single-threaded. Two solves must not share a DiffusionFunction; call semidiscretize once per solve.
Not constructed directly; semidiscretize builds it.
FlashBang.FiniteDifferenceDiscretization — Type
FiniteDifferenceDiscretization(; order = 2)Finite-difference spatial discretization descriptor.
Deliberately minimal: spacing, boundary conditions, and the device all live on the CartesianGrid, and duplicating them here is exactly the wart this replaces — ArmyHeart's discretization object validated an order and a boundary it then never read. The only thing this type carries is what the grid genuinely does not know, and the only reason it exists at all is to keep the three-argument semidiscretize(split, disc, grid) signature open for a fourth-order stencil later.
order must be 2; MatrixFreeOperators' second-derivative stencils are second order.
See also: semidiscretize.
FlashBang.MonodomainModel — Type
MonodomainModel(; κ, ion, χ = 1, Cₘ = 1, stim = NoStimulationProtocol(),
φ_symbol = :φₘ, state_symbol = :states)The monodomain equation
χCₘ∂ₜφₘ = ∇⋅κ∇φₘ + χ(Iᵢₒₙ(φₘ, s, t) + Iₛₜᵢₘ(x, t))
∂ₜs = f(φₘ, s, t)for transmembrane potential φₘ and cell-model states s.
Keyword Arguments
κ: conductivity. ANumberis isotropic; anNTuple{N}orSVector{N}is axis-aligned diagonal anisotropy, one entry per grid dimension (fibres along an axis). Spatially varying conductivity fields are not supported in v0.ion: anyCytoZoo.AbstractCellModel. SuppliesIᵢₒₙand the state kinetics; it is the reaction half of the split.χ: surface-to-volume ratio.Cₘ: membrane capacitance per unit area.stim: anAbstractStimulationProtocol. Applied in the diffusion half, as a source term, uniformly in every dimension.φ_symbol,state_symbol: names the solution helpers (getvariable,setvariable!) accept for the voltage block and for the whole non-voltage state block.state_symbolis deliberately plural: a cell model may itself name a state:s(CytoZoo'sFHNModeldoes), and a singular default would shadow it.
The constructor is keyword-only on purpose. Thunderbolt's tutorial calls MonodomainModel(Cₘ, χ, …) while its fields are ordered (χ, Cₘ, …); the two are dimensionally interchangeable in the equation above, so a swapped pair is silent. Naming them removes the trap.
Units
Nothing here fixes a unit system — κ, χ, Cₘ, the stimulus, and the cell model must simply agree. What the code does fix is that κ enters as the diffusivity κ/(χCₘ) and the stimulus as Iₛₜᵢₘ/Cₘ.
The types are generic and Float32 runs, but the ArmyHeart reference measured non-finite results across 58.6% of the ToRORd parameter space in Float32. Treat single precision as a performance experiment, not a production setting.
Examples
using CytoZoo: FHNModel
model = MonodomainModel(; κ = 1.0e-3, ion = FHNModel())
# fibres along x, three-dimensional anisotropy
model = MonodomainModel(;
κ = (0.133, 0.0176, 0.0176),
χ = 140.0,
Cₘ = 0.01,
ion = FHNModel(),
)See also: ReactionDiffusionSplit, semidiscretize.
FlashBang.NoStimulationProtocol — Type
NoStimulationProtocol()The absence of an applied stimulus. Dispatching on this type removes the stimulus evaluation from the diffusion right-hand side entirely rather than multiplying by zero.
FlashBang.PointwiseODEFunction — Type
PointwiseODEFunction(ion, xs, overrides, nnodes, nstates, φ_symbol, state_symbol)Reaction half of the split: the cell model ion evaluated independently at every node.
The solution vector is state-blocked (structure of arrays):
u = [φₘ(1:N); s₁(1:N); s₂(1:N); … ; s_{M-1}(1:N)]so node i's full state is the strided slice u[i:N:N*M] and each state is contiguous. That layout is what makes the split cheap: the diffusion half acts on φₘ alone, which is the contiguous leading block 1:N, so its dof range needs no synchronizer object and no gather/scatter. It is also the coalesced layout on a GPU — neighbouring threads read neighbouring nodes of the same state.
Fields beyond the kinetics — nnodes, nstates, and the two symbols — are metadata for the solution helpers in solution.jl; the right-hand side itself uses only the first three plus the two counts.
Not constructed directly; semidiscretize builds it.
FlashBang.ReactionDiffusionSplit — Type
ReactionDiffusionSplit(model)
ReactionDiffusionSplit(model, overrides)Annotate model for a reaction-diffusion operator split: the ionic kinetics are advanced pointwise, the diffusion operator globally. This is a declaration only — no work happens until semidiscretize.
overrides is the NamedTuple of spatial parameter overrides handed to the cell model through a CytoZoo.SpatialContext at every node. This is how spatial heterogeneity enters: cell type, pH, hypoxia, or any other parameter the cell model resolves spatially. Values may be numbers, callables (x, t) -> v, or isbits CytoZoo.SpatialFunction functors (Constant, SpatialStep, SpatialGradient) — only the last kind is GPU-safe.
With no overrides the reaction half calls the cell model with p = nothing, which is the fast non-spatial dispatch every CytoZoo model compiles down to.
Examples
using CytoZoo: SpatialStep
split = ReactionDiffusionSplit(model)
# endocardial on the left half of x, epicardial on the right
split = ReactionDiffusionSplit(model, (celltype = SpatialStep(1, 0.5, 0.0, 1.0),))FlashBang.TransmembraneStimulationProtocol — Type
TransmembraneStimulationProtocol(f; nonzero_intervals = nothing)Applied stimulus current f(x, t) -> Iₐₚₚ, in the positive-is-depolarizing convention documented on AbstractStimulationProtocol.
nonzero_intervals is an optional collection of (t₀, t₁) windows outside which f is known to vanish. When supplied, the diffusion right-hand side skips the whole stimulus evaluation for times outside every window — worth having when a single 2 ms pulse rides on a 500 ms simulation. It is stored as a Tuple, not a Vector, so the protocol stays isbits and can be captured by value in a GPU broadcast.
The protocol is isbits (and therefore GPU-safe) exactly when f is: a plain function or a small immutable functor is fine, a closure over a boxed variable is not.
Examples
# 2 ms depolarizing pulse over the left 1 mm of a cable
stim = TransmembraneStimulationProtocol(
(x, t) -> (t <= 2.0 && x[1] <= 1.0) ? 50.0 : 0.0;
nonzero_intervals = ((0.0, 2.0),),
)FlashBang.apply_stimulus! — Method
apply_stimulus!(du, stim, xs, Cₘ, t) -> duAdd Iₛₜᵢₘ(x, t)/Cₘ to the voltage derivative at every node. A NoStimulationProtocol dispatches to a no-op — the evaluation is removed, not multiplied by zero — and a protocol with declared nonzero_intervals skips the broadcast outside them.
FlashBang.create_initial_condition — Method
create_initial_condition(f::GenericSplitFunction, [T]) -> AbstractVector{T}Allocate the solution vector and fill every node with the cell model's default_initial_state, on the same device the semidiscretization lives on.
T defaults to the grid's coordinate element type (Float64 for a Float64 grid), so the initial condition matches the operator rather than silently mixing precisions.
This is the spatially uniform starting point. Impose structure on top of it with setvariable! — an initial voltage profile, a heterogeneous gating state, and so on.
default_initial_state is whatever the cell model ships. For ToRORd it is not pre-paced (dφₘ/dt ≈ +240 mV/ms at t = 0); pace a single cell first and setvariable! the settled values if the opening transient matters.
Examples
u₀ = create_initial_condition(f)
setvariable!(u₀, f, :φₘ) do x
x[1] < 1.0 ? 1.0 : 0.0
endFlashBang.diffusion_operator — Method
diffusion_operator(model::MonodomainModel, grid::CartesianGrid) -> AbstractOperatorThe matrix-free operator ∇⋅(κ/(χCₘ))∇, folded once at setup so the right-hand side carries no coefficient arithmetic.
A scalar conductivity gives κ_eff∇². An axis-aligned diagonal κ gives
κ_min∇² + Σ_d (κ_eff,d − κ_min)∂²/∂x_d²which is exact for a diagonal tensor and cheaper than N separate second derivatives: the Laplacian carries the isotropic part in one halo exchange and only the genuinely anisotropic axes pay for a correction term. Axes equal to κ_min contribute nothing and are dropped, so an isotropic tuple collapses back to the single-Laplacian form.
FlashBang.getvariable — Method
getvariable(u, f::GenericSplitFunction, name::Symbol) -> SubArrayView of one named block of the solution vector.
name is a cell-model state name, the model's φ_symbol (:φₘ by default, aliasing whichever state the cell model reports as the transmembrane potential), or its state_symbol (:states by default) for the whole non-voltage block.
Being a view, writing through it writes the solution.
Examples
φ = getvariable(u, f, :φₘ) # length N
s = getvariable(u, f, :states) # length N*(M-1), all non-voltage states
w = getvariable(u, f, :CaMKt) # length N, one named stateFlashBang.is_active — Method
is_active(protocol, t) -> BoolWhether protocol can be nonzero at time t. Conservative: a protocol without declared nonzero_intervals is always reported active. Used by the diffusion right-hand side to skip stimulus evaluation, never to decide the value.
FlashBang.node_coordinates — Method
node_coordinates(grid::CartesianGrid{N}) -> AbstractVector{SVector{N}}
node_coordinates(f::GenericSplitFunction) -> AbstractVector{SVector{N}}Cell-center coordinates of every interior cell, flattened in the same order MatrixFreeOperators' flatten uses, on the grid's device. Given a semidiscretization it returns the very vector the right-hand sides use, so plotting φₘ against position needs no second construction.
FlashBang.num_nodes — Method
num_nodes(f::GenericSplitFunction) -> IntNumber of spatial nodes N in a semidiscretized problem.
FlashBang.semidiscretize — Method
semidiscretize(split::ReactionDiffusionSplit, disc::FiniteDifferenceDiscretization,
grid::CartesianGrid) -> GenericSplitFunctionTurn a continuous ReactionDiffusionSplit into a right-hand side on grid, ready for OperatorSplittingProblem.
The result is a two-operator GenericSplitFunction:
| operator | dof range | right-hand side |
|---|---|---|
| 1 | 1:N | DiffusionFunction — ∇⋅(κ/(χCₘ))∇φₘ + Iₛₜᵢₘ/Cₘ |
| 2 | 1:N*M | PointwiseODEFunction — the cell model at every node |
with N nodes and M states. No synchronizer objects are needed: under the state-blocked layout the voltage block the diffusion half acts on is the leading contiguous range, so the two dof ranges overlap exactly where the physics couples them.
Only zero-flux (homogeneous Neumann) boundaries are supported in v0; anything else raises an ArgumentError naming the offending face.
Examples
using CytoZoo: FHNModel
grid = CartesianGrid(((0.0, 20.0),), (200,); bc = ((Neumann(), Neumann()),))
model = MonodomainModel(; κ = 1.0e-3, ion = FHNModel())
f = semidiscretize(ReactionDiffusionSplit(model), FiniteDifferenceDiscretization(), grid)
u₀ = create_initial_condition(f)
prob = OperatorSplittingProblem(f, u₀, (0.0, 100.0))See also: create_initial_condition, solution_size.
FlashBang.setvariable! — Method
setvariable!(fun, u, f::GenericSplitFunction, name::Symbol) -> u
setvariable!(u, f::GenericSplitFunction, name::Symbol, value::Number) -> uFill one named state block by evaluating fun(x) at every node's cell-center coordinate, or with a constant value.
Reads naturally with do-block syntax, since the function is the first argument.
name must name a single state (a cell-model state name or the model's φ_symbol); the whole-state-block symbol is rejected, because one function of position cannot say what to put in each of several states.
Examples
setvariable!(u, f, :φₘ) do x
cos(π * x[1] / L)
end
setvariable!(u, f, :φₘ, 0.0)FlashBang.solution_size — Method
solution_size(f::GenericSplitFunction) -> IntLength of the solution vector: N * M for N nodes and M cell-model states per node.
FlashBang.variable_range — Method
variable_range(f::GenericSplitFunction, name::Symbol) -> UnitRange{Int}Index range of a named block in the solution vector — the raw form of getvariable, useful for building views of an integrator's u without holding one.