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.
@@ -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))