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,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