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,340 @@
1
+ """
2
+ Generate an Antimony piecewise function with linear or spline interpolation.
3
+
4
+ This script takes vectors of times and values and generates an Antimony
5
+ piecewise function that performs either linear or cubic spline interpolation
6
+ between the points.
7
+ """
8
+
9
+ import numpy as np
10
+ from typing import List, Union
11
+ from scipy.interpolate import CubicSpline, PchipInterpolator
12
+
13
+
14
+ def format_number(num: float, precision: int = 15) -> str:
15
+ """
16
+ Format a number for Antimony output, removing unnecessary trailing zeros.
17
+
18
+ Parameters
19
+ ----------
20
+ num : float
21
+ Number to format
22
+ precision : int
23
+ Maximum number of decimal places (default: 15)
24
+
25
+ Returns
26
+ -------
27
+ str
28
+ Formatted number string
29
+ """
30
+ # Use g format to remove trailing zeros, but limit precision
31
+ formatted = f"{num:.{precision}g}"
32
+ # Remove trailing decimal point if present
33
+ if formatted.endswith('.'):
34
+ formatted = formatted[:-1]
35
+ return formatted
36
+
37
+
38
+ def _generate_linear_piecewise(
39
+ times: Union[List[float], np.ndarray],
40
+ data: Union[List[float], np.ndarray],
41
+ data_name: str = "data",
42
+ time_var: str = "time",
43
+ default_before: Union[float, str] = 0.0,
44
+ default_after: Union[float, str, None] = None
45
+ ) -> str:
46
+ """
47
+ Helper function to generate an Antimony piecewise function with linear interpolation.
48
+ """
49
+ times = np.array(times)
50
+ data = np.array(data)
51
+
52
+ # Validate inputs
53
+ if len(times) != len(data):
54
+ raise ValueError("times and data must have the same length")
55
+ if len(times) < 2:
56
+ raise ValueError("At least 2 points are required for interpolation")
57
+
58
+ # Sort by time to ensure proper ordering
59
+ sort_idx = np.argsort(times)
60
+ times = times[sort_idx]
61
+ data = data[sort_idx]
62
+
63
+ # Set default to the last data value for times after the last point
64
+ if default_after is None:
65
+ default_after = data[-1]
66
+
67
+ # Build piecewise conditions and expressions
68
+ pieces = []
69
+
70
+ # Handle times before first point
71
+ pieces.append(str(default_before) if isinstance(default_before, str) else format_number(default_before))
72
+ pieces.append(f"{time_var} < {format_number(times[0])}")
73
+
74
+ # Generate linear interpolation for each interval
75
+ for i in range(len(times) - 1):
76
+ t_i = times[i]
77
+ t_next = times[i + 1]
78
+ v_i = data[i]
79
+ v_next = data[i + 1]
80
+
81
+ # Skip if times are equal (would cause division by zero)
82
+ if abs(t_next - t_i) < 1e-10:
83
+ continue
84
+
85
+ # Linear interpolation formula: v = v_i + (v_next - v_i) * (t - t_i) / (t_next - t_i)
86
+ # For Antimony, we'll use: (v_i + (v_next - v_i) * (time_var - t_i) / (t_next - t_i))
87
+
88
+ # Build the interpolation expression
89
+ if abs(v_next - v_i) < 1e-10:
90
+ # Constant value (no interpolation needed)
91
+ interp_expr = format_number(v_i)
92
+ else:
93
+ # Linear interpolation: v_i + slope * (time_var - t_i) / dt
94
+ slope = v_next - v_i
95
+ dt = t_next - t_i
96
+ v_i_str = format_number(v_i)
97
+ slope_str = format_number(slope)
98
+ t_i_str = format_number(t_i)
99
+ dt_str = format_number(dt)
100
+ interp_expr = f"({v_i_str} + ({slope_str} * ({time_var} - {t_i_str})) / {dt_str})"
101
+
102
+ pieces.append(interp_expr)
103
+ pieces.append(f"{time_var} < {format_number(t_next)}")
104
+
105
+ # Handle times after last point
106
+ pieces.append(str(default_after) if isinstance(default_after, str) else format_number(default_after))
107
+ pieces.append(f"{time_var} >= {format_number(times[-1])}")
108
+
109
+ # Join pieces into Antimony piecewise function
110
+ piecewise_str = ", ".join(pieces)
111
+ result = f"{data_name} := piecewise({piecewise_str})"
112
+
113
+ return result
114
+
115
+ def _generate_linear_piecewise_symbolic(
116
+ times: List[str],
117
+ data: List[str],
118
+ data_name: str = "data",
119
+ time_var: str = "time",
120
+ default_before: str = "0.0",
121
+ default_after: Union[str, None] = None
122
+ ) -> str:
123
+ """
124
+ Helper function to generate an Antimony piecewise function with linear interpolation
125
+ using symbolic data values and symbolic times (strings).
126
+ """
127
+ # Validate inputs
128
+ if len(times) != len(data):
129
+ raise ValueError("times and data must have the same length")
130
+ if len(times) < 2:
131
+ raise ValueError("At least 2 points are required for interpolation")
132
+
133
+ # Set default to the last data value for times after the last point
134
+ if default_after is None:
135
+ default_after = data[-1]
136
+
137
+ # Build piecewise conditions and expressions
138
+ pieces = []
139
+
140
+ times_str = [str(t) for t in times]
141
+
142
+ # Handle times before first point
143
+ pieces.append(str(default_before))
144
+ pieces.append(f"{time_var} < {times_str[0]}")
145
+
146
+ # Generate linear interpolation for each interval
147
+ for i in range(len(times_str) - 1):
148
+ t_i_str = times_str[i]
149
+ t_next_str = times_str[i + 1]
150
+ v_i_str = str(data[i])
151
+ v_next_str = str(data[i + 1])
152
+
153
+ # Skip if times represent the exact same symbolic name
154
+ if t_next_str == t_i_str:
155
+ continue
156
+
157
+ # Linear interpolation formula
158
+ dt_str = f"({t_next_str} - {t_i_str})"
159
+ interp_expr = f"({v_i_str} + ({v_next_str} - ({v_i_str})) * ({time_var} - {t_i_str}) / {dt_str})"
160
+
161
+ pieces.append(interp_expr)
162
+ pieces.append(f"{time_var} < {t_next_str}")
163
+
164
+ # Handle times after last point
165
+ pieces.append(str(default_after))
166
+ pieces.append(f"{time_var} > {times_str[-1]}")
167
+
168
+ # Join pieces into Antimony piecewise function
169
+ piecewise_str = ", ".join(pieces)
170
+ result = f"{data_name} := piecewise({piecewise_str})"
171
+
172
+ return result
173
+
174
+
175
+ def _generate_spline_piecewise(
176
+ times: Union[List[float], np.ndarray],
177
+ data: Union[List[float], np.ndarray],
178
+ data_name: str = "data",
179
+ time_var: str = "time",
180
+ antimony_continuation: bool = True,
181
+ monotone: bool = False
182
+ ) -> str:
183
+ """
184
+ Helper function to generate an Antimony piecewise function with spline interpolation.
185
+ MIT License
186
+
187
+ Copyright (c) 2026 UW Sauro Lab
188
+
189
+ Permission is hereby granted, free of charge, to any person obtaining a copy
190
+ of this software and associated documentation files (the "Software"), to deal
191
+ in the Software without restriction, including without limitation the rights
192
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
193
+ copies of the Software, and to permit persons to whom the Software is
194
+ furnished to do so, subject to the following conditions:
195
+
196
+ The above copyright notice and this permission notice shall be included in all
197
+ copies or substantial portions of the Software.
198
+
199
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
200
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
201
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
202
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
203
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
204
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
205
+ SOFTWARE.
206
+ """
207
+ x = np.asarray(times, dtype=float)
208
+ y = np.asarray(data, dtype=float)
209
+
210
+ if x.size != y.size:
211
+ raise ValueError("times and data must have the same length.")
212
+ if x.size < 3:
213
+ raise ValueError(
214
+ "At least 3 data points are required to generate a cubic spline."
215
+ )
216
+ if np.any(np.diff(x) <= 0):
217
+ sort_idx = np.argsort(x)
218
+ x = x[sort_idx]
219
+ y = y[sort_idx]
220
+ if np.any(np.diff(x) <= 0):
221
+ raise ValueError("time values must be strictly increasing.")
222
+
223
+ if monotone:
224
+ cs = PchipInterpolator(x, y)
225
+ else:
226
+ cs = CubicSpline(x, y, bc_type="natural")
227
+
228
+ n_segments = len(x) - 1
229
+ sep = ",\\ \n" if antimony_continuation else ",\n"
230
+ segments = []
231
+
232
+ for k in range(n_segments):
233
+ xk = x[k]
234
+ xk1 = x[k + 1]
235
+ c3 = cs.c[0, k] # cubic
236
+ c2 = cs.c[1, k] # quadratic
237
+ c1 = cs.c[2, k] # linear
238
+ c0 = cs.c[3, k] # constant
239
+
240
+ def fmt(v: float) -> str:
241
+ return f"{v:+.6f}" if v < 0 else f"+{v:.6f}"
242
+
243
+ delay = f"({time_var}-{xk:.6f})"
244
+ expr = (
245
+ f"(({c3:.6f}*{delay}"
246
+ f"{fmt(c2)})*{delay}"
247
+ f"{fmt(c1)})*{delay}"
248
+ f"{fmt(c0)}"
249
+ )
250
+ condition = f"({time_var} >={xk:.6f}) && ({time_var} <= {xk1:.6f})"
251
+ segments.append(f"{expr}, {condition}")
252
+
253
+ body = sep.join(segments)
254
+ return f"{data_name} := piecewise ({body})"
255
+
256
+
257
+ def generate_antimony_piecewise(
258
+ times: Union[List[float], np.ndarray, List[str]],
259
+ data: Union[List[float], np.ndarray, List[str]],
260
+ data_name: str = "data",
261
+ time_var: str = "time",
262
+ interpolation_type: str = "linear",
263
+ symbolic: bool = False,
264
+ time_tol: float = 1e-2,
265
+ **kwargs
266
+ ) -> str:
267
+ """
268
+ Generate an Antimony piecewise function with either linear or spline interpolation.
269
+
270
+ Parameters
271
+ ----------
272
+ times : array-like
273
+ Vector of time points (must be sorted in ascending order)
274
+ data : array-like
275
+ Vector of data values corresponding to each time point
276
+ data_name : str
277
+ Name of the data variable in Antimony output (default: "data")
278
+ time_var : str
279
+ Name of the time variable in Antimony output (default: "time")
280
+ interpolation_type : str
281
+ "linear" or "spline" (default: "linear")
282
+ symbolic : bool
283
+ If True, treats time and data as strings for symbolic piecewise definition. (default: False)
284
+ time_tol : float
285
+ Tolerance for combining and averaging repeated time points (default: 1e-2)
286
+ **kwargs
287
+ Additional arguments passed to the specific interpolation function:
288
+ - For linear: default_before (float/str), default_after (float/str or None)
289
+ - For spline: antimony_continuation (bool), monotone (bool)
290
+
291
+ Returns
292
+ -------
293
+ str
294
+ Antimony piecewise function definition
295
+ """
296
+ if not symbolic:
297
+ times = np.asarray(times, dtype=float)
298
+ data = np.asarray(data, dtype=float)
299
+
300
+ # Sort by time to ensure proper ordering
301
+ sort_idx = np.argsort(times)
302
+ times = times[sort_idx]
303
+ data = data[sort_idx]
304
+
305
+ # Average repeated times within tolerance
306
+ unique_times = []
307
+ unique_data = []
308
+
309
+ i = 0
310
+ while i < len(times):
311
+ current_time_sum = times[i]
312
+ current_data_sum = data[i]
313
+ count = 1
314
+
315
+ j = i + 1
316
+ while j < len(times) and (times[j] - times[i]) <= time_tol:
317
+ current_time_sum += times[j]
318
+ current_data_sum += data[j]
319
+ count += 1
320
+ j += 1
321
+
322
+ unique_times.append(current_time_sum / count)
323
+ unique_data.append(current_data_sum / count)
324
+
325
+ i = j
326
+
327
+ times = np.array(unique_times)
328
+ data = np.array(unique_data)
329
+
330
+ if interpolation_type.lower() == "linear":
331
+ valid_kwargs = {k: v for k, v in kwargs.items() if k in ["default_before", "default_after"]}
332
+ if symbolic:
333
+ return _generate_linear_piecewise_symbolic(times, data, data_name, time_var, **valid_kwargs)
334
+ else:
335
+ return _generate_linear_piecewise(times, data, data_name, time_var, **valid_kwargs)
336
+ elif interpolation_type.lower() == "spline":
337
+ valid_kwargs = {k: v for k, v in kwargs.items() if k in ["antimony_continuation", "monotone"]}
338
+ return _generate_spline_piecewise(times, data, data_name, time_var, **valid_kwargs)
339
+ else:
340
+ raise ValueError(f"Unknown interpolation_type: {interpolation_type}. Must be 'linear' or 'spline'.")
@@ -0,0 +1,41 @@
1
+ def ensure_isotopes_format(Isotopes):
2
+ """
3
+ Ensures that Isotopes is a list of strings and contains '' as an entry exactly once.
4
+
5
+ Args:
6
+ Isotopes: Input that should be a list of strings (or convertible to one)
7
+
8
+ Returns:
9
+ list: A list of strings with '' appearing exactly once
10
+
11
+ Examples:
12
+ >>> ensure_isotopes_format(['L', '13C'])
13
+ ['', 'L', '13C']
14
+ >>> ensure_isotopes_format(['', '', 'L'])
15
+ ['', 'L']
16
+ >>> ensure_isotopes_format(['L'])
17
+ ['', 'L']
18
+ """
19
+ # Convert to list if not already
20
+ if not isinstance(Isotopes, list):
21
+ Isotopes = list(Isotopes) if hasattr(Isotopes, '__iter__') else [Isotopes]
22
+
23
+ # Convert all entries to strings
24
+ Isotopes = [str(iso) for iso in Isotopes]
25
+
26
+ # Remove all empty strings
27
+ Isotopes = [iso for iso in Isotopes if iso != '']
28
+
29
+ # Remove duplicates while preserving order
30
+ seen = set()
31
+ Isotopes_unique = []
32
+ for iso in Isotopes:
33
+ if iso not in seen:
34
+ seen.add(iso)
35
+ Isotopes_unique.append(iso)
36
+ Isotopes = Isotopes_unique
37
+
38
+ # Ensure '' appears exactly once at the beginning
39
+ Isotopes.insert(0, '')
40
+
41
+ return Isotopes
@@ -0,0 +1,46 @@
1
+ import os
2
+ import sys
3
+
4
+ from framework.antimony_utils import convert_to_antimony
5
+ from framework.isotopomer_tools import ensure_isotopes_format
6
+
7
+ def generate_model(build_reactions_func, Isotopes, calling_file_path, model_name):
8
+ """
9
+ Generates an Antimony model using the provided reaction building function.
10
+
11
+ Args:
12
+ build_reactions_func (callable): Function that takes Isotopes list and returns (all_reactions, rules).
13
+ Isotopes (list): List of isotopes to include.
14
+ calling_file_path (str): The __file__ path of the calling script, used to determine output filenames.
15
+ model_name (str): The name of the model to generate.
16
+ """
17
+ Isotopes = ensure_isotopes_format(Isotopes)
18
+ all_reactions, rules = build_reactions_func(Isotopes)
19
+
20
+ # Resolve to absolute path so output_dir does not depend on cwd (fixes VSCode "Run Python File" where cwd can be script dir)
21
+ script_dir = os.path.abspath(os.path.dirname(os.path.normpath(calling_file_path)))
22
+ # Project root: parent of "scripts", or two levels up when script is in scripts/Example/
23
+ if os.path.basename(script_dir) == "scripts":
24
+ output_dir = os.path.abspath(os.path.join(script_dir, ".."))
25
+ else:
26
+ output_dir = os.path.abspath(os.path.join(script_dir, "..", ".."))
27
+ generated_dir = os.path.join(output_dir, "generated", model_name)
28
+ os.makedirs(generated_dir, exist_ok=True)
29
+
30
+ # Output rules and reaction_dict using model_name only (one write each)
31
+ rules_path = os.path.join(generated_dir, f'{model_name}_rules.txt')
32
+ with open(rules_path, "w", encoding="utf-8") as f:
33
+ for rule in rules:
34
+ f.write(str(rule) + "\n")
35
+ print("Wrote to", rules_path)
36
+
37
+ model_path = os.path.join(generated_dir, f'{model_name}_reaction_dict.txt')
38
+ with open(model_path, "w", encoding="utf-8") as f:
39
+ for reaction in all_reactions:
40
+ f.write(str(reaction) + "\n")
41
+ print("Wrote to", model_path)
42
+
43
+ # Convert to Antimony format (use absolute paths so outputs go to project dir regardless of cwd)
44
+ model_path_abs = os.path.abspath(model_path)
45
+ rules_path_abs = os.path.abspath(rules_path)
46
+ convert_to_antimony(model_path_abs, model_name, rules_path_abs, output_dir=output_dir)
framework/models.py ADDED
@@ -0,0 +1,189 @@
1
+ """
2
+ Structured models for PyAntiGen reactions and species.
3
+ Replaces raw dict usage with validated dataclasses for clearer types and early validation.
4
+ """
5
+ from dataclasses import dataclass
6
+ from typing import List, Union, Optional
7
+
8
+ # Valid rate types; RMA and BDF require two rate constants in Rate_eqtn_prototype
9
+ VALID_RATE_TYPES = frozenset({
10
+ "RMA", "BDF", "MA", "UDF",
11
+ "custom_conc_per_time", "custom_amt_per_time", "custom"
12
+ })
13
+
14
+ RATE_TYPES_TWO_CONSTANTS = frozenset({"RMA", "BDF"})
15
+
16
+
17
+ def normalize_species_list(value: Union[str, List[str], None]) -> List[str]:
18
+ """
19
+ Normalize reactants/products to a list of species name strings.
20
+ Accepts: None, list of strings, or string (e.g. '[A, B]' or '[A] + [B]').
21
+ """
22
+ if value is None:
23
+ return []
24
+ if isinstance(value, list):
25
+ return [str(s).strip() for s in value if s is not None and str(s).strip()]
26
+ s = str(value).strip()
27
+ if not s or s in ("0", "[0]"):
28
+ return []
29
+ return parse_species_list(s)
30
+
31
+
32
+ def _ensure_string_list(value: Union[str, List[str]]) -> List[str]:
33
+ """Alias for normalize_species_list for internal use."""
34
+ return normalize_species_list(value)
35
+
36
+
37
+ def parse_species_list(s: str) -> List[str]:
38
+ """
39
+ Parse a species list from string, supporting:
40
+ - '[A, B, C]' (comma-separated inside brackets)
41
+ - '[A] + [B]' or '[A]+[B]' (bracket-wrapped terms with +)
42
+ Returns list of stripped species names. Uses regex to extract bracketed or comma-separated tokens.
43
+ """
44
+ import re
45
+ s = s.strip()
46
+ if not s:
47
+ return []
48
+ # Already a single token (no brackets, no comma)
49
+ if not re.search(r'[\[\],+]', s):
50
+ return [s] if s and s != "0" else []
51
+ inner = s.strip()
52
+ # Only remove outer brackets for "[A, B]" style (single list), not "[A] + [B]"
53
+ if inner.startswith('[') and inner.endswith(']') and '+' not in inner[1:-1]:
54
+ inner = inner[1:-1].strip()
55
+ if not inner:
56
+ return []
57
+ # Split by comma only if we're in bracket list form (no + in the middle)
58
+ if '+' not in inner:
59
+ return [x.strip() for x in re.split(r'\s*,\s*', inner) if x.strip()]
60
+ # "[A] + [B]" or "A + B" form: split by + and clean each part
61
+ parts = re.split(r'\s*\+\s*', inner)
62
+ result = []
63
+ for p in parts:
64
+ p = p.strip()
65
+ if p.startswith('[') and p.endswith(']'):
66
+ p = p[1:-1].strip()
67
+ if p and p != "0":
68
+ result.append(p)
69
+ return result
70
+
71
+
72
+ def parse_rate_equation_list(rate_proto: Union[str, List[str]], rate_type: str) -> List[str]:
73
+ """
74
+ Parse Rate_eqtn_prototype into a list of one or two rate expression strings.
75
+ For RMA/BDF we expect two constants; for others one.
76
+ """
77
+ if rate_proto is None:
78
+ return []
79
+ if isinstance(rate_proto, list):
80
+ return [str(x).strip() for x in rate_proto if str(x).strip()]
81
+ s = str(rate_proto).strip()
82
+ if not s:
83
+ return []
84
+ if s.startswith('[') and s.endswith(']'):
85
+ inner = s[1:-1].strip()
86
+ return [x.strip() for x in inner.split(',') if x.strip()]
87
+ return [s]
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class Species:
92
+ """A species with optional explicit compartment (otherwise inferred from name suffix)."""
93
+ name: str
94
+ compartment: Optional[str] = None
95
+
96
+ def resolve_compartment(self) -> str:
97
+ """Compartment to use: explicit if set, else last segment after underscore."""
98
+ if self.compartment is not None:
99
+ return self.compartment
100
+ if '_' in self.name:
101
+ return self.name.split('_')[-1]
102
+ return self.name
103
+
104
+
105
+ @dataclass
106
+ class Reaction:
107
+ """
108
+ A single reaction with validated fields.
109
+ Reactants and Products are stored as lists of species names.
110
+ Rate_eqtn_prototype is one string (MA, UDF, custom) or two (RMA, BDF).
111
+ """
112
+ Reaction_name: str
113
+ Reactants: List[str]
114
+ Products: List[str]
115
+ Rate_type: str
116
+ Rate_eqtn_prototype: Union[str, List[str]]
117
+ # Optional: explicit compartment(s) so framework doesn't infer from name suffix
118
+ compartment: Optional[str] = None
119
+ compartment_reverse: Optional[str] = None # for product side when different
120
+
121
+ def __post_init__(self):
122
+ r = self.Reaction_name.replace(" ", "") if self.Reaction_name else "NA"
123
+ object.__setattr__(self, 'Reaction_name', r)
124
+ if self.Rate_type and self.Rate_type not in VALID_RATE_TYPES:
125
+ raise ValueError(
126
+ f"Invalid Rate_type '{self.Rate_type}'. "
127
+ f"Valid types: {', '.join(sorted(VALID_RATE_TYPES))}"
128
+ )
129
+ rate_list = parse_rate_equation_list(self.Rate_eqtn_prototype, self.Rate_type)
130
+ if self.Rate_type == "RMA" and len(rate_list) < 2:
131
+ raise ValueError(
132
+ f"RMA reaction requires two rate constants in Rate_eqtn_prototype, got: {self.Rate_eqtn_prototype!r}"
133
+ )
134
+ if self.Rate_type == "BDF" and len(rate_list) < 1:
135
+ raise ValueError(
136
+ f"BDF reaction requires at least one rate constant in Rate_eqtn_prototype, got: {self.Rate_eqtn_prototype!r}"
137
+ )
138
+
139
+ def to_dict(self) -> dict:
140
+ """Export as dict for serialization and RxnDict_to_antimony compatibility."""
141
+ # Serialize reactants/products as "[A, B]" for file format compatibility
142
+ def to_bracket(lst):
143
+ if not lst:
144
+ return "[0]"
145
+ return "[" + ", ".join(lst) + "]"
146
+ rate = self.Rate_eqtn_prototype
147
+ if isinstance(rate, list):
148
+ rate = "[" + ", ".join(rate) + "]"
149
+ d = {
150
+ "Reaction_name": self.Reaction_name,
151
+ "Reactants": to_bracket(self.Reactants),
152
+ "Products": to_bracket(self.Products),
153
+ "Rate_type": self.Rate_type,
154
+ "Rate_eqtn_prototype": rate,
155
+ }
156
+ if self.compartment is not None:
157
+ d["compartment"] = self.compartment
158
+ if self.compartment_reverse is not None:
159
+ d["compartment_reverse"] = self.compartment_reverse
160
+ return d
161
+
162
+
163
+ def reaction_from_args(
164
+ name: str,
165
+ reactants: Union[str, List[str]],
166
+ products: Union[str, List[str]],
167
+ rate_type: str,
168
+ rate_eqtn: Union[str, List[str]],
169
+ compartment: Optional[str] = None,
170
+ compartment_reverse: Optional[str] = None,
171
+ ) -> Reaction:
172
+ """Build and validate a Reaction from add_reaction-style arguments."""
173
+ if not name or not name.strip():
174
+ name = "NA"
175
+ if rate_type is None or rate_type == "":
176
+ raise ValueError("Rate_type is required")
177
+ if rate_eqtn is None or (isinstance(rate_eqtn, str) and rate_eqtn.strip() == ""):
178
+ raise ValueError("Rate_eqtn_prototype is required")
179
+ reactants_list = _ensure_string_list(reactants)
180
+ products_list = _ensure_string_list(products)
181
+ return Reaction(
182
+ Reaction_name=name.strip().replace(" ", ""),
183
+ Reactants=reactants_list,
184
+ Products=products_list,
185
+ Rate_type=rate_type.strip(),
186
+ Rate_eqtn_prototype=rate_eqtn,
187
+ compartment=compartment,
188
+ compartment_reverse=compartment_reverse,
189
+ )
@@ -0,0 +1,42 @@
1
+ class PyAntiGenModule:
2
+ """
3
+ Base class for declarative PyAntiGen modules.
4
+ Modules should inherit from this and implement the build() method.
5
+ When instantiated, the module will automatically build and add its reactions
6
+ to the passed PyAntiGen model instance.
7
+ """
8
+ def __init__(self, model, **kwargs):
9
+ self.model = model
10
+ self.config = kwargs
11
+
12
+ # Build reactions automatically on init
13
+ self.build()
14
+
15
+ def build(self):
16
+ """Override this in subclasses to define reactions."""
17
+ raise NotImplementedError("Modules must implement a build() method.")
18
+
19
+ def add_reaction(
20
+ self,
21
+ name,
22
+ reactants,
23
+ products,
24
+ rate_type,
25
+ rate_eqtn,
26
+ compartment=None,
27
+ compartment_reverse=None,
28
+ ):
29
+ """Helper to add reactions directly into the parent model."""
30
+ self.model.add_reaction(
31
+ name,
32
+ reactants,
33
+ products,
34
+ rate_type,
35
+ rate_eqtn,
36
+ compartment=compartment,
37
+ compartment_reverse=compartment_reverse,
38
+ )
39
+
40
+ def add_rule(self, rule):
41
+ """Helper to add rules directly into the parent model."""
42
+ self.model.add_rule(rule)