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,617 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
# Magnitude below which a species value is numerical dust rather than a
|
|
5
|
+
# quantity.
|
|
6
|
+
#
|
|
7
|
+
# Seventy years of pre-equilibration does not leave the compartments that hold
|
|
8
|
+
# no material at exactly zero. It leaves them at things like -2.4e-107, and on
|
|
9
|
+
# Lecanemab_10mgkg 94 of 269 species end the pre-dose block negative with 109
|
|
10
|
+
# of them below 1e-30. Nothing is wrong with that trajectory -- but when the
|
|
11
|
+
# dose then fires, those species acquire derivatives of order 1e+05 while
|
|
12
|
+
# sitting at 1e-107, so the integrator has to carry them up through roughly a
|
|
13
|
+
# hundred orders of magnitude. It does, by taking enormous numbers of tiny
|
|
14
|
+
# steps: the first 13.6 hours after the dose accounted for 98.5% of a 123 s
|
|
15
|
+
# block. Zeroing the dust first costs nothing and takes that block to 0.39 s,
|
|
16
|
+
# with no material change to any result.
|
|
17
|
+
#
|
|
18
|
+
# The threshold is deliberately far below anything physical. The real
|
|
19
|
+
# pre-dose value of these species is exactly zero, while genuinely small
|
|
20
|
+
# quantities elsewhere in the model reach 1e-15 (AB40_DensePlaqueNumber_BrainISF
|
|
21
|
+
# on the antibody-trial arms). A 1e-12 cut would delete those; 1e-25 cannot
|
|
22
|
+
# reach them and still removes dust that is eighty orders of magnitude smaller.
|
|
23
|
+
_DUST_THRESHOLD = 1e-25
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def clamp_state_dust(r, threshold=_DUST_THRESHOLD):
|
|
27
|
+
"""Zero every floating species whose magnitude is below *threshold*.
|
|
28
|
+
|
|
29
|
+
Returns the number of species zeroed. Negative dust and positive dust are
|
|
30
|
+
treated alike: both are noise around a true zero, and the negative half is
|
|
31
|
+
what trips CVODE's error-weight check on the next step.
|
|
32
|
+
"""
|
|
33
|
+
n = 0
|
|
34
|
+
for s_id in r.model.getFloatingSpeciesIds():
|
|
35
|
+
try:
|
|
36
|
+
v = r[s_id]
|
|
37
|
+
except Exception:
|
|
38
|
+
continue
|
|
39
|
+
if v != 0.0 and -threshold < v < threshold:
|
|
40
|
+
r[s_id] = 0.0
|
|
41
|
+
n += 1
|
|
42
|
+
return n
|
|
43
|
+
|
|
44
|
+
# Smallest per-species absolute tolerance CVODE is allowed to be given.
|
|
45
|
+
#
|
|
46
|
+
# RoadRunner does not hand CVODE the scalar `absolute_tolerance` set on the
|
|
47
|
+
# integrator. It derives a per-species vector from it, scaled by each species'
|
|
48
|
+
# current amount:
|
|
49
|
+
#
|
|
50
|
+
# atol_i = absolute_tolerance * amount_i (amount = conc * volume)
|
|
51
|
+
# atol_i = absolute_tolerance * volume_i when amount_i is exactly 0
|
|
52
|
+
#
|
|
53
|
+
# There is no lower bound on that product, so a species decaying toward zero
|
|
54
|
+
# drags its own tolerance down with it -- and the tolerance underflows long
|
|
55
|
+
# before the value does. Tracing the vector's floor through one GRADUATE arm:
|
|
56
|
+
#
|
|
57
|
+
# after load or reset() 6.6e-13
|
|
58
|
+
# after the 72-year pre-equilibration 1.48e-24
|
|
59
|
+
# by the third dosing piece 4.25e-234
|
|
60
|
+
# by the eighth 9.23e-301
|
|
61
|
+
# by the twelfth exactly 0
|
|
62
|
+
#
|
|
63
|
+
# At zero, CVODE refuses the call outright: CV_ILL_INPUT from cvInitialSetup,
|
|
64
|
+
# "Initial ewt has component(s) equal to zero (illegal)". That is a
|
|
65
|
+
# start-of-call check, so every r.simulate() is another chance to trip it, and
|
|
66
|
+
# the arms that trip it are the ones that integrate longest. Across the SILK
|
|
67
|
+
# arms the floor tracks follow-up length exactly -- 1.53e-178 at 20 years,
|
|
68
|
+
# 4.33e-230 at 27.8, 3.45e-270 at 35, and 2.07e-308 at 60, which is already
|
|
69
|
+
# subnormal. SILK_young_neg is named for when its labeling starts, not for how
|
|
70
|
+
# long it runs; it runs the longest and it is the one that fails.
|
|
71
|
+
#
|
|
72
|
+
# Re-setting the scalar does not repair the vector, and neither does
|
|
73
|
+
# setIntegrator; only reset() does, by restoring the initial state.
|
|
74
|
+
# setIndividualTolerance does, per species, and survives both simulate() and
|
|
75
|
+
# setIntegrator -- so the vector is floored here before each block instead.
|
|
76
|
+
#
|
|
77
|
+
# The value is bounded above by the smallest quantity that carries a result --
|
|
78
|
+
# 1e-15 in this model -- and below by the point where it stops reaching the
|
|
79
|
+
# tolerances that actually fail. 1e-21 is six orders below that smallest real
|
|
80
|
+
# quantity, so an absolute tolerance there still resolves it to six figures.
|
|
81
|
+
#
|
|
82
|
+
# This sits *above* _DUST_THRESHOLD, which an earlier value deliberately
|
|
83
|
+
# avoided. That constraint guards the wrong thing: everything below the dust
|
|
84
|
+
# threshold is zeroed by clamp_state_dust before the integrator sees it, so a
|
|
85
|
+
# floor above it cannot loosen anything that survives to be integrated.
|
|
86
|
+
# Requiring the floor to sit under the dust threshold pushed it down into a
|
|
87
|
+
# region where it no longer engages, which is how 1e-30 came to be a floor that
|
|
88
|
+
# almost never fires on the arms that need it.
|
|
89
|
+
#
|
|
90
|
+
# Measured on the current model, one evaluation of each spec at x0. An earlier
|
|
91
|
+
# table here recorded 0/18 retries at 1e-30; that no longer reproduces.
|
|
92
|
+
#
|
|
93
|
+
# microglia_clearance silk_appfull
|
|
94
|
+
# floor blocks retries blocks retries real obs
|
|
95
|
+
# 1e-30 66 11 85 0 --
|
|
96
|
+
# 1e-27 56 2 85 0 4.8e-09
|
|
97
|
+
# 1e-24 56 2 85 0 3.6e-09
|
|
98
|
+
# 1e-21 56 0 85 0 6.6e-09
|
|
99
|
+
# 1e-18 56 0 85 0 6.6e-09
|
|
100
|
+
#
|
|
101
|
+
# At 1e-30 six arms fall into the retry ladder -- all four Aducanumab doses,
|
|
102
|
+
# GRAD_1_Drug and Lecanemab 10 mg/kg -- and the ten extra blocks are the
|
|
103
|
+
# subdivisions that follow. Their tolerance minima sit at 1e-24 to 1e-29:
|
|
104
|
+
# above the old floor, so nothing was raised, and below what CVODE can work
|
|
105
|
+
# with. A retry loosens abs_tol to 1e-8 and rel_tol to 1e-10, so that run is
|
|
106
|
+
# not the accurate baseline it looks like -- it is the one that gave up
|
|
107
|
+
# accuracy to finish at all.
|
|
108
|
+
#
|
|
109
|
+
# At 1e-21 no arm retries or subdivides. Against the 1e-30 run the joint NLL
|
|
110
|
+
# moves by 1.1e-06 nats, and every state value that differs is a residue around
|
|
111
|
+
# a true zero -- the same population the earlier analysis identified. The
|
|
112
|
+
# largest mover of any size is Antibody_SubCutComp at 3.1e-07, the
|
|
113
|
+
# subcutaneous depot after the drug has cleared; the largest quantity that
|
|
114
|
+
# shifts at all is 4.2e-04, by 0.12%. Nothing that carries a result moves.
|
|
115
|
+
# On silk_appfull nothing changes at any floor: no retries anywhere, and
|
|
116
|
+
# observables agree to eight or nine significant figures.
|
|
117
|
+
#
|
|
118
|
+
# 1e-18 also clears the retries but leaves only three figures on a 1e-15
|
|
119
|
+
# quantity, so 1e-21 is the point that fixes the failures with headroom left.
|
|
120
|
+
_MIN_ABSOLUTE_TOLERANCE = 1e-21
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _scalar_tolerance(value, fallback=1e-9):
|
|
124
|
+
"""Read a tolerance back from the integrator as a single number.
|
|
125
|
+
|
|
126
|
+
Once ``setIndividualTolerance`` has been used, RoadRunner's
|
|
127
|
+
``absolute_tolerance`` *getter* stops returning the scalar that was set and
|
|
128
|
+
returns the whole per-species vector as a list instead. The retry ladder
|
|
129
|
+
then does ``min(cur_abs_tol * 10, 1e-4)`` on a list, where ``* 10`` is list
|
|
130
|
+
repetition and the comparison raises
|
|
131
|
+
|
|
132
|
+
'<' not supported between instances of 'float' and 'list'
|
|
133
|
+
|
|
134
|
+
which surfaces as an integration failure with a nonsense message. The
|
|
135
|
+
tightest entry is the right scalar to carry forward: it is the accuracy the
|
|
136
|
+
run was actually being held to, and loosening from there is what the ladder
|
|
137
|
+
is for.
|
|
138
|
+
"""
|
|
139
|
+
try:
|
|
140
|
+
return float(value)
|
|
141
|
+
except (TypeError, ValueError):
|
|
142
|
+
pass
|
|
143
|
+
try:
|
|
144
|
+
values = [float(v) for v in value]
|
|
145
|
+
except (TypeError, ValueError):
|
|
146
|
+
return fallback
|
|
147
|
+
return min(values) if values else fallback
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def state_vector_ids(r):
|
|
151
|
+
"""Ids for CVODE's state vector, in the integrator's own order.
|
|
152
|
+
|
|
153
|
+
``getAbsoluteToleranceVector`` returns one entry per *state variable*, and
|
|
154
|
+
the floating species are only the first part of that: a model with rate
|
|
155
|
+
rules carries one further entry per rate-rule variable, appended after the
|
|
156
|
+
species. Verified against RoadRunner by setting a distinct tolerance per id
|
|
157
|
+
and reading the vector back -- the order is exactly species then rate rules,
|
|
158
|
+
and ``setIndividualTolerance`` accepts a rate-rule id like any other.
|
|
159
|
+
|
|
160
|
+
This matters because the obvious thing -- zipping the vector against
|
|
161
|
+
``getFloatingSpeciesIds()`` -- is silently short. ``zip`` stops at the
|
|
162
|
+
shorter sequence, so every rate-rule entry falls off the end and is never
|
|
163
|
+
floored, on a model where nothing about the call looks wrong. Those
|
|
164
|
+
variables decay like any other state and CVODE's error test covers them, so
|
|
165
|
+
an unfloored one is enough to fail a block on its own.
|
|
166
|
+
"""
|
|
167
|
+
ids = []
|
|
168
|
+
try:
|
|
169
|
+
ids.extend(r.model.getFloatingSpeciesIds())
|
|
170
|
+
except Exception:
|
|
171
|
+
return ids
|
|
172
|
+
for accessor in ("getRateRuleIds", "getRateRuleSymbols"):
|
|
173
|
+
for holder in (r, getattr(r, "model", None)):
|
|
174
|
+
if holder is None:
|
|
175
|
+
continue
|
|
176
|
+
try:
|
|
177
|
+
extra = list(getattr(holder, accessor)())
|
|
178
|
+
except Exception:
|
|
179
|
+
continue
|
|
180
|
+
if extra:
|
|
181
|
+
return ids + extra
|
|
182
|
+
return ids
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def floor_tolerance_vector(r, floor=_MIN_ABSOLUTE_TOLERANCE):
|
|
186
|
+
"""Raise every per-state absolute tolerance below *floor* up to it.
|
|
187
|
+
|
|
188
|
+
Returns the number raised. Cheap and idempotent: on a freshly reset model
|
|
189
|
+
nothing is below the floor and nothing is touched.
|
|
190
|
+
|
|
191
|
+
Must run *after* the scalar tolerance is applied, because setting the scalar
|
|
192
|
+
re-derives the whole vector and discards individual settings. It therefore
|
|
193
|
+
belongs before each block rather than once per run -- the vector is healthy
|
|
194
|
+
at t=0 and degrades as the trajectory does, so flooring once at setup
|
|
195
|
+
achieves precisely nothing.
|
|
196
|
+
|
|
197
|
+
Covers the whole state vector, not just the floating species: see
|
|
198
|
+
:func:`state_vector_ids` for why that distinction is not academic.
|
|
199
|
+
"""
|
|
200
|
+
try:
|
|
201
|
+
current = list(r.integrator.getAbsoluteToleranceVector())
|
|
202
|
+
except Exception:
|
|
203
|
+
return 0
|
|
204
|
+
ids = state_vector_ids(r)
|
|
205
|
+
n = 0
|
|
206
|
+
# Still a zip, but now against the full state vector. If a future
|
|
207
|
+
# RoadRunner adds another class of state variable the tail is skipped
|
|
208
|
+
# rather than misassigned -- the ordering is a prefix, so what is named is
|
|
209
|
+
# named correctly.
|
|
210
|
+
for s_id, tol in zip(ids, current):
|
|
211
|
+
if tol < floor:
|
|
212
|
+
try:
|
|
213
|
+
r.integrator.setIndividualTolerance(s_id, floor)
|
|
214
|
+
n += 1
|
|
215
|
+
except Exception:
|
|
216
|
+
pass
|
|
217
|
+
return n
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class StackedResult(np.ndarray):
|
|
221
|
+
def __new__(cls, input_array, colnames):
|
|
222
|
+
obj = np.asarray(input_array).view(cls)
|
|
223
|
+
obj.colnames = list(colnames)
|
|
224
|
+
return obj
|
|
225
|
+
|
|
226
|
+
def __getitem__(self, key):
|
|
227
|
+
if isinstance(key, str):
|
|
228
|
+
if hasattr(self, 'colnames') and key in self.colnames:
|
|
229
|
+
return super().__getitem__((slice(None), self.colnames.index(key)))
|
|
230
|
+
raise KeyError(f"Column '{key}' not in simulation results. "
|
|
231
|
+
f"Available: {getattr(self, 'colnames', [])}")
|
|
232
|
+
return super().__getitem__(key)
|
|
233
|
+
|
|
234
|
+
def save_model_state(r):
|
|
235
|
+
# Cache the state keys on the RoadRunner instance to minimize overhead
|
|
236
|
+
try:
|
|
237
|
+
keys = r._cached_state_keys
|
|
238
|
+
except AttributeError:
|
|
239
|
+
try:
|
|
240
|
+
assignment_rules = set(r.getAssignmentRuleIds())
|
|
241
|
+
except Exception:
|
|
242
|
+
assignment_rules = set()
|
|
243
|
+
keys = ['time']
|
|
244
|
+
keys += [s for s in r.getFloatingSpeciesIds() if s not in assignment_rules]
|
|
245
|
+
keys += [s for s in r.getBoundarySpeciesIds() if s not in assignment_rules]
|
|
246
|
+
keys += [s for s in r.getGlobalParameterIds() if s not in assignment_rules]
|
|
247
|
+
r._cached_state_keys = keys
|
|
248
|
+
|
|
249
|
+
prev_selections = r.selections
|
|
250
|
+
r.selections = keys
|
|
251
|
+
values = list(r.getSelectedValues())
|
|
252
|
+
r.selections = prev_selections
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
"keys": keys,
|
|
256
|
+
"values": values
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
def restore_model_state(r, state):
|
|
260
|
+
r.setValues(state["keys"], state["values"])
|
|
261
|
+
|
|
262
|
+
def _adjust_settings_for_error(err_msg, cur_abs_tol, cur_rel_tol, cur_max_steps, cur_initial_step):
|
|
263
|
+
"""Return updated (abs_tol, rel_tol, max_steps, initial_step) based on the CVODE error message."""
|
|
264
|
+
if any(k in err_msg for k in ["mxstep", "too many steps", "too much work", "mxstep steps taken"]):
|
|
265
|
+
cur_max_steps = min(cur_max_steps * 2, 400000)
|
|
266
|
+
cur_abs_tol = min(cur_abs_tol * 2, 1e-4)
|
|
267
|
+
cur_rel_tol = min(cur_rel_tol * 2, 1e-4)
|
|
268
|
+
elif any(k in err_msg for k in ["error test", "hmin", "step size"]):
|
|
269
|
+
cur_abs_tol = min(cur_abs_tol * 10, 1e-4)
|
|
270
|
+
cur_rel_tol = min(cur_rel_tol * 10, 1e-4)
|
|
271
|
+
if cur_initial_step is None or cur_initial_step > 1e-9:
|
|
272
|
+
cur_initial_step = 1e-9
|
|
273
|
+
else:
|
|
274
|
+
cur_initial_step = cur_initial_step / 10
|
|
275
|
+
elif any(k in err_msg for k in ["corrector", "convergence", "mxncf"]):
|
|
276
|
+
cur_abs_tol = min(cur_abs_tol * 10, 1e-4)
|
|
277
|
+
cur_rel_tol = min(cur_rel_tol * 10, 1e-4)
|
|
278
|
+
cur_max_steps = min(cur_max_steps * 2, 400000)
|
|
279
|
+
if cur_initial_step is None or cur_initial_step > 1e-9:
|
|
280
|
+
cur_initial_step = 1e-9
|
|
281
|
+
else:
|
|
282
|
+
cur_initial_step = cur_initial_step / 10
|
|
283
|
+
else:
|
|
284
|
+
cur_abs_tol = min(cur_abs_tol * 10, 1e-4)
|
|
285
|
+
cur_rel_tol = min(cur_rel_tol * 10, 1e-4)
|
|
286
|
+
cur_max_steps = min(cur_max_steps * 2, 400000)
|
|
287
|
+
return cur_abs_tol, cur_rel_tol, cur_max_steps, cur_initial_step
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def safe_simulate(r, solver_settings, observed_species, depth=0, label=None):
|
|
291
|
+
# Extract start, end, points, variable_step_size
|
|
292
|
+
start = solver_settings.get("start")
|
|
293
|
+
if start is None:
|
|
294
|
+
start = solver_settings.get("start_time")
|
|
295
|
+
end = solver_settings.get("end")
|
|
296
|
+
if end is None:
|
|
297
|
+
end = solver_settings.get("end_time")
|
|
298
|
+
points = solver_settings.get("n_points")
|
|
299
|
+
if points is None:
|
|
300
|
+
points = solver_settings.get("eval_points")
|
|
301
|
+
variable_step_size = solver_settings.get("variable_step_size", True)
|
|
302
|
+
|
|
303
|
+
# Ensure proper types
|
|
304
|
+
start = float(start)
|
|
305
|
+
end = float(end)
|
|
306
|
+
points = int(points)
|
|
307
|
+
|
|
308
|
+
# Save original state before any simulation is attempted.
|
|
309
|
+
# This is critical because if the fast path fails, it leaves the model in a
|
|
310
|
+
# corrupted/advanced state that cannot be reverted without this snapshot.
|
|
311
|
+
saved_state = save_model_state(r)
|
|
312
|
+
|
|
313
|
+
# Fast path: try once with whatever settings the model already has.
|
|
314
|
+
try:
|
|
315
|
+
res = r.simulate(start, end, points, observed_species)
|
|
316
|
+
return res, {
|
|
317
|
+
"start": start, "end": end,
|
|
318
|
+
"variable_step_size": variable_step_size,
|
|
319
|
+
"attempts": 1, "subdivided": False,
|
|
320
|
+
}
|
|
321
|
+
except Exception as e:
|
|
322
|
+
last_exception = e
|
|
323
|
+
first_err_msg = str(e).lower()
|
|
324
|
+
first_err_str = str(e).strip()
|
|
325
|
+
|
|
326
|
+
# Slow path: simulation failed. Now pay the cost to restore state, clamp
|
|
327
|
+
# small negatives that may have caused a CVODE ill-defined error, and
|
|
328
|
+
# enter the adaptive retry loop.
|
|
329
|
+
#
|
|
330
|
+
# The configured scalar cannot be read back from the integrator once
|
|
331
|
+
# floor_tolerance_vector has run: the getter then returns the per-species
|
|
332
|
+
# vector, whose minimum is the artificial 1e-30 floor -- and a ladder
|
|
333
|
+
# seeded there loosens to 2e-30, fails every attempt, and subdivides
|
|
334
|
+
# forever. configure_integrator caches the real scalar for exactly this
|
|
335
|
+
# read; the getter path remains only for callers that never went through
|
|
336
|
+
# configure_integrator.
|
|
337
|
+
orig_abs_tol = getattr(r, "_scalar_abs_tol", None)
|
|
338
|
+
if orig_abs_tol is None:
|
|
339
|
+
orig_abs_tol = _scalar_tolerance(r.integrator.absolute_tolerance)
|
|
340
|
+
orig_rel_tol = _scalar_tolerance(r.integrator.relative_tolerance)
|
|
341
|
+
orig_max_steps = r.integrator.maximum_num_steps
|
|
342
|
+
orig_initial_step = r.integrator.initial_time_step
|
|
343
|
+
|
|
344
|
+
# Restore the model to the starting state to revert any changes made by the failed attempt
|
|
345
|
+
restore_model_state(r, saved_state)
|
|
346
|
+
|
|
347
|
+
# Clamp dust which can cause CVODE ill-defined errors. Done before re-saving
|
|
348
|
+
# so the clamped values become the restore target for subsequent retries.
|
|
349
|
+
#
|
|
350
|
+
# This used to zero everything below 1e-12, which on the antibody-trial arms
|
|
351
|
+
# also destroyed genuine quantities: AB40_DensePlaqueNumber_BrainISF sits at
|
|
352
|
+
# about 1e-15 there, and those arms do reach this path. The threshold now
|
|
353
|
+
# only reaches values that cannot be anything but noise.
|
|
354
|
+
clamp_state_dust(r)
|
|
355
|
+
|
|
356
|
+
# Save state again with the clamped values
|
|
357
|
+
saved_state = save_model_state(r)
|
|
358
|
+
|
|
359
|
+
# Pre-adjust settings using the first error so attempt 2 starts smarter
|
|
360
|
+
# than just retrying with the same configuration.
|
|
361
|
+
cur_abs_tol, cur_rel_tol, cur_max_steps, cur_initial_step = _adjust_settings_for_error(
|
|
362
|
+
first_err_msg, orig_abs_tol, orig_rel_tol,
|
|
363
|
+
min(max(orig_max_steps, 20000), 400000), orig_initial_step,
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
label_prefix = f" [{label}]" if label else ""
|
|
367
|
+
print(f" [safe_simulate]{label_prefix} (Depth {depth}) Initial attempt failed: {first_err_str}")
|
|
368
|
+
|
|
369
|
+
max_attempts = 10
|
|
370
|
+
attempt = 1 # one attempt already burned on the fast path
|
|
371
|
+
|
|
372
|
+
while attempt < max_attempts:
|
|
373
|
+
attempt += 1
|
|
374
|
+
r.integrator.absolute_tolerance = cur_abs_tol
|
|
375
|
+
r.integrator.relative_tolerance = cur_rel_tol
|
|
376
|
+
r.integrator.maximum_num_steps = int(cur_max_steps)
|
|
377
|
+
r.integrator.setValue('variable_step_size', variable_step_size)
|
|
378
|
+
# Setting the scalar re-derives the per-species vector from the current
|
|
379
|
+
# state and discards any individual settings, so the floor has to be
|
|
380
|
+
# re-applied here or every retry would run without it -- including the
|
|
381
|
+
# retries provoked by its absence.
|
|
382
|
+
floor_tolerance_vector(r)
|
|
383
|
+
if cur_initial_step is not None:
|
|
384
|
+
try:
|
|
385
|
+
r.integrator.initial_time_step = cur_initial_step
|
|
386
|
+
except Exception:
|
|
387
|
+
pass
|
|
388
|
+
|
|
389
|
+
# Restore state to try again
|
|
390
|
+
restore_model_state(r, saved_state)
|
|
391
|
+
|
|
392
|
+
try:
|
|
393
|
+
print(f" [safe_simulate]{label_prefix} (Depth {depth}) Attempt {attempt}/{max_attempts}: Simulating [{start:.4g} to {end:.4g}] "
|
|
394
|
+
f"points={points} with abs_tol={cur_abs_tol:.2e}, rel_tol={cur_rel_tol:.2e}, max_steps={cur_max_steps}")
|
|
395
|
+
res = r.simulate(start, end, points, observed_species)
|
|
396
|
+
|
|
397
|
+
# Restore original integrator settings on success
|
|
398
|
+
r.integrator.absolute_tolerance = orig_abs_tol
|
|
399
|
+
r.integrator.relative_tolerance = orig_rel_tol
|
|
400
|
+
r.integrator.maximum_num_steps = orig_max_steps
|
|
401
|
+
try:
|
|
402
|
+
r.integrator.initial_time_step = orig_initial_step
|
|
403
|
+
except Exception:
|
|
404
|
+
pass
|
|
405
|
+
|
|
406
|
+
metadata = {
|
|
407
|
+
"start": start,
|
|
408
|
+
"end": end,
|
|
409
|
+
"abs_tol": cur_abs_tol,
|
|
410
|
+
"rel_tol": cur_rel_tol,
|
|
411
|
+
"max_steps": cur_max_steps,
|
|
412
|
+
"initial_time_step": cur_initial_step,
|
|
413
|
+
"variable_step_size": variable_step_size,
|
|
414
|
+
"attempts": attempt,
|
|
415
|
+
"subdivided": False
|
|
416
|
+
}
|
|
417
|
+
return res, metadata
|
|
418
|
+
|
|
419
|
+
except Exception as e:
|
|
420
|
+
last_exception = e
|
|
421
|
+
err_msg = str(e).lower()
|
|
422
|
+
print(f" [safe_simulate]{label_prefix} (Depth {depth}) Attempt {attempt} failed: {str(e).strip()}")
|
|
423
|
+
cur_abs_tol, cur_rel_tol, cur_max_steps, cur_initial_step = _adjust_settings_for_error(
|
|
424
|
+
err_msg, cur_abs_tol, cur_rel_tol, cur_max_steps, cur_initial_step,
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
# Subdivide as last resort
|
|
428
|
+
if depth < 4 and points >= 2:
|
|
429
|
+
mid = (start + end) / 2.0
|
|
430
|
+
p1 = max(2, points // 2)
|
|
431
|
+
p2 = max(2, points - p1)
|
|
432
|
+
print(f" [safe_simulate]{label_prefix} (Depth {depth}) ALL solver adjustment attempts failed. Subdividing [{start:.4g}, {end:.4g}] "
|
|
433
|
+
f"into [{start:.4g}, {mid:.4g}] (points={p1}) and [{mid:.4g}, {end:.4g}] (points={p2})")
|
|
434
|
+
|
|
435
|
+
# Restore to initial state for the first half
|
|
436
|
+
restore_model_state(r, saved_state)
|
|
437
|
+
|
|
438
|
+
# Simulate first half
|
|
439
|
+
block1 = {
|
|
440
|
+
"start": start,
|
|
441
|
+
"end": mid,
|
|
442
|
+
"n_points": p1,
|
|
443
|
+
"variable_step_size": variable_step_size
|
|
444
|
+
}
|
|
445
|
+
res1, meta1 = safe_simulate(r, block1, observed_species, depth=depth+1, label=label)
|
|
446
|
+
|
|
447
|
+
# Simulate second half starting from current state (which is at mid)
|
|
448
|
+
block2 = {
|
|
449
|
+
"start": mid,
|
|
450
|
+
"end": end,
|
|
451
|
+
"n_points": p2,
|
|
452
|
+
"variable_step_size": variable_step_size
|
|
453
|
+
}
|
|
454
|
+
res2, meta2 = safe_simulate(r, block2, observed_species, depth=depth+1, label=label)
|
|
455
|
+
|
|
456
|
+
# Stack results
|
|
457
|
+
colnames = getattr(res1, "colnames", observed_species)
|
|
458
|
+
if len(res2) > 1:
|
|
459
|
+
stacked = np.vstack((res1, res2[1:]))
|
|
460
|
+
else:
|
|
461
|
+
stacked = res1
|
|
462
|
+
res = StackedResult(stacked, colnames)
|
|
463
|
+
|
|
464
|
+
# Restore original integrator settings before returning
|
|
465
|
+
r.integrator.absolute_tolerance = orig_abs_tol
|
|
466
|
+
r.integrator.relative_tolerance = orig_rel_tol
|
|
467
|
+
r.integrator.maximum_num_steps = orig_max_steps
|
|
468
|
+
try:
|
|
469
|
+
r.integrator.initial_time_step = orig_initial_step
|
|
470
|
+
except Exception:
|
|
471
|
+
pass
|
|
472
|
+
|
|
473
|
+
metadata = {
|
|
474
|
+
"start": start,
|
|
475
|
+
"end": end,
|
|
476
|
+
"subdivided": True,
|
|
477
|
+
"meta1": meta1,
|
|
478
|
+
"meta2": meta2
|
|
479
|
+
}
|
|
480
|
+
return res, metadata
|
|
481
|
+
|
|
482
|
+
# If all else fails, restore original settings and raise exception
|
|
483
|
+
r.integrator.absolute_tolerance = orig_abs_tol
|
|
484
|
+
r.integrator.relative_tolerance = orig_rel_tol
|
|
485
|
+
r.integrator.maximum_num_steps = orig_max_steps
|
|
486
|
+
try:
|
|
487
|
+
r.integrator.initial_time_step = orig_initial_step
|
|
488
|
+
except Exception:
|
|
489
|
+
pass
|
|
490
|
+
|
|
491
|
+
print(f" [safe_simulate]{label_prefix} (Depth {depth}) CRITICAL: All simulation attempts and subdivision failed for interval [{start:.4g}, {end:.4g}]")
|
|
492
|
+
try:
|
|
493
|
+
print(f" Final Time in model: {r.getValue('time')}")
|
|
494
|
+
sn = r.model.getFloatingSpeciesIds()
|
|
495
|
+
sv = [r.getValue(s) for s in sn]
|
|
496
|
+
nan_indices = [i for i, v in enumerate(sv) if not np.isfinite(v)]
|
|
497
|
+
if len(nan_indices) > 0:
|
|
498
|
+
print(f" NaNs detected in: {[sn[i] for i in nan_indices]}")
|
|
499
|
+
except Exception:
|
|
500
|
+
pass
|
|
501
|
+
raise last_exception
|
|
502
|
+
|
|
503
|
+
def configure_integrator(r, solver_settings):
|
|
504
|
+
"""Fill in solver defaults and apply them to *r*.
|
|
505
|
+
|
|
506
|
+
Split out of :func:`simulate` so that anything running a single block on its
|
|
507
|
+
own -- the pre-dose cache in particular -- integrates under exactly the same
|
|
508
|
+
solver configuration the block would have got inside the normal loop. Two
|
|
509
|
+
copies of this setup that drifted apart would produce a cached state subtly
|
|
510
|
+
different from the one it replaces.
|
|
511
|
+
|
|
512
|
+
Mutates *solver_settings* with the defaults, as the original inline version
|
|
513
|
+
did, so callers that read the values back afterwards still see them.
|
|
514
|
+
"""
|
|
515
|
+
solver_settings["integrator"] = solver_settings.get("integrator", "cvode")
|
|
516
|
+
solver_settings["absolute_tolerance"] = solver_settings.get("absolute_tolerance", 1e-8)
|
|
517
|
+
solver_settings["relative_tolerance"] = solver_settings.get("relative_tolerance", 1e-8)
|
|
518
|
+
solver_settings["stiff"] = solver_settings.get("stiff", True)
|
|
519
|
+
solver_settings["variable_step_size"] = solver_settings.get("variable_step_size", True)
|
|
520
|
+
# solver_settings["initial_time_step"] = solver_settings.get("initial_time_step", 1e-6)
|
|
521
|
+
solver_settings["maximum_num_steps"] = solver_settings.get("maximum_num_steps", 20000)
|
|
522
|
+
r.setIntegrator(solver_settings["integrator"])
|
|
523
|
+
r.integrator.absolute_tolerance = solver_settings["absolute_tolerance"]
|
|
524
|
+
# Cache the scalar where safe_simulate can recover it. The integrator's
|
|
525
|
+
# own getter is unreliable for this: after floor_tolerance_vector uses
|
|
526
|
+
# setIndividualTolerance it returns the per-species vector, whose min is
|
|
527
|
+
# the 1e-30 floor rather than the configured accuracy.
|
|
528
|
+
try:
|
|
529
|
+
r._scalar_abs_tol = float(solver_settings["absolute_tolerance"])
|
|
530
|
+
except Exception:
|
|
531
|
+
pass
|
|
532
|
+
r.integrator.relative_tolerance = solver_settings["relative_tolerance"]
|
|
533
|
+
r.integrator.setValue('stiff', solver_settings["stiff"])
|
|
534
|
+
r.integrator.variable_step_size = solver_settings["variable_step_size"]
|
|
535
|
+
# r.integrator.setValue('initial_time_step', solver_settings["initial_time_step"])
|
|
536
|
+
r.integrator.setValue('maximum_num_steps', solver_settings["maximum_num_steps"])
|
|
537
|
+
return solver_settings
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def simulate(r, solver_settings, observed_species, label=None):
|
|
541
|
+
|
|
542
|
+
configure_integrator(r, solver_settings)
|
|
543
|
+
|
|
544
|
+
# te.noticesOff()
|
|
545
|
+
# te.r.printVersionInfo()
|
|
546
|
+
# te.roadrunner.Logger.setLevel(te.roadrunner.Logger.LOG_ERROR)
|
|
547
|
+
|
|
548
|
+
t0 = time.perf_counter()
|
|
549
|
+
|
|
550
|
+
blocks = solver_settings['simulation_blocks']
|
|
551
|
+
if isinstance(blocks, dict):
|
|
552
|
+
block_list = list(blocks.values())
|
|
553
|
+
else:
|
|
554
|
+
block_list = blocks
|
|
555
|
+
|
|
556
|
+
# Drop any requested symbols that don't exist in this model variant.
|
|
557
|
+
# The available-symbol set never changes after the model is compiled, so
|
|
558
|
+
# cache it on the RoadRunner instance to avoid 5 SWIG round-trips per
|
|
559
|
+
# block per optimization step. The negative-clamp that used to live here
|
|
560
|
+
# has moved into safe_simulate's failure-recovery path.
|
|
561
|
+
try:
|
|
562
|
+
_available = r._available_symbols
|
|
563
|
+
except AttributeError:
|
|
564
|
+
_ids = (set(r.getFloatingSpeciesIds()) | set(r.getBoundarySpeciesIds())
|
|
565
|
+
| set(r.getAssignmentRuleIds()) | set(r.getGlobalParameterIds())
|
|
566
|
+
| set(r.getReactionIds()))
|
|
567
|
+
_available = {'time'} | _ids | {'[' + s + ']' for s in _ids}
|
|
568
|
+
try:
|
|
569
|
+
r._available_symbols = _available
|
|
570
|
+
except Exception:
|
|
571
|
+
pass
|
|
572
|
+
observed_species = [s for s in observed_species if s in _available]
|
|
573
|
+
|
|
574
|
+
all_results = None
|
|
575
|
+
|
|
576
|
+
# Find index of the last tracked block so we can skip trailing untracked ones.
|
|
577
|
+
last_tracked_idx = -1
|
|
578
|
+
for i, blk in enumerate(block_list):
|
|
579
|
+
if blk.get('tracked', True):
|
|
580
|
+
last_tracked_idx = i
|
|
581
|
+
|
|
582
|
+
for i, block in enumerate(block_list):
|
|
583
|
+
if i > last_tracked_idx:
|
|
584
|
+
break
|
|
585
|
+
tracked = block.get('tracked', True)
|
|
586
|
+
|
|
587
|
+
if 'maximum_num_steps' in block:
|
|
588
|
+
r.integrator.maximum_num_steps = block['maximum_num_steps']
|
|
589
|
+
|
|
590
|
+
# Before every block, not once per run: RoadRunner's per-species
|
|
591
|
+
# tolerance vector is derived from the current state, so it is healthy
|
|
592
|
+
# here at t=0 and underflows as the trajectory decays. See
|
|
593
|
+
# _MIN_ABSOLUTE_TOLERANCE.
|
|
594
|
+
floor_tolerance_vector(r)
|
|
595
|
+
|
|
596
|
+
try:
|
|
597
|
+
res, _ = safe_simulate(r, block, observed_species, label=label)
|
|
598
|
+
except Exception as exc:
|
|
599
|
+
raise RuntimeError(
|
|
600
|
+
f"Integration failed in block {i} "
|
|
601
|
+
f"[{block['start']:.4g}, {block['end']:.4g}]: {exc}"
|
|
602
|
+
) from exc
|
|
603
|
+
|
|
604
|
+
if not tracked:
|
|
605
|
+
# An untracked block exists only to leave the model in a state, and
|
|
606
|
+
# the dust it leaves in the empty compartments is what makes the
|
|
607
|
+
# first dosed block expensive. Clear it here, where the block's own
|
|
608
|
+
# output is being discarded anyway.
|
|
609
|
+
clamp_state_dust(r)
|
|
610
|
+
continue
|
|
611
|
+
if all_results is None:
|
|
612
|
+
all_results = res
|
|
613
|
+
else:
|
|
614
|
+
colnames = all_results.colnames
|
|
615
|
+
stacked = np.vstack((all_results, res))
|
|
616
|
+
all_results = StackedResult(stacked, colnames)
|
|
617
|
+
return all_results
|