rangefinder 0.1.0__py3-none-any.whl
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/__init__.py +35 -0
- rangefinder/analysis/__init__.py +1 -0
- rangefinder/analysis/apyt_support.py +195 -0
- rangefinder/analysis/assignment.py +2142 -0
- rangefinder/analysis/common.py +797 -0
- rangefinder/analysis/comparison.py +219 -0
- rangefinder/analysis/custom_pipeline.py +1774 -0
- rangefinder/analysis/isotope_audit.py +425 -0
- rangefinder/analysis/models.py +32 -0
- rangefinder/analysis/naive_pipeline.py +126 -0
- rangefinder/analysis/pipeline_utils.py +184 -0
- rangefinder/analysis/plotting.py +77 -0
- rangefinder/analysis/pyccapt_pipeline.py +101 -0
- rangefinder/analysis/pyopenms_pipeline.py +91 -0
- rangefinder/analysis/quality.py +164 -0
- rangefinder/analysis/segmentation.py +363 -0
- rangefinder/analysis/tof_fitting.py +549 -0
- rangefinder/benchmark/__init__.py +0 -0
- rangefinder/benchmark/detectors.py +254 -0
- rangefinder/benchmark/metrics.py +170 -0
- rangefinder/benchmark/ranges.py +134 -0
- rangefinder/benchmark/truth.py +191 -0
- rangefinder/bridge.py +76 -0
- rangefinder/bridge_runner.py +557 -0
- rangefinder/config/defaults.yaml +360 -0
- rangefinder/io/__init__.py +1 -0
- rangefinder/io/pos_loader.py +138 -0
- rangefinder/references/isotopes.py +44 -0
- rangefinder/utils/__init__.py +1 -0
- rangefinder/utils/config.py +10 -0
- rangefinder/utils/logging_utils.py +17 -0
- rangefinder/utils/paths.py +46 -0
- rangefinder/validation/__init__.py +0 -0
- rangefinder/validation/synthetic.py +304 -0
- rangefinder-0.1.0.dist-info/METADATA +135 -0
- rangefinder-0.1.0.dist-info/RECORD +39 -0
- rangefinder-0.1.0.dist-info/WHEEL +5 -0
- rangefinder-0.1.0.dist-info/licenses/LICENSE +21 -0
- rangefinder-0.1.0.dist-info/top_level.txt +1 -0
rangefinder/__init__.py
ADDED
|
@@ -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
|
+
)
|