diff --git a/docs/WET_LAB_HANDOFF.md b/docs/WET_LAB_HANDOFF.md index d8938f87..44031249 100644 --- a/docs/WET_LAB_HANDOFF.md +++ b/docs/WET_LAB_HANDOFF.md @@ -64,6 +64,8 @@ before ordering. The report flags: | `MET×N` | Met oxidation to sulfoxide in air | Order with >95% purity; store at −80°C; re-check purity on thaw | | `HYDROPHOBIC_RUN (XXXX)` | Aggregation; poor aqueous solubility | Test solubility at 1 mM in PBS before assay; add 0.1% DMSO if needed | | `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 | | `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 | diff --git a/src/openamp_foundry/qc/presynth_check.py b/src/openamp_foundry/qc/presynth_check.py index 132f39a1..a7da0a03 100644 --- a/src/openamp_foundry/qc/presynth_check.py +++ b/src/openamp_foundry/qc/presynth_check.py @@ -116,6 +116,13 @@ class SynthQC: c_amidation_recommended: bool = False # request NH₂ C-terminus instead of COOH c_amidation_reason: str = "" + # N-terminal acetylation + n_acetylation_recommended: bool = False # request Nα-Ac when interior trypsin sites exist + n_acetylation_reason: str = "" + + # Wave 2 D-amino acid substitution guidance + wave2_d_substitutions: list[str] = field(default_factory=list) + # Overall flag flags: list[str] = field(default_factory=list) synthesis_difficulty: str = "LOW" # LOW / MODERATE / HIGH @@ -143,6 +150,9 @@ def to_dict(self) -> dict: "hemolysis_start_conc": self.hemolysis_start_conc, "c_amidation_recommended": self.c_amidation_recommended, "c_amidation_reason": self.c_amidation_reason, + "n_acetylation_recommended": self.n_acetylation_recommended, + "n_acetylation_reason": self.n_acetylation_reason, + "wave2_d_substitutions": self.wave2_d_substitutions, "flags": self.flags, "synthesis_difficulty": self.synthesis_difficulty, } @@ -235,6 +245,35 @@ def check_sequence(candidate_id: str, seq: str, mu_h: float = 0.0) -> SynthQC: "amidation optional — consider for serum stability improvement." ) + # N-terminal acetylation recommendation. + # Nα-Ac blocks aminopeptidase-mediated N-terminal degradation (exopeptidase entry + # blocked). Recommend whenever interior trypsin sites exist — after trypsin exposure, + # the resulting N-terminal fragments become substrates for aminopeptidases. + # Synthesis cost: zero — specify "N-terminal acetylation (Ac-)" in synthesis order. + # Literature: Creighton (1993); Dhankhar et al. (2023). + if len(qc.trypsin_sites) > 0: + qc.n_acetylation_recommended = True + site_positions = [p + 1 for p in qc.trypsin_sites] + qc.n_acetylation_reason = ( + f"{len(qc.trypsin_sites)} interior trypsin site(s) detected at position(s) " + f"{site_positions}. N-terminal acetylation (Nα-Ac) blocks aminopeptidase-mediated " + "N-terminal degradation (exopeptidase entry blocked). Specify 'N-terminal acetylation " + "(Ac-)' in synthesis order — zero extra cost. " + "Literature: Creighton (1993); Dhankhar et al. (2023)." + ) + + # Wave 2 D-amino acid substitution guidance. + # For each interior trypsin cleavage site, D-Lys or D-Arg substitution blocks trypsin + # (requires L-stereochemistry) and extends serum t½ by an estimated 3–10×. + # Only the 3 most N-terminal sites are listed (highest-priority targets). + # Literature: Wade et al. 1990 PNAS. + top_sites = sorted(qc.trypsin_sites)[:3] + qc.wave2_d_substitutions = [ + f"Position {p + 1} ({seq[p]} → D-{'Arg' if seq[p] == 'R' else 'Lys'}): " + "eliminates trypsin cleavage site; extend serum t½ by estimated 3–10×" + for p in top_sites + ] + # Collect flags if qc.cysteine_count > 0: qc.flags.append(f"CYSTEINE×{qc.cysteine_count}: disulfide/oxidation risk — store under N₂") @@ -256,7 +295,9 @@ def check_sequence(candidate_id: str, seq: str, mu_h: float = 0.0) -> SynthQC: if qc.length > 30: qc.flags.append(f"LONG_PEPTIDE ({qc.length}aa): SPPS yield risk; consider native chemical ligation") - # Synthesis difficulty rating + # Synthesis difficulty rating is based on synthesis-risk flags only. + # Guidance flags (N_ACETYLATION_RECOMMENDED, WAVE2_D_AMINO) are informational and + # carry zero synthesis cost — they are appended after the difficulty is set. n_flags = len(qc.flags) if n_flags == 0: qc.synthesis_difficulty = "LOW" @@ -265,6 +306,18 @@ 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). + if qc.n_acetylation_recommended: + qc.flags.append( + "N_ACETYLATION_RECOMMENDED: specify 'Ac-' (N-terminal acetyl) in synthesis order " + "(blocks aminopeptidase entry; zero cost)" + ) + if qc.wave2_d_substitutions: + qc.flags.append( + f"WAVE2_D_AMINO: {len(qc.wave2_d_substitutions)} trypsin site(s) identified for " + "D-amino acid substitution in Wave 2 (see wave2_d_substitutions)" + ) + return qc diff --git a/tests/test_presynth_qc.py b/tests/test_presynth_qc.py index b799f277..f88c337f 100644 --- a/tests/test_presynth_qc.py +++ b/tests/test_presynth_qc.py @@ -457,3 +457,88 @@ def test_empty_sequence_no_crash(self): qc = check_sequence("empty", "") # Empty sequence: no C-term → c_amidation_recommended should be False (default) assert isinstance(qc.c_amidation_recommended, bool) + + +# --------------------------------------------------------------------------- +# Wave 2 guidance (N-acetylation + D-amino substitution) +# --------------------------------------------------------------------------- + +class TestWave2Guidance: + def test_n_acetylation_recommended_when_interior_trypsin_sites(self): + # K/R at 0-based pos 0,1,5,7,8 (regex excludes pos 10=C-terminal G; all K/R here are considered interior) + qc = check_sequence("w1", "RRWQWRMKKLG") + assert qc.n_acetylation_recommended is True + + def test_n_acetylation_not_recommended_when_no_interior_sites(self): + # "FLPLIGAVLSGIL" contains no K or R → no trypsin sites + qc = check_sequence("w2", "FLPLIGAVLSGIL") + assert qc.n_acetylation_recommended is False + + def test_d_amino_guidance_lists_all_trypsin_positions(self): + # "RRWQWRMKKLG" has 5 interior sites; entries must reference D- substitutions + qc = check_sequence("w3", "RRWQWRMKKLG") + assert len(qc.wave2_d_substitutions) > 0 + assert all("→ D-" in entry for entry in qc.wave2_d_substitutions) + + def test_d_amino_guidance_empty_for_no_trypsin_sites(self): + qc = check_sequence("w4", "FLPLIGAVLSGIL") + assert qc.wave2_d_substitutions == [] + + def test_d_amino_guidance_capped_at_3_positions(self): + # "RRWQWRMKKLG" has 5 interior trypsin sites → only top 3 returned + qc = check_sequence("w5", "RRWQWRMKKLG") + assert len(qc.wave2_d_substitutions) == 3 + + def test_n_acetylation_flag_appears_in_flags_list(self): + qc = check_sequence("w6", "RRWQWRMKKLG") + acetyl_flags = [f for f in qc.flags if "N_ACETYLATION_RECOMMENDED" in f] + assert len(acetyl_flags) == 1 + + def test_wave2_d_amino_flag_appears_in_flags_list(self): + qc = check_sequence("w7", "RRWQWRMKKLG") + d_flags = [f for f in qc.flags if "WAVE2_D_AMINO" in f] + assert len(d_flags) == 1 + + def test_to_dict_contains_new_fields(self): + qc = check_sequence("w8", "RRWQWRMKKLG") + d = qc.to_dict() + assert "n_acetylation_recommended" in d + assert "n_acetylation_reason" in d + assert "wave2_d_substitutions" in d + + def test_d_lys_for_interior_lys(self): + # "KKKAAA" has K at positions 0, 1, 2 — all interior → all D-Lys guidance + qc = check_sequence("w9", "KKKAAA") + assert any("D-Lys" in entry for entry in qc.wave2_d_substitutions) + + def test_d_arg_for_interior_arg(self): + # "RRRAAA" has R at positions 0, 1, 2 — all interior → all D-Arg guidance + qc = check_sequence("w10", "RRRAAA") + assert any("D-Arg" in entry for entry in qc.wave2_d_substitutions) + + def test_n_acetylation_not_recommended_when_only_terminal_kr(self): + # C-terminal K is excluded by trypsin regex (?=.) lookahead + guard + qc = check_sequence("w_cterm_only", "AAAAK") + assert qc.n_acetylation_recommended is False + assert qc.wave2_d_substitutions == [] + assert not any("N_ACETYLATION" in f for f in qc.flags) + + def test_d_amino_guidance_positions_are_1_indexed(self): + # RRWQWRMKKLG: internal K/R at 0-based positions 0,1,5,7,8 + # Top 3 (most N-terminal) = positions 0,1,5 → displayed as 1,2,6 + qc = check_sequence("w_pos_1idx", "RRWQWRMKKLG") + assert len(qc.wave2_d_substitutions) == 3 + assert "Position 1" in qc.wave2_d_substitutions[0] + assert "Position 2" in qc.wave2_d_substitutions[1] + assert "Position 6" in qc.wave2_d_substitutions[2] + + def test_empty_sequence_no_crash_wave2(self): + qc = check_sequence("w_empty", "") + assert qc.n_acetylation_recommended is False + assert qc.wave2_d_substitutions == [] + + def test_n_acetylation_reason_populated_when_recommended(self): + qc = check_sequence("w_reason", "RRWQWRMKKLG") + assert qc.n_acetylation_recommended is True + assert len(qc.n_acetylation_reason) > 0 + assert "acetylat" in qc.n_acetylation_reason.lower()