Hide keyboard shortcuts

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"""Storage and analysis for vibrational data""" 

2 

3import collections 

4from math import sin, pi, sqrt 

5from numbers import Real, Integral 

6from typing import Any, Dict, Iterator, List, Sequence, Tuple, TypeVar, Union 

7 

8import numpy as np 

9 

10from ase.atoms import Atoms 

11import ase.units as units 

12import ase.io 

13from ase.utils import jsonable, lazymethod 

14 

15from ase.calculators.singlepoint import SinglePointCalculator 

16from ase.spectrum.dosdata import RawDOSData 

17from ase.spectrum.doscollection import DOSCollection 

18 

19RealSequence4D = Sequence[Sequence[Sequence[Sequence[Real]]]] 

20VD = TypeVar('VD', bound='VibrationsData') 

21 

22 

23@jsonable('vibrationsdata') 

24class VibrationsData: 

25 """Class for storing and analyzing vibrational data (i.e. Atoms + Hessian) 

26 

27 This class is not responsible for calculating Hessians; the Hessian should 

28 be computed by a Calculator or some other algorithm. Once the 

29 VibrationsData has been constructed, this class provides some common 

30 processing options; frequency calculation, mode animation, DOS etc. 

31 

32 If the Atoms object is a periodic supercell, VibrationsData may be 

33 converted to a PhononData using the VibrationsData.to_phonondata() method. 

34 This provides access to q-point-dependent analyses such as phonon 

35 dispersion plotting. 

36 

37 Args: 

38 atoms: 

39 Equilibrium geometry of vibrating system. This will be stored as a 

40 lightweight copy with just positions, masses, unit cell. 

41 

42 hessian: Second-derivative in energy with respect to 

43 Cartesian nuclear movements as an (N, 3, N, 3) array. 

44 indices: indices of atoms which are included 

45 in Hessian. Default value (None) includes all atoms. 

46 

47 """ 

48 def __init__(self, 

49 atoms: Atoms, 

50 hessian: Union[RealSequence4D, np.ndarray], 

51 indices: Union[Sequence[int], np.ndarray] = None, 

52 ) -> None: 

53 

54 if indices is None: 

55 self._indices = np.arange(len(atoms), dtype=int) 

56 else: 

57 self._indices = np.array(indices, dtype=int) 

58 

59 n_atoms = self._check_dimensions(atoms, np.asarray(hessian), 

60 indices=self._indices) 

61 self._atoms = atoms.copy() 

62 

63 self._hessian2d = (np.asarray(hessian) 

64 .reshape(3 * n_atoms, 3 * n_atoms).copy()) 

65 

66 _setter_error = ("VibrationsData properties cannot be modified: construct " 

67 "a new VibrationsData with consistent atoms, Hessian and " 

68 "(optionally) indices/mask.") 

69 

70 @classmethod 

71 def from_2d(cls, atoms: Atoms, 

72 hessian_2d: Union[Sequence[Sequence[Real]], np.ndarray], 

73 indices: Sequence[int] = None) -> 'VibrationsData': 

74 """Instantiate VibrationsData when the Hessian is in a 3Nx3N format 

75 

76 Args: 

77 atoms: Equilibrium geometry of vibrating system 

78 

79 hessian: Second-derivative in energy with respect to 

80 Cartesian nuclear movements as a (3N, 3N) array. 

81 

82 indices: Indices of (non-frozen) atoms included in Hessian 

83 

84 """ 

85 if indices is None: 

86 indices = range(len(atoms)) 

87 assert indices is not None # Show Mypy that indices is now a sequence 

88 

89 hessian_2d_array = np.asarray(hessian_2d) 

90 n_atoms = cls._check_dimensions(atoms, hessian_2d_array, 

91 indices=indices, two_d=True) 

92 

93 return cls(atoms, hessian_2d_array.reshape(n_atoms, 3, n_atoms, 3), 

94 indices=indices) 

95 

96 @staticmethod 

97 def indices_from_mask(mask: Union[Sequence[bool], np.ndarray] 

98 ) -> List[int]: 

99 """Indices corresponding to boolean mask 

100 

101 This is provided as a convenience for instantiating VibrationsData with 

102 a boolean mask. For example, if the Hessian data includes only the H 

103 atoms in a structure:: 

104 

105 h_mask = atoms.get_chemical_symbols() == 'H' 

106 vib_data = VibrationsData(atoms, hessian, 

107 VibrationsData.indices_from_mask(h_mask)) 

108 

109 Take care to ensure that the length of the mask corresponds to the full 

110 number of atoms; this function is only aware of the mask it has been 

111 given. 

112 

113 Args: 

114 mask: a sequence of True, False values 

115 

116 Returns: 

117 indices of True elements 

118 

119 """ 

120 return np.where(mask)[0].tolist() 

121 

122 @staticmethod 

123 def _check_dimensions(atoms: Atoms, 

124 hessian: np.ndarray, 

125 indices: Sequence[int], 

126 two_d: bool = False) -> int: 

127 """Sanity check on array shapes from input data 

128 

129 Args: 

130 atoms: Structure 

131 indices: Indices of atoms used in Hessian 

132 hessian: Proposed Hessian array 

133 

134 Returns: 

135 Number of atoms contributing to Hessian 

136 

137 Raises: 

138 ValueError if Hessian dimensions are not (N, 3, N, 3) 

139 

140 """ 

141 

142 n_atoms = len(atoms[indices]) 

143 

144 if two_d: 

145 ref_shape = [n_atoms * 3, n_atoms * 3] 

146 ref_shape_txt = '{n:d}x{n:d}'.format(n=(n_atoms * 3)) 

147 

148 else: 

149 ref_shape = [n_atoms, 3, n_atoms, 3] 

150 ref_shape_txt = '{n:d}x3x{n:d}x3'.format(n=n_atoms) 

151 

152 if (isinstance(hessian, np.ndarray) 

153 and hessian.shape == tuple(ref_shape)): 

154 return n_atoms 

155 else: 

156 raise ValueError("Hessian for these atoms should be a " 

157 "{} numpy array.".format(ref_shape_txt)) 

158 

159 def get_atoms(self) -> Atoms: 

160 return self._atoms.copy() 

161 

162 def get_indices(self) -> np.ndarray: 

163 return self._indices.copy() 

164 

165 def get_mask(self) -> np.ndarray: 

166 """Boolean mask of atoms selected by indices""" 

167 return self._mask_from_indices(self._atoms, self.get_indices()) 

168 

169 @staticmethod 

170 def _mask_from_indices(atoms: Atoms, 

171 indices: Union[None, Sequence[int], np.ndarray] 

172 ) -> np.ndarray: 

173 """Boolean mask of atoms selected by indices""" 

174 natoms = len(atoms) 

175 

176 # Wrap indices to allow negative values 

177 indices = np.asarray(indices) % natoms 

178 

179 mask = np.full(natoms, False, dtype=bool) 

180 mask[indices] = True 

181 return mask 

182 

183 def get_hessian(self) -> np.ndarray: 

184 """The Hessian; second derivative of energy wrt positions 

185 

186 This format is preferred for iteration over atoms and when 

187 addressing specific elements of the Hessian. 

188 

189 Returns: 

190 array with shape (n_atoms, 3, n_atoms, 3) where 

191 - the first and third indices identify atoms in self.get_atoms() 

192 - the second and fourth indices cover the corresponding Cartesian 

193 movements in x, y, z 

194 

195 e.g. the element h[0, 2, 1, 0] gives a harmonic force exerted on 

196 atoms[1] in the x-direction in response to a movement in the 

197 z-direction of atoms[0] 

198 

199 """ 

200 n_atoms = int(self._hessian2d.shape[0] / 3) 

201 return self._hessian2d.reshape(n_atoms, 3, n_atoms, 3).copy() 

202 

203 def get_hessian_2d(self) -> np.ndarray: 

204 """Get the Hessian as a 2-D array 

205 

206 This format may be preferred for use with standard linear algebra 

207 functions 

208 

209 Returns: 

210 array with shape (n_atoms * 3, n_atoms * 3) where the elements are 

211 ordered by atom and Cartesian direction 

212 

213 [[at1x_at1x, at1x_at1y, at1x_at1z, at1x_at2x, ...], 

214 [at1y_at1x, at1y_at1y, at1y_at1z, at1y_at2x, ...], 

215 [at1z_at1x, at1z_at1y, at1z_at1z, at1z_at2x, ...], 

216 [at2x_at1x, at2x_at1y, at2x_at1z, at2x_at2x, ...], 

217 ...] 

218 

219 e.g. the element h[2, 3] gives a harmonic force exerted on 

220 atoms[1] in the x-direction in response to a movement in the 

221 z-direction of atoms[0] 

222 

223 """ 

224 return self._hessian2d.copy() 

225 

226 def todict(self) -> Dict[str, Any]: 

227 if np.allclose(self._indices, range(len(self._atoms))): 

228 indices = None 

229 else: 

230 indices = self.get_indices() 

231 

232 return {'atoms': self.get_atoms(), 

233 'hessian': self.get_hessian(), 

234 'indices': indices} 

235 

236 @classmethod 

237 def fromdict(cls, data: Dict[str, Any]) -> 'VibrationsData': 

238 # mypy is understandably suspicious of data coming from a dict that 

239 # holds mixed types, but it can see if we sanity-check with 'assert' 

240 assert isinstance(data['atoms'], Atoms) 

241 assert isinstance(data['hessian'], (collections.abc.Sequence, 

242 np.ndarray)) 

243 if data['indices'] is not None: 

244 assert isinstance(data['indices'], (collections.abc.Sequence, 

245 np.ndarray)) 

246 for index in data['indices']: 

247 assert isinstance(index, Integral) 

248 

249 return cls(data['atoms'], data['hessian'], indices=data['indices']) 

250 

251 @lazymethod 

252 def _energies_and_modes(self) -> Tuple[np.ndarray, np.ndarray]: 

253 """Diagonalise the Hessian to obtain harmonic modes 

254 

255 This method is an internal implementation of get_energies_and_modes(), 

256 see the docstring of that method for more information. 

257 

258 """ 

259 active_atoms = self._atoms[self.get_mask()] 

260 n_atoms = len(active_atoms) 

261 masses = active_atoms.get_masses() 

262 

263 if not np.all(masses): 

264 raise ValueError('Zero mass encountered in one or more of ' 

265 'the vibrated atoms. Use Atoms.set_masses()' 

266 ' to set all masses to non-zero values.') 

267 mass_weights = np.repeat(masses**-0.5, 3) 

268 

269 omega2, vectors = np.linalg.eigh(mass_weights 

270 * self.get_hessian_2d() 

271 * mass_weights[:, np.newaxis]) 

272 

273 unit_conversion = units._hbar * units.m / sqrt(units._e * units._amu) 

274 energies = unit_conversion * omega2.astype(complex)**0.5 

275 

276 modes = vectors.T.reshape(n_atoms * 3, n_atoms, 3) 

277 modes = modes * masses[np.newaxis, :, np.newaxis]**-0.5 

278 

279 return (energies, modes) 

280 

281 def get_energies_and_modes(self, all_atoms: bool = False 

282 ) -> Tuple[np.ndarray, np.ndarray]: 

283 """Diagonalise the Hessian to obtain harmonic modes 

284 

285 Results are cached so diagonalization will only be performed once for 

286 this object instance. 

287 

288 Args: 

289 all_atoms: 

290 If True, return modes as (3N, [N + N_frozen], 3) array where 

291 the second axis corresponds to the full list of atoms in the 

292 attached atoms object. Atoms that were not included in the 

293 Hessian will have displacement vectors of (0, 0, 0). 

294 

295 Returns: 

296 tuple (energies, modes) 

297 

298 Energies are given in units of eV. (To convert these to frequencies 

299 in cm-1, divide by ase.units.invcm.) 

300 

301 Modes are given in Cartesian coordinates as a (3N, N, 3) array 

302 where indices correspond to the (mode_index, atom, direction). 

303 

304 """ 

305 

306 energies, modes_from_hessian = self._energies_and_modes() 

307 

308 if all_atoms: 

309 n_active_atoms = len(self.get_indices()) 

310 n_all_atoms = len(self._atoms) 

311 modes = np.zeros((3 * n_active_atoms, n_all_atoms, 3)) 

312 modes[:, self.get_mask(), :] = modes_from_hessian 

313 else: 

314 modes = modes_from_hessian.copy() 

315 

316 return (energies.copy(), modes) 

317 

318 def get_modes(self, all_atoms: bool = False) -> np.ndarray: 

319 """Diagonalise the Hessian to obtain harmonic modes 

320 

321 Results are cached so diagonalization will only be performed once for 

322 this object instance. 

323 

324 all_atoms: 

325 If True, return modes as (3N, [N + N_frozen], 3) array where 

326 the second axis corresponds to the full list of atoms in the 

327 attached atoms object. Atoms that were not included in the 

328 Hessian will have displacement vectors of (0, 0, 0). 

329 

330 Returns: 

331 Modes in Cartesian coordinates as a (3N, N, 3) array where indices 

332 correspond to the (mode_index, atom, direction). 

333 

334 """ 

335 return self.get_energies_and_modes(all_atoms=all_atoms)[1] 

336 

337 def get_energies(self) -> np.ndarray: 

338 """Diagonalise the Hessian to obtain eigenvalues 

339 

340 Results are cached so diagonalization will only be performed once for 

341 this object instance. 

342 

343 Returns: 

344 Harmonic mode energies in units of eV 

345 

346 """ 

347 return self.get_energies_and_modes()[0] 

348 

349 def get_frequencies(self) -> np.ndarray: 

350 """Diagonalise the Hessian to obtain frequencies in cm^-1 

351 

352 Results are cached so diagonalization will only be performed once for 

353 this object instance. 

354 

355 Returns: 

356 Harmonic mode frequencies in units of cm^-1 

357 

358 """ 

359 

360 return self.get_energies() / units.invcm 

361 

362 def get_zero_point_energy(self) -> float: 

363 """Diagonalise the Hessian and sum hw/2 to obtain zero-point energy 

364 

365 Args: 

366 energies: 

367 Pre-computed energy eigenvalues. Use if available to avoid 

368 re-calculating these from the Hessian. 

369 

370 Returns: 

371 zero-point energy in eV 

372 """ 

373 return self._calculate_zero_point_energy(self.get_energies()) 

374 

375 @staticmethod 

376 def _calculate_zero_point_energy(energies: Union[Sequence[complex], 

377 np.ndarray]) -> float: 

378 return 0.5 * np.asarray(energies).real.sum() 

379 

380 def tabulate(self, im_tol: float = 1e-8) -> str: 

381 """Get a summary of the vibrational frequencies. 

382 

383 Args: 

384 im_tol: 

385 Tolerance for imaginary frequency in eV. If frequency has a 

386 larger imaginary component than im_tol, the imaginary component 

387 is shown in the summary table. 

388 

389 Returns: 

390 Summary table as formatted text 

391 """ 

392 

393 energies = self.get_energies() 

394 

395 return ('\n'.join(self._tabulate_from_energies(energies, 

396 im_tol=im_tol)) 

397 + '\n') 

398 

399 @classmethod 

400 def _tabulate_from_energies(cls, 

401 energies: Union[Sequence[complex], np.ndarray], 

402 im_tol: float = 1e-8) -> List[str]: 

403 summary_lines = ['---------------------', 

404 ' # meV cm^-1', 

405 '---------------------'] 

406 

407 for n, e in enumerate(energies): 

408 if abs(e.imag) > im_tol: 

409 c = 'i' 

410 e = e.imag 

411 else: 

412 c = '' 

413 e = e.real 

414 

415 summary_lines.append('{index:3d} {mev:6.1f}{im:1s} {cm:7.1f}{im}' 

416 .format(index=n, mev=(e * 1e3), 

417 cm=(e / units.invcm), im=c)) 

418 summary_lines.append('---------------------') 

419 summary_lines.append('Zero-point energy: {:.3f} eV'.format( 

420 cls._calculate_zero_point_energy(energies=energies))) 

421 

422 return summary_lines 

423 

424 def iter_animated_mode(self, mode_index: int, 

425 temperature: float = units.kB * 300, 

426 frames: int = 30) -> Iterator[Atoms]: 

427 """Obtain animated mode as a series of Atoms 

428 

429 Args: 

430 mode_index: Selection of mode to animate 

431 temperature: In energy units - use units.kB * T_IN_KELVIN 

432 frames: number of image frames in animation 

433 

434 Yields: 

435 Displaced atoms following vibrational mode 

436 

437 """ 

438 

439 mode = (self.get_modes(all_atoms=True)[mode_index] 

440 * sqrt(temperature / abs(self.get_energies()[mode_index]))) 

441 

442 for phase in np.linspace(0, 2 * pi, frames, endpoint=False): 

443 atoms = self.get_atoms() 

444 atoms.positions += sin(phase) * mode 

445 

446 yield atoms 

447 

448 def show_as_force(self, 

449 mode: int, 

450 scale: float = 0.2, 

451 show: bool = True) -> Atoms: 

452 """Illustrate mode as "forces" on atoms 

453 

454 Args: 

455 mode: mode index 

456 scale: scale factor 

457 show: if True, open the ASE GUI and show atoms 

458 

459 Returns: 

460 Atoms with scaled forces corresponding to mode eigenvectors (using 

461 attached SinglePointCalculator). 

462 

463 """ 

464 

465 atoms = self.get_atoms() 

466 mode = self.get_modes(all_atoms=True)[mode] * len(atoms) * 3 * scale 

467 atoms.calc = SinglePointCalculator(atoms, forces=mode) 

468 if show: 

469 atoms.edit() 

470 

471 return atoms 

472 

473 def write_jmol(self, 

474 filename: str = 'vib.xyz', 

475 ir_intensities: Union[Sequence[float], np.ndarray] = None 

476 ) -> None: 

477 """Writes file for viewing of the modes with jmol. 

478 

479 This is an extended XYZ file with eigenvectors given as extra columns 

480 and metadata given in the label/comment line for each image. The format 

481 is not quite human-friendly, but has the advantage that it can be 

482 imported back into ASE with ase.io.read. 

483 

484 Args: 

485 filename: Path for output file 

486 ir_intensities: If available, IR intensities can be included in the 

487 header lines. This does not affect the visualisation, but may 

488 be convenient when comparing to experimental data. 

489 """ 

490 

491 all_images = list(self._get_jmol_images(atoms=self.get_atoms(), 

492 energies=self.get_energies(), 

493 modes=self.get_modes(all_atoms=True), 

494 ir_intensities=ir_intensities)) 

495 ase.io.write(filename, all_images, format='extxyz') 

496 

497 @staticmethod 

498 def _get_jmol_images(atoms: Atoms, 

499 energies: np.ndarray, 

500 modes: np.ndarray, 

501 ir_intensities: 

502 Union[Sequence[float], np.ndarray] = None 

503 ) -> Iterator[Atoms]: 

504 """Get vibrational modes as a series of Atoms with attached data 

505 

506 For each image (Atoms object): 

507 

508 - eigenvalues are attached to image.arrays['mode'] 

509 - "mode#" and "frequency_cm-1" are set in image.info 

510 - "IR_intensity" is set if provided in ir_intensities 

511 - "masses" is removed 

512 

513 This is intended to set up the object for JMOL-compatible export using 

514 ase.io.extxyz. 

515 

516 

517 Args: 

518 atoms: The base atoms object; all images have the same positions 

519 energies: Complex vibrational energies in eV 

520 modes: Eigenvectors array corresponding to atoms and energies. This 

521 should cover the full set of atoms (i.e. modes = 

522 vib.get_modes(all_atoms=True)). 

523 ir_intensities: If available, IR intensities can be included in the 

524 header lines. This does not affect the visualisation, but may 

525 be convenient when comparing to experimental data. 

526 Returns: 

527 Iterator of Atoms objects 

528 

529 """ 

530 for i, (energy, mode) in enumerate(zip(energies, modes)): 

531 # write imaginary frequencies as negative numbers 

532 if energy.imag > energy.real: 

533 energy = float(-energy.imag) 

534 else: 

535 energy = energy.real 

536 

537 image = atoms.copy() 

538 image.info.update({'mode#': str(i), 

539 'frequency_cm-1': energy / units.invcm, 

540 }) 

541 image.arrays['mode'] = mode 

542 

543 # Custom masses are quite useful in vibration analysis, but will 

544 # show up in the xyz file unless we remove them 

545 if image.has('masses'): 

546 del image.arrays['masses'] 

547 

548 if ir_intensities is not None: 

549 image.info['IR_intensity'] = float(ir_intensities[i]) 

550 

551 yield image 

552 

553 def get_dos(self) -> RawDOSData: 

554 """Total phonon DOS""" 

555 energies = self.get_energies() 

556 return RawDOSData(energies, np.ones_like(energies)) 

557 

558 def get_pdos(self) -> DOSCollection: 

559 """Phonon DOS, including atomic contributions""" 

560 energies = self.get_energies() 

561 masses = self._atoms[self.get_mask()].get_masses() 

562 

563 # Get weights as N_moving_atoms x N_modes array 

564 vectors = self.get_modes() / masses[np.newaxis, :, np.newaxis]**-0.5 

565 all_weights = (np.linalg.norm(vectors, axis=-1)**2).T 

566 

567 mask = self.get_mask() 

568 all_info = [{'index': i, 'symbol': a.symbol} 

569 for i, a in enumerate(self._atoms) if mask[i]] 

570 

571 return DOSCollection([RawDOSData(energies, weights, info=info) 

572 for weights, info in zip(all_weights, all_info)]) 

573 

574 def with_new_masses(self: VD, masses: Union[Sequence[float], np.ndarray] 

575 ) -> VD: 

576 """Get a copy of vibrations with modified masses and the same Hessian 

577 

578 Args: 

579 masses: 

580 New sequence of masses corresponding to the atom order in 

581 self.get_atoms() 

582 Returns: 

583 A copy of the data with new masses for the same Hessian 

584 """ 

585 

586 new_atoms = self.get_atoms() 

587 new_atoms.set_masses(masses) 

588 return self.__class__(new_atoms, self.get_hessian(), 

589 indices=self.get_indices())