-
Notifications
You must be signed in to change notification settings - Fork 8
Expand file tree
/
Copy pathbasico.py
More file actions
483 lines (390 loc) · 17.5 KB
/
basico.py
File metadata and controls
483 lines (390 loc) · 17.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
# File: pysces.py
# Project: ThinLayers
# Authors: Frank T. Bergmann (frankbergmann@live.com), Jan Range (jan.range@simtech.uni-stuttgart.de)
# License: BSD-2 clause
# Copyright (c) 2025 Heidelberg University, University of Stuttgart
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import numpy as np
import pandas as pd
import os
from typing import Dict, List, Optional, Tuple
from pyenzyme.thinlayers.base import BaseThinLayer, SimResult, Time, InitCondDict
from pyenzyme.versions import v2
try:
import basico
import COPASI
except ModuleNotFoundError as e:
raise ModuleNotFoundError(
"ThinLayerCopasi is not available. "
"To use it, please install the following dependencies: "
f"{e}"
)
class ThinLayerCopasi(BaseThinLayer):
"""
COPASI implementation of the BaseThinLayer for kinetic modeling.
This class provides integration with the COPASI (COmplex PAthway SImulator)
for simulating and optimizing kinetic models from EnzymeML documents.
Attributes:
model (basico.COPASI.CDataModel): The COPASI model instance.
model_dir (Path | str): Directory for storing model files.
inits (list[InitMap]): Initial conditions for each measurement.
cols (list[str]): Column names for the experimental data.
parameters (list[dict[str, float]]): Optimizable parameters for the model.
"""
model: basico.COPASI.CDataModel
model_dir: Path | str
inits: dict[str, InitMap]
cols: list[str]
parameters: list
nu_enzmldoc: v2.EnzymeMLDocument
def __init__(
self,
enzmldoc: v2.EnzymeMLDocument,
model_dir: Path | str = "./copasi_models",
measurement_ids: Optional[List[str]] = None,
):
"""
Initialize the ThinLayerCopasi instance.
Args:
enzmldoc (v2.EnzymeMLDocument): EnzymeML document containing the model.
model_dir (Path | str): Directory where COPASI model files will be stored.
measurement_ids (Optional[List[str]]): IDs of measurements to include in the analysis.
If None, all measurements will be used.
Examples:
>>> import pyenzyme as pe
>>> import pyenzyme.thinlayers as tls
>>> doc = pe.read_enzymeml("path/to/enzmldoc.json")
>>> tl = tls.ThinLayerCopasi(doc)
"""
# not sure this is needed, everything ought to be supported
self._check_compliance(enzmldoc)
super().__init__(
enzmldoc=enzmldoc,
measurement_ids=measurement_ids,
df_per_measurement=False,
)
if not isinstance(model_dir, Path):
model_dir = Path(model_dir)
if not model_dir.exists():
# Create model directory if it doesn't exist
os.makedirs(model_dir, exist_ok=True)
self.model_dir = model_dir
# load the model into COPASI
self._get_copasi_model(model_dir)
def _check_compliance(self, enzmldoc: v2.EnzymeMLDocument):
"""
Check if the EnzymeML document is compliant with COPASI model.
"""
has_kinetic_laws = any(m.kinetic_law is not None for m in enzmldoc.reactions)
has_odes = any(
m.equation_type == v2.EquationType.ODE for m in enzmldoc.equations
)
if not has_kinetic_laws and not has_odes:
raise ValueError("EnzymeML document must contain kinetic laws or ODEs")
def integrate(
self,
model: v2.EnzymeMLDocument,
initial_conditions: InitCondDict,
t0: float,
t1: float,
nsteps: int = 100,
) -> Tuple[SimResult, Time]:
"""
Integrates the model from t0 to t1 with the given initial conditions.
This method simulates the model dynamics within the specified time range
and returns trajectories for all species.
Args:
model (v2.EnzymeMLDocument): EnzymeML document containing the model.
initial_conditions (InitCondDict): Dictionary mapping species IDs to initial concentrations.
t0 (float): Start time for integration.
t1 (float): End time for integration.
nsteps (int, optional): Number of time points to generate. Defaults to 100.
Returns:
Tuple[SimResult, Time]: A tuple containing:
- Dict mapping species IDs to concentration trajectories.
- List of time points.
Raises:
ValueError: If the provided model is different from the one used for initialization.
Examples:
>>> # Get initial conditions from a measurement
>>> initial_conditions = {
... s.species_id: s.initial for s in measurement.species_data
... if s.initial is not None
... }
>>> # Simulate from time 0 to 10
>>> results, time = tl.integrate(doc, initial_conditions, 0, 10)
"""
if model != self.enzmldoc:
raise ValueError(
"Model must be the same as the one used to initialize the ThinLayerCopasi. Otherwise, rerun the Thin Layer optimization with the new model."
)
# Convert the initial conditions to a InitMap
time = np.linspace(t0, t1, nsteps).tolist()
init_map = InitMap(
time=time,
species=initial_conditions,
)
out, species_order = self._simulate_condition(init_map)
return (
{species: traj.tolist() for species, traj in zip(species_order, out)},
time,
)
def optimize(self, method="Levenberg - Marquardt"):
"""
Optimizes model parameters to fit experimental data.
This method performs parameter estimation to minimize the difference
between simulated and experimental data.
Args:
method (str, optional): Optimization algorithm to use from COPASI. Defaults to "Levenberg - Marquard".
Available methods include:
- 'Levenberg - Marquardt': Levenberg-Marquardt (default)
- 'Hooke & Jeeves': Hooke & Jeeves
- 'Nelder - Mead': Nelder-Mead
- 'Steepest Descent': Steepest Descent
- 'NL2SOL': NL2SOL
- 'Praxis': Praxis
- 'Current Solution Statistics': just return the current solution
- 'Particle Swarm': Particle Swarm Optimization
- 'Evolution Strategy (SRES)': Evolution Strategy with Stochastic Ranking
- 'Genetic Algorithm SR': Genetic Algorithm with Stochastic Ranking
- 'Evolutionary Programming': Evolutionary Programming
- 'Genetic Algorithm': Genetic Algorithm
- 'Scatter Search': Scatter Search
- 'Differential Evolution': Differential Evolution
- 'Simulated Annealing': Simulated Annealing
- 'Random Search': Random Search
Returns:
{}: Result of the optimization.
Examples:
>>> # Optimize model parameters
>>> tl = ThinLayerCopasi(doc, ".")
>>> result = tl.optimize()
>>> print(f"Optimization success: {result.success}")
"""
# Get experimental data from the EnzymeML document
self._get_experimental_data()
# Initialize the model parameters
parameters = self._initialize_parameters()
basico.set_fit_parameters(parameters, model=self.model)
# Perform optimization
basico.run_parameter_estimation(method=method, update_model=True, model=self.model)
return basico.get_fit_statistic(include_parameters=True, model=self.model)
def write(self) -> v2.EnzymeMLDocument:
"""
Creates a new EnzymeML document with optimized parameter values.
This method updates parameter values in a copy of the original EnzymeML document
based on optimization results.
Returns:
v2.EnzymeMLDocument: A new EnzymeML document with optimized parameters.
Raises:
ValueError: If a parameter in the optimization results is not found in the document.
Examples:
>>> # Optimize and save optimized document
>>> tl = ThinLayerCopasi(doc)
>>> tl.optimize()
>>> optimized_doc = tl.write()
>>> pe.write_enzymeml(optimized_doc, "optimized_model.json")
"""
nu_enzmldoc = self.enzmldoc.model_copy(deep=True)
results = basico.get_fit_statistic(include_parameters=True) # type: ignore
# update the parameters in the enzmldoc
for parameter in nu_enzmldoc.parameters:
for fitted_param in results['parameters']:
if fitted_param['name'] == f'Values[{parameter.symbol}].InitialValue':
parameter.value = fitted_param['value']
break
return nu_enzmldoc
# ! Helper methods
def _initialize_parameters(self):
"""
Initializes Parameters instance with model parameters.
Returns:
list[dict[str, float]]: Parameters object ready for optimization.
Raises:
ValueError: If a parameter has neither an initial value nor a value attribute.
"""
# Initialize parameters
parameters = []
# Add global parameters
for param in self.enzmldoc.parameters:
# Build kwargs dictionary with conditional assignments
param_dict = {}
if param.lower_bound is not None:
param_dict["lower"] = param.lower_bound
if param.upper_bound is not None:
param_dict["upper"] = param.upper_bound
# Determine parameter value
if param.value:
param_dict["start"] = param.value
elif param.initial_value:
param_dict["start"] = param.initial_value
else:
raise ValueError(
f"Neither initial_value nor value given for parameter {param.name} in global parameters"
)
param_dict["name"] = 'Values[' + param.symbol + ']'
# add only if lower and upper bound are not np.nan
if "lower" in param_dict and "upper" in param_dict:
if not (np.isnan(param_dict["lower"]) or np.isnan(param_dict["upper"])):
parameters.append(param_dict)
return parameters
def _get_experimental_data(self):
"""
Extracts measurement data from the EnzymeML document.
Populates the inits, experimental_data, and cols attributes.
"""
self.inits = {
measurement.id: InitMap.from_measurement(measurement, self.df_map[measurement.id])
for measurement in self.enzmldoc.measurements
if measurement.id in self.measurement_ids
}
# get all species
species_df = basico.get_species(model=self.model).reset_index()
# construct sbml_id to name mapping dictionary
sbml_id_to_name = {row['sbml_id']: row['name'] for _, row in species_df.iterrows()}
# loop over 'id' colum of self.df and create a new experiment for each id
for id in self.df['id'].unique():
# get the dataframe for the id
df = self.df[self.df['id'] == id]
# drop id column
df = df.drop(columns=["id"])
# initializations
inits = self.inits[id]
# loop over remaining columns
for col in df.columns:
# if col is 'time' rename to Time and continue
if col == 'time':
df = df.rename(columns={"time": "Time"})
continue
# otherwise find in species_df and rename to '[col]'
if col in species_df['name'].values:
df = df.rename(columns={col: f"[{col}]"})
continue
if col in sbml_id_to_name.keys():
# locate the name corresponding to the sbml_id
species_name = sbml_id_to_name[col]
df = df.rename(columns={col: f"[{species_name}]"})
continue
# otherwise raise error
else:
raise ValueError(f"Column {col} not found in species_df")
# finally add all the initial concentrations to the dataframe with the
# initial value at Time == 0
for species in inits.species.keys():
if species in sbml_id_to_name.keys():
df.loc[df.index[0], f"[{sbml_id_to_name[species]}]_0"] = inits.species[species]
else:
df.loc[df.index[0], f"[{species}]_0"] = inits.species[species]
# now add as experiment to basico
basico.add_experiment(name=id, data=df, data_dir=self.model_dir)
def _get_copasi_model(self, model_dir: Path | str):
"""
Converts an EnzymeML document to a COPASI model.
Args:
model_dir (Path | str): Directory for storing model files.
"""
model_dir = self._prepare_model_directory(model_dir)
sbmlfile_name = self._create_sbml_file(model_dir)
self._load_copasi_model(sbmlfile_name, model_dir)
# TODO: check whether this is really needed!!!
#self._fix_compartment_sizes()
def _load_copasi_model(self, sbmlfile_name: str, model_dir: Path | str):
"""
Loads a COPASI model from an SBML file.
"""
self.model = basico.load_model(os.path.join(model_dir, sbmlfile_name))
def _prepare_model_directory(self, model_dir: Path | str) -> str:
"""
Ensures the model directory exists and returns it as a string.
Args:
model_dir (Path | str): Directory path for model files.
Returns:
str: Path to the model directory as a string.
"""
model_dir = str(model_dir)
os.makedirs(model_dir, exist_ok=True)
return model_dir
def _create_sbml_file(self, model_dir: str) -> str:
"""
Creates an SBML file from the EnzymeML document.
Args:
model_dir (str): Directory to save the SBML file.
Returns:
str: Name of the created SBML file.
"""
sbmlfile_name = f"{self.enzmldoc.name.replace(' ', '_')}.xml"
sbmlfile_path = os.path.join(model_dir, sbmlfile_name)
with open(sbmlfile_path, "w") as file:
file.write(self.sbml_xml)
return sbmlfile_name
def _simulate_condition(
self, init_concs: InitMap
) -> Tuple[List[np.ndarray], List[str]]:
"""
Simulates a single experimental condition.
Args:
init_concs (InitMap): Initial concentrations for the simulation.
Returns:
Tuple[List[np.ndarray], List[str]]:
- List of arrays containing trajectory data for each species
- List of species IDs
"""
# apply initial conditions to model
init_concs.to_copasi_model(self.model)
result = basico.run_time_course_with_output(output_selection=[f'[{species}]' for species in init_concs.species.keys()], values=init_concs.time, model=self.model)
# result is a pandas dataframe with the columns as the species and the rows as the time points
# enzymeml needs it separated by columns and converted to np.ndarray
# build a tuple of the result and the species
data = []
ids = []
for species in init_concs.species.keys():
data.append(result[f'[{species}]'].values)
ids.append(species)
return (data, ids)
@dataclass
class InitMap:
"""
Helper class for managing species initial concentrations.
This class provides type-safe handling of initial concentrations and
time points for COPASI model simulations.
Attributes:
time (List[float]): List of time points for simulation.
species (Dict[str, float]): Dictionary mapping species IDs to initial concentrations.
"""
time: List[float]
species: Dict[str, float]
@classmethod
def from_measurement(cls, meas: v2.Measurement, df: pd.DataFrame) -> "InitMap":
"""
Create an InitMap instance from a measurement and its dataframe.
Args:
meas (v2.Measurement): The measurement containing species data.
df (pd.DataFrame): DataFrame with time data for the measurement.
Returns:
InitMap: Initialized instance with time points and species initial values.
"""
return cls(
time=df["time"].tolist(),
species={
s.species_id: s.initial
for s in meas.species_data
if s.initial is not None
},
)
def to_copasi_model(self, model: COPASI.CDataModel):
"""
Apply initial conditions to a COPASI model.
This method sets the simulation time and initial species concentrations
in the COPASI model, ensuring zero values are replaced with small positive values.
Args:
model (COPASI.CDataModel): The COPASI model to update.
Returns:
COPASI.CDataModel: The updated model with initial conditions set.
"""
for species_id, value in self.species.items():
# seems odd to be doing this here, but it is the same for the PySCeS thin layer
if value == 0:
value = 1.0e-9 # is this really needed?
basico.set_species(species_id, exact=True, initial_concentration=value, model=model)