thyra 2.2.2__tar.gz → 2.2.4__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.2 → thyra-2.2.4}/PKG-INFO +2 -2
  2. {thyra-2.2.2 → thyra-2.2.4}/pyproject.toml +11 -2
  3. {thyra-2.2.2 → thyra-2.2.4}/thyra/__init__.py +1 -1
  4. thyra-2.2.4/thyra/converters/spatialdata/_chunking.py +89 -0
  5. {thyra-2.2.2 → thyra-2.2.4}/thyra/converters/spatialdata/base_spatialdata_converter.py +7 -3
  6. {thyra-2.2.2 → thyra-2.2.4}/thyra/metadata/extractors/imzml_extractor.py +84 -14
  7. thyra-2.2.2/thyra/converters/spatialdata/_chunking.py +0 -41
  8. {thyra-2.2.2 → thyra-2.2.4}/LICENSE +0 -0
  9. {thyra-2.2.2 → thyra-2.2.4}/README.md +0 -0
  10. {thyra-2.2.2 → thyra-2.2.4}/thyra/__main__.py +0 -0
  11. {thyra-2.2.2 → thyra-2.2.4}/thyra/alignment/__init__.py +0 -0
  12. {thyra-2.2.2 → thyra-2.2.4}/thyra/alignment/affine.py +0 -0
  13. {thyra-2.2.2 → thyra-2.2.4}/thyra/alignment/teaching_points.py +0 -0
  14. {thyra-2.2.2 → thyra-2.2.4}/thyra/config.py +0 -0
  15. {thyra-2.2.2 → thyra-2.2.4}/thyra/convert.py +0 -0
  16. {thyra-2.2.2 → thyra-2.2.4}/thyra/converters/__init__.py +0 -0
  17. {thyra-2.2.2 → thyra-2.2.4}/thyra/converters/spatialdata/__init__.py +0 -0
  18. {thyra-2.2.2 → thyra-2.2.4}/thyra/converters/spatialdata/converter.py +0 -0
  19. {thyra-2.2.2 → thyra-2.2.4}/thyra/converters/spatialdata/spatialdata_2d_converter.py +0 -0
  20. {thyra-2.2.2 → thyra-2.2.4}/thyra/converters/spatialdata/spatialdata_3d_converter.py +0 -0
  21. {thyra-2.2.2 → thyra-2.2.4}/thyra/converters/spatialdata/streaming_converter.py +0 -0
  22. {thyra-2.2.2 → thyra-2.2.4}/thyra/core/__init__.py +0 -0
  23. {thyra-2.2.2 → thyra-2.2.4}/thyra/core/base_converter.py +0 -0
  24. {thyra-2.2.2 → thyra-2.2.4}/thyra/core/base_extractor.py +0 -0
  25. {thyra-2.2.2 → thyra-2.2.4}/thyra/core/base_reader.py +0 -0
  26. {thyra-2.2.2 → thyra-2.2.4}/thyra/core/registry.py +0 -0
  27. {thyra-2.2.2 → thyra-2.2.4}/thyra/metadata/__init__.py +0 -0
  28. {thyra-2.2.2 → thyra-2.2.4}/thyra/metadata/extractors/__init__.py +0 -0
  29. {thyra-2.2.2 → thyra-2.2.4}/thyra/metadata/extractors/bruker_extractor.py +0 -0
  30. {thyra-2.2.2 → thyra-2.2.4}/thyra/metadata/extractors/waters_extractor.py +0 -0
  31. {thyra-2.2.2 → thyra-2.2.4}/thyra/metadata/ontology/__init__.py +0 -0
  32. {thyra-2.2.2 → thyra-2.2.4}/thyra/metadata/ontology/_ims.py +0 -0
  33. {thyra-2.2.2 → thyra-2.2.4}/thyra/metadata/ontology/_ms.py +0 -0
  34. {thyra-2.2.2 → thyra-2.2.4}/thyra/metadata/ontology/_uo.py +0 -0
  35. {thyra-2.2.2 → thyra-2.2.4}/thyra/metadata/ontology/cache.py +0 -0
  36. {thyra-2.2.2 → thyra-2.2.4}/thyra/metadata/types.py +0 -0
  37. {thyra-2.2.2 → thyra-2.2.4}/thyra/metadata/validator.py +0 -0
  38. {thyra-2.2.2 → thyra-2.2.4}/thyra/preview.py +0 -0
  39. {thyra-2.2.2 → thyra-2.2.4}/thyra/readers/__init__.py +0 -0
  40. {thyra-2.2.2 → thyra-2.2.4}/thyra/readers/bruker/__init__.py +0 -0
  41. {thyra-2.2.2 → thyra-2.2.4}/thyra/readers/bruker/base_bruker_reader.py +0 -0
  42. {thyra-2.2.2 → thyra-2.2.4}/thyra/readers/bruker/folder_structure.py +0 -0
  43. {thyra-2.2.2 → thyra-2.2.4}/thyra/readers/bruker/mis_parser.py +0 -0
  44. {thyra-2.2.2 → thyra-2.2.4}/thyra/readers/bruker/rapiflex/__init__.py +0 -0
  45. {thyra-2.2.2 → thyra-2.2.4}/thyra/readers/bruker/rapiflex/rapiflex_reader.py +0 -0
  46. {thyra-2.2.2 → thyra-2.2.4}/thyra/readers/bruker/timstof/__init__.py +0 -0
  47. {thyra-2.2.2 → thyra-2.2.4}/thyra/readers/bruker/timstof/sdk/__init__.py +0 -0
  48. {thyra-2.2.2 → thyra-2.2.4}/thyra/readers/bruker/timstof/sdk/dll/LICENCE-BRUKER.txt +0 -0
  49. {thyra-2.2.2 → thyra-2.2.4}/thyra/readers/bruker/timstof/sdk/dll/README.md +0 -0
  50. {thyra-2.2.2 → thyra-2.2.4}/thyra/readers/bruker/timstof/sdk/dll/timsdata.dll +0 -0
  51. {thyra-2.2.2 → thyra-2.2.4}/thyra/readers/bruker/timstof/sdk/dll/timsdata.so +0 -0
  52. {thyra-2.2.2 → thyra-2.2.4}/thyra/readers/bruker/timstof/sdk/dll_manager.py +0 -0
  53. {thyra-2.2.2 → thyra-2.2.4}/thyra/readers/bruker/timstof/sdk/platform_detector.py +0 -0
  54. {thyra-2.2.2 → thyra-2.2.4}/thyra/readers/bruker/timstof/sdk/sdk_functions.py +0 -0
  55. {thyra-2.2.2 → thyra-2.2.4}/thyra/readers/bruker/timstof/timstof_reader.py +0 -0
  56. {thyra-2.2.2 → thyra-2.2.4}/thyra/readers/bruker/timstof/utils/__init__.py +0 -0
  57. {thyra-2.2.2 → thyra-2.2.4}/thyra/readers/bruker/timstof/utils/batch_processor.py +0 -0
  58. {thyra-2.2.2 → thyra-2.2.4}/thyra/readers/bruker/timstof/utils/coordinate_cache.py +0 -0
  59. {thyra-2.2.2 → thyra-2.2.4}/thyra/readers/bruker/timstof/utils/mass_axis_builder.py +0 -0
  60. {thyra-2.2.2 → thyra-2.2.4}/thyra/readers/bruker/timstof/utils/memory_manager.py +0 -0
  61. {thyra-2.2.2 → thyra-2.2.4}/thyra/readers/imzml/__init__.py +0 -0
  62. {thyra-2.2.2 → thyra-2.2.4}/thyra/readers/imzml/imzml_reader.py +0 -0
  63. {thyra-2.2.2 → thyra-2.2.4}/thyra/readers/waters/__init__.py +0 -0
  64. {thyra-2.2.2 → thyra-2.2.4}/thyra/readers/waters/imaging_grid.py +0 -0
  65. {thyra-2.2.2 → thyra-2.2.4}/thyra/readers/waters/lib/MLReader.dll +0 -0
  66. {thyra-2.2.2 → thyra-2.2.4}/thyra/readers/waters/lib/MassLynxRaw.dll +0 -0
  67. {thyra-2.2.2 → thyra-2.2.4}/thyra/readers/waters/lib/libMLReader.so +0 -0
  68. {thyra-2.2.2 → thyra-2.2.4}/thyra/readers/waters/lib/libMassLynxRaw.so +0 -0
  69. {thyra-2.2.2 → thyra-2.2.4}/thyra/readers/waters/masslynx_lib.py +0 -0
  70. {thyra-2.2.2 → thyra-2.2.4}/thyra/readers/waters/waters_reader.py +0 -0
  71. {thyra-2.2.2 → thyra-2.2.4}/thyra/resampling/__init__.py +0 -0
  72. {thyra-2.2.2 → thyra-2.2.4}/thyra/resampling/common_axis.py +0 -0
  73. {thyra-2.2.2 → thyra-2.2.4}/thyra/resampling/constants.py +0 -0
  74. {thyra-2.2.2 → thyra-2.2.4}/thyra/resampling/data_characteristics.py +0 -0
  75. {thyra-2.2.2 → thyra-2.2.4}/thyra/resampling/decision_tree.py +0 -0
  76. {thyra-2.2.2 → thyra-2.2.4}/thyra/resampling/gaps.py +0 -0
  77. {thyra-2.2.2 → thyra-2.2.4}/thyra/resampling/instrument_detectors.py +0 -0
  78. {thyra-2.2.2 → thyra-2.2.4}/thyra/resampling/mass_axis/__init__.py +0 -0
  79. {thyra-2.2.2 → thyra-2.2.4}/thyra/resampling/mass_axis/base_generator.py +0 -0
  80. {thyra-2.2.2 → thyra-2.2.4}/thyra/resampling/mass_axis/fticr_generator.py +0 -0
  81. {thyra-2.2.2 → thyra-2.2.4}/thyra/resampling/mass_axis/linear_generator.py +0 -0
  82. {thyra-2.2.2 → thyra-2.2.4}/thyra/resampling/mass_axis/linear_tof_generator.py +0 -0
  83. {thyra-2.2.2 → thyra-2.2.4}/thyra/resampling/mass_axis/orbitrap_generator.py +0 -0
  84. {thyra-2.2.2 → thyra-2.2.4}/thyra/resampling/mass_axis/reflector_tof_generator.py +0 -0
  85. {thyra-2.2.2 → thyra-2.2.4}/thyra/resampling/strategies/__init__.py +0 -0
  86. {thyra-2.2.2 → thyra-2.2.4}/thyra/resampling/strategies/base.py +0 -0
  87. {thyra-2.2.2 → thyra-2.2.4}/thyra/resampling/strategies/nearest_neighbor.py +0 -0
  88. {thyra-2.2.2 → thyra-2.2.4}/thyra/resampling/strategies/tic_preserving.py +0 -0
  89. {thyra-2.2.2 → thyra-2.2.4}/thyra/resampling/tic.py +0 -0
  90. {thyra-2.2.2 → thyra-2.2.4}/thyra/resampling/types.py +0 -0
  91. {thyra-2.2.2 → thyra-2.2.4}/thyra/tools/__init__.py +0 -0
  92. {thyra-2.2.2 → thyra-2.2.4}/thyra/tools/check_ontology.py +0 -0
  93. {thyra-2.2.2 → thyra-2.2.4}/thyra/tools/make_example_data.py +0 -0
  94. {thyra-2.2.2 → thyra-2.2.4}/thyra/utils/__init__.py +0 -0
  95. {thyra-2.2.2 → thyra-2.2.4}/thyra/utils/bruker_exceptions.py +0 -0
  96. {thyra-2.2.2 → thyra-2.2.4}/thyra/utils/logging_config.py +0 -0
  97. {thyra-2.2.2 → thyra-2.2.4}/thyra/utils/windows_paths.py +0 -0
  98. {thyra-2.2.2 → thyra-2.2.4}/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.2
3
+ Version: 2.2.4
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
@@ -39,7 +39,7 @@ Requires-Dist: pyimzML (>=1.4.0)
39
39
  Requires-Dist: scipy (>=1.7.0)
40
40
  Requires-Dist: spatialdata (>=0.8.0,<0.9)
41
41
  Requires-Dist: tqdm (>=4.50.0)
42
- Requires-Dist: zarr (>=3.0.0,<3.2)
42
+ Requires-Dist: zarr (>=3.1.4,<3.2)
43
43
  Project-URL: Bug Tracker, https://github.com/M4i-Imaging-Mass-Spectrometry/thyra/issues
44
44
  Project-URL: Changelog, https://github.com/M4i-Imaging-Mass-Spectrometry/thyra/blob/main/CHANGELOG.md
45
45
  Project-URL: Contributing, https://github.com/M4i-Imaging-Mass-Spectrometry/thyra/blob/main/CONTRIBUTING.md
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [tool.poetry]
6
6
  name = "thyra"
7
- version = "2.2.2"
7
+ version = "2.2.4"
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>"]
@@ -86,7 +86,16 @@ spatialdata = ">=0.8.0, <0.9" # tested 0.8.0
86
86
  # bit ome_zarr 0.12.x with newer dask, and spatialdata 0.8 requires >= 0.16.
87
87
  ome-zarr = ">=0.16.0, <0.19" # tested 0.18.0
88
88
  tqdm = ">=4.50.0"
89
- zarr = ">=3.0.0, <3.2" # tested 3.1.3
89
+ # Floor is 3.1.4, and it is load-bearing. anndata >= 0.13 writes every zarr v3
90
+ # array with `shards="auto"`. zarr < 3.1.4 sized the auto chunk with
91
+ # `max_bytes=1024` where its own comment says "aim for a 1MiB chunk" (compare
92
+ # `_auto_partition` in 3.1.3 vs 3.1.4), so one shard -- one FILE -- came out at
93
+ # ~1 KB. Measured on test_data/pea.imzML: 393,310 files for a single table and
94
+ # 434 s of a 445 s conversion spent in the write, against 12 files and 2.7 s on
95
+ # 3.1.6. Nothing in Thyra changed; the anndata 0.13 bump alone did this.
96
+ # 3.1.4 is also where `array.target_shard_size_bytes` arrives, which is the
97
+ # knob _chunking.table_write_config sets to claim the budget from anndata.
98
+ zarr = ">=3.1.4, <3.2" # tested 3.1.6
90
99
  imagecodecs = ">=2024.1.1" # For reading compressed TIFF optical images
91
100
  # Floor is 0.13.2, not 0.13.0. anndata 0.13.0 imports
92
101
  # `typing_extensions.sentinel` (needs typing-extensions >= 4.16) but declares
@@ -36,7 +36,7 @@ warnings.filterwarnings(
36
36
  category=FutureWarning,
37
37
  )
38
38
 
39
- __version__ = "2.2.2"
39
+ __version__ = "2.2.4"
40
40
 
41
41
  # Import key components - avoid wildcard imports
42
42
  try:
@@ -0,0 +1,89 @@
1
+ """Chunk/shard policy for the zarr Thyra writes: rasters and the MSI table.
2
+
3
+ Single source of truth for how Thyra chunks the raster images it writes to
4
+ SpatialData zarr -- and the designated seam for tile-aligned zarr v3 sharding
5
+ once scverse/spatialdata PR #1106 (``raster_write_kwargs``) lands.
6
+
7
+ Cross-repo contract: the viewer (Ousia) morphology tile server streams
8
+ ``DEFAULT_TILE_SIZE`` (= 512) edge tiles. Once sharding lands, the zarr INNER
9
+ chunk edge must equal that so one served tile == one decompress. Thyra is a
10
+ separate package and CANNOT import Ousia, so the coupling is DUPLICATED BY
11
+ CONTRACT here -- keep ``INNER_TILE_EDGE`` numerically in sync with Ousia's
12
+ ``DEFAULT_TILE_SIZE``; a silent divergence reintroduces cold-tile amplification
13
+ on the viewer. See the Ousia ADR docs/ADR-pyramid-tile-sharding.md.
14
+
15
+ The raster policy above is passed per-array. The TABLE policy below cannot be:
16
+ ``SpatialData.write`` hands the table straight to ``anndata.write_elem`` and
17
+ exposes no ``dataset_kwargs`` seam, so the only supported lever is zarr's own
18
+ config. ``table_write_config()`` is that lever, and every writer that reaches
19
+ zarr through spatialdata (the 2D, 3D and streaming-COO converters, all via
20
+ ``_save_output``) must hold it open across the write. The streaming-PCS writer
21
+ hand-rolls its ``create_array`` calls and sizes them itself.
22
+ """
23
+
24
+ from contextlib import contextmanager
25
+ from typing import Iterator, Tuple
26
+
27
+ import zarr
28
+
29
+ # Outer chunk edge used TODAY (pre-sharding the chunk IS the outer block): a 4096
30
+ # block means a viewer 512-tile read decompresses at most one chunk per request.
31
+ IMAGE_CHUNK_EDGE = 4096
32
+
33
+ # Future zarr INNER chunk edge once #1106 lands == Ousia DEFAULT_TILE_SIZE (512).
34
+ # Duplicated by contract (Thyra cannot import Ousia).
35
+ INNER_TILE_EDGE = 512
36
+
37
+ # Uncompressed byte budget for ONE SHARD of a table array -- X/data, X/indices,
38
+ # X/indptr and every obs/var column anndata writes. One shard is one FILE, so
39
+ # this is what keeps a 36M-nnz table (test_data/bellini.imzML) in 11 files
40
+ # instead of 394,699.
41
+ #
42
+ # anndata >= 0.13 turns on ``shards="auto"`` for zarr v3 and would otherwise
43
+ # pick its own 1 GB budget; it explicitly yields to a caller-set value (see
44
+ # ``anndata._io.specs.methods.zarr_v3_sharding``). We set a smaller one because
45
+ # a shard is assembled in memory before it is written -- anndata's own comment
46
+ # says so -- and Thyra converts multi-GB acquisitions on workstations, where a
47
+ # 1 GB write buffer is a real cost. 128 MiB is still enormous relative to the
48
+ # file counts that hurt: test_data/pea.imzML (60.1M nnz, 481 MB of X/data)
49
+ # lands in 4 data files, so even a table 100x that size stays in the hundreds.
50
+ #
51
+ # The INNER chunk stays zarr's ~1 MiB auto pick: that is the unit a random read
52
+ # decompresses, and it is the size the non-sharded path used before anndata
53
+ # began auto-sharding, so read amplification is unchanged.
54
+ TABLE_SHARD_TARGET_BYTES = 128 * 1024 * 1024
55
+
56
+ # zarr < 3.1.4 computed the auto chunk with ``max_bytes=1024`` where 1 MiB was
57
+ # meant, so ``shards="auto"`` produced ~1 KB shards -- one file per KB of table.
58
+ # It also predates ``array.target_shard_size_bytes``, making the budget above
59
+ # unenforceable. pyproject pins the floor at 3.1.4 for exactly this reason;
60
+ # this constant is what the regression test checks the floor still buys us.
61
+ MIN_TABLE_SHARD_BYTES = 64 * 1024
62
+
63
+
64
+ @contextmanager
65
+ def table_write_config() -> Iterator[None]:
66
+ """Hold Thyra's table shard budget open across a ``SpatialData.write``.
67
+
68
+ Wraps zarr's global config, so it must stay open for the *whole* write --
69
+ the arrays are created lazily inside anndata, not up front.
70
+ """
71
+ with zarr.config.set({"array.target_shard_size_bytes": TABLE_SHARD_TARGET_BYTES}):
72
+ yield
73
+
74
+
75
+ def image_chunks(spatial_ndim: int, edge: int = IMAGE_CHUNK_EDGE) -> Tuple[int, ...]:
76
+ """The zarr chunk tuple for a Thyra-written raster image (leading channel axis).
77
+
78
+ ``spatial_ndim == 2`` (c, y, x) -> ``(1, edge, edge)``;
79
+ ``spatial_ndim == 3`` (c, z, y, x) -> ``(1, 1, edge, edge)``.
80
+
81
+ This is the ONE place the future inner=``INNER_TILE_EDGE`` + outer-shard policy
82
+ will live when spatialdata PR #1106 ships ``raster_write_kwargs``. Today it
83
+ returns the pre-sharding behaviour, byte-for-byte.
84
+ """
85
+ if spatial_ndim == 2:
86
+ return (1, edge, edge)
87
+ if spatial_ndim == 3:
88
+ return (1, 1, edge, edge)
89
+ raise ValueError(f"spatial_ndim must be 2 or 3, got {spatial_ndim}")
@@ -21,7 +21,7 @@ from ...resampling.gaps import zero_across_gaps
21
21
  from ...resampling.tic import preserved_tic, rescale_to_preserved_tic
22
22
  from ...resampling.types import ResamplingConfig
23
23
  from ...utils.zarr_atomic_write import install_windows_atomic_write_retry
24
- from ._chunking import image_chunks
24
+ from ._chunking import image_chunks, table_write_config
25
25
 
26
26
  logger = logging.getLogger(__name__)
27
27
 
@@ -1921,8 +1921,12 @@ class BaseSpatialDataConverter(BaseMSIConverter, ABC):
1921
1921
  # Add metadata
1922
1922
  self.add_metadata(sdata)
1923
1923
 
1924
- # Write to disk
1925
- with _suppress_upstream_warnings():
1924
+ # Write to disk. table_write_config() must wrap the write itself,
1925
+ # not just the SpatialData construction: anndata creates the table's
1926
+ # zarr arrays lazily inside sdata.write, and that is where the shard
1927
+ # budget is read. Without it the table lands in ~1 KB shards -- one
1928
+ # file per KB -- and the write dominates conversion wall-clock.
1929
+ with _suppress_upstream_warnings(), table_write_config():
1926
1930
  sdata.write(str(self.output_path))
1927
1931
  zarr.consolidate_metadata(str(self.output_path))
1928
1932
  logger.info(f"Successfully saved SpatialData to {self.output_path}")
@@ -9,7 +9,7 @@ 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 ImzMLAccessions, SpectrumType
12
+ from ...resampling.constants import BinaryDataType, ImzMLAccessions, SpectrumType
13
13
  from ..types import ComprehensiveMetadata, EssentialMetadata
14
14
 
15
15
  logger = logging.getLogger(__name__)
@@ -131,7 +131,7 @@ class ImzMLMetadataExtractor(MetadataExtractor):
131
131
  if param_by_name is None or not isinstance(param_by_name, dict):
132
132
  return False
133
133
 
134
- return "continuous" in param_by_name
134
+ return BinaryDataType.CONTINUOUS in param_by_name
135
135
  except Exception:
136
136
  return False
137
137
 
@@ -522,19 +522,27 @@ class ImzMLMetadataExtractor(MetadataExtractor):
522
522
  return None
523
523
 
524
524
  def _detect_centroid_spectrum(self) -> Optional[str]:
525
- """Detect spectrum type by looking for MS:1000127 (centroid) or MS:1000128 (profile)."""
525
+ """Detect spectrum type by looking for MS:1000127 (centroid) or MS:1000128 (profile).
526
+
527
+ What the file *declares* wins, wherever it is written. Only when the
528
+ file declares nothing does Thyra guess -- see
529
+ :meth:`_guess_spectrum_type_from_storage_mode` for what that guess is
530
+ worth.
531
+ """
526
532
  try:
527
- # Method 1: Check parser metadata first (no XML parsing needed)
528
- result = self._check_parser_metadata_for_centroid()
533
+ # 1. The declaration, from metadata the parser already holds.
534
+ result = self._check_parser_metadata_for_spectrum_type()
529
535
  if result:
530
536
  return result
531
537
 
532
- # Method 2: Stream-parse XML for spectrum type markers (memory efficient)
538
+ # 2. The same declaration, wherever else in the document it sits.
539
+ # Streams the XML, so it is tried only if step 1 came up empty.
533
540
  result = self._check_xml_for_spectrum_type()
534
541
  if result:
535
542
  return result
536
543
 
537
- return None
544
+ # 3. Nothing declared. Only now is a guess appropriate.
545
+ return self._guess_spectrum_type_from_storage_mode()
538
546
  except Exception as e:
539
547
  logger.debug(f"Could not detect spectrum type: {e}")
540
548
  return None
@@ -607,8 +615,13 @@ class ImzMLMetadataExtractor(MetadataExtractor):
607
615
  logger.warning("defusedxml not available, using xml.etree.ElementTree")
608
616
  return ET
609
617
 
610
- def _check_parser_metadata_for_centroid(self) -> Optional[str]:
611
- """Check parser metadata for processed flag indicating centroid data."""
618
+ def _file_description_params(self) -> Optional[Dict[str, Any]]:
619
+ """Return the fileDescription cvParams the parser already parsed.
620
+
621
+ ``param_by_name`` maps a term's CV *name* to its parsed value, and a
622
+ valueless cvParam -- which is what a flag like ``profile spectrum``
623
+ is -- parses to ``True``.
624
+ """
612
625
  if not (hasattr(self.parser, "metadata") and self.parser.metadata):
613
626
  return None
614
627
 
@@ -616,13 +629,70 @@ class ImzMLMetadataExtractor(MetadataExtractor):
616
629
  return None
617
630
 
618
631
  file_desc = self.parser.metadata.file_description
619
- if not hasattr(file_desc, "param_by_name"):
632
+ params = getattr(file_desc, "param_by_name", None)
633
+ if not isinstance(params, dict):
634
+ return None
635
+
636
+ return params
637
+
638
+ def _check_parser_metadata_for_spectrum_type(self) -> Optional[str]:
639
+ """Read the declared spectrum representation out of the fileDescription.
640
+
641
+ Cheap: the parser has already read this, so no XML pass is needed.
642
+ ``SpectrumType.CENTROID`` and ``SpectrumType.PROFILE`` are the CV
643
+ names of ``MS:1000127`` and ``MS:1000128``, which is what
644
+ ``param_by_name`` is keyed on.
645
+ """
646
+ params = self._file_description_params()
647
+ if params is None:
648
+ return None
649
+
650
+ if params.get(SpectrumType.CENTROID, False):
651
+ logger.info(
652
+ f"Detected centroid spectrum from declared "
653
+ f"{ImzMLAccessions.CENTROID_SPECTRUM} in fileDescription"
654
+ )
655
+ return SpectrumType.CENTROID
656
+
657
+ if params.get(SpectrumType.PROFILE, False):
658
+ logger.info(
659
+ f"Detected profile spectrum from declared "
660
+ f"{ImzMLAccessions.PROFILE_SPECTRUM} in fileDescription"
661
+ )
662
+ return SpectrumType.PROFILE
663
+
664
+ return None
665
+
666
+ def _guess_spectrum_type_from_storage_mode(self) -> Optional[str]:
667
+ """Last-resort guess: assume a processed-mode file is centroided.
668
+
669
+ Processed and centroid are orthogonal. ``IMS:1000031`` says each
670
+ spectrum carries its own m/z array; it says nothing about whether
671
+ the peaks in it are centroided or profile. Plenty of instruments
672
+ export profile spectra in processed mode -- ``bellini.imzML`` is
673
+ processed *and* declares ``MS:1000128 profile spectrum``.
674
+
675
+ This used to run before the declared accession was ever read, so
676
+ every processed file was reported as centroid whatever it said about
677
+ itself. It now runs only when the file declares neither
678
+ ``MS:1000127`` nor ``MS:1000128`` anywhere, where a guess is all
679
+ there is. Peak lists are the common case for processed exports, so
680
+ centroid is the better guess -- but it is still a guess, hence the
681
+ warning.
682
+ """
683
+ params = self._file_description_params()
684
+ if params is None:
620
685
  return None
621
686
 
622
- params = file_desc.param_by_name
623
- # If it's processed data, it's likely centroided
624
- if params.get("processed", False):
625
- logger.info("Assuming centroid spectrum for processed ImzML data")
687
+ if params.get(BinaryDataType.PROCESSED, False):
688
+ logger.warning(
689
+ "This imzML declares neither %s (centroid) nor %s (profile). "
690
+ "Assuming centroid, because it is stored in processed mode -- "
691
+ "but the two are independent, so check the result if the "
692
+ "spectra are actually profile.",
693
+ ImzMLAccessions.CENTROID_SPECTRUM,
694
+ ImzMLAccessions.PROFILE_SPECTRUM,
695
+ )
626
696
  return SpectrumType.CENTROID
627
697
 
628
698
  return None
@@ -1,41 +0,0 @@
1
- """Image chunk/shard policy for Thyra-written SpatialData rasters.
2
-
3
- Single source of truth for how Thyra chunks the raster images it writes to
4
- SpatialData zarr -- and the designated seam for tile-aligned zarr v3 sharding
5
- once scverse/spatialdata PR #1106 (``raster_write_kwargs``) lands.
6
-
7
- Cross-repo contract: the viewer (Ousia) morphology tile server streams
8
- ``DEFAULT_TILE_SIZE`` (= 512) edge tiles. Once sharding lands, the zarr INNER
9
- chunk edge must equal that so one served tile == one decompress. Thyra is a
10
- separate package and CANNOT import Ousia, so the coupling is DUPLICATED BY
11
- CONTRACT here -- keep ``INNER_TILE_EDGE`` numerically in sync with Ousia's
12
- ``DEFAULT_TILE_SIZE``; a silent divergence reintroduces cold-tile amplification
13
- on the viewer. See the Ousia ADR docs/ADR-pyramid-tile-sharding.md.
14
- """
15
-
16
- from typing import Tuple
17
-
18
- # Outer chunk edge used TODAY (pre-sharding the chunk IS the outer block): a 4096
19
- # block means a viewer 512-tile read decompresses at most one chunk per request.
20
- IMAGE_CHUNK_EDGE = 4096
21
-
22
- # Future zarr INNER chunk edge once #1106 lands == Ousia DEFAULT_TILE_SIZE (512).
23
- # Duplicated by contract (Thyra cannot import Ousia).
24
- INNER_TILE_EDGE = 512
25
-
26
-
27
- def image_chunks(spatial_ndim: int, edge: int = IMAGE_CHUNK_EDGE) -> Tuple[int, ...]:
28
- """The zarr chunk tuple for a Thyra-written raster image (leading channel axis).
29
-
30
- ``spatial_ndim == 2`` (c, y, x) -> ``(1, edge, edge)``;
31
- ``spatial_ndim == 3`` (c, z, y, x) -> ``(1, 1, edge, edge)``.
32
-
33
- This is the ONE place the future inner=``INNER_TILE_EDGE`` + outer-shard policy
34
- will live when spatialdata PR #1106 ships ``raster_write_kwargs``. Today it
35
- returns the pre-sharding behaviour, byte-for-byte.
36
- """
37
- if spatial_ndim == 2:
38
- return (1, edge, edge)
39
- if spatial_ndim == 3:
40
- return (1, 1, edge, edge)
41
- raise ValueError(f"spatial_ndim must be 2 or 3, got {spatial_ndim}")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes