From a4d554daf0360d16b5b53ba7faddbde25a592079 Mon Sep 17 00:00:00 2001 From: Daniel Weindl Date: Thu, 5 Dec 2024 09:56:12 +0100 Subject: [PATCH] Add `SbmlModel.from_antimony` Simplify creating a PEtab SbmlModel from antimony files or strings. Replace simplesbml by antimony in tests. --- petab/v1/models/sbml_model.py | 79 +++++++++++++++++++++++++++++- pyproject.toml | 8 ++- tests/v1/test_combine.py | 10 ++-- tests/v1/test_deprecated.py | 4 +- tests/v1/test_lint.py | 33 +++++++------ tests/v1/test_observables.py | 17 ++----- tests/v1/test_parameter_mapping.py | 73 ++++++++++++--------------- tests/v1/test_petab.py | 38 ++++++-------- tests/v1/test_sbml.py | 42 ++++++++++------ tests/v1/test_simplify.py | 9 ++-- 10 files changed, 191 insertions(+), 122 deletions(-) diff --git a/petab/v1/models/sbml_model.py b/petab/v1/models/sbml_model.py index 5102715d..55cd7b4d 100644 --- a/petab/v1/models/sbml_model.py +++ b/petab/v1/models/sbml_model.py @@ -1,4 +1,5 @@ """Functions for handling SBML models""" +from __future__ import annotations import itertools from collections.abc import Iterable @@ -32,8 +33,25 @@ def __init__( sbml_document: libsbml.SBMLDocument = None, model_id: str = None, ): + """Constructor. + + :param sbml_model: SBML model. Optional if `sbml_document` is given. + :param sbml_reader: SBML reader. Optional. + :param sbml_document: SBML document. Optional if `sbml_model` is given. + :param model_id: Model ID. Defaults to the SBML model ID.""" super().__init__() + if sbml_model is None and sbml_document is None: + raise ValueError( + "Either sbml_model or sbml_document must be given." + ) + + if sbml_model is None: + sbml_model = sbml_document.getModel() + + if sbml_document is None: + sbml_document = sbml_model.getSBMLDocument() + self.sbml_reader: libsbml.SBMLReader | None = sbml_reader self.sbml_document: libsbml.SBMLDocument | None = sbml_document self.sbml_model: libsbml.Model | None = sbml_model @@ -70,7 +88,7 @@ def __setstate__(self, state): self.__dict__.update(state) @staticmethod - def from_file(filepath_or_buffer, model_id: str = None): + def from_file(filepath_or_buffer, model_id: str = None) -> SbmlModel: sbml_reader, sbml_document, sbml_model = get_sbml_model( filepath_or_buffer ) @@ -82,7 +100,12 @@ def from_file(filepath_or_buffer, model_id: str = None): ) @staticmethod - def from_string(sbml_string, model_id: str = None): + def from_string(sbml_string, model_id: str = None) -> SbmlModel: + """Create SBML model from an SBML string. + + :param sbml_string: SBML model as string. + :param model_id: Model ID. Defaults to the SBML model ID. + """ sbml_reader, sbml_document, sbml_model = load_sbml_from_string( sbml_string ) @@ -97,6 +120,18 @@ def from_string(sbml_string, model_id: str = None): model_id=model_id, ) + @staticmethod + def from_antimony(ant_model: str | Path) -> SbmlModel: + """Create SBML model from an Antimony model. + + Requires the `antimony` package (https://github.com/sys-bio/antimony). + + :param ant_model: Antimony model as string or path to file. + Strings are interpreted as Antimony model strings. + """ + sbml_str = antimony2sbml(ant_model) + return SbmlModel.from_string(sbml_str) + @property def model_id(self): return self._model_id @@ -238,3 +273,43 @@ def sympify_sbml(sbml_obj: libsbml.ASTNode | libsbml.SBase) -> sp.Expr: ) return sp.sympify(formula_str, locals=_clash) + + +def antimony2sbml(ant_model: str | Path) -> str: + """Convert Antimony model to SBML. + + :param ant_model: Antimony model as string or path to file. + Strings are interpreted as Antimony model strings. + + :returns: + The SBML model as string. + """ + import antimony as ant + + # Unload everything / free memory + ant.clearPreviousLoads() + ant.freeAll() + + try: + # potentially fails because of too long file name + is_file = ant_model and Path(ant_model).exists() + except OSError: + is_file = False + + if is_file: + status = ant.loadAntimonyFile(str(ant_model)) + else: + status = ant.loadAntimonyString(ant_model) + if status < 0: + raise RuntimeError( + f"Antimony model could not be loaded: {ant.getLastError()}" + ) + + if (main_module_name := ant.getMainModuleName()) is None: + raise AssertionError("There is no Antimony module.") + + sbml_str = ant.getSBMLString(main_module_name) + if not sbml_str: + raise ValueError("Antimony model could not be converted to SBML.") + + return sbml_str diff --git a/pyproject.toml b/pyproject.toml index 1758476a..6eeb3480 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,11 +35,11 @@ maintainers = [ [project.optional-dependencies] tests = [ + "antimony>=2.14.0", + "pysb", "pytest", "pytest-cov", - "simplesbml", "scipy", - "pysb", ] quality = [ "pre-commit", @@ -48,6 +48,9 @@ reports = [ # https://github.com/spatialaudio/nbsphinx/issues/641 "Jinja2==3.0.3", ] +antimony = [ + "antimony>=2.14.0", +] combine = [ "python-libcombine>=0.2.6", ] @@ -61,6 +64,7 @@ doc = [ # https://github.com/spatialaudio/nbsphinx/issues/687#issuecomment-1339271312 "ipython>=7.21.0, !=8.7.0", "pysb", + "antimony>=2.14.0" ] vis = [ "matplotlib>=3.6.0", diff --git a/tests/v1/test_combine.py b/tests/v1/test_combine.py index 08ad5b77..4fca4105 100644 --- a/tests/v1/test_combine.py +++ b/tests/v1/test_combine.py @@ -4,8 +4,9 @@ import pandas as pd -import petab +import petab.v1 as petab from petab.C import * +from petab.v1.models.sbml_model import SbmlModel # import fixtures pytest_plugins = [ @@ -16,10 +17,7 @@ def test_combine_archive(): """Test `create_combine_archive` and `Problem.from_combine`""" # Create test files - import simplesbml - - ss_model = simplesbml.SbmlModel() - + model = SbmlModel.from_antimony("") # Create tables with arbitrary content measurement_df = pd.DataFrame( data={ @@ -80,7 +78,7 @@ def test_combine_archive(): ) as tempdir: # Write test data outdir = Path(tempdir) - petab.write_sbml(ss_model.document, outdir / sbml_file_name) + model.to_file(outdir / sbml_file_name) petab.write_measurement_df( measurement_df, outdir / measurement_file_name ) diff --git a/tests/v1/test_deprecated.py b/tests/v1/test_deprecated.py index 4af41fa3..b78e7856 100644 --- a/tests/v1/test_deprecated.py +++ b/tests/v1/test_deprecated.py @@ -14,7 +14,7 @@ def test_problem_with_sbml_model(): """Test that a problem can be correctly created from sbml model.""" # retrieve test data ( - ss_model, + model, condition_df, observable_df, measurement_df, @@ -23,7 +23,7 @@ def test_problem_with_sbml_model(): with pytest.deprecated_call(): petab_problem = petab.Problem( # noqa: F811 - sbml_model=ss_model.model, + model=model, condition_df=condition_df, measurement_df=measurement_df, parameter_df=parameter_df, diff --git a/tests/v1/test_lint.py b/tests/v1/test_lint.py index b178a425..d75bdcea 100644 --- a/tests/v1/test_lint.py +++ b/tests/v1/test_lint.py @@ -18,7 +18,6 @@ def test_assert_measured_observables_present(): # create test model - measurement_df = pd.DataFrame( data={ OBSERVABLE_ID: ["non-existing1"], @@ -255,15 +254,15 @@ def test_assert_no_leading_trailing_whitespace(): def test_assert_model_parameters_in_condition_or_parameter_table(): - import simplesbml - from petab.models.sbml_model import SbmlModel - ss_model = simplesbml.SbmlModel() - ss_model.addParameter("parameter1", 0.0) - ss_model.addParameter("noiseParameter1_", 0.0) - ss_model.addParameter("observableParameter1_", 0.0) - sbml_model = SbmlModel(sbml_model=ss_model.model) + ant_model = """ + parameter1 = 0.0 + noiseParameter1_ = 0.0 + observableParameter1_ = 0.0 + """ + sbml_model = SbmlModel.from_antimony(ant_model) + assert sbml_model.is_valid() lint.assert_model_parameters_in_condition_or_parameter_table( sbml_model, pd.DataFrame(columns=["parameter1"]), pd.DataFrame() @@ -284,7 +283,10 @@ def test_assert_model_parameters_in_condition_or_parameter_table(): sbml_model, pd.DataFrame(), pd.DataFrame() ) - ss_model.addAssignmentRule("parameter1", "parameter2") + sbml_model = SbmlModel.from_antimony( + ant_model + "\nparameter2 = 0\nparameter1 := parameter2" + ) + assert sbml_model.is_valid() lint.assert_model_parameters_in_condition_or_parameter_table( sbml_model, pd.DataFrame(), pd.DataFrame() ) @@ -499,12 +501,11 @@ def test_assert_measurement_conditions_present_in_condition_table(): def test_check_condition_df(): """Check that we correctly detect errors in condition table""" - import simplesbml from petab.models.sbml_model import SbmlModel - ss_model = simplesbml.SbmlModel() - model = SbmlModel(sbml_model=ss_model.model) + model = SbmlModel.from_antimony("") + condition_df = pd.DataFrame( data={ CONDITION_ID: ["condition1"], @@ -527,7 +528,7 @@ def test_check_condition_df(): lint.check_condition_df(condition_df, model, observable_df) # fix by adding parameter - ss_model.addParameter("p1", 1.0) + model = SbmlModel.from_antimony("p1 = 1") lint.check_condition_df(condition_df, model) # species missing in model @@ -536,7 +537,7 @@ def test_check_condition_df(): lint.check_condition_df(condition_df, model) # fix: - ss_model.addSpecies("[s1]", 1.0) + model = SbmlModel.from_antimony("p1 = 1; species s1 = 1") lint.check_condition_df(condition_df, model) # compartment missing in model @@ -545,7 +546,9 @@ def test_check_condition_df(): lint.check_condition_df(condition_df, model) # fix: - ss_model.addCompartment(comp_id="c2", vol=1.0) + model = SbmlModel.from_antimony( + "p1 = 1; species s1 = 1; compartment c2 = 1" + ) lint.check_condition_df(condition_df, model) diff --git a/tests/v1/test_observables.py b/tests/v1/test_observables.py index f9547fec..e870ac12 100644 --- a/tests/v1/test_observables.py +++ b/tests/v1/test_observables.py @@ -69,14 +69,11 @@ def test_write_observable_df(): def test_get_output_parameters(): """Test measurements.get_output_parameters.""" - # sbml model - import simplesbml - from petab.models.sbml_model import SbmlModel - ss_model = simplesbml.SbmlModel() - ss_model.addParameter("fixedParameter1", 1.0) - ss_model.addParameter("observable_1", 1.0) + model = SbmlModel.from_antimony( + "fixedParameter1 = 1.0; observable_1 = 1.0" + ) # observable file observable_df = pd.DataFrame( @@ -88,9 +85,7 @@ def test_get_output_parameters(): } ).set_index(OBSERVABLE_ID) - output_parameters = petab.get_output_parameters( - observable_df, SbmlModel(sbml_model=ss_model.model) - ) + output_parameters = petab.get_output_parameters(observable_df, model) assert output_parameters == ["offset", "scaling"] @@ -105,9 +100,7 @@ def test_get_output_parameters(): } ).set_index(OBSERVABLE_ID) - output_parameters = petab.get_output_parameters( - observable_df, SbmlModel(sbml_model=ss_model.model) - ) + output_parameters = petab.get_output_parameters(observable_df, model) assert output_parameters == ["N", "beta"] diff --git a/tests/v1/test_parameter_mapping.py b/tests/v1/test_parameter_mapping.py index e499bd5c..4fe44aa5 100644 --- a/tests/v1/test_parameter_mapping.py +++ b/tests/v1/test_parameter_mapping.py @@ -32,16 +32,15 @@ def test_no_condition_specific(condition_df_2_conditions): } ) - import simplesbml - - ss_model = simplesbml.SbmlModel() - ss_model.addParameter("dynamicParameter1", 1.0) - ss_model.addParameter("dynamicParameter2", 2.0) - ss_model.addParameter("dynamicParameter3", 3.0) + model = SbmlModel.from_antimony( + "dynamicParameter1 = 1.0; " + "dynamicParameter2 = 2.0; " + "dynamicParameter3 = 3.0; " + # add species, which will have initial concentration in condition + # table but which should not show up in mapping + "species someSpecies = 1.0" + ) - # add species, which will have initial concentration in condition table - # but which should not show up in mapping - ss_model.addSpecies("[someSpecies]", 1.0) condition_df["someSpecies"] = [0.0, 0.0] # Test without parameter table @@ -80,7 +79,6 @@ def test_no_condition_specific(condition_df_2_conditions): ), ] - model = SbmlModel(sbml_model=ss_model.model) actual = petab.get_optimization_to_simulation_parameter_mapping( model=model, measurement_df=measurement_df, @@ -245,13 +243,9 @@ def test_no_condition_specific(condition_df_2_conditions): def test_all_override(condition_df_2_conditions): # Condition-specific parameters overriding original parameters condition_df = condition_df_2_conditions - - import simplesbml - - ss_model = simplesbml.SbmlModel() - ss_model.addParameter("dynamicParameter1", 0.0) - ss_model.addParameter("dynamicParameter2", 0.0) - model = SbmlModel(sbml_model=ss_model.model) + model = SbmlModel.from_antimony( + "dynamicParameter1 = 0.0; dynamicParameter2 = 0.0" + ) measurement_df = pd.DataFrame( data={ @@ -364,15 +358,16 @@ def test_partial_override(condition_df_2_conditions): ) condition_df.set_index("conditionId", inplace=True) - import simplesbml - - ss_model = simplesbml.SbmlModel() - ss_model.addParameter("fixedParameter1", 0.5) - ss_model.addParameter("fixedParameter2", 1.0) - ss_model.addParameter("dynamicParameter1", 0.0) - ss_model.addParameter("observableParameter1_obs1", 0.0) - ss_model.addParameter("observableParameter2_obs1", 0.0) - ss_model.addParameter("observableParameter1_obs2", 0.0) + model = SbmlModel.from_antimony( + """ + fixedParameter1 = 0.5 + fixedParameter2 = 1.0 + dynamicParameter1 = 0.0 + observableParameter1_obs1 = 0.0 + observableParameter2_obs1 = 0.0 + observableParameter1_obs2 = 0.0 + """ + ) measurement_df = pd.DataFrame( data={ @@ -454,7 +449,7 @@ def test_partial_override(condition_df_2_conditions): actual = petab.get_optimization_to_simulation_parameter_mapping( measurement_df=measurement_df, condition_df=condition_df, - model=petab.models.sbml_model.SbmlModel(ss_model.model), + model=model, parameter_df=parameter_df, ) @@ -504,12 +499,9 @@ def test_parameterized_condition_table(): ) parameter_df.set_index(PARAMETER_ID, inplace=True) - import simplesbml - - ss_model = simplesbml.SbmlModel() - ss_model.addParameter("dynamicParameter1", 1.0) + model = SbmlModel.from_antimony("dynamicParameter1 = 1.0") - assert petab.get_model_parameters(ss_model.model) == [ + assert petab.get_model_parameters(model.sbml_model) == [ "dynamicParameter1" ] @@ -517,7 +509,7 @@ def test_parameterized_condition_table(): measurement_df=measurement_df, condition_df=condition_df, parameter_df=parameter_df, - model=petab.models.sbml_model.SbmlModel(ss_model.model), + model=model, ) expected = [ @@ -550,13 +542,10 @@ def test_parameterized_condition_table_changed_scale(): overridee_id = "overridee" # set up model - import simplesbml - - ss_model = simplesbml.SbmlModel() - ss_model.addParameter(overridee_id, 2.0) - assert petab.get_model_parameters(ss_model.model) == [overridee_id] + model = SbmlModel.from_antimony(f"{overridee_id} = 2.0") + assert petab.get_model_parameters(model.sbml_model) == [overridee_id] assert petab.get_model_parameters( - ss_model.model, with_values=True + model.sbml_model, with_values=True ) == {overridee_id: 2.0} # set up condition table @@ -614,7 +603,7 @@ def test_parameterized_condition_table_changed_scale(): measurement_df=measurement_df, condition_df=condition_df, parameter_df=parameter_df, - model=petab.models.sbml_model.SbmlModel(ss_model.model), + model=model, ) expected = [ @@ -638,7 +627,7 @@ def test_parameterized_condition_table_changed_scale(): measurement_df=measurement_df, condition_df=condition_df, parameter_df=parameter_df, - model=petab.models.sbml_model.SbmlModel(ss_model.model), + model=model, scaled_parameters=True, ) @@ -669,7 +658,7 @@ def test_parameterized_condition_table_changed_scale(): measurement_df=measurement_df, condition_df=condition_df, parameter_df=parameter_df, - model=petab.models.sbml_model.SbmlModel(ss_model.model), + model=model, ) expected = [ diff --git a/tests/v1/test_petab.py b/tests/v1/test_petab.py index eac237d2..ff9621fa 100644 --- a/tests/v1/test_petab.py +++ b/tests/v1/test_petab.py @@ -6,7 +6,6 @@ from math import nan from pathlib import Path -import libsbml import numpy as np import pandas as pd import pytest @@ -39,11 +38,8 @@ def condition_df_2_conditions(): def petab_problem(): """Test petab problem.""" # create test model - import simplesbml - - model = simplesbml.SbmlModel() - model.addParameter("fixedParameter1", 0.0) - model.addParameter("observable_1", 0.0) + ant_model = "fixedParameter1=0.0; observable_1=0.0" + model = SbmlModel.from_antimony(ant_model) petab_problem = petab.Problem() petab_problem.add_measurement( @@ -79,7 +75,7 @@ def petab_problem(): with tempfile.TemporaryDirectory() as temp_dir: sbml_file_name = Path(temp_dir, "model.xml") - libsbml.writeSBMLToFile(model.document, str(sbml_file_name)) + model.to_file(sbml_file_name) measurement_file_name = Path(temp_dir, "measurements.tsv") petab.write_measurement_df( @@ -285,13 +281,15 @@ def test_create_parameter_df( condition_df_2_conditions, ): # pylint: disable=W0621 """Test petab.create_parameter_df.""" - import simplesbml - - ss_model = simplesbml.SbmlModel() - ss_model.addSpecies("[x1]", 1.0) - ss_model.addParameter("fixedParameter1", 2.0) - ss_model.addParameter("p0", 3.0) - model = SbmlModel(sbml_model=ss_model.model) + ant_model = """ + species x1 = 1.0 + fixedParameter1 = 2.0 + p0 = 3.0 + # Add assignment rule target which should be ignored + assignment_target = 0.0 + assignment_target := 1.0 + """ + model = SbmlModel.from_antimony(ant_model) observable_df = pd.DataFrame( data={ @@ -300,10 +298,6 @@ def test_create_parameter_df( } ).set_index(OBSERVABLE_ID) - # Add assignment rule target which should be ignored - ss_model.addParameter("assignment_target", 0.0) - ss_model.addAssignmentRule("assignment_target", "1.0") - measurement_df = pd.DataFrame( data={ OBSERVABLE_ID: ["obs1", "obs2"], @@ -319,10 +313,10 @@ def test_create_parameter_df( with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") parameter_df = petab.v1.create_parameter_df( - ss_model.model, - condition_df_2_conditions, - observable_df, - measurement_df, + sbml_model=model.sbml_model, + condition_df=condition_df_2_conditions, + observable_df=observable_df, + measurement_df=measurement_df, ) assert len(w) == 1 assert issubclass(w[-1].category, DeprecationWarning) diff --git a/tests/v1/test_sbml.py b/tests/v1/test_sbml.py index 350a2f0d..5c262d43 100644 --- a/tests/v1/test_sbml.py +++ b/tests/v1/test_sbml.py @@ -13,17 +13,16 @@ def create_test_data(): # Create test model and data files - import simplesbml - - ss_model = simplesbml.SbmlModel() - ss_model.addCompartment(comp_id="compartment_1", vol=1) - for i in range(1, 4): - ss_model.addParameter(f"parameter_{i}", i) - - for i in range(1, 5): - ss_model.addSpecies(f"[species_{i}]", 10 * i) - - ss_model.addAssignmentRule("species_2", "25") + model = SbmlModel.from_antimony( + "\n".join( + [ + "compartment compartment_1 = 1", + *(f"species species_{i} = 10 * {i}" for i in range(1, 5)), + *(f"parameter_{i} = {i}" for i in range(1, 4)), + "species_2 := 25", + ] + ) + ) condition_df = pd.DataFrame( { @@ -68,7 +67,7 @@ def create_test_data(): ) parameter_df.set_index([petab.PARAMETER_ID], inplace=True) - return ss_model, condition_df, observable_df, measurement_df, parameter_df + return model, condition_df, observable_df, measurement_df, parameter_df def check_model(condition_model): @@ -99,7 +98,7 @@ def test_get_condition_specific_models(): """Test for petab.sbml.get_condition_specific_models""" # retrieve test data ( - ss_model, + model, condition_df, observable_df, measurement_df, @@ -107,7 +106,7 @@ def test_get_condition_specific_models(): ) = create_test_data() petab_problem = petab.Problem( - model=petab.models.sbml_model.SbmlModel(ss_model.model), + model=model, condition_df=condition_df, observable_df=observable_df, measurement_df=measurement_df, @@ -133,3 +132,18 @@ def test_sbml_model_repr(): sbml_model.setId("test") petab_model = SbmlModel(sbml_model) assert repr(petab_model) == "" + + +def test_sbml_from_ant(): + ant_model = """ + model test + R1: S1 -> S2; k1*S1 + k1 = 1 + end + """ + petab_model = SbmlModel.from_antimony(ant_model) + assert petab_model.model_id == "test" + assert petab_model.get_parameter_value("k1") == 1.0 + assert set(petab_model.get_valid_parameters_for_parameter_table()) == { + "k1" + } diff --git a/tests/v1/test_simplify.py b/tests/v1/test_simplify.py index 3d9a8909..9aa25f8f 100644 --- a/tests/v1/test_simplify.py +++ b/tests/v1/test_simplify.py @@ -3,7 +3,6 @@ import pandas as pd import pytest -import simplesbml from pandas.testing import * from petab import Problem @@ -14,9 +13,9 @@ @pytest.fixture def problem() -> Problem: - ss_model = simplesbml.SbmlModel() - ss_model.addParameter("some_parameter", val=1.0) - ss_model.addParameter("same_value_for_all_conditions", val=1.0) + model = SbmlModel.from_antimony( + "some_parameter = 1.0; same_value_for_all_conditions = 1.0" + ) observable_df = pd.DataFrame( { @@ -53,7 +52,7 @@ def problem() -> Problem: } ) yield Problem( - model=SbmlModel(sbml_model=ss_model.getModel()), + model=model, condition_df=conditions_df, observable_df=observable_df, measurement_df=measurement_df,