PyAntiGen 1.0.9__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.
Files changed (55) hide show
  1. framework/AntimonyGen.py +48 -0
  2. framework/RxnDict_to_antimony.py +594 -0
  3. framework/TelluriumGen.py +16 -0
  4. framework/__init__.py +0 -0
  5. framework/antimony_utils.py +294 -0
  6. framework/cli.py +229 -0
  7. framework/data_interpolation.py +340 -0
  8. framework/isotopomer_tools.py +41 -0
  9. framework/model_generation.py +46 -0
  10. framework/models.py +189 -0
  11. framework/module_base.py +42 -0
  12. framework/pyantigen.py +51 -0
  13. framework/rate_laws.py +101 -0
  14. framework/reaction_creation.py +43 -0
  15. framework/template/Example/AntiGen_paths.py +23 -0
  16. framework/template/Example/Engine/Anchor_cache.py +193 -0
  17. framework/template/Example/Engine/Deadline.py +535 -0
  18. framework/template/Example/Engine/Evaluator.py +1176 -0
  19. framework/template/Example/Engine/Event_times.py +491 -0
  20. framework/template/Example/Engine/Fast_profile.py +701 -0
  21. framework/template/Example/Engine/Fit_cache.py +329 -0
  22. framework/template/Example/Engine/Identifiability.py +698 -0
  23. framework/template/Example/Engine/Model_optimize.py +1483 -0
  24. framework/template/Example/Engine/Model_simulate.py +124 -0
  25. framework/template/Example/Engine/Nuisance_sensitivity.py +298 -0
  26. framework/template/Example/Engine/Optimize.py +6862 -0
  27. framework/template/Example/Engine/Petab_export.py +398 -0
  28. framework/template/Example/Engine/Preequil_cache.py +361 -0
  29. framework/template/Example/Engine/Profile_checkpoint.py +399 -0
  30. framework/template/Example/Engine/Results.py +395 -0
  31. framework/template/Example/Engine/Sensitivity_analysis.py +320 -0
  32. framework/template/Example/Engine/Simulate.py +617 -0
  33. framework/template/Example/Flipflop_reference.py +401 -0
  34. framework/template/Example/Model_generate.py +37 -0
  35. framework/template/Example/Model_run.py +261 -0
  36. framework/template/Example/Modules/Data.py +63 -0
  37. framework/template/Example/Modules/Events.py +14 -0
  38. framework/template/Example/Modules/Experiment.py +194 -0
  39. framework/template/Example/Modules/Loss_config.py +61 -0
  40. framework/template/Example/Modules/Observed_species.py +3 -0
  41. framework/template/Example/Modules/Optimizer_settings.py +258 -0
  42. framework/template/Example/Modules/Plots.py +89 -0
  43. framework/template/Example/Modules/Solver_settings.py +16 -0
  44. framework/template/Example/Modules/Update_opt_parameters.py +24 -0
  45. framework/template/Example/Modules/Update_parameters.py +49 -0
  46. framework/template/data/ADneg.csv +27 -0
  47. framework/template/data/ADpos.csv +27 -0
  48. framework/template/data/Flipflop.csv +29 -0
  49. framework/template/data/make_flipflop_data.py +174 -0
  50. pyantigen-1.0.9.dist-info/METADATA +129 -0
  51. pyantigen-1.0.9.dist-info/RECORD +55 -0
  52. pyantigen-1.0.9.dist-info/WHEEL +5 -0
  53. pyantigen-1.0.9.dist-info/entry_points.txt +2 -0
  54. pyantigen-1.0.9.dist-info/licenses/LICENSE +21 -0
  55. pyantigen-1.0.9.dist-info/top_level.txt +1 -0
@@ -0,0 +1,491 @@
1
+ """When, in absolute simulation hours, the right-hand side stops being smooth.
2
+
3
+ The block splitter in ``Modules.Solver_settings`` cuts long simulations into
4
+ pieces so no single ``r.simulate()`` call has to cross too many discontinuities.
5
+ It currently cuts on a wall-clock grid, which is a proxy for "how many doses are
6
+ in this interval" that only holds for evenly-spaced chronic dosing. This module
7
+ supplies the quantity the splitter actually wants: the times at which something
8
+ discontinuous happens.
9
+
10
+ Four things produce a time discontinuity in these models, and only the first is
11
+ what a naive scan would find.
12
+
13
+ * **Events with a constant time trigger.** ``generate_silk_events`` writes 49
14
+ hourly ``f_L`` steps plus two tail events; the antibody-trial generators write
15
+ ``at (time >= {age}*365*24 + {week}*7*24)``.
16
+
17
+ * **Events whose trigger contains model symbols.** The subcutaneous generators
18
+ end an infusion at ``({t_start}) + SubCut_D1``, and ``SubCut_D1`` is a *fitted*
19
+ parameter (``PK_GANTENERUMAB_names``). RoadRunner evaluates the trigger against
20
+ the current parameter value at simulation time, which is why those arms can run
21
+ with ``events_depend_on_opt_param`` False -- and why a list of event times
22
+ computed once at x0 goes stale as the fit moves. Times are therefore resolved
23
+ lazily, on every call, against the model's current values. Evaluating a hundred
24
+ two-node expressions costs microseconds against a multi-second simulation, so
25
+ there is nothing to gain by caching the numbers and a correctness bug to lose.
26
+
27
+ * **Arithmetic over several symbols.** The v4 SILK model triggers CSF draws at
28
+ ``{i} + t_CSFdraw`` and ``{i} + V_LP/Q_CSF``, so the evaluator has to handle
29
+ expressions rather than constants.
30
+
31
+ * **Time-based piecewise assignment rules.** ``generate_antimony_piecewise``
32
+ emits ``X := piecewise(...)``, which is an assignment rule and *not* an event.
33
+ RoadRunner does not root-find those, so CVODE steps straight over the
34
+ breakpoints and fits a high-order polynomial across a kink -- numerically worse
35
+ than an event, and invisible to anything that only enumerates events. The v4
36
+ model drives ``V_SP3`` through 144 such breakpoints.
37
+
38
+ Not every ``piecewise`` is a time discontinuity: the model's own rules file
39
+ carries 26 guards of the form ``piecewise(1, AB40Total_BrainISF < 1e-12, ...)``,
40
+ which switch on *state*. Those are a real numerical hazard too, but no
41
+ time-based splitter can help with them, so they are filtered out here rather
42
+ than reported as cut candidates.
43
+
44
+ The times come from the compiled model via libSBML rather than from the
45
+ generated Antimony text. That catches every event regardless of which generator
46
+ wrote it, survives any syntax the generators use, and describes the model that
47
+ actually runs.
48
+
49
+ Fail open, never silently: if any trigger cannot be resolved to a number,
50
+ :meth:`EventTimeTable.times` returns None and the splitter falls back to its
51
+ wall-clock cap. A missed event would let a cut land exactly on a discontinuity,
52
+ which is the one place a cut must never go.
53
+ """
54
+
55
+ import math
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # A compiled arithmetic expression
60
+ # ---------------------------------------------------------------------------
61
+ #
62
+ # libSBML AST nodes are compiled into plain tuples rather than being kept as
63
+ # handed over. Two reasons, both learned the hard way:
64
+ #
65
+ # * The nodes are owned by the SBMLDocument. Holding them past the document's
66
+ # lifetime is a dangling pointer, and the table outlives the parse.
67
+ # * ``node.getType()`` returns an opaque SWIG pointer in this libSBML build,
68
+ # so the AST_* integer constants do not compare equal to it and any code
69
+ # written against them silently matches nothing. The ``is*`` predicates are
70
+ # the portable interface.
71
+ #
72
+ # Node forms: ('num', float) | ('sym', name) | ('op', char, left, right)
73
+ # | ('neg', child) | ('fn', name, *children)
74
+
75
+
76
+ class _Unevaluable(Exception):
77
+ """A trigger this module declines to guess at."""
78
+
79
+
80
+ _BINARY = {
81
+ '+': lambda a, b: a + b,
82
+ '-': lambda a, b: a - b,
83
+ '*': lambda a, b: a * b,
84
+ '/': lambda a, b: a / b,
85
+ '^': lambda a, b: a ** b,
86
+ }
87
+
88
+ # Functions that are pure, scalar and unambiguous in arity. Antimony turns even
89
+ # ``2^3`` into a ``power`` function node rather than an operator, so without at
90
+ # least this much a perfectly ordinary trigger would send the whole table to
91
+ # None. ``log`` is left out on purpose: its arity differs between MathML and
92
+ # SBML L3, and guessing wrong would put an event at the wrong instant, which is
93
+ # worse than declining to place it at all.
94
+ _FUNCTIONS = {
95
+ 'power': (2, lambda a, b: a ** b),
96
+ 'root': (2, lambda degree, x: x ** (1.0 / degree)),
97
+ 'sqrt': (1, math.sqrt),
98
+ 'abs': (1, abs),
99
+ 'ceiling': (1, math.ceil),
100
+ 'floor': (1, math.floor),
101
+ 'exp': (1, math.exp),
102
+ 'ln': (1, math.log),
103
+ 'log10': (1, math.log10),
104
+ 'min': (2, min),
105
+ 'max': (2, max),
106
+ }
107
+
108
+
109
+ def _compile(node):
110
+ """libSBML AST -> tuple tree, or raise :class:`_Unevaluable`."""
111
+ if node is None:
112
+ raise _Unevaluable("empty node")
113
+
114
+ if node.isNumber():
115
+ return ('num', float(node.getValue()))
116
+
117
+ if node.isName():
118
+ name = node.getName()
119
+ if not name:
120
+ raise _Unevaluable("unnamed symbol")
121
+ return ('sym', name)
122
+
123
+ if node.isUMinus():
124
+ if node.getNumChildren() != 1:
125
+ raise _Unevaluable("malformed unary minus")
126
+ return ('neg', _compile(node.getChild(0)))
127
+
128
+ if node.isUPlus():
129
+ if node.getNumChildren() != 1:
130
+ raise _Unevaluable("malformed unary plus")
131
+ return _compile(node.getChild(0))
132
+
133
+ if node.isOperator():
134
+ char = node.getCharacter()
135
+ if char not in _BINARY:
136
+ raise _Unevaluable(f"operator {char!r}")
137
+ if node.getNumChildren() != 2:
138
+ raise _Unevaluable(f"operator {char!r} with "
139
+ f"{node.getNumChildren()} children")
140
+ return ('op', char, _compile(node.getChild(0)),
141
+ _compile(node.getChild(1)))
142
+
143
+ if node.isFunction():
144
+ name = node.getName()
145
+ spec = _FUNCTIONS.get(name)
146
+ if spec is None:
147
+ raise _Unevaluable(f"function {name!r}")
148
+ arity, _fn = spec
149
+ if node.getNumChildren() != arity:
150
+ raise _Unevaluable(f"function {name!r} with "
151
+ f"{node.getNumChildren()} argument(s)")
152
+ return ('fn', name) + tuple(_compile(node.getChild(i))
153
+ for i in range(arity))
154
+
155
+ # Constants (pi, exponentiale, avogadro) evaluate fine, but nothing in these
156
+ # models uses one in a time trigger, so treating them as unknown costs
157
+ # nothing and keeps the evaluator honest about what it has actually seen.
158
+ raise _Unevaluable("unsupported node")
159
+
160
+
161
+ def _evaluate(node, lookup):
162
+ kind = node[0]
163
+ if kind == 'num':
164
+ return node[1]
165
+ if kind == 'sym':
166
+ return lookup(node[1])
167
+ if kind == 'neg':
168
+ return -_evaluate(node[1], lookup)
169
+ if kind == 'fn':
170
+ _arity, fn = _FUNCTIONS[node[1]]
171
+ return fn(*(_evaluate(arg, lookup) for arg in node[2:]))
172
+ return _BINARY[node[1]](_evaluate(node[2], lookup),
173
+ _evaluate(node[3], lookup))
174
+
175
+
176
+ def _symbols(node, out=None):
177
+ out = set() if out is None else out
178
+ kind = node[0]
179
+ if kind == 'sym':
180
+ out.add(node[1])
181
+ elif kind == 'neg':
182
+ _symbols(node[1], out)
183
+ elif kind == 'op':
184
+ _symbols(node[2], out)
185
+ _symbols(node[3], out)
186
+ elif kind == 'fn':
187
+ for arg in node[2:]:
188
+ _symbols(arg, out)
189
+ return out
190
+
191
+
192
+ # ---------------------------------------------------------------------------
193
+ # Finding the thresholds
194
+ # ---------------------------------------------------------------------------
195
+
196
+ def _is_time(node):
197
+ """Is this AST node the simulation-time symbol?
198
+
199
+ ``time`` arrives as a csymbol whose ``isName`` is true and whose name is
200
+ 'time'. The AST type code would say so more directly if it were comparable
201
+ (see the note above), so the name is what gets checked.
202
+ """
203
+ return bool(node is not None and node.isName() and node.getName() == 'time')
204
+
205
+
206
+ def _threshold_from_relational(node):
207
+ """``time >= expr`` (either way round) -> compiled *expr*, else None.
208
+
209
+ Which comparison operator it is does not matter. Any relation between time
210
+ and an expression marks the instant the relation flips, and that instant is
211
+ the value of the expression -- so the operator carries no information the
212
+ splitter needs, and not reading it avoids depending on an accessor libSBML
213
+ does not offer.
214
+ """
215
+ if node.getNumChildren() != 2:
216
+ return None
217
+ left, right = node.getChild(0), node.getChild(1)
218
+ if _is_time(left) and not _is_time(right):
219
+ other = right
220
+ elif _is_time(right) and not _is_time(left):
221
+ other = left
222
+ else:
223
+ return None
224
+ return _compile(other)
225
+
226
+
227
+ def _collect(node, found):
228
+ """Walk *node*, appending every compiled time threshold to *found*.
229
+
230
+ Raises :class:`_Unevaluable` if a relation involving time cannot be reduced
231
+ to an expression, which is what makes the whole table refuse to answer.
232
+ Relations that do not involve time at all -- the state guards in the model's
233
+ own rules -- are simply not collected; they are not failures.
234
+ """
235
+ if node is None:
236
+ return
237
+
238
+ if node.isRelational():
239
+ if _is_time(node.getChild(0)) or _is_time(node.getChild(1)):
240
+ expr = _threshold_from_relational(node)
241
+ if expr is None:
242
+ raise _Unevaluable("time compared against time")
243
+ found.append(expr)
244
+ return
245
+
246
+ # Logical connectives, piecewise conditions and anything else: recurse.
247
+ # Over-collecting is safe. An extra candidate only offers the splitter one
248
+ # more place it may not cut; a missed one lets it cut on a discontinuity.
249
+ for i in range(node.getNumChildren()):
250
+ _collect(node.getChild(i), found)
251
+
252
+
253
+ # ---------------------------------------------------------------------------
254
+ # The table
255
+ # ---------------------------------------------------------------------------
256
+
257
+ class EventTimeTable:
258
+ """Lazily resolved time discontinuities for one compiled model.
259
+
260
+ Holds compiled expressions, not numbers: see the module docstring on
261
+ ``SubCut_D1``. Call :meth:`times` as often as you like.
262
+ """
263
+
264
+ def __init__(self, r, entries, unresolved, n_events=0, n_rules=0):
265
+ self._r = r
266
+ self._entries = entries # list of (source_label, node)
267
+ self.unresolved = list(unresolved) # list of (source_label, reason)
268
+ self.n_events = n_events
269
+ self.n_rules = n_rules
270
+
271
+ def __len__(self):
272
+ return len(self._entries)
273
+
274
+ def dependencies(self):
275
+ """Model symbols the thresholds are computed from."""
276
+ out = set()
277
+ for _label, node in self._entries:
278
+ _symbols(node, out)
279
+ return out
280
+
281
+ def times(self):
282
+ """Sorted discontinuity times in absolute hours, or None if unknown.
283
+
284
+ None means "something here could not be resolved", and callers must
285
+ treat it as no information at all rather than as an empty schedule. An
286
+ empty *list*, by contrast, is a positive finding: this model has no time
287
+ discontinuities, which is what lets the splitter leave a seventy-year
288
+ pre-aging block whole.
289
+ """
290
+ if self.unresolved:
291
+ return None
292
+
293
+ # One read per distinct symbol, not one per trigger that mentions it.
294
+ # The 50 Gantenerumab infusion-off edges all reference SubCut_D1, and a
295
+ # model lookup is a SWIG round-trip; memoizing takes this call from
296
+ # 231 us to 147 us on that arm, the remainder being the 100 expression
297
+ # evaluations themselves. The memo lives for one call only, so a
298
+ # parameter that moves between calls is still picked up.
299
+ cache = {}
300
+
301
+ def lookup(name):
302
+ if name in cache:
303
+ return cache[name]
304
+ try:
305
+ value = float(self._r[name])
306
+ except Exception as exc:
307
+ raise _Unevaluable(f"{name}: {exc}") from exc
308
+ cache[name] = value
309
+ return value
310
+
311
+ out = []
312
+ for _label, node in self._entries:
313
+ try:
314
+ t = _evaluate(node, lookup)
315
+ except _Unevaluable:
316
+ return None
317
+ except (ArithmeticError, TypeError, ValueError):
318
+ return None
319
+ if not math.isfinite(t):
320
+ return None
321
+ out.append(float(t))
322
+
323
+ out.sort()
324
+ return _dedupe(out)
325
+
326
+
327
+ def _dedupe(times, rel_tol=1e-12):
328
+ """Drop values that differ only by floating-point noise.
329
+
330
+ Two generators can compute the same instant along different routes --
331
+ ``{age}*365*24 + 48`` and ``{age}*365*24 + 48.00`` -- and at t of order 1e6
332
+ those need not land on the same float. Genuinely distinct events here are
333
+ never closer than the 23 h of ``SubCut_D1`` or the 1 h of the SILK ladder,
334
+ so a relative tolerance of 1e-12 cannot merge two real ones.
335
+ """
336
+ out = []
337
+ for t in times:
338
+ if out and abs(t - out[-1]) <= rel_tol * max(1.0, abs(t), abs(out[-1])):
339
+ continue
340
+ out.append(t)
341
+ return out
342
+
343
+
344
+ def build_event_time_table(r, verbose=False, label=None):
345
+ """Read *r*'s events and time-based piecewise rules into an EventTimeTable.
346
+
347
+ Returns None if the model's SBML cannot be read at all, which callers should
348
+ treat exactly like an unresolved trigger: no information.
349
+ """
350
+ try:
351
+ import libsbml
352
+ except ImportError:
353
+ if verbose:
354
+ print("[events] libsbml is unavailable; block splitting will fall "
355
+ "back to the wall-clock cap.")
356
+ return None
357
+
358
+ try:
359
+ doc = libsbml.readSBMLFromString(r.getSBML())
360
+ model = doc.getModel()
361
+ except Exception as exc:
362
+ if verbose:
363
+ print(f"[events] could not read the model's SBML ({exc}); block "
364
+ f"splitting will fall back to the wall-clock cap.")
365
+ return None
366
+ if model is None:
367
+ return None
368
+
369
+ entries, unresolved = [], []
370
+
371
+ n_events = model.getNumEvents()
372
+ for i in range(n_events):
373
+ event = model.getEvent(i)
374
+ name = event.getId() or f"event[{i}]"
375
+ trigger = event.getTrigger()
376
+ if trigger is None:
377
+ continue
378
+ found = []
379
+ try:
380
+ _collect(trigger.getMath(), found)
381
+ except _Unevaluable as exc:
382
+ unresolved.append((name, str(exc)))
383
+ continue
384
+ if not found:
385
+ # A trigger with no time in it: a state-triggered event. It is a
386
+ # genuine discontinuity that this module cannot place on the time
387
+ # axis, so it has to count as unresolved -- pretending the model is
388
+ # event-free would be worse than declining to answer.
389
+ unresolved.append((name, "trigger does not depend on time"))
390
+ continue
391
+ entries.extend((name, node) for node in found)
392
+
393
+ n_rules = 0
394
+ for i in range(model.getNumRules()):
395
+ rule = model.getRule(i)
396
+ rule_math = rule.getMath()
397
+ if rule_math is None or not _contains_piecewise(rule_math):
398
+ continue
399
+ name = rule.getVariable() or f"rule[{i}]"
400
+ found = []
401
+ try:
402
+ _collect(rule_math, found)
403
+ except _Unevaluable as exc:
404
+ unresolved.append((name, str(exc)))
405
+ continue
406
+ if found:
407
+ # Only rules that actually switch on time are counted; the model's
408
+ # state guards (``AB40Total_BrainISF < 1e-12``) contribute nothing
409
+ # and are not a failure.
410
+ n_rules += 1
411
+ entries.extend((name, node) for node in found)
412
+
413
+ table = EventTimeTable(r, entries, unresolved, n_events, n_rules)
414
+
415
+ if verbose:
416
+ _describe(table, label)
417
+ return table
418
+
419
+
420
+ def _contains_piecewise(node):
421
+ if node is None:
422
+ return False
423
+ if node.isPiecewise():
424
+ return True
425
+ return any(_contains_piecewise(node.getChild(i))
426
+ for i in range(node.getNumChildren()))
427
+
428
+
429
+ def _describe(table, label):
430
+ who = f"'{label}'" if label else "model"
431
+ if table.unresolved:
432
+ print(f"[events] {who}: {len(table.unresolved)} trigger(s) could not be "
433
+ f"resolved, so block splitting falls back to the wall-clock cap:")
434
+ for name, reason in table.unresolved[:5]:
435
+ print(f" {name}: {reason}")
436
+ if len(table.unresolved) > 5:
437
+ print(f" ... and {len(table.unresolved) - 5} more")
438
+ return
439
+ deps = sorted(table.dependencies())
440
+ times = table.times()
441
+ n = len(times) if times is not None else 0
442
+ span = (f"{times[0]:.4g} to {times[-1]:.4g} h" if n else "none")
443
+ print(f"[events] {who}: {n} time discontinuity(ies) from "
444
+ f"{table.n_events} event(s) and {table.n_rules} time-based "
445
+ f"piecewise rule(s); {span}.")
446
+ if deps:
447
+ print(f" resolved against: {', '.join(deps)}")
448
+
449
+
450
+ def attach_event_times(replicate, r, verbose=False):
451
+ """Give *replicate* the means to report its own discontinuity times.
452
+
453
+ ``Solver_settings`` callables are handed only the replicate, and they live
454
+ in ``Modules`` where nothing else imports from ``Engine``. Passing a bound
455
+ method keeps that layering intact and keeps the resolution lazy, so a
456
+ trigger built on a fitted parameter is re-read every evaluation instead of
457
+ being frozen at x0.
458
+
459
+ Returns the table, or None if one could not be built.
460
+ """
461
+ table = build_event_time_table(r, verbose=verbose,
462
+ label=replicate.get("Label"))
463
+ if table is None:
464
+ replicate.pop("_event_times_fn", None)
465
+ return None
466
+ replicate["_event_times_fn"] = table.times
467
+ return table
468
+
469
+
470
+ def without_event_times(replicates):
471
+ """Shallow copies of *replicates* with the attached callable removed.
472
+
473
+ For sending a replicate to a pool worker. cloudpickle *will* carry the
474
+ callable -- it serializes the closed-over RoadRunner by value -- which makes
475
+ this a correctness fix rather than a serialization one: a shipped table
476
+ resolves its times against the parent's parameter values, and a worker
477
+ running a profile point is holding a different vector by construction. With
478
+ a fitted parameter in a trigger the worker would then integrate one
479
+ schedule while splitting on another.
480
+
481
+ Copies rather than mutating: the parent still needs its own attachment.
482
+ """
483
+ out = {}
484
+ for name, rep in replicates.items():
485
+ try:
486
+ trimmed = {k: v for k, v in rep.items() if k != "_event_times_fn"}
487
+ except AttributeError:
488
+ out[name] = rep
489
+ continue
490
+ out[name] = trimmed
491
+ return out