thyra 4.2.0__tar.gz → 4.3.1__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.2.0 → thyra-4.3.1}/PKG-INFO +1 -1
  2. {thyra-4.2.0 → thyra-4.3.1}/pyproject.toml +1 -1
  3. {thyra-4.2.0 → thyra-4.3.1}/thyra/__init__.py +1 -1
  4. {thyra-4.2.0 → thyra-4.3.1}/thyra/__main__.py +12 -2
  5. {thyra-4.2.0 → thyra-4.3.1}/thyra/converters/spatialdata/streaming_converter.py +83 -21
  6. {thyra-4.2.0 → thyra-4.3.1}/thyra/core/base_reader.py +48 -0
  7. {thyra-4.2.0 → thyra-4.3.1}/thyra/core/frames.py +7 -0
  8. {thyra-4.2.0 → thyra-4.3.1}/thyra/metadata/extractors/imzml_extractor.py +67 -0
  9. {thyra-4.2.0 → thyra-4.3.1}/thyra/metadata/extractors/mzpeak_extractor.py +239 -42
  10. {thyra-4.2.0 → thyra-4.3.1}/thyra/metadata/schema/__init__.py +2 -0
  11. {thyra-4.2.0 → thyra-4.3.1}/thyra/metadata/schema/builder.py +27 -6
  12. {thyra-4.2.0 → thyra-4.3.1}/thyra/metadata/schema/models.py +18 -2
  13. {thyra-4.2.0 → thyra-4.3.1}/thyra/metadata/schema/msi_metadata.linkml.yaml +2 -2
  14. thyra-4.2.0/thyra/metadata/schema/msi_metadata_schema_v0_9.json → thyra-4.3.1/thyra/metadata/schema/msi_metadata_schema_v0_10.json +2 -2
  15. {thyra-4.2.0 → thyra-4.3.1}/thyra/readers/bruker/rapiflex/rapiflex_reader.py +5 -1
  16. {thyra-4.2.0 → thyra-4.3.1}/thyra/readers/bruker/solarix/solarix_reader.py +45 -1
  17. {thyra-4.2.0 → thyra-4.3.1}/thyra/readers/bruker/timstof/timstof_reader.py +191 -78
  18. {thyra-4.2.0 → thyra-4.3.1}/thyra/readers/imzml/imzml_reader.py +50 -3
  19. thyra-4.3.1/thyra/readers/mzpeak/chunk_decoding.py +787 -0
  20. {thyra-4.2.0 → thyra-4.3.1}/thyra/readers/mzpeak/mzpeak_reader.py +443 -75
  21. {thyra-4.2.0 → thyra-4.3.1}/thyra/readers/phi/phi_reader.py +4 -1
  22. {thyra-4.2.0 → thyra-4.3.1}/thyra/readers/waters/waters_reader.py +47 -1
  23. {thyra-4.2.0 → thyra-4.3.1}/.gitignore +0 -0
  24. {thyra-4.2.0 → thyra-4.3.1}/LICENSE +0 -0
  25. {thyra-4.2.0 → thyra-4.3.1}/README.md +0 -0
  26. {thyra-4.2.0 → thyra-4.3.1}/thyra/alignment/__init__.py +0 -0
  27. {thyra-4.2.0 → thyra-4.3.1}/thyra/alignment/affine.py +0 -0
  28. {thyra-4.2.0 → thyra-4.3.1}/thyra/alignment/teaching_points.py +0 -0
  29. {thyra-4.2.0 → thyra-4.3.1}/thyra/config.py +0 -0
  30. {thyra-4.2.0 → thyra-4.3.1}/thyra/convert.py +0 -0
  31. {thyra-4.2.0 → thyra-4.3.1}/thyra/converters/__init__.py +0 -0
  32. {thyra-4.2.0 → thyra-4.3.1}/thyra/converters/spatialdata/__init__.py +0 -0
  33. {thyra-4.2.0 → thyra-4.3.1}/thyra/converters/spatialdata/_chunking.py +0 -0
  34. {thyra-4.2.0 → thyra-4.3.1}/thyra/converters/spatialdata/base_spatialdata_converter.py +0 -0
  35. {thyra-4.2.0 → thyra-4.3.1}/thyra/converters/spatialdata/converter.py +0 -0
  36. {thyra-4.2.0 → thyra-4.3.1}/thyra/converters/spatialdata/csc_assembly.py +0 -0
  37. {thyra-4.2.0 → thyra-4.3.1}/thyra/converters/spatialdata/fused_passes.py +0 -0
  38. {thyra-4.2.0 → thyra-4.3.1}/thyra/converters/spatialdata/mobility_heatmap.py +0 -0
  39. {thyra-4.2.0 → thyra-4.3.1}/thyra/converters/spatialdata/mobility_table.py +0 -0
  40. {thyra-4.2.0 → thyra-4.3.1}/thyra/converters/spatialdata/msms_table.py +0 -0
  41. {thyra-4.2.0 → thyra-4.3.1}/thyra/converters/spatialdata/optical_image.py +0 -0
  42. {thyra-4.2.0 → thyra-4.3.1}/thyra/converters/spatialdata/root_attrs.py +0 -0
  43. {thyra-4.2.0 → thyra-4.3.1}/thyra/converters/spatialdata/sibling_tables.py +0 -0
  44. {thyra-4.2.0 → thyra-4.3.1}/thyra/converters/spatialdata/uns_assembler.py +0 -0
  45. {thyra-4.2.0 → thyra-4.3.1}/thyra/core/__init__.py +0 -0
  46. {thyra-4.2.0 → thyra-4.3.1}/thyra/core/base_converter.py +0 -0
  47. {thyra-4.2.0 → thyra-4.3.1}/thyra/core/base_extractor.py +0 -0
  48. {thyra-4.2.0 → thyra-4.3.1}/thyra/core/conversion_state.py +0 -0
  49. {thyra-4.2.0 → thyra-4.3.1}/thyra/core/drop_tally.py +0 -0
  50. {thyra-4.2.0 → thyra-4.3.1}/thyra/core/mass_axis.py +0 -0
  51. {thyra-4.2.0 → thyra-4.3.1}/thyra/core/mobility.py +0 -0
  52. {thyra-4.2.0 → thyra-4.3.1}/thyra/core/msms.py +0 -0
  53. {thyra-4.2.0 → thyra-4.3.1}/thyra/core/registry.py +0 -0
  54. {thyra-4.2.0 → thyra-4.3.1}/thyra/errors.py +0 -0
  55. {thyra-4.2.0 → thyra-4.3.1}/thyra/metadata/__init__.py +0 -0
  56. {thyra-4.2.0 → thyra-4.3.1}/thyra/metadata/constants.py +0 -0
  57. {thyra-4.2.0 → thyra-4.3.1}/thyra/metadata/document.py +0 -0
  58. {thyra-4.2.0 → thyra-4.3.1}/thyra/metadata/extractors/__init__.py +0 -0
  59. {thyra-4.2.0 → thyra-4.3.1}/thyra/metadata/extractors/bruker_extractor.py +0 -0
  60. {thyra-4.2.0 → thyra-4.3.1}/thyra/metadata/extractors/imzml_header_extractor.py +0 -0
  61. {thyra-4.2.0 → thyra-4.3.1}/thyra/metadata/extractors/phi_extractor.py +0 -0
  62. {thyra-4.2.0 → thyra-4.3.1}/thyra/metadata/extractors/solarix_extractor.py +0 -0
  63. {thyra-4.2.0 → thyra-4.3.1}/thyra/metadata/extractors/waters_extractor.py +0 -0
  64. {thyra-4.2.0 → thyra-4.3.1}/thyra/metadata/ontology/__init__.py +0 -0
  65. {thyra-4.2.0 → thyra-4.3.1}/thyra/metadata/ontology/_ims.py +0 -0
  66. {thyra-4.2.0 → thyra-4.3.1}/thyra/metadata/ontology/_ms.py +0 -0
  67. {thyra-4.2.0 → thyra-4.3.1}/thyra/metadata/ontology/_uo.py +0 -0
  68. {thyra-4.2.0 → thyra-4.3.1}/thyra/metadata/ontology/cache.py +0 -0
  69. {thyra-4.2.0 → thyra-4.3.1}/thyra/metadata/ontology/dump_obo_files.py +0 -0
  70. {thyra-4.2.0 → thyra-4.3.1}/thyra/metadata/personal_data.py +0 -0
  71. {thyra-4.2.0 → thyra-4.3.1}/thyra/metadata/schema/cli.py +0 -0
  72. {thyra-4.2.0 → thyra-4.3.1}/thyra/metadata/schema/generate.py +0 -0
  73. {thyra-4.2.0 → thyra-4.3.1}/thyra/metadata/schema/metaspace.py +0 -0
  74. {thyra-4.2.0 → thyra-4.3.1}/thyra/metadata/schema/store_io.py +0 -0
  75. {thyra-4.2.0 → thyra-4.3.1}/thyra/metadata/schema/validate.py +0 -0
  76. {thyra-4.2.0 → thyra-4.3.1}/thyra/metadata/schema/vocab.py +0 -0
  77. {thyra-4.2.0 → thyra-4.3.1}/thyra/metadata/types.py +0 -0
  78. {thyra-4.2.0 → thyra-4.3.1}/thyra/metadata/uns_compat.py +0 -0
  79. {thyra-4.2.0 → thyra-4.3.1}/thyra/metadata/validator.py +0 -0
  80. {thyra-4.2.0 → thyra-4.3.1}/thyra/preview.py +0 -0
  81. {thyra-4.2.0 → thyra-4.3.1}/thyra/py.typed +0 -0
  82. {thyra-4.2.0 → thyra-4.3.1}/thyra/readers/__init__.py +0 -0
  83. {thyra-4.2.0 → thyra-4.3.1}/thyra/readers/bruker/__init__.py +0 -0
  84. {thyra-4.2.0 → thyra-4.3.1}/thyra/readers/bruker/base_bruker_reader.py +0 -0
  85. {thyra-4.2.0 → thyra-4.3.1}/thyra/readers/bruker/folder_structure.py +0 -0
  86. {thyra-4.2.0 → thyra-4.3.1}/thyra/readers/bruker/mis_parser.py +0 -0
  87. {thyra-4.2.0 → thyra-4.3.1}/thyra/readers/bruker/rapiflex/__init__.py +0 -0
  88. {thyra-4.2.0 → thyra-4.3.1}/thyra/readers/bruker/solarix/__init__.py +0 -0
  89. {thyra-4.2.0 → thyra-4.3.1}/thyra/readers/bruker/timstof/__init__.py +0 -0
  90. {thyra-4.2.0 → thyra-4.3.1}/thyra/readers/bruker/timstof/sdk/__init__.py +0 -0
  91. {thyra-4.2.0 → thyra-4.3.1}/thyra/readers/bruker/timstof/sdk/dll/LICENCE-BRUKER.txt +0 -0
  92. {thyra-4.2.0 → thyra-4.3.1}/thyra/readers/bruker/timstof/sdk/dll/README.md +0 -0
  93. {thyra-4.2.0 → thyra-4.3.1}/thyra/readers/bruker/timstof/sdk/dll/timsdata.dll +0 -0
  94. {thyra-4.2.0 → thyra-4.3.1}/thyra/readers/bruker/timstof/sdk/dll/timsdata.so +0 -0
  95. {thyra-4.2.0 → thyra-4.3.1}/thyra/readers/bruker/timstof/sdk/dll_manager.py +0 -0
  96. {thyra-4.2.0 → thyra-4.3.1}/thyra/readers/bruker/timstof/sdk/platform_detector.py +0 -0
  97. {thyra-4.2.0 → thyra-4.3.1}/thyra/readers/bruker/timstof/sdk/sdk_functions.py +0 -0
  98. {thyra-4.2.0 → thyra-4.3.1}/thyra/readers/bruker/vendor_db.py +0 -0
  99. {thyra-4.2.0 → thyra-4.3.1}/thyra/readers/imzml/__init__.py +0 -0
  100. {thyra-4.2.0 → thyra-4.3.1}/thyra/readers/imzml/_pyimzml_compat.py +0 -0
  101. {thyra-4.2.0 → thyra-4.3.1}/thyra/readers/imzml/header.py +0 -0
  102. {thyra-4.2.0 → thyra-4.3.1}/thyra/readers/imzml/mobility_array.py +0 -0
  103. {thyra-4.2.0 → thyra-4.3.1}/thyra/readers/mzpeak/__init__.py +0 -0
  104. {thyra-4.2.0 → thyra-4.3.1}/thyra/readers/phi/__init__.py +0 -0
  105. {thyra-4.2.0 → thyra-4.3.1}/thyra/readers/phi/event_stream.py +0 -0
  106. {thyra-4.2.0 → thyra-4.3.1}/thyra/readers/phi/mass_axis.py +0 -0
  107. {thyra-4.2.0 → thyra-4.3.1}/thyra/readers/phi/phi_header.py +0 -0
  108. {thyra-4.2.0 → thyra-4.3.1}/thyra/readers/waters/__init__.py +0 -0
  109. {thyra-4.2.0 → thyra-4.3.1}/thyra/readers/waters/imaging_grid.py +0 -0
  110. {thyra-4.2.0 → thyra-4.3.1}/thyra/readers/waters/instrument.py +0 -0
  111. {thyra-4.2.0 → thyra-4.3.1}/thyra/readers/waters/lib/MLReader.dll +0 -0
  112. {thyra-4.2.0 → thyra-4.3.1}/thyra/readers/waters/lib/MassLynxRaw.dll +0 -0
  113. {thyra-4.2.0 → thyra-4.3.1}/thyra/readers/waters/lib/libMLReader.so +0 -0
  114. {thyra-4.2.0 → thyra-4.3.1}/thyra/readers/waters/lib/libMassLynxRaw.so +0 -0
  115. {thyra-4.2.0 → thyra-4.3.1}/thyra/readers/waters/masslynx_lib.py +0 -0
  116. {thyra-4.2.0 → thyra-4.3.1}/thyra/resampling/__init__.py +0 -0
  117. {thyra-4.2.0 → thyra-4.3.1}/thyra/resampling/axis_planner.py +0 -0
  118. {thyra-4.2.0 → thyra-4.3.1}/thyra/resampling/binning.py +0 -0
  119. {thyra-4.2.0 → thyra-4.3.1}/thyra/resampling/common_axis.py +0 -0
  120. {thyra-4.2.0 → thyra-4.3.1}/thyra/resampling/constants.py +0 -0
  121. {thyra-4.2.0 → thyra-4.3.1}/thyra/resampling/data_characteristics.py +0 -0
  122. {thyra-4.2.0 → thyra-4.3.1}/thyra/resampling/decision_tree.py +0 -0
  123. {thyra-4.2.0 → thyra-4.3.1}/thyra/resampling/gaps.py +0 -0
  124. {thyra-4.2.0 → thyra-4.3.1}/thyra/resampling/instrument_detectors.py +0 -0
  125. {thyra-4.2.0 → thyra-4.3.1}/thyra/resampling/interpolation.py +0 -0
  126. {thyra-4.2.0 → thyra-4.3.1}/thyra/resampling/mass_axis/__init__.py +0 -0
  127. {thyra-4.2.0 → thyra-4.3.1}/thyra/resampling/mass_axis/base_generator.py +0 -0
  128. {thyra-4.2.0 → thyra-4.3.1}/thyra/resampling/mass_axis/fticr_generator.py +0 -0
  129. {thyra-4.2.0 → thyra-4.3.1}/thyra/resampling/mass_axis/linear_generator.py +0 -0
  130. {thyra-4.2.0 → thyra-4.3.1}/thyra/resampling/mass_axis/linear_tof_generator.py +0 -0
  131. {thyra-4.2.0 → thyra-4.3.1}/thyra/resampling/mass_axis/orbitrap_generator.py +0 -0
  132. {thyra-4.2.0 → thyra-4.3.1}/thyra/resampling/mass_axis/reflector_tof_generator.py +0 -0
  133. {thyra-4.2.0 → thyra-4.3.1}/thyra/resampling/mass_axis/tof_generator.py +0 -0
  134. {thyra-4.2.0 → thyra-4.3.1}/thyra/resampling/mobility_grid.py +0 -0
  135. {thyra-4.2.0 → thyra-4.3.1}/thyra/resampling/strategies/__init__.py +0 -0
  136. {thyra-4.2.0 → thyra-4.3.1}/thyra/resampling/strategies/base.py +0 -0
  137. {thyra-4.2.0 → thyra-4.3.1}/thyra/resampling/strategies/nearest_neighbor.py +0 -0
  138. {thyra-4.2.0 → thyra-4.3.1}/thyra/resampling/strategies/tic_preserving.py +0 -0
  139. {thyra-4.2.0 → thyra-4.3.1}/thyra/resampling/tic.py +0 -0
  140. {thyra-4.2.0 → thyra-4.3.1}/thyra/resampling/types.py +0 -0
  141. {thyra-4.2.0 → thyra-4.3.1}/thyra/tools/__init__.py +0 -0
  142. {thyra-4.2.0 → thyra-4.3.1}/thyra/tools/check_ontology.py +0 -0
  143. {thyra-4.2.0 → thyra-4.3.1}/thyra/tools/make_example_data.py +0 -0
  144. {thyra-4.2.0 → thyra-4.3.1}/thyra/utils/__init__.py +0 -0
  145. {thyra-4.2.0 → thyra-4.3.1}/thyra/utils/bruker_exceptions.py +0 -0
  146. {thyra-4.2.0 → thyra-4.3.1}/thyra/utils/imzml_coordinate_base.py +0 -0
  147. {thyra-4.2.0 → thyra-4.3.1}/thyra/utils/logging_config.py +0 -0
  148. {thyra-4.2.0 → thyra-4.3.1}/thyra/utils/pyimzml_direct.py +0 -0
  149. {thyra-4.2.0 → thyra-4.3.1}/thyra/utils/windows_paths.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: thyra
3
- Version: 4.2.0
3
+ Version: 4.3.1
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.2.0"
7
+ version = "4.3.1"
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.2.0"
70
+ __version__ = "4.3.1"
71
71
 
72
72
  #: Public name -> the module that defines it, imported on first access.
73
73
  _LAZY_ATTRS = {
@@ -331,7 +331,14 @@ def _display_calibration_info(input: Path, use_recalibrated: bool) -> None:
331
331
  f"{'s' if n_recalibrations > 1 else ''} since acquisition"
332
332
  )
333
333
  for state in states:
334
- active_marker = " (active/will be used)" if state["id"] == active_id else ""
334
+ # With --no-recalibrated, Bruker's library applies none of the
335
+ # states, the newest included: it takes the analysis database's
336
+ # own calibration instead.
337
+ active_marker = (
338
+ " (active/will be used)"
339
+ if use_recalibrated and state["id"] == active_id
340
+ else ""
341
+ )
335
342
  click.echo(
336
343
  f" State {state['id']}: {state['datetime']} "
337
344
  f"[{state['source']}]{active_marker}"
@@ -340,7 +347,10 @@ def _display_calibration_info(input: Path, use_recalibrated: bool) -> None:
340
347
  if use_recalibrated:
341
348
  click.echo(f"\nUsing active calibration state (State {active_id})")
342
349
  else:
343
- click.echo("\nUsing original calibration (--no-recalibrated flag set)")
350
+ click.echo(
351
+ "\nUsing original calibration (--no-recalibrated flag set): "
352
+ "none of these states"
353
+ )
344
354
 
345
355
  click.echo(
346
356
  "\nNote: this lists the states only; one cannot be chosen here. "
@@ -51,21 +51,27 @@ from tqdm import tqdm
51
51
 
52
52
  from ...core.conversion_state import ConversionState
53
53
  from ...errors import ConversionRefused
54
+ from ...metadata.schema import MSI_OBS_ACQUISITION_ORDER_COLUMN
54
55
  from .base_spatialdata_converter import BaseSpatialDataConverter
55
56
  from .csc_assembly import CscAssembly, index_dtype
56
57
 
57
58
  logger = logging.getLogger(__name__)
58
59
 
60
+ #: The acquisition order of a grid position no spectrum has reached yet:
61
+ #: larger than any real one, so the first spectrum's order replaces it.
62
+ _NOT_ACQUIRED = np.iinfo(np.int64).max
63
+
59
64
 
60
65
  class _TableUnit:
61
66
  """One table of the store in the making: a z plane, or the whole volume.
62
67
 
63
68
  Holds what the two passes accumulate for it -- the column counts and
64
69
  the scattered matrix (``assembly``), which grid positions carry a
65
- spectrum (``occupancy``) and the TIC raster -- and, once pass 1 is
66
- done, the row layout: ``kept_grid`` is the grid index of each table
67
- row and ``row_of_grid`` the table row of each grid position (-1 when
68
- dropped as empty).
70
+ spectrum (``occupancy``), the TIC raster and, when the reader knows
71
+ it, each position's acquisition order -- and, once pass 1 is done,
72
+ the row layout: ``kept_grid`` is the grid index of each table row and
73
+ ``row_of_grid`` the table row of each grid position (-1 when dropped
74
+ as empty).
69
75
 
70
76
  Args:
71
77
  key: The table's element key (``{id}_z{z}`` or ``{id}``).
@@ -75,6 +81,8 @@ class _TableUnit:
75
81
  n_cols: Length of the mass axis.
76
82
  tic_shape: ``(n_y, n_x)`` for a plane, ``(n_z, n_y, n_x)`` for a
77
83
  volume.
84
+ records_order: Whether every spectrum comes with its acquisition
85
+ order (the reader's ``has_acquisition_order``).
78
86
  """
79
87
 
80
88
  def __init__(
@@ -85,6 +93,7 @@ class _TableUnit:
85
93
  n_grid: int,
86
94
  n_cols: int,
87
95
  tic_shape: Tuple[int, ...],
96
+ records_order: bool = False,
88
97
  ) -> None:
89
98
  """Allocate the pass-1 accumulators; the matrix comes after pass 1."""
90
99
  self.key = key
@@ -104,14 +113,31 @@ class _TableUnit:
104
113
  #: coordinate to name if it does not come out as pass 1's.
105
114
  self.observed: Dict[int, Tuple[float, Tuple[int, int, int]]] = {}
106
115
  self.tic = np.zeros(tic_shape, dtype=np.float64)
116
+ #: Per grid position, the smallest acquisition order among the
117
+ #: spectra it got; ``None`` when the reader does not know the
118
+ #: order. A position no spectrum reached keeps the sentinel and
119
+ #: never becomes a row, so no row can read it.
120
+ self.acquisition_order: Optional[NDArray[np.int64]] = (
121
+ np.full(self.n_grid, _NOT_ACQUIRED, dtype=np.int64)
122
+ if records_order
123
+ else None
124
+ )
107
125
  self.kept_grid: Optional[NDArray[np.int64]] = None
108
126
  self.row_of_grid: Optional[NDArray[np.int64]] = None
109
127
  self.n_rows = 0
110
128
 
111
129
  def count(
112
- self, grid: int, mz_indices: NDArray[np.int_], values: NDArray[np.float64]
130
+ self,
131
+ grid: int,
132
+ mz_indices: NDArray[np.int_],
133
+ values: NDArray[np.float64],
134
+ order: Optional[int] = None,
113
135
  ) -> None:
114
- """Pass 1: one spectrum at grid position ``grid``."""
136
+ """Pass 1: one spectrum at grid position ``grid``.
137
+
138
+ ``order`` is the spectrum's acquisition order, required when the
139
+ unit records one.
140
+ """
115
141
  self.assembly.count(grid, mz_indices)
116
142
  if self.occupancy[grid]:
117
143
  # A pixel the source measured twice. One position is one row,
@@ -122,6 +148,16 @@ class _TableUnit:
122
148
  self.assembly.merge_duplicates = True
123
149
  self.occupancy[grid] = True
124
150
  self.tic.reshape(-1)[grid] += values.sum()
151
+ if self.acquisition_order is not None:
152
+ if order is None:
153
+ raise ValueError(
154
+ f"{self.key}: a spectrum reached the table without its "
155
+ "acquisition order"
156
+ )
157
+ # A row summed from two measurements was first measured at the
158
+ # earlier one, whatever order the reader yields them in.
159
+ if order < self.acquisition_order[grid]:
160
+ self.acquisition_order[grid] = order
125
161
 
126
162
  def finish_counting(self) -> None:
127
163
  """Decide which grid positions become rows, and in what order.
@@ -436,6 +472,7 @@ class StreamingSpatialDataConverter(BaseSpatialDataConverter):
436
472
  raise ValueError("Common mass axis is not initialized")
437
473
  n_x, n_y, n_z = self._dimensions
438
474
  n_cols = len(self._common_mass_axis)
475
+ records_order = bool(self.reader.has_acquisition_order)
439
476
  if self.handle_3d:
440
477
  return [
441
478
  _TableUnit(
@@ -445,6 +482,7 @@ class StreamingSpatialDataConverter(BaseSpatialDataConverter):
445
482
  n_x * n_y * n_z,
446
483
  n_cols,
447
484
  (n_z, n_y, n_x),
485
+ records_order,
448
486
  )
449
487
  ]
450
488
  return [
@@ -455,6 +493,7 @@ class StreamingSpatialDataConverter(BaseSpatialDataConverter):
455
493
  n_x * n_y,
456
494
  n_cols,
457
495
  (n_y, n_x),
496
+ records_order,
458
497
  )
459
498
  for z in range(n_z)
460
499
  ]
@@ -533,22 +572,36 @@ class StreamingSpatialDataConverter(BaseSpatialDataConverter):
533
572
  return units[0], z * n_x * n_y + y * n_x + x
534
573
  return units[z], y * n_x + x
535
574
 
536
- def _iter_pass_spectra(
537
- self, passes: Any, phase: str
538
- ) -> Generator[Tuple[Tuple[int, int, int], Any, Any, Any], None, None]:
575
+ def _iter_pass_spectra(self, passes: Any, phase: str) -> Generator[
576
+ Tuple[Tuple[int, int, int], Optional[int], Any, Any, Any],
577
+ None,
578
+ None,
579
+ ]:
539
580
  """The summed spectra of one pass, with the frame record they came from.
540
581
 
541
- Yields ``(coords, mzs, intensities, frame)``: ``frame`` is the
542
- record the sinks are fed from, or ``None`` when the pass reads
543
- the summed spectra directly (no sinks, or a reader without
544
- records). A frame whose summed spectrum is empty is not yielded,
545
- exactly as ``iter_spectra`` never yields it, but the sinks of
546
- ``phase`` (``"count"`` or ``"scatter"``) still see it with no row,
547
- which is what their own pass would have shown them.
582
+ Yields ``(coords, order, mzs, intensities, frame)``: ``order`` is
583
+ the spectrum's acquisition order, or ``None`` when the reader does
584
+ not know it, and ``frame`` is the record the sinks are fed from,
585
+ or ``None`` when the pass reads the summed spectra directly (no
586
+ sinks, or a reader without records). A frame whose summed spectrum
587
+ is empty is not yielded, exactly as ``iter_spectra`` never yields
588
+ it, but the sinks of ``phase`` (``"count"`` or ``"scatter"``) still
589
+ see it with no row, which is what their own pass would have shown
590
+ them.
548
591
  """
592
+ ordered = bool(self.reader.has_acquisition_order)
549
593
  if passes is None or passes.empty:
594
+ if ordered:
595
+ for (
596
+ coords,
597
+ order,
598
+ mzs,
599
+ intensities,
600
+ ) in self.reader.iter_spectra_with_acquisition_order():
601
+ yield coords, order, mzs, intensities, None
602
+ return
550
603
  for coords, mzs, intensities in self.reader.iter_spectra():
551
- yield coords, mzs, intensities, None
604
+ yield coords, None, mzs, intensities, None
552
605
  return
553
606
  feed = getattr(passes, phase)
554
607
  for frame in self.reader.iter_frame_scans():
@@ -556,7 +609,8 @@ class StreamingSpatialDataConverter(BaseSpatialDataConverter):
556
609
  if spectrum is None:
557
610
  feed(frame, None)
558
611
  continue
559
- yield frame.coords, spectrum[0], spectrum[1], frame
612
+ order = frame.acquisition_order if ordered else None
613
+ yield frame.coords, order, spectrum[0], spectrum[1], frame
560
614
 
561
615
  def _process_spectra(self, state: ConversionState) -> None:
562
616
  """Run both passes: count, size the arrays, scatter.
@@ -762,7 +816,7 @@ class StreamingSpatialDataConverter(BaseSpatialDataConverter):
762
816
  desc="Pre-scan" if passes is None else "Pre-scan + sibling tables",
763
817
  unit="spectrum",
764
818
  ) as pbar:
765
- for coords, mzs, intensities, frame in self._iter_pass_spectra(
819
+ for coords, order, mzs, intensities, frame in self._iter_pass_spectra(
766
820
  passes, "count"
767
821
  ):
768
822
  x, y, z = coords
@@ -791,7 +845,7 @@ class StreamingSpatialDataConverter(BaseSpatialDataConverter):
791
845
  # never writes -- explicit zeros, out of order, which
792
846
  # scipy reports as a non-canonical matrix.
793
847
  if unit is not None:
794
- unit.count(grid, mz_indices, values)
848
+ unit.count(grid, mz_indices, values, order)
795
849
 
796
850
  # Behind the same check, and for the same reason
797
851
  # the per-table average is taken over rows: the
@@ -876,7 +930,7 @@ class StreamingSpatialDataConverter(BaseSpatialDataConverter):
876
930
  desc="Scatter to CSC" if passes is None else "Scatter to CSC + siblings",
877
931
  unit="spectrum",
878
932
  ) as pbar:
879
- for coords, mzs, intensities, frame in self._iter_pass_spectra(
933
+ for coords, _, mzs, intensities, frame in self._iter_pass_spectra(
880
934
  passes, "scatter"
881
935
  ):
882
936
  x, y, z = coords
@@ -1010,6 +1064,9 @@ class StreamingSpatialDataConverter(BaseSpatialDataConverter):
1010
1064
  the in-plane pitch so the table lands in the same micrometre frame
1011
1065
  as the TIC volume's ``Scale``. Column order and dtypes are the ones
1012
1066
  the two in-memory converters wrote, so a store reads back the same.
1067
+ ``acquisition_order`` follows, int64, when the reader knows the
1068
+ order (design decision D26); the rows stay in grid order either
1069
+ way.
1013
1070
  """
1014
1071
  if unit.kept_grid is None or self._dimensions is None:
1015
1072
  raise RuntimeError("The row layout is not decided yet")
@@ -1053,6 +1110,11 @@ class StreamingSpatialDataConverter(BaseSpatialDataConverter):
1053
1110
  obs.set_index("instance_id", inplace=True)
1054
1111
  # Always add per-pixel region numbers for a consistent schema.
1055
1112
  obs["region_number"] = self.build_region_numbers(x_idx, y_idx)
1113
+ # Indexed by the same kept grid as every column above, so a row's
1114
+ # order is its own position's, region filtering included: the
1115
+ # filter removes frames before pass 1 ever sees them.
1116
+ if unit.acquisition_order is not None:
1117
+ obs[MSI_OBS_ACQUISITION_ORDER_COLUMN] = unit.acquisition_order[kept]
1056
1118
  return obs
1057
1119
 
1058
1120
  def _tic_image(self, unit: _TableUnit) -> Any:
@@ -319,6 +319,54 @@ class BaseMSIReader(ABC):
319
319
  """
320
320
  return None
321
321
 
322
+ # ------------------------------------------------------------------
323
+ # Acquisition order
324
+ #
325
+ # When a spectrum was recorded, relative to the others. ``iter_spectra``
326
+ # says where each spectrum sits and nothing about when, and the rows a
327
+ # converter writes follow the grid, which a serpentine scan or a slide
328
+ # of several regions does not. A drift plotted against the rows is
329
+ # plotted against raster order.
330
+ # ------------------------------------------------------------------
331
+
332
+ @property
333
+ def has_acquisition_order(self) -> bool:
334
+ """Whether the source says in which order its spectra were acquired."""
335
+ return False
336
+
337
+ def iter_spectra_with_acquisition_order(self) -> Generator[
338
+ Tuple[
339
+ Tuple[int, int, int],
340
+ int,
341
+ NDArray[np.float64],
342
+ NDArray[np.float64],
343
+ ],
344
+ None,
345
+ None,
346
+ ]:
347
+ """:meth:`iter_spectra`, with each spectrum's acquisition order.
348
+
349
+ Yields ``((x, y, z), order, mzs, intensities)``: the spectra
350
+ :meth:`iter_spectra` yields, in its order, with its coordinates and
351
+ arrays. ``order`` grows with acquisition time and no two spectra
352
+ share one. It is the source's own number for the spectrum where the
353
+ source numbers them (a Bruker ``Frames.Id``), else the spectrum's
354
+ 0-based position in the sequence the source lists them in. So it
355
+ need not start at 0 or be contiguous: sort by it, never index with
356
+ it.
357
+
358
+ The converter reads the spectra through this method whenever
359
+ :attr:`has_acquisition_order` is True, so a subclass that changes
360
+ what :meth:`iter_spectra` yields has to change this as well.
361
+
362
+ Raises:
363
+ NotImplementedError: When :attr:`has_acquisition_order` is False.
364
+ """
365
+ raise NotImplementedError(
366
+ f"{type(self).__name__} does not know the order its spectra were "
367
+ "acquired in"
368
+ )
369
+
322
370
  # ------------------------------------------------------------------
323
371
  # One read per frame for every table
324
372
  #
@@ -46,6 +46,13 @@ class FrameScans(Protocol):
46
46
 
47
47
  coords: Coords
48
48
 
49
+ @property
50
+ def acquisition_order(self) -> Optional[int]:
51
+ """The frame's order as ``iter_spectra_with_acquisition_order`` yields it.
52
+
53
+ ``None`` when the reader does not know the order.
54
+ """
55
+
49
56
  def spectrum(self) -> Optional[Spectrum]:
50
57
  """``(mzs, intensities)`` as :meth:`BaseMSIReader.iter_spectra` yields, or ``None``.
51
58
 
@@ -33,6 +33,18 @@ UM_PER_UNIT = {
33
33
  "UO:0000018": 0.001, # nanometer
34
34
  }
35
35
 
36
+ # The name IMS:1000046 carried until imagingMS.obo commit 421481e of
37
+ # 2017-09-07. Under that name the value is the AREA of a pixel, and
38
+ # IMS:1000047 is "image shape", which is not a size at all. Files written
39
+ # under the old vocabulary are still published.
40
+ OLD_PIXEL_SIZE_NAME = "pixel size"
41
+
42
+
43
+ def has_old_pixel_size_name(name: object) -> bool:
44
+ """Whether IMS:1000046 is spelled as it was when it gave an area."""
45
+ return isinstance(name, str) and name.strip().lower() == OLD_PIXEL_SIZE_NAME
46
+
47
+
36
48
  # Analyzer-component terms, in preference order. The first one found under a
37
49
  # <componentList><analyzer> is surfaced as ``instrument_info["analyzer"]``, so
38
50
  # on a hybrid that declares several analyzers the highest-resolution stage --
@@ -533,12 +545,21 @@ class ImzMLMetadataExtractor(MetadataExtractor):
533
545
  nanometre pixel size as micrometres, 1000x too large, and
534
546
  ``convert_msi`` still returned ``True``. The unit survives on the
535
547
  ParamGroup path, so it is read from there and the value converted.
548
+
549
+ The name the file gave the term survives there too. Under its old
550
+ name the value is an area, so it is not read, see
551
+ :meth:`_is_old_pixel_size`.
536
552
  """
537
553
  if hasattr(self.parser, "imzmldict") and self.parser.imzmldict:
538
554
  # Check for pixel size parameters in the parsed dictionary
539
555
  x_size = self.parser.imzmldict.get("pixel size x")
540
556
  y_size = self.parser.imzmldict.get("pixel size y")
541
557
 
558
+ if x_size is not None and self._is_old_pixel_size(
559
+ self._pixel_size_raw_name(), x_size
560
+ ):
561
+ return None
562
+
542
563
  if x_size is not None and y_size is not None:
543
564
  try:
544
565
  x_size, y_size = float(x_size), float(y_size)
@@ -582,6 +603,44 @@ class ImzMLMetadataExtractor(MetadataExtractor):
582
603
  units[accession] = unit_accession
583
604
  return units
584
605
 
606
+ def _pixel_size_raw_name(self) -> Optional[str]:
607
+ """The name the file itself gives IMS:1000046, or ``None``.
608
+
609
+ pyimzml renames the term to its current name while parsing, so
610
+ ``name`` always reads "pixel size (x)". ``raw_name`` is what the
611
+ document says. First declaration wins, as for the unit.
612
+ """
613
+ metadata = getattr(self.parser, "metadata", None)
614
+ scan_settings = getattr(metadata, "scan_settings", None)
615
+ if not isinstance(scan_settings, dict):
616
+ return None
617
+
618
+ for group in scan_settings.values():
619
+ for param in getattr(group, "cv_params", []):
620
+ if param[1] == ImzMLAccessions.PIXEL_SIZE_X:
621
+ raw_name = param[3]
622
+ return raw_name if isinstance(raw_name, str) else None
623
+ return None
624
+
625
+ def _is_old_pixel_size(self, name: object, value: object) -> bool:
626
+ """Whether IMS:1000046 is written under its name of before 2017.
627
+
628
+ Under that name the value is the area of a pixel. Read as a length
629
+ it is wrong by its own square root: 10000 for a 100 um pixel. So it
630
+ is not read, the log says why, and the conversion asks for
631
+ ``--pixel-size``.
632
+ """
633
+ if not has_old_pixel_size_name(name):
634
+ return False
635
+ logger.warning(
636
+ '%s names IMS:1000046 "pixel size", as the vocabulary did before '
637
+ "2017, when the value (%r) was the area of a pixel. It is not "
638
+ "read as a pixel size. Pass --pixel-size.",
639
+ Path(self.imzml_path).name,
640
+ value,
641
+ )
642
+ return True
643
+
585
644
  def _pixel_size_to_um(self, value: float, unit_accession: Optional[str]) -> float:
586
645
  """Convert a declared pixel size to micrometres, or refuse.
587
646
 
@@ -1170,11 +1229,15 @@ class ImzMLMetadataExtractor(MetadataExtractor):
1170
1229
  The unit conversion happens outside the ``try`` so a refused unit
1171
1230
  propagates as loudly as it does on the fast path, while a merely
1172
1231
  unparseable document still degrades to "no pixel size".
1232
+
1233
+ IMS:1000046 under its old name is not read here either, see
1234
+ :meth:`_is_old_pixel_size`.
1173
1235
  """
1174
1236
  x_size = None
1175
1237
  y_size = None
1176
1238
  x_unit = None
1177
1239
  y_unit = None
1240
+ x_name = None
1178
1241
 
1179
1242
  try:
1180
1243
  if not hasattr(self.parser, "metadata") or not hasattr(
@@ -1196,6 +1259,7 @@ class ImzMLMetadataExtractor(MetadataExtractor):
1196
1259
  if accession == ImzMLAccessions.PIXEL_SIZE_X:
1197
1260
  x_size = float(cvparam.get("value", 0))
1198
1261
  x_unit = cvparam.get("unitAccession")
1262
+ x_name = cvparam.get("name")
1199
1263
  elif accession == ImzMLAccessions.PIXEL_SIZE_Y:
1200
1264
  y_size = float(cvparam.get("value", 0))
1201
1265
  y_unit = cvparam.get("unitAccession")
@@ -1204,6 +1268,9 @@ class ImzMLMetadataExtractor(MetadataExtractor):
1204
1268
  logger.warning(f"Failed to parse XML metadata for pixel size: {e}")
1205
1269
  return None
1206
1270
 
1271
+ if x_size is not None and self._is_old_pixel_size(x_name, x_size):
1272
+ return None
1273
+
1207
1274
  if x_size is not None and y_size is not None:
1208
1275
  x_um = self._pixel_size_to_um(x_size, x_unit)
1209
1276
  y_um = self._pixel_size_to_um(y_size, y_unit)