diff --git a/src/openamp_foundry/qc/presynth_check.py b/src/openamp_foundry/qc/presynth_check.py index aa2a45fd..266fc3d5 100644 --- a/src/openamp_foundry/qc/presynth_check.py +++ b/src/openamp_foundry/qc/presynth_check.py @@ -118,6 +118,9 @@ class SynthQC: deamidation_sites: list[str] = field(default_factory=list) # e.g. ["N3G", "Q5S"] isomerization_sites: list[str] = field(default_factory=list) # e.g. ["D2G", "D9S"] + # N-terminal pyroglutamate cyclization + pyroglutamate_risk: bool = False # N-terminal Q or E → pGlu (activity loss) + # Tryptophan photolability tryptophan_count: int = 0 trp_photolability_risk: bool = False # ≥ 3 Trp → photooxidation risk under assay lighting @@ -169,6 +172,7 @@ def to_dict(self) -> dict: "chymotrypsin_sites": self.chymotrypsin_sites, "deamidation_sites": self.deamidation_sites, "isomerization_sites": self.isomerization_sites, + "pyroglutamate_risk": self.pyroglutamate_risk, "tryptophan_count": self.tryptophan_count, "trp_photolability_risk": self.trp_photolability_risk, "has_uv_chromophore": self.has_uv_chromophore, @@ -237,6 +241,15 @@ def check_sequence(candidate_id: str, seq: str, mu_h: float = 0.0) -> SynthQC: for m in _ISOMERIZATION_RE.finditer(seq) ] + # N-terminal pyroglutamate cyclization: Q at position 1 cyclises spontaneously to + # pyroglutamic acid (pGlu) at physiological pH (t½ hours–days at 37°C, 5–50× MIC loss). + # The same ring-closure occurs for E1 but requires acid catalysis (pH 2–4); at pH 7.4 the + # E1 rate is negligible (t½ months–years) and is not flagged here to avoid overcalling + # synthesis_difficulty. Nα-acetylation (zero synthesis cost) locks the free amine and + # completely blocks Q1 cyclisation. Literature: Chelius et al. (2006) Anal Chem 78:2370; + # Goolcharran & Borchardt (1998) J Pharm Sci 87:425. + qc.pyroglutamate_risk = len(seq) > 0 and seq[0] == "Q" + # 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 @@ -359,6 +372,13 @@ def check_sequence(candidate_id: str, seq: str, mu_h: float = 0.0) -> SynthQC: 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.pyroglutamate_risk: + qc.flags.append( + "PYROGLUTAMATE_RISK: N-terminal Q cyclises to pGlu (t½ hours–days at 37°C, pH 7.4); " + "blocks N-terminus, typically 5–50× MIC loss — specify N-terminal Ac (Nα-acetylation) " + "in synthesis order (zero extra cost) to prevent cyclisation; or substitute Q1 → K1/R1 " + "(note: K1/R1 adds a trypsin site; only use if serum stability is not gated)" + ) 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: diff --git a/tests/test_presynth_qc.py b/tests/test_presynth_qc.py index 439d5b91..6d48f1d8 100644 --- a/tests/test_presynth_qc.py +++ b/tests/test_presynth_qc.py @@ -107,6 +107,59 @@ def test_met_flag_in_flags_list(self): assert "Nle" in met_flags[0], "Met flag must recommend Nle substitution (regression guard for PR #102)" +# --------------------------------------------------------------------------- +# Pyroglutamate risk +# --------------------------------------------------------------------------- + +class TestPyroglutamate: + def test_q_terminal_sets_risk(self): + qc = check_sequence("q", "QRLMKKIGSAIKFL") + assert qc.pyroglutamate_risk + + def test_no_risk_for_all_non_q_starts(self): + # All canonical AAs except Q — includes E (negligible at pH 7.4, not flagged) + non_q = "ACDEFGHIKLMNPRSTUVWY" + for aa in non_q: + if aa == "U": + continue # selenocysteine not canonical here + qc = check_sequence("x", aa + "RLMKKIGSAIKFL") + assert not qc.pyroglutamate_risk, f"False positive for N-terminal {aa}" + + def test_e_terminal_does_not_set_risk(self): + # E→pGlu requires acid catalysis (pH 2–4); negligible at physiological pH + qc = check_sequence("e", "ERLMKKIGSAIKFL") + assert not qc.pyroglutamate_risk + + def test_pyroglutamate_flag_emitted_for_q(self): + qc = check_sequence("q", "QRLMKKIGSAIKFL") + pglu_flags = [f for f in qc.flags if "PYROGLUTAMATE" in f] + assert len(pglu_flags) == 1 + assert "pGlu" in pglu_flags[0] + assert "Nα-acetylation" in pglu_flags[0] + assert "hours–days" in pglu_flags[0] + + def test_pyroglutamate_not_flagged_for_internal_q(self): + # Internal Q triggers deamidation (if followed by G/S), not pyroglutamate + qc = check_sequence("x", "KQRLMKKIGSAIKFL") + assert not qc.pyroglutamate_risk + + def test_q_with_qg_motif_gets_both_pyroglutamate_and_deamidation_flags(self): + # QGKLRK: Q1 → pyroglutamate risk AND Q1G deamidation (two independent mechanisms) + qc = check_sequence("qq", "QGKLRK") + assert qc.pyroglutamate_risk + assert any("Q1G" in s for s in qc.deamidation_sites) + pglu_flags = [f for f in qc.flags if "PYROGLUTAMATE" in f] + deam_flags = [f for f in qc.flags if "DEAMIDATION" in f] + assert len(pglu_flags) == 1 + assert len(deam_flags) == 1 + + def test_pyroglutamate_in_to_dict(self): + qc = check_sequence("q", "QRLMKKIGSAIKFL") + d = qc.to_dict() + assert "pyroglutamate_risk" in d + assert d["pyroglutamate_risk"] is True + + # --------------------------------------------------------------------------- # Trypsin/chymotrypsin sites # ---------------------------------------------------------------------------