Skills › Scientific Agent Skills › analytical-method-validation › check_bioanalytical_run.py
check_bioanalytical_run.py
329 lines · skills/analytical-method-validation/scripts/check_bioanalytical_run.py at 5f2bc03
1#!/usr/bin/env python3
2"""Apply ICH M10 acceptance criteria to a bioanalytical run or an ISR dataset.
4Chromatographic assays and ligand binding assays carry DIFFERENT numeric
5criteria in ICH M10, and conflating them is the most common error in this area.
6--modality is therefore mandatory: nothing here has a default.
8 # calibration standards and QCs from one analytical run
9 python3 check_bioanalytical_run.py --modality chromatographic --run run1.csv
11 # incurred sample reanalysis
12 python3 check_bioanalytical_run.py --modality lba --isr isr.csv
14 # LBA total-error criterion from accuracy/precision validation data
15 python3 check_bioanalytical_run.py --modality lba --total-error ap.csv
17Input for --run: columns `type` (calibrator|qc), `nominal`, `measured`, and an
18optional `label` (e.g. LLOQ, low, medium, high, ULOQ, or ANCHOR for LBA anchor
19points, which are excluded from the calibration pass count).
21Input for --isr: columns `original` and `repeat`.
23Input for --total-error: columns `label`, `accuracy_pct` (signed percent bias,
24not recovery), `precision_pct` (non-negative CV).
26Exit codes: 0 all applied criteria met, 1 criteria not met, 2 bad input.
27"""
29from __future__ import annotations
31import argparse
32import math
33import sys
35sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent))
37from _catalog import M10_CRITERIA # noqa: E402
38from _common import ( # noqa: E402
39 EXIT_FINDINGS,
40 EXIT_OK,
41 InputError,
42 add_common_args,
43 emit,
44 finding,
45 note,
46 parse_rows,
47 read_input,
48 require_columns,
49 run_cli,
50 to_float,
51)
53LIMIT_LABELS = {"lloq", "uloq"}
56def calibrator_tolerance(label: str, crit: dict) -> float:
57 """Tolerance for a calibration standard at this label.
59 Routine-run QCs are not routed through here: M10 applies one flat tolerance
60 to every QC level for run acceptance, which check_run uses directly.
61 """
62 low = label.strip().lower()
63 if low == "lloq":
64 return crit["calibration_tolerance_lloq_pct"]
65 if low == "uloq":
66 return crit["calibration_tolerance_uloq_pct"]
67 return crit["calibration_tolerance_pct"]
70def check_run(path: str, crit: dict) -> tuple[list[dict], list[str]]:
71 rows = parse_rows(read_input(path), path)
72 require_columns(rows, ["type", "nominal", "measured"])
73 findings: list[str] = []
74 detail: list[dict] = []
76 cal_pass = cal_total = 0
77 cal_by_level: dict[float, list[bool]] = {}
78 qc_by_level: dict[float, list[bool]] = {}
79 qc_labels: dict[float, str] = {}
80 qc_pass = qc_total = 0
82 for i, r in enumerate(rows):
83 kind = (r["type"] or "").strip().lower()
84 label = (r.get("label") or "").strip()
85 nominal = to_float(r["nominal"], "nominal", i)
86 measured = to_float(r["measured"], "measured", i)
87 if nominal <= 0:
88 raise InputError(f"row {i + 1}: nominal must be positive")
89 dev = 100.0 * (measured - nominal) / nominal
91 if kind in ("calibrator", "cal", "standard", "std"):
92 if label.lower() == "anchor":
93 if crit["total_error_pct"] is None:
94 raise InputError("ANCHOR exclusion is supported for LBA only")
95 detail.append({"type": "calibrator", "label": "ANCHOR", "nominal": nominal,
96 "measured": measured, "deviation_pct": dev,
97 "tolerance_pct": float("nan"), "within": "excluded"})
98 continue
99 tol = calibrator_tolerance(label, crit)
100 ok = abs(dev) <= tol
101 cal_total += 1
102 cal_pass += int(ok)
103 cal_by_level.setdefault(nominal, []).append(ok)
104 detail.append({"type": "calibrator", "label": label or "-", "nominal": nominal,
105 "measured": measured, "deviation_pct": dev,
106 "tolerance_pct": tol, "within": ok})
107 elif kind in ("qc", "quality-control"):
108 tol = crit["qc_run_tolerance_pct"]
109 ok = abs(dev) <= tol
110 key = nominal
111 qc_labels[key] = label or f"{nominal:g}"
112 qc_by_level.setdefault(key, []).append(ok)
113 qc_total += 1
114 qc_pass += int(ok)
115 detail.append({"type": "qc", "label": qc_labels[key], "nominal": nominal,
116 "measured": measured, "deviation_pct": dev,
117 "tolerance_pct": tol, "within": ok})
118 else:
119 raise InputError(
120 f"row {i + 1}: type must be calibrator or qc, got {r['type']!r}"
121 )
123 # Calibration curve criteria.
124 if cal_total:
125 n_levels = sum(any(flags) for flags in cal_by_level.values())
126 need_levels = crit["calibration_min_levels"]
127 if n_levels < need_levels:
128 findings.append(
129 f"calibration curve has {n_levels} passing concentration levels; M10 requires a "
130 f"minimum of {need_levels} passing levels"
131 )
132 frac = cal_pass / cal_total
133 need = crit["calibration_min_pass_fraction"]
134 if frac < need:
135 findings.append(
136 f"{cal_pass}/{cal_total} calibration standards within tolerance "
137 f"({frac * 100:.1f}%); M10 requires at least {need * 100:.0f}%"
138 )
139 for nominal, flags in cal_by_level.items():
140 if sum(flags) / len(flags) < 0.5:
141 findings.append(f"calibration level {nominal:g}: fewer than 50% of replicates pass")
142 if cal_pass < cal_total:
143 findings.append("failed calibrators require documented exclusion and a refitted curve; "
144 "this checker does not refit or establish a revised range")
145 for boundary in ("lloq", "uloq"):
146 points = [r for r in detail if r["type"] == "calibrator" and r["label"].lower() == boundary]
147 if not points:
148 findings.append(f"label {boundary.upper()} explicitly to verify boundary tolerances")
149 else:
150 expected = min(cal_by_level) if boundary == "lloq" else max(cal_by_level)
151 if any(point["nominal"] != expected for point in points):
152 raise InputError(f"{boundary.upper()} label does not identify the calibration boundary")
153 for anchor in (r for r in detail if r["within"] == "excluded"):
154 if min(cal_by_level) <= anchor["nominal"] <= max(cal_by_level):
155 raise InputError("LBA anchor points must lie outside the quantitation range")
156 else:
157 findings.append("no non-anchor calibration standards supplied; run assessment incomplete")
159 # Routine run QC criteria: both the overall fraction and per-level fraction.
160 if qc_total:
161 n_qc_levels = len(qc_by_level)
162 if n_qc_levels < crit["qc_levels_routine_run"]:
163 findings.append(
164 f"{n_qc_levels} QC levels present; M10 expects at least "
165 f"{crit['qc_levels_routine_run']} for run acceptance"
166 )
167 frac = qc_pass / qc_total
168 need = crit["qc_run_pass_fraction"]
169 if frac < need:
170 findings.append(
171 f"{qc_pass}/{qc_total} QCs within +/-{crit['qc_run_tolerance_pct']:.0f}% "
172 f"({frac * 100:.1f}%); M10 requires at least {need * 100:.0f}% of the total"
173 )
174 for level, flags in sorted(qc_by_level.items()):
175 if len(flags) < 2:
176 findings.append(f"QC level {qc_labels[level]}: at least two QC samples are needed")
177 level_frac = sum(flags) / len(flags)
178 if level_frac < crit["qc_run_pass_fraction_per_level"]:
179 findings.append(
180 f"QC level {qc_labels[level]}: {sum(flags)}/{len(flags)} within tolerance "
181 f"({level_frac * 100:.0f}%); M10 requires at least "
182 f"{crit['qc_run_pass_fraction_per_level'] * 100:.0f}% at each level"
183 )
184 else:
185 findings.append("no QCs supplied; run assessment incomplete")
186 return detail, findings
189def check_isr(path: str, crit: dict) -> tuple[list[dict], list[str]]:
190 rows = parse_rows(read_input(path), path)
191 require_columns(rows, ["original", "repeat"])
192 tol = crit["isr_tolerance_pct"]
193 detail, findings = [], []
194 passes = 0
195 for i, r in enumerate(rows):
196 original = to_float(r["original"], "original", i)
197 repeat = to_float(r["repeat"], "repeat", i)
198 if original <= 0 or repeat <= 0:
199 raise InputError("ISR requires positive quantified concentrations within the validated range")
200 mean_val = 0.5 * (original + repeat)
201 if mean_val == 0:
202 raise InputError(f"row {i + 1}: mean of original and repeat is zero")
203 # M10 defines the ISR percent difference against the mean of the two.
204 diff = 100.0 * (repeat - original) / mean_val
205 ok = abs(diff) <= tol
206 passes += int(ok)
207 detail.append({"sample": r.get("sample", str(i + 1)), "original": original,
208 "repeat": repeat, "mean": mean_val, "percent_difference": diff,
209 "tolerance_pct": tol, "within": ok})
210 frac = passes / len(rows)
211 if frac < crit["isr_pass_fraction"]:
212 findings.append(
213 f"ISR: {passes}/{len(rows)} repeats within +/-{tol:.0f}% ({frac * 100:.1f}%); "
214 f"M10 requires at least {crit['isr_pass_fraction'] * 100:.0f}%"
215 )
216 return detail, findings
219def check_total_error(path: str, crit: dict) -> tuple[list[dict], list[str]]:
220 if crit["total_error_pct"] is None:
221 raise InputError(
222 "ICH M10 states a total-error criterion for ligand binding assays only; "
223 "use --modality lba, or omit --total-error for a chromatographic assay"
224 )
225 rows = parse_rows(read_input(path), path)
226 require_columns(rows, ["label", "accuracy_pct", "precision_pct"])
227 detail, findings = [], []
228 for i, r in enumerate(rows):
229 label = (r["label"] or "").strip()
230 acc = to_float(r["accuracy_pct"], "accuracy_pct", i)
231 prec = to_float(r["precision_pct"], "precision_pct", i)
232 if prec < 0:
233 raise InputError("precision_pct must be a non-negative CV; accuracy_pct is signed bias, not recovery")
234 total = abs(acc) + abs(prec)
235 limit = (
236 crit["total_error_pct_at_limits"]
237 if label.lower() in LIMIT_LABELS
238 else crit["total_error_pct"]
239 )
240 ok = total <= limit
241 detail.append({"label": label or "-", "accuracy_pct": acc, "precision_pct": prec,
242 "total_error_pct": total, "limit_pct": limit, "within": ok})
243 if not ok:
244 findings.append(
245 f"{label or 'level ' + str(i + 1)}: total error {total:.2f}% exceeds "
246 f"{limit:.0f}%"
247 )
248 return detail, findings
251def main() -> int:
252 parser = argparse.ArgumentParser(
253 description="Apply ICH M10 acceptance criteria to bioanalytical data."
254 )
255 parser.add_argument("--modality", required=True, choices=sorted(M10_CRITERIA),
256 help="chromatographic or lba -- the criteria differ and there is no default")
257 parser.add_argument("--run", help="calibrators and QCs from an analytical run")
258 parser.add_argument("--isr", help="incurred sample reanalysis data")
259 parser.add_argument("--total-error", dest="total_error",
260 help="accuracy/precision per level (ligand binding assays only)")
261 parser.add_argument("--criteria", action="store_true",
262 help="print the criteria that would be applied and exit")
263 add_common_args(parser)
264 args = parser.parse_args()
266 crit = M10_CRITERIA[args.modality]
268 if args.criteria:
269 rows = [
270 {"criterion": k, "value": ("n/a" if v is None else v)}
271 for k, v in crit.items()
272 if k not in ("label", "notes")
273 ]
274 emit(rows, args.format)
275 note(crit["label"])
276 note(crit["notes"])
277 return EXIT_OK
279 if not any((args.run, args.isr, args.total_error)):
280 raise InputError("supply at least one of --run, --isr, --total-error, or --criteria")
282 detail: list[dict] = []
283 findings: list[str] = []
284 sections: dict[str, list[dict]] = {}
286 if args.run:
287 d, f = check_run(args.run, crit)
288 sections["run"] = d
289 findings += f
290 if args.isr:
291 d, f = check_isr(args.isr, crit)
292 sections["isr"] = d
293 findings += f
294 if args.total_error:
295 d, f = check_total_error(args.total_error, crit)
296 sections["total_error"] = d
297 findings += f
299 if args.format == "json":
300 emit([{"modality": args.modality, **sections, "findings": findings}], "json")
301 else:
302 for name, rows in sections.items():
303 print(f"[{name}]")
304 emit(rows, args.format)
305 print()
307 note(crit["label"])
308 note(crit["notes"])
309 note(
310 "chromatographic and ligand binding assay criteria differ throughout M10; this run was "
311 f"assessed as: {args.modality}"
312 )
313 for f in findings:
314 finding(f)
315 if not findings:
316 note("all applied criteria met")
317 note(
318 "run acceptance is a documented decision by the analyst; this tool applies stated "
319 "criteria and does not accept or reject a run"
320 )
321 if args.run:
322 note("one row must represent a reportable standard or QC sample, not an individual LBA well; "
323 "check blanks, zero samples, study-sample-dependent 5% QC count, bracketing, "
324 "plate/batch criteria, and calibration refitting separately")
325 return EXIT_FINDINGS if findings else EXIT_OK
328if __name__ == "__main__":
329 run_cli(main)