Coverage for ase / thermochemistry.py: 92.86%

658 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-30 08:22 +0000

1# fmt: off 

2 

3"""Modules for calculating thermochemical information from computational 

4outputs.""" 

5 

6import os 

7import sys 

8from abc import ABC, abstractmethod 

9from collections.abc import Sequence 

10from typing import Literal 

11from warnings import catch_warnings, simplefilter, warn 

12 

13import numpy as np 

14from scipy.integrate import trapezoid 

15 

16from ase import Atoms, units 

17 

18_GEOMETRY_OPTIONS = Literal['linear', 'nonlinear', 'monatomic'] 

19_VIB_SELECTION_OPTIONS = Literal['exact', 'all', 'highest', 'abs_highest'] 

20_FLOAT_OR_FLOATWITHDICT = float | tuple[float, dict[str, float]] 

21_FLOATWITHDICT = tuple[float, dict[str, float]] 

22 

23 

24def _sum_contributions(contrib_dicts: dict[str, float]) -> float: 

25 """Combine a Dict of floats to their sum. 

26 

27 Ommits keys starting with an underscore. 

28 """ 

29 return np.sum([value for key, value in contrib_dicts.items() 

30 if not key.startswith('_')]) 

31 

32 

33class AbstractMode(ABC): 

34 """Abstract base class for mode objects. 

35 

36 Input: 

37 

38 energy: float 

39 Initialize the mode with its energy in eV. 

40 Note: This energy refers to the vibrational energy of the mode. 

41 Get it e.g. from ase.vibrations.Vibrations.get_energies. 

42 """ 

43 

44 def __init__(self, energy: float) -> None: 

45 self.energy = energy 

46 

47 @abstractmethod 

48 def get_internal_energy( 

49 self, 

50 temperature: float, 

51 contributions: bool) -> _FLOAT_OR_FLOATWITHDICT: 

52 raise NotImplementedError 

53 

54 @abstractmethod 

55 def get_entropy(self, temperature: float, 

56 contributions: bool) -> _FLOAT_OR_FLOATWITHDICT: 

57 raise NotImplementedError 

58 

59 

60class HarmonicMode(AbstractMode): 

61 """Class for a single harmonic mode.""" 

62 

63 def get_ZPE_correction(self) -> float: 

64 """Returns the zero-point vibrational energy correction in eV.""" 

65 return 0.5 * self.energy 

66 

67 def get_vib_energy_contribution(self, temperature: float) -> float: 

68 """Calculates the change in internal energy due to vibrations from 

69 0K to the specified temperature for a set of vibrations given in 

70 eV and a temperature given in Kelvin. 

71 Returns the energy change in eV.""" 

72 kT = units.kB * temperature 

73 return self.energy / (np.exp(self.energy / kT) - 1.) 

74 

75 def get_vib_entropy_contribution(self, temperature: float) -> float: 

76 """Calculates the entropy due to vibrations given in eV and a 

77 temperature given in Kelvin. Returns the entropy in eV/K.""" 

78 e = self.energy / units.kB / temperature 

79 S_v = e / (np.exp(e) - 1.) - np.log(1. - np.exp(-e)) 

80 S_v *= units.kB 

81 return S_v 

82 

83 def get_internal_energy(self, 

84 temperature: float, 

85 contributions: bool 

86 ) -> _FLOAT_OR_FLOATWITHDICT: 

87 """Returns the internal energy in the harmonic approximation at a 

88 specified temperature (K).""" 

89 ret = {} 

90 ret['ZPE'] = self.get_ZPE_correction() 

91 ret['dU_v'] = self.get_vib_energy_contribution(temperature) 

92 ret_sum = _sum_contributions(ret) 

93 

94 if contributions: 

95 return ret_sum, ret 

96 else: 

97 return ret_sum 

98 

99 def get_entropy(self, 

100 temperature: float, 

101 contributions: bool) -> _FLOAT_OR_FLOATWITHDICT: 

102 """Returns the entropy in the harmonic approximation at a specified 

103 temperature (K). 

104 

105 Parameters 

106 ---------- 

107 temperature : float 

108 The temperature in Kelvin. 

109 contributions : bool 

110 If True, return the contributions to the entropy as a dict too. 

111 Here it will only contain the vibrational entropy. 

112 

113 Returns 

114 ------- 

115 float or Tuple[float, Dict[str, float]] 

116 """ 

117 ret = {} 

118 ret['S_v'] = self.get_vib_entropy_contribution(temperature) 

119 if contributions: 

120 return ret['S_v'], ret 

121 else: 

122 return ret['S_v'] 

123 

124 

125class RRHOMode(HarmonicMode): 

126 """Class for a single RRHO mode, including Grimme's scaling method 

127 based on :doi:`10.1002/chem.201200497` and :doi:`10.1039/D1SC00621E`. 

128 

129 Inputs: 

130 

131 mean_inertia : float 

132 The mean moment of inertia in amu*angstrom^2. Use 

133 `np.mean(ase.Atoms.get_moments_of_inertia())` to calculate. 

134 tau : float 

135 the vibrational energy threshold in :math:`cm^{-1}`, named 

136 :math:`\\tau` in :doi:`10.1039/D1SC00621E`. 

137 Values close or equal to 0 will result in the standard harmonic 

138 approximation. Defaults to :math:`35cm^{-1}`. 

139 treat_int_energy : bool 

140 Extend the msRRHO treatement to the internal thermal energy. 

141 If False, only the entropy contribution as in Grimmmes paper is 

142 modified according to the RRHO scheme. If True, the approach of 

143 Otlyotov and Minenkov :doi:`10.1002/jcc.27129` is used to modify 

144 the internal energy contribution. 

145 """ 

146 

147 def __init__(self, energy: float, 

148 mean_inertia: float, 

149 tau: float = 35.0, 

150 treat_int_energy: bool = False) -> None: 

151 if np.iscomplex(energy): 

152 raise ValueError( 

153 "Imaginary frequencies are not allowed in RRHO mode.") 

154 super().__init__(energy) 

155 self._mean_inertia = mean_inertia 

156 self._tau = tau 

157 self._alpha = 4 # from paper 10.1002/chem.201200497 

158 self.treat_int_energy = treat_int_energy 

159 

160 @property 

161 def frequency(self) -> float: 

162 return self.energy / units.invcm 

163 

164 def _head_gordon_damp(self, freq: float) -> float: 

165 """Head-Gordon damping function. 

166 

167 Equation 8 from :doi:`10.1002/chem.201200497` 

168 

169 Parameters 

170 ---------- 

171 freq : float 

172 The frequency in the same unit as tau. 

173 

174 Returns 

175 ------- 

176 float 

177 """ 

178 return 1 / (1 + (self._tau / freq)**self._alpha) 

179 

180 def _apply_head_gordon_damp(self, freq: float, 

181 large_part: float, 

182 small_part: float) -> float: 

183 """Apply the head-gordon damping scheme to two contributions. 

184 

185 Equation 7 from :doi:`10.1002/chem.201200497` 

186 

187 Returns the damped sum of the two contributions.""" 

188 part_one = self._head_gordon_damp(freq) * large_part 

189 part_two = (1 - self._head_gordon_damp(freq)) * small_part 

190 return part_one + part_two 

191 

192 def get_RRHO_entropy_r(self, temperature: float) -> float: 

193 """Calculates the rotation of a rigid rotor for low frequency modes. 

194 

195 Equation numbering from :doi:`10.1002/chem.201200497` 

196 

197 Returns the entropy contribution in eV/K.""" 

198 kT = units._k * temperature 

199 R = units._k * units._Nav # J / K / mol 

200 B_av = (self._mean_inertia / 

201 (units.kg * units.m**2)) # from amu/A^2 to kg m^2 

202 # note, some codes use B_av = 1e-44 as in 10.1002/chem.201200497 

203 # eq 4 

204 omega = units._c * self.frequency * 1e2 # s^-1 

205 mu = units._hplanck / (8 * np.pi**2 * omega) # kg m^2 

206 # eq 5 

207 mu_prime = (mu * B_av) / (mu + B_av) # kg m^2 

208 # eq 6 

209 x = np.sqrt(8 * np.pi**3 * mu_prime * kT / (units._hplanck)**2) 

210 # filter zeros out and set them to zero 

211 if x == 0.0: 

212 log_x = 0.0 

213 else: 

214 log_x = np.log(x) 

215 S_r = R * (0.5 + log_x) # J/(Js)^2 

216 S_r *= units.J / units._Nav # J/K/mol to eV/K 

217 return S_r 

218 

219 def get_entropy(self, 

220 temperature: float, 

221 contributions: bool) -> _FLOAT_OR_FLOATWITHDICT: 

222 ret = {} 

223 ret['_S_vib_v'] = self.get_vib_entropy_contribution(temperature) 

224 ret['_S_vib_r'] = self.get_RRHO_entropy_r(temperature) 

225 ret['S_vib_damped'] = self._apply_head_gordon_damp( 

226 self.frequency, ret['_S_vib_v'], ret['_S_vib_r']) 

227 if contributions: 

228 return ret['S_vib_damped'], ret 

229 else: 

230 return ret['S_vib_damped'] 

231 

232 def get_rrho_internal_energy_v_contribution( 

233 self, temperature: float) -> float: 

234 """RRHO Vibrational Internal Energy Contribution from 

235 :doi:`10.1002/jcc.27129`. 

236 

237 Returns the internal energy contribution in eV.""" 

238 # equation numbering from :doi:`10.1002/jcc.27129` 

239 # eq 4 

240 # hv = self.energy 

241 theta = self.energy / units.kB 

242 E_v = 0.5 + 1 / (np.exp(theta / temperature) - 1) 

243 E_v *= self.energy # = theta * units.kB 

244 return E_v 

245 

246 @staticmethod 

247 def get_rrho_internal_energy_r_contribution(temperature: float) -> float: 

248 """Calculates the rotation of a rigid rotor contribution. 

249 

250 Equation numbering from :doi:`10.1002/jcc.27129` 

251 

252 Returns the internal energy contribution in eV.""" 

253 # eq 5 

254 R = units._k * units._Nav 

255 E_r = R * temperature / 2 

256 E_r *= units.J / units._Nav 

257 return E_r 

258 

259 def get_internal_energy(self, 

260 temperature: float, 

261 contributions: bool) -> _FLOAT_OR_FLOATWITHDICT: 

262 """Returns the internal energy in the msRRHO approximation at a 

263 specified temperature (K). 

264 

265 If self.treat_int_energy is True, the approach of Otlyotov 

266 and Minenkov :doi:`10.1002/jcc.27129` is used. Otherwise, the approach 

267 of Grimme :doi:`10.1002/chem.201200497` is used. 

268 """ 

269 if self.treat_int_energy: 

270 # Otlyotov and Minenkov approach with damping between vibrational 

271 # and rotational contributions to the internal energy 

272 # Note: The ZPE is not needed here, as the formula in the paper 

273 # uses the "bottom of the well" as reference. See 

274 # https://gaussian.com/wp-content/uploads/dl/thermo.pdf 

275 # for more formulas 

276 ret = {} 

277 ret['_dU_vib_v'] = self.get_rrho_internal_energy_v_contribution( 

278 temperature) 

279 ret['_dU_vib_r'] = self.get_rrho_internal_energy_r_contribution( 

280 temperature) 

281 ret['dU_vib_damped'] = self._apply_head_gordon_damp( 

282 self.frequency, ret['_dU_vib_v'], ret['_dU_vib_r']) 

283 if contributions: 

284 return ret['dU_vib_damped'], ret 

285 else: 

286 return ret['dU_vib_damped'] 

287 else: 

288 # Grimme uses the Harmonic approach for the internal energy 

289 return super().get_internal_energy(temperature, contributions) 

290 

291 

292class BaseThermoChem(ABC): 

293 """Abstract base class containing common methods used in thermochemistry 

294 calculations.""" 

295 

296 def __init__(self, 

297 vib_energies: Sequence[float] | None = None, 

298 atoms: Atoms | None = None, 

299 modes: Sequence[AbstractMode] | None = None, 

300 spin: float | None = None) -> None: 

301 if vib_energies is None and modes is None: 

302 raise ValueError("Either vib_energies or modes must be provided.") 

303 elif modes: 

304 self.modes = modes 

305 # in the monoatomic case, the vib_energies can be an empty list 

306 else: 

307 self._vib_energies = vib_energies 

308 self.referencepressure = 1.0e5 # Pa 

309 if atoms: 

310 # check that atoms has no periodic boundary conditions 

311 # moments of inertia are wrong otherwise. 

312 if atoms.pbc.any(): 

313 raise ValueError("Atoms object should not have periodic " 

314 "boundary conditions for BaseThermoChem.") 

315 self.atoms = atoms 

316 self.spin = spin 

317 

318 @classmethod 

319 def from_transition_state(cls, vib_energies: Sequence[complex], 

320 *args, **kwargs) -> "BaseThermoChem": 

321 """Create a new instance for a transition state. 

322 

323 This will work just as the standard constructor, but will remove 

324 one imaginary frequency from the given vib_energies first. 

325 If there is more than one imaginary frequency, an error will be raised. 

326 

327 Returns 

328 ------- 

329 BaseThermoChem instance 

330 """ 

331 

332 if sum(np.iscomplex(vib_energies)): 

333 # supress user warning 

334 with catch_warnings(): 

335 simplefilter("ignore") 

336 vib_e, n_imag = _clean_vib_energies(vib_energies, True) 

337 if n_imag != 1: 

338 raise ValueError("Not exactly one imaginary frequency found.") 

339 else: 

340 raise ValueError("No imaginary frequencies found in vib_energies.") 

341 

342 thermo = cls(vib_e, *args, **kwargs) 

343 

344 return thermo 

345 

346 @staticmethod 

347 def combine_contributions( 

348 contrib_dicts: Sequence[dict[str, float]]) -> dict[str, float]: 

349 """Combine the contributions from multiple modes.""" 

350 ret: dict[str, float] = {} 

351 for contrib_dict in contrib_dicts: 

352 for key, value in contrib_dict.items(): 

353 if key in ret: 

354 ret[key] += value 

355 else: 

356 ret[key] = value 

357 return ret 

358 

359 def print_contributions( 

360 self, contributions: dict[str, float], verbose: bool) -> None: 

361 """Print the contributions.""" 

362 if verbose: 

363 fmt = "{:<15s}{:13.3f} eV" 

364 for key, value in contributions.items(): 

365 # subvalues start with _ 

366 if key.startswith('_'): 

367 key.replace('_', '... ') 

368 self._vprint(fmt.format(key, value)) 

369 

370 @abstractmethod 

371 def get_internal_energy(self, temperature: float, verbose: bool) -> float: 

372 raise NotImplementedError 

373 

374 @abstractmethod 

375 def get_entropy(self, temperature: float, 

376 pressure: float = units.bar, 

377 verbose: bool = True) -> float: 

378 raise NotImplementedError 

379 

380 @property 

381 def vib_energies(self) -> np.ndarray: 

382 """Get the vibrational energies in eV. 

383 

384 For backwards compatibility, this will return the modes' energies if 

385 they are available. Otherwise, it will return the stored vib_energies. 

386 """ 

387 # build from modes if available 

388 if hasattr(self, 'modes'): 

389 return np.array([mode.energy for mode in self.modes]) 

390 elif hasattr(self, '_vib_energies'): 

391 return np.array(self._vib_energies) 

392 else: 

393 raise AttributeError("No vibrational energies available.") 

394 

395 @vib_energies.setter 

396 def vib_energies(self, value: Sequence[float]) -> None: 

397 """For backwards compatibility, raise a deprecation warning.""" 

398 warn( 

399 "The vib_energies attribute is deprecated and will be removed in a" 

400 "future release. Please use the modes attribute instead." 

401 "Setting this outside the constructor will not update the modes", 

402 DeprecationWarning) 

403 self._vib_energies = value 

404 

405 def get_ZPE_correction(self) -> float: 

406 """Returns the zero-point vibrational energy correction in eV.""" 

407 return 0.5 * np.sum(self.vib_energies) 

408 

409 @staticmethod 

410 def get_ideal_translational_energy(temperature: float) -> float: 

411 """Returns the translational heat capacity times T in eV. 

412 

413 Parameters 

414 ---------- 

415 temperature : float 

416 The temperature in Kelvin. 

417 

418 Returns 

419 ------- 

420 float 

421 """ 

422 return 3. / 2. * units.kB * \ 

423 temperature # translational heat capacity (3-d gas) 

424 

425 @staticmethod 

426 def get_ideal_rotational_energy(geometry: _GEOMETRY_OPTIONS, 

427 temperature: float) -> float: 

428 """Returns the rotational heat capacity times T in eV. 

429 

430 Parameters 

431 ---------- 

432 geometry : str 

433 The geometry of the molecule. Options are 'nonlinear', 

434 'linear', and 'monatomic'. 

435 temperature : float 

436 The temperature in Kelvin. 

437 

438 Returns 

439 ------- 

440 float 

441 """ 

442 if geometry == 'nonlinear': # rotational heat capacity 

443 Cv_r = 3. / 2. * units.kB 

444 elif geometry == 'linear': 

445 Cv_r = units.kB 

446 elif geometry == 'monatomic': 

447 Cv_r = 0. 

448 else: 

449 raise ValueError('Invalid geometry: %s' % geometry) 

450 return Cv_r * temperature 

451 

452 def get_ideal_trans_entropy( 

453 self, 

454 atoms: Atoms, 

455 temperature: float) -> float: 

456 """Returns the translational entropy in eV/K. 

457 

458 Parameters 

459 ---------- 

460 atoms : ase.Atoms 

461 The atoms object. 

462 temperature : float 

463 The temperature in Kelvin. 

464 

465 Returns 

466 ------- 

467 float 

468 """ 

469 # Translational entropy (term inside the log is in SI units). 

470 mass = sum(atoms.get_masses()) * units._amu # kg/molecule 

471 S_t = (2 * np.pi * mass * units._k * 

472 temperature / units._hplanck**2)**(3.0 / 2) 

473 S_t *= units._k * temperature / self.referencepressure 

474 S_t = units.kB * (np.log(S_t) + 5.0 / 2.0) 

475 return S_t 

476 

477 def get_vib_energy_contribution(self, temperature: float) -> float: 

478 """Calculates the change in internal energy due to vibrations from 

479 0K to the specified temperature for a set of vibrations given in 

480 eV and a temperature given in Kelvin. 

481 Returns the energy change in eV.""" 

482 # Leftover for HinderedThermo class, delete once that is converted 

483 # to use modes 

484 kT = units.kB * temperature 

485 dU = 0. 

486 

487 for energy in self.vib_energies: 

488 dU += energy / (np.exp(energy / kT) - 1.) 

489 return dU 

490 

491 def get_vib_entropy_contribution(self, 

492 temperature: float, 

493 return_list: bool = False 

494 ) -> float | Sequence[float]: 

495 """Calculates the entropy due to vibrations for a set of vibrations 

496 given in eV and a temperature given in Kelvin. Returns the entropy 

497 in eV/K.""" 

498 kT = units.kB * temperature 

499 S_v = 0. 

500 energies = np.array(self.vib_energies) 

501 energies /= kT # eV/ eV/K*K 

502 S_v = energies / (np.exp(energies) - 1.) - \ 

503 np.log(1. - np.exp(-energies)) 

504 S_v *= units.kB 

505 if return_list: 

506 return S_v 

507 else: 

508 return np.sum(S_v) 

509 

510 def _vprint(self, text): 

511 """Print output if verbose flag True.""" 

512 if self.verbose: 

513 sys.stdout.write(text + os.linesep) 

514 

515 def get_ideal_entropy( 

516 self, 

517 temperature: float, 

518 translation: bool = False, 

519 vibration: bool = False, 

520 rotation: bool = False, 

521 geometry: _GEOMETRY_OPTIONS | None = None, 

522 electronic: bool = False, 

523 pressure: float | None = None, 

524 symmetrynumber: int | None = None) -> _FLOATWITHDICT: 

525 """Returns the entropy, in eV/K and a dict of the contributions. 

526 

527 Parameters 

528 ---------- 

529 temperature : float 

530 The temperature in Kelvin. 

531 translation : bool 

532 Include translational entropy. 

533 vibration : bool 

534 Include vibrational entropy. 

535 rotation : bool 

536 Include rotational entropy. 

537 geometry : str 

538 The geometry of the molecule. Options are 'nonlinear', 

539 'linear', and 'monatomic'. 

540 electronic : bool 

541 Include electronic entropy. 

542 pressure : float 

543 The pressure in Pa. Only needed for the translational entropy. 

544 symmetrynumber : int 

545 The symmetry number of the molecule. Only needed for linear and 

546 nonlinear molecules. 

547 

548 Returns 

549 ------- 

550 Tuple of one float and one dict 

551 The float is the total entropy in eV/K. 

552 The dict contains the contributions to the entropy. 

553 """ 

554 

555 if (geometry in ['linear', 'nonlinear']) and (symmetrynumber is None): 

556 raise ValueError( 

557 'Symmetry number required for linear and nonlinear molecules.') 

558 

559 if not hasattr(self, 'atoms'): 

560 raise ValueError( 

561 'Atoms object required for ideal entropy calculation.') 

562 

563 if electronic and (self.spin is None): 

564 raise ValueError( 

565 'Spin value required for electronic entropy calculation.') 

566 

567 S: float = 0.0 

568 ret = {} 

569 

570 if translation: 

571 S_t = self.get_ideal_trans_entropy(self.atoms, temperature) 

572 ret['S_t'] = S_t 

573 S += S_t 

574 if pressure: 

575 # Pressure correction to translational entropy. 

576 S_p = - units.kB * np.log(pressure / self.referencepressure) 

577 S += S_p 

578 ret['S_p'] = S_p 

579 

580 if rotation: 

581 # Rotational entropy (term inside the log is in SI units). 

582 if geometry == 'monatomic': 

583 S_r = 0.0 

584 elif geometry == 'nonlinear': 

585 inertias = (self.atoms.get_moments_of_inertia() * units._amu / 

586 1e10**2) # kg m^2 

587 S_r = np.sqrt(np.pi * np.prod(inertias)) / symmetrynumber 

588 S_r *= (8.0 * np.pi**2 * units._k * temperature / 

589 units._hplanck**2)**(3.0 / 2.0) 

590 S_r = units.kB * (np.log(S_r) + 3.0 / 2.0) 

591 elif geometry == 'linear': 

592 inertias = (self.atoms.get_moments_of_inertia() * units._amu / 

593 (10.0**10)**2) # kg m^2 

594 inertia = max(inertias) # should be two identical and one zero 

595 S_r = (8 * np.pi**2 * inertia * units._k * temperature / 

596 symmetrynumber / units._hplanck**2) 

597 S_r = units.kB * (np.log(S_r) + 1.) 

598 else: 

599 raise RuntimeError(f"Invalid geometry: {geometry}") 

600 S += S_r 

601 ret['S_r'] = S_r 

602 

603 # Electronic entropy. 

604 if electronic: 

605 assert self.spin is not None # for mypy, error is raised above 

606 S_e = units.kB * np.log(2 * self.spin + 1) 

607 S += S_e 

608 ret['S_e'] = S_e 

609 

610 # Vibrational entropy 

611 if vibration: 

612 S_v = self.get_vib_entropy_contribution(temperature, 

613 return_list=False) 

614 assert isinstance(S_v, float) # make mypy happy 

615 S += S_v 

616 ret['S_v'] = S_v 

617 

618 return S, ret 

619 

620 

621class HarmonicThermo(BaseThermoChem): 

622 """Class for calculating thermodynamic properties in the approximation 

623 that all degrees of freedom are treated harmonically. Often used for 

624 adsorbates. 

625 

626 Note: This class does not include the translational and rotational 

627 contributions to the entropy by default. Use the get_ideal_entropy method 

628 for that and add them manually. 

629 

630 Inputs: 

631 

632 vib_energies : list 

633 a list of the harmonic energies of the adsorbate (e.g., from 

634 ase.vibrations.Vibrations.get_energies). The number of 

635 energies should match the number of degrees of freedom of the 

636 adsorbate; i.e., 3*n, where n is the number of atoms. Note that 

637 this class does not check that the user has supplied the correct 

638 number of energies. Units of energies are eV. Note, that if 'modes' 

639 are given, these energies are not used. 

640 potentialenergy : float 

641 the potential energy in eV (e.g., from atoms.get_potential_energy) 

642 (if potentialenergy is unspecified, then the methods of this 

643 class can be interpreted as the energy corrections) 

644 ignore_imag_modes : bool 

645 If 'True', any imaginary frequencies will be removed in the 

646 calculation of the thermochemical properties. 

647 If 'False' (default), an error will be raised if any imaginary 

648 frequencies are present. 

649 modes : list of AbstractMode 

650 A list of mode objects. If not provided, :class:`HarmonicMode` objects 

651 will be created from the vib_energies. This is useful if you want to 

652 replace individual modes with non-harmonic modes. 

653 """ 

654 

655 def __init__(self, vib_energies: Sequence[complex], 

656 potentialenergy: float = 0., 

657 ignore_imag_modes: bool = False, 

658 modes: Sequence[AbstractMode] | None = None) -> None: 

659 

660 # Check for imaginary frequencies. 

661 vib_energies, n_imag = _clean_vib_energies( 

662 vib_energies, ignore_imag_modes) 

663 if modes is None: 

664 modes = [HarmonicMode(energy) for energy in vib_energies] 

665 super().__init__(modes=modes) 

666 

667 self.n_imag = n_imag 

668 

669 self.potentialenergy = potentialenergy 

670 

671 def get_internal_energy(self, temperature: float, 

672 verbose: bool = True) -> float: 

673 """Returns the internal energy, in eV, in the harmonic approximation 

674 at a specified temperature (K).""" 

675 

676 self.verbose = verbose 

677 vprint = self._vprint 

678 fmt = '%-15s%13.3f eV' 

679 vprint('Internal energy components at T = %.2f K:' % temperature) 

680 vprint('=' * 31) 

681 

682 vprint(fmt % ('E_pot', self.potentialenergy)) 

683 

684 U, contribs = zip(*[mode.get_internal_energy( 

685 temperature, contributions=True) for mode in self.modes]) 

686 U = np.sum(U) 

687 U += self.potentialenergy 

688 

689 self.print_contributions(self.combine_contributions(contribs), verbose) 

690 vprint('-' * 31) 

691 vprint(fmt % ('U', U)) 

692 vprint('=' * 31) 

693 

694 return U 

695 

696 def get_entropy(self, temperature: float, 

697 pressure: float = units.bar, 

698 verbose: bool = True) -> float: 

699 """Returns the entropy, in eV/K at a specified temperature (K). 

700 

701 Note: This does not include the translational and rotational 

702 contributions to the entropy. Use the get_ideal_entropy method 

703 for that. 

704 

705 Parameters 

706 ---------- 

707 temperature : float 

708 The temperature in Kelvin. 

709 pressure : float 

710 Not used, but kept for compatibility with other classes. 

711 verbose : bool 

712 If True, print the contributions to the entropy. 

713 

714 Returns 

715 ------- 

716 float 

717 """ 

718 

719 self.verbose = verbose 

720 vprint = self._vprint 

721 fmt = '%-15s%13.7f eV/K%13.3f eV' 

722 vprint('Entropy components at T = %.2f K:' % temperature) 

723 vprint('=' * 49) 

724 vprint('%15s%13s %13s' % ('', 'S', 'T*S')) 

725 

726 S, contribs = zip(*[mode.get_entropy( 

727 temperature, contributions=True) for mode in self.modes]) 

728 S = np.sum(S) 

729 

730 self.print_contributions(self.combine_contributions(contribs), verbose) 

731 vprint('-' * 49) 

732 vprint(fmt % ('S', S, S * temperature)) 

733 vprint('=' * 49) 

734 

735 return S 

736 

737 def get_helmholtz_energy(self, temperature: float, 

738 verbose: bool = True) -> float: 

739 """Returns the Helmholtz free energy, in eV, in the harmonic 

740 approximation at a specified temperature (K).""" 

741 

742 self.verbose = True 

743 vprint = self._vprint 

744 

745 U = self.get_internal_energy(temperature, verbose=verbose) 

746 vprint('') 

747 S = self.get_entropy(temperature, verbose=verbose) 

748 F = U - temperature * S 

749 

750 vprint('') 

751 vprint('Free energy components at T = %.2f K:' % temperature) 

752 vprint('=' * 23) 

753 fmt = '%5s%15.3f eV' 

754 vprint(fmt % ('U', U)) 

755 vprint(fmt % ('-T*S', -temperature * S)) 

756 vprint('-' * 23) 

757 vprint(fmt % ('F', F)) 

758 vprint('=' * 23) 

759 return F 

760 

761 

762class QuasiHarmonicThermo(HarmonicThermo): 

763 """Subclass of :class:`HarmonicThermo`, including the quasi-harmonic 

764 approximation of Cramer, Truhlar and coworkers :doi:`10.1021/jp205508z`. 

765 

766 Note: This class not include the translational and rotational 

767 contributions to the entropy by default. Use the get_ideal_entropy method 

768 for that and add them manually. 

769 

770 Inputs: 

771 

772 vib_energies : list 

773 a list of the energies of the vibrations (e.g., from 

774 ase.vibrations.Vibrations.get_energies). The number of 

775 energies should match the number of degrees of freedom of the 

776 adsorbate; i.e., 3*n, where n is the number of atoms. Note that 

777 this class does not check that the user has supplied the correct 

778 number of energies. Units of energies are eV. 

779 potentialenergy : float 

780 the potential energy in eV (e.g., from atoms.get_potential_energy) 

781 (if potentialenergy is unspecified, then the methods of this 

782 class can be interpreted as the energy corrections) 

783 ignore_imag_modes : bool 

784 If 'True', any imaginary frequencies will be removed in the 

785 calculation of the thermochemical properties. 

786 If 'False' (default), an error will be raised if any imaginary 

787 frequencies are present. 

788 modes : list of AbstractMode 

789 A list of mode objects. If not provided, :class:`HarmonicMode` objects 

790 will be created from the raised vib_energies. This is useful if you want 

791 to replace individual modes with non-harmonic modes. 

792 raise_to : float 

793 The value to which all frequencies smaller than this value will be 

794 raised. Unit is eV. Defaults to :math:`100cm^{-1} = 0.012398 eV`. 

795 

796 """ 

797 

798 @staticmethod 

799 def _raise(input: Sequence[float], raise_to: float) -> Sequence[float]: 

800 return [raise_to if x < raise_to else x for x in input] 

801 

802 def __init__(self, vib_energies: Sequence[complex], 

803 potentialenergy: float = 0., 

804 ignore_imag_modes: bool = False, 

805 modes: Sequence[AbstractMode] | None = None, 

806 raise_to: float = 100 * units.invcm) -> None: 

807 

808 # Check for imaginary frequencies. 

809 vib_energies, _ = _clean_vib_energies( 

810 vib_energies, ignore_imag_modes) 

811 # raise the low frequencies to a certain value 

812 vib_energies = self._raise(vib_energies, raise_to) 

813 if modes is None: 

814 modes = [HarmonicMode(energy) for energy in vib_energies] 

815 super().__init__(vib_energies, 

816 potentialenergy=potentialenergy, 

817 ignore_imag_modes=ignore_imag_modes, 

818 modes=modes) 

819 

820 

821class MSRRHOThermo(QuasiHarmonicThermo): 

822 """Subclass of :class:`QuasiHarmonicThermo`, 

823 including Grimme's scaling method based on 

824 :doi:`10.1002/chem.201200497` and :doi:`10.1039/D1SC00621E`. 

825 

826 Note: This class not include the translational and rotational 

827 contributions to the entropy by default. Use the get_ideal_entropy method 

828 for that and add them manually. 

829 

830 We enforce treating imaginary modes as Grimme suggests (converting 

831 them to real by multiplying them with :math:`-i`). So make sure to check 

832 your input energies. 

833 

834 Inputs: 

835 

836 vib_energies : list 

837 a list of the energies of the vibrations (e.g., from 

838 ase.vibrations.Vibrations.get_energies). The number of 

839 energies should match the number of degrees of freedom of the 

840 atoms object; i.e., 3*n, where n is the number of atoms. Note that 

841 this class does not check that the user has supplied the correct 

842 number of energies. Units of energies are eV. Note, that if 'modes' 

843 are given, these energies are not used. 

844 atoms: an ASE atoms object 

845 used to calculate rotational moments of inertia and molecular mass. 

846 Should not contain periodic boundary conditions. 

847 tau : float 

848 the vibrational energy threshold in :math:`cm^{-1}`, namcomplexed 

849 :math:`\\tau` in :doi:`10.1039/D1SC00621E`. 

850 Values close or equal to 0 will result in the standard harmonic 

851 approximation. Defaults to :math:`35cm^{-1}`. 

852 nu_scal : float 

853 Linear scaling factor for the vibrational frequencies. Named 

854 :math:`\\nu_{scal}` in :doi:`10.1039/D1SC00621E`. 

855 Defaults to 1.0, check the `Truhlar group database 

856 <https://comp.chem.umn.edu/freqscale/index.html>`_ 

857 for values corresponding to your level of theory. 

858 Note that for :math:`\\nu_{scal}=1.0` this method is equivalent to 

859 the quasi-RRHO method in :doi:`10.1002/chem.201200497`. 

860 treat_int_energy : bool 

861 Extend the msRRHO treatement to the internal energy. If False, only 

862 the entropy contribution as in Grimmmes paper is considered. 

863 If true, the approach of Otlyotov and Minenkov 

864 :doi:`10.1002/jcc.27129` is used. 

865 modes : list of AbstractMode 

866 A list of mode objects. If not provided, :class:`RRHOMode` objects will 

867 be created from the raised vib_energies. This is useful if you want to 

868 replace individual modes with non-harmonic modes. 

869 

870 """ 

871 

872 def __init__(self, vib_energies: Sequence[complex], atoms: Atoms, 

873 potentialenergy: float = 0., 

874 tau: float = 35., nu_scal: float = 1.0, 

875 treat_int_energy: bool = False, 

876 modes: Sequence[AbstractMode] | None = None) -> None: 

877 

878 # check that atoms has no periodic boundary conditions 

879 # moments of inertia are wrong otherwise. 

880 if atoms.pbc.any(): 

881 raise ValueError("Atoms object should not have periodic " 

882 "boundary conditions for MSRRHOThermo.") 

883 inertia = np.mean(atoms.get_moments_of_inertia()) 

884 self.atoms = atoms 

885 

886 # clean the energies by converting imaginary to real 

887 # i.e. multiply with -i 

888 vib_e: Sequence[float] # make mypy happy 

889 vib_e = [np.imag(v) if np.iscomplex(v) 

890 else np.real(v) for v in vib_energies] 

891 

892 self.nu_scal = nu_scal 

893 # scale the frequencies (i.e. energies) before passing them on 

894 vib_e_scaled = [float(v) for v in np.multiply(vib_e, nu_scal)] 

895 

896 if modes is None: 

897 modes = [RRHOMode(energy, inertia, 

898 tau=tau, 

899 treat_int_energy=treat_int_energy 

900 ) for energy in vib_e_scaled] 

901 

902 super().__init__(vib_e_scaled, 

903 potentialenergy=potentialenergy, 

904 ignore_imag_modes=False, 

905 modes=modes, 

906 raise_to=0.0) 

907 self.treat_int_energy = treat_int_energy 

908 

909 

910class HinderedThermo(BaseThermoChem): 

911 """Class for calculating thermodynamic properties in the hindered 

912 translator and hindered rotor model where all but three degrees of 

913 freedom are treated as harmonic vibrations, two are treated as 

914 hindered translations, and one is treated as a hindered rotation. 

915 

916 Inputs: 

917 

918 vib_energies : list 

919 a list of all the vibrational energies of the adsorbate (e.g., from 

920 ase.vibrations.Vibrations.get_energies). If atoms is not provided, 

921 then the number of energies must match the number of degrees of freedom 

922 of the adsorbate; i.e., 3*n, where n is the number of atoms. Note 

923 that this class does not check that the user has supplied the 

924 correct number of energies. 

925 Units of energies are eV. 

926 trans_barrier_energy : float 

927 the translational energy barrier in eV. This is the barrier for an 

928 adsorbate to diffuse on the surface. 

929 rot_barrier_energy : float 

930 the rotational energy barrier in eV. This is the barrier for an 

931 adsorbate to rotate about an axis perpendicular to the surface. 

932 sitedensity : float 

933 density of surface sites in cm^-2 

934 rotationalminima : integer 

935 the number of equivalent minima for an adsorbate's full rotation. 

936 For example, 6 for an adsorbate on an fcc(111) top site 

937 potentialenergy : float 

938 the potential energy in eV (e.g., from atoms.get_potential_energy) 

939 (if potentialenergy is unspecified, then the methods of this class 

940 can be interpreted as the energy corrections) 

941 mass : float 

942 the mass of the adsorbate in amu (if mass is unspecified, then it will 

943 be calculated from the atoms class) 

944 inertia : float 

945 the reduced moment of inertia of the adsorbate in amu*Ang^-2 

946 (if inertia is unspecified, then it will be calculated from the 

947 atoms class) 

948 atoms : an ASE atoms object 

949 used to calculate rotational moments of inertia and molecular mass 

950 symmetrynumber : integer 

951 symmetry number of the adsorbate. This is the number of symmetric arms 

952 of the adsorbate and depends upon how it is bound to the surface. 

953 For example, propane bound through its end carbon has a symmetry 

954 number of 1 but propane bound through its middle carbon has a symmetry 

955 number of 2. (if symmetrynumber is unspecified, then the default is 1) 

956 ignore_imag_modes : bool 

957 If 'True', any imaginary frequencies present after the 3N-3 cut will 

958 be removed in the calculation of the thermochemical properties. 

959 If 'False' (default), an error will be raised if imaginary frequencies 

960 are present after the 3N-3 cut. 

961 """ 

962 

963 def __init__(self, vib_energies, trans_barrier_energy, rot_barrier_energy, 

964 sitedensity, rotationalminima, potentialenergy=0., 

965 mass=None, inertia=None, atoms=None, symmetrynumber=1, 

966 ignore_imag_modes: bool = False) -> None: 

967 

968 self.trans_barrier_energy = trans_barrier_energy * units._e 

969 self.rot_barrier_energy = rot_barrier_energy * units._e 

970 self.area = 1. / sitedensity / 100.0**2 

971 self.rotationalminima = rotationalminima 

972 self.potentialenergy = potentialenergy 

973 self.atoms = atoms 

974 self.symmetry = symmetrynumber 

975 

976 # Sort the vibrations 

977 vib_energies = list(vib_energies) 

978 vib_energies.sort(key=np.abs) 

979 

980 # Keep only the relevant vibrational energies (3N-3) 

981 if atoms: 

982 vib_energies = vib_energies[-(3 * len(atoms) - 3):] 

983 else: 

984 vib_energies = vib_energies[-(len(vib_energies) - 3):] 

985 

986 # Check for imaginary frequencies. 

987 vib_energies, n_imag = _clean_vib_energies( 

988 vib_energies, ignore_imag_modes) 

989 super().__init__(vib_energies=vib_energies) 

990 self.n_imag = n_imag 

991 

992 if (mass or atoms) and (inertia or atoms): 

993 if mass: 

994 self.mass = mass * units._amu 

995 elif atoms: 

996 self.mass = np.sum(atoms.get_masses()) * units._amu 

997 if inertia: 

998 self.inertia = inertia * units._amu / units.m**2 

999 elif atoms: 

1000 # This is tricky because the moments of inertia 

1001 # only work for non-periodic systems. 

1002 # Check it here and raise an error if periodic 

1003 if atoms.get_pbc().any(): 

1004 raise RuntimeError('Atoms object with periodic ' 

1005 'boundary conditions cannot be ' 

1006 'used to calculate moments of ' 

1007 'inertia. Please provide ' 

1008 'a non-periodic Atoms object.') 

1009 self.inertia = (atoms.get_moments_of_inertia()[2] * 

1010 units._amu / units.m**2) 

1011 else: 

1012 raise RuntimeError('Either mass and inertia of the ' 

1013 'adsorbate must be specified or ' 

1014 'atoms must be specified.') 

1015 

1016 # Calculate hindered translational and rotational frequencies 

1017 self.freq_t = np.sqrt(self.trans_barrier_energy / 

1018 (2 * self.mass * self.area)) 

1019 self.freq_r = 1. / (2 * np.pi) * np.sqrt(self.rotationalminima**2 * 

1020 self.rot_barrier_energy / 

1021 (2 * self.inertia)) 

1022 

1023 def get_internal_energy(self, temperature, verbose=True): 

1024 """Returns the internal energy (including the zero point energy), 

1025 in eV, in the hindered translator and hindered rotor model at a 

1026 specified temperature (K).""" 

1027 

1028 from scipy.special import iv 

1029 

1030 self.verbose = verbose 

1031 vprint = self._vprint 

1032 fmt = '%-15s%13.3f eV' 

1033 vprint('Internal energy components at T = %.2f K:' % temperature) 

1034 vprint('=' * 31) 

1035 

1036 U = 0. 

1037 

1038 vprint(fmt % ('E_pot', self.potentialenergy)) 

1039 U += self.potentialenergy 

1040 

1041 # Translational Energy 

1042 T_t = units._k * temperature / (units._hplanck * self.freq_t) 

1043 R_t = self.trans_barrier_energy / (units._hplanck * self.freq_t) 

1044 dU_t = 2 * (-1. / 2 - 1. / T_t / (2 + 16 * R_t) + R_t / 2 / T_t - 

1045 R_t / 2 / T_t * 

1046 iv(1, R_t / 2 / T_t) / iv(0, R_t / 2 / T_t) + 

1047 1. / T_t / (np.exp(1. / T_t) - 1)) 

1048 dU_t *= units.kB * temperature 

1049 vprint(fmt % ('E_trans', dU_t)) 

1050 U += dU_t 

1051 

1052 # Rotational Energy 

1053 T_r = units._k * temperature / (units._hplanck * self.freq_r) 

1054 R_r = self.rot_barrier_energy / (units._hplanck * self.freq_r) 

1055 dU_r = (-1. / 2 - 1. / T_r / (2 + 16 * R_r) + R_r / 2 / T_r - 

1056 R_r / 2 / T_r * 

1057 iv(1, R_r / 2 / T_r) / iv(0, R_r / 2 / T_r) + 

1058 1. / T_r / (np.exp(1. / T_r) - 1)) 

1059 dU_r *= units.kB * temperature 

1060 vprint(fmt % ('E_rot', dU_r)) 

1061 U += dU_r 

1062 

1063 # Vibrational Energy 

1064 dU_v = self.get_vib_energy_contribution(temperature) 

1065 vprint(fmt % ('E_vib', dU_v)) 

1066 U += dU_v 

1067 

1068 # Zero Point Energy 

1069 dU_zpe = self.get_zero_point_energy() 

1070 vprint(fmt % ('E_ZPE', dU_zpe)) 

1071 U += dU_zpe 

1072 

1073 vprint('-' * 31) 

1074 vprint(fmt % ('U', U)) 

1075 vprint('=' * 31) 

1076 return U 

1077 

1078 def get_zero_point_energy(self, verbose=True): 

1079 """Returns the zero point energy, in eV, in the hindered 

1080 translator and hindered rotor model""" 

1081 

1082 zpe_t = 2 * (1. / 2 * self.freq_t * units._hplanck / units._e) 

1083 zpe_r = 1. / 2 * self.freq_r * units._hplanck / units._e 

1084 zpe_v = self.get_ZPE_correction() 

1085 zpe = zpe_t + zpe_r + zpe_v 

1086 return zpe 

1087 

1088 def get_entropy(self, temperature, 

1089 pressure=units.bar, 

1090 verbose=True): 

1091 """Returns the entropy, in eV/K, in the hindered translator 

1092 and hindered rotor model at a specified temperature (K).""" 

1093 

1094 from scipy.special import iv 

1095 

1096 self.verbose = verbose 

1097 vrpint = self._vprint 

1098 fmt = '%-15s%13.7f eV/K%13.3f eV' 

1099 vrpint('Entropy components at T = %.2f K:' % temperature) 

1100 vrpint('=' * 49) 

1101 vrpint('%15s%13s %13s' % ('', 'S', 'T*S')) 

1102 

1103 S = 0. 

1104 

1105 # Translational Entropy 

1106 T_t = units._k * temperature / (units._hplanck * self.freq_t) 

1107 R_t = self.trans_barrier_energy / (units._hplanck * self.freq_t) 

1108 S_t = 2 * (-1. / 2 + 1. / 2 * np.log(np.pi * R_t / T_t) - 

1109 R_t / 2 / T_t * 

1110 iv(1, R_t / 2 / T_t) / iv(0, R_t / 2 / T_t) + 

1111 np.log(iv(0, R_t / 2 / T_t)) + 

1112 1. / T_t / (np.exp(1. / T_t) - 1) - 

1113 np.log(1 - np.exp(-1. / T_t))) 

1114 S_t *= units.kB 

1115 vrpint(fmt % ('S_trans', S_t, S_t * temperature)) 

1116 S += S_t 

1117 

1118 # Rotational Entropy 

1119 T_r = units._k * temperature / (units._hplanck * self.freq_r) 

1120 R_r = self.rot_barrier_energy / (units._hplanck * self.freq_r) 

1121 S_r = (-1. / 2 + 1. / 2 * np.log(np.pi * R_r / T_r) - 

1122 np.log(self.symmetry) - 

1123 R_r / 2 / T_r * iv(1, R_r / 2 / T_r) / iv(0, R_r / 2 / T_r) + 

1124 np.log(iv(0, R_r / 2 / T_r)) + 

1125 1. / T_r / (np.exp(1. / T_r) - 1) - 

1126 np.log(1 - np.exp(-1. / T_r))) 

1127 S_r *= units.kB 

1128 vrpint(fmt % ('S_rot', S_r, S_r * temperature)) 

1129 S += S_r 

1130 

1131 # Vibrational Entropy 

1132 S_v = self.get_vib_entropy_contribution(temperature) 

1133 vrpint(fmt % ('S_vib', S_v, S_v * temperature)) 

1134 S += S_v 

1135 

1136 # Concentration Related Entropy 

1137 N_over_A = np.exp(1. / 3) * (10.0**5 / 

1138 (units._k * temperature))**(2. / 3) 

1139 S_c = 1 - np.log(N_over_A) - np.log(self.area) 

1140 S_c *= units.kB 

1141 vrpint(fmt % ('S_con', S_c, S_c * temperature)) 

1142 S += S_c 

1143 

1144 vrpint('-' * 49) 

1145 vrpint(fmt % ('S', S, S * temperature)) 

1146 vrpint('=' * 49) 

1147 return S 

1148 

1149 def get_helmholtz_energy(self, temperature, verbose=True): 

1150 """Returns the Helmholtz free energy, in eV, in the hindered 

1151 translator and hindered rotor model at a specified temperature 

1152 (K).""" 

1153 

1154 self.verbose = True 

1155 vprint = self._vprint 

1156 

1157 U = self.get_internal_energy(temperature, verbose=verbose) 

1158 vprint('') 

1159 S = self.get_entropy(temperature, verbose=verbose) 

1160 F = U - temperature * S 

1161 

1162 vprint('') 

1163 vprint('Free energy components at T = %.2f K:' % temperature) 

1164 vprint('=' * 23) 

1165 fmt = '%5s%15.3f eV' 

1166 vprint(fmt % ('U', U)) 

1167 vprint(fmt % ('-T*S', -temperature * S)) 

1168 vprint('-' * 23) 

1169 vprint(fmt % ('F', F)) 

1170 vprint('=' * 23) 

1171 return F 

1172 

1173 

1174class IdealGasThermo(BaseThermoChem): 

1175 """Class for calculating thermodynamic properties of a molecule 

1176 based on statistical mechanical treatments in the ideal gas 

1177 approximation. 

1178 

1179 Inputs for enthalpy calculations: 

1180 

1181 vib_energies : list 

1182 a list of the vibrational energies of the molecule (e.g., from 

1183 ase.vibrations.Vibrations.get_energies). The number of expected 

1184 vibrations is calculated by the geometry and the number of atoms. 

1185 By default, the lowest energies are cut to enforce the expected 

1186 length. By setting vib_selection, this behaviour can be changed. 

1187 If either atoms and natoms is unspecified, the full list is used. 

1188 Units are eV. 

1189 geometry : 'monatomic', 'linear', or 'nonlinear' 

1190 geometry of the molecule 

1191 potentialenergy : float 

1192 the potential energy in eV (e.g., from atoms.get_potential_energy) 

1193 (if potentialenergy is unspecified, then the methods of this 

1194 class can be interpreted as the energy corrections) 

1195 natoms : integer 

1196 the number of atoms, used along with 'geometry' to determine how 

1197 many vibrations to use. (Not needed if an atoms object is supplied 

1198 in 'atoms' or if the user desires the entire list of vibrations 

1199 to be used.) 

1200 vib_selection : 'exact', 'highest' (default), 'abs_highest', 'all' 

1201 selection of input vibrational energies considered to be true 

1202 vibrations (excluding translations and rotations) implied by the 

1203 geometry and number of atoms. Only applied if number of atoms 

1204 is provided, either with natoms or atoms. 

1205 - 'exact': no selection; the number of input vibrational energies must 

1206 be equal to the number of true vibrations 

1207 - 'highest': select vibrational energies whose square is the highest 

1208 , i.e. large real energies followed by small real energies, small 

1209 imaginary energies and large imaginary energies. Will not catch 

1210 unrealistically large imaginary frequencies, resulting from e.g. 

1211 unrelaxed molecules. This is the default. 

1212 - 'abs_highest': select vibrational energies whose absolute number are 

1213 highest, real or imaginary. Will fail intentionally for 

1214 unrealistically large imaginary frequencies, but also unintentionally 

1215 for small real energies, corresponding e.g. to frustrated rotations 

1216 in a larger molecule. 

1217 - 'all': Use all input vibrational energies without checking the 

1218 number of them. 

1219 ignore_imag_modes : bool 

1220 If 'True', any imaginary frequencies present after the 3N-3 cut will 

1221 be removed in the calculation of the thermochemical properties. 

1222 If 'False' (default), an error will be raised if imaginary frequencies 

1223 are present after the 3N-3 cut. 

1224 

1225 Extra inputs needed for entropy / free energy calculations: 

1226 

1227 atoms : an ASE atoms object 

1228 used to calculate rotational moments of inertia and molecular mass 

1229 symmetrynumber : integer 

1230 symmetry number of the molecule. See, for example, Table 10.1 and 

1231 Appendix B of C. Cramer "Essentials of Computational Chemistry", 

1232 2nd Ed. 

1233 spin : float 

1234 the total electronic spin. (0 for molecules in which all electrons 

1235 are paired, 0.5 for a free radical with a single unpaired electron, 

1236 1.0 for a triplet with two unpaired electrons, such as O_2.) 

1237 

1238 """ 

1239 

1240 def __init__(self, vib_energies: Sequence[complex], 

1241 geometry: _GEOMETRY_OPTIONS, 

1242 potentialenergy: float = 0., 

1243 atoms: Atoms | None = None, 

1244 symmetrynumber: int | None = None, 

1245 spin: float | None = None, 

1246 natoms: int | None = None, 

1247 vib_selection: _VIB_SELECTION_OPTIONS | None = 'highest', 

1248 ignore_imag_modes: bool = False, 

1249 modes: Sequence[AbstractMode] | None = None) -> None: 

1250 self.potentialenergy = potentialenergy 

1251 self.geometry = geometry 

1252 self.sigma = symmetrynumber 

1253 if natoms is None and atoms: 

1254 natoms = len(atoms) 

1255 

1256 # Preliminary sorting vibrations by square 

1257 vib_energies = list(vib_energies) 

1258 vib_energies.sort(key=lambda f: (f ** 2).real) 

1259 

1260 if natoms and vib_selection != 'all': 

1261 # Determine number of true vibrations from geometry 

1262 if geometry == 'nonlinear': 

1263 num_vibs = 3 * natoms - 6 

1264 elif geometry == 'linear': 

1265 num_vibs = 3 * natoms - 5 

1266 elif geometry == 'monatomic': 

1267 num_vibs = 0 

1268 else: 

1269 raise ValueError(f"Unsupported geometry: {geometry}") 

1270 if num_vibs < 0: 

1271 raise ValueError( 

1272 f"Number of atoms ({natoms}) too small for " + 

1273 f"geometry '{geometry}'." 

1274 ) 

1275 

1276 if vib_selection == 'exact': 

1277 # Demand the expected number of true vibrations 

1278 if len(vib_energies) != num_vibs: 

1279 raise ValueError( 

1280 f"{num_vibs} vibrational energies expected, " + 

1281 f"{len(vib_energies)} received.\n" + 

1282 "To select true vibrational energies automatically," + 

1283 " set vib_selection." 

1284 ) 

1285 

1286 elif vib_selection in ('highest', 'abs_highest'): 

1287 # Cut vibrations to get the expected number of true vibrations 

1288 if num_vibs == 0: 

1289 vib_energies = [] 

1290 else: 

1291 if vib_selection == 'abs_highest': 

1292 vib_energies.sort(key=np.abs) 

1293 vib_energies = vib_energies[-num_vibs:] 

1294 if len(vib_energies) != num_vibs: 

1295 raise ValueError( 

1296 f"Too few vibration modes ({len(vib_energies)}) " + 

1297 "after selection. {num_vibs} expected" 

1298 ) 

1299 else: 

1300 raise ValueError( 

1301 f"(Unsupported vib_selection: {vib_selection})" 

1302 ) 

1303 elif not natoms and not atoms and vib_selection != 'all': 

1304 raise ValueError( 

1305 "Either natoms or atoms must be specified to select " + 

1306 "true vibrational energies. Else, set vib_selection to 'all'." 

1307 ) 

1308 

1309 # Check for imaginary frequencies. 

1310 vib_energies, n_imag = _clean_vib_energies( 

1311 vib_energies, ignore_imag_modes) 

1312 super().__init__(vib_energies=vib_energies, 

1313 atoms=atoms, 

1314 spin=spin) 

1315 self.n_imag = n_imag 

1316 

1317 def get_internal_energy(self, temperature: float, 

1318 verbose: bool = True) -> float: 

1319 """Returns the internal energy, in eV, in the ideal gas approximation 

1320 at a specified temperature (K).""" 

1321 

1322 self.verbose = verbose 

1323 vprint = self._vprint 

1324 fmt = '%-15s%13.3f eV' 

1325 vprint('Enthalpy components at T = %.2f K:' % temperature) 

1326 vprint('=' * 31) 

1327 

1328 U = 0. 

1329 

1330 vprint(fmt % ('E_pot', self.potentialenergy)) 

1331 U += self.potentialenergy 

1332 

1333 zpe = self.get_ZPE_correction() 

1334 vprint(fmt % ('E_ZPE', zpe)) 

1335 U += zpe 

1336 

1337 Cv_tT = self.get_ideal_translational_energy(temperature) 

1338 vprint(fmt % ('Cv_trans (0->T)', Cv_tT)) 

1339 U += Cv_tT 

1340 

1341 Cv_rT = self.get_ideal_rotational_energy(self.geometry, temperature) 

1342 vprint(fmt % ('Cv_rot (0->T)', Cv_rT)) 

1343 U += Cv_rT 

1344 

1345 dU_v = self.get_vib_energy_contribution(temperature) 

1346 vprint(fmt % ('Cv_vib (0->T)', dU_v)) 

1347 U += dU_v 

1348 

1349 vprint('-' * 31) 

1350 vprint(fmt % ('U', U)) 

1351 vprint('=' * 31) 

1352 return U 

1353 

1354 def get_enthalpy(self, temperature: float, 

1355 verbose: bool = True) -> float: 

1356 """Returns the enthalpy, in eV, in the ideal gas approximation 

1357 at a specified temperature (K).""" 

1358 

1359 self.verbose = verbose 

1360 vprint = self._vprint 

1361 fmt = '%-15s%13.3f eV' 

1362 vprint('Enthalpy components at T = %.2f K:' % temperature) 

1363 vprint('=' * 31) 

1364 

1365 H = 0. 

1366 H += self.get_internal_energy(temperature, verbose=verbose) 

1367 

1368 Cp_corr = units.kB * temperature 

1369 vprint(fmt % ('(C_v -> C_p)', Cp_corr)) 

1370 H += Cp_corr 

1371 

1372 vprint('-' * 31) 

1373 vprint(fmt % ('H', H)) 

1374 vprint('=' * 31) 

1375 return H 

1376 

1377 def get_entropy(self, temperature: float, 

1378 pressure: float = units.bar, 

1379 verbose: bool = True) -> float: 

1380 """Returns the entropy, in eV/K, in the ideal gas approximation 

1381 at a specified temperature (K) and pressure (Pa).""" 

1382 

1383 if self.atoms is None or self.sigma is None or self.spin is None: 

1384 raise RuntimeError('atoms, symmetrynumber, and spin must be ' 

1385 'specified for entropy and free energy ' 

1386 'calculations.') 

1387 self.verbose = verbose 

1388 vprint = self._vprint 

1389 fmt = '%-15s%13.7f eV/K%13.3f eV' 

1390 vprint(f'Entropy components at T = {temperature:.2f} K and' 

1391 f' P = {pressure:.1f} Pa:') 

1392 vprint('=' * 49) 

1393 vprint('{"":15s}{"S":13s} {"T*S:13s}') 

1394 S, S_dict = self.get_ideal_entropy(temperature, 

1395 translation=True, 

1396 vibration=True, 

1397 rotation=True, 

1398 geometry=self.geometry, 

1399 electronic=True, 

1400 pressure=pressure, 

1401 symmetrynumber=self.sigma) 

1402 

1403 vprint( 

1404 fmt % 

1405 ('S_trans (1 bar)', 

1406 S_dict['S_t'], 

1407 S_dict['S_t'] * 

1408 temperature)) 

1409 vprint(fmt % ('S_rot', S_dict['S_r'], S_dict['S_r'] * temperature)) 

1410 vprint(fmt % ('S_elec', S_dict['S_e'], S_dict['S_e'] * temperature)) 

1411 vprint(fmt % ('S_vib', S_dict['S_v'], S_dict['S_v'] * temperature)) 

1412 vprint( 

1413 fmt % 

1414 ('S (1 bar -> P)', 

1415 S_dict['S_p'], 

1416 S_dict['S_p'] * temperature)) 

1417 vprint('-' * 49) 

1418 vprint(fmt % ('S', S, S * temperature)) 

1419 vprint('=' * 49) 

1420 return S 

1421 

1422 def get_gibbs_energy(self, temperature: float, 

1423 pressure: float, 

1424 verbose: bool = True) -> float: 

1425 """Returns the Gibbs free energy, in eV, in the ideal gas 

1426 approximation at a specified temperature (K) and pressure (Pa).""" 

1427 

1428 self.verbose = verbose 

1429 vprint = self._vprint 

1430 

1431 H = self.get_enthalpy(temperature, verbose=verbose) 

1432 vprint('') 

1433 S = self.get_entropy(temperature, pressure=pressure, verbose=verbose) 

1434 G = H - temperature * S 

1435 

1436 vprint('') 

1437 vprint('Free energy components at T = %.2f K and P = %.1f Pa:' % 

1438 (temperature, pressure)) 

1439 vprint('=' * 23) 

1440 fmt = '%5s%15.3f eV' 

1441 vprint(fmt % ('H', H)) 

1442 vprint(fmt % ('-T*S', -temperature * S)) 

1443 vprint('-' * 23) 

1444 vprint(fmt % ('G', G)) 

1445 vprint('=' * 23) 

1446 return G 

1447 

1448 

1449class CrystalThermo(BaseThermoChem): 

1450 """Class for calculating thermodynamic properties of a crystalline 

1451 solid in the approximation that a lattice of N atoms behaves as a 

1452 system of 3N independent harmonic oscillators. 

1453 

1454 Inputs: 

1455 

1456 phonon_DOS : list 

1457 a list of the phonon density of states, 

1458 where each value represents the phonon DOS at the vibrational energy 

1459 value of the corresponding index in phonon_energies. 

1460 

1461 phonon_energies : list 

1462 a list of the range of vibrational energies (hbar*omega) over which 

1463 the phonon density of states has been evaluated. This list should be 

1464 the same length as phonon_DOS and integrating phonon_DOS over 

1465 phonon_energies should yield approximately 3N, where N is the number 

1466 of atoms per unit cell. If the first element of this list is 

1467 zero-valued it will be deleted along with the first element of 

1468 phonon_DOS. Units of vibrational energies are eV. 

1469 

1470 potentialenergy : float 

1471 the potential energy in eV (e.g., from atoms.get_potential_energy) 

1472 (if potentialenergy is unspecified, then the methods of this 

1473 class can be interpreted as the energy corrections) 

1474 

1475 formula_units : int 

1476 the number of formula units per unit cell. If unspecified, the 

1477 thermodynamic quantities calculated will be listed on a 

1478 per-unit-cell basis. 

1479 """ 

1480 

1481 @classmethod 

1482 def from_transition_state(cls, *args, **kwargs) -> "CrystalThermo": 

1483 """ 

1484 Not yet implemented for CrystalThermo but needed to overwrite 

1485 BaseThermoChem method. 

1486 """ 

1487 raise NotImplementedError( 

1488 "from_transition_state is not implemented for CrystalThermo." 

1489 ) 

1490 

1491 def __init__(self, phonon_DOS, phonon_energies, 

1492 formula_units=None, potentialenergy=0.): 

1493 self.phonon_energies = phonon_energies 

1494 self.phonon_DOS = phonon_DOS 

1495 

1496 if formula_units: 

1497 self.formula_units = formula_units 

1498 self.potentialenergy = potentialenergy / formula_units 

1499 else: 

1500 self.formula_units = 0 

1501 self.potentialenergy = potentialenergy 

1502 

1503 def get_internal_energy(self, temperature, verbose=True): 

1504 """Returns the internal energy, in eV, of crystalline solid 

1505 at a specified temperature (K).""" 

1506 

1507 self.verbose = verbose 

1508 vprint = self._vprint 

1509 fmt = '%-15s%13.4f eV' 

1510 if self.formula_units == 0: 

1511 vprint('Internal energy components at ' 

1512 'T = %.2f K,\non a per-unit-cell basis:' % temperature) 

1513 else: 

1514 vprint('Internal energy components at ' 

1515 'T = %.2f K,\non a per-formula-unit basis:' % temperature) 

1516 vprint('=' * 31) 

1517 

1518 U = 0. 

1519 

1520 omega_e = self.phonon_energies 

1521 dos_e = self.phonon_DOS 

1522 if omega_e[0] == 0.: 

1523 omega_e = np.delete(omega_e, 0) 

1524 dos_e = np.delete(dos_e, 0) 

1525 

1526 vprint(fmt % ('E_pot', self.potentialenergy)) 

1527 U += self.potentialenergy 

1528 

1529 zpe_list = omega_e / 2. 

1530 if self.formula_units == 0: 

1531 zpe = trapezoid(zpe_list * dos_e, omega_e) 

1532 else: 

1533 zpe = trapezoid(zpe_list * dos_e, omega_e) / self.formula_units 

1534 vprint(fmt % ('E_ZPE', zpe)) 

1535 U += zpe 

1536 

1537 B = 1. / (units.kB * temperature) 

1538 E_vib = omega_e / (np.exp(omega_e * B) - 1.) 

1539 if self.formula_units == 0: 

1540 E_phonon = trapezoid(E_vib * dos_e, omega_e) 

1541 else: 

1542 E_phonon = trapezoid(E_vib * dos_e, omega_e) / self.formula_units 

1543 vprint(fmt % ('E_phonon', E_phonon)) 

1544 U += E_phonon 

1545 

1546 vprint('-' * 31) 

1547 vprint(fmt % ('U', U)) 

1548 vprint('=' * 31) 

1549 return U 

1550 

1551 def get_entropy(self, temperature, verbose=True): 

1552 """Returns the entropy, in eV/K, of crystalline solid 

1553 at a specified temperature (K).""" 

1554 

1555 self.verbose = verbose 

1556 vprint = self._vprint 

1557 fmt = '%-15s%13.7f eV/K%13.4f eV' 

1558 if self.formula_units == 0: 

1559 vprint('Entropy components at ' 

1560 'T = %.2f K,\non a per-unit-cell basis:' % temperature) 

1561 else: 

1562 vprint('Entropy components at ' 

1563 'T = %.2f K,\non a per-formula-unit basis:' % temperature) 

1564 vprint('=' * 49) 

1565 vprint('%15s%13s %13s' % ('', 'S', 'T*S')) 

1566 

1567 omega_e = self.phonon_energies 

1568 dos_e = self.phonon_DOS 

1569 if omega_e[0] == 0.: 

1570 omega_e = np.delete(omega_e, 0) 

1571 dos_e = np.delete(dos_e, 0) 

1572 

1573 B = 1. / (units.kB * temperature) 

1574 S_vib = (omega_e / (temperature * (np.exp(omega_e * B) - 1.)) - 

1575 units.kB * np.log(1. - np.exp(-omega_e * B))) 

1576 if self.formula_units == 0: 

1577 S = trapezoid(S_vib * dos_e, omega_e) 

1578 else: 

1579 S = trapezoid(S_vib * dos_e, omega_e) / self.formula_units 

1580 

1581 vprint('-' * 49) 

1582 vprint(fmt % ('S', S, S * temperature)) 

1583 vprint('=' * 49) 

1584 return S 

1585 

1586 def get_helmholtz_energy(self, temperature, verbose=True): 

1587 """Returns the Helmholtz free energy, in eV, of crystalline solid 

1588 at a specified temperature (K).""" 

1589 

1590 self.verbose = True 

1591 vprint = self._vprint 

1592 

1593 U = self.get_internal_energy(temperature, verbose=verbose) 

1594 vprint('') 

1595 S = self.get_entropy(temperature, verbose=verbose) 

1596 F = U - temperature * S 

1597 

1598 vprint('') 

1599 if self.formula_units == 0: 

1600 vprint('Helmholtz free energy components at ' 

1601 'T = %.2f K,\non a per-unit-cell basis:' % temperature) 

1602 else: 

1603 vprint('Helmholtz free energy components at ' 

1604 'T = %.2f K,\non a per-formula-unit basis:' % temperature) 

1605 vprint('=' * 23) 

1606 fmt = '%5s%15.4f eV' 

1607 vprint(fmt % ('U', U)) 

1608 vprint(fmt % ('-T*S', -temperature * S)) 

1609 vprint('-' * 23) 

1610 vprint(fmt % ('F', F)) 

1611 vprint('=' * 23) 

1612 return F 

1613 

1614 

1615def _clean_vib_energies(vib_energies: Sequence[complex], 

1616 ignore_imag_modes: bool = False 

1617 ) -> tuple[Sequence[float], int]: 

1618 """Checks and deal with the presence of imaginary vibrational modes 

1619 

1620 Also removes +0.j from real vibrational energies. 

1621 

1622 Inputs: 

1623 

1624 vib_energies : list 

1625 a list of the vibrational energies 

1626 

1627 ignore_imag_modes : bool 

1628 If 'True', any imaginary frequencies will be removed. 

1629 If 'False' (default), an error will be raised if imaginary 

1630 frequencies are present. 

1631 

1632 Outputs: 

1633 

1634 vib_energies : list 

1635 a list of the real vibrational energies. 

1636 

1637 n_imag : int 

1638 the number of imaginary frequencies treated. 

1639 """ 

1640 # raise to a value, accept int and float in contrast to documentation 

1641 if ignore_imag_modes: 

1642 n_vib_energies = len(vib_energies) 

1643 vib_energies = [np.real(v) for v in vib_energies if np.real(v) > 0] 

1644 n_imag = n_vib_energies - len(vib_energies) 

1645 if n_imag > 0: 

1646 warn(f"{n_imag} imag modes removed", UserWarning) 

1647 else: 

1648 if sum(np.iscomplex(vib_energies)): 

1649 raise ValueError('Imaginary vibrational energies are present.') 

1650 n_imag = 0 

1651 ret = [np.real(v) for v in vib_energies] # clear +0.j 

1652 

1653 return ret, n_imag