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