Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/WET_LAB_HANDOFF.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
51 changes: 45 additions & 6 deletions src/openamp_foundry/qc/presynth_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,}")
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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 = (
Expand Down Expand Up @@ -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:
Expand All @@ -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 "
Expand Down
143 changes: 143 additions & 0 deletions tests/test_presynth_qc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading