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.
Files changed (68) hide show
  1. tofscope/__init__.py +1 -0
  2. tofscope/api/__init__.py +0 -0
  3. tofscope/api/app.py +549 -0
  4. tofscope/api/auth.py +82 -0
  5. tofscope/api/schemas.py +91 -0
  6. tofscope/application/__init__.py +0 -0
  7. tofscope/application/analyses.py +237 -0
  8. tofscope/application/cache.py +166 -0
  9. tofscope/application/commands.py +153 -0
  10. tofscope/application/explore.py +161 -0
  11. tofscope/application/identification.py +153 -0
  12. tofscope/application/identification_models.py +75 -0
  13. tofscope/application/jobs.py +347 -0
  14. tofscope/application/lifecycle.py +80 -0
  15. tofscope/application/models.py +74 -0
  16. tofscope/application/preparations.py +46 -0
  17. tofscope/application/resources.py +63 -0
  18. tofscope/application/saves.py +76 -0
  19. tofscope/application/signals.py +74 -0
  20. tofscope/application/spatial.py +78 -0
  21. tofscope/cli.py +157 -0
  22. tofscope/config.py +139 -0
  23. tofscope/presentation/__init__.py +0 -0
  24. tofscope/presentation/coordinates.py +31 -0
  25. tofscope/presentation/data.py +54 -0
  26. tofscope/presentation/explore.py +267 -0
  27. tofscope/presentation/exports.py +102 -0
  28. tofscope/presentation/isotopes.py +31 -0
  29. tofscope/presentation/volumes.py +210 -0
  30. tofscope/resources/__init__.py +0 -0
  31. tofscope/resources/reference/README.md +36 -0
  32. tofscope/resources/reference/isotopes.json +36901 -0
  33. tofscope/science/__init__.py +0 -0
  34. tofscope/science/abundance.py +42 -0
  35. tofscope/science/depth_calibration.py +73 -0
  36. tofscope/science/integration.py +149 -0
  37. tofscope/science/isotopes.py +90 -0
  38. tofscope/science/raw_peaks.py +50 -0
  39. tofscope/science/signals.py +114 -0
  40. tofscope/science/volume_layers.py +112 -0
  41. tofscope/static/assets/VtkScene-q-o3MPFv.js +5774 -0
  42. tofscope/static/assets/datasophos-wordmark.png +0 -0
  43. tofscope/static/assets/favicon.svg +5 -0
  44. tofscope/static/assets/index-CeV0NVRS.css +1 -0
  45. tofscope/static/assets/index-djLJCKw_.js +24 -0
  46. tofscope/static/index.html +14 -0
  47. tofscope/static/licenses/matplotlib.txt +99 -0
  48. tofscope/static/manifest.json +13 -0
  49. tofscope/storage/__init__.py +0 -0
  50. tofscope/storage/acquisitions.py +281 -0
  51. tofscope/storage/analysis_document.py +210 -0
  52. tofscope/storage/bounded_events.py +167 -0
  53. tofscope/storage/compact_copy.py +143 -0
  54. tofscope/storage/dataset_io.py +166 -0
  55. tofscope/storage/explorer.py +201 -0
  56. tofscope/storage/file_browser.py +41 -0
  57. tofscope/storage/identification_document.py +47 -0
  58. tofscope/storage/integration_store.py +140 -0
  59. tofscope/storage/native_saved_copy.py +252 -0
  60. tofscope/storage/raw_preparation.py +195 -0
  61. tofscope/storage/saved_files.py +331 -0
  62. tofscope/storage/vendor_export.py +114 -0
  63. tofscope-0.1.0.dist-info/METADATA +14 -0
  64. tofscope-0.1.0.dist-info/RECORD +68 -0
  65. tofscope-0.1.0.dist-info/WHEEL +5 -0
  66. tofscope-0.1.0.dist-info/entry_points.txt +2 -0
  67. tofscope-0.1.0.dist-info/licenses/LICENSE +21 -0
  68. 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)