thyra 2.2.4__tar.gz → 2.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. {thyra-2.2.4 → thyra-2.3.0}/PKG-INFO +1 -1
  2. {thyra-2.2.4 → thyra-2.3.0}/pyproject.toml +1 -1
  3. {thyra-2.2.4 → thyra-2.3.0}/thyra/__init__.py +1 -1
  4. {thyra-2.2.4 → thyra-2.3.0}/thyra/__main__.py +28 -4
  5. {thyra-2.2.4 → thyra-2.3.0}/thyra/convert.py +11 -0
  6. {thyra-2.2.4 → thyra-2.3.0}/thyra/converters/spatialdata/base_spatialdata_converter.py +74 -46
  7. {thyra-2.2.4 → thyra-2.3.0}/thyra/converters/spatialdata/spatialdata_3d_converter.py +5 -0
  8. {thyra-2.2.4 → thyra-2.3.0}/thyra/converters/spatialdata/streaming_converter.py +22 -34
  9. {thyra-2.2.4 → thyra-2.3.0}/thyra/metadata/extractors/imzml_extractor.py +75 -4
  10. {thyra-2.2.4 → thyra-2.3.0}/thyra/readers/imzml/imzml_reader.py +17 -1
  11. thyra-2.3.0/thyra/resampling/constants.py +91 -0
  12. thyra-2.2.4/thyra/resampling/constants.py +0 -44
  13. {thyra-2.2.4 → thyra-2.3.0}/LICENSE +0 -0
  14. {thyra-2.2.4 → thyra-2.3.0}/README.md +0 -0
  15. {thyra-2.2.4 → thyra-2.3.0}/thyra/alignment/__init__.py +0 -0
  16. {thyra-2.2.4 → thyra-2.3.0}/thyra/alignment/affine.py +0 -0
  17. {thyra-2.2.4 → thyra-2.3.0}/thyra/alignment/teaching_points.py +0 -0
  18. {thyra-2.2.4 → thyra-2.3.0}/thyra/config.py +0 -0
  19. {thyra-2.2.4 → thyra-2.3.0}/thyra/converters/__init__.py +0 -0
  20. {thyra-2.2.4 → thyra-2.3.0}/thyra/converters/spatialdata/__init__.py +0 -0
  21. {thyra-2.2.4 → thyra-2.3.0}/thyra/converters/spatialdata/_chunking.py +0 -0
  22. {thyra-2.2.4 → thyra-2.3.0}/thyra/converters/spatialdata/converter.py +0 -0
  23. {thyra-2.2.4 → thyra-2.3.0}/thyra/converters/spatialdata/spatialdata_2d_converter.py +0 -0
  24. {thyra-2.2.4 → thyra-2.3.0}/thyra/core/__init__.py +0 -0
  25. {thyra-2.2.4 → thyra-2.3.0}/thyra/core/base_converter.py +0 -0
  26. {thyra-2.2.4 → thyra-2.3.0}/thyra/core/base_extractor.py +0 -0
  27. {thyra-2.2.4 → thyra-2.3.0}/thyra/core/base_reader.py +0 -0
  28. {thyra-2.2.4 → thyra-2.3.0}/thyra/core/registry.py +0 -0
  29. {thyra-2.2.4 → thyra-2.3.0}/thyra/metadata/__init__.py +0 -0
  30. {thyra-2.2.4 → thyra-2.3.0}/thyra/metadata/extractors/__init__.py +0 -0
  31. {thyra-2.2.4 → thyra-2.3.0}/thyra/metadata/extractors/bruker_extractor.py +0 -0
  32. {thyra-2.2.4 → thyra-2.3.0}/thyra/metadata/extractors/waters_extractor.py +0 -0
  33. {thyra-2.2.4 → thyra-2.3.0}/thyra/metadata/ontology/__init__.py +0 -0
  34. {thyra-2.2.4 → thyra-2.3.0}/thyra/metadata/ontology/_ims.py +0 -0
  35. {thyra-2.2.4 → thyra-2.3.0}/thyra/metadata/ontology/_ms.py +0 -0
  36. {thyra-2.2.4 → thyra-2.3.0}/thyra/metadata/ontology/_uo.py +0 -0
  37. {thyra-2.2.4 → thyra-2.3.0}/thyra/metadata/ontology/cache.py +0 -0
  38. {thyra-2.2.4 → thyra-2.3.0}/thyra/metadata/types.py +0 -0
  39. {thyra-2.2.4 → thyra-2.3.0}/thyra/metadata/validator.py +0 -0
  40. {thyra-2.2.4 → thyra-2.3.0}/thyra/preview.py +0 -0
  41. {thyra-2.2.4 → thyra-2.3.0}/thyra/readers/__init__.py +0 -0
  42. {thyra-2.2.4 → thyra-2.3.0}/thyra/readers/bruker/__init__.py +0 -0
  43. {thyra-2.2.4 → thyra-2.3.0}/thyra/readers/bruker/base_bruker_reader.py +0 -0
  44. {thyra-2.2.4 → thyra-2.3.0}/thyra/readers/bruker/folder_structure.py +0 -0
  45. {thyra-2.2.4 → thyra-2.3.0}/thyra/readers/bruker/mis_parser.py +0 -0
  46. {thyra-2.2.4 → thyra-2.3.0}/thyra/readers/bruker/rapiflex/__init__.py +0 -0
  47. {thyra-2.2.4 → thyra-2.3.0}/thyra/readers/bruker/rapiflex/rapiflex_reader.py +0 -0
  48. {thyra-2.2.4 → thyra-2.3.0}/thyra/readers/bruker/timstof/__init__.py +0 -0
  49. {thyra-2.2.4 → thyra-2.3.0}/thyra/readers/bruker/timstof/sdk/__init__.py +0 -0
  50. {thyra-2.2.4 → thyra-2.3.0}/thyra/readers/bruker/timstof/sdk/dll/LICENCE-BRUKER.txt +0 -0
  51. {thyra-2.2.4 → thyra-2.3.0}/thyra/readers/bruker/timstof/sdk/dll/README.md +0 -0
  52. {thyra-2.2.4 → thyra-2.3.0}/thyra/readers/bruker/timstof/sdk/dll/timsdata.dll +0 -0
  53. {thyra-2.2.4 → thyra-2.3.0}/thyra/readers/bruker/timstof/sdk/dll/timsdata.so +0 -0
  54. {thyra-2.2.4 → thyra-2.3.0}/thyra/readers/bruker/timstof/sdk/dll_manager.py +0 -0
  55. {thyra-2.2.4 → thyra-2.3.0}/thyra/readers/bruker/timstof/sdk/platform_detector.py +0 -0
  56. {thyra-2.2.4 → thyra-2.3.0}/thyra/readers/bruker/timstof/sdk/sdk_functions.py +0 -0
  57. {thyra-2.2.4 → thyra-2.3.0}/thyra/readers/bruker/timstof/timstof_reader.py +0 -0
  58. {thyra-2.2.4 → thyra-2.3.0}/thyra/readers/bruker/timstof/utils/__init__.py +0 -0
  59. {thyra-2.2.4 → thyra-2.3.0}/thyra/readers/bruker/timstof/utils/batch_processor.py +0 -0
  60. {thyra-2.2.4 → thyra-2.3.0}/thyra/readers/bruker/timstof/utils/coordinate_cache.py +0 -0
  61. {thyra-2.2.4 → thyra-2.3.0}/thyra/readers/bruker/timstof/utils/mass_axis_builder.py +0 -0
  62. {thyra-2.2.4 → thyra-2.3.0}/thyra/readers/bruker/timstof/utils/memory_manager.py +0 -0
  63. {thyra-2.2.4 → thyra-2.3.0}/thyra/readers/imzml/__init__.py +0 -0
  64. {thyra-2.2.4 → thyra-2.3.0}/thyra/readers/waters/__init__.py +0 -0
  65. {thyra-2.2.4 → thyra-2.3.0}/thyra/readers/waters/imaging_grid.py +0 -0
  66. {thyra-2.2.4 → thyra-2.3.0}/thyra/readers/waters/lib/MLReader.dll +0 -0
  67. {thyra-2.2.4 → thyra-2.3.0}/thyra/readers/waters/lib/MassLynxRaw.dll +0 -0
  68. {thyra-2.2.4 → thyra-2.3.0}/thyra/readers/waters/lib/libMLReader.so +0 -0
  69. {thyra-2.2.4 → thyra-2.3.0}/thyra/readers/waters/lib/libMassLynxRaw.so +0 -0
  70. {thyra-2.2.4 → thyra-2.3.0}/thyra/readers/waters/masslynx_lib.py +0 -0
  71. {thyra-2.2.4 → thyra-2.3.0}/thyra/readers/waters/waters_reader.py +0 -0
  72. {thyra-2.2.4 → thyra-2.3.0}/thyra/resampling/__init__.py +0 -0
  73. {thyra-2.2.4 → thyra-2.3.0}/thyra/resampling/common_axis.py +0 -0
  74. {thyra-2.2.4 → thyra-2.3.0}/thyra/resampling/data_characteristics.py +0 -0
  75. {thyra-2.2.4 → thyra-2.3.0}/thyra/resampling/decision_tree.py +0 -0
  76. {thyra-2.2.4 → thyra-2.3.0}/thyra/resampling/gaps.py +0 -0
  77. {thyra-2.2.4 → thyra-2.3.0}/thyra/resampling/instrument_detectors.py +0 -0
  78. {thyra-2.2.4 → thyra-2.3.0}/thyra/resampling/mass_axis/__init__.py +0 -0
  79. {thyra-2.2.4 → thyra-2.3.0}/thyra/resampling/mass_axis/base_generator.py +0 -0
  80. {thyra-2.2.4 → thyra-2.3.0}/thyra/resampling/mass_axis/fticr_generator.py +0 -0
  81. {thyra-2.2.4 → thyra-2.3.0}/thyra/resampling/mass_axis/linear_generator.py +0 -0
  82. {thyra-2.2.4 → thyra-2.3.0}/thyra/resampling/mass_axis/linear_tof_generator.py +0 -0
  83. {thyra-2.2.4 → thyra-2.3.0}/thyra/resampling/mass_axis/orbitrap_generator.py +0 -0
  84. {thyra-2.2.4 → thyra-2.3.0}/thyra/resampling/mass_axis/reflector_tof_generator.py +0 -0
  85. {thyra-2.2.4 → thyra-2.3.0}/thyra/resampling/strategies/__init__.py +0 -0
  86. {thyra-2.2.4 → thyra-2.3.0}/thyra/resampling/strategies/base.py +0 -0
  87. {thyra-2.2.4 → thyra-2.3.0}/thyra/resampling/strategies/nearest_neighbor.py +0 -0
  88. {thyra-2.2.4 → thyra-2.3.0}/thyra/resampling/strategies/tic_preserving.py +0 -0
  89. {thyra-2.2.4 → thyra-2.3.0}/thyra/resampling/tic.py +0 -0
  90. {thyra-2.2.4 → thyra-2.3.0}/thyra/resampling/types.py +0 -0
  91. {thyra-2.2.4 → thyra-2.3.0}/thyra/tools/__init__.py +0 -0
  92. {thyra-2.2.4 → thyra-2.3.0}/thyra/tools/check_ontology.py +0 -0
  93. {thyra-2.2.4 → thyra-2.3.0}/thyra/tools/make_example_data.py +0 -0
  94. {thyra-2.2.4 → thyra-2.3.0}/thyra/utils/__init__.py +0 -0
  95. {thyra-2.2.4 → thyra-2.3.0}/thyra/utils/bruker_exceptions.py +0 -0
  96. {thyra-2.2.4 → thyra-2.3.0}/thyra/utils/logging_config.py +0 -0
  97. {thyra-2.2.4 → thyra-2.3.0}/thyra/utils/windows_paths.py +0 -0
  98. {thyra-2.2.4 → thyra-2.3.0}/thyra/utils/zarr_atomic_write.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: thyra
3
- Version: 2.2.4
3
+ Version: 2.3.0
4
4
  Summary: A modern Python library for converting Mass Spectrometry Imaging (MSI) data into SpatialData/Zarr format - your portal to spatial omics
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [tool.poetry]
6
6
  name = "thyra"
7
- version = "2.2.4"
7
+ version = "2.3.0"
8
8
  description = "A modern Python library for converting Mass Spectrometry Imaging (MSI) data into SpatialData/Zarr format - your portal to spatial omics"
9
9
  authors = ["Theodoros Visvikis <t.visvikis@maastrichtuniversity.nl>"]
10
10
  maintainers = ["Theodoros Visvikis <t.visvikis@maastrichtuniversity.nl>"]
@@ -36,7 +36,7 @@ warnings.filterwarnings(
36
36
  category=FutureWarning,
37
37
  )
38
38
 
39
- __version__ = "2.2.4"
39
+ __version__ = "2.3.0"
40
40
 
41
41
  # Import key components - avoid wildcard imports
42
42
  try:
@@ -261,11 +261,21 @@ def _build_resampling_config(
261
261
  def _build_reader_options(
262
262
  use_recalibrated: bool,
263
263
  intensity_threshold: Optional[float],
264
- ) -> dict[str, bool | float]:
265
- """Build reader options dictionary from CLI parameters."""
266
- options: dict[str, bool | float] = {"use_recalibrated_state": use_recalibrated}
264
+ spectrum_type: str = "auto",
265
+ ) -> dict[str, bool | float | str]:
266
+ """Build reader options dictionary from CLI parameters.
267
+
268
+ ``spectrum_type="auto"`` is the CLI's way of saying "no override", so it is
269
+ omitted entirely rather than forwarded -- readers take ``None``/absent to
270
+ mean detect, and ``"auto"`` is not a representation.
271
+ """
272
+ options: dict[str, bool | float | str] = {
273
+ "use_recalibrated_state": use_recalibrated
274
+ }
267
275
  if intensity_threshold is not None:
268
276
  options["intensity_threshold"] = intensity_threshold
277
+ if spectrum_type != "auto":
278
+ options["spectrum_type"] = spectrum_type
269
279
  return options
270
280
 
271
281
 
@@ -548,6 +558,17 @@ class GroupedCommand(click.Command):
548
558
  "(default: no limit)"
549
559
  ),
550
560
  )
561
+ @click.option(
562
+ "--spectrum-type",
563
+ type=click.Choice(["auto", "profile", "centroid"]),
564
+ default="auto",
565
+ help=(
566
+ "Spectrum representation (default: auto-detect from the file). "
567
+ "Overrides what the imzML declares via MS:1000127/MS:1000128; "
568
+ "contradicting a declaration is logged as a warning. SCiLS Lab spells "
569
+ "this --rep_type. imzML only."
570
+ ),
571
+ )
551
572
  # -- Bruker-specific --
552
573
  @click.option(
553
574
  "--use-recalibrated/--no-recalibrated",
@@ -597,6 +618,7 @@ def main(
597
618
  resample_reference_mz: float,
598
619
  resample_gap_tolerance: Optional[float],
599
620
  mass_axis_type: str,
621
+ spectrum_type: str,
600
622
  sparse_format: str,
601
623
  include_optical: bool,
602
624
  intensity_threshold: Optional[float],
@@ -664,7 +686,9 @@ def main(
664
686
  )
665
687
 
666
688
  # Build reader options for format-specific settings
667
- reader_options = _build_reader_options(use_recalibrated, intensity_threshold)
689
+ reader_options = _build_reader_options(
690
+ use_recalibrated, intensity_threshold, spectrum_type
691
+ )
668
692
 
669
693
  # Perform conversion
670
694
  success = convert_msi(
@@ -290,6 +290,17 @@ def convert_msi(
290
290
  which is the limit SCiLS Lab places on the same
291
291
  quantity (2026b User Guide, p.76). Pass None for
292
292
  no limit.
293
+ - spectrum_type: str - For imzML, declare the spectrum
294
+ representation explicitly: 'profile' or 'centroid'
295
+ (the full CV names are accepted too). Outranks the
296
+ file's own MS:1000127/MS:1000128, so it can correct a
297
+ file that declares the wrong thing; contradicting a
298
+ declaration is logged as a warning. SCiLS Lab spells
299
+ this --rep_type (2026b User Guide, p.81). Default:
300
+ None, meaning detect. **Changes stored values** for
301
+ files where it disagrees with what detection would
302
+ have chosen, because the representation feeds
303
+ instrument and axis-type selection.
293
304
  sparse_format: Sparse matrix format ('csc' or 'csr')
294
305
  include_optical: Include optical images (default: True)
295
306
  apply_optical_alignment: If True (default) and the MSI source
@@ -494,56 +494,72 @@ class BaseSpatialDataConverter(BaseMSIConverter, ABC):
494
494
  except Exception as e:
495
495
  logger.debug(f"Could not extract spectrum metadata: {e}")
496
496
 
497
- def _add_metadata_to_uns(self, adata) -> None:
498
- """Add MSI metadata to AnnData .uns for preservation in SpatialData.
499
-
500
- This stores comprehensive metadata including:
501
- - Essential metadata (dimensions, mass range, source)
502
- - Format-specific metadata (FlexImaging areas, teaching points, etc.)
503
- - Acquisition parameters
504
- - Instrument information
505
- - Raw metadata (complete original metadata for future use)
497
+ def build_uns_metadata(self) -> Dict[str, Any]:
498
+ """The provenance block every write path must persist, identically.
499
+
500
+ Single source of truth for what lands in the table's ``uns``:
501
+
502
+ - ``essential_metadata`` -- dimensions, mass range, source path,
503
+ spectrum type and the Thyra version that wrote the store.
504
+ - ``format_specific`` -- vendor metadata (FlexImaging areas,
505
+ teaching points, imzML file mode, ...).
506
+ - ``acquisition_params`` / ``instrument_info`` -- when the reader
507
+ has them.
508
+ - ``raw_metadata`` -- the source metadata as read.
509
+ - ``regions`` -- the acquisition region summary, as JSON.
510
+
511
+ This exists because the converters have two write paths and they
512
+ drifted. The in-memory and streaming-COO paths hand the table to
513
+ ``anndata``'s writer, which serialises whatever is in
514
+ ``adata.uns``; the streaming-PCS path hand-writes the Zarr layout
515
+ and used to compose its own, much smaller block -- with
516
+ ``spectrum_type`` hardcoded to ``"processed"``, which is not even
517
+ a value the extractors produce. A dataset that crossed
518
+ ``StreamingSpatialDataConverter.PCS_SIZE_THRESHOLD_GB`` therefore
519
+ came out claiming a spectrum representation it did not have, and
520
+ without any of the other sections, while a slightly smaller one
521
+ from the same instrument came out complete. Both paths now render
522
+ this mapping, so a section added here reaches every store.
523
+
524
+ Sections the reader has nothing for are omitted rather than
525
+ written empty, so consumers can tell "not available from this
526
+ format" from "available and empty".
527
+
528
+ Returns:
529
+ Mapping of ``uns`` key to the value to store. Empty if the
530
+ reader cannot produce comprehensive metadata at all.
506
531
  """
507
532
  try:
508
533
  comp_meta = self.reader.get_comprehensive_metadata()
534
+ except Exception as e:
535
+ # Non-fatal: a store without provenance still holds the
536
+ # spectra. But it is not a debug-level event -- the whole
537
+ # point of the block is that a consumer can say where the
538
+ # data came from, so losing it has to be visible in the log.
539
+ logger.warning("Could not read metadata for uns provenance: %s", e)
540
+ return {}
541
+
542
+ uns: Dict[str, Any] = {}
543
+ try:
544
+ self._collect_essential_metadata(uns, comp_meta)
545
+ self._collect_optional_sections(uns, comp_meta)
546
+ self._collect_region_info(uns)
547
+ except Exception as e:
548
+ logger.warning("Could not build the full uns provenance block: %s", e)
509
549
 
510
- self._store_format_specific(adata, comp_meta)
511
- self._store_acquisition_params(adata, comp_meta)
512
- self._store_instrument_info(adata, comp_meta)
513
- self._store_essential_metadata(adata, comp_meta)
514
- self._store_raw_metadata(adata, comp_meta)
515
- self._store_region_info(adata)
550
+ return uns
516
551
 
517
- logger.debug("Added MSI metadata to AnnData .uns")
518
- except Exception as e:
519
- logger.debug(f"Could not add metadata to .uns: {e}")
520
-
521
- def _store_format_specific(self, adata, comp_meta) -> None:
522
- """Store format-specific metadata (FlexImaging areas, teaching points)."""
523
- if hasattr(comp_meta, "format_specific") and comp_meta.format_specific:
524
- adata.uns["format_specific"] = comp_meta.format_specific
525
-
526
- def _store_acquisition_params(self, adata, comp_meta) -> None:
527
- """Store acquisition parameters."""
528
- if hasattr(comp_meta, "acquisition_params") and comp_meta.acquisition_params:
529
- adata.uns["acquisition_params"] = comp_meta.acquisition_params
530
-
531
- def _store_instrument_info(self, adata, comp_meta) -> None:
532
- """Store instrument information."""
533
- if hasattr(comp_meta, "instrument_info") and comp_meta.instrument_info:
534
- adata.uns["instrument_info"] = comp_meta.instrument_info
535
-
536
- def _store_essential_metadata(self, adata, comp_meta) -> None:
537
- """Store essential metadata (convert tuples to lists for Zarr)."""
538
- if not hasattr(comp_meta, "essential"):
552
+ def _collect_essential_metadata(self, uns: Dict[str, Any], comp_meta: Any) -> None:
553
+ """Add ``essential_metadata`` (tuples become lists for Zarr)."""
554
+ essential = getattr(comp_meta, "essential", None)
555
+ if essential is None:
539
556
  return
540
557
 
541
- essential = comp_meta.essential
542
558
  dims = essential.dimensions
543
559
  mrange = essential.mass_range
544
560
  from thyra import __version__
545
561
 
546
- adata.uns["essential_metadata"] = {
562
+ uns["essential_metadata"] = {
547
563
  "source_path": str(essential.source_path),
548
564
  "dimensions": list(dims) if dims else None,
549
565
  "mass_range": list(mrange) if mrange else None,
@@ -551,13 +567,19 @@ class BaseSpatialDataConverter(BaseMSIConverter, ABC):
551
567
  "thyra_version": __version__,
552
568
  }
553
569
 
554
- def _store_raw_metadata(self, adata, comp_meta) -> None:
555
- """Store raw metadata (complete original data for future use)."""
556
- if hasattr(comp_meta, "raw_metadata") and comp_meta.raw_metadata:
557
- adata.uns["raw_metadata"] = self._serialize_for_zarr(comp_meta.raw_metadata)
570
+ def _collect_optional_sections(self, uns: Dict[str, Any], comp_meta: Any) -> None:
571
+ """Add the vendor sections the reader actually populated."""
572
+ for key in ("format_specific", "acquisition_params", "instrument_info"):
573
+ value = getattr(comp_meta, key, None)
574
+ if value:
575
+ uns[key] = self._serialize_for_zarr(value)
576
+
577
+ raw_metadata = getattr(comp_meta, "raw_metadata", None)
578
+ if raw_metadata:
579
+ uns["raw_metadata"] = self._serialize_for_zarr(raw_metadata)
558
580
 
559
- def _store_region_info(self, adata) -> None:
560
- """Store acquisition region summary in uns as JSON.
581
+ def _collect_region_info(self, uns: Dict[str, Any]) -> None:
582
+ """Add the acquisition region summary as JSON.
561
583
 
562
584
  Stored as a JSON string because AnnData/zarr cannot round-trip
563
585
  a list of dicts (they get stringified individually). JSON
@@ -568,7 +590,13 @@ class BaseSpatialDataConverter(BaseMSIConverter, ABC):
568
590
  """
569
591
  region_info = getattr(self, "_region_info", None)
570
592
  if region_info:
571
- adata.uns["regions"] = json.dumps(self._serialize_for_zarr(region_info))
593
+ uns["regions"] = json.dumps(self._serialize_for_zarr(region_info))
594
+
595
+ def _add_metadata_to_uns(self, adata) -> None:
596
+ """Apply :meth:`build_uns_metadata` to an AnnData about to be written."""
597
+ uns = self.build_uns_metadata()
598
+ adata.uns.update(uns)
599
+ logger.debug("Added MSI metadata to AnnData .uns: %s", sorted(uns))
572
600
 
573
601
  def _serialize_for_zarr(self, obj):
574
602
  """Recursively convert tuples to lists for Zarr serialization."""
@@ -221,6 +221,11 @@ class SpatialData3DConverter(BaseSpatialDataConverter):
221
221
  per_region[str(r)] = total / max(count, 1)
222
222
  adata.uns["average_spectrum_per_region"] = per_region
223
223
 
224
+ # Add MSI metadata to .uns. The 2D and streaming paths have
225
+ # always done this; the 3D one never did, so a volume came
226
+ # out with no record of where it came from at all.
227
+ self._add_metadata_to_uns(adata)
228
+
224
229
  # Make sure region column exists and is correct
225
230
  region_key = f"{self.dataset_id}_pixels"
226
231
  if "region" not in adata.obs.columns:
@@ -1296,6 +1296,27 @@ class StreamingSpatialDataConverter(BaseSpatialDataConverter):
1296
1296
 
1297
1297
  return mm_indices, mm_data
1298
1298
 
1299
+ def _write_uns_provenance(self, uns_group: "zarr.Group") -> None:
1300
+ """Write the shared provenance block into a hand-written ``uns`` group.
1301
+
1302
+ This path composes the Zarr layout itself, so it cannot reach
1303
+ ``adata.uns`` the way ``_save_output`` does. It renders the same
1304
+ mapping through anndata's own element writer instead, which is
1305
+ what produces the ``encoding-type`` / ``encoding-version`` pairs
1306
+ ``read_lazy`` needs -- getting those right by hand is exactly
1307
+ what this path used to have to do, and exactly where it drifted
1308
+ from the other one.
1309
+
1310
+ Only ``uns`` goes through anndata here. ``obs``/``var`` stay
1311
+ hand-written: they carry the pandas string dtypes this path
1312
+ exists to sidestep, whereas the provenance block is plain dicts,
1313
+ strings, lists and numbers.
1314
+ """
1315
+ from anndata.io import write_elem
1316
+
1317
+ for key, value in self.build_uns_metadata().items():
1318
+ write_elem(uns_group, key, value)
1319
+
1299
1320
  def _write_csc_arrays_to_zarr(
1300
1321
  self,
1301
1322
  mm_indices: np.memmap,
@@ -1540,40 +1561,7 @@ class StreamingSpatialDataConverter(BaseSpatialDataConverter):
1540
1561
  a.attrs["encoding-type"] = "string"
1541
1562
  a.attrs["encoding-version"] = "0.2.0"
1542
1563
 
1543
- em_group = uns_group.create_group("essential_metadata")
1544
- em_group.attrs["encoding-type"] = "dict"
1545
- em_group.attrs["encoding-version"] = "0.1.0"
1546
- a = em_group.create_array(
1547
- "dimensions", data=np.asarray(np.array(self._dimensions))
1548
- )
1549
- a.attrs["encoding-type"] = "array"
1550
- a.attrs["encoding-version"] = "0.2.0"
1551
- a = em_group.create_array(
1552
- "mass_range",
1553
- data=np.array([mz_values.min(), mz_values.max()]),
1554
- )
1555
- a.attrs["encoding-type"] = "array"
1556
- a.attrs["encoding-version"] = "0.2.0"
1557
- a = em_group.create_array(
1558
- "source_path",
1559
- data=np.array(str(self.reader.data_path), dtype=str_dtype),
1560
- )
1561
- a.attrs["encoding-type"] = "string"
1562
- a.attrs["encoding-version"] = "0.2.0"
1563
- a = em_group.create_array(
1564
- "spectrum_type",
1565
- data=np.array("processed", dtype=str_dtype),
1566
- )
1567
- a.attrs["encoding-type"] = "string"
1568
- a.attrs["encoding-version"] = "0.2.0"
1569
- from thyra import __version__
1570
-
1571
- a = em_group.create_array(
1572
- "thyra_version",
1573
- data=np.array(__version__, dtype=str_dtype),
1574
- )
1575
- a.attrs["encoding-type"] = "string"
1576
- a.attrs["encoding-version"] = "0.2.0"
1564
+ self._write_uns_provenance(uns_group)
1577
1565
 
1578
1566
  a = uns_group.create_array("average_spectrum", data=avg_spectrum)
1579
1567
  a.attrs["encoding-type"] = "array"
@@ -9,7 +9,12 @@ from numpy.typing import NDArray
9
9
  from pyimzml.ImzMLParser import ImzMLParser
10
10
 
11
11
  from ...core.base_extractor import MetadataExtractor
12
- from ...resampling.constants import BinaryDataType, ImzMLAccessions, SpectrumType
12
+ from ...resampling.constants import (
13
+ BinaryDataType,
14
+ ImzMLAccessions,
15
+ SpectrumType,
16
+ normalize_spectrum_type,
17
+ )
13
18
  from ..types import ComprehensiveMetadata, EssentialMetadata
14
19
 
15
20
  logger = logging.getLogger(__name__)
@@ -18,16 +23,32 @@ logger = logging.getLogger(__name__)
18
23
  class ImzMLMetadataExtractor(MetadataExtractor):
19
24
  """ImzML-specific metadata extractor with optimized two-phase extraction."""
20
25
 
21
- def __init__(self, parser: ImzMLParser, imzml_path: Path):
26
+ def __init__(
27
+ self,
28
+ parser: ImzMLParser,
29
+ imzml_path: Path,
30
+ spectrum_type: Optional[str] = None,
31
+ ):
22
32
  """Initialize ImzML metadata extractor.
23
33
 
24
34
  Args:
25
35
  parser: Initialized ImzML parser
26
36
  imzml_path: Path to the ImzML file
37
+ spectrum_type: Optional override for the spectrum representation,
38
+ ``"profile"`` or ``"centroid"`` (SCiLS Lab spells this
39
+ ``--rep_type``). When given, it wins over anything the file
40
+ says. ``None`` -- the default -- leaves detection alone.
41
+
42
+ Raises:
43
+ ValueError: If ``spectrum_type`` is not a recognised
44
+ representation.
27
45
  """
28
46
  super().__init__(parser)
29
47
  self.parser = parser
30
48
  self.imzml_path = imzml_path
49
+ # Normalised at construction so a typo fails here rather than silently
50
+ # falling through to auto-detection during extraction.
51
+ self.spectrum_type_override = normalize_spectrum_type(spectrum_type)
31
52
 
32
53
  def _extract_essential_impl(self) -> EssentialMetadata:
33
54
  """Extract essential metadata optimized for speed."""
@@ -524,12 +545,19 @@ class ImzMLMetadataExtractor(MetadataExtractor):
524
545
  def _detect_centroid_spectrum(self) -> Optional[str]:
525
546
  """Detect spectrum type by looking for MS:1000127 (centroid) or MS:1000128 (profile).
526
547
 
527
- What the file *declares* wins, wherever it is written. Only when the
528
- file declares nothing does Thyra guess -- see
548
+ An explicit ``spectrum_type`` override wins over everything, so a user
549
+ can correct a file that declares the wrong thing. Otherwise what the
550
+ file *declares* wins, wherever it is written, and only when the file
551
+ declares nothing does Thyra guess -- see
529
552
  :meth:`_guess_spectrum_type_from_storage_mode` for what that guess is
530
553
  worth.
531
554
  """
532
555
  try:
556
+ # 0. An explicit override outranks the file.
557
+ if self.spectrum_type_override is not None:
558
+ self._report_spectrum_type_override()
559
+ return self.spectrum_type_override
560
+
533
561
  # 1. The declaration, from metadata the parser already holds.
534
562
  result = self._check_parser_metadata_for_spectrum_type()
535
563
  if result:
@@ -547,6 +575,49 @@ class ImzMLMetadataExtractor(MetadataExtractor):
547
575
  logger.debug(f"Could not detect spectrum type: {e}")
548
576
  return None
549
577
 
578
+ def _report_spectrum_type_override(self) -> None:
579
+ """Log an override, loudly when it contradicts the file's declaration.
580
+
581
+ Overriding a declared ``MS:1000127``/``MS:1000128`` is a legitimate
582
+ thing to want -- files do declare the wrong representation -- and it is
583
+ also a good way to corrupt a conversion silently. So the two cases are
584
+ not logged alike: agreeing with the file, or overriding a file that
585
+ declares nothing, is INFO; contradicting an explicit declaration is
586
+ WARNING and names both values.
587
+
588
+ This still reads the file's *declarations* (steps 1 and 2) in order to
589
+ compare, but never its guess -- there is nothing informative about
590
+ contradicting a guess. That costs at most the same bounded XML scan
591
+ detection would have done, and only on the opt-in path.
592
+ """
593
+ override = self.spectrum_type_override
594
+ declared = self._check_parser_metadata_for_spectrum_type()
595
+ if declared is None:
596
+ declared = self._check_xml_for_spectrum_type()
597
+
598
+ if declared is None:
599
+ logger.info(
600
+ "Using spectrum_type override %r; the file declares neither %s "
601
+ "(centroid) nor %s (profile).",
602
+ override,
603
+ ImzMLAccessions.CENTROID_SPECTRUM,
604
+ ImzMLAccessions.PROFILE_SPECTRUM,
605
+ )
606
+ elif declared == override:
607
+ logger.info(
608
+ "Using spectrum_type override %r, which agrees with the file.",
609
+ override,
610
+ )
611
+ else:
612
+ logger.warning(
613
+ "spectrum_type override %r CONTRADICTS the file, which declares "
614
+ "%r. Proceeding with the override -- the stored spectrum type, "
615
+ "and any decision Thyra makes from it, will not match what the "
616
+ "file says about itself. Remove the override to trust the file.",
617
+ override,
618
+ declared,
619
+ )
620
+
550
621
  def _check_xml_for_spectrum_type(self) -> Optional[str]:
551
622
  """Check XML for spectrum type markers using streaming parser.
552
623
 
@@ -12,6 +12,7 @@ from ...core.base_extractor import MetadataExtractor
12
12
  from ...core.base_reader import BaseMSIReader
13
13
  from ...core.registry import register_reader
14
14
  from ...metadata.extractors.imzml_extractor import ImzMLMetadataExtractor
15
+ from ...resampling.constants import normalize_spectrum_type
15
16
 
16
17
  logger = logging.getLogger(__name__)
17
18
 
@@ -351,6 +352,14 @@ class ImzMLReader(BaseMSIReader):
351
352
  ``DEFAULT_MAX_MASS_AXIS_LENGTH`` (10 million, SCiLS Lab's own
352
353
  limit); pass ``None`` explicitly for unlimited. See
353
354
  ``_extract_continuous_mass_axis``.
355
+
356
+ ``spectrum_type`` declares the spectrum representation
357
+ explicitly -- ``"profile"`` or ``"centroid"`` -- instead of
358
+ letting Thyra read or guess it. SCiLS Lab spells the same thing
359
+ ``--rep_type``. Use it when a file declares the wrong
360
+ representation; it outranks the file's own ``MS:1000127`` /
361
+ ``MS:1000128``, and contradicting a declaration is logged as a
362
+ warning. Defaults to ``None`` (detect).
354
363
  """
355
364
  super().__init__(data_path, **kwargs)
356
365
  self.filepath: Optional[Union[str, Path]] = data_path
@@ -361,6 +370,11 @@ class ImzMLReader(BaseMSIReader):
361
370
  self.max_mass_axis_length: Optional[int] = kwargs.get(
362
371
  "max_mass_axis_length", DEFAULT_MAX_MASS_AXIS_LENGTH
363
372
  )
373
+ # Validated here rather than at extraction time, so a bad value fails
374
+ # while the caller is still looking at its own arguments.
375
+ self.spectrum_type: Optional[str] = normalize_spectrum_type(
376
+ kwargs.get("spectrum_type")
377
+ )
364
378
  self.parser: Optional[ImzMLParser] = None
365
379
  self.ibd_file: Optional[Any] = None
366
380
  self.imzml_path: Optional[Path] = None
@@ -499,7 +513,9 @@ class ImzMLReader(BaseMSIReader):
499
513
  if not self.imzml_path:
500
514
  raise ValueError("ImzML path not available")
501
515
 
502
- return ImzMLMetadataExtractor(self.parser, self.imzml_path)
516
+ return ImzMLMetadataExtractor(
517
+ self.parser, self.imzml_path, spectrum_type=self.spectrum_type
518
+ )
503
519
 
504
520
  @property
505
521
  def has_shared_mass_axis(self) -> bool:
@@ -0,0 +1,91 @@
1
+ """Constants for resampling and instrument detection."""
2
+
3
+ from typing import Dict, Optional
4
+
5
+
6
+ class ImzMLAccessions:
7
+ """PSI-MS and imzML controlled vocabulary accession codes."""
8
+
9
+ # Spectrum type (MS ontology)
10
+ CENTROID_SPECTRUM = "MS:1000127"
11
+ PROFILE_SPECTRUM = "MS:1000128"
12
+
13
+ # Binary data type (imzML ontology)
14
+ CONTINUOUS_BINARY = "IMS:1000030"
15
+ PROCESSED_BINARY = "IMS:1000031"
16
+
17
+ # Software identifiers
18
+ SCILS_LAB = "MS:1002384"
19
+
20
+
21
+ class Thresholds:
22
+ """Threshold values for data classification."""
23
+
24
+ # Peak density threshold for detecting profile data
25
+ # Profile data typically has >5000 points per spectrum
26
+ PROFILE_PEAK_DENSITY = 5000
27
+
28
+ # Dataset size threshold for streaming converter (GB)
29
+ STREAMING_SIZE_GB = 10
30
+
31
+ # Dataset size threshold for PCS method in streaming converter (GB)
32
+ PCS_SIZE_GB = 30
33
+
34
+
35
+ class SpectrumType:
36
+ """Spectrum type string constants."""
37
+
38
+ CENTROID = "centroid spectrum"
39
+ PROFILE = "profile spectrum"
40
+
41
+
42
+ # What a caller may write for a spectrum representation, mapped to the CV name
43
+ # Thyra stores. The bare words match SCiLS Lab's ``--rep_type PROFILE|CENTROID``
44
+ # (2026b User Guide p.81); the full CV names are what MS:1000127 / MS:1000128
45
+ # are called, and are what comes back out of the store, so both are accepted.
46
+ SPECTRUM_TYPE_ALIASES: Dict[str, str] = {
47
+ "centroid": SpectrumType.CENTROID,
48
+ "centroided": SpectrumType.CENTROID,
49
+ "centroid spectrum": SpectrumType.CENTROID,
50
+ "profile": SpectrumType.PROFILE,
51
+ "profile spectrum": SpectrumType.PROFILE,
52
+ }
53
+
54
+
55
+ def normalize_spectrum_type(value: Optional[object]) -> Optional[str]:
56
+ """Map a user-supplied spectrum representation onto a :class:`SpectrumType`.
57
+
58
+ Args:
59
+ value: ``"profile"``, ``"centroid"``, either CV name, or ``None``.
60
+ Case and surrounding whitespace are ignored.
61
+
62
+ Returns:
63
+ The matching :class:`SpectrumType` constant, or ``None`` for ``None``.
64
+
65
+ Raises:
66
+ ValueError: If the value is not a recognised representation. A typo
67
+ here would otherwise be indistinguishable from "no override" and
68
+ would silently hand the file back to auto-detection, so this fails
69
+ loudly instead.
70
+ """
71
+ if value is None:
72
+ return None
73
+
74
+ if not isinstance(value, str):
75
+ raise ValueError(
76
+ f"spectrum_type must be a string or None, got {type(value).__name__}"
77
+ )
78
+
79
+ key = value.strip().lower()
80
+ if key in SPECTRUM_TYPE_ALIASES:
81
+ return SPECTRUM_TYPE_ALIASES[key]
82
+
83
+ accepted = ", ".join(repr(k) for k in sorted(SPECTRUM_TYPE_ALIASES))
84
+ raise ValueError(f"Unknown spectrum_type {value!r}. Accepted values: {accepted}.")
85
+
86
+
87
+ class BinaryDataType:
88
+ """Binary data type string constants."""
89
+
90
+ CONTINUOUS = "continuous"
91
+ PROCESSED = "processed"
@@ -1,44 +0,0 @@
1
- """Constants for resampling and instrument detection."""
2
-
3
-
4
- class ImzMLAccessions:
5
- """PSI-MS and imzML controlled vocabulary accession codes."""
6
-
7
- # Spectrum type (MS ontology)
8
- CENTROID_SPECTRUM = "MS:1000127"
9
- PROFILE_SPECTRUM = "MS:1000128"
10
-
11
- # Binary data type (imzML ontology)
12
- CONTINUOUS_BINARY = "IMS:1000030"
13
- PROCESSED_BINARY = "IMS:1000031"
14
-
15
- # Software identifiers
16
- SCILS_LAB = "MS:1002384"
17
-
18
-
19
- class Thresholds:
20
- """Threshold values for data classification."""
21
-
22
- # Peak density threshold for detecting profile data
23
- # Profile data typically has >5000 points per spectrum
24
- PROFILE_PEAK_DENSITY = 5000
25
-
26
- # Dataset size threshold for streaming converter (GB)
27
- STREAMING_SIZE_GB = 10
28
-
29
- # Dataset size threshold for PCS method in streaming converter (GB)
30
- PCS_SIZE_GB = 30
31
-
32
-
33
- class SpectrumType:
34
- """Spectrum type string constants."""
35
-
36
- CENTROID = "centroid spectrum"
37
- PROFILE = "profile spectrum"
38
-
39
-
40
- class BinaryDataType:
41
- """Binary data type string constants."""
42
-
43
- CONTINUOUS = "continuous"
44
- PROCESSED = "processed"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes