Coverage for cvpack/atomic_function.py: 93%

105 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-16 00:10 +0000

1""" 

2.. class:: AtomicFunction 

3 :platform: Linux, MacOS, Windows 

4 :synopsis: A generic function of the coordinates of a group of atoms 

5 

6.. classauthor:: Charlles Abreu <craabreu@gmail.com> 

7 

8""" 

9 

10from __future__ import annotations 

11 

12import numpy as np 

13import openmm 

14from numpy.typing import ArrayLike 

15from openmm import unit as mmunit 

16 

17from .base_custom_function import BaseCustomFunction 

18from .units import ScalarQuantity, VectorQuantity 

19 

20 

21class AtomicFunction(BaseCustomFunction, openmm.CustomCompoundBondForce): 

22 r""" 

23 A generic function of the coordinates of :math:`m` groups of :math:`n` atoms: 

24 

25 .. math:: 

26 

27 f({\bf r}) = \sum_{i=1}^m F\left( 

28 {\bf r}_{i,1}, {\bf r}_{i,2}, \dots, {\bf r}_{i,n} 

29 \right) 

30 

31 where :math:`F` is a user-defined function and :math:`{\bf r}_{i,j}` is the 

32 coordinate of the :math:`j`-th atom of the :math:`i`-th group. 

33 

34 The function :math:`F` is defined as a string and can be any expression supported 

35 by :OpenMM:`CustomCompoundBondForce`. If the expression contains named parameters, 

36 the value of each parameter can be passed in one of three ways: 

37 

38 #. By a semicolon-separated definition in the function string, such as described 

39 in the :OpenMM:`CustomCompoundBondForce` documentation. In this case, the 

40 parameter value will be the same for all groups of atoms. 

41 

42 #. By a 1D array or list of length :math:`m` passed as a keyword argument to 

43 the :class:`AtomicFunction` constructor. In this case, each value will be 

44 assigned to a different group of atoms. 

45 

46 #. By a scalar passed as a keyword argument to the :class:`AtomicFunction` 

47 constructor. In this case, the parameter will apply to all atom groups and will 

48 become available for on-the-fly modification during a simulation via the 

49 ``setParameter`` method of an :OpenMM:`Context` object. **Warning**: other 

50 collective variables or :OpenMM:`Force` objects in the same system will share 

51 the same values of equal-named parameters. 

52 

53 Parameters 

54 ---------- 

55 function 

56 The function to be evaluated. It must be a valid 

57 :OpenMM:`CustomCompoundBondForce` expression. 

58 groups 

59 The indices of the atoms in each group, passed as a 2D array-like object of 

60 shape `(m, n)`, where `m` is the number of groups and `n` is the number of 

61 atoms per group. If a 1D object is passed, it is assumed that `m` is 1 and 

62 `n` is the length of the object. 

63 unit 

64 The unit of measurement of the collective variable. It must be compatible 

65 with the MD unit system (mass in `daltons`, distance in `nanometers`, time 

66 in `picoseconds`, temperature in `kelvin`, energy in `kilojoules_per_mol`, 

67 angle in `radians`). If the collective variable does not have a unit, use 

68 `unit.dimensionless`. 

69 periodicBounds 

70 The lower and upper bounds of the collective variable if it is periodic, or 

71 ``None`` if it is not. 

72 pbc 

73 Whether to use periodic boundary conditions when computing atomic distances. 

74 This should be ``True`` for periodic systems. 

75 name 

76 The name of the collective variable. 

77 

78 Keyword Args 

79 ------------ 

80 **parameters 

81 The named parameters of the function. Each parameter can be a 1D array-like 

82 object or a scalar. In the former case, the array must have length :math:`m` 

83 and each entry will be assigned to a different group of atoms. In the latter 

84 case, it will define a global :OpenMM:`Context` parameter. 

85 

86 Raises 

87 ------ 

88 ValueError 

89 If the groups are not specified as a 1D or 2D array-like object. 

90 ValueError 

91 If the unit of the collective variable is not compatible with the MD unit 

92 system. 

93 

94 Example 

95 ------- 

96 >>> import cvpack 

97 >>> import openmm 

98 >>> import numpy as np 

99 >>> from openmm import unit 

100 >>> from openmmtools import testsystems 

101 >>> model = testsystems.AlanineDipeptideVacuum() 

102 >>> angle1 = cvpack.Angle(0, 5, 10) 

103 >>> angle2 = cvpack.Angle(10, 15, 20) 

104 >>> colvar = cvpack.AtomicFunction( 

105 ... "(kappa/2)*(angle(p1, p2, p3) - theta0)^2", 

106 ... unit.kilojoules_per_mole, 

107 ... [[0, 5, 10], [10, 15, 20]], 

108 ... kappa = 1000 * unit.kilojoules_per_mole/unit.radian**2, 

109 ... theta0 = [np.pi/2, np.pi/3] * unit.radian, 

110 ... ) 

111 >>> for cv in [angle1, angle2, colvar]: 

112 ... cv.addToSystem(model.system) 

113 >>> integrator = openmm.VerletIntegrator(0) 

114 >>> platform = openmm.Platform.getPlatformByName('Reference') 

115 >>> context = openmm.Context(model.system, integrator, platform) 

116 >>> context.setPositions(model.positions) 

117 >>> theta1 = angle1.getValue(context) / openmm.unit.radian 

118 >>> theta2 = angle2.getValue(context) / openmm.unit.radian 

119 >>> 500*((theta1 - np.pi/2)**2 + (theta2 - np.pi/3)**2) 

120 429.479... 

121 >>> colvar.getValue(context) 

122 429.479... kJ/mol 

123 >>> context.setParameter( 

124 ... "kappa", 

125 ... 2000 * unit.kilojoules_per_mole/unit.radian**2 

126 ... ) 

127 >>> colvar.getValue(context) 

128 858.958... kJ/mol 

129 """ 

130 

131 def __init__( 

132 self, 

133 function: str, 

134 unit: mmunit.Unit, 

135 groups: ArrayLike, 

136 periodicBounds: VectorQuantity | None = None, 

137 pbc: bool = False, 

138 name: str = "atomic_function", 

139 **parameters: ScalarQuantity | VectorQuantity, 

140 ) -> None: 

141 groups = np.atleast_2d(groups) 

142 num_groups, atoms_per_group, *other_dimensions = groups.shape 

143 if other_dimensions: 

144 raise ValueError("Array `groups` cannot have more than 2 dimensions") 

145 super().__init__(atoms_per_group, function) 

146 overalls, perbonds = self._extractParameters(num_groups, **parameters) 

147 self._addParameters(overalls, perbonds, groups, pbc) 

148 groups = [[int(atom) for atom in group] for group in groups] 

149 self._registerCV( 

150 name, 

151 unit, 

152 function=function, 

153 unit=unit, 

154 groups=groups, 

155 periodicBounds=periodicBounds, 

156 pbc=pbc, 

157 **overalls, 

158 **perbonds, 

159 ) 

160 if periodicBounds is not None: 

161 self._registerPeriodicBounds(*periodicBounds) 

162 

163 @classmethod 

164 def _fromCustomForce( # noqa: PLR0912 

165 cls, 

166 force: openmm.CustomAngleForce 

167 | openmm.CustomBondForce 

168 | openmm.CustomCompoundBondForce 

169 | openmm.CustomExternalForce 

170 | openmm.CustomTorsionForce, 

171 unit: mmunit.Unit, 

172 periodicBounds: VectorQuantity | None = None, 

173 pbc: bool = False, 

174 ) -> AtomicFunction: 

175 """ 

176 Create an :class:`AtomicFunction` from an object of :openmm:`CustomBondForce`, 

177 :openmm:`CustomAngleForce`, :openmm:`CustomTorsionForce`, or 

178 :openmm:`CustomCompoundBondForce` class. 

179 """ 

180 if isinstance(force, openmm.CustomExternalForce): 

181 if "periodicdistance" in force.getEnergyFunction(): 

182 raise ValueError( 

183 "Converting a CustomExternalForce that uses periodicdistance is " 

184 "not supported" 

185 ) 

186 number, item, definition = 1, "Particle", "; x=x1; y=y1; z=z1" 

187 elif isinstance(force, openmm.CustomBondForce): 

188 number, item, definition = 2, "Bond", "; r=distance(p1, p2)" 

189 elif isinstance(force, openmm.CustomAngleForce): 

190 number, item, definition = 3, "Angle", "; theta=angle(p1, p2, p3)" 

191 elif isinstance(force, openmm.CustomTorsionForce): 

192 number, item, definition = 4, "Torsion", "; theta=dihedral(p1, p2, p3, p4)" 

193 elif isinstance(force, openmm.CustomCompoundBondForce): 

194 number, item, definition = force.getNumParticlesPerBond(), "Bond", "" 

195 else: 

196 raise TypeError(f"Unsupported force type: {type(force).__name__}") 

197 function = force.getEnergyFunction() + definition 

198 parameters = {} 

199 for i in range(force.getNumGlobalParameters()): 

200 name = force.getGlobalParameterName(i) 

201 value = force.getGlobalParameterDefaultValue(i) 

202 parameters[name] = value 

203 per_item_parameter_names = [] 

204 for i in range(getattr(force, f"getNumPer{item}Parameters")()): 

205 per_item_parameter_names.append( 

206 getattr(force, f"getPer{item}ParameterName")(i) 

207 ) 

208 for name in per_item_parameter_names: 

209 parameters[name] = [] 

210 atoms = [] 

211 for i in range(getattr(force, f"getNum{item}s")()): 

212 if isinstance(force, openmm.CustomCompoundBondForce): 

213 indices, per_item_parameters = force.getBondParameters(i) 

214 else: 

215 *indices, per_item_parameters = getattr( 

216 force, 

217 f"get{item}Parameters", 

218 )(i) 

219 atoms.append(indices) 

220 for name, value in zip(per_item_parameter_names, per_item_parameters): 

221 parameters[name].append(value) 

222 atoms = np.asarray(atoms).reshape(-1, number) 

223 return cls(function, unit, atoms, periodicBounds, pbc, **parameters) 

224 

225 @classmethod 

226 def _fromHarmonicBondForce( 

227 cls, 

228 force: openmm.HarmonicBondForce, 

229 unit: mmunit.Unit, 

230 pbc: bool = False, 

231 ) -> AtomicFunction: 

232 """ 

233 Create an :class:`AtomicFunction` from a :OpenMM:`HarmonicBondForce`. 

234 """ 

235 parameters = {"r0": [], "k": []} 

236 atoms = [] 

237 for i in range(force.getNumBonds()): 

238 *indices, length, k = force.getBondParameters(i) 

239 atoms.append(indices) 

240 parameters["r0"].append(length) 

241 parameters["k"].append(k) 

242 return cls( 

243 "(k/2)*(distance(p1, p2)-r0)^2", 

244 unit, 

245 atoms, 

246 None, 

247 pbc, 

248 **parameters, 

249 ) 

250 

251 @classmethod 

252 def _fromHarmonicAngleForce( 

253 cls, 

254 force: openmm.HarmonicAngleForce, 

255 unit: mmunit.Unit, 

256 pbc: bool = False, 

257 ) -> AtomicFunction: 

258 """ 

259 Create an :class:`AtomicFunction` from a :OpenMM:`HarmonicAngleForce`. 

260 """ 

261 parameters = {"theta0": [], "k": []} 

262 atoms = [] 

263 for i in range(force.getNumAngles()): 

264 *indices, angle, k = force.getAngleParameters(i) 

265 atoms.append(indices) 

266 parameters["theta0"].append(angle) 

267 parameters["k"].append(k) 

268 return cls( 

269 "(k/2)*(angle(p1, p2, p3)-theta0)^2", 

270 unit, 

271 atoms, 

272 None, 

273 pbc, 

274 **parameters, 

275 ) 

276 

277 @classmethod 

278 def _fromPeriodicTorsionForce( 

279 cls, 

280 force: openmm.PeriodicTorsionForce, 

281 unit: mmunit.Unit, 

282 pbc: bool = False, 

283 ) -> AtomicFunction: 

284 """ 

285 Create an :class:`AtomicFunction` from a :OpenMM:`PeriodicTorsionForce`. 

286 """ 

287 parameters = {"periodicity": [], "phase": [], "k": []} 

288 atoms = [] 

289 for i in range(force.getNumTorsions()): 

290 *indices, periodicity, phase, k = force.getTorsionParameters(i) 

291 atoms.append(indices) 

292 parameters["periodicity"].append(periodicity) 

293 parameters["phase"].append(phase) 

294 parameters["k"].append(k) 

295 return cls( 

296 "k*(1 + cos(periodicity*dihedral(p1, p2, p3, p4) - phase))", 

297 unit, 

298 atoms, 

299 None, 

300 pbc, 

301 **parameters, 

302 ) 

303 

304 @classmethod 

305 def fromOpenMMForce( 

306 cls, 

307 force: openmm.Force, 

308 unit: mmunit.Unit, 

309 periodicBounds: VectorQuantity | None = None, 

310 pbc: bool | None = None, 

311 ) -> AtomicFunction: 

312 """ 

313 Create an :class:`AtomicFunction` from an :OpenMM:`Force`. 

314 

315 Parameters 

316 ---------- 

317 force 

318 The force to be converted 

319 unit 

320 The unit of measurement of the collective variable. It must be 

321 compatible with the MD unit system (mass in `daltons`, distance in 

322 `nanometers`, time in `picoseconds`, temperature in `kelvin`, energy in 

323 `kilojoules_per_mol`, angle in `radians`). If the collective variable 

324 does not have a unit, use `unit.dimensionless`. 

325 periodicBounds 

326 The lower and upper bounds of the collective variable if it is periodic, 

327 or ``None`` if it is not. This parameter is considered only if `force` is 

328 a custom :OpenMM:`Force`. 

329 pbc 

330 Whether to use periodic boundary conditions when computing atomic 

331 distances. If ``None``, use the setting of `force`. 

332 

333 Raises 

334 ------ 

335 TypeError 

336 If the force is not convertible to an :class:`AtomicFunction` 

337 ValueError 

338 If the force uses tabulated functions, or if it is a 

339 :OpenMM:`CustomExternalForce` that uses ``periodicdistance`` 

340 

341 Examples 

342 -------- 

343 >>> import cvpack 

344 >>> import numpy as np 

345 >>> import openmm 

346 >>> from openmm import unit 

347 >>> from openmm import app 

348 >>> from openmmtools import testsystems 

349 >>> model = testsystems.LysozymeImplicit() 

350 >>> residues = [r for r in model.topology.residues() if 59 <= r.index <= 79] 

351 >>> helix_content = cvpack.HelixTorsionContent(residues) 

352 >>> model.system.addForce(helix_content) 

353 6 

354 >>> num_atoms = model.system.getNumParticles() 

355 >>> mean_x = openmm.CustomExternalForce("x/num_atoms") 

356 >>> mean_x.addGlobalParameter("num_atoms", num_atoms) 

357 0 

358 >>> for i in range(num_atoms): 

359 ... _ = mean_x.addParticle(i, []) 

360 >>> model.system.addForce(mean_x) 

361 7 

362 >>> forces = {force.getName(): force for force in model.system.getForces()} 

363 >>> copies = { 

364 ... name: cvpack.AtomicFunction.fromOpenMMForce( 

365 ... force, unit.kilojoules_per_mole 

366 ... ) 

367 ... for name, force in forces.items() 

368 ... if name in [ 

369 ... "HarmonicBondForce", 

370 ... "HarmonicAngleForce", 

371 ... "PeriodicTorsionForce", 

372 ... "CustomExternalForce" 

373 ... ] 

374 ... } 

375 >>> copies["HelixTorsionContent"] = cvpack.AtomicFunction.fromOpenMMForce( 

376 ... helix_content, unit.dimensionless 

377 ... ) 

378 >>> indices = {} 

379 >>> for index, (name, force) in enumerate(copies.items(), start=1): 

380 ... _ = model.system.addForce(force) 

381 ... force.setForceGroup(index) 

382 ... indices[name] = index 

383 >>> platform = openmm.Platform.getPlatformByName('Reference') 

384 >>> integrator = openmm.VerletIntegrator(0) 

385 >>> context = openmm.Context(model.system, integrator, platform) 

386 >>> context.setPositions(model.positions) 

387 >>> for name in copies: 

388 ... state = context.getState(getEnergy=True, groups={indices[name]}) 

389 ... value = state.getPotentialEnergy() / unit.kilojoules_per_mole 

390 ... copy_value = copies[name].getValue(context) 

391 ... print(f"{name}: original={value:.6f}, copy={copy_value}") 

392 HarmonicBondForce: original=2094.312..., copy=2094.312... kJ/mol 

393 HarmonicAngleForce: original=3239.795..., copy=3239.795... kJ/mol 

394 PeriodicTorsionForce: original=4226.05..., copy=4226.05... kJ/mol 

395 CustomExternalForce: original=5.02155..., copy=5.02155... kJ/mol 

396 HelixTorsionContent: original=17.4528..., copy=17.4528... dimensionless 

397 """ 

398 if not isinstance(force, openmm.Force): 

399 raise TypeError(f"Force {force} is not convertible to an AtomicFunction") 

400 if pbc is None: 

401 pbc = force.usesPeriodicBoundaryConditions() 

402 if getattr(force, "getNumTabulatedFunctions", lambda: 0)() > 0: 

403 raise ValueError("Forces with tabulated functions are not supported") 

404 if isinstance( 

405 force, 

406 ( 

407 openmm.CustomAngleForce, 

408 openmm.CustomBondForce, 

409 openmm.CustomCompoundBondForce, 

410 openmm.CustomExternalForce, 

411 openmm.CustomTorsionForce, 

412 ), 

413 ): 

414 return cls._fromCustomForce(force, unit, periodicBounds, pbc) 

415 if isinstance(force, openmm.HarmonicBondForce): 

416 return cls._fromHarmonicBondForce(force, unit, pbc) 

417 if isinstance(force, openmm.HarmonicAngleForce): 

418 return cls._fromHarmonicAngleForce(force, unit, pbc) 

419 if isinstance(force, openmm.PeriodicTorsionForce): 

420 return cls._fromPeriodicTorsionForce(force, unit, pbc) 

421 raise TypeError(f"Force {force} is not convertible to an AtomicFunction") 

422 

423 

424AtomicFunction.registerTag("!cvpack.AtomicFunction")