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,161 @@
|
|
|
1
|
+
from dataclasses import replace
|
|
2
|
+
from tofscope.science.depth_calibration import DepthAnchor, validate_anchors
|
|
3
|
+
from tofscope.application.commands import CommandEnvelope
|
|
4
|
+
from tofscope.application.models import DisplaySettings, SignalSnapshot
|
|
5
|
+
from tofscope.application.signals import get_signal, normalize_view, usable_frames, valid_name
|
|
6
|
+
from tofscope.application.analyses import Conflict, CapacityError
|
|
7
|
+
from tofscope.science.signals import SignalRecipe
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ExploreService:
|
|
11
|
+
def __init__(self, workspaces, repository):
|
|
12
|
+
self.analyses = workspaces
|
|
13
|
+
self.repository = repository
|
|
14
|
+
|
|
15
|
+
def stored(self, s, index, frames=None):
|
|
16
|
+
if not 0 <= index < len(s.peaks):
|
|
17
|
+
raise ValueError("Unknown stored channel.")
|
|
18
|
+
coverage = tuple(frames) if frames is not None else (0, s.shape[0] - 1)
|
|
19
|
+
if not 0 <= coverage[0] <= coverage[1] < s.shape[0]:
|
|
20
|
+
raise ValueError("Coverage outside acquisition.")
|
|
21
|
+
p = s.peaks[index]
|
|
22
|
+
return SignalRecipe(
|
|
23
|
+
label=f"{p.label} · {p.mass:g} Th",
|
|
24
|
+
source="stored",
|
|
25
|
+
stored_index=index,
|
|
26
|
+
intervals=((p.low, p.high),),
|
|
27
|
+
frames=coverage,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
def validate_envelope(self, e, envelope, fingerprint, check_source=True):
|
|
31
|
+
s = e.snapshot
|
|
32
|
+
if e.closed:
|
|
33
|
+
raise KeyError("Analysis unavailable.")
|
|
34
|
+
if envelope.request_id in e.receipts:
|
|
35
|
+
old, result = e.receipts[envelope.request_id]
|
|
36
|
+
if old != fingerprint:
|
|
37
|
+
raise Conflict("Request ID reused with different content.", s.revision)
|
|
38
|
+
return result
|
|
39
|
+
if envelope.expected_revision != s.revision:
|
|
40
|
+
raise Conflict("Analysis revision changed.", s.revision)
|
|
41
|
+
if envelope.dataset_generation != s.dataset.generation:
|
|
42
|
+
raise Conflict("Acquisition generation changed.", s.revision)
|
|
43
|
+
if check_source:
|
|
44
|
+
self.repository.assert_current(s.owner, s.dataset)
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
def receipt(self, e, request_id, fingerprint, result):
|
|
48
|
+
e.receipts[request_id] = (fingerprint, result)
|
|
49
|
+
while len(e.receipts) > 256:
|
|
50
|
+
e.receipts.popitem(last=False)
|
|
51
|
+
|
|
52
|
+
def apply(self, owner, analysis_id, envelope):
|
|
53
|
+
if not isinstance(envelope, CommandEnvelope):
|
|
54
|
+
envelope = CommandEnvelope(**envelope)
|
|
55
|
+
fingerprint = envelope.model_dump_json()
|
|
56
|
+
e = self.analyses.entry(owner, analysis_id)
|
|
57
|
+
with self.analyses.signal_lock, e.lock:
|
|
58
|
+
previous = self.validate_envelope(e, envelope, fingerprint)
|
|
59
|
+
if previous is not None:
|
|
60
|
+
return previous
|
|
61
|
+
s = e.snapshot
|
|
62
|
+
c = envelope.command
|
|
63
|
+
kind = c.type
|
|
64
|
+
patch = {}
|
|
65
|
+
if kind == 'add_stored_signal':
|
|
66
|
+
recipe = self.stored(s, c.index, c.frames)
|
|
67
|
+
old = next((r for r in s.signals if r.recipe is not None and
|
|
68
|
+
r.recipe.source == 'stored' and r.recipe.stored_index == c.index and
|
|
69
|
+
r.recipe.frames == recipe.frames), None)
|
|
70
|
+
if old and not c.duplicate:
|
|
71
|
+
patch['profile_signal_ids'] = tuple(dict.fromkeys((*s.profile_signal_ids, old.id)))
|
|
72
|
+
else:
|
|
73
|
+
self.analyses.admit_signal(owner, len(s.signals))
|
|
74
|
+
signal = SignalSnapshot(recipe.id, recipe.label, s.next_style_index, recipe)
|
|
75
|
+
patch = dict(signals=(*s.signals, signal), next_style_index=s.next_style_index+1,
|
|
76
|
+
profile_signal_ids=(*s.profile_signal_ids, signal.id))
|
|
77
|
+
elif kind == 'annotate_signal':
|
|
78
|
+
from tofscope.application.identification import assignment
|
|
79
|
+
signal = get_signal(s, c.signal_id)
|
|
80
|
+
updated = replace(signal, assignment=assignment(c.candidate_id, c.measured_mz))
|
|
81
|
+
patch['signals'] = tuple(updated if r.id == signal.id else r for r in s.signals)
|
|
82
|
+
elif kind in ('rename_signal', 'select_image_signal', 'set_profile_visible', 'remove_signal', 'set_stored_coverage'):
|
|
83
|
+
signal = get_signal(s, c.signal_id)
|
|
84
|
+
if kind == 'rename_signal':
|
|
85
|
+
updated = replace(signal, name=valid_name(c.name), name_revision=signal.name_revision+1)
|
|
86
|
+
patch['signals'] = tuple(updated if r.id == signal.id else r for r in s.signals)
|
|
87
|
+
elif kind == 'select_image_signal':
|
|
88
|
+
if not usable_frames(s, signal):
|
|
89
|
+
raise ValueError('Signal has no ready coverage in the acquisition.')
|
|
90
|
+
patch['image_signal_id'] = signal.id
|
|
91
|
+
elif kind == 'set_profile_visible':
|
|
92
|
+
if signal.id == s.image_signal_id and not c.visible:
|
|
93
|
+
raise ValueError('The image signal is included in its depth profile.')
|
|
94
|
+
if signal.recipe is None:
|
|
95
|
+
raise ValueError('Signal calculation is not ready.')
|
|
96
|
+
patch['profile_signal_ids'] = tuple(dict.fromkeys((*s.profile_signal_ids, signal.id))) if c.visible else tuple(x for x in s.profile_signal_ids if x != signal.id)
|
|
97
|
+
elif kind == 'remove_signal':
|
|
98
|
+
patch['signals'] = tuple(r for r in s.signals if r.id != signal.id)
|
|
99
|
+
patch['profile_signal_ids'] = tuple(x for x in s.profile_signal_ids if x != signal.id)
|
|
100
|
+
if signal.id == s.image_signal_id:
|
|
101
|
+
eligible = [r for r in patch['signals'] if usable_frames(s, r)]
|
|
102
|
+
if not eligible:
|
|
103
|
+
raise ValueError('Add another ready image signal before removing this one.')
|
|
104
|
+
patch['image_signal_id'] = eligible[0].id
|
|
105
|
+
else:
|
|
106
|
+
if signal.recipe is None or signal.recipe.source != 'stored':
|
|
107
|
+
raise ValueError('Only stored channel coverage can be changed without calculation.')
|
|
108
|
+
first, last = c.frames
|
|
109
|
+
if not 0 <= first <= last < s.shape[0]:
|
|
110
|
+
raise ValueError('Coverage outside acquisition.')
|
|
111
|
+
updated = replace(signal, recipe=replace(signal.recipe, frames=tuple(c.frames)), recipe_version=signal.recipe_version+1)
|
|
112
|
+
patch['signals'] = tuple(updated if r.id == signal.id else r for r in s.signals)
|
|
113
|
+
elif kind == 'set_spatial':
|
|
114
|
+
from tofscope.application.spatial import validate_spatial
|
|
115
|
+
patch['spatial'] = validate_spatial(c.spatial, s.signals)
|
|
116
|
+
elif kind == 'set_roi':
|
|
117
|
+
x0, x1, y0, y1 = c.roi
|
|
118
|
+
if not 0 <= x0 <= x1 < s.shape[2] or not 0 <= y0 <= y1 < s.shape[1]:
|
|
119
|
+
raise ValueError('ROI outside pixel domain.')
|
|
120
|
+
patch['roi'] = tuple(c.roi)
|
|
121
|
+
elif kind == 'set_depth':
|
|
122
|
+
d = c.depth
|
|
123
|
+
first, last = 0, s.shape[0] - 1
|
|
124
|
+
if d.mode == 'frame' and not first <= d.index <= last:
|
|
125
|
+
raise ValueError('Frame outside acquisition.')
|
|
126
|
+
if d.mode == 'window' and not first <= d.first <= d.last <= last:
|
|
127
|
+
raise ValueError('Window outside acquisition.')
|
|
128
|
+
patch['depth'] = d
|
|
129
|
+
elif kind == 'set_depth_anchors':
|
|
130
|
+
anchors = validate_anchors(tuple(DepthAnchor(x.frame, x.depth_nm) for x in c.anchors), s.shape[0])
|
|
131
|
+
patch['depth_anchors'] = anchors
|
|
132
|
+
if len(anchors) < 2:
|
|
133
|
+
patch['depth_coordinates'] = 'frame'
|
|
134
|
+
elif kind == 'set_depth_display':
|
|
135
|
+
if c.coordinates == 'depth' and len(s.depth_anchors) < 2:
|
|
136
|
+
raise ValueError('Add a positive-depth anchor first.')
|
|
137
|
+
patch.update(depth_unit=c.unit, depth_coordinates=c.coordinates)
|
|
138
|
+
elif kind == 'set_display':
|
|
139
|
+
patch['display'] = DisplaySettings(c.log, c.relative, c.image, s.display.spectrum_log if c.spectrum_log is None else c.spectrum_log, s.display.colormap if c.colormap is None else c.colormap)
|
|
140
|
+
if kind == 'remove_signal':
|
|
141
|
+
patch['spatial'] = s.spatial.model_copy(update={
|
|
142
|
+
'layers': tuple(x for x in s.spatial.layers if x.signal_id != signal.id),
|
|
143
|
+
'solo': None if s.spatial.solo == signal.id else s.spatial.solo,
|
|
144
|
+
})
|
|
145
|
+
result = normalize_view(replace(s, revision=s.revision+1, **patch))
|
|
146
|
+
if result.isotope_measurement:
|
|
147
|
+
from tofscope.application.identification import current_result
|
|
148
|
+
result = replace(result, isotope_measurement=current_result(result))
|
|
149
|
+
if kind == 'remove_signal':
|
|
150
|
+
e.tokens.pop(signal.id, None)
|
|
151
|
+
# Hidden and removed signals retain metadata without pinned volume leases.
|
|
152
|
+
visible = set(result.profile_signal_ids) | {result.image_signal_id}
|
|
153
|
+
for sid in list(e.leases):
|
|
154
|
+
if sid not in visible or kind == 'set_stored_coverage' and sid == signal.id:
|
|
155
|
+
e.leases.pop(sid).release()
|
|
156
|
+
e.snapshot = result
|
|
157
|
+
if kind == "remove_signal":
|
|
158
|
+
for callback in self.analyses.on_remove:
|
|
159
|
+
callback(analysis_id, signal.id)
|
|
160
|
+
self.receipt(e, envelope.request_id, fingerprint, result)
|
|
161
|
+
return result
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Owner-scoped atomic matching and explicit, bounded isotope measurements."""
|
|
2
|
+
from dataclasses import dataclass, replace, asdict
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import numpy as np
|
|
6
|
+
from tofscope.application.identification_models import (
|
|
7
|
+
AtomicAssignment, CatalogProvenance, CandidateResult, MeasuredSignal, MeasurementResult,
|
|
8
|
+
)
|
|
9
|
+
from tofscope.application.signals import get_signal
|
|
10
|
+
from tofscope.application.analyses import CapacityError
|
|
11
|
+
from tofscope.science.isotopes import CATALOG_PATH, load_catalog, find_candidates, ion_mz, CandidateLimitExceeded
|
|
12
|
+
from tofscope.science.abundance import measure_isotopes
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def catalog_provenance():
|
|
16
|
+
meta = json.loads(CATALOG_PATH.read_text())['metadata']
|
|
17
|
+
return CatalogProvenance(meta['records_sha256'], meta['retrieved'], json.dumps(meta['sources'], sort_keys=True))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def assignment(candidate_id, measured_mz=None):
|
|
21
|
+
if candidate_id is None:
|
|
22
|
+
return None
|
|
23
|
+
for isotope in load_catalog():
|
|
24
|
+
for charge in (-3, -2, -1, 1, 2, 3):
|
|
25
|
+
if candidate_id == f'{isotope.symbol}-{isotope.mass_number}:{charge:+d}':
|
|
26
|
+
predicted = ion_mz(isotope, charge)
|
|
27
|
+
error = measured_mz - predicted if measured_mz is not None else None
|
|
28
|
+
return AtomicAssignment(candidate_id, isotope, charge, predicted, catalog_provenance(),
|
|
29
|
+
measured_mz, error, error / predicted * 1e6 if error is not None else None)
|
|
30
|
+
raise ValueError('Atomic candidate unavailable.')
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def requested_frames(snapshot):
|
|
34
|
+
depth = snapshot.depth
|
|
35
|
+
return ((depth.index, depth.index) if depth.mode == 'frame' else
|
|
36
|
+
(depth.first, depth.last) if depth.mode == 'window' else (0, snapshot.shape[0] - 1))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def dependency_key(snapshot, ids):
|
|
40
|
+
rows = []
|
|
41
|
+
for sid in ids:
|
|
42
|
+
signal = get_signal(snapshot, sid)
|
|
43
|
+
rows.append((sid, signal.recipe_version, asdict(signal.recipe) if signal.recipe else None,
|
|
44
|
+
signal.materialization_id, asdict(signal.assignment) if signal.assignment else None))
|
|
45
|
+
value = (snapshot.dataset.generation, snapshot.roi, requested_frames(snapshot), rows)
|
|
46
|
+
return hashlib.sha256(json.dumps(value, sort_keys=True).encode()).hexdigest()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def current_result(snapshot):
|
|
50
|
+
result = snapshot.isotope_measurement
|
|
51
|
+
if result is None:
|
|
52
|
+
return None
|
|
53
|
+
try:
|
|
54
|
+
stale = snapshot.stale or result.dependency_key != dependency_key(snapshot, [r.signal_id for r in result.rows])
|
|
55
|
+
except ValueError:
|
|
56
|
+
stale = True
|
|
57
|
+
return replace(result, out_of_date=stale)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class MeasurementTask:
|
|
62
|
+
snapshot: object
|
|
63
|
+
signal_ids: tuple[str, ...]
|
|
64
|
+
dependency_key: str
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class IdentificationService:
|
|
68
|
+
def __init__(self, analyses, explore, cache, jobs):
|
|
69
|
+
self.analyses, self.explore, self.cache, self.jobs = analyses, explore, cache, jobs
|
|
70
|
+
self.catalog = load_catalog()
|
|
71
|
+
self.provenance = catalog_provenance()
|
|
72
|
+
jobs.measurement_handler = self.run
|
|
73
|
+
|
|
74
|
+
def candidates(self, owner, aid, mz, tolerance, unit, charges):
|
|
75
|
+
s = self.analyses.get(owner, aid)
|
|
76
|
+
self.cache.repository.assert_current(owner, s.dataset)
|
|
77
|
+
limit = min(256, max(0, (self.jobs.settings.response_bytes - 4096) // 2048))
|
|
78
|
+
try:
|
|
79
|
+
candidates = find_candidates(mz, tolerance, unit, charges, self.catalog, max_results=limit)
|
|
80
|
+
except CandidateLimitExceeded as exc:
|
|
81
|
+
raise CapacityError(str(exc)) from None
|
|
82
|
+
return CandidateResult(tuple(candidates), self.provenance)
|
|
83
|
+
|
|
84
|
+
def measure(self, owner, aid, envelope, recipe_ids):
|
|
85
|
+
ids = tuple(recipe_ids)
|
|
86
|
+
fingerprint = json.dumps({'isotopes': ids, 'envelope': envelope.model_dump(mode='json')}, sort_keys=True)
|
|
87
|
+
e = self.analyses.entry(owner, aid)
|
|
88
|
+
with e.lock:
|
|
89
|
+
old = self.explore.validate_envelope(e, envelope, fingerprint)
|
|
90
|
+
if old is not None:
|
|
91
|
+
return old
|
|
92
|
+
s = e.snapshot
|
|
93
|
+
if not ids or len(ids) > 128 or len(set(ids)) != len(ids):
|
|
94
|
+
raise ValueError('Choose 1–128 distinct ready signals.')
|
|
95
|
+
if any(get_signal(s, sid).recipe is None for sid in ids):
|
|
96
|
+
raise ValueError('Finish signal calculations before measuring.')
|
|
97
|
+
task = MeasurementTask(s, ids, dependency_key(s, ids))
|
|
98
|
+
with self.jobs.condition:
|
|
99
|
+
job = self.jobs.enqueue_task(owner, task, 'measurement', aid)
|
|
100
|
+
self.jobs.jobs[job.id].referenced = True
|
|
101
|
+
previous = self.jobs.jobs.get(s.isotope_job_id)
|
|
102
|
+
if previous:
|
|
103
|
+
previous.referenced = False
|
|
104
|
+
terminal = [jid for jid, item in self.jobs.jobs.items()
|
|
105
|
+
if item.owner == owner and not item.referenced and
|
|
106
|
+
item.snapshot.status in {'succeeded', 'failed', 'cancelled', 'obsolete'}]
|
|
107
|
+
for jid in terminal[:-256]:
|
|
108
|
+
del self.jobs.jobs[jid]
|
|
109
|
+
# A new explicit measurement supersedes an earlier pending result.
|
|
110
|
+
e.snapshot = replace(s, revision=s.revision+1, isotope_job_id=job.id)
|
|
111
|
+
self.explore.receipt(e, envelope.request_id, fingerprint, job)
|
|
112
|
+
return job
|
|
113
|
+
|
|
114
|
+
def result(self, owner, aid):
|
|
115
|
+
s = self.analyses.get(owner, aid)
|
|
116
|
+
self.cache.repository.assert_current(owner, s.dataset)
|
|
117
|
+
return current_result(s)
|
|
118
|
+
|
|
119
|
+
def run(self, job):
|
|
120
|
+
task = job.task
|
|
121
|
+
s = task.snapshot
|
|
122
|
+
e = self.analyses.entry(job.owner, s.id)
|
|
123
|
+
signals = tuple(get_signal(s, sid) for sid in task.signal_ids)
|
|
124
|
+
|
|
125
|
+
def cancelled():
|
|
126
|
+
return (job.cancelled.is_set() or e.closed or e.snapshot.dataset != s.dataset or
|
|
127
|
+
e.snapshot.isotope_job_id != job.snapshot.id)
|
|
128
|
+
|
|
129
|
+
def check():
|
|
130
|
+
if cancelled():
|
|
131
|
+
raise InterruptedError()
|
|
132
|
+
|
|
133
|
+
def read(path, recipe):
|
|
134
|
+
with e.lock:
|
|
135
|
+
check()
|
|
136
|
+
signal = next(row for row in signals if row.recipe.id == recipe.id)
|
|
137
|
+
return self.cache.load(job.owner, s.id, s.dataset, recipe,
|
|
138
|
+
materialization_id=signal.materialization_id, cancel=cancelled)
|
|
139
|
+
|
|
140
|
+
# The numerical routine owns one lease at a time; it never accumulates volumes.
|
|
141
|
+
rows = measure_isotopes(None, [r.recipe for r in signals], s.roi, requested_frames(s), reader=read)
|
|
142
|
+
if any(r.summed_intensity is not None and not np.isfinite(r.summed_intensity) for r in rows):
|
|
143
|
+
raise ValueError('Non-finite measurement intensity.')
|
|
144
|
+
result = MeasurementResult(job.snapshot.id, s.dataset.generation, s.roi, requested_frames(s),
|
|
145
|
+
task.dependency_key, tuple(MeasuredSignal(row.id, row.name, row.assignment, row.recipe.intervals, value)
|
|
146
|
+
for row, value in zip(signals, rows)), self.provenance)
|
|
147
|
+
with e.lock:
|
|
148
|
+
check()
|
|
149
|
+
self.cache.repository.assert_current(job.owner, s.dataset)
|
|
150
|
+
# Preserve captured results even if ROI/recipes changed while the job ran.
|
|
151
|
+
updated = replace(e.snapshot, revision=e.snapshot.revision+1, isotope_measurement=result)
|
|
152
|
+
e.snapshot = replace(updated, isotope_measurement=current_result(updated))
|
|
153
|
+
return None
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Captured atomic assignments and isotope measurement provenance."""
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
import re
|
|
6
|
+
from tofscope.science.isotopes import IsotopeRecord, IonCandidate, ion_mz
|
|
7
|
+
from tofscope.science.abundance import IsotopeMeasurement
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class CatalogProvenance:
|
|
12
|
+
records_sha256: str
|
|
13
|
+
retrieved: str
|
|
14
|
+
sources_json: str
|
|
15
|
+
convention: str = 'Neutral isotope mass minus signed electron mass; binding energies omitted.'
|
|
16
|
+
|
|
17
|
+
def __post_init__(self):
|
|
18
|
+
if not re.fullmatch(r'[0-9a-f]{64}', self.records_sha256) or not self.retrieved or not self.convention:
|
|
19
|
+
raise ValueError('Incomplete catalog provenance.')
|
|
20
|
+
if not json.loads(self.sources_json):
|
|
21
|
+
raise ValueError('Missing catalog sources.')
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class AtomicAssignment:
|
|
26
|
+
candidate_id: str
|
|
27
|
+
isotope: IsotopeRecord
|
|
28
|
+
charge: int
|
|
29
|
+
predicted_mz: float
|
|
30
|
+
catalog: CatalogProvenance
|
|
31
|
+
measured_mz: float | None = None
|
|
32
|
+
error_Th: float | None = None
|
|
33
|
+
error_ppm: float | None = None
|
|
34
|
+
|
|
35
|
+
def __post_init__(self):
|
|
36
|
+
expected = ion_mz(self.isotope, self.charge)
|
|
37
|
+
identity = f'{self.isotope.symbol}-{self.isotope.mass_number}:{self.charge:+d}'
|
|
38
|
+
if self.candidate_id != identity or not math.isclose(self.predicted_mz, expected, rel_tol=1e-12):
|
|
39
|
+
raise ValueError('Inconsistent atomic assignment.')
|
|
40
|
+
if self.measured_mz is None:
|
|
41
|
+
if self.error_Th is not None or self.error_ppm is not None:
|
|
42
|
+
raise ValueError('Mass errors require a measured mass.')
|
|
43
|
+
elif (not math.isfinite(self.measured_mz) or self.measured_mz <= 0 or
|
|
44
|
+
self.error_Th is None or self.error_ppm is None or
|
|
45
|
+
not math.isclose(self.error_Th, self.measured_mz - expected, abs_tol=1e-12) or
|
|
46
|
+
not math.isclose(self.error_ppm, (self.measured_mz - expected) / expected * 1e6, abs_tol=1e-9)):
|
|
47
|
+
raise ValueError('Inconsistent measured mass error.')
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class CandidateResult:
|
|
52
|
+
candidates: tuple[IonCandidate, ...]
|
|
53
|
+
catalog: CatalogProvenance
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class MeasuredSignal:
|
|
58
|
+
signal_id: str
|
|
59
|
+
name: str
|
|
60
|
+
assignment: AtomicAssignment | None
|
|
61
|
+
intervals: tuple[tuple[float, float], ...]
|
|
62
|
+
measurement: IsotopeMeasurement
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class MeasurementResult:
|
|
67
|
+
job_id: str
|
|
68
|
+
dataset_generation: str
|
|
69
|
+
roi: tuple[int, int, int, int]
|
|
70
|
+
frames: tuple[int, int]
|
|
71
|
+
dependency_key: str
|
|
72
|
+
rows: tuple[MeasuredSignal, ...]
|
|
73
|
+
catalog: CatalogProvenance
|
|
74
|
+
out_of_date: bool = False
|
|
75
|
+
interpretation: str = 'Selected-set signal fractions; not quantitative composition.'
|