thyra 2.2.1__tar.gz → 2.2.3__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.2.1 → thyra-2.2.3}/PKG-INFO +1 -1
  2. {thyra-2.2.1 → thyra-2.2.3}/pyproject.toml +1 -1
  3. {thyra-2.2.1 → thyra-2.2.3}/thyra/__init__.py +1 -1
  4. {thyra-2.2.1 → thyra-2.2.3}/thyra/metadata/extractors/imzml_extractor.py +84 -14
  5. {thyra-2.2.1 → thyra-2.2.3}/thyra/resampling/data_characteristics.py +14 -1
  6. {thyra-2.2.1 → thyra-2.2.3}/thyra/resampling/instrument_detectors.py +102 -11
  7. {thyra-2.2.1 → thyra-2.2.3}/LICENSE +0 -0
  8. {thyra-2.2.1 → thyra-2.2.3}/README.md +0 -0
  9. {thyra-2.2.1 → thyra-2.2.3}/thyra/__main__.py +0 -0
  10. {thyra-2.2.1 → thyra-2.2.3}/thyra/alignment/__init__.py +0 -0
  11. {thyra-2.2.1 → thyra-2.2.3}/thyra/alignment/affine.py +0 -0
  12. {thyra-2.2.1 → thyra-2.2.3}/thyra/alignment/teaching_points.py +0 -0
  13. {thyra-2.2.1 → thyra-2.2.3}/thyra/config.py +0 -0
  14. {thyra-2.2.1 → thyra-2.2.3}/thyra/convert.py +0 -0
  15. {thyra-2.2.1 → thyra-2.2.3}/thyra/converters/__init__.py +0 -0
  16. {thyra-2.2.1 → thyra-2.2.3}/thyra/converters/spatialdata/__init__.py +0 -0
  17. {thyra-2.2.1 → thyra-2.2.3}/thyra/converters/spatialdata/_chunking.py +0 -0
  18. {thyra-2.2.1 → thyra-2.2.3}/thyra/converters/spatialdata/base_spatialdata_converter.py +0 -0
  19. {thyra-2.2.1 → thyra-2.2.3}/thyra/converters/spatialdata/converter.py +0 -0
  20. {thyra-2.2.1 → thyra-2.2.3}/thyra/converters/spatialdata/spatialdata_2d_converter.py +0 -0
  21. {thyra-2.2.1 → thyra-2.2.3}/thyra/converters/spatialdata/spatialdata_3d_converter.py +0 -0
  22. {thyra-2.2.1 → thyra-2.2.3}/thyra/converters/spatialdata/streaming_converter.py +0 -0
  23. {thyra-2.2.1 → thyra-2.2.3}/thyra/core/__init__.py +0 -0
  24. {thyra-2.2.1 → thyra-2.2.3}/thyra/core/base_converter.py +0 -0
  25. {thyra-2.2.1 → thyra-2.2.3}/thyra/core/base_extractor.py +0 -0
  26. {thyra-2.2.1 → thyra-2.2.3}/thyra/core/base_reader.py +0 -0
  27. {thyra-2.2.1 → thyra-2.2.3}/thyra/core/registry.py +0 -0
  28. {thyra-2.2.1 → thyra-2.2.3}/thyra/metadata/__init__.py +0 -0
  29. {thyra-2.2.1 → thyra-2.2.3}/thyra/metadata/extractors/__init__.py +0 -0
  30. {thyra-2.2.1 → thyra-2.2.3}/thyra/metadata/extractors/bruker_extractor.py +0 -0
  31. {thyra-2.2.1 → thyra-2.2.3}/thyra/metadata/extractors/waters_extractor.py +0 -0
  32. {thyra-2.2.1 → thyra-2.2.3}/thyra/metadata/ontology/__init__.py +0 -0
  33. {thyra-2.2.1 → thyra-2.2.3}/thyra/metadata/ontology/_ims.py +0 -0
  34. {thyra-2.2.1 → thyra-2.2.3}/thyra/metadata/ontology/_ms.py +0 -0
  35. {thyra-2.2.1 → thyra-2.2.3}/thyra/metadata/ontology/_uo.py +0 -0
  36. {thyra-2.2.1 → thyra-2.2.3}/thyra/metadata/ontology/cache.py +0 -0
  37. {thyra-2.2.1 → thyra-2.2.3}/thyra/metadata/types.py +0 -0
  38. {thyra-2.2.1 → thyra-2.2.3}/thyra/metadata/validator.py +0 -0
  39. {thyra-2.2.1 → thyra-2.2.3}/thyra/preview.py +0 -0
  40. {thyra-2.2.1 → thyra-2.2.3}/thyra/readers/__init__.py +0 -0
  41. {thyra-2.2.1 → thyra-2.2.3}/thyra/readers/bruker/__init__.py +0 -0
  42. {thyra-2.2.1 → thyra-2.2.3}/thyra/readers/bruker/base_bruker_reader.py +0 -0
  43. {thyra-2.2.1 → thyra-2.2.3}/thyra/readers/bruker/folder_structure.py +0 -0
  44. {thyra-2.2.1 → thyra-2.2.3}/thyra/readers/bruker/mis_parser.py +0 -0
  45. {thyra-2.2.1 → thyra-2.2.3}/thyra/readers/bruker/rapiflex/__init__.py +0 -0
  46. {thyra-2.2.1 → thyra-2.2.3}/thyra/readers/bruker/rapiflex/rapiflex_reader.py +0 -0
  47. {thyra-2.2.1 → thyra-2.2.3}/thyra/readers/bruker/timstof/__init__.py +0 -0
  48. {thyra-2.2.1 → thyra-2.2.3}/thyra/readers/bruker/timstof/sdk/__init__.py +0 -0
  49. {thyra-2.2.1 → thyra-2.2.3}/thyra/readers/bruker/timstof/sdk/dll/LICENCE-BRUKER.txt +0 -0
  50. {thyra-2.2.1 → thyra-2.2.3}/thyra/readers/bruker/timstof/sdk/dll/README.md +0 -0
  51. {thyra-2.2.1 → thyra-2.2.3}/thyra/readers/bruker/timstof/sdk/dll/timsdata.dll +0 -0
  52. {thyra-2.2.1 → thyra-2.2.3}/thyra/readers/bruker/timstof/sdk/dll/timsdata.so +0 -0
  53. {thyra-2.2.1 → thyra-2.2.3}/thyra/readers/bruker/timstof/sdk/dll_manager.py +0 -0
  54. {thyra-2.2.1 → thyra-2.2.3}/thyra/readers/bruker/timstof/sdk/platform_detector.py +0 -0
  55. {thyra-2.2.1 → thyra-2.2.3}/thyra/readers/bruker/timstof/sdk/sdk_functions.py +0 -0
  56. {thyra-2.2.1 → thyra-2.2.3}/thyra/readers/bruker/timstof/timstof_reader.py +0 -0
  57. {thyra-2.2.1 → thyra-2.2.3}/thyra/readers/bruker/timstof/utils/__init__.py +0 -0
  58. {thyra-2.2.1 → thyra-2.2.3}/thyra/readers/bruker/timstof/utils/batch_processor.py +0 -0
  59. {thyra-2.2.1 → thyra-2.2.3}/thyra/readers/bruker/timstof/utils/coordinate_cache.py +0 -0
  60. {thyra-2.2.1 → thyra-2.2.3}/thyra/readers/bruker/timstof/utils/mass_axis_builder.py +0 -0
  61. {thyra-2.2.1 → thyra-2.2.3}/thyra/readers/bruker/timstof/utils/memory_manager.py +0 -0
  62. {thyra-2.2.1 → thyra-2.2.3}/thyra/readers/imzml/__init__.py +0 -0
  63. {thyra-2.2.1 → thyra-2.2.3}/thyra/readers/imzml/imzml_reader.py +0 -0
  64. {thyra-2.2.1 → thyra-2.2.3}/thyra/readers/waters/__init__.py +0 -0
  65. {thyra-2.2.1 → thyra-2.2.3}/thyra/readers/waters/imaging_grid.py +0 -0
  66. {thyra-2.2.1 → thyra-2.2.3}/thyra/readers/waters/lib/MLReader.dll +0 -0
  67. {thyra-2.2.1 → thyra-2.2.3}/thyra/readers/waters/lib/MassLynxRaw.dll +0 -0
  68. {thyra-2.2.1 → thyra-2.2.3}/thyra/readers/waters/lib/libMLReader.so +0 -0
  69. {thyra-2.2.1 → thyra-2.2.3}/thyra/readers/waters/lib/libMassLynxRaw.so +0 -0
  70. {thyra-2.2.1 → thyra-2.2.3}/thyra/readers/waters/masslynx_lib.py +0 -0
  71. {thyra-2.2.1 → thyra-2.2.3}/thyra/readers/waters/waters_reader.py +0 -0
  72. {thyra-2.2.1 → thyra-2.2.3}/thyra/resampling/__init__.py +0 -0
  73. {thyra-2.2.1 → thyra-2.2.3}/thyra/resampling/common_axis.py +0 -0
  74. {thyra-2.2.1 → thyra-2.2.3}/thyra/resampling/constants.py +0 -0
  75. {thyra-2.2.1 → thyra-2.2.3}/thyra/resampling/decision_tree.py +0 -0
  76. {thyra-2.2.1 → thyra-2.2.3}/thyra/resampling/gaps.py +0 -0
  77. {thyra-2.2.1 → thyra-2.2.3}/thyra/resampling/mass_axis/__init__.py +0 -0
  78. {thyra-2.2.1 → thyra-2.2.3}/thyra/resampling/mass_axis/base_generator.py +0 -0
  79. {thyra-2.2.1 → thyra-2.2.3}/thyra/resampling/mass_axis/fticr_generator.py +0 -0
  80. {thyra-2.2.1 → thyra-2.2.3}/thyra/resampling/mass_axis/linear_generator.py +0 -0
  81. {thyra-2.2.1 → thyra-2.2.3}/thyra/resampling/mass_axis/linear_tof_generator.py +0 -0
  82. {thyra-2.2.1 → thyra-2.2.3}/thyra/resampling/mass_axis/orbitrap_generator.py +0 -0
  83. {thyra-2.2.1 → thyra-2.2.3}/thyra/resampling/mass_axis/reflector_tof_generator.py +0 -0
  84. {thyra-2.2.1 → thyra-2.2.3}/thyra/resampling/strategies/__init__.py +0 -0
  85. {thyra-2.2.1 → thyra-2.2.3}/thyra/resampling/strategies/base.py +0 -0
  86. {thyra-2.2.1 → thyra-2.2.3}/thyra/resampling/strategies/nearest_neighbor.py +0 -0
  87. {thyra-2.2.1 → thyra-2.2.3}/thyra/resampling/strategies/tic_preserving.py +0 -0
  88. {thyra-2.2.1 → thyra-2.2.3}/thyra/resampling/tic.py +0 -0
  89. {thyra-2.2.1 → thyra-2.2.3}/thyra/resampling/types.py +0 -0
  90. {thyra-2.2.1 → thyra-2.2.3}/thyra/tools/__init__.py +0 -0
  91. {thyra-2.2.1 → thyra-2.2.3}/thyra/tools/check_ontology.py +0 -0
  92. {thyra-2.2.1 → thyra-2.2.3}/thyra/tools/make_example_data.py +0 -0
  93. {thyra-2.2.1 → thyra-2.2.3}/thyra/utils/__init__.py +0 -0
  94. {thyra-2.2.1 → thyra-2.2.3}/thyra/utils/bruker_exceptions.py +0 -0
  95. {thyra-2.2.1 → thyra-2.2.3}/thyra/utils/logging_config.py +0 -0
  96. {thyra-2.2.1 → thyra-2.2.3}/thyra/utils/windows_paths.py +0 -0
  97. {thyra-2.2.1 → thyra-2.2.3}/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.2.1
3
+ Version: 2.2.3
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.2.1"
7
+ version = "2.2.3"
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.2.1"
39
+ __version__ = "2.2.3"
40
40
 
41
41
  # Import key components - avoid wildcard imports
42
42
  try:
@@ -9,7 +9,7 @@ from numpy.typing import NDArray
9
9
  from pyimzml.ImzMLParser import ImzMLParser
10
10
 
11
11
  from ...core.base_extractor import MetadataExtractor
12
- from ...resampling.constants import ImzMLAccessions, SpectrumType
12
+ from ...resampling.constants import BinaryDataType, ImzMLAccessions, SpectrumType
13
13
  from ..types import ComprehensiveMetadata, EssentialMetadata
14
14
 
15
15
  logger = logging.getLogger(__name__)
@@ -131,7 +131,7 @@ class ImzMLMetadataExtractor(MetadataExtractor):
131
131
  if param_by_name is None or not isinstance(param_by_name, dict):
132
132
  return False
133
133
 
134
- return "continuous" in param_by_name
134
+ return BinaryDataType.CONTINUOUS in param_by_name
135
135
  except Exception:
136
136
  return False
137
137
 
@@ -522,19 +522,27 @@ class ImzMLMetadataExtractor(MetadataExtractor):
522
522
  return None
523
523
 
524
524
  def _detect_centroid_spectrum(self) -> Optional[str]:
525
- """Detect spectrum type by looking for MS:1000127 (centroid) or MS:1000128 (profile)."""
525
+ """Detect spectrum type by looking for MS:1000127 (centroid) or MS:1000128 (profile).
526
+
527
+ What the file *declares* wins, wherever it is written. Only when the
528
+ file declares nothing does Thyra guess -- see
529
+ :meth:`_guess_spectrum_type_from_storage_mode` for what that guess is
530
+ worth.
531
+ """
526
532
  try:
527
- # Method 1: Check parser metadata first (no XML parsing needed)
528
- result = self._check_parser_metadata_for_centroid()
533
+ # 1. The declaration, from metadata the parser already holds.
534
+ result = self._check_parser_metadata_for_spectrum_type()
529
535
  if result:
530
536
  return result
531
537
 
532
- # Method 2: Stream-parse XML for spectrum type markers (memory efficient)
538
+ # 2. The same declaration, wherever else in the document it sits.
539
+ # Streams the XML, so it is tried only if step 1 came up empty.
533
540
  result = self._check_xml_for_spectrum_type()
534
541
  if result:
535
542
  return result
536
543
 
537
- return None
544
+ # 3. Nothing declared. Only now is a guess appropriate.
545
+ return self._guess_spectrum_type_from_storage_mode()
538
546
  except Exception as e:
539
547
  logger.debug(f"Could not detect spectrum type: {e}")
540
548
  return None
@@ -607,8 +615,13 @@ class ImzMLMetadataExtractor(MetadataExtractor):
607
615
  logger.warning("defusedxml not available, using xml.etree.ElementTree")
608
616
  return ET
609
617
 
610
- def _check_parser_metadata_for_centroid(self) -> Optional[str]:
611
- """Check parser metadata for processed flag indicating centroid data."""
618
+ def _file_description_params(self) -> Optional[Dict[str, Any]]:
619
+ """Return the fileDescription cvParams the parser already parsed.
620
+
621
+ ``param_by_name`` maps a term's CV *name* to its parsed value, and a
622
+ valueless cvParam -- which is what a flag like ``profile spectrum``
623
+ is -- parses to ``True``.
624
+ """
612
625
  if not (hasattr(self.parser, "metadata") and self.parser.metadata):
613
626
  return None
614
627
 
@@ -616,13 +629,70 @@ class ImzMLMetadataExtractor(MetadataExtractor):
616
629
  return None
617
630
 
618
631
  file_desc = self.parser.metadata.file_description
619
- if not hasattr(file_desc, "param_by_name"):
632
+ params = getattr(file_desc, "param_by_name", None)
633
+ if not isinstance(params, dict):
634
+ return None
635
+
636
+ return params
637
+
638
+ def _check_parser_metadata_for_spectrum_type(self) -> Optional[str]:
639
+ """Read the declared spectrum representation out of the fileDescription.
640
+
641
+ Cheap: the parser has already read this, so no XML pass is needed.
642
+ ``SpectrumType.CENTROID`` and ``SpectrumType.PROFILE`` are the CV
643
+ names of ``MS:1000127`` and ``MS:1000128``, which is what
644
+ ``param_by_name`` is keyed on.
645
+ """
646
+ params = self._file_description_params()
647
+ if params is None:
648
+ return None
649
+
650
+ if params.get(SpectrumType.CENTROID, False):
651
+ logger.info(
652
+ f"Detected centroid spectrum from declared "
653
+ f"{ImzMLAccessions.CENTROID_SPECTRUM} in fileDescription"
654
+ )
655
+ return SpectrumType.CENTROID
656
+
657
+ if params.get(SpectrumType.PROFILE, False):
658
+ logger.info(
659
+ f"Detected profile spectrum from declared "
660
+ f"{ImzMLAccessions.PROFILE_SPECTRUM} in fileDescription"
661
+ )
662
+ return SpectrumType.PROFILE
663
+
664
+ return None
665
+
666
+ def _guess_spectrum_type_from_storage_mode(self) -> Optional[str]:
667
+ """Last-resort guess: assume a processed-mode file is centroided.
668
+
669
+ Processed and centroid are orthogonal. ``IMS:1000031`` says each
670
+ spectrum carries its own m/z array; it says nothing about whether
671
+ the peaks in it are centroided or profile. Plenty of instruments
672
+ export profile spectra in processed mode -- ``bellini.imzML`` is
673
+ processed *and* declares ``MS:1000128 profile spectrum``.
674
+
675
+ This used to run before the declared accession was ever read, so
676
+ every processed file was reported as centroid whatever it said about
677
+ itself. It now runs only when the file declares neither
678
+ ``MS:1000127`` nor ``MS:1000128`` anywhere, where a guess is all
679
+ there is. Peak lists are the common case for processed exports, so
680
+ centroid is the better guess -- but it is still a guess, hence the
681
+ warning.
682
+ """
683
+ params = self._file_description_params()
684
+ if params is None:
620
685
  return None
621
686
 
622
- params = file_desc.param_by_name
623
- # If it's processed data, it's likely centroided
624
- if params.get("processed", False):
625
- logger.info("Assuming centroid spectrum for processed ImzML data")
687
+ if params.get(BinaryDataType.PROCESSED, False):
688
+ logger.warning(
689
+ "This imzML declares neither %s (centroid) nor %s (profile). "
690
+ "Assuming centroid, because it is stored in processed mode -- "
691
+ "but the two are independent, so check the result if the "
692
+ "spectra are actually profile.",
693
+ ImzMLAccessions.CENTROID_SPECTRUM,
694
+ ImzMLAccessions.PROFILE_SPECTRUM,
695
+ )
626
696
  return SpectrumType.CENTROID
627
697
 
628
698
  return None
@@ -66,6 +66,14 @@ class DataCharacteristics:
66
66
 
67
67
  Profile data typically has >5000 points per spectrum,
68
68
  indicating continuous signal rather than centroid peaks.
69
+
70
+ This says how densely the spectra are sampled. It says nothing about
71
+ which instrument produced them, so it **must not be used to pick a
72
+ resampling strategy or an axis type**: those encode assumptions about
73
+ the acquisition's physics. ``RapiflexDetector`` used to match on this
74
+ alone, which handed MALDI-TOF treatment to any dense profile data --
75
+ TOF-SIMS, Orbitrap, anything -- with no check that the data was
76
+ MALDI-TOF at all.
69
77
  """
70
78
  avg = self.avg_peaks_per_spectrum
71
79
  return (
@@ -76,7 +84,12 @@ class DataCharacteristics:
76
84
 
77
85
  @property
78
86
  def is_maldi_tof(self) -> bool:
79
- """Check if this is MALDI-TOF data."""
87
+ """Check if this is MALDI-TOF data.
88
+
89
+ Carries the same caveat as :attr:`is_high_density_profile`, which its
90
+ last clause consults: density plus a vendor name is not a modality.
91
+ No detector calls this.
92
+ """
80
93
  return (
81
94
  self.is_rapiflex_format
82
95
  or self.instrument_type == "MALDI-TOF"
@@ -52,6 +52,30 @@ class InstrumentDetector(ABC):
52
52
  """Get the recommended mass axis type for this instrument."""
53
53
  pass
54
54
 
55
+ @property
56
+ def source_grid_law(self) -> Optional[AxisType]:
57
+ """Spacing law of the *source* m/z grid, when it is known.
58
+
59
+ ``tic_preserving`` interpolates onto the target axis and then applies
60
+ one global scaling factor. That composite operator is exact only when
61
+ the source grid follows the same spacing law as the target axis; off
62
+ the diagonal it distorts the ratio between two peaks by exactly
63
+ ``(m_hi / m_lo) ** (p_target - p_source)``, with ``p`` = 0, 0.5, 1,
64
+ 1.5, 2 for constant / linear_tof / reflector_tof / orbitrap / fticr.
65
+
66
+ SCiLS Lab gates its own TIC-preserving resampling on the same
67
+ condition -- "If all axis types are identical, a TIC preserving
68
+ resampling is applied, otherwise a linear interpolation is performed"
69
+ (SCiLS Lab 2026b User Guide, p.80).
70
+
71
+ ``None`` -- the default -- means Thyra does not know the source law.
72
+ That is the honest answer for every detector that matches on
73
+ something other than a vendor format whose grid Thyra itself lays
74
+ out, and :class:`InstrumentDetectorChain` refuses ``TIC_PRESERVING``
75
+ for such a detector.
76
+ """
77
+ return None
78
+
55
79
 
56
80
  class CentroidImzMLDetector(InstrumentDetector):
57
81
  """Detector for centroid ImzML data.
@@ -91,23 +115,33 @@ class RapiflexDetector(InstrumentDetector):
91
115
  return "Rapiflex MALDI-TOF"
92
116
 
93
117
  def matches(self, characteristics: DataCharacteristics) -> bool:
94
- """Check if data matches Rapiflex, Bruker MALDI-TOF, or high-density profile."""
118
+ """Check if data came from Bruker flexImaging/Rapiflex.
119
+
120
+ Both branches below are written by one producer, the Rapiflex
121
+ metadata extractor, so a match here means the spectra arrive on the
122
+ uniform-in-m/z grid ``RapiflexReader`` builds.
123
+
124
+ Peak density is deliberately *not* consulted. It used to be: any
125
+ profile data averaging more than
126
+ ``Thresholds.PROFILE_PEAK_DENSITY`` points per spectrum was handed
127
+ MALDI-TOF treatment regardless of what instrument produced it. That
128
+ made the detector modality-blind -- a dense TOF-SIMS or Orbitrap
129
+ profile acquisition would have been resampled by MALDI-TOF logic
130
+ onto a MALDI-shaped axis, silently. SCiLS Lab does not guess
131
+ modality either; its importer takes ``--project TIMSTOF|TOF|FT`` as
132
+ an argument (2026b User Guide, p.81). Unknown-provenance profile
133
+ data now falls through to :class:`DefaultDetector`, which bins
134
+ counts rather than interpolating and so is safe for any modality.
135
+ """
95
136
  # Direct Rapiflex format detection
96
137
  if characteristics.is_rapiflex_format:
97
138
  return True
98
139
 
99
140
  # Bruker MALDI-TOF detection
100
- if (
141
+ return (
101
142
  characteristics.instrument_type == "MALDI-TOF"
102
143
  and characteristics.manufacturer == "Bruker"
103
- ):
104
- return True
105
-
106
- # Profile data with high peak density (likely MALDI-TOF)
107
- if characteristics.is_high_density_profile:
108
- return True
109
-
110
- return False
144
+ )
111
145
 
112
146
  def get_resampling_method(self) -> ResamplingMethod:
113
147
  """Return TIC-preserving for profile MALDI-TOF data."""
@@ -117,6 +151,19 @@ class RapiflexDetector(InstrumentDetector):
117
151
  """Return constant axis matching SCiLS Lab convention."""
118
152
  return AxisType.CONSTANT
119
153
 
154
+ @property
155
+ def source_grid_law(self) -> Optional[AxisType]:
156
+ """Report the constant law of the flexImaging source grid.
157
+
158
+ ``RapiflexReader.get_common_mass_axis`` lays every spectrum out with
159
+ ``np.linspace(mass_start, mass_end, n_points)``, so the source
160
+ spacing is constant in m/z -- the same law as the ``constant`` target
161
+ axis :meth:`get_axis_type` asks for. Source law equal to target law
162
+ is what makes ``tic_preserving`` exact here, and it is the reason
163
+ this is the only route on which the chain permits it.
164
+ """
165
+ return AxisType.CONSTANT
166
+
120
167
 
121
168
  class TimsTOFDetector(InstrumentDetector):
122
169
  """Detector for Bruker timsTOF data.
@@ -273,6 +320,9 @@ class InstrumentDetectorChain:
273
320
  ) -> ResamplingMethod:
274
321
  """Get resampling method for the detected instrument.
275
322
 
323
+ ``TIC_PRESERVING`` additionally has to clear the matching-axis-law
324
+ gate; see :meth:`_gate_tic_preserving`.
325
+
276
326
  Args:
277
327
  characteristics: Data characteristics to match
278
328
 
@@ -280,10 +330,51 @@ class InstrumentDetectorChain:
280
330
  Recommended resampling method for the instrument
281
331
  """
282
332
  detector = self.detect(characteristics)
283
- method = detector.get_resampling_method()
333
+ method = self._gate_tic_preserving(detector)
284
334
  logger.info(f"Selected resampling method: {method.name}")
285
335
  return method
286
336
 
337
+ @staticmethod
338
+ def _gate_tic_preserving(detector: InstrumentDetector) -> ResamplingMethod:
339
+ """Allow ``TIC_PRESERVING`` only when source and target laws agree.
340
+
341
+ SCiLS Lab applies TIC-preserving resampling to profile data only
342
+ when all the mass axes being combined are of the same type, and
343
+ linear interpolation otherwise (2026b User Guide, p.80). That is
344
+ also precisely the condition under which Thyra's operator --
345
+ interpolate, then rescale by one global factor -- is exact. See
346
+ :attr:`InstrumentDetector.source_grid_law` for the error off the
347
+ diagonal.
348
+
349
+ A detector that has not declared its source grid law does not clear
350
+ the gate. Auto-selection therefore cannot reach the interpolating
351
+ path on data whose acquisition Thyra has not actually identified.
352
+
353
+ Args:
354
+ detector: The detector that matched.
355
+
356
+ Returns:
357
+ The detector's method, or ``NEAREST_NEIGHBOR`` in its place.
358
+ """
359
+ method = detector.get_resampling_method()
360
+ if method is not ResamplingMethod.TIC_PRESERVING:
361
+ return method
362
+
363
+ axis_type = detector.get_axis_type()
364
+ source_law = detector.source_grid_law
365
+ if source_law is axis_type:
366
+ return method
367
+
368
+ logger.info(
369
+ "%s asks for TIC_PRESERVING onto a %s axis, but the source grid "
370
+ "law is %s. TIC-preserving resampling is exact only when the two "
371
+ "match, so NEAREST_NEIGHBOR is used instead.",
372
+ detector.name,
373
+ axis_type.name,
374
+ "unknown" if source_law is None else source_law.name,
375
+ )
376
+ return ResamplingMethod.NEAREST_NEIGHBOR
377
+
287
378
  def get_axis_type(self, characteristics: DataCharacteristics) -> AxisType:
288
379
  """Get axis type for the detected instrument.
289
380
 
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
File without changes
File without changes
File without changes
File without changes