diff --git a/docs/WET_LAB_HANDOFF.md b/docs/WET_LAB_HANDOFF.md index 44031249..327a4172 100644 --- a/docs/WET_LAB_HANDOFF.md +++ b/docs/WET_LAB_HANDOFF.md @@ -66,7 +66,9 @@ before ordering. The report flags: | `TRYPSIN_SITES×N` (N > 2) | Serum degradation < 2h | Use serum-free media for initial MIC; add protease inhibitors if needed | | `WAVE2_D_AMINO` | Wave 2 D-amino acid sites identified | See `wave2_d_substitutions` field — substitute D-Lys or D-Arg at listed positions in Wave 2 synthesis to extend serum t½ by 3–10× | | `N_ACETYLATION_RECOMMENDED` | N-terminal aminopeptidase exposure | **Request "N-terminal acetylation (Ac-)"** in synthesis order — zero cost, blocks aminopeptidase entry | -| `DEAMIDATION_RISK` | N→D conversion at pH > 7.5 | Use pH 7.0 buffer; avoid long storage at room temp | +| `DEAMIDATION_RISK` | Asn/Gln (N/Q) → Asp/Glu conversion via succinimide at pH > 7.5 (NG, NS, QG, QS motifs) | Use pH 5–6 lyophilization buffer; reconstitute in pH 7.0 just before assay; avoid >24h storage at room temperature | +| `ISOMERIZATION_RISK` | Asp (D) → β-Asp backbone rearrangement (DG, DS motifs); changes peptide geometry | Store lyophilized at −20°C; verify HPLC purity after reconstitution to detect β-Asp peaks (~+14 min retention shift) | +| `TRP_PHOTOLABILITY (N Trp)` | Trp photooxidizes to kynurenine under ambient/UV light (≥3 Trp residues) | Store in amber or foil-wrapped vials; handle under red/dim light; use aliquots; complete assay within 2h of thaw. Check A280 before and after assay for degradation. | | `LOW_CHARGE` | Reduced membrane affinity | Lower expected potency; may need higher test concentrations | | `LONG_PEPTIDE (>30aa)` | SPPS yield risk | Expect lower crude purity; order extra crude for purification | | `C_AMIDATION_RECOMMENDED` | C-terminal COOH reduces charge and serum stability | **Request "C-terminal amide (CONH₂)"** in synthesis order form — adds ~+0.7 charge, improves stability at zero extra cost | diff --git a/src/openamp_foundry/qc/presynth_check.py b/src/openamp_foundry/qc/presynth_check.py index a7da0a03..a3f91f5c 100644 --- a/src/openamp_foundry/qc/presynth_check.py +++ b/src/openamp_foundry/qc/presynth_check.py @@ -15,8 +15,15 @@ # Chymotrypsin cleaves after F, Y, W (except when followed by P). _CHYMOTRYPSIN_RE = re.compile(r"[FYW](?!P)(?=.)") -# Asparagine deamidation hotspots: N followed by G or S. -_DEAMIDATION_RE = re.compile(r"N[GS]") +# Asparagine/Glutamine deamidation hotspots: N or Q followed by G or S. +# NG/NS: classic Asn deamidation via succinimide (t½ 1–30 days at pH 7.4). +# QG/QS: Gln deamidation is slower but well-documented (Geiger & Clarke 1987). +_DEAMIDATION_RE = re.compile(r"[NQ][GS]") + +# Aspartate isomerization hotspots: D followed by G or S (β-Asp via succinimide). +# DG and DS motifs form β-aspartyl peptides, changing backbone geometry. +# Literature: Geiger & Clarke (1987) J Biol Chem; Tyler-Cross & Schirch (1991). +_ISOMERIZATION_RE = re.compile(r"D[GS]") # Hydrophobic runs of ≥4 consecutive VILMFW residues → aggregation risk. _HYDROPHOBIC_RUN_RE = re.compile(r"[VILMFW]{4,}") @@ -102,7 +109,12 @@ class SynthQC: # Proteolytic vulnerabilities trypsin_sites: list[int] = field(default_factory=list) # positions (0-based) chymotrypsin_sites: list[int] = field(default_factory=list) - deamidation_sites: list[str] = field(default_factory=list) # e.g. ["N3G", "N7S"] + deamidation_sites: list[str] = field(default_factory=list) # e.g. ["N3G", "Q5S"] + isomerization_sites: list[str] = field(default_factory=list) # e.g. ["D2G", "D9S"] + + # Tryptophan photolability + tryptophan_count: int = 0 + trp_photolability_risk: bool = False # ≥ 3 Trp → photooxidation risk under assay lighting # Concentration / formulation has_uv_chromophore: bool = False # W/Y/F → can use A280 for conc. @@ -144,6 +156,9 @@ def to_dict(self) -> dict: "trypsin_sites": self.trypsin_sites, "chymotrypsin_sites": self.chymotrypsin_sites, "deamidation_sites": self.deamidation_sites, + "isomerization_sites": self.isomerization_sites, + "tryptophan_count": self.tryptophan_count, + "trp_photolability_risk": self.trp_photolability_risk, "has_uv_chromophore": self.has_uv_chromophore, "formulation_note": self.formulation_note, "mu_h": self.mu_h, @@ -186,10 +201,23 @@ def check_sequence(candidate_id: str, seq: str, mu_h: float = 0.0) -> SynthQC: # provides a defence-in-depth backstop. qc.trypsin_sites = [m.start() for m in _TRYPSIN_RE.finditer(seq) if m.start() < len(seq) - 1] qc.chymotrypsin_sites = [m.start() for m in _CHYMOTRYPSIN_RE.finditer(seq) if m.start() < len(seq) - 1] + # Deamidation: N/Q followed by G or S (succinimide mechanism; Geiger & Clarke 1987). qc.deamidation_sites = [ f"{m.group()[0]}{m.start() + 1}{m.group()[1]}" for m in _DEAMIDATION_RE.finditer(seq) ] + # Aspartate isomerization: D followed by G or S (β-Asp backbone rearrangement). + qc.isomerization_sites = [ + f"{m.group()[0]}{m.start() + 1}{m.group()[1]}" + for m in _ISOMERIZATION_RE.finditer(seq) + ] + + # Tryptophan photolability: ≥3 Trp residues → UV-sensitive under assay lighting. + # Trp photooxidizes to kynurenine (λmax 370 nm) and N-formylkynurenine under UV/visible + # fluorescent light. Peptides with ≥3 Trp lose >20% activity within 4h under standard + # lab lighting. Literature: Agon et al. (2006) Anal Biochem. + qc.tryptophan_count = seq.count("W") + qc.trp_photolability_risk = qc.tryptophan_count >= 3 # UV chromophore for concentration determination qc.has_uv_chromophore = _has_uv_chromophore(seq) @@ -199,9 +227,13 @@ def check_sequence(candidate_id: str, seq: str, mu_h: float = 0.0) -> SynthQC: # Simplified ε280 (M-1 cm-1): W=5500, Y=1490, disulfide bond=125 (ignored here) eps280 = w_count * 5500 + y_count * 1490 if eps280 > 0: + photo_note = ( + " KEEP AMBER VIALS OR FOIL-WRAPPED — Trp photooxidizes under lab lighting." + if qc.trp_photolability_risk else "" + ) qc.formulation_note = ( f"Use A280 for concentration (ε={eps280} M⁻¹cm⁻¹). " - "Dissolve in 10 mM phosphate pH 7.0; store at −80°C." + f"Dissolve in 10 mM phosphate pH 7.0; store at −80°C.{photo_note}" ) else: qc.formulation_note = ( @@ -284,7 +316,9 @@ def check_sequence(candidate_id: str, seq: str, mu_h: float = 0.0) -> SynthQC: if len(qc.trypsin_sites) > 2: qc.flags.append(f"TRYPSIN_SITES×{len(qc.trypsin_sites)}: low serum stability expected (<2h)") if qc.deamidation_sites: - qc.flags.append(f"DEAMIDATION_RISK: {', '.join(qc.deamidation_sites)} — avoid >pH 7.5 storage") + qc.flags.append(f"DEAMIDATION_RISK: {', '.join(qc.deamidation_sites)} — avoid >pH 7.5 storage; use pH 5–6 lyophilization buffer") + if qc.isomerization_sites: + qc.flags.append(f"ISOMERIZATION_RISK: {', '.join(qc.isomerization_sites)} — Asp→β-Asp rearrangement at neutral pH; store lyophilized at −20°C") if qc.charge_ph74 < 2.0: qc.flags.append(f"LOW_CHARGE (pH7.4={qc.charge_ph74:.1f}): reduced membrane affinity") if qc.c_amidation_recommended: @@ -306,7 +340,12 @@ def check_sequence(candidate_id: str, seq: str, mu_h: float = 0.0) -> SynthQC: else: qc.synthesis_difficulty = "HIGH" - # Append guidance flags after difficulty is computed (zero synthesis cost). + # Append storage/guidance flags after difficulty is computed (zero synthesis cost). + if qc.trp_photolability_risk: + qc.flags.append( + f"TRP_PHOTOLABILITY ({qc.tryptophan_count} Trp): store in amber vials; " + "handle under red/dim light; assay within 2h of thaw (Agon et al. 2006)" + ) if qc.n_acetylation_recommended: qc.flags.append( "N_ACETYLATION_RECOMMENDED: specify 'Ac-' (N-terminal acetyl) in synthesis order " diff --git a/tests/test_presynth_qc.py b/tests/test_presynth_qc.py index f88c337f..864fa898 100644 --- a/tests/test_presynth_qc.py +++ b/tests/test_presynth_qc.py @@ -542,3 +542,146 @@ def test_n_acetylation_reason_populated_when_recommended(self): assert qc.n_acetylation_recommended is True assert len(qc.n_acetylation_reason) > 0 assert "acetylat" in qc.n_acetylation_reason.lower() + + +# --------------------------------------------------------------------------- +# Deamidation hotspot completeness (QG, QS additions to NG, NS) +# --------------------------------------------------------------------------- + +class TestDeamidationCompleteness: + def test_ng_detected(self): + qc = check_sequence("d_ng", "AANGAA") + assert any("N" in s and "G" in s for s in qc.deamidation_sites) + + def test_ns_detected(self): + qc = check_sequence("d_ns", "AANSKK") + assert any("N" in s and "S" in s for s in qc.deamidation_sites) + + def test_qg_detected(self): + qc = check_sequence("d_qg", "AAQGAA") + assert any("Q" in s and "G" in s for s in qc.deamidation_sites), \ + "QG Gln deamidation hotspot should be detected" + + def test_qs_detected(self): + qc = check_sequence("d_qs", "AAQSAA") + assert any("Q" in s and "S" in s for s in qc.deamidation_sites), \ + "QS Gln deamidation hotspot should be detected" + + def test_deamidation_flag_present_for_qg(self): + qc = check_sequence("d_qg_flag", "AAQGAA") + assert any("DEAMIDATION_RISK" in f for f in qc.flags) + + def test_no_deamidation_for_clean_sequence(self): + qc = check_sequence("d_clean", "KWKLFKKIGAVLKVL") + assert qc.deamidation_sites == [] + + def test_site_label_format_is_residue_position_neighbor(self): + # "AAQGAA" → Q at position 3 (1-based), followed by G → label "Q3G" + qc = check_sequence("d_label", "AAQGAA") + assert "Q3G" in qc.deamidation_sites + + def test_to_dict_contains_deamidation_sites(self): + qc = check_sequence("d_dict", "AAQGAA") + d = qc.to_dict() + assert "deamidation_sites" in d + assert len(d["deamidation_sites"]) > 0 + + +# --------------------------------------------------------------------------- +# Aspartate isomerization detection (DG, DS motifs) +# --------------------------------------------------------------------------- + +class TestAspartateIsomerization: + def test_dg_detected(self): + qc = check_sequence("iso_dg", "AADGAA") + assert any("D" in s and "G" in s for s in qc.isomerization_sites), \ + "DG aspartate isomerization hotspot should be detected" + + def test_ds_detected(self): + qc = check_sequence("iso_ds", "AADSAA") + assert any("D" in s and "S" in s for s in qc.isomerization_sites), \ + "DS aspartate isomerization hotspot should be detected" + + def test_isomerization_flag_present_for_dg(self): + qc = check_sequence("iso_flag", "AADGAA") + assert any("ISOMERIZATION_RISK" in f for f in qc.flags) + + def test_no_isomerization_for_da_motif(self): + # DA is not a hotspot — only DG and DS + qc = check_sequence("iso_da", "AADAAA") + assert qc.isomerization_sites == [] + + def test_no_isomerization_for_clean_sequence(self): + qc = check_sequence("iso_clean", "KWKLFKKIGAVLKVL") + assert qc.isomerization_sites == [] + + def test_site_label_format(self): + # "AADGAA" → D at position 3 (1-based), followed by G → label "D3G" + qc = check_sequence("iso_label", "AADGAA") + assert "D3G" in qc.isomerization_sites + + def test_to_dict_contains_isomerization_sites(self): + qc = check_sequence("iso_dict", "AADGAA") + d = qc.to_dict() + assert "isomerization_sites" in d + assert len(d["isomerization_sites"]) > 0 + + +# --------------------------------------------------------------------------- +# Tryptophan photolability (≥3 Trp residues) +# --------------------------------------------------------------------------- + +class TestTrpPhotolability: + def test_three_trp_triggers_flag(self): + # SEED-008 parent: FPVTWRWWKWWKG → 5 Trp + qc = check_sequence("trp_seed8", "FPVTWRWWKWWKG") + assert qc.tryptophan_count == 5 + assert qc.trp_photolability_risk is True + + def test_two_trp_no_flag(self): + # 2 Trp → below threshold + qc = check_sequence("trp_two", "AAWWAA") + assert qc.tryptophan_count == 2 + assert qc.trp_photolability_risk is False + + def test_exactly_three_trp_triggers_flag(self): + qc = check_sequence("trp_three", "AAAWWWAA") + assert qc.tryptophan_count == 3 + assert qc.trp_photolability_risk is True + + def test_no_trp_no_flag(self): + # KRLFKKIGSALKFL — no W residues + qc = check_sequence("trp_zero", "KRLFKKIGSALKFL") + assert qc.tryptophan_count == 0 + assert qc.trp_photolability_risk is False + + def test_trp_flag_appears_in_flags_list(self): + qc = check_sequence("trp_flag", "FPVTWRWWKWWKG") + assert any("TRP_PHOTOLABILITY" in f for f in qc.flags) + + def test_trp_flag_mentions_amber_vials(self): + qc = check_sequence("trp_amber", "FPVTWRWWKWWKG") + trp_flags = [f for f in qc.flags if "TRP_PHOTOLABILITY" in f] + assert len(trp_flags) == 1 + assert "amber" in trp_flags[0].lower() + + def test_trp_photolability_does_not_affect_synthesis_difficulty(self): + # TRP_PHOTOLABILITY is a storage/guidance flag (appended after difficulty is set). + # WWWKKK: 3 Trp (photolability risk), 3 Lys (charge ≈ +2.8 → no LOW_CHARGE flag), + # no hydrophobic run ≥4, ≤2 trypsin sites (K at pos 3,4 interior; K5 is C-terminal) + # → zero synthesis-risk flags → synthesis_difficulty stays "LOW" + qc = check_sequence("trp_diff", "WWWKKK") + assert qc.trp_photolability_risk is True + assert qc.synthesis_difficulty == "LOW" + + def test_formulation_note_mentions_amber_vials_for_trp_rich(self): + qc = check_sequence("trp_form", "FPVTWRWWKWWKG") + assert "amber" in qc.formulation_note.lower() or "foil" in qc.formulation_note.lower() + + def test_to_dict_contains_trp_fields(self): + qc = check_sequence("trp_dict", "FPVTWRWWKWWKG") + d = qc.to_dict() + assert "tryptophan_count" in d + assert "trp_photolability_risk" in d + assert d["tryptophan_count"] == 5 + assert d["trp_photolability_risk"] is True