thyra 4.3.1__tar.gz → 4.3.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 (149) hide show
  1. {thyra-4.3.1 → thyra-4.3.2}/PKG-INFO +1 -1
  2. {thyra-4.3.1 → thyra-4.3.2}/pyproject.toml +1 -1
  3. {thyra-4.3.1 → thyra-4.3.2}/thyra/__init__.py +1 -1
  4. {thyra-4.3.1 → thyra-4.3.2}/thyra/convert.py +6 -3
  5. {thyra-4.3.1 → thyra-4.3.2}/thyra/metadata/extractors/imzml_extractor.py +137 -116
  6. {thyra-4.3.1 → thyra-4.3.2}/thyra/metadata/extractors/mzpeak_extractor.py +103 -15
  7. {thyra-4.3.1 → thyra-4.3.2}/thyra/readers/mzpeak/chunk_decoding.py +13 -1
  8. {thyra-4.3.1 → thyra-4.3.2}/thyra/readers/mzpeak/mzpeak_reader.py +463 -30
  9. {thyra-4.3.1 → thyra-4.3.2}/thyra/resampling/instrument_detectors.py +5 -2
  10. {thyra-4.3.1 → thyra-4.3.2}/.gitignore +0 -0
  11. {thyra-4.3.1 → thyra-4.3.2}/LICENSE +0 -0
  12. {thyra-4.3.1 → thyra-4.3.2}/README.md +0 -0
  13. {thyra-4.3.1 → thyra-4.3.2}/thyra/__main__.py +0 -0
  14. {thyra-4.3.1 → thyra-4.3.2}/thyra/alignment/__init__.py +0 -0
  15. {thyra-4.3.1 → thyra-4.3.2}/thyra/alignment/affine.py +0 -0
  16. {thyra-4.3.1 → thyra-4.3.2}/thyra/alignment/teaching_points.py +0 -0
  17. {thyra-4.3.1 → thyra-4.3.2}/thyra/config.py +0 -0
  18. {thyra-4.3.1 → thyra-4.3.2}/thyra/converters/__init__.py +0 -0
  19. {thyra-4.3.1 → thyra-4.3.2}/thyra/converters/spatialdata/__init__.py +0 -0
  20. {thyra-4.3.1 → thyra-4.3.2}/thyra/converters/spatialdata/_chunking.py +0 -0
  21. {thyra-4.3.1 → thyra-4.3.2}/thyra/converters/spatialdata/base_spatialdata_converter.py +0 -0
  22. {thyra-4.3.1 → thyra-4.3.2}/thyra/converters/spatialdata/converter.py +0 -0
  23. {thyra-4.3.1 → thyra-4.3.2}/thyra/converters/spatialdata/csc_assembly.py +0 -0
  24. {thyra-4.3.1 → thyra-4.3.2}/thyra/converters/spatialdata/fused_passes.py +0 -0
  25. {thyra-4.3.1 → thyra-4.3.2}/thyra/converters/spatialdata/mobility_heatmap.py +0 -0
  26. {thyra-4.3.1 → thyra-4.3.2}/thyra/converters/spatialdata/mobility_table.py +0 -0
  27. {thyra-4.3.1 → thyra-4.3.2}/thyra/converters/spatialdata/msms_table.py +0 -0
  28. {thyra-4.3.1 → thyra-4.3.2}/thyra/converters/spatialdata/optical_image.py +0 -0
  29. {thyra-4.3.1 → thyra-4.3.2}/thyra/converters/spatialdata/root_attrs.py +0 -0
  30. {thyra-4.3.1 → thyra-4.3.2}/thyra/converters/spatialdata/sibling_tables.py +0 -0
  31. {thyra-4.3.1 → thyra-4.3.2}/thyra/converters/spatialdata/streaming_converter.py +0 -0
  32. {thyra-4.3.1 → thyra-4.3.2}/thyra/converters/spatialdata/uns_assembler.py +0 -0
  33. {thyra-4.3.1 → thyra-4.3.2}/thyra/core/__init__.py +0 -0
  34. {thyra-4.3.1 → thyra-4.3.2}/thyra/core/base_converter.py +0 -0
  35. {thyra-4.3.1 → thyra-4.3.2}/thyra/core/base_extractor.py +0 -0
  36. {thyra-4.3.1 → thyra-4.3.2}/thyra/core/base_reader.py +0 -0
  37. {thyra-4.3.1 → thyra-4.3.2}/thyra/core/conversion_state.py +0 -0
  38. {thyra-4.3.1 → thyra-4.3.2}/thyra/core/drop_tally.py +0 -0
  39. {thyra-4.3.1 → thyra-4.3.2}/thyra/core/frames.py +0 -0
  40. {thyra-4.3.1 → thyra-4.3.2}/thyra/core/mass_axis.py +0 -0
  41. {thyra-4.3.1 → thyra-4.3.2}/thyra/core/mobility.py +0 -0
  42. {thyra-4.3.1 → thyra-4.3.2}/thyra/core/msms.py +0 -0
  43. {thyra-4.3.1 → thyra-4.3.2}/thyra/core/registry.py +0 -0
  44. {thyra-4.3.1 → thyra-4.3.2}/thyra/errors.py +0 -0
  45. {thyra-4.3.1 → thyra-4.3.2}/thyra/metadata/__init__.py +0 -0
  46. {thyra-4.3.1 → thyra-4.3.2}/thyra/metadata/constants.py +0 -0
  47. {thyra-4.3.1 → thyra-4.3.2}/thyra/metadata/document.py +0 -0
  48. {thyra-4.3.1 → thyra-4.3.2}/thyra/metadata/extractors/__init__.py +0 -0
  49. {thyra-4.3.1 → thyra-4.3.2}/thyra/metadata/extractors/bruker_extractor.py +0 -0
  50. {thyra-4.3.1 → thyra-4.3.2}/thyra/metadata/extractors/imzml_header_extractor.py +0 -0
  51. {thyra-4.3.1 → thyra-4.3.2}/thyra/metadata/extractors/phi_extractor.py +0 -0
  52. {thyra-4.3.1 → thyra-4.3.2}/thyra/metadata/extractors/solarix_extractor.py +0 -0
  53. {thyra-4.3.1 → thyra-4.3.2}/thyra/metadata/extractors/waters_extractor.py +0 -0
  54. {thyra-4.3.1 → thyra-4.3.2}/thyra/metadata/ontology/__init__.py +0 -0
  55. {thyra-4.3.1 → thyra-4.3.2}/thyra/metadata/ontology/_ims.py +0 -0
  56. {thyra-4.3.1 → thyra-4.3.2}/thyra/metadata/ontology/_ms.py +0 -0
  57. {thyra-4.3.1 → thyra-4.3.2}/thyra/metadata/ontology/_uo.py +0 -0
  58. {thyra-4.3.1 → thyra-4.3.2}/thyra/metadata/ontology/cache.py +0 -0
  59. {thyra-4.3.1 → thyra-4.3.2}/thyra/metadata/ontology/dump_obo_files.py +0 -0
  60. {thyra-4.3.1 → thyra-4.3.2}/thyra/metadata/personal_data.py +0 -0
  61. {thyra-4.3.1 → thyra-4.3.2}/thyra/metadata/schema/__init__.py +0 -0
  62. {thyra-4.3.1 → thyra-4.3.2}/thyra/metadata/schema/builder.py +0 -0
  63. {thyra-4.3.1 → thyra-4.3.2}/thyra/metadata/schema/cli.py +0 -0
  64. {thyra-4.3.1 → thyra-4.3.2}/thyra/metadata/schema/generate.py +0 -0
  65. {thyra-4.3.1 → thyra-4.3.2}/thyra/metadata/schema/metaspace.py +0 -0
  66. {thyra-4.3.1 → thyra-4.3.2}/thyra/metadata/schema/models.py +0 -0
  67. {thyra-4.3.1 → thyra-4.3.2}/thyra/metadata/schema/msi_metadata.linkml.yaml +0 -0
  68. {thyra-4.3.1 → thyra-4.3.2}/thyra/metadata/schema/msi_metadata_schema_v0_10.json +0 -0
  69. {thyra-4.3.1 → thyra-4.3.2}/thyra/metadata/schema/store_io.py +0 -0
  70. {thyra-4.3.1 → thyra-4.3.2}/thyra/metadata/schema/validate.py +0 -0
  71. {thyra-4.3.1 → thyra-4.3.2}/thyra/metadata/schema/vocab.py +0 -0
  72. {thyra-4.3.1 → thyra-4.3.2}/thyra/metadata/types.py +0 -0
  73. {thyra-4.3.1 → thyra-4.3.2}/thyra/metadata/uns_compat.py +0 -0
  74. {thyra-4.3.1 → thyra-4.3.2}/thyra/metadata/validator.py +0 -0
  75. {thyra-4.3.1 → thyra-4.3.2}/thyra/preview.py +0 -0
  76. {thyra-4.3.1 → thyra-4.3.2}/thyra/py.typed +0 -0
  77. {thyra-4.3.1 → thyra-4.3.2}/thyra/readers/__init__.py +0 -0
  78. {thyra-4.3.1 → thyra-4.3.2}/thyra/readers/bruker/__init__.py +0 -0
  79. {thyra-4.3.1 → thyra-4.3.2}/thyra/readers/bruker/base_bruker_reader.py +0 -0
  80. {thyra-4.3.1 → thyra-4.3.2}/thyra/readers/bruker/folder_structure.py +0 -0
  81. {thyra-4.3.1 → thyra-4.3.2}/thyra/readers/bruker/mis_parser.py +0 -0
  82. {thyra-4.3.1 → thyra-4.3.2}/thyra/readers/bruker/rapiflex/__init__.py +0 -0
  83. {thyra-4.3.1 → thyra-4.3.2}/thyra/readers/bruker/rapiflex/rapiflex_reader.py +0 -0
  84. {thyra-4.3.1 → thyra-4.3.2}/thyra/readers/bruker/solarix/__init__.py +0 -0
  85. {thyra-4.3.1 → thyra-4.3.2}/thyra/readers/bruker/solarix/solarix_reader.py +0 -0
  86. {thyra-4.3.1 → thyra-4.3.2}/thyra/readers/bruker/timstof/__init__.py +0 -0
  87. {thyra-4.3.1 → thyra-4.3.2}/thyra/readers/bruker/timstof/sdk/__init__.py +0 -0
  88. {thyra-4.3.1 → thyra-4.3.2}/thyra/readers/bruker/timstof/sdk/dll/LICENCE-BRUKER.txt +0 -0
  89. {thyra-4.3.1 → thyra-4.3.2}/thyra/readers/bruker/timstof/sdk/dll/README.md +0 -0
  90. {thyra-4.3.1 → thyra-4.3.2}/thyra/readers/bruker/timstof/sdk/dll/timsdata.dll +0 -0
  91. {thyra-4.3.1 → thyra-4.3.2}/thyra/readers/bruker/timstof/sdk/dll/timsdata.so +0 -0
  92. {thyra-4.3.1 → thyra-4.3.2}/thyra/readers/bruker/timstof/sdk/dll_manager.py +0 -0
  93. {thyra-4.3.1 → thyra-4.3.2}/thyra/readers/bruker/timstof/sdk/platform_detector.py +0 -0
  94. {thyra-4.3.1 → thyra-4.3.2}/thyra/readers/bruker/timstof/sdk/sdk_functions.py +0 -0
  95. {thyra-4.3.1 → thyra-4.3.2}/thyra/readers/bruker/timstof/timstof_reader.py +0 -0
  96. {thyra-4.3.1 → thyra-4.3.2}/thyra/readers/bruker/vendor_db.py +0 -0
  97. {thyra-4.3.1 → thyra-4.3.2}/thyra/readers/imzml/__init__.py +0 -0
  98. {thyra-4.3.1 → thyra-4.3.2}/thyra/readers/imzml/_pyimzml_compat.py +0 -0
  99. {thyra-4.3.1 → thyra-4.3.2}/thyra/readers/imzml/header.py +0 -0
  100. {thyra-4.3.1 → thyra-4.3.2}/thyra/readers/imzml/imzml_reader.py +0 -0
  101. {thyra-4.3.1 → thyra-4.3.2}/thyra/readers/imzml/mobility_array.py +0 -0
  102. {thyra-4.3.1 → thyra-4.3.2}/thyra/readers/mzpeak/__init__.py +0 -0
  103. {thyra-4.3.1 → thyra-4.3.2}/thyra/readers/phi/__init__.py +0 -0
  104. {thyra-4.3.1 → thyra-4.3.2}/thyra/readers/phi/event_stream.py +0 -0
  105. {thyra-4.3.1 → thyra-4.3.2}/thyra/readers/phi/mass_axis.py +0 -0
  106. {thyra-4.3.1 → thyra-4.3.2}/thyra/readers/phi/phi_header.py +0 -0
  107. {thyra-4.3.1 → thyra-4.3.2}/thyra/readers/phi/phi_reader.py +0 -0
  108. {thyra-4.3.1 → thyra-4.3.2}/thyra/readers/waters/__init__.py +0 -0
  109. {thyra-4.3.1 → thyra-4.3.2}/thyra/readers/waters/imaging_grid.py +0 -0
  110. {thyra-4.3.1 → thyra-4.3.2}/thyra/readers/waters/instrument.py +0 -0
  111. {thyra-4.3.1 → thyra-4.3.2}/thyra/readers/waters/lib/MLReader.dll +0 -0
  112. {thyra-4.3.1 → thyra-4.3.2}/thyra/readers/waters/lib/MassLynxRaw.dll +0 -0
  113. {thyra-4.3.1 → thyra-4.3.2}/thyra/readers/waters/lib/libMLReader.so +0 -0
  114. {thyra-4.3.1 → thyra-4.3.2}/thyra/readers/waters/lib/libMassLynxRaw.so +0 -0
  115. {thyra-4.3.1 → thyra-4.3.2}/thyra/readers/waters/masslynx_lib.py +0 -0
  116. {thyra-4.3.1 → thyra-4.3.2}/thyra/readers/waters/waters_reader.py +0 -0
  117. {thyra-4.3.1 → thyra-4.3.2}/thyra/resampling/__init__.py +0 -0
  118. {thyra-4.3.1 → thyra-4.3.2}/thyra/resampling/axis_planner.py +0 -0
  119. {thyra-4.3.1 → thyra-4.3.2}/thyra/resampling/binning.py +0 -0
  120. {thyra-4.3.1 → thyra-4.3.2}/thyra/resampling/common_axis.py +0 -0
  121. {thyra-4.3.1 → thyra-4.3.2}/thyra/resampling/constants.py +0 -0
  122. {thyra-4.3.1 → thyra-4.3.2}/thyra/resampling/data_characteristics.py +0 -0
  123. {thyra-4.3.1 → thyra-4.3.2}/thyra/resampling/decision_tree.py +0 -0
  124. {thyra-4.3.1 → thyra-4.3.2}/thyra/resampling/gaps.py +0 -0
  125. {thyra-4.3.1 → thyra-4.3.2}/thyra/resampling/interpolation.py +0 -0
  126. {thyra-4.3.1 → thyra-4.3.2}/thyra/resampling/mass_axis/__init__.py +0 -0
  127. {thyra-4.3.1 → thyra-4.3.2}/thyra/resampling/mass_axis/base_generator.py +0 -0
  128. {thyra-4.3.1 → thyra-4.3.2}/thyra/resampling/mass_axis/fticr_generator.py +0 -0
  129. {thyra-4.3.1 → thyra-4.3.2}/thyra/resampling/mass_axis/linear_generator.py +0 -0
  130. {thyra-4.3.1 → thyra-4.3.2}/thyra/resampling/mass_axis/linear_tof_generator.py +0 -0
  131. {thyra-4.3.1 → thyra-4.3.2}/thyra/resampling/mass_axis/orbitrap_generator.py +0 -0
  132. {thyra-4.3.1 → thyra-4.3.2}/thyra/resampling/mass_axis/reflector_tof_generator.py +0 -0
  133. {thyra-4.3.1 → thyra-4.3.2}/thyra/resampling/mass_axis/tof_generator.py +0 -0
  134. {thyra-4.3.1 → thyra-4.3.2}/thyra/resampling/mobility_grid.py +0 -0
  135. {thyra-4.3.1 → thyra-4.3.2}/thyra/resampling/strategies/__init__.py +0 -0
  136. {thyra-4.3.1 → thyra-4.3.2}/thyra/resampling/strategies/base.py +0 -0
  137. {thyra-4.3.1 → thyra-4.3.2}/thyra/resampling/strategies/nearest_neighbor.py +0 -0
  138. {thyra-4.3.1 → thyra-4.3.2}/thyra/resampling/strategies/tic_preserving.py +0 -0
  139. {thyra-4.3.1 → thyra-4.3.2}/thyra/resampling/tic.py +0 -0
  140. {thyra-4.3.1 → thyra-4.3.2}/thyra/resampling/types.py +0 -0
  141. {thyra-4.3.1 → thyra-4.3.2}/thyra/tools/__init__.py +0 -0
  142. {thyra-4.3.1 → thyra-4.3.2}/thyra/tools/check_ontology.py +0 -0
  143. {thyra-4.3.1 → thyra-4.3.2}/thyra/tools/make_example_data.py +0 -0
  144. {thyra-4.3.1 → thyra-4.3.2}/thyra/utils/__init__.py +0 -0
  145. {thyra-4.3.1 → thyra-4.3.2}/thyra/utils/bruker_exceptions.py +0 -0
  146. {thyra-4.3.1 → thyra-4.3.2}/thyra/utils/imzml_coordinate_base.py +0 -0
  147. {thyra-4.3.1 → thyra-4.3.2}/thyra/utils/logging_config.py +0 -0
  148. {thyra-4.3.1 → thyra-4.3.2}/thyra/utils/pyimzml_direct.py +0 -0
  149. {thyra-4.3.1 → thyra-4.3.2}/thyra/utils/windows_paths.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: thyra
3
- Version: 4.3.1
3
+ Version: 4.3.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
  Project-URL: Homepage, https://github.com/M4i-Imaging-Mass-Spectrometry/thyra
6
6
  Project-URL: Repository, https://github.com/M4i-Imaging-Mass-Spectrometry/thyra
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "thyra"
7
- version = "4.3.1"
7
+ version = "4.3.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 = [{ name = "Theodoros Visvikis", email = "t.visvikis@maastrichtuniversity.nl" }]
10
10
  maintainers = [{ name = "Theodoros Visvikis", email = "t.visvikis@maastrichtuniversity.nl" }]
@@ -67,7 +67,7 @@ warnings.filterwarnings(
67
67
  category=FutureWarning,
68
68
  )
69
69
 
70
- __version__ = "4.3.1"
70
+ __version__ = "4.3.2"
71
71
 
72
72
  #: Public name -> the module that defines it, imported on first access.
73
73
  _LAZY_ATTRS = {
@@ -358,9 +358,12 @@ def _determine_pixel_size(
358
358
  essential_metadata = reader.get_essential_metadata()
359
359
 
360
360
  if essential_metadata.pixel_size is None:
361
- logger.error("Pixel size not found in metadata")
362
- logger.error("Use --pixel-size parameter (e.g., --pixel-size 25)")
363
- raise ConversionRefused("Pixel size not found in metadata")
361
+ # Said once: the refusal is logged where it is caught. Logging it
362
+ # here as well printed the same line twice.
363
+ raise ConversionRefused(
364
+ "Pixel size not found in metadata. Pass it with --pixel-size "
365
+ "(for example --pixel-size 25)."
366
+ )
364
367
 
365
368
  final_pixel_size = essential_metadata.pixel_size[0] # Use X size
366
369
  logger.info(f"Detected pixel size: {final_pixel_size:.1f} um")
@@ -159,6 +159,138 @@ def _family_from_model_text(text: str) -> Optional[str]:
159
159
  return None
160
160
 
161
161
 
162
+ def instrument_info_from_configurations(configs: List[Any]) -> Dict[str, Any]:
163
+ """Instrument model, serial number, analyzer and family of a run.
164
+
165
+ ``configs`` are instrument configurations shaped as pyimzml parses
166
+ them: each has ``param_by_accession`` and ``param_by_name`` dicts, a
167
+ valueless term reads as ``True``, and ``components`` lists objects with
168
+ a ``type`` and their own ``param_by_accession``. The imzML extractor
169
+ passes pyimzml's own; the mzPeak extractor builds the same shape from
170
+ the archive's JSON.
171
+
172
+ Returns:
173
+ ``instrument_model``, ``instrument_serial_number``, ``analyzer`` and
174
+ ``instrument_type``, each only when the configurations state it.
175
+ """
176
+ info: Dict[str, Any] = {}
177
+ if not configs:
178
+ return info
179
+
180
+ model = _instrument_model(configs)
181
+ if model:
182
+ info["instrument_model"] = model
183
+
184
+ serial = _config_param(configs, "MS:1000529")
185
+ if isinstance(serial, str) and serial.strip():
186
+ info["instrument_serial_number"] = serial.strip()
187
+
188
+ analyzer_accession = _analyzer_accession(configs)
189
+ if analyzer_accession is not None:
190
+ info["analyzer"] = ONTOLOGY.terms[analyzer_accession][0]
191
+
192
+ family = _analyzer_family(configs, analyzer_accession, model)
193
+ if family is not None:
194
+ info["instrument_type"] = family
195
+
196
+ return info
197
+
198
+
199
+ def _config_param(configs: List[Any], accession: str) -> Any:
200
+ """The first value any configuration declares for ``accession``."""
201
+ for config in configs:
202
+ params = getattr(config, "param_by_accession", None)
203
+ if isinstance(params, dict) and accession in params:
204
+ return params[accession]
205
+ return None
206
+
207
+
208
+ def _instrument_model(configs: List[Any]) -> Optional[str]:
209
+ """The declared instrument model, from the shape the exporter chose.
210
+
211
+ Vendors write the model two ways: generic ``MS:1000031 instrument
212
+ model`` carrying the name as free text, or the model's own child term
213
+ as a valueless flag (``MS:1001549 solariX``, ``MS:1003124 timsTOF
214
+ fleX``). The explicit value wins; a known model accession resolves
215
+ through the shipped ontology table; and a term newer than the table is
216
+ still usable when its *name* identifies a family.
217
+ """
218
+ value = _config_param(configs, "MS:1000031")
219
+ if isinstance(value, str) and value.strip():
220
+ return value.strip()
221
+
222
+ for config in configs:
223
+ params = getattr(config, "param_by_accession", None)
224
+ if not isinstance(params, dict):
225
+ continue
226
+ for accession in params:
227
+ if accession in _MODEL_FAMILIES:
228
+ return ONTOLOGY.terms[accession][0]
229
+
230
+ for config in configs:
231
+ params = getattr(config, "param_by_name", None)
232
+ if not isinstance(params, dict):
233
+ continue
234
+ for name, value in params.items():
235
+ if (
236
+ isinstance(name, str)
237
+ and value is True
238
+ and _family_from_model_text(name) is not None
239
+ ):
240
+ return name
241
+
242
+ return None
243
+
244
+
245
+ def _analyzer_accession(configs: List[Any]) -> Optional[str]:
246
+ """The highest-priority analyzer term declared on any componentList."""
247
+ declared: set = set()
248
+ for config in configs:
249
+ for component in getattr(config, "components", None) or []:
250
+ if getattr(component, "type", None) != "analyzer":
251
+ continue
252
+ params = getattr(component, "param_by_accession", None)
253
+ if isinstance(params, dict):
254
+ declared.update(params)
255
+
256
+ for accession in _ANALYZER_ACCESSIONS:
257
+ if accession in declared:
258
+ return accession
259
+ return None
260
+
261
+
262
+ def _analyzer_family(
263
+ configs: List[Any],
264
+ analyzer_accession: Optional[str],
265
+ model: Optional[str],
266
+ ) -> Optional[str]:
267
+ """Resolve the axis family, preferring the analyzer declaration.
268
+
269
+ The analyzer cvParam states the physics outright, so it outranks the
270
+ model term, which outranks a substring match on free-form model text.
271
+ A file that declares none of the three resolves to ``None``.
272
+ """
273
+ if analyzer_accession in _ANALYZER_FAMILIES:
274
+ return _ANALYZER_FAMILIES[analyzer_accession]
275
+ if analyzer_accession is not None:
276
+ # A recognised analyzer outside the family table (TOF, ion trap)
277
+ # is a real declaration; do not second-guess it from the model.
278
+ return None
279
+
280
+ for config in configs:
281
+ params = getattr(config, "param_by_accession", None)
282
+ if not isinstance(params, dict):
283
+ continue
284
+ for accession in params:
285
+ family = _MODEL_FAMILIES.get(accession)
286
+ if family is not None:
287
+ return family
288
+
289
+ if model is not None:
290
+ return _family_from_model_text(model)
291
+ return None
292
+
293
+
162
294
  class ImzMLMetadataExtractor(MetadataExtractor):
163
295
  """ImzML-specific metadata extractor with optimized two-phase extraction."""
164
296
 
@@ -824,29 +956,12 @@ class ImzMLMetadataExtractor(MetadataExtractor):
824
956
  chain matches on (``"FT-ICR"`` / ``"Orbitrap"``), when the analyzer
825
957
  or the model resolves to a family the chain distinguishes. Absent
826
958
  otherwise: an unstated analyzer stays unstated.
827
- """
828
- info: Dict[str, Any] = {}
829
- configs = self._instrument_configurations()
830
- if not configs:
831
- return info
832
-
833
- model = self._instrument_model(configs)
834
- if model:
835
- info["instrument_model"] = model
836
959
 
837
- serial = self._config_param(configs, "MS:1000529")
838
- if isinstance(serial, str) and serial.strip():
839
- info["instrument_serial_number"] = serial.strip()
840
-
841
- analyzer_accession = self._analyzer_accession(configs)
842
- if analyzer_accession is not None:
843
- info["analyzer"] = ONTOLOGY.terms[analyzer_accession][0]
844
-
845
- family = self._analyzer_family(configs, analyzer_accession, model)
846
- if family is not None:
847
- info["instrument_type"] = family
848
-
849
- return info
960
+ The rules live in :func:`instrument_info_from_configurations`, which
961
+ the mzPeak extractor calls too, so an archive and the imzML it was
962
+ made from resolve to the same instrument.
963
+ """
964
+ return instrument_info_from_configurations(self._instrument_configurations())
850
965
 
851
966
  def _instrument_configurations(self) -> List[Any]:
852
967
  """The parsed instrumentConfiguration ParamGroups, in document order."""
@@ -856,100 +971,6 @@ class ImzMLMetadataExtractor(MetadataExtractor):
856
971
  return []
857
972
  return list(configs.values())
858
973
 
859
- @staticmethod
860
- def _config_param(configs: List[Any], accession: str) -> Any:
861
- """The first value any configuration declares for ``accession``."""
862
- for config in configs:
863
- params = getattr(config, "param_by_accession", None)
864
- if isinstance(params, dict) and accession in params:
865
- return params[accession]
866
- return None
867
-
868
- def _instrument_model(self, configs: List[Any]) -> Optional[str]:
869
- """The declared instrument model, from the shape the exporter chose.
870
-
871
- Vendors write the model two ways: generic ``MS:1000031 instrument
872
- model`` carrying the name as free text, or the model's own child term
873
- as a valueless flag (``MS:1001549 solariX``, ``MS:1003124 timsTOF
874
- fleX``). The explicit value wins; a known model accession resolves
875
- through the shipped ontology table; and a term newer than the table is
876
- still usable when its *name* identifies a family.
877
- """
878
- value = self._config_param(configs, "MS:1000031")
879
- if isinstance(value, str) and value.strip():
880
- return value.strip()
881
-
882
- for config in configs:
883
- params = getattr(config, "param_by_accession", None)
884
- if not isinstance(params, dict):
885
- continue
886
- for accession in params:
887
- if accession in _MODEL_FAMILIES:
888
- return ONTOLOGY.terms[accession][0]
889
-
890
- for config in configs:
891
- params = getattr(config, "param_by_name", None)
892
- if not isinstance(params, dict):
893
- continue
894
- for name, value in params.items():
895
- if (
896
- isinstance(name, str)
897
- and value is True
898
- and _family_from_model_text(name) is not None
899
- ):
900
- return name
901
-
902
- return None
903
-
904
- @staticmethod
905
- def _analyzer_accession(configs: List[Any]) -> Optional[str]:
906
- """The highest-priority analyzer term declared on any componentList."""
907
- declared: set = set()
908
- for config in configs:
909
- for component in getattr(config, "components", None) or []:
910
- if getattr(component, "type", None) != "analyzer":
911
- continue
912
- params = getattr(component, "param_by_accession", None)
913
- if isinstance(params, dict):
914
- declared.update(params)
915
-
916
- for accession in _ANALYZER_ACCESSIONS:
917
- if accession in declared:
918
- return accession
919
- return None
920
-
921
- def _analyzer_family(
922
- self,
923
- configs: List[Any],
924
- analyzer_accession: Optional[str],
925
- model: Optional[str],
926
- ) -> Optional[str]:
927
- """Resolve the axis family, preferring the analyzer declaration.
928
-
929
- The analyzer cvParam states the physics outright, so it outranks the
930
- model term, which outranks a substring match on free-form model text.
931
- A file that declares none of the three resolves to ``None``.
932
- """
933
- if analyzer_accession in _ANALYZER_FAMILIES:
934
- return _ANALYZER_FAMILIES[analyzer_accession]
935
- if analyzer_accession is not None:
936
- # A recognised analyzer outside the family table (TOF, ion trap)
937
- # is a real declaration; do not second-guess it from the model.
938
- return None
939
-
940
- for config in configs:
941
- params = getattr(config, "param_by_accession", None)
942
- if not isinstance(params, dict):
943
- continue
944
- for accession in params:
945
- family = _MODEL_FAMILIES.get(accession)
946
- if family is not None:
947
- return family
948
-
949
- if model is not None:
950
- return _family_from_model_text(model)
951
- return None
952
-
953
974
  def _extract_raw_metadata(self) -> Dict[str, Any]:
954
975
  """Extract raw metadata from imzmldict and spectrum cvParams."""
955
976
  raw_metadata = {}
@@ -12,14 +12,20 @@ from __future__ import annotations
12
12
  import logging
13
13
  import math
14
14
  from pathlib import Path
15
+ from types import SimpleNamespace
15
16
  from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
16
17
 
17
18
  import numpy as np
18
19
 
19
20
  from ...core.base_extractor import MetadataExtractor
20
21
  from ..constants import ImzMLAccessions, SpectrumType
22
+ from ..ontology.cache import ONTOLOGY
21
23
  from ..types import ComprehensiveMetadata, EssentialMetadata
22
- from .imzml_extractor import UM_PER_UNIT, has_old_pixel_size_name
24
+ from .imzml_extractor import (
25
+ UM_PER_UNIT,
26
+ has_old_pixel_size_name,
27
+ instrument_info_from_configurations,
28
+ )
23
29
 
24
30
  if TYPE_CHECKING: # pragma: no cover - typing only
25
31
  # Imported for typing only. A runtime import here would be a cycle:
@@ -84,10 +90,11 @@ class MzPeakMetadataExtractor(MetadataExtractor):
84
90
  coordinates = index.coordinates
85
91
  raw = index.raw_positions
86
92
 
87
- # Grid is sized from what the file actually contains. mzPeak also
88
- # declares IMS:1000042/43, but a declared extent that disagrees with
89
- # the positions would silently pad or clip the output, so the
90
- # declaration is kept as provenance only.
93
+ # Grid is sized from what the file actually contains, from index 0
94
+ # (the base, as D14 sets it for imzML) to the largest position.
95
+ # mzPeak also declares IMS:1000042/43, but a declared extent that
96
+ # disagrees with the positions would silently pad or clip the
97
+ # output, so the declaration is kept as provenance only.
91
98
  dimensions = (
92
99
  int(coordinates[:, 0].max()) + 1,
93
100
  int(coordinates[:, 1].max()) + 1,
@@ -123,12 +130,20 @@ class MzPeakMetadataExtractor(MetadataExtractor):
123
130
  data: the metadata member has one row per spectrum, so this is a
124
131
  9-row read on a 36k-point file and stays a one-row-per-spectrum read
125
132
  at any scale.
133
+
134
+ A spectrum that is not converted (on no pixel, or an MSn spectrum
135
+ beside MS1) does not widen the range.
126
136
  """
127
137
  table = self.archive.parquet("spectrum", "metadata").read(
128
- columns=["lowest_observed_mz", "highest_observed_mz"]
138
+ columns=["index", "lowest_observed_mz", "highest_observed_mz"]
129
139
  )
130
140
  low = np.asarray(table.column("lowest_observed_mz").to_numpy(), dtype=float)
131
141
  high = np.asarray(table.column("highest_observed_mz").to_numpy(), dtype=float)
142
+ index = self.archive.spatial_index()
143
+ if not index.complete:
144
+ rows = np.asarray(table.column("index").to_numpy(), dtype=np.int64)
145
+ kept = np.isin(rows, index.spectrum_indices)
146
+ low, high = low[kept], high[kept]
132
147
  low = low[np.isfinite(low)]
133
148
  high = high[np.isfinite(high)]
134
149
  if low.size == 0 or high.size == 0:
@@ -174,7 +189,12 @@ class MzPeakMetadataExtractor(MetadataExtractor):
174
189
  x = parameters.get(ImzMLAccessions.PIXEL_SIZE_X)
175
190
  y = parameters.get(ImzMLAccessions.PIXEL_SIZE_Y)
176
191
  if x is None and y is None:
177
- logger.info("Pixel size not found in metadata of %s", self.data_path.name)
192
+ logger.info(
193
+ "%s states no pixel size: its scan settings hold neither %s " "nor %s.",
194
+ self.data_path.name,
195
+ ImzMLAccessions.PIXEL_SIZE_X,
196
+ ImzMLAccessions.PIXEL_SIZE_Y,
197
+ )
178
198
  return None
179
199
  if x is not None and y is not None and not self._has_old_name(x):
180
200
  return self._declared_pair(x, y)
@@ -446,6 +466,11 @@ class MzPeakMetadataExtractor(MetadataExtractor):
446
466
  nulls = self.archive.null_count()
447
467
  if not nulls:
448
468
  return declared
469
+ if not index.complete:
470
+ # The padding count covers the whole member and cannot be split
471
+ # by spectrum, so with spectra left out the count keeps its
472
+ # padding: an upper bound rather than a figure too low.
473
+ return declared
449
474
  return max(0, declared - int(nulls))
450
475
 
451
476
  # ------------------------------------------------------------------
@@ -507,11 +532,74 @@ class MzPeakMetadataExtractor(MetadataExtractor):
507
532
  }
508
533
 
509
534
  def _instrument_info(self, metadata: Dict[str, Any]) -> Dict[str, Any]:
510
- """Instrument and software lists, as the archive records them."""
511
- return {
512
- "instrument_configuration_list": metadata.get(
513
- "instrument_configuration_list"
514
- ),
515
- "software_list": metadata.get("software_list"),
516
- "data_processing_method_list": metadata.get("data_processing_method_list"),
517
- }
535
+ """The instrument as the imzML route reads it, and the raw lists.
536
+
537
+ Model, serial number, analyzer and family are resolved by the
538
+ function the imzML extractor uses, so an archive converts on the
539
+ same mass axis as the imzML it was made from. The family is what
540
+ picks the axis: without it an Orbitrap archive was binned as an
541
+ unknown instrument.
542
+ """
543
+ configurations = metadata.get("instrument_configuration_list")
544
+ info = instrument_info_from_configurations(
545
+ _as_parsed_configurations(configurations)
546
+ )
547
+ info.update(
548
+ {
549
+ "instrument_configuration_list": configurations,
550
+ "software_list": metadata.get("software_list"),
551
+ "data_processing_method_list": metadata.get(
552
+ "data_processing_method_list"
553
+ ),
554
+ }
555
+ )
556
+ return info
557
+
558
+
559
+ def _as_parsed_parameters(parameters: Any) -> SimpleNamespace:
560
+ """One mzPeak parameter list in the shape pyimzml gives a ParamGroup.
561
+
562
+ As pyimzml does, a term without a value reads as ``True`` and a known
563
+ accession is named by the ontology, not by the name the writer gave.
564
+ """
565
+ by_accession: Dict[str, Any] = {}
566
+ by_name: Dict[str, Any] = {}
567
+ for parameter in parameters if isinstance(parameters, list) else []:
568
+ if not isinstance(parameter, dict):
569
+ continue
570
+ value = parameter.get("value")
571
+ value = True if value is None else value
572
+ accession = parameter.get("accession")
573
+ name = parameter.get("name")
574
+ if isinstance(accession, str) and accession:
575
+ by_accession.setdefault(accession, value)
576
+ known = ONTOLOGY.terms.get(accession)
577
+ if known:
578
+ name = known[0]
579
+ if isinstance(name, str) and name:
580
+ by_name.setdefault(name, value)
581
+ return SimpleNamespace(param_by_accession=by_accession, param_by_name=by_name)
582
+
583
+
584
+ def _as_parsed_configurations(configurations: Any) -> List[SimpleNamespace]:
585
+ """The archive's instrument configurations, shaped as pyimzml parses them.
586
+
587
+ See :func:`~.imzml_extractor.instrument_info_from_configurations`.
588
+ mzPeak writes a component's kind as ``component_type`` ("analyzer",
589
+ "ionsource", "detector"); pyimzml calls it ``type``.
590
+ """
591
+ parsed: List[SimpleNamespace] = []
592
+ for configuration in configurations if isinstance(configurations, list) else []:
593
+ if not isinstance(configuration, dict):
594
+ continue
595
+ group = _as_parsed_parameters(configuration.get("parameters"))
596
+ components = []
597
+ for component in configuration.get("components") or []:
598
+ if not isinstance(component, dict):
599
+ continue
600
+ parsed_component = _as_parsed_parameters(component.get("parameters"))
601
+ parsed_component.type = component.get("component_type")
602
+ components.append(parsed_component)
603
+ group.components = components
604
+ parsed.append(group)
605
+ return parsed
@@ -79,6 +79,17 @@ REFUSED_TERMS: Dict[str, str] = {
79
79
  "MS:9999002": "timsTOF m/z grid of the reference implementation",
80
80
  }
81
81
 
82
+ #: Grid models the default timsTOF (TDF) lane of mzpeak-convert writes.
83
+ TIMSTOF_GRID_TYPES = ("MS:9999001", "MS:9999002")
84
+
85
+ #: What a refusal of those models adds: the option of that converter that
86
+ #: writes the m/z themselves (checked against the help of 0.16.0).
87
+ TIMSTOF_GRID_HINT = (
88
+ " mzpeak-convert writes this model for a timsTOF .d by default. Convert "
89
+ "the .d again with --no-ims-compact to store m/z Thyra reads, or "
90
+ "convert the .d with Thyra directly."
91
+ )
92
+
82
93
  #: Transforms of the intensity array, by the column that holds them.
83
94
  INTENSITY_TRANSFORMS: Dict[str, str] = {
84
95
  "intensity_numpress_slof_bytes": "MS:1002314",
@@ -208,10 +219,11 @@ def validate_encodings(
208
219
  return
209
220
  for grid_type in grid_types:
210
221
  if grid_type not in DECODED_GRID_TYPES:
222
+ hint = TIMSTOF_GRID_HINT if grid_type in TIMSTOF_GRID_TYPES else ""
211
223
  raise ConversionRefused(
212
224
  f"{source} stores m/z on a grid of type "
213
225
  f"{describe_term(grid_type)}, which Thyra does not decode. "
214
- f"It decodes {_decoded(DECODED_GRID_TYPES)}."
226
+ f"It decodes {_decoded(DECODED_GRID_TYPES)}.{hint}"
215
227
  )
216
228
 
217
229