thyra 3.7.4__tar.gz → 3.8.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 (117) hide show
  1. {thyra-3.7.4 → thyra-3.8.0}/PKG-INFO +2 -2
  2. {thyra-3.7.4 → thyra-3.8.0}/pyproject.toml +23 -11
  3. {thyra-3.7.4 → thyra-3.8.0}/thyra/__init__.py +1 -1
  4. {thyra-3.7.4 → thyra-3.8.0}/thyra/converters/spatialdata/_chunking.py +20 -5
  5. {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/extractors/imzml_extractor.py +258 -13
  6. {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/extractors/waters_extractor.py +17 -3
  7. {thyra-3.7.4 → thyra-3.8.0}/thyra/resampling/data_characteristics.py +16 -0
  8. {thyra-3.7.4 → thyra-3.8.0}/thyra/resampling/instrument_detectors.py +43 -0
  9. {thyra-3.7.4 → thyra-3.8.0}/.gitignore +0 -0
  10. {thyra-3.7.4 → thyra-3.8.0}/LICENSE +0 -0
  11. {thyra-3.7.4 → thyra-3.8.0}/README.md +0 -0
  12. {thyra-3.7.4 → thyra-3.8.0}/thyra/__main__.py +0 -0
  13. {thyra-3.7.4 → thyra-3.8.0}/thyra/alignment/__init__.py +0 -0
  14. {thyra-3.7.4 → thyra-3.8.0}/thyra/alignment/affine.py +0 -0
  15. {thyra-3.7.4 → thyra-3.8.0}/thyra/alignment/teaching_points.py +0 -0
  16. {thyra-3.7.4 → thyra-3.8.0}/thyra/config.py +0 -0
  17. {thyra-3.7.4 → thyra-3.8.0}/thyra/convert.py +0 -0
  18. {thyra-3.7.4 → thyra-3.8.0}/thyra/converters/__init__.py +0 -0
  19. {thyra-3.7.4 → thyra-3.8.0}/thyra/converters/spatialdata/__init__.py +0 -0
  20. {thyra-3.7.4 → thyra-3.8.0}/thyra/converters/spatialdata/base_spatialdata_converter.py +0 -0
  21. {thyra-3.7.4 → thyra-3.8.0}/thyra/converters/spatialdata/converter.py +0 -0
  22. {thyra-3.7.4 → thyra-3.8.0}/thyra/converters/spatialdata/spatialdata_2d_converter.py +0 -0
  23. {thyra-3.7.4 → thyra-3.8.0}/thyra/converters/spatialdata/spatialdata_3d_converter.py +0 -0
  24. {thyra-3.7.4 → thyra-3.8.0}/thyra/converters/spatialdata/streaming_converter.py +0 -0
  25. {thyra-3.7.4 → thyra-3.8.0}/thyra/core/__init__.py +0 -0
  26. {thyra-3.7.4 → thyra-3.8.0}/thyra/core/base_converter.py +0 -0
  27. {thyra-3.7.4 → thyra-3.8.0}/thyra/core/base_extractor.py +0 -0
  28. {thyra-3.7.4 → thyra-3.8.0}/thyra/core/base_reader.py +0 -0
  29. {thyra-3.7.4 → thyra-3.8.0}/thyra/core/registry.py +0 -0
  30. {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/__init__.py +0 -0
  31. {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/extractors/__init__.py +0 -0
  32. {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/extractors/bruker_extractor.py +0 -0
  33. {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/extractors/phi_extractor.py +0 -0
  34. {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/ontology/__init__.py +0 -0
  35. {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/ontology/_ims.py +0 -0
  36. {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/ontology/_ms.py +0 -0
  37. {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/ontology/_uo.py +0 -0
  38. {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/ontology/cache.py +0 -0
  39. {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/schema/__init__.py +0 -0
  40. {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/schema/builder.py +0 -0
  41. {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/schema/cli.py +0 -0
  42. {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/schema/generate.py +0 -0
  43. {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/schema/metaspace.py +0 -0
  44. {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/schema/models.py +0 -0
  45. {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/schema/msi_metadata.linkml.yaml +0 -0
  46. {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/schema/msi_metadata_schema_v0_1.json +0 -0
  47. {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/schema/store_io.py +0 -0
  48. {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/schema/validate.py +0 -0
  49. {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/schema/vocab.py +0 -0
  50. {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/types.py +0 -0
  51. {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/uns_compat.py +0 -0
  52. {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/validator.py +0 -0
  53. {thyra-3.7.4 → thyra-3.8.0}/thyra/preview.py +0 -0
  54. {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/__init__.py +0 -0
  55. {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/bruker/__init__.py +0 -0
  56. {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/bruker/base_bruker_reader.py +0 -0
  57. {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/bruker/folder_structure.py +0 -0
  58. {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/bruker/mis_parser.py +0 -0
  59. {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/bruker/rapiflex/__init__.py +0 -0
  60. {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/bruker/rapiflex/rapiflex_reader.py +0 -0
  61. {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/bruker/timstof/__init__.py +0 -0
  62. {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/bruker/timstof/sdk/__init__.py +0 -0
  63. {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/bruker/timstof/sdk/dll/LICENCE-BRUKER.txt +0 -0
  64. {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/bruker/timstof/sdk/dll/README.md +0 -0
  65. {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/bruker/timstof/sdk/dll/timsdata.dll +0 -0
  66. {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/bruker/timstof/sdk/dll/timsdata.so +0 -0
  67. {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/bruker/timstof/sdk/dll_manager.py +0 -0
  68. {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/bruker/timstof/sdk/platform_detector.py +0 -0
  69. {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/bruker/timstof/sdk/sdk_functions.py +0 -0
  70. {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/bruker/timstof/timstof_reader.py +0 -0
  71. {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/bruker/timstof/utils/__init__.py +0 -0
  72. {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/bruker/timstof/utils/batch_processor.py +0 -0
  73. {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/bruker/timstof/utils/coordinate_cache.py +0 -0
  74. {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/bruker/timstof/utils/mass_axis_builder.py +0 -0
  75. {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/bruker/timstof/utils/memory_manager.py +0 -0
  76. {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/imzml/__init__.py +0 -0
  77. {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/imzml/imzml_reader.py +0 -0
  78. {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/phi/__init__.py +0 -0
  79. {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/phi/event_stream.py +0 -0
  80. {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/phi/mass_axis.py +0 -0
  81. {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/phi/phi_header.py +0 -0
  82. {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/phi/phi_reader.py +0 -0
  83. {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/waters/__init__.py +0 -0
  84. {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/waters/imaging_grid.py +0 -0
  85. {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/waters/lib/MLReader.dll +0 -0
  86. {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/waters/lib/MassLynxRaw.dll +0 -0
  87. {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/waters/lib/libMLReader.so +0 -0
  88. {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/waters/lib/libMassLynxRaw.so +0 -0
  89. {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/waters/masslynx_lib.py +0 -0
  90. {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/waters/waters_reader.py +0 -0
  91. {thyra-3.7.4 → thyra-3.8.0}/thyra/resampling/__init__.py +0 -0
  92. {thyra-3.7.4 → thyra-3.8.0}/thyra/resampling/common_axis.py +0 -0
  93. {thyra-3.7.4 → thyra-3.8.0}/thyra/resampling/constants.py +0 -0
  94. {thyra-3.7.4 → thyra-3.8.0}/thyra/resampling/decision_tree.py +0 -0
  95. {thyra-3.7.4 → thyra-3.8.0}/thyra/resampling/gaps.py +0 -0
  96. {thyra-3.7.4 → thyra-3.8.0}/thyra/resampling/mass_axis/__init__.py +0 -0
  97. {thyra-3.7.4 → thyra-3.8.0}/thyra/resampling/mass_axis/base_generator.py +0 -0
  98. {thyra-3.7.4 → thyra-3.8.0}/thyra/resampling/mass_axis/fticr_generator.py +0 -0
  99. {thyra-3.7.4 → thyra-3.8.0}/thyra/resampling/mass_axis/linear_generator.py +0 -0
  100. {thyra-3.7.4 → thyra-3.8.0}/thyra/resampling/mass_axis/linear_tof_generator.py +0 -0
  101. {thyra-3.7.4 → thyra-3.8.0}/thyra/resampling/mass_axis/orbitrap_generator.py +0 -0
  102. {thyra-3.7.4 → thyra-3.8.0}/thyra/resampling/mass_axis/reflector_tof_generator.py +0 -0
  103. {thyra-3.7.4 → thyra-3.8.0}/thyra/resampling/strategies/__init__.py +0 -0
  104. {thyra-3.7.4 → thyra-3.8.0}/thyra/resampling/strategies/base.py +0 -0
  105. {thyra-3.7.4 → thyra-3.8.0}/thyra/resampling/strategies/nearest_neighbor.py +0 -0
  106. {thyra-3.7.4 → thyra-3.8.0}/thyra/resampling/strategies/tic_preserving.py +0 -0
  107. {thyra-3.7.4 → thyra-3.8.0}/thyra/resampling/tic.py +0 -0
  108. {thyra-3.7.4 → thyra-3.8.0}/thyra/resampling/types.py +0 -0
  109. {thyra-3.7.4 → thyra-3.8.0}/thyra/tools/__init__.py +0 -0
  110. {thyra-3.7.4 → thyra-3.8.0}/thyra/tools/check_ontology.py +0 -0
  111. {thyra-3.7.4 → thyra-3.8.0}/thyra/tools/make_example_data.py +0 -0
  112. {thyra-3.7.4 → thyra-3.8.0}/thyra/utils/__init__.py +0 -0
  113. {thyra-3.7.4 → thyra-3.8.0}/thyra/utils/bruker_exceptions.py +0 -0
  114. {thyra-3.7.4 → thyra-3.8.0}/thyra/utils/logging_config.py +0 -0
  115. {thyra-3.7.4 → thyra-3.8.0}/thyra/utils/pyimzml_direct.py +0 -0
  116. {thyra-3.7.4 → thyra-3.8.0}/thyra/utils/windows_paths.py +0 -0
  117. {thyra-3.7.4 → thyra-3.8.0}/thyra/utils/zarr_atomic_write.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: thyra
3
- Version: 3.7.4
3
+ Version: 3.8.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
  Project-URL: Homepage, https://github.com/M4i-Imaging-Mass-Spectrometry/thyra
6
6
  Project-URL: Repository, https://github.com/M4i-Imaging-Mass-Spectrometry/thyra
@@ -47,7 +47,7 @@ Requires-Dist: spatialdata<0.9,>=0.8.0
47
47
  Requires-Dist: tifffile>=2022.8.12
48
48
  Requires-Dist: tqdm>=4.50.0
49
49
  Requires-Dist: xarray>=2024.10.0
50
- Requires-Dist: zarr<3.2,>=3.1.4
50
+ Requires-Dist: zarr<3.2,>=3.1.6
51
51
  Description-Content-Type: text/markdown
52
52
 
53
53
  <p align="center">
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "thyra"
7
- version = "3.7.4"
7
+ version = "3.8.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 = [{ name = "Theodoros Visvikis", email = "t.visvikis@maastrichtuniversity.nl" }]
10
10
  maintainers = [{ name = "Theodoros Visvikis", email = "t.visvikis@maastrichtuniversity.nl" }]
@@ -91,16 +91,28 @@ dependencies = [
91
91
  # bit ome_zarr 0.12.x with newer dask, and spatialdata 0.8 requires >= 0.16.
92
92
  "ome-zarr>=0.16.0, <0.19", # tested 0.18.0
93
93
  "tqdm>=4.50.0",
94
- # Floor is 3.1.4, and it is load-bearing. anndata >= 0.13 writes every zarr v3
95
- # array with `shards="auto"`. zarr < 3.1.4 sized the auto chunk with
96
- # `max_bytes=1024` where its own comment says "aim for a 1MiB chunk" (compare
97
- # `_auto_partition` in 3.1.3 vs 3.1.4), so one shard -- one FILE -- came out at
98
- # ~1 KB. Measured on test_data/pea.imzML: 393,310 files for a single table and
99
- # 434 s of a 445 s conversion spent in the write, against 12 files and 2.7 s on
100
- # 3.1.6. Nothing in Thyra changed; the anndata 0.13 bump alone did this.
101
- # 3.1.4 is also where `array.target_shard_size_bytes` arrives, which is the
102
- # knob _chunking.table_write_config sets to claim the budget from anndata.
103
- "zarr>=3.1.4, <3.2", # tested 3.1.6
94
+ # Floor is 3.1.6, and it is load-bearing. anndata >= 0.13 writes every zarr v3
95
+ # array with `shards="auto"`, and `_auto_partition` sized the auto INNER chunk
96
+ # with `max_bytes=1024` where the line right above it says "aim for a 1MiB
97
+ # chunk". zarr-python#3603 changed that 1024 to 1048576; it merged six days
98
+ # AFTER 3.1.5 shipped, so 3.1.4 and 3.1.5 both still carry the defect
99
+ # (`chunk_grids.py:267` in each) and 3.1.6 is the first release without it.
100
+ # Do not drop the floor to 3.1.4 on the grounds that 3.1.4 is where
101
+ # `array.target_shard_size_bytes` arrives -- the knob _chunking.table_write_config
102
+ # sets to claim the budget from anndata. That knob is necessary, not sufficient.
103
+ #
104
+ # Both halves of the damage, measured on X/data of test_data/pea.imzML (60.1M
105
+ # nnz, f8, 481 MB):
106
+ # - Below 3.1.4 the knob does not exist, so a shard is 2 auto chunks -- ~1.8 KB,
107
+ # and a shard is one FILE. 393,310 files for a single table, and 434 s of a
108
+ # 445 s conversion spent in the write, against 12 files and 2.7 s on 3.1.6.
109
+ # Nothing in Thyra changed; the anndata 0.13 bump alone did this.
110
+ # - On 3.1.4/3.1.5 the 128 MiB budget IS honoured, so the file count stays sane
111
+ # (4), but the chunk inside those files is 920 B: ~145,900 separately
112
+ # compressed chunks per shard against 142 on 3.1.6, plus the shard index that
113
+ # implies. A 3.1.5 resolve buys that silently, and the shard-level assertion
114
+ # cannot see it; _chunking.MIN_TABLE_CHUNK_BYTES is what catches it.
115
+ "zarr>=3.1.6, <3.2", # tested 3.1.6
104
116
  "imagecodecs>=2024.1.1", # For reading compressed TIFF optical images
105
117
  # Floor is 0.13.2, not 0.13.0. anndata 0.13.0 imports
106
118
  # `typing_extensions.sentinel` (needs typing-extensions >= 4.16) but declares
@@ -36,7 +36,7 @@ warnings.filterwarnings(
36
36
  category=FutureWarning,
37
37
  )
38
38
 
39
- __version__ = "3.7.4"
39
+ __version__ = "3.8.0"
40
40
 
41
41
  # Import key components - avoid wildcard imports
42
42
  try:
@@ -53,13 +53,28 @@ INNER_TILE_EDGE = 512
53
53
  # began auto-sharding, so read amplification is unchanged.
54
54
  TABLE_SHARD_TARGET_BYTES = 128 * 1024 * 1024
55
55
 
56
- # zarr < 3.1.4 computed the auto chunk with ``max_bytes=1024`` where 1 MiB was
57
- # meant, so ``shards="auto"`` produced ~1 KB shards -- one file per KB of table.
58
- # It also predates ``array.target_shard_size_bytes``, making the budget above
59
- # unenforceable. pyproject pins the floor at 3.1.4 for exactly this reason;
60
- # this constant is what the regression test checks the floor still buys us.
56
+ # zarr < 3.1.6 computed the auto chunk with ``max_bytes=1024`` where 1 MiB was
57
+ # meant (zarr-python#3603, first released in 3.1.6 -- it landed after both 3.1.4
58
+ # and 3.1.5). zarr < 3.1.4 additionally predates ``array.target_shard_size_bytes``,
59
+ # making the budget above unenforceable, and there ``shards="auto"`` produced
60
+ # ~1 KB SHARDS -- one file per KB of table. pyproject pins the floor at 3.1.6 for
61
+ # both reasons.
62
+ #
63
+ # This constant guards the shard, i.e. the file, so it only ever catches that
64
+ # second, pre-3.1.4 half: on 3.1.4/3.1.5 the budget above still lands large
65
+ # shards and it is the chunk inside them that collapses. MIN_TABLE_CHUNK_BYTES
66
+ # is the half that catches those two.
61
67
  MIN_TABLE_SHARD_BYTES = 64 * 1024
62
68
 
69
+ # Minimum size for the INNER chunk of a table array -- the unit that sits inside
70
+ # a shard and the unit a random read decompresses. This is the half of the defect
71
+ # a large shard hides: on 3.1.4/3.1.5 the file count looks fine while each file
72
+ # quietly holds ~146,000 separately compressed chunks instead of ~142, and pays
73
+ # for all of them in the shard index. 64 KiB sits well above the ~800 B those
74
+ # versions hand the regression-test fixture and well below the ~200 KB 3.1.6
75
+ # hands it, so the two are never close to the threshold.
76
+ MIN_TABLE_CHUNK_BYTES = 64 * 1024
77
+
63
78
 
64
79
  @contextmanager
65
80
  def table_write_config() -> Iterator[None]:
@@ -16,6 +16,7 @@ from ...resampling.constants import (
16
16
  normalize_spectrum_type,
17
17
  )
18
18
  from ...utils.pyimzml_direct import read_spectrum_mzs_only
19
+ from ..ontology.cache import ONTOLOGY
19
20
  from ..types import ComprehensiveMetadata, EssentialMetadata
20
21
 
21
22
  logger = logging.getLogger(__name__)
@@ -30,6 +31,119 @@ UM_PER_UNIT = {
30
31
  "UO:0000018": 0.001, # nanometer
31
32
  }
32
33
 
34
+ # Analyzer-component terms, in preference order. The first one found under a
35
+ # <componentList><analyzer> is surfaced as ``instrument_info["analyzer"]``, so
36
+ # on a hybrid that declares several analyzers the highest-resolution stage --
37
+ # the one that defines the mass axis of the stored spectra -- wins.
38
+ _ANALYZER_ACCESSIONS = (
39
+ "MS:1000079", # fourier transform ion cyclotron resonance mass spectrometer
40
+ "MS:1000484", # orbitrap
41
+ "MS:1000084", # time-of-flight
42
+ "MS:1000264", # ion trap
43
+ )
44
+
45
+ # The subset of analyzers whose axis-spacing law the resampling detector chain
46
+ # distinguishes. Values are the exact ``instrument_type`` strings
47
+ # ``FTICRDetector`` and ``OrbitrapDetector`` match on -- change both together.
48
+ # TOF is deliberately absent: an imzML declaring a TOF analyzer says nothing
49
+ # about which vendor grid the spectra arrived on, and stamping a TOF type here
50
+ # could arm detectors whose source-grid claims only hold for their own reader.
51
+ _ANALYZER_FAMILIES = {
52
+ "MS:1000079": "FT-ICR",
53
+ "MS:1000484": "Orbitrap",
54
+ }
55
+
56
+ # Instrument-model terms that imply an analyzer family when no analyzer
57
+ # component is declared. The local ontology tables are flat -- no is-a links --
58
+ # so family membership is enumerated rather than walked.
59
+ _FTICR_MODEL_ACCESSIONS = frozenset(
60
+ {
61
+ "MS:1000141", # apex IV
62
+ "MS:1000142", # apex Q
63
+ "MS:1000448", # LTQ FT
64
+ "MS:1000557", # LTQ FT Ultra
65
+ "MS:1000695", # apex ultra
66
+ "MS:1001548", # Bruker Daltonics solarix series
67
+ "MS:1001549", # solariX
68
+ "MS:1001556", # Bruker Daltonics apex series
69
+ }
70
+ )
71
+
72
+ _ORBITRAP_MODEL_ACCESSIONS = frozenset(
73
+ {
74
+ "MS:1000449", # LTQ Orbitrap
75
+ "MS:1000555", # LTQ Orbitrap Discovery
76
+ "MS:1000556", # LTQ Orbitrap XL
77
+ "MS:1000639", # LTQ Orbitrap XL ETD
78
+ "MS:1000643", # MALDI LTQ Orbitrap
79
+ "MS:1000649", # Exactive
80
+ "MS:1001742", # LTQ Orbitrap Velos
81
+ "MS:1001910", # LTQ Orbitrap Elite
82
+ "MS:1001911", # Q Exactive
83
+ "MS:1002416", # Orbitrap Fusion
84
+ "MS:1002417", # Orbitrap Fusion ETD
85
+ "MS:1002523", # Q Exactive HF
86
+ "MS:1002526", # Exactive Plus
87
+ "MS:1002634", # Q Exactive Plus
88
+ "MS:1002732", # Orbitrap Fusion Lumos
89
+ "MS:1002835", # LTQ Orbitrap Classic
90
+ "MS:1002877", # Q Exactive HF-X
91
+ "MS:1003028", # Orbitrap Exploris 480
92
+ "MS:1003029", # Orbitrap Eclipse
93
+ "MS:1003094", # Orbitrap Exploris 240
94
+ "MS:1003095", # Orbitrap Exploris 120
95
+ "MS:1003096", # LTQ Orbitrap Velos Pro
96
+ "MS:1003112", # Orbitrap ID-X
97
+ }
98
+ )
99
+
100
+ # timsTOF models carry no family here on purpose: the timsTOF decision is
101
+ # ``DataCharacteristics.is_timstof``, a substring match on the surfaced model
102
+ # name -- the same single path the native .d route goes through. Stamping an
103
+ # ``instrument_type`` as well would create a second, divergable path.
104
+ _TIMSTOF_MODEL_ACCESSIONS = frozenset(
105
+ {
106
+ "MS:1003005", # timsTOF Pro
107
+ "MS:1003123", # Bruker Daltonics timsTOF series
108
+ "MS:1003124", # timsTOF fleX
109
+ "MS:1003229", # timsTOF
110
+ "MS:1003230", # timsTOF Pro 2
111
+ "MS:1003231", # timsTOF SCP
112
+ }
113
+ )
114
+
115
+ _MODEL_FAMILIES: Dict[str, Optional[str]] = {
116
+ **{acc: "FT-ICR" for acc in _FTICR_MODEL_ACCESSIONS},
117
+ **{acc: "Orbitrap" for acc in _ORBITRAP_MODEL_ACCESSIONS},
118
+ **{acc: None for acc in _TIMSTOF_MODEL_ACCESSIONS},
119
+ }
120
+
121
+ # Last-resort family match, applied to model text the file provides itself:
122
+ # the free-text value of MS:1000031 "instrument model", or the name of a model
123
+ # term newer than the shipped ontology tables. Tokens are vendor product names
124
+ # unambiguous enough that a substring hit identifies the family ("scimaX MRMS",
125
+ # "Orbitrap Astral", "solariX XR" all resolve correctly); generic words like
126
+ # "ft" or "tof" are deliberately not in the list.
127
+ _FAMILY_NAME_TOKENS = (
128
+ ("solarix", "FT-ICR"),
129
+ ("scimax", "FT-ICR"),
130
+ ("mrms", "FT-ICR"),
131
+ ("ft-icr", "FT-ICR"),
132
+ ("fticr", "FT-ICR"),
133
+ ("orbitrap", "Orbitrap"),
134
+ ("exactive", "Orbitrap"),
135
+ ("exploris", "Orbitrap"),
136
+ )
137
+
138
+
139
+ def _family_from_model_text(text: str) -> Optional[str]:
140
+ """Resolve an analyzer family from free-form instrument-model text."""
141
+ lowered = text.lower()
142
+ for token, family in _FAMILY_NAME_TOKENS:
143
+ if token in lowered:
144
+ return family
145
+ return None
146
+
33
147
 
34
148
  class ImzMLMetadataExtractor(MetadataExtractor):
35
149
  """ImzML-specific metadata extractor with optimized two-phase extraction."""
@@ -658,21 +772,152 @@ class ImzMLMetadataExtractor(MetadataExtractor):
658
772
  return params
659
773
 
660
774
  def _extract_instrument_info(self) -> Dict[str, Any]:
661
- """Extract instrument information."""
662
- instrument = {}
775
+ """Extract instrument information from the instrumentConfiguration blocks.
776
+
777
+ This used to read ``imzmldict`` for "instrument model" and friends, but
778
+ pyimzml's ``__readimzmlmeta`` only ever collects pixel-geometry and
779
+ laser parameters -- none of those keys can exist, so every store Thyra
780
+ wrote carried an empty ``instrument_information`` for imzML sources,
781
+ and neither ``FTICRDetector`` nor ``OrbitrapDetector`` could ever fire
782
+ on an imzML. The declarations survive on ``parser.metadata``, so they
783
+ are read from there:
784
+
785
+ - ``instrument_model`` / ``instrument_serial_number`` -- from the
786
+ instrumentConfiguration's own cvParams, referenceableParamGroup
787
+ inheritance included (Bruker and SCiLS exports put the model term in
788
+ a ``CommonInstrumentParams`` group).
789
+ - ``analyzer`` -- the CV name of the first recognised
790
+ ``<componentList><analyzer>`` term, which is what
791
+ ``normalize_analyzer`` in the metadata schema accepts.
792
+ - ``instrument_type`` -- the exact string the resampling detector
793
+ chain matches on (``"FT-ICR"`` / ``"Orbitrap"``), when the analyzer
794
+ or the model resolves to a family the chain distinguishes. Absent
795
+ otherwise: an unstated analyzer stays unstated.
796
+ """
797
+ info: Dict[str, Any] = {}
798
+ configs = self._instrument_configurations()
799
+ if not configs:
800
+ return info
663
801
 
664
- if hasattr(self.parser, "imzmldict") and self.parser.imzmldict:
665
- instrument_keys = [
666
- "instrument model",
667
- "instrument serial number",
668
- "software",
669
- "software version",
670
- ]
671
- for key in instrument_keys:
672
- if key in self.parser.imzmldict:
673
- instrument[key.replace(" ", "_")] = self.parser.imzmldict[key]
802
+ model = self._instrument_model(configs)
803
+ if model:
804
+ info["instrument_model"] = model
674
805
 
675
- return instrument
806
+ serial = self._config_param(configs, "MS:1000529")
807
+ if isinstance(serial, str) and serial.strip():
808
+ info["instrument_serial_number"] = serial.strip()
809
+
810
+ analyzer_accession = self._analyzer_accession(configs)
811
+ if analyzer_accession is not None:
812
+ info["analyzer"] = ONTOLOGY.terms[analyzer_accession][0]
813
+
814
+ family = self._analyzer_family(configs, analyzer_accession, model)
815
+ if family is not None:
816
+ info["instrument_type"] = family
817
+
818
+ return info
819
+
820
+ def _instrument_configurations(self) -> List[Any]:
821
+ """The parsed instrumentConfiguration ParamGroups, in document order."""
822
+ metadata = getattr(self.parser, "metadata", None)
823
+ configs = getattr(metadata, "instrument_configurations", None)
824
+ if not isinstance(configs, dict):
825
+ return []
826
+ return list(configs.values())
827
+
828
+ @staticmethod
829
+ def _config_param(configs: List[Any], accession: str) -> Any:
830
+ """The first value any configuration declares for ``accession``."""
831
+ for config in configs:
832
+ params = getattr(config, "param_by_accession", None)
833
+ if isinstance(params, dict) and accession in params:
834
+ return params[accession]
835
+ return None
836
+
837
+ def _instrument_model(self, configs: List[Any]) -> Optional[str]:
838
+ """The declared instrument model, from the shape the exporter chose.
839
+
840
+ Vendors write the model two ways: generic ``MS:1000031 instrument
841
+ model`` carrying the name as free text, or the model's own child term
842
+ as a valueless flag (``MS:1001549 solariX``, ``MS:1003124 timsTOF
843
+ fleX``). The explicit value wins; a known model accession resolves
844
+ through the shipped ontology table; and a term newer than the table is
845
+ still usable when its *name* identifies a family.
846
+ """
847
+ value = self._config_param(configs, "MS:1000031")
848
+ if isinstance(value, str) and value.strip():
849
+ return value.strip()
850
+
851
+ for config in configs:
852
+ params = getattr(config, "param_by_accession", None)
853
+ if not isinstance(params, dict):
854
+ continue
855
+ for accession in params:
856
+ if accession in _MODEL_FAMILIES:
857
+ return ONTOLOGY.terms[accession][0]
858
+
859
+ for config in configs:
860
+ params = getattr(config, "param_by_name", None)
861
+ if not isinstance(params, dict):
862
+ continue
863
+ for name, value in params.items():
864
+ if (
865
+ isinstance(name, str)
866
+ and value is True
867
+ and _family_from_model_text(name) is not None
868
+ ):
869
+ return name
870
+
871
+ return None
872
+
873
+ @staticmethod
874
+ def _analyzer_accession(configs: List[Any]) -> Optional[str]:
875
+ """The highest-priority analyzer term declared on any componentList."""
876
+ declared: set = set()
877
+ for config in configs:
878
+ for component in getattr(config, "components", None) or []:
879
+ if getattr(component, "type", None) != "analyzer":
880
+ continue
881
+ params = getattr(component, "param_by_accession", None)
882
+ if isinstance(params, dict):
883
+ declared.update(params)
884
+
885
+ for accession in _ANALYZER_ACCESSIONS:
886
+ if accession in declared:
887
+ return accession
888
+ return None
889
+
890
+ def _analyzer_family(
891
+ self,
892
+ configs: List[Any],
893
+ analyzer_accession: Optional[str],
894
+ model: Optional[str],
895
+ ) -> Optional[str]:
896
+ """Resolve the axis family, preferring the analyzer declaration.
897
+
898
+ The analyzer cvParam states the physics outright, so it outranks the
899
+ model term, which outranks a substring match on free-form model text.
900
+ A file that declares none of the three resolves to ``None``.
901
+ """
902
+ if analyzer_accession in _ANALYZER_FAMILIES:
903
+ return _ANALYZER_FAMILIES[analyzer_accession]
904
+ if analyzer_accession is not None:
905
+ # A recognised analyzer outside the family table (TOF, ion trap)
906
+ # is a real declaration; do not second-guess it from the model.
907
+ return None
908
+
909
+ for config in configs:
910
+ params = getattr(config, "param_by_accession", None)
911
+ if not isinstance(params, dict):
912
+ continue
913
+ for accession in params:
914
+ family = _MODEL_FAMILIES.get(accession)
915
+ if family is not None:
916
+ return family
917
+
918
+ if model is not None:
919
+ return _family_from_model_text(model)
920
+ return None
676
921
 
677
922
  def _extract_raw_metadata(self) -> Dict[str, Any]:
678
923
  """Extract raw metadata from imzmldict and spectrum cvParams."""
@@ -235,8 +235,16 @@ class WatersMetadataExtractor(MetadataExtractor):
235
235
  )
236
236
 
237
237
  def _extract_waters_specific(self) -> Dict[str, Any]:
238
- """Extract Waters format-specific metadata."""
238
+ """Extract Waters format-specific metadata.
239
+
240
+ ``format`` is the key ``DataCharacteristics.from_metadata`` reads for
241
+ its format flags (``is_waters_raw`` prefix-matches on "Waters ").
242
+ ``data_format`` predates it and only ever fed the stored
243
+ format-specific block, so both are kept: renaming it would change
244
+ stored metadata for no downstream gain.
245
+ """
239
246
  return {
247
+ "format": "Waters MassLynx raw",
240
248
  "data_format": "waters_raw",
241
249
  "data_path": str(self._data_path),
242
250
  "is_imaging": True,
@@ -280,9 +288,15 @@ class WatersMetadataExtractor(MetadataExtractor):
280
288
  return params
281
289
 
282
290
  def _extract_instrument_info(self) -> Dict[str, Any]:
283
- """Extract instrument information."""
291
+ """Extract instrument information.
292
+
293
+ The key is ``manufacturer`` because that is what
294
+ ``DataCharacteristics.from_metadata`` reads; this used to say
295
+ ``vendor``, which nothing downstream consumed, so the fact that a
296
+ file was Waters never reached the resampling detector chain.
297
+ """
284
298
  return {
285
- "vendor": "Waters",
299
+ "manufacturer": "Waters",
286
300
  "format": "MassLynx .raw",
287
301
  }
288
302
 
@@ -34,6 +34,7 @@ class DataCharacteristics:
34
34
  is_rapiflex_format: bool = False
35
35
  is_timstof: bool = False
36
36
  is_phi_tofsims: bool = False
37
+ is_waters_raw: bool = False
37
38
 
38
39
  @property
39
40
  def needs_resampling(self) -> bool:
@@ -123,7 +124,17 @@ class DataCharacteristics:
123
124
  instrument_info.get("instrument_type") if instrument_info else None
124
125
  )
125
126
  manufacturer = instrument_info.get("manufacturer") if instrument_info else None
127
+ # Bruker .d surfaces the name in GlobalMetadata, which stays
128
+ # authoritative; an imzML surfaces the declared model term through
129
+ # instrument_info instead. Folding the two here keeps ``is_timstof``
130
+ # below the single deciding path for both routes -- a timsTOF fleX
131
+ # exported to imzML used to lose its identity entirely because only
132
+ # GlobalMetadata was consulted.
126
133
  instrument_name = global_meta.get("InstrumentName") if global_meta else None
134
+ if not instrument_name and instrument_info:
135
+ instrument_name = instrument_info.get(
136
+ "instrument_model"
137
+ ) or instrument_info.get("instrument_name")
127
138
 
128
139
  # Format detection
129
140
  is_rapiflex = (
@@ -144,6 +155,10 @@ class DataCharacteristics:
144
155
  source_format = format_specific.get("format") if format_specific else None
145
156
  is_phi_tofsims = bool(source_format and source_format.startswith("PHI "))
146
157
 
158
+ # WatersMetadataExtractor stamps "Waters MassLynx raw"; same prefix
159
+ # convention as the PHI flag above.
160
+ is_waters_raw = bool(source_format and source_format.startswith("Waters "))
161
+
147
162
  return cls(
148
163
  spectrum_type=spectrum_type,
149
164
  total_peaks=total_peaks,
@@ -154,4 +169,5 @@ class DataCharacteristics:
154
169
  is_rapiflex_format=is_rapiflex,
155
170
  is_timstof=is_timstof,
156
171
  is_phi_tofsims=is_phi_tofsims,
172
+ is_waters_raw=is_waters_raw,
157
173
  )
@@ -298,6 +298,48 @@ class PhiToFSIMSDetector(InstrumentDetector):
298
298
  return AxisType.LINEAR_TOF
299
299
 
300
300
 
301
+ class WatersDetector(InstrumentDetector):
302
+ """Detector for Waters MassLynx .raw imaging data (SELECT SERIES MRT et al.).
303
+
304
+ Without this detector a Waters acquisition's fate hinged on its declared
305
+ spectrum representation: centroid files happened to land on
306
+ :class:`CentroidImzMLDetector` -- the right answer, reached by accident on
307
+ a detector whose name says imzML -- while profile files fell through to
308
+ :class:`DefaultDetector`. That reported ``CONSTANT``, which downstream
309
+ pairs with the 0.1 Da default bin width: R = 5,000 at m/z 500, on an
310
+ instrument built for 100,000+.
311
+ """
312
+
313
+ @property
314
+ def name(self) -> str:
315
+ """Return detector name."""
316
+ return "Waters MassLynx"
317
+
318
+ def matches(self, characteristics: DataCharacteristics) -> bool:
319
+ """Check whether the Waters extractor stamped the MassLynx raw format."""
320
+ return characteristics.is_waters_raw
321
+
322
+ def get_resampling_method(self) -> ResamplingMethod:
323
+ """Return nearest-neighbour, which bins counts and cannot invent them."""
324
+ return ResamplingMethod.NEAREST_NEIGHBOR
325
+
326
+ def get_axis_type(self) -> AxisType:
327
+ """Return reflector TOF: bin width proportional to m/z.
328
+
329
+ SCiLS Lab 2026b calls the same law Orthogonal TOF (formerly Reflector
330
+ TOF): bin size proportional to m/z, i.e. constant relative resolution
331
+ (2026b User Guide, p.75). That is the law for an orthogonal-TOF
332
+ instrument like the SELECT SERIES MRT.
333
+ """
334
+ return AxisType.REFLECTOR_TOF
335
+
336
+ # ``source_grid_law`` stays at the inherited ``None`` on purpose: MassLynx
337
+ # lays out the stored m/z grid, not Thyra, and no law has been measured
338
+ # for it. Declaring one untested would open ``_gate_tic_preserving`` --
339
+ # the gate that keeps auto-selection off the interpolating path -- on
340
+ # data whose grid Thyra has not actually identified.
341
+
342
+
301
343
  class DefaultDetector(InstrumentDetector):
302
344
  """Fallback detector for unknown instruments.
303
345
 
@@ -351,6 +393,7 @@ class InstrumentDetectorChain:
351
393
  FTICRDetector(),
352
394
  OrbitrapDetector(),
353
395
  PhiToFSIMSDetector(),
396
+ WatersDetector(),
354
397
  # Generic spectrum type detector
355
398
  CentroidImzMLDetector(),
356
399
  # Fallback last
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
File without changes