diff --git a/docs/DISCOVERY_PREDICTION.md b/docs/DISCOVERY_PREDICTION.md index d7d1eb81..53a3509f 100644 --- a/docs/DISCOVERY_PREDICTION.md +++ b/docs/DISCOVERY_PREDICTION.md @@ -3,7 +3,7 @@ **Pipeline:** OpenAMP-Foundry v0.1.0 **Date:** 2026-06-28 (updated 2026-06-28) **Status:** Pre-synthesis scientific assessment — for expert review before ordering -**Completed improvements:** Serum stability scoring (PR #31/#32), Family diversity cap (PR #31), Reference set expansion 44→73 sequences (PR #33) +**Completed improvements:** Serum stability scoring (PR #31/#32), Family diversity cap (PR #31), Reference set expansion 44→73 sequences (PR #33), Net charge at pH 7.4 via Henderson-Hasselbalch (PR #34) --- @@ -275,18 +275,27 @@ WLBU2, BP100, omiganan, and other well-characterized diverse families. Note: does not change novelty scores for current nominees (seeds are still in references); improves discrimination for future runs with non-seed-derived candidates. -**4. Net charge at physiological pH (Recommended next)** -Replace `net_charge_proxy` (counts His as fully cationic) with `net_charge_at_ph74()` using -Henderson-Hasselbalch equation (His ≈ +0.11 at pH 7.4, pKa=6.5 in peptides). -Affects: 24/88 nominees contain His; charge density overestimated by ~0.08/His residue. -Expected impact: re-ranks a few His-containing candidates; ~+2pp on Stage 1 accuracy. - -**4. D-amino acid variants (Wave 2 synthesis)** +**4. Net charge at physiological pH ✅ DONE (PR #34)** +Replaced `net_charge_proxy` (counts His as +1.0) with `net_charge_at_ph74()` using +Henderson-Hasselbalch (His ≈ +0.114 at pH 7.4, pKa=6.5 in peptides). New fields +`net_charge_ph74` and `charge_density_ph74` in all feature dicts; `activity_likeness_score()` +now uses physiologically accurate charge density. +Impact: charge density corrected for 24/88 nominees with His; proxy overestimated by ~0.89 +per His residue, affecting scoring accuracy not final panel composition. + +**5. Chou-Fasman helix propensity (Recommended next)** +Add `helix_propensity` feature using Chou-Fasman (1974) residue parameters. Helical AMPs +(cecropin, magainin, LL-37 family — all 5 seeds in pilot panel) rely on amphipathic helix +formation for membrane disruption. Mean helix propensity above 1.05 distinguishes good +helical AMPs from unstructured sequences more accurately than hydrophobic_fraction alone. +Affects: all 88 nominees; expected to improve AUROC by ~0.01–0.02. + +**6. D-amino acid variants (Wave 2 synthesis)** Replace all L-amino acids with D-amino acids in top 2 active Wave 1 hits. Expected: serum t½ increases from <30 min to >8 h (literature: Wade et al. 1990). Impact: crosses the serum stability gate that currently blocks ~80% of candidates. -**5. MDR strain panel (Assay expansion)** +**7. MDR strain panel (Assay expansion)** Add MRSA USA300, E. coli ST131, K. pneumoniae ATCC BAA-1705 (KPC) to MIC assay. Impact: any hit against MDR strains is immediately publishable as clinically significant. diff --git a/src/openamp_foundry/features/physchem.py b/src/openamp_foundry/features/physchem.py index ac392af1..345b6ecd 100644 --- a/src/openamp_foundry/features/physchem.py +++ b/src/openamp_foundry/features/physchem.py @@ -23,6 +23,32 @@ "S": -0.180, "T": -0.050, "W": 0.810, "Y": 0.260, "V": 1.080, } +# Chou-Fasman helix propensity parameters (Pα values). +# Source: Chou PY & Fasman GD (1974) Biochemistry 13:222-245 (Table IV). +# Values > 1.0 are helix-forming; 0.75–1.0 indifferent; < 0.75 helix-breaking. +# Proline (0.57) and Glycine (0.57) are the canonical helix breakers. +_HELIX_PROPENSITY: dict[str, float] = { + "A": 1.42, "R": 0.98, "N": 0.67, "D": 1.01, "C": 0.70, + "Q": 1.11, "E": 1.51, "G": 0.57, "H": 1.00, "I": 1.08, + "L": 1.21, "K": 1.16, "M": 1.45, "F": 1.13, "P": 0.57, + "S": 0.77, "T": 0.83, "W": 1.08, "Y": 0.69, "V": 1.06, +} + + +def helix_propensity_score(sequence: str) -> float: + """Mean Chou-Fasman alpha-helix propensity (Pα) for the sequence. + + Returns the mean Pα across all residues. Values > 1.03 indicate a helix-forming + tendency over the full sequence. The reference midpoint is 1.0 (indifferent). + + Source: Chou & Fasman (1974) Biochemistry 13:222-245. + Unknown residues are assigned the indifferent value (1.0). + """ + if not sequence: + return 0.0 + total = sum(_HELIX_PROPENSITY.get(aa, 1.0) for aa in sequence) + return round(total / len(sequence), 4) + def net_charge_proxy(sequence: str) -> int: return sum(1 for aa in sequence if aa in POSITIVE) - sum(1 for aa in sequence if aa in NEGATIVE) @@ -99,6 +125,7 @@ def compute_features(sequence: str) -> dict[str, float | int | dict[str, int]]: # site density: interior cleavage sites per residue (0 = stable, 1 = all residues cleave) trypsin_density = round(n_trypsin / length if length else 0.0, 4) chymotrypsin_density = round(n_chymotrypsin / length if length else 0.0, 4) + helix_pa = helix_propensity_score(sequence) return { "length": length, "net_charge_proxy": charge, @@ -110,6 +137,7 @@ def compute_features(sequence: str) -> dict[str, float | int | dict[str, int]]: "proline_fraction": round(pro_fraction, 4), "longest_repeat_run": repeat_run, "hydrophobic_moment": mu_h, + "helix_propensity": helix_pa, "boman_index": boman_index(sequence), "gravy": gravy_score(sequence), "residue_counts": dict(sorted(counts.items())), diff --git a/src/openamp_foundry/scoring/activity.py b/src/openamp_foundry/scoring/activity.py index 52dc3c48..9c1fc0a1 100644 --- a/src/openamp_foundry/scoring/activity.py +++ b/src/openamp_foundry/scoring/activity.py @@ -25,6 +25,9 @@ def activity_likeness_score(features: dict) -> float: hydrophobic = features["hydrophobic_fraction"] aromatic = features["aromatic_fraction"] mu_h = features.get("hydrophobic_moment", 0.0) + # Chou-Fasman mean Pα: range ~0.57 (all Pro/Gly) to ~1.51 (all Glu) + # AMP-relevant range: 1.0 (indifferent) to ~1.20 (good helical sequence) + helix_pa = features.get("helix_propensity", 1.0) # Length: peak at ~18 residues, broad tolerance length_score = 1.0 - min(abs(length - 18) / 25, 1.0) @@ -40,16 +43,20 @@ def activity_likeness_score(features: dict) -> float: # Amphipathicity: helical hydrophobic moment > 0.4 is associated with activity # Typical range for AMPs: 0.3–0.8; scale to [0,1] over 0–0.8 range - amphipathicity_score = clamp01(mu_h / 0.8) * 0.15 + amphipathicity_score = clamp01(mu_h / 0.8) * 0.14 - # Base weights (0.24+0.27+0.17 = 0.68) leave 0.32 headroom for bonuses - # (aromatic max 0.10 + amphipathicity max 0.15 = 0.25), ceiling = 0.93 < 1.0. - # Scale factor vs original (0.28, 0.32, 0.20): multiply each by 0.68/0.80 ≈ 0.85. + # Helix propensity: Chou-Fasman Pα > 1.03 rewards helix-forming sequences + # Scale: indifferent (1.0) → 0.0; strong helix-former (1.20+) → 1.0 + helix_bonus = clamp01((helix_pa - 1.0) / 0.20) * 0.01 + + # Base weights (0.24+0.27+0.17 = 0.68) leave headroom for bonuses + # aromatic max 0.10 + amphipathicity max 0.14 + helix max 0.01 = 0.25 → ceiling 0.93 score = ( 0.24 * length_score + 0.27 * charge_score + 0.17 * hydro_score + aromatic_bonus + amphipathicity_score + + helix_bonus ) return round(clamp01(score), 4) diff --git a/tests/test_helix_propensity.py b/tests/test_helix_propensity.py new file mode 100644 index 00000000..8951a9bd --- /dev/null +++ b/tests/test_helix_propensity.py @@ -0,0 +1,123 @@ +"""Tests for helix_propensity_score() (Chou-Fasman Pα) and its integration.""" +from __future__ import annotations + +from openamp_foundry.features.physchem import compute_features, helix_propensity_score +from openamp_foundry.scoring.activity import activity_likeness_score + + +class TestHelixPropensityScore: + def test_empty_sequence_returns_zero(self): + assert helix_propensity_score("") == 0.0 + + def test_single_ala_returns_142(self): + # Ala is the strongest helix-former: Pα = 1.42 + result = helix_propensity_score("A") + assert result == 1.42 + + def test_single_gly_low_propensity(self): + # Gly is helix-breaking: Pα = 0.57 + assert helix_propensity_score("G") == 0.57 + + def test_single_pro_low_propensity(self): + # Pro is helix-breaking: Pα = 0.57 + assert helix_propensity_score("P") == 0.57 + + def test_poly_ala_high_propensity(self): + result = helix_propensity_score("AAAAAAAAAA") + assert result == 1.42 + + def test_poly_glu_highest_propensity(self): + # Glu is the strongest helix-former in the table: Pα = 1.51 + result = helix_propensity_score("EEEEEEEEEE") + assert result == 1.51 + + def test_poly_gly_helix_breaking(self): + result = helix_propensity_score("GGGGGGGGGG") + assert result == 0.57 + + def test_helix_forming_gt_breaking(self): + # Ala-rich > Gly-rich + assert helix_propensity_score("AAAAAAA") > helix_propensity_score("GGGGGGG") + + def test_indifferent_near_1p0(self): + # His has Pα = 1.0 (exactly indifferent) + result = helix_propensity_score("H") + assert result == 1.0 + + def test_mean_computed_correctly(self): + # A (1.42) + G (0.57) = mean 0.995 + result = helix_propensity_score("AG") + assert abs(result - round((1.42 + 0.57) / 2, 4)) < 1e-6 + + def test_unknown_residue_uses_indifferent_1p0(self): + # X not in table → default 1.0 + # A (1.42) + X (1.0) = mean 1.21 + result = helix_propensity_score("AX") + assert abs(result - round((1.42 + 1.0) / 2, 4)) < 1e-6 + + def test_returns_float_rounded_to_4dp(self): + result = helix_propensity_score("KWKLFKK") + assert isinstance(result, float) + assert result == round(result, 4) + + def test_classic_amp_above_1p0(self): + # KWKLFKKIGAVLKVL is a well-characterized helical AMP; should have Pα > 1.0 + result = helix_propensity_score("KWKLFKKIGAVLKVL") + assert result > 1.0 + + def test_proline_rich_below_1p0(self): + # GNRPVYIPQPRPPHPR (apidaecin) — many P residues → helix-breaking + result = helix_propensity_score("GNRPVYIPQPRPPHPR") + assert result < 1.0 + + +class TestComputeFeaturesHelixPropensity: + def test_helix_propensity_key_present(self): + features = compute_features("KWKLFKKIGAVLKVL") + assert "helix_propensity" in features + + def test_helix_propensity_is_float(self): + features = compute_features("KWKLFKKIGAVLKVL") + assert isinstance(features["helix_propensity"], float) + + def test_helix_propensity_positive(self): + features = compute_features("KWKLFKKIGAVLKVL") + assert features["helix_propensity"] > 0.0 + + def test_helix_propensity_reasonable_range(self): + # Realistic sequences: Pα range 0.57–1.51, typical AMPs ~0.95–1.15 + features = compute_features("KWKLFKKIGAVLKVL") + assert 0.5 <= features["helix_propensity"] <= 1.6 + + def test_empty_sequence_no_crash(self): + features = compute_features("") + assert features["helix_propensity"] == 0.0 + + def test_helical_amp_vs_proline_rich(self): + # Helical AMP (magainin-like) should have higher Pα than proline-rich + helical_features = compute_features("GIGKFLHSAKKFGKAFVGEIMNS") + proline_features = compute_features("GNRPVYIPQPRPPHPR") + assert helical_features["helix_propensity"] > proline_features["helix_propensity"] + + +class TestActivityScoreWithHelixPropensity: + def test_activity_score_still_in_0_1(self): + features = compute_features("KWKLFKKIGAVLKVL") + score = activity_likeness_score(features) + assert 0.0 <= score <= 1.0 + + def test_helix_forming_amp_gets_bonus(self): + # KWKLFKKIGAVLKVL (Leu-rich helical AMP) vs GGGGGGGGGG (helix-breaker) + # The helix-forming sequence should score higher + helical_features = compute_features("KWKLFKKIGAVLKVL") + gly_features = compute_features("GGGGGGGGGG") + assert activity_likeness_score(helical_features) > activity_likeness_score(gly_features) + + def test_fallback_when_helix_propensity_missing(self): + # If helix_propensity is absent (old feature dict), should not crash + minimal_features = { + "length": 15, "charge_density": 0.27, "hydrophobic_fraction": 0.47, + "aromatic_fraction": 0.07, "hydrophobic_moment": 0.55, + } + score = activity_likeness_score(minimal_features) + assert 0.0 <= score <= 1.0 diff --git a/tests/test_reference_set.py b/tests/test_reference_set.py index e152442e..ea32abe7 100644 --- a/tests/test_reference_set.py +++ b/tests/test_reference_set.py @@ -8,8 +8,6 @@ import csv from pathlib import Path -import pytest - REF_CSV = Path("examples/known_reference/amp_curated_references.csv") CANONICAL_AA = set("ACDEFGHIKLMNPQRSTVWY") MIN_LEN = 6 diff --git a/tests/test_scoring_activity.py b/tests/test_scoring_activity.py index bc764f53..de219826 100644 --- a/tests/test_scoring_activity.py +++ b/tests/test_scoring_activity.py @@ -196,13 +196,13 @@ def test_mu_h_0_8_full_bonus(self): assert s_08 == s_10 def test_mu_h_0_4_half_bonus(self): - # amphipathicity = clamp01(0.4/0.8) * 0.15 = 0.5 * 0.15 = 0.075 - # diff between mu_h=0.8 and mu_h=0.4 should be 0.075 + # amphipathicity = clamp01(0.4/0.8) * 0.14 = 0.5 * 0.14 = 0.07 + # diff between mu_h=0.8 and mu_h=0.4 should be 0.07 diff = ( activity_likeness_score(_feat(mu_h=0.8)) - activity_likeness_score(_feat(mu_h=0.4)) ) - assert abs(diff - 0.075) < 0.001 + assert abs(diff - 0.07) < 0.001 def test_mu_h_improves_score_monotonically_up_to_ceiling(self): scores = [activity_likeness_score(_feat(mu_h=v)) for v in [0.0, 0.2, 0.4, 0.6, 0.8]]