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
|
File without changes
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Original-intensity isotope areas, with explicit coverage and ambiguity."""
|
|
2
|
+
from dataclasses import dataclass,replace
|
|
3
|
+
import numpy as np
|
|
4
|
+
from contextlib import nullcontext
|
|
5
|
+
from tofscope.science.signals import read_signal
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class IsotopeMeasurement:
|
|
10
|
+
recipe_id: str
|
|
11
|
+
requested_frames: tuple[int,int]
|
|
12
|
+
actual_frames: tuple[int,int] | None
|
|
13
|
+
summed_intensity: float | None
|
|
14
|
+
observed_fraction: float | None
|
|
15
|
+
ambiguous: bool
|
|
16
|
+
status: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def measure_isotopes(path,recipes,roi,frames,*,reader=None):
|
|
20
|
+
recipes=tuple(recipes)
|
|
21
|
+
if not recipes:raise ValueError('Choose at least one isotope window.')
|
|
22
|
+
if len(frames)!=2 or any(type(z) is not int for z in frames) or not 0<=frames[0]<=frames[1]:raise ValueError('Invalid measurement frame limits.')
|
|
23
|
+
if len(roi)!=4 or any(type(z) is not int for z in roi) or not (0<=roi[0]<=roi[1] and 0<=roi[2]<=roi[3]):raise ValueError('Invalid measurement ROI.')
|
|
24
|
+
first=max(frames[0],*(r.frames[0] for r in recipes));last=min(frames[1],*(r.frames[1] for r in recipes))
|
|
25
|
+
ambiguous=set()
|
|
26
|
+
for i,a in enumerate(recipes):
|
|
27
|
+
for j,b in enumerate(recipes[:i]):
|
|
28
|
+
if any(max(x0,y0)<=min(x1,y1) for x0,x1 in a.intervals for y0,y1 in b.intervals):ambiguous.update((i,j))
|
|
29
|
+
if first>last:
|
|
30
|
+
return [IsotopeMeasurement(r.id,tuple(frames),None,None,None,i in ambiguous,'No common frame coverage') for i,r in enumerate(recipes)]
|
|
31
|
+
rows=[];x0,x1,y0,y1=roi
|
|
32
|
+
for i,recipe in enumerate(recipes):
|
|
33
|
+
with reader(path,recipe) if reader else nullcontext(read_signal(path,recipe)) as source:
|
|
34
|
+
data = source.array if reader else source
|
|
35
|
+
if x1>=data.shape[2] or y1>=data.shape[1]:raise ValueError('Measurement ROI is outside the acquisition.')
|
|
36
|
+
area=float(data[first-recipe.frames[0]:last-recipe.frames[0]+1,y0:y1+1,x0:x1+1].sum(dtype=np.float64))
|
|
37
|
+
status='Overlapping mass windows' if i in ambiguous else 'Ambiguous selected set' if ambiguous else 'Common frame coverage' if (first,last)!=tuple(frames) else 'Measured'
|
|
38
|
+
rows.append(IsotopeMeasurement(recipe.id,tuple(frames),(first,last),area,None,i in ambiguous,status))
|
|
39
|
+
total=sum(r.summed_intensity for r in rows)
|
|
40
|
+
if not ambiguous and np.isfinite(total) and total>0:
|
|
41
|
+
rows=[replace(r,observed_fraction=r.summed_intensity/total) for r in rows]
|
|
42
|
+
return rows
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Physical profile coordinates; acquisition intensities and frames stay intact."""
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class DepthAnchor:
|
|
8
|
+
frame: int
|
|
9
|
+
depth_nm: float
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def convert_depth(value,source_unit,target_unit):
|
|
13
|
+
scales={'nm':1.,'µm':1000.,'um':1000.,'mm':1e6}
|
|
14
|
+
if source_unit not in scales or target_unit not in scales:raise ValueError('Depth units must be nm, µm or mm.')
|
|
15
|
+
return np.asarray(value)*scales[source_unit]/scales[target_unit]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def calibrated_window(frames,calibration=None):
|
|
19
|
+
"""Intersect display coverage without extrapolating or changing selection."""
|
|
20
|
+
first,last=frames
|
|
21
|
+
if calibration:
|
|
22
|
+
first=max(first,calibration.anchors[0].frame);last=min(last,calibration.anchors[-1].frame)
|
|
23
|
+
return (first,last) if first<=last else None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def display_depth(frames,calibration=None,unit='nm'):
|
|
27
|
+
return convert_depth(calibration.frame_to_depth(frames),'nm',unit) if calibration else np.asarray(frames)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def validate_anchors(anchors,acquisition_frames):
|
|
31
|
+
if type(acquisition_frames) is not int or acquisition_frames<1:raise ValueError('Acquisition must contain frames.')
|
|
32
|
+
rows=[]
|
|
33
|
+
for a in anchors:
|
|
34
|
+
if type(a.frame) is not int or not 0<=a.frame<acquisition_frames:raise ValueError('Depth anchor frame must be an acquisition frame integer.')
|
|
35
|
+
if not np.isfinite(a.depth_nm):raise ValueError('Depths must be finite.')
|
|
36
|
+
if a.frame==0 and a.depth_nm!=0:raise ValueError('Frame 0 is fixed at depth 0.')
|
|
37
|
+
rows.append(DepthAnchor(a.frame,float(a.depth_nm)))
|
|
38
|
+
if not any(a.frame==0 for a in rows):rows.append(DepthAnchor(0,0.))
|
|
39
|
+
rows.sort(key=lambda a:a.frame)
|
|
40
|
+
for a,b in zip(rows,rows[1:]):
|
|
41
|
+
if a.frame==b.frame:raise ValueError('Depth anchor frames must be unique.')
|
|
42
|
+
if a.depth_nm>=b.depth_nm:raise ValueError('Depth must increase with acquisition frame.')
|
|
43
|
+
return tuple(rows)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class DepthCalibration:
|
|
48
|
+
anchors: tuple[DepthAnchor,...]
|
|
49
|
+
acquisition_frames: int
|
|
50
|
+
|
|
51
|
+
def __post_init__(self):
|
|
52
|
+
rows=validate_anchors(self.anchors,self.acquisition_frames)
|
|
53
|
+
if len(rows)<2:raise ValueError('Add a positive-depth anchor to activate calibration.')
|
|
54
|
+
object.__setattr__(self,'anchors',rows)
|
|
55
|
+
|
|
56
|
+
def frame_to_depth(self,frames):
|
|
57
|
+
return np.interp(frames,[a.frame for a in self.anchors],[a.depth_nm for a in self.anchors],left=np.nan,right=np.nan)
|
|
58
|
+
|
|
59
|
+
def depth_to_frame(self,depth_nm):
|
|
60
|
+
return np.interp(depth_nm,[a.depth_nm for a in self.anchors],[a.frame for a in self.anchors],left=np.nan,right=np.nan)
|
|
61
|
+
|
|
62
|
+
def to_dict(self,unit='nm'):
|
|
63
|
+
convert_depth(1,'nm',unit)
|
|
64
|
+
return dict(anchors=[dict(frame=a.frame,depth_nm=a.depth_nm) for a in self.anchors],
|
|
65
|
+
acquisition_frames=self.acquisition_frames,unit='µm' if unit=='um' else unit,
|
|
66
|
+
interpolation='piecewise_linear',extrapolation=False)
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def from_dict(cls,data):
|
|
70
|
+
if data.get('interpolation')!='piecewise_linear' or data.get('extrapolation') is not False:
|
|
71
|
+
raise ValueError('Unsupported depth interpolation policy.')
|
|
72
|
+
convert_depth(1,'nm',data['unit'])
|
|
73
|
+
return cls(tuple(DepthAnchor(**a) for a in data['anchors']),data['acquisition_frames'])
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Inclusive mass-window unions; display transforms never enter this module."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import numpy as np
|
|
5
|
+
from tofscope.storage.dataset_io import locked_read
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class EventBatch:
|
|
10
|
+
"""Reader-independent event values and each spatial cell's slice into them."""
|
|
11
|
+
|
|
12
|
+
samples: np.ndarray
|
|
13
|
+
offsets: np.ndarray
|
|
14
|
+
lengths: np.ndarray
|
|
15
|
+
shape: tuple[int, ...]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def canonical_intervals(intervals, axis_limits):
|
|
19
|
+
"""Sort and merge touching/overlapping intervals, rejecting invalid limits."""
|
|
20
|
+
try:
|
|
21
|
+
limits = np.asarray(axis_limits, dtype=float)
|
|
22
|
+
pairs = np.asarray(tuple(intervals), dtype=float)
|
|
23
|
+
except (ValueError, TypeError) as exc:
|
|
24
|
+
raise ValueError("Mass windows must contain finite lower/upper pairs.") from exc
|
|
25
|
+
if limits.shape != (2,) or np.isnan(limits).any() or limits[0] >= limits[1]:
|
|
26
|
+
raise ValueError("Invalid calibrated mass-axis limits.")
|
|
27
|
+
if pairs.ndim != 2 or pairs.shape[1] != 2 or not len(pairs):
|
|
28
|
+
raise ValueError("Add at least one mass interval with lower and upper limits.")
|
|
29
|
+
if (
|
|
30
|
+
not np.isfinite(pairs).all()
|
|
31
|
+
or np.any(pairs[:, 0] >= pairs[:, 1])
|
|
32
|
+
or np.any(pairs[:, 0] < limits[0])
|
|
33
|
+
or np.any(pairs[:, 1] > limits[1])
|
|
34
|
+
):
|
|
35
|
+
raise ValueError(
|
|
36
|
+
"Mass windows must be finite, lower < upper, and within the calibrated mass axis."
|
|
37
|
+
)
|
|
38
|
+
merged = []
|
|
39
|
+
for low, high in sorted(map(tuple, pairs)):
|
|
40
|
+
if merged and low <= merged[-1][1]:
|
|
41
|
+
merged[-1] = (merged[-1][0], max(high, merged[-1][1]))
|
|
42
|
+
else:
|
|
43
|
+
merged.append((float(low), float(high)))
|
|
44
|
+
return tuple(merged)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def integrate_event_windows(events, mass_axis, clock_ratio, normalization, intervals):
|
|
48
|
+
"""Count each raw event once when its calibrated mass belongs to the union."""
|
|
49
|
+
# Compare exact stored bin values with user bounds without rounding those
|
|
50
|
+
# bounds down to the file's (often float32) dtype.
|
|
51
|
+
axis = np.asarray(mass_axis, dtype=np.float64)
|
|
52
|
+
if axis.ndim != 1 or not axis.size or not np.isfinite(axis).all():
|
|
53
|
+
raise ValueError("Mass axis must be a nonempty finite one-dimensional array.")
|
|
54
|
+
windows = canonical_intervals(intervals, (float(axis.min()), float(axis.max())))
|
|
55
|
+
if (
|
|
56
|
+
not np.isfinite([clock_ratio, normalization]).all()
|
|
57
|
+
or clock_ratio < 1
|
|
58
|
+
or int(clock_ratio) != clock_ratio
|
|
59
|
+
or normalization <= 0
|
|
60
|
+
):
|
|
61
|
+
raise ValueError("Invalid timing ratio or normalization.")
|
|
62
|
+
included = np.zeros(len(axis), dtype=bool)
|
|
63
|
+
for low, high in windows:
|
|
64
|
+
included |= (axis >= low) & (axis <= high)
|
|
65
|
+
if isinstance(events, EventBatch):
|
|
66
|
+
lengths = events.lengths
|
|
67
|
+
offsets = events.offsets
|
|
68
|
+
raw_samples = events.samples
|
|
69
|
+
else:
|
|
70
|
+
cells = events.ravel()
|
|
71
|
+
lengths = np.fromiter(
|
|
72
|
+
(len(cell) for cell in cells), dtype=np.int64, count=cells.size
|
|
73
|
+
)
|
|
74
|
+
offsets = np.concatenate(([0], np.cumsum(lengths[:-1])))
|
|
75
|
+
raw_samples = np.concatenate(list(cells)) if lengths.sum() else np.empty(0)
|
|
76
|
+
if not lengths.sum():
|
|
77
|
+
return np.zeros(events.shape, dtype=np.float32)
|
|
78
|
+
samples = raw_samples.astype(np.int64) // int(clock_ratio)
|
|
79
|
+
valid = (samples >= 0) & (samples < len(axis))
|
|
80
|
+
selected = np.zeros(len(samples), dtype=np.int32)
|
|
81
|
+
selected[valid] = included[samples[valid]]
|
|
82
|
+
counts = np.concatenate(([0], np.cumsum(selected, dtype=np.int64)))
|
|
83
|
+
per_cell = counts[offsets + lengths] - counts[offsets]
|
|
84
|
+
return (per_cell.reshape(events.shape) / normalization).astype(np.float32)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@locked_read
|
|
88
|
+
def custom_peak_windows(
|
|
89
|
+
path, intervals, first, last, progress=None, cancel=None, reserve=None
|
|
90
|
+
):
|
|
91
|
+
"""Reconstruct a union in one read per supported acquisition depth slice."""
|
|
92
|
+
import h5py
|
|
93
|
+
from operator import index
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
if isinstance(first, bool) or isinstance(last, bool):
|
|
97
|
+
raise TypeError
|
|
98
|
+
first, last = index(first), index(last)
|
|
99
|
+
except TypeError as exc:
|
|
100
|
+
raise ValueError("Integration frames must be integers.") from exc
|
|
101
|
+
with h5py.File(path, "r") as f:
|
|
102
|
+
if "FullSpectra/EventList" not in f:
|
|
103
|
+
raise ValueError("This file does not retain raw events.")
|
|
104
|
+
nz, ny, nx, _ = f["PeakData/PeakData"].shape
|
|
105
|
+
raw = f["FullSpectra/EventList"]
|
|
106
|
+
from contextlib import ExitStack
|
|
107
|
+
|
|
108
|
+
class Unmanaged:
|
|
109
|
+
def __enter__(self):
|
|
110
|
+
return self
|
|
111
|
+
|
|
112
|
+
def __exit__(self, *args):
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
def release(self):
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
reserve = reserve or (lambda size: Unmanaged())
|
|
119
|
+
with ExitStack() as allocation:
|
|
120
|
+
allocation.enter_context(
|
|
121
|
+
reserve(int(f["FullSpectra/MassAxis"].size * 32 + 4096))
|
|
122
|
+
)
|
|
123
|
+
return _integrate_frames(
|
|
124
|
+
f, raw, nz, ny, nx, intervals, first, last, progress, cancel, reserve
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _integrate_frames(
|
|
129
|
+
f, raw, nz, ny, nx, intervals, first, last, progress, cancel, reserve
|
|
130
|
+
):
|
|
131
|
+
from tofscope.storage.bounded_events import admitted_events
|
|
132
|
+
from tofscope.storage.explorer import integration_parameters
|
|
133
|
+
|
|
134
|
+
axis = f["FullSpectra/MassAxis"][:]
|
|
135
|
+
windows = canonical_intervals(intervals, (float(axis.min()), float(axis.max())))
|
|
136
|
+
if not 0 <= first <= last < nz or last >= raw.shape[0] or raw.shape[1:] != (ny, nx):
|
|
137
|
+
raise ValueError("Requested frame range or raw spatial shape is invalid.")
|
|
138
|
+
ratio, normalization = integration_parameters(f)
|
|
139
|
+
volume = np.empty((last - first + 1, ny, nx), dtype=np.float32)
|
|
140
|
+
for i, z in enumerate(range(first, last + 1)):
|
|
141
|
+
if cancel is not None and cancel():
|
|
142
|
+
raise InterruptedError("Integration cancelled.")
|
|
143
|
+
with admitted_events(raw, z, reserve) as events:
|
|
144
|
+
volume[i] = integrate_event_windows(
|
|
145
|
+
events, axis, ratio, normalization, windows
|
|
146
|
+
)
|
|
147
|
+
if progress is not None:
|
|
148
|
+
progress(i + 1, len(volume))
|
|
149
|
+
return volume
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Offline atomic-isotope candidates with explicit charge and provenance."""
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
ELECTRON_MASS_U=0.0005485799090441 # CODATA2022, NIST electron mass in u.
|
|
10
|
+
from importlib.resources import files
|
|
11
|
+
CATALOG_PATH=files('tofscope.resources').joinpath('reference/isotopes.json')
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class IsotopeRecord:
|
|
16
|
+
symbol: str
|
|
17
|
+
mass_number: int
|
|
18
|
+
neutral_mass_u: float
|
|
19
|
+
abundance_low: float | None
|
|
20
|
+
abundance_high: float | None
|
|
21
|
+
source_id: str
|
|
22
|
+
mass_uncertainty_u: float | None = None
|
|
23
|
+
mass_estimated: bool = False
|
|
24
|
+
abundance_uncertainty: float | None = None
|
|
25
|
+
|
|
26
|
+
def __post_init__(self):
|
|
27
|
+
if not re.fullmatch(r'[A-Z][a-z]{0,2}',self.symbol) or type(self.mass_number) is not int or self.mass_number<1:
|
|
28
|
+
raise ValueError('Invalid isotope identity.')
|
|
29
|
+
if not math.isfinite(self.neutral_mass_u) or self.neutral_mass_u<=0 or not self.source_id:
|
|
30
|
+
raise ValueError('Invalid isotope mass or source.')
|
|
31
|
+
lo,hi=self.abundance_low,self.abundance_high
|
|
32
|
+
if (lo is None)!=(hi is None) or (lo is not None and not (math.isfinite(lo) and math.isfinite(hi) and 0<=lo<=hi<=1)):
|
|
33
|
+
raise ValueError('Invalid reference abundance interval.')
|
|
34
|
+
for value in (self.mass_uncertainty_u,self.abundance_uncertainty):
|
|
35
|
+
if value is not None and (not math.isfinite(value) or value<0):raise ValueError('Invalid reference uncertainty.')
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class IonCandidate:
|
|
40
|
+
isotope: IsotopeRecord
|
|
41
|
+
charge: int
|
|
42
|
+
predicted_mz: float
|
|
43
|
+
error_Th: float
|
|
44
|
+
error_ppm: float
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def id(self):return f'{self.isotope.symbol}-{self.isotope.mass_number}:{self.charge:+d}'
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def ion_mz(record,charge: int):
|
|
51
|
+
if type(charge) is not int or charge not in (-3,-2,-1,1,2,3):raise ValueError('Atomic charge must be ±1, ±2 or ±3.')
|
|
52
|
+
return (record.neutral_mass_u-charge*ELECTRON_MASS_U)/abs(charge)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class CandidateLimitExceeded(ValueError):
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def find_candidates(mz,tolerance,tolerance_unit,charges,catalog,*,max_results=None):
|
|
60
|
+
if not math.isfinite(mz) or mz<=0:raise ValueError('Measured m/z must be positive and finite.')
|
|
61
|
+
if not math.isfinite(tolerance) or tolerance<0:raise ValueError('Tolerance must be finite and nonnegative.')
|
|
62
|
+
if tolerance_unit not in ('Th','ppm'):raise ValueError('Tolerance units must be Th or ppm.')
|
|
63
|
+
if not charges:raise ValueError('Select at least one charge state.')
|
|
64
|
+
for charge in charges:
|
|
65
|
+
if type(charge) is not int or charge not in (-3,-2,-1,1,2,3):raise ValueError('Atomic charge must be ±1, ±2 or ±3.')
|
|
66
|
+
candidates=[]
|
|
67
|
+
for isotope in catalog:
|
|
68
|
+
for charge in sorted(set(charges)):
|
|
69
|
+
predicted=ion_mz(isotope,charge);error=mz-predicted;ppm=error/predicted*1e6
|
|
70
|
+
if abs(error if tolerance_unit=='Th' else ppm)<=tolerance:
|
|
71
|
+
if max_results is not None and len(candidates) >= max_results:
|
|
72
|
+
raise CandidateLimitExceeded('Too many atomic candidates; narrow the tolerance or charge states.')
|
|
73
|
+
candidates.append(IonCandidate(isotope,charge,predicted,error,ppm))
|
|
74
|
+
return sorted(candidates,key=lambda r:(abs(r.error_Th),r.isotope.symbol,r.isotope.mass_number,r.charge))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def records_checksum(records):
|
|
78
|
+
return hashlib.sha256(json.dumps(records,sort_keys=True,separators=(',',':')).encode()).hexdigest()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def load_catalog(path=None):
|
|
82
|
+
data=json.loads(Path(path or CATALOG_PATH).read_text())
|
|
83
|
+
meta=data['metadata'];records=data['isotopes']
|
|
84
|
+
if meta.get('schema_version')!=1 or not meta.get('sources') or not meta.get('retrieved') or not meta.get('redistribution_review'):
|
|
85
|
+
raise ValueError('Reference provenance is incomplete.')
|
|
86
|
+
if records_checksum(records)!=meta['records_sha256']:raise ValueError('Isotope reference checksum mismatch.')
|
|
87
|
+
result=tuple(IsotopeRecord(**row) for row in records)
|
|
88
|
+
keys=[(r.symbol,r.mass_number) for r in result]
|
|
89
|
+
if len(set(keys))!=len(keys):raise ValueError('Duplicate isotope reference identity.')
|
|
90
|
+
return result
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Frame-sized floating-point peak reconstruction, independent of HDF5 and UI."""
|
|
2
|
+
import numpy as np
|
|
3
|
+
from tofscope.science.integration import EventBatch
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def integrate_peak_table(events, mass_axis, clock_ratio, normalization, windows):
|
|
7
|
+
"""Count every event in each inclusive window, preserving overlaps and order.
|
|
8
|
+
|
|
9
|
+
Disjoint windows are grouped so ordinary nominal tables take one or two
|
|
10
|
+
vectorized passes through the events, rather than one pass per channel.
|
|
11
|
+
"""
|
|
12
|
+
axis = np.asarray(mass_axis, dtype=np.float64)
|
|
13
|
+
bounds = np.asarray(windows, dtype=np.float64)
|
|
14
|
+
if (axis.ndim != 1 or not len(axis) or not np.isfinite(axis).all()
|
|
15
|
+
or bounds.ndim != 2 or bounds.shape[1] != 2 or not len(bounds)
|
|
16
|
+
or not np.isfinite(bounds).all() or np.any(bounds[:, 0] >= bounds[:, 1])
|
|
17
|
+
or not np.isfinite([clock_ratio, normalization]).all()
|
|
18
|
+
or clock_ratio < 1 or int(clock_ratio) != clock_ratio or normalization <= 0):
|
|
19
|
+
raise ValueError('Invalid raw calibration or peak windows.')
|
|
20
|
+
if isinstance(events, EventBatch):
|
|
21
|
+
raw, lengths, offsets = events.samples, events.lengths, events.offsets
|
|
22
|
+
else:
|
|
23
|
+
cells = events.ravel()
|
|
24
|
+
lengths = np.fromiter((len(cell) for cell in cells), dtype=np.int64, count=len(cells))
|
|
25
|
+
offsets = np.concatenate(([0], np.cumsum(lengths[:-1])))
|
|
26
|
+
raw = np.concatenate(list(cells)) if lengths.sum() else np.empty(0, dtype='uint32')
|
|
27
|
+
result = np.zeros((len(lengths), len(bounds)), dtype=np.float32)
|
|
28
|
+
if not lengths.sum():
|
|
29
|
+
return result.reshape((*events.shape, len(bounds)))
|
|
30
|
+
# Bounded HDF5 arenas may contain padding; select only each cell's events.
|
|
31
|
+
positions = np.repeat(offsets - np.cumsum(lengths) + lengths, lengths) + np.arange(lengths.sum())
|
|
32
|
+
samples = raw[positions].astype(np.int64) // int(clock_ratio)
|
|
33
|
+
valid = (samples >= 0) & (samples < len(axis))
|
|
34
|
+
masses = axis[samples[valid]]
|
|
35
|
+
pixels = np.repeat(np.arange(len(lengths), dtype=np.int64), lengths)[valid]
|
|
36
|
+
groups = []
|
|
37
|
+
for index in np.argsort(bounds[:, 0], kind='stable'):
|
|
38
|
+
for group in groups:
|
|
39
|
+
if bounds[group[-1], 1] < bounds[index, 0]:
|
|
40
|
+
group.append(index)
|
|
41
|
+
break
|
|
42
|
+
else:
|
|
43
|
+
groups.append([index])
|
|
44
|
+
for group in groups:
|
|
45
|
+
subset = bounds[group]
|
|
46
|
+
bins = np.searchsorted(subset[:, 0], masses, side='right') - 1
|
|
47
|
+
selected = (bins >= 0) & (masses <= subset[np.maximum(0, bins), 1])
|
|
48
|
+
counts = np.bincount(pixels[selected] * len(group) + bins[selected], minlength=len(lengths) * len(group))
|
|
49
|
+
result[:, group] = (counts.reshape(len(lengths), len(group)) / normalization).astype(np.float32)
|
|
50
|
+
return result.reshape((*events.shape, len(bounds)))
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Immutable scientific signal definitions, independent of presentation state."""
|
|
2
|
+
from dataclasses import dataclass,field
|
|
3
|
+
from operator import index
|
|
4
|
+
from uuid import UUID,uuid4
|
|
5
|
+
|
|
6
|
+
from tofscope.science.integration import canonical_intervals
|
|
7
|
+
from tofscope.storage.dataset_io import locked_read
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _integer(value):
|
|
11
|
+
if isinstance(value,bool):
|
|
12
|
+
raise ValueError('Frame and channel indices must be integers.')
|
|
13
|
+
try:
|
|
14
|
+
return index(value)
|
|
15
|
+
except TypeError as exc:
|
|
16
|
+
raise ValueError('Frame and channel indices must be integers.') from exc
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def interval_label(intervals):
|
|
20
|
+
return ' ∪ '.join(f'{low:.4f}–{high:.4f}' for low,high in intervals)+' Th'
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class SignalRecipe:
|
|
25
|
+
id: str=field(default_factory=lambda:str(uuid4()))
|
|
26
|
+
label: str=''
|
|
27
|
+
source: str='custom'
|
|
28
|
+
stored_index: int|None=None
|
|
29
|
+
saved_id: str|None=None
|
|
30
|
+
intervals: tuple[tuple[float,float],...]=()
|
|
31
|
+
frames: tuple[int,int]=(0,0)
|
|
32
|
+
|
|
33
|
+
def __post_init__(self):
|
|
34
|
+
try:
|
|
35
|
+
identifier=str(UUID(self.id))
|
|
36
|
+
except (ValueError,AttributeError,TypeError) as exc:
|
|
37
|
+
raise ValueError('Signal ID must be a UUID.') from exc
|
|
38
|
+
if self.source not in ('stored','custom','saved'):
|
|
39
|
+
raise ValueError('Unknown signal source.')
|
|
40
|
+
if not isinstance(self.label,str):
|
|
41
|
+
raise ValueError('Signal label must be text.')
|
|
42
|
+
try:
|
|
43
|
+
first,last=map(_integer,self.frames)
|
|
44
|
+
except (TypeError,ValueError) as exc:
|
|
45
|
+
raise ValueError('Frames must contain two inclusive integer limits.') from exc
|
|
46
|
+
if not 0<=first<=last:
|
|
47
|
+
raise ValueError('Frames must be nonnegative and first <= last.')
|
|
48
|
+
stored=_integer(self.stored_index) if self.stored_index is not None else None
|
|
49
|
+
if ((self.source=='stored' and (stored is None or stored<0))
|
|
50
|
+
or (self.source!='stored' and stored is not None)):
|
|
51
|
+
raise ValueError('Only stored signals must reference a stored channel index.')
|
|
52
|
+
if self.source=='saved':
|
|
53
|
+
try:
|
|
54
|
+
saved=str(UUID(self.saved_id))
|
|
55
|
+
except (TypeError,AttributeError,ValueError) as exc:
|
|
56
|
+
raise ValueError('Saved signals must reference a saved UUID.') from exc
|
|
57
|
+
elif self.saved_id is not None:
|
|
58
|
+
raise ValueError('Only saved signals may reference saved data.')
|
|
59
|
+
else:
|
|
60
|
+
saved=None
|
|
61
|
+
windows=canonical_intervals(self.intervals,(float('-inf'),float('inf')))
|
|
62
|
+
object.__setattr__(self,'id',identifier)
|
|
63
|
+
object.__setattr__(self,'stored_index',stored)
|
|
64
|
+
object.__setattr__(self,'saved_id',saved)
|
|
65
|
+
object.__setattr__(self,'intervals',windows)
|
|
66
|
+
object.__setattr__(self,'frames',(first,last))
|
|
67
|
+
|
|
68
|
+
def to_dict(self):
|
|
69
|
+
return dict(schema_version=3,id=self.id,label=self.label,source=self.source,
|
|
70
|
+
stored_index=self.stored_index,saved_id=self.saved_id,
|
|
71
|
+
intervals=[list(w) for w in self.intervals],frames=list(self.frames))
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def from_dict(cls,value):
|
|
75
|
+
version=value.get('schema_version')
|
|
76
|
+
if version==2:
|
|
77
|
+
source={'raw_reconstruction':'custom','stored':'stored'}.get(value.get('source'))
|
|
78
|
+
if source is None:
|
|
79
|
+
raise ValueError('Unknown legacy signal source.')
|
|
80
|
+
if source=='stored' and value.get('stored_index') is None:
|
|
81
|
+
raise ValueError('Legacy stored recipes need a stored channel index; choose that channel to restore it.')
|
|
82
|
+
return cls(label=value.get('signal',''),source=source,stored_index=value.get('stored_index'),
|
|
83
|
+
intervals=((value['lower_Th'],value['upper_Th']),),frames=tuple(value['frames_inclusive']))
|
|
84
|
+
if version!=3:
|
|
85
|
+
raise ValueError('Unsupported signal recipe schema version.')
|
|
86
|
+
return cls(id=value['id'],label=value.get('label',''),source=value['source'],
|
|
87
|
+
stored_index=value.get('stored_index'),saved_id=value.get('saved_id'),
|
|
88
|
+
intervals=value['intervals'],frames=value['frames'])
|
|
89
|
+
|
|
90
|
+
def computation_key(self,path):
|
|
91
|
+
from tofscope.storage.explorer import file_key
|
|
92
|
+
return (file_key(path),self.source,self.stored_index,self.saved_id,self.intervals,self.frames)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@locked_read
|
|
96
|
+
def read_signal(path, recipe, progress=None):
|
|
97
|
+
"""Return (depth,y,x), with array index zero at recipe.frames[0]."""
|
|
98
|
+
import h5py
|
|
99
|
+
from tofscope.storage.explorer import stored_peak
|
|
100
|
+
from tofscope.science.integration import custom_peak_windows
|
|
101
|
+
if recipe.source=='saved':
|
|
102
|
+
from tofscope.storage.integration_store import read_saved_integration,saved_integration_info
|
|
103
|
+
info=saved_integration_info(path,recipe.saved_id)
|
|
104
|
+
first,last=info['derivation']['frames']
|
|
105
|
+
if recipe.frames[0]<first or recipe.frames[1]>last or recipe.intervals!=tuple(tuple(w) for w in info['derivation']['intervals']):
|
|
106
|
+
raise ValueError('Saved reference coverage or intervals do not match its derivation.')
|
|
107
|
+
return read_saved_integration(path,recipe.saved_id)[recipe.frames[0]-first:recipe.frames[1]-first+1]
|
|
108
|
+
if recipe.source=='custom':
|
|
109
|
+
return custom_peak_windows(path,recipe.intervals,*recipe.frames,progress)
|
|
110
|
+
with h5py.File(path,'r') as f:
|
|
111
|
+
shape=f['PeakData/PeakData'].shape
|
|
112
|
+
if recipe.frames[1]>=shape[0] or recipe.stored_index>=shape[-1]:
|
|
113
|
+
raise ValueError('Stored channel or frame range is outside the acquisition.')
|
|
114
|
+
return stored_peak(path,recipe.stored_index)[recipe.frames[0]:recipe.frames[1]+1]
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from contextlib import nullcontext
|
|
2
|
+
"""Shared, acquisition-aligned previews with a total visible-voxel budget."""
|
|
3
|
+
from collections import OrderedDict
|
|
4
|
+
from dataclasses import dataclass,replace
|
|
5
|
+
import math
|
|
6
|
+
import numpy as np
|
|
7
|
+
from tofscope.storage.explorer import inspect_file,file_key
|
|
8
|
+
from tofscope.science.signals import SignalRecipe,read_signal
|
|
9
|
+
from tofscope.science.depth_calibration import calibrated_window
|
|
10
|
+
|
|
11
|
+
COLORSCALES=('Viridis','Magma','Plasma','Cividis','Reds','Blues','Greens','Purples')
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class VolumeLayer:
|
|
16
|
+
id: str
|
|
17
|
+
recipe: SignalRecipe
|
|
18
|
+
visible: bool = True
|
|
19
|
+
colorscale: str = 'Viridis'
|
|
20
|
+
opacity: float = .12
|
|
21
|
+
percentile: float = 70.
|
|
22
|
+
|
|
23
|
+
def __post_init__(self):
|
|
24
|
+
if not isinstance(self.id,str) or not self.id:raise ValueError('Layer ID is required.')
|
|
25
|
+
if type(self.visible) is not bool or self.colorscale not in COLORSCALES:raise ValueError('Invalid layer visibility or colormap.')
|
|
26
|
+
if not math.isfinite(self.opacity) or not 0<=self.opacity<=1:raise ValueError('Opacity must lie between 0 and 1.')
|
|
27
|
+
if not math.isfinite(self.percentile) or not 0<=self.percentile<=100:raise ValueError('Percentile must lie between 0 and 100.')
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class PreviewGrid:
|
|
32
|
+
z: np.ndarray
|
|
33
|
+
y: np.ndarray
|
|
34
|
+
x: np.ndarray
|
|
35
|
+
edges: tuple[np.ndarray,np.ndarray,np.ndarray]
|
|
36
|
+
physical_xy: bool
|
|
37
|
+
xy_extent: tuple[float,float] | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class VolumePreview:
|
|
42
|
+
layer_id: str
|
|
43
|
+
recipe: SignalRecipe
|
|
44
|
+
grid: PreviewGrid
|
|
45
|
+
values: np.ndarray
|
|
46
|
+
coverage: np.ndarray
|
|
47
|
+
valid_counts: np.ndarray
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
_CACHE=OrderedDict()
|
|
51
|
+
_CACHE_BYTES=0
|
|
52
|
+
|
|
53
|
+
def clear_preview_cache():
|
|
54
|
+
global _CACHE_BYTES
|
|
55
|
+
_CACHE.clear();_CACHE_BYTES=0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def build_layer_previews(path,layers,roi,frames,*,quality,voxel_budget=500_000,reader=None,calibration=None,use_cache=True,reader_context=None):
|
|
59
|
+
global _CACHE_BYTES
|
|
60
|
+
enabled=[layer for layer in layers if layer.visible]
|
|
61
|
+
if not enabled:return []
|
|
62
|
+
if type(voxel_budget) is not int or voxel_budget<len(enabled):raise ValueError('Preview budget must allow at least one voxel per visible layer.')
|
|
63
|
+
if quality not in ('Fast','Detailed'):raise ValueError('Unknown preview quality.')
|
|
64
|
+
meta=inspect_file(path);shape=meta['shape']
|
|
65
|
+
if len(frames)!=2 or any(type(z) is not int for z in frames) or not 0<=frames[0]<=frames[1]<shape[0]:raise ValueError('Invalid preview depth window.')
|
|
66
|
+
frames=calibrated_window(frames,calibration)
|
|
67
|
+
if frames is None:return []
|
|
68
|
+
if len(roi)!=4 or any(type(z) is not int for z in roi) or not (0<=roi[0]<=roi[1]<shape[2] and 0<=roi[2]<=roi[3]<shape[1]):raise ValueError('Invalid preview ROI.')
|
|
69
|
+
x0,x1,y0,y1=roi
|
|
70
|
+
sizes=(frames[1]-frames[0]+1,y1-y0+1,x1-x0+1);limits=(96,32,32) if quality=='Fast' else (160,48,48)
|
|
71
|
+
steps=[max(1,math.ceil(n/limit)) for n,limit in zip(sizes,limits)]
|
|
72
|
+
counts=lambda:[math.ceil(n/step) for n,step in zip(sizes,steps)]
|
|
73
|
+
while math.prod(counts())*len(enabled)>voxel_budget:
|
|
74
|
+
axis=max(range(3),key=lambda i:counts()[i]);steps[axis]+=1
|
|
75
|
+
starts=(frames[0],y0,x0)
|
|
76
|
+
edges=tuple(np.r_[np.arange(start,start+size,step),start+size] for start,size,step in zip(starts,sizes,steps))
|
|
77
|
+
z,y,x=((e[:-1]+e[1:]-1)/2 for e in edges)
|
|
78
|
+
fov=meta['fov_um']
|
|
79
|
+
if fov:y=(y+.5)*fov/shape[1];x=(x+.5)*fov/shape[2]
|
|
80
|
+
extent=((x1-x0+1)*fov/shape[2],(y1-y0+1)*fov/shape[1]) if fov else (x1-x0+1,y1-y0+1)
|
|
81
|
+
if calibration:
|
|
82
|
+
z=np.array([np.mean(calibration.frame_to_depth(np.arange(lo,hi))) for lo,hi in zip(edges[0][:-1],edges[0][1:])])
|
|
83
|
+
grid=PreviewGrid(z,y,x,edges,bool(fov),extent);result=[];identity=file_key(path)
|
|
84
|
+
block_sizes=np.diff(edges[0])[:,None,None]*np.diff(edges[1])[None,:,None]*np.diff(edges[2])[None,None,:]
|
|
85
|
+
for layer in enabled:
|
|
86
|
+
r=layer.recipe
|
|
87
|
+
key=(identity,r.source,r.stored_index,r.saved_id,r.intervals,r.frames,tuple(roi),tuple(frames),tuple(steps))
|
|
88
|
+
cached=_CACHE.get(key) if use_cache else None
|
|
89
|
+
if cached is None:
|
|
90
|
+
context=reader_context(r) if reader_context else nullcontext((reader or read_signal)(path,r))
|
|
91
|
+
with context as data:
|
|
92
|
+
if data.shape!=(r.frames[1]-r.frames[0]+1,shape[1],shape[2]):raise ValueError('Volume coverage does not match its recipe.')
|
|
93
|
+
values=np.full(tuple(counts()),np.nan,dtype=np.float32);valid_counts=np.zeros(values.shape,dtype=np.int32)
|
|
94
|
+
for i,(lo,hi) in enumerate(zip(edges[0],edges[0][1:])):
|
|
95
|
+
first=max(int(lo),r.frames[0]);last=min(int(hi)-1,r.frames[1])
|
|
96
|
+
if first>last:continue
|
|
97
|
+
part=data[first-r.frames[0]:last-r.frames[0]+1,y0:y1+1,x0:x1+1]
|
|
98
|
+
valid=np.isfinite(part);sums=np.where(valid,part,0).sum(axis=0,dtype=np.float64);numbers=valid.sum(axis=0,dtype=np.int32)
|
|
99
|
+
for axis,offset in ((0,y0),(1,x0)):
|
|
100
|
+
idx=edges[axis+1][:-1]-offset
|
|
101
|
+
sums=np.add.reduceat(sums,idx,axis=axis);numbers=np.add.reduceat(numbers,idx,axis=axis)
|
|
102
|
+
valid_counts[i]=numbers;np.divide(sums,numbers,out=values[i],where=numbers>0)
|
|
103
|
+
coverage=(valid_counts/block_sizes).astype(np.float32)
|
|
104
|
+
cached=(values,coverage,valid_counts)
|
|
105
|
+
for array in cached:array.setflags(write=False)
|
|
106
|
+
if use_cache:
|
|
107
|
+
_CACHE[key]=cached;_CACHE_BYTES+=sum(a.nbytes for a in cached)
|
|
108
|
+
while _CACHE_BYTES>64*2**20 and len(_CACHE)>1:
|
|
109
|
+
_,old=_CACHE.popitem(last=False);_CACHE_BYTES-=sum(a.nbytes for a in old)
|
|
110
|
+
else:_CACHE.move_to_end(key)
|
|
111
|
+
result.append(VolumePreview(layer.id,r,grid,*cached))
|
|
112
|
+
return result
|