PyAntiGen 1.0.9__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- framework/AntimonyGen.py +48 -0
- framework/RxnDict_to_antimony.py +594 -0
- framework/TelluriumGen.py +16 -0
- framework/__init__.py +0 -0
- framework/antimony_utils.py +294 -0
- framework/cli.py +229 -0
- framework/data_interpolation.py +340 -0
- framework/isotopomer_tools.py +41 -0
- framework/model_generation.py +46 -0
- framework/models.py +189 -0
- framework/module_base.py +42 -0
- framework/pyantigen.py +51 -0
- framework/rate_laws.py +101 -0
- framework/reaction_creation.py +43 -0
- framework/template/Example/AntiGen_paths.py +23 -0
- framework/template/Example/Engine/Anchor_cache.py +193 -0
- framework/template/Example/Engine/Deadline.py +535 -0
- framework/template/Example/Engine/Evaluator.py +1176 -0
- framework/template/Example/Engine/Event_times.py +491 -0
- framework/template/Example/Engine/Fast_profile.py +701 -0
- framework/template/Example/Engine/Fit_cache.py +329 -0
- framework/template/Example/Engine/Identifiability.py +698 -0
- framework/template/Example/Engine/Model_optimize.py +1483 -0
- framework/template/Example/Engine/Model_simulate.py +124 -0
- framework/template/Example/Engine/Nuisance_sensitivity.py +298 -0
- framework/template/Example/Engine/Optimize.py +6862 -0
- framework/template/Example/Engine/Petab_export.py +398 -0
- framework/template/Example/Engine/Preequil_cache.py +361 -0
- framework/template/Example/Engine/Profile_checkpoint.py +399 -0
- framework/template/Example/Engine/Results.py +395 -0
- framework/template/Example/Engine/Sensitivity_analysis.py +320 -0
- framework/template/Example/Engine/Simulate.py +617 -0
- framework/template/Example/Flipflop_reference.py +401 -0
- framework/template/Example/Model_generate.py +37 -0
- framework/template/Example/Model_run.py +261 -0
- framework/template/Example/Modules/Data.py +63 -0
- framework/template/Example/Modules/Events.py +14 -0
- framework/template/Example/Modules/Experiment.py +194 -0
- framework/template/Example/Modules/Loss_config.py +61 -0
- framework/template/Example/Modules/Observed_species.py +3 -0
- framework/template/Example/Modules/Optimizer_settings.py +258 -0
- framework/template/Example/Modules/Plots.py +89 -0
- framework/template/Example/Modules/Solver_settings.py +16 -0
- framework/template/Example/Modules/Update_opt_parameters.py +24 -0
- framework/template/Example/Modules/Update_parameters.py +49 -0
- framework/template/data/ADneg.csv +27 -0
- framework/template/data/ADpos.csv +27 -0
- framework/template/data/Flipflop.csv +29 -0
- framework/template/data/make_flipflop_data.py +174 -0
- pyantigen-1.0.9.dist-info/METADATA +129 -0
- pyantigen-1.0.9.dist-info/RECORD +55 -0
- pyantigen-1.0.9.dist-info/WHEEL +5 -0
- pyantigen-1.0.9.dist-info/entry_points.txt +2 -0
- pyantigen-1.0.9.dist-info/licenses/LICENSE +21 -0
- pyantigen-1.0.9.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|
+
)
|
framework/module_base.py
ADDED
|
@@ -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)
|