Coverage for dynasor/modes/mode_projector.py: 100%

211 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-03 19:46 +0000

1from __future__ import annotations 

2from typing import Optional 

3 

4import numpy as np 

5import warnings 

6import pickle 

7 

8from ase.calculators.singlepoint import SinglePointCalculator 

9from ase.units import fs 

10from ase import Atoms 

11from numpy.typing import NDArray 

12 

13from dynasor.units import radians_per_fs_to_THz 

14from dynasor.tools.structures import get_displacements 

15from .tools import get_dynamical_matrix, group_eigvals, symmetrize_eigenvectors 

16from .qpoint import QPoint 

17from .atoms import Prim, Supercell 

18from ..qpoints.tools import get_commensurate_lattice_points 

19 

20 

21class ModeProjector: 

22 """ 

23 The :class:`ModeProjector` maps between real atomic displacements `u` and 

24 complex mode coordinates `Q`. 

25 

26 Some special python methods are implemented. The `__str__` and `__repr__` 

27 provides useful info. :class:`QPoint <dynasor.modes.qpoint.QPoint>` objects are 

28 representations of a single q-point and associated information and can be accessed either by 

29 call providing a reduced wavevector 

30 

31 >>> mp((1/2, 0, 0)) # doctest: +SKIP 

32 

33 or by index corresponding to reduced q-point accessible from 

34 :attr:`~ModeProjector.q_reduced` 

35 

36 >>> mp[2] # doctest: +SKIP 

37 

38 In addition to mode coordinates `Q` the class can also map the atomic 

39 velocities `v` to mode momenta `P` as well as atomic forces `f` to mode 

40 forces `F`. The class can also map back. This mapping is done using 

41 getters and setters. Internally only Q, P and F are stored 

42 

43 >>> Q = mp.get_Q() # doctest: +SKIP 

44 >>> mp.set_u(u) # doctest: +SKIP 

45 

46 In addition, the forces corresponding to the harmonic forces can be 

47 accessed by :meth:`~ModeProjector.get_f_harmonic()` and 

48 :meth:`~ModeProjector.get_F_harmonic()`. For ASE Atoms objects the 

49 displacements etc. can be updated and applied by 

50 

51 >>> mp.update_from_atoms(atoms) # doctest: +SKIP 

52 >>> atoms = mp.get_atoms(harmonic_forces=False) # doctest: +SKIP 

53 

54 The shapes for each property uses the following variables 

55 

56 * `N`: Number of atoms in supercell 

57 * `Nu`: unit cells (`N/Np`) 

58 * `Np`: primitive basis atoms (`N/Nu`) 

59 * `Nb`: bands (`Np*3`) 

60 * `Nq`: q-points (`Nu`) 

61 

62 Please consult the documentation or the specific getters and setters to see 

63 the exact transformations used. 

64 

65 Units 

66 ^^^^^ 

67 The internal units in dynasor are Å, fs and eV. All frequencies are angular 

68 (a.k.a. the "physicist's convention" with 2π included). These are the units 

69 dynasor will expect and return. In, e.g., print functions conventional units 

70 such fs, Å, THz, Da, meV are commonly used. 

71 

72 **Mass:** 

73 The internal unit choice (eV, Å, fs) means that the mass unit is not Dalton 

74 but rather 0.009648533290731906 Da. 

75 We refer to this unit as the "dynasor mass unit" (dmu), 

76 i.e., 1 Da = 103.64269572045423 dmu. 

77 As a user you will only see this unit in the output of :class:`ModeProjector` objects. 

78 Masses provided via, e.g., ASE Atoms objects are converted internally. 

79 

80 **Waves:** 

81 dynasor reports and expects spatial (angular) frequencies in rad/Å and temporal (angular) 

82 frequencies in rad/fs. This follows the often-used convention in physics to include the 

83 factor of 2π in the wave vectors. For instance the wavelength is given by λ=2π/q. 

84 

85 **Mode amplitudes:** 

86 Mode amplitudes are reported in Å√dmu = fs√eV. 

87 

88 **Velocities:** 

89 For modes the momenta are reported in Å√dmu/fs or just √eV 

90 while atomic velocities are reported in Å/fs. 

91 

92 **Mode forces:** 

93 The force is defined as the derivative of the momenta with respect to time 

94 so the unit used when reporting mode forces is Å√dmu/fs² (or √eV/fs). 

95 

96 Internal arrays 

97 ^^^^^^^^^^^^^^^ 

98 For the curious, the internal data arrays are 

99 

100 * :attr:`primitive`, :attr:`supercell`, :attr:`force_constants` (input) 

101 * :attr:`_q`, :attr:`q_minus` (reduced q-points and which q-points are related by inversion) 

102 * :attr:`_D`, :attr:`_w2`, :attr:`_W` 

103 (dynamical matrices, frequencies (ev/Ų/Da), polarization vectors) 

104 * :attr:`_X` (eigenmodes which are mass weighted "polarization vectors" in the supercell) 

105 """ 

106 def __init__(self, primitive: Atoms, supercell: Atoms, force_constants: NDArray[float]): 

107 """The mode projector is initialized by a primitive cell and a 

108 supercell as well as harmonic force constants. 

109 

110 The force constants are assumed to be in units of eV/Ų as returned 

111 from phonopy. Be careful about the permutations when working with force 

112 constants and atoms object from different codes. 

113 

114 Parameters 

115 ---------- 

116 primitive 

117 Primitive cell. Note that the masses are stored internally as 

118 Dalton in ASE but will be converted to the internal dynasor 

119 mass unit (dmu). 

120 supercell 

121 Ideal supercell corresponding to the force constants. 

122 force_constants 

123 Force constants for the supercell in eV/Ų as a `(N, N, 3, 3)` array 

124 where `N` is `len(supercell)`. 

125 """ 

126 if len(primitive) == len(supercell): 

127 warnings.warn('Primitive and supercell have the same size') 

128 elif len(primitive) > len(supercell): 

129 raise ValueError('Primitive cell larger than supercell') 

130 elif not (len(supercell) / len(primitive)).is_integer(): 

131 raise ValueError('supercell size is not multiple of primitive size') 

132 

133 if len(supercell) != len(force_constants): 

134 raise ValueError('force constants shape is not compatible with supercell size') 

135 

136 if force_constants.shape != (len(supercell), len(supercell), 3, 3): 

137 raise ValueError('force constants shape should be (N, N, 3, 3)') 

138 

139 self.primitive = Prim(primitive) 

140 self.supercell = Supercell(supercell, primitive) 

141 self.force_constants = force_constants 

142 

143 # Find q-points in reduced primitive cell coordinates 

144 q_integer = get_commensurate_lattice_points(self.supercell.P.T) 

145 q_reduced = np.dot(q_integer, self.supercell.P_inv.T) 

146 self._q = np.array(sorted(tuple(q) for q in q_reduced)) 

147 

148 # The equivalent q-point corresponding to -q 

149 self._q_minus = [[tuple(q) for q in self._q].index(tuple((-q) % 1)) for q in self._q] 

150 

151 # Construct dynamical matrix and diagonalize at each q-point 

152 self._D, self._w2, self._W = [], [], [] 

153 for qi, q in enumerate(self._q): 

154 

155 D = get_dynamical_matrix( 

156 self.force_constants, self.supercell.offsets, self.supercell.indices, 

157 q.astype(np.float64)) 

158 

159 if qi == self.q_minus[qi]: 

160 assert np.allclose(D.imag, 0) 

161 D = D.real 

162 

163 D = np.einsum('ijab,i,j->ijab', 

164 D, self.primitive.masses**-0.5, self.primitive.masses**-0.5) 

165 D_matrix = D.transpose(0, 2, 1, 3).reshape(-1, self.primitive.n_atoms * 3) 

166 assert np.allclose(D_matrix, D_matrix.T.conj()) 

167 w2, W = np.linalg.eigh(D_matrix) 

168 W = W.T.reshape(-1, self.primitive.n_atoms, 3) 

169 

170 self._D.append(D) 

171 self._w2.append(w2) 

172 self._W.append(W) 

173 

174 self._D = np.array(self._D) 

175 self._w2 = np.array(self._w2) 

176 self._W = np.array(self._W) 

177 

178 # Post check basic symmetries, group eigenvalues and try to make degenerate modes nicer 

179 for q, q_minus in enumerate(self.q_minus): 

180 q_minus = self.q_minus[q] 

181 

182 assert np.allclose(self._D[q], self._D[q_minus].conj()) 

183 assert np.allclose(self._w2[q], self._w2[q_minus]) 

184 

185 # tolerances for grouping and sorting eigenvalues and eigenvectors 

186 group_decimals = 12 

187 tolerance = 10**(-group_decimals) 

188 # Keep sorting coarser than the convergence tolerance so tiny SVD 

189 # roundoff cannot change the order of equivalent modes. 

190 sort_decimals = 10 

191 

192 for group in group_eigvals(self._w2[q], tolerance**0.5): 

193 W = symmetrize_eigenvectors(self._W[q, group]) 

194 

195 # Try to order them 

196 W_sort = W.copy().transpose(0, 2, 1).reshape(len(W), -1) 

197 # abs is because we want to consider the magnitude 

198 # - (minus) basically reverts the sort order to place largest first 

199 # T is just because how lexsort works, we want to consider each 

200 # atom and direction as a key for the bands 

201 # -1 is because we want to make the x-direction of the first 

202 # atom the most significant key 

203 # At the end the first band should have the largest magnitude 

204 # for the first atom in x 

205 # Suppress numerical noise in the mode signatures before sorting. 

206 # Keep this precision deliberately below the factor-analysis 

207 # convergence noise so tiny SVD/BLAS differences cannot permute 

208 # otherwise equivalent degenerate modes. 

209 argsort = np.lexsort(np.round(-np.abs(W_sort).T[::-1], sort_decimals)) 

210 self._W[q, group] = W[argsort] 

211 

212 self._W[q_minus] = self._W[q].conj() 

213 

214 # Construct supercell projection matrix: q_ks = X_ksna u_na. 

215 # phase and mass depend only on (q, index), so compute once and 

216 # broadcast against a gather of self._W. 

217 indices = self.supercell.indices 

218 

219 # self._q has dtype=object (Fraction); cast to float for the phase 

220 q_float = self._q.astype(np.float64) 

221 phase = np.exp(-1j * 2*np.pi * q_float @ self.supercell.offsets.T) # (n_q, n_super) 

222 mass_sqrt = self.primitive.masses[indices]**0.5 # (n_super,) 

223 # Build _X in place to avoid extra full-size temporaries (halves peak 

224 # memory). astype pins the dtype (no-op unless _W is real, e.g. Gamma). 

225 self._X = self._W[:, :, indices, :].astype(np.complex128, copy=False) 

226 np.conjugate(self._X, out=self._X) 

227 self._X *= phase[:, None, :, None] 

228 self._X *= mass_sqrt[None, None, :, None] 

229 self._X /= (self.supercell.n_atoms / self.primitive.n_atoms)**0.5 

230 

231 # Init arrays to hold Q, P and F 

232 self._Q = np.zeros((len(self._q), self.primitive.n_atoms*3), dtype=np.complex128) 

233 self._P = np.zeros_like(self._Q) 

234 self._F = np.zeros_like(self._Q) 

235 

236 def __str__(self): 

237 strings = ['### ModeProjector ###'] 

238 strings += [f'{self.supercell}'] 

239 strings += [f'{self.primitive}'] 

240 string = '\n'.join(strings) 

241 

242 # ASCII DOS! 

243 width = 80 

244 height = 24 

245 dos = np.full((height, width), ' ') 

246 

247 THz = self.omegas * radians_per_fs_to_THz 

248 

249 hist, bins = np.histogram(THz.flat, bins=width) 

250 

251 for i, h in enumerate(hist): 

252 dos[np.round(h * (height - 1) / hist.max()).astype(int), i] = '+' # '·' or 'x' 

253 

254 dos = dos[::-1] 

255 dos[-1, dos[-1] == ' '] = '-' 

256 dos = '\n'.join([''.join(d) for d in dos]) 

257 

258 string += f'\n{dos}' 

259 string += f'\n|{THz.min():<10.2f} THz' + ' '*(width - 26) + f'{THz.max():>10.2f}|' 

260 

261 return string 

262 

263 def __repr__(self): 

264 return str(self) 

265 

266 def __getitem__(self, q) -> QPoint: 

267 """Returns the q-point object based on its index""" 

268 if q < 0 or q >= len(self._q): 

269 raise IndexError 

270 return QPoint(q, self) 

271 

272 def __call__(self, qpoint) -> QPoint: 

273 """Tries to find a matching q-point based on reduced coordinate""" 

274 qpoint = np.array(qpoint).astype(np.float64) % 1 

275 for q, qpoint2 in enumerate(np.array(self._q).astype(np.float64)): 

276 if np.allclose(qpoint, qpoint2): 

277 return QPoint(q, self) 

278 raise ValueError('qpoint not compatible, check mp.q_reduced') 

279 

280 # Getters ans setters for internal mutable arrays 

281 def get_Q(self) -> NDArray[complex]: 

282 """Return the mode coordinates as a ``(Nq, Nb)`` complex array in Å√dmu.""" 

283 return self._Q.copy() 

284 

285 def get_P(self) -> NDArray[complex]: 

286 """Return the mode momenta as a ``(Nq, Nb)`` complex array in √eV.""" 

287 return self._P.copy() 

288 

289 def get_F(self) -> NDArray[complex]: 

290 """Return the mode forces as a ``(Nq, Nb)`` complex array in eV/Å√dmu.""" 

291 return self._F.copy() 

292 

293 def get_F_harmonic(self) -> NDArray[complex]: 

294 r"""Return the harmonic mode forces as a ``(Nq, Nb)`` complex array in eV/Å√dmu. 

295 

296 Computed as :math:`-\omega^2 Q^*`. The conjugate follows the convention 

297 :math:`F = X^* f / m` used by :meth:`set_f`, in which :math:`F` is the force 

298 conjugate to :math:`Q`, i.e. :math:`-\partial V/\partial Q`. Note that 

299 :math:`Q` and :math:`Q^*` coincide only at self-conjugate q-points. 

300 """ 

301 return -self._w2 * self.get_Q().conj() 

302 

303 def set_Q(self, Q: NDArray[complex]) -> None: 

304 """Sets the internal mode coordinates :math:`Q`. 

305 

306 The function ensures :math:`Q(-q)=Q^*(q)`. 

307 """ 

308 # This ensures that stuff like mp.set_Q(0) works while not updating the 

309 # array until the assert 

310 Q_new = self.get_Q() 

311 Q_new[:] = Q 

312 if not np.allclose(np.conjugate(Q_new), Q_new[self.q_minus]): 

313 raise ValueError('Supplied Q does not fulfill Q(-q) = Q(q)*') 

314 self._Q[:] = Q_new 

315 

316 def set_P(self, P: NDArray[complex]) -> None: 

317 """Sets the internal mode momenta :math:`P`. 

318 

319 The function ensures :math:`P(-q)=P^*(q)`. 

320 """ 

321 P_new = self.get_P() 

322 P_new[:] = P 

323 if not np.allclose(np.conjugate(P_new), P_new[self.q_minus]): 

324 raise ValueError('Supplied P does not fulfill P(-q) = P(q)*') 

325 self._P[:] = P_new 

326 

327 def set_F(self, F: NDArray[complex]) -> None: 

328 """Sets the internal mode forces :math:`F`. 

329 

330 The function ensures :math:`F(-q)=F^*(q)`. 

331 """ 

332 F_new = self.get_F() 

333 F_new[:] = F 

334 if not np.allclose(np.conjugate(F_new), F_new[self.q_minus]): 

335 raise ValueError('Supplied F does not fulfill F(-q) = F(q)*') 

336 self._F[:] = F_new 

337 

338 def get_u(self) -> NDArray[float]: 

339 """Return the atomic displacements as a ``(N, 3)`` array in Å.""" 

340 u = np.einsum('ksna,ks,n->na', self._X.conj(), self._Q, 1 / self.supercell.masses) 

341 assert np.allclose(u.imag, 0) 

342 return u.real 

343 

344 def get_v(self) -> NDArray[float]: 

345 """Return the atomic velocities as a ``(N, 3)`` array in Å/fs.""" 

346 v = np.einsum('ksna,ks,n->na', self._X, self._P, 1 / self.supercell.masses) 

347 assert np.allclose(v.imag, 0) 

348 return v.real 

349 

350 def get_f(self) -> NDArray[float]: 

351 """Return the atomic forces as a ``(N, 3)`` array in eV/Å.""" 

352 f = np.einsum('ksna,ks->na', self._X, self._F) 

353 assert np.allclose(f.imag, 0) 

354 return f.real 

355 

356 def get_f_harmonic(self) -> NDArray[float]: 

357 """Return the harmonic atomic forces for the current displacements 

358 as a ``(N, 3)`` array in eV/Å. 

359 """ 

360 F_harmonic = self.get_F_harmonic() 

361 f_harmonic = np.einsum('ksna,ks->na', self._X, F_harmonic) 

362 assert np.allclose(f_harmonic.imag, 0) 

363 return f_harmonic.real 

364 

365 def set_u(self, u: NDArray[float]) -> None: 

366 """Sets the internal mode coordinates :math:`Q` given the atomic displacements :math:`u`. 

367 

368 .. math:: 

369 

370 Q = X u 

371 

372 Parameters 

373 ---------- 

374 u 

375 The atomic displacements in Å. 

376 """ 

377 Q = np.einsum('ksna,na->ks', self._X, u) 

378 self.set_Q(Q) 

379 

380 def set_v(self, v: NDArray[float]) -> None: 

381 """Sets the internal mode momenta :math:`P` given the atomic velocities :math:`v`. 

382 

383 .. math:: 

384 

385 P = X^* * v 

386 

387 Parameters 

388 ---------- 

389 v 

390 The atomic velocities in Å/fs. 

391 """ 

392 P = np.einsum('ksna,na->ks', self._X.conj(), v) 

393 self.set_P(P) 

394 

395 def set_f(self, f: NDArray[float]) -> None: 

396 """Sets the internal mode forces :math:`F` given the atomic forces :math:`f`. 

397 

398 .. math:: 

399 

400 F = X^* * f / m 

401 

402 Parameters 

403 ---------- 

404 f 

405 The atomic forces in eV/Å. 

406 """ 

407 F = np.einsum('ksna,na,n->ks', self._X.conj(), f, 1 / self.supercell.masses) 

408 self.set_F(F) 

409 

410 # Convenience functions to handle ASE Atoms objects 

411 def get_atoms(self, harmonic_forces: Optional[bool] = False) -> Atoms: 

412 r"""Returns ASE :class:`~ase.Atoms` object with displacement, 

413 velocities, forces, and harmonic energies. 

414 

415 Parameters 

416 ---------- 

417 harmonic_forces 

418 Whether the forces should be taken from the internal `F` or via `-\omega^2 Q`. 

419 """ 

420 atoms = self.supercell.to_ase() 

421 atoms.positions += self.get_u() 

422 atoms.set_velocities(self.get_v() / fs) 

423 E = self.potential_energies.sum() 

424 f = self.get_f_harmonic() if harmonic_forces else self.get_f() 

425 

426 atoms.calc = SinglePointCalculator( 

427 energy=E, forces=f, stress=None, magmoms=None, atoms=atoms) 

428 

429 return atoms 

430 

431 def update_from_atoms(self, atoms: Atoms) -> None: 

432 """Updates the :class:`ModeProjector` objects with displacements, velocities, 

433 and forces from an ASE :class:`~ase.Atoms` object. 

434 

435 Checks for an attached calculator in the first place and next for a forces array. 

436 

437 If no data sets corresponding array to zeros. 

438 

439 The velocities are converted to dynasor units internally. Note that 

440 :attr:`atoms`'s masses are not used; masses are fixed from the 

441 ``primitive``/``supercell`` structures given at construction, since the 

442 dynamical matrix and mode eigenvectors already depend on them. 

443 """ 

444 

445 u = get_displacements(atoms, self.supercell) 

446 if np.max(np.abs(u)) > 2.0: 

447 warnings.warn('Displacements larger than 2Å. Is the atoms object permuted?') 

448 self.set_u(u) 

449 self.set_v(atoms.get_velocities() * fs) 

450 try: 

451 self.set_f(atoms.get_forces()) 

452 except RuntimeError: 

453 if 'forces' in atoms.arrays: 

454 self.set_f(atoms.arrays['forces']) 

455 else: 

456 self.set_f(np.zeros_like(atoms.positions)) 

457 

458 # properties 

459 @property 

460 def q_minus(self) -> NDArray[float]: 

461 """The index of the corresponding counter-propagating mode (:math:`-q`).""" 

462 return self._q_minus.copy() 

463 

464 @property 

465 def q_reduced(self) -> NDArray[float]: 

466 """The q-points in reduced coordinates. 

467 

468 For example a zone boundary mode would be (1/2, 0, 0) 

469 """ 

470 return self._q.astype(float) 

471 

472 @property 

473 def q_cartesian(self) -> NDArray[float]: 

474 """The q-points in cartesian coordinates with unit of rad/Å (2π included).""" 

475 return 2 * np.pi * self.q_reduced @ self.primitive.inv_cell 

476 

477 @property 

478 def omegas(self) -> NDArray[float]: 

479 """The frequencies of each mode in rad/fs. 

480 

481 Following convention, negative values indicate imaginary frequencies. 

482 """ 

483 return np.sign(self._w2) * np.sqrt(np.abs(self._w2)) 

484 

485 @property 

486 def polarizations(self) -> NDArray[float]: 

487 """The polarization vectors for each mode `(Nq, Nb, Np, 3)`.""" 

488 return self._W 

489 

490 @property 

491 def eigenmodes(self) -> NDArray[float]: 

492 """The eigenmodes in the supercell as `(Nq, Nb, N, 3)`-array 

493 

494 The eigenmodes include the masses such that :math:`Q = X u` 

495 where :math:`u` are the supercell displacements. 

496 """ 

497 return self._X 

498 

499 @property 

500 def potential_energies(self) -> NDArray[float]: 

501 r"""Potential energy per mode as `(Nq, Nb)`-array. 

502 

503 The potential energies are defined as :math:`1/2 \omega^2 Q Q^*` and should equal 

504 :math:`1/2 k_B T` in equilibrium for a harmonic system. 

505 """ 

506 return 1 / 2 * np.abs(self._Q) ** 2 * self._w2 

507 

508 @property 

509 def kinetic_energies(self) -> NDArray[float]: 

510 """Kinetic energy per mode as `(Nq, Nb)`-array. 

511 

512 The kinetic energies are defined as :math:`1/2 P P^*`. Should equal 

513 :math:`1/2 k_B T` in equilibrium. 

514 """ 

515 return 1 / 2 * np.abs(self._P)**2 

516 

517 @property 

518 def virial_energies(self) -> NDArray[complex]: 

519 r"""The virial energies per mode as `(Nq, Nb)`-array. 

520 

521 The virial energies are defined here as :math:`-1/2 Q F`, which should have an 

522 expectation value of :math:`1/2 k_B T` per mode in equilibrium. For a harmonic 

523 system this is simply equal to the potential energy. This means that 

524 the virial energy can be used to monitor the anharmonicity or 

525 define a measure of the potential energy. 

526 

527 Note that while :math:`Q F` is in general complex the virial energy 

528 obeys :math:`E(-\boldsymbol{q}) = E^*(\boldsymbol{q})`, meaning the 

529 imaginary parts cancel between :math:`\boldsymbol{q}` and 

530 :math:`-\boldsymbol{q}` and the total is real. Take the real part to 

531 obtain per-mode energies; this leaves the sum unchanged. 

532 """ 

533 return -1 / 2 * self._Q * self._F 

534 

535 def write(self, file_name: str) -> None: 

536 """Uses pickle to write mode projector to file.""" 

537 with open(file_name, 'wb') as f: 

538 pickle.dump(self, f) 

539 

540 @classmethod 

541 def read(cls, file_name: str) -> ModeProjector: 

542 """Return :class:`ModeProjector` instance from pickle file 

543 that was saved using :func:`~ModeProjector.write`.""" 

544 with open(file_name, 'rb') as f: 

545 mp = pickle.load(f) 

546 return mp