Coverage for /builds/debichem-team/python-ase/ase/vibrations/data.py: 98.33%

180 statements  

« prev     ^ index     » next       coverage.py v7.5.3, created at 2025-03-06 04:00 +0000

1"""Storage and analysis for vibrational data""" 

2 

3import collections 

4from functools import cached_property 

5from math import pi, sin, sqrt 

6from numbers import Integral, Real 

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

8 

9import numpy as np 

10 

11import ase.io 

12import ase.units as units 

13from ase.atoms import Atoms 

14from ase.calculators.singlepoint import SinglePointCalculator 

15from ase.constraints import FixAtoms, FixCartesian, constrained_indices 

16from ase.spectrum.doscollection import DOSCollection 

17from ase.spectrum.dosdata import RawDOSData 

18from ase.utils import jsonable 

19 

20RealSequence4D = Sequence[Sequence[Sequence[Sequence[Real]]]] 

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

22 

23 

24@jsonable('vibrationsdata') 

25class VibrationsData: 

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

27 

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

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

30 VibrationsData has been constructed, this class provides some common 

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

32 

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

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

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

36 dispersion plotting. 

37 

38 If the Atoms object has FixedAtoms or FixedCartesian constraints, these 

39 will be respected and the Hessian should be sized accordingly. 

40 

41 Args: 

42 atoms: 

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

44 full copy. 

45 

46 hessian: Second-derivative in energy with respect to 

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

48 

49 indices: indices of atoms which are included 

50 in Hessian. Default value (None) includes all freely 

51 moving atoms (i.e. not fixed ones). Leave at None if 

52 constraints should be determined automatically from the 

53 atoms object. 

54 

55 """ 

56 

57 def __init__(self, 

58 atoms: Atoms, 

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

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

61 ) -> None: 

62 

63 if indices is None: 

64 indices = np.asarray(self.indices_from_constraints(atoms), 

65 dtype=int) 

66 

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

68 

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

70 indices=self._indices) 

71 self._atoms = atoms.copy() 

72 

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

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

75 

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

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

78 "(optionally) indices/mask.") 

79 

80 @classmethod 

81 def from_2d(cls, atoms: Atoms, 

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

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

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

85 

86 Args: 

87 atoms: Equilibrium geometry of vibrating system 

88 

89 hessian: Second-derivative in energy with respect to 

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

91 

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

93 

94 """ 

95 if indices is None: 

96 indices = range(len(atoms)) 

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

98 

99 hessian_2d_array = np.asarray(hessian_2d) 

100 n_atoms = cls._check_dimensions(atoms, hessian_2d_array, 

101 indices=indices, two_d=True) 

102 

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

104 indices=indices) 

105 

106 @staticmethod 

107 def indices_from_constraints(atoms: Atoms) -> List[int]: 

108 """Indices corresponding to Atoms Constraints 

109 

110 Deduces the freely moving atoms from the constraints set on the 

111 atoms object. VibrationsData only supports FixCartesian and 

112 FixAtoms. All others are neglected. 

113 

114 Args: 

115 atoms: Atoms object. 

116 

117 Retruns: 

118 indices of free atoms. 

119 

120 """ 

121 # Only fully fixed atoms supported by VibrationsData 

122 const_indices = constrained_indices( 

123 atoms, only_include=(FixCartesian, FixAtoms)) 

124 # Invert the selection to get free atoms 

125 indices = np.setdiff1d( 

126 np.array( 

127 range( 

128 len(atoms))), 

129 const_indices).astype(int) 

130 return indices.tolist() 

131 

132 @staticmethod 

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

134 ) -> List[int]: 

135 """Indices corresponding to boolean mask 

136 

137 This is provided as a convenience for instantiating VibrationsData with 

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

139 atoms in a structure:: 

140 

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

142 vib_data = VibrationsData(atoms, hessian, 

143 VibrationsData.indices_from_mask(h_mask)) 

144 

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

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

147 given. 

148 

149 Args: 

150 mask: a sequence of True, False values 

151 

152 Returns: 

153 indices of True elements 

154 

155 """ 

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

157 

158 @staticmethod 

159 def _check_dimensions(atoms: Atoms, 

160 hessian: np.ndarray, 

161 indices: Union[np.ndarray, Sequence[int]], 

162 two_d: bool = False) -> int: 

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

164 

165 Args: 

166 atoms: Structure 

167 indices: Indices of atoms used in Hessian 

168 hessian: Proposed Hessian array 

169 

170 Returns: 

171 Number of atoms contributing to Hessian 

172 

173 Raises: 

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

175 

176 """ 

177 

178 n_atoms = len(atoms[indices]) 

179 

180 if two_d: 

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

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

183 

184 else: 

185 ref_shape = [n_atoms, 3, n_atoms, 3] 

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

187 

188 if (isinstance(hessian, np.ndarray) 

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

190 return n_atoms 

191 else: 

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

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

194 

195 def get_atoms(self) -> Atoms: 

196 return self._atoms.copy() 

197 

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

199 return self._indices.copy() 

200 

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

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

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

204 

205 @staticmethod 

206 def _mask_from_indices(atoms: Atoms, 

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

208 ) -> np.ndarray: 

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

210 natoms = len(atoms) 

211 

212 # Wrap indices to allow negative values 

213 indices = np.asarray(indices) % natoms 

214 

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

216 mask[indices] = True 

217 return mask 

218 

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

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

221 

222 This format is preferred for iteration over atoms and when 

223 addressing specific elements of the Hessian. 

224 

225 Returns: 

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

227 

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

229 

230 - the second and fourth indices cover the corresponding 

231 Cartesian movements in x, y, z 

232 

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

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

235 z-direction of atoms[0] 

236 """ 

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

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

239 

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

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

242 

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

244 functions 

245 

246 Returns: 

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

248 ordered by atom and Cartesian direction:: 

249 

250 >> [[at1x_at1x, at1x_at1y, at1x_at1z, at1x_at2x, ...], 

251 >> [at1y_at1x, at1y_at1y, at1y_at1z, at1y_at2x, ...], 

252 >> [at1z_at1x, at1z_at1y, at1z_at1z, at1z_at2x, ...], 

253 >> [at2x_at1x, at2x_at1y, at2x_at1z, at2x_at2x, ...], 

254 >> ...] 

255 

256 

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

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

259 z-direction of atoms[0] 

260 """ 

261 return self._hessian2d.copy() 

262 

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

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

265 indices = None 

266 else: 

267 indices = self.get_indices() 

268 

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

270 'hessian': self.get_hessian(), 

271 'indices': indices} 

272 

273 @classmethod 

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

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

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

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

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

279 np.ndarray)) 

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

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

282 np.ndarray)) 

283 for index in data['indices']: 

284 assert isinstance(index, Integral) 

285 

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

287 

288 @cached_property 

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

290 """Diagonalise the Hessian to obtain harmonic modes 

291 

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

293 see the docstring of that method for more information. 

294 

295 """ 

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

297 n_atoms = len(active_atoms) 

298 masses = active_atoms.get_masses() 

299 

300 if not np.all(masses): 

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

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

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

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

305 

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

307 * self.get_hessian_2d() 

308 * mass_weights[:, np.newaxis]) 

309 

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

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

312 

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

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

315 

316 return (energies, modes) 

317 

318 def get_energies_and_modes(self, all_atoms: bool = False 

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

320 """Diagonalise the Hessian to obtain harmonic modes 

321 

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

323 this object instance. 

324 

325 Args: 

326 all_atoms: 

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

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

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

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

331 

332 Returns: 

333 tuple (energies, modes) 

334 

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

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

337 

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

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

340 

341 """ 

342 

343 energies, modes_from_hessian = self._energies_and_modes 

344 

345 if all_atoms: 

346 n_active_atoms = len(self.get_indices()) 

347 n_all_atoms = len(self._atoms) 

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

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

350 else: 

351 modes = modes_from_hessian.copy() 

352 

353 return (energies.copy(), modes) 

354 

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

356 """Diagonalise the Hessian to obtain harmonic modes 

357 

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

359 this object instance. 

360 

361 all_atoms: 

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

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

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

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

366 

367 Returns: 

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

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

370 

371 """ 

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

373 

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

375 """Diagonalise the Hessian to obtain eigenvalues 

376 

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

378 this object instance. 

379 

380 Returns: 

381 Harmonic mode energies in units of eV 

382 

383 """ 

384 return self.get_energies_and_modes()[0] 

385 

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

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

388 

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

390 this object instance. 

391 

392 Returns: 

393 Harmonic mode frequencies in units of cm^-1 

394 

395 """ 

396 

397 return self.get_energies() / units.invcm 

398 

399 def get_zero_point_energy(self) -> float: 

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

401 

402 Args: 

403 energies: 

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

405 re-calculating these from the Hessian. 

406 

407 Returns: 

408 zero-point energy in eV 

409 """ 

410 return self._calculate_zero_point_energy(self.get_energies()) 

411 

412 @staticmethod 

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

414 np.ndarray]) -> float: 

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

416 

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

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

419 

420 Args: 

421 im_tol: 

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

423 larger imaginary component than im_tol, the imaginary component 

424 is shown in the summary table. 

425 

426 Returns: 

427 Summary table as formatted text 

428 """ 

429 

430 energies = self.get_energies() 

431 

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

433 im_tol=im_tol)) 

434 + '\n') 

435 

436 @classmethod 

437 def _tabulate_from_energies(cls, 

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

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

440 summary_lines = ['---------------------', 

441 ' # meV cm^-1', 

442 '---------------------'] 

443 

444 for n, e in enumerate(energies): 

445 if abs(e.imag) > im_tol: 

446 c = 'i' 

447 e = e.imag 

448 else: 

449 c = '' 

450 e = e.real 

451 

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

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

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

455 summary_lines.append('---------------------') 

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

457 cls._calculate_zero_point_energy(energies=energies))) 

458 

459 return summary_lines 

460 

461 def iter_animated_mode(self, mode_index: int, 

462 temperature: float = units.kB * 300, 

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

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

465 

466 Args: 

467 mode_index: Selection of mode to animate 

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

469 frames: number of image frames in animation 

470 

471 Yields: 

472 Displaced atoms following vibrational mode 

473 

474 """ 

475 

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

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

478 

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

480 atoms = self.get_atoms() 

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

482 

483 yield atoms 

484 

485 def show_as_force(self, 

486 mode: int, 

487 scale: float = 0.2, 

488 show: bool = True) -> Atoms: 

489 """Illustrate mode as "forces" on atoms 

490 

491 Args: 

492 mode: mode index 

493 scale: scale factor 

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

495 

496 Returns: 

497 Atoms with scaled forces corresponding to mode eigenvectors (using 

498 attached SinglePointCalculator). 

499 

500 """ 

501 

502 atoms = self.get_atoms() 

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

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

505 if show: 

506 atoms.edit() 

507 

508 return atoms 

509 

510 def write_jmol(self, 

511 filename: str = 'vib.xyz', 

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

513 ) -> None: 

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

515 

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

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

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

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

520 

521 Args: 

522 filename: Path for output file 

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 """ 

527 

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

529 energies=self.get_energies(), 

530 modes=self.get_modes( 

531 all_atoms=True), 

532 ir_intensities=ir_intensities)) 

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

534 

535 @staticmethod 

536 def _get_jmol_images(atoms: Atoms, 

537 energies: np.ndarray, 

538 modes: np.ndarray, 

539 ir_intensities: 

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

541 ) -> Iterator[Atoms]: 

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

543 

544 For each image (Atoms object): 

545 

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

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

548 - "IR_intensity" is set if provided in ir_intensities 

549 - "masses" is removed 

550 

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

552 ase.io.extxyz. 

553 

554 

555 Args: 

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

557 energies: Complex vibrational energies in eV 

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

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

560 vib.get_modes(all_atoms=True)). 

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

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

563 be convenient when comparing to experimental data. 

564 Returns: 

565 Iterator of Atoms objects 

566 

567 """ 

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

569 # write imaginary frequencies as negative numbers 

570 if energy.imag > energy.real: 

571 energy = float(-energy.imag) 

572 else: 

573 energy = energy.real 

574 

575 image = atoms.copy() 

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

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

578 }) 

579 image.arrays['mode'] = mode 

580 

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

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

583 if image.has('masses'): 

584 del image.arrays['masses'] 

585 

586 if ir_intensities is not None: 

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

588 

589 yield image 

590 

591 def get_dos(self) -> RawDOSData: 

592 """Total phonon DOS""" 

593 energies = self.get_energies() 

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

595 

596 def get_pdos(self) -> DOSCollection: 

597 """Phonon DOS, including atomic contributions""" 

598 energies = self.get_energies() 

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

600 

601 # Get weights as N_moving_atoms x N_modes array 

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

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

604 

605 mask = self.get_mask() 

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

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

608 

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

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

611 

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

613 ) -> VD: 

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

615 

616 Args: 

617 masses: 

618 New sequence of masses corresponding to the atom order in 

619 self.get_atoms() 

620 Returns: 

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

622 """ 

623 

624 new_atoms = self.get_atoms() 

625 new_atoms.set_masses(masses) 

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

627 indices=self.get_indices())