thyra 2.0.3__tar.gz → 2.1.0__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.3 → thyra-2.1.0}/PKG-INFO +1 -1
  2. {thyra-2.0.3 → thyra-2.1.0}/pyproject.toml +1 -1
  3. {thyra-2.0.3 → thyra-2.1.0}/thyra/__init__.py +1 -1
  4. {thyra-2.0.3 → thyra-2.1.0}/thyra/__main__.py +17 -0
  5. {thyra-2.0.3 → thyra-2.1.0}/thyra/convert.py +7 -4
  6. {thyra-2.0.3 → thyra-2.1.0}/thyra/converters/spatialdata/base_spatialdata_converter.py +30 -2
  7. {thyra-2.0.3 → thyra-2.1.0}/thyra/readers/imzml/imzml_reader.py +19 -3
  8. thyra-2.1.0/thyra/resampling/gaps.py +76 -0
  9. {thyra-2.0.3 → thyra-2.1.0}/thyra/resampling/strategies/nearest_neighbor.py +21 -0
  10. {thyra-2.0.3 → thyra-2.1.0}/thyra/resampling/strategies/tic_preserving.py +23 -1
  11. {thyra-2.0.3 → thyra-2.1.0}/thyra/resampling/types.py +20 -3
  12. {thyra-2.0.3 → thyra-2.1.0}/LICENSE +0 -0
  13. {thyra-2.0.3 → thyra-2.1.0}/README.md +0 -0
  14. {thyra-2.0.3 → thyra-2.1.0}/thyra/alignment/__init__.py +0 -0
  15. {thyra-2.0.3 → thyra-2.1.0}/thyra/alignment/affine.py +0 -0
  16. {thyra-2.0.3 → thyra-2.1.0}/thyra/alignment/teaching_points.py +0 -0
  17. {thyra-2.0.3 → thyra-2.1.0}/thyra/config.py +0 -0
  18. {thyra-2.0.3 → thyra-2.1.0}/thyra/converters/__init__.py +0 -0
  19. {thyra-2.0.3 → thyra-2.1.0}/thyra/converters/spatialdata/__init__.py +0 -0
  20. {thyra-2.0.3 → thyra-2.1.0}/thyra/converters/spatialdata/_chunking.py +0 -0
  21. {thyra-2.0.3 → thyra-2.1.0}/thyra/converters/spatialdata/converter.py +0 -0
  22. {thyra-2.0.3 → thyra-2.1.0}/thyra/converters/spatialdata/spatialdata_2d_converter.py +0 -0
  23. {thyra-2.0.3 → thyra-2.1.0}/thyra/converters/spatialdata/spatialdata_3d_converter.py +0 -0
  24. {thyra-2.0.3 → thyra-2.1.0}/thyra/converters/spatialdata/streaming_converter.py +0 -0
  25. {thyra-2.0.3 → thyra-2.1.0}/thyra/core/__init__.py +0 -0
  26. {thyra-2.0.3 → thyra-2.1.0}/thyra/core/base_converter.py +0 -0
  27. {thyra-2.0.3 → thyra-2.1.0}/thyra/core/base_extractor.py +0 -0
  28. {thyra-2.0.3 → thyra-2.1.0}/thyra/core/base_reader.py +0 -0
  29. {thyra-2.0.3 → thyra-2.1.0}/thyra/core/registry.py +0 -0
  30. {thyra-2.0.3 → thyra-2.1.0}/thyra/metadata/__init__.py +0 -0
  31. {thyra-2.0.3 → thyra-2.1.0}/thyra/metadata/extractors/__init__.py +0 -0
  32. {thyra-2.0.3 → thyra-2.1.0}/thyra/metadata/extractors/bruker_extractor.py +0 -0
  33. {thyra-2.0.3 → thyra-2.1.0}/thyra/metadata/extractors/imzml_extractor.py +0 -0
  34. {thyra-2.0.3 → thyra-2.1.0}/thyra/metadata/extractors/waters_extractor.py +0 -0
  35. {thyra-2.0.3 → thyra-2.1.0}/thyra/metadata/ontology/__init__.py +0 -0
  36. {thyra-2.0.3 → thyra-2.1.0}/thyra/metadata/ontology/_ims.py +0 -0
  37. {thyra-2.0.3 → thyra-2.1.0}/thyra/metadata/ontology/_ms.py +0 -0
  38. {thyra-2.0.3 → thyra-2.1.0}/thyra/metadata/ontology/_uo.py +0 -0
  39. {thyra-2.0.3 → thyra-2.1.0}/thyra/metadata/ontology/cache.py +0 -0
  40. {thyra-2.0.3 → thyra-2.1.0}/thyra/metadata/types.py +0 -0
  41. {thyra-2.0.3 → thyra-2.1.0}/thyra/metadata/validator.py +0 -0
  42. {thyra-2.0.3 → thyra-2.1.0}/thyra/preview.py +0 -0
  43. {thyra-2.0.3 → thyra-2.1.0}/thyra/readers/__init__.py +0 -0
  44. {thyra-2.0.3 → thyra-2.1.0}/thyra/readers/bruker/__init__.py +0 -0
  45. {thyra-2.0.3 → thyra-2.1.0}/thyra/readers/bruker/base_bruker_reader.py +0 -0
  46. {thyra-2.0.3 → thyra-2.1.0}/thyra/readers/bruker/folder_structure.py +0 -0
  47. {thyra-2.0.3 → thyra-2.1.0}/thyra/readers/bruker/mis_parser.py +0 -0
  48. {thyra-2.0.3 → thyra-2.1.0}/thyra/readers/bruker/rapiflex/__init__.py +0 -0
  49. {thyra-2.0.3 → thyra-2.1.0}/thyra/readers/bruker/rapiflex/rapiflex_reader.py +0 -0
  50. {thyra-2.0.3 → thyra-2.1.0}/thyra/readers/bruker/timstof/__init__.py +0 -0
  51. {thyra-2.0.3 → thyra-2.1.0}/thyra/readers/bruker/timstof/sdk/__init__.py +0 -0
  52. {thyra-2.0.3 → thyra-2.1.0}/thyra/readers/bruker/timstof/sdk/dll/LICENCE-BRUKER.txt +0 -0
  53. {thyra-2.0.3 → thyra-2.1.0}/thyra/readers/bruker/timstof/sdk/dll/README.md +0 -0
  54. {thyra-2.0.3 → thyra-2.1.0}/thyra/readers/bruker/timstof/sdk/dll/timsdata.dll +0 -0
  55. {thyra-2.0.3 → thyra-2.1.0}/thyra/readers/bruker/timstof/sdk/dll/timsdata.so +0 -0
  56. {thyra-2.0.3 → thyra-2.1.0}/thyra/readers/bruker/timstof/sdk/dll_manager.py +0 -0
  57. {thyra-2.0.3 → thyra-2.1.0}/thyra/readers/bruker/timstof/sdk/platform_detector.py +0 -0
  58. {thyra-2.0.3 → thyra-2.1.0}/thyra/readers/bruker/timstof/sdk/sdk_functions.py +0 -0
  59. {thyra-2.0.3 → thyra-2.1.0}/thyra/readers/bruker/timstof/timstof_reader.py +0 -0
  60. {thyra-2.0.3 → thyra-2.1.0}/thyra/readers/bruker/timstof/utils/__init__.py +0 -0
  61. {thyra-2.0.3 → thyra-2.1.0}/thyra/readers/bruker/timstof/utils/batch_processor.py +0 -0
  62. {thyra-2.0.3 → thyra-2.1.0}/thyra/readers/bruker/timstof/utils/coordinate_cache.py +0 -0
  63. {thyra-2.0.3 → thyra-2.1.0}/thyra/readers/bruker/timstof/utils/mass_axis_builder.py +0 -0
  64. {thyra-2.0.3 → thyra-2.1.0}/thyra/readers/bruker/timstof/utils/memory_manager.py +0 -0
  65. {thyra-2.0.3 → thyra-2.1.0}/thyra/readers/imzml/__init__.py +0 -0
  66. {thyra-2.0.3 → thyra-2.1.0}/thyra/readers/waters/__init__.py +0 -0
  67. {thyra-2.0.3 → thyra-2.1.0}/thyra/readers/waters/imaging_grid.py +0 -0
  68. {thyra-2.0.3 → thyra-2.1.0}/thyra/readers/waters/lib/MLReader.dll +0 -0
  69. {thyra-2.0.3 → thyra-2.1.0}/thyra/readers/waters/lib/MassLynxRaw.dll +0 -0
  70. {thyra-2.0.3 → thyra-2.1.0}/thyra/readers/waters/lib/libMLReader.so +0 -0
  71. {thyra-2.0.3 → thyra-2.1.0}/thyra/readers/waters/lib/libMassLynxRaw.so +0 -0
  72. {thyra-2.0.3 → thyra-2.1.0}/thyra/readers/waters/masslynx_lib.py +0 -0
  73. {thyra-2.0.3 → thyra-2.1.0}/thyra/readers/waters/waters_reader.py +0 -0
  74. {thyra-2.0.3 → thyra-2.1.0}/thyra/resampling/__init__.py +0 -0
  75. {thyra-2.0.3 → thyra-2.1.0}/thyra/resampling/common_axis.py +0 -0
  76. {thyra-2.0.3 → thyra-2.1.0}/thyra/resampling/constants.py +0 -0
  77. {thyra-2.0.3 → thyra-2.1.0}/thyra/resampling/data_characteristics.py +0 -0
  78. {thyra-2.0.3 → thyra-2.1.0}/thyra/resampling/decision_tree.py +0 -0
  79. {thyra-2.0.3 → thyra-2.1.0}/thyra/resampling/instrument_detectors.py +0 -0
  80. {thyra-2.0.3 → thyra-2.1.0}/thyra/resampling/mass_axis/__init__.py +0 -0
  81. {thyra-2.0.3 → thyra-2.1.0}/thyra/resampling/mass_axis/base_generator.py +0 -0
  82. {thyra-2.0.3 → thyra-2.1.0}/thyra/resampling/mass_axis/fticr_generator.py +0 -0
  83. {thyra-2.0.3 → thyra-2.1.0}/thyra/resampling/mass_axis/linear_generator.py +0 -0
  84. {thyra-2.0.3 → thyra-2.1.0}/thyra/resampling/mass_axis/linear_tof_generator.py +0 -0
  85. {thyra-2.0.3 → thyra-2.1.0}/thyra/resampling/mass_axis/orbitrap_generator.py +0 -0
  86. {thyra-2.0.3 → thyra-2.1.0}/thyra/resampling/mass_axis/reflector_tof_generator.py +0 -0
  87. {thyra-2.0.3 → thyra-2.1.0}/thyra/resampling/strategies/__init__.py +0 -0
  88. {thyra-2.0.3 → thyra-2.1.0}/thyra/resampling/strategies/base.py +0 -0
  89. {thyra-2.0.3 → thyra-2.1.0}/thyra/resampling/tic.py +0 -0
  90. {thyra-2.0.3 → thyra-2.1.0}/thyra/tools/__init__.py +0 -0
  91. {thyra-2.0.3 → thyra-2.1.0}/thyra/tools/check_ontology.py +0 -0
  92. {thyra-2.0.3 → thyra-2.1.0}/thyra/tools/make_example_data.py +0 -0
  93. {thyra-2.0.3 → thyra-2.1.0}/thyra/utils/__init__.py +0 -0
  94. {thyra-2.0.3 → thyra-2.1.0}/thyra/utils/bruker_exceptions.py +0 -0
  95. {thyra-2.0.3 → thyra-2.1.0}/thyra/utils/logging_config.py +0 -0
  96. {thyra-2.0.3 → thyra-2.1.0}/thyra/utils/windows_paths.py +0 -0
  97. {thyra-2.0.3 → thyra-2.1.0}/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.3
3
+ Version: 2.1.0
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.3"
7
+ version = "2.1.0"
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.3"
39
+ __version__ = "2.1.0"
40
40
 
41
41
  # Import key components - avoid wildcard imports
42
42
  try:
@@ -243,6 +243,7 @@ def _build_resampling_config(
243
243
  resample_max_mz: Optional[float],
244
244
  resample_width_at_mz: Optional[float],
245
245
  resample_reference_mz: float,
246
+ resample_gap_tolerance: Optional[float] = None,
246
247
  ) -> dict:
247
248
  """Build resampling configuration dictionary."""
248
249
  return {
@@ -253,6 +254,7 @@ def _build_resampling_config(
253
254
  "max_mz": resample_max_mz,
254
255
  "width_at_mz": resample_width_at_mz,
255
256
  "reference_mz": resample_reference_mz,
257
+ "gap_tolerance_da": resample_gap_tolerance,
256
258
  }
257
259
 
258
260
 
@@ -536,6 +538,16 @@ class GroupedCommand(click.Command):
536
538
  default=1000.0,
537
539
  help="Reference m/z for width specification (default: 1000.0)",
538
540
  )
541
+ @click.option(
542
+ "--resample-gap-tolerance",
543
+ type=float,
544
+ default=None,
545
+ help=(
546
+ "For tic_preserving only: discard target bins farther than this many "
547
+ "Da from any measured m/z, instead of interpolating across the gap "
548
+ "(default: no limit)"
549
+ ),
550
+ )
539
551
  # -- Bruker-specific --
540
552
  @click.option(
541
553
  "--use-recalibrated/--no-recalibrated",
@@ -583,6 +595,7 @@ def main(
583
595
  resample_max_mz: Optional[float],
584
596
  resample_width_at_mz: Optional[float],
585
597
  resample_reference_mz: float,
598
+ resample_gap_tolerance: Optional[float],
586
599
  mass_axis_type: str,
587
600
  sparse_format: str,
588
601
  include_optical: bool,
@@ -604,6 +617,9 @@ def main(
604
617
  resample_width_at_mz,
605
618
  resample_reference_mz,
606
619
  )
620
+ _validate_positive_float(
621
+ resample_gap_tolerance, "resample_gap_tolerance", "Gap tolerance"
622
+ )
607
623
  _validate_positive_float(
608
624
  intensity_threshold, "intensity_threshold", "Intensity threshold"
609
625
  )
@@ -641,6 +657,7 @@ def main(
641
657
  resample_max_mz,
642
658
  resample_width_at_mz,
643
659
  resample_reference_mz,
660
+ resample_gap_tolerance,
644
661
  )
645
662
  if resample
646
663
  else None
@@ -283,10 +283,13 @@ def convert_msi(
283
283
  use active/recalibrated calibration (default True).
284
284
  - max_mass_axis_length: int - For processed-mode imzML
285
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.
286
+ mass axis exceeds this many unique m/z values. This
287
+ matters when the peak lists share no m/z values,
288
+ where the raw axis grows to roughly one column per
289
+ peak in the whole dataset. Default: 10,000,000,
290
+ which is the limit SCiLS Lab places on the same
291
+ quantity (2026b User Guide, p.76). Pass None for
292
+ no limit.
290
293
  sparse_format: Sparse matrix format ('csc' or 'csr')
291
294
  include_optical: Include optical images (default: True)
292
295
  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.gaps import zero_across_gaps
20
21
  from ...resampling.tic import preserved_tic, rescale_to_preserved_tic
21
22
  from ...resampling.types import ResamplingConfig
22
23
  from ...utils.zarr_atomic_write import install_windows_atomic_write_retry
@@ -137,6 +138,7 @@ def _normalize_resampling_config(
137
138
  ),
138
139
  min_mz=config.get("min_mz"),
139
140
  max_mz=config.get("max_mz"),
141
+ gap_tolerance_da=config.get("gap_tolerance_da"),
140
142
  )
141
143
 
142
144
 
@@ -378,6 +380,12 @@ class BaseSpatialDataConverter(BaseMSIConverter, ABC):
378
380
  self._max_mz = config.max_mz
379
381
  self._width_at_mz = config.mass_width_da
380
382
  self._reference_mz = config.reference_mz
383
+ self._gap_tolerance_da = config.gap_tolerance_da
384
+ if self._gap_tolerance_da is not None:
385
+ logger.info(
386
+ f"Interpolation gap tolerance: {self._gap_tolerance_da} Da "
387
+ "(target bins farther than this from any source m/z are zeroed)"
388
+ )
381
389
 
382
390
  def _get_cached_metadata_for_resampling(self) -> Dict[str, Any]:
383
391
  """Get cached metadata for resampling decision tree to avoid multiple reader calls."""
@@ -1082,8 +1090,13 @@ class BaseSpatialDataConverter(BaseMSIConverter, ABC):
1082
1090
  # bincount without minlength is fastest
1083
1091
  accumulated = np.bincount(indices_clipped, weights=intensities)
1084
1092
 
1085
- # Extract non-zero bins for sparse representation
1086
- nonzero_mask = accumulated > 0
1093
+ # Extract non-zero bins for sparse representation. The test is
1094
+ # ``!= 0`` rather than ``> 0`` because a bin whose accumulated value is
1095
+ # negative is still a measurement: dropping it silently raises the
1096
+ # stored TIC above the input's. Baseline-subtracted data can carry
1097
+ # negative intensities, though every export seen so far filters them
1098
+ # out upstream.
1099
+ nonzero_mask = accumulated != 0
1087
1100
  nonzero_indices = np.where(nonzero_mask)[0].astype(np.int_)
1088
1101
  nonzero_values = accumulated[nonzero_mask]
1089
1102
 
@@ -1111,6 +1124,11 @@ class BaseSpatialDataConverter(BaseMSIConverter, ABC):
1111
1124
  When the axis spans the spectrum, which is the default, that share
1112
1125
  is the whole input TIC.
1113
1126
 
1127
+ When ``ResamplingConfig.gap_tolerance_da`` is set, bins farther than
1128
+ that from any source m/z are zeroed before the rescale, so
1129
+ interpolation cannot claim regions nothing was measured in. See
1130
+ ``thyra.resampling.gaps``.
1131
+
1114
1132
  Args:
1115
1133
  mzs: Original m/z values from the spectrum.
1116
1134
  intensities: Corresponding intensity values.
@@ -1158,6 +1176,16 @@ class BaseSpatialDataConverter(BaseMSIConverter, ABC):
1158
1176
  right=0,
1159
1177
  )
1160
1178
 
1179
+ # Discard bins no source point vouches for, before the rescale so the
1180
+ # intensity returns to the bins that were measured rather than being
1181
+ # deleted. No-op when no tolerance was configured.
1182
+ zero_across_gaps(
1183
+ resampled,
1184
+ axis,
1185
+ mzs_sorted,
1186
+ getattr(self, "_gap_tolerance_da", None),
1187
+ )
1188
+
1161
1189
  # Rescale to the required TIC. This is the step that makes the
1162
1190
  # method live up to its name.
1163
1191
  return rescale_to_preserved_tic(resampled, axis, mzs_sorted, intensities_sorted)
@@ -27,6 +27,16 @@ _MASS_AXIS_BATCH_VALUES = 1 << 20
27
27
  # Bounds that function's temporaries to O(block) rather than O(batch).
28
28
  _MASS_AXIS_PROBE_BLOCK = 1 << 20
29
29
 
30
+ # Default cap on the number of unique m/z values the processed-mode raw mass
31
+ # axis may reach, in ELEMENTS. Taken from SCiLS Lab, which applies the same
32
+ # limit to the same quantity: "Data sets in SCiLS Lab are limited to a maximum
33
+ # of 10 million bins on the common mass axis" (SCiLS Lab 2026b User Guide,
34
+ # p.76). The cap previously defaulted to None because no defensible number was
35
+ # available; this is one, from the tool Thyra's resampling was designed
36
+ # against. Override with ``reader_options={"max_mass_axis_length": N}``, or
37
+ # pass None for the old unlimited behaviour.
38
+ DEFAULT_MAX_MASS_AXIS_LENGTH = 10_000_000
39
+
30
40
 
31
41
  def _dedupe_sorted(a: NDArray[Any]) -> NDArray[Any]:
32
42
  """Deduplicate an ALREADY-SORTED array, as ``np.unique`` would.
@@ -337,14 +347,20 @@ class ImzMLReader(BaseMSIReader):
337
347
  cache_coordinates: Whether to cache coordinates upfront
338
348
  **kwargs: Additional arguments. ``max_mass_axis_length`` caps the
339
349
  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``.
350
+ reach before the build gives up. Defaults to
351
+ ``DEFAULT_MAX_MASS_AXIS_LENGTH`` (10 million, SCiLS Lab's own
352
+ limit); pass ``None`` explicitly for unlimited. See
353
+ ``_extract_continuous_mass_axis``.
342
354
  """
343
355
  super().__init__(data_path, **kwargs)
344
356
  self.filepath: Optional[Union[str, Path]] = data_path
345
357
  self.batch_size: int = batch_size
346
358
  self.cache_coordinates: bool = cache_coordinates
347
- self.max_mass_axis_length: Optional[int] = kwargs.get("max_mass_axis_length")
359
+ # Absent means "use the default"; an explicit None means "unlimited",
360
+ # so this cannot be a bare ``.get(...)`` with a None fallback.
361
+ self.max_mass_axis_length: Optional[int] = kwargs.get(
362
+ "max_mass_axis_length", DEFAULT_MAX_MASS_AXIS_LENGTH
363
+ )
348
364
  self.parser: Optional[ImzMLParser] = None
349
365
  self.ibd_file: Optional[Any] = None
350
366
  self.imzml_path: Optional[Path] = None
@@ -0,0 +1,76 @@
1
+ """Refusing to interpolate across regions that were never measured.
2
+
3
+ ``np.interp`` has no notion of a gap. Handed a spectrum whose m/z values are
4
+ sparse or thresholded, it draws a straight line from the last point before the
5
+ gap to the first point after it, and every target bin in between comes back
6
+ with a fabricated intensity. Nothing was measured there.
7
+
8
+ On real data this is not a rounding-scale effect. Forcing ``tic_preserving``
9
+ onto ``bellini.imzML`` -- 2,222 points per spectrum, median source spacing
10
+ 0.0072 Da, largest gap 1,269 Da -- puts 80.6% of the output total ion current
11
+ into m/z regions more than 0.5 Da from any measured point.
12
+
13
+ The fix is the parameter Cardinal (``tolerance``) and matter
14
+ (``approx1(..., tol=)``) both expose: how far a target m/z may sit from the
15
+ nearest source m/z before its interpolated value is discarded rather than
16
+ trusted.
17
+
18
+ Order matters
19
+ -------------
20
+
21
+ Masking happens **before** the TIC rescale, not after. The intensity was
22
+ measured; it belongs somewhere. Interpolation had spread it across a gap where
23
+ nothing was recorded, so returning it to the bins that do have measurements
24
+ behind them keeps ``ResamplingMethod.TIC_PRESERVING``'s promise intact. Masking
25
+ after the rescale would instead delete that intensity and quietly break the
26
+ method's defining property.
27
+ """
28
+
29
+ from typing import Any, Optional
30
+
31
+ import numpy as np
32
+ import numpy.typing as npt
33
+
34
+
35
+ def zero_across_gaps(
36
+ resampled: npt.NDArray[Any],
37
+ axis: npt.NDArray[np.floating[Any]],
38
+ mzs: npt.NDArray[np.floating[Any]],
39
+ tolerance: Optional[float],
40
+ ) -> npt.NDArray[Any]:
41
+ """Zero the bins of ``resampled`` that no source point vouches for.
42
+
43
+ Modifies ``resampled`` in place and returns it.
44
+
45
+ A bin survives when some source m/z lies within ``tolerance`` of it. On a
46
+ source grid of uniform step ``s`` the farthest any target point can be
47
+ from a source point is ``s / 2``, so any tolerance of at least half the
48
+ source spacing leaves such a spectrum untouched -- which is what makes it
49
+ safe to switch on for continuous profile data. Half the *widest* source
50
+ gap is the rule to size it by; exactly half is a floating-point knife
51
+ edge, so give it a hair of margin.
52
+
53
+ Args:
54
+ resampled: Interpolated intensities on ``axis``, modified in place.
55
+ axis: The target mass axis, ascending.
56
+ mzs: Source m/z values, ascending.
57
+ tolerance: Maximum distance in Da from a target bin to the nearest
58
+ source m/z. ``None`` disables the check and returns ``resampled``
59
+ untouched, as does a non-positive tolerance.
60
+
61
+ Returns:
62
+ ``resampled``, with unsupported bins set to zero.
63
+ """
64
+ if tolerance is None or tolerance <= 0.0 or mzs.size == 0:
65
+ return resampled
66
+
67
+ # Distance from each target bin to the nearest source m/z. searchsorted
68
+ # gives the insertion point; the nearest source point is on one side or
69
+ # the other of it.
70
+ right = np.searchsorted(mzs, axis)
71
+ lo = np.clip(right - 1, 0, mzs.size - 1)
72
+ hi = np.clip(right, 0, mzs.size - 1)
73
+ nearest = np.minimum(np.abs(axis - mzs[lo]), np.abs(axis - mzs[hi]))
74
+
75
+ resampled[nearest > tolerance] = 0.0
76
+ return resampled
@@ -2,6 +2,23 @@
2
2
 
3
3
  This strategy is optimal for centroid data (e.g., from timsTOF
4
4
  instruments) where each peak represents a discrete mass value.
5
+
6
+ Not to be confused with the converters' ``_nearest_neighbor_resample``
7
+ (``thyra/converters/spatialdata/base_spatialdata_converter.py``), which is
8
+ what ``ResamplingMethod.NEAREST_NEIGHBOR`` actually selects during a
9
+ conversion. The two share a name and do opposite things with intensity:
10
+
11
+ - This class INTERPOLATES. Each target point takes the intensity of the
12
+ nearest source point, so one source peak is replicated across every
13
+ target point closer to it than to any other. Summed intensity therefore
14
+ scales with how densely the target axis is laid out, and is not
15
+ preserved. That is the defining behaviour of nearest-neighbour
16
+ interpolation, not a defect -- but it means this class is the wrong tool
17
+ if you care about total ion current.
18
+ - The converter's version BINS. Each source peak is accumulated into
19
+ exactly one target bin, so summed intensity is preserved exactly.
20
+
21
+ If you want the conversion pipeline's behaviour, use the converter.
5
22
  """
6
23
 
7
24
  from typing import Any
@@ -24,6 +41,10 @@ class NearestNeighborStrategy(ResamplingStrategy):
24
41
  and assigns its intensity. This preserves the discrete nature
25
42
  of centroid data.
26
43
 
44
+ Does NOT preserve total ion current -- a source peak is repeated
45
+ at every target point nearest to it, so the sum grows with the
46
+ target axis density. See the module docstring.
47
+
27
48
  Parameters
28
49
  ----------
29
50
  spectrum : Spectrum
@@ -13,11 +13,12 @@ cropped intensity rather than redistributing it over the bins that remain.
13
13
  When the axis spans the spectrum, the whole TIC is preserved exactly.
14
14
  """
15
15
 
16
- from typing import Any
16
+ from typing import Any, Optional
17
17
 
18
18
  import numpy as np
19
19
  import numpy.typing as npt
20
20
 
21
+ from ..gaps import zero_across_gaps
21
22
  from ..tic import preserved_tic, rescale_to_preserved_tic
22
23
  from .base import ResamplingStrategy, Spectrum
23
24
 
@@ -25,6 +26,18 @@ from .base import ResamplingStrategy, Spectrum
25
26
  class TICPreservingStrategy(ResamplingStrategy):
26
27
  """TIC-preserving linear interpolation strategy for profile data."""
27
28
 
29
+ def __init__(self, gap_tolerance_da: Optional[float] = None) -> None:
30
+ """Initialize the strategy.
31
+
32
+ Args:
33
+ gap_tolerance_da: How far, in Daltons, a target bin may sit from
34
+ the nearest source m/z before its interpolated value is
35
+ discarded. ``None`` (the default) keeps ``np.interp``'s own
36
+ behaviour of drawing straight lines across unmeasured
37
+ regions. See :mod:`thyra.resampling.gaps`.
38
+ """
39
+ self.gap_tolerance_da = gap_tolerance_da
40
+
28
41
  def resample(
29
42
  self, spectrum: Spectrum, target_axis: npt.NDArray[np.floating[Any]]
30
43
  ) -> Spectrum:
@@ -109,6 +122,15 @@ class TICPreservingStrategy(ResamplingStrategy):
109
122
  # Ensure no negative values (can happen with extrapolation)
110
123
  interpolated_intensity = np.maximum(interpolated_intensity, 0.0)
111
124
 
125
+ # Discard bins no source point vouches for, before the rescale so the
126
+ # intensity returns to the measured bins rather than being deleted.
127
+ zero_across_gaps(
128
+ interpolated_intensity,
129
+ np.asarray(target_axis, dtype=np.float64),
130
+ mzs_sorted,
131
+ self.gap_tolerance_da,
132
+ )
133
+
112
134
  # Scale onto the TIC the target axis is entitled to carry. Shared
113
135
  # with the converters' hot path so the two cannot drift apart again.
114
136
  rescale_to_preserved_tic(
@@ -70,7 +70,13 @@ class MassAxis:
70
70
  return np.diff(self.mz_values)
71
71
 
72
72
  def resolution_at(self, mz: float) -> float:
73
- """Calculate resolution at given m/z."""
73
+ """Return ``mz`` divided by the local bin width.
74
+
75
+ This is a property of the AXIS, not of the data on it. A finely
76
+ spaced axis reports a large number here whether or not the spectra
77
+ stored on it resolve anything at that scale, so it must not be
78
+ reported to users as the acquisition's resolving power.
79
+ """
74
80
  idx = int(np.searchsorted(self.mz_values, mz))
75
81
  if idx > 0 and idx < len(self.mz_values):
76
82
  delta_mz = float(self.mz_values[idx] - self.mz_values[idx - 1])
@@ -91,8 +97,11 @@ class ResamplingConfig:
91
97
  axis_type: Mass axis spacing model. ``None`` auto-detects from
92
98
  the instrument metadata.
93
99
  target_bins: Number of bins in the resampled axis. ``None``
94
- lets the resampler choose a bin count that preserves the
95
- native resolution.
100
+ derives the count from ``mass_width_da`` at ``reference_mz``
101
+ across the mass range. Note that this is a fixed target
102
+ width, not a measurement: the source spectra's own point
103
+ spacing is never consulted, so the resulting axis can be
104
+ finer or coarser than the data it will hold.
96
105
  mass_width_da: Bin width in Daltons at ``reference_mz``.
97
106
  Alternative to ``target_bins`` -- specify one or the other.
98
107
  reference_mz: Reference m/z for ``mass_width_da``. Default
@@ -100,6 +109,13 @@ class ResamplingConfig:
100
109
  default.
101
110
  min_mz: Override the lower bound of the mass range.
102
111
  max_mz: Override the upper bound of the mass range.
112
+ gap_tolerance_da: How far, in Daltons, a target bin may sit from the
113
+ nearest source m/z before ``tic_preserving`` discards its
114
+ interpolated value instead of trusting it. ``None`` -- the
115
+ default -- means no check, which is how ``np.interp`` behaves:
116
+ straight lines are drawn across regions where nothing was
117
+ measured. Only affects ``tic_preserving``; ``nearest_neighbor``
118
+ never invents a bin. See :mod:`thyra.resampling.gaps`.
103
119
  """
104
120
 
105
121
  method: Optional[ResamplingMethod] = None
@@ -109,3 +125,4 @@ class ResamplingConfig:
109
125
  reference_mz: float = DEFAULT_REFERENCE_MZ
110
126
  min_mz: Optional[float] = None
111
127
  max_mz: Optional[float] = None
128
+ gap_tolerance_da: Optional[float] = None
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes