"""
SBML Utilities
--------------
This module provides helper functions for working with SBML.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import sympy as sp
if TYPE_CHECKING:
from typing import Any, Union
SbmlID = Union[str, sp.Symbol]
import xml.dom.minidom
import libsbml
from sympy.core.parameters import evaluate
from sympy.printing.mathml import MathMLContentPrinter
from .import_utils import (
SBMLException,
_check_unsupported_functions,
_parse_special_functions,
amici_time_symbol,
sbml_time_symbol,
)
[docs]
class SbmlInvalidIdSyntax(SBMLException):
pass
[docs]
class SbmlDuplicateComponentIdError(SBMLException):
pass
[docs]
class SbmlMissingComponentIdError(SBMLException):
pass
[docs]
class SbmlMathError(SBMLException):
pass
[docs]
class SbmlAnnotationError(SBMLException):
pass
[docs]
def create_sbml_model(
model_id: str, level: int = 2, version: int = 5
) -> tuple[libsbml.SBMLDocument, libsbml.Model]:
"""Helper for creating an empty SBML model.
:param model_id:
SBML ID of the new model.
:param level:
Level of the new SBML document.
:param version:
Version of the new SBML document.
:return:
A tuple containing the newly created :py:class:`libsbml.SBMLDocument`
and :py:class:`libsbml.Model`.
"""
doc = libsbml.SBMLDocument(level, version)
model = doc.createModel()
model.setId(model_id)
return doc, model
[docs]
def add_compartment(
model: libsbml.Model,
compartment_id: SbmlID,
*,
size: float = 1.0,
) -> libsbml.Species:
"""Helper for adding a compartment to a SBML model.
:param model:
SBML model to which the compartment is to be added.
:param compartment_id:
SBML ID of the new compartment.
:param size:
Size of the new compartment. Defaults to `1.0`.
:return:
The new compartment as a :py:class:`libsbml.Compartment` object.
"""
compartment_id = str(compartment_id)
# Check whether a compartment with the same ID already exists
# TODO the resulting SBML may still be invalid
# if other types of objects (e.g., parameter) have the same ID
if model.getCompartment(compartment_id):
raise SbmlDuplicateComponentIdError(
f"A compartment with ID {compartment_id} has already been defined"
)
cmp = model.createCompartment()
if cmp.setId(compartment_id) != libsbml.LIBSBML_OPERATION_SUCCESS:
raise SbmlInvalidIdSyntax(f"{compartment_id} is not a valid SBML ID")
cmp.setSize(size)
return cmp
[docs]
def add_species(
model: libsbml.Model,
species_id: SbmlID,
*,
compartment_id: str | None = None,
name: bool | str = False,
initial_amount: float = 0.0,
units: str | None = None,
) -> libsbml.Species:
"""Helper for adding a species to a SBML model.
:param model:
SBML model to which the species is to be added.
:param species_id:
SBML ID of the new species.
:param compartment_id:
Compartment ID for the new species.
If there is only one compartment it can be auto-selected.
:param initial_amount:
Initial amount of the new species.
:param units:
Units attribute for the new species.
:return:
The new species as a :py:class:`libsbml.Species` object.
"""
species_id = str(species_id)
if name is True:
name = species_id
# Check whether an element with the same ID already exists
if model.getElementBySId(species_id):
raise SbmlDuplicateComponentIdError(
f"An element with ID {species_id} has already been defined."
)
if compartment_id is None:
compartments = model.getListOfCompartments()
if len(compartments) != 1:
raise ValueError(
"Compartment auto-selection is possible "
"only if there is one and only one compartment."
)
compartment_id = compartments[0].getId()
elif not model.getCompartment(compartment_id):
raise SbmlMissingComponentIdError(
f"No compartment with ID {compartment_id}."
)
sp = model.createSpecies()
if sp.setIdAttribute(species_id) != libsbml.LIBSBML_OPERATION_SUCCESS:
raise SbmlInvalidIdSyntax(f"{species_id} is not a valid SBML ID.")
sp.setCompartment(compartment_id)
sp.setInitialAmount(float(initial_amount))
if units is not None:
sp.setUnits(str(units))
if isinstance(name, str):
sp.setName(name)
return sp
[docs]
def add_parameter(
model: libsbml.Model,
parameter_id: SbmlID,
*,
name: bool | str = False,
value: float | None = None,
units: str | None = None,
constant: bool | None = None,
) -> libsbml.Parameter:
"""Helper for adding a parameter to a SBML model.
:param model:
SBML model to which the parameter is to be added.
:param parameter_id:
SBML ID of the new parameter.
:param name:
SBML name of the new parameter.
:param value:
Value attribute for the new parameter.
:param units:
Units attribute for the new parameter.
:param constant:
Constant attribute for the new parameter.
:return:
The new parameter as a :py:class:`libsbml.Parameter` object.
"""
parameter_id = str(parameter_id)
if name is True:
name = parameter_id
# Check whether an element with the same ID already exists
if model.getElementBySId(parameter_id):
raise SbmlDuplicateComponentIdError(
f"An element with ID {parameter_id} has already been defined."
)
par = model.createParameter()
if par.setIdAttribute(parameter_id) != libsbml.LIBSBML_OPERATION_SUCCESS:
raise SbmlInvalidIdSyntax(f"{parameter_id} is not a valid SBML ID.")
if units is not None:
par.setUnits(str(units))
if constant is not None:
par.setConstant(bool(constant))
if value is not None:
par.setValue(float(value))
if isinstance(name, str):
par.setName(name)
return par
[docs]
def add_assignment_rule(
model: libsbml.Model,
variable_id: SbmlID,
formula,
rule_id: str | None = None,
) -> libsbml.AssignmentRule:
"""Helper for adding an assignment rule to a SBML model.
:param model:
SBML model to which the assignment rule is to be added.
:param variable_id:
SBML ID of the quantity for which the assignment rule is to be added.
:param formula:
Formula for the assignment rule (it will be sympified).
:param rule_id:
SBML ID of the new assignment rule.
Defaults to `'assignment_' + variableId`.
:return:
The assignment rule as a :py:class:`libsbml.AssignmentRule` object.
"""
variable_id = str(variable_id)
if rule_id is None:
rule_id = "assignment_" + variable_id
# Check whether rules exists for this parameter or with the same name
if model.getRuleByVariable(variable_id):
raise SbmlDuplicateComponentIdError(
f"A rule for parameter {variable_id} has already been defined."
)
if model.getElementBySId(rule_id):
raise SbmlDuplicateComponentIdError(
f"An element with SBML ID {rule_id} has already been defined."
)
rule = model.createAssignmentRule()
if rule.setVariable(variable_id) != libsbml.LIBSBML_OPERATION_SUCCESS:
raise SbmlInvalidIdSyntax(f"{variable_id} is not a valid SBML ID.")
if rule.setIdAttribute(rule_id) != libsbml.LIBSBML_OPERATION_SUCCESS:
raise SbmlInvalidIdSyntax(f"{rule_id} is not a valid SBML ID.")
set_sbml_math(rule, formula)
return rule
[docs]
def add_rate_rule(
model: libsbml.Model,
variable_id: SbmlID,
formula,
rule_id: str | None = None,
) -> libsbml.RateRule:
"""
Helper for adding a rate rule to a SBML model.
:param model:
SBML model to which the rate rule is to be added.
:param variable_id:
SBML ID of the quantity for which the rate rule is to be added.
:param formula:
Formula for the rate rule (it will be sympified).
:param rule_id:
SBML ID of the new rate rule.
Defaults to `'rate_' + variableId`.
:return:
The new rate rule as a :py:class:`libsbml.RateRule` object.
"""
variable_id = str(variable_id)
if rule_id is None:
rule_id = "rate_" + variable_id
# Check whether rules exists for this parameter or with the same name
if model.getRuleByVariable(variable_id):
raise SbmlDuplicateComponentIdError(
f"A rule for parameter {variable_id} has already been defined."
)
if model.getElementBySId(rule_id):
raise SbmlDuplicateComponentIdError(
f"An element with SBML ID {rule_id} has already been defined."
)
rule = model.createRateRule()
if rule.setVariable(variable_id) != libsbml.LIBSBML_OPERATION_SUCCESS:
raise SbmlInvalidIdSyntax(f"{variable_id} is not a valid SBML ID.")
if rule.setIdAttribute(rule_id) != libsbml.LIBSBML_OPERATION_SUCCESS:
raise SbmlInvalidIdSyntax(f"{rule_id} is not a valid SBML ID.")
set_sbml_math(rule, formula)
return rule
[docs]
def add_inflow(
model: libsbml.Model,
species_id: SbmlID,
rate,
*,
reaction_id: str | None = None,
reversible: bool = False,
) -> libsbml.Reaction:
species_id = str(species_id)
if reaction_id is None:
reaction_id = f"inflow_of_{species_id}"
if model.getElementBySId(reaction_id):
raise SbmlDuplicateComponentIdError(
f"An element with SBML ID {reaction_id} has already been defined."
)
reaction = model.createReaction()
if reaction.setId(reaction_id) != libsbml.LIBSBML_OPERATION_SUCCESS:
raise SbmlInvalidIdSyntax(f"{reaction_id} is not a valid SBML ID.")
reaction.setReversible(reversible)
spr = reaction.createProduct()
spr.setSpecies(species_id)
kl = reaction.createKineticLaw()
compartment_id = model.getSpecies(species_id).getCompartment()
set_sbml_math(kl, sp.Symbol(compartment_id) * rate)
return reaction
[docs]
def get_sbml_units(model: libsbml.Model, x: SbmlID | sp.Basic) -> None | str:
"""Try to get the units for expression `x`.
:param model:
SBML model.
:param x:
Expression to get the units of.
:return:
A string if the units could be determined, otherwise `None`.
"""
# TODO can the SBML unit inference machinery be used?
x = sp.sympify(x)
if not x.is_Symbol:
return None
if x.name == sbml_time_symbol.name:
if model.isSetTimeUnits():
return model.getTimeUnits()
return None
par = model.getParameter(x.name)
if par is None:
return None
units = par.getUnits()
if units == "":
return None
return units
[docs]
def pretty_xml(ugly_xml: str) -> str:
"Prettifies an XML document (given as a string)."
dom = xml.dom.minidom.parseString(ugly_xml)
pretty_xml = dom.toprettyxml()
# We must delete the first line (xml header)
return pretty_xml[pretty_xml.index("\n") + 1 :]
[docs]
class MathMLSbmlPrinter(MathMLContentPrinter):
"""Prints a SymPy expression to a MathML expression parsable by libSBML.
Differences from `sympy.MathMLContentPrinter`:
1. underscores in symbol names are not converted to subscripts
2. symbols with name 'time' are converted to the SBML time symbol
"""
def _print_Symbol(self, sym: sp.Symbol) -> xml.dom.minidom.Element:
ci = self.dom.createElement(self.mathml_tag(sym))
ci.appendChild(self.dom.createTextNode(sym.name))
return ci
[docs]
def doprint(self, expr, *, pretty: bool = False) -> str:
mathml = '<math xmlns="http://www.w3.org/1998/Math/MathML">'
mathml += super().doprint(expr)
mathml += "</math>"
mathml = mathml.replace(
"<ci>time</ci>",
'<csymbol encoding="text" definitionURL='
'"http://www.sbml.org/sbml/symbols/time"> time </csymbol>',
)
return pretty_xml(mathml) if pretty else mathml
[docs]
def sbml_mathml(
expr, *, replace_time: bool = False, pretty: bool = False, **settings
) -> str:
"""Prints a SymPy expression to a MathML expression parsable by libSBML.
:param expr:
expression to be converted to MathML (will be sympified).
:param replace_time:
replace the AMICI time symbol with the SBML time symbol.
:param pretty:
prettify the resulting MathML.
"""
with evaluate(False):
expr = sp.sympify(expr)
if replace_time:
expr = expr.subs(amici_time_symbol, sbml_time_symbol)
return MathMLSbmlPrinter(settings).doprint(expr, pretty=pretty)
[docs]
def sbml_math_ast(expr, **kwargs) -> libsbml.ASTNode:
"""Convert a SymPy expression to SBML math AST.
:param expr:
expression to be converted (will be sympified).
:param kwargs:
extra options for MathML conversion.
"""
mathml = sbml_mathml(expr, **kwargs)
ast = libsbml.readMathMLFromString(mathml)
if ast is None:
raise SbmlMathError(
f"error while converting the following expression to SBML AST.\n"
f"expression:\n{expr}\n"
f"MathML:\n{pretty_xml(mathml)}"
)
return ast
[docs]
def set_sbml_math(obj: libsbml.SBase, expr, **kwargs) -> None:
"""Set the math attribute of a SBML node using a SymPy expression.
:param obj:
SBML node supporting `setMath` method.
:param expr:
expression to which the math attribute of `obj` should be se to
(will be sympified).
:param kwargs:
extra options for MathML conversion.
"""
mathml = sbml_math_ast(expr, **kwargs)
if obj.setMath(mathml) != libsbml.LIBSBML_OPERATION_SUCCESS:
raise SbmlMathError(
f"Could not set math attribute of SBML object {obj}\n"
f"expression:\n{expr}\n"
f"MathML:\n{pretty_xml(mathml)}"
)
[docs]
def mathml2sympy(
mathml: str,
*,
evaluate: bool = False,
locals: dict[str, Any] | None = None,
expression_type: str = "mathml2sympy",
) -> sp.Basic:
ast = libsbml.readMathMLFromString(mathml)
if ast is None:
raise ValueError(
f"libSBML could not parse MathML string:\n{pretty_xml(mathml)}"
)
formula = _parse_logical_operators(libsbml.formulaToL3String(ast))
if evaluate:
expr = sp.sympify(formula, locals=locals)
else:
with sp.core.parameters.evaluate(False):
expr = sp.sympify(formula, locals=locals)
expr = _parse_special_functions(expr)
if expression_type is not None:
_check_unsupported_functions(expr, expression_type)
return expr
def _parse_logical_operators(
math_str: str | float | None,
) -> str | float | None:
"""
Parses a math string in order to replace logical operators by a form
parsable for sympy
:param math_str:
str with mathematical expression
:param math_str:
parsed math_str
"""
if not isinstance(math_str, str):
return math_str
if " xor(" in math_str or " Xor(" in math_str:
raise SBMLException(
"Xor is currently not supported as logical " "operation."
)
return (math_str.replace("&&", "&")).replace("||", "|")