Coverage for /builds/kinetik161/ase/ase/ga/soft_mutation.py: 90.20%

245 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-12-10 11:04 +0000

1"""Soft-mutation operator and associated tools""" 

2import inspect 

3import json 

4 

5import numpy as np 

6from scipy.spatial.distance import cdist 

7 

8from ase.data import covalent_radii 

9from ase.ga.offspring_creator import OffspringCreator 

10from ase.ga.utilities import atoms_too_close, gather_atoms_by_tag 

11from ase.neighborlist import NeighborList 

12 

13 

14class TagFilter: 

15 """Filter which constrains same-tag atoms to behave 

16 like internally rigid moieties. 

17 """ 

18 

19 def __init__(self, atoms): 

20 self.atoms = atoms 

21 gather_atoms_by_tag(self.atoms) 

22 self.tags = self.atoms.get_tags() 

23 self.unique_tags = np.unique(self.tags) 

24 self.n = len(self.unique_tags) 

25 

26 def get_positions(self): 

27 all_pos = self.atoms.get_positions() 

28 cop_pos = np.zeros((self.n, 3)) 

29 for i in range(self.n): 

30 indices = np.where(self.tags == self.unique_tags[i]) 

31 cop_pos[i] = np.average(all_pos[indices], axis=0) 

32 return cop_pos 

33 

34 def set_positions(self, positions, **kwargs): 

35 cop_pos = self.get_positions() 

36 all_pos = self.atoms.get_positions() 

37 assert np.all(np.shape(positions) == np.shape(cop_pos)) 

38 for i in range(self.n): 

39 indices = np.where(self.tags == self.unique_tags[i]) 

40 shift = positions[i] - cop_pos[i] 

41 all_pos[indices] += shift 

42 self.atoms.set_positions(all_pos, **kwargs) 

43 

44 def get_forces(self, *args, **kwargs): 

45 f = self.atoms.get_forces() 

46 forces = np.zeros((self.n, 3)) 

47 for i in range(self.n): 

48 indices = np.where(self.tags == self.unique_tags[i]) 

49 forces[i] = np.sum(f[indices], axis=0) 

50 return forces 

51 

52 def get_masses(self): 

53 m = self.atoms.get_masses() 

54 masses = np.zeros(self.n) 

55 for i in range(self.n): 

56 indices = np.where(self.tags == self.unique_tags[i]) 

57 masses[i] = np.sum(m[indices]) 

58 return masses 

59 

60 def __len__(self): 

61 return self.n 

62 

63 

64class PairwiseHarmonicPotential: 

65 """Parent class for interatomic potentials of the type 

66 E(r_ij) = 0.5 * k_ij * (r_ij - r0_ij) ** 2 

67 """ 

68 

69 def __init__(self, atoms, rcut=10.): 

70 self.atoms = atoms 

71 self.pos0 = atoms.get_positions() 

72 self.rcut = rcut 

73 

74 # build neighborlist 

75 nat = len(self.atoms) 

76 self.nl = NeighborList([self.rcut / 2.] * nat, skin=0., bothways=True, 

77 self_interaction=False) 

78 self.nl.update(self.atoms) 

79 

80 self.calculate_force_constants() 

81 

82 def calculate_force_constants(self): 

83 msg = 'Child class needs to define a calculate_force_constants() ' \ 

84 'method which computes the force constants and stores them ' \ 

85 'in self.force_constants (as a list which contains, for every ' \ 

86 'atom, a list of the atom\'s force constants with its neighbors.' 

87 raise NotImplementedError(msg) 

88 

89 def get_forces(self, atoms): 

90 pos = atoms.get_positions() 

91 cell = atoms.get_cell() 

92 forces = np.zeros_like(pos) 

93 

94 for i, p in enumerate(pos): 

95 indices, offsets = self.nl.get_neighbors(i) 

96 p = pos[indices] + np.dot(offsets, cell) 

97 r = cdist(p, [pos[i]]) 

98 v = (p - pos[i]) / r 

99 p0 = self.pos0[indices] + np.dot(offsets, cell) 

100 r0 = cdist(p0, [self.pos0[i]]) 

101 dr = r - r0 

102 forces[i] = np.dot(self.force_constants[i].T, dr * v) 

103 

104 return forces 

105 

106 

107def get_number_of_valence_electrons(Z): 

108 """Return the number of valence electrons for the element with 

109 atomic number Z, simply based on its periodic table group. 

110 """ 

111 groups = [[], [1, 3, 11, 19, 37, 55, 87], [2, 4, 12, 20, 38, 56, 88], 

112 [21, 39, 57, 89]] 

113 

114 for i in range(9): 

115 groups.append(i + np.array([22, 40, 72, 104])) 

116 

117 for i in range(6): 

118 groups.append(i + np.array([5, 13, 31, 49, 81, 113])) 

119 

120 for i, group in enumerate(groups): 

121 if Z in group: 

122 nval = i if i < 13 else i - 10 

123 break 

124 else: 

125 raise ValueError('Z=%d not included in this dataset.' % Z) 

126 

127 return nval 

128 

129 

130class BondElectroNegativityModel(PairwiseHarmonicPotential): 

131 """Pairwise harmonic potential where the force constants are 

132 determined using the "bond electronegativity" model, see: 

133 

134 * `Lyakhov, Oganov, Valle, Comp. Phys. Comm. 181 (2010) 1623-1632`__ 

135 

136 __ https://dx.doi.org/10.1016/j.cpc.2010.06.007 

137 

138 * `Lyakhov, Oganov, Phys. Rev. B 84 (2011) 092103`__ 

139 

140 __ https://dx.doi.org/10.1103/PhysRevB.84.092103 

141 """ 

142 

143 def calculate_force_constants(self): 

144 cell = self.atoms.get_cell() 

145 pos = self.atoms.get_positions() 

146 num = self.atoms.get_atomic_numbers() 

147 nat = len(self.atoms) 

148 nl = self.nl 

149 

150 # computing the force constants 

151 s_norms = [] 

152 valence_states = [] 

153 r_cov = [] 

154 for i in range(nat): 

155 indices, offsets = nl.get_neighbors(i) 

156 p = pos[indices] + np.dot(offsets, cell) 

157 r = cdist(p, [pos[i]]) 

158 r_ci = covalent_radii[num[i]] 

159 s = 0. 

160 for j, index in enumerate(indices): 

161 d = r[j] - r_ci - covalent_radii[num[index]] 

162 s += np.exp(-d / 0.37) 

163 s_norms.append(s) 

164 valence_states.append(get_number_of_valence_electrons(num[i])) 

165 r_cov.append(r_ci) 

166 

167 self.force_constants = [] 

168 for i in range(nat): 

169 indices, offsets = nl.get_neighbors(i) 

170 p = pos[indices] + np.dot(offsets, cell) 

171 r = cdist(p, [pos[i]])[:, 0] 

172 fc = [] 

173 for j, ii in enumerate(indices): 

174 d = r[j] - r_cov[i] - r_cov[ii] 

175 chi_ik = 0.481 * valence_states[i] / (r_cov[i] + 0.5 * d) 

176 chi_jk = 0.481 * valence_states[ii] / (r_cov[ii] + 0.5 * d) 

177 cn_ik = s_norms[i] / np.exp(-d / 0.37) 

178 cn_jk = s_norms[ii] / np.exp(-d / 0.37) 

179 fc.append(np.sqrt(chi_ik * chi_jk / (cn_ik * cn_jk))) 

180 self.force_constants.append(np.array(fc)) 

181 

182 

183class SoftMutation(OffspringCreator): 

184 """Mutates the structure by displacing it along the lowest 

185 (nonzero) frequency modes found by vibrational analysis, as in: 

186 

187 `Lyakhov, Oganov, Valle, Comp. Phys. Comm. 181 (2010) 1623-1632`__ 

188 

189 __ https://dx.doi.org/10.1016/j.cpc.2010.06.007 

190 

191 As in the reference above, the next-lowest mode is used if the 

192 structure has already been softmutated along the current-lowest 

193 mode. This mutation hence acts in a deterministic way, in contrast 

194 to most other genetic operators. 

195 

196 If you find this implementation useful in your work, 

197 please consider citing: 

198 

199 `Van den Bossche, Gronbeck, Hammer, J. Chem. Theory Comput. 14 (2018)`__ 

200 

201 __ https://dx.doi.org/10.1021/acs.jctc.8b00039 

202 

203 in addition to the paper mentioned above. 

204 

205 Parameters: 

206 

207 blmin: dict 

208 The closest allowed interatomic distances on the form: 

209 {(Z, Z*): dist, ...}, where Z and Z* are atomic numbers. 

210 

211 bounds: list 

212 Lower and upper limits (in Angstrom) for the largest 

213 atomic displacement in the structure. For a given mode, 

214 the algorithm starts at zero amplitude and increases 

215 it until either blmin is violated or the largest 

216 displacement exceeds the provided upper bound). 

217 If the largest displacement in the resulting structure 

218 is lower than the provided lower bound, the mutant is 

219 considered too similar to the parent and None is 

220 returned. 

221 

222 calculator: ASE calculator object 

223 The calculator to be used in the vibrational 

224 analysis. The default is to use a calculator 

225 based on pairwise harmonic potentials with force 

226 constants from the "bond electronegativity" 

227 model described in the reference above. 

228 Any calculator with a working :func:`get_forces()` 

229 method will work. 

230 

231 rcut: float 

232 Cutoff radius in Angstrom for the pairwise harmonic 

233 potentials. 

234 

235 used_modes_file: str or None 

236 Name of json dump file where previously used 

237 modes will be stored (and read). If None, 

238 no such file will be used. Default is to use 

239 the filename 'used_modes.json'. 

240 

241 use_tags: boolean 

242 Whether to use the atomic tags to preserve molecular identity. 

243 """ 

244 

245 def __init__(self, blmin, bounds=[0.5, 2.0], 

246 calculator=BondElectroNegativityModel, rcut=10., 

247 used_modes_file='used_modes.json', use_tags=False, 

248 verbose=False): 

249 OffspringCreator.__init__(self, verbose) 

250 self.blmin = blmin 

251 self.bounds = bounds 

252 self.calc = calculator 

253 self.rcut = rcut 

254 self.used_modes_file = used_modes_file 

255 self.use_tags = use_tags 

256 self.descriptor = 'SoftMutation' 

257 

258 self.used_modes = {} 

259 if self.used_modes_file is not None: 

260 try: 

261 self.read_used_modes(self.used_modes_file) 

262 except OSError: 

263 # file doesn't exist (yet) 

264 pass 

265 

266 def _get_hessian(self, atoms, dx): 

267 """Returns the Hessian matrix d2E/dxi/dxj using a first-order 

268 central difference scheme with displacements dx. 

269 """ 

270 N = len(atoms) 

271 pos = atoms.get_positions() 

272 hessian = np.zeros((3 * N, 3 * N)) 

273 

274 for i in range(3 * N): 

275 row = np.zeros(3 * N) 

276 for direction in [-1, 1]: 

277 disp = np.zeros(3) 

278 disp[i % 3] = direction * dx 

279 pos_disp = np.copy(pos) 

280 pos_disp[i // 3] += disp 

281 atoms.set_positions(pos_disp) 

282 f = atoms.get_forces() 

283 row += -1 * direction * f.flatten() 

284 

285 row /= (2. * dx) 

286 hessian[i] = row 

287 

288 hessian += np.copy(hessian).T 

289 hessian *= 0.5 

290 atoms.set_positions(pos) 

291 

292 return hessian 

293 

294 def _calculate_normal_modes(self, atoms, dx=0.02, massweighing=False): 

295 """Performs the vibrational analysis.""" 

296 hessian = self._get_hessian(atoms, dx) 

297 if massweighing: 

298 m = np.array([np.repeat(atoms.get_masses()**-0.5, 3)]) 

299 hessian *= (m * m.T) 

300 

301 eigvals, eigvecs = np.linalg.eigh(hessian) 

302 modes = {eigval: eigvecs[:, i] for i, eigval in enumerate(eigvals)} 

303 return modes 

304 

305 def animate_mode(self, atoms, mode, nim=30, amplitude=1.0): 

306 """Returns an Atoms object showing an animation of the mode.""" 

307 pos = atoms.get_positions() 

308 mode = mode.reshape(np.shape(pos)) 

309 animation = [] 

310 for i in range(nim): 

311 newpos = pos + amplitude * mode * np.sin(i * 2 * np.pi / nim) 

312 image = atoms.copy() 

313 image.positions = newpos 

314 animation.append(image) 

315 return animation 

316 

317 def read_used_modes(self, filename): 

318 """Read used modes from json file.""" 

319 with open(filename) as fd: 

320 modes = json.load(fd) 

321 self.used_modes = {int(k): modes[k] for k in modes} 

322 return 

323 

324 def write_used_modes(self, filename): 

325 """Dump used modes to json file.""" 

326 with open(filename, 'w') as fd: 

327 json.dump(self.used_modes, fd) 

328 return 

329 

330 def get_new_individual(self, parents): 

331 f = parents[0] 

332 

333 indi = self.mutate(f) 

334 if indi is None: 

335 return indi, 'mutation: soft' 

336 

337 indi = self.initialize_individual(f, indi) 

338 indi.info['data']['parents'] = [f.info['confid']] 

339 

340 return self.finalize_individual(indi), 'mutation: soft' 

341 

342 def mutate(self, atoms): 

343 """Does the actual mutation.""" 

344 a = atoms.copy() 

345 

346 if inspect.isclass(self.calc): 

347 assert issubclass(self.calc, PairwiseHarmonicPotential) 

348 calc = self.calc(atoms, rcut=self.rcut) 

349 else: 

350 calc = self.calc 

351 a.calc = calc 

352 

353 if self.use_tags: 

354 a = TagFilter(a) 

355 

356 pos = a.get_positions() 

357 modes = self._calculate_normal_modes(a) 

358 

359 # Select the mode along which we want to move the atoms; 

360 # The first 3 translational modes as well as previously 

361 # applied modes are discarded. 

362 

363 keys = np.array(sorted(modes)) 

364 index = 3 

365 confid = atoms.info['confid'] 

366 if confid in self.used_modes: 

367 while index in self.used_modes[confid]: 

368 index += 1 

369 self.used_modes[confid].append(index) 

370 else: 

371 self.used_modes[confid] = [index] 

372 

373 if self.used_modes_file is not None: 

374 self.write_used_modes(self.used_modes_file) 

375 

376 key = keys[index] 

377 mode = modes[key].reshape(np.shape(pos)) 

378 

379 # Find a suitable amplitude for translation along the mode; 

380 # at every trial amplitude both positive and negative 

381 # directions are tried. 

382 

383 mutant = atoms.copy() 

384 amplitude = 0. 

385 increment = 0.1 

386 direction = 1 

387 largest_norm = np.max(np.apply_along_axis(np.linalg.norm, 1, mode)) 

388 

389 def expand(atoms, positions): 

390 if isinstance(atoms, TagFilter): 

391 a.set_positions(positions) 

392 return a.atoms.get_positions() 

393 else: 

394 return positions 

395 

396 while amplitude * largest_norm < self.bounds[1]: 

397 pos_new = pos + direction * amplitude * mode 

398 pos_new = expand(a, pos_new) 

399 mutant.set_positions(pos_new) 

400 mutant.wrap() 

401 too_close = atoms_too_close(mutant, self.blmin, 

402 use_tags=self.use_tags) 

403 if too_close: 

404 amplitude -= increment 

405 pos_new = pos + direction * amplitude * mode 

406 pos_new = expand(a, pos_new) 

407 mutant.set_positions(pos_new) 

408 mutant.wrap() 

409 break 

410 

411 if direction == 1: 

412 direction = -1 

413 else: 

414 direction = 1 

415 amplitude += increment 

416 

417 if amplitude * largest_norm < self.bounds[0]: 

418 mutant = None 

419 

420 return mutant