Coverage for ase / constraints.py: 87.36%
1203 statements
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-04 10:20 +0000
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-04 10:20 +0000
1# fmt: off
3"""Constraints"""
4from __future__ import annotations
6from copy import deepcopy
7from typing import Any, Sequence
8from warnings import warn
10import numpy as np
12from ase import Atoms
13from ase.geometry import (
14 conditional_find_mic,
15 find_mic,
16 get_angles,
17 get_angles_derivatives,
18 get_dihedrals,
19 get_dihedrals_derivatives,
20 get_distances_derivatives,
21 wrap_positions,
22)
23from ase.spacegroup.symmetrize import (
24 prep_symmetry,
25 refine_symmetry,
26 symmetrize_rank1,
27 symmetrize_rank2,
28)
29from ase.stress import full_3x3_to_voigt_6_stress, voigt_6_to_full_3x3_stress
30from ase.utils.parsemath import eval_expression
32__all__ = [
33 'FixCartesian', 'FixBondLength', 'FixedMode',
34 'FixAtoms', 'FixScaled', 'FixCom', 'FixSubsetCom', 'FixedPlane',
35 'FixConstraint', 'FixedLine', 'FixBondLengths', 'FixLinearTriatomic',
36 'FixInternals', 'Hookean', 'ExternalForce', 'MirrorForce', 'MirrorTorque',
37 'FixScaledParametricRelations', 'FixCartesianParametricRelations',
38 'FixSymmetry']
41def dict2constraint(dct: dict[str, Any]) -> FixConstraint:
42 """Convert dictionary to ASE `FixConstraint` object."""
43 if dct['name'] not in __all__:
44 raise ValueError
45 # address backward-compatibility breaking between ASE 3.22.0 and 3.23.0
46 # https://gitlab.com/ase/ase/-/merge_requests/3786
47 if dct['name'] in {'FixedLine', 'FixedPlane'} and 'a' in dct['kwargs']:
48 dct = deepcopy(dct)
49 dct['kwargs']['indices'] = dct['kwargs'].pop('a')
50 return globals()[dct['name']](**dct['kwargs'])
53def slice2enlist(s, n):
54 """Convert a slice object into a list of (new, old) tuples."""
55 if isinstance(s, slice):
56 return enumerate(range(*s.indices(n)))
57 return enumerate(s)
60def constrained_indices(atoms, only_include=None):
61 """Returns a list of indices for the atoms that are constrained
62 by a constraint that is applied. By setting only_include to a
63 specific type of constraint you can make it only look for that
64 given constraint.
65 """
66 indices = []
67 for constraint in atoms.constraints:
68 if only_include is not None:
69 if not isinstance(constraint, only_include):
70 continue
71 indices.extend(np.array(constraint.get_indices()))
72 return np.array(np.unique(indices))
75class FixConstraint:
76 """Base class for classes that fix one or more atoms in some way."""
78 def index_shuffle(self, atoms: Atoms, ind):
79 """Change the indices.
81 When the ordering of the atoms in the Atoms object changes,
82 this method can be called to shuffle the indices of the
83 constraints.
85 ind -- List or tuple of indices.
87 """
88 raise NotImplementedError
90 def repeat(self, m: int, n: int):
91 """ basic method to multiply by m, needs to know the length
92 of the underlying atoms object for the assignment of
93 multiplied constraints to work.
94 """
95 msg = ("Repeat is not compatible with your atoms' constraints."
96 ' Use atoms.set_constraint() before calling repeat to '
97 'remove your constraints.')
98 raise NotImplementedError(msg)
100 def get_removed_dof(self, atoms: Atoms):
101 """Get number of removed degrees of freedom due to constraint."""
102 raise NotImplementedError
104 def adjust_positions(self, atoms: Atoms, new):
105 """Adjust positions."""
107 def adjust_momenta(self, atoms: Atoms, momenta):
108 """Adjust momenta."""
109 # The default is in identical manner to forces.
110 # TODO: The default is however not always reasonable.
111 self.adjust_forces(atoms, momenta)
113 def adjust_forces(self, atoms: Atoms, forces):
114 """Adjust forces."""
116 def copy(self):
117 """Copy constraint."""
118 return dict2constraint(self.todict().copy())
120 def todict(self):
121 """Convert constraint to dictionary."""
124class IndexedConstraint(FixConstraint):
125 def __init__(self, indices=None, mask=None):
126 """Constrain chosen atoms.
128 Parameters
129 ----------
130 indices : sequence of int
131 Indices for those atoms that should be constrained.
132 mask : sequence of bool
133 One boolean per atom indicating if the atom should be
134 constrained or not.
135 """
137 if mask is not None:
138 if indices is not None:
139 raise ValueError('Use only one of "indices" and "mask".')
140 indices = mask
141 indices = np.atleast_1d(indices)
142 if np.ndim(indices) > 1:
143 raise ValueError('indices has wrong amount of dimensions. '
144 f'Got {np.ndim(indices)}, expected ndim <= 1')
146 if indices.dtype == bool:
147 indices = np.arange(len(indices))[indices]
148 elif len(indices) == 0:
149 indices = np.empty(0, dtype=int)
150 elif not np.issubdtype(indices.dtype, np.integer):
151 raise ValueError('Indices must be integers or boolean mask, '
152 f'not dtype={indices.dtype}')
154 if len(set(indices)) < len(indices):
155 raise ValueError(
156 'The indices array contains duplicates. '
157 'Perhaps you want to specify a mask instead, but '
158 'forgot the mask= keyword.')
160 self.index = indices
162 def index_shuffle(self, atoms, ind):
163 # See docstring of superclass
164 index = []
166 # Resolve negative indices:
167 actual_indices = set(np.arange(len(atoms))[self.index])
169 for new, old in slice2enlist(ind, len(atoms)):
170 if old in actual_indices:
171 index.append(new)
172 if len(index) == 0:
173 raise IndexError('All indices in FixAtoms not part of slice')
174 self.index = np.asarray(index, int)
175 # XXX make immutable
177 def get_indices(self):
178 return self.index.copy()
180 def repeat(self, m, n):
181 i0 = 0
182 natoms = 0
183 if isinstance(m, int):
184 m = (m, m, m)
185 index_new = []
186 for _ in range(m[2]):
187 for _ in range(m[1]):
188 for _ in range(m[0]):
189 i1 = i0 + n
190 index_new += [i + natoms for i in self.index]
191 i0 = i1
192 natoms += n
193 self.index = np.asarray(index_new, int)
194 # XXX make immutable
195 return self
197 def delete_atoms(self, indices, natoms):
198 """Removes atoms from the index array, if present.
200 Required for removing atoms with existing constraint.
201 """
203 i = np.zeros(natoms, int) - 1
204 new = np.delete(np.arange(natoms), indices)
205 i[new] = np.arange(len(new))
206 index = i[self.index]
207 self.index = index[index >= 0]
208 # XXX make immutable
209 if len(self.index) == 0:
210 return None
211 return self
214class FixAtoms(IndexedConstraint):
215 """Fix chosen atoms.
217 Examples
218 --------
219 Fix all Copper atoms:
221 >>> from ase.build import bulk
223 >>> atoms = bulk('Cu', 'fcc', a=3.6)
224 >>> mask = (atoms.symbols == 'Cu')
225 >>> c = FixAtoms(mask=mask)
226 >>> atoms.set_constraint(c)
228 Fix all atoms with z-coordinate less than 1.0 Angstrom:
230 >>> c = FixAtoms(mask=atoms.positions[:, 2] < 1.0)
231 >>> atoms.set_constraint(c)
232 """
234 def get_removed_dof(self, atoms):
235 return 3 * len(self.index)
237 def adjust_positions(self, atoms, new):
238 new[self.index] = atoms.positions[self.index]
240 def adjust_forces(self, atoms, forces):
241 forces[self.index] = 0.0
243 def __repr__(self):
244 clsname = type(self).__name__
245 indices = ints2string(self.index)
246 return f'{clsname}(indices={indices})'
248 def todict(self):
249 return {'name': 'FixAtoms',
250 'kwargs': {'indices': self.index.tolist()}}
253class FixCom(FixConstraint):
254 """Constraint class for fixing the center of mass."""
256 index = slice(None) # all atoms
258 def get_removed_dof(self, atoms):
259 return 3
261 def adjust_positions(self, atoms, new):
262 masses = atoms.get_masses()[self.index]
263 old_cm = atoms.get_center_of_mass(indices=self.index)
264 new_cm = masses @ new[self.index] / masses.sum()
265 diff = old_cm - new_cm
266 new += diff
268 def adjust_momenta(self, atoms, momenta):
269 """Adjust momenta so that the center-of-mass velocity is zero."""
270 masses = atoms.get_masses()[self.index]
271 velocity_com = momenta[self.index].sum(axis=0) / masses.sum()
272 momenta[self.index] -= masses[:, None] * velocity_com
274 def adjust_forces(self, atoms, forces):
275 # Eqs. (3) and (7) in https://doi.org/10.1021/jp9722824
276 masses = atoms.get_masses()[self.index]
277 lmd = masses @ forces[self.index] / sum(masses**2)
278 forces[self.index] -= masses[:, None] * lmd
280 def todict(self):
281 return {'name': 'FixCom',
282 'kwargs': {}}
285class FixSubsetCom(FixCom, IndexedConstraint):
286 """Constraint class for fixing the center of mass of a subset of atoms."""
288 def __init__(self, indices):
289 super().__init__(indices=indices)
291 def todict(self):
292 return {'name': self.__class__.__name__,
293 'kwargs': {'indices': self.index.tolist()}}
296def ints2string(x, threshold=None):
297 """Convert ndarray of ints to string."""
298 if threshold is None or len(x) <= threshold:
299 return str(x.tolist())
300 return str(x[:threshold].tolist())[:-1] + ', ...]'
303class FixBondLengths(FixConstraint):
304 maxiter = 500
306 def __init__(self, pairs, tolerance=1e-13,
307 bondlengths=None, iterations=None):
308 """iterations:
309 Ignored"""
310 self.pairs = np.asarray(pairs)
311 self.tolerance = tolerance
312 self.bondlengths = bondlengths
314 def get_removed_dof(self, atoms):
315 return len(self.pairs)
317 def adjust_positions(self, atoms, new):
318 old = atoms.positions
319 masses = atoms.get_masses()
321 if self.bondlengths is None:
322 self.bondlengths = self.initialize_bond_lengths(atoms)
324 for i in range(self.maxiter):
325 converged = True
326 for j, ab in enumerate(self.pairs):
327 a = ab[0]
328 b = ab[1]
329 cd = self.bondlengths[j]
330 r0 = old[a] - old[b]
331 d0, _ = find_mic(r0, atoms.cell, atoms.pbc)
332 d1 = new[a] - new[b] - r0 + d0
333 m = 1 / (1 / masses[a] + 1 / masses[b])
334 x = 0.5 * (cd**2 - np.dot(d1, d1)) / np.dot(d0, d1)
335 if abs(x) > self.tolerance:
336 new[a] += x * m / masses[a] * d0
337 new[b] -= x * m / masses[b] * d0
338 converged = False
339 if converged:
340 break
341 else:
342 raise RuntimeError('Did not converge')
344 def adjust_momenta(self, atoms, p):
345 old = atoms.positions
346 masses = atoms.get_masses()
348 if self.bondlengths is None:
349 self.bondlengths = self.initialize_bond_lengths(atoms)
351 for i in range(self.maxiter):
352 converged = True
353 for j, ab in enumerate(self.pairs):
354 a = ab[0]
355 b = ab[1]
356 cd = self.bondlengths[j]
357 d = old[a] - old[b]
358 d, _ = find_mic(d, atoms.cell, atoms.pbc)
359 dv = p[a] / masses[a] - p[b] / masses[b]
360 m = 1 / (1 / masses[a] + 1 / masses[b])
361 x = -np.dot(dv, d) / cd**2
362 if abs(x) > self.tolerance:
363 p[a] += x * m * d
364 p[b] -= x * m * d
365 converged = False
366 if converged:
367 break
368 else:
369 raise RuntimeError('Did not converge')
371 def adjust_forces(self, atoms, forces):
372 self.constraint_forces = -forces
373 self.adjust_momenta(atoms, forces)
374 self.constraint_forces += forces
376 def initialize_bond_lengths(self, atoms):
377 bondlengths = np.zeros(len(self.pairs))
379 for i, ab in enumerate(self.pairs):
380 bondlengths[i] = atoms.get_distance(ab[0], ab[1], mic=True)
382 return bondlengths
384 def get_indices(self):
385 return np.unique(self.pairs.ravel())
387 def todict(self):
388 return {'name': 'FixBondLengths',
389 'kwargs': {'pairs': self.pairs.tolist(),
390 'tolerance': self.tolerance}}
392 def index_shuffle(self, atoms, ind):
393 """Shuffle the indices of the two atoms in this constraint"""
394 map = np.zeros(len(atoms), int)
395 map[ind] = 1
396 n = map.sum()
397 map[:] = -1
398 map[ind] = range(n)
399 pairs = map[self.pairs]
400 self.pairs = pairs[(pairs != -1).all(1)]
401 if len(self.pairs) == 0:
402 raise IndexError('Constraint not part of slice')
405def FixBondLength(a1, a2):
406 """Fix distance between atoms with indices a1 and a2."""
407 return FixBondLengths([(a1, a2)])
410class FixLinearTriatomic(FixConstraint):
411 """Holonomic constraints for rigid linear triatomic molecules."""
413 def __init__(self, triples):
414 """Apply RATTLE-type bond constraints between outer atoms n and m
415 and linear vectorial constraints to the position of central
416 atoms o to fix the geometry of linear triatomic molecules of the
417 type:
419 n--o--m
421 Parameters:
423 triples: list
424 Indices of the atoms forming the linear molecules to constrain
425 as triples. Sequence should be (n, o, m) or (m, o, n).
427 When using these constraints in molecular dynamics or structure
428 optimizations, atomic forces need to be redistributed within a
429 triple. The function redistribute_forces_optimization implements
430 the redistribution of forces for structure optimization, while
431 the function redistribute_forces_md implements the redistribution
432 for molecular dynamics.
434 References:
436 Ciccotti et al. Molecular Physics 47 (1982)
437 :doi:`10.1080/00268978200100942`
438 """
439 self.triples = np.asarray(triples)
440 if self.triples.shape[1] != 3:
441 raise ValueError('"triples" has wrong size')
442 self.bondlengths = None
444 def get_removed_dof(self, atoms):
445 return 4 * len(self.triples)
447 @property
448 def n_ind(self):
449 return self.triples[:, 0]
451 @property
452 def m_ind(self):
453 return self.triples[:, 2]
455 @property
456 def o_ind(self):
457 return self.triples[:, 1]
459 def initialize(self, atoms):
460 masses = atoms.get_masses()
461 self.mass_n, self.mass_m, self.mass_o = self.get_slices(masses)
463 self.bondlengths = self.initialize_bond_lengths(atoms)
464 self.bondlengths_nm = self.bondlengths.sum(axis=1)
466 C1 = self.bondlengths[:, ::-1] / self.bondlengths_nm[:, None]
467 C2 = (C1[:, 0] ** 2 * self.mass_o * self.mass_m +
468 C1[:, 1] ** 2 * self.mass_n * self.mass_o +
469 self.mass_n * self.mass_m)
470 C2 = C1 / C2[:, None]
471 C3 = self.mass_n * C1[:, 1] - self.mass_m * C1[:, 0]
472 C3 = C2 * self.mass_o[:, None] * C3[:, None]
473 C3[:, 1] *= -1
474 C3 = (C3 + 1) / np.vstack((self.mass_n, self.mass_m)).T
475 C4 = (C1[:, 0]**2 + C1[:, 1]**2 + 1)
476 C4 = C1 / C4[:, None]
478 self.C1 = C1
479 self.C2 = C2
480 self.C3 = C3
481 self.C4 = C4
483 def adjust_positions(self, atoms, new):
484 old = atoms.positions
485 new_n, new_m, new_o = self.get_slices(new)
487 if self.bondlengths is None:
488 self.initialize(atoms)
490 r0 = old[self.n_ind] - old[self.m_ind]
491 d0, _ = find_mic(r0, atoms.cell, atoms.pbc)
492 d1 = new_n - new_m - r0 + d0
493 a = np.einsum('ij,ij->i', d0, d0)
494 b = np.einsum('ij,ij->i', d1, d0)
495 c = np.einsum('ij,ij->i', d1, d1) - self.bondlengths_nm ** 2
496 g = (b - (b**2 - a * c)**0.5) / (a * self.C3.sum(axis=1))
497 g = g[:, None] * self.C3
498 new_n -= g[:, 0, None] * d0
499 new_m += g[:, 1, None] * d0
500 if np.allclose(d0, r0):
501 new_o = (self.C1[:, 0, None] * new_n
502 + self.C1[:, 1, None] * new_m)
503 else:
504 v1, _ = find_mic(new_n, atoms.cell, atoms.pbc)
505 v2, _ = find_mic(new_m, atoms.cell, atoms.pbc)
506 rb = self.C1[:, 0, None] * v1 + self.C1[:, 1, None] * v2
507 new_o = wrap_positions(rb, atoms.cell, atoms.pbc)
509 self.set_slices(new_n, new_m, new_o, new)
511 def adjust_momenta(self, atoms, p):
512 old = atoms.positions
513 p_n, p_m, p_o = self.get_slices(p)
515 if self.bondlengths is None:
516 self.initialize(atoms)
518 mass_nn = self.mass_n[:, None]
519 mass_mm = self.mass_m[:, None]
520 mass_oo = self.mass_o[:, None]
522 d = old[self.n_ind] - old[self.m_ind]
523 d, _ = find_mic(d, atoms.cell, atoms.pbc)
524 dv = p_n / mass_nn - p_m / mass_mm
525 k = np.einsum('ij,ij->i', dv, d) / self.bondlengths_nm ** 2
526 k = self.C3 / (self.C3.sum(axis=1)[:, None]) * k[:, None]
527 p_n -= k[:, 0, None] * mass_nn * d
528 p_m += k[:, 1, None] * mass_mm * d
529 p_o = (mass_oo * (self.C1[:, 0, None] * p_n / mass_nn +
530 self.C1[:, 1, None] * p_m / mass_mm))
532 self.set_slices(p_n, p_m, p_o, p)
534 def adjust_forces(self, atoms, forces):
536 if self.bondlengths is None:
537 self.initialize(atoms)
539 A = self.C4 * np.diff(self.C1)
540 A[:, 0] *= -1
541 A -= 1
542 B = np.diff(self.C4) / (A.sum(axis=1))[:, None]
543 A /= (A.sum(axis=1))[:, None]
545 self.constraint_forces = -forces
546 old = atoms.positions
548 fr_n, fr_m, fr_o = self.redistribute_forces_optimization(forces)
550 d = old[self.n_ind] - old[self.m_ind]
551 d, _ = find_mic(d, atoms.cell, atoms.pbc)
552 df = fr_n - fr_m
553 k = -np.einsum('ij,ij->i', df, d) / self.bondlengths_nm ** 2
554 forces[self.n_ind] = fr_n + k[:, None] * d * A[:, 0, None]
555 forces[self.m_ind] = fr_m - k[:, None] * d * A[:, 1, None]
556 forces[self.o_ind] = fr_o + k[:, None] * d * B
558 self.constraint_forces += forces
560 def redistribute_forces_optimization(self, forces):
561 """Redistribute forces within a triple when performing structure
562 optimizations.
564 The redistributed forces needs to be further adjusted using the
565 appropriate Lagrange multipliers as implemented in adjust_forces."""
566 forces_n, forces_m, forces_o = self.get_slices(forces)
567 C1_1 = self.C1[:, 0, None]
568 C1_2 = self.C1[:, 1, None]
569 C4_1 = self.C4[:, 0, None]
570 C4_2 = self.C4[:, 1, None]
572 fr_n = ((1 - C4_1 * C1_1) * forces_n -
573 C4_1 * (C1_2 * forces_m - forces_o))
574 fr_m = ((1 - C4_2 * C1_2) * forces_m -
575 C4_2 * (C1_1 * forces_n - forces_o))
576 fr_o = ((1 - 1 / (C1_1**2 + C1_2**2 + 1)) * forces_o +
577 C4_1 * forces_n + C4_2 * forces_m)
579 return fr_n, fr_m, fr_o
581 def redistribute_forces_md(self, atoms, forces, rand=False):
582 """Redistribute forces within a triple when performing molecular
583 dynamics.
585 When rand=True, use the equations for random force terms, as
586 used e.g. by Langevin dynamics, otherwise apply the standard
587 equations for deterministic forces (see Ciccotti et al. Molecular
588 Physics 47 (1982))."""
589 if self.bondlengths is None:
590 self.initialize(atoms)
591 forces_n, forces_m, forces_o = self.get_slices(forces)
592 C1_1 = self.C1[:, 0, None]
593 C1_2 = self.C1[:, 1, None]
594 C2_1 = self.C2[:, 0, None]
595 C2_2 = self.C2[:, 1, None]
596 mass_nn = self.mass_n[:, None]
597 mass_mm = self.mass_m[:, None]
598 mass_oo = self.mass_o[:, None]
599 if rand:
600 mr1 = (mass_mm / mass_nn) ** 0.5
601 mr2 = (mass_oo / mass_nn) ** 0.5
602 mr3 = (mass_nn / mass_mm) ** 0.5
603 mr4 = (mass_oo / mass_mm) ** 0.5
604 else:
605 mr1 = 1.0
606 mr2 = 1.0
607 mr3 = 1.0
608 mr4 = 1.0
610 fr_n = ((1 - C1_1 * C2_1 * mass_oo * mass_mm) * forces_n -
611 C2_1 * (C1_2 * mr1 * mass_oo * mass_nn * forces_m -
612 mr2 * mass_mm * mass_nn * forces_o))
614 fr_m = ((1 - C1_2 * C2_2 * mass_oo * mass_nn) * forces_m -
615 C2_2 * (C1_1 * mr3 * mass_oo * mass_mm * forces_n -
616 mr4 * mass_mm * mass_nn * forces_o))
618 self.set_slices(fr_n, fr_m, 0.0, forces)
620 def get_slices(self, a):
621 a_n = a[self.n_ind]
622 a_m = a[self.m_ind]
623 a_o = a[self.o_ind]
625 return a_n, a_m, a_o
627 def set_slices(self, a_n, a_m, a_o, a):
628 a[self.n_ind] = a_n
629 a[self.m_ind] = a_m
630 a[self.o_ind] = a_o
632 def initialize_bond_lengths(self, atoms):
633 bondlengths = np.zeros((len(self.triples), 2))
635 for i in range(len(self.triples)):
636 bondlengths[i, 0] = atoms.get_distance(self.n_ind[i],
637 self.o_ind[i], mic=True)
638 bondlengths[i, 1] = atoms.get_distance(self.o_ind[i],
639 self.m_ind[i], mic=True)
641 return bondlengths
643 def get_indices(self):
644 return np.unique(self.triples.ravel())
646 def todict(self):
647 return {'name': 'FixLinearTriatomic',
648 'kwargs': {'triples': self.triples.tolist()}}
650 def index_shuffle(self, atoms, ind):
651 """Shuffle the indices of the three atoms in this constraint"""
652 map = np.zeros(len(atoms), int)
653 map[ind] = 1
654 n = map.sum()
655 map[:] = -1
656 map[ind] = range(n)
657 triples = map[self.triples]
658 self.triples = triples[(triples != -1).all(1)]
659 if len(self.triples) == 0:
660 raise IndexError('Constraint not part of slice')
663class FixedMode(FixConstraint):
664 """Constrain atoms to move along directions orthogonal to
665 a given mode only. Initialize with a mode, such as one produced by
666 ase.vibrations.Vibrations.get_mode()."""
668 def __init__(self, mode):
669 mode = np.asarray(mode)
670 self.mode = (mode / np.sqrt((mode**2).sum())).reshape(-1)
672 def get_removed_dof(self, atoms):
673 return len(atoms)
675 def adjust_positions(self, atoms, newpositions):
676 newpositions = newpositions.ravel()
677 oldpositions = atoms.positions.ravel()
678 step = newpositions - oldpositions
679 newpositions -= self.mode * np.dot(step, self.mode)
681 def adjust_forces(self, atoms, forces):
682 forces = forces.ravel()
683 forces -= self.mode * np.dot(forces, self.mode)
685 def index_shuffle(self, atoms, ind):
686 eps = 1e-12
687 mode = self.mode.reshape(-1, 3)
688 excluded = np.ones(len(mode), dtype=bool)
689 excluded[ind] = False
690 if (abs(mode[excluded]) > eps).any():
691 raise IndexError('All nonzero parts of mode not in slice')
692 self.mode = mode[ind].ravel()
694 def get_indices(self):
695 # This function will never properly work because it works on all
696 # atoms and it has no idea how to tell how many atoms it is
697 # attached to. If it is being used, surely the user knows
698 # everything is being constrained.
699 return []
701 def todict(self):
702 return {'name': 'FixedMode',
703 'kwargs': {'mode': self.mode.tolist()}}
705 def __repr__(self):
706 return f'FixedMode({self.mode.tolist()})'
709def _normalize(direction):
710 if np.shape(direction) != (3,):
711 raise ValueError("len(direction) is {len(direction)}. Has to be 3")
713 direction = np.asarray(direction) / np.linalg.norm(direction)
714 return direction
717class FixedPlane(IndexedConstraint):
718 """
719 Constraint object for fixing chosen atoms to only move in a plane.
721 The plane is defined by its normal vector *direction*
722 """
724 def __init__(self, indices, direction):
725 """Constrain chosen atoms.
727 Parameters
728 ----------
729 indices : int or list of int
730 Index or indices for atoms that should be constrained
731 direction : list of 3 int
732 Direction of the normal vector
734 Examples
735 --------
736 Fix all Copper atoms to only move in the yz-plane:
738 >>> from ase.build import bulk
739 >>> from ase.constraints import FixedPlane
741 >>> atoms = bulk('Cu', 'fcc', a=3.6)
742 >>> c = FixedPlane(
743 ... indices=[atom.index for atom in atoms if atom.symbol == 'Cu'],
744 ... direction=[1, 0, 0],
745 ... )
746 >>> atoms.set_constraint(c)
748 or constrain a single atom with the index 0 to move in the xy-plane:
750 >>> c = FixedPlane(indices=0, direction=[0, 0, 1])
751 >>> atoms.set_constraint(c)
752 """
753 super().__init__(indices=indices)
754 self.dir = _normalize(direction)
756 def adjust_positions(self, atoms, newpositions):
757 step = newpositions[self.index] - atoms.positions[self.index]
758 newpositions[self.index] -= _projection(step, self.dir)
760 def adjust_forces(self, atoms, forces):
761 forces[self.index] -= _projection(forces[self.index], self.dir)
763 def get_removed_dof(self, atoms):
764 return len(self.index)
766 def todict(self):
767 return {
768 'name': 'FixedPlane',
769 'kwargs': {'indices': self.index.tolist(),
770 'direction': self.dir.tolist()}
771 }
773 def __repr__(self):
774 return f'FixedPlane(indices={self.index}, {self.dir.tolist()})'
777def _projection(vectors, direction):
778 dotprods = vectors @ direction
779 projection = direction[None, :] * dotprods[:, None]
780 return projection
783class FixedLine(IndexedConstraint):
784 """
785 Constrain an atom index or a list of atom indices to move on a line only.
787 The line is defined by its vector *direction*
788 """
790 def __init__(self, indices, direction):
791 """Constrain chosen atoms.
793 Parameters
794 ----------
795 indices : int or list of int
796 Index or indices for atoms that should be constrained
797 direction : list of 3 int
798 Direction of the vector defining the line
800 Examples
801 --------
802 Fix all Copper atoms to only move in the x-direction:
804 >>> from ase.constraints import FixedLine
805 >>> c = FixedLine(
806 ... indices=[atom.index for atom in atoms if atom.symbol == 'Cu'],
807 ... direction=[1, 0, 0],
808 ... )
809 >>> atoms.set_constraint(c)
811 or constrain a single atom with the index 0 to move in the z-direction:
813 >>> c = FixedLine(indices=0, direction=[0, 0, 1])
814 >>> atoms.set_constraint(c)
815 """
816 super().__init__(indices)
817 self.dir = _normalize(direction)
819 def adjust_positions(self, atoms, newpositions):
820 step = newpositions[self.index] - atoms.positions[self.index]
821 projection = _projection(step, self.dir)
822 newpositions[self.index] = atoms.positions[self.index] + projection
824 def adjust_forces(self, atoms, forces):
825 forces[self.index] = _projection(forces[self.index], self.dir)
827 def get_removed_dof(self, atoms):
828 return 2 * len(self.index)
830 def __repr__(self):
831 return f'FixedLine(indices={self.index}, {self.dir.tolist()})'
833 def todict(self):
834 return {
835 'name': 'FixedLine',
836 'kwargs': {'indices': self.index.tolist(),
837 'direction': self.dir.tolist()}
838 }
841class FixCartesian(IndexedConstraint):
842 """Fix atoms in the directions of the cartesian coordinates.
844 Parameters
845 ----------
846 a : Sequence[int]
847 Indices of atoms to be fixed.
848 mask : tuple[bool, bool, bool], default: (True, True, True)
849 Cartesian directions to be fixed. (False: unfixed, True: fixed)
850 """
852 def __init__(self, a, mask=(True, True, True)):
853 super().__init__(indices=a)
854 self.mask = np.asarray(mask, bool)
856 def get_removed_dof(self, atoms: Atoms):
857 return self.mask.sum() * len(self.index)
859 def adjust_positions(self, atoms: Atoms, new):
860 new[self.index] = np.where(
861 self.mask[None, :],
862 atoms.positions[self.index],
863 new[self.index],
864 )
866 def adjust_forces(self, atoms: Atoms, forces):
867 forces[self.index] *= ~self.mask[None, :]
869 def todict(self):
870 return {'name': 'FixCartesian',
871 'kwargs': {'a': self.index.tolist(),
872 'mask': self.mask.tolist()}}
874 def __repr__(self):
875 name = type(self).__name__
876 return f'{name}(indices={self.index.tolist()}, {self.mask.tolist()})'
879class FixScaled(IndexedConstraint):
880 """Fix atoms in the directions of the unit vectors.
882 Parameters
883 ----------
884 a : Sequence[int]
885 Indices of atoms to be fixed.
886 mask : tuple[bool, bool, bool], default: (True, True, True)
887 Cell directions to be fixed. (False: unfixed, True: fixed)
888 """
890 def __init__(self, a, mask=(True, True, True), cell=None):
891 # XXX The unused cell keyword is there for compatibility
892 # with old trajectory files.
893 super().__init__(indices=a)
894 self.mask = np.asarray(mask, bool)
896 def get_removed_dof(self, atoms: Atoms):
897 return self.mask.sum() * len(self.index)
899 def adjust_positions(self, atoms: Atoms, new):
900 cell = atoms.cell
901 scaled_old = cell.scaled_positions(atoms.positions[self.index])
902 scaled_new = cell.scaled_positions(new[self.index])
903 scaled_new[:, self.mask] = scaled_old[:, self.mask]
904 new[self.index] = cell.cartesian_positions(scaled_new)
906 def adjust_forces(self, atoms: Atoms, forces):
907 # Forces are covariant to the coordinate transformation,
908 # use the inverse transformations
909 cell = atoms.cell
910 scaled_forces = cell.cartesian_positions(forces[self.index])
911 scaled_forces *= -(self.mask - 1)
912 forces[self.index] = cell.scaled_positions(scaled_forces)
914 def todict(self):
915 return {'name': 'FixScaled',
916 'kwargs': {'a': self.index.tolist(),
917 'mask': self.mask.tolist()}}
919 def __repr__(self):
920 name = type(self).__name__
921 return f'{name}(indices={self.index.tolist()}, {self.mask.tolist()})'
924# TODO: Better interface might be to use dictionaries in place of very
925# nested lists/tuples
926class FixInternals(FixConstraint):
927 """Constraint object for fixing multiple internal coordinates.
929 Allows fixing bonds, angles, dihedrals as well as linear combinations
930 of bonds (bondcombos).
932 Please provide angular units in degrees using `angles_deg` and
933 `dihedrals_deg`.
934 Fixing planar angles is not supported at the moment.
935 """
937 def __init__(self, bonds=None, angles=None, dihedrals=None,
938 angles_deg=None, dihedrals_deg=None,
939 bondcombos=None,
940 mic=False, epsilon=1.e-7):
941 """
942 A constrained internal coordinate is defined as a nested list:
943 '[value, [atom indices]]'. The constraint is initialized with a list of
944 constrained internal coordinates, i.e. '[[value, [atom indices]], ...]'.
945 If 'value' is None, the current value of the coordinate is constrained.
947 Parameters
948 ----------
949 bonds: nested python list, optional
950 List with targetvalue and atom indices defining the fixed bonds,
951 i.e. [[targetvalue, [index0, index1]], ...]
953 angles_deg: nested python list, optional
954 List with targetvalue and atom indices defining the fixedangles,
955 i.e. [[targetvalue, [index0, index1, index3]], ...]
957 dihedrals_deg: nested python list, optional
958 List with targetvalue and atom indices defining the fixed dihedrals,
959 i.e. [[targetvalue, [index0, index1, index3]], ...]
961 bondcombos: nested python list, optional
962 List with targetvalue, atom indices and linear coefficient defining
963 the fixed linear combination of bonds,
964 i.e. [[targetvalue, [[index0, index1, coefficient_for_bond],
965 [index1, index2, coefficient_for_bond]]], ...]
967 mic: bool, optional, default: False
968 Minimum image convention.
970 epsilon: float, optional, default: 1e-7
971 Convergence criterion.
972 """
973 warn_msg = 'Please specify {} in degrees using the {} argument.'
974 if angles:
975 warn(warn_msg.format('angles', 'angle_deg'), FutureWarning)
976 angles = np.asarray(angles)
977 angles[:, 0] = angles[:, 0] / np.pi * 180
978 angles = angles.tolist()
979 else:
980 angles = angles_deg
981 if dihedrals:
982 warn(warn_msg.format('dihedrals', 'dihedrals_deg'), FutureWarning)
983 dihedrals = np.asarray(dihedrals)
984 dihedrals[:, 0] = dihedrals[:, 0] / np.pi * 180
985 dihedrals = dihedrals.tolist()
986 else:
987 dihedrals = dihedrals_deg
989 self.bonds = bonds or []
990 self.angles = angles or []
991 self.dihedrals = dihedrals or []
992 self.bondcombos = bondcombos or []
993 self.mic = mic
994 self.epsilon = epsilon
996 self.n = (len(self.bonds) + len(self.angles) + len(self.dihedrals)
997 + len(self.bondcombos))
999 # Initialize these at run-time:
1000 self.constraints = []
1001 self.initialized = False
1003 def get_removed_dof(self, atoms):
1004 return self.n
1006 def initialize(self, atoms):
1007 if self.initialized:
1008 return
1009 masses = np.repeat(atoms.get_masses(), 3)
1010 cell = None
1011 pbc = None
1012 if self.mic:
1013 cell = atoms.cell
1014 pbc = atoms.pbc
1015 self.constraints = []
1016 for data, ConstrClass in [(self.bonds, self.FixBondLengthAlt),
1017 (self.angles, self.FixAngle),
1018 (self.dihedrals, self.FixDihedral),
1019 (self.bondcombos, self.FixBondCombo)]:
1020 for datum in data:
1021 targetvalue = datum[0]
1022 if targetvalue is None: # set to current value
1023 targetvalue = ConstrClass.get_value(atoms, datum[1],
1024 self.mic)
1025 constr = ConstrClass(targetvalue, datum[1], masses, cell, pbc)
1026 self.constraints.append(constr)
1027 self.initialized = True
1029 @staticmethod
1030 def get_bondcombo(atoms, indices, mic=False):
1031 """Convenience function to return the value of the bondcombo coordinate
1032 (linear combination of bond lengths) for the given Atoms object 'atoms'.
1033 Example: Get the current value of the linear combination of two bond
1034 lengths defined as `bondcombo = [[0, 1, 1.0], [2, 3, -1.0]]`."""
1035 c = sum(df[2] * atoms.get_distance(*df[:2], mic=mic) for df in indices)
1036 return c
1038 def get_subconstraint(self, atoms, definition):
1039 """Get pointer to a specific subconstraint.
1040 Identification by its definition via indices (and coefficients)."""
1041 self.initialize(atoms)
1042 for subconstr in self.constraints:
1043 if isinstance(definition[0], Sequence): # Combo constraint
1044 defin = [d + [c] for d, c in zip(subconstr.indices,
1045 subconstr.coefs)]
1046 if defin == definition:
1047 return subconstr
1048 else: # identify primitive constraints by their indices
1049 if subconstr.indices == [definition]:
1050 return subconstr
1051 raise ValueError('Given `definition` not found on Atoms object.')
1053 def shuffle_definitions(self, shuffle_dic, internal_type):
1054 dfns = [] # definitions
1055 for dfn in internal_type: # e.g. for bond in self.bonds
1056 append = True
1057 new_dfn = [dfn[0], list(dfn[1])]
1058 for old in dfn[1]:
1059 if old in shuffle_dic:
1060 new_dfn[1][dfn[1].index(old)] = shuffle_dic[old]
1061 else:
1062 append = False
1063 break
1064 if append:
1065 dfns.append(new_dfn)
1066 return dfns
1068 def shuffle_combos(self, shuffle_dic, internal_type):
1069 dfns = [] # definitions
1070 for dfn in internal_type: # i.e. for bondcombo in self.bondcombos
1071 append = True
1072 all_indices = [idx[0:-1] for idx in dfn[1]]
1073 new_dfn = [dfn[0], list(dfn[1])]
1074 for i, indices in enumerate(all_indices):
1075 for old in indices:
1076 if old in shuffle_dic:
1077 new_dfn[1][i][indices.index(old)] = shuffle_dic[old]
1078 else:
1079 append = False
1080 break
1081 if not append:
1082 break
1083 if append:
1084 dfns.append(new_dfn)
1085 return dfns
1087 def index_shuffle(self, atoms, ind):
1088 # See docstring of superclass
1089 self.initialize(atoms)
1090 shuffle_dic = dict(slice2enlist(ind, len(atoms)))
1091 shuffle_dic = {old: new for new, old in shuffle_dic.items()}
1092 self.bonds = self.shuffle_definitions(shuffle_dic, self.bonds)
1093 self.angles = self.shuffle_definitions(shuffle_dic, self.angles)
1094 self.dihedrals = self.shuffle_definitions(shuffle_dic, self.dihedrals)
1095 self.bondcombos = self.shuffle_combos(shuffle_dic, self.bondcombos)
1096 self.initialized = False
1097 self.initialize(atoms)
1098 if len(self.constraints) == 0:
1099 raise IndexError('Constraint not part of slice')
1101 def get_indices(self):
1102 cons = []
1103 for dfn in self.bonds + self.dihedrals + self.angles:
1104 cons.extend(dfn[1])
1105 for dfn in self.bondcombos:
1106 for partial_dfn in dfn[1]:
1107 cons.extend(partial_dfn[0:-1]) # last index is the coefficient
1108 return list(set(cons))
1110 def todict(self):
1111 return {'name': 'FixInternals',
1112 'kwargs': {'bonds': self.bonds,
1113 'angles_deg': self.angles,
1114 'dihedrals_deg': self.dihedrals,
1115 'bondcombos': self.bondcombos,
1116 'mic': self.mic,
1117 'epsilon': self.epsilon}}
1119 def adjust_positions(self, atoms, newpos):
1120 self.initialize(atoms)
1121 for constraint in self.constraints:
1122 constraint.setup_jacobian(atoms.positions)
1123 for _ in range(50):
1124 maxerr = 0.0
1125 for constraint in self.constraints:
1126 constraint.adjust_positions(atoms.positions, newpos)
1127 maxerr = max(abs(constraint.sigma), maxerr)
1128 if maxerr < self.epsilon:
1129 return
1130 msg = 'FixInternals.adjust_positions did not converge.'
1131 if any(constr.targetvalue > 175. or constr.targetvalue < 5. for constr
1132 in self.constraints if isinstance(constr, self.FixAngle)):
1133 msg += (' This may be caused by an almost planar angle.'
1134 ' Support for planar angles would require the'
1135 ' implementation of ghost, i.e. dummy, atoms.'
1136 ' See issue #868.')
1137 raise ValueError(msg)
1139 def adjust_forces(self, atoms, forces):
1140 """Project out translations and rotations and all other constraints"""
1141 self.initialize(atoms)
1142 positions = atoms.positions
1143 N = len(forces)
1144 list2_constraints = list(np.zeros((6, N, 3)))
1145 tx, ty, tz, rx, ry, rz = list2_constraints
1147 list_constraints = [r.ravel() for r in list2_constraints]
1149 tx[:, 0] = 1.0
1150 ty[:, 1] = 1.0
1151 tz[:, 2] = 1.0
1152 ff = forces.ravel()
1154 # Calculate the center of mass
1155 center = positions.sum(axis=0) / N
1157 rx[:, 1] = -(positions[:, 2] - center[2])
1158 rx[:, 2] = positions[:, 1] - center[1]
1159 ry[:, 0] = positions[:, 2] - center[2]
1160 ry[:, 2] = -(positions[:, 0] - center[0])
1161 rz[:, 0] = -(positions[:, 1] - center[1])
1162 rz[:, 1] = positions[:, 0] - center[0]
1164 # Normalizing transl., rotat. constraints
1165 for r in list2_constraints:
1166 r /= np.linalg.norm(r.ravel())
1168 # Add all angle, etc. constraint vectors
1169 for constraint in self.constraints:
1170 constraint.setup_jacobian(positions)
1171 constraint.adjust_forces(positions, forces)
1172 list_constraints.insert(0, constraint.jacobian)
1173 # QR DECOMPOSITION - GRAM SCHMIDT
1175 list_constraints = [r.ravel() for r in list_constraints]
1176 aa = np.column_stack(list_constraints)
1177 (aa, _bb) = np.linalg.qr(aa)
1178 # Projection
1179 hh = []
1180 for i, constraint in enumerate(self.constraints):
1181 hh.append(aa[:, i] * np.vstack(aa[:, i]))
1183 txx = aa[:, self.n] * np.vstack(aa[:, self.n])
1184 tyy = aa[:, self.n + 1] * np.vstack(aa[:, self.n + 1])
1185 tzz = aa[:, self.n + 2] * np.vstack(aa[:, self.n + 2])
1186 rxx = aa[:, self.n + 3] * np.vstack(aa[:, self.n + 3])
1187 ryy = aa[:, self.n + 4] * np.vstack(aa[:, self.n + 4])
1188 rzz = aa[:, self.n + 5] * np.vstack(aa[:, self.n + 5])
1189 T = txx + tyy + tzz + rxx + ryy + rzz
1190 for vec in hh:
1191 T += vec
1192 ff = np.dot(T, np.vstack(ff))
1193 forces[:, :] -= np.dot(T, np.vstack(ff)).reshape(-1, 3)
1195 def __repr__(self):
1196 constraints = [repr(constr) for constr in self.constraints]
1197 return f'FixInternals(_copy_init={constraints}, epsilon={self.epsilon})'
1199 # Classes for internal use in FixInternals
1200 class FixInternalsBase:
1201 """Base class for subclasses of FixInternals."""
1203 def __init__(self, targetvalue, indices, masses, cell, pbc):
1204 self.targetvalue = targetvalue # constant target value
1205 self.indices = [defin[0:-1] for defin in indices] # indices, defs
1206 self.coefs = np.asarray([defin[-1] for defin in indices])
1207 self.masses = masses
1208 self.jacobian = [] # geometric Jacobian matrix, Wilson B-matrix
1209 self.sigma = 1. # difference between current and target value
1210 self.projected_force = None # helps optimizers scan along constr.
1211 self.cell = cell
1212 self.pbc = pbc
1214 def finalize_jacobian(self, pos, n_internals, n, derivs):
1215 """Populate jacobian with derivatives for `n_internals` defined
1216 internals. n = 2 (bonds), 3 (angles), 4 (dihedrals)."""
1217 jacobian = np.zeros((n_internals, *pos.shape))
1218 for i, idx in enumerate(self.indices):
1219 for j in range(n):
1220 jacobian[i, idx[j]] = derivs[i, j]
1221 jacobian = jacobian.reshape((n_internals, 3 * len(pos)))
1222 return self.coefs @ jacobian
1224 def finalize_positions(self, newpos):
1225 jacobian = self.jacobian / self.masses
1226 lamda = -self.sigma / (jacobian @ self.get_jacobian(newpos))
1227 dnewpos = lamda * jacobian
1228 newpos += dnewpos.reshape(newpos.shape)
1230 def adjust_forces(self, positions, forces):
1231 self.projected_forces = ((self.jacobian @ forces.ravel())
1232 * self.jacobian)
1233 self.jacobian /= np.linalg.norm(self.jacobian)
1235 class FixBondCombo(FixInternalsBase):
1236 """Constraint subobject for fixing linear combination of bond lengths
1237 within FixInternals.
1239 sum_i( coef_i * bond_length_i ) = constant
1240 """
1242 def get_jacobian(self, pos):
1243 bondvectors = [pos[k] - pos[h] for h, k in self.indices]
1244 derivs = get_distances_derivatives(bondvectors, cell=self.cell,
1245 pbc=self.pbc)
1246 return self.finalize_jacobian(pos, len(bondvectors), 2, derivs)
1248 def setup_jacobian(self, pos):
1249 self.jacobian = self.get_jacobian(pos)
1251 def adjust_positions(self, oldpos, newpos):
1252 bondvectors = [newpos[k] - newpos[h] for h, k in self.indices]
1253 (_, ), (dists, ) = conditional_find_mic([bondvectors],
1254 cell=self.cell,
1255 pbc=self.pbc)
1256 value = self.coefs @ dists
1257 self.sigma = value - self.targetvalue
1258 self.finalize_positions(newpos)
1260 @staticmethod
1261 def get_value(atoms, indices, mic):
1262 return FixInternals.get_bondcombo(atoms, indices, mic)
1264 def __repr__(self):
1265 return (f'FixBondCombo({self.targetvalue}, {self.indices}, '
1266 '{self.coefs})')
1268 class FixBondLengthAlt(FixBondCombo):
1269 """Constraint subobject for fixing bond length within FixInternals.
1270 Fix distance between atoms with indices a1, a2."""
1272 def __init__(self, targetvalue, indices, masses, cell, pbc):
1273 if targetvalue <= 0.:
1274 raise ZeroDivisionError('Invalid targetvalue for fixed bond')
1275 indices = [list(indices) + [1.]] # bond definition with coef 1.
1276 super().__init__(targetvalue, indices, masses, cell=cell, pbc=pbc)
1278 @staticmethod
1279 def get_value(atoms, indices, mic):
1280 return atoms.get_distance(*indices, mic=mic)
1282 def __repr__(self):
1283 return f'FixBondLengthAlt({self.targetvalue}, {self.indices})'
1285 class FixAngle(FixInternalsBase):
1286 """Constraint subobject for fixing an angle within FixInternals.
1288 Convergence is potentially problematic for angles very close to
1289 0 or 180 degrees as there is a singularity in the Cartesian derivative.
1290 Fixing planar angles is therefore not supported at the moment.
1291 """
1293 def __init__(self, targetvalue, indices, masses, cell, pbc):
1294 """Fix atom movement to construct a constant angle."""
1295 if targetvalue <= 0. or targetvalue >= 180.:
1296 raise ZeroDivisionError('Invalid targetvalue for fixed angle')
1297 indices = [list(indices) + [1.]] # angle definition with coef 1.
1298 super().__init__(targetvalue, indices, masses, cell=cell, pbc=pbc)
1300 def gather_vectors(self, pos):
1301 v0 = [pos[h] - pos[k] for h, k, l in self.indices]
1302 v1 = [pos[l] - pos[k] for h, k, l in self.indices]
1303 return v0, v1
1305 def get_jacobian(self, pos):
1306 v0, v1 = self.gather_vectors(pos)
1307 derivs = get_angles_derivatives(v0, v1, cell=self.cell,
1308 pbc=self.pbc)
1309 return self.finalize_jacobian(pos, len(v0), 3, derivs)
1311 def setup_jacobian(self, pos):
1312 self.jacobian = self.get_jacobian(pos)
1314 def adjust_positions(self, oldpos, newpos):
1315 v0, v1 = self.gather_vectors(newpos)
1316 value = get_angles(v0, v1, cell=self.cell, pbc=self.pbc)
1317 self.sigma = value - self.targetvalue
1318 self.finalize_positions(newpos)
1320 @staticmethod
1321 def get_value(atoms, indices, mic):
1322 return atoms.get_angle(*indices, mic=mic)
1324 def __repr__(self):
1325 return f'FixAngle({self.targetvalue}, {self.indices})'
1327 class FixDihedral(FixInternalsBase):
1328 """Constraint subobject for fixing a dihedral angle within FixInternals.
1330 A dihedral becomes undefined when at least one of the inner two angles
1331 becomes planar. Make sure to avoid this situation.
1332 """
1334 def __init__(self, targetvalue, indices, masses, cell, pbc):
1335 indices = [list(indices) + [1.]] # dihedral def. with coef 1.
1336 super().__init__(targetvalue, indices, masses, cell=cell, pbc=pbc)
1338 def gather_vectors(self, pos):
1339 v0 = [pos[k] - pos[h] for h, k, l, m in self.indices]
1340 v1 = [pos[l] - pos[k] for h, k, l, m in self.indices]
1341 v2 = [pos[m] - pos[l] for h, k, l, m in self.indices]
1342 return v0, v1, v2
1344 def get_jacobian(self, pos):
1345 v0, v1, v2 = self.gather_vectors(pos)
1346 derivs = get_dihedrals_derivatives(v0, v1, v2, cell=self.cell,
1347 pbc=self.pbc)
1348 return self.finalize_jacobian(pos, len(v0), 4, derivs)
1350 def setup_jacobian(self, pos):
1351 self.jacobian = self.get_jacobian(pos)
1353 def adjust_positions(self, oldpos, newpos):
1354 v0, v1, v2 = self.gather_vectors(newpos)
1355 value = get_dihedrals(v0, v1, v2, cell=self.cell, pbc=self.pbc)
1356 # apply minimum dihedral difference 'convention': (diff <= 180)
1357 self.sigma = (value - self.targetvalue + 180) % 360 - 180
1358 self.finalize_positions(newpos)
1360 @staticmethod
1361 def get_value(atoms, indices, mic):
1362 return atoms.get_dihedral(*indices, mic=mic)
1364 def __repr__(self):
1365 return f'FixDihedral({self.targetvalue}, {self.indices})'
1368class FixParametricRelations(FixConstraint):
1370 def __init__(
1371 self,
1372 indices,
1373 Jacobian,
1374 const_shift,
1375 params=None,
1376 eps=1e-12,
1377 use_cell=False,
1378 ):
1379 """Constrains the degrees of freedom to act in a reduced parameter
1380 space defined by the Jacobian
1382 These constraints are based off the work in:
1383 https://arxiv.org/abs/1908.01610
1385 The constraints linearly maps the full 3N degrees of freedom,
1386 where N is number of active lattice vectors/atoms onto a
1387 reduced subset of M free parameters, where M <= 3*N. The
1388 Jacobian matrix and constant shift vector map the full set of
1389 degrees of freedom onto the reduced parameter space.
1391 Currently the constraint is set up to handle either atomic
1392 positions or lattice vectors at one time, but not both. To do
1393 both simply add a two constraints for each set. This is done
1394 to keep the mathematics behind the operations separate.
1396 It would be possible to extend these constraints to allow
1397 non-linear transformations if functionality to update the
1398 Jacobian at each position update was included. This would
1399 require passing an update function evaluate it every time
1400 adjust_positions is callled. This is currently NOT supported,
1401 and there are no plans to implement it in the future.
1403 Args:
1404 indices (list of int): indices of the constrained atoms
1405 (if not None or empty then cell_indices must be None or Empty)
1406 Jacobian (np.ndarray(shape=(3*len(indices), len(params)))):
1407 The Jacobian describing
1408 the parameter space transformation
1409 const_shift (np.ndarray(shape=(3*len(indices)))):
1410 A vector describing the constant term
1411 in the transformation not accounted for in the Jacobian
1412 params (list of str):
1413 parameters used in the parametric representation
1414 if None a list is generated based on the shape of the Jacobian
1415 eps (float): a small number to compare the similarity of
1416 numbers and set the precision used
1417 to generate the constraint expressions
1418 use_cell (bool): if True then act on the cell object
1420 """
1421 self.indices = np.array(indices)
1422 self.Jacobian = np.array(Jacobian)
1423 self.const_shift = np.array(const_shift)
1425 assert self.const_shift.shape[0] == 3 * len(self.indices)
1426 assert self.Jacobian.shape[0] == 3 * len(self.indices)
1428 self.eps = eps
1429 self.use_cell = use_cell
1431 if params is None:
1432 params = []
1433 if self.Jacobian.shape[1] > 0:
1434 int_fmt_str = "{:0" + \
1435 str(int(np.ceil(np.log10(self.Jacobian.shape[1])))) + "d}"
1436 for param_ind in range(self.Jacobian.shape[1]):
1437 params.append("param_" + int_fmt_str.format(param_ind))
1438 else:
1439 assert len(params) == self.Jacobian.shape[-1]
1441 self.params = params
1443 self.Jacobian_inv = np.linalg.inv(
1444 self.Jacobian.T @ self.Jacobian) @ self.Jacobian.T
1446 @classmethod
1447 def from_expressions(cls, indices, params, expressions,
1448 eps=1e-12, use_cell=False):
1449 """Converts the expressions into a Jacobian Matrix/const_shift
1450 vector and constructs a FixParametricRelations constraint
1452 The expressions must be a list like object of size 3*N and
1453 elements must be ordered as:
1454 [n_0,i; n_0,j; n_0,k; n_1,i; n_1,j; .... ; n_N-1,i; n_N-1,j; n_N-1,k],
1455 where i, j, and k are the first, second and third
1456 component of the atomic position/lattice
1457 vector. Currently only linear operations are allowed to be
1458 included in the expressions so
1459 only terms like:
1460 - const * param_0
1461 - sqrt[const] * param_1
1462 - const * param_0 +/- const * param_1 +/- ... +/- const * param_M
1463 where const is any real number and param_0, param_1, ..., param_M are
1464 the parameters passed in
1465 params, are allowed.
1467 For example, fractional atomic position constraints for wurtzite are:
1468 params = ["z1", "z2"]
1469 expressions = [
1470 "1.0/3.0", "2.0/3.0", "z1",
1471 "2.0/3.0", "1.0/3.0", "0.5 + z1",
1472 "1.0/3.0", "2.0/3.0", "z2",
1473 "2.0/3.0", "1.0/3.0", "0.5 + z2",
1474 ]
1476 For diamond are:
1477 params = []
1478 expressions = [
1479 "0.0", "0.0", "0.0",
1480 "0.25", "0.25", "0.25",
1481 ],
1483 and for stannite are
1484 params=["x4", "z4"]
1485 expressions = [
1486 "0.0", "0.0", "0.0",
1487 "0.0", "0.5", "0.5",
1488 "0.75", "0.25", "0.5",
1489 "0.25", "0.75", "0.5",
1490 "x4 + z4", "x4 + z4", "2*x4",
1491 "x4 - z4", "x4 - z4", "-2*x4",
1492 "0.0", "-1.0 * (x4 + z4)", "x4 - z4",
1493 "0.0", "x4 - z4", "-1.0 * (x4 + z4)",
1494 ]
1496 Args:
1497 indices (list of int): indices of the constrained atoms
1498 (if not None or empty then cell_indices must be None or Empty)
1499 params (list of str): parameters used in the
1500 parametric representation
1501 expressions (list of str): expressions used to convert from the
1502 parametric to the real space representation
1503 eps (float): a small number to compare the similarity of
1504 numbers and set the precision used
1505 to generate the constraint expressions
1506 use_cell (bool): if True then act on the cell object
1508 Returns:
1509 cls(
1510 indices,
1511 Jacobian generated from expressions,
1512 const_shift generated from expressions,
1513 params,
1514 eps-12,
1515 use_cell,
1516 )
1517 """
1518 Jacobian = np.zeros((3 * len(indices), len(params)))
1519 const_shift = np.zeros(3 * len(indices))
1521 for expr_ind, expression in enumerate(expressions):
1522 expression = expression.strip()
1524 # Convert subtraction to addition
1525 expression = expression.replace("-", "+(-1.0)*")
1526 if expression[0] == "+":
1527 expression = expression[1:]
1528 elif expression[:2] == "(+":
1529 expression = "(" + expression[2:]
1531 # Explicitly add leading zeros so when replacing param_1 with 0.0
1532 # param_11 does not become 0.01
1533 int_fmt_str = "{:0" + \
1534 str(int(np.ceil(np.log10(len(params) + 1)))) + "d}"
1536 param_dct = {}
1537 param_map = {}
1539 # Construct a standardized param template for A/B filling
1540 for param_ind, param in enumerate(params):
1541 param_str = "param_" + int_fmt_str.format(param_ind)
1542 param_map[param] = param_str
1543 param_dct[param_str] = 0.0
1545 # Replace the parameters according to the map
1546 # Sort by string length (long to short) to prevent cases like x11
1547 # becoming f"{param_map["x1"]}1"
1548 for param in sorted(params, key=lambda s: -1.0 * len(s)):
1549 expression = expression.replace(param, param_map[param])
1551 # Partial linearity check
1552 for express_sec in expression.split("+"):
1553 in_sec = [param in express_sec for param in param_dct]
1554 n_params_in_sec = len(np.where(np.array(in_sec))[0])
1555 if n_params_in_sec > 1:
1556 raise ValueError(
1557 "FixParametricRelations expressions must be linear.")
1559 const_shift[expr_ind] = float(
1560 eval_expression(expression, param_dct))
1562 for param_ind in range(len(params)):
1563 param_str = "param_" + int_fmt_str.format(param_ind)
1564 if param_str not in expression:
1565 Jacobian[expr_ind, param_ind] = 0.0
1566 continue
1567 param_dct[param_str] = 1.0
1568 test_1 = float(eval_expression(expression, param_dct))
1569 test_1 -= const_shift[expr_ind]
1570 Jacobian[expr_ind, param_ind] = test_1
1572 param_dct[param_str] = 2.0
1573 test_2 = float(eval_expression(expression, param_dct))
1574 test_2 -= const_shift[expr_ind]
1575 if abs(test_2 / test_1 - 2.0) > eps:
1576 raise ValueError(
1577 "FixParametricRelations expressions must be linear.")
1578 param_dct[param_str] = 0.0
1580 args = [
1581 indices,
1582 Jacobian,
1583 const_shift,
1584 params,
1585 eps,
1586 use_cell,
1587 ]
1588 if cls is FixScaledParametricRelations:
1589 args = args[:-1]
1590 return cls(*args)
1592 @property
1593 def expressions(self):
1594 """Generate the expressions represented by the current self.Jacobian
1595 and self.const_shift objects"""
1596 expressions = []
1597 per = int(round(-1 * np.log10(self.eps)))
1598 fmt_str = "{:." + str(per + 1) + "g}"
1599 for index, shift_val in enumerate(self.const_shift):
1600 exp = ""
1601 if np.all(np.abs(self.Jacobian[index]) < self.eps) or np.abs(
1602 shift_val) > self.eps:
1603 exp += fmt_str.format(shift_val)
1605 param_exp = ""
1606 for param_index, jacob_val in enumerate(self.Jacobian[index]):
1607 abs_jacob_val = np.round(np.abs(jacob_val), per + 1)
1608 if abs_jacob_val < self.eps:
1609 continue
1611 param = self.params[param_index]
1612 if param_exp or exp:
1613 if jacob_val > -1.0 * self.eps:
1614 param_exp += " + "
1615 else:
1616 param_exp += " - "
1617 elif (not exp) and (not param_exp) and (
1618 jacob_val < -1.0 * self.eps):
1619 param_exp += "-"
1621 if np.abs(abs_jacob_val - 1.0) <= self.eps:
1622 param_exp += f"{param:s}"
1623 else:
1624 param_exp += (fmt_str +
1625 "*{:s}").format(abs_jacob_val, param)
1627 exp += param_exp
1629 expressions.append(exp)
1630 return np.array(expressions).reshape((-1, 3))
1632 def todict(self):
1633 """Create a dictionary representation of the constraint"""
1634 return {
1635 "name": type(self).__name__,
1636 "kwargs": {
1637 "indices": self.indices,
1638 "params": self.params,
1639 "Jacobian": self.Jacobian,
1640 "const_shift": self.const_shift,
1641 "eps": self.eps,
1642 "use_cell": self.use_cell,
1643 }
1644 }
1646 def __repr__(self):
1647 """The str representation of the constraint"""
1648 if len(self.indices) > 1:
1649 indices_str = "[{:d}, ..., {:d}]".format(
1650 self.indices[0], self.indices[-1])
1651 else:
1652 indices_str = f"[{self.indices[0]:d}]"
1654 if len(self.params) > 1:
1655 params_str = "[{:s}, ..., {:s}]".format(
1656 self.params[0], self.params[-1])
1657 elif len(self.params) == 1:
1658 params_str = f"[{self.params[0]:s}]"
1659 else:
1660 params_str = "[]"
1662 return '{:s}({:s}, {:s}, ..., {:e})'.format(
1663 type(self).__name__,
1664 indices_str,
1665 params_str,
1666 self.eps
1667 )
1670class FixScaledParametricRelations(FixParametricRelations):
1672 def __init__(
1673 self,
1674 indices,
1675 Jacobian,
1676 const_shift,
1677 params=None,
1678 eps=1e-12,
1679 ):
1680 """The fractional coordinate version of FixParametricRelations
1682 All arguments are the same, but since this is for fractional
1683 coordinates use_cell is false"""
1684 super().__init__(
1685 indices,
1686 Jacobian,
1687 const_shift,
1688 params,
1689 eps,
1690 False,
1691 )
1693 def adjust_contravariant(self, cell, vecs, B):
1694 """Adjust the values of a set of vectors that are contravariant
1695 with the unit transformation"""
1696 scaled = cell.scaled_positions(vecs).flatten()
1697 scaled = self.Jacobian_inv @ (scaled - B)
1698 scaled = ((self.Jacobian @ scaled) + B).reshape((-1, 3))
1700 return cell.cartesian_positions(scaled)
1702 def adjust_positions(self, atoms, positions):
1703 """Adjust positions of the atoms to match the constraints"""
1704 positions[self.indices] = self.adjust_contravariant(
1705 atoms.cell,
1706 positions[self.indices],
1707 self.const_shift,
1708 )
1709 positions[self.indices] = self.adjust_B(
1710 atoms.cell, positions[self.indices])
1712 def adjust_B(self, cell, positions):
1713 """Wraps the positions back to the unit cell and adjust B to
1714 keep track of this change"""
1715 fractional = cell.scaled_positions(positions)
1716 wrapped_fractional = (fractional % 1.0) % 1.0
1717 self.const_shift += np.round(wrapped_fractional - fractional).flatten()
1718 return cell.cartesian_positions(wrapped_fractional)
1720 def adjust_momenta(self, atoms, momenta):
1721 """Adjust momenta of the atoms to match the constraints"""
1722 momenta[self.indices] = self.adjust_contravariant(
1723 atoms.cell,
1724 momenta[self.indices],
1725 np.zeros(self.const_shift.shape),
1726 )
1728 def adjust_forces(self, atoms, forces):
1729 """Adjust forces of the atoms to match the constraints"""
1730 # Forces are coavarient to the coordinate transformation, use the
1731 # inverse transformations
1732 cart2frac_jacob = np.zeros(2 * (3 * len(atoms),))
1733 for i_atom in range(len(atoms)):
1734 cart2frac_jacob[3 * i_atom:3 * (i_atom + 1),
1735 3 * i_atom:3 * (i_atom + 1)] = atoms.cell.T
1737 jacobian = cart2frac_jacob @ self.Jacobian
1738 jacobian_inv = np.linalg.inv(jacobian.T @ jacobian) @ jacobian.T
1740 reduced_forces = jacobian.T @ forces.flatten()
1741 forces[self.indices] = (jacobian_inv.T @ reduced_forces).reshape(-1, 3)
1743 def todict(self):
1744 """Create a dictionary representation of the constraint"""
1745 dct = super().todict()
1746 del dct["kwargs"]["use_cell"]
1747 return dct
1750class FixCartesianParametricRelations(FixParametricRelations):
1752 def __init__(
1753 self,
1754 indices,
1755 Jacobian,
1756 const_shift,
1757 params=None,
1758 eps=1e-12,
1759 use_cell=False,
1760 ):
1761 """The Cartesian coordinate version of FixParametricRelations"""
1762 super().__init__(
1763 indices,
1764 Jacobian,
1765 const_shift,
1766 params,
1767 eps,
1768 use_cell,
1769 )
1771 def adjust_contravariant(self, vecs, B):
1772 """Adjust the values of a set of vectors that are contravariant with
1773 the unit transformation"""
1774 vecs = self.Jacobian_inv @ (vecs.flatten() - B)
1775 vecs = ((self.Jacobian @ vecs) + B).reshape((-1, 3))
1776 return vecs
1778 def adjust_positions(self, atoms, positions):
1779 """Adjust positions of the atoms to match the constraints"""
1780 if self.use_cell:
1781 return
1782 positions[self.indices] = self.adjust_contravariant(
1783 positions[self.indices],
1784 self.const_shift,
1785 )
1787 def adjust_momenta(self, atoms, momenta):
1788 """Adjust momenta of the atoms to match the constraints"""
1789 if self.use_cell:
1790 return
1791 momenta[self.indices] = self.adjust_contravariant(
1792 momenta[self.indices],
1793 np.zeros(self.const_shift.shape),
1794 )
1796 def adjust_forces(self, atoms, forces):
1797 """Adjust forces of the atoms to match the constraints"""
1798 if self.use_cell:
1799 return
1801 forces_reduced = self.Jacobian.T @ forces[self.indices].flatten()
1802 forces[self.indices] = (self.Jacobian_inv.T @
1803 forces_reduced).reshape(-1, 3)
1805 def adjust_cell(self, atoms, cell):
1806 """Adjust the cell of the atoms to match the constraints"""
1807 if not self.use_cell:
1808 return
1809 cell[self.indices] = self.adjust_contravariant(
1810 cell[self.indices],
1811 np.zeros(self.const_shift.shape),
1812 )
1814 def adjust_stress(self, atoms, stress):
1815 """Adjust the stress of the atoms to match the constraints"""
1816 if not self.use_cell:
1817 return
1819 stress_3x3 = voigt_6_to_full_3x3_stress(stress)
1820 stress_reduced = self.Jacobian.T @ stress_3x3[self.indices].flatten()
1821 stress_3x3[self.indices] = (
1822 self.Jacobian_inv.T @ stress_reduced).reshape(-1, 3)
1824 stress[:] = full_3x3_to_voigt_6_stress(stress_3x3)
1827class Hookean(FixConstraint):
1828 """Applies a Hookean restorative force between a pair of atoms, an atom
1829 and a point, or an atom and a plane."""
1831 def __init__(self, a1, a2, k, rt=None):
1832 """Forces two atoms to stay close together by applying no force if
1833 they are below a threshold length, rt, and applying a Hookean
1834 restorative force when the distance between them exceeds rt. Can
1835 also be used to tether an atom to a fixed point in space or to a
1836 distance above a plane.
1838 a1 : int
1839 Index of atom 1
1840 a2 : one of three options
1841 1) index of atom 2
1842 2) a fixed point in cartesian space to which to tether a1
1843 3) a plane given as (A, B, C, D) in A x + B y + C z + D = 0.
1844 k : float
1845 Hooke's law (spring) constant to apply when distance
1846 exceeds threshold_length. Units of eV A^-2.
1847 rt : float
1848 The threshold length below which there is no force. The
1849 length is 1) between two atoms, 2) between atom and point.
1850 This argument is not supplied in case 3. Units of A.
1852 If a plane is specified, the Hooke's law force is applied if the atom
1853 is on the normal side of the plane. For instance, the plane with
1854 (A, B, C, D) = (0, 0, 1, -7) defines a plane in the xy plane with a z
1855 intercept of +7 and a normal vector pointing in the +z direction.
1856 If the atom has z > 7, then a downward force would be applied of
1857 k * (atom.z - 7). The same plane with the normal vector pointing in
1858 the -z direction would be given by (A, B, C, D) = (0, 0, -1, 7).
1860 References:
1862 Andrew A. Peterson, Topics in Catalysis volume 57, pages40–53 (2014)
1863 https://link.springer.com/article/10.1007%2Fs11244-013-0161-8
1864 """
1866 if isinstance(a2, int):
1867 self._type = 'two atoms'
1868 self.indices = [a1, a2]
1869 elif len(a2) == 3:
1870 self._type = 'point'
1871 self.index = a1
1872 self.origin = np.array(a2)
1873 elif len(a2) == 4:
1874 self._type = 'plane'
1875 self.index = a1
1876 self.plane = a2
1877 else:
1878 raise RuntimeError('Unknown type for a2')
1879 self.threshold = rt
1880 self.spring = k
1882 def get_removed_dof(self, atoms):
1883 return 0
1885 def todict(self):
1886 dct = {'name': 'Hookean'}
1887 dct['kwargs'] = {'rt': self.threshold,
1888 'k': self.spring}
1889 if self._type == 'two atoms':
1890 dct['kwargs']['a1'] = self.indices[0]
1891 dct['kwargs']['a2'] = self.indices[1]
1892 elif self._type == 'point':
1893 dct['kwargs']['a1'] = self.index
1894 dct['kwargs']['a2'] = self.origin
1895 elif self._type == 'plane':
1896 dct['kwargs']['a1'] = self.index
1897 dct['kwargs']['a2'] = self.plane
1898 else:
1899 raise NotImplementedError(f'Bad type: {self._type}')
1900 return dct
1902 def adjust_positions(self, atoms, newpositions):
1903 pass
1905 def adjust_momenta(self, atoms, momenta):
1906 pass
1908 def adjust_forces(self, atoms, forces):
1909 positions = atoms.positions
1910 if self._type == 'plane':
1911 A, B, C, D = self.plane
1912 x, y, z = positions[self.index]
1913 d = ((A * x + B * y + C * z + D) /
1914 np.sqrt(A**2 + B**2 + C**2))
1915 if d < 0:
1916 return
1917 magnitude = self.spring * d
1918 direction = - np.array((A, B, C)) / np.linalg.norm((A, B, C))
1919 forces[self.index] += direction * magnitude
1920 return
1921 if self._type == 'two atoms':
1922 p1, p2 = positions[self.indices]
1923 elif self._type == 'point':
1924 p1 = positions[self.index]
1925 p2 = self.origin
1926 displace, _ = find_mic(p2 - p1, atoms.cell, atoms.pbc)
1927 bondlength = np.linalg.norm(displace)
1928 if bondlength > self.threshold:
1929 magnitude = self.spring * (bondlength - self.threshold)
1930 direction = displace / np.linalg.norm(displace)
1931 if self._type == 'two atoms':
1932 forces[self.indices[0]] += direction * magnitude
1933 forces[self.indices[1]] -= direction * magnitude
1934 else:
1935 forces[self.index] += direction * magnitude
1937 def adjust_potential_energy(self, atoms):
1938 """Returns the difference to the potential energy due to an active
1939 constraint. (That is, the quantity returned is to be added to the
1940 potential energy.)"""
1941 positions = atoms.positions
1942 if self._type == 'plane':
1943 A, B, C, D = self.plane
1944 x, y, z = positions[self.index]
1945 d = ((A * x + B * y + C * z + D) /
1946 np.sqrt(A**2 + B**2 + C**2))
1947 if d > 0:
1948 return 0.5 * self.spring * d**2
1949 else:
1950 return 0.
1951 if self._type == 'two atoms':
1952 p1, p2 = positions[self.indices]
1953 elif self._type == 'point':
1954 p1 = positions[self.index]
1955 p2 = self.origin
1956 displace, _ = find_mic(p2 - p1, atoms.cell, atoms.pbc)
1957 bondlength = np.linalg.norm(displace)
1958 if bondlength > self.threshold:
1959 return 0.5 * self.spring * (bondlength - self.threshold)**2
1960 else:
1961 return 0.
1963 def get_indices(self):
1964 if self._type == 'two atoms':
1965 return self.indices
1966 elif self._type == 'point':
1967 return self.index
1968 elif self._type == 'plane':
1969 return self.index
1971 def index_shuffle(self, atoms, ind):
1972 # See docstring of superclass
1973 if self._type == 'two atoms':
1974 newa = [-1, -1] # Signal error
1975 for new, old in slice2enlist(ind, len(atoms)):
1976 for i, a in enumerate(self.indices):
1977 if old == a:
1978 newa[i] = new
1979 if newa[0] == -1 or newa[1] == -1:
1980 raise IndexError('Constraint not part of slice')
1981 self.indices = newa
1982 elif (self._type == 'point') or (self._type == 'plane'):
1983 newa = -1 # Signal error
1984 for new, old in slice2enlist(ind, len(atoms)):
1985 if old == self.index:
1986 newa = new
1987 break
1988 if newa == -1:
1989 raise IndexError('Constraint not part of slice')
1990 self.index = newa
1992 def __repr__(self):
1993 if self._type == 'two atoms':
1994 return 'Hookean(%d, %d)' % tuple(self.indices)
1995 elif self._type == 'point':
1996 return 'Hookean(%d) to cartesian' % self.index
1997 else:
1998 return 'Hookean(%d) to plane' % self.index
2001class ExternalForce(FixConstraint):
2002 """Constraint object for pulling two atoms apart by an external force.
2004 You can combine this constraint for example with FixBondLength but make
2005 sure that *ExternalForce* comes first in the list if there are overlaps
2006 between atom1-2 and atom3-4:
2008 >>> from ase.build import bulk
2010 >>> atoms = bulk('Cu', 'fcc', a=3.6)
2011 >>> atom1, atom2, atom3, atom4 = atoms[:4]
2012 >>> fext = 1.0
2013 >>> con1 = ExternalForce(atom1, atom2, f_ext)
2014 >>> con2 = FixBondLength(atom3, atom4)
2015 >>> atoms.set_constraint([con1, con2])
2017 see ase/test/external_force.py"""
2019 def __init__(self, a1, a2, f_ext):
2020 self.indices = [a1, a2]
2021 self.external_force = f_ext
2023 def get_removed_dof(self, atoms):
2024 return 0
2026 def adjust_positions(self, atoms, new):
2027 pass
2029 def adjust_forces(self, atoms, forces):
2030 dist = np.subtract.reduce(atoms.positions[self.indices])
2031 force = self.external_force * dist / np.linalg.norm(dist)
2032 forces[self.indices] += (force, -force)
2034 def adjust_potential_energy(self, atoms):
2035 dist = np.subtract.reduce(atoms.positions[self.indices])
2036 return -np.linalg.norm(dist) * self.external_force
2038 def index_shuffle(self, atoms, ind):
2039 """Shuffle the indices of the two atoms in this constraint"""
2040 newa = [-1, -1] # Signal error
2041 for new, old in slice2enlist(ind, len(atoms)):
2042 for i, a in enumerate(self.indices):
2043 if old == a:
2044 newa[i] = new
2045 if newa[0] == -1 or newa[1] == -1:
2046 raise IndexError('Constraint not part of slice')
2047 self.indices = newa
2049 def __repr__(self):
2050 return 'ExternalForce(%d, %d, %f)' % (self.indices[0],
2051 self.indices[1],
2052 self.external_force)
2054 def todict(self):
2055 return {'name': 'ExternalForce',
2056 'kwargs': {'a1': self.indices[0], 'a2': self.indices[1],
2057 'f_ext': self.external_force}}
2060class MirrorForce(FixConstraint):
2061 """Constraint object for mirroring the force between two atoms.
2063 This class is designed to find a transition state with the help of a
2064 single optimization. It can be used if the transition state belongs to a
2065 bond breaking reaction. First the given bond length will be fixed until
2066 all other degrees of freedom are optimized, then the forces of the two
2067 atoms will be mirrored to find the transition state. The mirror plane is
2068 perpendicular to the connecting line of the atoms. Transition states in
2069 dependence of the force can be obtained by stretching the molecule and
2070 fixing its total length with *FixBondLength* or by using *ExternalForce*
2071 during the optimization with *MirrorForce*.
2073 Parameters
2074 ----------
2075 a1: int
2076 First atom index.
2077 a2: int
2078 Second atom index.
2079 max_dist: float
2080 Upper limit of the bond length interval where the transition state
2081 can be found.
2082 min_dist: float
2083 Lower limit of the bond length interval where the transition state
2084 can be found.
2085 fmax: float
2086 Maximum force used for the optimization.
2088 Notes
2089 -----
2090 You can combine this constraint for example with FixBondLength but make
2091 sure that *MirrorForce* comes first in the list if there are overlaps
2092 between atom1-2 and atom3-4:
2094 >>> from ase.build import bulk
2096 >>> atoms = bulk('Cu', 'fcc', a=3.6)
2097 >>> atom1, atom2, atom3, atom4 = atoms[:4]
2098 >>> con1 = MirrorForce(atom1, atom2)
2099 >>> con2 = FixBondLength(atom3, atom4)
2100 >>> atoms.set_constraint([con1, con2])
2102 """
2104 def __init__(self, a1, a2, max_dist=2.5, min_dist=1., fmax=0.1):
2105 self.indices = [a1, a2]
2106 self.min_dist = min_dist
2107 self.max_dist = max_dist
2108 self.fmax = fmax
2110 def adjust_positions(self, atoms, new):
2111 pass
2113 def adjust_forces(self, atoms, forces):
2114 dist = np.subtract.reduce(atoms.positions[self.indices])
2115 d = np.linalg.norm(dist)
2116 if (d < self.min_dist) or (d > self.max_dist):
2117 # Stop structure optimization
2118 forces[:] *= 0
2119 return
2120 dist /= d
2121 df = np.subtract.reduce(forces[self.indices])
2122 f = df.dot(dist)
2123 con_saved = atoms.constraints
2124 try:
2125 con = [con for con in con_saved
2126 if not isinstance(con, MirrorForce)]
2127 atoms.set_constraint(con)
2128 forces_copy = atoms.get_forces()
2129 finally:
2130 atoms.set_constraint(con_saved)
2131 df1 = -1 / 2. * f * dist
2132 forces_copy[self.indices] += (df1, -df1)
2133 # Check if forces would be converged if the bond with mirrored forces
2134 # would also be fixed
2135 if (forces_copy**2).sum(axis=1).max() < self.fmax**2:
2136 factor = 1.
2137 else:
2138 factor = 0.
2139 df1 = -(1 + factor) / 2. * f * dist
2140 forces[self.indices] += (df1, -df1)
2142 def index_shuffle(self, atoms, ind):
2143 """Shuffle the indices of the two atoms in this constraint
2145 """
2146 newa = [-1, -1] # Signal error
2147 for new, old in slice2enlist(ind, len(atoms)):
2148 for i, a in enumerate(self.indices):
2149 if old == a:
2150 newa[i] = new
2151 if newa[0] == -1 or newa[1] == -1:
2152 raise IndexError('Constraint not part of slice')
2153 self.indices = newa
2155 def __repr__(self):
2156 return 'MirrorForce(%d, %d, %f, %f, %f)' % (
2157 self.indices[0], self.indices[1], self.max_dist, self.min_dist,
2158 self.fmax)
2160 def todict(self):
2161 return {'name': 'MirrorForce',
2162 'kwargs': {'a1': self.indices[0], 'a2': self.indices[1],
2163 'max_dist': self.max_dist,
2164 'min_dist': self.min_dist, 'fmax': self.fmax}}
2167class MirrorTorque(FixConstraint):
2168 """Constraint object for mirroring the torque acting on a dihedral
2169 angle defined by four atoms.
2171 This class is designed to find a transition state with the help of a
2172 single optimization. It can be used if the transition state belongs to a
2173 cis-trans-isomerization with a change of dihedral angle. First the given
2174 dihedral angle will be fixed until all other degrees of freedom are
2175 optimized, then the torque acting on the dihedral angle will be mirrored
2176 to find the transition state. Transition states in
2177 dependence of the force can be obtained by stretching the molecule and
2178 fixing its total length with *FixBondLength* or by using *ExternalForce*
2179 during the optimization with *MirrorTorque*.
2181 This constraint can be used to find
2182 transition states of cis-trans-isomerization.
2184 a1 a4
2185 | |
2186 a2 __ a3
2188 Parameters
2189 ----------
2190 a1: int
2191 First atom index.
2192 a2: int
2193 Second atom index.
2194 a3: int
2195 Third atom index.
2196 a4: int
2197 Fourth atom index.
2198 max_angle: float
2199 Upper limit of the dihedral angle interval where the transition state
2200 can be found.
2201 min_angle: float
2202 Lower limit of the dihedral angle interval where the transition state
2203 can be found.
2204 fmax: float
2205 Maximum force used for the optimization.
2207 Notes
2208 -----
2209 You can combine this constraint for example with FixBondLength but make
2210 sure that *MirrorTorque* comes first in the list if there are overlaps
2211 between atom1-4 and atom5-6:
2213 >>> from ase.build import bulk
2215 >>> atoms = bulk('Cu', 'fcc', a=3.6)
2216 >>> atom1, atom2, atom3, atom4, atom5, atom6 = atoms[:6]
2217 >>> con1 = MirrorTorque(atom1, atom2, atom3, atom4)
2218 >>> con2 = FixBondLength(atom5, atom6)
2219 >>> atoms.set_constraint([con1, con2])
2221 """
2223 def __init__(self, a1, a2, a3, a4, max_angle=2 * np.pi, min_angle=0.,
2224 fmax=0.1):
2225 self.indices = [a1, a2, a3, a4]
2226 self.min_angle = min_angle
2227 self.max_angle = max_angle
2228 self.fmax = fmax
2230 def adjust_positions(self, atoms, new):
2231 pass
2233 def adjust_forces(self, atoms, forces):
2234 angle = atoms.get_dihedral(self.indices[0], self.indices[1],
2235 self.indices[2], self.indices[3])
2236 angle *= np.pi / 180.
2237 if (angle < self.min_angle) or (angle > self.max_angle):
2238 # Stop structure optimization
2239 forces[:] *= 0
2240 return
2241 p = atoms.positions[self.indices]
2242 f = forces[self.indices]
2244 f0 = (f[1] + f[2]) / 2.
2245 ff = f - f0
2246 p0 = (p[2] + p[1]) / 2.
2247 m0 = np.cross(p[1] - p0, ff[1]) / (p[1] - p0).dot(p[1] - p0)
2248 fff = ff - np.cross(m0, p - p0)
2249 d1 = np.cross(np.cross(p[1] - p0, p[0] - p[1]), p[1] - p0) / \
2250 (p[1] - p0).dot(p[1] - p0)
2251 d2 = np.cross(np.cross(p[2] - p0, p[3] - p[2]), p[2] - p0) / \
2252 (p[2] - p0).dot(p[2] - p0)
2253 omegap1 = (np.cross(d1, fff[0]) / d1.dot(d1)).dot(p[1] - p0) / \
2254 np.linalg.norm(p[1] - p0)
2255 omegap2 = (np.cross(d2, fff[3]) / d2.dot(d2)).dot(p[2] - p0) / \
2256 np.linalg.norm(p[2] - p0)
2257 omegap = omegap1 + omegap2
2258 con_saved = atoms.constraints
2259 try:
2260 con = [con for con in con_saved
2261 if not isinstance(con, MirrorTorque)]
2262 atoms.set_constraint(con)
2263 forces_copy = atoms.get_forces()
2264 finally:
2265 atoms.set_constraint(con_saved)
2266 df1 = -1 / 2. * omegap * np.cross(p[1] - p0, d1) / \
2267 np.linalg.norm(p[1] - p0)
2268 df2 = -1 / 2. * omegap * np.cross(p[2] - p0, d2) / \
2269 np.linalg.norm(p[2] - p0)
2270 forces_copy[self.indices] += (df1, [0., 0., 0.], [0., 0., 0.], df2)
2271 # Check if forces would be converged if the dihedral angle with
2272 # mirrored torque would also be fixed
2273 if (forces_copy**2).sum(axis=1).max() < self.fmax**2:
2274 factor = 1.
2275 else:
2276 factor = 0.
2277 df1 = -(1 + factor) / 2. * omegap * np.cross(p[1] - p0, d1) / \
2278 np.linalg.norm(p[1] - p0)
2279 df2 = -(1 + factor) / 2. * omegap * np.cross(p[2] - p0, d2) / \
2280 np.linalg.norm(p[2] - p0)
2281 forces[self.indices] += (df1, [0., 0., 0.], [0., 0., 0.], df2)
2283 def index_shuffle(self, atoms, ind):
2284 # See docstring of superclass
2285 indices = []
2286 for new, old in slice2enlist(ind, len(atoms)):
2287 if old in self.indices:
2288 indices.append(new)
2289 if len(indices) == 0:
2290 raise IndexError('All indices in MirrorTorque not part of slice')
2291 self.indices = np.asarray(indices, int)
2293 def __repr__(self):
2294 return 'MirrorTorque(%d, %d, %d, %d, %f, %f, %f)' % (
2295 self.indices[0], self.indices[1], self.indices[2],
2296 self.indices[3], self.max_angle, self.min_angle, self.fmax)
2298 def todict(self):
2299 return {'name': 'MirrorTorque',
2300 'kwargs': {'a1': self.indices[0], 'a2': self.indices[1],
2301 'a3': self.indices[2], 'a4': self.indices[3],
2302 'max_angle': self.max_angle,
2303 'min_angle': self.min_angle, 'fmax': self.fmax}}
2306class FixSymmetry(FixConstraint):
2307 """
2308 Constraint to preserve spacegroup symmetry during optimisation.
2310 Requires spglib package to be available.
2311 """
2313 def __init__(self, atoms, symprec=0.01, adjust_positions=True,
2314 adjust_cell=True, verbose=False):
2315 self.atoms = atoms.copy()
2316 self.symprec = symprec
2317 self.verbose = verbose
2318 refine_symmetry(atoms, symprec, self.verbose) # refine initial symmetry
2319 sym = prep_symmetry(atoms, symprec, self.verbose)
2320 self.rotations, self.translations, self.symm_map = sym
2321 self.do_adjust_positions = adjust_positions
2322 self.do_adjust_cell = adjust_cell
2324 def adjust_cell(self, atoms, cell):
2325 if not self.do_adjust_cell:
2326 return
2327 # stress should definitely be symmetrized as a rank 2 tensor
2328 # UnitCellFilter uses deformation gradient as cell DOF with steps
2329 # dF = stress.F^-T quantity that should be symmetrized is therefore dF .
2330 # F^T assume prev F = I, so just symmetrize dF
2331 cur_cell = atoms.get_cell()
2332 cur_cell_inv = atoms.cell.reciprocal().T
2334 # F defined such that cell = cur_cell . F^T
2335 # assume prev F = I, so dF = F - I
2336 delta_deform_grad = np.dot(cur_cell_inv, cell).T - np.eye(3)
2338 # symmetrization doesn't work properly with large steps, since
2339 # it depends on current cell, and cell is being changed by deformation
2340 # gradient
2341 max_delta_deform_grad = np.max(np.abs(delta_deform_grad))
2342 if max_delta_deform_grad > 0.25:
2343 raise RuntimeError('FixSymmetry adjust_cell does not work properly'
2344 ' with large deformation gradient step {} > 0.25'
2345 .format(max_delta_deform_grad))
2346 elif max_delta_deform_grad > 0.15:
2347 warn('FixSymmetry adjust_cell may be ill behaved with large '
2348 'deformation gradient step {}'.format(max_delta_deform_grad))
2350 symmetrized_delta_deform_grad = symmetrize_rank2(cur_cell, cur_cell_inv,
2351 delta_deform_grad,
2352 self.rotations)
2353 cell[:] = np.dot(cur_cell,
2354 (symmetrized_delta_deform_grad + np.eye(3)).T)
2356 def adjust_positions(self, atoms, new):
2357 if not self.do_adjust_positions:
2358 return
2359 # symmetrize changes in position as rank 1 tensors
2360 step = new - atoms.positions
2361 symmetrized_step = symmetrize_rank1(atoms.get_cell(),
2362 atoms.cell.reciprocal().T, step,
2363 self.rotations, self.translations,
2364 self.symm_map)
2365 new[:] = atoms.positions + symmetrized_step
2367 def adjust_forces(self, atoms, forces):
2368 # symmetrize forces as rank 1 tensors
2369 # print('adjusting forces')
2370 forces[:] = symmetrize_rank1(atoms.get_cell(),
2371 atoms.cell.reciprocal().T, forces,
2372 self.rotations, self.translations,
2373 self.symm_map)
2375 def adjust_stress(self, atoms, stress):
2376 # symmetrize stress as rank 2 tensor
2377 raw_stress = voigt_6_to_full_3x3_stress(stress)
2378 symmetrized_stress = symmetrize_rank2(atoms.get_cell(),
2379 atoms.cell.reciprocal().T,
2380 raw_stress, self.rotations)
2381 stress[:] = full_3x3_to_voigt_6_stress(symmetrized_stress)
2383 def index_shuffle(self, atoms, ind):
2384 if len(atoms) != len(ind) or len(set(ind)) != len(ind):
2385 raise RuntimeError("FixSymmetry can only accomodate atom"
2386 " permutions, and len(Atoms) == {} "
2387 "!= len(ind) == {} or ind has duplicates"
2388 .format(len(atoms), len(ind)))
2390 ind_reversed = np.zeros((len(ind)), dtype=int)
2391 ind_reversed[ind] = range(len(ind))
2392 new_symm_map = []
2393 for sm in self.symm_map:
2394 new_sm = np.array([-1] * len(atoms))
2395 for at_i in range(len(ind)):
2396 new_sm[ind_reversed[at_i]] = ind_reversed[sm[at_i]]
2397 new_symm_map.append(new_sm)
2399 self.symm_map = new_symm_map
2401 def todict(self):
2402 return {
2403 'name': 'FixSymmetry',
2404 'kwargs': {
2405 'atoms': self.atoms,
2406 'symprec': self.symprec,
2407 'adjust_positions': self.do_adjust_positions,
2408 'adjust_cell': self.do_adjust_cell,
2409 'verbose': self.verbose,
2410 },
2411 }