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 +6 -0
- benchtop/_abstract_simulator.py +24 -0
- benchtop/_observable_calculator.py +190 -0
- benchtop/_organizer.py +147 -0
- benchtop/_record.py +204 -0
- benchtop/_results_cacher.py +137 -0
- benchtop/_worker.py +186 -0
- benchtop/arguments.py +99 -0
- benchtop/experiment.py +394 -0
- benchtop/file_loader.py +141 -0
- benchtop/launcher.py +79 -0
- benchtop/registry.py +28 -0
- benchtop-0.1.0.dist-info/METADATA +236 -0
- benchtop-0.1.0.dist-info/RECORD +34 -0
- benchtop-0.1.0.dist-info/WHEEL +5 -0
- benchtop-0.1.0.dist-info/entry_points.txt +2 -0
- benchtop-0.1.0.dist-info/licenses/LICENSE +21 -0
- benchtop-0.1.0.dist-info/top_level.txt +3 -0
- tests/conftest.py +40 -0
- tests/data/plot-model.py +0 -0
- tests/make_dummy.py +30 -0
- tests/run_tests.py +11 -0
- tests/test_benchtop.py +207 -0
- tests/test_cache.py +166 -0
- tests/test_obscalc.py +174 -0
- tests/test_organizer.py +148 -0
- tests/test_record.py +134 -0
- tests/test_sparced_wrapper.py +18 -0
- tests/test_worker.py +186 -0
- wrappers/SingleCell.py +122 -0
- wrappers/amici_wrapper.py +91 -0
- wrappers/bngsim_wrapper.py +50 -0
- wrappers/sparced_wrapper.py +167 -0
- wrappers/tellurium_wrapper.py +52 -0
benchtop/__init__.py
ADDED
|
@@ -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())
|