Command line interface#
fenicsx-beat ships a beat command line tool that runs a tissue-level monodomain simulation
(and its postprocessing) purely from a config.toml file, so routine runs — including batch/array
jobs on an HPC cluster — don’t need a Python script. This page covers installation, the workflow,
the commands and override mechanisms, the output folder layout, and the built-in templates. See
also Running on a cluster and the generated configuration
reference.
Note
The CLI drives one spatially homogeneous-or-layered cell model per simulation from one .ode file
(shared parameters, or per-region overrides via [cell.regions.<name>] — see the
config reference). Per-region different .ode files, and changing
ODE parameters at specific times during a run (parameter_schedule), are future work; the
demos that need them (e.g. pvc.py,
pace_train.py) are not fully reproducible via config.toml yet — see the
templates table below for the exact deviations.
Installation#
The CLI’s dependencies (pydantic, pydantic-pint, cardiac-geometriesx, gotranx,
io4dolfinx, …) are bundled in the cli extra:
pip install "fenicsx-beat[cli]"
Visualization from beat post additionally needs pyvista (e.g. via pip install "fenicsx-beat[docs]", or just pip install pyvista); it’s optional and silently skipped with a
log warning if not installed.
Warning
If your config uses solver.pde.type = "irksome" / solver.ode.type = "irksome", install the
irksome extra too – but not straight from PyPI: irksome[dolfinx] on PyPI ignores
backend="dolfinx" and imports firedrake regardless. Install the git+ version instead:
pip install "irksome[dolfinx] @ git+https://github.com/firedrakeproject/Irksome.git"
Without it, any config that needs it fails fast with an install hint before doing any mesh/MPI work; it does not fail silently.
Quick start#
beat init case/config.toml --template slab # write a starter config (+ its .ode file)
beat validate-config case/config.toml # parse + validate, print the resolved config
beat geometry case/config.toml # generate/cache the mesh, then stop
beat run case/config.toml # run the simulation
beat post case/config.toml # activation times, VTX, PNG/GIF
beat ecg case/config.toml # pseudo-ECG at [postprocess.points]
beat init --template NAME copies NAME’s config.toml (and any companion files, e.g. its
.ode model) from the package’s built-in templates – see the templates table for
the full list, one per row. Without --template, it defaults to slab, a thin box of tissue
stimulated at one end (the simplest possible geometry, good for checking the CLI wiring itself)
with geometry.type = "box_slab" – a structured tetrahedral box built directly by beat in
memory on every run (cheap; nothing is written to disk), no gmsh/mesh-generation tool of your own
needed. beat init --template lv_endocardial/biv_endocardial/ukb_atlas instead generate a real
gmsh mesh (geometry.type = "slab"/"lv_ellipsoid"/"biv_ellipsoid"/"ukb", via
cardiac-geometriesx, and ukb-atlas for the last one), which can take from seconds to minutes
depending on resolution. beat geometry (also run implicitly by beat run, beat post and beat ecg) generates such a mesh once and reuses it on every later invocation: it is cached in its own
subfolder geometry.folder/<hash>/, keyed on a hash of the [geometry] section (everything except
folder and unit). Changing a geometry parameter therefore creates a new subfolder next to the
old one rather than replacing it, and beat never deletes geometry.folder itself or anything in
it that it didn’t create – geometry.folder = "." (the config’s own directory) is safe. Delete
stale <hash>/ subfolders by hand when you no longer need them. Run beat geometry on its own
first if you just want to warm that cache (e.g. on a cluster login node, see Running on a
cluster); it logs the subfolder it used.
geometry.type = "folder" is the other case: point folder at a mesh you (or geox, or another
cardiac-geometriesx-compatible tool) already generated externally, with a markers.json describing
its facet/cell tags – beat then only loads it, via
cardiac_geometries.geometry.Geometry.from_folder.
A point in [postprocess.points] used only for beat ecg doesn’t need to lie inside the mesh –
e.g. a far-field “electrode” position – but beat post can’t report an activation time there and
records null for it (with a log warning), which is distinct from the -1.0 it uses for a point
that’s inside the mesh but simply hadn’t activated by the end of the recorded run. Also note that
postprocess.activation_threshold is in the cell model’s own units for v: a real ionic model’s
v is in mV (so a physiological threshold is around 0.0), while a normalized two-variable model
like Mitchell-Schaeffer (used by a couple of templates, e.g. irksome_model_gotranx) has v
roughly in [0, 1], so its threshold should be something like 0.5 instead.
Commands#
validate-config, geometry, run, ecg and post all take a config path and accept repeatable
--set KEY=VALUE overrides. init takes an optional config path (default config.toml) but no
--set (there’s no existing config to override yet). version takes neither – it only prints
version numbers. The global -v/--verbose, --log-all-cpus and --dry-run flags are accepted
by every subcommand, either before or after the subcommand name: beat -v run config.toml and
beat run config.toml -v are equivalent.
Command |
Behaviour |
|---|---|
|
Write a starter config from a template (default: |
|
Parse + validate + print the resolved config. Builds nothing (no mesh, no MPI-collective work). |
|
Generate (or load) the geometry and stop. For a generated type the mesh is cached in |
|
Run the simulation. |
|
Activation times, VTX conversion and visualizations from |
|
Pseudo-ECG at |
|
Versions of beat, dolfinx, mpi4py, petsc4py. |
beat --dry-run <command> ... (e.g. beat --dry-run run config.toml --set 'solver.dt="0.02 ms"')
logs the raw, argparse-parsed arguments and exits – it does not load the TOML file or resolve
--set/env overrides, so it can’t catch a config or override mistake, only confirm which flags
argparse itself accepted. To actually check that a config (with its overrides applied) parses and
validates, use beat validate-config instead, which does load and resolve everything and prints
the fully resolved result.
Exit codes#
0success.1a configuration/validation error (ConfigError), a command-line usage error (unknown flag, missing argument), or a missingcliextra (the error names thepip install "fenicsx-beat[cli]"fix).2a runtime failure: a solver failure (e.g. a non-finite transmembrane potential), or any other unexpected error (mesh generation, I/O, …). It is logged as a one-line error; run with-vfor the full traceback.
beat run builds the whole simulation – cell model, geometry, markers, stimuli, solvers – before
it touches the output folder, so a failure during that setup leaves the output folder exactly as it
was (no run.json, and with --overwrite the previous results are not deleted). Only once the
run has started does a failure set run.json: status = "failed" (with the error message). Not
every ConfigError is caught at the same point, though all of them are caught before the actual
PDE/ODE solve loop (except a restart checkpoint that doesn’t match, which is found when it’s
loaded):
Bad TOML, wrong units, an unknown TOML key or geometry/stimulus/solver
type, a missingcell.ode_file, an unknowncell.scheme, or acell.parameters/cell.regions.*.parametersname the generated cell model doesn’t have – all reported before any mesh is built or loaded (the cell-model code is generated fromcell.ode_fileitself and doesn’t need a mesh).Marker names (
[[stimulus]],cell.layers) and fiber availability (fibers = "from_geometry"needing anf0from the geometry) can only be checked once the mesh has actually been built or loaded, since they are properties of that mesh – so these are reported early inbeat run, right after the geometry step, but not before it.
For a generated geometry (slab/lv_ellipsoid/biv_ellipsoid/ukb), building the mesh itself
can be by far the most expensive part of that early setup – run beat validate-config (parse-time
checks only, no mesh) and then beat geometry (builds/caches the mesh once, cheaply reused by every
later command) before submitting a long or queued job, so that if a marker/fiber-config mistake
is still there, beat run fails within seconds against the already-cached mesh, rather than after
regenerating it inside the timed job.
Overrides#
Config values can come from four places, in order of increasing precedence:
The TOML file itself.
Environment variables:
BEAT_<SECTION>__<KEY>, e.g.BEAT_SOLVER__DT="0.02 ms". TheBEAT_prefix and__(double underscore) nesting delimiter are fixed, but the section/key names themselves are matched case-insensitively against the config schema, soBEAT_EP__C_MandBEAT_ep__c_mboth resolve toep.C_m(whose field name is mixed-case).--set dotted.key=value(repeatable).valueis parsed as a TOML literal, the same way it would appear on the right-hand side of akey = valueline in the file:--set 'solver.dt="0.02 ms"'(a quantity is still a quoted string)--set ep.conductivity.sigma_il=0.2--set 'postprocess.points.P1=[0,0,0]'List elements are addressed by index:
--set 'stimulus.0.start="10 ms"'sets the first[[stimulus]]table’sstart.Unknown keys are an error (
ConfigError), never silently dropped.
Dedicated flags on
beat run/beat post/beat ecg:--output-folderand (onbeat run)--petsc-options.
--output-folder overrides output.folder and, unlike every path inside the config file,
resolves against the current working directory rather than the config file’s directory –
handy for array jobs launched from one shared directory (see
Running on a cluster).
--petsc-options "-ksp_type cg -pc_type hypre" merges into solver.petsc_options (parsed with
shlex, each -key value pair; a bare -flag becomes True). Negative numbers are accepted as
option values, not mistaken for the next flag, e.g. --petsc-options "-ksp_rtol -1e-6".
Relative paths written inside the config file (cell.ode_file, geometry.folder for
type = "folder", output.folder) resolve against the config file’s own directory, not the
current working directory – so beat run /abs/path/to/config.toml from anywhere still finds
ode_file/the mesh next to the config, which matters once a cluster job script cds elsewhere
before running srun beat run ....
Whatever the config resolves to after all four layers, it’s written out in full to
output/config.resolved.toml at the start of every beat run – the single source of truth for
“what actually ran”.
pydantic-settings’ CliSettingsSource is deliberately not used here (poor support for lists of
discriminated unions, and an unwieldy auto-generated --help).
Output folder layout#
beat run writes into output.folder (default output, relative to the config file):
output/
config.resolved.toml # the fully resolved configuration of the (latest) run
run.json # versions, n_ranks, start/end wall time, status: running/finished/failed
output.log # log file (output_all_cpus.log too when running on >1 rank)
cell_model_<hash>.py # gotranx-generated code for cell.ode_file (hash: file contents + scheme); kept by --overwrite
init_states/<region>_<hash>.npy # cached single-cell steady state (only if cell.steady_state is set); kept by --overwrite
results.bp # io4dolfinx: v (+ output.fields), every output.save_every
restart.bp # io4dolfinx: v and every ODE state, every output.checkpoint_every and at the end
restart.json # the latest complete checkpoint's time/step and a hash of the run's physics
performance.json # timing summary (only with output.performance = true)
post/ # written by `beat post` / `beat ecg`, see below
beat run never writes VTX itself – only the io4dolfinx files above. beat post config.toml
reads results.bp (which can happen later, on any number of ranks, independent of how many ranks
the run itself used) and writes into post/. The config given to beat post/beat ecg must
describe the same physics as the run that wrote results.bp – the same check as for --restart
(below), against the hash in restart.json, or, if the run stopped before writing its first
checkpoint, against config.resolved.toml. Only [output], [postprocess] and the run length may
differ; anything else (e.g. an edited geometry.dx) is refused with a ConfigError naming
config.resolved.toml to compare with, rather than crashing or silently producing wrong results.
output/post/
v.bp # results.bp converted to VTX (ParaView), if postprocess.vtx (default true)
activation_time.bp # full-mesh local activation-time map (VTX)
activation_times.json # activation times at postprocess.points (null if a point is outside the mesh)
voltage_final.png # snapshot of v at the last saved time (needs pyvista)
activation_time_map.png # snapshot of the activation-time map (needs pyvista)
voltage.gif # animation of v(t) over the whole run, if postprocess.make_gif (needs pyvista)
beat ecg config.toml additionally writes post/ecg.csv (and post/ecg.png, if matplotlib is
installed) with the recovered extracellular potential at postprocess.points.
On more than one rank, the PNG/GIF previews show only rank 0’s partition of the mesh (with a log
warning); the VTX files are always complete. Run beat post on a single rank for full previews.
--overwrite and --restart#
Re-running into a non-empty output folder (e.g. an array-job index collision, or simply rerunning
by hand) is refused by default – beat never silently deletes anything:
--overwritedeletes only the artifactsbeatitself wrote (everything listed above, pluspost/, except the content-hashedcell_model_<hash>.py/init_states/caches, which stay valid and are reused) and starts fresh. It does so only after the new config has been fully validated (the simulation is built first), so a mistake in the config never costs the previous results. Anything else in that folder – notably your ownconfig.toml/.odefiles, if the output folder happens to be the config’s own directory – is left untouched.--restartcontinues fromrestart.json/restart.bpinstead. It refuses if the run’s physics has changed since the checkpoint was written: the check is a hash of the whole resolved config excluding the run lengthsolver.end_time/solver.num_beats/solver.BCL(so extending the simulated time, or switching fromend_timetonum_beats/BCL, is fine;BCLonly sets the run length,num_beats x BCL– it doesn’t pace anything, use a stimulusperiodfor that, andbeat runwarns if no stimulus has one) and excluding[output]and[postprocess]entirely (changesave_every,checkpoint_every,performance, any[postprocess]setting, freely across a restart).geometry.folderonly matters forgeometry.type = "folder"(where it is the mesh being simulated); for every generated geometry type it’s just a cache location and is excluded like any other non-physics path. Restarting on a different number of MPI ranks than the original run is allowed. If the run stopped before its first checkpoint there is norestart.jsonyet, and both--restartand a plain rerun are refused with a message saying so – use--overwriteto start over.A restart never rewrites a
results.bptimestamp that’s already there: io4dolfinx appends a duplicate write at an existing timestamp, and its reader returns the first match, so re-writing the same time would be silently ignored on read anyway – the runner simply skips it.
Units#
Every physical quantity in the config is a pint string, "<value> <unit>" (e.g. dt = "0.05 ms",
sigma_el = "0.62 S/m") – a bare number is rejected with a validation error naming the field.
The one field that needs a moment’s thought is stimulus.amplitude: it’s a current density, but
which dimension depends on the stimulus type and, for a marker, the marker’s own dimension –
not the mesh’s topological dimension. Precisely (src/beat/cli/stimulus.py::_scaled_amplitude,
mirroring beat.stimulation.compute_effective_dim):
effective_dim = entity_dim + (3 - mesh.topology.dim), where entity_dim is the dimension of the
marked entity (facet or cell) for a marker stimulus, and the mesh’s own topological dimension for
box/random_endocardial. A facet’s entity_dim is always mesh.topology.dim - 1, so it always
cancels out to effective_dim = 2 regardless of the mesh dimension; a cell marker’s entity_dim
equals mesh.topology.dim, always cancelling to effective_dim = 3. In short:
A
markerstimulus on a facet marker is always areal,uA/cm**2, whatever the mesh’s own dimension (1D, 2D or 3D).A
markerstimulus on a cell marker, aboxstimulus, and arandom_endocardialstimulus are all always volumetric,uA/cm**3, likewise regardless of the mesh’s own dimension.
A current per length (uA/cm) is therefore never valid. Get the dimension wrong and the config is
rejected before any solve, naming the mismatch: at parse time (beat validate-config) for uA/cm
and for a box/random_endocardial amplitude that isn’t per volume; once the mesh is loaded for
a marker stimulus, whose facet-or-cell dimension is a property of the mesh.
Templates#
Each template under src/beat/cli/templates/<name>/ is a runnable config.toml (plus any
companion .ode file) reproducing one of the tissue-level demos as closely as the CLI schema
allows; beat init --template NAME copies it (and its companions) into place. Where a demo does
something the schema can’t express yet, the template’s header comment documents the deviation –
summarized here:
Template |
Demo |
Known deviation |
|---|---|---|
|
Cable partitioned into endo/mid/epi celltypes by raw x-position isn’t expressible via |
|
|
– |
|
|
Rescaled from a dimensionless unit square to 100x100 mm; conductivity from the |
|
|
The demo has no ODE/cell model at all (pure diffusion); the CLI always couples an ODE step, so this template ships a trivial |
|
|
The demo’s per-DOF g_Kr/g_Ks heterogeneity (right half of the cable) has no marker to key |
|
|
The demo switches its stimulus off at runtime (a parameter schedule, future work); this template uses a fixed PDE pulse train for the whole run instead. |
|
|
– |
|
|
– |
|
|
Needs network access on first run (atlas download, cached afterwards with the mesh in |
|
|
Uses |
|
|
Conductivity built from |
examples/cli/README.md in the repository points at the same templates for anyone browsing the
source tree directly rather than an installed package.
See also#
Configuration reference – every section/field, generated from the pydantic models, so it can’t drift from the code.
Running on a cluster – SLURM array jobs, wall-time/
--restartpatterns, and solver advice for large meshes.