Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1"""Soft-mutation operator and associated tools"""
2import inspect
3import json
4import numpy as np
5from ase.data import covalent_radii
6from ase.neighborlist import NeighborList
7from ase.ga.offspring_creator import OffspringCreator
8from ase.ga.utilities import atoms_too_close, gather_atoms_by_tag
9from scipy.spatial.distance import cdist
12class TagFilter:
13 """Filter which constrains same-tag atoms to behave
14 like internally rigid moieties.
15 """
16 def __init__(self, atoms):
17 self.atoms = atoms
18 gather_atoms_by_tag(self.atoms)
19 self.tags = self.atoms.get_tags()
20 self.unique_tags = np.unique(self.tags)
21 self.n = len(self.unique_tags)
23 def get_positions(self):
24 all_pos = self.atoms.get_positions()
25 cop_pos = np.zeros((self.n, 3))
26 for i in range(self.n):
27 indices = np.where(self.tags == self.unique_tags[i])
28 cop_pos[i] = np.average(all_pos[indices], axis=0)
29 return cop_pos
31 def set_positions(self, positions, **kwargs):
32 cop_pos = self.get_positions()
33 all_pos = self.atoms.get_positions()
34 assert np.all(np.shape(positions) == np.shape(cop_pos))
35 for i in range(self.n):
36 indices = np.where(self.tags == self.unique_tags[i])
37 shift = positions[i] - cop_pos[i]
38 all_pos[indices] += shift
39 self.atoms.set_positions(all_pos, **kwargs)
41 def get_forces(self, *args, **kwargs):
42 f = self.atoms.get_forces()
43 forces = np.zeros((self.n, 3))
44 for i in range(self.n):
45 indices = np.where(self.tags == self.unique_tags[i])
46 forces[i] = np.sum(f[indices], axis=0)
47 return forces
49 def get_masses(self):
50 m = self.atoms.get_masses()
51 masses = np.zeros(self.n)
52 for i in range(self.n):
53 indices = np.where(self.tags == self.unique_tags[i])
54 masses[i] = np.sum(m[indices])
55 return masses
57 def __len__(self):
58 return self.n
61class PairwiseHarmonicPotential:
62 """Parent class for interatomic potentials of the type
63 E(r_ij) = 0.5 * k_ij * (r_ij - r0_ij) ** 2
64 """
65 def __init__(self, atoms, rcut=10.):
66 self.atoms = atoms
67 self.pos0 = atoms.get_positions()
68 self.rcut = rcut
70 # build neighborlist
71 nat = len(self.atoms)
72 self.nl = NeighborList([self.rcut / 2.] * nat, skin=0., bothways=True,
73 self_interaction=False)
74 self.nl.update(self.atoms)
76 self.calculate_force_constants()
78 def calculate_force_constants(self):
79 msg = 'Child class needs to define a calculate_force_constants() ' \
80 'method which computes the force constants and stores them ' \
81 'in self.force_constants (as a list which contains, for every ' \
82 'atom, a list of the atom\'s force constants with its neighbors.'
83 raise NotImplementedError(msg)
85 def get_forces(self, atoms):
86 pos = atoms.get_positions()
87 cell = atoms.get_cell()
88 forces = np.zeros_like(pos)
90 for i, p in enumerate(pos):
91 indices, offsets = self.nl.get_neighbors(i)
92 p = pos[indices] + np.dot(offsets, cell)
93 r = cdist(p, [pos[i]])
94 v = (p - pos[i]) / r
95 p0 = self.pos0[indices] + np.dot(offsets, cell)
96 r0 = cdist(p0, [self.pos0[i]])
97 dr = r - r0
98 forces[i] = np.dot(self.force_constants[i].T, dr * v)
100 return forces
103def get_number_of_valence_electrons(Z):
104 """Return the number of valence electrons for the element with
105 atomic number Z, simply based on its periodic table group.
106 """
107 groups = [[], [1, 3, 11, 19, 37, 55, 87], [2, 4, 12, 20, 38, 56, 88],
108 [21, 39, 57, 89]]
110 for i in range(9):
111 groups.append(i + np.array([22, 40, 72, 104]))
113 for i in range(6):
114 groups.append(i + np.array([5, 13, 31, 49, 81, 113]))
116 for i, group in enumerate(groups):
117 if Z in group:
118 nval = i if i < 13 else i - 10
119 break
120 else:
121 raise ValueError('Z=%d not included in this dataset.' % Z)
123 return nval
126class BondElectroNegativityModel(PairwiseHarmonicPotential):
127 """Pairwise harmonic potential where the force constants are
128 determined using the "bond electronegativity" model, see:
130 * `Lyakhov, Oganov, Valle, Comp. Phys. Comm. 181 (2010) 1623-1632`__
132 __ https://dx.doi.org/10.1016/j.cpc.2010.06.007
134 * `Lyakhov, Oganov, Phys. Rev. B 84 (2011) 092103`__
136 __ https://dx.doi.org/10.1103/PhysRevB.84.092103
137 """
138 def calculate_force_constants(self):
139 cell = self.atoms.get_cell()
140 pos = self.atoms.get_positions()
141 num = self.atoms.get_atomic_numbers()
142 nat = len(self.atoms)
143 nl = self.nl
145 # computing the force constants
146 s_norms = []
147 valence_states = []
148 r_cov = []
149 for i in range(nat):
150 indices, offsets = nl.get_neighbors(i)
151 p = pos[indices] + np.dot(offsets, cell)
152 r = cdist(p, [pos[i]])
153 r_ci = covalent_radii[num[i]]
154 s = 0.
155 for j, index in enumerate(indices):
156 d = r[j] - r_ci - covalent_radii[num[index]]
157 s += np.exp(-d / 0.37)
158 s_norms.append(s)
159 valence_states.append(get_number_of_valence_electrons(num[i]))
160 r_cov.append(r_ci)
162 self.force_constants = []
163 for i in range(nat):
164 indices, offsets = nl.get_neighbors(i)
165 p = pos[indices] + np.dot(offsets, cell)
166 r = cdist(p, [pos[i]])[:, 0]
167 fc = []
168 for j, ii in enumerate(indices):
169 d = r[j] - r_cov[i] - r_cov[ii]
170 chi_ik = 0.481 * valence_states[i] / (r_cov[i] + 0.5 * d)
171 chi_jk = 0.481 * valence_states[ii] / (r_cov[ii] + 0.5 * d)
172 cn_ik = s_norms[i] / np.exp(-d / 0.37)
173 cn_jk = s_norms[ii] / np.exp(-d / 0.37)
174 fc.append(np.sqrt(chi_ik * chi_jk / (cn_ik * cn_jk)))
175 self.force_constants.append(np.array(fc))
178class SoftMutation(OffspringCreator):
179 """Mutates the structure by displacing it along the lowest
180 (nonzero) frequency modes found by vibrational analysis, as in:
182 `Lyakhov, Oganov, Valle, Comp. Phys. Comm. 181 (2010) 1623-1632`__
184 __ https://dx.doi.org/10.1016/j.cpc.2010.06.007
186 As in the reference above, the next-lowest mode is used if the
187 structure has already been softmutated along the current-lowest
188 mode. This mutation hence acts in a deterministic way, in contrast
189 to most other genetic operators.
191 If you find this implementation useful in your work,
192 please consider citing:
194 `Van den Bossche, Gronbeck, Hammer, J. Chem. Theory Comput. 14 (2018)`__
196 __ https://dx.doi.org/10.1021/acs.jctc.8b00039
198 in addition to the paper mentioned above.
200 Parameters:
202 blmin: dict
203 The closest allowed interatomic distances on the form:
204 {(Z, Z*): dist, ...}, where Z and Z* are atomic numbers.
206 bounds: list
207 Lower and upper limits (in Angstrom) for the largest
208 atomic displacement in the structure. For a given mode,
209 the algorithm starts at zero amplitude and increases
210 it until either blmin is violated or the largest
211 displacement exceeds the provided upper bound).
212 If the largest displacement in the resulting structure
213 is lower than the provided lower bound, the mutant is
214 considered too similar to the parent and None is
215 returned.
217 calculator: ASE calculator object
218 The calculator to be used in the vibrational
219 analysis. The default is to use a calculator
220 based on pairwise harmonic potentials with force
221 constants from the "bond electronegativity"
222 model described in the reference above.
223 Any calculator with a working :func:`get_forces()`
224 method will work.
226 rcut: float
227 Cutoff radius in Angstrom for the pairwise harmonic
228 potentials.
230 used_modes_file: str or None
231 Name of json dump file where previously used
232 modes will be stored (and read). If None,
233 no such file will be used. Default is to use
234 the filename 'used_modes.json'.
236 use_tags: boolean
237 Whether to use the atomic tags to preserve molecular identity.
238 """
239 def __init__(self, blmin, bounds=[0.5, 2.0],
240 calculator=BondElectroNegativityModel, rcut=10.,
241 used_modes_file='used_modes.json', use_tags=False,
242 verbose=False):
243 OffspringCreator.__init__(self, verbose)
244 self.blmin = blmin
245 self.bounds = bounds
246 self.calc = calculator
247 self.rcut = rcut
248 self.used_modes_file = used_modes_file
249 self.use_tags = use_tags
250 self.descriptor = 'SoftMutation'
252 self.used_modes = {}
253 if self.used_modes_file is not None:
254 try:
255 self.read_used_modes(self.used_modes_file)
256 except IOError:
257 # file doesn't exist (yet)
258 pass
260 def _get_hessian(self, atoms, dx):
261 """Returns the Hessian matrix d2E/dxi/dxj using a first-order
262 central difference scheme with displacements dx.
263 """
264 N = len(atoms)
265 pos = atoms.get_positions()
266 hessian = np.zeros((3 * N, 3 * N))
268 for i in range(3 * N):
269 row = np.zeros(3 * N)
270 for direction in [-1, 1]:
271 disp = np.zeros(3)
272 disp[i % 3] = direction * dx
273 pos_disp = np.copy(pos)
274 pos_disp[i // 3] += disp
275 atoms.set_positions(pos_disp)
276 f = atoms.get_forces()
277 row += -1 * direction * f.flatten()
279 row /= (2. * dx)
280 hessian[i] = row
282 hessian += np.copy(hessian).T
283 hessian *= 0.5
284 atoms.set_positions(pos)
286 return hessian
288 def _calculate_normal_modes(self, atoms, dx=0.02, massweighing=False):
289 """Performs the vibrational analysis."""
290 hessian = self._get_hessian(atoms, dx)
291 if massweighing:
292 m = np.array([np.repeat(atoms.get_masses()**-0.5, 3)])
293 hessian *= (m * m.T)
295 eigvals, eigvecs = np.linalg.eigh(hessian)
296 modes = {eigval: eigvecs[:, i] for i, eigval in enumerate(eigvals)}
297 return modes
299 def animate_mode(self, atoms, mode, nim=30, amplitude=1.0):
300 """Returns an Atoms object showing an animation of the mode."""
301 pos = atoms.get_positions()
302 mode = mode.reshape(np.shape(pos))
303 animation = []
304 for i in range(nim):
305 newpos = pos + amplitude * mode * np.sin(i * 2 * np.pi / nim)
306 image = atoms.copy()
307 image.positions = newpos
308 animation.append(image)
309 return animation
311 def read_used_modes(self, filename):
312 """Read used modes from json file."""
313 with open(filename, 'r') as fd:
314 modes = json.load(fd)
315 self.used_modes = {int(k): modes[k] for k in modes}
316 return
318 def write_used_modes(self, filename):
319 """Dump used modes to json file."""
320 with open(filename, 'w') as fd:
321 json.dump(self.used_modes, fd)
322 return
324 def get_new_individual(self, parents):
325 f = parents[0]
327 indi = self.mutate(f)
328 if indi is None:
329 return indi, 'mutation: soft'
331 indi = self.initialize_individual(f, indi)
332 indi.info['data']['parents'] = [f.info['confid']]
334 return self.finalize_individual(indi), 'mutation: soft'
336 def mutate(self, atoms):
337 """Does the actual mutation."""
338 a = atoms.copy()
340 if inspect.isclass(self.calc):
341 assert issubclass(self.calc, PairwiseHarmonicPotential)
342 calc = self.calc(atoms, rcut=self.rcut)
343 else:
344 calc = self.calc
345 a.calc = calc
347 if self.use_tags:
348 a = TagFilter(a)
350 pos = a.get_positions()
351 modes = self._calculate_normal_modes(a)
353 # Select the mode along which we want to move the atoms;
354 # The first 3 translational modes as well as previously
355 # applied modes are discarded.
357 keys = np.array(sorted(modes))
358 index = 3
359 confid = atoms.info['confid']
360 if confid in self.used_modes:
361 while index in self.used_modes[confid]:
362 index += 1
363 self.used_modes[confid].append(index)
364 else:
365 self.used_modes[confid] = [index]
367 if self.used_modes_file is not None:
368 self.write_used_modes(self.used_modes_file)
370 key = keys[index]
371 mode = modes[key].reshape(np.shape(pos))
373 # Find a suitable amplitude for translation along the mode;
374 # at every trial amplitude both positive and negative
375 # directions are tried.
377 mutant = atoms.copy()
378 amplitude = 0.
379 increment = 0.1
380 direction = 1
381 largest_norm = np.max(np.apply_along_axis(np.linalg.norm, 1, mode))
383 def expand(atoms, positions):
384 if isinstance(atoms, TagFilter):
385 a.set_positions(positions)
386 return a.atoms.get_positions()
387 else:
388 return positions
390 while amplitude * largest_norm < self.bounds[1]:
391 pos_new = pos + direction * amplitude * mode
392 pos_new = expand(a, pos_new)
393 mutant.set_positions(pos_new)
394 mutant.wrap()
395 too_close = atoms_too_close(mutant, self.blmin,
396 use_tags=self.use_tags)
397 if too_close:
398 amplitude -= increment
399 pos_new = pos + direction * amplitude * mode
400 pos_new = expand(a, pos_new)
401 mutant.set_positions(pos_new)
402 mutant.wrap()
403 break
405 if direction == 1:
406 direction = -1
407 else:
408 direction = 1
409 amplitude += increment
411 if amplitude * largest_norm < self.bounds[0]:
412 mutant = None
414 return mutant