thyra 4.3.1__tar.gz → 4.3.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. {thyra-4.3.1 → thyra-4.3.3}/PKG-INFO +1 -1
  2. {thyra-4.3.1 → thyra-4.3.3}/pyproject.toml +1 -1
  3. {thyra-4.3.1 → thyra-4.3.3}/thyra/__init__.py +1 -1
  4. {thyra-4.3.1 → thyra-4.3.3}/thyra/convert.py +6 -3
  5. {thyra-4.3.1 → thyra-4.3.3}/thyra/converters/spatialdata/optical_image.py +38 -12
  6. {thyra-4.3.1 → thyra-4.3.3}/thyra/core/base_reader.py +42 -1
  7. {thyra-4.3.1 → thyra-4.3.3}/thyra/metadata/extractors/imzml_extractor.py +143 -119
  8. {thyra-4.3.1 → thyra-4.3.3}/thyra/metadata/extractors/mzpeak_extractor.py +104 -16
  9. {thyra-4.3.1 → thyra-4.3.3}/thyra/readers/mzpeak/chunk_decoding.py +13 -1
  10. {thyra-4.3.1 → thyra-4.3.3}/thyra/readers/mzpeak/mzpeak_reader.py +612 -32
  11. {thyra-4.3.1 → thyra-4.3.3}/thyra/resampling/instrument_detectors.py +5 -2
  12. {thyra-4.3.1 → thyra-4.3.3}/thyra/utils/imzml_coordinate_base.py +85 -1
  13. {thyra-4.3.1 → thyra-4.3.3}/.gitignore +0 -0
  14. {thyra-4.3.1 → thyra-4.3.3}/LICENSE +0 -0
  15. {thyra-4.3.1 → thyra-4.3.3}/README.md +0 -0
  16. {thyra-4.3.1 → thyra-4.3.3}/thyra/__main__.py +0 -0
  17. {thyra-4.3.1 → thyra-4.3.3}/thyra/alignment/__init__.py +0 -0
  18. {thyra-4.3.1 → thyra-4.3.3}/thyra/alignment/affine.py +0 -0
  19. {thyra-4.3.1 → thyra-4.3.3}/thyra/alignment/teaching_points.py +0 -0
  20. {thyra-4.3.1 → thyra-4.3.3}/thyra/config.py +0 -0
  21. {thyra-4.3.1 → thyra-4.3.3}/thyra/converters/__init__.py +0 -0
  22. {thyra-4.3.1 → thyra-4.3.3}/thyra/converters/spatialdata/__init__.py +0 -0
  23. {thyra-4.3.1 → thyra-4.3.3}/thyra/converters/spatialdata/_chunking.py +0 -0
  24. {thyra-4.3.1 → thyra-4.3.3}/thyra/converters/spatialdata/base_spatialdata_converter.py +0 -0
  25. {thyra-4.3.1 → thyra-4.3.3}/thyra/converters/spatialdata/converter.py +0 -0
  26. {thyra-4.3.1 → thyra-4.3.3}/thyra/converters/spatialdata/csc_assembly.py +0 -0
  27. {thyra-4.3.1 → thyra-4.3.3}/thyra/converters/spatialdata/fused_passes.py +0 -0
  28. {thyra-4.3.1 → thyra-4.3.3}/thyra/converters/spatialdata/mobility_heatmap.py +0 -0
  29. {thyra-4.3.1 → thyra-4.3.3}/thyra/converters/spatialdata/mobility_table.py +0 -0
  30. {thyra-4.3.1 → thyra-4.3.3}/thyra/converters/spatialdata/msms_table.py +0 -0
  31. {thyra-4.3.1 → thyra-4.3.3}/thyra/converters/spatialdata/root_attrs.py +0 -0
  32. {thyra-4.3.1 → thyra-4.3.3}/thyra/converters/spatialdata/sibling_tables.py +0 -0
  33. {thyra-4.3.1 → thyra-4.3.3}/thyra/converters/spatialdata/streaming_converter.py +0 -0
  34. {thyra-4.3.1 → thyra-4.3.3}/thyra/converters/spatialdata/uns_assembler.py +0 -0
  35. {thyra-4.3.1 → thyra-4.3.3}/thyra/core/__init__.py +0 -0
  36. {thyra-4.3.1 → thyra-4.3.3}/thyra/core/base_converter.py +0 -0
  37. {thyra-4.3.1 → thyra-4.3.3}/thyra/core/base_extractor.py +0 -0
  38. {thyra-4.3.1 → thyra-4.3.3}/thyra/core/conversion_state.py +0 -0
  39. {thyra-4.3.1 → thyra-4.3.3}/thyra/core/drop_tally.py +0 -0
  40. {thyra-4.3.1 → thyra-4.3.3}/thyra/core/frames.py +0 -0
  41. {thyra-4.3.1 → thyra-4.3.3}/thyra/core/mass_axis.py +0 -0
  42. {thyra-4.3.1 → thyra-4.3.3}/thyra/core/mobility.py +0 -0
  43. {thyra-4.3.1 → thyra-4.3.3}/thyra/core/msms.py +0 -0
  44. {thyra-4.3.1 → thyra-4.3.3}/thyra/core/registry.py +0 -0
  45. {thyra-4.3.1 → thyra-4.3.3}/thyra/errors.py +0 -0
  46. {thyra-4.3.1 → thyra-4.3.3}/thyra/metadata/__init__.py +0 -0
  47. {thyra-4.3.1 → thyra-4.3.3}/thyra/metadata/constants.py +0 -0
  48. {thyra-4.3.1 → thyra-4.3.3}/thyra/metadata/document.py +0 -0
  49. {thyra-4.3.1 → thyra-4.3.3}/thyra/metadata/extractors/__init__.py +0 -0
  50. {thyra-4.3.1 → thyra-4.3.3}/thyra/metadata/extractors/bruker_extractor.py +0 -0
  51. {thyra-4.3.1 → thyra-4.3.3}/thyra/metadata/extractors/imzml_header_extractor.py +0 -0
  52. {thyra-4.3.1 → thyra-4.3.3}/thyra/metadata/extractors/phi_extractor.py +0 -0
  53. {thyra-4.3.1 → thyra-4.3.3}/thyra/metadata/extractors/solarix_extractor.py +0 -0
  54. {thyra-4.3.1 → thyra-4.3.3}/thyra/metadata/extractors/waters_extractor.py +0 -0
  55. {thyra-4.3.1 → thyra-4.3.3}/thyra/metadata/ontology/__init__.py +0 -0
  56. {thyra-4.3.1 → thyra-4.3.3}/thyra/metadata/ontology/_ims.py +0 -0
  57. {thyra-4.3.1 → thyra-4.3.3}/thyra/metadata/ontology/_ms.py +0 -0
  58. {thyra-4.3.1 → thyra-4.3.3}/thyra/metadata/ontology/_uo.py +0 -0
  59. {thyra-4.3.1 → thyra-4.3.3}/thyra/metadata/ontology/cache.py +0 -0
  60. {thyra-4.3.1 → thyra-4.3.3}/thyra/metadata/ontology/dump_obo_files.py +0 -0
  61. {thyra-4.3.1 → thyra-4.3.3}/thyra/metadata/personal_data.py +0 -0
  62. {thyra-4.3.1 → thyra-4.3.3}/thyra/metadata/schema/__init__.py +0 -0
  63. {thyra-4.3.1 → thyra-4.3.3}/thyra/metadata/schema/builder.py +0 -0
  64. {thyra-4.3.1 → thyra-4.3.3}/thyra/metadata/schema/cli.py +0 -0
  65. {thyra-4.3.1 → thyra-4.3.3}/thyra/metadata/schema/generate.py +0 -0
  66. {thyra-4.3.1 → thyra-4.3.3}/thyra/metadata/schema/metaspace.py +0 -0
  67. {thyra-4.3.1 → thyra-4.3.3}/thyra/metadata/schema/models.py +0 -0
  68. {thyra-4.3.1 → thyra-4.3.3}/thyra/metadata/schema/msi_metadata.linkml.yaml +0 -0
  69. {thyra-4.3.1 → thyra-4.3.3}/thyra/metadata/schema/msi_metadata_schema_v0_10.json +0 -0
  70. {thyra-4.3.1 → thyra-4.3.3}/thyra/metadata/schema/store_io.py +0 -0
  71. {thyra-4.3.1 → thyra-4.3.3}/thyra/metadata/schema/validate.py +0 -0
  72. {thyra-4.3.1 → thyra-4.3.3}/thyra/metadata/schema/vocab.py +0 -0
  73. {thyra-4.3.1 → thyra-4.3.3}/thyra/metadata/types.py +0 -0
  74. {thyra-4.3.1 → thyra-4.3.3}/thyra/metadata/uns_compat.py +0 -0
  75. {thyra-4.3.1 → thyra-4.3.3}/thyra/metadata/validator.py +0 -0
  76. {thyra-4.3.1 → thyra-4.3.3}/thyra/preview.py +0 -0
  77. {thyra-4.3.1 → thyra-4.3.3}/thyra/py.typed +0 -0
  78. {thyra-4.3.1 → thyra-4.3.3}/thyra/readers/__init__.py +0 -0
  79. {thyra-4.3.1 → thyra-4.3.3}/thyra/readers/bruker/__init__.py +0 -0
  80. {thyra-4.3.1 → thyra-4.3.3}/thyra/readers/bruker/base_bruker_reader.py +0 -0
  81. {thyra-4.3.1 → thyra-4.3.3}/thyra/readers/bruker/folder_structure.py +0 -0
  82. {thyra-4.3.1 → thyra-4.3.3}/thyra/readers/bruker/mis_parser.py +0 -0
  83. {thyra-4.3.1 → thyra-4.3.3}/thyra/readers/bruker/rapiflex/__init__.py +0 -0
  84. {thyra-4.3.1 → thyra-4.3.3}/thyra/readers/bruker/rapiflex/rapiflex_reader.py +0 -0
  85. {thyra-4.3.1 → thyra-4.3.3}/thyra/readers/bruker/solarix/__init__.py +0 -0
  86. {thyra-4.3.1 → thyra-4.3.3}/thyra/readers/bruker/solarix/solarix_reader.py +0 -0
  87. {thyra-4.3.1 → thyra-4.3.3}/thyra/readers/bruker/timstof/__init__.py +0 -0
  88. {thyra-4.3.1 → thyra-4.3.3}/thyra/readers/bruker/timstof/sdk/__init__.py +0 -0
  89. {thyra-4.3.1 → thyra-4.3.3}/thyra/readers/bruker/timstof/sdk/dll/LICENCE-BRUKER.txt +0 -0
  90. {thyra-4.3.1 → thyra-4.3.3}/thyra/readers/bruker/timstof/sdk/dll/README.md +0 -0
  91. {thyra-4.3.1 → thyra-4.3.3}/thyra/readers/bruker/timstof/sdk/dll/timsdata.dll +0 -0
  92. {thyra-4.3.1 → thyra-4.3.3}/thyra/readers/bruker/timstof/sdk/dll/timsdata.so +0 -0
  93. {thyra-4.3.1 → thyra-4.3.3}/thyra/readers/bruker/timstof/sdk/dll_manager.py +0 -0
  94. {thyra-4.3.1 → thyra-4.3.3}/thyra/readers/bruker/timstof/sdk/platform_detector.py +0 -0
  95. {thyra-4.3.1 → thyra-4.3.3}/thyra/readers/bruker/timstof/sdk/sdk_functions.py +0 -0
  96. {thyra-4.3.1 → thyra-4.3.3}/thyra/readers/bruker/timstof/timstof_reader.py +0 -0
  97. {thyra-4.3.1 → thyra-4.3.3}/thyra/readers/bruker/vendor_db.py +0 -0
  98. {thyra-4.3.1 → thyra-4.3.3}/thyra/readers/imzml/__init__.py +0 -0
  99. {thyra-4.3.1 → thyra-4.3.3}/thyra/readers/imzml/_pyimzml_compat.py +0 -0
  100. {thyra-4.3.1 → thyra-4.3.3}/thyra/readers/imzml/header.py +0 -0
  101. {thyra-4.3.1 → thyra-4.3.3}/thyra/readers/imzml/imzml_reader.py +0 -0
  102. {thyra-4.3.1 → thyra-4.3.3}/thyra/readers/imzml/mobility_array.py +0 -0
  103. {thyra-4.3.1 → thyra-4.3.3}/thyra/readers/mzpeak/__init__.py +0 -0
  104. {thyra-4.3.1 → thyra-4.3.3}/thyra/readers/phi/__init__.py +0 -0
  105. {thyra-4.3.1 → thyra-4.3.3}/thyra/readers/phi/event_stream.py +0 -0
  106. {thyra-4.3.1 → thyra-4.3.3}/thyra/readers/phi/mass_axis.py +0 -0
  107. {thyra-4.3.1 → thyra-4.3.3}/thyra/readers/phi/phi_header.py +0 -0
  108. {thyra-4.3.1 → thyra-4.3.3}/thyra/readers/phi/phi_reader.py +0 -0
  109. {thyra-4.3.1 → thyra-4.3.3}/thyra/readers/waters/__init__.py +0 -0
  110. {thyra-4.3.1 → thyra-4.3.3}/thyra/readers/waters/imaging_grid.py +0 -0
  111. {thyra-4.3.1 → thyra-4.3.3}/thyra/readers/waters/instrument.py +0 -0
  112. {thyra-4.3.1 → thyra-4.3.3}/thyra/readers/waters/lib/MLReader.dll +0 -0
  113. {thyra-4.3.1 → thyra-4.3.3}/thyra/readers/waters/lib/MassLynxRaw.dll +0 -0
  114. {thyra-4.3.1 → thyra-4.3.3}/thyra/readers/waters/lib/libMLReader.so +0 -0
  115. {thyra-4.3.1 → thyra-4.3.3}/thyra/readers/waters/lib/libMassLynxRaw.so +0 -0
  116. {thyra-4.3.1 → thyra-4.3.3}/thyra/readers/waters/masslynx_lib.py +0 -0
  117. {thyra-4.3.1 → thyra-4.3.3}/thyra/readers/waters/waters_reader.py +0 -0
  118. {thyra-4.3.1 → thyra-4.3.3}/thyra/resampling/__init__.py +0 -0
  119. {thyra-4.3.1 → thyra-4.3.3}/thyra/resampling/axis_planner.py +0 -0
  120. {thyra-4.3.1 → thyra-4.3.3}/thyra/resampling/binning.py +0 -0
  121. {thyra-4.3.1 → thyra-4.3.3}/thyra/resampling/common_axis.py +0 -0
  122. {thyra-4.3.1 → thyra-4.3.3}/thyra/resampling/constants.py +0 -0
  123. {thyra-4.3.1 → thyra-4.3.3}/thyra/resampling/data_characteristics.py +0 -0
  124. {thyra-4.3.1 → thyra-4.3.3}/thyra/resampling/decision_tree.py +0 -0
  125. {thyra-4.3.1 → thyra-4.3.3}/thyra/resampling/gaps.py +0 -0
  126. {thyra-4.3.1 → thyra-4.3.3}/thyra/resampling/interpolation.py +0 -0
  127. {thyra-4.3.1 → thyra-4.3.3}/thyra/resampling/mass_axis/__init__.py +0 -0
  128. {thyra-4.3.1 → thyra-4.3.3}/thyra/resampling/mass_axis/base_generator.py +0 -0
  129. {thyra-4.3.1 → thyra-4.3.3}/thyra/resampling/mass_axis/fticr_generator.py +0 -0
  130. {thyra-4.3.1 → thyra-4.3.3}/thyra/resampling/mass_axis/linear_generator.py +0 -0
  131. {thyra-4.3.1 → thyra-4.3.3}/thyra/resampling/mass_axis/linear_tof_generator.py +0 -0
  132. {thyra-4.3.1 → thyra-4.3.3}/thyra/resampling/mass_axis/orbitrap_generator.py +0 -0
  133. {thyra-4.3.1 → thyra-4.3.3}/thyra/resampling/mass_axis/reflector_tof_generator.py +0 -0
  134. {thyra-4.3.1 → thyra-4.3.3}/thyra/resampling/mass_axis/tof_generator.py +0 -0
  135. {thyra-4.3.1 → thyra-4.3.3}/thyra/resampling/mobility_grid.py +0 -0
  136. {thyra-4.3.1 → thyra-4.3.3}/thyra/resampling/strategies/__init__.py +0 -0
  137. {thyra-4.3.1 → thyra-4.3.3}/thyra/resampling/strategies/base.py +0 -0
  138. {thyra-4.3.1 → thyra-4.3.3}/thyra/resampling/strategies/nearest_neighbor.py +0 -0
  139. {thyra-4.3.1 → thyra-4.3.3}/thyra/resampling/strategies/tic_preserving.py +0 -0
  140. {thyra-4.3.1 → thyra-4.3.3}/thyra/resampling/tic.py +0 -0
  141. {thyra-4.3.1 → thyra-4.3.3}/thyra/resampling/types.py +0 -0
  142. {thyra-4.3.1 → thyra-4.3.3}/thyra/tools/__init__.py +0 -0
  143. {thyra-4.3.1 → thyra-4.3.3}/thyra/tools/check_ontology.py +0 -0
  144. {thyra-4.3.1 → thyra-4.3.3}/thyra/tools/make_example_data.py +0 -0
  145. {thyra-4.3.1 → thyra-4.3.3}/thyra/utils/__init__.py +0 -0
  146. {thyra-4.3.1 → thyra-4.3.3}/thyra/utils/bruker_exceptions.py +0 -0
  147. {thyra-4.3.1 → thyra-4.3.3}/thyra/utils/logging_config.py +0 -0
  148. {thyra-4.3.1 → thyra-4.3.3}/thyra/utils/pyimzml_direct.py +0 -0
  149. {thyra-4.3.1 → thyra-4.3.3}/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.3
4
4
  Summary: A modern Python library for converting Mass Spectrometry Imaging (MSI) data into SpatialData/Zarr format - your portal to spatial omics
5
5
  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.3"
8
8
  description = "A modern Python library for converting Mass Spectrometry Imaging (MSI) data into SpatialData/Zarr format - your portal to spatial omics"
9
9
  authors = [{ 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.3"
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")
@@ -126,7 +126,7 @@ from ._chunking import image_chunks
126
126
 
127
127
  if TYPE_CHECKING: # pragma: no cover - typing only
128
128
  from ...alignment.teaching_points import RegionMapping
129
- from ...core.base_reader import BaseMSIReader
129
+ from ...core.base_reader import BaseMSIReader, OpticalImageLabel
130
130
 
131
131
  logger = logging.getLogger(__name__)
132
132
 
@@ -944,6 +944,11 @@ def _calc_optical_scale_factors(
944
944
  return factors
945
945
 
946
946
 
947
+ def _clean_stem(stem: str) -> str:
948
+ """A file stem made fit for an element name: lowercase, at most 30 long."""
949
+ return stem.lower().replace(" ", "_").replace("-", "_")[:30]
950
+
951
+
947
952
  class OpticalImages:
948
953
  """The optical images of one conversion: alignment, declaration, streaming.
949
954
 
@@ -1459,9 +1464,11 @@ class OpticalImages:
1459
1464
  images: The store's images, to add the placeholder to
1460
1465
  """
1461
1466
  # Generate a clean name for the image layer
1462
- image_name = self._element_name(image_path)
1467
+ label = self._reader_label(image_path)
1468
+ image_name = self._element_name(image_path, label)
1469
+ source_file = label.source_file if label is not None else image_path.name
1463
1470
 
1464
- logger.info(f"Loading optical image: {image_path.name} as '{image_name}'")
1471
+ logger.info(f"Loading optical image: {source_file} as '{image_name}'")
1465
1472
 
1466
1473
  # Only the page header is read here. The pixels never enter this
1467
1474
  # process whole: the element is declared to SpatialData as a lazy
@@ -1584,8 +1591,10 @@ class OpticalImages:
1584
1591
  # rewrites the stem (_0000 -> highres, and a stem over 30
1585
1592
  # characters is truncated), and the alignment image is only
1586
1593
  # distinguishable by its transform, and only when the alignment
1587
- # was applied. Recorded here, written by the root attrs.
1588
- self._sources[image_name] = image_path.name
1594
+ # was applied. Recorded here, written by the root attrs. For an
1595
+ # image a reader copied out of a container, the member it came
1596
+ # from, not the copy.
1597
+ self._sources[image_name] = source_file
1589
1598
  if is_primary:
1590
1599
  self._alignment_element = image_name
1591
1600
 
@@ -1688,18 +1697,39 @@ class OpticalImages:
1688
1697
  "global": {**systems["global"], "reference_element": None},
1689
1698
  }
1690
1699
 
1691
- def _element_name(self, image_path: Path) -> str:
1700
+ def _reader_label(self, image_path: Path) -> Optional[OpticalImageLabel]:
1701
+ """The reader's name for an image it copied out of its source.
1702
+
1703
+ Asked for with ``getattr`` for the reason
1704
+ :meth:`_adopt_reader_primary_optical` gives: a reader here need not
1705
+ inherit from ``BaseMSIReader``.
1706
+ """
1707
+ resolve = getattr(self.reader, "get_optical_image_label", None)
1708
+ if not callable(resolve):
1709
+ return None
1710
+ label: Optional[OpticalImageLabel] = resolve(image_path)
1711
+ return label
1712
+
1713
+ def _element_name(
1714
+ self, image_path: Path, label: Optional[OpticalImageLabel] = None
1715
+ ) -> str:
1692
1716
  """Generate a clean name for an optical image layer.
1693
1717
 
1694
1718
  The suffix is dropped, so the same acquisition exported as a .tif or
1695
- a .jpg lands under the same element name.
1719
+ a .jpg lands under the same element name. The ``_0000`` / ``_0001``
1720
+ rule is for files named by the acquisition software; an image a
1721
+ reader copied out of a container is named by the reader's label.
1696
1722
 
1697
1723
  Args:
1698
1724
  image_path: Path to the optical image
1725
+ label: The reader's name for the image, if it gave one.
1699
1726
 
1700
1727
  Returns:
1701
1728
  Clean name for the image layer (e.g., 'optical_0000', 'optical_deriv')
1702
1729
  """
1730
+ if label is not None:
1731
+ return f"{self.dataset_id}_optical_{_clean_stem(label.name)}"
1732
+
1703
1733
  stem = image_path.stem.lower()
1704
1734
 
1705
1735
  # Extract meaningful suffix from filename
@@ -1710,11 +1740,7 @@ class OpticalImages:
1710
1740
  elif "deriv" in stem:
1711
1741
  suffix = "overview"
1712
1742
  else:
1713
- # Use stem with special chars replaced
1714
- suffix = stem.replace(" ", "_").replace("-", "_")
1715
- # Truncate if too long
1716
- if len(suffix) > 30:
1717
- suffix = suffix[:30]
1743
+ suffix = _clean_stem(stem)
1718
1744
 
1719
1745
  return f"{self.dataset_id}_optical_{suffix}"
1720
1746
 
@@ -52,7 +52,16 @@ for all seven readers at once, so the next change to it is a visible one.
52
52
  import logging
53
53
  from abc import ABC, abstractmethod
54
54
  from pathlib import Path
55
- from typing import TYPE_CHECKING, Dict, Generator, List, Optional, Tuple, Union
55
+ from typing import (
56
+ TYPE_CHECKING,
57
+ Dict,
58
+ Generator,
59
+ List,
60
+ NamedTuple,
61
+ Optional,
62
+ Tuple,
63
+ Union,
64
+ )
56
65
 
57
66
  import numpy as np
58
67
  from numpy.typing import NDArray
@@ -70,6 +79,22 @@ from .mass_axis import validate_max_mass_axis_length
70
79
  logger = logging.getLogger(__name__)
71
80
 
72
81
 
82
+ class OpticalImageLabel(NamedTuple):
83
+ """What a store calls an optical image whose file name is not the source's.
84
+
85
+ A reader that copies images out of a container hands the converter files
86
+ it named itself. Their names say nothing, and the name rule written for
87
+ vendor folders (``_0000`` is the high resolution scan) would read meaning
88
+ into them.
89
+ """
90
+
91
+ name: str
92
+ """The element name after ``<dataset_id>_optical_``."""
93
+
94
+ source_file: str
95
+ """What the store records the element came from."""
96
+
97
+
73
98
  class BaseMSIReader(ABC):
74
99
  """Abstract base class for reading MSI data formats."""
75
100
 
@@ -169,6 +194,22 @@ class BaseMSIReader(ABC):
169
194
  """
170
195
  return None
171
196
 
197
+ def get_optical_image_label(self, path: Path) -> Optional[OpticalImageLabel]:
198
+ """Name an optical image this reader copied out of its source.
199
+
200
+ Default implementation returns None: the file is the source's own,
201
+ and its name is what the store is named after. Override alongside
202
+ :meth:`get_optical_image_paths` when the reader names the files.
203
+
204
+ Args:
205
+ path: One of the paths :meth:`get_optical_image_paths` returned.
206
+
207
+ Returns:
208
+ The element name and the source file to record, or None to name
209
+ the element after the file.
210
+ """
211
+ return None
212
+
172
213
  @abstractmethod
173
214
  def get_common_mass_axis(self) -> NDArray[np.float64]:
174
215
  """Return the common mass axis for all spectra.
@@ -10,7 +10,7 @@ from pyimzml.ImzMLParser import ImzMLParser
10
10
 
11
11
  from ...core.base_extractor import MetadataExtractor
12
12
  from ...errors import ConversionRefused
13
- from ...utils.imzml_coordinate_base import coordinate_bases
13
+ from ...utils.imzml_coordinate_base import coordinate_bases, recorded_offsets
14
14
  from ...utils.pyimzml_direct import read_spectrum_mzs_only
15
15
  from ..constants import (
16
16
  BinaryDataType,
@@ -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
 
@@ -220,8 +352,11 @@ class ImzMLMetadataExtractor(MetadataExtractor):
220
352
  # store can say where its origin came from: the converter
221
353
  # writes it to coordinate_systems.global.coordinate_offsets_px.
222
354
  # (1, 1, 1) for an ordinary 1-based file, (0, 0, ...) for a
223
- # 0-based one -- see _coordinate_bases() (issue #244).
224
- coordinate_offsets=self._coordinate_bases(coords),
355
+ # 0-based one -- see _coordinate_bases() (issue #244). z is 0
356
+ # when the file states none (D30).
357
+ coordinate_offsets=recorded_offsets(
358
+ self._coordinate_bases(coords), coords[:, 2], self.imzml_path
359
+ ),
225
360
  spectrum_type=spectrum_type,
226
361
  )
227
362
 
@@ -824,29 +959,12 @@ class ImzMLMetadataExtractor(MetadataExtractor):
824
959
  chain matches on (``"FT-ICR"`` / ``"Orbitrap"``), when the analyzer
825
960
  or the model resolves to a family the chain distinguishes. Absent
826
961
  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
-
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
962
 
849
- return info
963
+ The rules live in :func:`instrument_info_from_configurations`, which
964
+ the mzPeak extractor calls too, so an archive and the imzML it was
965
+ made from resolve to the same instrument.
966
+ """
967
+ return instrument_info_from_configurations(self._instrument_configurations())
850
968
 
851
969
  def _instrument_configurations(self) -> List[Any]:
852
970
  """The parsed instrumentConfiguration ParamGroups, in document order."""
@@ -856,100 +974,6 @@ class ImzMLMetadataExtractor(MetadataExtractor):
856
974
  return []
857
975
  return list(configs.values())
858
976
 
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
977
  def _extract_raw_metadata(self) -> Dict[str, Any]:
954
978
  """Extract raw metadata from imzmldict and spectrum cvParams."""
955
979
  raw_metadata = {}