Coverage for src / molecular_simulations / analysis / interaction_energy.py: 58%
158 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-13 01:26 -0600
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-13 01:26 -0600
1from abc import ABC, abstractmethod
2from copy import deepcopy
3from openmm import *
4from openmm.app import *
5from openmm.unit import *
6import MDAnalysis as mda
7from MDAnalysis.analysis.distances import contact_matrix
8import mdtraj as md
9import numpy as np
10import parmed as pmd
11from pathlib import Path
12from pdbfixer import PDBFixer
13import pickle
14import gc
15from tqdm import tqdm
16from typing import Dict, List, Tuple, Union
18PathLike = Union[Path, str]
20class InteractionEnergy(ABC):
21 def __init__(self):
22 pass
24 @abstractmethod
25 def compute(self):
26 pass
28 @abstractmethod
29 def energy(self):
30 pass
32 @abstractmethod
33 def get_selection(self):
34 pass
36class StaticInteractionEnergy(InteractionEnergy):
37 """
38 Computes the linear interaction energy between specified chain and other simulation
39 components. Can specify a range of residues in chain to limit calculation to. Works on
40 a static model but can be adapted to run on dynamics data.
42 Inputs:
43 pdb (str): Path to input PDB file
44 chain (str): Defaults to A. The chain for which to compute the energy between.
45 Computes energy between this chain and all other components in PDB file.
46 Set to a whitespace if there are no chains in your PDB.
47 platform (str): Defaults to CUDA. Supports running on GPU for speed.
48 first_residue (int | None): Defaults to None. If set, will restrict the
49 calculation to residues beginning with resid `first_residue`.
50 last_residue (int | None): Defaults to None. If set, will restrict the
51 calculation to residues ending with resid `last_residue`.
52 """
53 def __init__(self,
54 pdb: str,
55 chain: str='A',
56 platform: str='CUDA',
57 first_residue: Union[int, None]=None,
58 last_residue: Union[int, None]=None):
59 self.pdb = pdb
60 self.chain = chain
61 self.platform = Platform.getPlatformByName(platform)
62 self.first = first_residue
63 self.last = last_residue
65 def get_system(self) -> System:
66 """
67 Builds implicit solvent OpenMM system.
69 Returns:
70 (System): OpenMM system object.
71 """
72 pdb = PDBFile(self.pdb)
73 positions, topology = pdb.positions, pdb.topology
74 forcefield = ForceField('amber14-all.xml', 'implicit/gbn2.xml')
75 try:
76 system = forcefield.createSystem(topology,
77 soluteDielectric=1.,
78 solventDielectric=80.)
79 except ValueError:
80 positions, topology = self.fix_pdb()
81 system = forcefield.createSystem(topology,
82 soluteDielectric=1.,
83 solventDielectric=80.)
85 self.positions = positions
86 self.get_selection(topology)
88 return system
90 def compute(self,
91 positions: Union[np.ndarray, None]=None) -> None:
92 """
93 Compute interaction energy of system. Can optionally provide atomic
94 positions such that this operation can be scaled onto a trajectory of
95 frames rather than a static model.
97 Arguments:
98 positions (np.ndarray | None): Defaults to None. If provided, inject
99 the positions into the OpenMM context.
101 Returns:
102 None
103 """
104 self.lj = None
105 self.coulomb = None
107 system = self.get_system()
108 if positions is None:
109 positions = self.positions
111 for force in system.getForces():
112 if isinstance(force, NonbondedForce):
113 force.setForceGroup(0)
114 force.addGlobalParameter("solute_coulomb_scale", 1)
115 force.addGlobalParameter("solute_lj_scale", 1)
116 force.addGlobalParameter("solvent_coulomb_scale", 1)
117 force.addGlobalParameter("solvent_lj_scale", 1)
119 for i in range(force.getNumParticles()):
120 charge, sigma, epsilon = force.getParticleParameters(i)
121 force.setParticleParameters(i, 0, 0, 0)
122 if i in self.selection:
123 force.addParticleParameterOffset("solute_coulomb_scale", i, charge, 0, 0)
124 force.addParticleParameterOffset("solute_lj_scale", i, 0, sigma, epsilon)
125 else:
126 force.addParticleParameterOffset("solvent_coulomb_scale", i, charge, 0, 0)
127 force.addParticleParameterOffset("solvent_lj_scale", i, 0, sigma, epsilon)
129 for i in range(force.getNumExceptions()):
130 p1, p2, chargeProd, sigma, epsilon = force.getExceptionParameters(i)
131 force.setExceptionParameters(i, p1, p2, 0, 0, 0)
133 else:
134 force.setForceGroup(2)
136 integrator = VerletIntegrator(0.001*picosecond)
138 context = Context(system, integrator, self.platform)
139 context.setPositions(positions)
141 total_coulomb = self.energy(context, 1, 0, 1, 0)
142 solute_coulomb = self.energy(context, 1, 0, 0, 0)
143 solvent_coulomb = self.energy(context, 0, 0, 1, 0)
144 total_lj = self.energy(context, 0, 1, 0, 1)
145 solute_lj = self.energy(context, 0, 1, 0, 0)
146 solvent_lj = self.energy(context, 0, 0, 0, 1)
148 coul_final = total_coulomb - solute_coulomb - solvent_coulomb
149 lj_final = total_lj - solute_lj - solvent_lj
151 self.coulomb = coul_final.value_in_unit(kilocalories_per_mole)
152 self.lj = lj_final.value_in_unit(kilocalories_per_mole)
154 def get_selection(self,
155 topology: Topology) -> None:
156 """
157 Using the poorly documented OpenMM selection language, get indices of
158 atoms that we want to isolate for pairwise interaction energy calculation.
160 Arguments:
161 topology (Topology): OpenMM topology object.
163 Returns:
164 None
165 """
166 if self.first is None and self.last is None:
167 selection = [a.index
168 for a in topology.atoms()
169 if a.residue.chain.id == self.chain]
170 elif self.first is not None and self.last is None:
171 selection = [a.index
172 for a in topology.atoms()
173 if a.residue.chain.id == self.chain
174 and int(self.first) <= int(a.residue.id)]
175 elif self.first is None:
176 selection = [a.index
177 for a in topology.atoms()
178 if a.residue.chain.id == self.chain
179 and int(self.last) >= int(a.residue.id)]
180 else:
181 selection = [a.index
182 for a in topology.atoms()
183 if a.residue.chain.id == self.chain
184 and int(self.first) <= int(a.residue.id) <= int(self.last)]
186 self.selection = selection
188 def fix_pdb(self) -> None:
189 """
190 Using the OpenMM adjacent tool, PDBFixer, repair input PDB by adding
191 hydrogens, and missing atoms such that we can actually construct an
192 OpenMM system.
194 Returns:
195 None
196 """
197 fixer = PDBFixer(filename=self.pdb)
198 fixer.findMissingResidues()
199 fixer.findMissingAtoms()
200 fixer.addMissingAtoms()
201 fixer.addMissingHydrogens(7.0)
203 return fixer.positions, fixer.topology
205 @property
206 def interactions(self) -> np.ndarray:
207 """
208 Places the LJ and coulombic energies into an array of shape (1, 2).
210 Returns:
211 (np.ndarray): Energy array.
212 """
213 return np.vstack([self.lj, self.coulomb])
215 @staticmethod
216 def energy(context: Context,
217 solute_coulomb_scale: int=0,
218 solute_lj_scale: int=0,
219 solvent_coulomb_scale: int=0,
220 solvent_lj_scale: int=0) -> float:
221 """
222 Computes the potential energy for provided context object.
224 Arguments:
225 context (Context): OpenMM context object.
226 solute_coulomb_scale (int): Defaults to 0. If 1 we will consider solute
227 contributions to coulombic non-bonded energy.
228 solute_lj_scale (int): Defaults to 0. If 1 we will consider solute
229 contributions to LJ non-bonded energy.
230 solvent_coulomb_scale (int): Defaults to 0. If 1 we will consider solvent
231 contributions to coulombic non-bonded energy.
232 solvent_lj_scale (int): Defaults to 0. If 1 we will consider solvent
233 contributions to LJ non-bonded energy.
235 Returns:
236 (float): Computed energy term.
237 """
238 context.setParameter("solute_coulomb_scale", solute_coulomb_scale)
239 context.setParameter("solute_lj_scale", solute_lj_scale)
240 context.setParameter("solvent_coulomb_scale", solvent_coulomb_scale)
241 context.setParameter("solvent_lj_scale", solvent_lj_scale)
242 return context.getState(getEnergy=True, groups={0}).getPotentialEnergy()
244class InteractionEnergyFrame(StaticInteractionEnergy):
245 """
246 Inherits from StaticInteractionEnergy and overloads `get_system` to allow for
247 more easily running this analysis on a trajectory of frames. Requires the
248 OpenMM system and topology to be built externally and passed in rather than
249 beginning from a PDB file.
251 Arguments:
252 system (System): OpenMM system object.
253 top (Topology): OpenMM topology object.
254 chain (str): Defaults to A. The chain for which to compute the energy between.
255 Computes energy between this chain and all other components in PDB file.
256 Set to a whitespace if there are no chains in your PDB.
257 platform (str): Defaults to CUDA. Supports running on GPU for speed.
258 first_residue (int | None): Defaults to None. If set, will restrict the
259 calculation to residues beginning with resid `first_residue`.
260 last_residue (int | None): Defaults to None. If set, will restrict the
261 calculation to residues ending with resid `last_residue`.
262 """
263 def __init__(self,
264 system: System,
265 top: Topology,
266 chain: str='A',
267 platform: str='CUDA',
268 first_residue: Union[int, None]=None,
269 last_residue: Union[int, None]=None):
270 super().__init__('', chain, platform, first_residue, last_residue)
271 self.system = system
272 self.top = top
274 def get_system(self) -> System:
275 """
276 Sets self.selection via self.get_selection and returns existing OpenMM
277 system object.
279 Returns:
280 (System): OpenMM system object.
281 """
282 self.get_selection(self.top)
283 return self.system
285class DynamicInteractionEnergy:
286 """
287 Class for obtaining interaction energies of a trajectory. Utilizes the
288 InteractionEnergyFrame child class to run per-frame energy calculations
289 and orchestrates the trajectory operations.
291 Arguments:
292 top (PathLike): Path to prmtop topology file.
293 traj (PathLike): Path to DCD trajectory file.
294 stride (int): Defaults to 1. The stride with which to move through the trajectory.
295 chain (str): Defaults to A. The chain for which to compute the energy between.
296 Computes energy between this chain and all other components in PDB file.
297 Set to a whitespace if there are no chains in your PDB.
298 platform (str): Defaults to CUDA. Supports running on GPU for speed.
299 first_residue (int | None): Defaults to None. If set, will restrict the
300 calculation to residues beginning with resid `first_residue`.
301 last_residue (int | None): Defaults to None. If set, will restrict the
302 calculation to residues ending with resid `last_residue`.
303 progress_bar (bool): Defaults to False. If True a tqdm progress bar will
304 display progress.
305 """
306 def __init__(self,
307 top: PathLike,
308 traj: PathLike,
309 stride: int=1,
310 chain: str='A',
311 platform: str='CUDA',
312 first_residue: Union[int, None]=None,
313 last_residue: Union[int, None]=None,
314 progress_bar: bool=False):
315 top = Path(top)
316 traj = Path(traj)
317 self.system = self.build_system(top)
318 self.coordinates = self.load_traj(top, traj)
319 self.stride = stride
320 self.progress = progress_bar
322 self.IE = InteractionEnergyFrame(self.system, self.top, chain,
323 platform, first_residue, last_residue)
325 def compute_energies(self) -> None:
326 """
327 Computes the energy for each frame in trajectory, storing internally.
329 Returns:
330 None
331 """
332 n_frames = self.coordinates.shape[0] // self.stride
333 self.energies = np.zeros((n_frames, 2))
335 if self.progress:
336 pbar = tqdm(total=n_frames, position=0, leave=False)
338 for i in range(n_frames):
339 fr = i * self.stride
340 self.IE.compute(self.coordinates[fr, :, :])
341 self.energies[i, 0] = self.IE.lj
342 self.energies[i, 1] = self.IE.coulomb
344 if self.progress:
345 pbar.update(1)
347 if self.progress:
348 pbar.close()
350 def build_system(self,
351 top: PathLike) -> System:
352 """
353 Builds OpenMM system for both pdb and prmtop topology files.
355 Arguments:
356 top (PathLike): Path to topology file.
358 Returns:
359 (System): OpenMM system object.
360 """
361 if top.suffix == '.pdb':
362 top = PDBFile(str(top)).topology
363 self.top = top
364 forcefield = ForceField('amber14-all.xml', 'implicit/gbn2.xml')
365 return forcefield.createSystem(top,
366 soluteDielectric=1.,
367 solventDielectric=78.5)
368 elif top.suffix == '.prmtop':
369 top = AmberPrmtopFile(str(top))
370 self.top = top
371 return top.createSystem(nonbondedMethod=CutoffNonPeriodic,
372 nonbondedCutoff=2. * nanometers,
373 constraints=HBonds)
374 else:
375 raise NotImplementedError(f'Error! Topology type {top} not implemented!')
377 def load_traj(self,
378 top: PathLike,
379 traj: PathLike) -> np.ndarray:
380 """
381 Loads trajectory into mdtraj and extracts full coordinate array.
383 Arguments:
384 top (PathLike): Path to topology file.
385 traj (PathLike): Path to trajectory file.
387 Returns:
388 (np.ndarray): Coordinate array of shape (n_frames, n_atoms, 3)
389 """
390 return md.load(str(traj), top=str(top)).xyz
392 def setup_pbar(self) -> None:
393 """
394 Builds tqdm progress bar.
396 Returns:
397 None
398 """
399 self.pbar = tqdm(total=self.coordinates.shape[0], position=0, leave=False)