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 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)