Skip to content

Commit c9e7b00

Browse files
Fixes to Timecourse
1 parent ff8b23e commit c9e7b00

3 files changed

Lines changed: 180 additions & 41 deletions

File tree

scripts/make_biomodels_timecourse.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ def main(
1919
first_model_num: int = 0,
2020
last_model_num: int = int(1e9),
2121
excluded_models: List[str] = EXCLUDED_MODELS,
22+
is_initialize: bool = True, # Ignore existing serialized Timecourse when initializing (for testing).
2223
) -> None:
2324
'''Serialize timecourses for all BioModels.
2425
@@ -41,16 +42,21 @@ def main(
4142
last_model_num=last_model_num):
4243
model_name = item.model_name
4344
if not item.sbml_paths:
45+
print(f"Skipping {model_name} (no SBML files)")
4446
continue
4547
pkl_path = os.path.join(cn.TIMECOURSE_SERIALIZATION_DIR,
4648
f"{model_name}_timecourse.pkl")
47-
if os.path.isfile(pkl_path):
49+
if os.path.isfile(pkl_path) and (not is_initialize):
50+
print(f"Skipping {model_name} (already serialized)")
4851
continue
4952
try:
5053
model = Model.makeBiomodel(model_name)
5154
timecourse = Timecourse(model=model, end_time=item.end_time)
5255
_ = timecourse.jacobian_collection_arr # Force calculations
5356
path = timecourse.serialize()
57+
serialized_timecourse = Timecourse.deserialize(path=path)
58+
if not serialized_timecourse == timecourse:
59+
raise ValueError(f"Deserialized timecourse does not match original for {model_name}.")
5460
except Exception as e:
5561
print(f"Error processing {model_name}: {e}")
5662

src/timecourse.py

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ def __init__(self, model: Model,
2626
end_time: Optional[float] = None,
2727
num_point: int = cn.NUM_POINTS,
2828
timecourse_df: pd.DataFrame = pd.DataFrame(),
29-
jacobian_collection_arr: np.ndarray = np.array([])
29+
jacobian_collection_arr: np.ndarray = np.array([]),
3030
) -> None:
3131
"""
3232
Parameters
@@ -39,6 +39,10 @@ def __init__(self, model: Model,
3939
Time to end the simulation.
4040
num_points : int
4141
Number of time points to simulate.
42+
timecourse_df : pd.DataFrame
43+
Optional pre-computed timecourse DataFrame (index: time, columns: species).
44+
jacobian_collection_arr : np.ndarray
45+
Optional pre-computed Jacobian collection (shape: [num_time_points, num_species, num_species]).
4246
"""
4347
self.model = model
4448
self.start_time = start_time
@@ -48,6 +52,20 @@ def __init__(self, model: Model,
4852
self._timecourse_df = timecourse_df
4953
self._jacobian_collection_arr = jacobian_collection_arr
5054

55+
def __eq__(self, other: object) -> bool:
56+
if not isinstance(other, Timecourse):
57+
return NotImplemented
58+
return (self.model == other.model and
59+
bool(np.isclose(self.start_time, other.start_time)) and
60+
(self.end_time == other.end_time
61+
if self.end_time is None or other.end_time is None
62+
else bool(np.isclose(self.end_time, other.end_time))) and
63+
self.num_point == other.num_point and
64+
bool(np.allclose(self.timecourse_df.values,
65+
other.timecourse_df.values)) and
66+
bool(np.allclose(self.jacobian_collection_arr,
67+
other.jacobian_collection_arr)))
68+
5169
def _updateEndtime(self, end_time: Optional[float]=None)->float | None:
5270
"""Determine the end time and its source."""
5371
if end_time is not None:
@@ -83,6 +101,14 @@ def jacobian_collection_arr(self) -> np.ndarray:
83101
self._jacobian_collection_arr = simulation_result.jacobian_collection_arr
84102
self._timecourse_df = simulation_result.timecourse_df
85103
return self._jacobian_collection_arr
104+
105+
def _checkSpeciesNames(self, names: List[str]) -> None:
106+
"""Check that the species names in the simulation result match the model."""
107+
result_species = list(names)
108+
if result_species != self.model.species_names:
109+
raise ValueError(
110+
f"Simulation species {result_species} do not match "
111+
f"model species {self.model.species_names}.")
86112

87113
def _simulate(self, is_jacobian_collection: bool = False) -> SimulationResult:
88114
"""Create a Trajectory by running a simulation.
@@ -112,12 +138,18 @@ def _simulate(self, is_jacobian_collection: bool = False) -> SimulationResult:
112138
if self.start_time > 0:
113139
rr.simulate(0, self.start_time, 2)
114140
try:
115-
result_arr = np.array(rr.simulate(self.start_time,
116-
self.end_time, self.num_point))
141+
rr_result = rr.simulate(self.start_time, self.end_time, self.num_point)
117142
except Exception as e:
118143
raise ValueError(f"Simulation failed: {e}")
144+
# Check column order before converting to ndarray (colnames lost after np.array).
145+
# Skip the leading 'time' column and strip brackets from species names.
146+
result_species = [
147+
c[1:-1] if c.startswith("[") and c.endswith("]") else c
148+
for c in rr_result.colnames[1:] # type: ignore
149+
]
150+
self._checkSpeciesNames(result_species)
151+
result_arr = np.array(rr_result)
119152
timepoint_arr = result_arr[:, 0]
120-
# FIXME: Use species names in NamedArray and sort by model.species_names --- IGNORE ---
121153
timecourse_df = pd.DataFrame(
122154
result_arr[:, 1:],
123155
index=timepoint_arr,
@@ -136,7 +168,10 @@ def _simulate(self, is_jacobian_collection: bool = False) -> SimulationResult:
136168
rr.simulate(self.start_time, self.start_time + 1e-10, 2)
137169
else:
138170
rr.simulate(timepoint_arr[i - 1], t, 2)
139-
jacobian_arr = np.array(rr.getFullJacobian()).copy()
171+
jacobian_arr = rr.getFullJacobian()
172+
self._checkSpeciesNames(jacobian_arr.rownames)
173+
self._checkSpeciesNames(jacobian_arr.colnames)
174+
jacobian_arr = np.array(jacobian_arr).copy()
140175
if np.all(np.isclose(jacobian_arr, 0.0)):
141176
raise ValueError(
142177
f"Jacobian at t={t} is all zeros; model may be degenerate.")
@@ -158,8 +193,7 @@ def serialize(self) -> str:
158193
"""
159194
if not self.model.model_name:
160195
raise ValueError("Model must have a name to serialize Timecourse.")
161-
path = os.path.join(cn.TIMECOURSE_SERIALIZATION_DIR,
162-
f"{self.model.model_name}_timecourse.pkl")
196+
path = self.makeBiomodelSerializePath(self.model.model_name)
163197
dct = {
164198
"model": self.model,
165199
"start_time": self.start_time,
@@ -171,17 +205,38 @@ def serialize(self) -> str:
171205
pickle.dump(dct, f)
172206
return path
173207

208+
@staticmethod
209+
def makeBiomodelSerializePath(model_name: str) -> str:
210+
"""
211+
Get the expected path for a serialized Timecourse of a BioModel.
212+
213+
Parameters:
214+
model_name (str): The name of the BioModel.
215+
"""
216+
return os.path.join(cn.TIMECOURSE_SERIALIZATION_DIR, f"{model_name}_timecourse.pkl")
217+
174218
@classmethod
175-
def deserialize(cls, path: str) -> 'Timecourse':
219+
def deserialize(cls, path: str = "", model_name: str = "") -> 'Timecourse':
176220
"""
177221
Deserialize a Timecourse from a file
222+
At least one of `path` or `model_name` must be provided.
223+
If both are provided, `path` takes precedence.
178224
179225
Parameters:
180226
path (str): The path to the serialized file.
227+
model_name (str): The name of the BioModel (used if path is not specified).
181228
182229
Returns:
183230
Timecourse: The deserialized Timecourse object.
184231
"""
232+
if not path and not model_name:
233+
raise ValueError("At least one of `path` or `model_name` must be provided.")
234+
if not path:
235+
path = cls.makeBiomodelSerializePath(model_name)
236+
# Check if the file exists
237+
if not os.path.isfile(path):
238+
raise FileNotFoundError(f"No serialized Timecourse found at {path}")
239+
# Deserialize
185240
with open(path, 'rb') as f:
186241
dct = pickle.load(f)
187242
return cls(

tests/test_timecourse.py

Lines changed: 110 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,46 @@ def test_jacobian_collection_arr_stored(self) -> None:
8080
self.assertGreater(tc._jacobian_collection_arr.size, 0)
8181

8282

83+
class TestTimecourseJacobianCollectionArr(unittest.TestCase):
84+
"""Tests for Timecourse.jacobian_collection_arr property."""
85+
86+
def test_returns_ndarray(self) -> None:
87+
if IGNORE_TESTS:
88+
return
89+
tc = _makeTimecourse()
90+
self.assertIsInstance(tc.jacobian_collection_arr, np.ndarray)
91+
92+
def test_shape(self) -> None:
93+
if IGNORE_TESTS:
94+
return
95+
tc = _makeTimecourse()
96+
self.assertEqual(tc.jacobian_collection_arr.shape,
97+
(NUM_POINT, NUM_SPECIES, NUM_SPECIES))
98+
99+
def test_returns_prepopulated_array(self) -> None:
100+
if IGNORE_TESTS:
101+
return
102+
tc = _makeTimecourse()
103+
np.testing.assert_array_equal(
104+
tc.jacobian_collection_arr, tc._jacobian_collection_arr)
105+
106+
def test_cached_on_second_access(self) -> None:
107+
if IGNORE_TESTS:
108+
return
109+
tc = _makeTimecourse()
110+
first = tc.jacobian_collection_arr
111+
second = tc.jacobian_collection_arr
112+
self.assertIs(first, second)
113+
114+
def test_simulation_populates_timecourse_df(self) -> None:
115+
if IGNORE_TESTS:
116+
return
117+
tc = Timecourse(model=_makeModel(), end_time=10.0)
118+
self.assertTrue(tc._timecourse_df.empty)
119+
_ = tc.jacobian_collection_arr
120+
self.assertFalse(tc._timecourse_df.empty)
121+
122+
83123
class TestTimecourseSerialize(unittest.TestCase):
84124
"""Tests for Timecourse.serialize."""
85125

@@ -121,6 +161,71 @@ def test_raises_without_model_name(self) -> None:
121161
tc.serialize()
122162

123163

164+
class TestTimecourseEq(unittest.TestCase):
165+
"""Tests for Timecourse.__eq__."""
166+
167+
def test_equal_timecourses(self) -> None:
168+
if IGNORE_TESTS:
169+
return
170+
tc1 = _makeTimecourse()
171+
tc2 = _makeTimecourse()
172+
self.assertEqual(tc1, tc2)
173+
174+
def test_different_model_not_equal(self) -> None:
175+
if IGNORE_TESTS:
176+
return
177+
tc1 = _makeTimecourse()
178+
tc2 = _makeTimecourse()
179+
tc2.model = Model(ANTIMONY_MODEL, model_name="other")
180+
self.assertNotEqual(tc1, tc2)
181+
182+
def test_different_start_time_not_equal(self) -> None:
183+
if IGNORE_TESTS:
184+
return
185+
tc1 = _makeTimecourse()
186+
tc2 = _makeTimecourse()
187+
tc2.start_time = 99.0
188+
self.assertNotEqual(tc1, tc2)
189+
190+
def test_different_end_time_not_equal(self) -> None:
191+
if IGNORE_TESTS:
192+
return
193+
tc1 = _makeTimecourse()
194+
tc2 = _makeTimecourse()
195+
tc2.end_time = 99.0
196+
self.assertNotEqual(tc1, tc2)
197+
198+
def test_different_num_point_not_equal(self) -> None:
199+
if IGNORE_TESTS:
200+
return
201+
tc1 = _makeTimecourse()
202+
tc2 = _makeTimecourse()
203+
tc2.num_point = 999
204+
self.assertNotEqual(tc1, tc2)
205+
206+
def test_different_timecourse_df_not_equal(self) -> None:
207+
if IGNORE_TESTS:
208+
return
209+
tc1 = _makeTimecourse()
210+
tc2 = _makeTimecourse()
211+
tc2._timecourse_df = tc2._timecourse_df * 2
212+
self.assertNotEqual(tc1, tc2)
213+
214+
def test_different_jacobian_not_equal(self) -> None:
215+
if IGNORE_TESTS:
216+
return
217+
tc1 = _makeTimecourse()
218+
tc2 = _makeTimecourse()
219+
tc2._jacobian_collection_arr = tc2._jacobian_collection_arr * 2
220+
self.assertNotEqual(tc1, tc2)
221+
222+
def test_not_equal_to_non_timecourse(self) -> None:
223+
if IGNORE_TESTS:
224+
return
225+
tc = _makeTimecourse()
226+
self.assertIs(tc.__eq__("not a timecourse"), NotImplemented)
227+
228+
124229
class TestTimecourseRoundtrip(unittest.TestCase):
125230
"""Roundtrip tests for Timecourse.serialize / Timecourse.deserialize."""
126231

@@ -138,44 +243,17 @@ def _serializeAndDeserialize(self) -> tuple:
138243
restored = Timecourse.deserialize(path)
139244
return original, restored
140245

141-
def test_timecourse_df_roundtrip(self) -> None:
142-
if IGNORE_TESTS:
143-
return
144-
original, restored = self._serializeAndDeserialize()
145-
pd.testing.assert_frame_equal(
146-
original._timecourse_df, restored._timecourse_df)
147-
148-
def test_jacobian_collection_arr_roundtrip(self) -> None:
246+
def test_roundtrip_equal(self) -> None:
149247
if IGNORE_TESTS:
150248
return
151249
original, restored = self._serializeAndDeserialize()
152-
np.testing.assert_array_equal(
153-
original._jacobian_collection_arr,
154-
restored._jacobian_collection_arr)
250+
self.assertEqual(original, restored)
155251

156-
def test_start_time_roundtrip(self) -> None:
252+
def test_deserialize_raises_with_empty_path(self) -> None:
157253
if IGNORE_TESTS:
158254
return
159-
original, restored = self._serializeAndDeserialize()
160-
self.assertEqual(original.start_time, restored.start_time)
161-
162-
def test_end_time_roundtrip(self) -> None:
163-
if IGNORE_TESTS:
164-
return
165-
original, restored = self._serializeAndDeserialize()
166-
self.assertEqual(original.end_time, restored.end_time)
167-
168-
def test_num_points_roundtrip(self) -> None:
169-
if IGNORE_TESTS:
170-
return
171-
original, restored = self._serializeAndDeserialize()
172-
self.assertEqual(original.num_point, restored.num_point)
173-
174-
def test_model_name_roundtrip(self) -> None:
175-
if IGNORE_TESTS:
176-
return
177-
original, restored = self._serializeAndDeserialize()
178-
self.assertEqual(original.model.model_name, restored.model.model_name)
255+
with self.assertRaises(Exception):
256+
Timecourse.deserialize("")
179257

180258

181259
if __name__ == "__main__":

0 commit comments

Comments
 (0)