rt-anchor 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.
- rt_anchor/__init__.py +45 -0
- rt_anchor/calibrate.py +149 -0
- rt_anchor/cli.py +137 -0
- rt_anchor/config.py +86 -0
- rt_anchor/errors.py +40 -0
- rt_anchor/helpers.py +126 -0
- rt_anchor/identify.py +213 -0
- rt_anchor/io/__init__.py +5 -0
- rt_anchor/io/adapters.py +256 -0
- rt_anchor/io/loader.py +81 -0
- rt_anchor/io/schema.py +68 -0
- rt_anchor/panel.py +213 -0
- rt_anchor/pipeline.py +255 -0
- rt_anchor/py.typed +0 -0
- rt_anchor/viz/__init__.py +10 -0
- rt_anchor/viz/metrics.py +229 -0
- rt_anchor/viz/performance.py +77 -0
- rt_anchor/viz/repeatability.py +133 -0
- rt_anchor/viz/report.py +276 -0
- rt_anchor/viz/structures.py +41 -0
- rt_anchor/viz/theme.py +142 -0
- rt_anchor/viz/tic.py +201 -0
- rt_anchor-0.1.0.dist-info/METADATA +89 -0
- rt_anchor-0.1.0.dist-info/RECORD +28 -0
- rt_anchor-0.1.0.dist-info/WHEEL +5 -0
- rt_anchor-0.1.0.dist-info/entry_points.txt +2 -0
- rt_anchor-0.1.0.dist-info/licenses/LICENSE +21 -0
- rt_anchor-0.1.0.dist-info/top_level.txt +1 -0
rt_anchor/__init__.py
ADDED
|
@@ -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
|
+
]
|
rt_anchor/calibrate.py
ADDED
|
@@ -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
|
rt_anchor/cli.py
ADDED
|
@@ -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())
|
rt_anchor/config.py
ADDED
|
@@ -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)
|
rt_anchor/errors.py
ADDED
|
@@ -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)."""
|
rt_anchor/helpers.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""User-facing helpers: format inspection, result writers, logging setup.
|
|
2
|
+
|
|
3
|
+
None of these produce visualisations — outputs are CSV / JSON / plain-text log.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
from typing import Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
from .io.adapters import detect_format
|
|
16
|
+
from .io.loader import load_feature_table
|
|
17
|
+
from .pipeline import CalibrationResult
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def describe_input(path: str, source_format: Optional[str] = None,
|
|
21
|
+
polarity: Optional[str] = None) -> Dict:
|
|
22
|
+
"""Detect + summarise an input table without calibrating (dry-run helper)."""
|
|
23
|
+
ft = load_feature_table(path, source_format=source_format, polarity=polarity)
|
|
24
|
+
info = ft.summary()
|
|
25
|
+
info["path"] = path
|
|
26
|
+
if ft.meta:
|
|
27
|
+
info["meta_keys"] = list(ft.meta.keys())
|
|
28
|
+
return info
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def which_format(path: str) -> str:
|
|
32
|
+
fmt, _, _ = detect_format(path)
|
|
33
|
+
return fmt
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def setup_logger(logfile: Optional[str] = None, level: int = logging.INFO) -> logging.Logger:
|
|
37
|
+
logger = logging.getLogger("rt_anchor")
|
|
38
|
+
logger.setLevel(level)
|
|
39
|
+
logger.handlers.clear()
|
|
40
|
+
fmt = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s", "%Y-%m-%d %H:%M:%S")
|
|
41
|
+
sh = logging.StreamHandler()
|
|
42
|
+
sh.setFormatter(fmt)
|
|
43
|
+
logger.addHandler(sh)
|
|
44
|
+
if logfile:
|
|
45
|
+
fh = logging.FileHandler(logfile)
|
|
46
|
+
fh.setFormatter(fmt)
|
|
47
|
+
logger.addHandler(fh)
|
|
48
|
+
return logger
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def write_results(result: CalibrationResult, out_prefix: str,
|
|
52
|
+
write_log: bool = True, report: bool = True,
|
|
53
|
+
tic_style: str = "clean", report_formats=("html", "pdf")) -> Dict[str, str]:
|
|
54
|
+
"""Write the calibrated table (CSV), model (JSON), log (txt), and — by
|
|
55
|
+
default — a visual Report (interactive HTML + static PDF).
|
|
56
|
+
|
|
57
|
+
Set ``report=False`` for data-only output. Returns {kind: path}.
|
|
58
|
+
"""
|
|
59
|
+
os.makedirs(os.path.dirname(os.path.abspath(out_prefix)) or ".", exist_ok=True)
|
|
60
|
+
paths: Dict[str, str] = {}
|
|
61
|
+
|
|
62
|
+
csv_path = f"{out_prefix}_calibrated.csv"
|
|
63
|
+
result.table.to_csv(csv_path, index=False)
|
|
64
|
+
paths["calibrated_csv"] = csv_path
|
|
65
|
+
|
|
66
|
+
model_path = f"{out_prefix}_model.json"
|
|
67
|
+
with open(model_path, "w") as fh:
|
|
68
|
+
# allow_nan=False + a NaN->null sanitiser so the file is *valid* JSON
|
|
69
|
+
# (bare ``NaN`` is rejected by strict parsers, e.g. JS ``JSON.parse``).
|
|
70
|
+
json.dump(_sanitize_json(result.model), fh, indent=2,
|
|
71
|
+
default=_json_default, allow_nan=False)
|
|
72
|
+
paths["model_json"] = model_path
|
|
73
|
+
|
|
74
|
+
anchors_path = f"{out_prefix}_anchors.csv"
|
|
75
|
+
result.anchors.to_csv(anchors_path, index=False)
|
|
76
|
+
paths["anchors_csv"] = anchors_path
|
|
77
|
+
|
|
78
|
+
if write_log:
|
|
79
|
+
log_path = f"{out_prefix}_log.txt"
|
|
80
|
+
with open(log_path, "w") as fh:
|
|
81
|
+
fh.write("\n".join(result.log) + "\n")
|
|
82
|
+
paths["log_txt"] = log_path
|
|
83
|
+
|
|
84
|
+
if report:
|
|
85
|
+
try:
|
|
86
|
+
from .viz.report import write_report
|
|
87
|
+
paths.update(write_report(result, out_prefix, tic_style=tic_style,
|
|
88
|
+
formats=report_formats))
|
|
89
|
+
except ImportError as e:
|
|
90
|
+
result.log.append(f"report skipped (missing viz deps: {e}); "
|
|
91
|
+
f"install rt_anchor[report] for matplotlib+plotly")
|
|
92
|
+
return paths
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _sanitize_json(o):
|
|
96
|
+
"""Recursively replace non-finite floats (NaN/inf) with ``None``.
|
|
97
|
+
|
|
98
|
+
Needed because numpy floats are ``float`` subclasses, so the json C-encoder
|
|
99
|
+
serialises them natively (emitting a bare, invalid ``NaN``) and never calls
|
|
100
|
+
``default``. We walk the structure and null-out non-finite values so the
|
|
101
|
+
written file is valid JSON.
|
|
102
|
+
"""
|
|
103
|
+
import math
|
|
104
|
+
|
|
105
|
+
if isinstance(o, dict):
|
|
106
|
+
return {k: _sanitize_json(v) for k, v in o.items()}
|
|
107
|
+
if isinstance(o, (list, tuple)):
|
|
108
|
+
return [_sanitize_json(v) for v in o]
|
|
109
|
+
if isinstance(o, float): # includes np.float64 (a float subclass)
|
|
110
|
+
return None if not math.isfinite(o) else float(o)
|
|
111
|
+
return o
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _json_default(o):
|
|
115
|
+
import numpy as np
|
|
116
|
+
if isinstance(o, (np.integer,)):
|
|
117
|
+
return int(o)
|
|
118
|
+
if isinstance(o, (np.floating,)):
|
|
119
|
+
return None if not np.isfinite(o) else float(o)
|
|
120
|
+
if isinstance(o, (np.bool_,)):
|
|
121
|
+
return bool(o)
|
|
122
|
+
if isinstance(o, np.ndarray):
|
|
123
|
+
return o.tolist()
|
|
124
|
+
if isinstance(o, float) and (o != o): # NaN
|
|
125
|
+
return None
|
|
126
|
+
return str(o)
|