tofscope 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.
- tofscope/__init__.py +1 -0
- tofscope/api/__init__.py +0 -0
- tofscope/api/app.py +549 -0
- tofscope/api/auth.py +82 -0
- tofscope/api/schemas.py +91 -0
- tofscope/application/__init__.py +0 -0
- tofscope/application/analyses.py +237 -0
- tofscope/application/cache.py +166 -0
- tofscope/application/commands.py +153 -0
- tofscope/application/explore.py +161 -0
- tofscope/application/identification.py +153 -0
- tofscope/application/identification_models.py +75 -0
- tofscope/application/jobs.py +347 -0
- tofscope/application/lifecycle.py +80 -0
- tofscope/application/models.py +74 -0
- tofscope/application/preparations.py +46 -0
- tofscope/application/resources.py +63 -0
- tofscope/application/saves.py +76 -0
- tofscope/application/signals.py +74 -0
- tofscope/application/spatial.py +78 -0
- tofscope/cli.py +157 -0
- tofscope/config.py +139 -0
- tofscope/presentation/__init__.py +0 -0
- tofscope/presentation/coordinates.py +31 -0
- tofscope/presentation/data.py +54 -0
- tofscope/presentation/explore.py +267 -0
- tofscope/presentation/exports.py +102 -0
- tofscope/presentation/isotopes.py +31 -0
- tofscope/presentation/volumes.py +210 -0
- tofscope/resources/__init__.py +0 -0
- tofscope/resources/reference/README.md +36 -0
- tofscope/resources/reference/isotopes.json +36901 -0
- tofscope/science/__init__.py +0 -0
- tofscope/science/abundance.py +42 -0
- tofscope/science/depth_calibration.py +73 -0
- tofscope/science/integration.py +149 -0
- tofscope/science/isotopes.py +90 -0
- tofscope/science/raw_peaks.py +50 -0
- tofscope/science/signals.py +114 -0
- tofscope/science/volume_layers.py +112 -0
- tofscope/static/assets/VtkScene-q-o3MPFv.js +5774 -0
- tofscope/static/assets/datasophos-wordmark.png +0 -0
- tofscope/static/assets/favicon.svg +5 -0
- tofscope/static/assets/index-CeV0NVRS.css +1 -0
- tofscope/static/assets/index-djLJCKw_.js +24 -0
- tofscope/static/index.html +14 -0
- tofscope/static/licenses/matplotlib.txt +99 -0
- tofscope/static/manifest.json +13 -0
- tofscope/storage/__init__.py +0 -0
- tofscope/storage/acquisitions.py +281 -0
- tofscope/storage/analysis_document.py +210 -0
- tofscope/storage/bounded_events.py +167 -0
- tofscope/storage/compact_copy.py +143 -0
- tofscope/storage/dataset_io.py +166 -0
- tofscope/storage/explorer.py +201 -0
- tofscope/storage/file_browser.py +41 -0
- tofscope/storage/identification_document.py +47 -0
- tofscope/storage/integration_store.py +140 -0
- tofscope/storage/native_saved_copy.py +252 -0
- tofscope/storage/raw_preparation.py +195 -0
- tofscope/storage/saved_files.py +331 -0
- tofscope/storage/vendor_export.py +114 -0
- tofscope-0.1.0.dist-info/METADATA +14 -0
- tofscope-0.1.0.dist-info/RECORD +68 -0
- tofscope-0.1.0.dist-info/WHEEL +5 -0
- tofscope-0.1.0.dist-info/entry_points.txt +2 -0
- tofscope-0.1.0.dist-info/licenses/LICENSE +21 -0
- tofscope-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""Bounded round-robin user scheduling, with one active job per user."""
|
|
2
|
+
|
|
3
|
+
from collections import OrderedDict, deque
|
|
4
|
+
from dataclasses import dataclass, replace, field
|
|
5
|
+
from threading import Condition, Event, RLock, Thread
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
from uuid import uuid4
|
|
9
|
+
from typing import Literal
|
|
10
|
+
from tofscope.storage.saved_files import SavedFile
|
|
11
|
+
from tofscope.application.analyses import CapacityError
|
|
12
|
+
from tofscope.application.models import SignalSnapshot
|
|
13
|
+
from tofscope.application.signals import get_signal, normalize_view, valid_name
|
|
14
|
+
from tofscope.science.integration import canonical_intervals
|
|
15
|
+
from tofscope.science.signals import SignalRecipe
|
|
16
|
+
|
|
17
|
+
TERMINAL = {"succeeded", "failed", "cancelled", "obsolete"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class IntegrationSpec:
|
|
22
|
+
intervals: tuple[tuple[float, float], ...]
|
|
23
|
+
frames: tuple[int, int]
|
|
24
|
+
name: str
|
|
25
|
+
signal_id: str | None = None
|
|
26
|
+
expected_recipe_version: int | None = None
|
|
27
|
+
candidate_id: str | None = None
|
|
28
|
+
measured_mz: float | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class JobSnapshot:
|
|
33
|
+
id: str
|
|
34
|
+
analysis_id: str
|
|
35
|
+
status: str
|
|
36
|
+
signal_id: str = ""
|
|
37
|
+
recipe_version: int = 0
|
|
38
|
+
done: int = 0
|
|
39
|
+
total: int = 0
|
|
40
|
+
safe_error: str | None = None
|
|
41
|
+
kind: Literal["integration", "save", "preparation", "measurement"] = "integration"
|
|
42
|
+
stage: str = ""
|
|
43
|
+
saved_file: SavedFile | None = None
|
|
44
|
+
saved_revision: int | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class Job:
|
|
49
|
+
snapshot: JobSnapshot
|
|
50
|
+
owner: str
|
|
51
|
+
source: object
|
|
52
|
+
recipe: SignalRecipe
|
|
53
|
+
signal_id: str
|
|
54
|
+
token: int
|
|
55
|
+
name_revision: int
|
|
56
|
+
referenced: bool = True
|
|
57
|
+
cancelled: Event = field(default_factory=Event)
|
|
58
|
+
task: object = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class JobService:
|
|
62
|
+
def __init__(self, workspaces, explore, cache, settings):
|
|
63
|
+
self.analyses = workspaces
|
|
64
|
+
self.explore = explore
|
|
65
|
+
self.cache = cache
|
|
66
|
+
self.settings = settings
|
|
67
|
+
self.jobs = OrderedDict()
|
|
68
|
+
self.pending = {}
|
|
69
|
+
self.users = deque()
|
|
70
|
+
self.running = set()
|
|
71
|
+
self.condition = Condition(RLock())
|
|
72
|
+
self.stopping = False
|
|
73
|
+
self.save_handler = None
|
|
74
|
+
self.preparation_handler = None
|
|
75
|
+
self.measurement_handler = None
|
|
76
|
+
self.threads = [
|
|
77
|
+
Thread(target=self._worker, daemon=True, name=f"tofscope-job-{i}")
|
|
78
|
+
for i in range(settings.workers)
|
|
79
|
+
]
|
|
80
|
+
workspaces.on_close.append(self.close_analysis)
|
|
81
|
+
workspaces.on_remove.append(self.remove_signal)
|
|
82
|
+
for thread in self.threads:
|
|
83
|
+
thread.start()
|
|
84
|
+
|
|
85
|
+
def submit(self, owner, analysis_id, envelope, spec):
|
|
86
|
+
e = self.analyses.entry(owner, analysis_id)
|
|
87
|
+
fingerprint = json.dumps(
|
|
88
|
+
{"envelope": envelope.model_dump(mode="json"), "spec": spec.__dict__},
|
|
89
|
+
sort_keys=True,
|
|
90
|
+
)
|
|
91
|
+
with self.analyses.signal_lock, e.lock:
|
|
92
|
+
old = self.explore.validate_envelope(e, envelope, fingerprint)
|
|
93
|
+
if old is not None:
|
|
94
|
+
return old
|
|
95
|
+
s = e.snapshot
|
|
96
|
+
name = valid_name(spec.name)
|
|
97
|
+
windows = canonical_intervals(spec.intervals, s.mass_limits)
|
|
98
|
+
from tofscope.application.identification import assignment
|
|
99
|
+
atomic_assignment = assignment(spec.candidate_id, spec.measured_mz) if spec.candidate_id else None
|
|
100
|
+
if spec.signal_id is not None:
|
|
101
|
+
signal = get_signal(s, spec.signal_id)
|
|
102
|
+
if signal.recipe is not None and signal.recipe.source != 'custom':
|
|
103
|
+
raise ValueError('Stored channels require a custom copy before raw integration.')
|
|
104
|
+
if spec.expected_recipe_version != signal.recipe_version:
|
|
105
|
+
raise ValueError('Signal recipe changed; refresh before calculating.')
|
|
106
|
+
else:
|
|
107
|
+
self.analyses.admit_signal(owner, len(s.signals))
|
|
108
|
+
signal = SignalSnapshot(str(uuid4()), name, s.next_style_index, None, 0)
|
|
109
|
+
first, last = spec.frames
|
|
110
|
+
if (
|
|
111
|
+
type(first) is not int
|
|
112
|
+
or type(last) is not int
|
|
113
|
+
or not 0 <= first <= last < s.shape[0]
|
|
114
|
+
):
|
|
115
|
+
raise ValueError("Integration frames outside stored domain.")
|
|
116
|
+
with self.condition:
|
|
117
|
+
active = [
|
|
118
|
+
j for j in self.jobs.values() if j.snapshot.status not in TERMINAL
|
|
119
|
+
]
|
|
120
|
+
if (
|
|
121
|
+
len(active) >= self.settings.jobs_global
|
|
122
|
+
or sum(j.owner == owner for j in active)
|
|
123
|
+
>= self.settings.jobs_per_user
|
|
124
|
+
or sum(j.snapshot.analysis_id == analysis_id for j in active)
|
|
125
|
+
>= self.settings.jobs_per_analysis
|
|
126
|
+
):
|
|
127
|
+
raise CapacityError("Integration queue full; wait or cancel a job.")
|
|
128
|
+
token = e.tokens.get(signal.id, 0) + 1
|
|
129
|
+
e.tokens[signal.id] = token
|
|
130
|
+
recipe = SignalRecipe(id=signal.id, intervals=windows, frames=tuple(spec.frames), label=name)
|
|
131
|
+
snap = JobSnapshot(str(uuid4()), analysis_id, 'queued', signal.id,
|
|
132
|
+
signal.recipe_version+1, total=last-first+1)
|
|
133
|
+
self.jobs[snap.id] = Job(snap, owner, s.dataset, recipe, signal.id, token, signal.name_revision)
|
|
134
|
+
if signal.job_id in self.jobs:
|
|
135
|
+
self.jobs[signal.job_id].referenced = False
|
|
136
|
+
updated = replace(signal, job_id=snap.id, assignment=atomic_assignment if spec.candidate_id else signal.assignment)
|
|
137
|
+
signals = tuple(updated if r.id == signal.id else r for r in s.signals)
|
|
138
|
+
if spec.signal_id is None:
|
|
139
|
+
signals = (*signals, updated)
|
|
140
|
+
e.snapshot = replace(s, signals=signals, revision=s.revision+1,
|
|
141
|
+
next_style_index=s.next_style_index+(spec.signal_id is None))
|
|
142
|
+
self.pending.setdefault(owner, deque()).append(snap.id)
|
|
143
|
+
if owner not in self.users:
|
|
144
|
+
self.users.append(owner)
|
|
145
|
+
self.explore.receipt(e, envelope.request_id, fingerprint, snap)
|
|
146
|
+
self.condition.notify_all()
|
|
147
|
+
# Keep a bounded terminal receipt history; live jobs are never removed.
|
|
148
|
+
terminal = [
|
|
149
|
+
id
|
|
150
|
+
for id, j in self.jobs.items()
|
|
151
|
+
if j.owner == owner and j.snapshot.status in TERMINAL and not j.referenced
|
|
152
|
+
]
|
|
153
|
+
for id in terminal[:-256]:
|
|
154
|
+
del self.jobs[id]
|
|
155
|
+
return snap
|
|
156
|
+
|
|
157
|
+
def enqueue_save(self, owner, task):
|
|
158
|
+
return self.enqueue_task(owner, task, 'save', task.snapshot.id, task.snapshot.revision)
|
|
159
|
+
|
|
160
|
+
def enqueue_task(self, owner, task, kind, aid, saved_revision=None):
|
|
161
|
+
with self.condition:
|
|
162
|
+
active = [j for j in self.jobs.values() if j.snapshot.status not in TERMINAL]
|
|
163
|
+
if (len(active) >= self.settings.jobs_global or
|
|
164
|
+
sum(j.owner == owner for j in active) >= self.settings.jobs_per_user or
|
|
165
|
+
sum(j.snapshot.analysis_id == aid for j in active) >= self.settings.jobs_per_analysis):
|
|
166
|
+
raise CapacityError('Work queue full; wait or cancel a job.')
|
|
167
|
+
snap = JobSnapshot(str(uuid4()), aid, 'queued', kind=kind, saved_revision=saved_revision)
|
|
168
|
+
source = task.snapshot.dataset if kind in {'save', 'measurement'} else task.source
|
|
169
|
+
self.jobs[snap.id] = Job(snap, owner, source, None, '', 0, 0, referenced=False, task=task)
|
|
170
|
+
self.pending.setdefault(owner, deque()).append(snap.id)
|
|
171
|
+
if owner not in self.users:
|
|
172
|
+
self.users.append(owner)
|
|
173
|
+
terminal = [id for id, j in self.jobs.items() if j.owner == owner and j.snapshot.status in TERMINAL and not j.referenced]
|
|
174
|
+
for id in terminal[:-256]:
|
|
175
|
+
del self.jobs[id]
|
|
176
|
+
self.condition.notify_all()
|
|
177
|
+
return snap
|
|
178
|
+
|
|
179
|
+
def get(self, owner, job_id):
|
|
180
|
+
with self.condition:
|
|
181
|
+
j = self.jobs.get(job_id)
|
|
182
|
+
if j is None or j.owner != owner:
|
|
183
|
+
raise KeyError("Job unavailable.")
|
|
184
|
+
return j.snapshot
|
|
185
|
+
|
|
186
|
+
def cancel(self, owner, job_id):
|
|
187
|
+
with self.condition:
|
|
188
|
+
snap = self.get(owner, job_id)
|
|
189
|
+
j = self.jobs[job_id]
|
|
190
|
+
if snap.status not in TERMINAL:
|
|
191
|
+
j.cancelled.set()
|
|
192
|
+
if snap.status == "queued":
|
|
193
|
+
j.snapshot = replace(snap, status="cancelled")
|
|
194
|
+
self.condition.notify_all()
|
|
195
|
+
return j.snapshot
|
|
196
|
+
|
|
197
|
+
def remove_signal(self, analysis_id, signal_id):
|
|
198
|
+
with self.condition:
|
|
199
|
+
for jid, job in self.jobs.items():
|
|
200
|
+
if job.snapshot.analysis_id == analysis_id and job.signal_id == signal_id:
|
|
201
|
+
job.referenced = False
|
|
202
|
+
self.cancel(job.owner, jid)
|
|
203
|
+
|
|
204
|
+
def close_analysis(self, analysis_id):
|
|
205
|
+
with self.condition:
|
|
206
|
+
for jid, job in self.jobs.items():
|
|
207
|
+
if job.snapshot.analysis_id == analysis_id:
|
|
208
|
+
job.referenced = False
|
|
209
|
+
self.cancel(job.owner, jid)
|
|
210
|
+
|
|
211
|
+
def _next(self):
|
|
212
|
+
for _ in range(len(self.users)):
|
|
213
|
+
owner = self.users.popleft()
|
|
214
|
+
q = self.pending[owner]
|
|
215
|
+
while q and (q[0] not in self.jobs or self.jobs[q[0]].snapshot.status in TERMINAL):
|
|
216
|
+
q.popleft()
|
|
217
|
+
if not q:
|
|
218
|
+
continue
|
|
219
|
+
self.users.append(owner)
|
|
220
|
+
if owner in self.running:
|
|
221
|
+
continue
|
|
222
|
+
id = q.popleft()
|
|
223
|
+
self.running.add(owner)
|
|
224
|
+
j = self.jobs[id]
|
|
225
|
+
j.snapshot = replace(j.snapshot, status="running")
|
|
226
|
+
return j
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
def _worker(self):
|
|
230
|
+
while True:
|
|
231
|
+
with self.condition:
|
|
232
|
+
j = self._next()
|
|
233
|
+
while j is None and not self.stopping:
|
|
234
|
+
self.condition.wait()
|
|
235
|
+
j = self._next()
|
|
236
|
+
if self.stopping and j is None:
|
|
237
|
+
return
|
|
238
|
+
lease = None
|
|
239
|
+
try:
|
|
240
|
+
if j.snapshot.kind in {'save', 'preparation', 'measurement'}:
|
|
241
|
+
if j.cancelled.is_set():
|
|
242
|
+
raise InterruptedError()
|
|
243
|
+
handler = {'save': self.save_handler, 'preparation': self.preparation_handler, 'measurement': self.measurement_handler}[j.snapshot.kind]
|
|
244
|
+
result = handler(j)
|
|
245
|
+
with self.condition:
|
|
246
|
+
j.snapshot = replace(j.snapshot, status='succeeded', saved_file=result, done=j.snapshot.total)
|
|
247
|
+
continue
|
|
248
|
+
aid = j.snapshot.analysis_id
|
|
249
|
+
e = self.analyses.entry(j.owner, aid)
|
|
250
|
+
|
|
251
|
+
def current():
|
|
252
|
+
return (
|
|
253
|
+
not e.closed
|
|
254
|
+
and e.snapshot.dataset == j.source
|
|
255
|
+
and e.tokens.get(j.signal_id) == j.token
|
|
256
|
+
and any(r.id == j.signal_id for r in e.snapshot.signals)
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
def cancelled():
|
|
260
|
+
return j.cancelled.is_set() or not current()
|
|
261
|
+
|
|
262
|
+
if cancelled():
|
|
263
|
+
raise InterruptedError()
|
|
264
|
+
|
|
265
|
+
def progress(done, total):
|
|
266
|
+
with self.condition:
|
|
267
|
+
j.snapshot = replace(j.snapshot, done=done, total=total)
|
|
268
|
+
|
|
269
|
+
lease = self.cache.load(
|
|
270
|
+
j.owner,
|
|
271
|
+
aid,
|
|
272
|
+
j.source,
|
|
273
|
+
j.recipe,
|
|
274
|
+
progress=progress,
|
|
275
|
+
cancel=cancelled,
|
|
276
|
+
)
|
|
277
|
+
with e.lock:
|
|
278
|
+
if cancelled():
|
|
279
|
+
raise InterruptedError()
|
|
280
|
+
self.cache.repository.assert_current(j.owner, j.source)
|
|
281
|
+
s = e.snapshot
|
|
282
|
+
old = get_signal(s, j.signal_id)
|
|
283
|
+
name = old.name if old.name_revision != j.name_revision else j.recipe.label
|
|
284
|
+
updated = replace(old, name=name, recipe=j.recipe, recipe_version=j.snapshot.recipe_version, materialization_id=None)
|
|
285
|
+
result = normalize_view(replace(s, revision=s.revision+1,
|
|
286
|
+
signals=tuple(updated if r.id == old.id else r for r in s.signals),
|
|
287
|
+
profile_signal_ids=tuple(dict.fromkeys((*s.profile_signal_ids, old.id))) if old.recipe is None else s.profile_signal_ids))
|
|
288
|
+
if result.isotope_measurement:
|
|
289
|
+
from tofscope.application.identification import current_result
|
|
290
|
+
result = replace(result, isotope_measurement=current_result(result))
|
|
291
|
+
previous = e.leases.pop(old.id, None)
|
|
292
|
+
if previous:
|
|
293
|
+
previous.release()
|
|
294
|
+
if old.id in result.profile_signal_ids or old.id == result.image_signal_id:
|
|
295
|
+
e.leases[old.id] = lease
|
|
296
|
+
lease = None
|
|
297
|
+
e.snapshot = result
|
|
298
|
+
with self.condition:
|
|
299
|
+
j.snapshot = replace(
|
|
300
|
+
j.snapshot, status="succeeded", done=j.snapshot.total
|
|
301
|
+
)
|
|
302
|
+
except (InterruptedError, KeyError):
|
|
303
|
+
with self.condition:
|
|
304
|
+
j.snapshot = replace(
|
|
305
|
+
j.snapshot,
|
|
306
|
+
status="cancelled" if j.cancelled.is_set() else "obsolete",
|
|
307
|
+
)
|
|
308
|
+
except Exception as exc:
|
|
309
|
+
with self.condition:
|
|
310
|
+
j.snapshot = replace(
|
|
311
|
+
j.snapshot,
|
|
312
|
+
status="cancelled" if j.cancelled.is_set() else "obsolete"
|
|
313
|
+
if "changed" in str(exc).lower()
|
|
314
|
+
else "failed",
|
|
315
|
+
safe_error=("Isotope measurement failed; check source, coverage and memory limits." if j.snapshot.kind == "measurement" else "Preparing raw data failed; check free space, source/calibration and output name." if j.snapshot.kind == "preparation" else "Saving failed; check free space, output name and current analysis revision." if j.snapshot.kind == "save" else "Integration failed; check source/calibration or narrow the frame range.")
|
|
316
|
+
if not isinstance(exc, CapacityError)
|
|
317
|
+
else str(exc),
|
|
318
|
+
)
|
|
319
|
+
finally:
|
|
320
|
+
if lease:
|
|
321
|
+
lease.release()
|
|
322
|
+
with self.condition:
|
|
323
|
+
self.running.discard(j.owner)
|
|
324
|
+
self.condition.notify_all()
|
|
325
|
+
|
|
326
|
+
def wait(self, owner, job_id, timeout=30):
|
|
327
|
+
deadline = time.monotonic() + timeout
|
|
328
|
+
with self.condition:
|
|
329
|
+
while True:
|
|
330
|
+
s = self.get(owner, job_id)
|
|
331
|
+
if s.status in TERMINAL:
|
|
332
|
+
return s
|
|
333
|
+
left = deadline - time.monotonic()
|
|
334
|
+
if left <= 0:
|
|
335
|
+
raise TimeoutError("Job did not complete.")
|
|
336
|
+
self.condition.wait(left)
|
|
337
|
+
|
|
338
|
+
def shutdown(self):
|
|
339
|
+
with self.condition:
|
|
340
|
+
self.stopping = True
|
|
341
|
+
for j in self.jobs.values():
|
|
342
|
+
if j.snapshot.status not in TERMINAL:
|
|
343
|
+
self.cancel(j.owner, j.snapshot.id)
|
|
344
|
+
self.condition.notify_all()
|
|
345
|
+
for thread in self.threads:
|
|
346
|
+
thread.join(timeout=35)
|
|
347
|
+
self.cache.clear()
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from threading import RLock
|
|
3
|
+
import time
|
|
4
|
+
from uuid import uuid4
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class ViewLease:
|
|
9
|
+
id: str
|
|
10
|
+
analysis_id: str
|
|
11
|
+
expires_at: float
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LifecycleService:
|
|
15
|
+
def __init__(self, workspaces, jobs, settings, clock=time.monotonic):
|
|
16
|
+
self.analyses = workspaces
|
|
17
|
+
self.jobs = jobs
|
|
18
|
+
self.settings = settings
|
|
19
|
+
self.clock = clock
|
|
20
|
+
self.views = {}
|
|
21
|
+
self.last_seen = {}
|
|
22
|
+
self.lock = RLock()
|
|
23
|
+
workspaces.on_close.append(self.closed)
|
|
24
|
+
|
|
25
|
+
def attach(self, owner, analysis_id):
|
|
26
|
+
self.analyses.get(owner, analysis_id)
|
|
27
|
+
with self.lock:
|
|
28
|
+
now = self.clock()
|
|
29
|
+
lease = ViewLease(
|
|
30
|
+
str(uuid4()), analysis_id, now + self.settings.lease_seconds
|
|
31
|
+
)
|
|
32
|
+
self.views[lease.id] = (owner, lease)
|
|
33
|
+
self.last_seen[analysis_id] = now
|
|
34
|
+
return lease
|
|
35
|
+
|
|
36
|
+
def heartbeat(self, owner, vid):
|
|
37
|
+
with self.lock:
|
|
38
|
+
o, lease = self.views.get(vid, (None, None))
|
|
39
|
+
if o != owner:
|
|
40
|
+
raise KeyError("View unavailable.")
|
|
41
|
+
self.analyses.get(owner, lease.analysis_id)
|
|
42
|
+
now = self.clock()
|
|
43
|
+
lease = ViewLease(vid, lease.analysis_id, now + self.settings.lease_seconds)
|
|
44
|
+
self.views[vid] = (owner, lease)
|
|
45
|
+
self.last_seen[lease.analysis_id] = now
|
|
46
|
+
return lease
|
|
47
|
+
|
|
48
|
+
def detach(self, owner, vid):
|
|
49
|
+
with self.lock:
|
|
50
|
+
o, lease = self.views.get(vid, (None, None))
|
|
51
|
+
if o != owner:
|
|
52
|
+
raise KeyError("View unavailable.")
|
|
53
|
+
self.last_seen[lease.analysis_id] = self.clock()
|
|
54
|
+
del self.views[vid]
|
|
55
|
+
|
|
56
|
+
def closed(self, aid):
|
|
57
|
+
with self.lock:
|
|
58
|
+
self.views = {
|
|
59
|
+
v: pair for v, pair in self.views.items() if pair[1].analysis_id != aid
|
|
60
|
+
}
|
|
61
|
+
self.last_seen.pop(aid, None)
|
|
62
|
+
|
|
63
|
+
def sweep(self):
|
|
64
|
+
now = self.clock()
|
|
65
|
+
with self.lock:
|
|
66
|
+
live = {v.analysis_id for _, v in self.views.values() if v.expires_at > now}
|
|
67
|
+
for e in list(self.analyses.entries.values()):
|
|
68
|
+
s = e.snapshot
|
|
69
|
+
if s.id in live:
|
|
70
|
+
self.last_seen[s.id] = now
|
|
71
|
+
continue
|
|
72
|
+
absence = now - self.last_seen.setdefault(s.id, now)
|
|
73
|
+
if absence >= self.settings.reconnect_seconds:
|
|
74
|
+
self.jobs.close_analysis(s.id)
|
|
75
|
+
if absence >= self.settings.idle_seconds:
|
|
76
|
+
try:
|
|
77
|
+
self.analyses.close(s.owner, s.id)
|
|
78
|
+
except KeyError:
|
|
79
|
+
pass
|
|
80
|
+
self.views = {k: v for k, v in self.views.items() if v[1].expires_at > now}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from tofscope.application.spatial import SpatialView
|
|
2
|
+
from tofscope.application.identification_models import AtomicAssignment, MeasurementResult
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Literal
|
|
5
|
+
from tofscope.science.depth_calibration import DepthAnchor
|
|
6
|
+
from tofscope.storage.acquisitions import DatasetRef
|
|
7
|
+
from tofscope.science.signals import SignalRecipe
|
|
8
|
+
from tofscope.application.commands import DepthSelection
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class DisplaySettings:
|
|
13
|
+
log: bool = True
|
|
14
|
+
relative: bool = True
|
|
15
|
+
image: str = "sims"
|
|
16
|
+
spectrum_log: bool = True
|
|
17
|
+
colormap: Literal["ion", "gray", "viridis", "magma", "cividis"] = "viridis"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def __post_init__(self):
|
|
21
|
+
# Saved analyses and older clients may still use the retired palette name.
|
|
22
|
+
if self.colormap == "ion":
|
|
23
|
+
object.__setattr__(self, "colormap", "viridis")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Peak:
|
|
28
|
+
index: int
|
|
29
|
+
label: str
|
|
30
|
+
mass: float
|
|
31
|
+
low: float
|
|
32
|
+
high: float
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class SignalSnapshot:
|
|
37
|
+
id: str
|
|
38
|
+
name: str
|
|
39
|
+
style_index: int
|
|
40
|
+
recipe: SignalRecipe | None
|
|
41
|
+
recipe_version: int = 1
|
|
42
|
+
name_revision: int = 0
|
|
43
|
+
job_id: str | None = None
|
|
44
|
+
materialization_id: str | None = None
|
|
45
|
+
assignment: AtomicAssignment | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class AnalysisSnapshot:
|
|
50
|
+
id: str
|
|
51
|
+
owner: str
|
|
52
|
+
dataset: DatasetRef
|
|
53
|
+
revision: int
|
|
54
|
+
roi: tuple[int, int, int, int]
|
|
55
|
+
depth: DepthSelection
|
|
56
|
+
signals: tuple[SignalSnapshot, ...]
|
|
57
|
+
image_signal_id: str
|
|
58
|
+
profile_signal_ids: tuple[str, ...]
|
|
59
|
+
next_style_index: int
|
|
60
|
+
display: DisplaySettings
|
|
61
|
+
shape: tuple[int, int, int, int]
|
|
62
|
+
peaks: tuple[Peak, ...]
|
|
63
|
+
fov_um: float | None
|
|
64
|
+
mass_limits: tuple[float, float]
|
|
65
|
+
stale: bool = False
|
|
66
|
+
has_se: bool = False
|
|
67
|
+
saved_revision: int | None = None
|
|
68
|
+
opened_revision: int = 0
|
|
69
|
+
depth_anchors: tuple[DepthAnchor, ...] = (DepthAnchor(0, 0.),)
|
|
70
|
+
depth_unit: Literal['nm', 'um', 'mm'] = 'nm'
|
|
71
|
+
depth_coordinates: Literal['frame', 'depth'] = 'frame'
|
|
72
|
+
isotope_measurement: MeasurementResult | None = None
|
|
73
|
+
isotope_job_id: str | None = None
|
|
74
|
+
spatial: SpatialView = SpatialView()
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Explicit generation-bound raw preparations on the shared owner work queue."""
|
|
2
|
+
from dataclasses import dataclass, replace
|
|
3
|
+
from uuid import uuid4
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
from tofscope.storage.raw_preparation import inspect_source, prepare_copy
|
|
7
|
+
from tofscope.storage.saved_files import filename
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class PreparationTask:
|
|
12
|
+
id: str
|
|
13
|
+
source: object
|
|
14
|
+
name: str
|
|
15
|
+
required_bytes: int
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PreparationService:
|
|
19
|
+
def __init__(self, repository, jobs, files):
|
|
20
|
+
self.repository, self.jobs, self.files = repository, jobs, files
|
|
21
|
+
jobs.preparation_handler = self.run
|
|
22
|
+
|
|
23
|
+
def inspect(self, owner, path, location='acquisition'):
|
|
24
|
+
return inspect_source(self.repository, owner, path, location)[1]
|
|
25
|
+
|
|
26
|
+
def submit(self, owner, path, location, generation, name, *, accept_assumptions=False):
|
|
27
|
+
name = filename(name)
|
|
28
|
+
source, preview = inspect_source(self.repository, owner, path, location)
|
|
29
|
+
if preview.generation != generation:
|
|
30
|
+
raise ValueError('Acquisition changed; inspect it again before preparation.')
|
|
31
|
+
if not preview.requires_preparation:
|
|
32
|
+
raise ValueError('This file already contains integrated peak data.')
|
|
33
|
+
if preview.assumptions and not accept_assumptions:
|
|
34
|
+
raise ValueError('Missing acquisition metadata requires explicit acceptance of the reconstruction assumptions.')
|
|
35
|
+
if os.path.lexists(self.files.owner_root(owner) / name):
|
|
36
|
+
raise FileExistsError('Output already exists; choose a new name.')
|
|
37
|
+
task = PreparationTask(str(uuid4()), source, name, preview.required_bytes)
|
|
38
|
+
return self.jobs.enqueue_task(owner, task, 'preparation', task.id)
|
|
39
|
+
|
|
40
|
+
def run(self, job):
|
|
41
|
+
def progress(stage, done, total):
|
|
42
|
+
with self.jobs.condition:
|
|
43
|
+
job.snapshot = replace(job.snapshot, stage=stage, done=done, total=total)
|
|
44
|
+
task = job.task
|
|
45
|
+
return prepare_copy(self.repository, self.files, job.owner, task.source, task.name,
|
|
46
|
+
task.required_bytes, job.cancelled, progress, task.id)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Admission for managed arrays and transport buffers; allocator RSS is separate."""
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from threading import RLock
|
|
5
|
+
from tofscope.application.analyses import CapacityError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Reservation:
|
|
9
|
+
def __init__(self, budget, owner, analysis_id, n, physical):
|
|
10
|
+
self.budget = budget
|
|
11
|
+
self.owner = owner
|
|
12
|
+
self.analysis_id = analysis_id
|
|
13
|
+
self.n = n
|
|
14
|
+
self.physical = physical
|
|
15
|
+
self.released = False
|
|
16
|
+
|
|
17
|
+
def release(self):
|
|
18
|
+
with self.budget.lock:
|
|
19
|
+
if self.released:
|
|
20
|
+
return
|
|
21
|
+
self.released = True
|
|
22
|
+
if self.physical:
|
|
23
|
+
self.budget.physical_bytes -= self.n
|
|
24
|
+
if self.owner is not None:
|
|
25
|
+
self.budget.users[self.owner] -= self.n
|
|
26
|
+
self.budget.analyses[self.analysis_id] -= self.n
|
|
27
|
+
|
|
28
|
+
def __enter__(self):
|
|
29
|
+
return self
|
|
30
|
+
|
|
31
|
+
def __exit__(self, *args):
|
|
32
|
+
self.release()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ResourceBudget:
|
|
36
|
+
def __init__(self, settings):
|
|
37
|
+
self.settings = settings
|
|
38
|
+
self.lock = RLock()
|
|
39
|
+
self.physical_bytes = 0
|
|
40
|
+
self.users = defaultdict(int)
|
|
41
|
+
self.analyses = defaultdict(int)
|
|
42
|
+
|
|
43
|
+
def reserve(self, owner, analysis_id, bytes, physical=True):
|
|
44
|
+
if type(bytes) is not int or bytes < 0:
|
|
45
|
+
raise ValueError("An exact nonnegative allocation size is required.")
|
|
46
|
+
with self.lock:
|
|
47
|
+
s = self.settings
|
|
48
|
+
if (physical and self.physical_bytes + bytes > s.global_bytes) or (
|
|
49
|
+
owner is not None
|
|
50
|
+
and (
|
|
51
|
+
self.users[owner] + bytes > s.user_bytes
|
|
52
|
+
or self.analyses[analysis_id] + bytes > s.analysis_bytes
|
|
53
|
+
)
|
|
54
|
+
):
|
|
55
|
+
raise CapacityError(
|
|
56
|
+
"Memory budget reached; hide profiles or close an analysis."
|
|
57
|
+
)
|
|
58
|
+
if physical:
|
|
59
|
+
self.physical_bytes += bytes
|
|
60
|
+
if owner is not None:
|
|
61
|
+
self.users[owner] += bytes
|
|
62
|
+
self.analyses[analysis_id] += bytes
|
|
63
|
+
return Reservation(self, owner, analysis_id, bytes, physical)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Revision-bound saved copies run through the shared fair job scheduler."""
|
|
2
|
+
from contextlib import contextmanager
|
|
3
|
+
from dataclasses import dataclass, replace
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
from tofscope.application.analyses import Conflict
|
|
7
|
+
from tofscope.storage.analysis_document import encode_document
|
|
8
|
+
from tofscope.storage.saved_files import SavedFileStore, COPY_BYTES, filename
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class SaveTask:
|
|
13
|
+
snapshot: object
|
|
14
|
+
document: object
|
|
15
|
+
name: str
|
|
16
|
+
directory: str = ''
|
|
17
|
+
destination_location: str = 'saved'
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SaveService:
|
|
21
|
+
def __init__(self, workspaces, jobs, cache, repository, settings):
|
|
22
|
+
self.analyses, self.jobs, self.cache, self.repository = workspaces, jobs, cache, repository
|
|
23
|
+
self.files = SavedFileStore(settings)
|
|
24
|
+
jobs.save_handler = self.run
|
|
25
|
+
|
|
26
|
+
def submit(self, owner, analysis_id, envelope, name):
|
|
27
|
+
name = filename(name)
|
|
28
|
+
directory = getattr(envelope, 'directory', '')
|
|
29
|
+
location = getattr(envelope, 'destination_location', 'saved')
|
|
30
|
+
e = self.analyses.entry(owner, analysis_id)
|
|
31
|
+
fingerprint = json.dumps({'kind': 'save', 'envelope': envelope.model_dump(mode='json'), 'filename': name}, sort_keys=True)
|
|
32
|
+
with e.lock:
|
|
33
|
+
old = self.jobs.explore.validate_envelope(e, envelope, fingerprint)
|
|
34
|
+
if old is not None:
|
|
35
|
+
return old
|
|
36
|
+
try:
|
|
37
|
+
self.files.directory(owner, directory, location, writable=True)
|
|
38
|
+
except FileNotFoundError:
|
|
39
|
+
raise ValueError('Save folder is unavailable.') from None
|
|
40
|
+
with self.jobs.condition:
|
|
41
|
+
if any(j.snapshot.analysis_id == analysis_id and j.snapshot.kind in {'integration', 'measurement'} and j.snapshot.status not in {'succeeded', 'failed', 'cancelled', 'obsolete'} for j in self.jobs.jobs.values()):
|
|
42
|
+
raise ValueError('Finish or cancel calculations before saving.')
|
|
43
|
+
task = SaveTask(e.snapshot, encode_document(e.snapshot), name, directory, location)
|
|
44
|
+
result = self.jobs.enqueue_save(owner, task)
|
|
45
|
+
self.jobs.explore.receipt(e, envelope.request_id, fingerprint, result)
|
|
46
|
+
return result
|
|
47
|
+
|
|
48
|
+
def run(self, job):
|
|
49
|
+
task = job.task
|
|
50
|
+
snapshot = task.snapshot
|
|
51
|
+
entry = self.analyses.entry(job.owner, snapshot.id)
|
|
52
|
+
def progress(stage, done, total):
|
|
53
|
+
from dataclasses import replace
|
|
54
|
+
with self.jobs.condition:
|
|
55
|
+
job.snapshot = replace(job.snapshot, stage=stage, done=done, total=total)
|
|
56
|
+
def frames(signal_id):
|
|
57
|
+
row = next(x for x in snapshot.signals if x.id == signal_id)
|
|
58
|
+
with self.cache.load(job.owner, snapshot.id, snapshot.dataset, row.recipe, materialization_id=row.materialization_id, cancel=job.cancelled.is_set) as lease:
|
|
59
|
+
yield from lease.array
|
|
60
|
+
@contextmanager
|
|
61
|
+
def publish_guard():
|
|
62
|
+
with entry.lock:
|
|
63
|
+
if entry.closed or job.cancelled.is_set():
|
|
64
|
+
raise InterruptedError()
|
|
65
|
+
if entry.snapshot.revision != snapshot.revision or entry.snapshot.dataset != snapshot.dataset:
|
|
66
|
+
raise Conflict('Analysis changed during save; save the current revision again.')
|
|
67
|
+
self.repository.assert_current(job.owner, snapshot.dataset)
|
|
68
|
+
yield
|
|
69
|
+
entry.snapshot = replace(entry.snapshot, saved_revision=snapshot.revision)
|
|
70
|
+
self.repository.assert_current(job.owner, snapshot.dataset)
|
|
71
|
+
path = self.repository.path(job.owner, snapshot.dataset)
|
|
72
|
+
# Native mass-bin validation processes bounded 65,536-element blocks.
|
|
73
|
+
scratch = COPY_BYTES + snapshot.shape[1] * snapshot.shape[2] * 12 + 4 * 1024**2
|
|
74
|
+
with self.cache.budget.reserve(job.owner, snapshot.id, scratch):
|
|
75
|
+
return self.files.publish_copy(job.owner, path, task.document, frames, task.name,
|
|
76
|
+
job.cancelled, progress, publish_guard, task.directory, task.destination_location)
|