thyra 3.7.4__tar.gz → 3.8.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.7.4 → thyra-3.8.0}/PKG-INFO +2 -2
- {thyra-3.7.4 → thyra-3.8.0}/pyproject.toml +23 -11
- {thyra-3.7.4 → thyra-3.8.0}/thyra/__init__.py +1 -1
- {thyra-3.7.4 → thyra-3.8.0}/thyra/converters/spatialdata/_chunking.py +20 -5
- {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/extractors/imzml_extractor.py +258 -13
- {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/extractors/waters_extractor.py +17 -3
- {thyra-3.7.4 → thyra-3.8.0}/thyra/resampling/data_characteristics.py +16 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/resampling/instrument_detectors.py +43 -0
- {thyra-3.7.4 → thyra-3.8.0}/.gitignore +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/LICENSE +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/README.md +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/__main__.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/alignment/__init__.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/alignment/affine.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/alignment/teaching_points.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/config.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/convert.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/converters/__init__.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/converters/spatialdata/__init__.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/converters/spatialdata/base_spatialdata_converter.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/converters/spatialdata/converter.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/converters/spatialdata/spatialdata_2d_converter.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/converters/spatialdata/spatialdata_3d_converter.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/converters/spatialdata/streaming_converter.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/core/__init__.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/core/base_converter.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/core/base_extractor.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/core/base_reader.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/core/registry.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/__init__.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/extractors/__init__.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/extractors/bruker_extractor.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/extractors/phi_extractor.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/ontology/__init__.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/ontology/_ims.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/ontology/_ms.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/ontology/_uo.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/ontology/cache.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/schema/__init__.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/schema/builder.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/schema/cli.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/schema/generate.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/schema/metaspace.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/schema/models.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/schema/msi_metadata.linkml.yaml +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/schema/msi_metadata_schema_v0_1.json +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/schema/store_io.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/schema/validate.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/schema/vocab.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/types.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/uns_compat.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/metadata/validator.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/preview.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/__init__.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/bruker/__init__.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/bruker/base_bruker_reader.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/bruker/folder_structure.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/bruker/mis_parser.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/bruker/rapiflex/__init__.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/bruker/rapiflex/rapiflex_reader.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/bruker/timstof/__init__.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/bruker/timstof/sdk/__init__.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/bruker/timstof/sdk/dll/LICENCE-BRUKER.txt +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/bruker/timstof/sdk/dll/README.md +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/bruker/timstof/sdk/dll/timsdata.dll +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/bruker/timstof/sdk/dll/timsdata.so +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/bruker/timstof/sdk/dll_manager.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/bruker/timstof/sdk/platform_detector.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/bruker/timstof/sdk/sdk_functions.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/bruker/timstof/timstof_reader.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/bruker/timstof/utils/__init__.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/bruker/timstof/utils/batch_processor.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/bruker/timstof/utils/coordinate_cache.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/bruker/timstof/utils/mass_axis_builder.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/bruker/timstof/utils/memory_manager.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/imzml/__init__.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/imzml/imzml_reader.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/phi/__init__.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/phi/event_stream.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/phi/mass_axis.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/phi/phi_header.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/phi/phi_reader.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/waters/__init__.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/waters/imaging_grid.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/waters/lib/MLReader.dll +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/waters/lib/MassLynxRaw.dll +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/waters/lib/libMLReader.so +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/waters/lib/libMassLynxRaw.so +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/waters/masslynx_lib.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/readers/waters/waters_reader.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/resampling/__init__.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/resampling/common_axis.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/resampling/constants.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/resampling/decision_tree.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/resampling/gaps.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/resampling/mass_axis/__init__.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/resampling/mass_axis/base_generator.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/resampling/mass_axis/fticr_generator.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/resampling/mass_axis/linear_generator.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/resampling/mass_axis/linear_tof_generator.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/resampling/mass_axis/orbitrap_generator.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/resampling/mass_axis/reflector_tof_generator.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/resampling/strategies/__init__.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/resampling/strategies/base.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/resampling/strategies/nearest_neighbor.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/resampling/strategies/tic_preserving.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/resampling/tic.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/resampling/types.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/tools/__init__.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/tools/check_ontology.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/tools/make_example_data.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/utils/__init__.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/utils/bruker_exceptions.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/utils/logging_config.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/utils/pyimzml_direct.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/utils/windows_paths.py +0 -0
- {thyra-3.7.4 → thyra-3.8.0}/thyra/utils/zarr_atomic_write.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: thyra
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.8.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
|
Project-URL: Homepage, https://github.com/M4i-Imaging-Mass-Spectrometry/thyra
|
|
6
6
|
Project-URL: Repository, https://github.com/M4i-Imaging-Mass-Spectrometry/thyra
|
|
@@ -47,7 +47,7 @@ Requires-Dist: spatialdata<0.9,>=0.8.0
|
|
|
47
47
|
Requires-Dist: tifffile>=2022.8.12
|
|
48
48
|
Requires-Dist: tqdm>=4.50.0
|
|
49
49
|
Requires-Dist: xarray>=2024.10.0
|
|
50
|
-
Requires-Dist: zarr<3.2,>=3.1.
|
|
50
|
+
Requires-Dist: zarr<3.2,>=3.1.6
|
|
51
51
|
Description-Content-Type: text/markdown
|
|
52
52
|
|
|
53
53
|
<p align="center">
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "thyra"
|
|
7
|
-
version = "3.
|
|
7
|
+
version = "3.8.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 = [{ name = "Theodoros Visvikis", email = "t.visvikis@maastrichtuniversity.nl" }]
|
|
10
10
|
maintainers = [{ name = "Theodoros Visvikis", email = "t.visvikis@maastrichtuniversity.nl" }]
|
|
@@ -91,16 +91,28 @@ dependencies = [
|
|
|
91
91
|
# bit ome_zarr 0.12.x with newer dask, and spatialdata 0.8 requires >= 0.16.
|
|
92
92
|
"ome-zarr>=0.16.0, <0.19", # tested 0.18.0
|
|
93
93
|
"tqdm>=4.50.0",
|
|
94
|
-
# Floor is 3.1.
|
|
95
|
-
# array with `shards="auto"
|
|
96
|
-
# `max_bytes=1024` where
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
#
|
|
100
|
-
# 3.1.
|
|
101
|
-
#
|
|
102
|
-
#
|
|
103
|
-
|
|
94
|
+
# Floor is 3.1.6, and it is load-bearing. anndata >= 0.13 writes every zarr v3
|
|
95
|
+
# array with `shards="auto"`, and `_auto_partition` sized the auto INNER chunk
|
|
96
|
+
# with `max_bytes=1024` where the line right above it says "aim for a 1MiB
|
|
97
|
+
# chunk". zarr-python#3603 changed that 1024 to 1048576; it merged six days
|
|
98
|
+
# AFTER 3.1.5 shipped, so 3.1.4 and 3.1.5 both still carry the defect
|
|
99
|
+
# (`chunk_grids.py:267` in each) and 3.1.6 is the first release without it.
|
|
100
|
+
# Do not drop the floor to 3.1.4 on the grounds that 3.1.4 is where
|
|
101
|
+
# `array.target_shard_size_bytes` arrives -- the knob _chunking.table_write_config
|
|
102
|
+
# sets to claim the budget from anndata. That knob is necessary, not sufficient.
|
|
103
|
+
#
|
|
104
|
+
# Both halves of the damage, measured on X/data of test_data/pea.imzML (60.1M
|
|
105
|
+
# nnz, f8, 481 MB):
|
|
106
|
+
# - Below 3.1.4 the knob does not exist, so a shard is 2 auto chunks -- ~1.8 KB,
|
|
107
|
+
# and a shard is one FILE. 393,310 files for a single table, and 434 s of a
|
|
108
|
+
# 445 s conversion spent in the write, against 12 files and 2.7 s on 3.1.6.
|
|
109
|
+
# Nothing in Thyra changed; the anndata 0.13 bump alone did this.
|
|
110
|
+
# - On 3.1.4/3.1.5 the 128 MiB budget IS honoured, so the file count stays sane
|
|
111
|
+
# (4), but the chunk inside those files is 920 B: ~145,900 separately
|
|
112
|
+
# compressed chunks per shard against 142 on 3.1.6, plus the shard index that
|
|
113
|
+
# implies. A 3.1.5 resolve buys that silently, and the shard-level assertion
|
|
114
|
+
# cannot see it; _chunking.MIN_TABLE_CHUNK_BYTES is what catches it.
|
|
115
|
+
"zarr>=3.1.6, <3.2", # tested 3.1.6
|
|
104
116
|
"imagecodecs>=2024.1.1", # For reading compressed TIFF optical images
|
|
105
117
|
# Floor is 0.13.2, not 0.13.0. anndata 0.13.0 imports
|
|
106
118
|
# `typing_extensions.sentinel` (needs typing-extensions >= 4.16) but declares
|
|
@@ -53,13 +53,28 @@ INNER_TILE_EDGE = 512
|
|
|
53
53
|
# began auto-sharding, so read amplification is unchanged.
|
|
54
54
|
TABLE_SHARD_TARGET_BYTES = 128 * 1024 * 1024
|
|
55
55
|
|
|
56
|
-
# zarr < 3.1.
|
|
57
|
-
# meant,
|
|
58
|
-
#
|
|
59
|
-
#
|
|
60
|
-
#
|
|
56
|
+
# zarr < 3.1.6 computed the auto chunk with ``max_bytes=1024`` where 1 MiB was
|
|
57
|
+
# meant (zarr-python#3603, first released in 3.1.6 -- it landed after both 3.1.4
|
|
58
|
+
# and 3.1.5). zarr < 3.1.4 additionally predates ``array.target_shard_size_bytes``,
|
|
59
|
+
# making the budget above unenforceable, and there ``shards="auto"`` produced
|
|
60
|
+
# ~1 KB SHARDS -- one file per KB of table. pyproject pins the floor at 3.1.6 for
|
|
61
|
+
# both reasons.
|
|
62
|
+
#
|
|
63
|
+
# This constant guards the shard, i.e. the file, so it only ever catches that
|
|
64
|
+
# second, pre-3.1.4 half: on 3.1.4/3.1.5 the budget above still lands large
|
|
65
|
+
# shards and it is the chunk inside them that collapses. MIN_TABLE_CHUNK_BYTES
|
|
66
|
+
# is the half that catches those two.
|
|
61
67
|
MIN_TABLE_SHARD_BYTES = 64 * 1024
|
|
62
68
|
|
|
69
|
+
# Minimum size for the INNER chunk of a table array -- the unit that sits inside
|
|
70
|
+
# a shard and the unit a random read decompresses. This is the half of the defect
|
|
71
|
+
# a large shard hides: on 3.1.4/3.1.5 the file count looks fine while each file
|
|
72
|
+
# quietly holds ~146,000 separately compressed chunks instead of ~142, and pays
|
|
73
|
+
# for all of them in the shard index. 64 KiB sits well above the ~800 B those
|
|
74
|
+
# versions hand the regression-test fixture and well below the ~200 KB 3.1.6
|
|
75
|
+
# hands it, so the two are never close to the threshold.
|
|
76
|
+
MIN_TABLE_CHUNK_BYTES = 64 * 1024
|
|
77
|
+
|
|
63
78
|
|
|
64
79
|
@contextmanager
|
|
65
80
|
def table_write_config() -> Iterator[None]:
|
|
@@ -16,6 +16,7 @@ from ...resampling.constants import (
|
|
|
16
16
|
normalize_spectrum_type,
|
|
17
17
|
)
|
|
18
18
|
from ...utils.pyimzml_direct import read_spectrum_mzs_only
|
|
19
|
+
from ..ontology.cache import ONTOLOGY
|
|
19
20
|
from ..types import ComprehensiveMetadata, EssentialMetadata
|
|
20
21
|
|
|
21
22
|
logger = logging.getLogger(__name__)
|
|
@@ -30,6 +31,119 @@ UM_PER_UNIT = {
|
|
|
30
31
|
"UO:0000018": 0.001, # nanometer
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
# Analyzer-component terms, in preference order. The first one found under a
|
|
35
|
+
# <componentList><analyzer> is surfaced as ``instrument_info["analyzer"]``, so
|
|
36
|
+
# on a hybrid that declares several analyzers the highest-resolution stage --
|
|
37
|
+
# the one that defines the mass axis of the stored spectra -- wins.
|
|
38
|
+
_ANALYZER_ACCESSIONS = (
|
|
39
|
+
"MS:1000079", # fourier transform ion cyclotron resonance mass spectrometer
|
|
40
|
+
"MS:1000484", # orbitrap
|
|
41
|
+
"MS:1000084", # time-of-flight
|
|
42
|
+
"MS:1000264", # ion trap
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# The subset of analyzers whose axis-spacing law the resampling detector chain
|
|
46
|
+
# distinguishes. Values are the exact ``instrument_type`` strings
|
|
47
|
+
# ``FTICRDetector`` and ``OrbitrapDetector`` match on -- change both together.
|
|
48
|
+
# TOF is deliberately absent: an imzML declaring a TOF analyzer says nothing
|
|
49
|
+
# about which vendor grid the spectra arrived on, and stamping a TOF type here
|
|
50
|
+
# could arm detectors whose source-grid claims only hold for their own reader.
|
|
51
|
+
_ANALYZER_FAMILIES = {
|
|
52
|
+
"MS:1000079": "FT-ICR",
|
|
53
|
+
"MS:1000484": "Orbitrap",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# Instrument-model terms that imply an analyzer family when no analyzer
|
|
57
|
+
# component is declared. The local ontology tables are flat -- no is-a links --
|
|
58
|
+
# so family membership is enumerated rather than walked.
|
|
59
|
+
_FTICR_MODEL_ACCESSIONS = frozenset(
|
|
60
|
+
{
|
|
61
|
+
"MS:1000141", # apex IV
|
|
62
|
+
"MS:1000142", # apex Q
|
|
63
|
+
"MS:1000448", # LTQ FT
|
|
64
|
+
"MS:1000557", # LTQ FT Ultra
|
|
65
|
+
"MS:1000695", # apex ultra
|
|
66
|
+
"MS:1001548", # Bruker Daltonics solarix series
|
|
67
|
+
"MS:1001549", # solariX
|
|
68
|
+
"MS:1001556", # Bruker Daltonics apex series
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
_ORBITRAP_MODEL_ACCESSIONS = frozenset(
|
|
73
|
+
{
|
|
74
|
+
"MS:1000449", # LTQ Orbitrap
|
|
75
|
+
"MS:1000555", # LTQ Orbitrap Discovery
|
|
76
|
+
"MS:1000556", # LTQ Orbitrap XL
|
|
77
|
+
"MS:1000639", # LTQ Orbitrap XL ETD
|
|
78
|
+
"MS:1000643", # MALDI LTQ Orbitrap
|
|
79
|
+
"MS:1000649", # Exactive
|
|
80
|
+
"MS:1001742", # LTQ Orbitrap Velos
|
|
81
|
+
"MS:1001910", # LTQ Orbitrap Elite
|
|
82
|
+
"MS:1001911", # Q Exactive
|
|
83
|
+
"MS:1002416", # Orbitrap Fusion
|
|
84
|
+
"MS:1002417", # Orbitrap Fusion ETD
|
|
85
|
+
"MS:1002523", # Q Exactive HF
|
|
86
|
+
"MS:1002526", # Exactive Plus
|
|
87
|
+
"MS:1002634", # Q Exactive Plus
|
|
88
|
+
"MS:1002732", # Orbitrap Fusion Lumos
|
|
89
|
+
"MS:1002835", # LTQ Orbitrap Classic
|
|
90
|
+
"MS:1002877", # Q Exactive HF-X
|
|
91
|
+
"MS:1003028", # Orbitrap Exploris 480
|
|
92
|
+
"MS:1003029", # Orbitrap Eclipse
|
|
93
|
+
"MS:1003094", # Orbitrap Exploris 240
|
|
94
|
+
"MS:1003095", # Orbitrap Exploris 120
|
|
95
|
+
"MS:1003096", # LTQ Orbitrap Velos Pro
|
|
96
|
+
"MS:1003112", # Orbitrap ID-X
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# timsTOF models carry no family here on purpose: the timsTOF decision is
|
|
101
|
+
# ``DataCharacteristics.is_timstof``, a substring match on the surfaced model
|
|
102
|
+
# name -- the same single path the native .d route goes through. Stamping an
|
|
103
|
+
# ``instrument_type`` as well would create a second, divergable path.
|
|
104
|
+
_TIMSTOF_MODEL_ACCESSIONS = frozenset(
|
|
105
|
+
{
|
|
106
|
+
"MS:1003005", # timsTOF Pro
|
|
107
|
+
"MS:1003123", # Bruker Daltonics timsTOF series
|
|
108
|
+
"MS:1003124", # timsTOF fleX
|
|
109
|
+
"MS:1003229", # timsTOF
|
|
110
|
+
"MS:1003230", # timsTOF Pro 2
|
|
111
|
+
"MS:1003231", # timsTOF SCP
|
|
112
|
+
}
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
_MODEL_FAMILIES: Dict[str, Optional[str]] = {
|
|
116
|
+
**{acc: "FT-ICR" for acc in _FTICR_MODEL_ACCESSIONS},
|
|
117
|
+
**{acc: "Orbitrap" for acc in _ORBITRAP_MODEL_ACCESSIONS},
|
|
118
|
+
**{acc: None for acc in _TIMSTOF_MODEL_ACCESSIONS},
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
# Last-resort family match, applied to model text the file provides itself:
|
|
122
|
+
# the free-text value of MS:1000031 "instrument model", or the name of a model
|
|
123
|
+
# term newer than the shipped ontology tables. Tokens are vendor product names
|
|
124
|
+
# unambiguous enough that a substring hit identifies the family ("scimaX MRMS",
|
|
125
|
+
# "Orbitrap Astral", "solariX XR" all resolve correctly); generic words like
|
|
126
|
+
# "ft" or "tof" are deliberately not in the list.
|
|
127
|
+
_FAMILY_NAME_TOKENS = (
|
|
128
|
+
("solarix", "FT-ICR"),
|
|
129
|
+
("scimax", "FT-ICR"),
|
|
130
|
+
("mrms", "FT-ICR"),
|
|
131
|
+
("ft-icr", "FT-ICR"),
|
|
132
|
+
("fticr", "FT-ICR"),
|
|
133
|
+
("orbitrap", "Orbitrap"),
|
|
134
|
+
("exactive", "Orbitrap"),
|
|
135
|
+
("exploris", "Orbitrap"),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _family_from_model_text(text: str) -> Optional[str]:
|
|
140
|
+
"""Resolve an analyzer family from free-form instrument-model text."""
|
|
141
|
+
lowered = text.lower()
|
|
142
|
+
for token, family in _FAMILY_NAME_TOKENS:
|
|
143
|
+
if token in lowered:
|
|
144
|
+
return family
|
|
145
|
+
return None
|
|
146
|
+
|
|
33
147
|
|
|
34
148
|
class ImzMLMetadataExtractor(MetadataExtractor):
|
|
35
149
|
"""ImzML-specific metadata extractor with optimized two-phase extraction."""
|
|
@@ -658,21 +772,152 @@ class ImzMLMetadataExtractor(MetadataExtractor):
|
|
|
658
772
|
return params
|
|
659
773
|
|
|
660
774
|
def _extract_instrument_info(self) -> Dict[str, Any]:
|
|
661
|
-
"""Extract instrument information.
|
|
662
|
-
|
|
775
|
+
"""Extract instrument information from the instrumentConfiguration blocks.
|
|
776
|
+
|
|
777
|
+
This used to read ``imzmldict`` for "instrument model" and friends, but
|
|
778
|
+
pyimzml's ``__readimzmlmeta`` only ever collects pixel-geometry and
|
|
779
|
+
laser parameters -- none of those keys can exist, so every store Thyra
|
|
780
|
+
wrote carried an empty ``instrument_information`` for imzML sources,
|
|
781
|
+
and neither ``FTICRDetector`` nor ``OrbitrapDetector`` could ever fire
|
|
782
|
+
on an imzML. The declarations survive on ``parser.metadata``, so they
|
|
783
|
+
are read from there:
|
|
784
|
+
|
|
785
|
+
- ``instrument_model`` / ``instrument_serial_number`` -- from the
|
|
786
|
+
instrumentConfiguration's own cvParams, referenceableParamGroup
|
|
787
|
+
inheritance included (Bruker and SCiLS exports put the model term in
|
|
788
|
+
a ``CommonInstrumentParams`` group).
|
|
789
|
+
- ``analyzer`` -- the CV name of the first recognised
|
|
790
|
+
``<componentList><analyzer>`` term, which is what
|
|
791
|
+
``normalize_analyzer`` in the metadata schema accepts.
|
|
792
|
+
- ``instrument_type`` -- the exact string the resampling detector
|
|
793
|
+
chain matches on (``"FT-ICR"`` / ``"Orbitrap"``), when the analyzer
|
|
794
|
+
or the model resolves to a family the chain distinguishes. Absent
|
|
795
|
+
otherwise: an unstated analyzer stays unstated.
|
|
796
|
+
"""
|
|
797
|
+
info: Dict[str, Any] = {}
|
|
798
|
+
configs = self._instrument_configurations()
|
|
799
|
+
if not configs:
|
|
800
|
+
return info
|
|
663
801
|
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
"instrument serial number",
|
|
668
|
-
"software",
|
|
669
|
-
"software version",
|
|
670
|
-
]
|
|
671
|
-
for key in instrument_keys:
|
|
672
|
-
if key in self.parser.imzmldict:
|
|
673
|
-
instrument[key.replace(" ", "_")] = self.parser.imzmldict[key]
|
|
802
|
+
model = self._instrument_model(configs)
|
|
803
|
+
if model:
|
|
804
|
+
info["instrument_model"] = model
|
|
674
805
|
|
|
675
|
-
|
|
806
|
+
serial = self._config_param(configs, "MS:1000529")
|
|
807
|
+
if isinstance(serial, str) and serial.strip():
|
|
808
|
+
info["instrument_serial_number"] = serial.strip()
|
|
809
|
+
|
|
810
|
+
analyzer_accession = self._analyzer_accession(configs)
|
|
811
|
+
if analyzer_accession is not None:
|
|
812
|
+
info["analyzer"] = ONTOLOGY.terms[analyzer_accession][0]
|
|
813
|
+
|
|
814
|
+
family = self._analyzer_family(configs, analyzer_accession, model)
|
|
815
|
+
if family is not None:
|
|
816
|
+
info["instrument_type"] = family
|
|
817
|
+
|
|
818
|
+
return info
|
|
819
|
+
|
|
820
|
+
def _instrument_configurations(self) -> List[Any]:
|
|
821
|
+
"""The parsed instrumentConfiguration ParamGroups, in document order."""
|
|
822
|
+
metadata = getattr(self.parser, "metadata", None)
|
|
823
|
+
configs = getattr(metadata, "instrument_configurations", None)
|
|
824
|
+
if not isinstance(configs, dict):
|
|
825
|
+
return []
|
|
826
|
+
return list(configs.values())
|
|
827
|
+
|
|
828
|
+
@staticmethod
|
|
829
|
+
def _config_param(configs: List[Any], accession: str) -> Any:
|
|
830
|
+
"""The first value any configuration declares for ``accession``."""
|
|
831
|
+
for config in configs:
|
|
832
|
+
params = getattr(config, "param_by_accession", None)
|
|
833
|
+
if isinstance(params, dict) and accession in params:
|
|
834
|
+
return params[accession]
|
|
835
|
+
return None
|
|
836
|
+
|
|
837
|
+
def _instrument_model(self, configs: List[Any]) -> Optional[str]:
|
|
838
|
+
"""The declared instrument model, from the shape the exporter chose.
|
|
839
|
+
|
|
840
|
+
Vendors write the model two ways: generic ``MS:1000031 instrument
|
|
841
|
+
model`` carrying the name as free text, or the model's own child term
|
|
842
|
+
as a valueless flag (``MS:1001549 solariX``, ``MS:1003124 timsTOF
|
|
843
|
+
fleX``). The explicit value wins; a known model accession resolves
|
|
844
|
+
through the shipped ontology table; and a term newer than the table is
|
|
845
|
+
still usable when its *name* identifies a family.
|
|
846
|
+
"""
|
|
847
|
+
value = self._config_param(configs, "MS:1000031")
|
|
848
|
+
if isinstance(value, str) and value.strip():
|
|
849
|
+
return value.strip()
|
|
850
|
+
|
|
851
|
+
for config in configs:
|
|
852
|
+
params = getattr(config, "param_by_accession", None)
|
|
853
|
+
if not isinstance(params, dict):
|
|
854
|
+
continue
|
|
855
|
+
for accession in params:
|
|
856
|
+
if accession in _MODEL_FAMILIES:
|
|
857
|
+
return ONTOLOGY.terms[accession][0]
|
|
858
|
+
|
|
859
|
+
for config in configs:
|
|
860
|
+
params = getattr(config, "param_by_name", None)
|
|
861
|
+
if not isinstance(params, dict):
|
|
862
|
+
continue
|
|
863
|
+
for name, value in params.items():
|
|
864
|
+
if (
|
|
865
|
+
isinstance(name, str)
|
|
866
|
+
and value is True
|
|
867
|
+
and _family_from_model_text(name) is not None
|
|
868
|
+
):
|
|
869
|
+
return name
|
|
870
|
+
|
|
871
|
+
return None
|
|
872
|
+
|
|
873
|
+
@staticmethod
|
|
874
|
+
def _analyzer_accession(configs: List[Any]) -> Optional[str]:
|
|
875
|
+
"""The highest-priority analyzer term declared on any componentList."""
|
|
876
|
+
declared: set = set()
|
|
877
|
+
for config in configs:
|
|
878
|
+
for component in getattr(config, "components", None) or []:
|
|
879
|
+
if getattr(component, "type", None) != "analyzer":
|
|
880
|
+
continue
|
|
881
|
+
params = getattr(component, "param_by_accession", None)
|
|
882
|
+
if isinstance(params, dict):
|
|
883
|
+
declared.update(params)
|
|
884
|
+
|
|
885
|
+
for accession in _ANALYZER_ACCESSIONS:
|
|
886
|
+
if accession in declared:
|
|
887
|
+
return accession
|
|
888
|
+
return None
|
|
889
|
+
|
|
890
|
+
def _analyzer_family(
|
|
891
|
+
self,
|
|
892
|
+
configs: List[Any],
|
|
893
|
+
analyzer_accession: Optional[str],
|
|
894
|
+
model: Optional[str],
|
|
895
|
+
) -> Optional[str]:
|
|
896
|
+
"""Resolve the axis family, preferring the analyzer declaration.
|
|
897
|
+
|
|
898
|
+
The analyzer cvParam states the physics outright, so it outranks the
|
|
899
|
+
model term, which outranks a substring match on free-form model text.
|
|
900
|
+
A file that declares none of the three resolves to ``None``.
|
|
901
|
+
"""
|
|
902
|
+
if analyzer_accession in _ANALYZER_FAMILIES:
|
|
903
|
+
return _ANALYZER_FAMILIES[analyzer_accession]
|
|
904
|
+
if analyzer_accession is not None:
|
|
905
|
+
# A recognised analyzer outside the family table (TOF, ion trap)
|
|
906
|
+
# is a real declaration; do not second-guess it from the model.
|
|
907
|
+
return None
|
|
908
|
+
|
|
909
|
+
for config in configs:
|
|
910
|
+
params = getattr(config, "param_by_accession", None)
|
|
911
|
+
if not isinstance(params, dict):
|
|
912
|
+
continue
|
|
913
|
+
for accession in params:
|
|
914
|
+
family = _MODEL_FAMILIES.get(accession)
|
|
915
|
+
if family is not None:
|
|
916
|
+
return family
|
|
917
|
+
|
|
918
|
+
if model is not None:
|
|
919
|
+
return _family_from_model_text(model)
|
|
920
|
+
return None
|
|
676
921
|
|
|
677
922
|
def _extract_raw_metadata(self) -> Dict[str, Any]:
|
|
678
923
|
"""Extract raw metadata from imzmldict and spectrum cvParams."""
|
|
@@ -235,8 +235,16 @@ class WatersMetadataExtractor(MetadataExtractor):
|
|
|
235
235
|
)
|
|
236
236
|
|
|
237
237
|
def _extract_waters_specific(self) -> Dict[str, Any]:
|
|
238
|
-
"""Extract Waters format-specific metadata.
|
|
238
|
+
"""Extract Waters format-specific metadata.
|
|
239
|
+
|
|
240
|
+
``format`` is the key ``DataCharacteristics.from_metadata`` reads for
|
|
241
|
+
its format flags (``is_waters_raw`` prefix-matches on "Waters ").
|
|
242
|
+
``data_format`` predates it and only ever fed the stored
|
|
243
|
+
format-specific block, so both are kept: renaming it would change
|
|
244
|
+
stored metadata for no downstream gain.
|
|
245
|
+
"""
|
|
239
246
|
return {
|
|
247
|
+
"format": "Waters MassLynx raw",
|
|
240
248
|
"data_format": "waters_raw",
|
|
241
249
|
"data_path": str(self._data_path),
|
|
242
250
|
"is_imaging": True,
|
|
@@ -280,9 +288,15 @@ class WatersMetadataExtractor(MetadataExtractor):
|
|
|
280
288
|
return params
|
|
281
289
|
|
|
282
290
|
def _extract_instrument_info(self) -> Dict[str, Any]:
|
|
283
|
-
"""Extract instrument information.
|
|
291
|
+
"""Extract instrument information.
|
|
292
|
+
|
|
293
|
+
The key is ``manufacturer`` because that is what
|
|
294
|
+
``DataCharacteristics.from_metadata`` reads; this used to say
|
|
295
|
+
``vendor``, which nothing downstream consumed, so the fact that a
|
|
296
|
+
file was Waters never reached the resampling detector chain.
|
|
297
|
+
"""
|
|
284
298
|
return {
|
|
285
|
-
"
|
|
299
|
+
"manufacturer": "Waters",
|
|
286
300
|
"format": "MassLynx .raw",
|
|
287
301
|
}
|
|
288
302
|
|
|
@@ -34,6 +34,7 @@ class DataCharacteristics:
|
|
|
34
34
|
is_rapiflex_format: bool = False
|
|
35
35
|
is_timstof: bool = False
|
|
36
36
|
is_phi_tofsims: bool = False
|
|
37
|
+
is_waters_raw: bool = False
|
|
37
38
|
|
|
38
39
|
@property
|
|
39
40
|
def needs_resampling(self) -> bool:
|
|
@@ -123,7 +124,17 @@ class DataCharacteristics:
|
|
|
123
124
|
instrument_info.get("instrument_type") if instrument_info else None
|
|
124
125
|
)
|
|
125
126
|
manufacturer = instrument_info.get("manufacturer") if instrument_info else None
|
|
127
|
+
# Bruker .d surfaces the name in GlobalMetadata, which stays
|
|
128
|
+
# authoritative; an imzML surfaces the declared model term through
|
|
129
|
+
# instrument_info instead. Folding the two here keeps ``is_timstof``
|
|
130
|
+
# below the single deciding path for both routes -- a timsTOF fleX
|
|
131
|
+
# exported to imzML used to lose its identity entirely because only
|
|
132
|
+
# GlobalMetadata was consulted.
|
|
126
133
|
instrument_name = global_meta.get("InstrumentName") if global_meta else None
|
|
134
|
+
if not instrument_name and instrument_info:
|
|
135
|
+
instrument_name = instrument_info.get(
|
|
136
|
+
"instrument_model"
|
|
137
|
+
) or instrument_info.get("instrument_name")
|
|
127
138
|
|
|
128
139
|
# Format detection
|
|
129
140
|
is_rapiflex = (
|
|
@@ -144,6 +155,10 @@ class DataCharacteristics:
|
|
|
144
155
|
source_format = format_specific.get("format") if format_specific else None
|
|
145
156
|
is_phi_tofsims = bool(source_format and source_format.startswith("PHI "))
|
|
146
157
|
|
|
158
|
+
# WatersMetadataExtractor stamps "Waters MassLynx raw"; same prefix
|
|
159
|
+
# convention as the PHI flag above.
|
|
160
|
+
is_waters_raw = bool(source_format and source_format.startswith("Waters "))
|
|
161
|
+
|
|
147
162
|
return cls(
|
|
148
163
|
spectrum_type=spectrum_type,
|
|
149
164
|
total_peaks=total_peaks,
|
|
@@ -154,4 +169,5 @@ class DataCharacteristics:
|
|
|
154
169
|
is_rapiflex_format=is_rapiflex,
|
|
155
170
|
is_timstof=is_timstof,
|
|
156
171
|
is_phi_tofsims=is_phi_tofsims,
|
|
172
|
+
is_waters_raw=is_waters_raw,
|
|
157
173
|
)
|
|
@@ -298,6 +298,48 @@ class PhiToFSIMSDetector(InstrumentDetector):
|
|
|
298
298
|
return AxisType.LINEAR_TOF
|
|
299
299
|
|
|
300
300
|
|
|
301
|
+
class WatersDetector(InstrumentDetector):
|
|
302
|
+
"""Detector for Waters MassLynx .raw imaging data (SELECT SERIES MRT et al.).
|
|
303
|
+
|
|
304
|
+
Without this detector a Waters acquisition's fate hinged on its declared
|
|
305
|
+
spectrum representation: centroid files happened to land on
|
|
306
|
+
:class:`CentroidImzMLDetector` -- the right answer, reached by accident on
|
|
307
|
+
a detector whose name says imzML -- while profile files fell through to
|
|
308
|
+
:class:`DefaultDetector`. That reported ``CONSTANT``, which downstream
|
|
309
|
+
pairs with the 0.1 Da default bin width: R = 5,000 at m/z 500, on an
|
|
310
|
+
instrument built for 100,000+.
|
|
311
|
+
"""
|
|
312
|
+
|
|
313
|
+
@property
|
|
314
|
+
def name(self) -> str:
|
|
315
|
+
"""Return detector name."""
|
|
316
|
+
return "Waters MassLynx"
|
|
317
|
+
|
|
318
|
+
def matches(self, characteristics: DataCharacteristics) -> bool:
|
|
319
|
+
"""Check whether the Waters extractor stamped the MassLynx raw format."""
|
|
320
|
+
return characteristics.is_waters_raw
|
|
321
|
+
|
|
322
|
+
def get_resampling_method(self) -> ResamplingMethod:
|
|
323
|
+
"""Return nearest-neighbour, which bins counts and cannot invent them."""
|
|
324
|
+
return ResamplingMethod.NEAREST_NEIGHBOR
|
|
325
|
+
|
|
326
|
+
def get_axis_type(self) -> AxisType:
|
|
327
|
+
"""Return reflector TOF: bin width proportional to m/z.
|
|
328
|
+
|
|
329
|
+
SCiLS Lab 2026b calls the same law Orthogonal TOF (formerly Reflector
|
|
330
|
+
TOF): bin size proportional to m/z, i.e. constant relative resolution
|
|
331
|
+
(2026b User Guide, p.75). That is the law for an orthogonal-TOF
|
|
332
|
+
instrument like the SELECT SERIES MRT.
|
|
333
|
+
"""
|
|
334
|
+
return AxisType.REFLECTOR_TOF
|
|
335
|
+
|
|
336
|
+
# ``source_grid_law`` stays at the inherited ``None`` on purpose: MassLynx
|
|
337
|
+
# lays out the stored m/z grid, not Thyra, and no law has been measured
|
|
338
|
+
# for it. Declaring one untested would open ``_gate_tic_preserving`` --
|
|
339
|
+
# the gate that keeps auto-selection off the interpolating path -- on
|
|
340
|
+
# data whose grid Thyra has not actually identified.
|
|
341
|
+
|
|
342
|
+
|
|
301
343
|
class DefaultDetector(InstrumentDetector):
|
|
302
344
|
"""Fallback detector for unknown instruments.
|
|
303
345
|
|
|
@@ -351,6 +393,7 @@ class InstrumentDetectorChain:
|
|
|
351
393
|
FTICRDetector(),
|
|
352
394
|
OrbitrapDetector(),
|
|
353
395
|
PhiToFSIMSDetector(),
|
|
396
|
+
WatersDetector(),
|
|
354
397
|
# Generic spectrum type detector
|
|
355
398
|
CentroidImzMLDetector(),
|
|
356
399
|
# Fallback last
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|