Coverage for ase / _4 / symopt / relax.py: 91.99%

337 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-05-21 15:52 +0000

1from dataclasses import dataclass 

2 

3import numpy as np 

4import scipy 

5 

6from ase import Atoms 

7from ase._4.symopt.relax_print import ( 

8 pprint_atoms, 

9 pretty, 

10 pretty_atomic_dofs, 

11 pretty_dofs, 

12 pretty_header, 

13 pretty_subheader, 

14) 

15from ase.cell import Cell 

16from ase.utils.abc import Optimizable 

17 

18 

19def green(text: str) -> str: 

20 return f'\x1b[32m{text}\x1b[0m' 

21 

22 

23def chol_derivative(A, dA): 

24 """ 

25 Compute the derivative of the Cholesky factorization. 

26 

27 A = L L^T 

28 

29 where L is lower-triangular. For a small symmetric perturbation `dA`, 

30 this function returns an approximation of the differential `dL` of 

31 the Cholesky factor: 

32 

33 L + dL ≈ chol(A + dA). 

34 

35 Either L or A must be given as an input. 

36 """ 

37 A = (A + A.T) / 2 

38 dA = (dA + dA.T) / 2 

39 

40 L = np.linalg.cholesky(A) 

41 Linv = np.linalg.inv(L) 

42 S = Linv @ dA @ Linv.T 

43 X = np.tril(S) 

44 X[np.diag_indices_from(X)] *= 0.5 

45 return L @ X 

46 

47 

48def symmetrize_atoms( 

49 S_ac: np.ndarray, U_scc: np.ndarray, f_sc: np.ndarray, atommap_sa, tol=1e-12 

50): 

51 """ 

52 Symmetrize fractional atomic coordinates under a space-group. 

53 

54 Given atomic scaled positions `S_ac` and a set of space-group operations 

55 (U_scc, f_sc), this function projects the positions onto the symmetry- 

56 invariant subspace by averaging over all symmetry-related images. 

57 

58 Parameters 

59 ---------- 

60 S_ac : ndarray, shape (na, 3) 

61 Scaled atomic coordinates. 

62 

63 U_scc : ndarray, shape (ns, 3, 3) 

64 Rotation matrices. 

65 

66 f_sc : ndarray, shape (ns, 3) 

67 Translation vectors. 

68 

69 atommap_sa : ndarray, shape (ns, na) 

70 Mapping such that atommap_sa[s, a] gives the index of the atom 

71 to which atom `a` is mapped by symmetry operation `s`. 

72 

73 tol : float, optional 

74 Tolerance for snapping values close to 0 or 1 back to 0. 

75 

76 Returns 

77 ------- 

78 Ssym_ac : ndarray, shape (na, 3) 

79 Symmetrized fractional atomic coordinates in [0, 1). 

80 

81 Notes 

82 ----- 

83 The symmetrization is done in the complex phase representation 

84 exp(2πi x) to correctly average periodic fractional coordinates. 

85 """ 

86 ns, na = atommap_sa.shape 

87 tmp_Ssym_ac = np.zeros_like(S_ac, dtype=np.complex128) 

88 for a in range(na): 

89 for s in range(ns): 

90 new = U_scc[s].T @ S_ac[a] - f_sc[s] 

91 tmp_Ssym_ac[atommap_sa[s, a]] += np.exp(2j * np.pi * new) 

92 Ssym_ac = (np.angle(tmp_Ssym_ac) / (2 * np.pi)) % 1.0 % 1.0 

93 Ssym_ac[np.abs(Ssym_ac) < tol] = 0.0 

94 Ssym_ac[np.abs(Ssym_ac - 1.0) < tol] = 0.0 

95 return Ssym_ac 

96 

97 

98@dataclass 

99class AtomsSymmetries: 

100 """ 

101 Dataclass to contain symmetry information from atoms. 

102 

103 This is to set up an interface for spglib/GPAW or whatever 

104 source of symmetry operations. 

105 """ 

106 

107 rotation_scc: np.ndarray 

108 atommap_sa: np.ndarray 

109 translation_sc: np.ndarray 

110 symmorphic: bool 

111 symprec: float 

112 

113 @classmethod 

114 def from_GPAW(cls, atoms, log=None, *, tolerance, symmorphic): 

115 from gpaw.new.symmetry import create_symmetries_object 

116 

117 gpaw_symmetries = create_symmetries_object( 

118 atoms, tolerance=tolerance, symmorphic=symmorphic 

119 ) 

120 log(gpaw_symmetries) 

121 sym = AtomsSymmetries( 

122 gpaw_symmetries.rotation_scc, 

123 gpaw_symmetries.atommap_sa, 

124 gpaw_symmetries.translation_sc, 

125 symmorphic, 

126 tolerance, 

127 ) 

128 return sym 

129 

130 

131@dataclass 

132class SymmeryAdaptedCellCoordinates: 

133 """Class for defining symmetry adapted cell coordinates 

134 

135 Note: This is not symmetry adapted cell, it just provides the set of 

136 generalized coordinates for the symmetry adapted cell. 

137 To get the cell, call C_cv = get_cell(cell_z). 

138 

139 sacc = SymmeryAdaptedCellCoordinates(...) 

140 sacc.get_cell(cell_z), where cell_z is 1D array of the cell coordinates. 

141 

142 Thus, the M_cc and C_cv here is just the origin of the coordinate system. 

143 """ 

144 

145 # Symmetrized cell 

146 M_cc: np.ndarray # Rename to M0_cc 

147 C_cv: np.ndarray # Rename to C0_cv 

148 dM_zcc: np.ndarray 

149 dM_zvv: np.ndarray 

150 rot_vv: np.ndarray 

151 

152 def get_cell(self, cell_z): 

153 """ 

154 Construct the real-space unit cell from symmetry-adapted coordinates. 

155 

156 Given generalized cell coordinates `cell_z`, this method reconstructs 

157 the metric tensor (see get_M_cc) and then computes a corresponding 

158 cell matrix C_cv via a Cholesky factorization of M_cc, 

159 followed by a fixed rotation `rot_vv`: 

160 

161 C_cv = chol(M_cc) @ rot_vv.T 

162 

163 Parameters 

164 ---------- 

165 cell_z : ndarray, shape (nz,) 

166 Symmetry-adapted cell coordinates. 

167 

168 Returns 

169 ------- 

170 cell : ase.geometry.Cell 

171 The reconstructed unit cell. 

172 """ 

173 

174 M_cc = self.get_M_cc(cell_z) 

175 try: 

176 C_cv = np.linalg.cholesky(M_cc) @ self.rot_vv.T 

177 except np.linalg.LinAlgError: 

178 raise RuntimeError('Failed to create cell from metric', M_cc) 

179 

180 return Cell(C_cv) 

181 

182 def get_M_cc(self, cell_z): 

183 """ 

184 Reconstruct the metric tensor from symmetry-adapted coordinates. 

185 

186 Computes the metric tensor as a linear expansion around a reference 

187 metric M0_cc in the symmetry-allowed tangent directions: 

188 

189 M_cc = M0_cc + sum_z cell_z[z] * dM_zcc[z] 

190 

191 Parameters 

192 ---------- 

193 cell_z : ndarray, shape (nz,) 

194 Symmetry-adapted cell coordinates. 

195 

196 Returns 

197 ------- 

198 M_cc : ndarray, shape (3, 3) 

199 Symmetrized metric tensor corresponding to `cell_z`. 

200 """ 

201 return self.M_cc + np.einsum('z,zcd->cd', cell_z, self.dM_zcc) 

202 

203 @classmethod 

204 def build(cls, cell, pbc_c, rotation_scc: np.ndarray, *, log): 

205 return cls(*cls.unit_cell_symmetry(cell, rotation_scc, pbc_c, log=log)) 

206 

207 @classmethod 

208 def symmetrize_cell(cls, C_cv, rotation_scc): 

209 """Symmetrize the cell 

210 

211 Calculates the cell metric, and applies the rotation operations to it. 

212 New cell lower diagonal cell is calculated via Cholesky decomposition. 

213 By doing polar decomposition to the deformation gradient, the rotation 

214 back to the original cell rotation is obtained. 

215 

216 Returns osymC_cV, symC_cv, M_cc, rot_vv 

217 

218 Where osymC_cv is the symmetrized original like cell 

219 symC_cv is the lower diagonal symmetrized cell 

220 M_cc is the symmetrized cell metric 

221 rot_vv is the rotation matrix between osymC_Cv and symC_cv 

222 such that osymC_cv = symC_cv @ rot_vv.T 

223 

224 """ 

225 # Calculate the cell metric 

226 M_cc = C_cv @ C_cv.T 

227 # Symmetrize the cell metric 

228 M_cc = np.einsum( 

229 'scd,de,sfe->cf', rotation_scc, M_cc, rotation_scc, optimize=True 

230 ) / len(rotation_scc) 

231 

232 symC_cv = np.linalg.cholesky(M_cc) 

233 

234 # Deformation gradient 

235 F_vv = np.linalg.inv(C_cv) @ symC_cv 

236 

237 # Sanity check 

238 assert np.allclose(C_cv @ F_vv, symC_cv) 

239 

240 # Do a polar decomposition to rotate the symmetrized cell back 

241 rot_vv, P_vv = scipy.linalg.polar(F_vv) 

242 osymC_cv = symC_cv @ rot_vv.T 

243 

244 return osymC_cv, symC_cv, M_cc, rot_vv 

245 

246 @classmethod 

247 def unit_cell_symmetry( 

248 cls, C_cv, rotation_scc, pbc_c, units='Å^2', log=None 

249 ): 

250 pretty( 

251 C_cv @ C_cv.T, "Cell metric (M_cc' = C_cv C_c'v)", units, log=log 

252 ) 

253 osymC_cv, symC_cv, M_cc, rot_vv = cls.symmetrize_cell( 

254 C_cv, rotation_scc 

255 ) 

256 pretty( 

257 M_cc, "Symmetrized cell metric (M_cc' = C_cv C_c'v)", units, log=log 

258 ) 

259 

260 # Now we can construct exact Cartesian rotation matrices 

261 iosymC_cv = np.linalg.inv(osymC_cv) 

262 U_svv = [osymC_cv.T @ U_cc.T @ iosymC_cv.T for U_cc in rotation_scc] 

263 U_svv = np.array(U_svv) 

264 

265 # Build unit vector in symmetric matrix space 

266 def e(i, j): 

267 eps_ij = np.zeros((3, 3)) 

268 eps_ij[i, j] = 1.0 

269 eps_ij[j, i] = 1.0 

270 return eps_ij 

271 

272 eps_ijk = np.zeros((3, 3, 6)) 

273 k = 0 

274 for i in range(3): 

275 for j in range(i, 3): 

276 if i == j: 

277 s = 1.0 

278 else: 

279 s = 2 ** (-0.5) 

280 eps_ijk[i, j, k] = s 

281 eps_ijk[j, i, k] = s 

282 k += 1 

283 

284 A_blocks = [] 

285 for U_vv in U_svv: 

286 rows = [] 

287 for k in range(6): 

288 row = U_vv @ eps_ijk[:, :, k] @ U_vv.T - eps_ijk[:, :, k] 

289 rows.append(row.reshape((9,))) 

290 A_blocks.append(np.vstack(rows)) 

291 for c in range(3): 

292 if not pbc_c[c]: 

293 A_blocks.append(e(c, c).reshape((9,))) 

294 A = np.vstack(A_blocks) 

295 A = A @ eps_ijk.reshape((9, 6)) 

296 # Compute null space via SVD 

297 U, S, Vh = np.linalg.svd(A) 

298 tol = 1e-6 

299 null_mask = S < tol 

300 nullspace = Vh[null_mask] 

301 dM_zcc = [] 

302 dM_zvv = [] 

303 for B in nullspace: 

304 B = B @ eps_ijk.reshape((9, 6)).T 

305 dM_vv = B.reshape((3, 3)) 

306 dof = osymC_cv @ dM_vv @ osymC_cv.T 

307 dM_zcc.append(dof) 

308 dM_zvv.append(rot_vv @ dM_vv @ rot_vv.T) 

309 dM_zcc = np.array(dM_zcc).reshape((-1, 3, 3)) 

310 dM_zvv = np.array(dM_zvv).reshape((-1, 3, 3)) 

311 

312 # Do a QR decomposition, try to get more zeros to coordinates 

313 basis = np.array(dM_zcc).reshape((-1, 9)) 

314 Q, R = np.linalg.qr(basis) 

315 dM_zcc = (Q.T @ basis).reshape((-1, 3, 3)) 

316 

317 # Normalize tangent space vectors 

318 Cinv = np.linalg.inv(C_cv) 

319 for z in range(len(dM_zcc)): 

320 dC = chol_derivative(M_cc, dM_zcc[z]) @ rot_vv.T 

321 eps = 0.5 * (Cinv @ dC + dC.T @ Cinv.T) 

322 

323 dM_zcc[z] /= np.sum(np.abs(eps)) * np.linalg.det(C_cv) 

324 dM_zcc[z] *= 40 

325 # Define the direction of the tangent vector such that 

326 # it increases the volume. Sign cannot be used because of shear 

327 if np.trace(np.linalg.inv(C_cv) @ dC) < 0: 

328 dM_zcc[z] *= -1 

329 

330 pretty_dofs(dM_zcc, M_cc, rot_vv, osymC_cv, log=log) 

331 

332 # TODO: Move U_svv 

333 return M_cc, osymC_cv, dM_zcc, dM_zvv, rot_vv 

334 

335 

336@dataclass 

337class SymmetryAdaptedScaledCoordinates: 

338 dof_zac: np.ndarray 

339 s0_ac: np.ndarray 

340 

341 def get_scaled_coordinates(self, atoms_z: np.ndarray): 

342 return self.s0_ac + np.einsum('zac,z->ac', self.dof_zac, atoms_z) 

343 

344 @classmethod 

345 def build( 

346 cls, 

347 s_ac, 

348 rotation_scc, 

349 translation_sc, 

350 atommap_sa, 

351 symprec, 

352 C_cv, 

353 *, 

354 log, 

355 ): 

356 ns, na = atommap_sa.shape 

357 B_ascac = np.zeros((na, ns, 3, na, 3), int) 

358 for s, U_cc in enumerate(rotation_scc): 

359 for a in range(na): 

360 a2 = atommap_sa[s, a] 

361 B_ascac[a, s, :, a] = U_cc.T 

362 B_ascac[a, s, :, a2] -= np.eye(3, dtype=int) 

363 B_EA = B_ascac.reshape((na * ns * 3, na * 3)) 

364 # Extra translational gauge degrees of freedom 

365 B_A = np.zeros((na * 3, 3)) 

366 for a in range(na): 

367 B_A[(a * 3) : (a * 3 + 3), :] = np.eye(3) 

368 B_EA = np.vstack([B_EA, B_A.T]) 

369 # import sympy as sp 

370 

371 # nullspace = np.array(sp.Matrix(np.array(B_EA, 

372 # dtype=int)).nullspace(), dtype=float) 

373 

374 # Make sure the old svd code reproduces the same result 

375 U, S, Vh = np.linalg.svd(B_EA, False) 

376 tol = 1e-6 

377 null_mask = S < tol 

378 nullspace = Vh[null_mask] 

379 

380 # def same_rowspace(N, M, tol=1e-10): 

381 # A = np.vstack([N, M]) 

382 # rA = np.linalg.matrix_rank(A, tol) 

383 # rN = np.linalg.matrix_rank(N, tol) 

384 # rM = np.linalg.matrix_rank(M, tol) 

385 # return rA == rN == rM 

386 # assert same_rowspace(nullspace, nullspace2) 

387 

388 # Just make the printing prettyer for now 

389 nullspace = np.where(np.abs(nullspace) < 1e-10, 0, nullspace) 

390 

391 s0_ac = symmetrize_atoms( 

392 s_ac, 

393 rotation_scc, 

394 translation_sc, 

395 atommap_sa, 

396 ) 

397 

398 log(f'Atomic degrees of freedom: {len(nullspace)}') 

399 

400 dof_zac = nullspace.reshape((-1, na, 3)) 

401 

402 if len(dof_zac): 

403 dof_zav = np.einsum('zac,cv->zav', dof_zac, C_cv) 

404 # Normalize such that the distance in Cartesian real space 

405 # is reflected on the generalized coordinate 

406 dof_zac /= np.max(np.linalg.norm(dof_zav, axis=2), axis=1)[ 

407 :, None, None 

408 ] 

409 

410 sasc = SymmetryAdaptedScaledCoordinates(dof_zac, s0_ac) 

411 

412 return sasc 

413 

414 

415class SymmetryAdaptedAtoms(Optimizable): 

416 """Implementation of symmetry adapted atoms 

417 

418 Symmetry adapted atoms WILL symmetrize the actual_atoms given to init. 

419 

420 SymmetryAdaptedAtoms does not behave like Atoms object, but will expose the 

421 __ase_optimizable__ protocol, so it can be optimized with ASE. 

422 """ 

423 

424 def __init__( 

425 self, actual_atoms: Atoms, symmetries: AtomsSymmetries, log=print 

426 ): 

427 self.actual_atoms = actual_atoms 

428 self.symmetries = symmetries 

429 self.symmetry_force_violation = np.inf 

430 self.fmax = 0.01 

431 

432 pretty_subheader('Symmetry adapted cell coordinates', log) 

433 self.cell_coordinates: SymmeryAdaptedCellCoordinates = ( 

434 SymmeryAdaptedCellCoordinates.build( 

435 self.actual_atoms.cell, 

436 self.actual_atoms.pbc, 

437 self.symmetries.rotation_scc, 

438 log=log, 

439 ) 

440 ) 

441 

442 pretty_subheader('Symmetry adapted atomic coordinates', log) 

443 self.atom_coordinates = SymmetryAdaptedScaledCoordinates.build( 

444 self.actual_atoms.get_scaled_positions(), 

445 self.symmetries.rotation_scc, 

446 self.symmetries.translation_sc, 

447 self.symmetries.atommap_sa, 

448 self.symmetries.symprec, 

449 self.cell_coordinates.C_cv, 

450 log=log, 

451 ) 

452 pretty_atomic_dofs(actual_atoms, self.atom_coordinates.dof_zac, log=log) 

453 

454 assert isinstance( 

455 self.atom_coordinates, SymmetryAdaptedScaledCoordinates 

456 ) 

457 # s_ac = dof_zac s_z -> ds_ac/d_sz = dof_zac 

458 # dR_av / dsz = dR_av / d_sac ds_ac / ds_z 

459 # R_av = s_ac C_cv 

460 # 

461 # self.actual_atoms.set_cell(self.cell_coordinates.C_cv, 

462 # scale_atoms=True) 

463 # 

464 # self.actual_atoms.wrap() 

465 # self.actual_atoms.set_scaled_positions(self.S_ac) 

466 if 1: 

467 log('Skipping sanity checks for now') 

468 else: 

469 pass 

470 # new_positions = atoms.get_positions() 

471 # dR_av = new_positions - old_positions 

472 # s_ac = np.linalg.solve(self.C_cv, dR_av.T) 

473 # assert ( 

474 # np.max(np.abs(new_positions.flatten() - 

475 # old_positions.flatten())) 

476 # < symprec 

477 # ) 

478 

479 self._ndofs_cell = len(self.cell_coordinates.dM_zcc) 

480 self._ndofs_atoms = len(self.atom_coordinates.dof_zac) 

481 self._ndofs = self._ndofs_cell + self._ndofs_atoms 

482 

483 self.value_z = np.zeros((self._ndofs)) 

484 # !!! This actually symmetrizes actual atoms 

485 self.set_x(self.value_z) 

486 self.actual_atoms.wrap() 

487 

488 @classmethod 

489 def from_atoms(cls, atoms, log=print, *, symprec, symmorphic): 

490 symmetries = AtomsSymmetries.from_GPAW( 

491 atoms, 

492 tolerance=symprec, 

493 symmorphic=symmorphic, 

494 log=log, 

495 ) 

496 return cls(atoms, symmetries, log=log) 

497 

498 @property 

499 def stress_conv(self): 

500 S_vv = self.actual_atoms.get_stress(voigt=False) 

501 C_cv = self.actual_atoms.cell 

502 S_cc = C_cv @ S_vv @ np.linalg.inv(C_cv) 

503 for c, periodic in enumerate(self.actual_atoms.pbc): 

504 if periodic: 

505 continue 

506 S_cc[c, :] = 0.0 

507 S_cc[:, c] = 0.0 

508 S_vv = np.linalg.inv(C_cv) @ S_cc @ C_cv 

509 return np.max(np.max(np.linalg.norm(S_vv))) 

510 

511 # Properties for internal degrees of freedom 

512 @property 

513 def cell_z(self): 

514 return self.get_x()[: self._ndofs_cell] 

515 

516 # From here on out, these are the __ase_optimizable__ interface 

517 def ndofs(self): 

518 return self._ndofs 

519 

520 def get_x(self): 

521 return self.value_z.copy() 

522 

523 def set_x(self, x): 

524 self.value_z[:] = x 

525 self.actual_atoms.set_cell(self.cell_coordinates.get_cell(self.cell_z)) 

526 

527 self.actual_atoms.set_scaled_positions( 

528 self.atom_coordinates.get_scaled_coordinates(self.atoms_z) 

529 ) 

530 

531 def get_gradient(self): 

532 grad_z = np.zeros(self._ndofs_cell) 

533 S_vv = self.actual_atoms.get_stress(voigt=False) 

534 C_cv = self.cell_coordinates.get_cell(self.cell_z) 

535 V = np.linalg.det(C_cv) 

536 Cinv = np.linalg.inv(C_cv) 

537 

538 M_cc = self.cell_coordinates.get_M_cc(self.cell_z) 

539 

540 # TODO: Move to SymmetryAdaptedCellCoordinates 

541 # dE/deps_vv deps_vv/dC_cv dC_cv/dz 

542 

543 ncellz = len(self.cell_coordinates.dM_zcc) 

544 for z in range(ncellz): 

545 dC_cv = ( 

546 chol_derivative(M_cc, self.cell_coordinates.dM_zcc[z]) 

547 @ self.cell_coordinates.rot_vv.T 

548 ) 

549 grad_z[z] = V * np.sum(S_vv * (Cinv @ dC_cv + dC_cv.T @ Cinv.T) / 2) 

550 

551 F_av = self.actual_atoms.get_forces() 

552 # dE/ds_z = dE/dR_av dR_av/ds_ac ds_ac/ds_z 

553 # R_av = ds_ac C_cv 

554 # ds_ac = self.dof_zac S_z 

555 atoms_grad_z = -np.einsum( 

556 'av,cv,zac->z', F_av, C_cv, self.atom_coordinates.dof_zac 

557 ) 

558 

559 natomz = len(self.atom_coordinates.dof_zac) 

560 # For sanity check, we want to project the atomic gradient back 

561 # minimizing the Cartesian metrix. 

562 if natomz > 0: 

563 dof_zX = np.einsum( 

564 'cv,zac->zav', C_cv, self.atom_coordinates.dof_zac 

565 ).reshape((natomz, -1)) 

566 back_Fav = -( 

567 dof_zX.T @ np.linalg.inv(dof_zX @ dof_zX.T) @ atoms_grad_z 

568 ).reshape(F_av.shape) 

569 else: 

570 # Even if there is degrees of freedom, it is possible to 

571 # get symmetry violation 

572 back_Fav = np.zeros_like(F_av) 

573 

574 dF_av = F_av - back_Fav 

575 dF = np.max(np.linalg.norm(dF_av, axis=1)) 

576 self.symmetry_force_violation = dF 

577 self.back_Fav = back_Fav 

578 

579 if dF > self.fmax / 20: 

580 # Should probably be logged somehow instead of being a warning 

581 # as such. This may happen if the code's forces are noisy. 

582 import warnings 

583 

584 warning_chunks = [ 

585 'Warning!!! Back projection of symmetry adapted' 

586 f' forces to Cartesian space failed by {dF:7.13f}\n' 

587 'atom Obtained force Back projected force' 

588 ] 

589 

590 for a, (F_v, F2_v) in enumerate(zip(F_av, back_Fav)): 

591 warning_chunks.append( 

592 f'{a:5d} {F_v[0]:7.4f} {F_v[1]:7.4f} {F_v[2]:7.4f}' 

593 f' {F2_v[0]:7.4f} {F2_v[1]:7.4f} {F2_v[2]:7.4f}' 

594 ) 

595 

596 warnings.warn('\n'.join(warning_chunks)) 

597 

598 return np.hstack([grad_z, atoms_grad_z]) 

599 

600 def gradient_norm(self, grad_z): 

601 # Go actually to cell metric 

602 return np.max(np.abs(grad_z)) 

603 

604 def get_value(self): 

605 return self.actual_atoms.get_potential_energy() 

606 

607 def iterimages(self): 

608 return [self.actual_atoms] 

609 

610 def converged(self, gradient, fmax): 

611 # Convergence needs to be from the back projected forces. 

612 # The symmetry violating forces will never converge. 

613 Fconv = np.max(np.linalg.norm(self.back_Fav, axis=1)) 

614 return Fconv < self.fmax and self.stress_conv < self.smax 

615 

616 @property 

617 def atoms_z(self): 

618 return self.get_x()[self._ndofs_cell :] 

619 

620 

621class Relax: 

622 """General utility class to log and perform symmetry adapted relax""" 

623 

624 def __init__( 

625 self, 

626 symmorphic=False, 

627 logfile=None, 

628 teelog=True, 

629 *, 

630 atoms: Atoms, 

631 calc, 

632 optimizer_factory, 

633 symprec, 

634 comm, 

635 ): 

636 self.comm = comm 

637 self.logfile = logfile 

638 self.logf = None 

639 if self.logfile: 

640 if self.comm.rank == 0: 

641 self.logf = open(self.logfile, 'w') 

642 self.teelog = teelog 

643 

644 if atoms.calc is not None: 

645 raise ValueError('Do not attach a calculator to Atoms yet.') 

646 

647 self.symprec = symprec 

648 

649 pretty_header('Symmetry adapted Cell and Atomic Relaxation', self.log) 

650 pretty_subheader('Original atoms', self.log) 

651 self.original_atoms = atoms.copy() 

652 pprint_atoms(self.original_atoms, self.log) 

653 

654 self.atoms = atoms 

655 self.symmetry_adapted_atoms = SymmetryAdaptedAtoms.from_atoms( 

656 self.atoms, log=self.log, symmorphic=False, symprec=symprec 

657 ) 

658 

659 # Now, with cell and atoms symmetrized, 

660 # it is safe to assign the calculator 

661 # TODO: Implement Setter or something 

662 self.calc = calc 

663 self.symmetry_adapted_atoms.actual_atoms.calc = calc() 

664 

665 pretty_subheader('Symmetrized atoms', self.log) 

666 pprint_atoms(self.symmetry_adapted_atoms.actual_atoms, self.log) 

667 

668 self.optimizer_factory = optimizer_factory 

669 self.optimizer = self.optimizer_factory(self.symmetry_adapted_atoms) 

670 

671 def log(self, *args, **kwargs): 

672 if self.comm.rank == 0: 

673 if self.logf: 

674 print(*args, **kwargs, flush=True, file=self.logf) 

675 if self.teelog: 

676 print(*args, **kwargs) 

677 

678 def run(self, *, fmax=0.01, smax=0.0001, steps=20): 

679 # Why would symmetry adapted atoms care about smax and fmax 

680 # But it needs to be ase optimizable, so it needs to do that 

681 self.symmetry_adapted_atoms.smax = smax 

682 self.symmetry_adapted_atoms.fmax = fmax 

683 

684 self.smax = smax 

685 self.fmax = fmax 

686 self.maxiter = steps 

687 i = 0 

688 dtitles = ' '.join( 

689 [ 

690 f'q{i:02d}' 

691 for i in range(len(self.symmetry_adapted_atoms.atoms_z)) 

692 ] 

693 ) 

694 self.log( 

695 f'iter time E maxF maxS maxG a1 a2' 

696 f' a3 L1 L2 L3 {dtitles} log_10 viol.' 

697 ) 

698 for _ in self.optimizer.irun(fmax=fmax): 

699 import time 

700 

701 T = time.localtime() 

702 tstr = '%02d:%02d:%02d' % (T[3], T[4], T[5]) 

703 E = self.symmetry_adapted_atoms.actual_atoms.get_potential_energy() 

704 F = self.symmetry_adapted_atoms.back_Fav # get_forces() 

705 g = self.symmetry_adapted_atoms.get_gradient() 

706 Fmax = np.max(np.linalg.norm(F, axis=1)) 

707 sFmax = f'{Fmax:7.3f}' 

708 if Fmax < self.fmax: 

709 sFmax = green(sFmax) 

710 

711 Smax = self.symmetry_adapted_atoms.stress_conv 

712 sSmax = f'{Smax:7.4f}' 

713 if Smax < self.smax: 

714 sSmax = green(sSmax) 

715 

716 gmax = np.max(np.abs(g)) 

717 

718 cell = self.symmetry_adapted_atoms.actual_atoms.cell 

719 a = cell.angles() 

720 l = cell.lengths() 

721 cell = f'{a[0]:5.1f} {a[1]:5.1f} {a[2]:5.1f} ' 

722 cell += f'{l[0]:7.3f} {l[1]:7.3f} {l[2]:7.3f}' 

723 

724 dofs = '' 

725 for Z in self.symmetry_adapted_atoms.atoms_z: 

726 dofs += f' {Z:6.3f}' 

727 syviol = np.log10( 

728 self.symmetry_adapted_atoms.symmetry_force_violation 

729 ) 

730 symviol = f'{syviol:4.1f}' 

731 if syviol < self.fmax: 

732 symviol = green(symviol) 

733 self.log( 

734 f'{i:5d} {tstr} {E:9.5f} {sFmax} {sSmax} {gmax:7.3f}' 

735 f' {cell}{dofs} {symviol}' 

736 ) 

737 i += 1 

738 if i > self.maxiter or i > 40: 

739 self.log(f'Not converged in {self.maxiter} or 40 steps.') 

740 return False 

741 

742 return True 

743 

744 def visualize_modes(self): 

745 from ase.io.trajectory import Trajectory 

746 

747 traj = Trajectory('modes.traj', 'w') 

748 for z in range(self.ndofs()): 

749 x = np.zeros((self.ndofs(),)) 

750 for i in np.arange(0, 6 * np.pi, 0.1): 

751 x[z] = np.sin(i) * 0.004 

752 self.set_x(x) 

753 traj.write(self.atoms.copy())