diff --git a/python/sdist/amici/de_model.py b/python/sdist/amici/de_model.py index 463f78f927..46270d0a50 100644 --- a/python/sdist/amici/de_model.py +++ b/python/sdist/amici/de_model.py @@ -409,7 +409,8 @@ def states(self) -> list[State]: def _process_sbml_rate_of(self) -> None: """Substitute any SBML-rateOf constructs in the model equations""" - rate_of_func = sp.core.function.UndefinedFunction("rateOf") + from sbmlmath import rate_of as rate_of_func + species_sym_to_xdot = dict( zip(self.sym("x"), self.sym("xdot"), strict=True) ) @@ -449,25 +450,21 @@ def get_rate(symbol: sp.Symbol): # replace rateOf-instances in x0 by xdot equation for i_state in range(len(self.eq("x0"))): - if rate_ofs := self._eqs["x0"][i_state].find(rate_of_func): - self._eqs["x0"][i_state] = self._eqs["x0"][i_state].subs( - { - rate_of: get_rate(rate_of.args[0]) - for rate_of in rate_ofs - } - ) + new, replacement = self._eqs["x0"][i_state].replace( + rate_of_func, get_rate, map=True + ) + if replacement: + self._eqs["x0"][i_state] = new # replace rateOf-instances in w by xdot equation # here we may need toposort, as xdot may depend on w made_substitutions = False for i_expr in range(len(self.eq("w"))): - if rate_ofs := self._eqs["w"][i_expr].find(rate_of_func): - self._eqs["w"][i_expr] = self._eqs["w"][i_expr].subs( - { - rate_of: get_rate(rate_of.args[0]) - for rate_of in rate_ofs - } - ) + new, replacement = self._eqs["w"][i_expr].replace( + rate_of_func, get_rate, map=True + ) + if replacement: + self._eqs["w"][i_expr] = new made_substitutions = True if made_substitutions: diff --git a/python/sdist/amici/import_utils.py b/python/sdist/amici/import_utils.py index 6cadfd390b..4502e3d0b0 100644 --- a/python/sdist/amici/import_utils.py +++ b/python/sdist/amici/import_utils.py @@ -540,6 +540,8 @@ def _parse_heaviside_trigger(trigger: sp.Expr) -> sp.Expr: ] ) + # TODO: x XOR y = (A & ~B) | (~A & B) + # TODO: x == y if isinstance(trigger, sp.And): return sp.Mul(*[_parse_heaviside_trigger(arg) for arg in trigger.args]) @@ -598,6 +600,7 @@ def _check_unsupported_functions( sp.functions.factorial, sp.functions.ceiling, sp.functions.floor, + sp.functions.tan, sp.functions.sec, sp.functions.csc, sp.functions.cot, diff --git a/python/sdist/amici/sbml_import.py b/python/sdist/amici/sbml_import.py index eb35132ad5..4ad578098e 100644 --- a/python/sdist/amici/sbml_import.py +++ b/python/sdist/amici/sbml_import.py @@ -2,7 +2,7 @@ SBML Import ----------- This module provides all necessary functionality to import a model specified -in the `Systems Biology Markup Language (SBML) `_. +in the `Systems Biology Markup Language (SBML) `_. """ import copy @@ -21,9 +21,10 @@ from collections.abc import Iterable, Sequence import libsbml +from sbmlmath import SBMLMathMLParser, TimeSymbol, avogadro import numpy as np import sympy as sp -from sympy.logic.boolalg import BooleanFalse, BooleanTrue +from sympy.logic.boolalg import BooleanFalse, BooleanTrue, BooleanFunction from . import has_clibs from .de_model import DEModel @@ -37,7 +38,6 @@ RESERVED_SYMBOLS, _check_unsupported_functions, _get_str_symbol_identifiers, - _parse_special_functions, amici_time_symbol, annotation_namespace, generate_measurement_symbol, @@ -51,9 +51,10 @@ toposort_symbols, _default_simplify, generate_flux_symbol, + _parse_piecewise_to_heaviside, ) from .logging import get_logger, log_execution_time, set_log_level -from .sbml_utils import SBMLException, _parse_logical_operators +from .sbml_utils import SBMLException from .splines import AbstractSpline from sympy.matrices.dense import MutableDenseMatrix @@ -193,6 +194,13 @@ def __init__( self.sbml_parser_settings.setParseUnits(libsbml.L3P_NO_UNITS) self._discard_annotations: bool = discard_annotations + self._mathml_parser = SBMLMathMLParser( + sbml_level=self.sbml.getLevel(), + sbml_version=self.sbml.getVersion(), + symbol_kwargs={"real": True}, + ignore_units=True, + evaluate=True, + ) @log_execution_time("loading SBML", logger) def _process_document(self) -> None: @@ -282,7 +290,7 @@ def sbml2amici( compute_conservation_laws: bool = True, simplify: Callable | None = _default_simplify, cache_simplify: bool = False, - log_as_log10: bool = True, + log_as_log10: bool = None, generate_sensitivity_code: bool = True, hardcode_symbols: Sequence[str] = None, ) -> None: @@ -313,11 +321,6 @@ def sbml2amici( Passing a sympy expression directly will be the safer option for more complex expressions. - .. note:: - - When passing sympy expressions, all Symbols therein must have the - ``real=True`` assumption. - .. note:: In any math expressions passed to this function, ``time`` will @@ -443,9 +446,8 @@ def sbml2amici( See :meth:`amici.DEModel.__init__`. :param log_as_log10: - If ``True``, log in the SBML model will be parsed as ``log10`` - (default), if ``False``, log will be parsed as natural logarithm - ``ln``. + This option is deprecated and will be removed in a future version. + Also, this option never had any effect on model import. :param generate_sensitivity_code: If ``False``, the code required for sensitivity computation will @@ -459,6 +461,16 @@ def sbml2amici( """ set_log_level(logger, verbose) + if log_as_log10 is not None: + # deprecated 04/2025 + warnings.warn( + "The `log_as_log10` argument is deprecated and will be " + "removed in a future version. This argument can safely be " + "dropped without replacement.", + category=DeprecationWarning, + stacklevel=2, + ) + ode_model = self._build_ode_model( observables=observables, event_observables=event_observables, @@ -471,7 +483,6 @@ def sbml2amici( compute_conservation_laws=compute_conservation_laws, simplify=simplify, cache_simplify=cache_simplify, - log_as_log10=log_as_log10, hardcode_symbols=hardcode_symbols, ) @@ -507,7 +518,7 @@ def sbml2jax( compute_conservation_laws: bool = True, simplify: Callable | None = _default_simplify, cache_simplify: bool = False, - log_as_log10: bool = True, + log_as_log10: bool = None, ) -> None: """ Generate and compile AMICI jax files for the model provided to the @@ -573,12 +584,20 @@ def sbml2jax( see :meth:`amici.DEModel.__init__` :param log_as_log10: - If ``True``, log in the SBML model will be parsed as ``log10`` - (default), if ``False``, log will be parsed as natural logarithm - ``ln``. + This option is deprecated and will be removed in a future version. + Also, this option never had any effect on model import. """ set_log_level(logger, verbose) + if log_as_log10 is not None: + warnings.warn( + "The `log_as_log10` argument is deprecated and will be " + "removed in a future version. This argument can safely be " + "dropped without replacement.", + category=DeprecationWarning, + stacklevel=2, + ) + ode_model = self._build_ode_model( observables=observables, sigmas=sigmas, @@ -587,7 +606,6 @@ def sbml2jax( compute_conservation_laws=compute_conservation_laws, simplify=simplify, cache_simplify=cache_simplify, - log_as_log10=log_as_log10, ) from amici.jax.ode_export import ODEExporter @@ -613,7 +631,6 @@ def _build_ode_model( compute_conservation_laws: bool = True, simplify: Callable | None = _default_simplify, cache_simplify: bool = False, - log_as_log10: bool = True, hardcode_symbols: Sequence[str] = None, ) -> DEModel: """Generate a DEModel from this SBML model. @@ -644,11 +661,6 @@ def _build_ode_model( event_noise_distributions = {} self._reset_symbols() - self.sbml_parser_settings.setParseLog( - libsbml.L3P_PARSE_LOG_AS_LOG10 - if log_as_log10 - else libsbml.L3P_PARSE_LOG_AS_LN - ) self._process_sbml( constant_parameters=constant_parameters, hardcode_symbols=hardcode_symbols, @@ -905,7 +917,7 @@ def check_event_support(self) -> None: delay = event.getDelay() if delay is not None: try: - delay_time = float(self._sympy_from_sbml_math(delay)) + delay_time = float(self._sympify(delay)) if delay_time != 0: raise ValueError # `TypeError` would be raised in the above `float(...)` @@ -973,6 +985,7 @@ def _gather_base_locals( # to the most recent SI definition. "avogadro": sp.Float(6.02214179e23), "exponentiale": sp.E, + "log10": lambda x: sp.log(x, 10), } for s, v in special_symbols_and_funs.items(): self.add_local_symbol(s, v) @@ -1026,7 +1039,7 @@ def _gather_dependent_locals(self): continue self.add_local_symbol( r.getId(), - self._sympy_from_sbml_math(r.getKineticLaw() or sp.Float(0)), + self._sympify(r.getKineticLaw() or sp.Float(0)), ) def add_local_symbol(self, key: str, value: sp.Expr): @@ -1070,7 +1083,7 @@ def _process_compartments(self) -> None: init = sp.Float(1.0) if comp.isSetVolume(): - init = self._sympy_from_sbml_math(comp.getVolume()) + init = self._sympify(comp.getVolume()) ia_sym = self._get_element_initial_assignment(comp.getId()) if ia_sym is not None: @@ -1171,7 +1184,7 @@ def _process_rate_rules(self): continue variable = symbol_with_assumptions(rule.getVariable()) - formula = self._sympy_from_sbml_math(rule) + formula = self._sympify(rule) if formula is None: continue @@ -1189,7 +1202,7 @@ def _process_rate_rules(self): del self.compartments[variable] elif variable in self.symbols[SymbolId.PARAMETER]: - init = self._sympy_from_sbml_math( + init = self._sympify( self.symbols[SymbolId.PARAMETER][variable]["value"], ) name = self.symbols[SymbolId.PARAMETER][variable]["name"] @@ -1431,7 +1444,7 @@ def _process_reactions(self): if reaction.isSetId(): sym_math = self._local_symbols[reaction.getId()] else: - sym_math = self._sympy_from_sbml_math( + sym_math = self._sympify( reaction.getKineticLaw() or sp.Float(0) ) @@ -1484,7 +1497,7 @@ def _process_rules(self) -> None: ) def _process_rule_algebraic(self, rule: libsbml.AlgebraicRule): - formula = self._sympy_from_sbml_math(rule) + formula = self._sympify(rule) if formula is None: return @@ -1636,7 +1649,7 @@ def _process_rule_assignment(self, rule: libsbml.AssignmentRule): self.splines.append(spline) return - formula = self._sympy_from_sbml_math(rule) + formula = self._sympify(rule) if formula is None: return @@ -1791,7 +1804,7 @@ def get_empty_bolus_value() -> sp.Float: # get and parse the trigger function trigger_sbml = event.getTrigger() - trigger_sym = self._sympy_from_sbml_math(trigger_sbml) + trigger_sym = self._sympify(trigger_sbml, bool2num=False) trigger = _parse_event_trigger(trigger_sym) # parse the boluses / event assignments @@ -1807,7 +1820,7 @@ def get_empty_bolus_value() -> sp.Float: if event_assignment.getMath() is None: # Ignore event assignments with no change in value. continue - formula = self._sympy_from_sbml_math(event_assignment) + formula = self._sympify(event_assignment) try: # Try to find the species in the state vector. index = state_vector.index(variable_sym) @@ -2017,7 +2030,7 @@ def _process_observables( self.symbols[SymbolId.OBSERVABLE] = { symbol_with_assumptions(obs): { "name": definition.get("name", f"y{iobs}"), - "value": self._sympy_from_sbml_math(definition["formula"]), + "value": self._sympify(definition["formula"]), "transformation": noise_distribution_to_observable_transformation( noise_distributions.get(obs, "normal") ), @@ -2047,9 +2060,7 @@ def _process_observables( { name for sigma in sigmas.values() - for symbol in self._sympy_from_sbml_math( - sigma - ).free_symbols + for symbol in self._sympify(sigma).free_symbols if re.match(r"noiseParameter\d+$", (name := str(symbol))) } ) @@ -2064,9 +2075,7 @@ def _process_observables( { name for obs in observables.values() - for symbol in self._sympy_from_sbml_math( - obs["formula"] - ).free_symbols + for symbol in self._sympify(obs["formula"]).free_symbols if re.match( r"observableParameter\d+$", (name := str(symbol)) ) @@ -2127,7 +2136,7 @@ def _process_event_observables( self.symbols[SymbolId.EVENT_OBSERVABLE] = { symbol_with_assumptions(obs): { "name": definition.get("name", f"z{iobs}"), - "value": self._sympy_from_sbml_math(definition["formula"]), + "value": self._sympify(definition["formula"]), "event": sp.Symbol(definition.get("event")), "transformation": noise_distribution_to_observable_transformation( event_noise_distributions.get(obs, "normal") @@ -2249,7 +2258,7 @@ def _process_log_likelihood( if not event_reg: sigmas = { - obs_id: self._sympy_from_sbml_math(sigma) + obs_id: self._sympify(sigma) for obs_id, sigma in sigmas.items() } obs_syms = set(self.symbols[obs_symbol].keys()) @@ -2281,6 +2290,8 @@ def _process_log_likelihood( symbol = symbol_with_assumptions(f"J{obs_id}") dist = noise_distributions.get(str(obs_id), "normal") cost_fun = noise_distribution_to_cost_function(dist)(obs_id) + # TODO: clarify expected grammar for cost_fun + # python vs SBML L3 vs PEtab value = sp.sympify( cost_fun, locals=dict( @@ -2365,7 +2376,7 @@ def _process_species_references(self): stoich = self._get_element_stoichiometry(species_reference) self._replace_in_all_expressions( _get_identifier_symbol(species_reference), - self._sympy_from_sbml_math(stoich), + self._sympify(stoich), ) def _make_initial( @@ -2844,58 +2855,157 @@ def _clean_reserved_symbols(self) -> None: for k, v in symbols.items() } - def _sympy_from_sbml_math( - self, var_or_math: [libsbml.SBase, str] - ) -> sp.Expr | float | None: + def _sympify( + self, + var_or_math: libsbml.SBase + | libsbml.ASTNode + | float + | int + | sp.Basic + | str, + piecewise_to_heaviside: bool = True, + bool2num: bool = True, + ) -> sp.Expr | None: """ - Sympify Math of SBML variables with all sanity checks and - transformations + Sympify math expressions with all sanity checks and transformations. :param var_or_math: - SBML variable that has a getMath() function or math string - :return: - sympfified symbolic expression + A math expression to sympify: + + * a string (see `sympy.sympify`) + * a number + * a libSBML ASTNode + * a libSBML object that has a ``getMath()`` function + :param piecewise_to_heaviside: + If ``True``, piecewise expressions are transformed to Heaviside + expressions. If ``False``, piecewise expressions are returned as + is. + :param bool2num: + If ``True``, boolean expressions are transformed to numeric + expressions. If ``False``, boolean expressions are returned as is. + :raises SBMLException: + In case of unsupported expressions. + :returns: + The sympy expression or None if the input was None or an empty string. """ - if isinstance(var_or_math, libsbml.SBase): - math_string = libsbml.formulaToL3StringWithSettings( - var_or_math.getMath(), self.sbml_parser_settings + if var_or_math is None: + return None + + # numeric values + if isinstance(var_or_math, float | int): + return ( + sp.Integer(var_or_math) + if var_or_math.is_integer() + else sp.Float(var_or_math) ) - ele_name = var_or_math.element_name - else: - math_string = var_or_math - ele_name = "string" - math_string = replace_logx(math_string) - try: + + def subs_locals(expr: sp.Basic) -> sp.Basic: + """ + Substitute free symbols to match assumptions of other model + entities where necessary, and replace parameters by values in case + of hardcoded parameters. + """ + return expr.subs( + { + sym: local + for sym in expr.free_symbols + if (local := self._local_symbols.get(str(sym), sym)) != sym + } + ) + + # already a sympy object + if isinstance(var_or_math, sp.Basic): + ele_name = "SymPy expression" + expr = subs_locals(var_or_math) + + # an expression string + elif isinstance(var_or_math, str): + if not var_or_math: + return None + + ele_name = "Expression string" try: - formula = sp.sympify( - _parse_logical_operators(math_string), - locals=self._local_symbols, + expr = sp.sympify( + var_or_math, + locals={ + "true": sp.Float(1.0), + "false": sp.Float(0.0), + **self._local_symbols, + }, ) - except TypeError as err: - if str(err) == "BooleanAtom not allowed in this context.": - formula = sp.sympify( - _parse_logical_operators(math_string), - locals={ - "true": sp.Float(1.0), - "false": sp.Float(0.0), - **self._local_symbols, - }, - ) - else: - raise - except (sp.SympifyError, TypeError, ZeroDivisionError) as err: - raise SBMLException( - f'{ele_name} "{math_string}" ' - "contains an unsupported expression: " - f"{err}." + except (sp.SympifyError, TypeError, ZeroDivisionError) as err: + raise SBMLException( + f'{ele_name} "{var_or_math}" ' + "contains an unsupported expression: " + f"{err}." + ) + + # actual libSBML objects + elif isinstance(var_or_math, libsbml.ASTNode | libsbml.SBase): + if isinstance(var_or_math, libsbml.ASTNode): + ast_node = var_or_math + sbml_obj = ast_node.getParentSBMLObject() + elif isinstance(var_or_math, libsbml.SBase): + sbml_obj = var_or_math + ast_node = var_or_math.getMath() + if ast_node is None: + return None + ele_name = sbml_obj.getId() + + mathml = libsbml.writeMathMLWithNamespaceToString( + ast_node, + libsbml.SBMLNamespaces( + self.sbml.getLevel(), self.sbml.getVersion() + ), + ) + try: + expr = self._mathml_parser.parse_str(mathml) + except (ValueError, NotImplementedError) as err: + raise SBMLException( + f'{ele_name} "{mathml}" ' + "contains an unsupported expression: " + f"{err}." + ) + # replace special time object by `sbml_time_symbol` + # which will later be replaced by `amici_time_symbol` + expr = expr.replace(TimeSymbol, lambda *args: sbml_time_symbol) + expr = expr.subs(avogadro, avogadro.evalf()) + expr = subs_locals(expr) + else: + raise ValueError( + f"Unsupported input: {var_or_math}, type: {type(var_or_math)}" ) - if isinstance(formula, sp.Expr): - formula = _parse_special_functions_sbml(formula) - _check_unsupported_functions_sbml( - formula, expression_type=ele_name + if expr.has(sp.Xor): + raise SBMLException( + "Xor is currently not supported as logical operation." ) - return formula + + try: + _check_unsupported_functions_sbml(expr, expression_type=ele_name) + except SBMLException: + # try to (partially) evaluate expressions + # that would otherwise be unsupported + expr = expr.simplify().evalf() + _check_unsupported_functions_sbml(expr, expression_type=ele_name) + + # piecewise to heavisides + if piecewise_to_heaviside: + try: + expr = expr.replace( + sp.Piecewise, + lambda *args: _parse_piecewise_to_heaviside(args), + ) + except RuntimeError as err: + raise SBMLException(str(err)) from err + + # boolean to numeric piecewise + if bool2num and isinstance(expr, BooleanFunction): + from sbmlmath.mathml_parser import _bool2num + + expr = _bool2num(expr) + + return expr def _get_element_initial_assignment( self, element_id: str @@ -2911,7 +3021,7 @@ def _get_element_initial_assignment( assignment = self.sbml.getInitialAssignment(element_id) if assignment is None: return None - sym = self._sympy_from_sbml_math(assignment) + sym = self._sympify(assignment, piecewise_to_heaviside=False) # this is an initial assignment so we need to use # initial conditions sym = self._make_initial(sym) @@ -2956,7 +3066,7 @@ def is_assignment_rule_target(self, element: libsbml.SBase) -> bool: boolean indicating truth of function name """ a = self.sbml.getAssignmentRuleByVariable(element.getId()) - return a is not None and self._sympy_from_sbml_math(a) is not None + return a is not None and self._sympify(a) is not None def is_rate_rule_target(self, element: libsbml.SBase) -> bool: """ @@ -2970,7 +3080,7 @@ def is_rate_rule_target(self, element: libsbml.SBase) -> bool: boolean indicating truth of function name """ a = self.sbml.getRateRuleByVariable(element.getId()) - return a is not None and self._sympy_from_sbml_math(a) is not None + return a is not None and self._sympify(a) is not None def _transform_dxdt_to_concentration( self, species_id: sp.Symbol, dxdt: sp.Expr @@ -3087,7 +3197,7 @@ def _parse_event_trigger(trigger: sp.Expr) -> sp.Expr: """ # Events can be defined without trigger, i.e., the event will never fire. # In this case, set a dummy trigger: - if trigger is None: + if trigger is None or trigger is sp.false: return sp.Float(1.0) if trigger.is_Relational: root = trigger.args[0] - trigger.args[1] @@ -3282,22 +3392,6 @@ def _get_list_of_species_references( ] -def replace_logx(math_str: str | float | None) -> str | float | None: - """ - Replace logX(.) by log(., X) since sympy cannot parse the former - - :param math_str: - string for sympification - - :return: - sympifiable string - """ - if not isinstance(math_str, str): - return math_str - - return re.sub(r"(^|\W)log(\d+)\(", r"\g<1>1/ln(\2)*ln(", math_str) - - def _collect_event_assignment_parameter_targets( sbml_model: libsbml.Model, ) -> list[sp.Symbol]: @@ -3325,15 +3419,6 @@ def _check_unsupported_functions_sbml( raise SBMLException(str(err)) -def _parse_special_functions_sbml( - sym: sp.Expr, toplevel: bool = True -) -> sp.Expr: - try: - return _parse_special_functions(sym, toplevel) - except RuntimeError as err: - raise SBMLException(str(err)) - - def _validate_observables( observables: dict[str, dict[str, str | sp.Expr]] | None, sigmas: dict[str, str | float | sp.Expr], @@ -3409,7 +3494,9 @@ def _non_const_conservation_laws_supported(sbml_model: libsbml.Model) -> bool: return True -def _rateof_to_dummy(sym_math): +def _rateof_to_dummy( + sym_math: sp.Expr, +) -> tuple[sp.Expr, dict[sp.Function, sp.Dummy]]: """Replace rateOf(...) by dummy variable if `rateOf(some_species)` is used in an initial assignment, we don't want to substitute the species argument @@ -3420,18 +3507,20 @@ def _rateof_to_dummy(sym_math): [...substitute...] sym_math = _dummy_to_rateof(sym_math, rateof_to_dummy) """ - if rate_ofs := sym_math.find(sp.core.function.UndefinedFunction("rateOf")): - # replace by dummies to avoid species substitution - rateof_dummies = { - rate_of: sp.Dummy(f"Dummy_RateOf_{rate_of.args[0].name}") - for rate_of in rate_ofs - } + from sbmlmath import rate_of - return sym_math.subs(rateof_dummies), rateof_dummies - return sym_math, {} + new_expr, rateof_to_dummies = sym_math.replace( + # `x` is the rateOf argument + rate_of, + lambda x: sp.Dummy(f"Dummy_RateOf_{x.name}"), + map=True, + ) + return new_expr, rateof_to_dummies -def _dummy_to_rateof(sym_math, rateof_dummies): +def _dummy_to_rateof( + sym_math: sp.Expr, rateof_dummies: dict[sp.Function, sp.Dummy] +) -> sp.Expr: """Back-substitution of dummies from `_rateof_to_dummy`""" if rateof_dummies: return sym_math.subs({v: k for k, v in rateof_dummies.items()}) diff --git a/python/sdist/amici/sbml_utils.py b/python/sdist/amici/sbml_utils.py index 68a0093e6b..cedec8dd3e 100644 --- a/python/sdist/amici/sbml_utils.py +++ b/python/sdist/amici/sbml_utils.py @@ -19,6 +19,7 @@ import libsbml from sympy.core.parameters import evaluate + from sympy.printing.mathml import MathMLContentPrinter from .import_utils import ( @@ -487,6 +488,7 @@ def set_sbml_math(obj: libsbml.SBase, expr, **kwargs) -> None: ) +# TODO: replace by `sbmlmath` functions def mathml2sympy( mathml: str, *, @@ -516,6 +518,7 @@ def mathml2sympy( return expr +# TODO: remove after getting rid of `mathml2sympy` def _parse_logical_operators( math_str: str | float | None, ) -> str | float | None: diff --git a/python/sdist/pyproject.toml b/python/sdist/pyproject.toml index ddac4f9392..70807ae111 100644 --- a/python/sdist/pyproject.toml +++ b/python/sdist/pyproject.toml @@ -24,6 +24,7 @@ dependencies = [ "python-libsbml", "pandas>=2.0.2", "pyarrow", + "sbmlmath>=0.4.0", "toposort", "setuptools>=48", "mpmath", diff --git a/python/tests/test_sbml_import.py b/python/tests/test_sbml_import.py index f8254c042d..342b6146b2 100644 --- a/python/tests/test_sbml_import.py +++ b/python/tests/test_sbml_import.py @@ -61,7 +61,7 @@ def test_sbml2amici_no_observables(): @skip_on_valgrind def test_sbml2amici_nested_observables_fail(): - """Test model generation works for model without observables""" + """Test that import fails if nested observables are used.""" sbml_doc, sbml_model = simple_sbml_model() sbml_importer = SbmlImporter(sbml_source=sbml_model, from_file=False) model_name = "test_sbml2amici_nested_observables_fail" @@ -679,7 +679,7 @@ def test_code_gen_uses_lhs_symbol_ids(): @skip_on_valgrind def test_hardcode_parameters(): - """Test model generation works for model without observables""" + """Test hardcoding parameters.""" sbml_doc, sbml_model = simple_sbml_model() sbml_importer = SbmlImporter(sbml_source=sbml_model, from_file=False) r = sbml_model.createRateRule() diff --git a/tox.ini b/tox.ini index 4c10b020dd..8627d09b41 100644 --- a/tox.ini +++ b/tox.ini @@ -6,7 +6,7 @@ envlist = doc [testenv] -passenv = AMICI_PARALLEL_COMPILE,CC,CXX +passenv = AMICI_PARALLEL_COMPILE,CC,CXX,GITHUB_ACTIONS [testenv:doc] description =