File size: 16,568 Bytes
9591ffa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
984d8d5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
255dec1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
335978d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
"""
Unit tests for the MD simulation engine (no network; OpenMM only for the
thermostat test, which auto-skips when OpenMM is absent).

Covers:
- Kabsch RMSD correctness (rigid-body invariance, known displacements)
- Adaptive production length scaling (size-dependent, bounded)
- Radius of gyration (translation invariance, scaling, known geometry)
- Shrake–Ruger SASA (single atom, buried area, random-cloud bounds, rotation)
- Langevin thermostat reaches its target temperature (random data)
- Position conversion
- JSON-safe native conversion
"""

import numpy as np
import pytest

from app.tools.md_sim import (
    _kabsch_rmsd,
    _adaptive_production_steps,
    _positions_to_np,
    _radius_of_gyration,
    _sasa_shrake_ruger,
    _temperature_from_ke,
    _to_native,
    _PROBE_RADIUS_ANGSTROM,
)


def _rotz(a):
    c, s = np.cos(a), np.sin(a)
    return np.array([[c, -s, 0], [s, c, 0], [0, 0, 1]])


def _roty(a):
    c, s = np.cos(a), np.sin(a)
    return np.array([[c, 0, s], [0, 1, 0], [-s, 0, c]])


class TestKabschRMSD:
    def test_identical_coordinates(self):
        ref = np.random.RandomState(0).rand(100, 3) * 10
        assert _kabsch_rmsd(ref, ref.copy()) < 1e-9

    def test_rigid_body_invariance(self):
        """Rotation + translation must yield RMSD ~0 (Kabsch removes both)."""
        rng = np.random.RandomState(1)
        ref = rng.rand(80, 3) * 15
        R = _rotz(0.7) @ _roty(1.2) @ _rotz(0.3)
        mov = ref @ R.T + np.array([5.0, -3.0, 2.0])
        assert _kabsch_rmsd(ref, mov) < 1e-8

    def test_pure_translation_invariance(self):
        """A uniform translation alone must yield RMSD ~0."""
        rng = np.random.RandomState(7)
        ref = rng.rand(60, 3) * 10
        mov = ref + np.array([1.0, 2.0, -3.0])
        assert _kabsch_rmsd(ref, mov) < 1e-8

    def test_noise_upper_bound(self):
        """Adding noise must produce RMSD <= per-atom raw (un-aligned) RMSD, and > 0."""
        rng = np.random.RandomState(4)
        ref = rng.rand(100, 3) * 10
        noise = rng.normal(0, 0.5, ref.shape)
        mov = ref + noise
        # per-atom RMSD of un-aligned pair = sqrt(mean over ALL coords of noise^2) * sqrt(3)
        raw = float(np.sqrt((noise**2).mean())) * np.sqrt(3)
        got = _kabsch_rmsd(ref, mov)
        assert 0.0 < got <= raw * 1.001
        assert got > raw * 0.7  # optimal rotation shouldn't over-correct

    def test_single_atom(self):
        # A single atom always centers to the origin, so RMSD is 0.
        assert _kabsch_rmsd(np.array([[0.0, 0, 0]]), np.array([[1.0, 0, 0]])) < 1e-9

    def test_shape_mismatch_raises(self):
        with pytest.raises(ValueError):
            _kabsch_rmsd(np.zeros((3, 3)), np.zeros((4, 3)))

    def test_empty_input_returns_zero(self):
        assert _kabsch_rmsd(np.zeros((0, 3)), np.zeros((0, 3))) == 0.0


class TestAdaptiveProductionSteps:
    def test_small_protein_gets_target(self):
        assert _adaptive_production_steps(642) >= 100_000

    def test_larger_protein_gets_fewer_steps(self):
        steps_big = _adaptive_production_steps(30_000)
        steps_huge = _adaptive_production_steps(60_000)
        assert steps_big > steps_huge

    def test_never_exceeds_cap(self):
        assert _adaptive_production_steps(10) <= 1000 * 500  # 1 ns cap

    def test_never_below_floor(self):
        assert _adaptive_production_steps(1_000_000) >= 2 * 500

    def test_zero_atoms_returns_target(self):
        assert _adaptive_production_steps(0) == 250 * 500


class TestPositionConversion:
    def test_converts_openmm_like_positions(self):
        class P:
            def __init__(self, x, y, z):
                self.x, self.y, self.z = x, y, z

        # OpenMM positions are in nanometers; conversion must scale to Å (×10).
        positions = [P(1, 2, 3), P(4, 5, 6)]
        out = _positions_to_np(positions)
        assert out.shape == (2, 3)
        np.testing.assert_allclose(out[0], [10, 20, 30])
        np.testing.assert_allclose(out[1], [40, 50, 60])


class TestToNative:
    def test_converts_numpy_types(self):
        out = _to_native({"a": np.float32(1.5), "b": np.int64(3), "c": np.array([1.0, 2.0])})
        assert isinstance(out["a"], float)
        assert isinstance(out["b"], int)
        assert isinstance(out["c"], list)

    def test_nested_structures(self):
        out = _to_native([{"x": np.float64(1.0)}, [np.int32(2)]])
        assert isinstance(out[0]["x"], float)
        assert isinstance(out[1][0], int)


class TestRadiusOfGyration:
    def test_empty_input_zero(self):
        assert _radius_of_gyration(np.zeros((0, 3))) == 0.0

    def test_single_atom_zero(self):
        assert _radius_of_gyration(np.array([[1.0, 2.0, 3.0]])) == 0.0

    def test_known_geometry_cube_vertices(self):
        # Cube vertices at (±1, ±1, ±1): centroid at origin, every atom at distance √3.
        coords = np.array([[-1, -1, -1], [1, -1, -1], [-1, 1, -1], [-1, -1, 1],
                           [1, 1, 1], [-1, 1, 1], [1, -1, 1], [1, 1, -1]], dtype=float)
        assert abs(_radius_of_gyration(coords) - np.sqrt(3)) < 1e-9

    def test_translation_invariance(self):
        rng = np.random.default_rng(2)
        coords = rng.normal(size=(60, 3)) * 8
        shifted = coords + np.array([7.0, -4.0, 3.0])
        assert abs(_radius_of_gyration(coords) - _radius_of_gyration(shifted)) < 1e-9

    def test_scaling(self):
        rng = np.random.default_rng(5)
        coords = rng.normal(size=(40, 3)) * 5
        assert abs(_radius_of_gyration(2 * coords) - 2 * _radius_of_gyration(coords)) < 1e-9

    def test_matches_manual_formula(self):
        rng = np.random.default_rng(9)
        coords = rng.normal(size=(50, 3)) * 10
        com = coords.mean(axis=0)
        expected = float(np.sqrt(((coords - com) ** 2).sum(axis=1).mean()))
        assert abs(_radius_of_gyration(coords) - expected) < 1e-9


class TestSASA:
    def test_empty_input_zero(self):
        assert _sasa_shrake_ruger(np.zeros((0, 3)), np.zeros((0,))) == 0.0

    def test_single_atom_full_sphere(self):
        r = 1.7
        sasa = _sasa_shrake_ruger(np.array([[0.0, 0.0, 0.0]]), np.array([r]))
        expected = 4 * np.pi * (r + _PROBE_RADIUS_ANGSTROM) ** 2
        assert abs(sasa - expected) < expected * 0.02

    def test_overlap_buries_surface(self):
        # Two atoms 1 Å apart overlap heavily: surface must be between one and
        # two full spheres.
        r = 1.7
        coords = np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]])
        radii = np.array([r, r])
        sasa = _sasa_shrake_ruger(coords, radii)
        single = 4 * np.pi * (r + _PROBE_RADIUS_ANGSTROM) ** 2
        assert single < sasa < 2 * single

    def test_separated_atoms_sum_to_twice_single(self):
        r = 1.7
        d = 2 * (r + _PROBE_RADIUS_ANGSTROM) + 1.0  # well beyond interaction range
        coords = np.array([[0.0, 0.0, 0.0], [d, 0.0, 0.0]])
        radii = np.array([r, r])
        sasa = _sasa_shrake_ruger(coords, radii)
        single = 4 * np.pi * (r + _PROBE_RADIUS_ANGSTROM) ** 2
        assert abs(sasa - 2 * single) < single * 0.05

    def test_random_cloud_bounded(self):
        rng = np.random.default_rng(11)
        coords = rng.normal(size=(300, 3)) * 12
        radii = rng.choice([1.7, 1.55, 1.52, 1.2], size=300)
        sasa = _sasa_shrake_ruger(coords, radii)
        assert sasa > 0
        sphere_sum = 4 * np.pi * ((radii + _PROBE_RADIUS_ANGSTROM) ** 2).sum()
        assert sasa < sphere_sum

    def test_rotation_invariance(self):
        rng = np.random.default_rng(13)
        coords = rng.normal(size=(200, 3)) * 10
        radii = rng.choice([1.7, 1.55, 1.52, 1.8, 1.2], size=200)
        R = _rotz(0.7) @ _roty(1.2)
        s1 = _sasa_shrake_ruger(coords, radii)
        s2 = _sasa_shrake_ruger(coords @ R.T, radii)
        assert abs(s1 - s2) / s1 < 0.02

    def test_translation_invariance(self):
        rng = np.random.default_rng(17)
        coords = rng.normal(size=(150, 3)) * 9
        radii = rng.choice([1.7, 1.55, 1.52], size=150)
        s1 = _sasa_shrake_ruger(coords, radii)
        s2 = _sasa_shrake_ruger(coords + np.array([4.0, -6.0, 2.0]), radii)
        assert abs(s1 - s2) / s1 < 0.02


class TestTemperatureFromKE:
    def test_zero_dof_returns_zero(self):
        assert _temperature_from_ke(10.0, 0) == 0.0
        assert _temperature_from_ke(10.0, -3) == 0.0

    def test_known_value(self):
        # T = 2·KE / (k_B·N_dof); solve for KE to give exactly 300 K.
        n_dof = 597
        ke = 0.5 * 0.0083144621 * 300 * n_dof
        assert abs(_temperature_from_ke(ke, n_dof) - 300.0) < 1e-9

    def test_scales_linearly_with_ke(self):
        t1 = _temperature_from_ke(10.0, 100)
        t2 = _temperature_from_ke(20.0, 100)
        assert abs(t2 - 2 * t1) < 1e-9


class TestLangevinTemperature:
    def test_random_system_reaches_thermostat_target(self):
        openmm = pytest.importorskip("openmm")
        from openmm import unit

        rng = np.random.default_rng(42)
        n_particles = 200

        # Force-free system: only the Langevin thermostat acts, so the kinetic
        # energy must converge to the Maxwell–Boltzmann average at 300 K.
        # (Adding LJ forces would make random overlapping atoms explode — a
        # physical, not numerical, effect — so we keep the test force-free.)
        system = openmm.System()
        for _ in range(n_particles):
            system.addParticle(12.0 * unit.dalton)

        integrator = openmm.LangevinMiddleIntegrator(
            300 * unit.kelvin, 1 / unit.picosecond, 2 * unit.femtoseconds)
        context = openmm.Context(system, integrator, openmm.Platform.getPlatformByName("CPU"))
        try:
            context.setPositions(rng.normal(size=(n_particles, 3)) * unit.nanometer)
            integrator.step(2000)  # warm-up from zero velocities
            temps = []
            for _ in range(50):
                integrator.step(20)
                st = context.getState(getEnergy=True)
                ke = st.getKineticEnergy().value_in_unit(unit.kilojoule_per_mole)
                temps.append(_temperature_from_ke(ke, 3 * n_particles - 3))
            mean_temp = float(np.mean(temps))
            assert 250 < mean_temp < 350, f"mean temperature {mean_temp:.1f} K far from 300 K target"
        finally:
            del context


class TestBioPythonFallback:
    """BioPython structural-analysis fallback (regression for 'str' object has
    no attribute 'name' — BioPython Atom.element is a string, not an object)."""

    _MINI_PDB = """\
ATOM      1  N   ALA A   1      11.104   6.134  -6.504  1.00 11.79           N
ATOM      2  CA  ALA A   1      11.639   6.071  -5.145  1.00 11.80           C
ATOM      3  C   ALA A   1      12.839   6.979  -4.873  1.00 11.52           C
ATOM      4  O   ALA A   1      13.419   7.636  -5.737  1.00 11.76           O
ATOM      5  CB  ALA A   1      10.514   6.523  -4.225  1.00 12.30           C
ATOM      6  N   GLY A   2      13.220   7.004  -3.610  1.00 11.67           N
ATOM      7  CA  GLY A   2      14.370   7.807  -3.226  1.00 11.95           C
ATOM      8  C   GLY A   2      14.359   8.072  -1.730  1.00 12.19           C
ATOM      9  O   GLY A   2      13.417   8.612  -1.169  1.00 12.34           O
END
"""

    def test_fallback_completes_with_sasa(self, tmp_path):
        from app.tools.md_sim import _run_biopython_analysis
        pdb_path = tmp_path / "mini.pdb"
        pdb_path.write_text(self._MINI_PDB)
        result = _run_biopython_analysis(str(pdb_path), "MINI", "minimize")
        assert result["status"] == "complete"
        assert result["engine"] == "biopython_structural"
        assert len(result["radius_of_gyration"]) >= 1
        assert result["sasa"][0]["sasa_angstrom2"] > 0
        assert result["atom_count"] > 0


class TestCTerminalOxt:
    """Regression: RCSB PDBs omit the C-terminal carboxylate oxygen (OXT).
    AMBER14's C-terminal templates require OXT while the internal template
    requires the next residue's C bond, so an unterminated C-terminus (seen on
    1TIM HIS 248) matches neither and addHydrogens() raises ValueError.
    _add_missing_terminal_oxt must add OXT so the OpenMM path succeeds instead
    of degrading to the BioPython fallback."""

    _MINI_PDB = """\
ATOM   1852  N   LYS A 247      53.278  15.624   7.791  1.00  0.00           N
ATOM   1853  CA  LYS A 247      53.240  14.342   7.088  1.00  0.00           C
ATOM   1854  C   LYS A 247      52.815  13.161   7.998  1.00  0.00           C
ATOM   1855  O   LYS A 247      52.797  13.349   9.221  1.00  0.00           O
ATOM   1856  CB  LYS A 247      54.648  14.071   6.522  1.00  0.00           C
ATOM   1857  CG  LYS A 247      55.669  13.813   7.643  1.00  0.00           C
ATOM   1858  CD  LYS A 247      57.068  13.604   7.028  1.00  0.00           C
ATOM   1859  CE  LYS A 247      58.070  13.199   8.124  1.00  0.00           C
ATOM   1860  NZ  LYS A 247      59.431  13.100   7.578  1.00  0.00           N
ATOM   1861  N   HIS A 248      52.499  12.017   7.395  1.00  0.00           N
ATOM   1862  CA  HIS A 248      52.071  10.790   8.078  1.00  0.00           C
ATOM   1863  C   HIS A 248      53.091  10.557   9.224  1.00  0.00           C
ATOM   1864  O   HIS A 248      53.300  11.600  10.100  1.00  0.00           O
ATOM   1865  CB  HIS A 248      52.029   9.501   7.220  1.00  0.00           C
ATOM   1866  CG  HIS A 248      50.801   9.422   6.366  1.00  0.00           C
ATOM   1867  ND1 HIS A 248      49.565   9.056   6.862  1.00  0.00           N
ATOM   1868  CD2 HIS A 248      50.660   9.717   5.034  1.00  0.00           C
ATOM   1869  CE1 HIS A 248      48.727   9.129   5.833  1.00  0.00           C
ATOM   1870  NE2 HIS A 248      49.338   9.522   4.722  1.00  0.00           N
END
"""

    def test_c_terminal_his_without_oxt_runs_openmm(self, tmp_path):
        pytest.importorskip("openmm")
        from app.tools.md_sim import _run_openmm
        pdb_path = tmp_path / "cterm.pdb"
        pdb_path.write_text(self._MINI_PDB)
        result = _run_openmm(str(pdb_path), "CTERM", "minimize")
        assert result["status"] == "complete"
        assert result["engine"] == "openmm"
        assert result["atom_count"] > 0
        assert result["residue_count"] == 2

    def test_add_missing_oxt_returns_zero_when_not_needed(self):
        # A structure without protein residues needs no OXT work.
        from app.tools.md_sim import _add_missing_terminal_oxt
        from openmm.app import Topology, Modeller
        from openmm import unit
        topo = Topology()
        modeller = Modeller(topo, [] * unit.nanometer)
        assert _add_missing_terminal_oxt(modeller) == 0

    def test_oxt_geometry_does_not_clash_with_sidechain(self, tmp_path):
        # Regression: the OXT position was originally computed by reflecting O
        # through C (a point reflection), which sent it straight into the
        # backbone (OXT ~1.4 A from CA/CB). That clash made the initial GBSA
        # forces enormous and tipped OpenMM into "Particle coordinate is NaN"
        # during minimizeEnergy on some platforms. OXT must be placed at the
        # ~120 deg carboxylate angle, well away from CA/CB.
        pytest.importorskip("openmm")
        from app.tools.md_sim import _add_missing_terminal_oxt
        from openmm.app import PDBFile, Modeller
        from openmm import unit
        import math
        pdb_path = tmp_path / "cterm.pdb"
        pdb_path.write_text(self._MINI_PDB)
        pdb = PDBFile(str(pdb_path))
        modeller = Modeller(pdb.topology, pdb.positions)
        assert _add_missing_terminal_oxt(modeller) == 1

        pos = {}
        for atom in modeller.topology.atoms():
            pos[atom.name] = modeller.positions[atom.index].value_in_unit(unit.nanometer)
        def dist(a, b):
            return math.dist(pos[a], pos[b])
        # Bond to C preserved, and no clash with backbone/sidechain atoms.
        assert 0.10 < dist("C", "OXT") < 0.16
        assert dist("CA", "OXT") > 0.20
        assert dist("CB", "OXT") > 0.20
        assert dist("O", "OXT") > 0.20
        # Near-planar carboxylate, ~120 deg O-C-OXT angle (not 180 deg).
        c, o, oxt = pos["C"], pos["O"], pos["OXT"]
        v1 = (o[0]-c[0], o[1]-c[1], o[2]-c[2])
        v2 = (oxt[0]-c[0], oxt[1]-c[1], oxt[2]-c[2])
        n1 = math.dist(o, c); n2 = math.dist(oxt, c)
        cosang = (v1[0]*v2[0] + v1[1]*v2[1] + v1[2]*v2[2]) / (n1*n2)
        angle = math.degrees(math.acos(max(-1.0, min(1.0, cosang))))
        assert 60.0 < angle < 180.0