Changelog#
Unreleased#
Changed — this changes generated code#
Removable singularities are now guarded by default, in the right-hand side as
well as in the Rush-Larsen linearization. Every assignment with a removable
pole in a state variable – a gate rate such as
(V + 10)/(exp((V + 10)/10) - 1), or a GHK flux divided by exp(vfrt) - 1 –
is rewritten in that state and wrapped in a Piecewise whose other branch is
a truncated Taylor series of order 3 over a small window around the pole.
This matters most for the linearization. Since 2.0.0 the Rush-Larsen schemes
differentiate through intermediates, which turns a removable pole into a
double pole: ToRORd’s d(dv_dt)/dv was +30.1 at v = -1e-9 and raised at
v = 0, against a true value near -0.0405, and every action potential
crosses v = 0 twice. A positive linearization turns the exponential update
from decaying to growing.
If you regenerate a model with such a pole, its output changes – including
rhs, for ToRORd_dyn_chloride and ORdmm_Land, whose GHK poles were not guarded
before. Pass --no-remove-singularities (or remove_singularities=False to
load_ode, or remove_singularities = false in the config file) to emit the
expressions exactly as written. ode2cellml never guards: it exports a model,
not code.
Fixed#
The previous mechanism (
ODE.remove_singularities, reachable only from Python) discarded every GHK pole as infinite, because it looked for poles in whatever intermediate the model author had named; replaced a pole by a constant, which differentiates to zero and left the linearization wrong; usedsympy.limit, which returns 0 for float-coefficient gate rates such as0.2*(V + 23)/(1 - exp(-0.04*(V + 23)))where the true limit is 5; and tested for the pole with exact equality. It has been removed.sympy.csein the Rush-Larsen schemes no longer hoists subexpressions out ofPiecewisebranches, where a hoisted temporary could benanat exactly the value the branch exists to avoid.
Added#
gotranx.singularities, and--remove-singularities/--no-remove-singularitiesonode2py,ode2c,ode2julia,ode2mtk,ode2uflandode2md.
2.0.0#
Changed — this changes the numerics of generated code#
generalized_rush_larsen and hybrid_rush_larsen now linearize on the true
diagonal of the Jacobian, ∂f_i/∂y_i, differentiating through intermediate
expressions.
Previously the linearization was the derivative of the state derivative
expression as written. When a derivative was routed through an intermediate —
which is how essentially every cardiac model is written —
sympy correctly returned zero and the scheme silently emitted forward Euler
for that state. In practice this meant the exponential update reached the gating
variables only, and generalized_rush_larsen degenerated to the original 1978
Rush-Larsen method rather than the generalization of Sundnes et al. (2009) that
it cites.
∂f_i/∂y_i is a property of the right-hand side, not of how it is spelled, so
I = g*(V - E); dV_dt = -I and dV_dt = -(g*(V - E)) now generate the same
scheme. They previously did not.
If you regenerate an existing model, its numerics will change. They will be
more accurate and considerably more stable — models with fast calcium buffering
could previously require a time step 20-40x smaller than the literature value —
but the output is not bit-identical to 1.8.0. There is no opt-out flag; pin
gotranx==1.8.0 if you need to reproduce earlier output exactly.
Affected states in the bundled models, as a guide to the scale of the change:
model |
states linearized before |
after |
|---|---|---|
|
39/48 |
48/48 |
|
41/52 |
52/52 |
|
13/19 |
19/19 |
|
7/8 |
8/8 |
|
1/2 |
2/2 |
|
3/3 |
3/3 (unchanged) |
Fixed#
hybrid_rush_larsenapplied its zero-derivative test after the stiff-state test and skipped the bookkeeping, so--stiff-states Vproduced forward Euler and then reported thatVwas “not found in the ODE”. States that are present are now always reported as found, and an explicitly stiff state whose derivative is genuinely zero produces a warning instead.The diagnostic message read “where marked as stiff” and passed the state set as a
structlogextra=key rather than formatting it into the message.
Added#
gotranx.linearization.diagonal_jacobian(ode, remove_unused=False), which returns∂f_i/∂y_ifor every state by forward-mode automatic differentiation over the assignment graph.
Notes for the generated code#
Deep linearization makes the generated scheme measurably bigger and slower to
generate. Measured on ToRORd_dyn_chloride’s Python generalized_rush_larsen:
Generated source: 1052 → 2754 lines; zero-division guards 54 → 66. A
d<state>_dt_linearizedlocal now appears for every state, alongside_linearization_temp_<k>CSE temporaries. Relevant if you post-process generated sources.Code generation time: ~3.6x slower (0.79 s → 2.84 s; 0.59 s of that is the AD sweep and most of the rest is CSE). Fine for a one-off codegen step, but CI benchmarks that time code generation itself will notice.
Peak live temporaries on the plain-numpy backend. CSE introduces named locals for every factored-out subexpression, and CPython keeps every local bound until the function returns (unlike C, Julia, and JAX-under-
jit, which reuse buffers once a compiler’s own liveness analysis says a value is dead). Measured: distinct locals in the generated ToRORd function go 595 → 1011 (+70%) once deep linearization’s CSE’d temporaries are added. For vectorized use over many cells (seedocs/vectorized_computations.md) that is a real memory increase, andcse=Falsebelow is the way out of it.CSE can now be turned off. Both
generalized_rush_larsenandhybrid_rush_larsentake acseparameter, defaultTrue, exposed on the CLI as--cse/--no-cseforode2py,ode2c,ode2juliaandode2ufl, and ascseunder[tool.gotranx]inpyproject.toml.Trueruns onesympy.cseacross every state being linearized at once, so a subexpression shared between states is computed once.Falseperforms no CSE at all: everyd<state>_dt_linearizedbecomes one fully inlined expression, which emits no temporaries whatsoever — the option that avoids the memory cost described above — at the price of far more arithmetic.Measured operation count for the linearization block relative to the plain rhs, and number of temporaries:
model
cse=Trueops / tempscse=Falseops / tempsfitzhughnagumo0.51x / 2
0.62x / 0
lorentz0.38x / 0
0.38x / 0
beeler_reuter_19770.34x / 7
0.59x / 0
tentusscher_panfilov_2006_M_cell0.67x / 74
1.38x / 0
ORdmm_Land1.23x / 381
12.65x / 0
ToRORd_dyn_chloride1.08x / 430
9.43x / 0
Note that 1.8.0 already ran CSE, but one pass per state rather than one across all of them. That per-state strategy was briefly kept as a third option and then dropped: it never saved more than 11% of the temporaries and always cost 18–31% more operations than a joint pass, so no preference selected it. If a future change starts emitting
delfor a temporary once it is dead — which nothing does today, in any backend — per-state becomes worth reconsidering, since a per-state temporary is provably dead at the end of that state’s own update where a shared one may not be.If you diff generated output across gotranx versions: regenerating an existing model now produces different temporary names than 1.8.0 (the shared pool is
_linearization_temp_<k>, not_<derivative>_linearized_<k>), and the different factoring reassociates floating-point operations, which can shift results at the ~1e-14 level.