Coverage for /builds/ase/ase/ase/filters.py: 86.38%

301 statements  

« prev     ^ index     » next       coverage.py v7.5.3, created at 2025-08-02 00:12 +0000

1# fmt: off 

2 

3"""Filters""" 

4from functools import cached_property 

5from itertools import product 

6from warnings import warn 

7 

8import numpy as np 

9 

10from ase.calculators.calculator import PropertyNotImplementedError 

11from ase.stress import full_3x3_to_voigt_6_stress, voigt_6_to_full_3x3_stress 

12from ase.utils import deprecated 

13from ase.utils.abc import Optimizable 

14 

15__all__ = [ 

16 'Filter', 'StrainFilter', 'UnitCellFilter', 'FrechetCellFilter', 

17 'ExpCellFilter' 

18] 

19 

20 

21class OptimizableFilter(Optimizable): 

22 def __init__(self, filterobj): 

23 self.filterobj = filterobj 

24 

25 def get_x(self): 

26 return self.filterobj.get_positions().ravel() 

27 

28 def set_x(self, x): 

29 self.filterobj.set_positions(x.reshape(-1, 3)) 

30 

31 def get_gradient(self): 

32 return self.filterobj.get_forces().ravel() 

33 

34 @cached_property 

35 def _use_force_consistent_energy(self): 

36 # This boolean is in principle invalidated if the 

37 # calculator changes. This can lead to weird things 

38 # in multi-step optimizations. 

39 try: 

40 self.filterobj.get_potential_energy(force_consistent=True) 

41 except PropertyNotImplementedError: 

42 return False 

43 else: 

44 return True 

45 

46 def get_value(self): 

47 force_consistent = self._use_force_consistent_energy 

48 return self.filterobj.get_potential_energy( 

49 force_consistent=force_consistent) 

50 

51 def ndofs(self): 

52 return 3 * len(self.filterobj) 

53 

54 def iterimages(self): 

55 return self.filterobj.iterimages() 

56 

57 

58class Filter: 

59 """Subset filter class.""" 

60 

61 def __init__(self, atoms, indices=None, mask=None): 

62 """Filter atoms. 

63 

64 This filter can be used to hide degrees of freedom in an Atoms 

65 object. 

66 

67 Parameters 

68 ---------- 

69 indices : list of int 

70 Indices for those atoms that should remain visible. 

71 mask : list of bool 

72 One boolean per atom indicating if the atom should remain 

73 visible or not. 

74 

75 If a Trajectory tries to save this object, it will instead 

76 save the underlying Atoms object. To prevent this, override 

77 the iterimages method. 

78 """ 

79 

80 self.atoms = atoms 

81 self.constraints = [] 

82 # Make self.info a reference to the underlying atoms' info dictionary. 

83 self.info = self.atoms.info 

84 

85 if indices is None and mask is None: 

86 raise ValueError('Use "indices" or "mask".') 

87 if indices is not None and mask is not None: 

88 raise ValueError('Use only one of "indices" and "mask".') 

89 

90 if mask is not None: 

91 self.index = np.asarray(mask, bool) 

92 self.n = self.index.sum() 

93 else: 

94 self.index = np.asarray(indices, int) 

95 self.n = len(self.index) 

96 

97 def iterimages(self): 

98 # Present the real atoms object to Trajectory and friends 

99 return self.atoms.iterimages() 

100 

101 def get_cell(self): 

102 """Returns the computational cell. 

103 

104 The computational cell is the same as for the original system. 

105 """ 

106 return self.atoms.get_cell() 

107 

108 def get_pbc(self): 

109 """Returns the periodic boundary conditions. 

110 

111 The boundary conditions are the same as for the original system. 

112 """ 

113 return self.atoms.get_pbc() 

114 

115 def get_positions(self): 

116 'Return the positions of the visible atoms.' 

117 return self.atoms.get_positions()[self.index] 

118 

119 def set_positions(self, positions, **kwargs): 

120 'Set the positions of the visible atoms.' 

121 pos = self.atoms.get_positions() 

122 pos[self.index] = positions 

123 self.atoms.set_positions(pos, **kwargs) 

124 

125 positions = property(get_positions, set_positions, 

126 doc='Positions of the atoms') 

127 

128 def get_momenta(self): 

129 'Return the momenta of the visible atoms.' 

130 return self.atoms.get_momenta()[self.index] 

131 

132 def set_momenta(self, momenta, **kwargs): 

133 'Set the momenta of the visible atoms.' 

134 mom = self.atoms.get_momenta() 

135 mom[self.index] = momenta 

136 self.atoms.set_momenta(mom, **kwargs) 

137 

138 def get_atomic_numbers(self): 

139 'Return the atomic numbers of the visible atoms.' 

140 return self.atoms.get_atomic_numbers()[self.index] 

141 

142 def set_atomic_numbers(self, atomic_numbers): 

143 'Set the atomic numbers of the visible atoms.' 

144 z = self.atoms.get_atomic_numbers() 

145 z[self.index] = atomic_numbers 

146 self.atoms.set_atomic_numbers(z) 

147 

148 def get_tags(self): 

149 'Return the tags of the visible atoms.' 

150 return self.atoms.get_tags()[self.index] 

151 

152 def set_tags(self, tags): 

153 'Set the tags of the visible atoms.' 

154 tg = self.atoms.get_tags() 

155 tg[self.index] = tags 

156 self.atoms.set_tags(tg) 

157 

158 def get_forces(self, *args, **kwargs): 

159 return self.atoms.get_forces(*args, **kwargs)[self.index] 

160 

161 def get_stress(self, *args, **kwargs): 

162 return self.atoms.get_stress(*args, **kwargs) 

163 

164 def get_stresses(self, *args, **kwargs): 

165 return self.atoms.get_stresses(*args, **kwargs)[self.index] 

166 

167 def get_masses(self): 

168 return self.atoms.get_masses()[self.index] 

169 

170 def get_potential_energy(self, **kwargs): 

171 """Calculate potential energy. 

172 

173 Returns the potential energy of the full system. 

174 """ 

175 return self.atoms.get_potential_energy(**kwargs) 

176 

177 def get_chemical_symbols(self): 

178 return self.atoms.get_chemical_symbols() 

179 

180 def get_initial_magnetic_moments(self): 

181 return self.atoms.get_initial_magnetic_moments() 

182 

183 def get_calculator(self): 

184 """Returns the calculator. 

185 

186 WARNING: The calculator is unaware of this filter, and sees a 

187 different number of atoms. 

188 """ 

189 return self.atoms.calc 

190 

191 @property 

192 def calc(self): 

193 return self.atoms.calc 

194 

195 def get_celldisp(self): 

196 return self.atoms.get_celldisp() 

197 

198 def has(self, name): 

199 'Check for existence of array.' 

200 return self.atoms.has(name) 

201 

202 def __len__(self): 

203 'Return the number of movable atoms.' 

204 return self.n 

205 

206 def __getitem__(self, i): 

207 'Return an atom.' 

208 return self.atoms[self.index[i]] 

209 

210 def __ase_optimizable__(self): 

211 return OptimizableFilter(self) 

212 

213 

214class StrainFilter(Filter): 

215 """Modify the supercell while keeping the scaled positions fixed. 

216 

217 Presents the strain of the supercell as the generalized positions, 

218 and the global stress tensor (times the volume) as the generalized 

219 force. 

220 

221 This filter can be used to relax the unit cell until the stress is 

222 zero. If MDMin is used for this, the timestep (dt) to be used 

223 depends on the system size. 0.01/x where x is a typical dimension 

224 seems like a good choice. 

225 

226 The stress and strain are presented as 6-vectors, the order of the 

227 components follow the standard engingeering practice: xx, yy, zz, 

228 yz, xz, xy. 

229 

230 """ 

231 

232 def __init__(self, atoms, mask=None, include_ideal_gas=False): 

233 """Create a filter applying a homogeneous strain to a list of atoms. 

234 

235 The first argument, atoms, is the atoms object. 

236 

237 The optional second argument, mask, is a list of six booleans, 

238 indicating which of the six independent components of the 

239 strain that are allowed to become non-zero. It defaults to 

240 [1,1,1,1,1,1]. 

241 

242 """ 

243 

244 self.strain = np.zeros(6) 

245 self.include_ideal_gas = include_ideal_gas 

246 

247 if mask is None: 

248 mask = np.ones(6) 

249 else: 

250 mask = np.array(mask) 

251 

252 Filter.__init__(self, atoms=atoms, mask=mask) 

253 self.mask = mask 

254 self.origcell = atoms.get_cell() 

255 

256 def get_positions(self): 

257 return self.strain.reshape((2, 3)).copy() 

258 

259 def set_positions(self, new): 

260 new = new.ravel() * self.mask 

261 eps = np.array([[1.0 + new[0], 0.5 * new[5], 0.5 * new[4]], 

262 [0.5 * new[5], 1.0 + new[1], 0.5 * new[3]], 

263 [0.5 * new[4], 0.5 * new[3], 1.0 + new[2]]]) 

264 

265 self.atoms.set_cell(np.dot(self.origcell, eps), scale_atoms=True) 

266 self.strain[:] = new 

267 

268 def get_forces(self, **kwargs): 

269 stress = self.atoms.get_stress(include_ideal_gas=self.include_ideal_gas) 

270 return -self.atoms.get_volume() * (stress * self.mask).reshape((2, 3)) 

271 

272 def has(self, x): 

273 return self.atoms.has(x) 

274 

275 def __len__(self): 

276 return 2 

277 

278 

279class UnitCellFilter(Filter): 

280 """Modify the supercell and the atom positions. """ 

281 

282 def __init__(self, atoms, mask=None, 

283 cell_factor=None, 

284 hydrostatic_strain=False, 

285 constant_volume=False, 

286 orig_cell=None, 

287 scalar_pressure=0.0): 

288 """Create a filter that returns the atomic forces and unit cell 

289 stresses together, so they can simultaneously be minimized. 

290 

291 The first argument, atoms, is the atoms object. The optional second 

292 argument, mask, is a list of booleans, indicating which of the six 

293 independent components of the strain are relaxed. 

294 

295 - True = relax to zero 

296 - False = fixed, ignore this component 

297 

298 Degrees of freedom are the positions in the original undeformed cell, 

299 plus the deformation tensor (extra 3 "atoms"). This gives forces 

300 consistent with numerical derivatives of the potential energy 

301 with respect to the cell degreees of freedom. 

302 

303 For full details see: 

304 E. B. Tadmor, G. S. Smith, N. Bernstein, and E. Kaxiras, 

305 Phys. Rev. B 59, 235 (1999) 

306 

307 You can still use constraints on the atoms, e.g. FixAtoms, to control 

308 the relaxation of the atoms. 

309 

310 >>> # this should be equivalent to the StrainFilter 

311 >>> atoms = Atoms(...) 

312 >>> atoms.set_constraint(FixAtoms(mask=[True for atom in atoms])) 

313 >>> ucf = UnitCellFilter(atoms) 

314 

315 You should not attach this UnitCellFilter object to a 

316 trajectory. Instead, create a trajectory for the atoms, and 

317 attach it to an optimizer like this: 

318 

319 >>> atoms = Atoms(...) 

320 >>> ucf = UnitCellFilter(atoms) 

321 >>> qn = QuasiNewton(ucf) 

322 >>> traj = Trajectory('TiO2.traj', 'w', atoms) 

323 >>> qn.attach(traj) 

324 >>> qn.run(fmax=0.05) 

325 

326 Helpful conversion table: 

327 

328 - 0.05 eV/A^3 = 8 GPA 

329 - 0.003 eV/A^3 = 0.48 GPa 

330 - 0.0006 eV/A^3 = 0.096 GPa 

331 - 0.0003 eV/A^3 = 0.048 GPa 

332 - 0.0001 eV/A^3 = 0.02 GPa 

333 

334 Additional optional arguments: 

335 

336 cell_factor: float (default float(len(atoms))) 

337 Factor by which deformation gradient is multiplied to put 

338 it on the same scale as the positions when assembling 

339 the combined position/cell vector. The stress contribution to 

340 the forces is scaled down by the same factor. This can be thought 

341 of as a very simple preconditioners. Default is number of atoms 

342 which gives approximately the correct scaling. 

343 

344 hydrostatic_strain: bool (default False) 

345 Constrain the cell by only allowing hydrostatic deformation. 

346 The virial tensor is replaced by np.diag([np.trace(virial)]*3). 

347 

348 constant_volume: bool (default False) 

349 Project out the diagonal elements of the virial tensor to allow 

350 relaxations at constant volume, e.g. for mapping out an 

351 energy-volume curve. Note: this only approximately conserves 

352 the volume and breaks energy/force consistency so can only be 

353 used with optimizers that do require do a line minimisation 

354 (e.g. FIRE). 

355 

356 scalar_pressure: float (default 0.0) 

357 Applied pressure to use for enthalpy pV term. As above, this 

358 breaks energy/force consistency. 

359 """ 

360 

361 Filter.__init__(self, atoms=atoms, indices=range(len(atoms))) 

362 self.atoms = atoms 

363 if orig_cell is None: 

364 self.orig_cell = atoms.get_cell() 

365 else: 

366 self.orig_cell = orig_cell 

367 self.stress = None 

368 

369 if mask is None: 

370 mask = np.ones(6) 

371 mask = np.asarray(mask) 

372 if mask.shape == (6,): 

373 self.mask = voigt_6_to_full_3x3_stress(mask) 

374 elif mask.shape == (3, 3): 

375 self.mask = mask 

376 else: 

377 raise ValueError('shape of mask should be (3,3) or (6,)') 

378 

379 if cell_factor is None: 

380 cell_factor = float(len(atoms)) 

381 self.hydrostatic_strain = hydrostatic_strain 

382 self.constant_volume = constant_volume 

383 self.scalar_pressure = scalar_pressure 

384 self.cell_factor = cell_factor 

385 self.copy = self.atoms.copy 

386 self.arrays = self.atoms.arrays 

387 

388 def deform_grad(self): 

389 return np.linalg.solve(self.orig_cell, self.atoms.cell).T 

390 

391 def get_positions(self): 

392 """ 

393 this returns an array with shape (natoms + 3,3). 

394 

395 the first natoms rows are the positions of the atoms, the last 

396 three rows are the deformation tensor associated with the unit cell, 

397 scaled by self.cell_factor. 

398 """ 

399 

400 cur_deform_grad = self.deform_grad() 

401 natoms = len(self.atoms) 

402 pos = np.zeros((natoms + 3, 3)) 

403 # UnitCellFilter's positions are the self.atoms.positions but without 

404 # the applied deformation gradient 

405 pos[:natoms] = np.linalg.solve(cur_deform_grad, 

406 self.atoms.positions.T).T 

407 # UnitCellFilter's cell DOFs are the deformation gradient times a 

408 # scaling factor 

409 pos[natoms:] = self.cell_factor * cur_deform_grad 

410 return pos 

411 

412 def set_positions(self, new, **kwargs): 

413 """ 

414 new is an array with shape (natoms+3,3). 

415 

416 the first natoms rows are the positions of the atoms, the last 

417 three rows are the deformation tensor used to change the cell shape. 

418 

419 the new cell is first set from original cell transformed by the new 

420 deformation gradient, then the positions are set with respect to the 

421 current cell by transforming them with the same deformation gradient 

422 """ 

423 

424 natoms = len(self.atoms) 

425 new_atom_positions = new[:natoms] 

426 new_deform_grad = new[natoms:] / self.cell_factor 

427 deform = (new_deform_grad - np.eye(3)).T * self.mask 

428 # Set the new cell from the original cell and the new 

429 # deformation gradient. Both current and final structures should 

430 # preserve symmetry, so if set_cell() calls FixSymmetry.adjust_cell(), 

431 # it should be OK 

432 newcell = self.orig_cell @ (np.eye(3) + deform) 

433 

434 self.atoms.set_cell(newcell, 

435 scale_atoms=True) 

436 # Set the positions from the ones passed in (which are without the 

437 # deformation gradient applied) and the new deformation gradient. 

438 # This should also preserve symmetry, so if set_positions() calls 

439 # FixSymmetyr.adjust_positions(), it should be OK 

440 self.atoms.set_positions(new_atom_positions @ (np.eye(3) + deform), 

441 **kwargs) 

442 

443 def get_potential_energy(self, force_consistent=True): 

444 """ 

445 returns potential energy including enthalpy PV term. 

446 """ 

447 atoms_energy = self.atoms.get_potential_energy( 

448 force_consistent=force_consistent) 

449 return atoms_energy + self.scalar_pressure * self.atoms.get_volume() 

450 

451 def get_forces(self, **kwargs): 

452 """ 

453 returns an array with shape (natoms+3,3) of the atomic forces 

454 and unit cell stresses. 

455 

456 the first natoms rows are the forces on the atoms, the last 

457 three rows are the forces on the unit cell, which are 

458 computed from the stress tensor. 

459 """ 

460 

461 stress = self.atoms.get_stress(**kwargs) 

462 atoms_forces = self.atoms.get_forces(**kwargs) 

463 

464 volume = self.atoms.get_volume() 

465 virial = -volume * (voigt_6_to_full_3x3_stress(stress) + 

466 np.diag([self.scalar_pressure] * 3)) 

467 cur_deform_grad = self.deform_grad() 

468 atoms_forces = atoms_forces @ cur_deform_grad 

469 virial = np.linalg.solve(cur_deform_grad, virial.T).T 

470 

471 if self.hydrostatic_strain: 

472 vtr = virial.trace() 

473 virial = np.diag([vtr / 3.0, vtr / 3.0, vtr / 3.0]) 

474 

475 # Zero out components corresponding to fixed lattice elements 

476 if (self.mask != 1.0).any(): 

477 virial *= self.mask 

478 

479 if self.constant_volume: 

480 vtr = virial.trace() 

481 np.fill_diagonal(virial, np.diag(virial) - vtr / 3.0) 

482 

483 natoms = len(self.atoms) 

484 forces = np.zeros((natoms + 3, 3)) 

485 forces[:natoms] = atoms_forces 

486 forces[natoms:] = virial / self.cell_factor 

487 

488 self.stress = -full_3x3_to_voigt_6_stress(virial) / volume 

489 return forces 

490 

491 def get_stress(self): 

492 raise PropertyNotImplementedError 

493 

494 def has(self, x): 

495 return self.atoms.has(x) 

496 

497 def __len__(self): 

498 return (len(self.atoms) + 3) 

499 

500 

501class FrechetCellFilter(UnitCellFilter): 

502 """Modify the supercell and the atom positions.""" 

503 

504 def __init__(self, atoms, mask=None, 

505 exp_cell_factor=None, 

506 hydrostatic_strain=False, 

507 constant_volume=False, 

508 scalar_pressure=0.0): 

509 r"""Create a filter that returns the atomic forces and unit cell 

510 stresses together, so they can simultaneously be minimized. 

511 

512 The first argument, atoms, is the atoms object. The optional second 

513 argument, mask, is a list of booleans, indicating which of the six 

514 independent components of the strain are relaxed. 

515 

516 - True = relax to zero 

517 - False = fixed, ignore this component 

518 

519 Degrees of freedom are the positions in the original undeformed cell, 

520 plus the log of the deformation tensor (extra 3 "atoms"). This gives 

521 forces consistent with numerical derivatives of the potential energy 

522 with respect to the cell degrees of freedom. 

523 

524 You can still use constraints on the atoms, e.g. FixAtoms, to control 

525 the relaxation of the atoms. 

526 

527 >>> # this should be equivalent to the StrainFilter 

528 >>> atoms = Atoms(...) 

529 >>> atoms.set_constraint(FixAtoms(mask=[True for atom in atoms])) 

530 >>> ecf = FrechetCellFilter(atoms) 

531 

532 You should not attach this FrechetCellFilter object to a 

533 trajectory. Instead, create a trajectory for the atoms, and 

534 attach it to an optimizer like this: 

535 

536 >>> atoms = Atoms(...) 

537 >>> ecf = FrechetCellFilter(atoms) 

538 >>> qn = QuasiNewton(ecf) 

539 >>> traj = Trajectory('TiO2.traj', 'w', atoms) 

540 >>> qn.attach(traj) 

541 >>> qn.run(fmax=0.05) 

542 

543 Helpful conversion table: 

544 

545 - 0.05 eV/A^3 = 8 GPA 

546 - 0.003 eV/A^3 = 0.48 GPa 

547 - 0.0006 eV/A^3 = 0.096 GPa 

548 - 0.0003 eV/A^3 = 0.048 GPa 

549 - 0.0001 eV/A^3 = 0.02 GPa 

550 

551 Additional optional arguments: 

552 

553 exp_cell_factor: float (default float(len(atoms))) 

554 Scaling factor for cell variables. The cell gradients in 

555 FrechetCellFilter.get_forces() is divided by exp_cell_factor. 

556 By default, set the number of atoms. We recommend to set 

557 an extensive value for this parameter. 

558 

559 hydrostatic_strain: bool (default False) 

560 Constrain the cell by only allowing hydrostatic deformation. 

561 The virial tensor is replaced by np.diag([np.trace(virial)]*3). 

562 

563 constant_volume: bool (default False) 

564 Project out the diagonal elements of the virial tensor to allow 

565 relaxations at constant volume, e.g. for mapping out an 

566 energy-volume curve. 

567 

568 scalar_pressure: float (default 0.0) 

569 Applied pressure to use for enthalpy pV term. As above, this 

570 breaks energy/force consistency. 

571 

572 Implementation note: 

573 

574 The implementation is based on that of Christoph Ortner in JuLIP.jl: 

575 https://github.com/JuliaMolSim/JuLIP.jl/blob/master/src/expcell.jl 

576 

577 The initial implementation of ExpCellFilter gave inconsistent gradients 

578 for cell variables (matrix log of the deformation tensor). If you would 

579 like to keep the previous behavior, please use ExpCellFilter. 

580 

581 The derivation of gradients of energy w.r.t positions and the log of the 

582 deformation tensor is given in 

583 https://github.com/lan496/lan496.github.io/blob/main/notes/cell_grad.pdf 

584 """ 

585 

586 Filter.__init__(self, atoms=atoms, indices=range(len(atoms))) 

587 UnitCellFilter.__init__(self, atoms=atoms, mask=mask, 

588 hydrostatic_strain=hydrostatic_strain, 

589 constant_volume=constant_volume, 

590 scalar_pressure=scalar_pressure) 

591 

592 # We defer the scipy import to avoid high immediate import overhead 

593 from scipy.linalg import expm, expm_frechet, logm 

594 self.expm = expm 

595 self.logm = logm 

596 self.expm_frechet = expm_frechet 

597 

598 # Scaling factor for cell gradients 

599 if exp_cell_factor is None: 

600 exp_cell_factor = float(len(atoms)) 

601 self.exp_cell_factor = exp_cell_factor 

602 

603 def get_positions(self): 

604 pos = UnitCellFilter.get_positions(self) 

605 natoms = len(self.atoms) 

606 pos[natoms:] = self.logm(pos[natoms:]) * self.exp_cell_factor 

607 return pos 

608 

609 def set_positions(self, new, **kwargs): 

610 natoms = len(self.atoms) 

611 new2 = new.copy() 

612 new2[natoms:] = self.expm(new[natoms:] / self.exp_cell_factor) 

613 UnitCellFilter.set_positions(self, new2, **kwargs) 

614 

615 def get_forces(self, **kwargs): 

616 # forces on atoms are same as UnitCellFilter, we just 

617 # need to modify the stress contribution 

618 stress = self.atoms.get_stress(**kwargs) 

619 volume = self.atoms.get_volume() 

620 virial = -volume * (voigt_6_to_full_3x3_stress(stress) + 

621 np.diag([self.scalar_pressure] * 3)) 

622 

623 cur_deform_grad = self.deform_grad() 

624 cur_deform_grad_log = self.logm(cur_deform_grad) 

625 

626 if self.hydrostatic_strain: 

627 vtr = virial.trace() 

628 virial = np.diag([vtr / 3.0, vtr / 3.0, vtr / 3.0]) 

629 

630 # Zero out components corresponding to fixed lattice elements 

631 if (self.mask != 1.0).any(): 

632 virial *= self.mask 

633 

634 # Cell gradient for UnitCellFilter 

635 ucf_cell_grad = virial @ np.linalg.inv(cur_deform_grad.T) 

636 

637 # Cell gradient for FrechetCellFilter 

638 deform_grad_log_force = np.zeros((3, 3)) 

639 for mu, nu in product(range(3), repeat=2): 

640 dir = np.zeros((3, 3)) 

641 dir[mu, nu] = 1.0 

642 # Directional derivative of deformation to (mu, nu) strain direction 

643 expm_der = self.expm_frechet( 

644 cur_deform_grad_log, 

645 dir, 

646 compute_expm=False 

647 ) 

648 deform_grad_log_force[mu, nu] = np.sum(expm_der * ucf_cell_grad) 

649 

650 # Cauchy stress used for convergence testing 

651 convergence_crit_stress = -(virial / volume) 

652 if self.constant_volume: 

653 # apply constraint to force 

654 dglf_trace = deform_grad_log_force.trace() 

655 np.fill_diagonal(deform_grad_log_force, 

656 np.diag(deform_grad_log_force) - dglf_trace / 3.0) 

657 # apply constraint to Cauchy stress used for convergence testing 

658 ccs_trace = convergence_crit_stress.trace() 

659 np.fill_diagonal(convergence_crit_stress, 

660 np.diag(convergence_crit_stress) - ccs_trace / 3.0) 

661 

662 atoms_forces = self.atoms.get_forces(**kwargs) 

663 atoms_forces = atoms_forces @ cur_deform_grad 

664 

665 # pack gradients into vector 

666 natoms = len(self.atoms) 

667 forces = np.zeros((natoms + 3, 3)) 

668 forces[:natoms] = atoms_forces 

669 forces[natoms:] = deform_grad_log_force / self.exp_cell_factor 

670 self.stress = full_3x3_to_voigt_6_stress(convergence_crit_stress) 

671 return forces 

672 

673 

674class ExpCellFilter(UnitCellFilter): 

675 

676 @deprecated(DeprecationWarning( 

677 'Use FrechetCellFilter for better convergence w.r.t. cell variables.' 

678 )) 

679 def __init__(self, atoms, mask=None, 

680 cell_factor=None, 

681 hydrostatic_strain=False, 

682 constant_volume=False, 

683 scalar_pressure=0.0): 

684 r"""Create a filter that returns the atomic forces and unit cell 

685 stresses together, so they can simultaneously be minimized. 

686 

687 The first argument, atoms, is the atoms object. The optional second 

688 argument, mask, is a list of booleans, indicating which of the six 

689 independent components of the strain are relaxed. 

690 

691 - True = relax to zero 

692 - False = fixed, ignore this component 

693 

694 Degrees of freedom are the positions in the original undeformed 

695 cell, plus the log of the deformation tensor (extra 3 "atoms"). This 

696 gives forces consistent with numerical derivatives of the potential 

697 energy with respect to the cell degrees of freedom. 

698 

699 For full details see: 

700 E. B. Tadmor, G. S. Smith, N. Bernstein, and E. Kaxiras, 

701 Phys. Rev. B 59, 235 (1999) 

702 

703 You can still use constraints on the atoms, e.g. FixAtoms, to 

704 control the relaxation of the atoms. 

705 

706 >>> # this should be equivalent to the StrainFilter 

707 >>> atoms = Atoms(...) 

708 >>> atoms.set_constraint(FixAtoms(mask=[True for atom in atoms])) 

709 >>> ecf = ExpCellFilter(atoms) 

710 

711 You should not attach this ExpCellFilter object to a 

712 trajectory. Instead, create a trajectory for the atoms, and 

713 attach it to an optimizer like this: 

714 

715 >>> atoms = Atoms(...) 

716 >>> ecf = ExpCellFilter(atoms) 

717 >>> qn = QuasiNewton(ecf) 

718 >>> traj = Trajectory('TiO2.traj', 'w', atoms) 

719 >>> qn.attach(traj) 

720 >>> qn.run(fmax=0.05) 

721 

722 Helpful conversion table: 

723 

724 - 0.05 eV/A^3 = 8 GPA 

725 - 0.003 eV/A^3 = 0.48 GPa 

726 - 0.0006 eV/A^3 = 0.096 GPa 

727 - 0.0003 eV/A^3 = 0.048 GPa 

728 - 0.0001 eV/A^3 = 0.02 GPa 

729 

730 Additional optional arguments: 

731 

732 cell_factor: (DEPRECATED) 

733 Retained for backwards compatibility, but no longer used. 

734 

735 hydrostatic_strain: bool (default False) 

736 Constrain the cell by only allowing hydrostatic deformation. 

737 The virial tensor is replaced by np.diag([np.trace(virial)]*3). 

738 

739 constant_volume: bool (default False) 

740 Project out the diagonal elements of the virial tensor to allow 

741 relaxations at constant volume, e.g. for mapping out an 

742 energy-volume curve. 

743 

744 scalar_pressure: float (default 0.0) 

745 Applied pressure to use for enthalpy pV term. As above, this 

746 breaks energy/force consistency. 

747 

748 Implementation details: 

749 

750 The implementation is based on that of Christoph Ortner in JuLIP.jl: 

751 https://github.com/libAtoms/JuLIP.jl/blob/expcell/src/Constraints.jl#L244 

752 

753 We decompose the deformation gradient as 

754 

755 F = exp(U) F0 

756 x = F * F0^{-1} z = exp(U) z 

757 

758 If we write the energy as a function of U we can transform the 

759 stress associated with a perturbation V into a derivative using a 

760 linear map V -> L(U, V). 

761 

762 \phi( exp(U+tV) (z+tv) ) ~ \phi'(x) . (exp(U) v) + \phi'(x) . 

763 ( L(U, V) exp(-U) exp(U) z ) 

764 

765 where 

766 

767 \nabla E(U) : V = [S exp(-U)'] : L(U,V) 

768 = L'(U, S exp(-U)') : V 

769 = L(U', S exp(-U)') : V 

770 = L(U, S exp(-U)) : V (provided U = U') 

771 

772 where the : operator represents double contraction, 

773 i.e. A:B = trace(A'B), and 

774 

775 F = deformation tensor - 3x3 matrix 

776 F0 = reference deformation tensor - 3x3 matrix, np.eye(3) here 

777 U = cell degrees of freedom used here - 3x3 matrix 

778 V = perturbation to cell DoFs - 3x3 matrix 

779 v = perturbation to position DoFs 

780 x = atomic positions in deformed cell 

781 z = atomic positions in original cell 

782 \phi = potential energy 

783 S = stress tensor [3x3 matrix] 

784 L(U, V) = directional derivative of exp at U in direction V, i.e 

785 d/dt exp(U + t V)|_{t=0} = L(U, V) 

786 

787 This means we can write 

788 

789 d/dt E(U + t V)|_{t=0} = L(U, S exp (-U)) : V 

790 

791 and therefore the contribution to the gradient of the energy is 

792 

793 \nabla E(U) / \nabla U_ij = [L(U, S exp(-U))]_ij 

794 

795 .. deprecated:: 3.23.0 

796 Use :class:`~ase.filters.FrechetCellFilter` for better convergence 

797 w.r.t. cell variables. 

798 """ 

799 Filter.__init__(self, atoms=atoms, indices=range(len(atoms))) 

800 UnitCellFilter.__init__(self, atoms=atoms, mask=mask, 

801 cell_factor=cell_factor, 

802 hydrostatic_strain=hydrostatic_strain, 

803 constant_volume=constant_volume, 

804 scalar_pressure=scalar_pressure) 

805 if cell_factor is not None: 

806 # cell_factor used in UnitCellFilter does not affect on gradients of 

807 # ExpCellFilter. 

808 warn("cell_factor is deprecated") 

809 self.cell_factor = 1.0 

810 

811 # We defer the scipy import to avoid high immediate import overhead 

812 from scipy.linalg import expm, logm 

813 self.expm = expm 

814 self.logm = logm 

815 

816 def get_forces(self, **kwargs): 

817 forces = UnitCellFilter.get_forces(self, **kwargs) 

818 

819 # forces on atoms are same as UnitCellFilter, we just 

820 # need to modify the stress contribution 

821 stress = self.atoms.get_stress(**kwargs) 

822 volume = self.atoms.get_volume() 

823 virial = -volume * (voigt_6_to_full_3x3_stress(stress) + 

824 np.diag([self.scalar_pressure] * 3)) 

825 

826 cur_deform_grad = self.deform_grad() 

827 cur_deform_grad_log = self.logm(cur_deform_grad) 

828 

829 if self.hydrostatic_strain: 

830 vtr = virial.trace() 

831 virial = np.diag([vtr / 3.0, vtr / 3.0, vtr / 3.0]) 

832 

833 # Zero out components corresponding to fixed lattice elements 

834 if (self.mask != 1.0).any(): 

835 virial *= self.mask 

836 

837 deform_grad_log_force_naive = virial.copy() 

838 Y = np.zeros((6, 6)) 

839 Y[0:3, 0:3] = cur_deform_grad_log 

840 Y[3:6, 3:6] = cur_deform_grad_log 

841 Y[0:3, 3:6] = - virial @ self.expm(-cur_deform_grad_log) 

842 deform_grad_log_force = -self.expm(Y)[0:3, 3:6] 

843 for (i1, i2) in [(0, 1), (0, 2), (1, 2)]: 

844 ff = 0.5 * (deform_grad_log_force[i1, i2] + 

845 deform_grad_log_force[i2, i1]) 

846 deform_grad_log_force[i1, i2] = ff 

847 deform_grad_log_force[i2, i1] = ff 

848 

849 # check for reasonable alignment between naive and 

850 # exact search directions 

851 all_are_equal = np.all(np.isclose(deform_grad_log_force, 

852 deform_grad_log_force_naive)) 

853 if all_are_equal or \ 

854 (np.sum(deform_grad_log_force * deform_grad_log_force_naive) / 

855 np.sqrt(np.sum(deform_grad_log_force**2) * 

856 np.sum(deform_grad_log_force_naive**2)) > 0.8): 

857 deform_grad_log_force = deform_grad_log_force_naive 

858 

859 # Cauchy stress used for convergence testing 

860 convergence_crit_stress = -(virial / volume) 

861 if self.constant_volume: 

862 # apply constraint to force 

863 dglf_trace = deform_grad_log_force.trace() 

864 np.fill_diagonal(deform_grad_log_force, 

865 np.diag(deform_grad_log_force) - dglf_trace / 3.0) 

866 # apply constraint to Cauchy stress used for convergence testing 

867 ccs_trace = convergence_crit_stress.trace() 

868 np.fill_diagonal(convergence_crit_stress, 

869 np.diag(convergence_crit_stress) - ccs_trace / 3.0) 

870 

871 # pack gradients into vector 

872 natoms = len(self.atoms) 

873 forces[natoms:] = deform_grad_log_force 

874 self.stress = full_3x3_to_voigt_6_stress(convergence_crit_stress) 

875 return forces