PyAntiGen 2.1.4__tar.gz → 2.1.6__tar.gz

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 (112) hide show
  1. {pyantigen-2.1.4 → pyantigen-2.1.6}/PKG-INFO +1 -1
  2. {pyantigen-2.1.4 → pyantigen-2.1.6}/PyAntiGen.egg-info/PKG-INFO +1 -1
  3. {pyantigen-2.1.4 → pyantigen-2.1.6}/PyAntiGen.egg-info/scm_file_list.json +1 -0
  4. pyantigen-2.1.6/PyAntiGen.egg-info/scm_version.json +8 -0
  5. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/_version.py +3 -3
  6. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/engine/Optimize.py +5 -0
  7. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/engine/Segmented.py +2 -2
  8. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/engine/Simulate.py +193 -2
  9. pyantigen-2.1.4/PyAntiGen.egg-info/scm_version.json +0 -8
  10. {pyantigen-2.1.4 → pyantigen-2.1.6}/.github/workflows/publish.yml +0 -0
  11. {pyantigen-2.1.4 → pyantigen-2.1.6}/.github/workflows/tests.yml +0 -0
  12. {pyantigen-2.1.4 → pyantigen-2.1.6}/.gitignore +0 -0
  13. {pyantigen-2.1.4 → pyantigen-2.1.6}/.vscode/launch.json +0 -0
  14. {pyantigen-2.1.4 → pyantigen-2.1.6}/LICENSE +0 -0
  15. {pyantigen-2.1.4 → pyantigen-2.1.6}/MANIFEST.in +0 -0
  16. {pyantigen-2.1.4 → pyantigen-2.1.6}/PyAntiGen.egg-info/SOURCES.txt +0 -0
  17. {pyantigen-2.1.4 → pyantigen-2.1.6}/PyAntiGen.egg-info/dependency_links.txt +0 -0
  18. {pyantigen-2.1.4 → pyantigen-2.1.6}/PyAntiGen.egg-info/entry_points.txt +0 -0
  19. {pyantigen-2.1.4 → pyantigen-2.1.6}/PyAntiGen.egg-info/requires.txt +0 -0
  20. {pyantigen-2.1.4 → pyantigen-2.1.6}/PyAntiGen.egg-info/top_level.txt +0 -0
  21. {pyantigen-2.1.4 → pyantigen-2.1.6}/README.md +0 -0
  22. {pyantigen-2.1.4 → pyantigen-2.1.6}/docs/ANALYTIC_GRADIENT.md +0 -0
  23. {pyantigen-2.1.4 → pyantigen-2.1.6}/docs/MULTISTART.md +0 -0
  24. {pyantigen-2.1.4 → pyantigen-2.1.6}/docs/PROTOCOL_LAYER.md +0 -0
  25. {pyantigen-2.1.4 → pyantigen-2.1.6}/docs/V2_DESIGN.md +0 -0
  26. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/.agents/skills/module_generation/SKILL.md +0 -0
  27. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/.agents/skills/ode_conversion_antimony/SKILL.md +0 -0
  28. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/.agents/skills/ode_extraction_pipeline/SKILL.md +0 -0
  29. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/.gitignore +0 -0
  30. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/__init__.py +0 -0
  31. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/cli.py +0 -0
  32. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/engine/Analytic_gradient.py +0 -0
  33. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/engine/Anchor_cache.py +0 -0
  34. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/engine/Deadline.py +0 -0
  35. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/engine/Differential_evolution.py +0 -0
  36. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/engine/Evaluator.py +0 -0
  37. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/engine/Event_times.py +0 -0
  38. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/engine/Fast_profile.py +0 -0
  39. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/engine/Fit_cache.py +0 -0
  40. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/engine/Identifiability.py +0 -0
  41. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/engine/Model_cache.py +0 -0
  42. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/engine/Model_optimize.py +0 -0
  43. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/engine/Model_simulate.py +0 -0
  44. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/engine/Multistart.py +0 -0
  45. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/engine/Nelder_mead.py +0 -0
  46. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/engine/Noise_floor.py +0 -0
  47. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/engine/Petab_export.py +0 -0
  48. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/engine/Preequil_cache.py +0 -0
  49. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/engine/Profile_checkpoint.py +0 -0
  50. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/engine/Profile_plan.py +0 -0
  51. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/engine/README.md +0 -0
  52. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/engine/Results.py +0 -0
  53. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/engine/Scaled_amici.py +0 -0
  54. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/engine/Sensitivity_analysis.py +0 -0
  55. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/engine/Stop_rules.py +0 -0
  56. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/engine/__init__.py +0 -0
  57. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/generate/AntimonyGen.py +0 -0
  58. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/generate/RxnDict_to_antimony.py +0 -0
  59. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/generate/TelluriumGen.py +0 -0
  60. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/generate/__init__.py +0 -0
  61. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/generate/antimony_utils.py +0 -0
  62. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/generate/data_interpolation.py +0 -0
  63. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/generate/input_schedule.py +0 -0
  64. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/generate/isotopomer_tools.py +0 -0
  65. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/generate/model_generation.py +0 -0
  66. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/generate/models.py +0 -0
  67. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/generate/module_base.py +0 -0
  68. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/generate/pyantigen.py +0 -0
  69. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/generate/rate_laws.py +0 -0
  70. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/generate/reaction_creation.py +0 -0
  71. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/generate/scaled_time.py +0 -0
  72. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/study/__init__.py +0 -0
  73. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/study/assay.py +0 -0
  74. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/study/describe.py +0 -0
  75. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/study/design.py +0 -0
  76. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/study/lower_v1.py +0 -0
  77. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/study/optimization.py +0 -0
  78. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/study/params.py +0 -0
  79. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/study/quantity.py +0 -0
  80. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/study/reagent.py +0 -0
  81. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/study/refs.py +0 -0
  82. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/study/remarks.py +0 -0
  83. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/study/serialize.py +0 -0
  84. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/study/validate.py +0 -0
  85. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/template/Example/AntiGen_paths.py +0 -0
  86. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/template/Example/Flipflop_reference.py +0 -0
  87. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/template/Example/Model_generate.py +0 -0
  88. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/template/Example/Model_run.py +0 -0
  89. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/template/Example/Modules/Data.py +0 -0
  90. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/template/Example/Modules/Events.py +0 -0
  91. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/template/Example/Modules/Experiment.py +0 -0
  92. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/template/Example/Modules/Loss_config.py +0 -0
  93. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/template/Example/Modules/Observed_species.py +0 -0
  94. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/template/Example/Modules/Optimizer_settings.py +0 -0
  95. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/template/Example/Modules/Plots.py +0 -0
  96. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/template/Example/Modules/Solver_settings.py +0 -0
  97. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/template/Example/Modules/Update_opt_parameters.py +0 -0
  98. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/template/Example/Modules/Update_parameters.py +0 -0
  99. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/template/Example/optimizations/Example1_ADneg.json +0 -0
  100. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/template/Example/optimizations/Example1_ADpos.json +0 -0
  101. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/template/Example/optimizations/Example3_joint.json +0 -0
  102. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/template/Example/optimizations/Example4_flipflop.json +0 -0
  103. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/template/Example/optimizations/example.py +0 -0
  104. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/template/Example/studies/example.json +0 -0
  105. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/template/Example/studies/example.py +0 -0
  106. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/template/Example/studies/flipflop.json +0 -0
  107. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/template/data/ADneg.csv +0 -0
  108. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/template/data/ADpos.csv +0 -0
  109. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/template/data/Flipflop.csv +0 -0
  110. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyantigen/template/data/make_flipflop_data.py +0 -0
  111. {pyantigen-2.1.4 → pyantigen-2.1.6}/pyproject.toml +0 -0
  112. {pyantigen-2.1.4 → pyantigen-2.1.6}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyAntiGen
3
- Version: 2.1.4
3
+ Version: 2.1.6
4
4
  Summary: Declarative generation, simulation and identifiability analysis of compartmental Antimony/SBML models
5
5
  Author-email: Don Elbert <elbert5770@gmail.com>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyAntiGen
3
- Version: 2.1.4
3
+ Version: 2.1.6
4
4
  Summary: Declarative generation, simulation and identifiability analysis of compartmental Antimony/SBML models
5
5
  Author-email: Don Elbert <elbert5770@gmail.com>
6
6
  License-Expression: MIT
@@ -143,6 +143,7 @@
143
143
  "tests/engine/test_solver_fingerprint.py",
144
144
  "tests/engine/test_stop_rules.py",
145
145
  "tests/engine/test_tolerance_floor.py",
146
+ "tests/engine/test_tolerance_pinning.py",
146
147
  "tests/engine/test_wald_units.py",
147
148
  "tests/generate/test_gen_antimony_utils.py",
148
149
  "tests/generate/test_gen_data_interpolation.py",
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "2.1.6",
3
+ "distance": 0,
4
+ "node": "g9171689079d2fc724733c9b7be5620f998df09cf",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-10-19"
8
+ }
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '2.1.4'
22
- __version_tuple__ = version_tuple = (2, 1, 4)
21
+ __version__ = version = '2.1.6'
22
+ __version_tuple__ = version_tuple = (2, 1, 6)
23
23
 
24
- __commit_id__ = commit_id = 'g2e65abb87'
24
+ __commit_id__ = commit_id = 'g917168907'
@@ -8279,6 +8279,11 @@ def predict_concentrations(params, observable_data, model, param_names, observab
8279
8279
  model.setIntegrator('cvode')
8280
8280
  model.integrator.absolute_tolerance = 1e-8
8281
8281
  _model_cache.put(model, "_scalar_abs_tol", 1e-8)
8282
+ # This helper takes no solver settings, so it cannot ask for pinning.
8283
+ # Drop any policy an earlier configure_integrator left on the model, or
8284
+ # its reference scale -- taken for a different parameter vector -- would
8285
+ # keep pinning here.
8286
+ _model_cache.put(model, "_tolerance_policy", None)
8282
8287
  model.integrator.relative_tolerance = 1e-8
8283
8288
  model.integrator.setValue('stiff', True)
8284
8289
  model.integrator.variable_step_size = True
@@ -44,7 +44,7 @@ caches separately must not contain a switch: ``switches_before`` says whether it
44
44
  """
45
45
  import numpy as np
46
46
 
47
- from pyantigen.engine.Simulate import StackedResult, floor_tolerance_vector, safe_simulate
47
+ from pyantigen.engine.Simulate import StackedResult, apply_tolerance_vector, safe_simulate
48
48
  from pyantigen.generate.input_schedule import ScheduleError
49
49
 
50
50
 
@@ -154,7 +154,7 @@ class SegmentRunner:
154
154
  between = mine[(mine > u + eu) & (mine < v - ev)]
155
155
  times = [u] + between.tolist() + [v]
156
156
 
157
- floor_tolerance_vector(self.r)
157
+ apply_tolerance_vector(self.r)
158
158
  res, _meta = safe_simulate(
159
159
  self.r, {"times": times, "variable_step_size": False},
160
160
  observed_species, label=label)
@@ -233,6 +233,181 @@ def floor_tolerance_vector(r, floor=_MIN_ABSOLUTE_TOLERANCE):
233
233
  return n
234
234
 
235
235
 
236
+ # ---------------------------------------------------------------------------
237
+ # Pinning the vector, not only flooring it
238
+ # ---------------------------------------------------------------------------
239
+ # floor_tolerance_vector raises what has fallen below the floor and leaves
240
+ # everything else as RoadRunner has it. That is enough for a vector that has
241
+ # drifted DOWN, which is what the floor was written for. It does nothing about
242
+ # two other things:
243
+ #
244
+ # * The retry ladder sets the scalar before every attempt, and setting the
245
+ # scalar re-derives the whole vector from the amounts the model holds
246
+ # *at that moment* -- the failing state, not the initial one. Measured on
247
+ # Aducanumab 60 mg/kg: between the segment-start vector and the vector of
248
+ # attempt 2, 176 entries were more than 10x larger and 62 more than 10x
249
+ # smaller (ratios 1.7e-11 .. 2.9e+07). The floor raised 3 of them. So the
250
+ # loosened retries do not run at "the same tolerances, looser"; they run at
251
+ # tolerances derived from wherever the integration died.
252
+ #
253
+ # * RoadRunner's initial-derived scale is atol_i = scalar * amount_i, or
254
+ # scalar * volume_i for a species that starts at 0 (see the note above
255
+ # _MIN_ABSOLUTE_TOLERANCE). Every species that starts empty therefore gets
256
+ # one number, scalar * its compartment volume -- 6.7e-11 at scalar 1e-9 for
257
+ # the 0.067-volume endosome -- however small it later runs. After the FcRn
258
+ # saturation front of a 60 mg/kg dose the endosomal antibody species sit at
259
+ # 1.6e-9 .. 1.3e-8, so that budget is 0.5-4% of their value, and CVODE fails
260
+ # (CV_ILL_INPUT / CV_TOO_MUCH_ACC) ~200 h after the dose. Lowering the scalar
261
+ # 10x, pinning those five species at 1e-15, or capping every entry at 1e-12
262
+ # each took the displaced-parameter failures from 6/40 to 0/40.
263
+ #
264
+ # Pinning keeps ONE reference -- the per-state scale RoadRunner derives at
265
+ # configure_integrator when the model is at its initial time -- and from it
266
+ # sets every entry explicitly before each block, each segment and each ladder
267
+ # attempt:
268
+ #
269
+ # atol_i = clip( scalar * scale_i , floor , cap_ratio * scalar )
270
+ #
271
+ # *scalar* is the configured one, or the ladder's current (loosened) one, so a
272
+ # retry scales the same vector instead of re-deriving it. The cap is relative
273
+ # to the scalar for the same reason: an absolute cap would undo the ladder's
274
+ # loosening. cap_ratio is optional; without it pinning only restores the
275
+ # initial-derived vector.
276
+ #
277
+ # Opt in per replicate through the solver settings:
278
+ #
279
+ # "tolerance_pinning": True, # default TOLERANCE_PINNING (False)
280
+ # "tolerance_cap_ratio": 1e-3, # default TOLERANCE_CAP_RATIO (None)
281
+ #
282
+ # Both are plain values in the settings dict, so Profile_checkpoint's solver
283
+ # fingerprint already covers them: switching them on starts a new checkpoint
284
+ # directory rather than mixing two objectives. Left off, apply_tolerance_vector
285
+ # is floor_tolerance_vector and nothing changes.
286
+ TOLERANCE_PINNING = False
287
+ TOLERANCE_CAP_RATIO = None
288
+
289
+
290
+ def tolerance_policy(solver_settings):
291
+ """``{"pin": bool, "cap_ratio": float | None}`` from *solver_settings*.
292
+
293
+ ``tolerance_pinning`` must be a real bool: ``bool("false")`` is True, so a
294
+ string from a config file would switch pinning on while reading as off.
295
+ """
296
+ pin = solver_settings.get("tolerance_pinning", TOLERANCE_PINNING)
297
+ if not isinstance(pin, (bool, np.bool_)):
298
+ raise TypeError(f"tolerance_pinning must be True or False, got {pin!r}")
299
+ pin = bool(pin)
300
+ cap = solver_settings.get("tolerance_cap_ratio", TOLERANCE_CAP_RATIO)
301
+ if cap is not None:
302
+ if isinstance(cap, (bool, np.bool_, str)):
303
+ raise TypeError(f"tolerance_cap_ratio must be a number, got {cap!r}")
304
+ cap = float(cap)
305
+ if not cap > 0:
306
+ raise ValueError(f"tolerance_cap_ratio must be positive, got {cap}")
307
+ if not pin:
308
+ raise ValueError("tolerance_cap_ratio needs tolerance_pinning: a cap is "
309
+ "applied to the pinned vector")
310
+ return {"pin": pin, "cap_ratio": cap}
311
+
312
+
313
+ def _remember_tolerance_policy(r, solver_settings, scalar):
314
+ """Cache the policy on *r* and, at the model's initial time, the reference scale.
315
+
316
+ Called by configure_integrator right after the scalar is set, when RoadRunner
317
+ has just derived the vector. The scale is only taken at time 0: a second
318
+ configure on a state restored from the pre-dose cache would derive it from
319
+ amounts that have already decayed, which is the drift this exists to undo.
320
+ """
321
+ policy = tolerance_policy(solver_settings)
322
+ if not policy["pin"]:
323
+ _model_cache.put(r, "_tolerance_policy", None)
324
+ return
325
+ # The model clock is the test for "still at the initial state": reset() and
326
+ # resetAll() return it to exactly 0.0 whatever was simulated before (a model
327
+ # has no other start time), and save/restore_model_state carry 'time' with
328
+ # the amounts, so a restored state brings its clock back with it. A clock
329
+ # that is not 0 means the model has been integrated, whatever its start.
330
+ try:
331
+ at_start = float(r.getCurrentTime()) == 0.0
332
+ except Exception:
333
+ at_start = False
334
+ if at_start:
335
+ # At the initial time the reference is re-derived every time, and a
336
+ # failure to do so drops the old one: it belongs to an earlier parameter
337
+ # vector, and pinning to it would silently use the wrong scales. (Away
338
+ # from t=0 the stored reference is kept on purpose -- see above.)
339
+ reference = None
340
+ try:
341
+ vec = np.asarray(r.integrator.getAbsoluteToleranceVector(), float)
342
+ ids = state_vector_ids(r)
343
+ n = min(len(ids), len(vec))
344
+ if float(scalar) > 0 and n:
345
+ reference = (list(ids[:n]), vec[:n] / float(scalar))
346
+ except Exception:
347
+ pass
348
+ _model_cache.put(r, "_atol_reference", reference)
349
+ if _model_cache.get(r, "_atol_reference") is None:
350
+ # Never saw the initial state, so there is no initial scale to pin to.
351
+ import warnings
352
+ warnings.warn("tolerance_pinning requested but the model was first configured "
353
+ "away from its initial time; falling back to the floor",
354
+ RuntimeWarning, stacklevel=3)
355
+ _model_cache.put(r, "_tolerance_policy", None)
356
+ return
357
+ _model_cache.put(r, "_tolerance_policy", policy)
358
+
359
+
360
+ def apply_tolerance_vector(r, scalar=None, floor=_MIN_ABSOLUTE_TOLERANCE):
361
+ """Set the per-state absolute tolerances for the next integration.
362
+
363
+ Pinning off (the default): exactly :func:`floor_tolerance_vector`. Pinning
364
+ on: every entry is set to ``clip(scalar * scale_i, floor, cap_ratio * scalar)``
365
+ from the reference scale. *scalar* defaults to the configured absolute
366
+ tolerance; the retry ladder passes its current, loosened one. Returns the
367
+ number of entries set.
368
+ """
369
+ policy = _model_cache.get(r, "_tolerance_policy")
370
+ ref = _model_cache.get(r, "_atol_reference") if policy else None
371
+ if ref is None:
372
+ return floor_tolerance_vector(r, floor)
373
+ if scalar is None:
374
+ scalar = _model_cache.get(r, "_scalar_abs_tol")
375
+ if scalar is None:
376
+ return floor_tolerance_vector(r, floor)
377
+ ids, scale = ref
378
+ try:
379
+ current = np.asarray(r.integrator.getAbsoluteToleranceVector(), float)
380
+ except Exception:
381
+ # A vector that cannot be read cannot be floored either (the floor reads
382
+ # it first), so there is nothing to fall back to.
383
+ return 0
384
+ if len(current) < len(ids):
385
+ return floor_tolerance_vector(r, floor)
386
+ scalar = float(scalar)
387
+ want = scalar * scale
388
+ if policy["cap_ratio"] is not None:
389
+ want = np.minimum(want, policy["cap_ratio"] * scalar)
390
+ want = np.maximum(want, floor)
391
+ n = 0
392
+ failed = []
393
+ for i in np.flatnonzero(~np.isclose(current[:len(ids)], want, rtol=1e-9, atol=0.0)):
394
+ try:
395
+ r.integrator.setIndividualTolerance(ids[i], float(want[i]))
396
+ n += 1
397
+ except Exception:
398
+ failed.append(ids[i])
399
+ if failed and not _model_cache.get(r, "_tolerance_set_warned"):
400
+ # Once per model: a state left at its drifted tolerance is what pinning
401
+ # exists to prevent, so it must not be silent, but this runs before
402
+ # every block and would otherwise repeat for the whole fit.
403
+ import warnings
404
+ _model_cache.put(r, "_tolerance_set_warned", True)
405
+ warnings.warn(f"tolerance_pinning could not set the absolute tolerance of "
406
+ f"{len(failed)} state(s) (first: {failed[0]!r}); they keep "
407
+ f"RoadRunner's derived value", RuntimeWarning, stacklevel=2)
408
+ return n
409
+
410
+
236
411
  class StackedResult(np.ndarray):
237
412
  def __new__(cls, input_array, colnames):
238
413
  obj = np.asarray(input_array).view(cls)
@@ -584,6 +759,15 @@ def safe_simulate(r, solver_settings, observed_species, depth=0, label=None):
584
759
  # corrupted/advanced state that cannot be reverted without this snapshot.
585
760
  saved_state = save_model_state(r)
586
761
 
762
+ # A sub-call of a subdivision begins wherever the previous one left the
763
+ # vector: re-derived from the decayed state after a restore, or loosened by
764
+ # a ladder. The top-level call is pinned by its caller (_run_blocks,
765
+ # SegmentRunner); the halves are not, so pin them here, from the configured
766
+ # scalar. Each half then tries the configured accuracy and runs its own
767
+ # ladder if that fails.
768
+ if depth > 0:
769
+ apply_tolerance_vector(r)
770
+
587
771
  # Fast path: try once with whatever settings the model already has.
588
772
  try:
589
773
  res = _run(r, start, end, points, times, observed_species)
@@ -653,7 +837,7 @@ def safe_simulate(r, solver_settings, observed_species, depth=0, label=None):
653
837
  # state and discards any individual settings, so the floor has to be
654
838
  # re-applied here or every retry would run without it -- including the
655
839
  # retries provoked by its absence.
656
- floor_tolerance_vector(r)
840
+ apply_tolerance_vector(r, scalar=cur_abs_tol)
657
841
  if cur_initial_step is not None:
658
842
  try:
659
843
  r.integrator.initial_time_step = cur_initial_step
@@ -670,6 +854,7 @@ def safe_simulate(r, solver_settings, observed_species, depth=0, label=None):
670
854
 
671
855
  # Restore original integrator settings on success
672
856
  r.integrator.absolute_tolerance = orig_abs_tol
857
+ apply_tolerance_vector(r, scalar=orig_abs_tol)
673
858
  r.integrator.relative_tolerance = orig_rel_tol
674
859
  r.integrator.maximum_num_steps = orig_max_steps
675
860
  try:
@@ -748,6 +933,7 @@ def safe_simulate(r, solver_settings, observed_species, depth=0, label=None):
748
933
 
749
934
  # Restore original integrator settings before returning
750
935
  r.integrator.absolute_tolerance = orig_abs_tol
936
+ apply_tolerance_vector(r, scalar=orig_abs_tol)
751
937
  r.integrator.relative_tolerance = orig_rel_tol
752
938
  r.integrator.maximum_num_steps = orig_max_steps
753
939
  try:
@@ -766,6 +952,7 @@ def safe_simulate(r, solver_settings, observed_species, depth=0, label=None):
766
952
 
767
953
  # If all else fails, restore original settings and raise exception
768
954
  r.integrator.absolute_tolerance = orig_abs_tol
955
+ apply_tolerance_vector(r, scalar=orig_abs_tol)
769
956
  r.integrator.relative_tolerance = orig_rel_tol
770
957
  r.integrator.maximum_num_steps = orig_max_steps
771
958
  try:
@@ -797,6 +984,9 @@ def configure_integrator(r, solver_settings):
797
984
  Mutates *solver_settings* with the defaults, as the original inline version
798
985
  did, so callers that read the values back afterwards still see them.
799
986
  """
987
+ # Before anything is touched: a bad pinning setting is a configuration
988
+ # error, and should not leave the integrator half set up.
989
+ tolerance_policy(solver_settings)
800
990
  solver_settings["integrator"] = solver_settings.get("integrator", "cvode")
801
991
  solver_settings["absolute_tolerance"] = solver_settings.get("absolute_tolerance", 1e-8)
802
992
  solver_settings["relative_tolerance"] = solver_settings.get("relative_tolerance", 1e-8)
@@ -822,6 +1012,7 @@ def configure_integrator(r, solver_settings):
822
1012
  r.integrator.variable_step_size = solver_settings["variable_step_size"]
823
1013
  # r.integrator.setValue('initial_time_step', solver_settings["initial_time_step"])
824
1014
  r.integrator.setValue('maximum_num_steps', solver_settings["maximum_num_steps"])
1015
+ _remember_tolerance_policy(r, solver_settings, atol)
825
1016
  return solver_settings
826
1017
 
827
1018
 
@@ -891,7 +1082,7 @@ def _run_blocks(r, block_list, last_tracked_idx, observed_species, label, runner
891
1082
  # tolerance vector is derived from the current state, so it is healthy
892
1083
  # here at t=0 and underflows as the trajectory decays. See
893
1084
  # _MIN_ABSOLUTE_TOLERANCE.
894
- floor_tolerance_vector(r)
1085
+ apply_tolerance_vector(r)
895
1086
 
896
1087
  try:
897
1088
  if runner is not None:
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "2.1.4",
3
- "distance": 0,
4
- "node": "g2e65abb870fe7cb1136e01bcd8c30b6d0f712dd6",
5
- "dirty": false,
6
- "branch": "HEAD",
7
- "node_date": "2026-10-14"
8
- }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes