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/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
+ )