Skip to content

feat: run non-spatial simulations on VCell's Sundials ODE solver (simulate_ode) - #65

Merged
jcschaff merged 7 commits into
mainfrom
feat/ode-solver
Oct 24, 2026
Merged

jcschaff merged 7 commits into
mainfrom
feat/ode-solver

Conversation

@jcschaff

@jcschaff jcschaff commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

pyvcell can now run non-spatial simulations on VCell's Sundials ODE solvers: CVODE, IDA, and Combined IDA/CVODE.

API

import pyvcell.vcml as vc
bm = vc.load_vcml_file("model.vcml")
result = vc.simulate_ode(bm, "Simulation0")        # OdeResult: times, variable_names, values; result["S1"]
result = vc.simulate_ode_vcml(vcml_text, "Simulation0")   # a VCML document as-is (e.g. VCML 0.4, which pyvcell can't read)
app.add_ode_sim("sim", duration=100.0, output_time_step=1.0)   # Combined by default; CVODE_SOLVER / IDA_SOLVER

simulate_ode checks the model first: the simulation must be non-spatial and on an ODE solver. It then imports the optional parts lazily, with install or upgrade messages that name the extra or the libvcell version needed.

Reading and writing non-spatial simulations

simulate_ode runs a model through pyvcell's VCML round trip, so these fixes were needed. The figures below come from a corpus of 2,730 VCell models (vcell-fenics/vcml_biomodels):

  • Non-spatial simulations were dropped. The reader skipped every simulation without a MeshSpecification, and every one without OutputTimeStep. Now:

    • Simulation.mesh_size is optional, with None meaning non-spatial;
    • output is exactly one of output_time_step, keep_every + keep_at_most, or output_times. keep_every + keep_at_most is VCell's ODE default, used by 83% of the corpus's ODE simulations.
  • Solver settings were hard-coded. absolute_tolerance, relative_tolerance and max_time_step are now read and written. Their defaults are the former literals, spelled the same way, so existing spatial output is byte-identical.

  • MathOverrides were dropped. They were written empty, so a re-saved or re-run model used its default parameters, not its simulation's. 53% of corpus models have overrides. Simulation.math_overrides keeps each <Constant> verbatim, including parameter scans (ConstantArraySpec). In the test fixture Nonspatial_ODE.vcml, J_r0 was 20 in the simulation and came back as 2.

  • MassConservationModelReduction was dropped. VCell's default (reduce) then applied, and conserved species disappeared from the results. 822 corpus applications set it to false. Application.mass_conservation_model_reduction keeps it; None means VCell's default, as before.

  • <ModelUnitSystem> was dropped. Every write put the model in VCell's default units, so VCell derived different math. Public BioModel 266638414, which uses dimensionless units and lumped kinetics, gained a KMOLE factor, and its 102 output points became 24. 8% of corpus models (215) use a non-default unit system. Model.unit_system now keeps it verbatim.

Units: respected, and changed only when asked (behaviour change)

  • A loaded model keeps its own unit system when it's written and simulated.
  • unit_system="vcell" on load_sbml_* and load_antimony_* converts to VCell's units, as before.
  • A model whose SBML leaves its units undefined now fails to load unless assume_units says what its numbers are. SBML Level 3 has no default units, and that's what Antimony writes, so an Antimony model with no declared units falls in this case.
    • assume_units="vcell" reads the numbers as µM, µm and s, with molecules/µm² on membranes. That's what pyvcell used to do silently. It refuses a model that declares some of its units.
    • assume_units="sbml_l2" uses SBML Level 2's defaults (mol, litre, m², m, s), filling in only the units that are undefined.
    • The two combine: assume_units="sbml_l2", unit_system="vcell" reads mol/litre and converts to µM.
  • Callers updated. The guides and their notebooks, the sysbio notebooks and the example script now load their unitless Antimony with assume_units="vcell", so their results are unchanged. docs/guides/sbml-models.md gains a Units section.
  • Two tests changed. They asserted variable names from the dropped unit system (s0_init_uM). Their fixture is in µmol/l, so VCell now names it s0_init_umol_l_1, with the same values.

Tests

tests/vcml/test_ode_simulation.py adds 17 tests:

  • round trips of non-spatial simulations, the output modes, solver settings, overrides and model reduction;
  • validation: exactly one output mode; add_ode_sim only on a non-spatial application, with an ODE solver;
  • the .ida reader;
  • the unit system round trip, and loading unitless models (the error, assume_units "vcell" and "sbml_l2", conversion afterwards, partial units);
  • solve tests, which skip unless libvcell has vcml_to_ode_input and pyvcell-odesolver is installed. These include a fidelity test: the original document and pyvcell's rewrite must give the same solver settings and the same answer. That test is what found the two dropped settings.

The new fixtures are public BioModels 31668439 (VCML 0.4, IDA) and 266638414 (dimensionless units), both downloaded anonymously from the VCell API.

CI: green on Python 3.12, 3.13 and 3.14 (Linux): 136 passed and 13 skipped, with all 17 ODE tests running against libvcell 0.0.23 and pyvcell-odesolver 0.9.7.

Corpus sweep: original vs pyvcell's round trip

The sweep took 200 random VCML 4 models with an ODE simulation from vcell-fenics/vcml_biomodels. For each, it solved the original document (simulate_ode_vcml), then pyvcell's load-and-rewrite of it, and compared the results.

Outcome Models
Same answer (every value within 1e-6 of its variable's range, or 1e-5 relative) 103
pyvcell can't load the model (libvcell's VCML→VCML normalization fails) 67
Round trip solves, with different output variables 14
Round trip solves, with different time points or values 3
Timeout (> 120 s) or crash 4
The original itself fails to generate math 9
  • Of the 120 round trips that solve, 103 (86%) give the same answer.
  • The 17 that differ, and the 67 that can't be loaded, are gaps in the load-and-rewrite path, not diagnosed here and outside this PR's scope. That path is libvcell's VCML normalization, which pyvcell's load calls, plus pyvcell's reader and writer.
    • The load failures are mostly failed to generate math: null (16) and a missing electrical unit factor parameter in kinetics (14).
    • 25 of the 67 are rule-based models that libvcell couldn't run at all before 0.0.22.
  • For comparison, the same 200 models on libvcell 0.0.20 (no BioNetGen): 41 originals failed, against 9 now, and 102 were the same.

🤖 Generated with Claude Code

jcschaff and others added 3 commits October 23, 2026 13:04
…ulate_ode)

vc.simulate_ode(biomodel, simulation) runs a non-spatial simulation on CVODE,
IDA or Combined IDA/CVODE: libvcell (>= 0.0.20, vcml_to_ode_input) writes the
solver input and pyvcell-odesolver (the new `ode` extra) solves it; the .ida
table comes back as an OdeResult (times, variable_names, values).
vc.simulate_ode_vcml(vcml, name) does the same for a VCML document as-is,
without reading it into a Biomodel, so documents pyvcell cannot represent
(VCML 0.4, 2009) run too.

Reading and writing non-spatial simulations, which this needs:
- The reader skipped every simulation without a MeshSpecification, and every
  one without OutputTimeStep. Simulation.mesh_size is now optional (None:
  non-spatial), and the output is one of output_time_step, keep_every +
  keep_at_most (VCell's ODE default: 83% of the ODE simulations in a
  2,730-model VCell corpus) or output_times; exactly one is set.
- The writer hard-coded the tolerances, the maximum time step and the output
  step. Simulation gains absolute_tolerance, relative_tolerance and
  max_time_step, read and written; their defaults are the former literals,
  written with the same spelling, so existing output is unchanged.
- MathOverrides were dropped (written empty): a re-saved or re-run model used
  its default parameters, not its simulation's. 53% of the corpus has them.
  Simulation.math_overrides keeps each <Constant> verbatim, parameter scans
  (ConstantArraySpec) included.
- MassConservationModelReduction was dropped, so VCell's default (reduce)
  applied and conserved species left the results. Application gains
  mass_conservation_model_reduction (None: VCell's default, as before).

Application.add_ode_sim(...) adds an ODE simulation to a non-spatial
application. Tests: 12 in tests/vcml/test_ode_simulation.py, among them a
fidelity test (the original document and pyvcell's rewrite give the same
solver settings and the same answer), which found the two dropped settings.
New fixture: public BioModel 31668439 (VCML 0.4, IDA), downloaded anonymously
from the VCell API.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
A Biomodel now keeps its <ModelUnitSystem> (Model.unit_system, the attributes
verbatim) and writes it back; it used to be dropped, so every write put the
model in VCell's default units and VCell derived different math. Public
BioModel 266638414 (dimensionless units, lumped kinetics) gained a KMOLE
factor and 102 output points became 24; 215 of 2,730 VCell corpus models
have a non-default unit system.

No unit system changes unless asked:
- unit_system="vcell" (load_sbml_*, load_antimony_*) converts to VCell's
  units, as before.
- A model whose SBML leaves its units undefined (SBML Level 3 has no
  defaults; Antimony writes Level 3) now fails to load unless assume_units
  says what its numbers are: "vcell" (read as uM, um, s; molecules/um2 on
  membranes: the old implicit behaviour, now stated) or "sbml_l2" (SBML
  Level 2's defaults: mol, litre, m2, m, s; only the undefined units are
  filled). "vcell" refuses a model that declares some units. The two
  combine: assume_units="sbml_l2", unit_system="vcell" converts mol/litre to
  uM. load_antimony_* gain both arguments, load_sbml_url too.

Callers updated: the guides, their notebooks, the sysbio notebooks and the
example script load their unitless Antimony with assume_units="vcell", so
their results are unchanged; docs/guides/sbml-models.md describes units.
Two tests asserted variable names from the dropped unit system
(s0_init_uM); their fixture is in umol/l, so VCell now names them
s0_init_umol_l_1 (the same values).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@jcschaff

Copy link
Copy Markdown
Member Author

Corpus sweep on this branch (145d4e8): 200 current-format models from the 2,730-model VCell corpus (vcell-fenics/vcml_biomodels), picked at random with a fixed seed. Each has a non-spatial Sundials simulation. For each model, the sweep solves the original document (simulate_ode_vcml) and pyvcell's round trip of it (load_vcml_str → to_vcml_str), then compares the two results.

Before this PR, none of the 200 could run through pyvcell: the reader dropped every non-spatial simulation.

Outcome Models
The original document solves 159
— and pyvcell's round trip gives the same results (same variables and time points; values within 1e-5 relative) 102 (64% of the 159)
— pyvcell's rewrite is rejected by VCell (gaps in pyvcell's model below) 42
— the rewrite solves but differs 14
— timeout (> 120 s) 1
The original document fails in libvcell 41

Where pyvcell's model still falls short: follow-ups, none of them regressions.

  • Rewrite rejected (42):
    • 16 × failed to generate math: null, not yet diagnosed;
    • 14 × electrical kinetics: the electrical unit factor parameter isn't modelled, for General, GHK and GeneralCurrent kinetics;
    • 3 × VCML 0.4 initial conditions;
    • a few geometry and naming cases.
  • Rewrite solves but differs (14):
    • 11 eliminate a different conserved species. The math is equivalent, but the result has other columns. Species order is the same, so the cause is still open.
    • 2 have different time points (the solver's output steps);
    • 1 has different values.
  • Original fails (41):
    • 28 are rule-based (BioNetGen) models whose math libvcell can't regenerate. A stored-math fallback is in progress in libvcell.
    • the rest are invalid or incomplete models, such as a size parameter not set, or a simulation name with quotes.

🤖 Generated with Claude Code

…note

libvcell appends "; stored math (...)" when it used a rule-based
application's stored math (virtualcell/libvcell: rule-based stored math).
The file name is the part before the ';'; 0.0.20 sends just the name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…xtra

libvcell 0.0.20 is on PyPI with all of 0.0.19's wheels (macOS arm64, Linux
2_28/2_34 x86_64 and aarch64, Windows), so CI now runs the ODE solve tests
instead of skipping them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
jcschaff and others added 2 commits October 24, 2026 02:06
…+ runtime)

0.9.6's Linux wheels failed to load on a stock Linux (libc++.so.1: cannot open shared object file).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@jcschaff
jcschaff marked this pull request as ready for review October 24, 2026 07:05
@jcschaff
jcschaff merged commit 7d808e6 into main Oct 24, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant