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.
- framework/AntimonyGen.py +48 -0
- framework/RxnDict_to_antimony.py +594 -0
- framework/TelluriumGen.py +16 -0
- framework/__init__.py +0 -0
- framework/antimony_utils.py +294 -0
- framework/cli.py +229 -0
- framework/data_interpolation.py +340 -0
- framework/isotopomer_tools.py +41 -0
- framework/model_generation.py +46 -0
- framework/models.py +189 -0
- framework/module_base.py +42 -0
- framework/pyantigen.py +51 -0
- framework/rate_laws.py +101 -0
- framework/reaction_creation.py +43 -0
- framework/template/Example/AntiGen_paths.py +23 -0
- framework/template/Example/Engine/Anchor_cache.py +193 -0
- framework/template/Example/Engine/Deadline.py +535 -0
- framework/template/Example/Engine/Evaluator.py +1176 -0
- framework/template/Example/Engine/Event_times.py +491 -0
- framework/template/Example/Engine/Fast_profile.py +701 -0
- framework/template/Example/Engine/Fit_cache.py +329 -0
- framework/template/Example/Engine/Identifiability.py +698 -0
- framework/template/Example/Engine/Model_optimize.py +1483 -0
- framework/template/Example/Engine/Model_simulate.py +124 -0
- framework/template/Example/Engine/Nuisance_sensitivity.py +298 -0
- framework/template/Example/Engine/Optimize.py +6862 -0
- framework/template/Example/Engine/Petab_export.py +398 -0
- framework/template/Example/Engine/Preequil_cache.py +361 -0
- framework/template/Example/Engine/Profile_checkpoint.py +399 -0
- framework/template/Example/Engine/Results.py +395 -0
- framework/template/Example/Engine/Sensitivity_analysis.py +320 -0
- framework/template/Example/Engine/Simulate.py +617 -0
- framework/template/Example/Flipflop_reference.py +401 -0
- framework/template/Example/Model_generate.py +37 -0
- framework/template/Example/Model_run.py +261 -0
- framework/template/Example/Modules/Data.py +63 -0
- framework/template/Example/Modules/Events.py +14 -0
- framework/template/Example/Modules/Experiment.py +194 -0
- framework/template/Example/Modules/Loss_config.py +61 -0
- framework/template/Example/Modules/Observed_species.py +3 -0
- framework/template/Example/Modules/Optimizer_settings.py +258 -0
- framework/template/Example/Modules/Plots.py +89 -0
- framework/template/Example/Modules/Solver_settings.py +16 -0
- framework/template/Example/Modules/Update_opt_parameters.py +24 -0
- framework/template/Example/Modules/Update_parameters.py +49 -0
- framework/template/data/ADneg.csv +27 -0
- framework/template/data/ADpos.csv +27 -0
- framework/template/data/Flipflop.csv +29 -0
- framework/template/data/make_flipflop_data.py +174 -0
- pyantigen-1.0.9.dist-info/METADATA +129 -0
- pyantigen-1.0.9.dist-info/RECORD +55 -0
- pyantigen-1.0.9.dist-info/WHEEL +5 -0
- pyantigen-1.0.9.dist-info/entry_points.txt +2 -0
- pyantigen-1.0.9.dist-info/licenses/LICENSE +21 -0
- 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
|