| """ |
| 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 |
| |
| 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 |
|
|
| def test_single_atom(self): |
| |
| 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 |
|
|
| 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 |
|
|
| |
| 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): |
| |
| 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): |
| |
| |
| 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 |
| 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): |
| |
| 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 |
|
|
| |
| |
| |
| |
| 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) |
| 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): |
| |
| 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): |
| |
| |
| |
| |
| |
| |
| 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]) |
| |
| 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 |
| |
| 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 |
|
|