thyra 2.0.0__tar.gz → 2.0.1__tar.gz

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 (97) hide show
  1. {thyra-2.0.0 → thyra-2.0.1}/PKG-INFO +1 -1
  2. {thyra-2.0.0 → thyra-2.0.1}/pyproject.toml +1 -1
  3. {thyra-2.0.0 → thyra-2.0.1}/thyra/__init__.py +1 -1
  4. {thyra-2.0.0 → thyra-2.0.1}/thyra/convert.py +6 -0
  5. {thyra-2.0.0 → thyra-2.0.1}/thyra/converters/spatialdata/base_spatialdata_converter.py +47 -6
  6. {thyra-2.0.0 → thyra-2.0.1}/thyra/readers/imzml/imzml_reader.py +352 -45
  7. thyra-2.0.1/thyra/resampling/strategies/tic_preserving.py +126 -0
  8. thyra-2.0.1/thyra/resampling/tic.py +146 -0
  9. thyra-2.0.0/thyra/resampling/strategies/tic_preserving.py +0 -109
  10. {thyra-2.0.0 → thyra-2.0.1}/LICENSE +0 -0
  11. {thyra-2.0.0 → thyra-2.0.1}/README.md +0 -0
  12. {thyra-2.0.0 → thyra-2.0.1}/thyra/__main__.py +0 -0
  13. {thyra-2.0.0 → thyra-2.0.1}/thyra/alignment/__init__.py +0 -0
  14. {thyra-2.0.0 → thyra-2.0.1}/thyra/alignment/affine.py +0 -0
  15. {thyra-2.0.0 → thyra-2.0.1}/thyra/alignment/teaching_points.py +0 -0
  16. {thyra-2.0.0 → thyra-2.0.1}/thyra/config.py +0 -0
  17. {thyra-2.0.0 → thyra-2.0.1}/thyra/converters/__init__.py +0 -0
  18. {thyra-2.0.0 → thyra-2.0.1}/thyra/converters/spatialdata/__init__.py +0 -0
  19. {thyra-2.0.0 → thyra-2.0.1}/thyra/converters/spatialdata/_chunking.py +0 -0
  20. {thyra-2.0.0 → thyra-2.0.1}/thyra/converters/spatialdata/converter.py +0 -0
  21. {thyra-2.0.0 → thyra-2.0.1}/thyra/converters/spatialdata/spatialdata_2d_converter.py +0 -0
  22. {thyra-2.0.0 → thyra-2.0.1}/thyra/converters/spatialdata/spatialdata_3d_converter.py +0 -0
  23. {thyra-2.0.0 → thyra-2.0.1}/thyra/converters/spatialdata/streaming_converter.py +0 -0
  24. {thyra-2.0.0 → thyra-2.0.1}/thyra/core/__init__.py +0 -0
  25. {thyra-2.0.0 → thyra-2.0.1}/thyra/core/base_converter.py +0 -0
  26. {thyra-2.0.0 → thyra-2.0.1}/thyra/core/base_extractor.py +0 -0
  27. {thyra-2.0.0 → thyra-2.0.1}/thyra/core/base_reader.py +0 -0
  28. {thyra-2.0.0 → thyra-2.0.1}/thyra/core/registry.py +0 -0
  29. {thyra-2.0.0 → thyra-2.0.1}/thyra/metadata/__init__.py +0 -0
  30. {thyra-2.0.0 → thyra-2.0.1}/thyra/metadata/extractors/__init__.py +0 -0
  31. {thyra-2.0.0 → thyra-2.0.1}/thyra/metadata/extractors/bruker_extractor.py +0 -0
  32. {thyra-2.0.0 → thyra-2.0.1}/thyra/metadata/extractors/imzml_extractor.py +0 -0
  33. {thyra-2.0.0 → thyra-2.0.1}/thyra/metadata/extractors/waters_extractor.py +0 -0
  34. {thyra-2.0.0 → thyra-2.0.1}/thyra/metadata/ontology/__init__.py +0 -0
  35. {thyra-2.0.0 → thyra-2.0.1}/thyra/metadata/ontology/_ims.py +0 -0
  36. {thyra-2.0.0 → thyra-2.0.1}/thyra/metadata/ontology/_ms.py +0 -0
  37. {thyra-2.0.0 → thyra-2.0.1}/thyra/metadata/ontology/_uo.py +0 -0
  38. {thyra-2.0.0 → thyra-2.0.1}/thyra/metadata/ontology/cache.py +0 -0
  39. {thyra-2.0.0 → thyra-2.0.1}/thyra/metadata/types.py +0 -0
  40. {thyra-2.0.0 → thyra-2.0.1}/thyra/metadata/validator.py +0 -0
  41. {thyra-2.0.0 → thyra-2.0.1}/thyra/preview.py +0 -0
  42. {thyra-2.0.0 → thyra-2.0.1}/thyra/readers/__init__.py +0 -0
  43. {thyra-2.0.0 → thyra-2.0.1}/thyra/readers/bruker/__init__.py +0 -0
  44. {thyra-2.0.0 → thyra-2.0.1}/thyra/readers/bruker/base_bruker_reader.py +0 -0
  45. {thyra-2.0.0 → thyra-2.0.1}/thyra/readers/bruker/folder_structure.py +0 -0
  46. {thyra-2.0.0 → thyra-2.0.1}/thyra/readers/bruker/mis_parser.py +0 -0
  47. {thyra-2.0.0 → thyra-2.0.1}/thyra/readers/bruker/rapiflex/__init__.py +0 -0
  48. {thyra-2.0.0 → thyra-2.0.1}/thyra/readers/bruker/rapiflex/rapiflex_reader.py +0 -0
  49. {thyra-2.0.0 → thyra-2.0.1}/thyra/readers/bruker/timstof/__init__.py +0 -0
  50. {thyra-2.0.0 → thyra-2.0.1}/thyra/readers/bruker/timstof/sdk/__init__.py +0 -0
  51. {thyra-2.0.0 → thyra-2.0.1}/thyra/readers/bruker/timstof/sdk/dll/LICENCE-BRUKER.txt +0 -0
  52. {thyra-2.0.0 → thyra-2.0.1}/thyra/readers/bruker/timstof/sdk/dll/README.md +0 -0
  53. {thyra-2.0.0 → thyra-2.0.1}/thyra/readers/bruker/timstof/sdk/dll/timsdata.dll +0 -0
  54. {thyra-2.0.0 → thyra-2.0.1}/thyra/readers/bruker/timstof/sdk/dll/timsdata.so +0 -0
  55. {thyra-2.0.0 → thyra-2.0.1}/thyra/readers/bruker/timstof/sdk/dll_manager.py +0 -0
  56. {thyra-2.0.0 → thyra-2.0.1}/thyra/readers/bruker/timstof/sdk/platform_detector.py +0 -0
  57. {thyra-2.0.0 → thyra-2.0.1}/thyra/readers/bruker/timstof/sdk/sdk_functions.py +0 -0
  58. {thyra-2.0.0 → thyra-2.0.1}/thyra/readers/bruker/timstof/timstof_reader.py +0 -0
  59. {thyra-2.0.0 → thyra-2.0.1}/thyra/readers/bruker/timstof/utils/__init__.py +0 -0
  60. {thyra-2.0.0 → thyra-2.0.1}/thyra/readers/bruker/timstof/utils/batch_processor.py +0 -0
  61. {thyra-2.0.0 → thyra-2.0.1}/thyra/readers/bruker/timstof/utils/coordinate_cache.py +0 -0
  62. {thyra-2.0.0 → thyra-2.0.1}/thyra/readers/bruker/timstof/utils/mass_axis_builder.py +0 -0
  63. {thyra-2.0.0 → thyra-2.0.1}/thyra/readers/bruker/timstof/utils/memory_manager.py +0 -0
  64. {thyra-2.0.0 → thyra-2.0.1}/thyra/readers/imzml/__init__.py +0 -0
  65. {thyra-2.0.0 → thyra-2.0.1}/thyra/readers/waters/__init__.py +0 -0
  66. {thyra-2.0.0 → thyra-2.0.1}/thyra/readers/waters/imaging_grid.py +0 -0
  67. {thyra-2.0.0 → thyra-2.0.1}/thyra/readers/waters/lib/MLReader.dll +0 -0
  68. {thyra-2.0.0 → thyra-2.0.1}/thyra/readers/waters/lib/MassLynxRaw.dll +0 -0
  69. {thyra-2.0.0 → thyra-2.0.1}/thyra/readers/waters/lib/libMLReader.so +0 -0
  70. {thyra-2.0.0 → thyra-2.0.1}/thyra/readers/waters/lib/libMassLynxRaw.so +0 -0
  71. {thyra-2.0.0 → thyra-2.0.1}/thyra/readers/waters/masslynx_lib.py +0 -0
  72. {thyra-2.0.0 → thyra-2.0.1}/thyra/readers/waters/waters_reader.py +0 -0
  73. {thyra-2.0.0 → thyra-2.0.1}/thyra/resampling/__init__.py +0 -0
  74. {thyra-2.0.0 → thyra-2.0.1}/thyra/resampling/common_axis.py +0 -0
  75. {thyra-2.0.0 → thyra-2.0.1}/thyra/resampling/constants.py +0 -0
  76. {thyra-2.0.0 → thyra-2.0.1}/thyra/resampling/data_characteristics.py +0 -0
  77. {thyra-2.0.0 → thyra-2.0.1}/thyra/resampling/decision_tree.py +0 -0
  78. {thyra-2.0.0 → thyra-2.0.1}/thyra/resampling/instrument_detectors.py +0 -0
  79. {thyra-2.0.0 → thyra-2.0.1}/thyra/resampling/mass_axis/__init__.py +0 -0
  80. {thyra-2.0.0 → thyra-2.0.1}/thyra/resampling/mass_axis/base_generator.py +0 -0
  81. {thyra-2.0.0 → thyra-2.0.1}/thyra/resampling/mass_axis/fticr_generator.py +0 -0
  82. {thyra-2.0.0 → thyra-2.0.1}/thyra/resampling/mass_axis/linear_generator.py +0 -0
  83. {thyra-2.0.0 → thyra-2.0.1}/thyra/resampling/mass_axis/linear_tof_generator.py +0 -0
  84. {thyra-2.0.0 → thyra-2.0.1}/thyra/resampling/mass_axis/orbitrap_generator.py +0 -0
  85. {thyra-2.0.0 → thyra-2.0.1}/thyra/resampling/mass_axis/reflector_tof_generator.py +0 -0
  86. {thyra-2.0.0 → thyra-2.0.1}/thyra/resampling/strategies/__init__.py +0 -0
  87. {thyra-2.0.0 → thyra-2.0.1}/thyra/resampling/strategies/base.py +0 -0
  88. {thyra-2.0.0 → thyra-2.0.1}/thyra/resampling/strategies/nearest_neighbor.py +0 -0
  89. {thyra-2.0.0 → thyra-2.0.1}/thyra/resampling/types.py +0 -0
  90. {thyra-2.0.0 → thyra-2.0.1}/thyra/tools/__init__.py +0 -0
  91. {thyra-2.0.0 → thyra-2.0.1}/thyra/tools/check_ontology.py +0 -0
  92. {thyra-2.0.0 → thyra-2.0.1}/thyra/tools/make_example_data.py +0 -0
  93. {thyra-2.0.0 → thyra-2.0.1}/thyra/utils/__init__.py +0 -0
  94. {thyra-2.0.0 → thyra-2.0.1}/thyra/utils/bruker_exceptions.py +0 -0
  95. {thyra-2.0.0 → thyra-2.0.1}/thyra/utils/logging_config.py +0 -0
  96. {thyra-2.0.0 → thyra-2.0.1}/thyra/utils/windows_paths.py +0 -0
  97. {thyra-2.0.0 → thyra-2.0.1}/thyra/utils/zarr_atomic_write.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: thyra
3
- Version: 2.0.0
3
+ Version: 2.0.1
4
4
  Summary: A modern Python library for converting Mass Spectrometry Imaging (MSI) data into SpatialData/Zarr format - your portal to spatial omics
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [tool.poetry]
6
6
  name = "thyra"
7
- version = "2.0.0"
7
+ version = "2.0.1"
8
8
  description = "A modern Python library for converting Mass Spectrometry Imaging (MSI) data into SpatialData/Zarr format - your portal to spatial omics"
9
9
  authors = ["Theodoros Visvikis <t.visvikis@maastrichtuniversity.nl>"]
10
10
  maintainers = ["Theodoros Visvikis <t.visvikis@maastrichtuniversity.nl>"]
@@ -36,7 +36,7 @@ warnings.filterwarnings(
36
36
  category=FutureWarning,
37
37
  )
38
38
 
39
- __version__ = "2.0.0"
39
+ __version__ = "2.0.1"
40
40
 
41
41
  # Import key components - avoid wildcard imports
42
42
  try:
@@ -281,6 +281,12 @@ def convert_msi(
281
281
  to include. Default: None (no filtering).
282
282
  - use_recalibrated_state: bool - For Bruker data,
283
283
  use active/recalibrated calibration (default True).
284
+ - max_mass_axis_length: int - For processed-mode imzML
285
+ converted with --no-resample, give up once the raw
286
+ mass axis exceeds this many unique m/z values.
287
+ Default: None (unlimited). Useful when the peak
288
+ lists share no m/z values, where the raw axis grows
289
+ to roughly one column per peak in the whole dataset.
284
290
  sparse_format: Sparse matrix format ('csc' or 'csr')
285
291
  include_optical: Include optical images (default: True)
286
292
  apply_optical_alignment: If True (default) and the MSI source
@@ -17,6 +17,7 @@ from ...core.base_converter import BaseMSIConverter, PixelSizeSource
17
17
  from ...core.base_reader import BaseMSIReader
18
18
  from ...metadata.types import ComprehensiveMetadata, EssentialMetadata
19
19
  from ...resampling import ResamplingDecisionTree, ResamplingMethod
20
+ from ...resampling.tic import preserved_tic, rescale_to_preserved_tic
20
21
  from ...resampling.types import ResamplingConfig
21
22
  from ...utils.zarr_atomic_write import install_windows_atomic_write_retry
22
23
  from ._chunking import image_chunks
@@ -1091,11 +1092,38 @@ class BaseSpatialDataConverter(BaseMSIConverter, ABC):
1091
1092
  def _tic_preserving_resample(
1092
1093
  self, mzs: NDArray[np.float64], intensities: NDArray[np.float64]
1093
1094
  ) -> NDArray[np.float64]:
1094
- """Resample using TIC-preserving linear interpolation - optimized."""
1095
+ """Resample onto the common axis, preserving total ion current.
1096
+
1097
+ Linear interpolation onto the target axis, followed by rescaling so
1098
+ the resampled spectrum carries the same total ion current as the
1099
+ input -- the behaviour ``ResamplingMethod.TIC_PRESERVING`` and
1100
+ docs/resampling.md both describe.
1101
+
1102
+ The rescaling is not cosmetic. Interpolation samples the spectrum at
1103
+ every target point, so the raw interpolated sum scales with the
1104
+ density of the target axis rather than staying fixed. Onto the
1105
+ default 190,000-bin axis, 4,000 source points came back with 47x the
1106
+ input TIC, and a 150-peak centroid spectrum with over 1000x.
1107
+
1108
+ The TIC preserved is the share of the spectrum lying inside the
1109
+ target axis range -- see ``thyra.resampling.tic``, which holds the
1110
+ rule and the reasoning, and which ``TICPreservingStrategy`` uses too.
1111
+ When the axis spans the spectrum, which is the default, that share
1112
+ is the whole input TIC.
1113
+
1114
+ Args:
1115
+ mzs: Original m/z values from the spectrum.
1116
+ intensities: Corresponding intensity values.
1117
+
1118
+ Returns:
1119
+ Intensities on the common mass axis, of the axis's length.
1120
+ """
1095
1121
  if self._common_mass_axis is None:
1096
1122
  raise RuntimeError("Common mass axis is not initialized")
1123
+
1124
+ axis = self._common_mass_axis
1097
1125
  if mzs.size == 0:
1098
- return np.zeros(len(self._common_mass_axis))
1126
+ return np.zeros(len(axis))
1099
1127
 
1100
1128
  # OPTIMIZED: Check if already sorted to avoid unnecessary sorting
1101
1129
  if np.all(mzs[:-1] <= mzs[1:]):
@@ -1108,18 +1136,31 @@ class BaseSpatialDataConverter(BaseMSIConverter, ABC):
1108
1136
  mzs_sorted = mzs[sort_indices]
1109
1137
  intensities_sorted = intensities[sort_indices]
1110
1138
 
1139
+ if mzs_sorted.size == 1:
1140
+ # np.interp cannot interpolate a lone point onto a grid that
1141
+ # does not contain it -- it would return all zeros and lose the
1142
+ # peak. Place it in its nearest bin, as the nearest_neighbor
1143
+ # path and TICPreservingStrategy both do.
1144
+ resampled = np.zeros(len(axis))
1145
+ target_tic = preserved_tic(
1146
+ mzs_sorted, intensities_sorted, float(axis[0]), float(axis[-1])
1147
+ )
1148
+ if target_tic > 0.0:
1149
+ resampled[int(np.argmin(np.abs(axis - mzs_sorted[0])))] = target_tic
1150
+ return resampled
1151
+
1111
1152
  # Interpolate onto the common mass axis (np.interp is highly optimized)
1112
- if self._common_mass_axis is None:
1113
- raise RuntimeError("Common mass axis is not initialized")
1114
1153
  resampled = np.interp(
1115
- self._common_mass_axis,
1154
+ axis,
1116
1155
  mzs_sorted,
1117
1156
  intensities_sorted,
1118
1157
  left=0,
1119
1158
  right=0,
1120
1159
  )
1121
1160
 
1122
- return resampled
1161
+ # Rescale to the required TIC. This is the step that makes the
1162
+ # method live up to its name.
1163
+ return rescale_to_preserved_tic(resampled, axis, mzs_sorted, intensities_sorted)
1123
1164
 
1124
1165
  def _process_resampled_spectrum(
1125
1166
  self,
@@ -15,6 +15,308 @@ from ...metadata.extractors.imzml_extractor import ImzMLMetadataExtractor
15
15
 
16
16
  logger = logging.getLogger(__name__)
17
17
 
18
+ # Scratch buffer floor for the processed-mode mass axis build, in ELEMENTS.
19
+ # 1Mi elements is 8 MiB at float64. Measured at a 512 MiB m/z payload on a
20
+ # shared grid (peak extra RSS / wall clock): 2^14 -> 7.14 MiB / 3.04 s,
21
+ # 2^17 -> 6.90 / 3.06, 2^20 -> 15.30 / 2.30, 2^22 -> 40.29 / 2.11,
22
+ # 2^24 -> 148.38 / 2.06. A 1024x sweep of the knob moves wall clock 1.5x with
23
+ # no cliff anywhere, so this is a knee rather than a fragile constant.
24
+ _MASS_AXIS_BATCH_VALUES = 1 << 20
25
+
26
+ # Block size for the searchsorted probe in ``_filter_absent``, in ELEMENTS.
27
+ # Bounds that function's temporaries to O(block) rather than O(batch).
28
+ _MASS_AXIS_PROBE_BLOCK = 1 << 20
29
+
30
+
31
+ def _dedupe_sorted(a: NDArray[Any]) -> NDArray[Any]:
32
+ """Deduplicate an ALREADY-SORTED array, as ``np.unique`` would.
33
+
34
+ Reproduces numpy's ``_unique1d`` mask including its ``equal_nan=True``
35
+ behaviour, which collapses a trailing run of NaNs to its first element.
36
+ Unlike ``np.unique`` it does not make the extra full-size ``flatten()``
37
+ copy, which is one of the three payload-sized allocations the old
38
+ collect-everything-then-unique build paid for.
39
+
40
+ Args:
41
+ a: A sorted array.
42
+
43
+ Returns:
44
+ The distinct values of ``a``, in order.
45
+ """
46
+ n = a.size
47
+ if n == 0:
48
+ return a[:0].copy()
49
+
50
+ mask = np.empty(n, dtype=bool)
51
+ mask[0] = True
52
+
53
+ if a.dtype.kind == "f" and bool(np.isnan(a[-1])):
54
+ first_nan = int(np.searchsorted(a, a[-1], side="left"))
55
+ # The ``first_nan > 0`` guard is load-bearing: an all-NaN input makes
56
+ # the slices empty and ``np.not_equal`` raises on the shape mismatch.
57
+ if first_nan > 0:
58
+ np.not_equal(a[1:first_nan], a[: first_nan - 1], out=mask[1:first_nan])
59
+ mask[first_nan] = True
60
+ mask[first_nan + 1 :] = False
61
+ else:
62
+ np.not_equal(a[1:], a[:-1], out=mask[1:])
63
+
64
+ return a[mask]
65
+
66
+
67
+ def _filter_absent(
68
+ acc: NDArray[Any],
69
+ s: NDArray[Any],
70
+ block: int = _MASS_AXIS_PROBE_BLOCK,
71
+ ) -> NDArray[Any]:
72
+ """Return the elements of ``s`` that are not already in ``acc``.
73
+
74
+ Both arrays must be sorted and free of duplicates. ``s`` is walked in
75
+ blocks so the search temporaries stay O(block) rather than O(len(s)).
76
+ Returns ``s`` itself, uncopied, when none of its values are present --
77
+ the all-distinct fast path, worth half a payload at peak.
78
+
79
+ Args:
80
+ acc: The running axis, sorted and unique.
81
+ s: A candidate batch, sorted and unique.
82
+ block: Probe block size in elements.
83
+
84
+ Returns:
85
+ The subset of ``s`` absent from ``acc``.
86
+ """
87
+ n = s.size
88
+ # ``acc.size == 0`` must be handled here: the ``np.minimum`` below would
89
+ # otherwise index ``acc[-1]`` on an empty array and raise IndexError.
90
+ if n == 0 or acc.size == 0:
91
+ return s
92
+
93
+ keep = np.empty(n, dtype=bool)
94
+ n_keep = 0
95
+ is_float = acc.dtype.kind == "f"
96
+ a_len = acc.size
97
+
98
+ for lo in range(0, n, block):
99
+ hi = min(lo + block, n)
100
+ sb = s[lo:hi]
101
+ pos = np.searchsorted(acc, sb, side="left")
102
+ out_of_range = pos >= a_len
103
+ np.minimum(pos, a_len - 1, out=pos)
104
+ va = acc[pos]
105
+ eq = va == sb
106
+ if is_float:
107
+ # searchsorted routes a NaN key onto acc's first NaN, but NaN does
108
+ # not compare equal to itself, so without this the same NaN would
109
+ # be re-inserted on every fold.
110
+ np.logical_or(eq, np.isnan(va) & np.isnan(sb), out=eq)
111
+ np.logical_and(eq, ~out_of_range, out=eq)
112
+ np.logical_not(eq, out=eq)
113
+ keep[lo:hi] = eq
114
+ n_keep += int(np.count_nonzero(eq))
115
+ del pos, out_of_range, va, eq, sb
116
+
117
+ if n_keep == n:
118
+ return s
119
+ if n_keep == 0:
120
+ return s[:0]
121
+ return s[keep]
122
+
123
+
124
+ def _merge_disjoint(box_a: List[Any], box_b: List[Any]) -> NDArray[Any]:
125
+ """Merge two DISJOINT sorted arrays, releasing each once it is copied.
126
+
127
+ The inputs arrive boxed in one-element lists so this function can drop the
128
+ caller's reference as soon as each side has been copied. Passing them as
129
+ plain parameters would keep both alive for the whole call and add a full
130
+ copy of the larger side to the peak.
131
+
132
+ The result needs no second deduplication because ``_filter_absent`` has
133
+ already removed the overlap, which saves another full-size copy.
134
+
135
+ Args:
136
+ box_a: One-element list holding the first sorted array.
137
+ box_b: One-element list holding the second sorted array.
138
+
139
+ Returns:
140
+ The sorted union of the two inputs.
141
+ """
142
+ a, b = box_a[0], box_b[0]
143
+ out = np.empty(a.size + b.size, dtype=a.dtype)
144
+
145
+ n_a = a.size
146
+ out[:n_a] = a
147
+ box_a[0] = None
148
+ del a
149
+
150
+ out[n_a:] = b
151
+ box_b[0] = None
152
+ del b
153
+
154
+ # Exactly two ascending runs, which is the case quicksort handles best.
155
+ out.sort(kind="quicksort")
156
+ return out
157
+
158
+
159
+ def _read_spectrum_mzs(parser: Any, idx: int) -> Optional[NDArray[Any]]:
160
+ """Read one spectrum's m/z values, or None if missing or unreadable.
161
+
162
+ Args:
163
+ parser: An initialized ImzML parser.
164
+ idx: Spectrum index.
165
+
166
+ Returns:
167
+ The m/z array, or None when the spectrum is empty or failed to read.
168
+ """
169
+ try:
170
+ spectrum_data = parser.getspectrum(idx)
171
+ except Exception as e:
172
+ logger.warning(f"Error getting spectrum {idx}: {e}")
173
+ return None
174
+
175
+ if spectrum_data is None or len(spectrum_data) < 1:
176
+ return None
177
+ mzs = spectrum_data[0]
178
+ return mzs if mzs.size else None
179
+
180
+
181
+ class _MassAxisAccumulator:
182
+ """Builds a sorted, unique m/z axis without holding every spectrum.
183
+
184
+ Values are copied into a scratch buffer; when that buffer fills, it is
185
+ sorted, deduplicated, and merged into the running axis. The buffer's
186
+ capacity tracks the axis length, so the number of merges is logarithmic in
187
+ the input rather than linear -- a fixed batch size would re-copy the
188
+ accumulator once per batch, which is the quadratic behaviour that makes a
189
+ naive ``np.union1d`` fold far slower than the code it replaces.
190
+
191
+ Peak memory therefore depends on the number of *unique* m/z values rather
192
+ than on the size of the file.
193
+ """
194
+
195
+ def __init__(self, total_spectra: int, max_length: Optional[int] = None) -> None:
196
+ """Initialize the accumulator.
197
+
198
+ Args:
199
+ total_spectra: Spectrum count, used only in the error message.
200
+ max_length: Cap on unique m/z values, or None for unlimited.
201
+ """
202
+ self._total_spectra = total_spectra
203
+ self._max_length = max_length
204
+ self._acc: Optional[NDArray[Any]] = None
205
+ self._buf: Optional[NDArray[Any]] = None
206
+ self._cap = 0
207
+ self._cap_target = _MASS_AXIS_BATCH_VALUES
208
+ self._n = 0
209
+ self.saw_any = False
210
+
211
+ def add(self, mzs: NDArray[Any], index: int) -> None:
212
+ """Buffer one spectrum's m/z values, folding first if the buffer is full.
213
+
214
+ Args:
215
+ mzs: The spectrum's m/z values. Not modified.
216
+ index: Spectrum index, used only in the error message.
217
+ """
218
+ m = mzs.size
219
+ if not m:
220
+ return
221
+ self.saw_any = True
222
+
223
+ if self._buf is None:
224
+ self._allocate(m, mzs.dtype)
225
+ elif self._n + m > self._cap:
226
+ self._fold(index)
227
+ if self._buf is None:
228
+ self._allocate(m, mzs.dtype)
229
+ elif m > self._cap:
230
+ self._buf = None
231
+ self._allocate(m, mzs.dtype, exact=True)
232
+
233
+ assert self._buf is not None
234
+ self._buf[self._n : self._n + m] = mzs
235
+ self._n += m
236
+
237
+ def finish(self, index: int) -> NDArray[Any]:
238
+ """Fold whatever is buffered and return the axis.
239
+
240
+ Args:
241
+ index: Last spectrum index, used only in the error message.
242
+
243
+ Returns:
244
+ Sorted, unique m/z values.
245
+
246
+ Raises:
247
+ ValueError: If no spectrum yielded any m/z values.
248
+ """
249
+ self._fold(index)
250
+ self._buf = None
251
+
252
+ if not self.saw_any or self._acc is None:
253
+ raise ValueError("No spectra found to build common mass axis")
254
+ if self._acc.size == 0:
255
+ raise ValueError("Failed to extract any m/z values")
256
+ return self._acc
257
+
258
+ def _allocate(self, m: int, dtype: Any, exact: bool = False) -> None:
259
+ """Allocate the scratch buffer, at least large enough for one spectrum."""
260
+ self._cap = m if exact else max(self._cap_target, m)
261
+ self._buf = np.empty(self._cap, dtype=dtype)
262
+
263
+ def _fold(self, index: int) -> None:
264
+ """Sort, deduplicate and merge the buffered batch into the axis."""
265
+ if self._n == 0 or self._buf is None:
266
+ return
267
+
268
+ view = self._buf[: self._n]
269
+ view.sort(kind="quicksort")
270
+ run = _dedupe_sorted(view)
271
+ self._n = 0
272
+
273
+ if self._cap > _MASS_AXIS_BATCH_VALUES:
274
+ # The scratch has grown to axis size, so release it before the
275
+ # merge allocates. Gating on the batch floor keeps this from firing
276
+ # on every one of the thousands of folds a shared-grid dataset
277
+ # performs, where re-faulting the buffer each time costs more than
278
+ # the memory it frees.
279
+ view = None
280
+ self._buf = None
281
+ self._cap = 0
282
+
283
+ if self._acc is None:
284
+ self._acc = run
285
+ else:
286
+ new = _filter_absent(self._acc, run)
287
+ run = None # drop the batch before the merge allocates
288
+ if new.size:
289
+ box_a, box_b = [self._acc], [new]
290
+ self._acc = None
291
+ new = None
292
+ self._acc = _merge_disjoint(box_a, box_b)
293
+
294
+ self._check_limit(index)
295
+
296
+ # Record the next capacity but do NOT allocate it here. On the final
297
+ # fold the stream is already exhausted, so allocating would commit a
298
+ # whole axis-sized buffer that is never written.
299
+ self._cap_target = max(_MASS_AXIS_BATCH_VALUES, self._acc.size)
300
+ if self._cap and self._cap != self._cap_target:
301
+ self._buf = None
302
+ self._cap = 0
303
+
304
+ def _check_limit(self, index: int) -> None:
305
+ """Stop if the axis has outgrown ``max_length``."""
306
+ if self._max_length is None or self._acc is None:
307
+ return
308
+ if self._acc.size <= self._max_length:
309
+ return
310
+ raise ValueError(
311
+ f"Common mass axis exceeded {self._max_length:,} unique m/z values "
312
+ f"after {index + 1:,} of {self._total_spectra:,} spectra "
313
+ f"({self._acc.size:,} so far). The peak lists in this dataset do "
314
+ "not share m/z values, so a raw axis grows to roughly one column "
315
+ "per peak, which is not usable downstream. Convert with resampling "
316
+ "instead (it is the default; --no-resample disables it), or raise "
317
+ "max_mass_axis_length."
318
+ )
319
+
18
320
 
19
321
  @register_reader("imzml")
20
322
  class ImzMLReader(BaseMSIReader):
@@ -33,12 +335,16 @@ class ImzMLReader(BaseMSIReader):
33
335
  data_path: Path to the imzML file
34
336
  batch_size: Default batch size for spectrum iteration
35
337
  cache_coordinates: Whether to cache coordinates upfront
36
- **kwargs: Additional arguments
338
+ **kwargs: Additional arguments. ``max_mass_axis_length`` caps the
339
+ number of unique m/z values the processed-mode raw axis may
340
+ reach before the build gives up; None (the default) is
341
+ unlimited. See ``_extract_continuous_mass_axis``.
37
342
  """
38
343
  super().__init__(data_path, **kwargs)
39
344
  self.filepath: Optional[Union[str, Path]] = data_path
40
345
  self.batch_size: int = batch_size
41
346
  self.cache_coordinates: bool = cache_coordinates
347
+ self.max_mass_axis_length: Optional[int] = kwargs.get("max_mass_axis_length")
42
348
  self.parser: Optional[ImzMLParser] = None
43
349
  self.ibd_file: Optional[Any] = None
44
350
  self.imzml_path: Optional[Path] = None
@@ -207,25 +513,46 @@ class ImzMLReader(BaseMSIReader):
207
513
  return self._common_mass_axis
208
514
 
209
515
  def _extract_continuous_mass_axis(self, parser: ImzMLParser) -> NDArray[np.float64]:
210
- """Extract continuous mass axis from processed data."""
211
- # For processed data, collect unique m/z values across spectra
516
+ """Build the common mass axis for processed-mode data.
517
+
518
+ Returns exactly what ``np.unique(np.concatenate(all_mzs))`` returned:
519
+ sorted, deduplicated, with the dtype following the file's mzPrecision.
520
+
521
+ This streams the file rather than collecting it. The previous
522
+ implementation held every spectrum's m/z array in a list, concatenated
523
+ that into one array, and handed the result to ``np.unique`` -- three
524
+ live copies of the whole m/z payload P, plus the output. Measured peak
525
+ was 3.40-3.51x P on data whose spectra share m/z values and 4.32-4.39x
526
+ P when every value is distinct, which put a hard ceiling around a
527
+ 40 GB input on a 128 GB machine.
528
+
529
+ Here each spectrum is copied into a scratch buffer, and when that
530
+ buffer fills it is sorted, deduplicated, and merged into the running
531
+ axis. Peak memory becomes a function of the number of *unique* values
532
+ rather than the payload: measured 15.2-15.5 MiB, flat, for payloads
533
+ from 32 MiB to 16 GiB of shared-grid data, and 2.00x P in the
534
+ all-distinct case, which is the structural floor for an out-of-place
535
+ merge.
536
+
537
+ Args:
538
+ parser: An initialized ImzML parser.
539
+
540
+ Returns:
541
+ Sorted, unique m/z values across all spectra.
542
+
543
+ Raises:
544
+ ValueError: If no spectrum yielded any m/z values, or if
545
+ ``max_mass_axis_length`` is set and the axis outgrows it.
546
+ """
212
547
  logger.info(
213
548
  "Building common mass axis from all unique m/z values " "(processed mode)"
214
549
  )
215
550
 
216
551
  total_spectra = len(parser.coordinates)
217
- all_mzs = self._collect_processed_mzs(parser, total_spectra)
218
-
219
- if not all_mzs:
220
- raise ValueError("No spectra found to build common mass axis")
221
-
222
- return self._finalize_mass_axis(all_mzs)
223
-
224
- def _collect_processed_mzs(
225
- self, parser: ImzMLParser, total_spectra: int
226
- ) -> List[NDArray[np.float64]]:
227
- """Collect m/z values from all processed spectra."""
228
- all_mzs: List[NDArray[np.float64]] = []
552
+ accumulator = _MassAxisAccumulator(
553
+ total_spectra, getattr(self, "max_mass_axis_length", None)
554
+ )
555
+ idx = -1
229
556
 
230
557
  with tqdm(
231
558
  total=total_spectra,
@@ -233,38 +560,18 @@ class ImzMLReader(BaseMSIReader):
233
560
  unit="spectrum",
234
561
  ) as pbar:
235
562
  for idx in range(total_spectra):
236
- try:
237
- spectrum_data = parser.getspectrum(idx)
238
- if spectrum_data is None or len(spectrum_data) < 1:
239
- continue
240
-
241
- mzs = spectrum_data[0]
242
- if mzs.size > 0:
243
- all_mzs.append(mzs)
244
- except Exception as e:
245
- logger.warning(f"Error getting spectrum {idx}: {e}")
563
+ mzs = _read_spectrum_mzs(parser, idx)
564
+ if mzs is not None:
565
+ # Deliberately outside the read's try/except: a
566
+ # max_mass_axis_length failure must not be swallowed and
567
+ # logged as a per-spectrum warning.
568
+ accumulator.add(mzs, idx)
569
+ mzs = None
246
570
  pbar.update(1)
247
571
 
248
- return all_mzs
249
-
250
- def _finalize_mass_axis(
251
- self, all_mzs: List[NDArray[np.float64]]
252
- ) -> NDArray[np.float64]:
253
- """Finalize the mass axis from collected m/z values."""
254
- try:
255
- combined_mzs = np.concatenate(all_mzs)
256
- unique_mzs = np.unique(combined_mzs)
257
-
258
- if unique_mzs.size == 0:
259
- raise ValueError("Failed to extract any m/z values")
260
-
261
- logger.info(
262
- f"Created common mass axis with {len(unique_mzs)} unique " f"m/z values"
263
- )
264
- return unique_mzs
265
- except Exception as e:
266
- # Re-raise with more context
267
- raise ValueError(f"Error creating common mass axis: {e}") from e
572
+ axis = accumulator.finish(idx)
573
+ logger.info(f"Created common mass axis with {axis.size} unique m/z values")
574
+ return axis
268
575
 
269
576
  def _get_spectrum_coordinates(
270
577
  self, parser: ImzMLParser, idx: int
@@ -0,0 +1,126 @@
1
+ """TIC-preserving linear interpolation strategy for profile data.
2
+
3
+ This strategy uses linear interpolation while preserving the Total Ion
4
+ Current (TIC) of the original spectrum, making it ideal for profile data
5
+ from most MS instruments.
6
+
7
+ The rule for *which* total is preserved lives in ``thyra.resampling.tic``
8
+ and is shared with the converters' per-pixel path, so the two
9
+ implementations of ``tic_preserving`` cannot drift apart again. In short:
10
+ the preserved total is the share of the spectrum lying inside the target
11
+ axis range, so resampling onto an axis that crops the spectrum drops the
12
+ cropped intensity rather than redistributing it over the bins that remain.
13
+ When the axis spans the spectrum, the whole TIC is preserved exactly.
14
+ """
15
+
16
+ from typing import Any
17
+
18
+ import numpy as np
19
+ import numpy.typing as npt
20
+
21
+ from ..tic import preserved_tic, rescale_to_preserved_tic
22
+ from .base import ResamplingStrategy, Spectrum
23
+
24
+
25
+ class TICPreservingStrategy(ResamplingStrategy):
26
+ """TIC-preserving linear interpolation strategy for profile data."""
27
+
28
+ def resample(
29
+ self, spectrum: Spectrum, target_axis: npt.NDArray[np.floating[Any]]
30
+ ) -> Spectrum:
31
+ """Resample spectrum using TIC-preserving linear interpolation.
32
+
33
+ Linearly interpolates onto ``target_axis`` and then scales the
34
+ result so it carries the Total Ion Current the axis is entitled to
35
+ -- the whole input TIC when the axis spans the spectrum, and the
36
+ share lying inside the axis otherwise. See ``thyra.resampling.tic``
37
+ for why the share is measured by integration rather than by counting
38
+ the source points that fall in range.
39
+
40
+ Parameters
41
+ ----------
42
+ spectrum : Spectrum
43
+ Input spectrum to resample
44
+ target_axis : npt.NDArray[np.floating[Any]]
45
+ Target mass axis values
46
+
47
+ Returns
48
+ -------
49
+ Spectrum
50
+ Resampled spectrum with target_axis as mz values
51
+ """
52
+ if len(spectrum.mz) == 0:
53
+ # Handle empty spectrum
54
+ return Spectrum(
55
+ mz=target_axis.copy(),
56
+ intensity=np.zeros_like(target_axis),
57
+ coordinates=spectrum.coordinates,
58
+ metadata=spectrum.metadata,
59
+ )
60
+
61
+ if len(spectrum.mz) == 1:
62
+ # A lone point cannot be interpolated onto a grid that does not
63
+ # contain it, so place it in its nearest bin -- unless it falls
64
+ # outside the axis, in which case it is cropped away like any
65
+ # other out-of-range peak and preserved_tic returns 0.
66
+ resampled_intensity = np.zeros_like(target_axis)
67
+ kept = preserved_tic(
68
+ np.asarray(spectrum.mz, dtype=np.float64),
69
+ np.asarray(spectrum.intensity, dtype=np.float64),
70
+ float(target_axis[0]),
71
+ float(target_axis[-1]),
72
+ )
73
+ if kept > 0.0:
74
+ closest_idx = np.argmin(np.abs(target_axis - spectrum.mz[0]))
75
+ resampled_intensity[closest_idx] = kept
76
+
77
+ return Spectrum(
78
+ mz=target_axis.copy(),
79
+ intensity=resampled_intensity,
80
+ coordinates=spectrum.coordinates,
81
+ metadata=spectrum.metadata,
82
+ )
83
+
84
+ if np.sum(spectrum.intensity) == 0:
85
+ # Handle zero intensity spectrum
86
+ return Spectrum(
87
+ mz=target_axis.copy(),
88
+ intensity=np.zeros_like(target_axis),
89
+ coordinates=spectrum.coordinates,
90
+ metadata=spectrum.metadata,
91
+ )
92
+
93
+ # np.interp and preserved_tic both require ascending m/z.
94
+ order = np.argsort(spectrum.mz)
95
+ mzs_sorted = np.asarray(spectrum.mz, dtype=np.float64)[order]
96
+ intensities_sorted = np.asarray(spectrum.intensity, dtype=np.float64)[order]
97
+
98
+ # Interpolate to target axis, dropping anything outside the source
99
+ # range. Equivalent to the previous interp1d(bounds_error=False,
100
+ # fill_value=0.0) for linear interpolation, without the scipy call.
101
+ interpolated_intensity = np.interp(
102
+ target_axis,
103
+ mzs_sorted,
104
+ intensities_sorted,
105
+ left=0.0,
106
+ right=0.0,
107
+ )
108
+
109
+ # Ensure no negative values (can happen with extrapolation)
110
+ interpolated_intensity = np.maximum(interpolated_intensity, 0.0)
111
+
112
+ # Scale onto the TIC the target axis is entitled to carry. Shared
113
+ # with the converters' hot path so the two cannot drift apart again.
114
+ rescale_to_preserved_tic(
115
+ interpolated_intensity,
116
+ np.asarray(target_axis, dtype=np.float64),
117
+ mzs_sorted,
118
+ intensities_sorted,
119
+ )
120
+
121
+ return Spectrum(
122
+ mz=target_axis.copy(),
123
+ intensity=np.asarray(interpolated_intensity, dtype=np.float64),
124
+ coordinates=spectrum.coordinates,
125
+ metadata=spectrum.metadata,
126
+ )