rangefinder 0.1.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.
- rangefinder-0.1.0/LICENSE +21 -0
- rangefinder-0.1.0/PKG-INFO +135 -0
- rangefinder-0.1.0/README.md +91 -0
- rangefinder-0.1.0/pyproject.toml +68 -0
- rangefinder-0.1.0/setup.cfg +4 -0
- rangefinder-0.1.0/src/rangefinder/__init__.py +35 -0
- rangefinder-0.1.0/src/rangefinder/analysis/__init__.py +1 -0
- rangefinder-0.1.0/src/rangefinder/analysis/apyt_support.py +195 -0
- rangefinder-0.1.0/src/rangefinder/analysis/assignment.py +2142 -0
- rangefinder-0.1.0/src/rangefinder/analysis/common.py +797 -0
- rangefinder-0.1.0/src/rangefinder/analysis/comparison.py +219 -0
- rangefinder-0.1.0/src/rangefinder/analysis/custom_pipeline.py +1774 -0
- rangefinder-0.1.0/src/rangefinder/analysis/isotope_audit.py +425 -0
- rangefinder-0.1.0/src/rangefinder/analysis/models.py +32 -0
- rangefinder-0.1.0/src/rangefinder/analysis/naive_pipeline.py +126 -0
- rangefinder-0.1.0/src/rangefinder/analysis/pipeline_utils.py +184 -0
- rangefinder-0.1.0/src/rangefinder/analysis/plotting.py +77 -0
- rangefinder-0.1.0/src/rangefinder/analysis/pyccapt_pipeline.py +101 -0
- rangefinder-0.1.0/src/rangefinder/analysis/pyopenms_pipeline.py +91 -0
- rangefinder-0.1.0/src/rangefinder/analysis/quality.py +164 -0
- rangefinder-0.1.0/src/rangefinder/analysis/segmentation.py +363 -0
- rangefinder-0.1.0/src/rangefinder/analysis/tof_fitting.py +549 -0
- rangefinder-0.1.0/src/rangefinder/benchmark/__init__.py +0 -0
- rangefinder-0.1.0/src/rangefinder/benchmark/detectors.py +254 -0
- rangefinder-0.1.0/src/rangefinder/benchmark/metrics.py +170 -0
- rangefinder-0.1.0/src/rangefinder/benchmark/ranges.py +134 -0
- rangefinder-0.1.0/src/rangefinder/benchmark/truth.py +191 -0
- rangefinder-0.1.0/src/rangefinder/bridge.py +76 -0
- rangefinder-0.1.0/src/rangefinder/bridge_runner.py +557 -0
- rangefinder-0.1.0/src/rangefinder/config/defaults.yaml +360 -0
- rangefinder-0.1.0/src/rangefinder/io/__init__.py +1 -0
- rangefinder-0.1.0/src/rangefinder/io/pos_loader.py +138 -0
- rangefinder-0.1.0/src/rangefinder/references/isotopes.py +44 -0
- rangefinder-0.1.0/src/rangefinder/utils/__init__.py +1 -0
- rangefinder-0.1.0/src/rangefinder/utils/config.py +10 -0
- rangefinder-0.1.0/src/rangefinder/utils/logging_utils.py +17 -0
- rangefinder-0.1.0/src/rangefinder/utils/paths.py +46 -0
- rangefinder-0.1.0/src/rangefinder/validation/__init__.py +0 -0
- rangefinder-0.1.0/src/rangefinder/validation/synthetic.py +304 -0
- rangefinder-0.1.0/src/rangefinder.egg-info/PKG-INFO +135 -0
- rangefinder-0.1.0/src/rangefinder.egg-info/SOURCES.txt +47 -0
- rangefinder-0.1.0/src/rangefinder.egg-info/dependency_links.txt +1 -0
- rangefinder-0.1.0/src/rangefinder.egg-info/requires.txt +20 -0
- rangefinder-0.1.0/src/rangefinder.egg-info/top_level.txt +1 -0
- rangefinder-0.1.0/tests/test_apyt_support.py +54 -0
- rangefinder-0.1.0/tests/test_isotope_systematics.py +155 -0
- rangefinder-0.1.0/tests/test_parsimony_guards.py +96 -0
- rangefinder-0.1.0/tests/test_segmentation.py +119 -0
- rangefinder-0.1.0/tests/test_tof_fitting_regression.py +82 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kyle McDonald
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rangefinder
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Automated ranging, species identification, and composition for time-of-flight (APT) mass spectra
|
|
5
|
+
Author: Kyle McDonald
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/kylemcdonald/rangefinder
|
|
8
|
+
Project-URL: Repository, https://github.com/kylemcdonald/rangefinder.git
|
|
9
|
+
Project-URL: Issues, https://github.com/kylemcdonald/rangefinder/issues
|
|
10
|
+
Keywords: atom probe tomography,mass spectrometry,ranging,composition
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: Chemistry
|
|
22
|
+
Classifier: Topic :: Scientific/Engineering :: Physics
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Requires-Dist: ase>=3.22
|
|
27
|
+
Requires-Dist: ifes-apt-tc-data-modeling>=0.4
|
|
28
|
+
Requires-Dist: numpy>=1.24
|
|
29
|
+
Requires-Dist: scipy>=1.10
|
|
30
|
+
Requires-Dist: pandas>=2.0
|
|
31
|
+
Requires-Dist: pyyaml>=6.0
|
|
32
|
+
Requires-Dist: matplotlib>=3.6
|
|
33
|
+
Provides-Extra: comparators
|
|
34
|
+
Requires-Dist: pyccapt>=0.2; extra == "comparators"
|
|
35
|
+
Requires-Dist: pyopenms>=3.0; extra == "comparators"
|
|
36
|
+
Provides-Extra: test
|
|
37
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
38
|
+
Provides-Extra: dev
|
|
39
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
40
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
41
|
+
Requires-Dist: ruff>=0.9; extra == "dev"
|
|
42
|
+
Requires-Dist: twine>=6; extra == "dev"
|
|
43
|
+
Dynamic: license-file
|
|
44
|
+
|
|
45
|
+
# Rangefinder
|
|
46
|
+
|
|
47
|
+
[](https://github.com/kylemcdonald/rangefinder/actions/workflows/ci.yml)
|
|
48
|
+
|
|
49
|
+
Automated **ranging**, species identification, and composition for time-of-flight
|
|
50
|
+
(atom probe tomography) mass spectra. Rangefinder goes from a raw event list
|
|
51
|
+
(`.pos`/`.epos`, or any array of *m/z* values) to ranged peaks, identified ionic
|
|
52
|
+
species, and isotope-resolved composition — with **no user-supplied ranges, no
|
|
53
|
+
candidate element list, no interactive tuning, and no manual curation**.
|
|
54
|
+
|
|
55
|
+
It is deterministic (operator variance is exactly zero), pure Python
|
|
56
|
+
(NumPy/SciPy/pandas), and ships its own benchmark harness against the open-source
|
|
57
|
+
APT ecosystem.
|
|
58
|
+
|
|
59
|
+
## Install
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
python -m pip install rangefinder
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
For development:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
git clone https://github.com/kylemcdonald/rangefinder.git
|
|
69
|
+
cd rangefinder
|
|
70
|
+
python -m venv .venv
|
|
71
|
+
source .venv/bin/activate
|
|
72
|
+
python -m pip install -e ".[dev]"
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Quick start
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from pathlib import Path
|
|
79
|
+
|
|
80
|
+
import rangefinder
|
|
81
|
+
from rangefinder.io.pos_loader import PosSampleData, PosSampleMetadata
|
|
82
|
+
from rangefinder.benchmark.truth import load_pos_arrays
|
|
83
|
+
|
|
84
|
+
sample_path = Path("your_sample.pos")
|
|
85
|
+
x, y, z, mz = load_pos_arrays(sample_path)
|
|
86
|
+
meta = PosSampleMetadata(path=sample_path, sample_name="s", sample_slug="s",
|
|
87
|
+
event_count=len(mz), file_size_bytes=len(mz) * 16,
|
|
88
|
+
verified_big_endian_float32=True,
|
|
89
|
+
verified_columns=("x_nm", "y_nm", "z_nm", "m_over_z_da"))
|
|
90
|
+
sample = PosSampleData(metadata=meta, x_nm=x, y_nm=y, z_nm=z, m_over_z_da=mz)
|
|
91
|
+
|
|
92
|
+
config = rangefinder.load_default_config()
|
|
93
|
+
artifacts = rangefinder.run_custom_pipeline(sample, Path("out"), config)
|
|
94
|
+
print(artifacts.elemental_composition)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Layout
|
|
98
|
+
|
|
99
|
+
- `src/rangefinder/` — the package.
|
|
100
|
+
- `analysis/` — the Rangefinder pipeline (`custom_pipeline`) plus the comparator
|
|
101
|
+
front-ends (naive, PyCCAPT, pyOpenMS) and shared assignment/composition stages.
|
|
102
|
+
- `benchmark/` — ground-truth registry, metrics, and detection-ablation harness.
|
|
103
|
+
- `io/`, `references/`, `validation/`, `utils/` — POS loading, isotope tables,
|
|
104
|
+
the synthetic generator, config/paths helpers.
|
|
105
|
+
- `bridge.py`, `bridge_runner.py` — run foreign-toolchain comparators
|
|
106
|
+
(APAV, ms_deisotope, APyT) in a separate environment.
|
|
107
|
+
- `config/defaults.yaml` — the bundled default pipeline configuration
|
|
108
|
+
(`rangefinder.default_config_path()`).
|
|
109
|
+
- `scripts/` — the benchmark and paper-table generators.
|
|
110
|
+
- `paper/` — `rangefinder.tex` and its auto-generated `tables/`.
|
|
111
|
+
- `controls/` — public CC-BY reference datasets (fetch large files with
|
|
112
|
+
`scripts/download_benchmark_controls.sh`; reproducible raw data and generated
|
|
113
|
+
synthetic controls are git-ignored, while provenance and range files are
|
|
114
|
+
tracked).
|
|
115
|
+
|
|
116
|
+
## Reproduce the benchmark and paper
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
scripts/download_benchmark_controls.sh # fetch public controls
|
|
120
|
+
scripts/bootstrap_env.sh # install comparator environments
|
|
121
|
+
python scripts/run_benchmark.py # all methods x all datasets
|
|
122
|
+
python scripts/run_detection_ablation.py # detection-stage ablation
|
|
123
|
+
python scripts/run_ablation.py # pipeline-stage ablation
|
|
124
|
+
python scripts/build_paper_tables.py # regenerate paper/tables/*.tex
|
|
125
|
+
tectonic paper/rangefinder.tex
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Every number in the paper regenerates from these commands; table numbers are
|
|
129
|
+
never hand-edited.
|
|
130
|
+
|
|
131
|
+
## License
|
|
132
|
+
|
|
133
|
+
The software is released under the [MIT License](LICENSE). The reference data
|
|
134
|
+
and range files under `controls/` remain CC-BY-4.0 as documented in
|
|
135
|
+
[`controls/README.md`](controls/README.md).
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Rangefinder
|
|
2
|
+
|
|
3
|
+
[](https://github.com/kylemcdonald/rangefinder/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
Automated **ranging**, species identification, and composition for time-of-flight
|
|
6
|
+
(atom probe tomography) mass spectra. Rangefinder goes from a raw event list
|
|
7
|
+
(`.pos`/`.epos`, or any array of *m/z* values) to ranged peaks, identified ionic
|
|
8
|
+
species, and isotope-resolved composition — with **no user-supplied ranges, no
|
|
9
|
+
candidate element list, no interactive tuning, and no manual curation**.
|
|
10
|
+
|
|
11
|
+
It is deterministic (operator variance is exactly zero), pure Python
|
|
12
|
+
(NumPy/SciPy/pandas), and ships its own benchmark harness against the open-source
|
|
13
|
+
APT ecosystem.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
python -m pip install rangefinder
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
For development:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
git clone https://github.com/kylemcdonald/rangefinder.git
|
|
25
|
+
cd rangefinder
|
|
26
|
+
python -m venv .venv
|
|
27
|
+
source .venv/bin/activate
|
|
28
|
+
python -m pip install -e ".[dev]"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Quick start
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
import rangefinder
|
|
37
|
+
from rangefinder.io.pos_loader import PosSampleData, PosSampleMetadata
|
|
38
|
+
from rangefinder.benchmark.truth import load_pos_arrays
|
|
39
|
+
|
|
40
|
+
sample_path = Path("your_sample.pos")
|
|
41
|
+
x, y, z, mz = load_pos_arrays(sample_path)
|
|
42
|
+
meta = PosSampleMetadata(path=sample_path, sample_name="s", sample_slug="s",
|
|
43
|
+
event_count=len(mz), file_size_bytes=len(mz) * 16,
|
|
44
|
+
verified_big_endian_float32=True,
|
|
45
|
+
verified_columns=("x_nm", "y_nm", "z_nm", "m_over_z_da"))
|
|
46
|
+
sample = PosSampleData(metadata=meta, x_nm=x, y_nm=y, z_nm=z, m_over_z_da=mz)
|
|
47
|
+
|
|
48
|
+
config = rangefinder.load_default_config()
|
|
49
|
+
artifacts = rangefinder.run_custom_pipeline(sample, Path("out"), config)
|
|
50
|
+
print(artifacts.elemental_composition)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Layout
|
|
54
|
+
|
|
55
|
+
- `src/rangefinder/` — the package.
|
|
56
|
+
- `analysis/` — the Rangefinder pipeline (`custom_pipeline`) plus the comparator
|
|
57
|
+
front-ends (naive, PyCCAPT, pyOpenMS) and shared assignment/composition stages.
|
|
58
|
+
- `benchmark/` — ground-truth registry, metrics, and detection-ablation harness.
|
|
59
|
+
- `io/`, `references/`, `validation/`, `utils/` — POS loading, isotope tables,
|
|
60
|
+
the synthetic generator, config/paths helpers.
|
|
61
|
+
- `bridge.py`, `bridge_runner.py` — run foreign-toolchain comparators
|
|
62
|
+
(APAV, ms_deisotope, APyT) in a separate environment.
|
|
63
|
+
- `config/defaults.yaml` — the bundled default pipeline configuration
|
|
64
|
+
(`rangefinder.default_config_path()`).
|
|
65
|
+
- `scripts/` — the benchmark and paper-table generators.
|
|
66
|
+
- `paper/` — `rangefinder.tex` and its auto-generated `tables/`.
|
|
67
|
+
- `controls/` — public CC-BY reference datasets (fetch large files with
|
|
68
|
+
`scripts/download_benchmark_controls.sh`; reproducible raw data and generated
|
|
69
|
+
synthetic controls are git-ignored, while provenance and range files are
|
|
70
|
+
tracked).
|
|
71
|
+
|
|
72
|
+
## Reproduce the benchmark and paper
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
scripts/download_benchmark_controls.sh # fetch public controls
|
|
76
|
+
scripts/bootstrap_env.sh # install comparator environments
|
|
77
|
+
python scripts/run_benchmark.py # all methods x all datasets
|
|
78
|
+
python scripts/run_detection_ablation.py # detection-stage ablation
|
|
79
|
+
python scripts/run_ablation.py # pipeline-stage ablation
|
|
80
|
+
python scripts/build_paper_tables.py # regenerate paper/tables/*.tex
|
|
81
|
+
tectonic paper/rangefinder.tex
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Every number in the paper regenerates from these commands; table numbers are
|
|
85
|
+
never hand-edited.
|
|
86
|
+
|
|
87
|
+
## License
|
|
88
|
+
|
|
89
|
+
The software is released under the [MIT License](LICENSE). The reference data
|
|
90
|
+
and range files under `controls/` remain CC-BY-4.0 as documented in
|
|
91
|
+
[`controls/README.md`](controls/README.md).
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77.0.3", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "rangefinder"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Automated ranging, species identification, and composition for time-of-flight (APT) mass spectra"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Kyle McDonald" }]
|
|
14
|
+
keywords = ["atom probe tomography", "mass spectrometry", "ranging", "composition"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Science/Research",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Programming Language :: Python :: 3.14",
|
|
26
|
+
"Topic :: Scientific/Engineering :: Chemistry",
|
|
27
|
+
"Topic :: Scientific/Engineering :: Physics",
|
|
28
|
+
]
|
|
29
|
+
dependencies = [
|
|
30
|
+
"ase>=3.22",
|
|
31
|
+
"ifes-apt-tc-data-modeling>=0.4",
|
|
32
|
+
"numpy>=1.24",
|
|
33
|
+
"scipy>=1.10",
|
|
34
|
+
"pandas>=2.0",
|
|
35
|
+
"pyyaml>=6.0",
|
|
36
|
+
"matplotlib>=3.6",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[project.optional-dependencies]
|
|
40
|
+
# Foreign-toolchain comparators used only by the benchmark harness.
|
|
41
|
+
comparators = ["pyccapt>=0.2", "pyopenms>=3.0"]
|
|
42
|
+
test = ["pytest>=8"]
|
|
43
|
+
dev = ["build>=1.2", "pytest>=8", "ruff>=0.9", "twine>=6"]
|
|
44
|
+
|
|
45
|
+
[project.urls]
|
|
46
|
+
Homepage = "https://github.com/kylemcdonald/rangefinder"
|
|
47
|
+
Repository = "https://github.com/kylemcdonald/rangefinder.git"
|
|
48
|
+
Issues = "https://github.com/kylemcdonald/rangefinder/issues"
|
|
49
|
+
|
|
50
|
+
[tool.setuptools]
|
|
51
|
+
package-dir = { "" = "src" }
|
|
52
|
+
include-package-data = true
|
|
53
|
+
|
|
54
|
+
[tool.setuptools.packages.find]
|
|
55
|
+
where = ["src"]
|
|
56
|
+
|
|
57
|
+
[tool.setuptools.dynamic]
|
|
58
|
+
version = { attr = "rangefinder.__version__" }
|
|
59
|
+
|
|
60
|
+
[tool.setuptools.package-data]
|
|
61
|
+
rangefinder = ["config/*.yaml"]
|
|
62
|
+
|
|
63
|
+
[tool.pytest.ini_options]
|
|
64
|
+
testpaths = ["tests"]
|
|
65
|
+
|
|
66
|
+
[tool.ruff]
|
|
67
|
+
line-length = 100
|
|
68
|
+
target-version = "py310"
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Rangefinder: automated ranging, species identification, and composition for
|
|
2
|
+
time-of-flight mass spectra.
|
|
3
|
+
|
|
4
|
+
Goes from a raw event list (``.pos``/``.epos`` or any array of m/z values) to
|
|
5
|
+
ranged peaks, identified species, and isotope-resolved composition, with no
|
|
6
|
+
user-supplied ranges, element list, or interactive tuning.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from importlib import resources
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from rangefinder.analysis.custom_pipeline import run_custom_pipeline
|
|
14
|
+
from rangefinder.analysis.naive_pipeline import run_naive_pipeline
|
|
15
|
+
from rangefinder.utils.config import load_config
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"run_custom_pipeline",
|
|
19
|
+
"run_naive_pipeline",
|
|
20
|
+
"default_config_path",
|
|
21
|
+
"load_default_config",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
__version__ = "0.1.0"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def default_config_path() -> Path:
|
|
28
|
+
"""Path to the bundled default pipeline configuration."""
|
|
29
|
+
with resources.as_file(resources.files("rangefinder").joinpath("config/defaults.yaml")) as path:
|
|
30
|
+
return Path(path)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_default_config() -> dict:
|
|
34
|
+
"""Load the bundled default pipeline configuration."""
|
|
35
|
+
return load_config(default_config_path())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Analysis pipelines."""
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import Counter, defaultdict
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
from rangefinder.analysis.assignment import infer_element_support
|
|
10
|
+
from rangefinder.analysis.common import build_species_library, neutral_formula_from_element_counter
|
|
11
|
+
from rangefinder.references.isotopes import isotope_table
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_FORMULA_RE = re.compile(r"([A-Z][a-z]?)(\d*)")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parse_formula(formula: str) -> dict[str, int]:
|
|
18
|
+
text = str(formula).strip()
|
|
19
|
+
if not text:
|
|
20
|
+
raise ValueError("Formula must be non-empty.")
|
|
21
|
+
counts: dict[str, int] = {}
|
|
22
|
+
position = 0
|
|
23
|
+
for match in _FORMULA_RE.finditer(text):
|
|
24
|
+
if match.start() != position:
|
|
25
|
+
raise ValueError(f"Unsupported formula syntax: {formula!r}")
|
|
26
|
+
symbol = str(match.group(1))
|
|
27
|
+
count_text = str(match.group(2))
|
|
28
|
+
count = int(count_text) if count_text else 1
|
|
29
|
+
counts[symbol] = counts.get(symbol, 0) + count
|
|
30
|
+
position = match.end()
|
|
31
|
+
if position != len(text):
|
|
32
|
+
raise ValueError(f"Unsupported formula syntax: {formula!r}")
|
|
33
|
+
return counts
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def formula_category(formula: str) -> str:
|
|
37
|
+
counts = parse_formula(formula)
|
|
38
|
+
return "atomic" if len(counts) == 1 and next(iter(counts.values())) == 1 else "molecular"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def charged_formula_label(formula: str, charge: int) -> str:
|
|
42
|
+
return f"{formula}{'+' if int(charge) == 1 else f'{int(charge)}+'}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def select_apyt_species_candidates(peaks: pd.DataFrame, *, config: dict) -> pd.DataFrame:
|
|
46
|
+
if peaks.empty:
|
|
47
|
+
return pd.DataFrame(
|
|
48
|
+
columns=[
|
|
49
|
+
"formula",
|
|
50
|
+
"charge",
|
|
51
|
+
"category",
|
|
52
|
+
"support_score",
|
|
53
|
+
"natural_probability",
|
|
54
|
+
"m_over_z_da",
|
|
55
|
+
]
|
|
56
|
+
)
|
|
57
|
+
analysis_cfg = config["analysis"]
|
|
58
|
+
element_support = infer_element_support(
|
|
59
|
+
peaks,
|
|
60
|
+
max_charge=int(analysis_cfg["max_charge"]),
|
|
61
|
+
ppm_tolerance=float(analysis_cfg["ppm_tolerance"]),
|
|
62
|
+
min_absolute_tolerance_da=float(analysis_cfg["min_absolute_tolerance_da"]),
|
|
63
|
+
max_absolute_tolerance_da=float(analysis_cfg["max_absolute_tolerance_da"]),
|
|
64
|
+
)
|
|
65
|
+
always_include = list(dict.fromkeys(list(analysis_cfg["always_include_elements"])))
|
|
66
|
+
species_library = build_species_library(
|
|
67
|
+
isotope_table(),
|
|
68
|
+
element_support,
|
|
69
|
+
always_include=always_include,
|
|
70
|
+
max_candidate_elements=int(
|
|
71
|
+
analysis_cfg.get("apyt_max_candidate_elements", analysis_cfg["max_candidate_elements"])
|
|
72
|
+
),
|
|
73
|
+
max_charge=int(analysis_cfg.get("apyt_max_charge", analysis_cfg["max_charge"])),
|
|
74
|
+
max_molecular_charge=int(
|
|
75
|
+
analysis_cfg.get("apyt_max_molecular_charge", analysis_cfg["max_molecular_charge"])
|
|
76
|
+
),
|
|
77
|
+
max_molecular_atoms=int(
|
|
78
|
+
analysis_cfg.get("apyt_max_molecular_atoms", analysis_cfg["max_molecular_atoms"])
|
|
79
|
+
),
|
|
80
|
+
)
|
|
81
|
+
if species_library.empty:
|
|
82
|
+
return pd.DataFrame()
|
|
83
|
+
selected = species_library.copy()
|
|
84
|
+
selected["formula"] = selected["element_counts"].map(
|
|
85
|
+
lambda counts: neutral_formula_from_element_counter(Counter(dict(counts)))
|
|
86
|
+
)
|
|
87
|
+
min_fit_mz = float(
|
|
88
|
+
analysis_cfg.get(
|
|
89
|
+
"apyt_min_fit_mz",
|
|
90
|
+
min(float(analysis_cfg["default_zoom_bin_width_da"]), 0.01),
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
selected = selected[
|
|
94
|
+
(selected["m_over_z_da"] >= min_fit_mz)
|
|
95
|
+
& (selected["m_over_z_da"] <= float(analysis_cfg["max_mz"]))
|
|
96
|
+
].copy()
|
|
97
|
+
if selected.empty:
|
|
98
|
+
return pd.DataFrame()
|
|
99
|
+
atomic = (
|
|
100
|
+
selected[selected["category"] == "atomic"][
|
|
101
|
+
["formula", "charge", "category", "support_score", "natural_probability", "m_over_z_da"]
|
|
102
|
+
]
|
|
103
|
+
.groupby(["formula", "charge", "category"], as_index=False)
|
|
104
|
+
.max(numeric_only=True)
|
|
105
|
+
)
|
|
106
|
+
molecular = selected[selected["category"] == "molecular"].copy()
|
|
107
|
+
if not molecular.empty:
|
|
108
|
+
molecular = molecular.sort_values(
|
|
109
|
+
["support_score", "natural_probability", "m_over_z_da"],
|
|
110
|
+
ascending=[False, False, True],
|
|
111
|
+
)
|
|
112
|
+
molecular = molecular.drop_duplicates(["formula", "charge"], keep="first")
|
|
113
|
+
minimum_support = float(analysis_cfg.get("apyt_min_support_score", 0.0))
|
|
114
|
+
minimum_probability = float(analysis_cfg.get("apyt_min_natural_probability", 0.0))
|
|
115
|
+
molecular = molecular[
|
|
116
|
+
(molecular["support_score"] >= minimum_support)
|
|
117
|
+
& (molecular["natural_probability"] >= minimum_probability)
|
|
118
|
+
]
|
|
119
|
+
molecular = molecular.head(int(analysis_cfg.get("apyt_max_molecular_species", 0)))
|
|
120
|
+
molecular = molecular[
|
|
121
|
+
["formula", "charge", "category", "support_score", "natural_probability", "m_over_z_da"]
|
|
122
|
+
]
|
|
123
|
+
selected = pd.concat([atomic, molecular], ignore_index=True)
|
|
124
|
+
if selected.empty:
|
|
125
|
+
return pd.DataFrame()
|
|
126
|
+
return selected.sort_values(
|
|
127
|
+
["category", "support_score", "natural_probability", "formula", "charge"],
|
|
128
|
+
ascending=[True, False, False, True, True],
|
|
129
|
+
ignore_index=True,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def build_apyt_species_dictionary(
|
|
134
|
+
selection: pd.DataFrame,
|
|
135
|
+
*,
|
|
136
|
+
atomic_volume_nm3: float,
|
|
137
|
+
) -> dict[str, tuple[tuple[int, ...], float]]:
|
|
138
|
+
if selection.empty:
|
|
139
|
+
return {}
|
|
140
|
+
species: dict[str, tuple[tuple[int, ...], float]] = {}
|
|
141
|
+
for formula, group in selection.groupby("formula", sort=True):
|
|
142
|
+
charges = tuple(sorted({int(value) for value in group["charge"].tolist()}))
|
|
143
|
+
species[str(formula)] = (charges, float(atomic_volume_nm3))
|
|
144
|
+
return species
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def apyt_species_tables_from_counts(
|
|
148
|
+
counts_list: list[dict[str, float | int | str]],
|
|
149
|
+
) -> tuple[pd.DataFrame, pd.DataFrame]:
|
|
150
|
+
species_rows: list[dict[str, object]] = []
|
|
151
|
+
element_totals: defaultdict[str, float] = defaultdict(float)
|
|
152
|
+
for item in counts_list:
|
|
153
|
+
formula = str(item["element"])
|
|
154
|
+
charge = int(item["charge"])
|
|
155
|
+
count = float(item["count"])
|
|
156
|
+
if not np.isfinite(count) or count <= 0.0:
|
|
157
|
+
continue
|
|
158
|
+
category = formula_category(formula)
|
|
159
|
+
species_rows.append(
|
|
160
|
+
{
|
|
161
|
+
"species_label": charged_formula_label(formula, charge),
|
|
162
|
+
"category": category,
|
|
163
|
+
"charge": charge,
|
|
164
|
+
"weighted_counts": count,
|
|
165
|
+
"robust_counts": count,
|
|
166
|
+
}
|
|
167
|
+
)
|
|
168
|
+
for symbol, atom_count in parse_formula(formula).items():
|
|
169
|
+
element_totals[symbol] += count * float(atom_count)
|
|
170
|
+
species = pd.DataFrame(species_rows)
|
|
171
|
+
if species.empty:
|
|
172
|
+
return pd.DataFrame(), pd.DataFrame()
|
|
173
|
+
species = species.groupby(["species_label", "category", "charge"], as_index=False).sum(
|
|
174
|
+
numeric_only=True
|
|
175
|
+
)
|
|
176
|
+
total_species = max(float(species["weighted_counts"].sum()), 1.0)
|
|
177
|
+
species["atomic_percent_weighted"] = 100.0 * species["weighted_counts"] / total_species
|
|
178
|
+
species["atomic_percent_robust"] = 100.0 * species["robust_counts"] / total_species
|
|
179
|
+
elements = pd.DataFrame(
|
|
180
|
+
[
|
|
181
|
+
{
|
|
182
|
+
"element": symbol,
|
|
183
|
+
"weighted_counts": count,
|
|
184
|
+
"robust_counts": count,
|
|
185
|
+
}
|
|
186
|
+
for symbol, count in sorted(element_totals.items())
|
|
187
|
+
]
|
|
188
|
+
)
|
|
189
|
+
total_elements = max(float(elements["weighted_counts"].sum()), 1.0)
|
|
190
|
+
elements["atomic_percent_weighted"] = 100.0 * elements["weighted_counts"] / total_elements
|
|
191
|
+
elements["atomic_percent_robust"] = 100.0 * elements["robust_counts"] / total_elements
|
|
192
|
+
return (
|
|
193
|
+
species.sort_values("atomic_percent_weighted", ascending=False, ignore_index=True),
|
|
194
|
+
elements.sort_values("atomic_percent_weighted", ascending=False, ignore_index=True),
|
|
195
|
+
)
|