sbmlode 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.
- sbmlode/__init__.py +44 -0
- sbmlode/analysis.py +981 -0
- sbmlode/astutil.py +164 -0
- sbmlode/dependencies.py +77 -0
- sbmlode/documents.py +914 -0
- sbmlode/events.py +103 -0
- sbmlode/formats.py +978 -0
- sbmlode/io.py +152 -0
- sbmlode/printers/__init__.py +41 -0
- sbmlode/printers/base.py +1094 -0
- sbmlode/printers/document.py +279 -0
- sbmlode/printers/julia.py +180 -0
- sbmlode/printers/latex.py +141 -0
- sbmlode/printers/python.py +155 -0
- sbmlode/printers/r.py +166 -0
- sbmlode/printers/typst.py +129 -0
- sbmlode/symbols.py +518 -0
- sbmlode/system.py +471 -0
- sbmlode/templates/julia.jl.jinja +719 -0
- sbmlode/templates/latex.tex.jinja +199 -0
- sbmlode/templates/markdown.md.jinja +163 -0
- sbmlode/templates/python.py.jinja +683 -0
- sbmlode/templates/r.R.jinja +808 -0
- sbmlode/templates/typst.typ.jinja +175 -0
- sbmlode/text.py +247 -0
- sbmlode/units.py +198 -0
- sbmlode-0.1.0.dist-info/METADATA +100 -0
- sbmlode-0.1.0.dist-info/RECORD +30 -0
- sbmlode-0.1.0.dist-info/WHEEL +4 -0
- sbmlode-0.1.0.dist-info/licenses/LICENSE +7 -0
sbmlode/analysis.py
ADDED
|
@@ -0,0 +1,981 @@
|
|
|
1
|
+
"""The analysis of an SBML model into its `OdeSystem`.
|
|
2
|
+
|
|
3
|
+
`analyse` reads the document (flattened, in L3) and `_Analysis` resolves the
|
|
4
|
+
semantics of SBML core which `sbmlode.system` describes, one method
|
|
5
|
+
per element and rule.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from collections.abc import Mapping, Sequence
|
|
10
|
+
from dataclasses import replace
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import libsbml
|
|
14
|
+
|
|
15
|
+
from sbmlode.astutil import (
|
|
16
|
+
drop_zero_terms,
|
|
17
|
+
name,
|
|
18
|
+
node,
|
|
19
|
+
number,
|
|
20
|
+
product,
|
|
21
|
+
signed_sum,
|
|
22
|
+
walk,
|
|
23
|
+
)
|
|
24
|
+
from sbmlode.dependencies import names, order
|
|
25
|
+
from sbmlode.events import trigger_root
|
|
26
|
+
from sbmlode.io import flatten, read_document
|
|
27
|
+
from sbmlode.system import (
|
|
28
|
+
Assignment,
|
|
29
|
+
Event,
|
|
30
|
+
EventAssignment,
|
|
31
|
+
FunctionDefinition,
|
|
32
|
+
Kind,
|
|
33
|
+
ModelInfo,
|
|
34
|
+
Ode,
|
|
35
|
+
OdeSystem,
|
|
36
|
+
Participant,
|
|
37
|
+
Quantity,
|
|
38
|
+
Reaction,
|
|
39
|
+
Role,
|
|
40
|
+
Symbol,
|
|
41
|
+
)
|
|
42
|
+
from sbmlode.units import udef_to_string
|
|
43
|
+
|
|
44
|
+
__all__ = ["analyse"]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def analyse(source: Path | str | libsbml.SBMLDocument) -> OdeSystem:
|
|
48
|
+
"""Analyse an SBML model into its ODE system, see `OdeSystem.from_sbml`."""
|
|
49
|
+
doc, file_name, level = _document(source)
|
|
50
|
+
return _Analysis(doc, file_name, level).system()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# --- reading --------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _document(
|
|
57
|
+
source: Path | str | libsbml.SBMLDocument,
|
|
58
|
+
) -> tuple[libsbml.SBMLDocument, str | None, tuple[int, int]]:
|
|
59
|
+
"""The document of a source, flattened and in L3.
|
|
60
|
+
|
|
61
|
+
A document which is passed is copied before it is changed.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
the document, the name of the file it was read from and the level and
|
|
65
|
+
version it was written in
|
|
66
|
+
"""
|
|
67
|
+
if isinstance(source, libsbml.SBMLDocument):
|
|
68
|
+
doc, file_name, owned = source, None, False
|
|
69
|
+
else:
|
|
70
|
+
doc, owned = read_document(source), True
|
|
71
|
+
is_sbml = isinstance(source, str) and "<sbml" in source
|
|
72
|
+
file_name = None if is_sbml else Path(source).name
|
|
73
|
+
model: libsbml.Model | None = doc.getModel()
|
|
74
|
+
if model is None:
|
|
75
|
+
raise ValueError("The SBML document has no model.")
|
|
76
|
+
level = (doc.getLevel(), doc.getVersion())
|
|
77
|
+
comp: libsbml.CompModelPlugin | None = model.getPlugin("comp")
|
|
78
|
+
if comp is not None and comp.getNumSubmodels() > 0:
|
|
79
|
+
doc = flatten(doc if owned else doc.clone())
|
|
80
|
+
owned = True
|
|
81
|
+
if doc.getLevel() < 3:
|
|
82
|
+
# L3 holds every construct of L1 and L2, a stoichiometry math becomes a
|
|
83
|
+
# species reference with an assignment rule
|
|
84
|
+
doc = doc if owned else doc.clone()
|
|
85
|
+
if not doc.setLevelAndVersion(3, 2, False):
|
|
86
|
+
raise ValueError(
|
|
87
|
+
f"The L{level[0]}V{level[1]} document cannot be read as L3V2."
|
|
88
|
+
)
|
|
89
|
+
return doc, file_name, level
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# the elements of XHTML which are blocks of text, a paragraph of the plain text each
|
|
93
|
+
_BLOCKS = frozenset(
|
|
94
|
+
{
|
|
95
|
+
"address",
|
|
96
|
+
"article",
|
|
97
|
+
"aside",
|
|
98
|
+
"blockquote",
|
|
99
|
+
"br",
|
|
100
|
+
"caption",
|
|
101
|
+
"dd",
|
|
102
|
+
"div",
|
|
103
|
+
"dl",
|
|
104
|
+
"dt",
|
|
105
|
+
"figcaption",
|
|
106
|
+
"figure",
|
|
107
|
+
"footer",
|
|
108
|
+
"h1",
|
|
109
|
+
"h2",
|
|
110
|
+
"h3",
|
|
111
|
+
"h4",
|
|
112
|
+
"h5",
|
|
113
|
+
"h6",
|
|
114
|
+
"header",
|
|
115
|
+
"hr",
|
|
116
|
+
"li",
|
|
117
|
+
"main",
|
|
118
|
+
"nav",
|
|
119
|
+
"ol",
|
|
120
|
+
"p",
|
|
121
|
+
"pre",
|
|
122
|
+
"section",
|
|
123
|
+
"table",
|
|
124
|
+
"tr",
|
|
125
|
+
"ul",
|
|
126
|
+
}
|
|
127
|
+
)
|
|
128
|
+
_CELLS = frozenset({"td", "th"})
|
|
129
|
+
# the edge of a text node, between which and an inline element the indentation of
|
|
130
|
+
# the XML adds white space, `(<em> x</em>\n )`
|
|
131
|
+
_EDGE = "\x00"
|
|
132
|
+
_AFTER_OPENING = re.compile(r"\(\s*\x00[\s\x00]*")
|
|
133
|
+
_BEFORE_PUNCTUATION = re.compile(r"[\s\x00]*\x00\s*([).,;:!?])")
|
|
134
|
+
|
|
135
|
+
# a unit without a magnitude, a product or a quotient, `l` or `m^3`
|
|
136
|
+
_SINGLE_UNIT = re.compile(r"[^\s*/()^]+(\^[0-9.]+)?")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _plain_text(notes: libsbml.XMLNode) -> str:
|
|
140
|
+
"""The text of XHTML notes as paragraphs, separated by a blank line.
|
|
141
|
+
|
|
142
|
+
A block of XHTML (`_BLOCKS`, e.g. a paragraph, a heading, an item of a list, a
|
|
143
|
+
row of a table, a line break) is a paragraph, the cells of a row are separated
|
|
144
|
+
by a space, and the white space of a paragraph is one space; the white space at
|
|
145
|
+
the edge of an inline element after an opening parenthesis and before a closing
|
|
146
|
+
one or a punctuation mark is none, so that the line breaks and the indentation
|
|
147
|
+
of the XML do not show in the text.
|
|
148
|
+
"""
|
|
149
|
+
paragraphs: list[str] = []
|
|
150
|
+
texts: list[str] = []
|
|
151
|
+
|
|
152
|
+
def flush() -> None:
|
|
153
|
+
joined = "".join(texts)
|
|
154
|
+
# the white space at the edge of an inline element, `( <i>x</i>\n )`, the
|
|
155
|
+
# white space within a text, `a : b`, is kept
|
|
156
|
+
joined = _AFTER_OPENING.sub("(", _BEFORE_PUNCTUATION.sub(r"\1", joined))
|
|
157
|
+
paragraph = " ".join(joined.replace(_EDGE, "").split())
|
|
158
|
+
if paragraph:
|
|
159
|
+
paragraphs.append(paragraph)
|
|
160
|
+
texts.clear()
|
|
161
|
+
|
|
162
|
+
def visit(node: libsbml.XMLNode) -> None:
|
|
163
|
+
if node.isText():
|
|
164
|
+
texts.extend((_EDGE, node.getCharacters(), _EDGE))
|
|
165
|
+
return
|
|
166
|
+
block = node.getName() in _BLOCKS
|
|
167
|
+
if block:
|
|
168
|
+
flush()
|
|
169
|
+
for k in range(node.getNumChildren()):
|
|
170
|
+
visit(node.getChild(k))
|
|
171
|
+
if block:
|
|
172
|
+
flush()
|
|
173
|
+
elif node.getName() in _CELLS:
|
|
174
|
+
texts.append(" ")
|
|
175
|
+
|
|
176
|
+
visit(notes)
|
|
177
|
+
flush()
|
|
178
|
+
return "\n\n".join(paragraphs)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _rename(ast: libsbml.ASTNode, renamed: Mapping[str, str]) -> None:
|
|
182
|
+
"""Rename the names of the math in place."""
|
|
183
|
+
for current in walk(ast):
|
|
184
|
+
if current.getType() == libsbml.AST_NAME and current.getName() in renamed:
|
|
185
|
+
current.setName(renamed[current.getName()])
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# the types of the distrib functions, e.g. `normal(mean, sd)`
|
|
189
|
+
_DISTRIB = range(
|
|
190
|
+
libsbml.AST_DISTRIB_FUNCTION_NORMAL, libsbml.AST_DISTRIB_FUNCTION_RAYLEIGH + 1
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _term(
|
|
195
|
+
sign: int, stoichiometry: float | str, factor: str | None, rid: str
|
|
196
|
+
) -> tuple[int, libsbml.ASTNode]:
|
|
197
|
+
"""The signed term of a reaction in the ode of a species, `2 * f * J`.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
sign: `-1` for a reactant, `1` for a product
|
|
201
|
+
stoichiometry: the number or the id of the species reference
|
|
202
|
+
factor: the conversion factor of the species
|
|
203
|
+
rid: the id of the reaction
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
the sign and the magnitude of the term, for `signed_sum`
|
|
207
|
+
"""
|
|
208
|
+
factors = []
|
|
209
|
+
if isinstance(stoichiometry, str):
|
|
210
|
+
factors.append(name(stoichiometry))
|
|
211
|
+
elif stoichiometry != 1.0:
|
|
212
|
+
if stoichiometry < 0:
|
|
213
|
+
sign, stoichiometry = -sign, -stoichiometry
|
|
214
|
+
factors.append(number(stoichiometry))
|
|
215
|
+
if factor:
|
|
216
|
+
factors.append(name(factor))
|
|
217
|
+
factors.append(name(rid))
|
|
218
|
+
return sign, product(factors)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _with_amounts(
|
|
222
|
+
species: Sequence[Quantity], amounts: Sequence[Quantity]
|
|
223
|
+
) -> list[Quantity]:
|
|
224
|
+
"""The species, each followed by its amount if it is held as amount."""
|
|
225
|
+
amount_of = {amount.amount_of: amount for amount in amounts}
|
|
226
|
+
return [
|
|
227
|
+
quantity
|
|
228
|
+
for s in species
|
|
229
|
+
for quantity in (s, amount_of.get(s.symbol.sid))
|
|
230
|
+
if quantity is not None
|
|
231
|
+
]
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _ordered(assignments: list[Assignment]) -> tuple[Assignment, ...]:
|
|
235
|
+
"""The assignments in the order of their dependencies."""
|
|
236
|
+
by_variable = {a.variable: a for a in assignments}
|
|
237
|
+
items = [(a.variable, a.math) for a in assignments]
|
|
238
|
+
return tuple(by_variable[sid] for sid in order(items))
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# --- the analysis ---------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class _Analysis:
|
|
245
|
+
"""The analysis of one model, which builds its `OdeSystem`.
|
|
246
|
+
|
|
247
|
+
The math of the document is read once into copies (`_math`), every math the
|
|
248
|
+
system holds is a copy with each rateOf resolved (`_resolve`).
|
|
249
|
+
"""
|
|
250
|
+
|
|
251
|
+
def __init__(
|
|
252
|
+
self, doc: libsbml.SBMLDocument, file_name: str | None, level: tuple[int, int]
|
|
253
|
+
) -> None:
|
|
254
|
+
"""Read the rules of the model and the ids it takes.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
doc: the document, flattened and in L3
|
|
258
|
+
file_name: the name of the file the document was read from
|
|
259
|
+
level: the level and version the document was written in
|
|
260
|
+
"""
|
|
261
|
+
# the document owns the model, it is kept alive while the model is read
|
|
262
|
+
self.doc = doc
|
|
263
|
+
self.model: libsbml.Model = doc.getModel()
|
|
264
|
+
self.file_name = file_name
|
|
265
|
+
self.level = level
|
|
266
|
+
self.unsupported: list[tuple[str, str]] = []
|
|
267
|
+
self.taken: set[str] = {
|
|
268
|
+
element.getIdAttribute() for element in self.model.getListOfAllElements()
|
|
269
|
+
} | {self.model.getId()}
|
|
270
|
+
self.rate_rules: dict[str, libsbml.ASTNode] = {}
|
|
271
|
+
self.assignment_rules: dict[str, libsbml.ASTNode] = {}
|
|
272
|
+
self.initial_assignments: dict[str, libsbml.ASTNode] = {}
|
|
273
|
+
self.event_variables: set[str] = {
|
|
274
|
+
assignment.getVariable()
|
|
275
|
+
for event in self.model.getListOfEvents()
|
|
276
|
+
for assignment in event.getListOfEventAssignments()
|
|
277
|
+
}
|
|
278
|
+
self._read_rules()
|
|
279
|
+
# the quantities and reactions by id, the amount of each species held as
|
|
280
|
+
# amount, the conversion factor of each species, the right hand side of
|
|
281
|
+
# each state as read and with its rateOf resolved
|
|
282
|
+
self.quantities: dict[str, Quantity] = {}
|
|
283
|
+
self.reaction_ids: set[str] = set()
|
|
284
|
+
self.amounts: dict[str, str] = {}
|
|
285
|
+
self.factors: dict[str, str] = {}
|
|
286
|
+
self.rhs: dict[str, libsbml.ASTNode] = {}
|
|
287
|
+
self.resolved: dict[str, libsbml.ASTNode] = {}
|
|
288
|
+
|
|
289
|
+
# --- helpers ------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
def _unique(self, base: str) -> str:
|
|
292
|
+
"""An id which the model does not take, `base`, else `base_1`, `base_2`, ..."""
|
|
293
|
+
sid, k = base, 0
|
|
294
|
+
while sid in self.taken:
|
|
295
|
+
k += 1
|
|
296
|
+
sid = f"{base}_{k}"
|
|
297
|
+
self.taken.add(sid)
|
|
298
|
+
return sid
|
|
299
|
+
|
|
300
|
+
def _unit(self, uid: str) -> str | None:
|
|
301
|
+
"""The unit of a unit id, `None` if it is not set."""
|
|
302
|
+
return udef_to_string(uid, self.model) if uid else None
|
|
303
|
+
|
|
304
|
+
def _symbol(
|
|
305
|
+
self,
|
|
306
|
+
element: libsbml.SBase,
|
|
307
|
+
kind: Kind,
|
|
308
|
+
unit: str | None,
|
|
309
|
+
sid: str = "",
|
|
310
|
+
source: tuple[str, ...] = (),
|
|
311
|
+
) -> Symbol:
|
|
312
|
+
"""The symbol of an element, under the given id if it is renamed.
|
|
313
|
+
|
|
314
|
+
`source` is the element of the model a renamed symbol stands for, see
|
|
315
|
+
`Symbol.element`.
|
|
316
|
+
"""
|
|
317
|
+
return Symbol(
|
|
318
|
+
sid=sid or element.getId(),
|
|
319
|
+
name=element.getName() if element.isSetName() else None,
|
|
320
|
+
unit=unit,
|
|
321
|
+
sbo=element.getSBOTermID() if element.isSetSBOTerm() else None,
|
|
322
|
+
kind=kind,
|
|
323
|
+
element=source,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
def _math(self, element: str, ast: libsbml.ASTNode) -> libsbml.ASTNode:
|
|
327
|
+
"""A copy of the math of an element, its unsupported constructs collected."""
|
|
328
|
+
for current in walk(ast):
|
|
329
|
+
if current.getType() == libsbml.AST_FUNCTION_DELAY:
|
|
330
|
+
self._unsupported("delay", element)
|
|
331
|
+
elif current.getType() in _DISTRIB:
|
|
332
|
+
self._unsupported("distrib function", element)
|
|
333
|
+
return ast.deepCopy()
|
|
334
|
+
|
|
335
|
+
def _unsupported(self, construct: str, element: str) -> None:
|
|
336
|
+
"""Collect an unsupported construct once."""
|
|
337
|
+
if (construct, element) not in self.unsupported:
|
|
338
|
+
self.unsupported.append((construct, element))
|
|
339
|
+
|
|
340
|
+
def _read_rules(self) -> None:
|
|
341
|
+
"""Read the rules and the initial assignments by their variable."""
|
|
342
|
+
rule: libsbml.Rule
|
|
343
|
+
for k, rule in enumerate(self.model.getListOfRules()):
|
|
344
|
+
if rule.isAlgebraic():
|
|
345
|
+
label = rule.getIdAttribute() or rule.getMetaId() or f"rule{k}"
|
|
346
|
+
self._unsupported("algebraic rule", label)
|
|
347
|
+
continue
|
|
348
|
+
variable = rule.getVariable()
|
|
349
|
+
if variable in self.rate_rules or variable in self.assignment_rules:
|
|
350
|
+
raise ValueError(f"The variable '{variable}' has more than one rule.")
|
|
351
|
+
# a rule without math is ignored (L3V2)
|
|
352
|
+
if rule.isSetMath():
|
|
353
|
+
rules = self.rate_rules if rule.isRate() else self.assignment_rules
|
|
354
|
+
rules[variable] = self._math(variable, rule.getMath())
|
|
355
|
+
assignment: libsbml.InitialAssignment
|
|
356
|
+
for assignment in self.model.getListOfInitialAssignments():
|
|
357
|
+
symbol = assignment.getSymbol()
|
|
358
|
+
if symbol in self.assignment_rules or symbol in self.initial_assignments:
|
|
359
|
+
raise ValueError(
|
|
360
|
+
f"The variable '{symbol}' has an initial assignment and an "
|
|
361
|
+
f"assignment rule or a second initial assignment."
|
|
362
|
+
)
|
|
363
|
+
# an initial assignment without math is ignored (L3V2)
|
|
364
|
+
if assignment.isSetMath():
|
|
365
|
+
math = self._math(symbol, assignment.getMath())
|
|
366
|
+
self.initial_assignments[symbol] = math
|
|
367
|
+
|
|
368
|
+
def _role(self, sid: str) -> Role:
|
|
369
|
+
"""The role of a quantity by its rules."""
|
|
370
|
+
if sid in self.rate_rules:
|
|
371
|
+
return "state"
|
|
372
|
+
return "assigned" if sid in self.assignment_rules else "constant"
|
|
373
|
+
|
|
374
|
+
def _quantity(
|
|
375
|
+
self,
|
|
376
|
+
element: libsbml.Compartment | libsbml.Parameter | libsbml.SpeciesReference,
|
|
377
|
+
kind: Kind,
|
|
378
|
+
unit: str | None,
|
|
379
|
+
value: float | None,
|
|
380
|
+
) -> Quantity:
|
|
381
|
+
"""The quantity of a compartment, parameter or species reference."""
|
|
382
|
+
return Quantity(
|
|
383
|
+
symbol=self._symbol(element, kind, unit),
|
|
384
|
+
value=value,
|
|
385
|
+
constant=element.getConstant(),
|
|
386
|
+
role=self._role(element.getId()),
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
# --- the system ---------------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
def system(self) -> OdeSystem:
|
|
392
|
+
"""Analyse the model into its ODE system."""
|
|
393
|
+
functions = self._read_functions()
|
|
394
|
+
reactions, local_parameters, references = self._read_reactions()
|
|
395
|
+
compartments = [
|
|
396
|
+
self._quantity(
|
|
397
|
+
c,
|
|
398
|
+
"compartment",
|
|
399
|
+
self._compartment_unit(c),
|
|
400
|
+
# a compartment without a size has the size 1, as roadrunner holds
|
|
401
|
+
# it, e.g. a compartment of 0 dimensions
|
|
402
|
+
c.getSize() if c.isSetSize() else 1.0,
|
|
403
|
+
)
|
|
404
|
+
for c in self.model.getListOfCompartments()
|
|
405
|
+
]
|
|
406
|
+
species, amounts = self._read_species(reactions)
|
|
407
|
+
parameters = [
|
|
408
|
+
self._quantity(
|
|
409
|
+
p,
|
|
410
|
+
"parameter",
|
|
411
|
+
self._unit(p.getUnits()),
|
|
412
|
+
p.getValue() if p.isSetValue() else None,
|
|
413
|
+
)
|
|
414
|
+
for p in self.model.getListOfParameters()
|
|
415
|
+
]
|
|
416
|
+
parameters.extend(local_parameters)
|
|
417
|
+
quantities = [
|
|
418
|
+
*compartments,
|
|
419
|
+
*_with_amounts(species, amounts),
|
|
420
|
+
*parameters,
|
|
421
|
+
*references,
|
|
422
|
+
]
|
|
423
|
+
self.quantities = {q.symbol.sid: q for q in quantities}
|
|
424
|
+
self.reaction_ids = {r.symbol.sid for r in reactions}
|
|
425
|
+
odes = self._build_odes(quantities, reactions)
|
|
426
|
+
odes = [
|
|
427
|
+
replace(ode, rhs=self._resolved_rate(ode.variable, ode.variable))
|
|
428
|
+
for ode in odes
|
|
429
|
+
]
|
|
430
|
+
reactions = [
|
|
431
|
+
replace(r, rate=self._resolve(r.rate, r.symbol.sid)) for r in reactions
|
|
432
|
+
]
|
|
433
|
+
return OdeSystem(
|
|
434
|
+
info=self._read_info(),
|
|
435
|
+
compartments=tuple(compartments),
|
|
436
|
+
species=tuple(species),
|
|
437
|
+
amounts=tuple(amounts),
|
|
438
|
+
parameters=tuple(parameters),
|
|
439
|
+
species_references=tuple(references),
|
|
440
|
+
functions=tuple(functions),
|
|
441
|
+
assignments=self._build_assignments(amounts, reactions),
|
|
442
|
+
initial=self._build_initial(quantities, reactions),
|
|
443
|
+
reactions=tuple(reactions),
|
|
444
|
+
odes=tuple(odes),
|
|
445
|
+
events=tuple(self._read_events()),
|
|
446
|
+
unsupported=tuple(self.unsupported),
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
# --- the elements -------------------------------------------------------------
|
|
450
|
+
|
|
451
|
+
def _read_info(self) -> ModelInfo:
|
|
452
|
+
"""The information of the model."""
|
|
453
|
+
model = self.model
|
|
454
|
+
units = {
|
|
455
|
+
"time": model.getTimeUnits(),
|
|
456
|
+
"substance": model.getSubstanceUnits(),
|
|
457
|
+
"extent": model.getExtentUnits(),
|
|
458
|
+
"volume": model.getVolumeUnits(),
|
|
459
|
+
"area": model.getAreaUnits(),
|
|
460
|
+
"length": model.getLengthUnits(),
|
|
461
|
+
}
|
|
462
|
+
return ModelInfo(
|
|
463
|
+
sid=model.getId() if model.isSetId() else None,
|
|
464
|
+
name=model.getName() if model.isSetName() else None,
|
|
465
|
+
level=self.level[0],
|
|
466
|
+
version=self.level[1],
|
|
467
|
+
notes=_plain_text(model.getNotes()) if model.isSetNotes() else None,
|
|
468
|
+
units={key: self._unit(uid) for key, uid in units.items()},
|
|
469
|
+
source=self.file_name,
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
def _read_functions(self) -> list[FunctionDefinition]:
|
|
473
|
+
"""The function definitions, which stay functions called by the math."""
|
|
474
|
+
functions = []
|
|
475
|
+
function: libsbml.FunctionDefinition
|
|
476
|
+
for function in self.model.getListOfFunctionDefinitions():
|
|
477
|
+
fid = function.getId()
|
|
478
|
+
body: libsbml.ASTNode | None = function.getBody()
|
|
479
|
+
# a function definition without math is ignored (L3V2)
|
|
480
|
+
if body is None:
|
|
481
|
+
continue
|
|
482
|
+
body = self._math(fid, body)
|
|
483
|
+
if any(n.getType() == libsbml.AST_FUNCTION_RATE_OF for n in walk(body)):
|
|
484
|
+
self._unsupported("rateOf in a function definition", fid)
|
|
485
|
+
arguments = tuple(
|
|
486
|
+
function.getArgument(k).getName()
|
|
487
|
+
for k in range(function.getNumArguments())
|
|
488
|
+
)
|
|
489
|
+
symbol = self._symbol(function, "function", None)
|
|
490
|
+
functions.append(FunctionDefinition(symbol, arguments, body))
|
|
491
|
+
return functions
|
|
492
|
+
|
|
493
|
+
def _read_reactions(self) -> tuple[list[Reaction], list[Quantity], list[Quantity]]:
|
|
494
|
+
"""The reactions, their renamed local parameters and species references."""
|
|
495
|
+
extent = self._unit(self.model.getExtentUnits())
|
|
496
|
+
time = self._unit(self.model.getTimeUnits())
|
|
497
|
+
unit = f"{extent}/{time}" if extent and time else None
|
|
498
|
+
reactions: list[Reaction] = []
|
|
499
|
+
local_parameters: list[Quantity] = []
|
|
500
|
+
references: list[Quantity] = []
|
|
501
|
+
reaction: libsbml.Reaction
|
|
502
|
+
for reaction in self.model.getListOfReactions():
|
|
503
|
+
rid = reaction.getId()
|
|
504
|
+
if reaction.isSetFast() and reaction.getFast():
|
|
505
|
+
self._unsupported("fast reaction", rid)
|
|
506
|
+
law: libsbml.KineticLaw | None = reaction.getKineticLaw()
|
|
507
|
+
renamed: dict[str, str] = {}
|
|
508
|
+
parameter: libsbml.LocalParameter
|
|
509
|
+
for parameter in [] if law is None else law.getListOfLocalParameters():
|
|
510
|
+
sid = self._unique(f"{rid}_{parameter.getId()}")
|
|
511
|
+
renamed[parameter.getId()] = sid
|
|
512
|
+
symbol = self._symbol(
|
|
513
|
+
parameter,
|
|
514
|
+
"parameter",
|
|
515
|
+
self._unit(parameter.getUnits()),
|
|
516
|
+
sid,
|
|
517
|
+
(rid, parameter.getId()),
|
|
518
|
+
)
|
|
519
|
+
value = parameter.getValue() if parameter.isSetValue() else None
|
|
520
|
+
local_parameters.append(Quantity(symbol, value, True, "constant"))
|
|
521
|
+
if law is not None and law.isSetMath():
|
|
522
|
+
rate = self._math(rid, law.getMath())
|
|
523
|
+
_rename(rate, renamed)
|
|
524
|
+
else:
|
|
525
|
+
# a reaction without a kinetic law has no flux, as roadrunner holds it
|
|
526
|
+
rate = number(0.0)
|
|
527
|
+
sides: list[list[Participant]] = [[], []]
|
|
528
|
+
for side, listed in enumerate(
|
|
529
|
+
(reaction.getListOfReactants(), reaction.getListOfProducts())
|
|
530
|
+
):
|
|
531
|
+
ref: libsbml.SpeciesReference
|
|
532
|
+
for ref in listed:
|
|
533
|
+
sides[side].append(self._participant(ref))
|
|
534
|
+
if ref.isSetId():
|
|
535
|
+
# an unset stoichiometry is 1, as roadrunner holds it
|
|
536
|
+
value = (
|
|
537
|
+
ref.getStoichiometry() if ref.isSetStoichiometry() else 1.0
|
|
538
|
+
)
|
|
539
|
+
references.append(
|
|
540
|
+
self._quantity(ref, "species_reference", None, value)
|
|
541
|
+
)
|
|
542
|
+
reactions.append(
|
|
543
|
+
Reaction(
|
|
544
|
+
symbol=self._symbol(reaction, "reaction", unit),
|
|
545
|
+
reactants=tuple(sides[0]),
|
|
546
|
+
products=tuple(sides[1]),
|
|
547
|
+
modifiers=tuple(
|
|
548
|
+
m.getSpecies() for m in reaction.getListOfModifiers()
|
|
549
|
+
),
|
|
550
|
+
reversible=reaction.getReversible(),
|
|
551
|
+
rate=rate,
|
|
552
|
+
local_parameters=tuple(renamed.values()),
|
|
553
|
+
)
|
|
554
|
+
)
|
|
555
|
+
return reactions, local_parameters, references
|
|
556
|
+
|
|
557
|
+
def _participant(self, ref: libsbml.SpeciesReference) -> Participant:
|
|
558
|
+
"""The species of a species reference and its stoichiometry."""
|
|
559
|
+
sid = ref.getId()
|
|
560
|
+
changed = (
|
|
561
|
+
self.rate_rules,
|
|
562
|
+
self.assignment_rules,
|
|
563
|
+
self.initial_assignments,
|
|
564
|
+
self.event_variables,
|
|
565
|
+
)
|
|
566
|
+
if ref.isSetId() and any(sid in ids for ids in changed):
|
|
567
|
+
return Participant(ref.getSpecies(), sid)
|
|
568
|
+
value = ref.getStoichiometry() if ref.isSetStoichiometry() else 1.0
|
|
569
|
+
return Participant(ref.getSpecies(), value)
|
|
570
|
+
|
|
571
|
+
def _compartment_unit(self, compartment: libsbml.Compartment) -> str | None:
|
|
572
|
+
"""The unit of a compartment, else the unit of the model of its dimensions."""
|
|
573
|
+
if compartment.isSetUnits():
|
|
574
|
+
return self._unit(compartment.getUnits())
|
|
575
|
+
default = {
|
|
576
|
+
3.0: self.model.getVolumeUnits,
|
|
577
|
+
2.0: self.model.getAreaUnits,
|
|
578
|
+
1.0: self.model.getLengthUnits,
|
|
579
|
+
}.get(compartment.getSpatialDimensionsAsDouble())
|
|
580
|
+
return self._unit(default()) if default else None
|
|
581
|
+
|
|
582
|
+
def _species_unit(self, species: libsbml.Species) -> tuple[str | None, str | None]:
|
|
583
|
+
"""The unit of the substance of a species and the unit of the species."""
|
|
584
|
+
substance = self._unit(
|
|
585
|
+
species.getSubstanceUnits() or self.model.getSubstanceUnits()
|
|
586
|
+
)
|
|
587
|
+
if species.getHasOnlySubstanceUnits():
|
|
588
|
+
return substance, substance
|
|
589
|
+
compartment = self.model.getCompartment(species.getCompartment())
|
|
590
|
+
volume = self._compartment_unit(compartment) if compartment else None
|
|
591
|
+
if not substance or not volume:
|
|
592
|
+
return substance, None
|
|
593
|
+
# a single unit, also with an exponent, needs no parentheses, mmol/m^3
|
|
594
|
+
single = _SINGLE_UNIT.fullmatch(volume) is not None
|
|
595
|
+
return substance, f"{substance}/{volume if single else f'({volume})'}"
|
|
596
|
+
|
|
597
|
+
def _read_species(
|
|
598
|
+
self, reactions: list[Reaction]
|
|
599
|
+
) -> tuple[list[Quantity], list[Quantity]]:
|
|
600
|
+
"""The species and the amounts of the species held as amount."""
|
|
601
|
+
reacting = {p.species for r in reactions for p in (*r.reactants, *r.products)}
|
|
602
|
+
species_list: list[Quantity] = []
|
|
603
|
+
amounts: list[Quantity] = []
|
|
604
|
+
species: libsbml.Species
|
|
605
|
+
for species in self.model.getListOfSpecies():
|
|
606
|
+
sid, cid = species.getId(), species.getCompartment()
|
|
607
|
+
in_amount = species.getHasOnlySubstanceUnits()
|
|
608
|
+
if not cid and (not in_amount or species.isSetInitialConcentration()):
|
|
609
|
+
# L3 requires the compartment, libsbml reads a species without one
|
|
610
|
+
raise ValueError(
|
|
611
|
+
f"The species {sid!r} has no compartment, which converts between "
|
|
612
|
+
f"its amount and its concentration."
|
|
613
|
+
)
|
|
614
|
+
boundary, constant = species.getBoundaryCondition(), species.getConstant()
|
|
615
|
+
substance, unit = self._species_unit(species)
|
|
616
|
+
factor = species.getConversionFactor() or self.model.getConversionFactor()
|
|
617
|
+
if factor:
|
|
618
|
+
self.factors[sid] = factor
|
|
619
|
+
value = None
|
|
620
|
+
if species.isSetInitialAmount() and in_amount:
|
|
621
|
+
value = species.getInitialAmount()
|
|
622
|
+
elif species.isSetInitialConcentration() and not in_amount:
|
|
623
|
+
value = species.getInitialConcentration()
|
|
624
|
+
role: Role = (
|
|
625
|
+
"state"
|
|
626
|
+
if sid in reacting and not boundary and not constant
|
|
627
|
+
else "constant"
|
|
628
|
+
)
|
|
629
|
+
variable_size = self._role(cid) != "constant" or cid in self.event_variables
|
|
630
|
+
if sid in self.assignment_rules or sid in self.rate_rules:
|
|
631
|
+
role = self._role(sid)
|
|
632
|
+
elif not in_amount and variable_size:
|
|
633
|
+
# held as amount, the concentration is assigned
|
|
634
|
+
aid = self._unique(f"n_{sid}")
|
|
635
|
+
self.amounts[sid] = aid
|
|
636
|
+
amounts.append(
|
|
637
|
+
Quantity(
|
|
638
|
+
symbol=Symbol(
|
|
639
|
+
aid,
|
|
640
|
+
f"amount of {species.getName() or sid}",
|
|
641
|
+
substance,
|
|
642
|
+
None,
|
|
643
|
+
"species",
|
|
644
|
+
(sid,),
|
|
645
|
+
),
|
|
646
|
+
value=(
|
|
647
|
+
species.getInitialAmount()
|
|
648
|
+
if species.isSetInitialAmount()
|
|
649
|
+
else None
|
|
650
|
+
),
|
|
651
|
+
constant=constant,
|
|
652
|
+
role=role,
|
|
653
|
+
compartment=cid,
|
|
654
|
+
amount=True,
|
|
655
|
+
boundary=boundary,
|
|
656
|
+
conversion_factor=factor or None,
|
|
657
|
+
amount_of=sid,
|
|
658
|
+
)
|
|
659
|
+
)
|
|
660
|
+
role = "assigned"
|
|
661
|
+
species_list.append(
|
|
662
|
+
Quantity(
|
|
663
|
+
symbol=self._symbol(species, "species", unit),
|
|
664
|
+
value=value,
|
|
665
|
+
constant=constant,
|
|
666
|
+
role=role,
|
|
667
|
+
compartment=cid or None,
|
|
668
|
+
amount=in_amount,
|
|
669
|
+
boundary=boundary,
|
|
670
|
+
conversion_factor=factor or None,
|
|
671
|
+
)
|
|
672
|
+
)
|
|
673
|
+
return species_list, amounts
|
|
674
|
+
|
|
675
|
+
# --- the odes -----------------------------------------------------------------
|
|
676
|
+
|
|
677
|
+
def _build_odes(
|
|
678
|
+
self, quantities: list[Quantity], reactions: list[Reaction]
|
|
679
|
+
) -> list[Ode]:
|
|
680
|
+
"""The odes of the states, their right hand sides with rateOf not resolved."""
|
|
681
|
+
terms: dict[str, list[tuple[int, libsbml.ASTNode]]] = {}
|
|
682
|
+
for reaction in reactions:
|
|
683
|
+
rid = reaction.symbol.sid
|
|
684
|
+
for sign, side in ((-1, reaction.reactants), (1, reaction.products)):
|
|
685
|
+
for p in side:
|
|
686
|
+
term = _term(
|
|
687
|
+
sign, p.stoichiometry, self.factors.get(p.species), rid
|
|
688
|
+
)
|
|
689
|
+
terms.setdefault(p.species, []).append(term)
|
|
690
|
+
odes = []
|
|
691
|
+
for quantity in quantities:
|
|
692
|
+
sid = quantity.symbol.sid
|
|
693
|
+
if quantity.role != "state":
|
|
694
|
+
continue
|
|
695
|
+
if sid in self.rate_rules:
|
|
696
|
+
ode = Ode(sid, self.rate_rules[sid], "rate_rule")
|
|
697
|
+
elif quantity.amount:
|
|
698
|
+
reaction_terms = signed_sum(terms[quantity.amount_of or sid])
|
|
699
|
+
rhs = reaction_terms.deepCopy()
|
|
700
|
+
ode = Ode(
|
|
701
|
+
sid, rhs, "reactions", reaction_terms, None, quantity.amount_of
|
|
702
|
+
)
|
|
703
|
+
else:
|
|
704
|
+
reaction_terms = signed_sum(terms[sid])
|
|
705
|
+
cid = str(quantity.compartment)
|
|
706
|
+
rhs = node(libsbml.AST_DIVIDE, reaction_terms.deepCopy(), name(cid))
|
|
707
|
+
ode = Ode(sid, rhs, "reactions", reaction_terms, cid)
|
|
708
|
+
self.rhs[sid] = ode.rhs
|
|
709
|
+
odes.append(ode)
|
|
710
|
+
return odes
|
|
711
|
+
|
|
712
|
+
def _resolved_rate(
|
|
713
|
+
self, sid: str, element: str, stack: frozenset[str] = frozenset()
|
|
714
|
+
) -> libsbml.ASTNode:
|
|
715
|
+
"""A copy of the right hand side of a state with every rateOf resolved."""
|
|
716
|
+
if sid not in self.resolved:
|
|
717
|
+
if sid in stack:
|
|
718
|
+
raise ValueError(
|
|
719
|
+
f"The rates of {sorted(stack)} depend on each other in a cycle."
|
|
720
|
+
)
|
|
721
|
+
self.resolved[sid] = self._resolve(self.rhs[sid], element, stack | {sid})
|
|
722
|
+
return self.resolved[sid].deepCopy()
|
|
723
|
+
|
|
724
|
+
def _rate_of(
|
|
725
|
+
self, sid: str, element: str, stack: frozenset[str]
|
|
726
|
+
) -> libsbml.ASTNode | None:
|
|
727
|
+
"""The rate of change of an id, `None` if it is unsupported."""
|
|
728
|
+
if sid in self.rhs:
|
|
729
|
+
return self._resolved_rate(sid, element, stack)
|
|
730
|
+
if sid in self.amounts:
|
|
731
|
+
# the concentration of an amount: d(n/V)/dt = (dn/dt - S dV/dt) / V
|
|
732
|
+
aid, cid = self.amounts[sid], str(self.quantities[sid].compartment)
|
|
733
|
+
if cid in self.assignment_rules:
|
|
734
|
+
self._unsupported("rateOf of an assigned variable", element)
|
|
735
|
+
return None
|
|
736
|
+
rate = self._resolved_rate(aid, element, stack) if aid in self.rhs else None
|
|
737
|
+
if cid in self.rhs:
|
|
738
|
+
size_rate = self._resolved_rate(cid, element, stack)
|
|
739
|
+
dilution = node(libsbml.AST_TIMES, name(sid), size_rate)
|
|
740
|
+
rate = signed_sum(
|
|
741
|
+
[*([] if rate is None else [(1, rate)]), (-1, dilution)]
|
|
742
|
+
)
|
|
743
|
+
if rate is None:
|
|
744
|
+
return number(0.0)
|
|
745
|
+
return node(libsbml.AST_DIVIDE, rate, name(cid))
|
|
746
|
+
quantity = self.quantities.get(sid)
|
|
747
|
+
if quantity is None and sid not in self.reaction_ids:
|
|
748
|
+
raise ValueError(
|
|
749
|
+
f"The rateOf in '{element}' refers to the unknown id '{sid}'."
|
|
750
|
+
)
|
|
751
|
+
if quantity is not None and quantity.role == "constant":
|
|
752
|
+
return number(0.0)
|
|
753
|
+
self._unsupported("rateOf of an assigned variable", element)
|
|
754
|
+
return None
|
|
755
|
+
|
|
756
|
+
def _resolve(
|
|
757
|
+
self, ast: libsbml.ASTNode, element: str, stack: frozenset[str] = frozenset()
|
|
758
|
+
) -> libsbml.ASTNode:
|
|
759
|
+
"""A copy of the math with every supported rateOf replaced by the rate.
|
|
760
|
+
|
|
761
|
+
The terms which become 0, the rate of a constant, are dropped.
|
|
762
|
+
"""
|
|
763
|
+
copy = ast.deepCopy()
|
|
764
|
+
if copy.getType() == libsbml.AST_FUNCTION_RATE_OF:
|
|
765
|
+
return self._replacement(copy, element, stack) or copy
|
|
766
|
+
replaced = False
|
|
767
|
+
parents = [copy]
|
|
768
|
+
while parents:
|
|
769
|
+
parent = parents.pop()
|
|
770
|
+
for k in range(parent.getNumChildren()):
|
|
771
|
+
child = parent.getChild(k)
|
|
772
|
+
rate = None
|
|
773
|
+
if child.getType() == libsbml.AST_FUNCTION_RATE_OF:
|
|
774
|
+
rate = self._replacement(child, element, stack)
|
|
775
|
+
if rate is None:
|
|
776
|
+
parents.append(child)
|
|
777
|
+
else:
|
|
778
|
+
# the parent takes ownership of the rate and deletes the rateOf
|
|
779
|
+
parent.replaceChild(k, rate, True)
|
|
780
|
+
replaced = True
|
|
781
|
+
return drop_zero_terms(copy) if replaced else copy
|
|
782
|
+
|
|
783
|
+
def _replacement(
|
|
784
|
+
self, rate_of: libsbml.ASTNode, element: str, stack: frozenset[str]
|
|
785
|
+
) -> libsbml.ASTNode | None:
|
|
786
|
+
"""The rate of change of the argument of a rateOf, `None` if unsupported."""
|
|
787
|
+
argument = rate_of.getChild(0) if rate_of.getNumChildren() == 1 else None
|
|
788
|
+
if argument is None or argument.getType() != libsbml.AST_NAME:
|
|
789
|
+
self._unsupported("rateOf of an expression", element)
|
|
790
|
+
return None
|
|
791
|
+
return self._rate_of(argument.getName(), element, stack)
|
|
792
|
+
|
|
793
|
+
# --- assignments and initial values -------------------------------------------
|
|
794
|
+
|
|
795
|
+
def _build_assignments(
|
|
796
|
+
self, amounts: list[Quantity], reactions: list[Reaction]
|
|
797
|
+
) -> tuple[Assignment, ...]:
|
|
798
|
+
"""The concentrations, assignment rules and reaction rates in dependency order."""
|
|
799
|
+
assignments = [
|
|
800
|
+
Assignment(str(q.amount_of), self._concentration(q), "concentration")
|
|
801
|
+
for q in amounts
|
|
802
|
+
]
|
|
803
|
+
assignments.extend(
|
|
804
|
+
Assignment(sid, self._resolve(ast, sid), "assignment_rule")
|
|
805
|
+
for sid, ast in self.assignment_rules.items()
|
|
806
|
+
)
|
|
807
|
+
assignments.extend(
|
|
808
|
+
Assignment(r.symbol.sid, r.rate.deepCopy(), "reaction") for r in reactions
|
|
809
|
+
)
|
|
810
|
+
return _ordered(assignments)
|
|
811
|
+
|
|
812
|
+
@staticmethod
|
|
813
|
+
def _concentration(amount: Quantity) -> libsbml.ASTNode:
|
|
814
|
+
"""The concentration of the species of an amount, `n / V`."""
|
|
815
|
+
cid = str(amount.compartment)
|
|
816
|
+
return node(libsbml.AST_DIVIDE, name(amount.symbol.sid), name(cid))
|
|
817
|
+
|
|
818
|
+
def _initial_of(self, quantity: Quantity) -> Assignment | None:
|
|
819
|
+
"""The initial value of a quantity if it is computed at t=0."""
|
|
820
|
+
sid, cid = quantity.symbol.sid, str(quantity.compartment)
|
|
821
|
+
if sid in self.assignment_rules:
|
|
822
|
+
math = self._resolve(self.assignment_rules[sid], sid)
|
|
823
|
+
return Assignment(sid, math, "assignment_rule")
|
|
824
|
+
if sid in self.initial_assignments:
|
|
825
|
+
math = self._resolve(self.initial_assignments[sid], sid)
|
|
826
|
+
return Assignment(sid, math, "initial_assignment")
|
|
827
|
+
species: libsbml.Species | None = self.model.getSpecies(sid)
|
|
828
|
+
if quantity.amount_of is not None:
|
|
829
|
+
# the initial amount, else the amount of the initial concentration
|
|
830
|
+
if quantity.value is None or quantity.amount_of in self.initial_assignments:
|
|
831
|
+
math = node(libsbml.AST_TIMES, name(quantity.amount_of), name(cid))
|
|
832
|
+
return Assignment(sid, math, "initial_value")
|
|
833
|
+
elif sid in self.amounts:
|
|
834
|
+
if species is not None and species.isSetInitialAmount():
|
|
835
|
+
return Assignment(
|
|
836
|
+
sid,
|
|
837
|
+
self._concentration(self.quantities[self.amounts[sid]]),
|
|
838
|
+
"concentration",
|
|
839
|
+
)
|
|
840
|
+
return Assignment(sid, number(quantity.value), "initial_value")
|
|
841
|
+
elif species is not None:
|
|
842
|
+
# converted with the initial size of the compartment
|
|
843
|
+
if species.isSetInitialAmount() and not quantity.amount:
|
|
844
|
+
value = number(species.getInitialAmount())
|
|
845
|
+
math = node(libsbml.AST_DIVIDE, value, name(cid))
|
|
846
|
+
return Assignment(sid, math, "initial_value")
|
|
847
|
+
if species.isSetInitialConcentration() and quantity.amount:
|
|
848
|
+
value = number(species.getInitialConcentration())
|
|
849
|
+
math = node(libsbml.AST_TIMES, value, name(cid))
|
|
850
|
+
return Assignment(sid, math, "initial_value")
|
|
851
|
+
if quantity.role == "state":
|
|
852
|
+
return Assignment(sid, number(quantity.value), "initial_value")
|
|
853
|
+
return None
|
|
854
|
+
|
|
855
|
+
def _build_initial(
|
|
856
|
+
self, quantities: list[Quantity], reactions: list[Reaction]
|
|
857
|
+
) -> tuple[Assignment, ...]:
|
|
858
|
+
"""The initial values at t=0 in dependency order, with the rates they need."""
|
|
859
|
+
initial = [a for q in quantities if (a := self._initial_of(q)) is not None]
|
|
860
|
+
referenced = set().union(*(names(a.math) for a in initial))
|
|
861
|
+
needed: set[str] = set()
|
|
862
|
+
while True:
|
|
863
|
+
# the rates the initial values need, and the rates these need
|
|
864
|
+
new = [r for r in reactions if r.symbol.sid in referenced - needed]
|
|
865
|
+
if not new:
|
|
866
|
+
break
|
|
867
|
+
for reaction in new:
|
|
868
|
+
needed.add(reaction.symbol.sid)
|
|
869
|
+
referenced |= names(reaction.rate)
|
|
870
|
+
initial.extend(
|
|
871
|
+
Assignment(r.symbol.sid, r.rate.deepCopy(), "reaction")
|
|
872
|
+
for r in reactions
|
|
873
|
+
if r.symbol.sid in needed
|
|
874
|
+
)
|
|
875
|
+
return _ordered(initial)
|
|
876
|
+
|
|
877
|
+
# --- events -------------------------------------------------------------------
|
|
878
|
+
|
|
879
|
+
def _read_events(self) -> list[Event]:
|
|
880
|
+
"""The events, their assignments to the representation of the states."""
|
|
881
|
+
events = []
|
|
882
|
+
event: libsbml.Event
|
|
883
|
+
for k, event in enumerate(self.model.getListOfEvents()):
|
|
884
|
+
eid = event.getId() if event.isSetId() else self._unique(f"event{k}")
|
|
885
|
+
trigger_element: libsbml.Trigger | None = event.getTrigger()
|
|
886
|
+
if trigger_element is not None and trigger_element.isSetMath():
|
|
887
|
+
trigger = self._resolve(self._math(eid, trigger_element.getMath()), eid)
|
|
888
|
+
else:
|
|
889
|
+
# an event without a trigger never fires (L3V2)
|
|
890
|
+
trigger = libsbml.ASTNode(libsbml.AST_CONSTANT_FALSE)
|
|
891
|
+
# the root of a call of a function definition is the root of its body
|
|
892
|
+
expanded = trigger.deepCopy()
|
|
893
|
+
libsbml.SBMLTransforms.replaceFD(
|
|
894
|
+
expanded, self.model.getListOfFunctionDefinitions()
|
|
895
|
+
)
|
|
896
|
+
try:
|
|
897
|
+
root: libsbml.ASTNode | None = trigger_root(expanded)
|
|
898
|
+
except NotImplementedError:
|
|
899
|
+
self._unsupported("event trigger", eid)
|
|
900
|
+
root = None
|
|
901
|
+
events.append(
|
|
902
|
+
Event(
|
|
903
|
+
symbol=self._symbol(event, "event", None, eid),
|
|
904
|
+
trigger=trigger,
|
|
905
|
+
root=root,
|
|
906
|
+
initial_value=(
|
|
907
|
+
trigger_element is None or trigger_element.getInitialValue()
|
|
908
|
+
),
|
|
909
|
+
persistent=(
|
|
910
|
+
trigger_element is None or trigger_element.getPersistent()
|
|
911
|
+
),
|
|
912
|
+
delay=self._optional_math(eid, event.getDelay()),
|
|
913
|
+
priority=self._optional_math(eid, event.getPriority()),
|
|
914
|
+
use_values_from_trigger_time=event.getUseValuesFromTriggerTime(),
|
|
915
|
+
assignments=self._event_assignments(eid, event),
|
|
916
|
+
)
|
|
917
|
+
)
|
|
918
|
+
return events
|
|
919
|
+
|
|
920
|
+
def _optional_math(
|
|
921
|
+
self, element: str, sbase: libsbml.Delay | libsbml.Priority | None
|
|
922
|
+
) -> libsbml.ASTNode | None:
|
|
923
|
+
"""The math of a delay or a priority, `None` if it is not set."""
|
|
924
|
+
if sbase is None or not sbase.isSetMath():
|
|
925
|
+
return None
|
|
926
|
+
return self._resolve(self._math(element, sbase.getMath()), element)
|
|
927
|
+
|
|
928
|
+
def _event_assignments(
|
|
929
|
+
self, eid: str, event: libsbml.Event
|
|
930
|
+
) -> tuple[EventAssignment, ...]:
|
|
931
|
+
"""The assignments of an event to the states and constants of the system.
|
|
932
|
+
|
|
933
|
+
The concentration of a species held as amount is assigned as amount, its
|
|
934
|
+
value scaled by the size at the execution (test case 01779). A species in
|
|
935
|
+
concentration with a rate rule is rescaled when the event changes the size
|
|
936
|
+
of its compartment, its amount stays (test case 01506). See
|
|
937
|
+
`EventAssignment` for when each part is evaluated.
|
|
938
|
+
"""
|
|
939
|
+
assigned: dict[str, libsbml.ASTNode] = {}
|
|
940
|
+
assignment: libsbml.EventAssignment
|
|
941
|
+
for assignment in event.getListOfEventAssignments():
|
|
942
|
+
variable = assignment.getVariable()
|
|
943
|
+
quantity = self.quantities.get(variable)
|
|
944
|
+
if quantity is None:
|
|
945
|
+
raise ValueError(
|
|
946
|
+
f"The event '{eid}' assigns '{variable}', which is no quantity."
|
|
947
|
+
)
|
|
948
|
+
if not assignment.isSetMath():
|
|
949
|
+
continue
|
|
950
|
+
if quantity.constant:
|
|
951
|
+
self._unsupported("event assignment to a constant", eid)
|
|
952
|
+
elif quantity.role == "assigned" and variable not in self.amounts:
|
|
953
|
+
self._unsupported("event assignment to an assigned variable", eid)
|
|
954
|
+
else:
|
|
955
|
+
math = self._math(eid, assignment.getMath())
|
|
956
|
+
assigned[variable] = self._resolve(math, eid)
|
|
957
|
+
result = []
|
|
958
|
+
for variable, math in assigned.items():
|
|
959
|
+
cid = str(self.quantities[variable].compartment)
|
|
960
|
+
if variable in self.amounts:
|
|
961
|
+
amount = self.amounts[variable]
|
|
962
|
+
result.append(EventAssignment(amount, math, scale=name(cid)))
|
|
963
|
+
elif self._rescaled(variable) and cid in assigned:
|
|
964
|
+
result.append(EventAssignment(variable, math, name(cid), cid))
|
|
965
|
+
else:
|
|
966
|
+
result.append(EventAssignment(variable, math))
|
|
967
|
+
for sid, quantity in self.quantities.items():
|
|
968
|
+
cid = str(quantity.compartment)
|
|
969
|
+
if self._rescaled(sid) and cid in assigned and sid not in assigned:
|
|
970
|
+
scale = node(libsbml.AST_TIMES, name(sid), name(cid))
|
|
971
|
+
result.append(EventAssignment(sid, None, scale, cid))
|
|
972
|
+
return tuple(result)
|
|
973
|
+
|
|
974
|
+
def _rescaled(self, sid: str) -> bool:
|
|
975
|
+
"""Check that an id is a species in concentration with a rate rule."""
|
|
976
|
+
quantity = self.quantities[sid]
|
|
977
|
+
return (
|
|
978
|
+
quantity.symbol.kind == "species"
|
|
979
|
+
and sid in self.rate_rules
|
|
980
|
+
and not quantity.amount
|
|
981
|
+
)
|