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
27 changes: 18 additions & 9 deletions docs/DISCOVERY_PREDICTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

---

Expand Down Expand Up @@ -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.

Expand Down
28 changes: 28 additions & 0 deletions src/openamp_foundry/features/physchem.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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())),
Expand Down
15 changes: 11 additions & 4 deletions src/openamp_foundry/scoring/activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
123 changes: 123 additions & 0 deletions tests/test_helix_propensity.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 0 additions & 2 deletions tests/test_reference_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions tests/test_scoring_activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]]
Expand Down
Loading