thyra 2.0.0__tar.gz → 2.0.2__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.2}/PKG-INFO +1 -1
  2. {thyra-2.0.0 → thyra-2.0.2}/pyproject.toml +1 -1
  3. {thyra-2.0.0 → thyra-2.0.2}/thyra/__init__.py +1 -1
  4. {thyra-2.0.0 → thyra-2.0.2}/thyra/convert.py +6 -0
  5. {thyra-2.0.0 → thyra-2.0.2}/thyra/converters/spatialdata/base_spatialdata_converter.py +47 -6
  6. {thyra-2.0.0 → thyra-2.0.2}/thyra/converters/spatialdata/streaming_converter.py +41 -7
  7. {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/imzml/imzml_reader.py +352 -45
  8. thyra-2.0.2/thyra/resampling/strategies/tic_preserving.py +126 -0
  9. thyra-2.0.2/thyra/resampling/tic.py +146 -0
  10. thyra-2.0.0/thyra/resampling/strategies/tic_preserving.py +0 -109
  11. {thyra-2.0.0 → thyra-2.0.2}/LICENSE +0 -0
  12. {thyra-2.0.0 → thyra-2.0.2}/README.md +0 -0
  13. {thyra-2.0.0 → thyra-2.0.2}/thyra/__main__.py +0 -0
  14. {thyra-2.0.0 → thyra-2.0.2}/thyra/alignment/__init__.py +0 -0
  15. {thyra-2.0.0 → thyra-2.0.2}/thyra/alignment/affine.py +0 -0
  16. {thyra-2.0.0 → thyra-2.0.2}/thyra/alignment/teaching_points.py +0 -0
  17. {thyra-2.0.0 → thyra-2.0.2}/thyra/config.py +0 -0
  18. {thyra-2.0.0 → thyra-2.0.2}/thyra/converters/__init__.py +0 -0
  19. {thyra-2.0.0 → thyra-2.0.2}/thyra/converters/spatialdata/__init__.py +0 -0
  20. {thyra-2.0.0 → thyra-2.0.2}/thyra/converters/spatialdata/_chunking.py +0 -0
  21. {thyra-2.0.0 → thyra-2.0.2}/thyra/converters/spatialdata/converter.py +0 -0
  22. {thyra-2.0.0 → thyra-2.0.2}/thyra/converters/spatialdata/spatialdata_2d_converter.py +0 -0
  23. {thyra-2.0.0 → thyra-2.0.2}/thyra/converters/spatialdata/spatialdata_3d_converter.py +0 -0
  24. {thyra-2.0.0 → thyra-2.0.2}/thyra/core/__init__.py +0 -0
  25. {thyra-2.0.0 → thyra-2.0.2}/thyra/core/base_converter.py +0 -0
  26. {thyra-2.0.0 → thyra-2.0.2}/thyra/core/base_extractor.py +0 -0
  27. {thyra-2.0.0 → thyra-2.0.2}/thyra/core/base_reader.py +0 -0
  28. {thyra-2.0.0 → thyra-2.0.2}/thyra/core/registry.py +0 -0
  29. {thyra-2.0.0 → thyra-2.0.2}/thyra/metadata/__init__.py +0 -0
  30. {thyra-2.0.0 → thyra-2.0.2}/thyra/metadata/extractors/__init__.py +0 -0
  31. {thyra-2.0.0 → thyra-2.0.2}/thyra/metadata/extractors/bruker_extractor.py +0 -0
  32. {thyra-2.0.0 → thyra-2.0.2}/thyra/metadata/extractors/imzml_extractor.py +0 -0
  33. {thyra-2.0.0 → thyra-2.0.2}/thyra/metadata/extractors/waters_extractor.py +0 -0
  34. {thyra-2.0.0 → thyra-2.0.2}/thyra/metadata/ontology/__init__.py +0 -0
  35. {thyra-2.0.0 → thyra-2.0.2}/thyra/metadata/ontology/_ims.py +0 -0
  36. {thyra-2.0.0 → thyra-2.0.2}/thyra/metadata/ontology/_ms.py +0 -0
  37. {thyra-2.0.0 → thyra-2.0.2}/thyra/metadata/ontology/_uo.py +0 -0
  38. {thyra-2.0.0 → thyra-2.0.2}/thyra/metadata/ontology/cache.py +0 -0
  39. {thyra-2.0.0 → thyra-2.0.2}/thyra/metadata/types.py +0 -0
  40. {thyra-2.0.0 → thyra-2.0.2}/thyra/metadata/validator.py +0 -0
  41. {thyra-2.0.0 → thyra-2.0.2}/thyra/preview.py +0 -0
  42. {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/__init__.py +0 -0
  43. {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/bruker/__init__.py +0 -0
  44. {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/bruker/base_bruker_reader.py +0 -0
  45. {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/bruker/folder_structure.py +0 -0
  46. {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/bruker/mis_parser.py +0 -0
  47. {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/bruker/rapiflex/__init__.py +0 -0
  48. {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/bruker/rapiflex/rapiflex_reader.py +0 -0
  49. {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/bruker/timstof/__init__.py +0 -0
  50. {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/bruker/timstof/sdk/__init__.py +0 -0
  51. {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/bruker/timstof/sdk/dll/LICENCE-BRUKER.txt +0 -0
  52. {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/bruker/timstof/sdk/dll/README.md +0 -0
  53. {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/bruker/timstof/sdk/dll/timsdata.dll +0 -0
  54. {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/bruker/timstof/sdk/dll/timsdata.so +0 -0
  55. {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/bruker/timstof/sdk/dll_manager.py +0 -0
  56. {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/bruker/timstof/sdk/platform_detector.py +0 -0
  57. {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/bruker/timstof/sdk/sdk_functions.py +0 -0
  58. {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/bruker/timstof/timstof_reader.py +0 -0
  59. {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/bruker/timstof/utils/__init__.py +0 -0
  60. {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/bruker/timstof/utils/batch_processor.py +0 -0
  61. {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/bruker/timstof/utils/coordinate_cache.py +0 -0
  62. {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/bruker/timstof/utils/mass_axis_builder.py +0 -0
  63. {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/bruker/timstof/utils/memory_manager.py +0 -0
  64. {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/imzml/__init__.py +0 -0
  65. {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/waters/__init__.py +0 -0
  66. {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/waters/imaging_grid.py +0 -0
  67. {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/waters/lib/MLReader.dll +0 -0
  68. {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/waters/lib/MassLynxRaw.dll +0 -0
  69. {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/waters/lib/libMLReader.so +0 -0
  70. {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/waters/lib/libMassLynxRaw.so +0 -0
  71. {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/waters/masslynx_lib.py +0 -0
  72. {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/waters/waters_reader.py +0 -0
  73. {thyra-2.0.0 → thyra-2.0.2}/thyra/resampling/__init__.py +0 -0
  74. {thyra-2.0.0 → thyra-2.0.2}/thyra/resampling/common_axis.py +0 -0
  75. {thyra-2.0.0 → thyra-2.0.2}/thyra/resampling/constants.py +0 -0
  76. {thyra-2.0.0 → thyra-2.0.2}/thyra/resampling/data_characteristics.py +0 -0
  77. {thyra-2.0.0 → thyra-2.0.2}/thyra/resampling/decision_tree.py +0 -0
  78. {thyra-2.0.0 → thyra-2.0.2}/thyra/resampling/instrument_detectors.py +0 -0
  79. {thyra-2.0.0 → thyra-2.0.2}/thyra/resampling/mass_axis/__init__.py +0 -0
  80. {thyra-2.0.0 → thyra-2.0.2}/thyra/resampling/mass_axis/base_generator.py +0 -0
  81. {thyra-2.0.0 → thyra-2.0.2}/thyra/resampling/mass_axis/fticr_generator.py +0 -0
  82. {thyra-2.0.0 → thyra-2.0.2}/thyra/resampling/mass_axis/linear_generator.py +0 -0
  83. {thyra-2.0.0 → thyra-2.0.2}/thyra/resampling/mass_axis/linear_tof_generator.py +0 -0
  84. {thyra-2.0.0 → thyra-2.0.2}/thyra/resampling/mass_axis/orbitrap_generator.py +0 -0
  85. {thyra-2.0.0 → thyra-2.0.2}/thyra/resampling/mass_axis/reflector_tof_generator.py +0 -0
  86. {thyra-2.0.0 → thyra-2.0.2}/thyra/resampling/strategies/__init__.py +0 -0
  87. {thyra-2.0.0 → thyra-2.0.2}/thyra/resampling/strategies/base.py +0 -0
  88. {thyra-2.0.0 → thyra-2.0.2}/thyra/resampling/strategies/nearest_neighbor.py +0 -0
  89. {thyra-2.0.0 → thyra-2.0.2}/thyra/resampling/types.py +0 -0
  90. {thyra-2.0.0 → thyra-2.0.2}/thyra/tools/__init__.py +0 -0
  91. {thyra-2.0.0 → thyra-2.0.2}/thyra/tools/check_ontology.py +0 -0
  92. {thyra-2.0.0 → thyra-2.0.2}/thyra/tools/make_example_data.py +0 -0
  93. {thyra-2.0.0 → thyra-2.0.2}/thyra/utils/__init__.py +0 -0
  94. {thyra-2.0.0 → thyra-2.0.2}/thyra/utils/bruker_exceptions.py +0 -0
  95. {thyra-2.0.0 → thyra-2.0.2}/thyra/utils/logging_config.py +0 -0
  96. {thyra-2.0.0 → thyra-2.0.2}/thyra/utils/windows_paths.py +0 -0
  97. {thyra-2.0.0 → thyra-2.0.2}/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.2
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.2"
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.2"
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,
@@ -40,6 +40,12 @@ if SPATIALDATA_AVAILABLE:
40
40
 
41
41
  logger = logging.getLogger(__name__)
42
42
 
43
+ # Elements per chunk when building the string index arrays. Bounds the
44
+ # transient cost of formatting to this many entries rather than the whole
45
+ # axis; measured at 17.5 bytes per entry at peak against 88 for a one-shot
46
+ # build, and 32 for an unchunked vectorised one.
47
+ _INDEX_BUILD_CHUNK = 1_000_000
48
+
43
49
 
44
50
  class StreamingSpatialDataConverter(BaseSpatialDataConverter):
45
51
  """Memory-efficient streaming converter for MSI data to SpatialData format.
@@ -491,11 +497,19 @@ class StreamingSpatialDataConverter(BaseSpatialDataConverter):
491
497
  indptr_arr.attrs["encoding-type"] = "array"
492
498
  indptr_arr.attrs["encoding-version"] = "0.2.0"
493
499
 
500
+ # Column indices are bounded by n_cols, not by total_nnz, so they need
501
+ # their own dtype decision. Hardcoding int32 here wrapped silently
502
+ # above 2,147,483,647 m/z bins: zarr truncates an oversized write
503
+ # without warning, and the resulting negative indices survive all the
504
+ # way into scipy without an error. This is the same switch point scipy
505
+ # uses, so the matrix rebuilt from these arrays needs no cast.
506
+ indices_dtype = np.int64 if n_cols > np.iinfo(np.int32).max else np.int32
507
+
494
508
  chunk_size_zarr = min(total_nnz, 1000000)
495
509
  indices_arr = X_group.create_array(
496
510
  "indices",
497
511
  shape=(total_nnz,),
498
- dtype=np.int32,
512
+ dtype=indices_dtype,
499
513
  chunks=(chunk_size_zarr,),
500
514
  )
501
515
  indices_arr.attrs["encoding-type"] = "array"
@@ -566,7 +580,11 @@ class StreamingSpatialDataConverter(BaseSpatialDataConverter):
566
580
  pos = write_pos[pixel_idx]
567
581
  positions = np.arange(pos, pos + nnz)
568
582
  buf_positions.append(positions)
569
- buf_indices.append(mz_indices.astype(np.int32))
583
+ # Take the dtype from the destination array rather than
584
+ # re-deriving it, so the buffer and the store cannot drift
585
+ # apart. Zarr accepts an oversized write and truncates it
586
+ # silently, so a mismatch here would be invisible.
587
+ buf_indices.append(mz_indices.astype(indices_arr.dtype))
570
588
  buf_data.append(resampled_ints.astype(np.float64))
571
589
  write_pos[pixel_idx] += nnz
572
590
  buf_size += nnz
@@ -716,11 +734,15 @@ class StreamingSpatialDataConverter(BaseSpatialDataConverter):
716
734
 
717
735
  logger.info(f"Loaded CSR components: {len(data):,} entries")
718
736
 
719
- # For large datasets (>2.1B entries), ensure 64-bit indices for scipy
737
+ # indices already carries the dtype chosen at write time, keyed on the
738
+ # column count (see _coo_setup_zarr_arrays), so there is nothing to fix
739
+ # up here. Upcasting it was never a safeguard anyway: it widened values
740
+ # that had already been truncated on the way in, and it was keyed on
741
+ # the wrong quantity. indptr is bounded by nnz, hence the check below.
742
+ # copy=False makes the no-op case free rather than a full-size copy.
720
743
  if len(data) > np.iinfo(np.int32).max:
721
744
  logger.info("Large dataset detected, using 64-bit sparse matrix indices")
722
- indptr = indptr.astype(np.int64)
723
- indices = indices.astype(np.int64)
745
+ indptr = indptr.astype(np.int64, copy=False)
724
746
 
725
747
  # Create CSR matrix directly (no COO intermediate)
726
748
  sparse_matrix: Union[sparse.csr_matrix, sparse.csc_matrix] = sparse.csr_matrix(
@@ -1417,7 +1439,9 @@ class StreamingSpatialDataConverter(BaseSpatialDataConverter):
1417
1439
 
1418
1440
  y_values = np.repeat(np.arange(n_y, dtype=np.int32), n_x)
1419
1441
  x_values = np.tile(np.arange(n_x, dtype=np.int32), n_y)
1420
- instance_ids = np.array([str(i) for i in range(n_rows)], dtype=str_dtype)
1442
+ # Same reasoning as the var index below, but n_rows is the pixel count
1443
+ # and stays far smaller than n_cols, so one shot needs no chunking.
1444
+ instance_ids = np.arange(n_rows, dtype=np.int64).astype(str_dtype)
1421
1445
  spatial_x = x_values.astype(np.float64) * self.pixel_size_um
1422
1446
  spatial_y = y_values.astype(np.float64) * self.pixel_size_um
1423
1447
 
@@ -1464,7 +1488,17 @@ class StreamingSpatialDataConverter(BaseSpatialDataConverter):
1464
1488
  mz_values = self._common_mass_axis
1465
1489
  if mz_values is None:
1466
1490
  raise RuntimeError("Common mass axis not initialized")
1467
- mz_index = np.array([f"mz_{i}" for i in range(n_cols)], dtype=str_dtype)
1491
+ # Built in chunks rather than from a list comprehension. Materialising
1492
+ # n_cols Python str objects first costs about 88 bytes per entry at
1493
+ # peak, against 17.5 for this; at 10 million bins that is 883 MB
1494
+ # versus 175 MB, on a path whose docstring promises roughly 200 MB
1495
+ # regardless of dataset size. The values are identical.
1496
+ mz_index = np.empty(n_cols, dtype=str_dtype)
1497
+ for start in range(0, n_cols, _INDEX_BUILD_CHUNK):
1498
+ stop = min(start + _INDEX_BUILD_CHUNK, n_cols)
1499
+ mz_index[start:stop] = np.strings.add(
1500
+ "mz_", np.arange(start, stop, dtype=np.int64).astype(str_dtype)
1501
+ )
1468
1502
  a = var_group.create_array("_index", data=mz_index)
1469
1503
  a.attrs["encoding-type"] = "string-array"
1470
1504
  a.attrs["encoding-version"] = "0.2.0"
@@ -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