Skip to content

Commit 1278eda

Browse files
jamesmkriegerclaude
andcommitted
Add unit tests for calcPerturbResponse directional responses
Exercises the return_vectors ModeEnsemble on the adenylate kinase open->closed transition (4ake chain A -> 1ake, bundled datafiles): ensemble shape, cov.f correctness, squared-magnitude eigenvalues, perturb_node restriction, the GNM 3d-only guard, and that a directed force recovers the closure better than any single ANM mode. Legacy three-tuple return is checked for back-compatibility. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 12ff267 commit 1278eda

1 file changed

Lines changed: 117 additions & 0 deletions

File tree

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""This module contains unit tests for :func:`.calcPerturbResponse`, in
2+
particular the directional ``return_vectors`` response ensemble, exercised on
3+
the adenylate kinase open->closed transition (4ake chain A -> 1ake chain A)."""
4+
5+
import numpy as np
6+
from numpy.testing import assert_allclose
7+
8+
from prody import (ANM, GNM, matchChains, superpose,
9+
calcDeformVector, calcOverlap, calcPerturbResponse, LOGGER)
10+
from prody.dynamics.signature import ModeEnsemble
11+
from prody.tests import unittest
12+
from prody.tests.datafiles import parseDatafile
13+
14+
LOGGER.verbosity = 'none'
15+
16+
17+
class TestCalcPerturbResponse(unittest.TestCase):
18+
19+
@classmethod
20+
def setUpClass(cls):
21+
# open (4ake, chain A) and closed (1ake) adenylate kinase; bundled data
22+
op = parseDatafile('pdb4ake_fixed').select('calpha')
23+
cl = parseDatafile('pdb1ake').select('calpha')
24+
matches = matchChains(op, cl, pwalign=True, seqid=90, overlap=90)
25+
op_ca, cl_ca = matches[0][0], matches[0][1]
26+
27+
# closure target: open->closed internal deformation (after superposition)
28+
cl_fit = cl_ca.copy()
29+
superpose(cl_fit, op_ca)
30+
cls.target = calcDeformVector(op_ca, cl_fit)
31+
32+
cls.n_atoms = op_ca.numAtoms()
33+
cls.anm = ANM('4ake_A')
34+
cls.anm.buildHessian(op_ca)
35+
cls.anm.calcModes(n_modes=None, zeros=False)
36+
cls.gnm = GNM('4ake_A')
37+
cls.gnm.buildKirchhoff(op_ca)
38+
cls.gnm.calcModes(n_modes=None, zeros=False)
39+
40+
def testLegacyReturnsTriple(self):
41+
"""Default call is unchanged: (matrix, effectiveness, sensitivity)."""
42+
prs, eff, sens = calcPerturbResponse(self.anm)
43+
self.assertEqual(prs.shape, (self.n_atoms, self.n_atoms))
44+
self.assertEqual(eff.shape, (self.n_atoms,))
45+
self.assertEqual(sens.shape, (self.n_atoms,))
46+
47+
def testReturnVectorsEnsembleShape(self):
48+
"""return_vectors gives a ModeEnsemble: one modeset/node, repeats modes."""
49+
repeats = 7
50+
ens = calcPerturbResponse(self.anm, return_vectors=True, repeats=repeats)
51+
self.assertIsInstance(ens, ModeEnsemble)
52+
self.assertEqual(ens.numModeSets(), self.n_atoms)
53+
self.assertEqual(ens.numModes(), repeats)
54+
self.assertEqual(ens.numAtoms(), self.n_atoms)
55+
56+
def testResponseVectorEqualsCovDotForce(self):
57+
"""The kept response vector is exactly cov . f (no square/average)."""
58+
cov = self.anm.getCovariance()
59+
node = 5
60+
force = np.array([1.0, -2.0, 0.5])
61+
ens = calcPerturbResponse(self.anm, return_vectors=True,
62+
perturb_node=node, force=force)
63+
self.assertEqual(ens.numModeSets(), 1)
64+
self.assertEqual(ens.numModes(), 1)
65+
resp = np.asarray(ens[0].getEigvecs()).flatten()
66+
expected = np.dot(cov[:, node * 3:node * 3 + 3], force)
67+
assert_allclose(resp, expected, rtol=1e-6, atol=1e-8)
68+
69+
def testEigvalsAreSquaredResponseMagnitude(self):
70+
"""Eigenvalues store the squared response magnitude of each force."""
71+
cov = self.anm.getCovariance()
72+
node = 3
73+
force = np.array([0.3, 0.4, 0.0])
74+
ens = calcPerturbResponse(self.anm, return_vectors=True,
75+
perturb_node=node, force=force)
76+
expected = np.dot(cov[:, node * 3:node * 3 + 3], force)
77+
eigval = np.atleast_1d(ens[0].getEigvals())[0]
78+
assert_allclose(eigval, (expected ** 2).sum(), rtol=1e-6, atol=1e-8)
79+
80+
def testPerturbNodeList(self):
81+
"""perturb_node restricts the scan to the requested nodes."""
82+
nodes = [0, 4, 9]
83+
ens = calcPerturbResponse(self.anm, return_vectors=True,
84+
repeats=3, perturb_node=nodes)
85+
self.assertEqual(ens.numModeSets(), len(nodes))
86+
87+
def testGNMRaisesForVectors(self):
88+
"""Directional responses require a 3d model; GNM has no direction."""
89+
self.assertRaises(ValueError, calcPerturbResponse, self.gnm,
90+
return_vectors=True)
91+
92+
def testDirectionalPRSRecoversClosure(self):
93+
"""A directed force at some node drives the open->closed closure: the
94+
best response overlaps the transition better than any single ANM mode."""
95+
np.random.seed(42)
96+
ens = calcPerturbResponse(self.anm, return_vectors=True, repeats=100)
97+
tv = self.target.getArray()
98+
tv = tv / np.linalg.norm(tv)
99+
100+
best_per_node = []
101+
for i in range(ens.numModeSets()):
102+
R = np.asarray(ens[i].getEigvecs())
103+
if R.shape[0] != tv.shape[0]:
104+
R = R.T
105+
R = R / np.linalg.norm(R, axis=0, keepdims=True)
106+
best_per_node.append(np.abs(tv @ R).max())
107+
best_directional = float(np.max(best_per_node))
108+
109+
best_single_mode = float(np.abs(calcOverlap(self.anm[:20], self.target)).max())
110+
111+
# closure is genuinely recovered, and beats the best single normal mode
112+
self.assertGreater(best_directional, 0.6)
113+
self.assertGreater(best_directional, best_single_mode)
114+
115+
116+
if __name__ == '__main__':
117+
unittest.main()

0 commit comments

Comments
 (0)