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,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.'