Command line interface#
fenicsx-pulse ships a pulse command line tool that runs a static or quasi-static/dynamic
cardiac mechanics 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’s v1 scope is static/quasi-static loading and prescribed-activation time stepping
([active] type = "active_stress" driven by a [[load]] target = "activation" profile, or
by an injected active model). Circulation coupling, prestress/unloading, gotranx/crossbridge
activation and non-zero Dirichlet values are not expressible via config.toml yet — see the
templates table below for the exact deviation from each demo it transcribes.
Installation#
The CLI’s dependencies (pydantic, pydantic-pint, cardiac-geometriesx, io4dolfinx, …) are
bundled in the cli extra:
pip install "fenicsx-pulse[cli]"
Two groups of templates need extra, optional packages, each checked (with an install hint) only when a config actually needs it:
A
bestel_pressure/bestel_activationload profile (bestel_lv,bestel_biv,cylinder_bestel) needspip install circulation scipy.A
biv_ellipsoid/ukbgeometry needsfenicsx-ldrbfor its fibres (biv_ellipsoid,ukb_bcs);ukbadditionally needsukb-atlasto fetch the atlas mesh itself.
Quick start#
pulse init case/config.toml --template lv_ellipsoid # write a starter config
pulse validate-config case/config.toml # parse + validate, print the resolved config
pulse geometry case/config.toml # generate/cache the mesh, then stop
pulse run case/config.toml # run the simulation
pulse post case/config.toml # VTX, derived fields, plots, point traces
pulse init --template NAME copies NAME’s config.toml (and any companion files) from the
package’s built-in templates into place — see the templates table for the full
list, one per row. Without --template, it defaults to lv_ellipsoid.
pulse geometry (also run implicitly by pulse run and pulse post) generates a mesh once and
reuses it on every later invocation for a generated geometry type (lv_ellipsoid,
biv_ellipsoid, cylinder, ukb): it is cached in its own subfolder geometry.folder/<hash>/,
keyed on a hash of the [geometry] section (everything except folder, unit, scale and
quadrature_degree). Changing a geometry parameter therefore creates a new subfolder next to
the old one rather than replacing it, and pulse never deletes geometry.folder itself or
anything in it that it didn’t create — geometry.folder = "." (the config’s own directory) is
safe. Run pulse 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. The mesh
(and its fibres) is generated in serial on rank 0, whatever the number of ranks, and then read and
distributed by every rank — cardiac-geometriesx’s generators are not reliably parallel-safe.
geometry.type = "folder" points folder at a mesh you (or another cardiac-geometriesx
-compatible tool) already generated externally; pulse only loads it, via
cardiac_geometries.geometry.Geometry.from_folder. geometry.type = "box" is the other
dependency-free case: a built-in dolfinx.mesh.create_box, facets tagged X0, X1, Y0, Y1, Z0, Z1,
nothing written to disk.
Commands#
validate-config, geometry, run 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: pulse -v run config.toml and
pulse 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. |
|
VTX, derived fields, plots and point traces from |
|
Versions of fenicsx-pulse, dolfinx, mpi4py, petsc4py. |
pulse --dry-run <command> ... (e.g. pulse --dry-run run config.toml --set 'time.dt="1 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 pulse 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-pulse[cli]"fix).2a runtime failure: a solver failure (Newton did not converge withinsolver.max_halvings), or any other unexpected error (mesh generation, I/O, …). It is logged as a one-line error; run with-vfor the full traceback.
pulse run builds the whole simulation — geometry, material, boundary conditions, loads, problem
— 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). A marker typo ([[load]], [bcs]) can only be checked once the mesh has actually been
built or loaded, since marker names are a property of that mesh — so it’s reported early in
pulse run, right after the geometry step, but not before it, and before --overwrite deletes
anything.
For a generated geometry (lv_ellipsoid/biv_ellipsoid/cylinder/ukb), building the mesh
itself can be by far the most expensive part of that early setup — run pulse validate-config
(parse-time checks only, no mesh) and then pulse geometry (builds/caches the mesh once, cheaply
reused by every later command) before submitting a long or queued job, so that if a marker mistake
is still there, pulse 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:
PULSE_<SECTION>__<KEY>, e.g.PULSE_TIME__DT="1 ms". ThePULSE_prefix and__(double underscore) nesting delimiter are fixed, but the section/key names themselves are matched case-insensitively against the config schema.--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 'time.dt="1 ms"'(a quantity is still a quoted string)--set material.mu="20 kPa"--set 'postprocess.points.apex=[0,0,-0.097]'List elements are addressed by index:
--set 'load.0.profile.to_value="20 kPa"'sets the first[[load]]table’sprofile.to_value.Unknown keys are an error (
ConfigError), never silently dropped.
Dedicated flags on
pulse run/pulse post:--output-folderand (onpulse 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 (geometry.folder for type = "folder",
output.folder, a [load.profile] file) resolve against the config file’s own directory, not
the current working directory — so pulse run /abs/path/to/config.toml from anywhere still finds
its companion files, which matters once a cluster job script cds elsewhere before running srun pulse 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 pulse run — the single source of truth for
“what actually ran”.
Config file#
Every section is a TOML table; every physical quantity is a string with units (see Units). The full field-by-field listing, generated from the pydantic models, is the configuration reference; this section shows one short snippet per section, in the order they’re resolved.
[geometry]#
[geometry]
type = "lv_ellipsoid" # folder | box | lv_ellipsoid | biv_ellipsoid | cylinder | ukb
unit = "mm" # length unit of the mesh coordinates
quadrature_degree = 4
fiber_space = "P_2"
[geometry.fibers]
type = "from_geometry" # from_geometry | axis | none
[material]#
[material]
type = "holzapfel_ogden" # holzapfel_ogden | guccione | neo_hookean | usyk | saint_venant_kirchhoff
preset = "transversely_isotropic"
[[material.region]] overrides one or more parameters on a cell marker (or an integer cell tag):
[[material.region]]
marker = "10"
a = "22.8 kPa"
[active]#
[active] # omitted ⇒ passive
type = "active_stress" # passive | active_stress
eta = 0.3
[compressibility]#
[compressibility]
type = "incompressible" # incompressible | compressible | compressible2 | compressible3
[viscoelasticity]#
[viscoelasticity]
type = "none" # none | viscous
viscous (like a Robin condition with damping = true) only acts on velocities, so it is
rejected unless problem.type = "dynamic".
[bcs]#
[bcs]
base_bc = "fixed" # fixed | free
base_marker = "BASE"
[[bcs.robin]]
marker = "EPI"
value = "1e3 Pa/m"
[[load]]#
A pressure load is the Neumann boundary condition on marker; an activation load drives Ta in
an active_stress model. Both are a profile evaluated at the current time:
[[load]]
target = "pressure" # pressure | activation
marker = "ENDO" # pressure only
[load.profile]
type = "ramp" # constant | ramp | table | bestel_pressure | bestel_activation
start = "0 s"
end = "1 s"
to_value = "15 kPa"
[time]#
[time]
start_time = "0 s"
end_time = "2 s"
num_steps = 20 # exactly one of dt / num_steps
There is one time axis for every run: pseudo-time for a static problem (each step is an
equilibrium solve, not physical time), physical time for a dynamic one. Phases (e.g. “ramp the
pressure, then ramp the activation”) are expressed as separate loads whose ramp/table windows
occupy different parts of that one axis — see the lv_ellipsoid template above. num_steps and
dt both describe the same axis; whichever is given, the other is derived
(dt = (end_time - start_time) / num_steps). With dt given, end_time - start_time must be an
integer multiple of it, and output.save_every/checkpoint_every (when non-zero) must be integer
multiples of the effective dt (all to a 1e-9 relative tolerance) — otherwise the config is
rejected instead of silently rounding the run length or the output grid.
[problem]#
[problem]
type = "static" # static | dynamic
u_space = "P_2"
p_space = "P_1"
[solver]#
[solver]
max_halvings = 4
[output]#
[output]
folder = "output"
save_every = "10 ms"
checkpoint_every = "0 s"
performance = false
[postprocess]#
[postprocess]
vtx = true
fields = ["fiber_stress", "fiber_strain"]
points = { apex = [0.0, 0.0, -0.097] }
Outputs#
pulse 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)
results.bp # io4dolfinx: u (+ p if incompressible), every output.save_every
loads.csv # t [s], every load [Pa], volume_<marker> [m^3] for cavity markers
restart.bp # io4dolfinx: the mechanics_* state functions
restart.json # the latest complete checkpoint's time/step and a physics hash
performance.json # timing summary (only with output.performance = true)
post/ # written by `pulse post`, see below
loads.csv has one row per saved time: t in seconds, every [[load]]’s value in pascal
(pressure_<marker> or activation), and volume_<marker> in cubic metres for every cavity
marker (ENDO, LV, RV) that carries a pressure load — summed over MPI ranks. With
[circulation] the columns are the coupling’s instead, see Circulation coupling.
pulse run never writes VTX itself — only the io4dolfinx files above. pulse 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 pulse post 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], [solver] and the run length may
differ; anything else (e.g. an edited geometry.nx) is refused with a ConfigError naming
config.resolved.toml to compare with, rather than crashing or silently producing wrong results.
output/post/
displacement.bp # results.bp converted to VTX (ParaView), if postprocess.vtx (default true)
fields.bp # derived DG1 fields (fiber_stress, fiber_strain), if postprocess.fields
points.csv # u at postprocess.points / vertex_tags, every saved time
loads.png # loads [kPa] and cavity volumes [mL] vs t, if matplotlib is installed
pv_loop_<marker>.png # pressure-volume loop per cavity marker with both a pressure and a volume
On more than one rank, the plots are produced on rank 0 only, contained so a plotting failure never deadlocks the other ranks (a warning is logged and the remaining postprocessing steps still run); the VTX files are always complete.
--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 — pulse never silently deletes anything:
--overwritedeletes only the artifactspulseitself wrote (everything listed above, pluspost/) 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, 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 length (time.end_time/num_steps— the effectivedtandstart_timeare hashed instead, so changingnum_stepswithout changingdtis still caught) and excluding[output],[postprocess]and[solver]entirely (max_halvingsandpetsc_optionsonly change how a step is solved, not the physics).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.
Newton failures#
Each step calls problem.solve(). If Newton fails to converge, the step is halved and retried
(_advance(t, dt/2, level+1) twice), recursively, up to solver.max_halvings levels deep (default
4). If the deepest halving still fails, pulse run raises SolverFailure (exit code 2) and the
problem — every state function, old-state function, and for a dynamic problem v_old/a_old and
the dt Constant — is restored to exactly where it was before the failed step() call, even
when some of the halves had already converged. The last checkpoint on disk is therefore always
intact and consistent. Since [solver] is not part of the physics hash, you may continue with
--restart and a larger solver.max_halvings (e.g. --set solver.max_halvings=8) or different
--petsc-options. A smaller time.dt or a changed load (e.g. a gentler ramp) changes the physics:
the restart is refused, so rerun with --overwrite (or into a new output folder) instead.
Performance#
Set [output] performance = true to have pulse run build a
pulse.PerformanceMonitor(log_frequency=output.log_every) and hand it to the simulation. Every
log_every steps it logs a line with the Newton and KSP iteration counts of the latest step, the
number of halvings so far, and the accumulated wall-clock time (rank 0’s) spent in step,
newton_solve, update_fields, save, checkpoint, volumes and loads. At the end of the run it logs a
summary table and writes performance.json (an --overwrite artifact) with the same totals.
From Python, pass the same monitor to build_simulation/StaticProblem/DynamicProblem
directly:
from pulse import PerformanceMonitor
from pulse.cli.overrides import load_config
from pulse.cli.runner import build_simulation
conf = load_config("config.toml")
monitor = PerformanceMonitor(log_frequency=10)
sim = build_simulation(conf, monitor=monitor)
Circulation coupling#
By default each cavity marker carries a pressure [[load]]. With [circulation] the cavities
are instead coupled to a 0D model of the circulation: the 3D mechanics takes the place of the
ventricles, and the two exchange cavity volumes and pressures. The three styles differ in how the
0D side is advanced:
Style |
How the 0D side is advanced |
Template |
Pick it when |
|---|---|---|---|
|
|
|
you want the classic phase-driven cycle without a closed loop |
|
any 0D model from an |
|
the circulation is an external solver, or you want to swap it freely |
|
the |
|
the circuit is a |
pulse init --template complete_cycle (or split_biv, monolithic_lv, monolithic_biv) writes a
runnable config for each. The coupling owns the solve of each step; with [circulation] the
cavity markers must not also have a pressure [[load]]. Some rules hold for every style:
geometry.unit must be "m" (the 0D volumes are converted to cubic metres), split needs
problem.type = "static", and cycle needs time.start_time = 0. The three styles are covered
in the Python API by pulse.coupling, see Coupling from Python.
type = "cycle"#
One [[circulation.cavity]] per cavity marker, with the five-phase timing, the pressures that
define the phases and a three-element Windkessel. Quantities are pint strings:
[circulation]
type = "cycle"
[[circulation.cavity]]
marker = "LV"
period = "0.8 s"
t_zero = "0.05 s"
t_end_diastole = "0.12 s"
preload_pressure = "500 Pa"
p_end_diastole = "1000 Pa"
p_fill = "500 Pa"
filling_rate = "0.104 mL/ms"
[circulation.cavity.windkessel]
p_init = "9000 Pa"
compliance = "1.5 mL/mmHg"
resistance = "1.1 mmHg*s/mL"
characteristic_impedance = "0.03 mmHg*s/mL"
The Bestel activation profile of this template needs period whenever peak is set. loads.csv
gains, for each cavity <marker>: phase_<marker> (the phase index),
volume_<marker> and pressure_<marker> (m^3 and Pa), Pc_<marker> (the Windkessel’s
compliance pressure) and Q_<marker> (the outflow). solver.preconditioner_lag sets the
steady-state snes_lag_preconditioner for this style.
type = "split"#
The 0D model is an .ode file (gotranx), advanced by forward Euler between two mechanics
solves. The mechanics is solved at the new volume, and the coupling reports the pressure back:
[circulation]
type = "split"
ode_file = "circulation:regazzoni2020.ode"
drop_components = ["timing", "LV", "RV"]
record = ["p_LA", "p_RA", "Q_MV", "Q_AV", "Q_TV", "Q_PV"]
[circulation.parameters]
RR = 1.0
tC_eff_LA = 0.9
tR_eff_LA = 0.07
tC_eff_RA = 0.9
tR_eff_RA = 0.07
[circulation.initial_state]
V_LA = 52.3098
V_RA = 52.0998
p_VEN_SYS = 21.5388
p_VEN_PUL = 9.0024
p_AR_PUL = 11.727
[[circulation.chamber]]
marker = "LV"
volume_state = "V_LV"
pressure_missing = "p_LV"
[[circulation.chamber]]
marker = "RV"
volume_state = "V_RV"
pressure_missing = "p_RV"
[circulation.inputs]
beat_phase = { type = "phase", period = "1 s" }
loads.csv gains volume_<marker> and pressure_<marker> for each chamber (m^3 and Pa),
circ_<state> for every state of the reduced .ode and circ_<name> for each entry of record,
the latter in the .ode’s own units (mL, mmHg, mL/s).
The cavity volume constraint rows are in mL, so with the default snes_atol = 1e-6 the
mesh’s cavity volume matches the 0D volume to within 1e-6 mL.
type = "monolithic"#
The same .ode keys, plus scheme ("backward_euler", the default, or "bdf2"); the 0D
states are unknowns of the Newton system of the displacement, so every step is one coupled solve.
"bdf2" takes a step as backward Euler whenever its dt differs from the last converged step’s
(the first step, the halves of a halved step, and the full step after them), since BDF2’s
coefficients hold only for equally long steps.
A [prestress] section provides the end-diastolic pressures the 0D initial state is consistent with:
[circulation]
type = "monolithic"
ode_file = "circulation:regazzoni2020.ode"
drop_components = ["timing", "LV"]
scheme = "backward_euler"
record = ["p_LA", "Q_MV", "Q_AV"]
[circulation.parameters]
RR = 1.0
tC_eff_LA = 0.9
tR_eff_LA = 0.07
tC_eff_RA = 0.9
tR_eff_RA = 0.07
[circulation.initial_state]
V_LA = 80.7094
V_RA = 66.7727
V_RV = 181.218
p_AR_SYS = 76.4746
p_VEN_SYS = 32.4307
p_AR_PUL = 20.2648
p_VEN_PUL = 17.2985
Q_AR_SYS = 60.484
Q_VEN_SYS = 80.196
Q_AR_PUL = 72.4093
Q_VEN_PUL = -392.833
[[circulation.chamber]]
marker = "ENDO"
volume_state = "V_LV"
pressure_missing = "p_LV"
[circulation.inputs]
beat_phase = { type = "phase", period = "1 s" }
loads.csv gains the same columns as for split.
The .ode rules (split and monolithic)#
ode_fileis a path relative to the config file, or"<package>:<file>"for a file inside an installed package: thesplit_bivandmonolithic_*templates use"circulation:regazzoni2020.ode", thecirculationpackage’s own copy of the model. Its contents (not its path) are part of the physics hash, so a restart refuses to continue if acirculationupgrade changed the file.drop_componentsremoves components from the model; each dropped component’s variables become missing inputs. Drop the chambers the mechanics replaces (LV,RV) andtiming. Each[[circulation.chamber]]then ties a meshmarkerto the.odestatevolume_state(mL) and the missing pressurepressure_missing(mmHg) the 3D model supplies.parametersandinitial_stateare plain floats in the.ode’s own units (mL, mmHg, s), not pint strings. The initial volumes of the chambers come from the mesh and are not written.inputssupplies the remaining missing variables. The only type isphase,t mod period, which is what Regazzoni’sbeat_phaseis oncetimingis dropped.recordlists monitored.odevariables to add toloads.csvascirc_<name>.With
timingdropped, the atrial onsets are thetC_eff_*/tR_eff_*parameters (the onsets re-reduced moduloRR, ascirculation.regazzoni2020.flat_ode_parameterscomputes them). ChangeRRand these together, or the atria are silently mistimed.
Prestress and re-inflation#
A mesh imaged in vivo is already loaded. [prestress] unloads it before the run, then the
run starts from the unloaded reference configuration:
[prestress]
ramp_steps = 20
inflate_steps = 30
[[prestress.target]]
marker = "ENDO"
pressure = "2.463 kPa"
The targets are the pressures the imaged mesh was loaded with. For
type = "cycle"a[[prestress.target]]is refused: the targets are each cavity’sp_end_diastole.The unloaded configuration is cached in
prestress.cache_folder/<hash16>/, where the hash covers the geometry,[material],[compressibility],[bcs], the targets,ramp_stepsand the function spaces: anything that changes the result. Changing[solver]or[circulation]reuses the cache, and so do other runs of the same physics.inflate_steps(splitandmonolithiconly; 0 skips) re-inflates the unloaded cavity to the imaged volume in that many static steps with the activation at its starting value, so the coupling starts from the imaged state. It needs a[[prestress.target]]for every[[circulation.chamber]]marker (the imaged volumes to go back to). It may need 6 to 8 steps or more; when a step fails, the error names the fraction of the way reached and says to raiseinflate_steps.
Coupling from Python#
pulse.coupling holds the coupling protocols the CLI builds from [circulation], so other
drivers can build their own. A Coupling is called in this order: cavities(mesh) and
problem_kwargs() while the problem is built, attach(problem) once it exists, then
initialize(t0) on a fresh run or load_state_dict(state) on a restart (never both), then
advance(t, dt) once per step. advance returns False when the solve fails; in that case the
problem’s restart functions and metadata and the coupling’s own state are as they were before the
call, except for per-step inputs (the Constants set before each solve) and solver hints, so the
caller can retry with a smaller dt.
A StepHook is state that is not a 0D model but advances with each step: before_solve,
after_solve (only after a converged attempt), state_dict/load_state_dict and
restart_functions for rollback and checkpoints, e.g. a stateful crossbridge model that drives
Ta:
from pulse.cli.overrides import load_config
from pulse.cli.runner import build_simulation
conf = load_config("config.toml")
sim = build_simulation(conf, coupling=my_coupling, hooks=[my_crossbridge_hook])
coupling= replaces the one built from [circulation]. The rules for injected objects:
Pass
fresh=Truefor a new run (and leave itFalsefor a restart or post-processing). The re-inflation (inflate_steps) and the missing-cache warning depend on it: a missing prestress cache is then expected rather than warned about, and the chambers are re-inflated.sim.start()raises aConfigErrorwheninflate_steps > 0and the simulation was built withoutfresh=True.StepHook.before_solve/after_solvemust raise on all ranks together, never on one rank alone: the others would wait for it in the next collective call.The re-inflation markers come from
[circulation]’s chambers, not from the injected coupling: an injected coupling with[circulation] type = "none"cannot re-inflate.A prestress applied by
apply_prestressdeforms the geometry in place, so an injectedgeometry=must not be reused across builds.
Using pulse from Python#
pulse run/pulse post are thin wrappers around a step API any Python script (or a coupled
driver, e.g. simcardemsx) can call directly:
from pulse.cli.overrides import load_config
from pulse.cli.runner import build_simulation
conf = load_config("config.toml")
sim = build_simulation(conf) # geometry=..., active_model=... may be injected
sim.start() # creates the output folder and writes the loads.csv header
dt = conf.time.dt_s()
for _ in range(conf.time.n_steps()):
sim.step(dt)
sim.save()
sim.checkpoint()
build_simulation accepts geometry= and active_model= to reuse an already-built geometry or
swap [active] for an externally driven active model (an injected active model and a
[[load]] target = "activation" are mutually exclusive — only one may drive Ta). geometry=
takes a pulse.cli.geometry.CLIGeometry, as returned by
pulse.cli.geometry.build_geometry(conf.geometry) (the mesh plus fibres and cell/vertex tags), not
a bare pulse.HeartGeometry; the simulation keeps it as sim.geo, with sim.geo.geometry the
pulse.HeartGeometry and sim.geo.mesh the mesh. The config’s markers are checked against it
like against a built geometry.
Units#
Every physical quantity in the config is a pint string, "<value> <unit>" (e.g.
dt = "1 ms", mu = "15 kPa") — a bare number is rejected with a validation error naming the
field. Internally, time is always SI seconds and every load/pressure is SI pascal; loads.csv
and config.resolved.toml are written in those units too (geometry.unit/scale only affect the
mesh coordinates, via mesh_unit, not the quantities above).
Templates#
Each template under src/pulse/cli/templates/<name>/ is a runnable config.toml reproducing one
of the demo/ scripts as closely as the CLI schema allows; pulse init --template NAME copies it
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 |
Needs beyond |
Known deviation |
|---|---|---|---|
|
|
— |
Pressure and activation applied together in one pseudo-time step (matches the demo’s single combined solve). |
|
|
— |
Pressure steps are a 5-row table over pseudo-time 1..5 s (the same five values the demo loops over). |
|
|
— |
A 10-step ramp to 10 kPa with adaptive halving replaces the demo’s own continuation scheme. |
|
|
— |
19 pseudo-time steps ramp pressure and Ta together instead of the demo’s 20 linspace points. |
|
|
— |
The demo’s single pressure step, then single activation step, become two one-step ramps over pseudo-time |
|
|
— |
Pressure then Ta are ramps over pseudo-time |
|
|
— |
The 10x-stiffer AHA segment 10 is a |
|
|
|
Fibres come from |
|
|
|
Pressures and Ta are tables instead of the demo’s explicit Python loop; needs network access on first run (atlas download, cached afterwards). |
|
|
|
100 quasi-static steps of 10 ms to |
|
|
|
1000 steps of 1 ms to |
|
|
|
Same 1000-step/1 ms schedule and load-timing offset as |
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.