Skill Tracer
Skills › Scientific Agent Skills › analytical-method-validation › plan_validation.py

plan_validation.py

381 lines · skills/analytical-method-validation/scripts/plan_validation.py at 5f2bc03

1#!/usr/bin/env python3
2"""Design a validation study: which framework, which tests, what study layout.
3
4Answers the first question of any validation exercise -- what am I required to
5demonstrate, and with how much data -- before any sample is injected.
6
7 python3 plan_validation.py --framework ich-q2r2 --attribute assay --technique hplc
8 python3 plan_validation.py --framework ich-m10 --modality lba
9 python3 plan_validation.py --list-frameworks
10 python3 plan_validation.py --framework ich-q2r2 --attribute impurity --protocol > protocol.md
12Exit codes: 0 plan produced, 2 bad input.
13"""
15from __future__ import annotations
17import argparse
18import sys
20sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent))
22from _catalog import ( # noqa: E402
23 DL_QL_APPROACHES,
24 FRAMEWORKS,
25 M10_CRITERIA,
26 Q2R2_REPORTABLE_RANGE,
27 Q2R2_STUDY_DESIGN,
28 Q2R2_TESTS_BY_ATTRIBUTE,
29 RESEARCH_DATE,
30 TECHNIQUE_NOTES,
31 resolve_attribute,
33from _common import ( # noqa: E402
34 EXIT_OK,
35 InputError,
36 add_common_args,
37 emit,
38 note,
39 run_cli,
42STATUS_TEXT = {
43 "required": "conduct",
44 "required-QL": "conduct (QL; DL too in some complex cases)",
45 "required-DL": "conduct (DL)",
46 "required-unless-reproducibility": (
47 "conduct, unless intermediate precision can be derived from a reproducibility dataset"
48 ),
49 "not-normally": "not normally conducted",
53def plan_q2r2(attribute: str, technique: str | None, range_use: str | None) -> list[dict]:
54 tests = Q2R2_TESTS_BY_ATTRIBUTE[attribute]
55 rows = []
56 for characteristic, status in tests.items():
57 design = Q2R2_STUDY_DESIGN.get(characteristic, {})
58 rows.append(
59 {
60 "characteristic": characteristic,
61 "required": STATUS_TEXT[status],
62 "study_design": design.get("requirement", ""),
63 "report": design.get("report", ""),
64 "reference": design.get("reference", ""),
65 }
66 )
67 # Robustness sits in development under Q14 but belongs in the plan.
68 rows.append(
69 {
70 "characteristic": "robustness",
71 "required": "development activity (ICH Q14); available on request",
72 "study_design": Q2R2_STUDY_DESIGN["robustness"]["requirement"],
73 "report": Q2R2_STUDY_DESIGN["robustness"]["report"],
74 "reference": Q2R2_STUDY_DESIGN["robustness"]["reference"],
75 }
76 )
77 if technique:
78 tn = TECHNIQUE_NOTES.get(technique)
79 if tn:
80 rows.append(
81 {
82 "characteristic": f"technique note ({technique})",
83 "required": f"see Q2(R2) Annex 2 {tn['annex_table']}",
84 "study_design": f"robustness parameters: {tn['robustness']}",
85 "report": tn["special"],
86 "reference": "Q2(R2) Annex 2",
87 }
88 )
89 if range_use:
90 rr = Q2R2_REPORTABLE_RANGE.get(range_use)
91 if rr:
92 rows.append(
93 {
94 "characteristic": f"reportable range ({range_use})",
95 "required": "confirm response, accuracy and precision across this range",
96 "study_design": f"low: {rr['low']}",
97 "report": f"high: {rr['high']}",
98 "reference": "Q2(R2) 2.3, Table 2",
99 }
100 )
101 return rows
104def plan_m10(modality: str) -> list[dict]:
105 crit = M10_CRITERIA[modality]
106 rows = [
107 {
108 "item": "calibration curve",
109 "requirement": (
110 f"minimum {crit['calibration_min_levels']} concentration levels including "
111 f"the LLOQ and ULOQ; at least "
112 f"{crit['calibration_min_pass_fraction'] * 100:.0f}% of standards and at least 6 levels must pass"
113 ),
114 "tolerance": (
115 f"+/-{crit['calibration_tolerance_pct']:.0f}% nominal; "
116 f"+/-{crit['calibration_tolerance_lloq_pct']:.0f}% at LLOQ; "
117 f"+/-{crit['calibration_tolerance_uloq_pct']:.0f}% at ULOQ"
118 ),
119 },
120 {
121 "item": "accuracy and precision QC levels",
122 "requirement": (
123 f"{crit['qc_levels_accuracy_precision']} levels; "
124 f"{crit['ap_replicates_per_run']} replicates per level per run; "
125 f"at least {crit['ap_min_runs']} runs over at least {crit['ap_min_days']} days"
126 ),
127 "tolerance": (
128 f"accuracy +/-{crit['accuracy_tolerance_pct']:.0f}% "
129 f"(+/-{crit['accuracy_tolerance_lloq_pct']:.0f}% at {crit['limit_levels']}); "
130 f"precision CV <={crit['precision_cv_pct']:.0f}% "
131 f"(<={crit['precision_cv_lloq_pct']:.0f}% at {crit['limit_levels']})"
132 ),
133 },
134 {
135 "item": "routine run acceptance",
136 "requirement": (
137 f"{crit['qc_levels_routine_run']} QC levels; at least "
138 f"{crit['qc_run_pass_fraction'] * 100:.0f}% of all QCs and at least "
139 f"{crit['qc_run_pass_fraction_per_level'] * 100:.0f}% at each level must pass"
140 ),
141 "tolerance": f"+/-{crit['qc_run_tolerance_pct']:.0f}% nominal",
142 },
143 {
144 "item": "selectivity",
145 "requirement": f"at least {crit['selectivity_min_sources']} individual matrix sources/lots",
146 "tolerance": (
147 f"interference <={crit['carryover_blank_pct_of_lloq']:.0f}% of LLOQ analyte "
148 f"response and <={crit['carryover_blank_pct_of_is']:.0f}% of IS response"
149 if crit["carryover_blank_pct_of_lloq"] is not None
150 else "per guideline; evaluate interference in each source"
151 ),
152 },
153 {
154 "item": "dilution integrity",
155 "requirement": "validate the dilution factors used in study sample analysis",
156 "tolerance": f"mean within +/-{crit['dilution_tolerance_pct']:.0f}% nominal",
157 },
158 {
159 "item": "stability",
160 "requirement": "cover the conditions and durations study samples actually experience",
161 "tolerance": f"mean at each QC level within +/-{crit['stability_tolerance_pct']:.0f}% nominal",
162 },
163 {
164 "item": "incurred sample reanalysis",
165 "requirement": (
166 f"repeat a predefined subset in separate runs; at least "
167 f"{crit['isr_pass_fraction'] * 100:.0f}% of repeats must agree"
168 ),
169 "tolerance": f"percent difference within +/-{crit['isr_tolerance_pct']:.0f}%",
170 },
171 ]
172 if crit["total_error_pct"] is not None:
173 rows.append(
174 {
175 "item": "total error",
176 "requirement": "absolute bias (%) plus precision CV (%)",
177 "tolerance": (
178 f"<={crit['total_error_pct']:.0f}%, "
179 f"<={crit['total_error_pct_at_limits']:.0f}% at LLOQ and ULOQ"
180 ),
181 }
182 )
183 return rows
186def render_protocol(framework: str, attribute: str | None, modality: str | None,
187 technique: str | None, range_use: str | None) -> str:
188 fw = FRAMEWORKS[framework]
189 lines = [
190 "# Analytical Procedure Validation Protocol",
191 "",
192 "> Draft skeleton. Every bracketed field is a decision the analyst and quality unit",
193 "> must make and record BEFORE data collection. Acceptance criteria set after seeing",
194 "> data are not acceptance criteria.",
195 "",
196 "## 1. Identification",
197 "",
198 "| Field | Value |",
199 "| --- | --- |",
200 "| Protocol number / version | [ ] |",
201 "| Analytical procedure identifier | [ ] |",
202 "| Product / analyte / matrix | [ ] |",
203 "| Governing framework | " + fw["title"] + " |",
204 "| Regional expectation confirmed | [ ] " + fw["effective_note"] + " |",
205 "| Author / date | [ ] |",
206 "| Reviewed / approved by (quality unit) | [ ] |",
207 "",
208 "## 2. Intended purpose and analytical target profile",
209 "",
210 "- Measurand and reporting unit: [ ]",
211 "- Decision the result supports (release, stability, in-process, clinical): [ ]",
212 "- Specification or reporting limits the procedure must serve: [ ]",
213 "- Required reportable range, derived from the specification: [ ]",
214 "- Performance characteristics and criteria: [ ] (formal ATP optional under ICH Q14)",
215 "",
216 "## 3. Pre-stated acceptance criteria",
217 "",
218 "| Characteristic | Criterion | Justification | Source |",
219 "| --- | --- | --- | --- |",
220 ]
221 if framework == "ich-m10" and modality:
222 for row in plan_m10(modality):
223 lines.append(
224 f"| {row['item']} | {row['tolerance']} | guideline default | ICH M10 |"
225 )
226 elif framework == "ich-q2r2" and attribute:
227 for row in plan_q2r2(attribute, technique, range_use):
228 crit = "[ ] state an appropriate criterion"
229 lines.append(
230 f"| {row['characteristic']} | {crit} | [ ] | {row['reference'] or 'ICH Q2(R2)'} |"
231 )
232 lines += [
233 "",
234 "> ICH Q2(R2) deliberately does not set numeric acceptance criteria for most",
235 "> characteristics. A criterion has to come from the specification, the ATP, product",
236 "> knowledge, or development data -- not from a remembered default.",
237 "",
238 "## 4. Study design",
239 "",
240 "| Characteristic | Levels | Replicates | Runs / days / analysts / instruments |",
241 "| --- | --- | --- | --- |",
242 "| [ ] | [ ] | [ ] | [ ] |",
243 "",
244 "- Reference materials and their documented identity/purity: [ ]",
245 "- Number of replicates matches the routine reportable result: [ ] yes / [ ] justified",
246 "- Prior knowledge or development data used in place of a test, with justification: [ ]",
247 "",
248 "## 5. Sample and solution handling",
249 "",
250 "- Preparation, storage, and solution stability window: [ ]",
251 "- Blank, placebo, and spiked matrix definitions: [ ]",
252 "",
253 "## 6. Statistical treatment",
254 "",
255 "- Calibration model and weighting, stated in advance: [ ]",
256 "- Interval to be reported with accuracy and precision (confidence level): [ ]",
257 "- Software, version, and calculation verification: [ ]",
258 "",
259 "## 7. Deviations and data integrity",
260 "",
261 "- Deviation handling and reporting: [ ]",
262 "- All results reported, including out-of-criteria values: [ ] confirmed",
263 "- Raw data location and audit trail: [ ]",
264 "",
265 "## 8. Approvals",
266 "",
267 "| Role | Name | Signature | Date |",
268 "| --- | --- | --- | --- |",
269 "| Author | | | |",
270 "| Technical reviewer | | | |",
271 "| Quality unit | | | |",
272 "",
273 f"_Framework metadata researched {RESEARCH_DATE}. Confirm the current guideline text "
274 f"and regional expectation before use: {fw['url']}_",
275 ]
276 return "\n".join(lines)
279def main() -> int:
280 parser = argparse.ArgumentParser(
281 description="Plan an analytical procedure validation study.",
282 formatter_class=argparse.RawDescriptionHelpFormatter,
283 )
284 parser.add_argument("--framework", help="governing framework key")
285 parser.add_argument("--attribute", help="measured attribute (assay, impurity, identity, ...)")
286 parser.add_argument("--modality", choices=sorted(M10_CRITERIA),
287 help="ICH M10 only: chromatographic or lba")
288 parser.add_argument("--technique", choices=sorted(TECHNIQUE_NOTES),
289 help="analytical technique, for Annex 2 notes")
290 parser.add_argument("--range-use", choices=sorted(Q2R2_REPORTABLE_RANGE),
291 help="reportable range example to include")
292 parser.add_argument("--protocol", action="store_true",
293 help="emit a validation protocol skeleton in Markdown")
294 parser.add_argument("--list-frameworks", action="store_true")
295 parser.add_argument("--list-dl-ql", action="store_true",
296 help="list the DL/QL estimation approaches")
297 add_common_args(parser)
298 args = parser.parse_args()
300 if args.list_frameworks:
301 rows = [
302 {
303 "key": key,
304 "title": fw["title"],
305 "adopted": fw["adopted"],
306 "text_reusable": "yes" if fw["reproducible"] else "no (paywalled)",
307 "scope": fw["scope"],
308 }
309 for key, fw in FRAMEWORKS.items()
310 ]
311 emit(rows, args.format)
312 note(f"framework metadata researched {RESEARCH_DATE}; confirm before relying on a date")
313 return EXIT_OK
315 if args.list_dl_ql:
316 rows = [
317 {"approach": k, "detection_limit": v["dl"], "quantitation_limit": v["ql"],
318 "note": v["note"]}
319 for k, v in DL_QL_APPROACHES.items()
320 ]
321 emit(rows, args.format)
322 note("source: ICH Q2(R2) 3.2.3")
323 return EXIT_OK
325 if not args.framework:
326 raise InputError("--framework is required (or use --list-frameworks)")
327 if args.framework not in FRAMEWORKS:
328 raise InputError(
329 f"unknown framework {args.framework!r}; choose from: {', '.join(FRAMEWORKS)}"
330 )
331 fw = FRAMEWORKS[args.framework]
333 attribute = None
334 if args.attribute:
335 try:
336 attribute = resolve_attribute(args.attribute)
337 except KeyError as exc:
338 raise InputError(str(exc)) from exc
340 if args.framework == "ich-m10":
341 if not args.modality:
342 raise InputError("ICH M10 needs --modality chromatographic|lba")
343 rows = plan_m10(args.modality)
344 elif args.framework == "ich-q2r2":
345 if not attribute:
346 raise InputError("ICH Q2(R2) needs --attribute (assay, impurity, identity, ...)")
347 rows = plan_q2r2(attribute, args.technique, args.range_use)
348 else:
349 note(
350 f"{fw['title']} is copyrighted and not reproduced here. This skill reports its "
351 "designation, scope, and where to obtain it; the study design must come from the "
352 "authorised text."
353 )
354 rows = [
355 {
356 "framework": args.framework,
357 "title": fw["title"],
358 "scope": fw["scope"],
359 "obtain_from": fw["url"],
360 "companion": fw["companion"],
361 }
362 ]
364 if args.protocol:
365 print(render_protocol(args.framework, attribute, args.modality,
366 args.technique, args.range_use))
367 note("protocol skeleton written; every bracketed field needs a decision before data")
368 return EXIT_OK
370 emit(rows, args.format)
371 note(f"framework: {fw['title']}")
372 note(f"regional applicability: {fw['effective_note']}")
373 if fw["companion"]:
374 note(f"companion: {fw['companion']}")
375 note(f"catalogue researched {RESEARCH_DATE}; confirm the current text at {fw['url']}")
376 note("this tool reports requirements; it does not decide fitness for purpose")
377 return EXIT_OK
380if __name__ == "__main__":
381 run_cli(main)