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.
Files changed (49) hide show
  1. rangefinder-0.1.0/LICENSE +21 -0
  2. rangefinder-0.1.0/PKG-INFO +135 -0
  3. rangefinder-0.1.0/README.md +91 -0
  4. rangefinder-0.1.0/pyproject.toml +68 -0
  5. rangefinder-0.1.0/setup.cfg +4 -0
  6. rangefinder-0.1.0/src/rangefinder/__init__.py +35 -0
  7. rangefinder-0.1.0/src/rangefinder/analysis/__init__.py +1 -0
  8. rangefinder-0.1.0/src/rangefinder/analysis/apyt_support.py +195 -0
  9. rangefinder-0.1.0/src/rangefinder/analysis/assignment.py +2142 -0
  10. rangefinder-0.1.0/src/rangefinder/analysis/common.py +797 -0
  11. rangefinder-0.1.0/src/rangefinder/analysis/comparison.py +219 -0
  12. rangefinder-0.1.0/src/rangefinder/analysis/custom_pipeline.py +1774 -0
  13. rangefinder-0.1.0/src/rangefinder/analysis/isotope_audit.py +425 -0
  14. rangefinder-0.1.0/src/rangefinder/analysis/models.py +32 -0
  15. rangefinder-0.1.0/src/rangefinder/analysis/naive_pipeline.py +126 -0
  16. rangefinder-0.1.0/src/rangefinder/analysis/pipeline_utils.py +184 -0
  17. rangefinder-0.1.0/src/rangefinder/analysis/plotting.py +77 -0
  18. rangefinder-0.1.0/src/rangefinder/analysis/pyccapt_pipeline.py +101 -0
  19. rangefinder-0.1.0/src/rangefinder/analysis/pyopenms_pipeline.py +91 -0
  20. rangefinder-0.1.0/src/rangefinder/analysis/quality.py +164 -0
  21. rangefinder-0.1.0/src/rangefinder/analysis/segmentation.py +363 -0
  22. rangefinder-0.1.0/src/rangefinder/analysis/tof_fitting.py +549 -0
  23. rangefinder-0.1.0/src/rangefinder/benchmark/__init__.py +0 -0
  24. rangefinder-0.1.0/src/rangefinder/benchmark/detectors.py +254 -0
  25. rangefinder-0.1.0/src/rangefinder/benchmark/metrics.py +170 -0
  26. rangefinder-0.1.0/src/rangefinder/benchmark/ranges.py +134 -0
  27. rangefinder-0.1.0/src/rangefinder/benchmark/truth.py +191 -0
  28. rangefinder-0.1.0/src/rangefinder/bridge.py +76 -0
  29. rangefinder-0.1.0/src/rangefinder/bridge_runner.py +557 -0
  30. rangefinder-0.1.0/src/rangefinder/config/defaults.yaml +360 -0
  31. rangefinder-0.1.0/src/rangefinder/io/__init__.py +1 -0
  32. rangefinder-0.1.0/src/rangefinder/io/pos_loader.py +138 -0
  33. rangefinder-0.1.0/src/rangefinder/references/isotopes.py +44 -0
  34. rangefinder-0.1.0/src/rangefinder/utils/__init__.py +1 -0
  35. rangefinder-0.1.0/src/rangefinder/utils/config.py +10 -0
  36. rangefinder-0.1.0/src/rangefinder/utils/logging_utils.py +17 -0
  37. rangefinder-0.1.0/src/rangefinder/utils/paths.py +46 -0
  38. rangefinder-0.1.0/src/rangefinder/validation/__init__.py +0 -0
  39. rangefinder-0.1.0/src/rangefinder/validation/synthetic.py +304 -0
  40. rangefinder-0.1.0/src/rangefinder.egg-info/PKG-INFO +135 -0
  41. rangefinder-0.1.0/src/rangefinder.egg-info/SOURCES.txt +47 -0
  42. rangefinder-0.1.0/src/rangefinder.egg-info/dependency_links.txt +1 -0
  43. rangefinder-0.1.0/src/rangefinder.egg-info/requires.txt +20 -0
  44. rangefinder-0.1.0/src/rangefinder.egg-info/top_level.txt +1 -0
  45. rangefinder-0.1.0/tests/test_apyt_support.py +54 -0
  46. rangefinder-0.1.0/tests/test_isotope_systematics.py +155 -0
  47. rangefinder-0.1.0/tests/test_parsimony_guards.py +96 -0
  48. rangefinder-0.1.0/tests/test_segmentation.py +119 -0
  49. 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
+ [![CI](https://github.com/kylemcdonald/rangefinder/actions/workflows/ci.yml/badge.svg)](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
+ [![CI](https://github.com/kylemcdonald/rangefinder/actions/workflows/ci.yml/badge.svg)](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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ )