Skip to content

Commit 25fde67

Browse files
authored
feat: add synthesis-order CLI — vendor-ready order CSV + checklist from panel QC (#45)
Adds synthesis-order subcommand and qc/order_generator.py module. Reads a panel CSV, runs presynth QC on every candidate, auto-fills N-terminal modification (Ac- when trypsin sites present), C-terminal modification (NH2 when c_amidation_recommended), purity spec (>95% HPLC), quantity (2× for HIGH-difficulty peptides), and condensed special-handling notes. Outputs synthesis_order.csv (GenScript- compatible) and synthesis_checklist.md (human-facing pre-order brief with per-candidate handling notes and Wave 2 D-amino guide). 54 new tests; 1036 total passing.
1 parent b58bb05 commit 25fde67

4 files changed

Lines changed: 946 additions & 1 deletion

File tree

‎Makefile‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: demo test lint ci clean bench-leakage bench-baseline bench-hidden-active generate phase3 pilot validate-scoring validate-scoring-strict external-predict pilot-confident presynth-qc gold-standard diversity
1+
.PHONY: demo test lint ci clean bench-leakage bench-baseline bench-hidden-active generate phase3 pilot validate-scoring validate-scoring-strict external-predict pilot-confident presynth-qc gold-standard diversity synthesis-order
22

33
PYTHON := $(shell [ -f .venv/bin/python ] && echo .venv/bin/python || echo python3)
44
PYTEST := $(shell [ -f .venv/bin/pytest ] && echo .venv/bin/pytest || echo pytest)
@@ -118,5 +118,12 @@ diversity:
118118
--panel-csv outputs/confident_panel.csv \
119119
--out outputs/diversity_report.md
120120

121+
synthesis-order:
122+
PYTHONPATH=src $(PYTHON) -m openamp_foundry.cli synthesis-order \
123+
--panel-csv outputs/confident_panel.csv \
124+
--out-csv outputs/synthesis_order.csv \
125+
--out-md outputs/synthesis_checklist.md \
126+
--quantity-mg 5
127+
121128
clean:
122129
rm -rf outputs/*.jsonl outputs/*.md outputs/*.json outputs/evidence outputs/phase3_evidence .pytest_cache .ruff_cache

‎src/openamp_foundry/cli.py‎

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,36 @@ def build_parser() -> argparse.ArgumentParser:
290290
help="Output markdown report path.",
291291
)
292292

293+
synthesis_order = sub.add_parser(
294+
"synthesis-order",
295+
help=(
296+
"Generate a vendor-ready synthesis order CSV and checklist from a panel CSV. "
297+
"Runs pre-synthesis QC on every candidate and auto-fills N/C-term modifications, "
298+
"purity spec, quantity, and special-handling notes."
299+
),
300+
)
301+
synthesis_order.add_argument(
302+
"--panel-csv",
303+
default="outputs/confident_panel.csv",
304+
help="Panel CSV (candidate_id + sequence columns required).",
305+
)
306+
synthesis_order.add_argument(
307+
"--out-csv",
308+
default="outputs/synthesis_order.csv",
309+
help="Output path for vendor-ready order CSV.",
310+
)
311+
synthesis_order.add_argument(
312+
"--out-md",
313+
default="outputs/synthesis_checklist.md",
314+
help="Output path for synthesis checklist markdown.",
315+
)
316+
synthesis_order.add_argument(
317+
"--quantity-mg",
318+
type=float,
319+
default=5.0,
320+
help="Default order quantity in mg per peptide (default: 5.0). HIGH-difficulty peptides get 2×.",
321+
)
322+
293323
batch_pack = sub.add_parser(
294324
"batch-pack",
295325
help=(
@@ -366,6 +396,9 @@ def main(argv: list[str] | None = None) -> int:
366396
if args.command == "batch-pack":
367397
return _run_batch_pack(args)
368398

399+
if args.command == "synthesis-order":
400+
return _run_synthesis_order(args)
401+
369402
if args.command == "presynth-qc":
370403
return _run_presynth_qc(args)
371404

@@ -631,6 +664,72 @@ def _run_generate_batch(args: argparse.Namespace) -> int:
631664
return 0
632665

633666

667+
def _run_synthesis_order(args: argparse.Namespace) -> int:
668+
import csv as _csv
669+
from datetime import datetime, timezone
670+
from openamp_foundry.features.physchem import compute_features
671+
from openamp_foundry.qc.order_generator import (
672+
generate_synthesis_order,
673+
write_order_csv,
674+
write_synthesis_checklist,
675+
)
676+
677+
panel_path = Path(args.panel_csv)
678+
if not panel_path.exists():
679+
print(json.dumps({"status": "error", "message": f"File not found: {args.panel_csv}"}))
680+
return 1
681+
682+
panel = []
683+
with open(panel_path, newline="", encoding="utf-8") as f:
684+
for row in _csv.DictReader(f):
685+
panel.append(row)
686+
687+
mu_h_map = {}
688+
for row in panel:
689+
seq = row["sequence"].strip().upper()
690+
feats = compute_features(seq)
691+
mu_h_map[row["candidate_id"]] = feats.get("hydrophobic_moment", 0.0)
692+
693+
qty = args.quantity_mg
694+
order_rows, qc_results = generate_synthesis_order(
695+
panel,
696+
mu_h_map=mu_h_map,
697+
default_quantity_mg=qty,
698+
high_difficulty_quantity_mg=qty * 2,
699+
)
700+
701+
generated_at = datetime.now(timezone.utc).isoformat()
702+
write_order_csv(order_rows, args.out_csv)
703+
write_synthesis_checklist(
704+
order_rows,
705+
qc_results,
706+
args.out_md,
707+
generated_at=generated_at,
708+
panel_csv=args.panel_csv,
709+
)
710+
711+
n_acetyl = sum(1 for r in order_rows if r["n_modification"] == "Ac-")
712+
n_amide = sum(1 for r in order_rows if r["c_modification"] == "NH2")
713+
n_high = sum(1 for r in order_rows if r["synthesis_difficulty"] == "HIGH")
714+
total_mg = sum(float(r["quantity_mg"]) for r in order_rows)
715+
716+
print(json.dumps({
717+
"status": "ok",
718+
"n_candidates": len(order_rows),
719+
"n_n_terminal_acetylation": n_acetyl,
720+
"n_c_terminal_amide": n_amide,
721+
"n_high_difficulty": n_high,
722+
"total_quantity_mg": total_mg,
723+
"out_csv": args.out_csv,
724+
"out_md": args.out_md,
725+
"disclaimer": (
726+
"This order is computationally generated. Human expert review required "
727+
"before submitting to vendor. No antimicrobial activity has been demonstrated."
728+
),
729+
}, indent=2))
730+
return 0
731+
732+
634733
def _run_presynth_qc(args: argparse.Namespace) -> int:
635734
import csv as _csv
636735
from datetime import datetime, timezone
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
"""Vendor-ready synthesis order generator for AMP pilot panels.
2+
3+
Reads a panel CSV, runs pre-synthesis QC on every candidate, and produces:
4+
1. synthesis_order.csv — GenScript-compatible order form (one row per peptide)
5+
2. synthesis_checklist.md — human-readable per-candidate synthesis brief
6+
7+
Columns in synthesis_order.csv:
8+
pilot_rank, candidate_id, sequence, length, mol_weight_da,
9+
n_modification, c_modification, purity_spec, quantity_mg,
10+
synthesis_difficulty, special_handling
11+
"""
12+
from __future__ import annotations
13+
14+
import csv
15+
from pathlib import Path
16+
from typing import TYPE_CHECKING
17+
18+
if TYPE_CHECKING:
19+
from openamp_foundry.qc.presynth_check import SynthQC
20+
21+
22+
# Canonical special-handling codes emitted for each QC flag category.
23+
# Kept short so they fit in a vendor order form cell.
24+
_HANDLING_MAP = {
25+
"CYSTEINE": "N2 storage; check purity pre-assay",
26+
"MET": "−80°C; check purity post-thaw",
27+
"HYDROPHOBIC_RUN": "Solubility check 1 mM PBS",
28+
"TRYPSIN_SITES": "Use serum-free media for initial MIC",
29+
"DEAMIDATION_RISK": "pH 5-6 lyophilization buffer",
30+
"ISOMERIZATION_RISK": "HPLC re-check post-reconstitution",
31+
"TRP_PHOTOLABILITY": "Amber vials; red/dim light only",
32+
"LOW_CHARGE": "Higher test concentrations may be needed",
33+
"LONG_PEPTIDE": "Discuss yield with vendor; order extra crude",
34+
}
35+
36+
37+
def _extract_handling(flags: list[str]) -> str:
38+
"""Map QC flag strings to short handling notes, deduplicated."""
39+
notes: list[str] = []
40+
seen: set[str] = set()
41+
for flag in flags:
42+
for key, note in _HANDLING_MAP.items():
43+
if flag.startswith(key) and note not in seen:
44+
notes.append(note)
45+
seen.add(note)
46+
return "; ".join(notes)
47+
48+
49+
def generate_synthesis_order(
50+
candidates: list[dict],
51+
mu_h_map: dict[str, float] | None = None,
52+
default_quantity_mg: float = 5.0,
53+
high_difficulty_quantity_mg: float = 10.0,
54+
) -> tuple[list[dict], list["SynthQC"]]:
55+
"""Generate vendor-ready order rows and QC results for a candidate panel.
56+
57+
Args:
58+
candidates: list of dicts with at minimum 'candidate_id' and 'sequence'.
59+
Optional keys: 'pilot_rank', 'ensemble', 'activity', 'safety'.
60+
mu_h_map: {candidate_id: hydrophobic_moment} for hemolysis stratification.
61+
default_quantity_mg: order quantity for LOW/MODERATE difficulty (default 5 mg).
62+
high_difficulty_quantity_mg: order quantity for HIGH difficulty (default 10 mg).
63+
64+
Returns:
65+
(order_rows, qc_results) — both aligned by index to the input candidates list.
66+
"""
67+
from openamp_foundry.qc.presynth_check import check_panel
68+
69+
mu_h_map = mu_h_map or {}
70+
qc_results = check_panel(candidates, mu_h_map=mu_h_map)
71+
72+
order_rows: list[dict] = []
73+
for candidate, qc in zip(candidates, qc_results):
74+
n_mod = "Ac-" if qc.n_acetylation_recommended else "Free"
75+
c_mod = "NH2" if qc.c_amidation_recommended else "OH"
76+
77+
qty = (
78+
high_difficulty_quantity_mg
79+
if qc.synthesis_difficulty == "HIGH"
80+
else default_quantity_mg
81+
)
82+
83+
special = _extract_handling(qc.flags)
84+
85+
order_rows.append({
86+
"pilot_rank": candidate.get("pilot_rank", ""),
87+
"candidate_id": qc.candidate_id,
88+
"sequence": qc.sequence,
89+
"length": qc.length,
90+
"mol_weight_da": qc.molecular_weight_da,
91+
"n_modification": n_mod,
92+
"c_modification": c_mod,
93+
"purity_spec": ">95% HPLC",
94+
"quantity_mg": qty,
95+
"synthesis_difficulty": qc.synthesis_difficulty,
96+
"special_handling": special,
97+
})
98+
99+
return order_rows, qc_results
100+
101+
102+
def write_order_csv(order_rows: list[dict], out_path: str | Path) -> None:
103+
"""Write vendor-ready order CSV."""
104+
out = Path(out_path)
105+
out.parent.mkdir(parents=True, exist_ok=True)
106+
fieldnames = [
107+
"pilot_rank", "candidate_id", "sequence", "length", "mol_weight_da",
108+
"n_modification", "c_modification", "purity_spec", "quantity_mg",
109+
"synthesis_difficulty", "special_handling",
110+
]
111+
with open(out, "w", newline="", encoding="utf-8") as f:
112+
writer = csv.DictWriter(f, fieldnames=fieldnames)
113+
writer.writeheader()
114+
writer.writerows(order_rows)
115+
116+
117+
def write_synthesis_checklist(
118+
order_rows: list[dict],
119+
qc_results: list["SynthQC"],
120+
out_path: str | Path,
121+
generated_at: str = "",
122+
panel_csv: str = "",
123+
) -> None:
124+
"""Write human-readable synthesis checklist markdown."""
125+
out = Path(out_path)
126+
out.parent.mkdir(parents=True, exist_ok=True)
127+
128+
n_total = len(order_rows)
129+
n_acetyl = sum(1 for r in order_rows if r["n_modification"] == "Ac-")
130+
n_amide = sum(1 for r in order_rows if r["c_modification"] == "NH2")
131+
n_high = sum(1 for r in order_rows if r["synthesis_difficulty"] == "HIGH")
132+
n_moderate = sum(1 for r in order_rows if r["synthesis_difficulty"] == "MODERATE")
133+
n_low = sum(1 for r in order_rows if r["synthesis_difficulty"] == "LOW")
134+
total_mg = sum(float(r["quantity_mg"]) for r in order_rows)
135+
136+
lines = [
137+
"# Synthesis Order Checklist",
138+
"",
139+
"> Generated by `make synthesis-order`. Send this with the synthesis_order.csv to vendor.",
140+
"",
141+
]
142+
if generated_at:
143+
lines += [f"Generated: {generated_at} "]
144+
if panel_csv:
145+
lines += [f"Panel: {panel_csv} "]
146+
lines += [
147+
f"Candidates: {n_total}",
148+
"",
149+
"## Order Summary",
150+
"",
151+
"| Metric | Value |",
152+
"|---|---|",
153+
f"| Total peptides | {n_total} |",
154+
f"| N-terminal Ac- (acetylation) | {n_acetyl} |",
155+
f"| C-terminal NH2 (amide) | {n_amide} |",
156+
f"| Synthesis difficulty LOW | {n_low} |",
157+
f"| Synthesis difficulty MODERATE | {n_moderate} |",
158+
f"| Synthesis difficulty HIGH | {n_high} |",
159+
f"| Total quantity ordered | {total_mg:.0f} mg |",
160+
"",
161+
"## Pre-Order Checklist",
162+
"",
163+
"- [ ] All sequences are standard L-amino acid one-letter codes",
164+
"- [ ] HPLC purity spec confirmed as >95% for all candidates",
165+
"- [ ] N-terminal Ac- candidates: confirm vendor accepts Nα-acetylation",
166+
"- [ ] C-terminal NH2 candidates: confirm amide C-terminus included in quote",
167+
f"- [ ] {n_high} HIGH-difficulty candidate(s): discuss yield with vendor before committing",
168+
"- [ ] Cys-containing peptides: confirm inert-atmosphere synthesis and storage",
169+
"- [ ] Trp-heavy peptides: confirm amber vial storage and red-light handling",
170+
"- [ ] Request CoA (certificate of analysis) with HPLC trace and MS confirmation for each peptide",
171+
"- [ ] Request minimum quantity as lyophilised powder (not solution)",
172+
"",
173+
"## Per-Candidate Order Details",
174+
"",
175+
"| # | ID | Seq (first 20 AA) | MW (Da) | N-term | C-term | Difficulty | Qty (mg) |",
176+
"|--:|---|---|---:|:---:|:---:|:---:|---:|",
177+
]
178+
179+
for i, (row, qc) in enumerate(zip(order_rows, qc_results), 1):
180+
seq_abbr = row["sequence"][:20] + ("…" if len(row["sequence"]) > 20 else "")
181+
lines.append(
182+
f"| {i} | {row['candidate_id']} | `{seq_abbr}` | {row['mol_weight_da']} "
183+
f"| {row['n_modification']} | {row['c_modification']} "
184+
f"| {row['synthesis_difficulty']} | {row['quantity_mg']:.0f} |"
185+
)
186+
187+
lines += ["", "## Per-Candidate Handling Notes", ""]
188+
for i, (row, qc) in enumerate(zip(order_rows, qc_results), 1):
189+
lines += [f"### {i}. {row['candidate_id']} — `{row['sequence']}`", ""]
190+
191+
lines += [
192+
f"- **MW:** {row['mol_weight_da']} Da | **pI:** {qc.isoelectric_point}",
193+
f"- **Charge pH 7.4:** {qc.charge_ph74:.2f}",
194+
f"- **N-modification:** {row['n_modification']}",
195+
f"- **C-modification:** {row['c_modification']}",
196+
f"- **Purity spec:** {row['purity_spec']}",
197+
f"- **Quantity:** {row['quantity_mg']:.0f} mg",
198+
f"- **Synthesis difficulty:** {row['synthesis_difficulty']}",
199+
]
200+
201+
if row["special_handling"]:
202+
lines.append(f"- **Special handling:** {row['special_handling']}")
203+
204+
if qc.n_acetylation_recommended:
205+
lines.append(f"- **N-Ac rationale:** {qc.n_acetylation_reason}")
206+
if qc.c_amidation_recommended:
207+
lines.append(f"- **C-amide rationale:** {qc.c_amidation_reason}")
208+
if qc.wave2_d_substitutions:
209+
lines.append("- **Wave 2 D-amino acid sites (future):**")
210+
for s in qc.wave2_d_substitutions:
211+
lines.append(f" - {s}")
212+
213+
if qc.flags:
214+
lines.append("- **QC flags:**")
215+
for flag in qc.flags:
216+
lines.append(f" - ⚠ {flag}")
217+
else:
218+
lines.append("- **QC flags:** None")
219+
lines.append("")
220+
221+
lines += [
222+
"## Wave 2 D-Amino Acid Substitution Guide",
223+
"",
224+
"For any Wave 1 candidate with interior trypsin sites and demonstrated MIC ≤ 16 μg/mL:",
225+
"",
226+
"1. Identify the `wave2_d_substitutions` positions (listed per-candidate above).",
227+
"2. Order D-Lys or D-Arg substituted variants from the same vendor.",
228+
"3. Expected serum t½ improvement: 3–10× (Wade et al. 1990 PNAS).",
229+
"4. Re-assay at same concentration range; compare MIC shift.",
230+
"",
231+
"## Disclaimer",
232+
"",
233+
"This checklist is computationally generated. QC flags are heuristic and do not",
234+
"guarantee synthesis success or biological activity. Always request vendor review",
235+
"for HIGH-difficulty sequences. No antimicrobial activity has been demonstrated",
236+
"until wet-lab confirmation.",
237+
]
238+
239+
out.write_text("\n".join(lines) + "\n", encoding="utf-8")

0 commit comments

Comments
 (0)