rt-anchor 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 (38) hide show
  1. rt_anchor-0.1.0/LICENSE +21 -0
  2. rt_anchor-0.1.0/PKG-INFO +89 -0
  3. rt_anchor-0.1.0/README.md +56 -0
  4. rt_anchor-0.1.0/pyproject.toml +48 -0
  5. rt_anchor-0.1.0/setup.cfg +4 -0
  6. rt_anchor-0.1.0/src/rt_anchor/__init__.py +45 -0
  7. rt_anchor-0.1.0/src/rt_anchor/calibrate.py +149 -0
  8. rt_anchor-0.1.0/src/rt_anchor/cli.py +137 -0
  9. rt_anchor-0.1.0/src/rt_anchor/config.py +86 -0
  10. rt_anchor-0.1.0/src/rt_anchor/errors.py +40 -0
  11. rt_anchor-0.1.0/src/rt_anchor/helpers.py +126 -0
  12. rt_anchor-0.1.0/src/rt_anchor/identify.py +213 -0
  13. rt_anchor-0.1.0/src/rt_anchor/io/__init__.py +5 -0
  14. rt_anchor-0.1.0/src/rt_anchor/io/adapters.py +256 -0
  15. rt_anchor-0.1.0/src/rt_anchor/io/loader.py +81 -0
  16. rt_anchor-0.1.0/src/rt_anchor/io/schema.py +68 -0
  17. rt_anchor-0.1.0/src/rt_anchor/panel.py +213 -0
  18. rt_anchor-0.1.0/src/rt_anchor/pipeline.py +255 -0
  19. rt_anchor-0.1.0/src/rt_anchor/py.typed +0 -0
  20. rt_anchor-0.1.0/src/rt_anchor/viz/__init__.py +10 -0
  21. rt_anchor-0.1.0/src/rt_anchor/viz/metrics.py +229 -0
  22. rt_anchor-0.1.0/src/rt_anchor/viz/performance.py +77 -0
  23. rt_anchor-0.1.0/src/rt_anchor/viz/repeatability.py +133 -0
  24. rt_anchor-0.1.0/src/rt_anchor/viz/report.py +276 -0
  25. rt_anchor-0.1.0/src/rt_anchor/viz/structures.py +41 -0
  26. rt_anchor-0.1.0/src/rt_anchor/viz/theme.py +142 -0
  27. rt_anchor-0.1.0/src/rt_anchor/viz/tic.py +201 -0
  28. rt_anchor-0.1.0/src/rt_anchor.egg-info/PKG-INFO +89 -0
  29. rt_anchor-0.1.0/src/rt_anchor.egg-info/SOURCES.txt +36 -0
  30. rt_anchor-0.1.0/src/rt_anchor.egg-info/dependency_links.txt +1 -0
  31. rt_anchor-0.1.0/src/rt_anchor.egg-info/entry_points.txt +2 -0
  32. rt_anchor-0.1.0/src/rt_anchor.egg-info/requires.txt +19 -0
  33. rt_anchor-0.1.0/src/rt_anchor.egg-info/top_level.txt +1 -0
  34. rt_anchor-0.1.0/tests/test_calibrate.py +206 -0
  35. rt_anchor-0.1.0/tests/test_cli.py +110 -0
  36. rt_anchor-0.1.0/tests/test_errors.py +144 -0
  37. rt_anchor-0.1.0/tests/test_io.py +190 -0
  38. rt_anchor-0.1.0/tests/test_viz.py +102 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bowen Yang
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,89 @@
1
+ Metadata-Version: 2.4
2
+ Name: rt-anchor
3
+ Version: 0.1.0
4
+ Summary: Standard-panel retention-index (iRT) calibration for LC-MS lipidomics feature tables (MS-DIAL / MZmine / MassCube / LipidScreener)
5
+ Author: Bowen Yang
6
+ License-Expression: MIT
7
+ Keywords: retention index,iRT,retention time,calibration,lipidomics,metabolomics,LC-MS,mass spectrometry,MS-DIAL,MZmine,MassCube,LipidScreener
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Science/Research
10
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
11
+ Classifier: Topic :: Scientific/Engineering :: Chemistry
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Operating System :: OS Independent
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: numpy>=1.21
18
+ Requires-Dist: pandas>=1.3
19
+ Requires-Dist: scipy>=1.7
20
+ Requires-Dist: matplotlib>=3.5
21
+ Requires-Dist: plotly>=5.0
22
+ Provides-Extra: excel
23
+ Requires-Dist: openpyxl>=3.0; extra == "excel"
24
+ Provides-Extra: report
25
+ Requires-Dist: matplotlib>=3.5; extra == "report"
26
+ Requires-Dist: plotly>=5.0; extra == "report"
27
+ Provides-Extra: structures
28
+ Requires-Dist: rdkit>=2022.9; extra == "structures"
29
+ Provides-Extra: test
30
+ Requires-Dist: pytest>=7.0; extra == "test"
31
+ Requires-Dist: openpyxl>=3.0; extra == "test"
32
+ Dynamic: license-file
33
+
34
+ # rt-anchor
35
+
36
+ Standard-panel **retention-index (iRT) calibration** for LC-MS lipidomics.
37
+
38
+ ## Purpose
39
+
40
+ Convert the retention time of every feature in an LC-MS table into a
41
+ dimensionless, portable **retention index (RI/iRT)** anchored on a spiked
42
+ standard panel — so retention is comparable across injections, batches, and
43
+ instruments. A monotone warp is fitted from the standards and applied to all
44
+ features; nothing in the original table is dropped, calibration only appends
45
+ columns.
46
+
47
+ ## Download
48
+
49
+ ```bash
50
+ pip install rt-anchor
51
+ pip install "rt-anchor[report,structures]" # + HTML/PDF report and structure hover
52
+ ```
53
+
54
+ ## Input
55
+
56
+ Required:
57
+
58
+ | Input | What |
59
+ |-------|------|
60
+ | **Sample feature table** | The table to calibrate. MS-DIAL / MZmine / MassCube / LipidScreener — auto-detected. Must have an **m/z** and a **retention-time** column. |
61
+ | **Standards run** | A run of the standard panel (same formats) — builds the native anchor template. **Required.** |
62
+ | **Polarity** | `positive` or `negative`. |
63
+
64
+ Optional: per-injection files (per-sample tier + repeatability QC), a custom
65
+ standard manifest / reference baseline, and the matching parameters
66
+ (`mz_tol_ppm`, `rt_window_min`, `min_anchors`, `extrapolate`).
67
+
68
+ ```python
69
+ from rt_anchor import calibrate, write_results
70
+ res = calibrate("samples.txt", polarity="positive", standards_table="standards.txt")
71
+ write_results(res, "out/run1")
72
+ ```
73
+ ```bash
74
+ rt-anchor calibrate --samples samples.txt --standards standards.txt --polarity positive --out out/run1
75
+ ```
76
+
77
+ ## Output
78
+
79
+ - **`*_calibrated.csv`** — the original table + appended columns: `RI` (iRT-style
80
+ index), `RI_uncertainty`, `RI_reliability` (high/medium/low/none), `RI_spread`,
81
+ `n_contributing`, `is_extrapolated`, `calibration_scope`, `warp_source`.
82
+ - **`*_model.json`** — the fitted warp, anchors, and QC metrics.
83
+ - **`*_anchors.csv`**, **`*_log.txt`** — anchors used and a run log.
84
+ - **`*_report.html`** + **`*_report.pdf`** — an interactive + static report
85
+ (detection, calibration warp, repeatability). On by default; `--no-report` to skip.
86
+
87
+ ## License
88
+
89
+ MIT
@@ -0,0 +1,56 @@
1
+ # rt-anchor
2
+
3
+ Standard-panel **retention-index (iRT) calibration** for LC-MS lipidomics.
4
+
5
+ ## Purpose
6
+
7
+ Convert the retention time of every feature in an LC-MS table into a
8
+ dimensionless, portable **retention index (RI/iRT)** anchored on a spiked
9
+ standard panel — so retention is comparable across injections, batches, and
10
+ instruments. A monotone warp is fitted from the standards and applied to all
11
+ features; nothing in the original table is dropped, calibration only appends
12
+ columns.
13
+
14
+ ## Download
15
+
16
+ ```bash
17
+ pip install rt-anchor
18
+ pip install "rt-anchor[report,structures]" # + HTML/PDF report and structure hover
19
+ ```
20
+
21
+ ## Input
22
+
23
+ Required:
24
+
25
+ | Input | What |
26
+ |-------|------|
27
+ | **Sample feature table** | The table to calibrate. MS-DIAL / MZmine / MassCube / LipidScreener — auto-detected. Must have an **m/z** and a **retention-time** column. |
28
+ | **Standards run** | A run of the standard panel (same formats) — builds the native anchor template. **Required.** |
29
+ | **Polarity** | `positive` or `negative`. |
30
+
31
+ Optional: per-injection files (per-sample tier + repeatability QC), a custom
32
+ standard manifest / reference baseline, and the matching parameters
33
+ (`mz_tol_ppm`, `rt_window_min`, `min_anchors`, `extrapolate`).
34
+
35
+ ```python
36
+ from rt_anchor import calibrate, write_results
37
+ res = calibrate("samples.txt", polarity="positive", standards_table="standards.txt")
38
+ write_results(res, "out/run1")
39
+ ```
40
+ ```bash
41
+ rt-anchor calibrate --samples samples.txt --standards standards.txt --polarity positive --out out/run1
42
+ ```
43
+
44
+ ## Output
45
+
46
+ - **`*_calibrated.csv`** — the original table + appended columns: `RI` (iRT-style
47
+ index), `RI_uncertainty`, `RI_reliability` (high/medium/low/none), `RI_spread`,
48
+ `n_contributing`, `is_extrapolated`, `calibration_scope`, `warp_source`.
49
+ - **`*_model.json`** — the fitted warp, anchors, and QC metrics.
50
+ - **`*_anchors.csv`**, **`*_log.txt`** — anchors used and a run log.
51
+ - **`*_report.html`** + **`*_report.pdf`** — an interactive + static report
52
+ (detection, calibration warp, repeatability). On by default; `--no-report` to skip.
53
+
54
+ ## License
55
+
56
+ MIT
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "rt-anchor"
7
+ version = "0.1.0"
8
+ description = "Standard-panel retention-index (iRT) calibration for LC-MS lipidomics feature tables (MS-DIAL / MZmine / MassCube / LipidScreener)"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Bowen Yang" }]
14
+ keywords = [
15
+ "retention index", "iRT", "retention time", "calibration",
16
+ "lipidomics", "metabolomics", "LC-MS", "mass spectrometry",
17
+ "MS-DIAL", "MZmine", "MassCube", "LipidScreener",
18
+ ]
19
+ classifiers = [
20
+ "Development Status :: 4 - Beta",
21
+ "Intended Audience :: Science/Research",
22
+ "Topic :: Scientific/Engineering :: Bio-Informatics",
23
+ "Topic :: Scientific/Engineering :: Chemistry",
24
+ "Programming Language :: Python :: 3",
25
+ "Operating System :: OS Independent",
26
+ ]
27
+ dependencies = [
28
+ "numpy>=1.21",
29
+ "pandas>=1.3",
30
+ "scipy>=1.7",
31
+ "matplotlib>=3.5",
32
+ "plotly>=5.0",
33
+ ]
34
+
35
+ [project.optional-dependencies]
36
+ excel = ["openpyxl>=3.0"]
37
+ report = ["matplotlib>=3.5", "plotly>=5.0"]
38
+ structures = ["rdkit>=2022.9"] # optional: molecular-structure hover for the default panel
39
+ test = ["pytest>=7.0", "openpyxl>=3.0"]
40
+
41
+ [project.scripts]
42
+ rt-anchor = "rt_anchor.cli:main"
43
+
44
+ [tool.setuptools.packages.find]
45
+ where = ["src"]
46
+
47
+ [tool.setuptools.package-data]
48
+ rt_anchor = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,45 @@
1
+ """rt_anchor — standard-panel retention-index (iRT) calibration for LC-MS lipidomics.
2
+
3
+ Unifies feature tables from MS-DIAL, MZmine, MassCube and LipidScreener, then
4
+ warps retention time onto a dimensionless iRT scale anchored by a spiked lipid
5
+ standard panel. Outputs are CSV / JSON / log (no visualisation).
6
+
7
+ Quick start
8
+ -----------
9
+ >>> from rt_anchor import calibrate, write_results
10
+ >>> res = calibrate("samples.txt", polarity="positive", standards_table="std.txt")
11
+ >>> write_results(res, "out/run1")
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from .calibrate import MonotoneWarp, apply_warp, fit_warp
17
+ from .config import CalibrationConfig
18
+ from .errors import (
19
+ AnchorIdentificationError,
20
+ CalibrationError,
21
+ ColumnResolutionError,
22
+ ConfigError,
23
+ InputFormatError,
24
+ PanelError,
25
+ RtAnchorError,
26
+ RTUnitError,
27
+ )
28
+ from .helpers import describe_input, setup_logger, which_format, write_results
29
+ from .identify import build_native_template, identify_anchors
30
+ from .io import FeatureTable, detect_format, load_feature_table
31
+ from .panel import DEFAULT_MANIFEST, Panel, build_panel, load_manifest_csv, load_reference_csv
32
+ from .pipeline import CalibrationResult, calibrate
33
+
34
+ __version__ = "0.1.0"
35
+
36
+ __all__ = [
37
+ "calibrate", "CalibrationResult", "CalibrationConfig",
38
+ "load_feature_table", "detect_format", "FeatureTable",
39
+ "build_panel", "Panel", "DEFAULT_MANIFEST", "load_manifest_csv", "load_reference_csv",
40
+ "build_native_template", "identify_anchors",
41
+ "fit_warp", "apply_warp", "MonotoneWarp",
42
+ "describe_input", "which_format", "write_results", "setup_logger",
43
+ "RtAnchorError", "InputFormatError", "ColumnResolutionError", "RTUnitError",
44
+ "PanelError", "AnchorIdentificationError", "CalibrationError", "ConfigError",
45
+ ]
@@ -0,0 +1,149 @@
1
+ """The calibration itself: a monotone RT->iRT warp plus numeric uncertainty.
2
+
3
+ * :class:`MonotoneWarp` — shape-preserving monotone interpolation (PCHIP) with
4
+ ``extrapolate=False`` so features beyond the anchor span become NaN (never a
5
+ fabricated value). Leave-one-anchor-out residuals give the honest in-domain
6
+ interpolation error.
7
+ * :func:`apply_warp` — turn a warp + a config into per-feature ``RI``,
8
+ ``RI_uncertainty``, ``RI_reliability`` and ``is_extrapolated``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+ from typing import Dict, List, Optional
15
+
16
+ import numpy as np
17
+ import pandas as pd
18
+ from scipy.interpolate import PchipInterpolator
19
+
20
+ from .config import CalibrationConfig
21
+ from .errors import CalibrationError
22
+
23
+
24
+ @dataclass
25
+ class MonotoneWarp:
26
+ rt: np.ndarray # anchor observed RT (minutes), strictly increasing
27
+ irt: np.ndarray # anchor iRT values
28
+ _pchip: PchipInterpolator
29
+ loo_resid: np.ndarray # per-anchor leave-one-out residual (iRT units)
30
+
31
+ @property
32
+ def rt_min(self) -> float:
33
+ return float(self.rt[0])
34
+
35
+ @property
36
+ def rt_max(self) -> float:
37
+ return float(self.rt[-1])
38
+
39
+ def predict(self, rt_min: np.ndarray, extrapolate: bool = False,
40
+ max_extrap_min: float = 1.0) -> np.ndarray:
41
+ rt_min = np.asarray(rt_min, dtype=float)
42
+ out = self._pchip(rt_min) # NaN outside [rt_min, rt_max] (extrapolate=False)
43
+ if extrapolate:
44
+ below = rt_min < self.rt_min
45
+ above = rt_min > self.rt_max
46
+ # linear terminal-slope extrapolation, capped
47
+ if below.any():
48
+ s = (self.irt[1] - self.irt[0]) / (self.rt[1] - self.rt[0])
49
+ d = np.clip(self.rt_min - rt_min[below], 0, max_extrap_min)
50
+ out[below] = self.irt[0] - s * d
51
+ if above.any():
52
+ s = (self.irt[-1] - self.irt[-2]) / (self.rt[-1] - self.rt[-2])
53
+ d = np.clip(rt_min[above] - self.rt_max, 0, max_extrap_min)
54
+ out[above] = self.irt[-1] + s * d
55
+ return out
56
+
57
+
58
+ def fit_warp(anchor_rt_min: np.ndarray, anchor_irt: np.ndarray,
59
+ config: CalibrationConfig) -> MonotoneWarp:
60
+ rt = np.asarray(anchor_rt_min, dtype=float)
61
+ irt = np.asarray(anchor_irt, dtype=float)
62
+ order = np.argsort(rt)
63
+ rt, irt = rt[order], irt[order]
64
+
65
+ # strict-increase guard (PCHIP requires strictly increasing x)
66
+ keep = np.concatenate([[True], np.diff(rt) > config.tie_epsilon_min])
67
+ rt, irt = rt[keep], irt[keep]
68
+ if rt.size < max(2, config.min_anchors_hard):
69
+ raise CalibrationError(
70
+ f"Only {rt.size} usable anchors after the monotonicity guard "
71
+ f"(need >= {max(2, config.min_anchors_hard)}). Loosen mz_tol_ppm / RT "
72
+ f"window, provide a standard-panel run, or check the polarity."
73
+ )
74
+ if not np.all(np.diff(irt) > 0):
75
+ raise CalibrationError("Anchor iRT values are not strictly increasing — reference baseline is inconsistent.")
76
+
77
+ pchip = PchipInterpolator(rt, irt, extrapolate=False)
78
+ loo = _loo_residuals(rt, irt)
79
+ return MonotoneWarp(rt=rt, irt=irt, _pchip=pchip, loo_resid=loo)
80
+
81
+
82
+ def _loo_residuals(rt: np.ndarray, irt: np.ndarray) -> np.ndarray:
83
+ """Leave-one-anchor-out residual (iRT) at each anchor; NaN for the two ends
84
+ (holding out an endpoint is extrapolation, not interpolation)."""
85
+ n = rt.size
86
+ res = np.full(n, np.nan)
87
+ for i in range(1, n - 1): # interior only
88
+ m = np.ones(n, bool); m[i] = False
89
+ f = PchipInterpolator(rt[m], irt[m], extrapolate=True)
90
+ res[i] = float(f(rt[i]) - irt[i])
91
+ return res
92
+
93
+
94
+ def apply_warp(rt_min: np.ndarray, warp: MonotoneWarp, config: CalibrationConfig,
95
+ scope: str, warp_source: str = "self") -> pd.DataFrame:
96
+ """Compute RI + uncertainty for an array of feature RTs (minutes)."""
97
+ rt_min = np.asarray(rt_min, dtype=float)
98
+ ri = warp.predict(rt_min, extrapolate=config.extrapolate,
99
+ max_extrap_min=config.max_extrapolation_min)
100
+ in_span = (rt_min >= warp.rt_min) & (rt_min <= warp.rt_max)
101
+ is_extrap = ~in_span & np.isfinite(rt_min)
102
+
103
+ sigma = _sigma_ri(rt_min, warp, config)
104
+ conf = _reliability(sigma, is_extrap, config)
105
+
106
+ # keep sigma / confidence consistent with RI: an uncalibrated feature (RI NaN,
107
+ # e.g. beyond the anchor span with extrapolate=False) gets sigma NaN + 'none'.
108
+ uncal = ~np.isfinite(ri)
109
+ sigma[uncal] = np.nan
110
+ conf[uncal] = "none"
111
+
112
+ return pd.DataFrame({
113
+ "RI": ri,
114
+ "RI_uncertainty": sigma,
115
+ "RI_reliability": conf,
116
+ "is_extrapolated": is_extrap,
117
+ "calibration_scope": scope,
118
+ "warp_source": warp_source,
119
+ })
120
+
121
+
122
+ def _sigma_ri(rt_min: np.ndarray, warp: MonotoneWarp, config: CalibrationConfig) -> np.ndarray:
123
+ slope = config.slope_irt_per_min()
124
+ # interior LOO residual as a function of RT (|resid|, iRT units)
125
+ interior = np.isfinite(warp.loo_resid)
126
+ if interior.any():
127
+ loo_interp = np.interp(rt_min, warp.rt[interior], np.abs(warp.loo_resid[interior]),
128
+ left=np.abs(warp.loo_resid[interior][0]),
129
+ right=np.abs(warp.loo_resid[interior][-1]))
130
+ sigma_scale = float(np.nanmedian(np.abs(warp.loo_resid[interior])))
131
+ else:
132
+ loo_interp = np.zeros_like(rt_min)
133
+ sigma_scale = 0.0
134
+ # nearest-anchor gap term (minutes -> iRT)
135
+ nearest_gap = np.min(np.abs(rt_min[:, None] - warp.rt[None, :]), axis=1)
136
+ gap_term = slope * config.sigma_gap_factor * nearest_gap
137
+ sigma_local = np.maximum(loo_interp, gap_term)
138
+ sigma = np.sqrt(sigma_local ** 2 + sigma_scale ** 2)
139
+ sigma[~np.isfinite(rt_min)] = np.nan
140
+ return sigma
141
+
142
+
143
+ def _reliability(sigma: np.ndarray, is_extrap: np.ndarray, config: CalibrationConfig) -> np.ndarray:
144
+ conf = np.full(sigma.shape, "medium", dtype=object)
145
+ conf[sigma < config.conf_high_irt] = "high"
146
+ conf[sigma > config.conf_low_irt] = "low"
147
+ conf[is_extrap] = "low"
148
+ conf[~np.isfinite(sigma)] = "none"
149
+ return conf
@@ -0,0 +1,137 @@
1
+ """Command-line interface.
2
+
3
+ rt-anchor describe <table>
4
+ rt-anchor calibrate --samples <table> --standards <run> --polarity positive [options] --out <prefix>
5
+
6
+ Outputs are CSV / JSON / log only (no plots).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import glob
13
+ import json
14
+ import os
15
+ import sys
16
+ from typing import List, Optional
17
+
18
+ from .config import CalibrationConfig
19
+ from .errors import RtAnchorError
20
+ from .helpers import describe_input, setup_logger, write_results
21
+ from .panel import load_manifest_csv, load_reference_csv
22
+ from .pipeline import calibrate
23
+
24
+
25
+ def _collect_single_files(spec: Optional[str]) -> Optional[List[str]]:
26
+ if not spec:
27
+ return None
28
+ if os.path.isdir(spec):
29
+ files = sorted(glob.glob(os.path.join(spec, "*.txt")) +
30
+ glob.glob(os.path.join(spec, "*.csv")))
31
+ else:
32
+ files = sorted(glob.glob(spec))
33
+ return files or None
34
+
35
+
36
+ def _build_config(args) -> CalibrationConfig:
37
+ if args.config:
38
+ with open(args.config) as fh:
39
+ cfg = CalibrationConfig.from_dict(json.load(fh))
40
+ else:
41
+ cfg = CalibrationConfig() # sensible defaults; tune with the flags below
42
+ if args.mz_tol_ppm is not None:
43
+ cfg.mz_tol_ppm = args.mz_tol_ppm
44
+ if args.rt_window is not None:
45
+ cfg.rt_window_min = args.rt_window
46
+ if args.min_anchors is not None:
47
+ cfg.min_anchors = args.min_anchors
48
+ if args.extrapolate:
49
+ cfg.extrapolate = True
50
+ return cfg
51
+
52
+
53
+ def main(argv: Optional[List[str]] = None) -> int:
54
+ p = argparse.ArgumentParser(prog="rt-anchor", description="Standard-panel iRT calibration for LC-MS lipidomics.")
55
+ sub = p.add_subparsers(dest="cmd", required=True)
56
+
57
+ d = sub.add_parser("describe", help="Detect format + summarise a table (no calibration).")
58
+ d.add_argument("table")
59
+ d.add_argument("--format", dest="source_format", default=None)
60
+ d.add_argument("--polarity", default=None)
61
+
62
+ c = sub.add_parser("calibrate", help="Calibrate a feature table to iRT.")
63
+ c.add_argument("--samples", required=True, help="Feature table to calibrate.")
64
+ c.add_argument("--standards", required=True,
65
+ help="Standard-panel run (required; builds the native anchor template).")
66
+ c.add_argument("--polarity", required=True, choices=["positive", "negative", "pos", "neg"])
67
+ c.add_argument("--single-files", default=None,
68
+ help="Dir or glob of per-injection tables -> per-sample tier.")
69
+ c.add_argument("--format", dest="source_format", default=None,
70
+ help="Force input format (masscube/lipidscreener/msdial/mzmine).")
71
+ c.add_argument("--rt-unit", dest="rt_unit", default=None, choices=["min", "sec"])
72
+ c.add_argument("--manifest", default=None, help="Custom standard manifest CSV (Mode B).")
73
+ c.add_argument("--reference", default=None, help="Custom reference-baseline CSV.")
74
+ c.add_argument("--config", default=None,
75
+ help="JSON config file (base; the --mz-tol-ppm/--rt-window/--min-anchors flags override individual fields).")
76
+ # matching / robustness parameters (replace any notion of an 'instrument type')
77
+ c.add_argument("--mz-tol-ppm", dest="mz_tol_ppm", type=float, default=None,
78
+ help="m/z match tolerance in ppm (default 15).")
79
+ c.add_argument("--rt-window", dest="rt_window", type=float, default=None,
80
+ help="RT search window around the native anchor RT, minutes (default 0.5).")
81
+ c.add_argument("--min-anchors", dest="min_anchors", type=int, default=None,
82
+ help="Minimum anchors for a confident warp (default 6).")
83
+ c.add_argument("--extrapolate", action="store_true",
84
+ help="Assign RI beyond the anchor span (capped); default off -> NaN beyond span.")
85
+ c.add_argument("--no-report", action="store_true", help="Skip the HTML/PDF report (data only).")
86
+ c.add_argument("--tic-style", default="clean", choices=["clean", "realistic", "both"],
87
+ help="Reconstructed-TIC style in the report (default clean; realistic adds simulated noise).")
88
+ c.add_argument("--report-format", default="html,pdf",
89
+ help="Comma list of report formats: html,pdf.")
90
+ c.add_argument("--out", required=True, help="Output path prefix.")
91
+
92
+ args = p.parse_args(argv)
93
+
94
+ if args.cmd == "describe":
95
+ try:
96
+ print(json.dumps(describe_input(args.table, source_format=args.source_format,
97
+ polarity=args.polarity), indent=2, default=str))
98
+ return 0
99
+ except RtAnchorError as e:
100
+ print(f"ERROR: {e}", file=sys.stderr)
101
+ return 2
102
+ except Exception as e: # e.g. FileNotFoundError -> clean message, no traceback
103
+ print(f"ERROR: {type(e).__name__}: {e}", file=sys.stderr)
104
+ return 3
105
+
106
+ # calibrate
107
+ log = setup_logger()
108
+ try:
109
+ cfg = _build_config(args)
110
+ manifest = load_manifest_csv(args.manifest) if args.manifest else None
111
+ reference = load_reference_csv(args.reference) if args.reference else None
112
+ single = _collect_single_files(args.single_files)
113
+ if single:
114
+ log.info(f"per-sample tier: {len(single)} injection tables")
115
+ result = calibrate(
116
+ sample_table=args.samples, polarity=args.polarity,
117
+ standards_table=args.standards, single_files=single,
118
+ config=cfg, manifest=manifest, reference=reference,
119
+ source_format=args.source_format, rt_unit=args.rt_unit,
120
+ )
121
+ for line in result.log:
122
+ log.info(line)
123
+ fmts = tuple(f.strip() for f in args.report_format.split(",") if f.strip())
124
+ paths = write_results(result, args.out, report=not args.no_report,
125
+ tic_style=args.tic_style, report_formats=fmts)
126
+ log.info(f"wrote: {json.dumps(paths)}")
127
+ return 0
128
+ except RtAnchorError as e:
129
+ log.error(str(e))
130
+ return 2
131
+ except Exception as e: # unexpected -> surface with type
132
+ log.error(f"{type(e).__name__}: {e}")
133
+ return 3
134
+
135
+
136
+ if __name__ == "__main__":
137
+ raise SystemExit(main())
@@ -0,0 +1,86 @@
1
+ """Configuration for the calibration.
2
+
3
+ Defaults are tuned for **QTOF** data (the common case for this project):
4
+ wider m/z tolerance and RT windows than an Orbitrap would need. Every value is
5
+ override-able (constructor kwargs, ``CalibrationConfig.qtof()`` /
6
+ ``.orbitrap()`` presets, or ``from_dict``). Nothing here is instrument-locked.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import asdict, dataclass, field
12
+ from typing import Optional, Tuple
13
+
14
+
15
+ @dataclass
16
+ class CalibrationConfig:
17
+ # ---- m/z matching ----
18
+ mz_tol_ppm: float = 15.0 # QTOF default (Orbitrap ~8)
19
+ mz_tol_min_da: float = 0.0 # optional absolute floor on the ppm window
20
+
21
+ # ---- RT search window around the native-template anchor RT (minutes) ----
22
+ rt_window_min: float = 0.5 # QTOF default (Orbitrap ~0.3)
23
+ # window used when NO native template exists and we must seed from reference RT
24
+ rt_window_seed_min: float = 2.0
25
+
26
+ # ---- iRT scale (fixed method constants; dimensionless, minute-derived) ----
27
+ scale_rt_lo_min: float = 1.0
28
+ scale_rt_hi_min: float = 18.4
29
+
30
+ # ---- anchor acceptance / warp robustness ----
31
+ min_anchors: int = 6 # below this -> low-confidence / uncalibrated
32
+ min_anchors_hard: int = 3 # below this -> refuse to calibrate
33
+ coverage_gap_min: float = 3.0 # max in-domain inter-anchor RT gap (minutes)
34
+ tie_epsilon_min: float = 0.005 # equal-RT tolerance for monotonicity guard
35
+ max_drop_anchors: int = 3 # cap on anchors removed by the guard
36
+
37
+ # ---- extrapolation ----
38
+ extrapolate: bool = False # if False: RI = NaN beyond anchor span
39
+ max_extrapolation_min: float = 1.0 # only used when extrapolate=True
40
+
41
+ # ---- confidence thresholds (iRT units) ----
42
+ conf_high_irt: float = 0.6 # RI_uncertainty < this -> high
43
+ conf_low_irt: float = 3.0 # RI_uncertainty > this -> low
44
+ sigma_gap_factor: float = 0.1 # sigma_local ~= factor * nearest-anchor gap (min) * slope
45
+
46
+ # ---- optional peak-quality gate (applied only if the columns exist) ----
47
+ min_gaussian_similarity: float = 0.0 # 0 disables; spec suggests ~0.7
48
+ asymmetry_range: Tuple[float, float] = (0.0, 1e9) # widen -> disabled
49
+
50
+ # ---- RT unit handling ----
51
+ rt_unit: Optional[str] = None # None = auto (per-format default + range check)
52
+
53
+ # ---- misc ----
54
+ verbose: bool = True
55
+
56
+ def slope_irt_per_min(self) -> float:
57
+ return 100.0 / (self.scale_rt_hi_min - self.scale_rt_lo_min)
58
+
59
+ def irt_from_rt(self, rt_min: float) -> float:
60
+ return 100.0 * (rt_min - self.scale_rt_lo_min) / (self.scale_rt_hi_min - self.scale_rt_lo_min)
61
+
62
+ def to_dict(self) -> dict:
63
+ return asdict(self)
64
+
65
+ @classmethod
66
+ def from_dict(cls, d: dict) -> "CalibrationConfig":
67
+ from .errors import ConfigError
68
+ known = {f for f in cls.__dataclass_fields__} # type: ignore[attr-defined]
69
+ unknown = set(d) - known
70
+ if unknown:
71
+ raise ConfigError(f"Unknown config keys: {sorted(unknown)}. Known: {sorted(known)}")
72
+ cfg = cls(**{k: v for k, v in d.items() if k in known})
73
+ if cfg.asymmetry_range is not None:
74
+ cfg.asymmetry_range = tuple(cfg.asymmetry_range) # json gives lists
75
+ return cfg
76
+
77
+ # ---- instrument presets ----
78
+ @classmethod
79
+ def qtof(cls, **overrides) -> "CalibrationConfig":
80
+ return cls(**overrides)
81
+
82
+ @classmethod
83
+ def orbitrap(cls, **overrides) -> "CalibrationConfig":
84
+ base = dict(mz_tol_ppm=8.0, rt_window_min=0.3)
85
+ base.update(overrides)
86
+ return cls(**base)
@@ -0,0 +1,40 @@
1
+ """Typed exceptions for rt_anchor.
2
+
3
+ Every error carries an actionable message: what went wrong, which input it
4
+ concerns, and (where possible) how to fix it. Callers can catch the base
5
+ ``RtAnchorError`` to handle any package-level failure.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+
11
+ class RtAnchorError(Exception):
12
+ """Base class for all rt_anchor errors."""
13
+
14
+
15
+ class ConfigError(RtAnchorError):
16
+ """The configuration is invalid (unknown keys, bad values)."""
17
+
18
+
19
+ class InputFormatError(RtAnchorError):
20
+ """The input file could not be parsed or its format could not be recognised."""
21
+
22
+
23
+ class ColumnResolutionError(InputFormatError):
24
+ """A required logical column (m/z or RT) could not be located in the table."""
25
+
26
+
27
+ class RTUnitError(RtAnchorError):
28
+ """Retention-time units are ambiguous or inconsistent with the declared unit."""
29
+
30
+
31
+ class PanelError(RtAnchorError):
32
+ """The standard manifest / reference baseline is malformed or incomplete."""
33
+
34
+
35
+ class AnchorIdentificationError(RtAnchorError):
36
+ """Too few standards could be identified to build a calibration."""
37
+
38
+
39
+ class CalibrationError(RtAnchorError):
40
+ """The monotone warp could not be fitted (e.g. degenerate anchors)."""