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.
- rt_anchor-0.1.0/LICENSE +21 -0
- rt_anchor-0.1.0/PKG-INFO +89 -0
- rt_anchor-0.1.0/README.md +56 -0
- rt_anchor-0.1.0/pyproject.toml +48 -0
- rt_anchor-0.1.0/setup.cfg +4 -0
- rt_anchor-0.1.0/src/rt_anchor/__init__.py +45 -0
- rt_anchor-0.1.0/src/rt_anchor/calibrate.py +149 -0
- rt_anchor-0.1.0/src/rt_anchor/cli.py +137 -0
- rt_anchor-0.1.0/src/rt_anchor/config.py +86 -0
- rt_anchor-0.1.0/src/rt_anchor/errors.py +40 -0
- rt_anchor-0.1.0/src/rt_anchor/helpers.py +126 -0
- rt_anchor-0.1.0/src/rt_anchor/identify.py +213 -0
- rt_anchor-0.1.0/src/rt_anchor/io/__init__.py +5 -0
- rt_anchor-0.1.0/src/rt_anchor/io/adapters.py +256 -0
- rt_anchor-0.1.0/src/rt_anchor/io/loader.py +81 -0
- rt_anchor-0.1.0/src/rt_anchor/io/schema.py +68 -0
- rt_anchor-0.1.0/src/rt_anchor/panel.py +213 -0
- rt_anchor-0.1.0/src/rt_anchor/pipeline.py +255 -0
- rt_anchor-0.1.0/src/rt_anchor/py.typed +0 -0
- rt_anchor-0.1.0/src/rt_anchor/viz/__init__.py +10 -0
- rt_anchor-0.1.0/src/rt_anchor/viz/metrics.py +229 -0
- rt_anchor-0.1.0/src/rt_anchor/viz/performance.py +77 -0
- rt_anchor-0.1.0/src/rt_anchor/viz/repeatability.py +133 -0
- rt_anchor-0.1.0/src/rt_anchor/viz/report.py +276 -0
- rt_anchor-0.1.0/src/rt_anchor/viz/structures.py +41 -0
- rt_anchor-0.1.0/src/rt_anchor/viz/theme.py +142 -0
- rt_anchor-0.1.0/src/rt_anchor/viz/tic.py +201 -0
- rt_anchor-0.1.0/src/rt_anchor.egg-info/PKG-INFO +89 -0
- rt_anchor-0.1.0/src/rt_anchor.egg-info/SOURCES.txt +36 -0
- rt_anchor-0.1.0/src/rt_anchor.egg-info/dependency_links.txt +1 -0
- rt_anchor-0.1.0/src/rt_anchor.egg-info/entry_points.txt +2 -0
- rt_anchor-0.1.0/src/rt_anchor.egg-info/requires.txt +19 -0
- rt_anchor-0.1.0/src/rt_anchor.egg-info/top_level.txt +1 -0
- rt_anchor-0.1.0/tests/test_calibrate.py +206 -0
- rt_anchor-0.1.0/tests/test_cli.py +110 -0
- rt_anchor-0.1.0/tests/test_errors.py +144 -0
- rt_anchor-0.1.0/tests/test_io.py +190 -0
- rt_anchor-0.1.0/tests/test_viz.py +102 -0
rt_anchor-0.1.0/LICENSE
ADDED
|
@@ -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.
|
rt_anchor-0.1.0/PKG-INFO
ADDED
|
@@ -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,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)."""
|