|
| 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