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
tofscope/api/schemas.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from typing import Literal, Annotated
|
|
2
|
+
from pydantic import Field, StrictInt, StrictBool
|
|
3
|
+
from tofscope.application.commands import StrictModel
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class OpenAcquisition(StrictModel):
|
|
7
|
+
relative_path: str = Field(min_length=1, max_length=4096)
|
|
8
|
+
location: Literal["acquisition", "saved"] = "acquisition"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PrepareAcquisition(OpenAcquisition):
|
|
12
|
+
generation: str = Field(min_length=1, max_length=128)
|
|
13
|
+
filename: str = Field(min_length=1, max_length=240)
|
|
14
|
+
accept_assumptions: StrictBool = False
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RevisionEnvelope(StrictModel):
|
|
18
|
+
request_id: str = Field(min_length=1, max_length=128)
|
|
19
|
+
expected_revision: StrictInt = Field(ge=0)
|
|
20
|
+
dataset_generation: str = Field(min_length=1, max_length=128)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ReplaceAcquisition(RevisionEnvelope):
|
|
24
|
+
relative_path: str = Field(min_length=1, max_length=4096)
|
|
25
|
+
location: Literal["acquisition", "saved"] = "acquisition"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
FiniteStrictFloat = Annotated[float, Field(strict=True, allow_inf_nan=False)]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class IntegrationInput(StrictModel):
|
|
32
|
+
intervals: list[tuple[FiniteStrictFloat, FiniteStrictFloat]] = Field(
|
|
33
|
+
min_length=1, max_length=128
|
|
34
|
+
)
|
|
35
|
+
frames: tuple[StrictInt, StrictInt]
|
|
36
|
+
name: str = Field(min_length=1, max_length=128)
|
|
37
|
+
signal_id: str | None = None
|
|
38
|
+
expected_recipe_version: StrictInt | None = Field(default=None, ge=0)
|
|
39
|
+
candidate_id: str | None = Field(default=None, max_length=64)
|
|
40
|
+
measured_mz: FiniteStrictFloat | None = Field(default=None, gt=0)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SubmitJob(RevisionEnvelope):
|
|
44
|
+
spec: IntegrationInput
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Bootstrap(StrictModel):
|
|
48
|
+
secret: str = Field(max_length=256)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class SessionInfo(StrictModel):
|
|
52
|
+
owner: str
|
|
53
|
+
csrf: str
|
|
54
|
+
initial_file: str | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ErrorResponse(StrictModel):
|
|
58
|
+
code: str
|
|
59
|
+
message: str
|
|
60
|
+
request_id: str | None = None
|
|
61
|
+
current_revision: int | None = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class SaveCopy(RevisionEnvelope):
|
|
65
|
+
filename: str = Field(min_length=1, max_length=240)
|
|
66
|
+
directory: str = Field(default='', max_length=4096)
|
|
67
|
+
destination_location: Literal['acquisition', 'saved'] = 'saved'
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class CreateSaveFolder(StrictModel):
|
|
71
|
+
name: str = Field(min_length=1, max_length=240)
|
|
72
|
+
directory: str = Field(default='', max_length=4096)
|
|
73
|
+
location: Literal['acquisition', 'saved'] = 'saved'
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class FolderLocations(StrictModel):
|
|
77
|
+
acquisitions: str
|
|
78
|
+
saved_copies: str
|
|
79
|
+
save_destination: str
|
|
80
|
+
can_save_to_acquisitions: bool = False
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class CandidateInput(StrictModel):
|
|
84
|
+
mz: FiniteStrictFloat = Field(gt=0)
|
|
85
|
+
tolerance: FiniteStrictFloat = Field(ge=0)
|
|
86
|
+
unit: Literal['Th', 'ppm']
|
|
87
|
+
charges: tuple[StrictInt, ...] = Field(min_length=1, max_length=6)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class MeasureIsotopes(RevisionEnvelope):
|
|
91
|
+
signal_ids: tuple[str, ...] = Field(min_length=1, max_length=128)
|
|
File without changes
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
from collections import OrderedDict
|
|
2
|
+
from dataclasses import dataclass, field, replace
|
|
3
|
+
from threading import RLock
|
|
4
|
+
from uuid import uuid4
|
|
5
|
+
from tofscope.storage.acquisitions import SourceChanged
|
|
6
|
+
from tofscope.application.models import (
|
|
7
|
+
AnalysisSnapshot,
|
|
8
|
+
DisplaySettings,
|
|
9
|
+
SignalSnapshot,
|
|
10
|
+
Peak,
|
|
11
|
+
)
|
|
12
|
+
from tofscope.application.commands import AllDepth
|
|
13
|
+
from tofscope.science.signals import SignalRecipe
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CapacityError(ValueError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Conflict(ValueError):
|
|
21
|
+
def __init__(self, message, revision=None):
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
self.revision = revision
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class AnalysisEntry:
|
|
28
|
+
snapshot: AnalysisSnapshot
|
|
29
|
+
lock: RLock = field(default_factory=RLock)
|
|
30
|
+
receipts: OrderedDict = field(default_factory=OrderedDict)
|
|
31
|
+
tokens: dict = field(default_factory=dict)
|
|
32
|
+
leases: dict = field(default_factory=dict)
|
|
33
|
+
closed: bool = False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AnalysisService:
|
|
37
|
+
def __init__(self, repository, settings):
|
|
38
|
+
self.repository = repository
|
|
39
|
+
self.settings = settings
|
|
40
|
+
self.entries = {}
|
|
41
|
+
self.lock = RLock()
|
|
42
|
+
self.signal_lock = RLock()
|
|
43
|
+
self.on_close = []
|
|
44
|
+
self.on_remove = []
|
|
45
|
+
self.prepare = lambda s: None
|
|
46
|
+
|
|
47
|
+
def list(self, owner):
|
|
48
|
+
with self.lock:
|
|
49
|
+
aids = [e.snapshot.id for e in self.entries.values() if not e.closed and e.snapshot.owner == owner]
|
|
50
|
+
result = []
|
|
51
|
+
for aid in aids:
|
|
52
|
+
try:
|
|
53
|
+
result.append(self.get(owner, aid))
|
|
54
|
+
except KeyError:
|
|
55
|
+
pass
|
|
56
|
+
return result
|
|
57
|
+
|
|
58
|
+
def entry(self, owner, aid):
|
|
59
|
+
with self.lock:
|
|
60
|
+
e = self.entries.get(aid)
|
|
61
|
+
if e is None or e.closed or e.snapshot.owner != owner:
|
|
62
|
+
raise KeyError("Analysis unavailable.")
|
|
63
|
+
return e
|
|
64
|
+
|
|
65
|
+
def get(self, owner, aid):
|
|
66
|
+
e = self.entry(owner, aid)
|
|
67
|
+
try:
|
|
68
|
+
self.repository.assert_current(owner, e.snapshot.dataset)
|
|
69
|
+
except (SourceChanged, OSError):
|
|
70
|
+
self.mark_stale(e.snapshot.dataset)
|
|
71
|
+
with e.lock:
|
|
72
|
+
return e.snapshot
|
|
73
|
+
|
|
74
|
+
def mark_stale(self, dataset):
|
|
75
|
+
with self.lock:
|
|
76
|
+
entries = list(self.entries.values())
|
|
77
|
+
for entry in entries:
|
|
78
|
+
with entry.lock:
|
|
79
|
+
s = entry.snapshot
|
|
80
|
+
if (
|
|
81
|
+
not entry.closed
|
|
82
|
+
and s.dataset.key == dataset.key
|
|
83
|
+
and s.dataset.generation == dataset.generation
|
|
84
|
+
and not s.stale
|
|
85
|
+
):
|
|
86
|
+
entry.snapshot = replace(s, stale=True, revision=s.revision + 1)
|
|
87
|
+
entry.tokens = {k: v + 1 for k, v in entry.tokens.items()}
|
|
88
|
+
self._release(entry)
|
|
89
|
+
|
|
90
|
+
def initial(self, owner, dataset, aid=None, revision=0):
|
|
91
|
+
with self.repository.metadata(owner, dataset, aid) as m:
|
|
92
|
+
nz, ny, nx, _ = m.shape
|
|
93
|
+
default = min(m.peaks, key=lambda p: abs(p["mass"] - 27))
|
|
94
|
+
frames = (0, nz - 1)
|
|
95
|
+
recipe = SignalRecipe(
|
|
96
|
+
label=f"{default['label']} · {default['mass']:g} Th",
|
|
97
|
+
source="stored",
|
|
98
|
+
stored_index=default["index"],
|
|
99
|
+
intervals=((default["low"], default["high"]),),
|
|
100
|
+
frames=frames,
|
|
101
|
+
)
|
|
102
|
+
snapshot = AnalysisSnapshot(
|
|
103
|
+
aid or str(uuid4()),
|
|
104
|
+
owner,
|
|
105
|
+
dataset,
|
|
106
|
+
revision,
|
|
107
|
+
(0, nx - 1, 0, ny - 1),
|
|
108
|
+
AllDepth(),
|
|
109
|
+
(SignalSnapshot(recipe.id, recipe.label, 0, recipe),),
|
|
110
|
+
recipe.id,
|
|
111
|
+
(recipe.id,),
|
|
112
|
+
1,
|
|
113
|
+
DisplaySettings(),
|
|
114
|
+
m.shape,
|
|
115
|
+
tuple(Peak(**p) for p in m.peaks),
|
|
116
|
+
m.fov_um,
|
|
117
|
+
(float(m.mass_axis.min()), float(m.mass_axis.max())),
|
|
118
|
+
has_se=bool(m.image_names),
|
|
119
|
+
opened_revision=revision,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
from tofscope.storage.analysis_document import read_document, restore_snapshot, ROOT, MAX_DOCUMENT_BYTES
|
|
123
|
+
from tofscope.storage.integration_store import saved_result_info, iter_saved_frames, metadata_size, ROOT as RESULT_ROOT
|
|
124
|
+
from contextlib import nullcontext
|
|
125
|
+
import hashlib
|
|
126
|
+
import h5py
|
|
127
|
+
with self.repository.access(owner, dataset) as path:
|
|
128
|
+
# Plain acquisitions need no document allocation. Reject oversized metadata first.
|
|
129
|
+
with h5py.File(path, 'r') as h:
|
|
130
|
+
if ROOT not in h:
|
|
131
|
+
return snapshot
|
|
132
|
+
try:
|
|
133
|
+
stored = h[f'{ROOT}/state_json']
|
|
134
|
+
if stored.ndim != 1 or stored.size > MAX_DOCUMENT_BYTES:
|
|
135
|
+
raise ValueError('Saved analysis metadata exceeds limits.')
|
|
136
|
+
estimated = stored.size * 16 + snapshot.shape[1] * snapshot.shape[2] * 8
|
|
137
|
+
except (KeyError, TypeError, AttributeError) as exc:
|
|
138
|
+
raise ValueError('Saved analysis metadata is invalid.') from exc
|
|
139
|
+
budget = self.repository.budget
|
|
140
|
+
with budget.reserve(owner, snapshot.id, estimated) if budget else nullcontext():
|
|
141
|
+
document = read_document(path, self.settings)
|
|
142
|
+
if document is None:
|
|
143
|
+
return snapshot
|
|
144
|
+
try:
|
|
145
|
+
with h5py.File(path, 'r') as handle:
|
|
146
|
+
result_metadata_bytes = sum(metadata_size(handle[f'{RESULT_ROOT}/{row.materialization_id}'])
|
|
147
|
+
for row in document.signals if row.materialization_id)
|
|
148
|
+
except (KeyError, TypeError) as exc:
|
|
149
|
+
raise ValueError('Saved result is missing or invalid.') from exc
|
|
150
|
+
with budget.reserve(owner, snapshot.id, result_metadata_bytes * 16) if budget else nullcontext():
|
|
151
|
+
for row in document.signals:
|
|
152
|
+
if row.materialization_id:
|
|
153
|
+
info = saved_result_info(path, row.materialization_id)
|
|
154
|
+
if tuple(info['spatial_shape']) != snapshot.shape[1:3] or tuple(info['derivation']['frames']) != row.recipe.frames or tuple(tuple(w) for w in info['derivation']['intervals']) != row.recipe.intervals:
|
|
155
|
+
raise ValueError('Saved result does not match the document.')
|
|
156
|
+
digest = hashlib.sha256()
|
|
157
|
+
for frame in iter_saved_frames(path, row.materialization_id, row.recipe.frames):
|
|
158
|
+
digest.update(frame.tobytes(order='C'))
|
|
159
|
+
with h5py.File(path, 'r') as h:
|
|
160
|
+
expected = h[f'ToFScope/Integrations/{row.materialization_id}'].attrs.get('sha256')
|
|
161
|
+
if digest.hexdigest() != expected:
|
|
162
|
+
raise ValueError('Saved result checksum is invalid.')
|
|
163
|
+
return restore_snapshot(document, snapshot)
|
|
164
|
+
|
|
165
|
+
def admit_collection(self, snapshot, replacing=None):
|
|
166
|
+
with self.lock:
|
|
167
|
+
entries = [e.snapshot for e in self.entries.values() if not e.closed and e.snapshot.id != replacing]
|
|
168
|
+
count = len(snapshot.signals)
|
|
169
|
+
if (count > self.settings.signals_per_analysis or
|
|
170
|
+
count + sum(len(s.signals) for s in entries if s.owner == snapshot.owner) > self.settings.signals_per_user or
|
|
171
|
+
count + sum(len(s.signals) for s in entries) > self.settings.signals_global):
|
|
172
|
+
raise CapacityError('Saved analysis exceeds signal limits.')
|
|
173
|
+
|
|
174
|
+
def admit_signal(self, owner, analysis_count):
|
|
175
|
+
# Called under signal_lock by every insertion, including pending jobs.
|
|
176
|
+
with self.lock:
|
|
177
|
+
entries = [e for e in self.entries.values() if not e.closed]
|
|
178
|
+
user_count = sum(len(e.snapshot.signals) for e in entries if e.snapshot.owner == owner)
|
|
179
|
+
global_count = sum(len(e.snapshot.signals) for e in entries)
|
|
180
|
+
if (analysis_count >= self.settings.signals_per_analysis or
|
|
181
|
+
user_count >= self.settings.signals_per_user or global_count >= self.settings.signals_global):
|
|
182
|
+
raise CapacityError('Signal limit reached; remove an unused signal first.')
|
|
183
|
+
|
|
184
|
+
def create_analysis(self, owner, dataset):
|
|
185
|
+
s = self.initial(owner, dataset)
|
|
186
|
+
with self.signal_lock, self.lock:
|
|
187
|
+
if (
|
|
188
|
+
len(self.entries) >= self.settings.analyses_global
|
|
189
|
+
or sum(e.snapshot.owner == owner for e in self.entries.values())
|
|
190
|
+
>= self.settings.analyses_per_user
|
|
191
|
+
):
|
|
192
|
+
raise CapacityError("Open analysis limit reached; close an analysis.")
|
|
193
|
+
self.admit_collection(s)
|
|
194
|
+
self.prepare(s)
|
|
195
|
+
self.entries[s.id] = AnalysisEntry(s)
|
|
196
|
+
return s
|
|
197
|
+
|
|
198
|
+
def replace(self, owner, aid, dataset, expected_revision):
|
|
199
|
+
e = self.entry(owner, aid)
|
|
200
|
+
with self.signal_lock, e.lock:
|
|
201
|
+
if e.closed:
|
|
202
|
+
raise KeyError("Analysis unavailable.")
|
|
203
|
+
if e.snapshot.revision != expected_revision:
|
|
204
|
+
raise Conflict("Analysis revision changed.", e.snapshot.revision)
|
|
205
|
+
s = self.initial(
|
|
206
|
+
owner, dataset, aid, e.snapshot.revision + 1
|
|
207
|
+
)
|
|
208
|
+
self.admit_collection(s, replacing=aid)
|
|
209
|
+
self.prepare(s)
|
|
210
|
+
self._release(e)
|
|
211
|
+
e.snapshot = s
|
|
212
|
+
e.tokens = {k: v + 1 for k, v in e.tokens.items()}
|
|
213
|
+
return s
|
|
214
|
+
|
|
215
|
+
def _release(self, e):
|
|
216
|
+
for lease in e.leases.values():
|
|
217
|
+
lease.release()
|
|
218
|
+
e.leases.clear()
|
|
219
|
+
|
|
220
|
+
def close(self, owner, aid, expected_revision=None):
|
|
221
|
+
e = self.entry(owner, aid)
|
|
222
|
+
with self.signal_lock, e.lock:
|
|
223
|
+
if expected_revision is not None and e.snapshot.revision != expected_revision:
|
|
224
|
+
raise Conflict("Analysis changed in another view; review it before closing.", e.snapshot.revision)
|
|
225
|
+
e.closed = True
|
|
226
|
+
self._release(e)
|
|
227
|
+
for callback in self.on_close:
|
|
228
|
+
callback(aid)
|
|
229
|
+
with self.lock:
|
|
230
|
+
self.entries.pop(aid, None)
|
|
231
|
+
|
|
232
|
+
def close_all(self):
|
|
233
|
+
for e in list(self.entries.values()):
|
|
234
|
+
try:
|
|
235
|
+
self.close(e.snapshot.owner, e.snapshot.id)
|
|
236
|
+
except KeyError:
|
|
237
|
+
pass
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
from collections import OrderedDict
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from threading import Condition, RLock
|
|
4
|
+
import numpy as np
|
|
5
|
+
from tofscope.application.analyses import CapacityError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class CachedArray:
|
|
10
|
+
array: np.ndarray
|
|
11
|
+
physical: object
|
|
12
|
+
consumers: dict = field(default_factory=dict)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ArrayLease:
|
|
16
|
+
def __init__(self, cache, key, consumer, array):
|
|
17
|
+
self.cache = cache
|
|
18
|
+
self.key = key
|
|
19
|
+
self.consumer = consumer
|
|
20
|
+
self.array = array
|
|
21
|
+
self.released = False
|
|
22
|
+
|
|
23
|
+
def release(self):
|
|
24
|
+
with self.cache.condition:
|
|
25
|
+
if self.released:
|
|
26
|
+
return
|
|
27
|
+
self.released = True
|
|
28
|
+
entry = self.cache.entries.get(self.key)
|
|
29
|
+
if entry is None:
|
|
30
|
+
return
|
|
31
|
+
count, reservation = entry.consumers[self.consumer]
|
|
32
|
+
if count == 1:
|
|
33
|
+
reservation.release()
|
|
34
|
+
del entry.consumers[self.consumer]
|
|
35
|
+
else:
|
|
36
|
+
entry.consumers[self.consumer] = (count - 1, reservation)
|
|
37
|
+
self.cache.condition.notify_all()
|
|
38
|
+
|
|
39
|
+
def __enter__(self):
|
|
40
|
+
return self
|
|
41
|
+
|
|
42
|
+
def __exit__(self, *args):
|
|
43
|
+
self.release()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ArrayCache:
|
|
47
|
+
def __init__(self, repository, budget):
|
|
48
|
+
self.repository = repository
|
|
49
|
+
self.budget = budget
|
|
50
|
+
self.entries = OrderedDict()
|
|
51
|
+
self.loading = set()
|
|
52
|
+
self.condition = Condition(RLock())
|
|
53
|
+
|
|
54
|
+
def key(self, dataset, recipe, materialization_id=None):
|
|
55
|
+
return (
|
|
56
|
+
dataset.key,
|
|
57
|
+
dataset.generation,
|
|
58
|
+
recipe.source,
|
|
59
|
+
recipe.stored_index,
|
|
60
|
+
recipe.saved_id,
|
|
61
|
+
recipe.intervals,
|
|
62
|
+
recipe.frames,
|
|
63
|
+
materialization_id,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def _evict(self, needed=None):
|
|
67
|
+
for key, entry in list(self.entries.items()):
|
|
68
|
+
if (
|
|
69
|
+
needed is not None
|
|
70
|
+
and self.budget.physical_bytes + needed
|
|
71
|
+
<= self.budget.settings.global_bytes
|
|
72
|
+
):
|
|
73
|
+
break
|
|
74
|
+
if not entry.consumers:
|
|
75
|
+
entry.physical.release()
|
|
76
|
+
del self.entries[key]
|
|
77
|
+
|
|
78
|
+
def clear(self):
|
|
79
|
+
with self.condition:
|
|
80
|
+
self._evict()
|
|
81
|
+
|
|
82
|
+
def acquire(self, owner, analysis_id, dataset, recipe, loader, estimated_bytes, materialization_id=None):
|
|
83
|
+
self.repository.assert_current(owner, dataset)
|
|
84
|
+
key = self.key(dataset, recipe, materialization_id)
|
|
85
|
+
consumer = (owner, analysis_id)
|
|
86
|
+
with self.condition:
|
|
87
|
+
while key in self.loading:
|
|
88
|
+
self.condition.wait()
|
|
89
|
+
if key in self.entries:
|
|
90
|
+
return self._lease(owner, analysis_id, dataset, key, consumer)
|
|
91
|
+
try:
|
|
92
|
+
physical = self.budget.reserve(None, None, estimated_bytes)
|
|
93
|
+
except CapacityError:
|
|
94
|
+
self._evict(estimated_bytes)
|
|
95
|
+
physical = self.budget.reserve(None, None, estimated_bytes)
|
|
96
|
+
try:
|
|
97
|
+
logical = self.budget.reserve(
|
|
98
|
+
owner, analysis_id, estimated_bytes, physical=False
|
|
99
|
+
)
|
|
100
|
+
except BaseException:
|
|
101
|
+
physical.release()
|
|
102
|
+
raise
|
|
103
|
+
self.loading.add(key)
|
|
104
|
+
try:
|
|
105
|
+
array = loader()
|
|
106
|
+
if not isinstance(array, np.ndarray) or array.nbytes != estimated_bytes:
|
|
107
|
+
raise CapacityError(
|
|
108
|
+
"Reader allocation did not match its preflight shape/dtype."
|
|
109
|
+
)
|
|
110
|
+
self.repository.assert_current(owner, dataset)
|
|
111
|
+
array.setflags(write=False)
|
|
112
|
+
with self.condition:
|
|
113
|
+
self.entries[key] = CachedArray(
|
|
114
|
+
array, physical, {consumer: (1, logical)}
|
|
115
|
+
)
|
|
116
|
+
return ArrayLease(self, key, consumer, array)
|
|
117
|
+
except BaseException:
|
|
118
|
+
physical.release()
|
|
119
|
+
logical.release()
|
|
120
|
+
raise
|
|
121
|
+
finally:
|
|
122
|
+
with self.condition:
|
|
123
|
+
self.loading.remove(key)
|
|
124
|
+
self.condition.notify_all()
|
|
125
|
+
|
|
126
|
+
def _lease(self, owner, aid, dataset, key, consumer):
|
|
127
|
+
self.repository.assert_current(owner, dataset)
|
|
128
|
+
entry = self.entries[key]
|
|
129
|
+
if consumer in entry.consumers:
|
|
130
|
+
n, res = entry.consumers[consumer]
|
|
131
|
+
entry.consumers[consumer] = (n + 1, res)
|
|
132
|
+
else:
|
|
133
|
+
entry.consumers[consumer] = (
|
|
134
|
+
1,
|
|
135
|
+
self.budget.reserve(
|
|
136
|
+
owner, aid, int(entry.array.nbytes), physical=False
|
|
137
|
+
),
|
|
138
|
+
)
|
|
139
|
+
self.entries.move_to_end(key)
|
|
140
|
+
return ArrayLease(self, key, consumer, entry.array)
|
|
141
|
+
|
|
142
|
+
def load(self, owner, aid, dataset, recipe, materialization_id=None, **kwargs):
|
|
143
|
+
with self.repository.access(owner, dataset) as path:
|
|
144
|
+
import h5py
|
|
145
|
+
|
|
146
|
+
with h5py.File(path, "r") as f:
|
|
147
|
+
shape = f["PeakData/PeakData"].shape
|
|
148
|
+
n = (recipe.frames[1] - recipe.frames[0] + 1) * shape[1] * shape[2] * 4
|
|
149
|
+
if recipe.source == "custom":
|
|
150
|
+
kwargs["reserve"] = lambda size: self.budget.reserve(owner, aid, size)
|
|
151
|
+
return self.acquire(
|
|
152
|
+
owner,
|
|
153
|
+
aid,
|
|
154
|
+
dataset,
|
|
155
|
+
recipe,
|
|
156
|
+
lambda: self.repository.read(owner, dataset, recipe, materialization_id=materialization_id, **kwargs),
|
|
157
|
+
n,
|
|
158
|
+
materialization_id=materialization_id,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def invalidate(self, dataset_key, generation):
|
|
162
|
+
with self.condition:
|
|
163
|
+
for key, entry in list(self.entries.items()):
|
|
164
|
+
if key[:2] == (dataset_key, generation) and not entry.consumers:
|
|
165
|
+
entry.physical.release()
|
|
166
|
+
del self.entries[key]
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
from tofscope.application.spatial import SpatialView
|
|
2
|
+
from typing import Annotated, Literal, Union
|
|
3
|
+
from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictBool, TypeAdapter
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class StrictModel(BaseModel):
|
|
7
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AllDepth(StrictModel):
|
|
11
|
+
mode: Literal["all"] = "all"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SingleFrame(StrictModel):
|
|
15
|
+
mode: Literal["frame"]
|
|
16
|
+
index: StrictInt
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DepthWindow(StrictModel):
|
|
20
|
+
mode: Literal["window"]
|
|
21
|
+
first: StrictInt
|
|
22
|
+
last: StrictInt
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
DepthSelection = Annotated[
|
|
26
|
+
Union[AllDepth, SingleFrame, DepthWindow], Field(discriminator="mode")
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AddStoredSignal(StrictModel):
|
|
31
|
+
type: Literal['add_stored_signal']
|
|
32
|
+
index: StrictInt
|
|
33
|
+
duplicate: StrictBool = False
|
|
34
|
+
frames: tuple[StrictInt, StrictInt] | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SignalId(StrictModel):
|
|
38
|
+
signal_id: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class RenameSignal(SignalId):
|
|
42
|
+
type: Literal['rename_signal']
|
|
43
|
+
name: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AnnotateSignal(SignalId):
|
|
47
|
+
type: Literal['annotate_signal']
|
|
48
|
+
candidate_id: str | None = Field(max_length=64)
|
|
49
|
+
measured_mz: Annotated[float, Field(strict=True, allow_inf_nan=False, gt=0)] | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class SelectImageSignal(SignalId):
|
|
53
|
+
type: Literal['select_image_signal']
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class SetProfileVisible(SignalId):
|
|
57
|
+
type: Literal['set_profile_visible']
|
|
58
|
+
visible: StrictBool
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class RemoveSignal(SignalId):
|
|
62
|
+
type: Literal['remove_signal']
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class SetStoredCoverage(SignalId):
|
|
66
|
+
type: Literal['set_stored_coverage']
|
|
67
|
+
frames: tuple[StrictInt, StrictInt]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class SetROI(StrictModel):
|
|
71
|
+
type: Literal["set_roi"]
|
|
72
|
+
roi: tuple[StrictInt, StrictInt, StrictInt, StrictInt]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class SetDepth(StrictModel):
|
|
76
|
+
type: Literal["set_depth"]
|
|
77
|
+
depth: DepthSelection
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class SetDisplay(StrictModel):
|
|
81
|
+
type: Literal["set_display"]
|
|
82
|
+
log: StrictBool
|
|
83
|
+
relative: StrictBool
|
|
84
|
+
image: Literal["sims", "se"] = "sims"
|
|
85
|
+
spectrum_log: StrictBool | None = None
|
|
86
|
+
colormap: Literal["ion", "gray", "viridis", "magma", "cividis"] | None = None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class AnchorInput(StrictModel):
|
|
90
|
+
frame: StrictInt
|
|
91
|
+
depth_nm: Annotated[float, Field(strict=True, allow_inf_nan=False)]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class SetDepthAnchors(StrictModel):
|
|
95
|
+
type: Literal['set_depth_anchors']
|
|
96
|
+
anchors: tuple[AnchorInput, ...] = Field(max_length=4096)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class SetDepthDisplay(StrictModel):
|
|
100
|
+
type: Literal['set_depth_display']
|
|
101
|
+
unit: Literal['nm', 'um', 'mm']
|
|
102
|
+
coordinates: Literal['frame', 'depth']
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class SetSpatial(StrictModel):
|
|
106
|
+
type: Literal['set_spatial']
|
|
107
|
+
spatial: SpatialView
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
Command = Annotated[
|
|
111
|
+
Union[
|
|
112
|
+
AddStoredSignal,
|
|
113
|
+
RenameSignal,
|
|
114
|
+
AnnotateSignal,
|
|
115
|
+
SelectImageSignal,
|
|
116
|
+
SetProfileVisible,
|
|
117
|
+
RemoveSignal,
|
|
118
|
+
SetStoredCoverage,
|
|
119
|
+
SetROI,
|
|
120
|
+
SetDepth,
|
|
121
|
+
SetDisplay,
|
|
122
|
+
SetDepthAnchors,
|
|
123
|
+
SetDepthDisplay,
|
|
124
|
+
SetSpatial,
|
|
125
|
+
],
|
|
126
|
+
Field(discriminator="type"),
|
|
127
|
+
]
|
|
128
|
+
command_adapter = TypeAdapter(Command)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class CommandEnvelope(StrictModel):
|
|
132
|
+
request_id: str = Field(min_length=1, max_length=128)
|
|
133
|
+
expected_revision: StrictInt = Field(ge=0)
|
|
134
|
+
dataset_generation: str = Field(min_length=1, max_length=128)
|
|
135
|
+
command: Command
|
|
136
|
+
|
|
137
|
+
def __init__(self, *args, **kwargs):
|
|
138
|
+
if args:
|
|
139
|
+
kwargs = (
|
|
140
|
+
dict(
|
|
141
|
+
zip(
|
|
142
|
+
(
|
|
143
|
+
"request_id",
|
|
144
|
+
"expected_revision",
|
|
145
|
+
"dataset_generation",
|
|
146
|
+
"command",
|
|
147
|
+
),
|
|
148
|
+
args,
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
| kwargs
|
|
152
|
+
)
|
|
153
|
+
super().__init__(**kwargs)
|