ODE export: design¶
Status: approved in conversation on 2026-10-05, replaces sbmlodefac.
Closes #481 (an updated code generator with complete coverage, run against the test suite) and #438 (initial assignments not supported).
Goal¶
An SBML model is exported as its system of ordinary differential equations in six formats which share one analysis and one math printer core:
- numerical formats, which are code that simulates the model: python (numpy, a scipy
OdeSolver), julia (OrdinaryDiffEq.jl of DifferentialEquations.jl), R (deSolve); - presentation formats, which are documents that describe the model: typst, LaTeX, markdown.
The numerical code is correct, meaning it reproduces roadrunner over the SBML semantic test suite, and readable, meaning a person can read the equations in it. The presentation formats render the same content in the same order, so a model reads the same in every one of them. This is first class functionality of sbmlutils with its own documentation page.
Decisions¶
| Question | Decision |
|---|---|
| Math engine | Own printers on the libsbml AST, one per dialect. sbmlmath is not used, see below. |
| Semantic coverage | Full, events included. Algebraic rules, delay() and fast reactions are reported as unsupported. |
| API | Clean break: new package sbmlode, odefac is removed. |
| Code shape | Option simulator: True writes a self contained simulator, False the right hand side and its helpers only. |
| Document shape | Option standalone: True writes a compilable document, False a fragment to include. |
| Verification | Every format is executed or compiled: python in the default test run, julia and R in tox environments and CI jobs of their own, typst through the typst python package, LaTeX with tectonic. |
Why not sbmlmath¶
Issue #481 suggests sbmlmath (MathML to sympy) with the sympy code printers. A probe with sbmlmath 0.4.1 and sympy 1.14.0 showed:
- parsing fails for a
piecewisewhose condition holds&&; rembecomes sympyMod, which is the floored modulo, while SBMLremhas the sign of the dividend;log(2, A)is printed by the numpy printer asnumpy.log(A, 2), whose second argument is theoutarray, and by the LaTeX printer as\log(A), dropping the base;- sympy has no typst printer, and
delayhas no printer at all; - sympy evaluates and reorders the expression, so the printed equation is no longer the equation of the model.
Each of these needs an override, so the sympy route ends in custom printers on top of two extra dependencies. A printer on the libsbml AST with a dialect table per language keeps the exact SBML semantics, preserves the structure of the math as written, covers typst, and generalizes python_math, which is already verified against roadrunner.
Architecture¶
sbmlutils/converters/ode/
__init__.py public API: OdeSystem, FORMATS, render, write
system.py OdeSystem: the frozen dataclass tree of the analysis, from_sbml
analysis.py analysis: SBML document to OdeSystem, one method per element
dependencies.py ordering of assignments, cycle detection
events.py the continuous root function of an event trigger
astutil.py shared helpers to build and read the libsbml AST
printers/
base.py MathPrinter: precedence, associativity, dispatch on AST type
python.py PythonPrinter
julia.py JuliaPrinter
r.py RPrinter
document.py DocumentPrinter: the base of the typeset dialects
latex.py LatexPrinter (markdown uses it inside $$)
typst.py TypstPrinter
symbols.py naming: code identifiers and typeset symbols
text.py free text: single line for code, escaping per document format
formats.py Format registry, jinja2 environment, filters, render, write
documents.py the context of the document formats, built with the printers
sbmlutils/resources/converters/ode/
python.py.jinja julia.jl.jinja r.R.jinja
typst.typ.jinja latex.tex.jinja markdown.md.jinja
The three layers are independent: the analysis knows no format, a printer knows no model, a template knows no libsbml.
Layer 1: analysis (system.py, analysis.py)¶
OdeSystem.from_sbml(source) takes a path, an SBML string or an SBMLDocument (read with read_sbml) and returns a frozen dataclass tree:
ModelInfo: id, name, SBML level and version, notes as plain text, model units (time, substance, extent, volume, area, length) as strings fromudef_to_string.Symbol: id, name, unit string, SBO term,kind(compartment,species,parameter,reaction,species_reference).Compartment,Species,Parameter: the symbol plus value,constant, and for a species its compartment,hasOnlySubstanceUnits,boundaryCondition, conversion factor.FunctionDefinition: id, arguments, body AST.InitialAssignment,Assignment(assignment rule): variable, AST.Reaction: id, reactants, products, modifiers with their stoichiometry (a number, or the AST of a stoichiometry given by a rule or an initial assignment of a species reference), reversible flag, rate AST, local parameters. Local parameters are renamed to<reaction id>_<local id>(made unique against all ids) and become constant parameters; the rate AST is rewritten accordingly.Ode: state variable, right hand side AST,origin(reactionsorrate_rule), and for species in concentration the volume term.Event: id, trigger AST,initialValue,persistent, delay AST, priority AST,useValuesFromTriggerTime, assignments (variable, value AST,scaleAST,divisorid): the new value isvalue * scale / new(divisor), where the value is evaluated at trigger or execution time asuseValuesFromTriggerTimesays, whilescale(the size conversion of a species in concentration or held as amount) is always evaluated at execution, anddivisoris a compartment assigned by the same event, see the docstring ofEventAssignment.unsupported: list of(construct, element id), e.g.("algebraic rule", "rule3").
The following SBML semantics are resolved once, here:
- State variables are the species which are neither constant nor boundary nor assigned and take part in a reaction or a rate rule, plus every parameter, compartment and species reference with a rate rule. A boundary species with a rate rule is a state as well. Every other species, parameter and compartment is constant or assigned.
- Species are stored as roadrunner holds them: in amount if
hasOnlySubstanceUnits, else in concentration. The reaction terms of the ODE of a species in concentration are divided by its compartment. A species in concentration whose compartment is not constant (a rate rule, an assignment rule or an event assignment changes its size) is integrated as its amountn_Sinstead, with the concentrationS = n_S / Vas an assignment: the amount is what SBML conserves when the size changes, so a rate rule, an assignment rule and an event on the compartment are all exact without adV/dtterm. - Conversion factors: a species conversion factor, else the model conversion factor, multiplies the reaction terms of the species.
- Stoichiometry: a number, or the id of a species reference with an assignment rule, rate rule or initial assignment, which is then a symbol of the system.
- Rate rules on species in concentration apply to the concentration as written (SBML semantics), no volume term.
rateOf(x)is replaced by the right hand side ofxfor a state, by0for a constant, and is unsupported for an assigned variable whose derivative would need symbolic differentiation.- Assignments (assignment rules and the reaction rates) are ordered by their dependencies with
graphlib.TopologicalSorter; a cycle raisesValueErrornaming the variables. Ties keep the order of the document. - Initial values at t=0: the order of evaluation is the topological order over initial assignments, assignment rules and the initial values of the elements together, so that an initial assignment which depends on an assignment rule (and the reverse) is evaluated right. Initial amounts and concentrations are converted to the representation of the state with the initial size of the compartment, which itself may come from an initial assignment.
- Function definitions are kept as functions, called by the math.
- Unsupported: algebraic rules,
delay(), fast reactions, an event assignment to a constant, the comp, fbc and distrib packages (a comp model is flattened withflatten_sbmlfirst). They are collected, never silently dropped.
Layer 2: math printers (printers/)¶
MathPrinter.print(ast, symbols) returns a string. The base class implements the traversal: dispatch on ASTNode.getType(), parentheses from a precedence and associativity table, n-ary operators, unary minus, and the structure of piecewise, log with base, root with degree, relations with more than two operands. A dialect overrides tables and a few node handlers:
| Construct | python | julia | R | LaTeX | typst |
|---|---|---|---|---|---|
| power | a ** b |
NaNMath.pow(a, b) |
a ^ b |
a^{b} |
a^(b) |
piecewise |
x if c else y (lazy) |
c ? x : y (lazy) |
if (c) x else y (lazy, scalar) |
cases environment |
cases(...) |
rem |
np.fmod(a, b) |
rem(a, b) |
sign(a) * (abs(a) %% abs(b)) |
\operatorname{rem} |
op("rem") |
quotient |
np.trunc(a / b) |
trunc(a / b) |
trunc(a / b) |
\operatorname{quotient} |
op("quotient") |
log(b, x) |
np.log(x) / np.log(b) |
NaNMath.log(x) / NaNMath.log(b) |
log(x, b) |
\log_{b}\left(x\right) |
log_(b) (x) |
and, or, xor, not |
and, or, bool(a) ^ bool(b), not |
&&, \|\|, xor, ! |
&&, \|\|, xor, ! |
\land, \lor, \oplus, \lnot |
and, or, xor, not |
time, avogadro |
t, literal |
t, literal |
t, literal |
t, N_A |
t, N_A |
INF, NaN |
np.inf, np.nan |
Inf, NaN |
Inf, NaN |
\infty, \mathrm{NaN} |
infinity, "NaN" |
Booleans are numbers in SBML: a relation used as a number becomes float(...) in python, Float64(...) in julia, as.numeric(...) in R. The dialects follow IEEE 754 for a relation with NaN (false, and true for !=): R, where such a relation is NA, writes every relation as isTRUE(a > b) and a != b as !isTRUE(a == b). Julia writes the functions whose Base version throws outside its real domain (sqrt, log, asin, acosh, a power with a negative base and a fractional exponent, ...) with NaNMath, so the result is NaN as in SBML; every julia number is a Float64 literal, so that integer arithmetic cannot overflow. Every construct the dialect cannot express raises NotImplementedError naming the construct. Identifiers come only from the symbols mapping; an identifier without a mapping raises, so an id is never written raw.
Presentation printers break long equations: the top level sum of a right hand side is split into lines of at most width terms (default 4) inside an align/aligned block.
Layer 3: formats (formats.py, templates)¶
FORMATS maps a name to a Format: template, printer, file suffixes, kind (code or document) and the options it accepts. The API:
from sbmlode import OdeSystem
system = OdeSystem.from_sbml("model.xml")
code: str = system.render("python", simulator=True)
system.write("model.py") # format from the suffix
system.write("model.typ", standalone=False)
system.render_template(Path("my_template.jinja"))
render checks the options against the format, builds the symbol mapping of the format (symbols.py), prints every AST once with the printer of the format and renders the template with a context of plain strings and lists, so a template never touches libsbml. A numerical format raises NotImplementedError listing system.unsupported if it is not empty; a document lists them in a section of their own.
Safety is kept from odefac: every free text goes through single_line in code comments and through the escaping of the format in documents; every id written into code is checked to be an SId; generated python names avoid keywords and the names the code uses itself (t, x, p, np, ...), likewise for julia and R.
Numerical formats¶
The three files have the same structure; python is 0-indexed, julia and R are 1-indexed, and the indices are hidden behind named locals.
- Header: model id and name, source, sbmlutils version, units of the model, the unsupported constructs (empty for a file that is written at all).
- Id tables
xids,pids,yids, one entry per line with name and unit as a comment. p: default values of the constant parameters, constant compartments and constant species.initial_values(p): returns(x0, p), the constants set by an initial assignment updated inp, evaluating initial values, initial assignments and assignment rules at t=0 in their order. This fixes #438.- Right hand side:
f_dxdt(t, x, p)in python (the order of the scipy solvers),f!(dx, x, p, t)in julia (every julia function takes(x, p, t), the order of DifferentialEquations.jl),f_dxdt(t, x, p)returninglist(dx)in R. The body unpacks named locals fromxandp, calls the function definitions (emitted as functions before it), evaluates the assignments in order, the reaction rates under their reaction ids, then one line per state, each with the name and unit as a comment.f_y(t, x, p)returns all assigned values and reaction rates. - Events (
simulator=TrueandFalse):event_triggers(t, x, p)returns one continuous root function value per event andevent_conditions(t, x, p)the exact truth value of every trigger;event_values_<id>(t, x, p)evaluates the assigned values (at trigger or execution time asuseValuesFromTriggerTimesays) andevent_assign_<id>(t, x, p, values)applies them at execution, with the size conversionsscale/divisorevaluated then;EVENTSlists per event its flags, delay and priority functions. A single relationa > bbecomesa - b,&&the minimum,||the maximum and!the negation of its operands' root functions, so that the sign of the root function is the truth value of the trigger. Strict and non-strict relations differ only at the root, which the event handling resolves withevent_conditions. simulate(t_end, points=101, p=None, x0=None, ...)(onlysimulator=True; python addsrtol,atol,method,max_step,max_steps): integrates with a limit of solver steps and a guard against a step size which no longer advances the time, both raising, so a model which blows up fails fast, stops where a trigger changes its truth value, evaluates triggers withinitialValue, orders simultaneous events by priority, schedules delayed events, drops non-persistent events whose trigger turned false before execution, applies assignments with the values of trigger or execution time, re-evaluates the assigned variables, restarts the integration, raises after a limit of cascaded executions at one time (10000, as roadrunner), and returns a table withtime, the states, the assigned values and the constants changed by events (pandasDataFrame,DataFrames.DataFrame,data.frame). Python steps ascipy.integrateOdeSolver(LSODA,rtol=1e-8,atol=1e-10as defaults, overridable) and finds a change of a trigger by bisection on the dense output with the exact boolean triggers; julia steps an OrdinaryDiffEq integrator (init/step!,Rodas5P) with aVectorContinuousCallbackending a step at a root, R integrates segment by segment withdeSolve::lsodaandrootfuncin an own loop (deSolveeventsknow no strict relations, delays, priorities or persistence); julia and R reuse the execution algorithm of python (execute_events) and resolve strict versus non-strict relations at a root withevent_conditions. The python file runs as a script (if __name__ == "__main__") and prints the head of the table.
The correctness contract: initial_values, f_dxdt, f_y and simulate match roadrunner (rtol=1e-6, atol=1e-9) for every case of the SBML semantic test suite without an unsupported construct.
Presentation formats¶
One section order for all three formats, the sections without content are omitted:
- Title (model name, else id), a metadata line (id, SBML level and version, source, sbmlutils version), the notes of the model as plain text.
- Units of the model.
- Compartments, species and parameters: tables with symbol, id, name, value, unit and the constant flag; species add compartment, amount or concentration and boundary condition.
- Function definitions as
f(x, y) = .... - Initial assignments and assignment rules, in their order.
- Reactions: the reaction equation (
2 A + B -> C,<->if reversible, modifiers after it), the ratev_r = ...and the local parameters. - ODE system:
d x / d t = ...per state, written with the reaction rates (v_1 - 2 v_2), conversion factors and volumes explicit, rate rules marked. - Events: trigger, delay, priority, flags and assignments.
- Unsupported constructs.
Symbols: an id is typeset as a math symbol. The part before the first _ is the base, the rest the subscript (k_cat_glc is k with subscript cat_glc); a base of more than one letter is upright. The option symbols="id" | "name" selects ids or element names (a name which is no valid symbol falls back to the id).
Format specifics:
- typst:
standalone=Truewrites#set document, page and text settings, headings and the content;Falsethe content only. Math in$ ... $, tables with#table. Escaped characters in text: backslash,#,$,*,_,@,<,>,[,]and the backtick. - LaTeX:
standalone=Truewrites anarticlewithamsmath,amssymb,booktabs,xltabular,parskipandhyperref, the tables beinglongtable, orxltabularwhere a column of long ids must wrap, andiftexchoosingfontencandlmodernfor pdfLaTeX andfontspecfor XeLaTeX and LuaLaTeX, so that it compiles with every engine;Falsethe body only and a comment listing the packages it needs. Escaping bytex_text. - markdown: GitHub flavored tables, display math in
$$ ... $$with the LaTeX printer (rendered by GitHub and by Zensical with MathJax), ids in code spans. Escaped:|,*,_, backtick,<,>and the HTML entities. This replaces the markdown whichcreate_model(create_markdown=True)writes.
Testing¶
Tests live in tests/converters/ode/, as test_ode_<topic>.py with the shared helpers in ode_helpers.py (the directory has no __init__.py, the unique basenames keep the import mode of pytest working):
test_ode_system.py: the analysis, one test per semantic rule above (conversion factors, variable compartments, local parameter renaming, stoichiometry math, rate rules on every kind,rateOf, ordering, cycles, initial values, unsupported collection);test_ode_astutil.py,test_ode_symbols.pyandtest_ode_text.pythe helpers of the analysis, the naming and the text escaping.test_ode_printers.py: every AST node type in every dialect against golden strings, the precedence table, and the evaluation of the numerical dialects against roadrunner for a set of formulas which covers every construct of the math.test_ode_python.py,test_ode_julia.pyandtest_ode_r.py: compareinitial_values,f_dxdt,f_yand the trajectory ofsimulatewith roadrunner. Python always (skips without roadrunner); julia and R are run through the command prefixes inSBMLUTILS_JULIAandSBMLUTILS_RSCRIPT(defaultjuliaandRscript, split like a shell command, so they can be a docker run) and skip when the toolchain or its packages are missing, unlessSBMLUTILS_REQUIRE_TOOLCHAINS=1makes that a failure. The tolerances against roadrunner arerel=1e-8for the values at a point andrtol=1e-6,atol=1e-9for a trajectory, relaxed to1e-4and1e-6for a model with events, whose times are located to the tolerance of the integration.test_ode_presentation.py: typst compiled with thetypstpython package (added to thedevextra), LaTeX compiled with tectonic when it is on the path, markdown parsed withmarkdown-it-py; golden files of the demo model, the repressilator and a model with events intests/converters/ode/golden/, regenerated with the environment variableSBMLUTILS_UPDATE_GOLDEN=1.test_ode_safety.py: the injection and SId tests, run on every format.test_ode_docs.py: the files ofdocs/images/ode, whichdocs/ode.mdshows, are the current output of the export.test_ode_testsuite.py: a curated subset of the SBML semantic test suite (CURATED, every feature tag at least once) runs in the default test run for python and, with the toolchain, in the tox environments for julia and R; the full sweeps run behind thesbml_testsuitemarker, a known failure is a strict xfail with its reason.scripts/ode_report.pyruns the sweep, one process per case, and reports the pass rate per numerical format with the known failures and their reasons, asscripts/roundtrip_report.pydoes.
Continuous integration: tox environments julia and R (python 3.14 plus the toolchain), jobs in ci-cd.yml with julia-actions/setup-julia (the packages of tests/converters/ode/julia/Project.toml: OrdinaryDiffEq, DataFrames, NaNMath, SpecialFunctions) and r-lib/actions/setup-r (deSolve, from binary packages), and a latex job with tectonic. They are not required checks of the ruleset at first, so a toolchain outage does not block a merge; the decision to make them required is left to the maintainer.
Documentation¶
docs/ode.md, "ODE export": the API, the options, one section per format with the output of the repressilator (code listings for the numerical formats, the typst output as SVG pages, the LaTeX source, the markdown rendered), how to run the generated code with its solver, and the table of supported SBML features. The SVG pages of the typst document are not committed:examples/converters/ode.pycompiles them (python -m examples.converters.ode docs/images/ode) with thetypstpackage, deterministically with the fonts of typst only, which thedocumentationworkflow runs before the build. The text files it writes next to them are committed, shown throughpymdownx.snippets, and kept current bytest_ode_docs.py. MathJax, which typesets the math of the markdown output, is vendored indocs/javascripts/mathjax/.docs/api/converters.ode.mdreplacesdocs/api/converters.odefac.md;docs/converters.mdlinks to the new page.examples/converters/ode.pywrites all six formats of a packaged model.
Migration¶
sbmlodefac and its templates are removed. create_model(create_markdown=True), examples/assignment.py and examples/tutorial/linear_chain.py use the new API. The release notes of the next release map the old calls to the new ones:
| 0.13 | new |
|---|---|
SBML2ODE.from_file(path) |
OdeSystem.from_sbml(path) |
SBML2ODE(doc) |
OdeSystem.from_sbml(doc) |
to_python(path) |
write(path) or render("python") |
to_R, to_julia, to_markdown, to_tex |
render("r" \| "julia" \| "markdown" \| "latex") |
to_custom_template(path) |
render_template(path) |