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.
- {thyra-3.2.2 → thyra-3.3.0}/PKG-INFO +9 -2
- {thyra-3.2.2 → thyra-3.3.0}/README.md +8 -1
- {thyra-3.2.2 → thyra-3.3.0}/pyproject.toml +1 -1
- {thyra-3.2.2 → thyra-3.3.0}/thyra/__init__.py +1 -1
- {thyra-3.2.2 → thyra-3.3.0}/thyra/converters/spatialdata/base_spatialdata_converter.py +62 -4
- {thyra-3.2.2 → thyra-3.3.0}/thyra/converters/spatialdata/streaming_converter.py +9 -1
- {thyra-3.2.2 → thyra-3.3.0}/thyra/core/base_reader.py +24 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/core/registry.py +43 -13
- {thyra-3.2.2 → thyra-3.3.0}/thyra/metadata/extractors/__init__.py +3 -0
- thyra-3.3.0/thyra/metadata/extractors/phi_extractor.py +178 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/__init__.py +6 -1
- thyra-3.3.0/thyra/readers/phi/__init__.py +27 -0
- thyra-3.3.0/thyra/readers/phi/event_stream.py +327 -0
- thyra-3.3.0/thyra/readers/phi/mass_axis.py +169 -0
- thyra-3.3.0/thyra/readers/phi/phi_header.py +279 -0
- thyra-3.3.0/thyra/readers/phi/phi_reader.py +360 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/utils/windows_paths.py +61 -4
- {thyra-3.2.2 → thyra-3.3.0}/LICENSE +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/__main__.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/alignment/__init__.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/alignment/affine.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/alignment/teaching_points.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/config.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/convert.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/converters/__init__.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/converters/spatialdata/__init__.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/converters/spatialdata/_chunking.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/converters/spatialdata/converter.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/converters/spatialdata/spatialdata_2d_converter.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/converters/spatialdata/spatialdata_3d_converter.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/core/__init__.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/core/base_converter.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/core/base_extractor.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/metadata/__init__.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/metadata/extractors/bruker_extractor.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/metadata/extractors/imzml_extractor.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/metadata/extractors/waters_extractor.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/metadata/ontology/__init__.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/metadata/ontology/_ims.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/metadata/ontology/_ms.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/metadata/ontology/_uo.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/metadata/ontology/cache.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/metadata/types.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/metadata/validator.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/preview.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/bruker/__init__.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/bruker/base_bruker_reader.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/bruker/folder_structure.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/bruker/mis_parser.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/bruker/rapiflex/__init__.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/bruker/rapiflex/rapiflex_reader.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/bruker/timstof/__init__.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/bruker/timstof/sdk/__init__.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/bruker/timstof/sdk/dll/LICENCE-BRUKER.txt +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/bruker/timstof/sdk/dll/README.md +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/bruker/timstof/sdk/dll/timsdata.dll +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/bruker/timstof/sdk/dll/timsdata.so +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/bruker/timstof/sdk/dll_manager.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/bruker/timstof/sdk/platform_detector.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/bruker/timstof/sdk/sdk_functions.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/bruker/timstof/timstof_reader.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/bruker/timstof/utils/__init__.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/bruker/timstof/utils/batch_processor.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/bruker/timstof/utils/coordinate_cache.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/bruker/timstof/utils/mass_axis_builder.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/bruker/timstof/utils/memory_manager.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/imzml/__init__.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/imzml/imzml_reader.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/waters/__init__.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/waters/imaging_grid.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/waters/lib/MLReader.dll +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/waters/lib/MassLynxRaw.dll +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/waters/lib/libMLReader.so +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/waters/lib/libMassLynxRaw.so +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/waters/masslynx_lib.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/readers/waters/waters_reader.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/resampling/__init__.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/resampling/common_axis.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/resampling/constants.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/resampling/data_characteristics.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/resampling/decision_tree.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/resampling/gaps.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/resampling/instrument_detectors.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/resampling/mass_axis/__init__.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/resampling/mass_axis/base_generator.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/resampling/mass_axis/fticr_generator.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/resampling/mass_axis/linear_generator.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/resampling/mass_axis/linear_tof_generator.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/resampling/mass_axis/orbitrap_generator.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/resampling/mass_axis/reflector_tof_generator.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/resampling/strategies/__init__.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/resampling/strategies/base.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/resampling/strategies/nearest_neighbor.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/resampling/strategies/tic_preserving.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/resampling/tic.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/resampling/types.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/tools/__init__.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/tools/check_ontology.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/tools/make_example_data.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/utils/__init__.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/utils/bruker_exceptions.py +0 -0
- {thyra-3.2.2 → thyra-3.3.0}/thyra/utils/logging_config.py +0 -0
- {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.
|
|
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.
|
|
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>"]
|
|
@@ -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
|
-
|
|
1439
|
-
index=[f"mz_{i}" for i in range(
|
|
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
|
-
|
|
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.
|
|
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
|
|
156
|
-
"""
|
|
157
|
-
|
|
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
|
|
187
|
+
"Waters .raw directory missing " f"_FUNC*.DAT files: {input_path}"
|
|
160
188
|
)
|
|
161
|
-
if self.
|
|
162
|
-
return "
|
|
189
|
+
if self._detect_phi_format(input_path):
|
|
190
|
+
return "phi"
|
|
163
191
|
raise ValueError(
|
|
164
|
-
"
|
|
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
|
+
]
|