sbml2cellml 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sbml2cellml/__init__.py +16 -0
- sbml2cellml/biomodels/__init__.py +1 -0
- sbml2cellml/biomodels/cases.py +122 -0
- sbml2cellml/biomodels/cli.py +228 -0
- sbml2cellml/biomodels/models.py +281 -0
- sbml2cellml/biomodels/runner.py +92 -0
- sbml2cellml/cellml.py +135 -0
- sbml2cellml/cellml2sbml.py +382 -0
- sbml2cellml/cli.py +159 -0
- sbml2cellml/console.py +18 -0
- sbml2cellml/log.py +80 -0
- sbml2cellml/mathml.py +115 -0
- sbml2cellml/sbml.py +89 -0
- sbml2cellml/sbml2cellml.py +272 -0
- sbml2cellml/sbmlmath.py +296 -0
- sbml2cellml/simulate.py +168 -0
- sbml2cellml/testsuite/__init__.py +1 -0
- sbml2cellml/testsuite/cases.py +322 -0
- sbml2cellml/testsuite/cli.py +130 -0
- sbml2cellml/testsuite/compare.py +224 -0
- sbml2cellml/testsuite/report.py +236 -0
- sbml2cellml/testsuite/results.py +138 -0
- sbml2cellml/testsuite/runner.py +285 -0
- sbml2cellml/testsuite/simulators.py +101 -0
- sbml2cellml/testsuite/worker.py +187 -0
- sbml2cellml/units.py +192 -0
- sbml2cellml/variables.py +176 -0
- sbml2cellml-0.1.0.dist-info/METADATA +89 -0
- sbml2cellml-0.1.0.dist-info/RECORD +32 -0
- sbml2cellml-0.1.0.dist-info/WHEEL +4 -0
- sbml2cellml-0.1.0.dist-info/entry_points.txt +5 -0
- sbml2cellml-0.1.0.dist-info/licenses/LICENSE +7 -0
sbml2cellml/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""sbml2cellml - conversion between SBML and CellML."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from sbml2cellml.cellml2sbml import convert_cellml2sbml
|
|
6
|
+
from sbml2cellml.sbml2cellml import convert_sbml2cellml
|
|
7
|
+
|
|
8
|
+
# the package does not configure logging, see `sbml2cellml.log`
|
|
9
|
+
logging.getLogger(__name__).addHandler(logging.NullHandler())
|
|
10
|
+
|
|
11
|
+
__author__ = "Matthias Koenig"
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
|
|
14
|
+
program_name: str = "sbml2cellml"
|
|
15
|
+
|
|
16
|
+
__all__ = ["convert_cellml2sbml", "convert_sbml2cellml"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""BioModels release check: the curated models through both converters."""
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Case construction for a BioModels model.
|
|
2
|
+
|
|
3
|
+
Every curated model runs the same generic timecourse: there are no expected
|
|
4
|
+
results (`Case.expected` is `None`, the roadrunner simulation of the
|
|
5
|
+
original SBML becomes the reference the later stages are compared with, see
|
|
6
|
+
`sbml2cellml.testsuite.runner`).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import libsbml
|
|
12
|
+
|
|
13
|
+
from sbml2cellml.biomodels.models import BioModelsError, ModelInfo
|
|
14
|
+
from sbml2cellml.testsuite.cases import Case, Settings
|
|
15
|
+
|
|
16
|
+
#: duration (time units of the model) of the generic timecourse
|
|
17
|
+
DURATION = 100.0
|
|
18
|
+
#: number of steps of the generic timecourse
|
|
19
|
+
STEPS = 100
|
|
20
|
+
#: absolute tolerance of the comparison
|
|
21
|
+
ABSOLUTE = 1e-6
|
|
22
|
+
#: relative tolerance of the comparison
|
|
23
|
+
RELATIVE = 1e-3
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def constructs(model: libsbml.Model, text: str) -> tuple[str, ...]:
|
|
27
|
+
"""SBML constructs a model uses, as component tags of a `Case`.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
model: the model to inspect.
|
|
31
|
+
text: the file content the model was read from, used to detect a
|
|
32
|
+
`csymbol` delay (not exposed on the `libsbml.Model` API).
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
The construct tags present in the model, e.g. `("Reactions",
|
|
36
|
+
"AssignmentRules")`.
|
|
37
|
+
"""
|
|
38
|
+
tags: list[str] = []
|
|
39
|
+
if model.getNumReactions():
|
|
40
|
+
tags.append("Reactions")
|
|
41
|
+
if model.getNumEvents():
|
|
42
|
+
tags.append("Events")
|
|
43
|
+
if model.getNumFunctionDefinitions():
|
|
44
|
+
tags.append("FunctionDefinitions")
|
|
45
|
+
if model.getNumInitialAssignments():
|
|
46
|
+
tags.append("InitialAssignments")
|
|
47
|
+
if model.getNumConstraints():
|
|
48
|
+
tags.append("Constraints")
|
|
49
|
+
rules = [model.getRule(k) for k in range(model.getNumRules())]
|
|
50
|
+
if any(rule.isAlgebraic() for rule in rules):
|
|
51
|
+
tags.append("AlgebraicRules")
|
|
52
|
+
if any(rule.isAssignment() for rule in rules):
|
|
53
|
+
tags.append("AssignmentRules")
|
|
54
|
+
if any(rule.isRate() for rule in rules):
|
|
55
|
+
tags.append("RateRules")
|
|
56
|
+
if "symbols/delay" in text:
|
|
57
|
+
tags.append("Delay")
|
|
58
|
+
return tuple(tags)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def biomodel_case(info: ModelInfo, sbml_path: Path, packages: tuple[str, ...]) -> Case:
|
|
62
|
+
"""Build the generic timecourse case of a BioModels model.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
info: metadata of the model.
|
|
66
|
+
sbml_path: path of the downloaded SBML file.
|
|
67
|
+
packages: SBML packages the model uses (`models.packages`); each
|
|
68
|
+
becomes a `<package>:package` component tag so `skip_reason`
|
|
69
|
+
skips the case the same way it skips a test suite case using an
|
|
70
|
+
unsupported package.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
The case, with `expected=None` and `test_type="TimeCourse"`.
|
|
74
|
+
`settings.variables` is the species ids, followed by the ids of the
|
|
75
|
+
rate-rule and assignment-rule targets which are not species
|
|
76
|
+
(parameters and compartments), in document order and without
|
|
77
|
+
duplicates; `amount` and `concentration` stay species only.
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
BioModelsError: if the SBML file has no model.
|
|
81
|
+
"""
|
|
82
|
+
doc = libsbml.readSBMLFromFile(str(sbml_path))
|
|
83
|
+
model = doc.getModel()
|
|
84
|
+
if model is None:
|
|
85
|
+
raise BioModelsError(f"{info.id}: no model in {sbml_path.name}")
|
|
86
|
+
species = list(model.getListOfSpecies())
|
|
87
|
+
species_ids = tuple(s.getId() for s in species)
|
|
88
|
+
variables = list(species_ids)
|
|
89
|
+
seen = set(species_ids)
|
|
90
|
+
for rule in model.getListOfRules():
|
|
91
|
+
if not (rule.isRate() or rule.isAssignment()):
|
|
92
|
+
continue
|
|
93
|
+
target = rule.getVariable()
|
|
94
|
+
if target and target not in seen:
|
|
95
|
+
variables.append(target)
|
|
96
|
+
seen.add(target)
|
|
97
|
+
settings = Settings(
|
|
98
|
+
start=0.0,
|
|
99
|
+
duration=DURATION,
|
|
100
|
+
steps=STEPS,
|
|
101
|
+
variables=tuple(variables),
|
|
102
|
+
absolute=ABSOLUTE,
|
|
103
|
+
relative=RELATIVE,
|
|
104
|
+
amount=frozenset(s.getId() for s in species if s.getHasOnlySubstanceUnits()),
|
|
105
|
+
concentration=frozenset(
|
|
106
|
+
s.getId() for s in species if not s.getHasOnlySubstanceUnits()
|
|
107
|
+
),
|
|
108
|
+
)
|
|
109
|
+
tags = constructs(model, sbml_path.read_text(encoding="utf-8")) + tuple(
|
|
110
|
+
f"{package}:package" for package in packages
|
|
111
|
+
)
|
|
112
|
+
return Case(
|
|
113
|
+
id=info.id,
|
|
114
|
+
case_dir=sbml_path.parent,
|
|
115
|
+
sbml_path=sbml_path,
|
|
116
|
+
settings=settings,
|
|
117
|
+
expected=None,
|
|
118
|
+
test_tags=(),
|
|
119
|
+
component_tags=tags,
|
|
120
|
+
test_type="TimeCourse",
|
|
121
|
+
name=info.name,
|
|
122
|
+
)
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""The `sbml2cellml-biomodels` command.
|
|
2
|
+
|
|
3
|
+
sbml2cellml-biomodels run [--models biomodels/models.json] [--ids ID,ID]
|
|
4
|
+
[--count N] [--work-dir biomodels/work]
|
|
5
|
+
[--results FILE] [--report FILE] [--timeout SECONDS] [-v]
|
|
6
|
+
sbml2cellml-biomodels update [--models biomodels/models.json] [--count N]
|
|
7
|
+
sbml2cellml-biomodels report --results FILE --output FILE
|
|
8
|
+
|
|
9
|
+
`run` runs the pipeline over `--ids` or, by default, the selection file,
|
|
10
|
+
writes the results and the report; it gives up without writing either when
|
|
11
|
+
too many ids failed to download (`DOWNLOAD_FAILURE_FRACTION`, e.g. a
|
|
12
|
+
BioModels outage) or when no case was left to run, and, when `--results`
|
|
13
|
+
already exists, prints its regressions and improvements against the new run.
|
|
14
|
+
`update` refreshes the selection file from the current BioModels search.
|
|
15
|
+
`report` renders a results file.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import logging
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from sbml2cellml import __version__, log
|
|
24
|
+
from sbml2cellml.biomodels.models import (
|
|
25
|
+
load_selection,
|
|
26
|
+
query_curated_ids,
|
|
27
|
+
write_selection,
|
|
28
|
+
)
|
|
29
|
+
from sbml2cellml.biomodels.runner import run_biomodels
|
|
30
|
+
from sbml2cellml.testsuite.report import write_report
|
|
31
|
+
from sbml2cellml.testsuite.results import STAGES, SuiteResult, improvements, regressions
|
|
32
|
+
|
|
33
|
+
DEFAULT_MODELS = Path("biomodels") / "models.json"
|
|
34
|
+
DEFAULT_RESULTS = Path("biomodels") / "results.json"
|
|
35
|
+
DEFAULT_REPORT = Path("docs") / "biomodels.md"
|
|
36
|
+
#: fraction of the requested ids whose download may fail before `run` gives
|
|
37
|
+
#: up instead of writing a mostly empty result (e.g. a BioModels outage)
|
|
38
|
+
DOWNLOAD_FAILURE_FRACTION = 0.05
|
|
39
|
+
#: intro paragraph of the BioModels report
|
|
40
|
+
BIOMODELS_INTRO = (
|
|
41
|
+
"Manually curated SBML models of [BioModels](https://www.biomodels.org) "
|
|
42
|
+
"(the ids in `biomodels/models.json`). Every model is simulated with "
|
|
43
|
+
"roadrunner over 0 to 100 time units in 100 steps (`reference`), converted "
|
|
44
|
+
"to CellML (`sbml2cellml`), simulated with libopencor (`libopencor`), "
|
|
45
|
+
"converted back to SBML (`cellml2sbml`) and simulated with roadrunner again "
|
|
46
|
+
"(`roundtrip`); the two later simulations are compared with the reference "
|
|
47
|
+
"for every species and every other variable set by a rate rule or an "
|
|
48
|
+
"assignment rule, with a relative tolerance of 1e-3 and an absolute "
|
|
49
|
+
"tolerance of 1e-6. A `reference` failure means roadrunner cannot simulate "
|
|
50
|
+
"the model, it says nothing about the converters. See "
|
|
51
|
+
"[Development](development.md#biomodels-check) for how to run it."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
56
|
+
"""Build the argument parser.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
The parser with the `run`, `update` and `report` subcommands.
|
|
60
|
+
"""
|
|
61
|
+
parser = argparse.ArgumentParser(
|
|
62
|
+
prog="sbml2cellml-biomodels",
|
|
63
|
+
description=(
|
|
64
|
+
"Run the curated BioModels selection through sbml2cellml and cellml2sbml."
|
|
65
|
+
),
|
|
66
|
+
)
|
|
67
|
+
parser.add_argument("--version", action="version", version=__version__)
|
|
68
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
69
|
+
|
|
70
|
+
run = subparsers.add_parser(
|
|
71
|
+
"run", help="run the selection, write results and report"
|
|
72
|
+
)
|
|
73
|
+
run.add_argument(
|
|
74
|
+
"--models", default=str(DEFAULT_MODELS), help="selection file (json)"
|
|
75
|
+
)
|
|
76
|
+
run.add_argument("--ids", help="comma separated model ids, overrides --models")
|
|
77
|
+
run.add_argument("--count", type=int, help="run only the first N ids")
|
|
78
|
+
run.add_argument(
|
|
79
|
+
"--work-dir", default="biomodels/work", help="directory for the converted files"
|
|
80
|
+
)
|
|
81
|
+
run.add_argument(
|
|
82
|
+
"--results", default=str(DEFAULT_RESULTS), help="results file (json)"
|
|
83
|
+
)
|
|
84
|
+
run.add_argument(
|
|
85
|
+
"--report", default=str(DEFAULT_REPORT), help="report file (markdown)"
|
|
86
|
+
)
|
|
87
|
+
run.add_argument(
|
|
88
|
+
"--timeout", type=float, default=60.0, help="seconds per simulation"
|
|
89
|
+
)
|
|
90
|
+
run.add_argument("-v", "--verbose", action="store_true", help="log the steps")
|
|
91
|
+
|
|
92
|
+
update = subparsers.add_parser(
|
|
93
|
+
"update", help="refresh the selection file from the current BioModels search"
|
|
94
|
+
)
|
|
95
|
+
update.add_argument(
|
|
96
|
+
"--models", default=str(DEFAULT_MODELS), help="selection file (json)"
|
|
97
|
+
)
|
|
98
|
+
update.add_argument("--count", type=int, help="keep only the first N ids")
|
|
99
|
+
|
|
100
|
+
report = subparsers.add_parser("report", help="render a results file")
|
|
101
|
+
report.add_argument(
|
|
102
|
+
"--results", default=str(DEFAULT_RESULTS), help="results file (json)"
|
|
103
|
+
)
|
|
104
|
+
report.add_argument(
|
|
105
|
+
"--output", default=str(DEFAULT_REPORT), help="report file (markdown)"
|
|
106
|
+
)
|
|
107
|
+
return parser
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _run(args: argparse.Namespace) -> int:
|
|
111
|
+
"""The `run` subcommand."""
|
|
112
|
+
if args.verbose:
|
|
113
|
+
log.enable_rich_logging(logging.INFO)
|
|
114
|
+
if args.ids:
|
|
115
|
+
ids = [mid.strip() for mid in args.ids.split(",")]
|
|
116
|
+
else:
|
|
117
|
+
models_path = Path(args.models)
|
|
118
|
+
if not models_path.is_file():
|
|
119
|
+
print(f"Selection file does not exist: '{models_path}'", file=sys.stderr)
|
|
120
|
+
return 1
|
|
121
|
+
ids = list(load_selection(models_path).models)
|
|
122
|
+
if args.count is not None:
|
|
123
|
+
ids = ids[: args.count]
|
|
124
|
+
|
|
125
|
+
result = run_biomodels(
|
|
126
|
+
ids, work_dir=Path(args.work_dir), timeout=args.timeout, progress=print
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
download_failures = sum(
|
|
130
|
+
1 for reason in result.skipped.values() if reason.startswith("download failed")
|
|
131
|
+
)
|
|
132
|
+
if ids and download_failures > DOWNLOAD_FAILURE_FRACTION * len(ids):
|
|
133
|
+
print(
|
|
134
|
+
f"{download_failures} of {len(ids)} ids failed to download, more than "
|
|
135
|
+
f"{DOWNLOAD_FAILURE_FRACTION:.0%} of the ids",
|
|
136
|
+
file=sys.stderr,
|
|
137
|
+
)
|
|
138
|
+
return 1
|
|
139
|
+
if not result.cases:
|
|
140
|
+
print("No cases to run", file=sys.stderr)
|
|
141
|
+
return 1
|
|
142
|
+
|
|
143
|
+
for stage in STAGES:
|
|
144
|
+
counts = result.counts(stage)
|
|
145
|
+
print(
|
|
146
|
+
f"{stage}: {counts['pass']} pass, {counts['fail']} fail, {counts['skip']} skip"
|
|
147
|
+
)
|
|
148
|
+
print(f"{len(result.skipped)} models skipped")
|
|
149
|
+
|
|
150
|
+
results_path = Path(args.results)
|
|
151
|
+
if results_path.is_file():
|
|
152
|
+
previous = SuiteResult.from_json(results_path)
|
|
153
|
+
regs = regressions(previous, result)
|
|
154
|
+
print(f"{len(regs)} regressions")
|
|
155
|
+
for line in regs:
|
|
156
|
+
print(line)
|
|
157
|
+
imps = improvements(previous, result)
|
|
158
|
+
print(f"{len(imps)} improvements")
|
|
159
|
+
for line in imps:
|
|
160
|
+
print(line)
|
|
161
|
+
|
|
162
|
+
results_path.parent.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
result.to_json(results_path)
|
|
164
|
+
report_path = Path(args.report)
|
|
165
|
+
report_path.parent.mkdir(parents=True, exist_ok=True)
|
|
166
|
+
write_report(
|
|
167
|
+
result,
|
|
168
|
+
report_path,
|
|
169
|
+
title="BioModels",
|
|
170
|
+
intro=BIOMODELS_INTRO,
|
|
171
|
+
command="sbml2cellml-biomodels",
|
|
172
|
+
names=True,
|
|
173
|
+
)
|
|
174
|
+
print(f"{results_path}\n{report_path}")
|
|
175
|
+
return 0
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _update(args: argparse.Namespace) -> int:
|
|
179
|
+
"""The `update` subcommand."""
|
|
180
|
+
ids = query_curated_ids()
|
|
181
|
+
if args.count is not None:
|
|
182
|
+
ids = ids[: args.count]
|
|
183
|
+
models_path = Path(args.models)
|
|
184
|
+
models_path.parent.mkdir(parents=True, exist_ok=True)
|
|
185
|
+
selection = write_selection(models_path, ids)
|
|
186
|
+
print(f"{len(selection.models)} ids written to {models_path}")
|
|
187
|
+
return 0
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _report(args: argparse.Namespace) -> int:
|
|
191
|
+
"""The `report` subcommand."""
|
|
192
|
+
results_path = Path(args.results)
|
|
193
|
+
if not results_path.is_file():
|
|
194
|
+
print(f"Results file does not exist: '{results_path}'", file=sys.stderr)
|
|
195
|
+
return 1
|
|
196
|
+
output = Path(args.output)
|
|
197
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
198
|
+
write_report(
|
|
199
|
+
SuiteResult.from_json(results_path),
|
|
200
|
+
output,
|
|
201
|
+
title="BioModels",
|
|
202
|
+
intro=BIOMODELS_INTRO,
|
|
203
|
+
command="sbml2cellml-biomodels",
|
|
204
|
+
names=True,
|
|
205
|
+
)
|
|
206
|
+
print(output)
|
|
207
|
+
return 0
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def main(argv: list[str] | None = None) -> int:
|
|
211
|
+
"""Run the command.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
argv: arguments without the program name, `sys.argv[1:]` by default.
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
0 on success, 1 on a missing selection or results file.
|
|
218
|
+
"""
|
|
219
|
+
args = build_parser().parse_args(argv)
|
|
220
|
+
if args.command == "run":
|
|
221
|
+
return _run(args)
|
|
222
|
+
if args.command == "update":
|
|
223
|
+
return _update(args)
|
|
224
|
+
return _report(args)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
if __name__ == "__main__":
|
|
228
|
+
sys.exit(main())
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Access to the BioModels database: search, model info, download, selection.
|
|
2
|
+
|
|
3
|
+
The curated model set is queried and downloaded through the BioModels REST
|
|
4
|
+
API (`https://www.biomodels.org`, `www.ebi.ac.uk/biomodels` rejects the
|
|
5
|
+
quoted search query used here). Model info and the downloaded SBML are
|
|
6
|
+
cached on disk under `biomodels_cache()` so a rerun of the check never
|
|
7
|
+
re-fetches a model it already has.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import dataclasses
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from datetime import date
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import requests
|
|
20
|
+
|
|
21
|
+
from sbml2cellml.testsuite.cases import cache_dir
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
#: root of the BioModels REST API
|
|
26
|
+
BIOMODELS_URL = "https://www.biomodels.org"
|
|
27
|
+
#: search query selecting the manually curated SBML models
|
|
28
|
+
SEARCH_QUERY = 'curationstatus:"Manually curated" AND modelformat:"SBML"'
|
|
29
|
+
#: number of models requested per search page
|
|
30
|
+
PAGE_SIZE = 100
|
|
31
|
+
#: timeout (seconds) of every BioModels request
|
|
32
|
+
TIMEOUT = 60.0
|
|
33
|
+
#: namespace declarations of an SBML package, e.g. `xmlns:comp="http://www.
|
|
34
|
+
#: sbml.org/sbml/level3/version1/comp/version1"`; group 1 is the declared
|
|
35
|
+
#: prefix, group 2 is the package name
|
|
36
|
+
_XMLNS = re.compile(
|
|
37
|
+
r'xmlns:(\w+)="http://www\.sbml\.org/sbml/level3/version\d+/(\w+)/version\d+"'
|
|
38
|
+
)
|
|
39
|
+
#: `comp` elements which actually change the model (a `comp:port` alone does
|
|
40
|
+
#: not touch the math, so it is not enough to count the package as used)
|
|
41
|
+
_COMP_ELEMENTS = ("submodel", "modelDefinition", "externalModelDefinition")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class BioModelsError(RuntimeError):
|
|
45
|
+
"""A BioModels request failed or returned an unexpected response."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class ModelInfo:
|
|
50
|
+
"""Metadata of one BioModels model, from `/{id}?format=json`."""
|
|
51
|
+
|
|
52
|
+
id: str
|
|
53
|
+
name: str
|
|
54
|
+
publication_id: str
|
|
55
|
+
format_version: str
|
|
56
|
+
main_file: str
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class Selection:
|
|
61
|
+
"""A snapshot of the curated model ids, e.g. `biomodels/models.json`."""
|
|
62
|
+
|
|
63
|
+
date: str
|
|
64
|
+
query: str
|
|
65
|
+
models: tuple[str, ...]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def biomodels_cache(cache: Path | None = None) -> Path:
|
|
69
|
+
"""Cache directory of the BioModels info and downloads.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
cache: cache root, `sbml2cellml.testsuite.cases.cache_dir()` by
|
|
73
|
+
default.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
`<cache>/biomodels`.
|
|
77
|
+
"""
|
|
78
|
+
return (cache or cache_dir()) / "biomodels"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _get_json(url: str, params: dict[str, str | int] | None = None) -> dict:
|
|
82
|
+
"""`GET` a BioModels endpoint and parse its JSON body.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
url: endpoint to request.
|
|
86
|
+
params: query parameters.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
The parsed JSON body.
|
|
90
|
+
|
|
91
|
+
Raises:
|
|
92
|
+
BioModelsError: if the request fails or the status is not ok.
|
|
93
|
+
"""
|
|
94
|
+
try:
|
|
95
|
+
response = requests.get(url, params=params, timeout=TIMEOUT)
|
|
96
|
+
response.raise_for_status()
|
|
97
|
+
return response.json()
|
|
98
|
+
except requests.RequestException as err:
|
|
99
|
+
raise BioModelsError(f"Request to {url} failed: {err}") from err
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def query_curated_ids() -> list[str]:
|
|
103
|
+
"""Ids of every manually curated SBML model, paged through the search.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
The model ids, sorted and without duplicates.
|
|
107
|
+
|
|
108
|
+
Raises:
|
|
109
|
+
BioModelsError: if a search page cannot be fetched.
|
|
110
|
+
"""
|
|
111
|
+
offset = 0
|
|
112
|
+
ids: list[str] = []
|
|
113
|
+
matches: int | None = None
|
|
114
|
+
while matches is None or offset < matches:
|
|
115
|
+
data = _get_json(
|
|
116
|
+
f"{BIOMODELS_URL}/search",
|
|
117
|
+
{
|
|
118
|
+
"query": SEARCH_QUERY,
|
|
119
|
+
"numResults": PAGE_SIZE,
|
|
120
|
+
"offset": offset,
|
|
121
|
+
"format": "json",
|
|
122
|
+
},
|
|
123
|
+
)
|
|
124
|
+
matches = int(data["matches"])
|
|
125
|
+
ids.extend(model["id"] for model in data["models"])
|
|
126
|
+
offset += PAGE_SIZE
|
|
127
|
+
logger.info("%d curated models found on BioModels", len(set(ids)))
|
|
128
|
+
return sorted(set(ids))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def model_info(model_id: str, cache: Path | None = None) -> ModelInfo:
|
|
132
|
+
"""Metadata of a model, cached as `<cache>/biomodels/<id>/info.json`.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
model_id: BioModels id, e.g. `BIOMD0000000001`.
|
|
136
|
+
cache: cache root, `sbml2cellml.testsuite.cases.cache_dir()` by
|
|
137
|
+
default.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
The model metadata.
|
|
141
|
+
|
|
142
|
+
Raises:
|
|
143
|
+
BioModelsError: if the request fails or the response has no main
|
|
144
|
+
SBML file.
|
|
145
|
+
"""
|
|
146
|
+
root = biomodels_cache(cache) / model_id
|
|
147
|
+
info_path = root / "info.json"
|
|
148
|
+
if info_path.is_file():
|
|
149
|
+
data = json.loads(info_path.read_text(encoding="utf-8"))
|
|
150
|
+
return ModelInfo(**data)
|
|
151
|
+
data = _get_json(f"{BIOMODELS_URL}/{model_id}", {"format": "json"})
|
|
152
|
+
main_files = data.get("files", {}).get("main") or []
|
|
153
|
+
if not main_files:
|
|
154
|
+
raise BioModelsError(f"{model_id}: no main SBML file in the BioModels response")
|
|
155
|
+
info = ModelInfo(
|
|
156
|
+
id=model_id,
|
|
157
|
+
name=data["name"],
|
|
158
|
+
publication_id=data.get("publicationId", ""),
|
|
159
|
+
format_version=data.get("format", {}).get("version", ""),
|
|
160
|
+
main_file=main_files[0]["name"],
|
|
161
|
+
)
|
|
162
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
tmp_path = info_path.with_name(info_path.name + ".part")
|
|
164
|
+
tmp_path.write_text(
|
|
165
|
+
json.dumps(dataclasses.asdict(info), indent=1), encoding="utf-8"
|
|
166
|
+
)
|
|
167
|
+
os.replace(tmp_path, info_path)
|
|
168
|
+
logger.info("Fetched BioModels info for %s: %s", model_id, info.name)
|
|
169
|
+
return info
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def download_model(model_id: str, cache: Path | None = None) -> Path:
|
|
173
|
+
"""Download the main SBML file of a model, cached on disk.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
model_id: BioModels id, e.g. `BIOMD0000000001`.
|
|
177
|
+
cache: cache root, `sbml2cellml.testsuite.cases.cache_dir()` by
|
|
178
|
+
default.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
Path of the cached SBML file.
|
|
182
|
+
|
|
183
|
+
Raises:
|
|
184
|
+
BioModelsError: if the info or download request fails.
|
|
185
|
+
"""
|
|
186
|
+
info = model_info(model_id, cache)
|
|
187
|
+
root = biomodels_cache(cache) / model_id
|
|
188
|
+
path = root / Path(info.main_file).name
|
|
189
|
+
if path.is_file():
|
|
190
|
+
return path
|
|
191
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
192
|
+
url = f"{BIOMODELS_URL}/model/download/{model_id}"
|
|
193
|
+
tmp_path = path.with_name(path.name + ".part")
|
|
194
|
+
logger.info("Downloading %s from %s", model_id, url)
|
|
195
|
+
try:
|
|
196
|
+
with requests.get(
|
|
197
|
+
url, params={"filename": info.main_file}, stream=True, timeout=TIMEOUT
|
|
198
|
+
) as response:
|
|
199
|
+
response.raise_for_status()
|
|
200
|
+
with tmp_path.open("wb") as f_sbml:
|
|
201
|
+
for chunk in response.iter_content(1 << 16):
|
|
202
|
+
f_sbml.write(chunk)
|
|
203
|
+
except requests.RequestException as err:
|
|
204
|
+
tmp_path.unlink(missing_ok=True)
|
|
205
|
+
raise BioModelsError(f"{model_id}: download failed: {err}") from err
|
|
206
|
+
os.replace(tmp_path, path)
|
|
207
|
+
return path
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def load_selection(path: Path) -> Selection:
|
|
211
|
+
"""Read a selection file (e.g. `biomodels/models.json`).
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
path: the selection file.
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
The selection.
|
|
218
|
+
"""
|
|
219
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
220
|
+
return Selection(
|
|
221
|
+
date=data["date"], query=data["query"], models=tuple(data["models"])
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def write_selection(path: Path, ids: list[str]) -> Selection:
|
|
226
|
+
"""Write a selection file with today's date and the given ids.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
path: file to write.
|
|
230
|
+
ids: model ids, written sorted and without duplicates.
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
The selection written.
|
|
234
|
+
"""
|
|
235
|
+
selection = Selection(
|
|
236
|
+
date=date.today().isoformat(),
|
|
237
|
+
query=SEARCH_QUERY,
|
|
238
|
+
models=tuple(sorted(set(ids))),
|
|
239
|
+
)
|
|
240
|
+
path.write_text(
|
|
241
|
+
json.dumps(
|
|
242
|
+
{
|
|
243
|
+
"date": selection.date,
|
|
244
|
+
"query": selection.query,
|
|
245
|
+
"models": list(selection.models),
|
|
246
|
+
},
|
|
247
|
+
indent=1,
|
|
248
|
+
)
|
|
249
|
+
+ "\n",
|
|
250
|
+
encoding="utf-8",
|
|
251
|
+
)
|
|
252
|
+
return selection
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def packages(sbml_path: Path) -> tuple[str, ...]:
|
|
256
|
+
"""SBML packages an SBML file uses, not merely declares.
|
|
257
|
+
|
|
258
|
+
A package declared through the root element's `xmlns` counts only when
|
|
259
|
+
an element with its prefix actually occurs in the file, e.g. `<comp:...`
|
|
260
|
+
for a `comp` declaration. `comp` is the exception: its `port` elements
|
|
261
|
+
do not change the math, so it counts only when a `<comp:submodel`,
|
|
262
|
+
`<comp:modelDefinition` or `<comp:externalModelDefinition` element
|
|
263
|
+
occurs.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
sbml_path: SBML file to inspect.
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
The package names, sorted and without duplicates.
|
|
270
|
+
"""
|
|
271
|
+
text = sbml_path.read_text(encoding="utf-8", errors="replace")
|
|
272
|
+
used: set[str] = set()
|
|
273
|
+
for prefix, package in {
|
|
274
|
+
(match.group(1), match.group(2)) for match in _XMLNS.finditer(text)
|
|
275
|
+
}:
|
|
276
|
+
if package == "comp":
|
|
277
|
+
if any(f"<{prefix}:{element}" in text for element in _COMP_ELEMENTS):
|
|
278
|
+
used.add(package)
|
|
279
|
+
elif f"<{prefix}:" in text:
|
|
280
|
+
used.add(package)
|
|
281
|
+
return tuple(sorted(used))
|