thyra 3.2.1__tar.gz → 3.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 (103) hide show
  1. {thyra-3.2.1 → thyra-3.3.0}/PKG-INFO +9 -2
  2. {thyra-3.2.1 → thyra-3.3.0}/README.md +8 -1
  3. {thyra-3.2.1 → thyra-3.3.0}/pyproject.toml +1 -1
  4. {thyra-3.2.1 → thyra-3.3.0}/thyra/__init__.py +1 -1
  5. {thyra-3.2.1 → thyra-3.3.0}/thyra/converters/spatialdata/base_spatialdata_converter.py +115 -56
  6. {thyra-3.2.1 → thyra-3.3.0}/thyra/converters/spatialdata/spatialdata_2d_converter.py +1 -3
  7. {thyra-3.2.1 → thyra-3.3.0}/thyra/converters/spatialdata/spatialdata_3d_converter.py +1 -3
  8. {thyra-3.2.1 → thyra-3.3.0}/thyra/converters/spatialdata/streaming_converter.py +10 -4
  9. {thyra-3.2.1 → thyra-3.3.0}/thyra/core/base_reader.py +24 -0
  10. {thyra-3.2.1 → thyra-3.3.0}/thyra/core/registry.py +43 -13
  11. {thyra-3.2.1 → thyra-3.3.0}/thyra/metadata/extractors/__init__.py +3 -0
  12. thyra-3.3.0/thyra/metadata/extractors/phi_extractor.py +178 -0
  13. {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/__init__.py +6 -1
  14. thyra-3.3.0/thyra/readers/phi/__init__.py +27 -0
  15. thyra-3.3.0/thyra/readers/phi/event_stream.py +327 -0
  16. thyra-3.3.0/thyra/readers/phi/mass_axis.py +169 -0
  17. thyra-3.3.0/thyra/readers/phi/phi_header.py +279 -0
  18. thyra-3.3.0/thyra/readers/phi/phi_reader.py +360 -0
  19. {thyra-3.2.1 → thyra-3.3.0}/thyra/utils/windows_paths.py +61 -4
  20. {thyra-3.2.1 → thyra-3.3.0}/LICENSE +0 -0
  21. {thyra-3.2.1 → thyra-3.3.0}/thyra/__main__.py +0 -0
  22. {thyra-3.2.1 → thyra-3.3.0}/thyra/alignment/__init__.py +0 -0
  23. {thyra-3.2.1 → thyra-3.3.0}/thyra/alignment/affine.py +0 -0
  24. {thyra-3.2.1 → thyra-3.3.0}/thyra/alignment/teaching_points.py +0 -0
  25. {thyra-3.2.1 → thyra-3.3.0}/thyra/config.py +0 -0
  26. {thyra-3.2.1 → thyra-3.3.0}/thyra/convert.py +0 -0
  27. {thyra-3.2.1 → thyra-3.3.0}/thyra/converters/__init__.py +0 -0
  28. {thyra-3.2.1 → thyra-3.3.0}/thyra/converters/spatialdata/__init__.py +0 -0
  29. {thyra-3.2.1 → thyra-3.3.0}/thyra/converters/spatialdata/_chunking.py +0 -0
  30. {thyra-3.2.1 → thyra-3.3.0}/thyra/converters/spatialdata/converter.py +0 -0
  31. {thyra-3.2.1 → thyra-3.3.0}/thyra/core/__init__.py +0 -0
  32. {thyra-3.2.1 → thyra-3.3.0}/thyra/core/base_converter.py +0 -0
  33. {thyra-3.2.1 → thyra-3.3.0}/thyra/core/base_extractor.py +0 -0
  34. {thyra-3.2.1 → thyra-3.3.0}/thyra/metadata/__init__.py +0 -0
  35. {thyra-3.2.1 → thyra-3.3.0}/thyra/metadata/extractors/bruker_extractor.py +0 -0
  36. {thyra-3.2.1 → thyra-3.3.0}/thyra/metadata/extractors/imzml_extractor.py +0 -0
  37. {thyra-3.2.1 → thyra-3.3.0}/thyra/metadata/extractors/waters_extractor.py +0 -0
  38. {thyra-3.2.1 → thyra-3.3.0}/thyra/metadata/ontology/__init__.py +0 -0
  39. {thyra-3.2.1 → thyra-3.3.0}/thyra/metadata/ontology/_ims.py +0 -0
  40. {thyra-3.2.1 → thyra-3.3.0}/thyra/metadata/ontology/_ms.py +0 -0
  41. {thyra-3.2.1 → thyra-3.3.0}/thyra/metadata/ontology/_uo.py +0 -0
  42. {thyra-3.2.1 → thyra-3.3.0}/thyra/metadata/ontology/cache.py +0 -0
  43. {thyra-3.2.1 → thyra-3.3.0}/thyra/metadata/types.py +0 -0
  44. {thyra-3.2.1 → thyra-3.3.0}/thyra/metadata/validator.py +0 -0
  45. {thyra-3.2.1 → thyra-3.3.0}/thyra/preview.py +0 -0
  46. {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/bruker/__init__.py +0 -0
  47. {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/bruker/base_bruker_reader.py +0 -0
  48. {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/bruker/folder_structure.py +0 -0
  49. {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/bruker/mis_parser.py +0 -0
  50. {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/bruker/rapiflex/__init__.py +0 -0
  51. {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/bruker/rapiflex/rapiflex_reader.py +0 -0
  52. {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/bruker/timstof/__init__.py +0 -0
  53. {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/bruker/timstof/sdk/__init__.py +0 -0
  54. {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/bruker/timstof/sdk/dll/LICENCE-BRUKER.txt +0 -0
  55. {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/bruker/timstof/sdk/dll/README.md +0 -0
  56. {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/bruker/timstof/sdk/dll/timsdata.dll +0 -0
  57. {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/bruker/timstof/sdk/dll/timsdata.so +0 -0
  58. {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/bruker/timstof/sdk/dll_manager.py +0 -0
  59. {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/bruker/timstof/sdk/platform_detector.py +0 -0
  60. {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/bruker/timstof/sdk/sdk_functions.py +0 -0
  61. {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/bruker/timstof/timstof_reader.py +0 -0
  62. {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/bruker/timstof/utils/__init__.py +0 -0
  63. {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/bruker/timstof/utils/batch_processor.py +0 -0
  64. {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/bruker/timstof/utils/coordinate_cache.py +0 -0
  65. {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/bruker/timstof/utils/mass_axis_builder.py +0 -0
  66. {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/bruker/timstof/utils/memory_manager.py +0 -0
  67. {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/imzml/__init__.py +0 -0
  68. {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/imzml/imzml_reader.py +0 -0
  69. {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/waters/__init__.py +0 -0
  70. {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/waters/imaging_grid.py +0 -0
  71. {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/waters/lib/MLReader.dll +0 -0
  72. {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/waters/lib/MassLynxRaw.dll +0 -0
  73. {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/waters/lib/libMLReader.so +0 -0
  74. {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/waters/lib/libMassLynxRaw.so +0 -0
  75. {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/waters/masslynx_lib.py +0 -0
  76. {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/waters/waters_reader.py +0 -0
  77. {thyra-3.2.1 → thyra-3.3.0}/thyra/resampling/__init__.py +0 -0
  78. {thyra-3.2.1 → thyra-3.3.0}/thyra/resampling/common_axis.py +0 -0
  79. {thyra-3.2.1 → thyra-3.3.0}/thyra/resampling/constants.py +0 -0
  80. {thyra-3.2.1 → thyra-3.3.0}/thyra/resampling/data_characteristics.py +0 -0
  81. {thyra-3.2.1 → thyra-3.3.0}/thyra/resampling/decision_tree.py +0 -0
  82. {thyra-3.2.1 → thyra-3.3.0}/thyra/resampling/gaps.py +0 -0
  83. {thyra-3.2.1 → thyra-3.3.0}/thyra/resampling/instrument_detectors.py +0 -0
  84. {thyra-3.2.1 → thyra-3.3.0}/thyra/resampling/mass_axis/__init__.py +0 -0
  85. {thyra-3.2.1 → thyra-3.3.0}/thyra/resampling/mass_axis/base_generator.py +0 -0
  86. {thyra-3.2.1 → thyra-3.3.0}/thyra/resampling/mass_axis/fticr_generator.py +0 -0
  87. {thyra-3.2.1 → thyra-3.3.0}/thyra/resampling/mass_axis/linear_generator.py +0 -0
  88. {thyra-3.2.1 → thyra-3.3.0}/thyra/resampling/mass_axis/linear_tof_generator.py +0 -0
  89. {thyra-3.2.1 → thyra-3.3.0}/thyra/resampling/mass_axis/orbitrap_generator.py +0 -0
  90. {thyra-3.2.1 → thyra-3.3.0}/thyra/resampling/mass_axis/reflector_tof_generator.py +0 -0
  91. {thyra-3.2.1 → thyra-3.3.0}/thyra/resampling/strategies/__init__.py +0 -0
  92. {thyra-3.2.1 → thyra-3.3.0}/thyra/resampling/strategies/base.py +0 -0
  93. {thyra-3.2.1 → thyra-3.3.0}/thyra/resampling/strategies/nearest_neighbor.py +0 -0
  94. {thyra-3.2.1 → thyra-3.3.0}/thyra/resampling/strategies/tic_preserving.py +0 -0
  95. {thyra-3.2.1 → thyra-3.3.0}/thyra/resampling/tic.py +0 -0
  96. {thyra-3.2.1 → thyra-3.3.0}/thyra/resampling/types.py +0 -0
  97. {thyra-3.2.1 → thyra-3.3.0}/thyra/tools/__init__.py +0 -0
  98. {thyra-3.2.1 → thyra-3.3.0}/thyra/tools/check_ontology.py +0 -0
  99. {thyra-3.2.1 → thyra-3.3.0}/thyra/tools/make_example_data.py +0 -0
  100. {thyra-3.2.1 → thyra-3.3.0}/thyra/utils/__init__.py +0 -0
  101. {thyra-3.2.1 → thyra-3.3.0}/thyra/utils/bruker_exceptions.py +0 -0
  102. {thyra-3.2.1 → thyra-3.3.0}/thyra/utils/logging_config.py +0 -0
  103. {thyra-3.2.1 → thyra-3.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: 3.2.1
3
+ Version: 3.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
@@ -81,7 +81,7 @@ for a full walkthrough, including the published example dataset
81
81
 
82
82
  ## Features
83
83
 
84
- - **Multiple Input Formats**: ImzML, Bruker (.d directories), Waters (.raw directories)
84
+ - **Multiple Input Formats**: ImzML, Bruker (.d directories), Waters (.raw directories), PHI SmartSoft-TOF ToF-SIMS (.raw files)
85
85
  - **SpatialData Output**: Modern, cloud-ready format with Zarr backend
86
86
  - **Memory Efficient**: Handles large datasets (100+ GB) through streaming processing
87
87
  - **Optical Alignment**: Automatic MSI-to-optical image registration for Bruker data
@@ -107,10 +107,17 @@ thyra input.imzML output.zarr
107
107
  # Bruker data with verbose logging
108
108
  thyra data.d output.zarr -v DEBUG
109
109
 
110
+ # PHI SmartSoft-TOF ToF-SIMS (a .raw file, not a directory)
111
+ thyra tofsims_run.raw output.zarr
112
+
110
113
  # Disable resampling
111
114
  thyra input.imzML output.zarr --no-resample
112
115
  ```
113
116
 
117
+ Thyra auto-detects the input format. Note that `.raw` is claimed by two
118
+ vendors and resolved by shape: Waters writes a directory, PHI writes a single
119
+ file. See [Supported Formats](https://M4i-Imaging-Mass-Spectrometry.github.io/thyra/supported-formats/).
120
+
114
121
  ### Python API
115
122
 
116
123
  ```python
@@ -27,7 +27,7 @@ for a full walkthrough, including the published example dataset
27
27
 
28
28
  ## Features
29
29
 
30
- - **Multiple Input Formats**: ImzML, Bruker (.d directories), Waters (.raw directories)
30
+ - **Multiple Input Formats**: ImzML, Bruker (.d directories), Waters (.raw directories), PHI SmartSoft-TOF ToF-SIMS (.raw files)
31
31
  - **SpatialData Output**: Modern, cloud-ready format with Zarr backend
32
32
  - **Memory Efficient**: Handles large datasets (100+ GB) through streaming processing
33
33
  - **Optical Alignment**: Automatic MSI-to-optical image registration for Bruker data
@@ -53,10 +53,17 @@ thyra input.imzML output.zarr
53
53
  # Bruker data with verbose logging
54
54
  thyra data.d output.zarr -v DEBUG
55
55
 
56
+ # PHI SmartSoft-TOF ToF-SIMS (a .raw file, not a directory)
57
+ thyra tofsims_run.raw output.zarr
58
+
56
59
  # Disable resampling
57
60
  thyra input.imzML output.zarr --no-resample
58
61
  ```
59
62
 
63
+ Thyra auto-detects the input format. Note that `.raw` is claimed by two
64
+ vendors and resolved by shape: Waters writes a directory, PHI writes a single
65
+ file. See [Supported Formats](https://M4i-Imaging-Mass-Spectrometry.github.io/thyra/supported-formats/).
66
+
60
67
  ### Python API
61
68
 
62
69
  ```python
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [tool.poetry]
6
6
  name = "thyra"
7
- version = "3.2.1"
7
+ version = "3.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__ = "3.2.1"
39
+ __version__ = "3.3.0"
40
40
 
41
41
  # Import key components - avoid wildcard imports
42
42
  try:
@@ -151,7 +151,7 @@ try:
151
151
  import xarray as xr
152
152
  import zarr
153
153
  from anndata import AnnData
154
- from shapely.geometry import Polygon, box
154
+ from shapely.geometry import box
155
155
  from spatialdata import SpatialData
156
156
  from spatialdata.models import Image2DModel, ShapesModel, TableModel
157
157
  from spatialdata.transformations import Affine, Identity, Scale, Sequence
@@ -614,9 +614,14 @@ class BaseSpatialDataConverter(BaseMSIConverter, ABC):
614
614
  logger.debug("Added MSI metadata to AnnData .uns: %s", sorted(uns))
615
615
 
616
616
  def _serialize_for_zarr(self, obj):
617
- """Recursively convert tuples to lists for Zarr serialization."""
617
+ """Recursively convert tuples to lists for Zarr serialization.
618
+
619
+ Dict keys are coerced to strings: Zarr group members must be named,
620
+ and a non-string key otherwise fails at write time, after the whole
621
+ conversion has already been done.
622
+ """
618
623
  if isinstance(obj, dict):
619
- return {k: self._serialize_for_zarr(v) for k, v in obj.items()}
624
+ return {str(k): self._serialize_for_zarr(v) for k, v in obj.items()}
620
625
  elif isinstance(obj, (list, tuple)):
621
626
  return [self._serialize_for_zarr(item) for item in obj]
622
627
  elif hasattr(obj, "__dict__"):
@@ -1434,11 +1439,64 @@ class BaseSpatialDataConverter(BaseMSIConverter, ABC):
1434
1439
  if self._common_mass_axis is None:
1435
1440
  raise ValueError("Common mass axis is not initialized")
1436
1441
 
1442
+ n_channels = len(self._common_mass_axis)
1443
+ columns: Dict[str, Any] = {"mz": self._common_mass_axis}
1444
+ columns.update(self._validated_mass_axis_annotations())
1445
+
1437
1446
  return pd.DataFrame(
1438
- {"mz": self._common_mass_axis},
1439
- index=[f"mz_{i}" for i in range(len(self._common_mass_axis))],
1447
+ columns,
1448
+ index=[f"mz_{i}" for i in range(n_channels)],
1440
1449
  )
1441
1450
 
1451
+ def _validated_mass_axis_annotations(self) -> Dict[str, Any]:
1452
+ """Reader-supplied per-channel columns that fit the axis being written.
1453
+
1454
+ A reader whose native axis is not m/z (flight time, drift time) can
1455
+ keep that axis alongside ``mz`` so the conversion stays reversible.
1456
+ Annotations whose length does not match are dropped rather than
1457
+ raising: that is the expected outcome when resampling rebuilds the
1458
+ axis, and it must not fail an otherwise good conversion.
1459
+ """
1460
+ if self._common_mass_axis is None:
1461
+ return {}
1462
+ n_channels = len(self._common_mass_axis)
1463
+
1464
+ # getattr rather than a bare call: readers predating this hook, and
1465
+ # test doubles that do not subclass BaseMSIReader, simply have nothing
1466
+ # to contribute and should not have to raise to say so.
1467
+ getter = getattr(self.reader, "get_mass_axis_annotations", None)
1468
+ if getter is None:
1469
+ return {}
1470
+
1471
+ try:
1472
+ annotations = getter()
1473
+ except Exception as exc: # pragma: no cover - reader-defined
1474
+ # Format eagerly. Passing the exception itself to the logger keeps
1475
+ # it alive inside the log record, and with it its traceback, the
1476
+ # caller frames reachable through tb_frame.f_back, and any memmap
1477
+ # those frames hold -- which on Windows leaves the backing file
1478
+ # locked long after the conversion has finished.
1479
+ detail = f"{type(exc).__name__}: {exc}"
1480
+ logger.warning("Reader failed to supply mass axis annotations: %s", detail)
1481
+ return {}
1482
+
1483
+ validated: Dict[str, Any] = {}
1484
+ for name, values in (annotations or {}).items():
1485
+ if name == "mz":
1486
+ logger.warning("Ignoring mass axis annotation named 'mz'")
1487
+ continue
1488
+ if len(values) != n_channels:
1489
+ logger.info(
1490
+ "Dropping mass axis annotation %r: %d values for a "
1491
+ "%d channel axis (expected when resampling rebuilds it)",
1492
+ name,
1493
+ len(values),
1494
+ n_channels,
1495
+ )
1496
+ continue
1497
+ validated[name] = values
1498
+ return validated
1499
+
1442
1500
  def _get_pixel_index(self, x: int, y: int, z: int) -> int:
1443
1501
  """Calculate linear pixel index from 3D coordinates.
1444
1502
 
@@ -1509,48 +1567,63 @@ class BaseSpatialDataConverter(BaseMSIConverter, ABC):
1509
1567
 
1510
1568
  coo_arrays["current_idx"] = end_idx
1511
1569
 
1512
- def _create_pixel_shapes(
1513
- self, adata: AnnData, is_3d: bool = False
1514
- ) -> "ShapesModel":
1570
+ def _create_pixel_shapes(self, adata: AnnData) -> "ShapesModel":
1515
1571
  """Create geometric shapes for pixels with proper transformations.
1516
1572
 
1517
1573
  When optical alignment is available (FlexImaging with Area definitions),
1518
1574
  shapes are created in optical image pixel coordinates for proper overlay.
1519
1575
  Otherwise, shapes use physical (micrometer) coordinates.
1520
1576
 
1521
- For a multi-slice volume the footprints are ``POLYGON Z``: a flat
1522
- square at the micrometre depth of the slice it was acquired on,
1523
- taken from ``obs["spatial_z"]``. They are footprints, not voxels --
1524
- shapely has no solid, so a pixel is its square at one depth rather
1525
- than a box spanning the slice thickness.
1526
-
1527
- Two alternatives were measured and rejected:
1528
-
1577
+ **Footprints are two-dimensional, including on a multi-slice
1578
+ volume.** A slice's depth is carried by the TIC image's ``Scale``
1579
+ and by ``obs["spatial_z"]``; it is deliberately not also put on
1580
+ the polygon geometry.
1581
+
1582
+ Three options were measured. All three are imperfect, so what
1583
+ follows is the reasoning rather than a claim that this one is
1584
+ free:
1585
+
1586
+ * **``POLYGON Z``** -- geometrically the most honest, and what
1587
+ 7317792 shipped in v3.2.0. It breaks ``spatialdata``'s spatial
1588
+ queries: measured on a 5x3x2 volume, a bounding box enclosing
1589
+ the whole dataset with 1000um of margin returned 26 of 30
1590
+ footprints and 26 of 30 table rows, with no exception and no
1591
+ warning. ``shapely.force_2d`` on the same geometry restored
1592
+ 30 of 30. A z-restricted query returned the same rows whether
1593
+ z was inside or far outside the data, so z is ignored by the
1594
+ query path rather than honoured.
1529
1595
  * **Flat 2D shapes carrying the depth in a transformation**
1530
- (one element per slice, each with a ``Translation`` in z). This
1531
- is what spatialdata's 2D shapes model invites, and it does not
1532
- work: the element parses without complaint, coexists with a 3D
1533
- image and round-trips, but the transform **silently drops z**
1534
- and every slice comes back at the same depth. It would put a
1535
- depth in the metadata that never reaches the geometry.
1536
- * **Leaving them flat** and documenting it. Honest, but then
1537
- ``docs/coordinate-systems.md``'s promise that every element
1538
- agrees at ``"global"`` stays false in z.
1539
-
1540
- The cost of the choice: ``ShapesModel.parse``/``validate`` and
1541
- every downstream ``transform`` emit a ``UserWarning`` that 2
1542
- dimensions are expected. It is left unsuppressed deliberately --
1543
- it is upstream telling consumers this is outside the model, and
1544
- hiding it would be Thyra deciding that on their behalf. Only the
1545
- micrometre branch gains z; under optical alignment the frame is
1546
- optical pixels, which have no depth calibration to place a slice
1547
- in.
1596
+ (one element per slice, each with a ``Translation`` in z).
1597
+ The element parses, coexists with a 3D image and round-trips,
1598
+ but the transform **silently drops z** and every slice comes
1599
+ back at the same depth -- a depth in the metadata that never
1600
+ reaches the geometry. ``test_3d_pixel_shapes_z.py`` pins this,
1601
+ because it is the change someone will otherwise propose.
1602
+ * **Flat, with the depth on the image and in ``obs`` only** --
1603
+ what this does.
1604
+
1605
+ The deciding argument is that ``spatialdata`` itself asks for
1606
+ this. ``ShapesModel.validate`` warns that a 3-dimensional
1607
+ geometry column "could led to unexpected behaviors" and names
1608
+ ``force_2d()`` as the remedy; the query result above is that
1609
+ behaviour. 3D shapes are not on the upstream roadmap
1610
+ (scverse/spatialdata#109 has been idle since June 2023 and
1611
+ covers images, labels and transformations only), and the live
1612
+ 2.5D discussion (#961) scopes itself to points, images and
1613
+ labels. Serial-section MSI is 2.5D in that taxonomy.
1614
+
1615
+ What this costs: ``docs/coordinate-systems.md``'s promise that
1616
+ every element agrees at ``"global"`` is exact in x and y and
1617
+ silent in z for the shapes element. That is a documented gap
1618
+ rather than a wrong answer, which a truncated query is not. See
1619
+ ``docs/output-format.md`` for the consumer-facing statement.
1620
+
1621
+ Reinstating z means restoring the ``is_3d`` parameter at all
1622
+ three call sites as well; it was removed with the geometry so it
1623
+ could not sit unread, which is how the original defect arose.
1548
1624
 
1549
1625
  Args:
1550
1626
  adata: AnnData object containing coordinates
1551
- is_3d: Whether this is the 3D volume route. Combined with
1552
- :attr:`_is_volume` (which also requires more than one
1553
- plane), decides whether footprints carry z.
1554
1627
 
1555
1628
  Returns:
1556
1629
  SpatialData shapes model
@@ -1628,29 +1701,15 @@ class BaseSpatialDataConverter(BaseMSIConverter, ABC):
1628
1701
  y_coords: NDArray[np.float64] = adata.obs["spatial_y"].values
1629
1702
  half_pixel_um = self.pixel_size_um / 2
1630
1703
 
1631
- # A volume's footprints carry the depth of the slice they were
1632
- # acquired on, so they meet the TIC volume at "global" instead
1633
- # of stacking flat at z=0. spatial_z is already the micrometre
1634
- # depth (z index * z_spacing_um), so it needs no scaling here
1635
- # -- the same column the table stores, which is what keeps the
1636
- # two elements agreeing.
1637
- z_coords: Optional[NDArray[np.float64]] = None
1638
- if is_3d and self._is_volume:
1639
- z_coords = adata.obs["spatial_z"].values
1640
-
1704
+ # Footprints are flat, on every route including volumes. A
1705
+ # slice's depth lives on the TIC image's Scale and in
1706
+ # obs["spatial_z"]; see the docstring for why it is not also
1707
+ # put on the geometry.
1641
1708
  for i in range(len(adata)):
1642
1709
  x, y = x_coords[i], y_coords[i]
1643
1710
  x0, y0 = x - half_pixel_um, y - half_pixel_um
1644
1711
  x1, y1 = x + half_pixel_um, y + half_pixel_um
1645
-
1646
- if z_coords is None:
1647
- pixel_box = box(x0, y0, x1, y1)
1648
- else:
1649
- z = float(z_coords[i])
1650
- pixel_box = Polygon(
1651
- [(x0, y0, z), (x1, y0, z), (x1, y1, z), (x0, y1, z)]
1652
- )
1653
- geometries.append(pixel_box)
1712
+ geometries.append(box(x0, y0, x1, y1))
1654
1713
 
1655
1714
  # Create GeoDataFrame with appropriate index
1656
1715
  if valid_indices is not None:
@@ -328,9 +328,7 @@ class SpatialData2DConverter(BaseSpatialDataConverter):
328
328
 
329
329
  # Add to tables and create shapes
330
330
  data_structures["tables"][slice_id] = table
331
- data_structures["shapes"][region_key] = self._create_pixel_shapes(
332
- adata, is_3d=False
333
- )
331
+ data_structures["shapes"][region_key] = self._create_pixel_shapes(adata)
334
332
 
335
333
  # Create TIC image for this slice
336
334
  tic_values = slice_data["tic_values"]
@@ -246,9 +246,7 @@ class SpatialData3DConverter(BaseSpatialDataConverter):
246
246
 
247
247
  # Add to tables and create shapes
248
248
  data_structures["tables"][self.dataset_id] = table
249
- data_structures["shapes"][region_key] = self._create_pixel_shapes(
250
- adata, is_3d=True
251
- )
249
+ data_structures["shapes"][region_key] = self._create_pixel_shapes(adata)
252
250
 
253
251
  # Create TIC image
254
252
  self._create_tic_image(data_structures)
@@ -1004,9 +1004,7 @@ class StreamingSpatialDataConverter(BaseSpatialDataConverter):
1004
1004
 
1005
1005
  # Add to tables and create shapes
1006
1006
  data_structures["tables"][slice_id] = table
1007
- data_structures["shapes"][region_key] = self._create_pixel_shapes(
1008
- adata, is_3d=False
1009
- )
1007
+ data_structures["shapes"][region_key] = self._create_pixel_shapes(adata)
1010
1008
 
1011
1009
  # Create TIC image for this slice
1012
1010
  tic_values = slice_data["tic_values"]
@@ -1702,7 +1700,10 @@ class StreamingSpatialDataConverter(BaseSpatialDataConverter):
1702
1700
  var_group.attrs["encoding-type"] = "dataframe"
1703
1701
  var_group.attrs["encoding-version"] = "0.2.0"
1704
1702
  var_group.attrs["_index"] = "_index"
1705
- var_group.attrs["column-order"] = ["mz"]
1703
+ # A reader whose native axis is not m/z keeps that axis here too, so
1704
+ # this route stores the same columns as the dataframe-based ones.
1705
+ annotations = self._validated_mass_axis_annotations()
1706
+ var_group.attrs["column-order"] = ["mz"] + sorted(annotations)
1706
1707
 
1707
1708
  mz_values = self._common_mass_axis
1708
1709
  if mz_values is None:
@@ -1725,6 +1726,11 @@ class StreamingSpatialDataConverter(BaseSpatialDataConverter):
1725
1726
  a.attrs["encoding-type"] = "array"
1726
1727
  a.attrs["encoding-version"] = "0.2.0"
1727
1728
 
1729
+ for name in sorted(annotations):
1730
+ a = var_group.create_array(name, data=np.asarray(annotations[name]))
1731
+ a.attrs["encoding-type"] = "array"
1732
+ a.attrs["encoding-version"] = "0.2.0"
1733
+
1728
1734
  # uns (metadata)
1729
1735
  uns_group = table_group.create_group("uns")
1730
1736
  uns_group.attrs["encoding-type"] = "dict"
@@ -213,6 +213,30 @@ class BaseMSIReader(ABC):
213
213
  # Return only the valid indices and their corresponding intensities
214
214
  return indices[indices_valid], intensities[indices_valid]
215
215
 
216
+ def get_mass_axis_annotations(
217
+ self,
218
+ ) -> Optional[dict]:
219
+ """Get extra per-channel columns to store alongside the m/z axis.
220
+
221
+ Returns a mapping of column name to an array with one entry per
222
+ entry of :meth:`get_common_mass_axis`. These are written into the
223
+ table's ``var`` next to ``mz``.
224
+
225
+ This exists so a format whose native axis is not m/z can keep that
226
+ axis in the output. Time-of-flight instruments measure flight time
227
+ and derive m/z from a calibration, so storing the flight time makes
228
+ the conversion reversible without the reader: a later recalibration
229
+ can be applied to the stored times directly.
230
+
231
+ Annotations are dropped if their length does not match the axis the
232
+ converter actually writes, which is what happens when resampling is
233
+ enabled and the axis is rebuilt.
234
+
235
+ Returns:
236
+ Mapping of column name to per-channel values, or None.
237
+ """
238
+ return None
239
+
216
240
  def get_region_map(self) -> Optional[dict]:
217
241
  """Get per-pixel region mapping for multi-region datasets.
218
242
 
@@ -36,11 +36,12 @@ class MSIRegistry:
36
36
  self._lock = RLock()
37
37
  self._readers: Dict[str, Type[BaseMSIReader]] = {}
38
38
  self._converters: Dict[str, Type[BaseMSIConverter]] = {}
39
- # Extension mapping for file-based formats
39
+ # Extension mapping for file-based formats. '.raw' is deliberately
40
+ # absent: it is claimed by both Waters and PHI and can only be
41
+ # resolved by inspecting the path (see _detect_raw_format).
40
42
  self._extension_to_format = {
41
43
  ".imzml": "imzml",
42
44
  ".d": "bruker",
43
- ".raw": "waters",
44
45
  }
45
46
 
46
47
  def register_reader(
@@ -112,6 +113,25 @@ class MSIRegistry:
112
113
  pass
113
114
  return False
114
115
 
116
+ def _detect_phi_format(self, path: Path) -> bool:
117
+ """Check if path is a PHI SmartSoft-TOF .raw file.
118
+
119
+ PHI writes a single file whose ASCII header opens with the four-byte
120
+ ``SOFH`` magic. This is what distinguishes it from Waters .raw data,
121
+ which is a directory.
122
+
123
+ Args:
124
+ path: File path to check
125
+
126
+ Returns:
127
+ True if the file starts with the PHI SOFH magic
128
+ """
129
+ try:
130
+ with path.open("rb") as handle:
131
+ return handle.read(4) == b"SOFH"
132
+ except (OSError, PermissionError):
133
+ return False
134
+
115
135
  def detect_format(self, input_path: Path) -> str:
116
136
  """Detect MSI format from input path.
117
137
 
@@ -120,6 +140,7 @@ class MSIRegistry:
120
140
  - .d directories (Bruker timsTOF)
121
141
  - Folders with .dat + _poslog.txt (Bruker Rapiflex)
122
142
  - .raw directories (Waters MassLynx)
143
+ - .raw files (PHI SmartSoft-TOF ToF-SIMS)
123
144
  """
124
145
  if not input_path.exists():
125
146
  raise ValueError(f"Input path does not exist: {input_path}")
@@ -136,7 +157,7 @@ class MSIRegistry:
136
157
  if extension == ".d":
137
158
  return self._detect_bruker_d_format(input_path)
138
159
  if extension == ".raw":
139
- return self._detect_waters_raw_format(input_path)
160
+ return self._detect_raw_format(input_path)
140
161
  if input_path.is_dir():
141
162
  return self._detect_directory_format(input_path)
142
163
  self._raise_unsupported_format(input_path)
@@ -152,16 +173,25 @@ class MSIRegistry:
152
173
  return bruker_format
153
174
  raise ValueError("Bruker .d directory missing analysis " f"files: {input_path}")
154
175
 
155
- def _detect_waters_raw_format(self, input_path: Path) -> str:
156
- """Validate and detect Waters format from .raw directory."""
157
- if not input_path.is_dir():
176
+ def _detect_raw_format(self, input_path: Path) -> str:
177
+ """Resolve a .raw path to the vendor that wrote it.
178
+
179
+ Two vendors claim the extension and they are distinguished by shape:
180
+ Waters .raw is a directory of _FUNC*.DAT files, PHI .raw is a single
181
+ file whose header begins with the SOFH magic.
182
+ """
183
+ if input_path.is_dir():
184
+ if self._detect_waters_format(input_path):
185
+ return "waters"
158
186
  raise ValueError(
159
- "Waters format requires .raw directory, " f"got file: {input_path}"
187
+ "Waters .raw directory missing " f"_FUNC*.DAT files: {input_path}"
160
188
  )
161
- if self._detect_waters_format(input_path):
162
- return "waters"
189
+ if self._detect_phi_format(input_path):
190
+ return "phi"
163
191
  raise ValueError(
164
- "Waters .raw directory missing " f"_FUNC*.DAT files: {input_path}"
192
+ f"Unrecognised .raw file: {input_path}. Expected either a Waters "
193
+ "directory containing _FUNC*.DAT files, or a PHI SmartSoft-TOF "
194
+ "file beginning with the SOFH magic."
165
195
  )
166
196
 
167
197
  def _detect_directory_format(self, input_path: Path) -> str:
@@ -179,7 +209,8 @@ class MSIRegistry:
179
209
  ".imzml",
180
210
  ".d (timsTOF)",
181
211
  "folder (Rapiflex)",
182
- ".raw (Waters)",
212
+ ".raw directory (Waters)",
213
+ ".raw file (PHI SmartSoft-TOF)",
183
214
  ]
184
215
  raise ValueError(
185
216
  f"Unsupported format for '{input_path}'. "
@@ -265,8 +296,7 @@ def detect_format(input_path: Path) -> str:
265
296
  input_path: Path to MSI data file or directory
266
297
 
267
298
  Returns:
268
- Format name ('imzml', 'bruker', 'rapiflex',
269
- or 'waters')
299
+ Format name ('imzml', 'bruker', 'rapiflex', 'waters', or 'phi')
270
300
  """
271
301
  return _registry.detect_format(input_path)
272
302
 
@@ -30,12 +30,14 @@ Example usage:
30
30
 
31
31
  from .bruker_extractor import BrukerMetadataExtractor
32
32
  from .imzml_extractor import ImzMLMetadataExtractor
33
+ from .phi_extractor import PhiMetadataExtractor
33
34
  from .waters_extractor import WatersMetadataExtractor
34
35
 
35
36
  # Public API
36
37
  __all__ = [
37
38
  "BrukerMetadataExtractor",
38
39
  "ImzMLMetadataExtractor",
40
+ "PhiMetadataExtractor",
39
41
  "WatersMetadataExtractor",
40
42
  ]
41
43
 
@@ -46,6 +48,7 @@ FORMAT_EXTRACTORS = {
46
48
  "tsf": BrukerMetadataExtractor,
47
49
  "tdf": BrukerMetadataExtractor,
48
50
  "waters": WatersMetadataExtractor,
51
+ "phi": PhiMetadataExtractor,
49
52
  }
50
53
 
51
54