thyra 2.0.0__tar.gz → 2.0.2__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-2.0.0 → thyra-2.0.2}/PKG-INFO +1 -1
- {thyra-2.0.0 → thyra-2.0.2}/pyproject.toml +1 -1
- {thyra-2.0.0 → thyra-2.0.2}/thyra/__init__.py +1 -1
- {thyra-2.0.0 → thyra-2.0.2}/thyra/convert.py +6 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/converters/spatialdata/base_spatialdata_converter.py +47 -6
- {thyra-2.0.0 → thyra-2.0.2}/thyra/converters/spatialdata/streaming_converter.py +41 -7
- {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/imzml/imzml_reader.py +352 -45
- thyra-2.0.2/thyra/resampling/strategies/tic_preserving.py +126 -0
- thyra-2.0.2/thyra/resampling/tic.py +146 -0
- thyra-2.0.0/thyra/resampling/strategies/tic_preserving.py +0 -109
- {thyra-2.0.0 → thyra-2.0.2}/LICENSE +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/README.md +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/__main__.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/alignment/__init__.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/alignment/affine.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/alignment/teaching_points.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/config.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/converters/__init__.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/converters/spatialdata/__init__.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/converters/spatialdata/_chunking.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/converters/spatialdata/converter.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/converters/spatialdata/spatialdata_2d_converter.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/converters/spatialdata/spatialdata_3d_converter.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/core/__init__.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/core/base_converter.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/core/base_extractor.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/core/base_reader.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/core/registry.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/metadata/__init__.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/metadata/extractors/__init__.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/metadata/extractors/bruker_extractor.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/metadata/extractors/imzml_extractor.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/metadata/extractors/waters_extractor.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/metadata/ontology/__init__.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/metadata/ontology/_ims.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/metadata/ontology/_ms.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/metadata/ontology/_uo.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/metadata/ontology/cache.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/metadata/types.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/metadata/validator.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/preview.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/__init__.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/bruker/__init__.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/bruker/base_bruker_reader.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/bruker/folder_structure.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/bruker/mis_parser.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/bruker/rapiflex/__init__.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/bruker/rapiflex/rapiflex_reader.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/bruker/timstof/__init__.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/bruker/timstof/sdk/__init__.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/bruker/timstof/sdk/dll/LICENCE-BRUKER.txt +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/bruker/timstof/sdk/dll/README.md +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/bruker/timstof/sdk/dll/timsdata.dll +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/bruker/timstof/sdk/dll/timsdata.so +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/bruker/timstof/sdk/dll_manager.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/bruker/timstof/sdk/platform_detector.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/bruker/timstof/sdk/sdk_functions.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/bruker/timstof/timstof_reader.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/bruker/timstof/utils/__init__.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/bruker/timstof/utils/batch_processor.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/bruker/timstof/utils/coordinate_cache.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/bruker/timstof/utils/mass_axis_builder.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/bruker/timstof/utils/memory_manager.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/imzml/__init__.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/waters/__init__.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/waters/imaging_grid.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/waters/lib/MLReader.dll +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/waters/lib/MassLynxRaw.dll +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/waters/lib/libMLReader.so +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/waters/lib/libMassLynxRaw.so +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/waters/masslynx_lib.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/readers/waters/waters_reader.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/resampling/__init__.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/resampling/common_axis.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/resampling/constants.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/resampling/data_characteristics.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/resampling/decision_tree.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/resampling/instrument_detectors.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/resampling/mass_axis/__init__.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/resampling/mass_axis/base_generator.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/resampling/mass_axis/fticr_generator.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/resampling/mass_axis/linear_generator.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/resampling/mass_axis/linear_tof_generator.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/resampling/mass_axis/orbitrap_generator.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/resampling/mass_axis/reflector_tof_generator.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/resampling/strategies/__init__.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/resampling/strategies/base.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/resampling/strategies/nearest_neighbor.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/resampling/types.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/tools/__init__.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/tools/check_ontology.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/tools/make_example_data.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/utils/__init__.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/utils/bruker_exceptions.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/utils/logging_config.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/utils/windows_paths.py +0 -0
- {thyra-2.0.0 → thyra-2.0.2}/thyra/utils/zarr_atomic_write.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
|
|
|
4
4
|
|
|
5
5
|
[tool.poetry]
|
|
6
6
|
name = "thyra"
|
|
7
|
-
version = "2.0.
|
|
7
|
+
version = "2.0.2"
|
|
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>"]
|
|
@@ -281,6 +281,12 @@ def convert_msi(
|
|
|
281
281
|
to include. Default: None (no filtering).
|
|
282
282
|
- use_recalibrated_state: bool - For Bruker data,
|
|
283
283
|
use active/recalibrated calibration (default True).
|
|
284
|
+
- max_mass_axis_length: int - For processed-mode imzML
|
|
285
|
+
converted with --no-resample, give up once the raw
|
|
286
|
+
mass axis exceeds this many unique m/z values.
|
|
287
|
+
Default: None (unlimited). Useful when the peak
|
|
288
|
+
lists share no m/z values, where the raw axis grows
|
|
289
|
+
to roughly one column per peak in the whole dataset.
|
|
284
290
|
sparse_format: Sparse matrix format ('csc' or 'csr')
|
|
285
291
|
include_optical: Include optical images (default: True)
|
|
286
292
|
apply_optical_alignment: If True (default) and the MSI source
|
|
@@ -17,6 +17,7 @@ from ...core.base_converter import BaseMSIConverter, PixelSizeSource
|
|
|
17
17
|
from ...core.base_reader import BaseMSIReader
|
|
18
18
|
from ...metadata.types import ComprehensiveMetadata, EssentialMetadata
|
|
19
19
|
from ...resampling import ResamplingDecisionTree, ResamplingMethod
|
|
20
|
+
from ...resampling.tic import preserved_tic, rescale_to_preserved_tic
|
|
20
21
|
from ...resampling.types import ResamplingConfig
|
|
21
22
|
from ...utils.zarr_atomic_write import install_windows_atomic_write_retry
|
|
22
23
|
from ._chunking import image_chunks
|
|
@@ -1091,11 +1092,38 @@ class BaseSpatialDataConverter(BaseMSIConverter, ABC):
|
|
|
1091
1092
|
def _tic_preserving_resample(
|
|
1092
1093
|
self, mzs: NDArray[np.float64], intensities: NDArray[np.float64]
|
|
1093
1094
|
) -> NDArray[np.float64]:
|
|
1094
|
-
"""Resample
|
|
1095
|
+
"""Resample onto the common axis, preserving total ion current.
|
|
1096
|
+
|
|
1097
|
+
Linear interpolation onto the target axis, followed by rescaling so
|
|
1098
|
+
the resampled spectrum carries the same total ion current as the
|
|
1099
|
+
input -- the behaviour ``ResamplingMethod.TIC_PRESERVING`` and
|
|
1100
|
+
docs/resampling.md both describe.
|
|
1101
|
+
|
|
1102
|
+
The rescaling is not cosmetic. Interpolation samples the spectrum at
|
|
1103
|
+
every target point, so the raw interpolated sum scales with the
|
|
1104
|
+
density of the target axis rather than staying fixed. Onto the
|
|
1105
|
+
default 190,000-bin axis, 4,000 source points came back with 47x the
|
|
1106
|
+
input TIC, and a 150-peak centroid spectrum with over 1000x.
|
|
1107
|
+
|
|
1108
|
+
The TIC preserved is the share of the spectrum lying inside the
|
|
1109
|
+
target axis range -- see ``thyra.resampling.tic``, which holds the
|
|
1110
|
+
rule and the reasoning, and which ``TICPreservingStrategy`` uses too.
|
|
1111
|
+
When the axis spans the spectrum, which is the default, that share
|
|
1112
|
+
is the whole input TIC.
|
|
1113
|
+
|
|
1114
|
+
Args:
|
|
1115
|
+
mzs: Original m/z values from the spectrum.
|
|
1116
|
+
intensities: Corresponding intensity values.
|
|
1117
|
+
|
|
1118
|
+
Returns:
|
|
1119
|
+
Intensities on the common mass axis, of the axis's length.
|
|
1120
|
+
"""
|
|
1095
1121
|
if self._common_mass_axis is None:
|
|
1096
1122
|
raise RuntimeError("Common mass axis is not initialized")
|
|
1123
|
+
|
|
1124
|
+
axis = self._common_mass_axis
|
|
1097
1125
|
if mzs.size == 0:
|
|
1098
|
-
return np.zeros(len(
|
|
1126
|
+
return np.zeros(len(axis))
|
|
1099
1127
|
|
|
1100
1128
|
# OPTIMIZED: Check if already sorted to avoid unnecessary sorting
|
|
1101
1129
|
if np.all(mzs[:-1] <= mzs[1:]):
|
|
@@ -1108,18 +1136,31 @@ class BaseSpatialDataConverter(BaseMSIConverter, ABC):
|
|
|
1108
1136
|
mzs_sorted = mzs[sort_indices]
|
|
1109
1137
|
intensities_sorted = intensities[sort_indices]
|
|
1110
1138
|
|
|
1139
|
+
if mzs_sorted.size == 1:
|
|
1140
|
+
# np.interp cannot interpolate a lone point onto a grid that
|
|
1141
|
+
# does not contain it -- it would return all zeros and lose the
|
|
1142
|
+
# peak. Place it in its nearest bin, as the nearest_neighbor
|
|
1143
|
+
# path and TICPreservingStrategy both do.
|
|
1144
|
+
resampled = np.zeros(len(axis))
|
|
1145
|
+
target_tic = preserved_tic(
|
|
1146
|
+
mzs_sorted, intensities_sorted, float(axis[0]), float(axis[-1])
|
|
1147
|
+
)
|
|
1148
|
+
if target_tic > 0.0:
|
|
1149
|
+
resampled[int(np.argmin(np.abs(axis - mzs_sorted[0])))] = target_tic
|
|
1150
|
+
return resampled
|
|
1151
|
+
|
|
1111
1152
|
# Interpolate onto the common mass axis (np.interp is highly optimized)
|
|
1112
|
-
if self._common_mass_axis is None:
|
|
1113
|
-
raise RuntimeError("Common mass axis is not initialized")
|
|
1114
1153
|
resampled = np.interp(
|
|
1115
|
-
|
|
1154
|
+
axis,
|
|
1116
1155
|
mzs_sorted,
|
|
1117
1156
|
intensities_sorted,
|
|
1118
1157
|
left=0,
|
|
1119
1158
|
right=0,
|
|
1120
1159
|
)
|
|
1121
1160
|
|
|
1122
|
-
|
|
1161
|
+
# Rescale to the required TIC. This is the step that makes the
|
|
1162
|
+
# method live up to its name.
|
|
1163
|
+
return rescale_to_preserved_tic(resampled, axis, mzs_sorted, intensities_sorted)
|
|
1123
1164
|
|
|
1124
1165
|
def _process_resampled_spectrum(
|
|
1125
1166
|
self,
|
|
@@ -40,6 +40,12 @@ if SPATIALDATA_AVAILABLE:
|
|
|
40
40
|
|
|
41
41
|
logger = logging.getLogger(__name__)
|
|
42
42
|
|
|
43
|
+
# Elements per chunk when building the string index arrays. Bounds the
|
|
44
|
+
# transient cost of formatting to this many entries rather than the whole
|
|
45
|
+
# axis; measured at 17.5 bytes per entry at peak against 88 for a one-shot
|
|
46
|
+
# build, and 32 for an unchunked vectorised one.
|
|
47
|
+
_INDEX_BUILD_CHUNK = 1_000_000
|
|
48
|
+
|
|
43
49
|
|
|
44
50
|
class StreamingSpatialDataConverter(BaseSpatialDataConverter):
|
|
45
51
|
"""Memory-efficient streaming converter for MSI data to SpatialData format.
|
|
@@ -491,11 +497,19 @@ class StreamingSpatialDataConverter(BaseSpatialDataConverter):
|
|
|
491
497
|
indptr_arr.attrs["encoding-type"] = "array"
|
|
492
498
|
indptr_arr.attrs["encoding-version"] = "0.2.0"
|
|
493
499
|
|
|
500
|
+
# Column indices are bounded by n_cols, not by total_nnz, so they need
|
|
501
|
+
# their own dtype decision. Hardcoding int32 here wrapped silently
|
|
502
|
+
# above 2,147,483,647 m/z bins: zarr truncates an oversized write
|
|
503
|
+
# without warning, and the resulting negative indices survive all the
|
|
504
|
+
# way into scipy without an error. This is the same switch point scipy
|
|
505
|
+
# uses, so the matrix rebuilt from these arrays needs no cast.
|
|
506
|
+
indices_dtype = np.int64 if n_cols > np.iinfo(np.int32).max else np.int32
|
|
507
|
+
|
|
494
508
|
chunk_size_zarr = min(total_nnz, 1000000)
|
|
495
509
|
indices_arr = X_group.create_array(
|
|
496
510
|
"indices",
|
|
497
511
|
shape=(total_nnz,),
|
|
498
|
-
dtype=
|
|
512
|
+
dtype=indices_dtype,
|
|
499
513
|
chunks=(chunk_size_zarr,),
|
|
500
514
|
)
|
|
501
515
|
indices_arr.attrs["encoding-type"] = "array"
|
|
@@ -566,7 +580,11 @@ class StreamingSpatialDataConverter(BaseSpatialDataConverter):
|
|
|
566
580
|
pos = write_pos[pixel_idx]
|
|
567
581
|
positions = np.arange(pos, pos + nnz)
|
|
568
582
|
buf_positions.append(positions)
|
|
569
|
-
|
|
583
|
+
# Take the dtype from the destination array rather than
|
|
584
|
+
# re-deriving it, so the buffer and the store cannot drift
|
|
585
|
+
# apart. Zarr accepts an oversized write and truncates it
|
|
586
|
+
# silently, so a mismatch here would be invisible.
|
|
587
|
+
buf_indices.append(mz_indices.astype(indices_arr.dtype))
|
|
570
588
|
buf_data.append(resampled_ints.astype(np.float64))
|
|
571
589
|
write_pos[pixel_idx] += nnz
|
|
572
590
|
buf_size += nnz
|
|
@@ -716,11 +734,15 @@ class StreamingSpatialDataConverter(BaseSpatialDataConverter):
|
|
|
716
734
|
|
|
717
735
|
logger.info(f"Loaded CSR components: {len(data):,} entries")
|
|
718
736
|
|
|
719
|
-
#
|
|
737
|
+
# indices already carries the dtype chosen at write time, keyed on the
|
|
738
|
+
# column count (see _coo_setup_zarr_arrays), so there is nothing to fix
|
|
739
|
+
# up here. Upcasting it was never a safeguard anyway: it widened values
|
|
740
|
+
# that had already been truncated on the way in, and it was keyed on
|
|
741
|
+
# the wrong quantity. indptr is bounded by nnz, hence the check below.
|
|
742
|
+
# copy=False makes the no-op case free rather than a full-size copy.
|
|
720
743
|
if len(data) > np.iinfo(np.int32).max:
|
|
721
744
|
logger.info("Large dataset detected, using 64-bit sparse matrix indices")
|
|
722
|
-
indptr = indptr.astype(np.int64)
|
|
723
|
-
indices = indices.astype(np.int64)
|
|
745
|
+
indptr = indptr.astype(np.int64, copy=False)
|
|
724
746
|
|
|
725
747
|
# Create CSR matrix directly (no COO intermediate)
|
|
726
748
|
sparse_matrix: Union[sparse.csr_matrix, sparse.csc_matrix] = sparse.csr_matrix(
|
|
@@ -1417,7 +1439,9 @@ class StreamingSpatialDataConverter(BaseSpatialDataConverter):
|
|
|
1417
1439
|
|
|
1418
1440
|
y_values = np.repeat(np.arange(n_y, dtype=np.int32), n_x)
|
|
1419
1441
|
x_values = np.tile(np.arange(n_x, dtype=np.int32), n_y)
|
|
1420
|
-
|
|
1442
|
+
# Same reasoning as the var index below, but n_rows is the pixel count
|
|
1443
|
+
# and stays far smaller than n_cols, so one shot needs no chunking.
|
|
1444
|
+
instance_ids = np.arange(n_rows, dtype=np.int64).astype(str_dtype)
|
|
1421
1445
|
spatial_x = x_values.astype(np.float64) * self.pixel_size_um
|
|
1422
1446
|
spatial_y = y_values.astype(np.float64) * self.pixel_size_um
|
|
1423
1447
|
|
|
@@ -1464,7 +1488,17 @@ class StreamingSpatialDataConverter(BaseSpatialDataConverter):
|
|
|
1464
1488
|
mz_values = self._common_mass_axis
|
|
1465
1489
|
if mz_values is None:
|
|
1466
1490
|
raise RuntimeError("Common mass axis not initialized")
|
|
1467
|
-
|
|
1491
|
+
# Built in chunks rather than from a list comprehension. Materialising
|
|
1492
|
+
# n_cols Python str objects first costs about 88 bytes per entry at
|
|
1493
|
+
# peak, against 17.5 for this; at 10 million bins that is 883 MB
|
|
1494
|
+
# versus 175 MB, on a path whose docstring promises roughly 200 MB
|
|
1495
|
+
# regardless of dataset size. The values are identical.
|
|
1496
|
+
mz_index = np.empty(n_cols, dtype=str_dtype)
|
|
1497
|
+
for start in range(0, n_cols, _INDEX_BUILD_CHUNK):
|
|
1498
|
+
stop = min(start + _INDEX_BUILD_CHUNK, n_cols)
|
|
1499
|
+
mz_index[start:stop] = np.strings.add(
|
|
1500
|
+
"mz_", np.arange(start, stop, dtype=np.int64).astype(str_dtype)
|
|
1501
|
+
)
|
|
1468
1502
|
a = var_group.create_array("_index", data=mz_index)
|
|
1469
1503
|
a.attrs["encoding-type"] = "string-array"
|
|
1470
1504
|
a.attrs["encoding-version"] = "0.2.0"
|
|
@@ -15,6 +15,308 @@ from ...metadata.extractors.imzml_extractor import ImzMLMetadataExtractor
|
|
|
15
15
|
|
|
16
16
|
logger = logging.getLogger(__name__)
|
|
17
17
|
|
|
18
|
+
# Scratch buffer floor for the processed-mode mass axis build, in ELEMENTS.
|
|
19
|
+
# 1Mi elements is 8 MiB at float64. Measured at a 512 MiB m/z payload on a
|
|
20
|
+
# shared grid (peak extra RSS / wall clock): 2^14 -> 7.14 MiB / 3.04 s,
|
|
21
|
+
# 2^17 -> 6.90 / 3.06, 2^20 -> 15.30 / 2.30, 2^22 -> 40.29 / 2.11,
|
|
22
|
+
# 2^24 -> 148.38 / 2.06. A 1024x sweep of the knob moves wall clock 1.5x with
|
|
23
|
+
# no cliff anywhere, so this is a knee rather than a fragile constant.
|
|
24
|
+
_MASS_AXIS_BATCH_VALUES = 1 << 20
|
|
25
|
+
|
|
26
|
+
# Block size for the searchsorted probe in ``_filter_absent``, in ELEMENTS.
|
|
27
|
+
# Bounds that function's temporaries to O(block) rather than O(batch).
|
|
28
|
+
_MASS_AXIS_PROBE_BLOCK = 1 << 20
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _dedupe_sorted(a: NDArray[Any]) -> NDArray[Any]:
|
|
32
|
+
"""Deduplicate an ALREADY-SORTED array, as ``np.unique`` would.
|
|
33
|
+
|
|
34
|
+
Reproduces numpy's ``_unique1d`` mask including its ``equal_nan=True``
|
|
35
|
+
behaviour, which collapses a trailing run of NaNs to its first element.
|
|
36
|
+
Unlike ``np.unique`` it does not make the extra full-size ``flatten()``
|
|
37
|
+
copy, which is one of the three payload-sized allocations the old
|
|
38
|
+
collect-everything-then-unique build paid for.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
a: A sorted array.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
The distinct values of ``a``, in order.
|
|
45
|
+
"""
|
|
46
|
+
n = a.size
|
|
47
|
+
if n == 0:
|
|
48
|
+
return a[:0].copy()
|
|
49
|
+
|
|
50
|
+
mask = np.empty(n, dtype=bool)
|
|
51
|
+
mask[0] = True
|
|
52
|
+
|
|
53
|
+
if a.dtype.kind == "f" and bool(np.isnan(a[-1])):
|
|
54
|
+
first_nan = int(np.searchsorted(a, a[-1], side="left"))
|
|
55
|
+
# The ``first_nan > 0`` guard is load-bearing: an all-NaN input makes
|
|
56
|
+
# the slices empty and ``np.not_equal`` raises on the shape mismatch.
|
|
57
|
+
if first_nan > 0:
|
|
58
|
+
np.not_equal(a[1:first_nan], a[: first_nan - 1], out=mask[1:first_nan])
|
|
59
|
+
mask[first_nan] = True
|
|
60
|
+
mask[first_nan + 1 :] = False
|
|
61
|
+
else:
|
|
62
|
+
np.not_equal(a[1:], a[:-1], out=mask[1:])
|
|
63
|
+
|
|
64
|
+
return a[mask]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _filter_absent(
|
|
68
|
+
acc: NDArray[Any],
|
|
69
|
+
s: NDArray[Any],
|
|
70
|
+
block: int = _MASS_AXIS_PROBE_BLOCK,
|
|
71
|
+
) -> NDArray[Any]:
|
|
72
|
+
"""Return the elements of ``s`` that are not already in ``acc``.
|
|
73
|
+
|
|
74
|
+
Both arrays must be sorted and free of duplicates. ``s`` is walked in
|
|
75
|
+
blocks so the search temporaries stay O(block) rather than O(len(s)).
|
|
76
|
+
Returns ``s`` itself, uncopied, when none of its values are present --
|
|
77
|
+
the all-distinct fast path, worth half a payload at peak.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
acc: The running axis, sorted and unique.
|
|
81
|
+
s: A candidate batch, sorted and unique.
|
|
82
|
+
block: Probe block size in elements.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
The subset of ``s`` absent from ``acc``.
|
|
86
|
+
"""
|
|
87
|
+
n = s.size
|
|
88
|
+
# ``acc.size == 0`` must be handled here: the ``np.minimum`` below would
|
|
89
|
+
# otherwise index ``acc[-1]`` on an empty array and raise IndexError.
|
|
90
|
+
if n == 0 or acc.size == 0:
|
|
91
|
+
return s
|
|
92
|
+
|
|
93
|
+
keep = np.empty(n, dtype=bool)
|
|
94
|
+
n_keep = 0
|
|
95
|
+
is_float = acc.dtype.kind == "f"
|
|
96
|
+
a_len = acc.size
|
|
97
|
+
|
|
98
|
+
for lo in range(0, n, block):
|
|
99
|
+
hi = min(lo + block, n)
|
|
100
|
+
sb = s[lo:hi]
|
|
101
|
+
pos = np.searchsorted(acc, sb, side="left")
|
|
102
|
+
out_of_range = pos >= a_len
|
|
103
|
+
np.minimum(pos, a_len - 1, out=pos)
|
|
104
|
+
va = acc[pos]
|
|
105
|
+
eq = va == sb
|
|
106
|
+
if is_float:
|
|
107
|
+
# searchsorted routes a NaN key onto acc's first NaN, but NaN does
|
|
108
|
+
# not compare equal to itself, so without this the same NaN would
|
|
109
|
+
# be re-inserted on every fold.
|
|
110
|
+
np.logical_or(eq, np.isnan(va) & np.isnan(sb), out=eq)
|
|
111
|
+
np.logical_and(eq, ~out_of_range, out=eq)
|
|
112
|
+
np.logical_not(eq, out=eq)
|
|
113
|
+
keep[lo:hi] = eq
|
|
114
|
+
n_keep += int(np.count_nonzero(eq))
|
|
115
|
+
del pos, out_of_range, va, eq, sb
|
|
116
|
+
|
|
117
|
+
if n_keep == n:
|
|
118
|
+
return s
|
|
119
|
+
if n_keep == 0:
|
|
120
|
+
return s[:0]
|
|
121
|
+
return s[keep]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _merge_disjoint(box_a: List[Any], box_b: List[Any]) -> NDArray[Any]:
|
|
125
|
+
"""Merge two DISJOINT sorted arrays, releasing each once it is copied.
|
|
126
|
+
|
|
127
|
+
The inputs arrive boxed in one-element lists so this function can drop the
|
|
128
|
+
caller's reference as soon as each side has been copied. Passing them as
|
|
129
|
+
plain parameters would keep both alive for the whole call and add a full
|
|
130
|
+
copy of the larger side to the peak.
|
|
131
|
+
|
|
132
|
+
The result needs no second deduplication because ``_filter_absent`` has
|
|
133
|
+
already removed the overlap, which saves another full-size copy.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
box_a: One-element list holding the first sorted array.
|
|
137
|
+
box_b: One-element list holding the second sorted array.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
The sorted union of the two inputs.
|
|
141
|
+
"""
|
|
142
|
+
a, b = box_a[0], box_b[0]
|
|
143
|
+
out = np.empty(a.size + b.size, dtype=a.dtype)
|
|
144
|
+
|
|
145
|
+
n_a = a.size
|
|
146
|
+
out[:n_a] = a
|
|
147
|
+
box_a[0] = None
|
|
148
|
+
del a
|
|
149
|
+
|
|
150
|
+
out[n_a:] = b
|
|
151
|
+
box_b[0] = None
|
|
152
|
+
del b
|
|
153
|
+
|
|
154
|
+
# Exactly two ascending runs, which is the case quicksort handles best.
|
|
155
|
+
out.sort(kind="quicksort")
|
|
156
|
+
return out
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _read_spectrum_mzs(parser: Any, idx: int) -> Optional[NDArray[Any]]:
|
|
160
|
+
"""Read one spectrum's m/z values, or None if missing or unreadable.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
parser: An initialized ImzML parser.
|
|
164
|
+
idx: Spectrum index.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
The m/z array, or None when the spectrum is empty or failed to read.
|
|
168
|
+
"""
|
|
169
|
+
try:
|
|
170
|
+
spectrum_data = parser.getspectrum(idx)
|
|
171
|
+
except Exception as e:
|
|
172
|
+
logger.warning(f"Error getting spectrum {idx}: {e}")
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
if spectrum_data is None or len(spectrum_data) < 1:
|
|
176
|
+
return None
|
|
177
|
+
mzs = spectrum_data[0]
|
|
178
|
+
return mzs if mzs.size else None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class _MassAxisAccumulator:
|
|
182
|
+
"""Builds a sorted, unique m/z axis without holding every spectrum.
|
|
183
|
+
|
|
184
|
+
Values are copied into a scratch buffer; when that buffer fills, it is
|
|
185
|
+
sorted, deduplicated, and merged into the running axis. The buffer's
|
|
186
|
+
capacity tracks the axis length, so the number of merges is logarithmic in
|
|
187
|
+
the input rather than linear -- a fixed batch size would re-copy the
|
|
188
|
+
accumulator once per batch, which is the quadratic behaviour that makes a
|
|
189
|
+
naive ``np.union1d`` fold far slower than the code it replaces.
|
|
190
|
+
|
|
191
|
+
Peak memory therefore depends on the number of *unique* m/z values rather
|
|
192
|
+
than on the size of the file.
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
def __init__(self, total_spectra: int, max_length: Optional[int] = None) -> None:
|
|
196
|
+
"""Initialize the accumulator.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
total_spectra: Spectrum count, used only in the error message.
|
|
200
|
+
max_length: Cap on unique m/z values, or None for unlimited.
|
|
201
|
+
"""
|
|
202
|
+
self._total_spectra = total_spectra
|
|
203
|
+
self._max_length = max_length
|
|
204
|
+
self._acc: Optional[NDArray[Any]] = None
|
|
205
|
+
self._buf: Optional[NDArray[Any]] = None
|
|
206
|
+
self._cap = 0
|
|
207
|
+
self._cap_target = _MASS_AXIS_BATCH_VALUES
|
|
208
|
+
self._n = 0
|
|
209
|
+
self.saw_any = False
|
|
210
|
+
|
|
211
|
+
def add(self, mzs: NDArray[Any], index: int) -> None:
|
|
212
|
+
"""Buffer one spectrum's m/z values, folding first if the buffer is full.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
mzs: The spectrum's m/z values. Not modified.
|
|
216
|
+
index: Spectrum index, used only in the error message.
|
|
217
|
+
"""
|
|
218
|
+
m = mzs.size
|
|
219
|
+
if not m:
|
|
220
|
+
return
|
|
221
|
+
self.saw_any = True
|
|
222
|
+
|
|
223
|
+
if self._buf is None:
|
|
224
|
+
self._allocate(m, mzs.dtype)
|
|
225
|
+
elif self._n + m > self._cap:
|
|
226
|
+
self._fold(index)
|
|
227
|
+
if self._buf is None:
|
|
228
|
+
self._allocate(m, mzs.dtype)
|
|
229
|
+
elif m > self._cap:
|
|
230
|
+
self._buf = None
|
|
231
|
+
self._allocate(m, mzs.dtype, exact=True)
|
|
232
|
+
|
|
233
|
+
assert self._buf is not None
|
|
234
|
+
self._buf[self._n : self._n + m] = mzs
|
|
235
|
+
self._n += m
|
|
236
|
+
|
|
237
|
+
def finish(self, index: int) -> NDArray[Any]:
|
|
238
|
+
"""Fold whatever is buffered and return the axis.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
index: Last spectrum index, used only in the error message.
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
Sorted, unique m/z values.
|
|
245
|
+
|
|
246
|
+
Raises:
|
|
247
|
+
ValueError: If no spectrum yielded any m/z values.
|
|
248
|
+
"""
|
|
249
|
+
self._fold(index)
|
|
250
|
+
self._buf = None
|
|
251
|
+
|
|
252
|
+
if not self.saw_any or self._acc is None:
|
|
253
|
+
raise ValueError("No spectra found to build common mass axis")
|
|
254
|
+
if self._acc.size == 0:
|
|
255
|
+
raise ValueError("Failed to extract any m/z values")
|
|
256
|
+
return self._acc
|
|
257
|
+
|
|
258
|
+
def _allocate(self, m: int, dtype: Any, exact: bool = False) -> None:
|
|
259
|
+
"""Allocate the scratch buffer, at least large enough for one spectrum."""
|
|
260
|
+
self._cap = m if exact else max(self._cap_target, m)
|
|
261
|
+
self._buf = np.empty(self._cap, dtype=dtype)
|
|
262
|
+
|
|
263
|
+
def _fold(self, index: int) -> None:
|
|
264
|
+
"""Sort, deduplicate and merge the buffered batch into the axis."""
|
|
265
|
+
if self._n == 0 or self._buf is None:
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
view = self._buf[: self._n]
|
|
269
|
+
view.sort(kind="quicksort")
|
|
270
|
+
run = _dedupe_sorted(view)
|
|
271
|
+
self._n = 0
|
|
272
|
+
|
|
273
|
+
if self._cap > _MASS_AXIS_BATCH_VALUES:
|
|
274
|
+
# The scratch has grown to axis size, so release it before the
|
|
275
|
+
# merge allocates. Gating on the batch floor keeps this from firing
|
|
276
|
+
# on every one of the thousands of folds a shared-grid dataset
|
|
277
|
+
# performs, where re-faulting the buffer each time costs more than
|
|
278
|
+
# the memory it frees.
|
|
279
|
+
view = None
|
|
280
|
+
self._buf = None
|
|
281
|
+
self._cap = 0
|
|
282
|
+
|
|
283
|
+
if self._acc is None:
|
|
284
|
+
self._acc = run
|
|
285
|
+
else:
|
|
286
|
+
new = _filter_absent(self._acc, run)
|
|
287
|
+
run = None # drop the batch before the merge allocates
|
|
288
|
+
if new.size:
|
|
289
|
+
box_a, box_b = [self._acc], [new]
|
|
290
|
+
self._acc = None
|
|
291
|
+
new = None
|
|
292
|
+
self._acc = _merge_disjoint(box_a, box_b)
|
|
293
|
+
|
|
294
|
+
self._check_limit(index)
|
|
295
|
+
|
|
296
|
+
# Record the next capacity but do NOT allocate it here. On the final
|
|
297
|
+
# fold the stream is already exhausted, so allocating would commit a
|
|
298
|
+
# whole axis-sized buffer that is never written.
|
|
299
|
+
self._cap_target = max(_MASS_AXIS_BATCH_VALUES, self._acc.size)
|
|
300
|
+
if self._cap and self._cap != self._cap_target:
|
|
301
|
+
self._buf = None
|
|
302
|
+
self._cap = 0
|
|
303
|
+
|
|
304
|
+
def _check_limit(self, index: int) -> None:
|
|
305
|
+
"""Stop if the axis has outgrown ``max_length``."""
|
|
306
|
+
if self._max_length is None or self._acc is None:
|
|
307
|
+
return
|
|
308
|
+
if self._acc.size <= self._max_length:
|
|
309
|
+
return
|
|
310
|
+
raise ValueError(
|
|
311
|
+
f"Common mass axis exceeded {self._max_length:,} unique m/z values "
|
|
312
|
+
f"after {index + 1:,} of {self._total_spectra:,} spectra "
|
|
313
|
+
f"({self._acc.size:,} so far). The peak lists in this dataset do "
|
|
314
|
+
"not share m/z values, so a raw axis grows to roughly one column "
|
|
315
|
+
"per peak, which is not usable downstream. Convert with resampling "
|
|
316
|
+
"instead (it is the default; --no-resample disables it), or raise "
|
|
317
|
+
"max_mass_axis_length."
|
|
318
|
+
)
|
|
319
|
+
|
|
18
320
|
|
|
19
321
|
@register_reader("imzml")
|
|
20
322
|
class ImzMLReader(BaseMSIReader):
|
|
@@ -33,12 +335,16 @@ class ImzMLReader(BaseMSIReader):
|
|
|
33
335
|
data_path: Path to the imzML file
|
|
34
336
|
batch_size: Default batch size for spectrum iteration
|
|
35
337
|
cache_coordinates: Whether to cache coordinates upfront
|
|
36
|
-
**kwargs: Additional arguments
|
|
338
|
+
**kwargs: Additional arguments. ``max_mass_axis_length`` caps the
|
|
339
|
+
number of unique m/z values the processed-mode raw axis may
|
|
340
|
+
reach before the build gives up; None (the default) is
|
|
341
|
+
unlimited. See ``_extract_continuous_mass_axis``.
|
|
37
342
|
"""
|
|
38
343
|
super().__init__(data_path, **kwargs)
|
|
39
344
|
self.filepath: Optional[Union[str, Path]] = data_path
|
|
40
345
|
self.batch_size: int = batch_size
|
|
41
346
|
self.cache_coordinates: bool = cache_coordinates
|
|
347
|
+
self.max_mass_axis_length: Optional[int] = kwargs.get("max_mass_axis_length")
|
|
42
348
|
self.parser: Optional[ImzMLParser] = None
|
|
43
349
|
self.ibd_file: Optional[Any] = None
|
|
44
350
|
self.imzml_path: Optional[Path] = None
|
|
@@ -207,25 +513,46 @@ class ImzMLReader(BaseMSIReader):
|
|
|
207
513
|
return self._common_mass_axis
|
|
208
514
|
|
|
209
515
|
def _extract_continuous_mass_axis(self, parser: ImzMLParser) -> NDArray[np.float64]:
|
|
210
|
-
"""
|
|
211
|
-
|
|
516
|
+
"""Build the common mass axis for processed-mode data.
|
|
517
|
+
|
|
518
|
+
Returns exactly what ``np.unique(np.concatenate(all_mzs))`` returned:
|
|
519
|
+
sorted, deduplicated, with the dtype following the file's mzPrecision.
|
|
520
|
+
|
|
521
|
+
This streams the file rather than collecting it. The previous
|
|
522
|
+
implementation held every spectrum's m/z array in a list, concatenated
|
|
523
|
+
that into one array, and handed the result to ``np.unique`` -- three
|
|
524
|
+
live copies of the whole m/z payload P, plus the output. Measured peak
|
|
525
|
+
was 3.40-3.51x P on data whose spectra share m/z values and 4.32-4.39x
|
|
526
|
+
P when every value is distinct, which put a hard ceiling around a
|
|
527
|
+
40 GB input on a 128 GB machine.
|
|
528
|
+
|
|
529
|
+
Here each spectrum is copied into a scratch buffer, and when that
|
|
530
|
+
buffer fills it is sorted, deduplicated, and merged into the running
|
|
531
|
+
axis. Peak memory becomes a function of the number of *unique* values
|
|
532
|
+
rather than the payload: measured 15.2-15.5 MiB, flat, for payloads
|
|
533
|
+
from 32 MiB to 16 GiB of shared-grid data, and 2.00x P in the
|
|
534
|
+
all-distinct case, which is the structural floor for an out-of-place
|
|
535
|
+
merge.
|
|
536
|
+
|
|
537
|
+
Args:
|
|
538
|
+
parser: An initialized ImzML parser.
|
|
539
|
+
|
|
540
|
+
Returns:
|
|
541
|
+
Sorted, unique m/z values across all spectra.
|
|
542
|
+
|
|
543
|
+
Raises:
|
|
544
|
+
ValueError: If no spectrum yielded any m/z values, or if
|
|
545
|
+
``max_mass_axis_length`` is set and the axis outgrows it.
|
|
546
|
+
"""
|
|
212
547
|
logger.info(
|
|
213
548
|
"Building common mass axis from all unique m/z values " "(processed mode)"
|
|
214
549
|
)
|
|
215
550
|
|
|
216
551
|
total_spectra = len(parser.coordinates)
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
return self._finalize_mass_axis(all_mzs)
|
|
223
|
-
|
|
224
|
-
def _collect_processed_mzs(
|
|
225
|
-
self, parser: ImzMLParser, total_spectra: int
|
|
226
|
-
) -> List[NDArray[np.float64]]:
|
|
227
|
-
"""Collect m/z values from all processed spectra."""
|
|
228
|
-
all_mzs: List[NDArray[np.float64]] = []
|
|
552
|
+
accumulator = _MassAxisAccumulator(
|
|
553
|
+
total_spectra, getattr(self, "max_mass_axis_length", None)
|
|
554
|
+
)
|
|
555
|
+
idx = -1
|
|
229
556
|
|
|
230
557
|
with tqdm(
|
|
231
558
|
total=total_spectra,
|
|
@@ -233,38 +560,18 @@ class ImzMLReader(BaseMSIReader):
|
|
|
233
560
|
unit="spectrum",
|
|
234
561
|
) as pbar:
|
|
235
562
|
for idx in range(total_spectra):
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
mzs
|
|
242
|
-
|
|
243
|
-
all_mzs.append(mzs)
|
|
244
|
-
except Exception as e:
|
|
245
|
-
logger.warning(f"Error getting spectrum {idx}: {e}")
|
|
563
|
+
mzs = _read_spectrum_mzs(parser, idx)
|
|
564
|
+
if mzs is not None:
|
|
565
|
+
# Deliberately outside the read's try/except: a
|
|
566
|
+
# max_mass_axis_length failure must not be swallowed and
|
|
567
|
+
# logged as a per-spectrum warning.
|
|
568
|
+
accumulator.add(mzs, idx)
|
|
569
|
+
mzs = None
|
|
246
570
|
pbar.update(1)
|
|
247
571
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
self, all_mzs: List[NDArray[np.float64]]
|
|
252
|
-
) -> NDArray[np.float64]:
|
|
253
|
-
"""Finalize the mass axis from collected m/z values."""
|
|
254
|
-
try:
|
|
255
|
-
combined_mzs = np.concatenate(all_mzs)
|
|
256
|
-
unique_mzs = np.unique(combined_mzs)
|
|
257
|
-
|
|
258
|
-
if unique_mzs.size == 0:
|
|
259
|
-
raise ValueError("Failed to extract any m/z values")
|
|
260
|
-
|
|
261
|
-
logger.info(
|
|
262
|
-
f"Created common mass axis with {len(unique_mzs)} unique " f"m/z values"
|
|
263
|
-
)
|
|
264
|
-
return unique_mzs
|
|
265
|
-
except Exception as e:
|
|
266
|
-
# Re-raise with more context
|
|
267
|
-
raise ValueError(f"Error creating common mass axis: {e}") from e
|
|
572
|
+
axis = accumulator.finish(idx)
|
|
573
|
+
logger.info(f"Created common mass axis with {axis.size} unique m/z values")
|
|
574
|
+
return axis
|
|
268
575
|
|
|
269
576
|
def _get_spectrum_coordinates(
|
|
270
577
|
self, parser: ImzMLParser, idx: int
|