thyra 3.2.2__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.2 → thyra-3.3.0}/PKG-INFO +9 -2
  2. {thyra-3.2.2 → thyra-3.3.0}/README.md +8 -1
  3. {thyra-3.2.2 → thyra-3.3.0}/pyproject.toml +1 -1
  4. {thyra-3.2.2 → thyra-3.3.0}/thyra/__init__.py +1 -1
  5. {thyra-3.2.2 → thyra-3.3.0}/thyra/converters/spatialdata/base_spatialdata_converter.py +62 -4
  6. {thyra-3.2.2 → thyra-3.3.0}/thyra/converters/spatialdata/streaming_converter.py +9 -1
  7. {thyra-3.2.2 → thyra-3.3.0}/thyra/core/base_reader.py +24 -0
  8. {thyra-3.2.2 → thyra-3.3.0}/thyra/core/registry.py +43 -13
  9. {thyra-3.2.2 → thyra-3.3.0}/thyra/metadata/extractors/__init__.py +3 -0
  10. thyra-3.3.0/thyra/metadata/extractors/phi_extractor.py +178 -0
  11. {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/__init__.py +6 -1
  12. thyra-3.3.0/thyra/readers/phi/__init__.py +27 -0
  13. thyra-3.3.0/thyra/readers/phi/event_stream.py +327 -0
  14. thyra-3.3.0/thyra/readers/phi/mass_axis.py +169 -0
  15. thyra-3.3.0/thyra/readers/phi/phi_header.py +279 -0
  16. thyra-3.3.0/thyra/readers/phi/phi_reader.py +360 -0
  17. {thyra-3.2.2 → thyra-3.3.0}/thyra/utils/windows_paths.py +61 -4
  18. {thyra-3.2.2 → thyra-3.3.0}/LICENSE +0 -0
  19. {thyra-3.2.2 → thyra-3.3.0}/thyra/__main__.py +0 -0
  20. {thyra-3.2.2 → thyra-3.3.0}/thyra/alignment/__init__.py +0 -0
  21. {thyra-3.2.2 → thyra-3.3.0}/thyra/alignment/affine.py +0 -0
  22. {thyra-3.2.2 → thyra-3.3.0}/thyra/alignment/teaching_points.py +0 -0
  23. {thyra-3.2.2 → thyra-3.3.0}/thyra/config.py +0 -0
  24. {thyra-3.2.2 → thyra-3.3.0}/thyra/convert.py +0 -0
  25. {thyra-3.2.2 → thyra-3.3.0}/thyra/converters/__init__.py +0 -0
  26. {thyra-3.2.2 → thyra-3.3.0}/thyra/converters/spatialdata/__init__.py +0 -0
  27. {thyra-3.2.2 → thyra-3.3.0}/thyra/converters/spatialdata/_chunking.py +0 -0
  28. {thyra-3.2.2 → thyra-3.3.0}/thyra/converters/spatialdata/converter.py +0 -0
  29. {thyra-3.2.2 → thyra-3.3.0}/thyra/converters/spatialdata/spatialdata_2d_converter.py +0 -0
  30. {thyra-3.2.2 → thyra-3.3.0}/thyra/converters/spatialdata/spatialdata_3d_converter.py +0 -0
  31. {thyra-3.2.2 → thyra-3.3.0}/thyra/core/__init__.py +0 -0
  32. {thyra-3.2.2 → thyra-3.3.0}/thyra/core/base_converter.py +0 -0
  33. {thyra-3.2.2 → thyra-3.3.0}/thyra/core/base_extractor.py +0 -0
  34. {thyra-3.2.2 → thyra-3.3.0}/thyra/metadata/__init__.py +0 -0
  35. {thyra-3.2.2 → thyra-3.3.0}/thyra/metadata/extractors/bruker_extractor.py +0 -0
  36. {thyra-3.2.2 → thyra-3.3.0}/thyra/metadata/extractors/imzml_extractor.py +0 -0
  37. {thyra-3.2.2 → thyra-3.3.0}/thyra/metadata/extractors/waters_extractor.py +0 -0
  38. {thyra-3.2.2 → thyra-3.3.0}/thyra/metadata/ontology/__init__.py +0 -0
  39. {thyra-3.2.2 → thyra-3.3.0}/thyra/metadata/ontology/_ims.py +0 -0
  40. {thyra-3.2.2 → thyra-3.3.0}/thyra/metadata/ontology/_ms.py +0 -0
  41. {thyra-3.2.2 → thyra-3.3.0}/thyra/metadata/ontology/_uo.py +0 -0
  42. {thyra-3.2.2 → thyra-3.3.0}/thyra/metadata/ontology/cache.py +0 -0
  43. {thyra-3.2.2 → thyra-3.3.0}/thyra/metadata/types.py +0 -0
  44. {thyra-3.2.2 → thyra-3.3.0}/thyra/metadata/validator.py +0 -0
  45. {thyra-3.2.2 → thyra-3.3.0}/thyra/preview.py +0 -0
  46. {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/bruker/__init__.py +0 -0
  47. {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/bruker/base_bruker_reader.py +0 -0
  48. {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/bruker/folder_structure.py +0 -0
  49. {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/bruker/mis_parser.py +0 -0
  50. {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/bruker/rapiflex/__init__.py +0 -0
  51. {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/bruker/rapiflex/rapiflex_reader.py +0 -0
  52. {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/bruker/timstof/__init__.py +0 -0
  53. {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/bruker/timstof/sdk/__init__.py +0 -0
  54. {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/bruker/timstof/sdk/dll/LICENCE-BRUKER.txt +0 -0
  55. {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/bruker/timstof/sdk/dll/README.md +0 -0
  56. {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/bruker/timstof/sdk/dll/timsdata.dll +0 -0
  57. {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/bruker/timstof/sdk/dll/timsdata.so +0 -0
  58. {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/bruker/timstof/sdk/dll_manager.py +0 -0
  59. {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/bruker/timstof/sdk/platform_detector.py +0 -0
  60. {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/bruker/timstof/sdk/sdk_functions.py +0 -0
  61. {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/bruker/timstof/timstof_reader.py +0 -0
  62. {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/bruker/timstof/utils/__init__.py +0 -0
  63. {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/bruker/timstof/utils/batch_processor.py +0 -0
  64. {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/bruker/timstof/utils/coordinate_cache.py +0 -0
  65. {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/bruker/timstof/utils/mass_axis_builder.py +0 -0
  66. {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/bruker/timstof/utils/memory_manager.py +0 -0
  67. {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/imzml/__init__.py +0 -0
  68. {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/imzml/imzml_reader.py +0 -0
  69. {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/waters/__init__.py +0 -0
  70. {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/waters/imaging_grid.py +0 -0
  71. {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/waters/lib/MLReader.dll +0 -0
  72. {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/waters/lib/MassLynxRaw.dll +0 -0
  73. {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/waters/lib/libMLReader.so +0 -0
  74. {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/waters/lib/libMassLynxRaw.so +0 -0
  75. {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/waters/masslynx_lib.py +0 -0
  76. {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/waters/waters_reader.py +0 -0
  77. {thyra-3.2.2 → thyra-3.3.0}/thyra/resampling/__init__.py +0 -0
  78. {thyra-3.2.2 → thyra-3.3.0}/thyra/resampling/common_axis.py +0 -0
  79. {thyra-3.2.2 → thyra-3.3.0}/thyra/resampling/constants.py +0 -0
  80. {thyra-3.2.2 → thyra-3.3.0}/thyra/resampling/data_characteristics.py +0 -0
  81. {thyra-3.2.2 → thyra-3.3.0}/thyra/resampling/decision_tree.py +0 -0
  82. {thyra-3.2.2 → thyra-3.3.0}/thyra/resampling/gaps.py +0 -0
  83. {thyra-3.2.2 → thyra-3.3.0}/thyra/resampling/instrument_detectors.py +0 -0
  84. {thyra-3.2.2 → thyra-3.3.0}/thyra/resampling/mass_axis/__init__.py +0 -0
  85. {thyra-3.2.2 → thyra-3.3.0}/thyra/resampling/mass_axis/base_generator.py +0 -0
  86. {thyra-3.2.2 → thyra-3.3.0}/thyra/resampling/mass_axis/fticr_generator.py +0 -0
  87. {thyra-3.2.2 → thyra-3.3.0}/thyra/resampling/mass_axis/linear_generator.py +0 -0
  88. {thyra-3.2.2 → thyra-3.3.0}/thyra/resampling/mass_axis/linear_tof_generator.py +0 -0
  89. {thyra-3.2.2 → thyra-3.3.0}/thyra/resampling/mass_axis/orbitrap_generator.py +0 -0
  90. {thyra-3.2.2 → thyra-3.3.0}/thyra/resampling/mass_axis/reflector_tof_generator.py +0 -0
  91. {thyra-3.2.2 → thyra-3.3.0}/thyra/resampling/strategies/__init__.py +0 -0
  92. {thyra-3.2.2 → thyra-3.3.0}/thyra/resampling/strategies/base.py +0 -0
  93. {thyra-3.2.2 → thyra-3.3.0}/thyra/resampling/strategies/nearest_neighbor.py +0 -0
  94. {thyra-3.2.2 → thyra-3.3.0}/thyra/resampling/strategies/tic_preserving.py +0 -0
  95. {thyra-3.2.2 → thyra-3.3.0}/thyra/resampling/tic.py +0 -0
  96. {thyra-3.2.2 → thyra-3.3.0}/thyra/resampling/types.py +0 -0
  97. {thyra-3.2.2 → thyra-3.3.0}/thyra/tools/__init__.py +0 -0
  98. {thyra-3.2.2 → thyra-3.3.0}/thyra/tools/check_ontology.py +0 -0
  99. {thyra-3.2.2 → thyra-3.3.0}/thyra/tools/make_example_data.py +0 -0
  100. {thyra-3.2.2 → thyra-3.3.0}/thyra/utils/__init__.py +0 -0
  101. {thyra-3.2.2 → thyra-3.3.0}/thyra/utils/bruker_exceptions.py +0 -0
  102. {thyra-3.2.2 → thyra-3.3.0}/thyra/utils/logging_config.py +0 -0
  103. {thyra-3.2.2 → 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.2
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.2"
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.2"
39
+ __version__ = "3.3.0"
40
40
 
41
41
  # Import key components - avoid wildcard imports
42
42
  try:
@@ -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
 
@@ -1700,7 +1700,10 @@ class StreamingSpatialDataConverter(BaseSpatialDataConverter):
1700
1700
  var_group.attrs["encoding-type"] = "dataframe"
1701
1701
  var_group.attrs["encoding-version"] = "0.2.0"
1702
1702
  var_group.attrs["_index"] = "_index"
1703
- 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)
1704
1707
 
1705
1708
  mz_values = self._common_mass_axis
1706
1709
  if mz_values is None:
@@ -1723,6 +1726,11 @@ class StreamingSpatialDataConverter(BaseSpatialDataConverter):
1723
1726
  a.attrs["encoding-type"] = "array"
1724
1727
  a.attrs["encoding-version"] = "0.2.0"
1725
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
+
1726
1734
  # uns (metadata)
1727
1735
  uns_group = table_group.create_group("uns")
1728
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
 
@@ -0,0 +1,178 @@
1
+ # thyra/metadata/extractors/phi_extractor.py
2
+ """Metadata extractor for PHI SmartSoft-TOF ToF-SIMS data.
3
+
4
+ Reads from the parsed acquisition header and the reader's aggregated event
5
+ index, so no additional file passes are needed.
6
+ """
7
+
8
+ import json
9
+ import logging
10
+ from typing import TYPE_CHECKING, Any, Dict
11
+
12
+ import numpy as np
13
+
14
+ from ...core.base_extractor import MetadataExtractor
15
+ from ..types import ComprehensiveMetadata, EssentialMetadata
16
+
17
+ if TYPE_CHECKING:
18
+ from ...readers.phi.phi_reader import PhiReader
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class PhiMetadataExtractor(MetadataExtractor):
24
+ """PHI-specific metadata extractor.
25
+
26
+ Args:
27
+ reader: The :class:`PhiReader` to describe.
28
+ """
29
+
30
+ def __init__(self, reader: "PhiReader"):
31
+ """Initialise the extractor.
32
+
33
+ Args:
34
+ reader: The :class:`PhiReader` to describe.
35
+ """
36
+ super().__init__(reader)
37
+ self._reader = reader
38
+
39
+ def _extract_essential_impl(self) -> EssentialMetadata:
40
+ """Extract metadata needed to drive conversion."""
41
+ reader = self._reader
42
+ dimensions = reader.dimensions
43
+ n_x, n_y, _ = dimensions
44
+
45
+ peak_counts = reader.get_peak_counts_per_pixel()
46
+ assert peak_counts is not None
47
+ total_peaks = int(peak_counts.sum())
48
+ n_spectra = int(np.count_nonzero(peak_counts))
49
+
50
+ pixel_size = None
51
+ size_um = reader.pixel_size_um
52
+ if size_um:
53
+ pixel_size = (float(size_um), float(size_um))
54
+
55
+ return EssentialMetadata(
56
+ dimensions=dimensions,
57
+ coordinate_bounds=(0.0, float(n_x - 1), 0.0, float(n_y - 1)),
58
+ mass_range=reader.mass_axis.mass_range,
59
+ pixel_size=pixel_size,
60
+ n_spectra=n_spectra,
61
+ total_peaks=total_peaks,
62
+ estimated_memory_gb=(total_peaks * 2 * 8) / (1024**3),
63
+ source_path=str(reader.data_path),
64
+ coordinate_offsets=(0, 0, 0),
65
+ # A sparse histogram on the detector's regular time-channel grid,
66
+ # not a peak-picked list.
67
+ spectrum_type="profile spectrum",
68
+ peak_counts_per_pixel=peak_counts,
69
+ )
70
+
71
+ def _extract_comprehensive_impl(self) -> ComprehensiveMetadata:
72
+ """Extract full metadata including vendor-specific detail."""
73
+ return ComprehensiveMetadata(
74
+ essential=self.get_essential(),
75
+ format_specific=self._extract_format_specific(),
76
+ acquisition_params=self._extract_acquisition_params(),
77
+ instrument_info=self._extract_instrument_info(),
78
+ raw_metadata=self._extract_raw_metadata(),
79
+ )
80
+
81
+ def _extract_format_specific(self) -> Dict[str, Any]:
82
+ reader = self._reader
83
+ header = reader.header
84
+ index = reader.block_index
85
+ axis = reader.mass_axis
86
+ return {
87
+ "format": "PHI SmartSoft-TOF raw",
88
+ "raw_file_format_version": header.entries.get("RawFileFormatVersion"),
89
+ "header_size": header.header_size,
90
+ "image_pixels": header.image_pixels,
91
+ "n_mass_channels": len(axis),
92
+ "bin_size_ns": header.bin_size_ns,
93
+ "flight_time_range_us": (
94
+ header.start_flight_time_us,
95
+ header.stop_flight_time_us,
96
+ ),
97
+ "n_event_blocks": len(index.data_spans),
98
+ "n_records": index.n_records,
99
+ "frames_in_stream": index.n_frames,
100
+ # Zarr group members must be named, so the block ids are keyed
101
+ # as strings rather than the ints they are in the file.
102
+ "block_id_counts": {
103
+ f"block_{block_id}": int(count)
104
+ for block_id, count in sorted(index.block_counts.items())
105
+ },
106
+ "block_chain_complete": index.clean,
107
+ "mosaic_tiles": index.tile_grid,
108
+ "declared_tiles": header.declared_tiles,
109
+ "pixel_size_source": reader.pixel_size_source,
110
+ "raster_size_um": header.raster_size_um,
111
+ "raster_size_calibration": header.raster_size_calibration,
112
+ }
113
+
114
+ def _extract_acquisition_params(self) -> Dict[str, Any]:
115
+ header = self._reader.header
116
+ acq = header.sections.get("Acq Base", {})
117
+ return {
118
+ "polarity": header.polarity,
119
+ "technique": header.entries.get("Technique"),
120
+ "acquisition_type": header.entries.get("AcquisitionType"),
121
+ "n_frames": header.n_frames,
122
+ "msms_active": header.msms_active,
123
+ "primary_species": header.entries.get("AcqPrimSpecies"),
124
+ "primary_current_na": header.entries.get("AcqPrimCurrent(nA)"),
125
+ "pulse_width_ns": header.entries.get("AcqPulseWidth"),
126
+ "raster_pattern": acq.get("Raster Pattern"),
127
+ "raster_resolution": acq.get("Raster Resolution"),
128
+ "mass_range_amu": (header.start_mz, header.stop_mz),
129
+ "acquisition_date": header.entries.get("AcqFileDate"),
130
+ }
131
+
132
+ def _extract_instrument_info(self) -> Dict[str, Any]:
133
+ header = self._reader.header
134
+ return {
135
+ "vendor": "Physical Electronics (PHI)",
136
+ "software_version": header.entries.get("SoftwareVersion"),
137
+ "platform": header.entries.get("Platform"),
138
+ "primary_gun": header.entries.get("AcqPrimGun"),
139
+ "emitter": header.entries.get("LmigEmitter"),
140
+ "detector_scan_mode": header.entries.get("DetectorScanMode"),
141
+ }
142
+
143
+ def _extract_raw_metadata(self) -> Dict[str, Any]:
144
+ reader = self._reader
145
+ header = reader.header
146
+ axis = reader.mass_axis
147
+ calibration: Dict[str, Any] = {
148
+ "source": reader.calibration_source,
149
+ "mass_slope_used": axis.slope,
150
+ "mass_offset_used": axis.offset,
151
+ "header_mass_slope": header.mass_slope,
152
+ "header_mass_offset": header.mass_offset,
153
+ "header_calibrated_flag": header.entries.get("Calibrated"),
154
+ # JSON strings: AnnData/zarr cannot round-trip a list of dicts.
155
+ "acquisition_calibrants": json.dumps(
156
+ [
157
+ {
158
+ "measured_mz": c.measured_mz,
159
+ "species": c.species,
160
+ "theoretical_mz": c.theoretical_mz,
161
+ }
162
+ for c in header.calibrants
163
+ ]
164
+ ),
165
+ }
166
+ appended = reader.block_index.appended
167
+ if appended:
168
+ calibration["appended_blocks"] = json.dumps(appended)
169
+ # The vendor's own key names are not valid Zarr group members --
170
+ # 'Mass/Time' contains a forward slash -- so the header is preserved
171
+ # verbatim as JSON rather than being mangled into a group hierarchy.
172
+ return {
173
+ "header_entries": json.dumps(dict(header.entries)),
174
+ "header_sections": json.dumps(
175
+ {k: dict(v) for k, v in header.sections.items()}
176
+ ),
177
+ "calibration": calibration,
178
+ }
@@ -5,12 +5,17 @@ This package provides reader implementations for different MSI data formats:
5
5
  - ImzML: Open format for MSI data
6
6
  - Bruker: timsTOF and Rapiflex data
7
7
  - Waters: MassLynx .raw imaging data
8
+ - PHI: SmartSoft-TOF .raw ToF-SIMS data
8
9
 
9
10
  Reader organization:
10
11
  - bruker/: All Bruker formats (timsTOF, Rapiflex)
11
12
  - imzml/: ImzML format reader
12
13
  - waters/: Waters .raw format reader (native DLL via ctypes)
14
+ - phi/: PHI SmartSoft-TOF .raw reader (pure Python, no vendor SDK)
15
+
16
+ Note that ``.raw`` is claimed by two vendors: Waters stores a directory,
17
+ PHI a single file. See :meth:`thyra.core.registry.MSIRegistry.detect_format`.
13
18
  """
14
19
 
15
20
  # Import readers to trigger registration
16
- from . import bruker, imzml, waters # noqa: F401
21
+ from . import bruker, imzml, phi, waters # noqa: F401
@@ -0,0 +1,27 @@
1
+ # thyra/readers/phi/__init__.py
2
+ """PHI SmartSoft-TOF MSI reader implementation.
3
+
4
+ Reads ToF-SIMS imaging data from PHI (Physical Electronics) nanoTOF
5
+ instruments, stored as a single ``.raw`` file holding an ASCII acquisition
6
+ header followed by a block-structured stream of individual ion events.
7
+
8
+ No vendor SDK or native library is required.
9
+ """
10
+
11
+ from .event_stream import BlockIndex, DataSpan, iter_event_batches, scan_blocks
12
+ from .mass_axis import PhiMassAxis, build_mass_axis
13
+ from .phi_header import Calibrant, PhiHeader, parse_phi_header
14
+ from .phi_reader import PhiReader
15
+
16
+ __all__ = [
17
+ "BlockIndex",
18
+ "Calibrant",
19
+ "DataSpan",
20
+ "PhiHeader",
21
+ "PhiMassAxis",
22
+ "PhiReader",
23
+ "build_mass_axis",
24
+ "iter_event_batches",
25
+ "parse_phi_header",
26
+ "scan_blocks",
27
+ ]