Coverage for ase / constraints / fix_internals.py: 92.53%

281 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-30 08:22 +0000

1from __future__ import annotations 

2 

3from typing import Sequence 

4from warnings import warn 

5 

6import numpy as np 

7 

8from ase.constraints.constraint import FixConstraint, slice2enlist 

9from ase.geometry import ( 

10 conditional_find_mic, 

11 get_angles, 

12 get_angles_derivatives, 

13 get_dihedrals, 

14 get_dihedrals_derivatives, 

15 get_distances_derivatives, 

16) 

17 

18 

19# TODO: Better interface might be to use dictionaries in place of very 

20# nested lists/tuples 

21class FixInternals(FixConstraint): 

22 """Constraint object for fixing multiple internal coordinates. 

23 

24 Allows fixing bonds, angles, dihedrals as well as linear combinations 

25 of bonds (bondcombos). 

26 

27 Please provide angular units in degrees using `angles_deg` and 

28 `dihedrals_deg`. 

29 Fixing planar angles is not supported at the moment. 

30 """ 

31 

32 def __init__( 

33 self, 

34 bonds=None, 

35 angles=None, 

36 dihedrals=None, 

37 angles_deg=None, 

38 dihedrals_deg=None, 

39 bondcombos=None, 

40 mic=False, 

41 epsilon=1.0e-7, 

42 ): 

43 """ 

44 A constrained internal coordinate is defined as a nested list: 

45 '[value, [atom indices]]'. The constraint is initialized with a list of 

46 constrained internal coordinates, i.e. '[[value, [atom indices]], ...]'. 

47 If 'value' is None, the current value of the coordinate is constrained. 

48 

49 Parameters 

50 ---------- 

51 bonds: nested python list, optional 

52 List with targetvalue and atom indices defining the fixed bonds, 

53 i.e. [[targetvalue, [index0, index1]], ...] 

54 

55 angles_deg: nested python list, optional 

56 List with targetvalue and atom indices defining the fixedangles, 

57 i.e. [[targetvalue, [index0, index1, index3]], ...] 

58 

59 dihedrals_deg: nested python list, optional 

60 List with targetvalue and atom indices defining the fixed dihedrals, 

61 i.e. [[targetvalue, [index0, index1, index3]], ...] 

62 

63 bondcombos: nested python list, optional 

64 List with targetvalue, atom indices and linear coefficient defining 

65 the fixed linear combination of bonds, 

66 i.e. [[targetvalue, [[index0, index1, coefficient_for_bond], 

67 [index1, index2, coefficient_for_bond]]], ...] 

68 

69 mic: bool, optional, default: False 

70 Minimum image convention. 

71 

72 epsilon: float, optional, default: 1e-7 

73 Convergence criterion. 

74 """ 

75 warn_msg = 'Please specify {} in degrees using the {} argument.' 

76 if angles: 

77 warn(warn_msg.format('angles', 'angle_deg'), FutureWarning) 

78 angles = np.asarray(angles) 

79 angles[:, 0] = angles[:, 0] / np.pi * 180 

80 angles = angles.tolist() 

81 else: 

82 angles = angles_deg 

83 if dihedrals: 

84 warn(warn_msg.format('dihedrals', 'dihedrals_deg'), FutureWarning) 

85 dihedrals = np.asarray(dihedrals) 

86 dihedrals[:, 0] = dihedrals[:, 0] / np.pi * 180 

87 dihedrals = dihedrals.tolist() 

88 else: 

89 dihedrals = dihedrals_deg 

90 

91 self.bonds = bonds or [] 

92 self.angles = angles or [] 

93 self.dihedrals = dihedrals or [] 

94 self.bondcombos = bondcombos or [] 

95 self.mic = mic 

96 self.epsilon = epsilon 

97 

98 self.n = ( 

99 len(self.bonds) 

100 + len(self.angles) 

101 + len(self.dihedrals) 

102 + len(self.bondcombos) 

103 ) 

104 

105 # Initialize these at run-time: 

106 self.constraints = [] 

107 self.initialized = False 

108 

109 def get_removed_dof(self, atoms): 

110 return self.n 

111 

112 def initialize(self, atoms): 

113 if self.initialized: 

114 return 

115 masses = np.repeat(atoms.get_masses(), 3) 

116 cell = None 

117 pbc = None 

118 if self.mic: 

119 cell = atoms.cell 

120 pbc = atoms.pbc 

121 self.constraints = [] 

122 for data, ConstrClass in [ 

123 (self.bonds, self.FixBondLengthAlt), 

124 (self.angles, self.FixAngle), 

125 (self.dihedrals, self.FixDihedral), 

126 (self.bondcombos, self.FixBondCombo), 

127 ]: 

128 for datum in data: 

129 targetvalue = datum[0] 

130 if targetvalue is None: # set to current value 

131 targetvalue = ConstrClass.get_value( 

132 atoms, datum[1], self.mic 

133 ) 

134 constr = ConstrClass(targetvalue, datum[1], masses, cell, pbc) 

135 self.constraints.append(constr) 

136 self.initialized = True 

137 

138 @staticmethod 

139 def get_bondcombo(atoms, indices, mic=False): 

140 """Convenience function to return the value of the bondcombo coordinate 

141 (linear combination of bond lengths) for the given Atoms object 'atoms'. 

142 Example: Get the current value of the linear combination of two bond 

143 lengths defined as `bondcombo = [[0, 1, 1.0], [2, 3, -1.0]]`.""" 

144 c = sum(df[2] * atoms.get_distance(*df[:2], mic=mic) for df in indices) 

145 return c 

146 

147 def get_subconstraint(self, atoms, definition): 

148 """Get pointer to a specific subconstraint. 

149 Identification by its definition via indices (and coefficients).""" 

150 self.initialize(atoms) 

151 for subconstr in self.constraints: 

152 if isinstance(definition[0], Sequence): # Combo constraint 

153 defin = [ 

154 d + [c] for d, c in zip(subconstr.indices, subconstr.coefs) 

155 ] 

156 if defin == definition: 

157 return subconstr 

158 else: # identify primitive constraints by their indices 

159 if subconstr.indices == [definition]: 

160 return subconstr 

161 raise ValueError('Given `definition` not found on Atoms object.') 

162 

163 def shuffle_definitions(self, shuffle_dic, internal_type): 

164 dfns = [] # definitions 

165 for dfn in internal_type: # e.g. for bond in self.bonds 

166 append = True 

167 new_dfn = [dfn[0], list(dfn[1])] 

168 for old in dfn[1]: 

169 if old in shuffle_dic: 

170 new_dfn[1][dfn[1].index(old)] = shuffle_dic[old] 

171 else: 

172 append = False 

173 break 

174 if append: 

175 dfns.append(new_dfn) 

176 return dfns 

177 

178 def shuffle_combos(self, shuffle_dic, internal_type): 

179 dfns = [] # definitions 

180 for dfn in internal_type: # i.e. for bondcombo in self.bondcombos 

181 append = True 

182 all_indices = [idx[0:-1] for idx in dfn[1]] 

183 new_dfn = [dfn[0], list(dfn[1])] 

184 for i, indices in enumerate(all_indices): 

185 for old in indices: 

186 if old in shuffle_dic: 

187 new_dfn[1][i][indices.index(old)] = shuffle_dic[old] 

188 else: 

189 append = False 

190 break 

191 if not append: 

192 break 

193 if append: 

194 dfns.append(new_dfn) 

195 return dfns 

196 

197 def index_shuffle(self, atoms, ind): 

198 # See docstring of superclass 

199 self.initialize(atoms) 

200 shuffle_dic = dict(slice2enlist(ind, len(atoms))) 

201 shuffle_dic = {old: new for new, old in shuffle_dic.items()} 

202 self.bonds = self.shuffle_definitions(shuffle_dic, self.bonds) 

203 self.angles = self.shuffle_definitions(shuffle_dic, self.angles) 

204 self.dihedrals = self.shuffle_definitions(shuffle_dic, self.dihedrals) 

205 self.bondcombos = self.shuffle_combos(shuffle_dic, self.bondcombos) 

206 self.initialized = False 

207 self.initialize(atoms) 

208 if len(self.constraints) == 0: 

209 raise IndexError('Constraint not part of slice') 

210 

211 def get_indices(self): 

212 cons = [] 

213 for dfn in self.bonds + self.dihedrals + self.angles: 

214 cons.extend(dfn[1]) 

215 for dfn in self.bondcombos: 

216 for partial_dfn in dfn[1]: 

217 cons.extend(partial_dfn[0:-1]) # last index is the coefficient 

218 return list(set(cons)) 

219 

220 def todict(self): 

221 return { 

222 'name': 'FixInternals', 

223 'kwargs': { 

224 'bonds': self.bonds, 

225 'angles_deg': self.angles, 

226 'dihedrals_deg': self.dihedrals, 

227 'bondcombos': self.bondcombos, 

228 'mic': self.mic, 

229 'epsilon': self.epsilon, 

230 }, 

231 } 

232 

233 def adjust_positions(self, atoms, newpos): 

234 self.initialize(atoms) 

235 for constraint in self.constraints: 

236 constraint.setup_jacobian(atoms.positions) 

237 for _ in range(50): 

238 maxerr = 0.0 

239 for constraint in self.constraints: 

240 constraint.adjust_positions(atoms.positions, newpos) 

241 maxerr = max(abs(constraint.sigma), maxerr) 

242 if maxerr < self.epsilon: 

243 return 

244 msg = 'FixInternals.adjust_positions did not converge.' 

245 if any( 

246 constr.targetvalue > 175.0 or constr.targetvalue < 5.0 

247 for constr in self.constraints 

248 if isinstance(constr, self.FixAngle) 

249 ): 

250 msg += ( 

251 ' This may be caused by an almost planar angle.' 

252 ' Support for planar angles would require the' 

253 ' implementation of ghost, i.e. dummy, atoms.' 

254 ' See issue #868.' 

255 ) 

256 raise ValueError(msg) 

257 

258 def adjust_forces(self, atoms, forces): 

259 """Project out translations and rotations and all other constraints""" 

260 self.initialize(atoms) 

261 positions = atoms.positions 

262 N = len(forces) 

263 list2_constraints = list(np.zeros((6, N, 3))) 

264 tx, ty, tz, rx, ry, rz = list2_constraints 

265 

266 list_constraints = [r.ravel() for r in list2_constraints] 

267 

268 tx[:, 0] = 1.0 

269 ty[:, 1] = 1.0 

270 tz[:, 2] = 1.0 

271 ff = forces.ravel() 

272 

273 # Calculate the center of mass 

274 center = positions.sum(axis=0) / N 

275 

276 rx[:, 1] = -(positions[:, 2] - center[2]) 

277 rx[:, 2] = positions[:, 1] - center[1] 

278 ry[:, 0] = positions[:, 2] - center[2] 

279 ry[:, 2] = -(positions[:, 0] - center[0]) 

280 rz[:, 0] = -(positions[:, 1] - center[1]) 

281 rz[:, 1] = positions[:, 0] - center[0] 

282 

283 # Normalizing transl., rotat. constraints 

284 for r in list2_constraints: 

285 r /= np.linalg.norm(r.ravel()) 

286 

287 # Add all angle, etc. constraint vectors 

288 for constraint in self.constraints: 

289 constraint.setup_jacobian(positions) 

290 constraint.adjust_forces(positions, forces) 

291 list_constraints.insert(0, constraint.jacobian) 

292 # QR DECOMPOSITION - GRAM SCHMIDT 

293 

294 list_constraints = [r.ravel() for r in list_constraints] 

295 aa = np.column_stack(list_constraints) 

296 (aa, _bb) = np.linalg.qr(aa) 

297 # Projection 

298 hh = [] 

299 for i, constraint in enumerate(self.constraints): 

300 hh.append(aa[:, i] * np.vstack(aa[:, i])) 

301 

302 txx = aa[:, self.n] * np.vstack(aa[:, self.n]) 

303 tyy = aa[:, self.n + 1] * np.vstack(aa[:, self.n + 1]) 

304 tzz = aa[:, self.n + 2] * np.vstack(aa[:, self.n + 2]) 

305 rxx = aa[:, self.n + 3] * np.vstack(aa[:, self.n + 3]) 

306 ryy = aa[:, self.n + 4] * np.vstack(aa[:, self.n + 4]) 

307 rzz = aa[:, self.n + 5] * np.vstack(aa[:, self.n + 5]) 

308 T = txx + tyy + tzz + rxx + ryy + rzz 

309 for vec in hh: 

310 T += vec 

311 ff = np.dot(T, np.vstack(ff)) 

312 forces[:, :] -= np.dot(T, np.vstack(ff)).reshape(-1, 3) 

313 

314 def __repr__(self): 

315 constraints = [repr(constr) for constr in self.constraints] 

316 return f'FixInternals(_copy_init={constraints}, epsilon={self.epsilon})' 

317 

318 # Classes for internal use in FixInternals 

319 class FixInternalsBase: 

320 """Base class for subclasses of FixInternals.""" 

321 

322 def __init__(self, targetvalue, indices, masses, cell, pbc): 

323 self.targetvalue = targetvalue # constant target value 

324 self.indices = [defin[0:-1] for defin in indices] # indices, defs 

325 self.coefs = np.asarray([defin[-1] for defin in indices]) 

326 self.masses = masses 

327 self.jacobian = [] # geometric Jacobian matrix, Wilson B-matrix 

328 self.sigma = 1.0 # difference between current and target value 

329 self.projected_force = None # helps optimizers scan along constr. 

330 self.cell = cell 

331 self.pbc = pbc 

332 

333 def finalize_jacobian(self, pos, n_internals, n, derivs): 

334 """Populate jacobian with derivatives for `n_internals` defined 

335 internals. n = 2 (bonds), 3 (angles), 4 (dihedrals).""" 

336 jacobian = np.zeros((n_internals, *pos.shape)) 

337 for i, idx in enumerate(self.indices): 

338 for j in range(n): 

339 jacobian[i, idx[j]] = derivs[i, j] 

340 jacobian = jacobian.reshape((n_internals, 3 * len(pos))) 

341 return self.coefs @ jacobian 

342 

343 def finalize_positions(self, newpos): 

344 jacobian = self.jacobian / self.masses 

345 lamda = -self.sigma / (jacobian @ self.get_jacobian(newpos)) 

346 dnewpos = lamda * jacobian 

347 newpos += dnewpos.reshape(newpos.shape) 

348 

349 def adjust_forces(self, positions, forces): 

350 self.projected_forces = ( 

351 self.jacobian @ forces.ravel() 

352 ) * self.jacobian 

353 self.jacobian /= np.linalg.norm(self.jacobian) 

354 

355 class FixBondCombo(FixInternalsBase): 

356 """Constraint subobject for fixing linear combination of bond lengths 

357 within FixInternals. 

358 

359 sum_i( coef_i * bond_length_i ) = constant 

360 """ 

361 

362 def get_jacobian(self, pos): 

363 bondvectors = [pos[k] - pos[h] for h, k in self.indices] 

364 derivs = get_distances_derivatives( 

365 bondvectors, cell=self.cell, pbc=self.pbc 

366 ) 

367 return self.finalize_jacobian(pos, len(bondvectors), 2, derivs) 

368 

369 def setup_jacobian(self, pos): 

370 self.jacobian = self.get_jacobian(pos) 

371 

372 def adjust_positions(self, oldpos, newpos): 

373 bondvectors = [newpos[k] - newpos[h] for h, k in self.indices] 

374 (_,), (dists,) = conditional_find_mic( 

375 [bondvectors], cell=self.cell, pbc=self.pbc 

376 ) 

377 value = self.coefs @ dists 

378 self.sigma = value - self.targetvalue 

379 self.finalize_positions(newpos) 

380 

381 @staticmethod 

382 def get_value(atoms, indices, mic): 

383 return FixInternals.get_bondcombo(atoms, indices, mic) 

384 

385 def __repr__(self): 

386 return ( 

387 f'FixBondCombo({self.targetvalue}, {self.indices}, ' 

388 '{self.coefs})' 

389 ) 

390 

391 class FixBondLengthAlt(FixBondCombo): 

392 """Constraint subobject for fixing bond length within FixInternals. 

393 Fix distance between atoms with indices a1, a2.""" 

394 

395 def __init__(self, targetvalue, indices, masses, cell, pbc): 

396 if targetvalue <= 0.0: 

397 raise ZeroDivisionError('Invalid targetvalue for fixed bond') 

398 indices = [list(indices) + [1.0]] # bond definition with coef 1. 

399 super().__init__(targetvalue, indices, masses, cell=cell, pbc=pbc) 

400 

401 @staticmethod 

402 def get_value(atoms, indices, mic): 

403 return atoms.get_distance(*indices, mic=mic) 

404 

405 def __repr__(self): 

406 return f'FixBondLengthAlt({self.targetvalue}, {self.indices})' 

407 

408 class FixAngle(FixInternalsBase): 

409 """Constraint subobject for fixing an angle within FixInternals. 

410 

411 Convergence is potentially problematic for angles very close to 

412 0 or 180 degrees as there is a singularity in the Cartesian derivative. 

413 Fixing planar angles is therefore not supported at the moment. 

414 """ 

415 

416 def __init__(self, targetvalue, indices, masses, cell, pbc): 

417 """Fix atom movement to construct a constant angle.""" 

418 if targetvalue <= 0.0 or targetvalue >= 180.0: 

419 raise ZeroDivisionError('Invalid targetvalue for fixed angle') 

420 indices = [list(indices) + [1.0]] # angle definition with coef 1. 

421 super().__init__(targetvalue, indices, masses, cell=cell, pbc=pbc) 

422 

423 def gather_vectors(self, pos): 

424 v0 = [pos[h] - pos[k] for h, k, l in self.indices] 

425 v1 = [pos[l] - pos[k] for h, k, l in self.indices] 

426 return v0, v1 

427 

428 def get_jacobian(self, pos): 

429 v0, v1 = self.gather_vectors(pos) 

430 derivs = get_angles_derivatives( 

431 v0, v1, cell=self.cell, pbc=self.pbc 

432 ) 

433 return self.finalize_jacobian(pos, len(v0), 3, derivs) 

434 

435 def setup_jacobian(self, pos): 

436 self.jacobian = self.get_jacobian(pos) 

437 

438 def adjust_positions(self, oldpos, newpos): 

439 v0, v1 = self.gather_vectors(newpos) 

440 value = get_angles(v0, v1, cell=self.cell, pbc=self.pbc) 

441 self.sigma = value - self.targetvalue 

442 self.finalize_positions(newpos) 

443 

444 @staticmethod 

445 def get_value(atoms, indices, mic): 

446 return atoms.get_angle(*indices, mic=mic) 

447 

448 def __repr__(self): 

449 return f'FixAngle({self.targetvalue}, {self.indices})' 

450 

451 class FixDihedral(FixInternalsBase): 

452 """Constraint subobject for fixing a dihedral angle within FixInternals. 

453 

454 A dihedral becomes undefined when at least one of the inner two angles 

455 becomes planar. Make sure to avoid this situation. 

456 """ 

457 

458 def __init__(self, targetvalue, indices, masses, cell, pbc): 

459 indices = [list(indices) + [1.0]] # dihedral def. with coef 1. 

460 super().__init__(targetvalue, indices, masses, cell=cell, pbc=pbc) 

461 

462 def gather_vectors(self, pos): 

463 v0 = [pos[k] - pos[h] for h, k, l, m in self.indices] 

464 v1 = [pos[l] - pos[k] for h, k, l, m in self.indices] 

465 v2 = [pos[m] - pos[l] for h, k, l, m in self.indices] 

466 return v0, v1, v2 

467 

468 def get_jacobian(self, pos): 

469 v0, v1, v2 = self.gather_vectors(pos) 

470 derivs = get_dihedrals_derivatives( 

471 v0, v1, v2, cell=self.cell, pbc=self.pbc 

472 ) 

473 return self.finalize_jacobian(pos, len(v0), 4, derivs) 

474 

475 def setup_jacobian(self, pos): 

476 self.jacobian = self.get_jacobian(pos) 

477 

478 def adjust_positions(self, oldpos, newpos): 

479 v0, v1, v2 = self.gather_vectors(newpos) 

480 value = get_dihedrals(v0, v1, v2, cell=self.cell, pbc=self.pbc) 

481 # apply minimum dihedral difference 'convention': (diff <= 180) 

482 self.sigma = (value - self.targetvalue + 180) % 360 - 180 

483 self.finalize_positions(newpos) 

484 

485 @staticmethod 

486 def get_value(atoms, indices, mic): 

487 return atoms.get_dihedral(*indices, mic=mic) 

488 

489 def __repr__(self): 

490 return f'FixDihedral({self.targetvalue}, {self.indices})'