r2sbml is a thin R interface to libSBML, built with Rcpp.
It does two things:
- Reads an SBML model and lets you query its components from R — species, compartments, parameters, reactions, rules, events and function definitions.
- Exports the model’s mass balances as ready-to-simulate code for six ODE targets: deSolve, mrgsolve, rxode2/nlmixr2, MATLAB, Julia and ubiquity.
It does not let you modify a model. There is no setter API
and no way to write SBML back out; r2sbml reads and
exports.
libSBML itself is bundled with the package and compiled from source during installation, so nothing needs to be installed separately and the build downloads nothing. See the README for the compiler and header prerequisites.
Ten small example models ship with the package and are used throughout this vignette:
basename(list.files(system.file("examples", package = "r2sbml"), pattern = "[.]xml$"))
#> [1] "sbmlalgebraicrules.xml"
#> [2] "sbmlassignmentrules.xml"
#> [3] "sbmlboundaryconditions.xml"
#> [4] "sbmlconversionfactor-alternative.xml"
#> [5] "sbmlconversionfactor.xml"
#> [6] "sbmldelay.xml"
#> [7] "sbmlevent.xml"
#> [8] "sbmlfunctiondefinition.xml"
#> [9] "sbmlmutlicompartment.xml"
#> [10] "sbmlsimple.xml"Reading a model
Everything except convertReactions() works in two
phases. First getModel() reads the file, keeps the
underlying SBMLDocument alive, and hands back an external
pointer:
sbml_file <- system.file("examples", "sbmlsimple.xml", package = "r2sbml")
model <- getModel(sbml_file)
#>
#> filename: /home/runner/work/_temp/Library/r2sbml/examples/sbmlsimple.xml
#> error(s): 0
#>
#>
#> File: /home/runner/work/_temp/Library/r2sbml/examples/sbmlsimple.xml (Level 3, version 2)
class(model)
#> [1] "externalptr"getModel() prints a short header as a side effect — the
file it read, how many SBML errors it found, and the Level/Version of
the document. Errors are fatal: it prints them and stops, naming the
file, rather than handing back a pointer you cannot trust. Warnings and
informational diagnostics are not fatal — they are reported as an R
warning and the model still loads, which is the same rule
convertReactions() applies.
Reading is not validation. libSBML reports parse-level and structural
problems as it reads, but getModel() does not run a
consistency check, so a file that loads cleanly can still be
semantically questionable — inconsistent units, say. r2sbml
never validates a model.
Every query function then takes that pointer as its only argument.
Functions that return data
getSpeciesTable(model)
#> Number Name ID InitialConcentration InitialAmount Compartment CompartmentVol
#> 1 1 E NaN 5e-21 comp 1e-14
#> 2 2 S NaN 1e-20 comp 1e-14
#> 3 3 P NaN 0e+00 comp 1e-14
#> 4 4 ES NaN 0e+00 comp 1e-14
#> BoundaryCondition Constant
#> 1 0 FALSE
#> 2 0 FALSE
#> 3 0 FALSE
#> 4 0 FALSE
getReactionTable(model)
#> Number ID Name Reversible RateLaw
#> 1 1 veq TRUE comp * (kon * E * S - koff * ES)
#> 2 2 vcat FALSE comp * kcat * ESgetSpeciesTable() reports both
InitialConcentration and InitialAmount
columns. An SBML species carries one or the other, never both, and the
unset one reads back as NaN — so the NaN
column above is telling you this model is amount-based, not that
anything is wrong.
Simpler accessors return plain vectors:
getCmtNames(model)
#> [1] "comp"
getCmtSizes(model)
#> 1e-14
#> [1] 1e-14
getNumSpecies(model)
#> [1] 4The full set of data-returning functions:
| Function | Returns |
|---|---|
getSpeciesTable(), getParameterTable(),
getReactionTable()
|
a data.frame
|
getSpeciesNames(), getCmtNames()
|
a character vector |
getSpeciesIC(), getCmtSizes()
|
a numeric vector |
getNumSpecies() |
an integer |
getSpeciesIC() reports whichever of
initialAmount or initialConcentration the
model sets, named by species id, and applies no unit
conversion — unlike convertReactions(), which must divide
an amount by the compartment volume because the ODE it writes integrates
concentrations. getSpeciesNames() and
getCmtNames() return the SBML name where one
is set and fall back to the id otherwise. See
vignette("limitations", package = "r2sbml") for both.
Functions that print
A second family writes to the console and returns 0
invisibly. Assigning them gets you the 0, not the text, so
capture them instead:
rules_model <- getModel(system.file("examples", "sbmlassignmentrules.xml",
package = "r2sbml"))
#>
#> filename: /home/runner/work/_temp/Library/r2sbml/examples/sbmlassignmentrules.xml
#> error(s): 0
#>
#>
#> File: /home/runner/work/_temp/Library/r2sbml/examples/sbmlassignmentrules.xml (Level 3, version 2)
getRuleMath(rules_model)
#> Rule 0, formula: S1 = T / (1 + Keq)
#> Rule 1, formula: S2 = Keq * S1
#> [1] 0
txt <- capture.output(getReactionMath(model))
txt
#> [1] "Reaction 0, formula: comp * (kon * E * S - koff * ES)"
#> [2] "Reaction 1, formula: comp * kcat * ES"
#> [3] "[1] 0"| Function | Prints |
|---|---|
getRuleMath() |
assignment, rate and algebraic rules |
getReactionMath() |
kinetic laws |
getFunctionDefinition() |
function definitions |
getEventMath() |
event triggers, delays and assignments |
printSBML(), echoSBML()
|
the document itself |
printSBML() and echoSBML() are the
exception to the two-phase rule: they take a filename, not a
model pointer.
Absent components are an error, not an empty result
A query for a component the model does not have raises an error. It does not return a zero-row data frame or a zero-length vector:
# sbmlsimple.xml has no rules at all
getRuleMath(model)
#> Error:
#> ! No Rules present in the model.The error names the component that was missing, so a caller can tell the cases apart. If you are looping over many models, wrap the call:
safely <- function(expr) tryCatch(expr, error = function(e) NULL)
files <- list.files(system.file("examples", package = "r2sbml"),
pattern = "[.]xml$", full.names = TRUE)
has_rules <- vapply(files, function(f) {
!is.null(safely(capture.output(getRuleMath(getModel(f)))))
}, logical(1))
data.frame(model = basename(files), has_rules = unname(has_rules))
#> model has_rules
#> 1 sbmlalgebraicrules.xml TRUE
#> 2 sbmlassignmentrules.xml TRUE
#> 3 sbmlboundaryconditions.xml TRUE
#> 4 sbmlconversionfactor-alternative.xml FALSE
#> 5 sbmlconversionfactor.xml FALSE
#> 6 sbmldelay.xml TRUE
#> 7 sbmlevent.xml TRUE
#> 8 sbmlfunctiondefinition.xml FALSE
#> 9 sbmlmutlicompartment.xml FALSE
#> 10 sbmlsimple.xml FALSEThe same applies to getParameterTable() on a model whose
parameters are all local to its kinetic laws —
sbmlsimple.xml is exactly that case.
Exporting mass balances
convertReactions() is the other half of the package, and
the one function that does not take a model pointer. It reads the SBML
file itself and writes a code file, returning NULL
invisibly:
out_r <- tempfile(fileext = ".R")
convertReactions(sbml_file, out_r, format = "R")
#> Conversion completed.
#> Number of ODEs - 4format |
Target |
|---|---|
"R" or "deSolve" (default) |
deSolve |
"mrgsolve" |
mrgsolve |
"nlmixr2" or "rxode2"
|
rxode2 / nlmixr2 |
"MATLAB" or "matlab"
|
a function file for ode15s
|
"Julia" or "julia"
|
an ODEProblem for DifferentialEquations.jl |
"ubiquity" or "Ubiquity"
|
a ubiquity system file |
An unrecognised format is an error, so a typo cannot leave you with an empty file that looks like a successful conversion:
convertReactions(sbml_file, tempfile(), format = "fortran")
#> Conversion completed.
#> Number of ODEs - 4
#> Error:
#> ! Unknown format 'fortran'. Use one of 'R'/'deSolve', 'mrgsolve', 'nlmixr2'/'rxode2', 'MATLAB', 'Julia' or 'ubiquity'.What happens before anything is written
convertReactions() applies four libSBML converters in
sequence:
-
replaceReactions— every reaction becomes a rate rule -
promoteLocalParameters— parameters local to a kinetic law become global -
expandInitialAssignments— initial assignments are folded into values -
expandFunctionDefinitions— user-defined functions are inlined
By the time a writer runs, the model is in rate-rule form with no local parameters and no function definitions left. That is why the generated code never contains a function definition, and why parameters that were buried inside a kinetic law appear as top-level constants.
The deSolve output
cat(readLines(out_r), sep = "\n")
#> ## Automatically generated model file by r2sbml at
#> ## Writing the model ODEs for solving in R
#> ## Solving the model requires desolve package
#>
#> ## Model Summary
#> ## functionDefinitions: 0
#> ## unitDefinitions: 2
#> ## compartmentTypes: 0
#> ## speciesTypes: 0
#> ## compartments: 1
#> ## species: 4
#> ## parameters: 3
#> ## initialAssignments: 0
#> ## rules: 4
#> ## constraints: 0
#> ## reactions: 0
#> ## events: 0
#>
#> ## Load required packages
#> library(deSolve)
#>
#> ## Units Definitions
#> ## per_second: Kind=, : Exponent=-1
#> ## litre_per_mole_second: Kind=, : Exponent=-1
#> ## litre_per_mole_second: Kind=, : Exponent=1
#> ## litre_per_mole_second: Kind=, : Exponent=-1
#>
#> ## Compartments
#> comp = 1e-14
#>
#> ## Initial Amounts
#> InitialAmounts <- c(
#> E = 5e-07,
#> S = 1e-06,
#> P = 0,
#> ES = 0
#> )
#>
#> ## Parameters
#> parameters <- c(
#> veq_koff = 0.2,
#> veq_kon = 1e+06,
#> vcat_kcat = 0.1
#> )
#>
#> ## Reactions
#>
#> ## Mass-Balances (ODEs)
#> massBalances <- function(time, states, params){
#>
#> ## Get States Names
#> E = states[["E"]]
#> S = states[["S"]]
#> P = states[["P"]]
#> ES = states[["ES"]]
#>
#> ## Get Parameter Names
#> veq_koff = params[["veq_koff"]]
#> veq_kon = params[["veq_kon"]]
#> vcat_kcat = params[["vcat_kcat"]]
#>
#> ## Mass Balances
#> dE_dt = -1 * (comp * (veq_kon * E * S - veq_koff * ES) / comp) + comp * vcat_kcat * ES / comp
#> dS_dt = -1 * (comp * (veq_kon * E * S - veq_koff * ES) / comp)
#> dP_dt = comp * vcat_kcat * ES / comp
#> dES_dt = comp * (veq_kon * E * S - veq_koff * ES) / comp + -1 * (comp * vcat_kcat * ES / comp)
#>
#> ## Make a list of Mass Balances
#> MassBalances <- c(
#> dE_dt ,
#> dS_dt ,
#> dP_dt ,
#> dES_dt
#> )
#> return(list(MassBalances))
#>
#> }Three things in that file are worth pointing out.
The state vector is decided deliberately. It is not simply “one equation per rule”. A species set by an assignment rule is computed inside the right-hand side and given no slot, because its value follows from the rule at every time point. A species with no rate rule — a boundary condition, or one in no reaction — keeps its slot with a zero derivative so it still appears in the solution.
Initial conditions are converted into the units the ODE
integrates. An SBML species symbol is a concentration unless
hasOnlySubstanceUnits is set, and the rate rules are
divided by the compartment volume to match. sbmlsimple.xml
declares initialAmount = 5e-21 in a 1e-14
compartment, so the generated initial condition is 5e-07,
not 5e-21. The volume used is the species’ own
compartment’s, so a multi-compartment model rescales species
individually.
Compartment volumes are emitted as plain constants when they are constant. If a compartment carries a rate rule instead, it joins the state vector and is integrated — see “Changing compartment volumes” below.
You can run the result directly:
library(deSolve)
env <- new.env()
source(out_r, local = env)
solution <- ode(y = env$InitialAmounts,
times = seq(0, 5, by = 1),
func = env$massBalances,
parms = env$parameters)
round(solution, 9)
#> time E S P ES
#> 1 0 5.00e-07 1.00e-06 0.00e+00 0.00e+00
#> 2 1 2.51e-07 7.36e-07 1.60e-08 2.49e-07
#> 3 2 1.90e-07 6.46e-07 4.40e-08 3.10e-07
#> 4 3 1.74e-07 5.98e-07 7.60e-08 3.26e-07
#> 5 4 1.72e-07 5.63e-07 1.09e-07 3.28e-07
#> 6 5 1.75e-07 5.34e-07 1.42e-07 3.25e-07
# free + bound enzyme is conserved, and so is total substrate
d <- as.data.frame(solution)
range(d$E + d$ES)
#> [1] 5e-07 5e-07
range(d$S + d$ES + d$P)
#> [1] 1e-06 1e-06The other five targets
Each target gets its own spelling of the same mathematics.
out_mrg <- tempfile(fileext = ".cpp")
convertReactions(sbml_file, out_mrg, format = "mrgsolve")
#> Conversion completed.
#> Number of ODEs - 4
cat(readLines(out_mrg), sep = "\n")
#> ## Automatically generated mrgsolve model file by r2sbml
#>
#> $PROB
#>
#> $PARAM
#> veq_koff = 0.2
#> veq_kon = 1e+06
#> vcat_kcat = 0.1
#>
#> $CMT
#> E
#> S
#> P
#> ES
#>
#> $MAIN
#> E_0 = 5e-07;
#> S_0 = 1e-06;
#> P_0 = 0;
#> ES_0 = 0;
#> comp = 1e-14;
#>
#> $ODE
#> dxdt_E = -1 * (comp * (veq_kon * E * S - veq_koff * ES) / comp) + comp * vcat_kcat * ES / comp;
#> dxdt_S = -1 * (comp * (veq_kon * E * S - veq_koff * ES) / comp);
#> dxdt_P = comp * vcat_kcat * ES / comp;
#> dxdt_ES = comp * (veq_kon * E * S - veq_koff * ES) / comp + -1 * (comp * vcat_kcat * ES / comp);mrgsolve model blocks are C++, where ^ is bitwise XOR
and does not compile for doubles, so powers are written
pow(a, b) for this target alone.
out_rx <- tempfile(fileext = ".R")
convertReactions(sbml_file, out_rx, format = "nlmixr2")
#> Conversion completed.
#> Number of ODEs - 4
cat(readLines(out_rx), sep = "\n")
#> ## Automatically generated nlmixr2/rxode model file by r2sbml
#>
#> model <- function() {
#> ini({
#> veq_koff <- 0.2
#> veq_kon <- 1e+06
#> vcat_kcat <- 0.1
#> })
#> model({
#> comp <- 1e-14
#> E(0) <- 5e-07
#> S(0) <- 1e-06
#> P(0) <- 0
#> ES(0) <- 0
#>
#> d/dt(E) <- -1 * (comp * (veq_kon * E * S - veq_koff * ES) / comp) + comp * vcat_kcat * ES / comp
#> d/dt(S) <- -1 * (comp * (veq_kon * E * S - veq_koff * ES) / comp)
#> d/dt(P) <- comp * vcat_kcat * ES / comp
#> d/dt(ES) <- comp * (veq_kon * E * S - veq_koff * ES) / comp + -1 * (comp * vcat_kcat * ES / comp)
#> })
#> }
out_m <- tempfile(fileext = ".m")
convertReactions(sbml_file, out_m, format = "MATLAB")
#> Conversion completed.
#> Number of ODEs - 4
cat(readLines(out_m), sep = "\n")
#> % Automatically generated MATLAB model file by r2sbml
#> %
#> % Save as file2a05386e582.m, then solve with: [t, y] = file2a05386e582();
#> % Columns of y are, in order: E, S, P, ES
#> %
#> % Model Summary
#> % compartments: 1
#> % species: 4
#> % parameters: 3
#> % rules: 4
#> % events: 0
#>
#> function [t, y] = file2a05386e582(tspan, y0)
#>
#> if nargin < 1 || isempty(tspan)
#> tspan = [0 10];
#> end
#>
#> % Compartments
#> comp = 1e-14;
#>
#> % Parameters
#> veq_koff = 0.2;
#> veq_kon = 1000000;
#> vcat_kcat = 0.1;
#>
#> % Initial amounts
#> if nargin < 2 || isempty(y0)
#> y0 = [ ...
#> 5e-07; % E
#> 1e-06; % S
#> 0; % P
#> 0; % ES
#> ];
#> end
#>
#> [t, y] = ode15s(@massBalances, tspan, y0);
#>
#> function dydt = massBalances(time, states)
#>
#> % States
#> E = states(1);
#> S = states(2);
#> P = states(3);
#> ES = states(4);
#>
#> % Mass balances
#> dydt = zeros(4, 1);
#> dydt(1) = -1 * (comp * (veq_kon * E * S - veq_koff * ES) / comp) + comp * vcat_kcat * ES / comp; % E
#> dydt(2) = -1 * (comp * (veq_kon * E * S - veq_koff * ES) / comp); % S
#> dydt(3) = comp * vcat_kcat * ES / comp; % P
#> dydt(4) = comp * (veq_kon * E * S - veq_koff * ES) / comp + -1 * (comp * vcat_kcat * ES / comp); % ES
#>
#> end
#> endMATLAB resolves a function by file name, so the generated function is
named after the output path you gave. Renaming the
.m file breaks it — regenerate instead of
renaming.
out_jl <- tempfile(fileext = ".jl")
convertReactions(sbml_file, out_jl, format = "Julia")
#> Conversion completed.
#> Number of ODEs - 4
cat(readLines(out_jl), sep = "\n")
#> # Automatically generated Julia model file by r2sbml
#> #
#> # Solve with: using DifferentialEquations; sol = solve(prob)
#> # Elements of u are, in order: E, S, P, ES
#> #
#> # Model Summary
#> # compartments: 1
#> # species: 4
#> # parameters: 3
#> # rules: 4
#> # events: 0
#>
#> using DifferentialEquations
#>
#> function massbalances!(du, u, p, t)
#>
#> time = t
#>
#> # Compartments
#> comp = 1e-14
#>
#> # Parameters
#> veq_koff = p[1]
#> veq_kon = p[2]
#> vcat_kcat = p[3]
#>
#> # States
#> E = u[1]
#> S = u[2]
#> P = u[3]
#> ES = u[4]
#>
#> # Mass balances
#> du[1] = -1 * (comp * (veq_kon * E * S - veq_koff * ES) / comp) + comp * vcat_kcat * ES / comp # E
#> du[2] = -1 * (comp * (veq_kon * E * S - veq_koff * ES) / comp) # S
#> du[3] = comp * vcat_kcat * ES / comp # P
#> du[4] = comp * (veq_kon * E * S - veq_koff * ES) / comp + -1 * (comp * vcat_kcat * ES / comp) # ES
#>
#> return nothing
#> end
#>
#> u0 = Float64[5e-07, 1e-06, 0, 0]
#> p = Float64[0.2, 1000000, 0.1]
#> tspan = (0.0, 10.0)
#> prob = ODEProblem(massbalances!, u0, tspan, p)
out_ub <- tempfile(fileext = ".txt")
convertReactions(sbml_file, out_ub, format = "ubiquity")
#> Conversion completed.
#> Number of ODEs - 4
cat(readLines(out_ub), sep = "\n")
#> # Automatically generated ubiquity system file by r2sbml
#> #
#> # Build with: cfg <- build_system(system_file = "<this file>")
#> #
#> # Model Summary
#> # compartments: 1
#> # species: 4
#> # parameters: 3
#> # rules: 4
#> # events: 0
#>
#> # Compartment volumes
#> <P> comp 1e-14 -inf inf litre yes System
#>
#> # Parameters
#> <P> veq_koff 0.2 -inf inf per_second yes System
#> <P> veq_kon 1000000 -inf inf litre_per_mole_second yes System
#> <P> vcat_kcat 0.1 -inf inf per_second yes System
#>
#> # Initial conditions
#> <I> E = 5e-07
#> <I> S = 1e-06
#> <I> P = 0
#> <I> ES = 0
#>
#> # Mass balances
#> <ODE:E> (((-1)*((comp*(((veq_kon*E)*S)-(veq_koff*ES)))/comp))+(((comp*vcat_kcat)*ES)/comp))
#> <ODE:S> ((-1)*((comp*(((veq_kon*E)*S)-(veq_koff*ES)))/comp))
#> <ODE:P> (((comp*vcat_kcat)*ES)/comp)
#> <ODE:ES> (((comp*(((veq_kon*E)*S)-(veq_koff*ES)))/comp)+((-1)*(((comp*vcat_kcat)*ES)/comp)))
#>
#> # Outputs
#> <O> E_out = E
#> <O> S_out = S
#> <O> P_out = P
#> <O> ES_out = ESubiquity has no compartment concept of its own, so a compartment
volume becomes an ordinary <P> constant that the rate
expressions divide by. It also spells exponentiation, the transcendental
functions and every comparison as bracketed prefix calls —
SIMINT_POWER[a][b], SIMINT_LOGN[a],
SIMINT_GT[a][b] — which is why its output looks so
different from the rest.
Parameter bounds are written -inf inf rather than the
eps inf you may have seen in hand-written ubiquity systems:
an SBML value may legitimately be zero or negative, and a lower bound
above the value would be inconsistent. They matter only for
estimation.
Model features that change the shape of the output
Assignment rules
A species driven by an assignment rule is not integrated. It is recomputed inside the right-hand side, before the derivatives that read it:
assign_file <- system.file("examples", "sbmlassignmentrules.xml", package = "r2sbml")
out_assign <- tempfile(fileext = ".R")
convertReactions(assign_file, out_assign, format = "R")
#> Conversion completed.
#> Number of ODEs - 5
lines <- readLines(out_assign)
cat(lines[grep("Assignment Rules", lines):length(lines)], sep = "\n")
#> ## Assignment Rules
#> S1 = T / (1 + Keq)
#> S2 = Keq * S1
#>
#> ## Mass Balances
#> dX0_dt = -1 * (in_k1 * X0 * cell / cell)
#> dX1_dt = out_k2 * S2 * cell / cell
#> dT_dt = in_k1 * X0 * cell / cell + -1 * (out_k2 * S2 * cell / cell)
#>
#> ## Make a list of Mass Balances
#> MassBalances <- c(
#> dX0_dt ,
#> dX1_dt ,
#> dT_dt
#> )
#> return(list(MassBalances))
#>
#> }S1 and S2 have no slot in the state vector
and no derivative; they are locals. Giving them slots would leave the
solver carrying copies that never update while the rules recompute
different values beside them.
Algebraic rules make the model a DAE
An algebraic rule is a constraint, 0 = f(...), not a
derivative. Where the target can solve a differential-algebraic system,
r2sbml emits one.
For deSolve that changes the shape of the generated function
entirely: it becomes a residual function for
daspk of (time, states, derivs, params), not a
derivative function for ode:
alg_file <- system.file("examples", "sbmlalgebraicrules.xml", package = "r2sbml")
out_alg <- tempfile(fileext = ".R")
convertReactions(alg_file, out_alg, format = "R")
#> Conversion completed.
#> Number of ODEs - 4
lines <- readLines(out_alg)
cat(lines[grep("Residuals:", lines)[1]:(grep("return\\(list\\(Residuals", lines))], sep = "\n")
#> ## Residuals: 0 = d<state>/dt - f() for an integrated state,
#> ## 0 = g() for one fixed by an algebraic rule
#> res_E = k1_on * E * S - (k1_off + k2) * ES ## algebraic rule fixing E
#> res_S = derivs[["S"]] - (-1 * (cell * (k1_on * E * S - k1_off * ES) / cell))
#> res_ES = E + ES - E_total ## algebraic rule fixing ES
#> res_P = derivs[["P"]] - (cell * k2 * ES / cell)
#> res_E_total = derivs[["E_total"]] ## E_total is constant
#>
#> ## Make a list of Residuals
#> Residuals <- c(
#> res_E ,
#> res_S ,
#> res_ES ,
#> res_P ,
#> res_E_total
#> )
#> return(list(Residuals))The file also defines InitialDerivatives, because
daspk needs y' at t = 0
consistent with the residuals:
env <- new.env()
source(out_alg, local = env)
sol <- deSolve::daspk(y = env$InitialAmounts,
dy = env$InitialDerivatives,
times = seq(0, 10, by = 2),
res = env$massBalances,
parms = env$parameters)
round(sol, 6)
#> time E S ES P E_total
#> 1 0 0.500000 1.000000 0.500000 0.000000 1
#> 2 2 0.638103 0.567146 0.361897 0.432854 1
#> 3 4 0.782187 0.278465 0.217813 0.721535 1
#> 4 6 0.892834 0.120029 0.107166 0.879971 1
#> 5 8 0.954673 0.047479 0.045327 0.952521 1
#> 6 10 0.982329 0.017989 0.017671 0.982011 1
d <- as.data.frame(sol)
# both algebraic rules hold along the whole trajectory
max(abs(d$E + d$ES - d$E_total))
#> [1] 1.511014e-13
max(abs(1 * d$E * d$S - (0.5 + 0.5) * d$ES))
#> [1] 4.561434e-07MATLAB and Julia keep the ordinary derivative shape and use a
singular mass matrix instead: M * y' = f,
with a zero row wherever a constraint sits, so that row reads
0 = f_i.
out_alg_m <- tempfile(fileext = ".m")
convertReactions(alg_file, out_alg_m, format = "MATLAB")
#> Conversion completed.
#> Number of ODEs - 4
lines <- readLines(out_alg_m)
cat(grep("M = eye|M\\(|MassSingular|ode15s", lines, value = TRUE), sep = "\n")
#> M = eye(5);
#> M(1, 1) = 0; % E
#> M(3, 3) = 0; % ES
#> opts = odeset('Mass', M, 'MassSingular', 'yes');
#> [t, y] = ode15s(@massBalances, tspan, y0, opts);Julia needs both the mass matrix and a solver that accepts
one, so the generated header names Rodas5() rather than a
bare solve(prob):
out_alg_jl <- tempfile(fileext = ".jl")
convertReactions(alg_file, out_alg_jl, format = "Julia")
#> Conversion completed.
#> Number of ODEs - 4
lines <- readLines(out_alg_jl)
cat(grep("Solve with|^M |^M\\[|mass_matrix|ODEProblem", lines, value = TRUE), sep = "\n")
#> # Solve with: using DifferentialEquations; sol = solve(prob, Rodas5())
#> M = zeros(5, 5)
#> M[2, 2] = 1.0 # S
#> M[4, 4] = 1.0 # P
#> M[5, 5] = 1.0 # E_total
#> massbalances = ODEFunction(massbalances!; mass_matrix = M)
#> prob = ODEProblem(massbalances, u0, tspan, p)mrgsolve, rxode2 and ubiquity integrate ODEs only. For those, the
rule is written out as a comment and convertReactions()
warns — see the limitations vignette.
Changing compartment volumes
If a compartment carries a rate rule, its volume is integrated
alongside the species, and every species inside it picks up a dilution
term. Since a species symbol is a concentration, [S] = n/V,
the derivative is
and libSBML’s converter supplies only the first term.
r2sbml adds the second.
The example models all have constant compartments, so here is a small model written out by hand — a species sitting in a compartment that grows at a constant rate, with no reactions at all:
growing <- tempfile(fileext = ".xml")
writeLines('<?xml version="1.0" encoding="UTF-8" ?>
<sbml xmlns="http://www.sbml.org/sbml/level3/version2/core" level="3" version="2">
<model substanceUnits="mole" volumeUnits="litre" timeUnits="second" extentUnits="mole">
<listOfCompartments>
<compartment id="c" size="1" spatialDimensions="3" constant="false"/>
</listOfCompartments>
<listOfSpecies>
<species id="A" compartment="c" initialConcentration="2" boundaryCondition="false"
hasOnlySubstanceUnits="false" constant="false"/>
</listOfSpecies>
<listOfParameters>
<parameter id="g" value="1" constant="true"/>
</listOfParameters>
<listOfRules>
<rateRule variable="c">
<math xmlns="http://www.w3.org/1998/Math/MathML"><ci>g</ci></math>
</rateRule>
</listOfRules>
</model>
</sbml>', growing)
out_grow <- tempfile(fileext = ".R")
convertReactions(growing, out_grow, format = "R")
#> Conversion completed.
#> Number of ODEs - 1
lines <- readLines(out_grow)
cat(grep("InitialAmounts|^ +[Ac] =|_dt|states\\[", lines, value = TRUE), sep = "\n")
#> InitialAmounts <- c(
#> A = 2,
#> c = 1 ## compartment volume, integrated
#> A = states[["A"]]
#> c = states[["c"]]
#> dA_dt = -A * (g) / c ## includes dilution by the changing volume
#> dc_dt = g ## compartment volume
#> dA_dt ,
#> dc_dtc is a state, not a constant, and A has a
non-zero derivative despite taking part in no reaction — its
concentration falls because its container is growing. The exact solution
is [A](t) = 2 / (1 + t), with the amount A * c
fixed at 2:
env <- new.env()
source(out_grow, local = env)
times <- c(0, 1, 3, 7)
d <- as.data.frame(deSolve::ode(y = env$InitialAmounts, times = times,
func = env$massBalances, parms = env$parameters,
rtol = 1e-10, atol = 1e-10))
data.frame(time = times, A = d$A, exact = 2 / (1 + times), amount = d$A * d$c)
#> time A exact amount
#> 1 0 2.00 2.00 2
#> 2 1 1.00 1.00 2
#> 3 3 0.50 0.50 2
#> 4 7 0.25 0.25 2Practical notes
Write to tempfile() in examples and
tests. convertReactions() overwrites its output
path without asking.
Expect console output.
convertReactions() prints
Conversion completed. and the number of ODEs;
getModel() prints its header. Wrap calls in
invisible(capture.output(...)) if that noise is
unwelcome.
Watch for warnings. Several model features cannot be
expressed in every target. Where that is true,
convertReactions() raises an R warning rather than silently
emitting code that runs but computes the wrong thing. The next vignette
lists every such case, and — just as importantly — the cases where no
warning is raised.
Where to go next
vignette("limitations", package = "r2sbml")covers what r2sbml does not do: unsupported SBML
constructs, the per-target capability matrix, which limitations are
announced and which are silent, and the known quirks of the query
functions.