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
39 changes: 39 additions & 0 deletions src/alphajudge/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,45 @@ def _lis_dir(idx_src: np.ndarray, idx_dst: np.ndarray) -> float:
b = _lis_dir(self._idx2, self._idx1)
return float(0.5 * (a + b))

def clis(self) -> float:
"""
Contact-restricted LIS (cLIS): the LIS PAE transform averaged only over
residue pairs that are also in direct physical contact (representative
atom, CB-else-CA, within contact_thresh; default Cβ-Cβ <= 8 A). Both
chain directions are scored separately and averaged, mirroring lis().
Returns 0.0 when no contacts or no valid PAE pairs.
"""
if not self._pairs:
return 0.0
ab: list[float] = []
ba: list[float] = []
for r1, r2 in self._pairs:
i = self._rim.get((r1.get_parent().id, r1.id))
j = self._rim.get((r2.get_parent().id, r2.id))
if i is None or j is None:
continue
p_ab = float(self._pae[i, j])
p_ba = float(self._pae[j, i])
if p_ab < 12.0:
ab.append((12.0 - p_ab) / 12.0)
if p_ba < 12.0:
ba.append((12.0 - p_ba) / 12.0)
a = float(np.mean(ab)) if ab else 0.0
b = float(np.mean(ba)) if ba else 0.0
return float(0.5 * (a + b))

def ilis(self) -> float:
"""
Integrated LIS (iLIS = sqrt(LIS * cLIS); Kim et al., AFM-LIS). The
geometric mean forces the score to 0 unless the interface has both broad
PAE confidence (LIS) and confident direct contacts (cLIS).
"""
lis = self.lis()
clis = self.clis()
if lis <= 0.0 or clis <= 0.0:
return 0.0
return float(math.sqrt(lis * clis))

@property
def polar(self) -> float:
return self._frac(POLAR_RES)
Expand Down
2 changes: 2 additions & 0 deletions src/alphajudge/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ def process(
"interface_pDockQ2": pd2,
"interface_ipSAE": iface.ipsae(),
"interface_LIS": iface.lis(),
"interface_cLIS": iface.clis(),
"interface_iLIS": iface.ilis(),
}

# Add expensive metrics only if not skipped
Expand Down
58 changes: 58 additions & 0 deletions test/test_parsers_and_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
"interface_pDockQ2",
"interface_ipSAE",
"interface_LIS",
"interface_cLIS",
"interface_iLIS",
"interface_hb",
"interface_sb",
"interface_ss",
Expand Down Expand Up @@ -332,6 +334,62 @@ def test_af2_runner_outputs_have_expected_scores(tmp_path: Path, af2_dir_src: Pa
assert nearly_equal(got_iptm_ptm, float(exp_ptm)), f"AF2 monomer iptm_ptm should be NaN or ptm for {m}"


class _FakeResidue:
"""Minimal hashable Bio.PDB-like residue with .id and .get_parent().id."""

def __init__(self, chain_id: str, res_id: Any):
self.id = res_id
self._parent = type("_Chain", (), {"id": chain_id})()

def get_parent(self):
return self._parent


def _make_residue(chain_id: str, res_id: Any) -> _FakeResidue:
return _FakeResidue(chain_id, res_id)


def test_clis_ilis_math_is_deterministic():
"""
cLIS restricts the LIS PAE transform to contacting residue pairs and iLIS is
the geometric mean sqrt(LIS * cLIS) (AFM-LIS, Kim et al.). Build a tiny 2+2
complex with a known PAE matrix so the arithmetic is checkable by hand.
"""
from alphajudge.interface import Interface

a1, a2 = _make_residue("A", (" ", 1, " ")), _make_residue("A", (" ", 2, " "))
b1, b2 = _make_residue("B", (" ", 1, " ")), _make_residue("B", (" ", 2, " "))

# PAE indices: A1->0, A2->1, B1->2, B2->3. Intra-chain PAE is irrelevant.
pae = np.array(
[
[0.0, 0.0, 0.0, 6.0],
[0.0, 0.0, 24.0, 24.0],
[0.0, 24.0, 0.0, 0.0],
[6.0, 24.0, 0.0, 0.0],
]
)

iface = object.__new__(Interface) # bypass __init__; set only what we exercise
iface._pae = pae
iface._idx1 = np.array([0, 1])
iface._idx2 = np.array([2, 3])
iface._rim = {("A", a1.id): 0, ("A", a2.id): 1, ("B", b1.id): 2, ("B", b2.id): 3}
iface._pairs = {(a1, b1)} # only A1-B1 is in physical contact

# LIS: A->B valid entries (12-pae)/12 = [1.0, 0.5]; B->A = [1.0, 0.5]; mean 0.75.
assert nearly_equal(iface.lis(), 0.75)
# cLIS: only the A1-B1 contact, pae 0 both directions -> 1.0.
assert nearly_equal(iface.clis(), 1.0)
# iLIS = sqrt(0.75 * 1.0).
assert nearly_equal(iface.ilis(), math.sqrt(0.75))

# No contacts -> cLIS 0 -> iLIS 0 regardless of LIS.
iface._pairs = set()
assert iface.clis() == 0.0
assert iface.ilis() == 0.0


# -------------------------
# AF3 runner: best/all + score checks
# -------------------------
Expand Down
Loading