Coverage for /builds/kinetik161/ase/ase/mep/dimer.py: 71.74%

591 statements  

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

1"""Minimum mode follower for finding saddle points in an unbiased way. 

2 

3There is, currently, only one implemented method: The Dimer method. 

4""" 

5 

6import sys 

7import time 

8import warnings 

9from math import atan, cos, degrees, pi, sin, sqrt, tan 

10from typing import Any, Dict 

11 

12import numpy as np 

13 

14from ase.calculators.singlepoint import SinglePointCalculator 

15from ase.optimize.optimize import OptimizableAtoms, Optimizer 

16from ase.parallel import world 

17from ase.utils import IOContext 

18 

19# Handy vector methods 

20norm = np.linalg.norm 

21 

22 

23class DimerOptimizable(OptimizableAtoms): 

24 def __init__(self, dimeratoms): 

25 self.dimeratoms = dimeratoms 

26 super().__init__(dimeratoms) 

27 

28 def converged(self, forces, fmax): 

29 forces_converged = super().converged(forces, fmax) 

30 return forces_converged and self.dimeratoms.get_curvature() < 0.0 

31 

32 

33def normalize(vector): 

34 """Create a unit vector along *vector*""" 

35 return vector / norm(vector) 

36 

37 

38def parallel_vector(vector, base): 

39 """Extract the components of *vector* that are parallel to *base*""" 

40 return np.vdot(vector, base) * base 

41 

42 

43def perpendicular_vector(vector, base): 

44 """Remove the components of *vector* that are parallel to *base*""" 

45 return vector - parallel_vector(vector, base) 

46 

47 

48def rotate_vectors(v1i, v2i, angle): 

49 """Rotate vectors *v1i* and *v2i* by *angle*""" 

50 cAng = cos(angle) 

51 sAng = sin(angle) 

52 v1o = v1i * cAng + v2i * sAng 

53 v2o = v2i * cAng - v1i * sAng 

54 # Ensure the length of the input and output vectors is equal 

55 return normalize(v1o) * norm(v1i), normalize(v2o) * norm(v2i) 

56 

57 

58class DimerEigenmodeSearch: 

59 """An implementation of the Dimer's minimum eigenvalue mode search. 

60 

61 This class implements the rotational part of the dimer saddle point 

62 searching method. 

63 

64 Parameters: 

65 

66 atoms: MinModeAtoms object 

67 MinModeAtoms is an extension to the Atoms object, which includes 

68 information about the lowest eigenvalue mode. 

69 control: DimerControl object 

70 Contains the parameters necessary for the eigenmode search. 

71 If no control object is supplied a default DimerControl 

72 will be created and used. 

73 basis: list of xyz-values 

74 Eigenmode. Must be an ndarray of shape (n, 3). 

75 It is possible to constrain the eigenmodes to be orthogonal 

76 to this given eigenmode. 

77 

78 Notes: 

79 

80 The code is inspired, with permission, by code written by the Henkelman 

81 group, which can be found at http://theory.cm.utexas.edu/vtsttools/code/ 

82 

83 References: 

84 

85 * Henkelman and Jonsson, JCP 111, 7010 (1999) 

86 * Olsen, Kroes, Henkelman, Arnaldsson, and Jonsson, JCP 121, 

87 9776 (2004). 

88 * Heyden, Bell, and Keil, JCP 123, 224101 (2005). 

89 * Kastner and Sherwood, JCP 128, 014106 (2008). 

90 

91 """ 

92 

93 def __init__(self, dimeratoms, control=None, eigenmode=None, basis=None, 

94 **kwargs): 

95 if hasattr(dimeratoms, 'get_eigenmode'): 

96 self.dimeratoms = dimeratoms 

97 else: 

98 e = 'The atoms object must be a MinModeAtoms object' 

99 raise TypeError(e) 

100 self.basis = basis 

101 

102 if eigenmode is None: 

103 self.eigenmode = self.dimeratoms.get_eigenmode() 

104 else: 

105 self.eigenmode = eigenmode 

106 

107 if control is None: 

108 self.control = DimerControl(**kwargs) 

109 w = 'Missing control object in ' + self.__class__.__name__ + \ 

110 '. Using default: DimerControl()' 

111 warnings.warn(w, UserWarning) 

112 if self.control.logfile is not None: 

113 self.control.logfile.write('DIM:WARN: ' + w + '\n') 

114 self.control.logfile.flush() 

115 else: 

116 self.control = control 

117 # kwargs must be empty if a control object is supplied 

118 for key in kwargs: 

119 e = f'__init__() got an unexpected keyword argument \'{(key)}\'' 

120 raise TypeError(e) 

121 

122 self.dR = self.control.get_parameter('dimer_separation') 

123 self.logfile = self.control.get_logfile() 

124 

125 def converge_to_eigenmode(self): 

126 """Perform an eigenmode search.""" 

127 self.set_up_for_eigenmode_search() 

128 stoprot = False 

129 

130 # Load the relevant parameters from control 

131 f_rot_min = self.control.get_parameter('f_rot_min') 

132 f_rot_max = self.control.get_parameter('f_rot_max') 

133 trial_angle = self.control.get_parameter('trial_angle') 

134 max_num_rot = self.control.get_parameter('max_num_rot') 

135 extrapolate = self.control.get_parameter('extrapolate_forces') 

136 

137 while not stoprot: 

138 if self.forces1E is None: 

139 self.update_virtual_forces() 

140 else: 

141 self.update_virtual_forces(extrapolated_forces=True) 

142 self.forces1A = self.forces1 

143 self.update_curvature() 

144 f_rot_A = self.get_rotational_force() 

145 

146 # Pre rotation stop criteria 

147 if norm(f_rot_A) <= f_rot_min: 

148 self.log(f_rot_A, None) 

149 stoprot = True 

150 else: 

151 n_A = self.eigenmode 

152 rot_unit_A = normalize(f_rot_A) 

153 

154 # Get the curvature and its derivative 

155 c0 = self.get_curvature() 

156 c0d = np.vdot((self.forces2 - self.forces1), rot_unit_A) / \ 

157 self.dR 

158 

159 # Trial rotation (no need to store the curvature) 

160 # NYI variable trial angles from [3] 

161 n_B, rot_unit_B = rotate_vectors(n_A, rot_unit_A, trial_angle) 

162 self.eigenmode = n_B 

163 self.update_virtual_forces() 

164 self.forces1B = self.forces1 

165 

166 # Get the curvature's derivative 

167 c1d = np.vdot((self.forces2 - self.forces1), rot_unit_B) / \ 

168 self.dR 

169 

170 # Calculate the Fourier coefficients 

171 a1 = c0d * cos(2 * trial_angle) - c1d / \ 

172 (2 * sin(2 * trial_angle)) 

173 b1 = 0.5 * c0d 

174 a0 = 2 * (c0 - a1) 

175 

176 # Estimate the rotational angle 

177 rotangle = atan(b1 / a1) / 2.0 

178 

179 # Make sure that you didn't find a maximum 

180 cmin = a0 / 2.0 + a1 * cos(2 * rotangle) + \ 

181 b1 * sin(2 * rotangle) 

182 if c0 < cmin: 

183 rotangle += pi / 2.0 

184 

185 # Rotate into the (hopefully) lowest eigenmode 

186 # NYI Conjugate gradient rotation 

187 n_min, dummy = rotate_vectors(n_A, rot_unit_A, rotangle) 

188 self.update_eigenmode(n_min) 

189 

190 # Store the curvature estimate instead of the old curvature 

191 self.update_curvature(cmin) 

192 

193 self.log(f_rot_A, rotangle) 

194 

195 # Force extrapolation scheme from [4] 

196 if extrapolate: 

197 self.forces1E = sin(trial_angle - rotangle) / \ 

198 sin(trial_angle) * self.forces1A + sin(rotangle) / \ 

199 sin(trial_angle) * self.forces1B + \ 

200 (1 - cos(rotangle) - sin(rotangle) * 

201 tan(trial_angle / 2.0)) * self.forces0 

202 else: 

203 self.forces1E = None 

204 

205 # Post rotation stop criteria 

206 if not stoprot: 

207 if self.control.get_counter('rotcount') >= max_num_rot: 

208 stoprot = True 

209 elif norm(f_rot_A) <= f_rot_max: 

210 stoprot = True 

211 

212 def log(self, f_rot_A, angle): 

213 """Log each rotational step.""" 

214 # NYI Log for the trial angle 

215 if self.logfile is not None: 

216 if angle: 

217 l = 'DIM:ROT: %7d %9d %9.4f %9.4f %9.4f\n' % \ 

218 (self.control.get_counter('optcount'), 

219 self.control.get_counter('rotcount'), 

220 self.get_curvature(), degrees(angle), norm(f_rot_A)) 

221 else: 

222 l = 'DIM:ROT: %7d %9d %9.4f %9s %9.4f\n' % \ 

223 (self.control.get_counter('optcount'), 

224 self.control.get_counter('rotcount'), 

225 self.get_curvature(), '---------', norm(f_rot_A)) 

226 self.logfile.write(l) 

227 self.logfile.flush() 

228 

229 def get_rotational_force(self): 

230 """Calculate the rotational force that acts on the dimer.""" 

231 rot_force = perpendicular_vector((self.forces1 - self.forces2), 

232 self.eigenmode) / (2.0 * self.dR) 

233 if self.basis is not None: 

234 if ( 

235 len(self.basis) == len(self.dimeratoms) 

236 and len(self.basis[0]) == 3 

237 and isinstance(self.basis[0][0], float)): 

238 rot_force = perpendicular_vector(rot_force, self.basis) 

239 else: 

240 for base in self.basis: 

241 rot_force = perpendicular_vector(rot_force, base) 

242 return rot_force 

243 

244 def update_curvature(self, curv=None): 

245 """Update the curvature in the MinModeAtoms object.""" 

246 if curv: 

247 self.curvature = curv 

248 else: 

249 self.curvature = np.vdot((self.forces2 - self.forces1), 

250 self.eigenmode) / (2.0 * self.dR) 

251 

252 def update_eigenmode(self, eigenmode): 

253 """Update the eigenmode in the MinModeAtoms object.""" 

254 self.eigenmode = eigenmode 

255 self.update_virtual_positions() 

256 self.control.increment_counter('rotcount') 

257 

258 def get_eigenmode(self): 

259 """Returns the current eigenmode.""" 

260 return self.eigenmode 

261 

262 def get_curvature(self): 

263 """Returns the curvature along the current eigenmode.""" 

264 return self.curvature 

265 

266 def get_control(self): 

267 """Return the control object.""" 

268 return self.control 

269 

270 def update_center_forces(self): 

271 """Get the forces at the center of the dimer.""" 

272 self.dimeratoms.set_positions(self.pos0) 

273 self.forces0 = self.dimeratoms.get_forces(real=True) 

274 self.energy0 = self.dimeratoms.get_potential_energy() 

275 

276 def update_virtual_forces(self, extrapolated_forces=False): 

277 """Get the forces at the endpoints of the dimer.""" 

278 self.update_virtual_positions() 

279 

280 # Estimate / Calculate the forces at pos1 

281 if extrapolated_forces: 

282 self.forces1 = self.forces1E.copy() 

283 else: 

284 self.forces1 = self.dimeratoms.get_forces(real=True, pos=self.pos1) 

285 

286 # Estimate / Calculate the forces at pos2 

287 if self.control.get_parameter('use_central_forces'): 

288 self.forces2 = 2 * self.forces0 - self.forces1 

289 else: 

290 self.forces2 = self.dimeratoms.get_forces(real=True, pos=self.pos2) 

291 

292 def update_virtual_positions(self): 

293 """Update the end point positions.""" 

294 self.pos1 = self.pos0 + self.eigenmode * self.dR 

295 self.pos2 = self.pos0 - self.eigenmode * self.dR 

296 

297 def set_up_for_eigenmode_search(self): 

298 """Before eigenmode search, prepare for rotation.""" 

299 self.pos0 = self.dimeratoms.get_positions() 

300 self.update_center_forces() 

301 self.update_virtual_positions() 

302 self.control.reset_counter('rotcount') 

303 self.forces1E = None 

304 

305 def set_up_for_optimization_step(self): 

306 """At the end of rotation, prepare for displacement of the dimer.""" 

307 self.dimeratoms.set_positions(self.pos0) 

308 self.forces1E = None 

309 

310 

311class MinModeControl(IOContext): 

312 """A parent class for controlling minimum mode saddle point searches. 

313 

314 Method specific control classes inherit this one. The only thing 

315 inheriting classes need to implement are the log() method and 

316 the *parameters* class variable with default values for ALL 

317 parameters needed by the method in question. 

318 When instantiating control classes default parameter values can 

319 be overwritten. 

320 

321 """ 

322 parameters: Dict[str, Any] = {} 

323 

324 def __init__(self, logfile='-', eigenmode_logfile=None, **kwargs): 

325 # Overwrite the defaults with the input parameters given 

326 for key in kwargs: 

327 if key not in self.parameters: 

328 e = (f'Invalid parameter >>{key}<< with value >>' 

329 f'{str(kwargs[key])}<< in {self.__class__.__name__}') 

330 raise ValueError(e) 

331 else: 

332 self.set_parameter(key, kwargs[key], log=False) 

333 

334 self.initialize_logfiles(logfile, eigenmode_logfile) 

335 self.counters = {'forcecalls': 0, 'rotcount': 0, 'optcount': 0} 

336 self.log() 

337 

338 def initialize_logfiles(self, logfile=None, eigenmode_logfile=None): 

339 self.logfile = self.openfile(logfile, comm=world) 

340 self.eigenmode_logfile = self.openfile(eigenmode_logfile, comm=world) 

341 

342 def log(self, parameter=None): 

343 """Log the parameters of the eigenmode search.""" 

344 

345 def set_parameter(self, parameter, value, log=True): 

346 """Change a parameter's value.""" 

347 if parameter not in self.parameters: 

348 e = f'Invalid parameter >>{parameter}<< with value >>{str(value)}<<' 

349 raise ValueError(e) 

350 self.parameters[parameter] = value 

351 if log: 

352 self.log(parameter) 

353 

354 def get_parameter(self, parameter): 

355 """Returns the value of a parameter.""" 

356 if parameter not in self.parameters: 

357 e = f'Invalid parameter >>{(parameter)}<<' 

358 raise ValueError(e) 

359 return self.parameters[parameter] 

360 

361 def get_logfile(self): 

362 """Returns the log file.""" 

363 return self.logfile 

364 

365 def get_eigenmode_logfile(self): 

366 """Returns the eigenmode log file.""" 

367 return self.eigenmode_logfile 

368 

369 def get_counter(self, counter): 

370 """Returns a given counter.""" 

371 return self.counters[counter] 

372 

373 def increment_counter(self, counter): 

374 """Increment a given counter.""" 

375 self.counters[counter] += 1 

376 

377 def reset_counter(self, counter): 

378 """Reset a given counter.""" 

379 self.counters[counter] = 0 

380 

381 def reset_all_counters(self): 

382 """Reset all counters.""" 

383 for key in self.counters: 

384 self.counters[key] = 0 

385 

386 

387class DimerControl(MinModeControl): 

388 """A class that takes care of the parameters needed for a Dimer search. 

389 

390 Parameters: 

391 

392 eigenmode_method: str 

393 The name of the eigenmode search method. 

394 f_rot_min: float 

395 Size of the rotational force under which no rotation will be 

396 performed. 

397 f_rot_max: float 

398 Size of the rotational force under which only one rotation will be 

399 performed. 

400 max_num_rot: int 

401 Maximum number of rotations per optimizer step. 

402 trial_angle: float 

403 Trial angle for the finite difference estimate of the rotational 

404 angle in radians. 

405 trial_trans_step: float 

406 Trial step size for the MinModeTranslate optimizer. 

407 maximum_translation: float 

408 Maximum step size and forced step size when the curvature is still 

409 positive for the MinModeTranslate optimizer. 

410 cg_translation: bool 

411 Conjugate Gradient for the MinModeTranslate optimizer. 

412 use_central_forces: bool 

413 Only calculate the forces at one end of the dimer and extrapolate 

414 the forces to the other. 

415 dimer_separation: float 

416 Separation of the dimer's images. 

417 initial_eigenmode_method: str 

418 How to construct the initial eigenmode of the dimer. If an eigenmode 

419 is given when creating the MinModeAtoms object, this will be ignored. 

420 Possible choices are: 'gauss' and 'displacement' 

421 extrapolate_forces: bool 

422 When more than one rotation is performed, an extrapolation scheme can 

423 be used to reduce the number of force evaluations. 

424 displacement_method: str 

425 How to displace the atoms. Possible choices are 'gauss' and 'vector'. 

426 gauss_std: float 

427 The standard deviation of the gauss curve used when doing random 

428 displacement. 

429 order: int 

430 How many lowest eigenmodes will be inverted. 

431 mask: list of bool 

432 Which atoms will be moved during displacement. 

433 displacement_center: int or [float, float, float] 

434 The center of displacement, nearby atoms will be displaced. 

435 displacement_radius: float 

436 When choosing which atoms to displace with the *displacement_center* 

437 keyword, this decides how many nearby atoms to displace. 

438 number_of_displacement_atoms: int 

439 The amount of atoms near *displacement_center* to displace. 

440 

441 """ 

442 # Default parameters for the Dimer eigenmode search 

443 parameters = {'eigenmode_method': 'dimer', 

444 'f_rot_min': 0.1, 

445 'f_rot_max': 1.00, 

446 'max_num_rot': 1, 

447 'trial_angle': pi / 4.0, 

448 'trial_trans_step': 0.001, 

449 'maximum_translation': 0.1, 

450 'cg_translation': True, 

451 'use_central_forces': True, 

452 'dimer_separation': 0.0001, 

453 'initial_eigenmode_method': 'gauss', 

454 'extrapolate_forces': False, 

455 'displacement_method': 'gauss', 

456 'gauss_std': 0.1, 

457 'order': 1, 

458 'mask': None, # NB mask should not be a "parameter" 

459 'displacement_center': None, 

460 'displacement_radius': None, 

461 'number_of_displacement_atoms': None} 

462 

463 # NB: Can maybe put this in EigenmodeSearch and MinModeControl 

464 def log(self, parameter=None): 

465 """Log the parameters of the eigenmode search.""" 

466 if self.logfile is not None: 

467 if parameter is not None: 

468 l = 'DIM:CONTROL: Updated Parameter: {} = {}\n'.format( 

469 parameter, str(self.get_parameter(parameter))) 

470 else: 

471 l = 'MINMODE:METHOD: Dimer\n' 

472 l += 'DIM:CONTROL: Search Parameters:\n' 

473 l += 'DIM:CONTROL: ------------------\n' 

474 for key in self.parameters: 

475 l += 'DIM:CONTROL: {} = {}\n'.format( 

476 key, str(self.get_parameter(key))) 

477 l += 'DIM:CONTROL: ------------------\n' 

478 l += 'DIM:ROT: OPT-STEP ROT-STEP CURVATURE ROT-ANGLE ' + \ 

479 'ROT-FORCE\n' 

480 self.logfile.write(l) 

481 self.logfile.flush() 

482 

483 

484class MinModeAtoms: 

485 """Wrapper for Atoms with information related to minimum mode searching. 

486 

487 Contains an Atoms object and pipes all unknown function calls to that 

488 object. 

489 Other information that is stored in this object are the estimate for 

490 the lowest eigenvalue, *curvature*, and its corresponding eigenmode, 

491 *eigenmode*. Furthermore, the original configuration of the Atoms 

492 object is stored for use in multiple minimum mode searches. 

493 The forces on the system are modified by inverting the component 

494 along the eigenmode estimate. This eventually brings the system to 

495 a saddle point. 

496 

497 Parameters: 

498 

499 atoms : Atoms object 

500 A regular Atoms object 

501 control : MinModeControl object 

502 Contains the parameters necessary for the eigenmode search. 

503 If no control object is supplied a default DimerControl 

504 will be created and used. 

505 mask: list of bool 

506 Determines which atoms will be moved when calling displace() 

507 random_seed: int 

508 The seed used for the random number generator. Defaults to 

509 modified version the current time. 

510 

511 References: [1]_ [2]_ [3]_ [4]_ 

512 

513 .. [1] Henkelman and Jonsson, JCP 111, 7010 (1999) 

514 .. [2] Olsen, Kroes, Henkelman, Arnaldsson, and Jonsson, JCP 121, 

515 9776 (2004). 

516 .. [3] Heyden, Bell, and Keil, JCP 123, 224101 (2005). 

517 .. [4] Kastner and Sherwood, JCP 128, 014106 (2008). 

518 

519 """ 

520 

521 def __init__(self, atoms, control=None, eigenmodes=None, 

522 random_seed=None, **kwargs): 

523 self.minmode_init = True 

524 self.atoms = atoms 

525 

526 # Initialize to None to avoid strange behaviour due to __getattr__ 

527 self.eigenmodes = eigenmodes 

528 self.curvatures = None 

529 

530 if control is None: 

531 self.control = DimerControl(**kwargs) 

532 w = 'Missing control object in ' + self.__class__.__name__ + \ 

533 '. Using default: DimerControl()' 

534 warnings.warn(w, UserWarning) 

535 if self.control.logfile is not None: 

536 self.control.logfile.write('DIM:WARN: ' + w + '\n') 

537 self.control.logfile.flush() 

538 else: 

539 self.control = control 

540 logfile = self.control.get_logfile() 

541 mlogfile = self.control.get_eigenmode_logfile() 

542 for key in kwargs: 

543 if key == 'logfile': 

544 logfile = kwargs[key] 

545 elif key == 'eigenmode_logfile': 

546 mlogfile = kwargs[key] 

547 else: 

548 self.control.set_parameter(key, kwargs[key]) 

549 self.control.initialize_logfiles(logfile=logfile, 

550 eigenmode_logfile=mlogfile) 

551 

552 # Seed the randomness 

553 if random_seed is None: 

554 t = time.time() 

555 if world.size > 1: 

556 t = world.sum(t) / world.size 

557 # Harvest the latter part of the current time 

558 random_seed = int(('%30.9f' % t)[-9:]) 

559 self.random_state = np.random.RandomState(random_seed) 

560 

561 # Check the order 

562 self.order = self.control.get_parameter('order') 

563 

564 # Construct the curvatures list 

565 self.curvatures = [100.0] * self.order 

566 

567 # Save the original state of the atoms. 

568 self.atoms0 = self.atoms.copy() 

569 self.save_original_forces() 

570 

571 # Get a reference to the log files 

572 self.logfile = self.control.get_logfile() 

573 self.mlogfile = self.control.get_eigenmode_logfile() 

574 

575 def __ase_optimizable__(self): 

576 return DimerOptimizable(self) 

577 

578 def save_original_forces(self, force_calculation=False): 

579 """Store the forces (and energy) of the original state.""" 

580 # NB: Would be nice if atoms.copy() took care of this. 

581 if self.calc is not None: 

582 # Hack because some calculators do not have calculation_required 

583 if (hasattr(self.calc, 'calculation_required') 

584 and not self.calc.calculation_required(self.atoms, 

585 ['energy', 'forces'])) or force_calculation: 

586 calc = SinglePointCalculator( 

587 self.atoms0, 

588 energy=self.atoms.get_potential_energy(), 

589 forces=self.atoms.get_forces()) 

590 self.atoms0.calc = calc 

591 

592 def initialize_eigenmodes(self, method=None, eigenmodes=None, 

593 gauss_std=None): 

594 """Make an initial guess for the eigenmode.""" 

595 if eigenmodes is None: 

596 pos = self.get_positions() 

597 old_pos = self.get_original_positions() 

598 if method is None: 

599 method = \ 

600 self.control.get_parameter('initial_eigenmode_method') 

601 if method.lower() == 'displacement' and (pos - old_pos).any(): 

602 eigenmode = normalize(pos - old_pos) 

603 elif method.lower() == 'gauss': 

604 self.displace(log=False, gauss_std=gauss_std, 

605 method=method) 

606 new_pos = self.get_positions() 

607 eigenmode = normalize(new_pos - pos) 

608 self.set_positions(pos) 

609 else: 

610 e = 'initial_eigenmode must use either \'gauss\' or ' + \ 

611 '\'displacement\', if the latter is used the atoms ' + \ 

612 'must have moved away from the original positions.' + \ 

613 f'You have requested \'{method}\'.' 

614 raise NotImplementedError(e) # NYI 

615 eigenmodes = [eigenmode] 

616 

617 # Create random higher order mode guesses 

618 if self.order > 1: 

619 if len(eigenmodes) == 1: 

620 for k in range(1, self.order): 

621 pos = self.get_positions() 

622 self.displace(log=False, gauss_std=gauss_std, 

623 method=method) 

624 new_pos = self.get_positions() 

625 eigenmode = normalize(new_pos - pos) 

626 self.set_positions(pos) 

627 eigenmodes += [eigenmode] 

628 

629 self.eigenmodes = eigenmodes 

630 # Ensure that the higher order mode guesses are all orthogonal 

631 if self.order > 1: 

632 for k in range(self.order): 

633 self.ensure_eigenmode_orthogonality(k) 

634 self.eigenmode_log() 

635 

636 # NB maybe this name might be confusing in context to 

637 # calc.calculation_required() 

638 def calculation_required(self): 

639 """Check if a calculation is required.""" 

640 return self.minmode_init or self.check_atoms != self.atoms 

641 

642 def calculate_real_forces_and_energies(self, **kwargs): 

643 """Calculate and store the potential energy and forces.""" 

644 if self.minmode_init: 

645 self.minmode_init = False 

646 self.initialize_eigenmodes(eigenmodes=self.eigenmodes) 

647 self.rotation_required = True 

648 self.forces0 = self.atoms.get_forces(**kwargs) 

649 self.energy0 = self.atoms.get_potential_energy() 

650 self.control.increment_counter('forcecalls') 

651 self.check_atoms = self.atoms.copy() 

652 

653 def get_potential_energy(self): 

654 """Return the potential energy.""" 

655 if self.calculation_required(): 

656 self.calculate_real_forces_and_energies() 

657 return self.energy0 

658 

659 def get_forces(self, real=False, pos=None, **kwargs): 

660 """Return the forces, projected or real.""" 

661 if self.calculation_required() and pos is None: 

662 self.calculate_real_forces_and_energies(**kwargs) 

663 if real and pos is None: 

664 return self.forces0 

665 elif real and pos is not None: 

666 old_pos = self.atoms.get_positions() 

667 self.atoms.set_positions(pos) 

668 forces = self.atoms.get_forces() 

669 self.control.increment_counter('forcecalls') 

670 self.atoms.set_positions(old_pos) 

671 return forces 

672 else: 

673 if self.rotation_required: 

674 self.find_eigenmodes(order=self.order) 

675 self.eigenmode_log() 

676 self.rotation_required = False 

677 self.control.increment_counter('optcount') 

678 return self.get_projected_forces() 

679 

680 def ensure_eigenmode_orthogonality(self, order): 

681 mode = self.eigenmodes[order - 1].copy() 

682 for k in range(order - 1): 

683 mode = perpendicular_vector(mode, self.eigenmodes[k]) 

684 self.eigenmodes[order - 1] = normalize(mode) 

685 

686 def find_eigenmodes(self, order=1): 

687 """Launch eigenmode searches.""" 

688 if self.control.get_parameter('eigenmode_method').lower() != 'dimer': 

689 e = 'Only the Dimer control object has been implemented.' 

690 raise NotImplementedError(e) # NYI 

691 for k in range(order): 

692 if k > 0: 

693 self.ensure_eigenmode_orthogonality(k + 1) 

694 search = DimerEigenmodeSearch( 

695 self, self.control, 

696 eigenmode=self.eigenmodes[k], basis=self.eigenmodes[:k]) 

697 search.converge_to_eigenmode() 

698 search.set_up_for_optimization_step() 

699 self.eigenmodes[k] = search.get_eigenmode() 

700 self.curvatures[k] = search.get_curvature() 

701 

702 def get_projected_forces(self, pos=None): 

703 """Return the projected forces.""" 

704 if pos is not None: 

705 forces = self.get_forces(real=True, pos=pos).copy() 

706 else: 

707 forces = self.forces0.copy() 

708 

709 # Loop through all the eigenmodes 

710 # NB: Can this be done with a linear combination, instead? 

711 for k, mode in enumerate(self.eigenmodes): 

712 # NYI This If statement needs to be overridable in the control 

713 if self.get_curvature(order=k) > 0.0 and self.order == 1: 

714 forces = -parallel_vector(forces, mode) 

715 else: 

716 forces -= 2 * parallel_vector(forces, mode) 

717 return forces 

718 

719 def restore_original_positions(self): 

720 """Restore the MinModeAtoms object positions to the original state.""" 

721 self.atoms.set_positions(self.get_original_positions()) 

722 

723 def get_barrier_energy(self): 

724 """The energy difference between the current and original states""" 

725 try: 

726 original_energy = self.get_original_potential_energy() 

727 dimer_energy = self.get_potential_energy() 

728 return dimer_energy - original_energy 

729 except RuntimeError: 

730 w = 'The potential energy is not available, without further ' + \ 

731 'calculations, most likely at the original state.' 

732 warnings.warn(w, UserWarning) 

733 return np.nan 

734 

735 def get_control(self): 

736 """Return the control object.""" 

737 return self.control 

738 

739 def get_curvature(self, order='max'): 

740 """Return the eigenvalue estimate.""" 

741 if order == 'max': 

742 return max(self.curvatures) 

743 else: 

744 return self.curvatures[order - 1] 

745 

746 def get_eigenmode(self, order=1): 

747 """Return the current eigenmode guess.""" 

748 return self.eigenmodes[order - 1] 

749 

750 def get_atoms(self): 

751 """Return the unextended Atoms object.""" 

752 return self.atoms 

753 

754 def set_atoms(self, atoms): 

755 """Set a new Atoms object""" 

756 self.atoms = atoms 

757 

758 def set_eigenmode(self, eigenmode, order=1): 

759 """Set the eigenmode guess.""" 

760 self.eigenmodes[order - 1] = eigenmode 

761 

762 def set_curvature(self, curvature, order=1): 

763 """Set the eigenvalue estimate.""" 

764 self.curvatures[order - 1] = curvature 

765 

766 # Pipe all the stuff from Atoms that is not overwritten. 

767 # Pipe all requests for get_original_* to self.atoms0. 

768 def __getattr__(self, attr): 

769 """Return any value of the Atoms object""" 

770 if 'original' in attr.split('_'): 

771 attr = attr.replace('_original_', '_') 

772 return getattr(self.atoms0, attr) 

773 else: 

774 return getattr(self.atoms, attr) 

775 

776 def __len__(self): 

777 return len(self.atoms) 

778 

779 def displace(self, displacement_vector=None, mask=None, method=None, 

780 displacement_center=None, radius=None, number_of_atoms=None, 

781 gauss_std=None, mic=True, log=True): 

782 """Move the atoms away from their current position. 

783 

784 This is one of the essential parts of minimum mode searches. 

785 The parameters can all be set in the control object and overwritten 

786 when this method is run, apart from *displacement_vector*. 

787 It is preferred to modify the control values rather than those here 

788 in order for the correct ones to show up in the log file. 

789 

790 *method* can be either 'gauss' for random displacement or 'vector' 

791 to perform a predefined displacement. 

792 

793 *gauss_std* is the standard deviation of the gauss curve that is 

794 used for random displacement. 

795 

796 *displacement_center* can be either the number of an atom or a 3D 

797 position. It must be accompanied by a *radius* (all atoms within it 

798 will be displaced) or a *number_of_atoms* which decides how many of 

799 the closest atoms will be displaced. 

800 

801 *mic* controls the usage of the Minimum Image Convention. 

802 

803 If both *mask* and *displacement_center* are used, the atoms marked 

804 as False in the *mask* will not be affected even though they are 

805 within reach of the *displacement_center*. 

806 

807 The parameters priority order: 

808 1) displacement_vector 

809 2) mask 

810 3) displacement_center (with radius and/or number_of_atoms) 

811 

812 If both *radius* and *number_of_atoms* are supplied with 

813 *displacement_center*, only atoms that fulfill both criteria will 

814 be displaced. 

815 

816 """ 

817 

818 # Fetch the default values from the control 

819 if mask is None: 

820 mask = self.control.get_parameter('mask') 

821 if method is None: 

822 method = self.control.get_parameter('displacement_method') 

823 if gauss_std is None: 

824 gauss_std = self.control.get_parameter('gauss_std') 

825 if displacement_center is None: 

826 displacement_center = \ 

827 self.control.get_parameter('displacement_center') 

828 if radius is None: 

829 radius = self.control.get_parameter('displacement_radius') 

830 if number_of_atoms is None: 

831 number_of_atoms = \ 

832 self.control.get_parameter('number_of_displacement_atoms') 

833 

834 # Check for conflicts 

835 if displacement_vector is not None and method.lower() != 'vector': 

836 e = 'displacement_vector was supplied but a different method ' + \ 

837 f'(\'{str(method)}\') was chosen.\n' 

838 raise ValueError(e) 

839 elif displacement_vector is None and method.lower() == 'vector': 

840 e = 'A displacement_vector must be supplied when using ' + \ 

841 f'method = \'{str(method)}\'.\n' 

842 raise ValueError(e) 

843 elif displacement_center is not None and radius is None and \ 

844 number_of_atoms is None: 

845 e = 'When displacement_center is chosen, either radius or ' + \ 

846 'number_of_atoms must be supplied.\n' 

847 raise ValueError(e) 

848 

849 # Set up the center of displacement mask (c_mask) 

850 if displacement_center is not None: 

851 c = displacement_center 

852 # Construct a distance list 

853 # The center is an atom 

854 if isinstance(c, int): 

855 # Parse negative indexes 

856 c = displacement_center % len(self) 

857 d = [(k, self.get_distance(k, c, mic=mic)) for k in 

858 range(len(self))] 

859 # The center is a position in 3D space 

860 elif len(c) == 3 and [type(c_k) for c_k in c] == [float] * 3: 

861 # NB: MIC is not considered. 

862 d = [(k, norm(self.get_positions()[k] - c)) 

863 for k in range(len(self))] 

864 else: 

865 e = 'displacement_center must be either the number of an ' + \ 

866 'atom in MinModeAtoms object or a 3D position ' + \ 

867 '(3-tuple of floats).' 

868 raise ValueError(e) 

869 

870 # Set up the mask 

871 if radius is not None: 

872 r_mask = [dist[1] < radius for dist in d] 

873 else: 

874 r_mask = [True for _ in range(len(self))] 

875 

876 if number_of_atoms is not None: 

877 d_sorted = [n[0] for n in sorted(d, key=lambda k: k[1])] 

878 n_nearest = d_sorted[:number_of_atoms] 

879 n_mask = [k in n_nearest for k in range(len(self))] 

880 else: 

881 n_mask = [True for _ in range(len(self))] 

882 

883 # Resolve n_mask / r_mask conflicts 

884 c_mask = [n_mask[k] and r_mask[k] for k in range(len(self))] 

885 else: 

886 c_mask = None 

887 

888 # Set up a True mask if there is no mask supplied 

889 if mask is None: 

890 mask = [True for _ in range(len(self))] 

891 if c_mask is None: 

892 w = 'It was not possible to figure out which atoms to ' + \ 

893 'displace, Will try to displace all atoms.\n' 

894 warnings.warn(w, UserWarning) 

895 if self.logfile is not None: 

896 self.logfile.write('MINMODE:WARN: ' + w + '\n') 

897 self.logfile.flush() 

898 

899 # Resolve mask / c_mask conflicts 

900 if c_mask is not None: 

901 mask = [mask[k] and c_mask[k] for k in range(len(self))] 

902 

903 if displacement_vector is None: 

904 displacement_vector = [] 

905 for k in range(len(self)): 

906 if mask[k]: 

907 diff_line = [] 

908 for _ in range(3): 

909 if method.lower() == 'gauss': 

910 if not gauss_std: 

911 gauss_std = \ 

912 self.control.get_parameter('gauss_std') 

913 diff = self.random_state.normal(0.0, gauss_std) 

914 else: 

915 e = f'Invalid displacement method >>{str(method)}<<' 

916 raise ValueError(e) 

917 diff_line.append(diff) 

918 displacement_vector.append(diff_line) 

919 else: 

920 displacement_vector.append([0.0] * 3) 

921 

922 # Remove displacement of masked atoms 

923 for k in range(len(mask)): 

924 if not mask[k]: 

925 displacement_vector[k] = [0.0] * 3 

926 

927 # Perform the displacement and log it 

928 if log: 

929 pos0 = self.get_positions() 

930 self.set_positions(self.get_positions() + displacement_vector) 

931 if log: 

932 parameters = {'mask': mask, 

933 'displacement_method': method, 

934 'gauss_std': gauss_std, 

935 'displacement_center': displacement_center, 

936 'displacement_radius': radius, 

937 'number_of_displacement_atoms': number_of_atoms} 

938 self.displacement_log(self.get_positions() - pos0, parameters) 

939 

940 def eigenmode_log(self): 

941 """Log the eigenmodes (eigenmode estimates)""" 

942 if self.mlogfile is not None: 

943 l = 'MINMODE:MODE: Optimization Step: %i\n' % \ 

944 (self.control.get_counter('optcount')) 

945 for m_num, mode in enumerate(self.eigenmodes): 

946 l += 'MINMODE:MODE: Order: %i\n' % m_num 

947 for k in range(len(mode)): 

948 l += 'MINMODE:MODE: %7i %15.8f %15.8f %15.8f\n' % ( 

949 k, mode[k][0], mode[k][1], mode[k][2]) 

950 self.mlogfile.write(l) 

951 self.mlogfile.flush() 

952 

953 def displacement_log(self, displacement_vector, parameters): 

954 """Log the displacement""" 

955 if self.logfile is not None: 

956 lp = 'MINMODE:DISP: Parameters, different from the control:\n' 

957 mod_para = False 

958 for key in parameters: 

959 if parameters[key] != self.control.get_parameter(key): 

960 lp += 'MINMODE:DISP: {} = {}\n'.format(str(key), 

961 str(parameters[key])) 

962 mod_para = True 

963 if mod_para: 

964 l = lp 

965 else: 

966 l = '' 

967 for k in range(len(displacement_vector)): 

968 l += 'MINMODE:DISP: %7i %15.8f %15.8f %15.8f\n' % ( 

969 k, 

970 displacement_vector[k][0], displacement_vector[k][1], 

971 displacement_vector[k][2]) 

972 self.logfile.write(l) 

973 self.logfile.flush() 

974 

975 def summarize(self): 

976 """Summarize the Minimum mode search.""" 

977 if self.logfile is None: 

978 logfile = sys.stdout 

979 else: 

980 logfile = self.logfile 

981 

982 c = self.control 

983 label = 'MINMODE:SUMMARY: ' 

984 

985 l = label + '-------------------------\n' 

986 l += label + 'Barrier: %16.4f\n' % self.get_barrier_energy() 

987 l += label + 'Curvature: %14.4f\n' % self.get_curvature() 

988 l += label + 'Optimizer steps: %8i\n' % c.get_counter('optcount') 

989 l += label + 'Forcecalls: %13i\n' % c.get_counter('forcecalls') 

990 l += label + '-------------------------\n' 

991 

992 logfile.write(l) 

993 

994 

995class MinModeTranslate(Optimizer): 

996 """An Optimizer specifically tailored to minimum mode following.""" 

997 

998 def __init__(self, dimeratoms, logfile='-', trajectory=None): 

999 Optimizer.__init__(self, dimeratoms, None, logfile, trajectory) 

1000 

1001 self.control = dimeratoms.get_control() 

1002 self.dimeratoms = dimeratoms 

1003 

1004 # Make a header for the log 

1005 if self.logfile is not None: 

1006 l = '' 

1007 if isinstance(self.control, DimerControl): 

1008 l = 'MinModeTranslate: STEP TIME ENERGY ' + \ 

1009 'MAX-FORCE STEPSIZE CURVATURE ROT-STEPS\n' 

1010 self.logfile.write(l) 

1011 self.logfile.flush() 

1012 

1013 # Load the relevant parameters from control 

1014 self.cg_on = self.control.get_parameter('cg_translation') 

1015 self.trial_step = self.control.get_parameter('trial_trans_step') 

1016 self.max_step = self.control.get_parameter('maximum_translation') 

1017 

1018 # Start conjugate gradient 

1019 if self.cg_on: 

1020 self.cg_init = True 

1021 

1022 def initialize(self): 

1023 """Set initial values.""" 

1024 self.r0 = None 

1025 self.f0 = None 

1026 

1027 def step(self, f=None): 

1028 """Perform the optimization step.""" 

1029 atoms = self.dimeratoms 

1030 if f is None: 

1031 f = atoms.get_forces() 

1032 r = atoms.get_positions() 

1033 curv = atoms.get_curvature() 

1034 f0p = f.copy() 

1035 r0 = r.copy() 

1036 direction = f0p.copy() 

1037 if self.cg_on: 

1038 direction = self.get_cg_direction(direction) 

1039 direction = normalize(direction) 

1040 if curv > 0.0: 

1041 step = direction * self.max_step 

1042 else: 

1043 r0t = r0 + direction * self.trial_step 

1044 f0tp = self.dimeratoms.get_projected_forces(r0t) 

1045 F = np.vdot((f0tp + f0p), direction) / 2.0 

1046 C = np.vdot((f0tp - f0p), direction) / self.trial_step 

1047 step = (-F / C + self.trial_step / 2.0) * direction 

1048 if norm(step) > self.max_step: 

1049 step = direction * self.max_step 

1050 self.log(f0p, norm(step)) 

1051 

1052 atoms.set_positions(r + step) 

1053 

1054 self.f0 = f.flat.copy() 

1055 self.r0 = r.flat.copy() 

1056 

1057 def get_cg_direction(self, direction): 

1058 """Apply the Conjugate Gradient algorithm to the step direction.""" 

1059 if self.cg_init: 

1060 self.cg_init = False 

1061 self.direction_old = direction.copy() 

1062 self.cg_direction = direction.copy() 

1063 old_norm = np.vdot(self.direction_old, self.direction_old) 

1064 # Polak-Ribiere Conjugate Gradient 

1065 if old_norm != 0.0: 

1066 betaPR = np.vdot(direction, (direction - self.direction_old)) / \ 

1067 old_norm 

1068 else: 

1069 betaPR = 0.0 

1070 if betaPR < 0.0: 

1071 betaPR = 0.0 

1072 self.cg_direction = direction + self.cg_direction * betaPR 

1073 self.direction_old = direction.copy() 

1074 return self.cg_direction.copy() 

1075 

1076 def log(self, f=None, stepsize=None): 

1077 """Log each step of the optimization.""" 

1078 if f is None: 

1079 f = self.dimeratoms.get_forces() 

1080 if self.logfile is not None: 

1081 T = time.localtime() 

1082 e = self.dimeratoms.get_potential_energy() 

1083 fmax = sqrt((f**2).sum(axis=1).max()) 

1084 rotsteps = self.dimeratoms.control.get_counter('rotcount') 

1085 curvature = self.dimeratoms.get_curvature() 

1086 l = '' 

1087 if stepsize: 

1088 if isinstance(self.control, DimerControl): 

1089 l = '%s: %4d %02d:%02d:%02d %15.6f %12.4f %12.6f ' \ 

1090 '%12.6f %10d\n' % ( 

1091 'MinModeTranslate', self.nsteps, 

1092 T[3], T[4], T[5], e, fmax, stepsize, curvature, 

1093 rotsteps) 

1094 else: 

1095 if isinstance(self.control, DimerControl): 

1096 l = '%s: %4d %02d:%02d:%02d %15.6f %12.4f %s ' \ 

1097 '%12.6f %10d\n' % ( 

1098 'MinModeTranslate', self.nsteps, 

1099 T[3], T[4], T[5], e, fmax, ' --------', 

1100 curvature, rotsteps) 

1101 self.logfile.write(l) 

1102 self.logfile.flush() 

1103 

1104 

1105def read_eigenmode(mlog, index=-1): 

1106 """Read an eigenmode. 

1107 To access the pre optimization eigenmode set index = 'null'. 

1108 

1109 """ 

1110 mlog_is_str = isinstance(mlog, str) 

1111 if mlog_is_str: 

1112 fd = open(mlog) 

1113 else: 

1114 fd = mlog 

1115 

1116 lines = fd.readlines() 

1117 

1118 # Detect the amount of atoms and iterations 

1119 k = 2 

1120 while lines[k].split()[1].lower() not in ['optimization', 'order']: 

1121 k += 1 

1122 n = k - 2 

1123 n_itr = (len(lines) // (n + 1)) - 2 

1124 

1125 # Locate the correct image. 

1126 if isinstance(index, str): 

1127 if index.lower() == 'null': 

1128 i = 0 

1129 else: 

1130 i = int(index) + 1 

1131 else: 

1132 if index >= 0: 

1133 i = index + 1 

1134 else: 

1135 if index < -n_itr - 1: 

1136 raise IndexError('list index out of range') 

1137 else: 

1138 i = index 

1139 

1140 mode = np.ndarray(shape=(n, 3), dtype=float) 

1141 k_atom = 0 

1142 for k in range(1, n + 1): 

1143 line = lines[i * (n + 1) + k].split() 

1144 for k_dim in range(3): 

1145 mode[k_atom][k_dim] = float(line[k_dim + 2]) 

1146 k_atom += 1 

1147 

1148 if mlog_is_str: 

1149 fd.close() 

1150 

1151 return mode 

1152 

1153 

1154# Aliases 

1155DimerAtoms = MinModeAtoms 

1156DimerTranslate = MinModeTranslate