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,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)