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.
Files changed (39) hide show
  1. rangefinder/__init__.py +35 -0
  2. rangefinder/analysis/__init__.py +1 -0
  3. rangefinder/analysis/apyt_support.py +195 -0
  4. rangefinder/analysis/assignment.py +2142 -0
  5. rangefinder/analysis/common.py +797 -0
  6. rangefinder/analysis/comparison.py +219 -0
  7. rangefinder/analysis/custom_pipeline.py +1774 -0
  8. rangefinder/analysis/isotope_audit.py +425 -0
  9. rangefinder/analysis/models.py +32 -0
  10. rangefinder/analysis/naive_pipeline.py +126 -0
  11. rangefinder/analysis/pipeline_utils.py +184 -0
  12. rangefinder/analysis/plotting.py +77 -0
  13. rangefinder/analysis/pyccapt_pipeline.py +101 -0
  14. rangefinder/analysis/pyopenms_pipeline.py +91 -0
  15. rangefinder/analysis/quality.py +164 -0
  16. rangefinder/analysis/segmentation.py +363 -0
  17. rangefinder/analysis/tof_fitting.py +549 -0
  18. rangefinder/benchmark/__init__.py +0 -0
  19. rangefinder/benchmark/detectors.py +254 -0
  20. rangefinder/benchmark/metrics.py +170 -0
  21. rangefinder/benchmark/ranges.py +134 -0
  22. rangefinder/benchmark/truth.py +191 -0
  23. rangefinder/bridge.py +76 -0
  24. rangefinder/bridge_runner.py +557 -0
  25. rangefinder/config/defaults.yaml +360 -0
  26. rangefinder/io/__init__.py +1 -0
  27. rangefinder/io/pos_loader.py +138 -0
  28. rangefinder/references/isotopes.py +44 -0
  29. rangefinder/utils/__init__.py +1 -0
  30. rangefinder/utils/config.py +10 -0
  31. rangefinder/utils/logging_utils.py +17 -0
  32. rangefinder/utils/paths.py +46 -0
  33. rangefinder/validation/__init__.py +0 -0
  34. rangefinder/validation/synthetic.py +304 -0
  35. rangefinder-0.1.0.dist-info/METADATA +135 -0
  36. rangefinder-0.1.0.dist-info/RECORD +39 -0
  37. rangefinder-0.1.0.dist-info/WHEEL +5 -0
  38. rangefinder-0.1.0.dist-info/licenses/LICENSE +21 -0
  39. rangefinder-0.1.0.dist-info/top_level.txt +1 -0
@@ -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
+ )