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
framework/pyantigen.py ADDED
@@ -0,0 +1,51 @@
1
+ import os
2
+ from framework.isotopomer_tools import ensure_isotopes_format
3
+ from framework.model_generation import generate_model
4
+
5
+ class PyAntiGen:
6
+ def __init__(self, name, isotopes=None):
7
+ self.name = name
8
+ self.isotopes = ensure_isotopes_format(isotopes or [''])
9
+ self.reactions = []
10
+ self.rules = []
11
+ self.counter = 0
12
+
13
+ def add_reaction(
14
+ self,
15
+ name,
16
+ reactants,
17
+ products,
18
+ rate_type,
19
+ rate_eqtn,
20
+ compartment=None,
21
+ compartment_reverse=None,
22
+ ):
23
+ from framework.reaction_creation import reaction_creation
24
+ self.counter, self.reactions = reaction_creation(
25
+ self.reactions,
26
+ self.counter,
27
+ name,
28
+ reactants,
29
+ products,
30
+ rate_type,
31
+ rate_eqtn,
32
+ compartment=compartment,
33
+ compartment_reverse=compartment_reverse,
34
+ )
35
+
36
+ def add_rule(self, rule):
37
+ self.rules.append(rule)
38
+
39
+ def generate(self, calling_file_path, model_name=None):
40
+ """
41
+ Generates the Antimony model. This wraps the existing model_generation logic.
42
+ """
43
+ if model_name is None:
44
+ model_name = self.name
45
+
46
+ def build_reactions_wrapper(Isotopes):
47
+ # The isotopes passed in here by generate_model are essentially self.isotopes
48
+ # because we formatted them earlier. So we can just return our state.
49
+ return self.reactions, self.rules
50
+
51
+ generate_model(build_reactions_wrapper, self.isotopes, calling_file_path, model_name)
framework/rate_laws.py ADDED
@@ -0,0 +1,101 @@
1
+ """
2
+ Rate law semantics and volumetric scaling for PyAntiGen.
3
+
4
+ Each rate type has defined units and explicit volume scaling behavior so that
5
+ generated Antimony has correct [amount/time] ODE terms.
6
+
7
+ Units summary:
8
+ - MA / RMA: User supplies [concentration]^n / time; framework multiplies by V_compartment
9
+ to yield [amount/time]. Single-compartment reactions.
10
+ - UDF / BDF: User supplies [volume/time] (flow rate); framework uses concentration
11
+ of species (no global V multiplier). Trans-compartment transport.
12
+ - custom_conc_per_time: User equation in [concentration]^n/time; framework multiplies
13
+ by V_compartment. Set species_names_are_conc_per_time=True (default).
14
+ - custom_amt_per_time: User equation already in [amount/time]; no volume scaling.
15
+ - custom: Raw expression as-is; user must include volume scaling if needed.
16
+ """
17
+ from abc import ABC, abstractmethod
18
+ from typing import Optional
19
+
20
+
21
+ class RateLaw(ABC):
22
+ """Base for rate law semantics (units and volume scaling)."""
23
+
24
+ @property
25
+ @abstractmethod
26
+ def multiplies_by_volume(self) -> bool:
27
+ """Whether the framework multiplies the rate expression by compartment volume."""
28
+ pass
29
+
30
+ @property
31
+ @abstractmethod
32
+ def user_units_description(self) -> str:
33
+ """Expected units of the user-supplied rate expression."""
34
+ pass
35
+
36
+
37
+ class MassActionLaw(RateLaw):
38
+ """MA / RMA: [concentration]^n / time; framework multiplies by V_compartment."""
39
+ rate_type = "MA"
40
+
41
+ @property
42
+ def multiplies_by_volume(self) -> bool:
43
+ return True
44
+
45
+ @property
46
+ def user_units_description(self) -> str:
47
+ return "[concentration]^n / time → framework multiplies by V_compartment to yield amount/time"
48
+
49
+
50
+ class VolumeTransportLaw(RateLaw):
51
+ """UDF / BDF: [volume/time]; species appear as concentration; no V multiplier on expression."""
52
+ rate_type = "UDF"
53
+
54
+ @property
55
+ def multiplies_by_volume(self) -> bool:
56
+ return False
57
+
58
+ @property
59
+ def user_units_description(self) -> str:
60
+ return "[volume/time]; species in equation are concentrations; no V_compartment multiplier"
61
+
62
+
63
+ class CustomLaw(RateLaw):
64
+ """
65
+ custom_conc_per_time / custom_amt_per_time / custom.
66
+ custom_conc_per_time: equation in conc/time → we multiply by V.
67
+ custom_amt_per_time: equation already in amount/time → no scaling.
68
+ custom: as-is; user must include volume scaling if needed.
69
+ """
70
+ def __init__(
71
+ self,
72
+ rate_type: str = "custom",
73
+ species_names_are_conc_per_time: Optional[bool] = None,
74
+ ):
75
+ self.rate_type = rate_type
76
+ self._mult_vol = (
77
+ species_names_are_conc_per_time
78
+ if species_names_are_conc_per_time is not None
79
+ else (rate_type == "custom_conc_per_time")
80
+ )
81
+
82
+ @property
83
+ def multiplies_by_volume(self) -> bool:
84
+ return self._mult_vol
85
+
86
+ @property
87
+ def user_units_description(self) -> str:
88
+ if self.multiplies_by_volume:
89
+ return "[concentration]^n/time → framework multiplies by V_compartment"
90
+ return "Equation already in [amount/time]; no volume scaling applied"
91
+
92
+
93
+ def get_rate_law_info(rate_type: str) -> str:
94
+ """Return a short docstring for the given rate type (units and scaling)."""
95
+ if rate_type in ("MA", "RMA"):
96
+ return MassActionLaw(rate_type).user_units_description
97
+ if rate_type in ("UDF", "BDF"):
98
+ return VolumeTransportLaw(rate_type).user_units_description
99
+ if rate_type in ("custom_conc_per_time", "custom_amt_per_time", "custom"):
100
+ return CustomLaw(rate_type).user_units_description
101
+ return "Unknown rate type"
@@ -0,0 +1,43 @@
1
+ from framework.models import (
2
+ reaction_from_args,
3
+ VALID_RATE_TYPES,
4
+ RATE_TYPES_TWO_CONSTANTS,
5
+ )
6
+
7
+
8
+ def reaction_creation(
9
+ all_reactions,
10
+ counter,
11
+ Reaction_name,
12
+ Reactants,
13
+ Products,
14
+ Rate_type,
15
+ Rate_eqtn_prototype,
16
+ compartment=None,
17
+ compartment_reverse=None,
18
+ ):
19
+ """
20
+ Create and append a validated reaction to all_reactions.
21
+ Accepts reactants/products as either list of strings or bracket string (e.g. "[A, B]").
22
+ Validates required fields and rate type at call time; raises on invalid input.
23
+ """
24
+ try:
25
+ reaction = reaction_from_args(
26
+ name=Reaction_name,
27
+ reactants=Reactants,
28
+ products=Products,
29
+ rate_type=Rate_type,
30
+ rate_eqtn=Rate_eqtn_prototype,
31
+ compartment=compartment,
32
+ compartment_reverse=compartment_reverse,
33
+ )
34
+ except ValueError as e:
35
+ raise ValueError(f"add_reaction validation failed: {e}") from e
36
+
37
+ if reaction.Rate_type in RATE_TYPES_TWO_CONSTANTS:
38
+ counter += 2
39
+ else:
40
+ counter += 1
41
+
42
+ all_reactions.append(reaction.to_dict())
43
+ return counter, all_reactions
@@ -0,0 +1,23 @@
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ # Use location: import Modules from the same folder as this script (model folder)
5
+ _project_dir = Path(__file__).resolve().parent
6
+ if str(_project_dir) not in sys.path:
7
+ sys.path.insert(0, str(_project_dir))
8
+
9
+ MODEL_NAME = _project_dir.name
10
+
11
+ # Add PyAntiGen root to sys.path if running from within the framework template
12
+ if _project_dir.parent.name == "template":
13
+ _pyantigen_root = _project_dir.parents[2]
14
+ if str(_pyantigen_root) not in sys.path:
15
+ sys.path.insert(0, str(_pyantigen_root))
16
+
17
+ if _project_dir.name == "scripts":
18
+ REPO_ROOT = str(_project_dir.parent)
19
+ else:
20
+ REPO_ROOT = str(_project_dir.parents[1])
21
+
22
+ if REPO_ROOT not in sys.path:
23
+ sys.path.insert(0, REPO_ROOT)
@@ -0,0 +1,193 @@
1
+ """Hessian-derived quantities, cached against the fit they belong to.
2
+
3
+ The Wald statistics cost ``1 + 2k + 2k(k-1)`` objective evaluations -- 513 on
4
+ the 16-parameter SILK APP spec -- and they are recomputed from scratch on every
5
+ launch. That was tolerable when an evaluation was assumed to cost seconds. It
6
+ is not: the measured cost on that spec is 116 s, so across 39 workers the
7
+ Hessian alone is about 25 minutes, and it is charged again on every link of a
8
+ chain that may run to a hundred links.
9
+
10
+ On a preemptible partition the number matters for a second and sharper reason.
11
+ Nothing is written until a profile point finishes, so a link only makes
12
+ progress if the node survives setup *plus* one slice. Cutting 25 minutes off
13
+ setup lowers that threshold directly, which is the difference between a
14
+ short-lived node contributing something and contributing nothing at all.
15
+
16
+ Caching is safe here because the Hessian is a pure function of things the run
17
+ already fingerprints: the model, the optimization spec, the parameter scaling
18
+ and the optimum it is taken at. A change in any of them produces a different
19
+ key and a miss, so a stale Hessian cannot be silently reused -- the failure
20
+ mode that would matter, since an SE that does not belong to this fit would set
21
+ the profile's whole grid in the wrong place.
22
+ """
23
+
24
+ import hashlib
25
+ import json
26
+ import os
27
+ from datetime import datetime
28
+
29
+ import numpy as np
30
+
31
+ from Engine.Profile_checkpoint import sweep_stale_temp_files
32
+
33
+ # Everything _attach_wald_stats puts in out["stats"]. Cached and restored as a
34
+ # set: a partial restore would leave the CI from one fit beside the SE of
35
+ # another.
36
+ WALD_FIELDS = ("wald_cov", "wald_se", "wald_se_opt", "wald_ci",
37
+ "wald_correlation")
38
+
39
+ # Bumped when the set or meaning of the cached fields changes, so old files
40
+ # miss rather than being misread.
41
+ # v1 held only the linear "wald_se".
42
+ # v2 adds "wald_se_opt", the SE in the optimizer's own space, which is what
43
+ # the profile and slice grids are placed with. A v1 file restored into a
44
+ # v2 run would leave that key absent and silently drop every grid back to
45
+ # the range_factor fallback.
46
+ _FORMAT = "wald-v2"
47
+
48
+ # Without this the block is not worth restoring: the profile grid is placed
49
+ # from it, and "no SE at all" triggers a different, deliberate fallback than
50
+ # "an SE in the wrong units".
51
+ _REQUIRED = ("wald_se", "wald_se_opt")
52
+
53
+
54
+ def _encode(obj):
55
+ """Arrays to nested lists, with non-finite values as null.
56
+
57
+ Non-finite entries are meaningful here -- an SE of nan is how "this
58
+ direction is flat, there is no usable standard error" is reported -- but
59
+ they are not portable JSON. They come back as nan, which is what every
60
+ consumer tests for with ``np.isfinite``.
61
+ """
62
+ if obj is None:
63
+ return None
64
+ arr = np.asarray(obj, dtype=float)
65
+ out = arr.tolist()
66
+
67
+ def _clean(v):
68
+ if isinstance(v, list):
69
+ return [_clean(x) for x in v]
70
+ return v if np.isfinite(v) else None
71
+
72
+ return _clean(out)
73
+
74
+
75
+ def _decode(obj):
76
+ """The inverse: nulls back to nan, lists back to arrays."""
77
+ if obj is None:
78
+ return None
79
+
80
+ def _fill(v):
81
+ if isinstance(v, list):
82
+ return [_fill(x) for x in v]
83
+ return float("nan") if v is None else float(v)
84
+
85
+ return np.asarray(_fill(obj), dtype=float)
86
+
87
+
88
+ def bounds_fingerprint(bounds):
89
+ """Hash of the declared bounds.
90
+
91
+ Separate from the profile's own spec hash on purpose. Bounds change the
92
+ Wald *interval* (it is clipped to them) without changing the model or the
93
+ optimum, so they belong in this cache's key -- but adding them to
94
+ ``spec_fingerprint`` would change every existing profile directory name and
95
+ orphan work already done.
96
+ """
97
+ if bounds is None:
98
+ return "none"
99
+ try:
100
+ blob = json.dumps(
101
+ [None if b is None else [None if v is None else round(float(v), 12)
102
+ for v in b]
103
+ for b in bounds],
104
+ sort_keys=True,
105
+ )
106
+ except (TypeError, ValueError):
107
+ return "unhashable"
108
+ return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:16]
109
+
110
+
111
+ class AnchorCache:
112
+ """Reads and writes the Wald block for one fit."""
113
+
114
+ def __init__(self, root, run_id, model_hash, spec_hash, bounds_hash,
115
+ n_params, enabled=True):
116
+ self.enabled = bool(enabled and root)
117
+ self.model_hash = model_hash
118
+ self.spec_hash = spec_hash
119
+ self.bounds_hash = bounds_hash
120
+ self.n_params = int(n_params)
121
+ self.dir = os.path.join(root, "profiles", run_id) if root else None
122
+ if self.enabled and self.dir:
123
+ try:
124
+ os.makedirs(self.dir, exist_ok=True)
125
+ # A kill between writing a temp file and renaming it leaves the
126
+ # temp behind; this directory is where they collect.
127
+ sweep_stale_temp_files(self.dir)
128
+ except OSError:
129
+ self.enabled = False
130
+
131
+ @property
132
+ def path(self):
133
+ return os.path.join(self.dir, "anchor.json") if self.dir else None
134
+
135
+ def load(self):
136
+ """The cached Wald block, or None on any miss.
137
+
138
+ Every failure is a miss rather than an error: a corrupt, truncated or
139
+ stale file must cost the 25 minutes of recomputation it was meant to
140
+ save, never the correctness of the run.
141
+ """
142
+ if not (self.enabled and self.path and os.path.exists(self.path)):
143
+ return None
144
+ try:
145
+ with open(self.path, "r", encoding="utf-8") as fh:
146
+ data = json.load(fh)
147
+ except (OSError, ValueError):
148
+ return None
149
+
150
+ if (data.get("format") != _FORMAT
151
+ or data.get("model_hash") != self.model_hash
152
+ or data.get("spec_hash") != self.spec_hash
153
+ or data.get("bounds_hash") != self.bounds_hash
154
+ or int(data.get("n_params") or -1) != self.n_params):
155
+ return None
156
+
157
+ stats = {}
158
+ for field in WALD_FIELDS:
159
+ if field in data:
160
+ stats[field] = _decode(data[field])
161
+
162
+ for field in _REQUIRED:
163
+ arr = stats.get(field)
164
+ if arr is None or np.asarray(arr).shape != (self.n_params,):
165
+ return None
166
+ return stats
167
+
168
+ def save(self, stats):
169
+ """Write the Wald block, atomically."""
170
+ if not (self.enabled and self.path):
171
+ return
172
+ payload = {
173
+ "format": _FORMAT,
174
+ "model_hash": self.model_hash,
175
+ "spec_hash": self.spec_hash,
176
+ "bounds_hash": self.bounds_hash,
177
+ "n_params": self.n_params,
178
+ "saved": datetime.now().isoformat(timespec="seconds"),
179
+ }
180
+ for field in WALD_FIELDS:
181
+ if stats.get(field) is not None:
182
+ payload[field] = _encode(stats[field])
183
+
184
+ tmp = f"{self.path}.{os.getpid()}.tmp"
185
+ try:
186
+ with open(tmp, "w", encoding="utf-8") as fh:
187
+ json.dump(payload, fh)
188
+ os.replace(tmp, self.path)
189
+ except (OSError, TypeError, ValueError):
190
+ try:
191
+ os.unlink(tmp)
192
+ except OSError:
193
+ pass