Benchtop 0.1.0__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.
benchtop/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Benchtop: parallel in-silico experiments for biological models."""
2
+
3
+ from .experiment import Experiment
4
+
5
+ __version__ = "0.1.0"
6
+ __all__ = ["Experiment"]
@@ -0,0 +1,24 @@
1
+ """Abstract base class for simulator wrappers (Tellurium, AMICI, etc.)."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from types import ModuleType
5
+
6
+
7
+ class AbstractSimulator(ABC):
8
+ """Interface: load model, modify state, simulate over a time grid."""
9
+
10
+ def __init__(self, *args, **kwargs):
11
+ self.tool = type("Tool", (), {})()
12
+ self.load(*args, **kwargs)
13
+
14
+ @abstractmethod
15
+ def load(self, *args, **kwargs) -> ModuleType:
16
+ """Load or compile the model from constructor arguments."""
17
+
18
+ @abstractmethod
19
+ def modify(self, component: str, value: float) -> None:
20
+ """Set a parameter or species value before simulation."""
21
+
22
+ @abstractmethod
23
+ def simulate(self, start: float, stop: float, step: float):
24
+ """Integrate from start to stop with given step; return trajectory."""
@@ -0,0 +1,190 @@
1
+ """Evaluate PEtab observable formulas and align simulation with measurements."""
2
+
3
+ import math
4
+ import re
5
+ import gc
6
+ from typing import List
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+
11
+
12
+ class ObservableCalculator:
13
+ """Downsample trajectories, evaluate formulas, pair with experimental data."""
14
+
15
+ def __init__(self, parent):
16
+ self.parent = parent
17
+ self.problem_name = parent.record.current_problem_name
18
+ self.results_dict = {
19
+ key: entry
20
+ for key, entry in parent.record.cache.results_dict.items()
21
+ if key != parent.record.cache.PROBLEMS_META_KEY
22
+ and entry.get("problem") == self.problem_name
23
+ }
24
+ self.cache = parent.record.cache
25
+ self.observable_df = parent.record.problem.observable_files[0]
26
+ self.measurement_df = parent.record.problem.measurement_files[0]
27
+ self.data_groups = self._group_conditions_and_observables()
28
+ self.observable_results = self._build_observable_results_dict()
29
+
30
+ def _group_conditions_and_observables(self) -> pd.core.groupby.generic.DataFrameGroupBy:
31
+ if self.measurement_df.empty or self.observable_df.empty:
32
+ raise ValueError("PEtab DataFrame is empty; cannot group.")
33
+
34
+ return self.measurement_df.groupby(["simulationConditionId", "observableId"])
35
+
36
+ def _build_observable_results_dict(self) -> dict:
37
+ return {
38
+ entry: {
39
+ "problem": self.problem_name,
40
+ "conditionId": self.results_dict[entry]["conditionId"],
41
+ "cell": self.results_dict[entry]["cell"],
42
+ }
43
+ for entry in self.results_dict
44
+ }
45
+
46
+ def run(self) -> dict:
47
+ """Compute observables for every cached simulation entry in the current problem."""
48
+ for entry in self.results_dict:
49
+ condition_id = self.results_dict[entry]["conditionId"]
50
+ matched_formulas = self._get_entry_formulas(condition_id)
51
+ needed_columns = self._columns_for_formulas(matched_formulas)
52
+
53
+ dataset = self.cache.load_columns(entry, needed_columns)
54
+
55
+ for observable_key, formula in matched_formulas.items():
56
+ self.observable_results[entry][observable_key] = {}
57
+ group = self.data_groups.get_group((condition_id, observable_key))
58
+
59
+ self.observable_results[entry][observable_key]["experiment"] = (
60
+ self._get_experimental_data(group)
61
+ )
62
+ self.observable_results[entry][observable_key]["simulation"] = (
63
+ self._calculate_formula(dataset, formula, group)
64
+ )
65
+ self.observable_results[entry][observable_key]["time"] = (
66
+ self._downsample_timepoints(dataset, group)
67
+ )
68
+
69
+ # Dump unnecessary data after calculate
70
+ del dataset
71
+ gc.collect()
72
+
73
+ return self.observable_results
74
+
75
+ def _columns_for_formulas(self, matched_formulas: dict) -> List[str]:
76
+ species: Set[str] = set()
77
+ for formula in matched_formulas.values():
78
+ null_like = {"", None, 0, "0", float("nan"), np.nan, "nan"}
79
+ if formula in null_like or (
80
+ isinstance(formula, float) and math.isnan(formula)
81
+ ):
82
+ continue
83
+ species.update(self._get_valid_species(formula))
84
+
85
+ return sorted(species | {"time"})
86
+
87
+ def _get_entry_formulas(self, condition_id: str) -> dict:
88
+ matched_obs_ids = self._get_condition_observables(condition_id)
89
+ return {
90
+ obs_id: self.observable_df["observableFormula"][
91
+ self.observable_df["observableId"] == obs_id
92
+ ].iloc[0]
93
+ for obs_id in matched_obs_ids
94
+ }
95
+
96
+ def _get_condition_observables(self, condition_id: str) -> list:
97
+ return [
98
+ obs
99
+ for (cond, obs) in self.data_groups.groups
100
+ if cond == condition_id
101
+ ]
102
+
103
+ def _get_experimental_data(self, group) -> np.ndarray:
104
+ return np.array(group["measurement"])
105
+
106
+ def _calculate_formula(self, dataset: pd.DataFrame, formula: str, group) -> np.ndarray:
107
+ """Substitute species arrays into formula and eval; downsample to exp times."""
108
+ null_like = {"", None, 0, "0", float("nan"), np.nan, "nan"}
109
+ if formula in null_like or (isinstance(formula, float) and math.isnan(formula)):
110
+ return None
111
+
112
+ species_names = self._get_valid_species(formula)
113
+ namespace = self._formula_namespace(species_names, dataset)
114
+
115
+ try:
116
+ formula_answer = eval(formula, {"np": np}, namespace)
117
+ except Exception:
118
+ raise RuntimeError(f"Failed to evaluate observable formula: {formula}")
119
+
120
+ return self._downsample_results(formula_answer, dataset, group)
121
+
122
+ @staticmethod
123
+ def _get_valid_species(formula: str) -> List[str]:
124
+ """Extract PEtab-compliant species identifiers from a formula string."""
125
+ if not isinstance(formula, str):
126
+ raise TypeError("Observable formula must be a string.")
127
+
128
+ valid_species = re.findall(
129
+ r"(?:@[A-Za-z_]+::[A-Za-z_]\w*|[A-Za-z_]\w*)(?:\(\))?",
130
+ formula,
131
+ )
132
+ if not valid_species:
133
+ raise ValueError("No valid species found in the observable formula.")
134
+
135
+ return valid_species
136
+
137
+ def _formula_namespace(self, species_names: list, dataset: pd.DataFrame) -> dict:
138
+ """Map species names to numpy arrays for safe eval."""
139
+ return {
140
+ name: np.asarray(self._safe_retrieve_array(dataset, name))
141
+ for name in species_names
142
+ }
143
+
144
+ @staticmethod
145
+ def _safe_retrieve_array(dataset, species_name) -> np.ndarray:
146
+ species_arr = dataset[species_name]
147
+ if species_arr is None:
148
+ raise KeyError(f"Species '{species_name}' not found in dataset.")
149
+
150
+ if isinstance(species_arr, (pd.Series, pd.DataFrame)):
151
+ species_arr = species_arr.to_numpy().ravel()
152
+ if not isinstance(species_arr, np.ndarray):
153
+ raise ValueError(
154
+ f"Replacement value for species '{species_name}' is not a valid array."
155
+ )
156
+
157
+ return species_arr
158
+
159
+ def _downsample_results(
160
+ self, observable_answer: np.ndarray, dataset: pd.DataFrame, group
161
+ ) -> np.ndarray:
162
+ """Slice simulation values to experimental measurement timepoints."""
163
+ valid_rows = group.dropna(subset=["measurement"])
164
+ if valid_rows.empty:
165
+ return observable_answer
166
+
167
+ exp_time = np.sort(
168
+ valid_rows["time"].dropna().astype(float).unique()
169
+ )
170
+ sim_indices = self._get_exp_time_indices(exp_time, dataset["time"])
171
+ return observable_answer[np.sort(sim_indices)]
172
+
173
+ @staticmethod
174
+ def _get_exp_time_indices(exp_time: np.ndarray, sim_time: np.ndarray) -> list:
175
+ """Index of closest simulation time for each experimental timepoint."""
176
+ return [
177
+ int(np.argmin(np.abs(sim_time - t)))
178
+ for t in np.sort(exp_time)
179
+ ]
180
+
181
+ def _downsample_timepoints(self, dataset: pd.DataFrame, group) -> np.ndarray:
182
+ valid_rows = group.dropna(subset=["measurement"])
183
+ if valid_rows.empty:
184
+ return dataset["time"]
185
+
186
+ exp_time = np.sort(
187
+ valid_rows["time"].dropna().astype(float).unique()
188
+ )
189
+ indices = self._get_exp_time_indices(exp_time, dataset["time"])
190
+ return np.unique(np.array(dataset["time"][indices]))
benchtop/_organizer.py ADDED
@@ -0,0 +1,147 @@
1
+ """Task scheduling: topological ordering, cell replication, and round-robin assignment."""
2
+
3
+ import os
4
+ from collections import defaultdict, deque
5
+
6
+ import pandas as pd
7
+
8
+
9
+ class Organizer:
10
+ """Build and assign simulation tasks respecting preequilibration dependencies."""
11
+
12
+ def __init__(self, workers: int = os.cpu_count()):
13
+ self.workers = workers
14
+
15
+ def task_organization(
16
+ self,
17
+ measurement_df: pd.DataFrame,
18
+ cell_count: int,
19
+ ) -> tuple[int, dict]:
20
+ """Return (num_rounds, rank_jobs_directory) for the full experiment."""
21
+ size = self.workers
22
+
23
+ topological_list = self.topologic_sort(measurements_df=measurement_df)
24
+ total_tasks = self.total_tasks(tasks=topological_list, cell_count=cell_count)
25
+ delayed_list = self.delay_secondary_conditions(
26
+ measurements_df=measurement_df,
27
+ task_list=total_tasks,
28
+ cell_count=cell_count,
29
+ )
30
+
31
+ rank_jobs_directory = {}
32
+ for i in range(size):
33
+ rank_ids = self.assign_tasks(i, len(delayed_list))
34
+ rank_jobs_directory[i] = [delayed_list[job] for job in rank_ids]
35
+
36
+ rounds_to_complete = -(-len(delayed_list) // size) # ceiling division
37
+ return rounds_to_complete, rank_jobs_directory
38
+
39
+ def topologic_sort(self, measurements_df: pd.DataFrame) -> list:
40
+ """Kahn topological sort of conditions; preequilibration edges first."""
41
+ if "preequilibrationConditionId" not in measurements_df.columns:
42
+ return measurements_df["simulationConditionId"].dropna().unique().tolist()
43
+
44
+ sim_nodes = measurements_df["simulationConditionId"].dropna().unique().tolist()
45
+ pre_nodes = measurements_df["preequilibrationConditionId"].dropna().unique().tolist()
46
+ nodes = sorted(set(sim_nodes) | set(pre_nodes))
47
+
48
+ succs = defaultdict(list)
49
+ indegree = {n: 0 for n in nodes}
50
+
51
+ for _, row in measurements_df.dropna(
52
+ subset=["preequilibrationConditionId"]
53
+ ).iterrows():
54
+ pre = row["preequilibrationConditionId"]
55
+ sim = row["simulationConditionId"]
56
+ succs[pre].append(sim)
57
+ indegree[sim] += 1
58
+
59
+ for k in succs:
60
+ succs[k].sort()
61
+
62
+ queue = deque(sorted(n for n, d in indegree.items() if d == 0))
63
+ ordered = []
64
+
65
+ while queue:
66
+ n = queue.popleft()
67
+ ordered.append(n)
68
+
69
+ for m in succs[n]:
70
+ indegree[m] -= 1
71
+ if indegree[m] == 0:
72
+ queue.append(m)
73
+
74
+ queue = deque(sorted(queue))
75
+
76
+ if len(ordered) != len(nodes):
77
+ raise RuntimeError("Circular dependency detected among conditions!")
78
+
79
+ return ordered
80
+
81
+ def delay_secondary_conditions(
82
+ self,
83
+ measurements_df: pd.DataFrame,
84
+ task_list: list,
85
+ cell_count: int,
86
+ ) -> list:
87
+ """Insert None padding so preequilibration completes before dependents."""
88
+ if "preequilibrationConditionId" not in measurements_df.columns:
89
+ return task_list
90
+
91
+ pre_conds = (
92
+ measurements_df["preequilibrationConditionId"]
93
+ .drop_duplicates()
94
+ .dropna()
95
+ .to_list()
96
+ )
97
+
98
+ for idx, job in enumerate(task_list):
99
+ if job is None:
100
+ continue
101
+
102
+ cond_id = job.split("+")[0]
103
+ if cond_id not in pre_conds:
104
+ continue
105
+
106
+ pause_ranks = max(self.workers - cell_count, 0)
107
+ while pause_ranks:
108
+ task_list.insert(idx + cell_count, None)
109
+ pause_ranks -= 1
110
+
111
+ pre_conds.pop(pre_conds.index(cond_id))
112
+
113
+ return task_list
114
+
115
+ def total_tasks(self, tasks: list, cell_count: int) -> list:
116
+ """Expand conditions into ``conditionId+cell`` task strings."""
117
+ return [
118
+ f"{cond}+{cell}"
119
+ for cond in tasks
120
+ for cell in range(1, cell_count + 1)
121
+ ]
122
+
123
+ def assign_tasks(self, rank: int, total_jobs: int) -> list:
124
+ """Round-robin job indices assigned to a single worker rank."""
125
+ size = self.workers
126
+ num_rounds = -(-total_jobs // size)
127
+
128
+ rank_jobs = []
129
+ for round_index in range(num_rounds):
130
+ job_id = rank + round_index * size
131
+ if job_id < total_jobs:
132
+ rank_jobs.append(job_id)
133
+
134
+ return rank_jobs
135
+
136
+ def task_assignment(self, rank_jobs_directory: dict, round_i: int) -> list:
137
+ """Collect one task per worker for the given round."""
138
+ round_i_tasks = []
139
+
140
+ for i in range(self.workers):
141
+ rank_jobs = rank_jobs_directory[i]
142
+ if round_i < len(rank_jobs):
143
+ round_i_tasks.append(rank_jobs[round_i])
144
+ else:
145
+ round_i_tasks.append(None)
146
+
147
+ return round_i_tasks
benchtop/_record.py ADDED
@@ -0,0 +1,204 @@
1
+ """Results index: maps condition/cell pairs to cache keys and lookup helpers."""
2
+
3
+ import os
4
+ import logging
5
+ import uuid
6
+ from types import SimpleNamespace
7
+ from typing import List, Union
8
+
9
+ import pandas as pd
10
+
11
+ from benchtop._results_cacher import ResultCache, DEFAULT_CACHE
12
+
13
+ logging.basicConfig(
14
+ level=logging.INFO,
15
+ format="%(asctime)s - %(levelname)s - %(message)s",
16
+ )
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class Record:
21
+ """Shared results dictionary accessed by workers across processes."""
22
+
23
+ def __init__(
24
+ self,
25
+ problems: Union[List[SimpleNamespace], SimpleNamespace],
26
+ cache_dir: Union[os.PathLike, str] = DEFAULT_CACHE,
27
+ load_index: bool = False,
28
+ ) -> None:
29
+ if isinstance(problems, list):
30
+ self.problems = problems
31
+ else:
32
+ self.problems = [problems]
33
+
34
+ self.problem = self.problems[0]
35
+ self.current_problem_name = self.problem.name
36
+
37
+ if not load_index:
38
+ results_dict = self._results_dictionary()
39
+ else:
40
+ results_dict = None
41
+
42
+ self.cache = ResultCache(
43
+ results_dict=results_dict,
44
+ cache_dir=cache_dir,
45
+ load_index=load_index,
46
+ problem_names=[p.name for p in self.problems],
47
+ )
48
+
49
+ if load_index:
50
+ merged = self._merge_loaded_index(self.cache.results_dict)
51
+ self.cache.results_dict = merged
52
+ self.cache._write_cache_index()
53
+
54
+ def set_current_problem(
55
+ self, problem: SimpleNamespace, problem_name: str | None = None
56
+ ) -> None:
57
+ """Point workers and lookups at the problem currently being simulated."""
58
+ self.problem = problem
59
+ self.current_problem_name = problem_name or problem.name
60
+
61
+ def _results_dictionary(self) -> dict:
62
+ """Build initial index: one entry per problem × condition × cell replicate."""
63
+ jobs = self._expected_jobs()
64
+ return {key: entry for key, entry in jobs.items()}
65
+
66
+ def _expected_jobs(self) -> dict:
67
+ """Return job entries keyed by stable cache identifiers."""
68
+ results = {}
69
+
70
+ for problem in self.problems:
71
+ conditions_df = problem.condition_files[0]
72
+ measurement_df = problem.measurement_files[0]
73
+
74
+ for _, condition in conditions_df.iterrows():
75
+ condition_id = condition["conditionId"]
76
+
77
+ for cell in range(1, problem.cell_count + 1):
78
+ if "datasetId" in measurement_df.columns:
79
+ identifier = measurement_df["datasetId"][
80
+ measurement_df["simulationConditionId"] == condition_id
81
+ ].values[0]
82
+ else:
83
+ identifier = self._identifier_generator()
84
+
85
+ results[identifier] = {
86
+ "problem": problem.name,
87
+ "conditionId": condition_id,
88
+ "cell": cell,
89
+ "complete": False,
90
+ }
91
+
92
+ return results
93
+
94
+ def _job_lookup_key(self, entry: dict) -> tuple:
95
+ return (
96
+ entry.get("problem"),
97
+ str(entry["conditionId"]),
98
+ str(entry["cell"]),
99
+ )
100
+
101
+ def _merge_loaded_index(self, loaded: dict) -> dict:
102
+ """Align a loaded cache index with the current benchmark configuration."""
103
+ expected_jobs = self._expected_jobs()
104
+ problem_names = [p.name for p in self.problems]
105
+
106
+ meta = loaded.get(ResultCache.PROBLEMS_META_KEY, {})
107
+ for name in problem_names:
108
+ meta.setdefault(name, {"complete": False})
109
+
110
+ loaded_by_job = {}
111
+ for key in loaded:
112
+ if key == ResultCache.PROBLEMS_META_KEY:
113
+ continue
114
+ entry = loaded[key]
115
+ loaded_by_job[self._job_lookup_key(entry)] = (key, entry)
116
+
117
+ merged = {ResultCache.PROBLEMS_META_KEY: meta}
118
+ used_keys = set()
119
+
120
+ for _, expected_entry in expected_jobs.items():
121
+ job_key = self._job_lookup_key(expected_entry)
122
+ if job_key in loaded_by_job:
123
+ cache_key, loaded_entry = loaded_by_job[job_key]
124
+ merged[cache_key] = loaded_entry
125
+ used_keys.add(cache_key)
126
+ else:
127
+ identifier = self._identifier_generator()
128
+ merged[identifier] = expected_entry
129
+
130
+ for job_key, (cache_key, loaded_entry) in loaded_by_job.items():
131
+ if cache_key not in used_keys:
132
+ merged[cache_key] = loaded_entry
133
+
134
+ return merged
135
+
136
+ def incomplete_tasks_for_problem(self, problem_name: str) -> list[str]:
137
+ """Return ``conditionId+cell`` task strings not yet marked complete."""
138
+ incomplete = []
139
+ for key in self.cache.job_keys():
140
+ entry = self.cache.results_dict[key]
141
+ if not self._job_belongs_to_problem(entry, problem_name):
142
+ continue
143
+ if not entry["complete"]:
144
+ incomplete.append(f"{entry['conditionId']}+{entry['cell']}")
145
+ return incomplete
146
+
147
+ @staticmethod
148
+ def _job_belongs_to_problem(entry: dict, problem_name: str) -> bool:
149
+ entry_problem = entry.get("problem")
150
+ if entry_problem is None:
151
+ return True
152
+ return entry_problem == problem_name
153
+
154
+ def find_job_key(
155
+ self, condition_id: str, cell: int, problem_name: str | None = None
156
+ ) -> str | None:
157
+ """Return cache key for a condition/cell pair within a problem."""
158
+ problem_name = problem_name or self.current_problem_name
159
+
160
+ for key in self.cache.job_keys():
161
+ entry = self.cache.results_dict[key]
162
+ entry_problem = entry.get("problem")
163
+ if entry_problem is not None and entry_problem != problem_name:
164
+ continue
165
+ if (
166
+ str(entry["conditionId"]) == str(condition_id)
167
+ and str(entry["cell"]) == str(cell)
168
+ ):
169
+ return key
170
+
171
+ return None
172
+
173
+ def results_lookup(
174
+ self,
175
+ condition_id: str,
176
+ cell: int,
177
+ problem_name: str | None = None,
178
+ ) -> pd.DataFrame | None:
179
+ """Load cached trajectory for a condition/cell pair."""
180
+ key = self.find_job_key(condition_id, cell, problem_name)
181
+ if key is None:
182
+ logger.error(
183
+ "No prior results found for %s at cell %s (problem=%s)",
184
+ condition_id,
185
+ cell,
186
+ problem_name or self.current_problem_name,
187
+ )
188
+ return None
189
+
190
+ logger.debug("Results found for %s, cell %s", condition_id, cell)
191
+ return self.cache.load(key)
192
+
193
+ def condition_cell_id(self, rank_task: str, conditions_df) -> tuple:
194
+ """Parse ``conditionId+cell`` task string into condition row and IDs."""
195
+ condition_id, cell = rank_task.split("+")
196
+
197
+ matches = conditions_df.loc[conditions_df["conditionId"] == condition_id]
198
+ if matches.empty:
199
+ raise ValueError(f"Condition ID '{condition_id}' not found in conditions_df")
200
+
201
+ return matches.iloc[0], cell, condition_id
202
+
203
+ def _identifier_generator(self) -> str:
204
+ return str(uuid.uuid4())