Coverage for ase / calculators / fd.py: 100.00%

70 statements  

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

1"""Module for `FiniteDifferenceCalculator`.""" 

2 

3from collections.abc import Iterable 

4from functools import partial 

5 

6import numpy as np 

7 

8from ase import Atoms 

9from ase.calculators.calculator import BaseCalculator, all_properties 

10 

11 

12class FiniteDifferenceCalculator(BaseCalculator): 

13 """Wrapper calculator using the finite-difference method. 

14 

15 The forces and the stress are computed using the finite-difference method. 

16 

17 .. versionadded:: 3.24.0 

18 """ 

19 

20 implemented_properties = all_properties 

21 

22 def __init__( 

23 self, 

24 calc: BaseCalculator, 

25 eps_disp: float | None = 1e-6, 

26 eps_strain: float | None = 1e-6, 

27 *, 

28 force_consistent: bool = True, 

29 ) -> None: 

30 """ 

31 

32 Parameters 

33 ---------- 

34 calc : :class:`~ase.calculators.calculator.BaseCalculator` 

35 ASE Calculator object to be wrapped. 

36 eps_disp : Optional[float], default 1e-6 

37 Displacement used for computing forces. 

38 If :py:obj:`None`, analytical forces are computed. 

39 eps_strain : Optional[float], default 1e-6 

40 Strain used for computing stress. 

41 If :py:obj:`None`, analytical stress is computed. 

42 force_consistent : bool, default :py:obj:`True` 

43 If :py:obj:`True`, the energies consistent with the forces are used 

44 for finite-difference calculations. 

45 

46 """ 

47 super().__init__() 

48 self.calc = calc 

49 self.eps_disp = eps_disp 

50 self.eps_strain = eps_strain 

51 self.force_consistent = force_consistent 

52 

53 def calculate(self, atoms: Atoms, properties, system_changes) -> None: 

54 atoms = atoms.copy() # copy to not mess up original `atoms` 

55 atoms.calc = self.calc 

56 self.results = {} 

57 self.results['energy'] = self.calc.get_potential_energy(atoms) 

58 for key in ['free_energy']: 

59 if key in self.calc.results: 

60 self.results[key] = self.calc.results[key] 

61 if self.eps_disp is None: 

62 self.results['forces'] = self.calc.get_forces(atoms) 

63 else: 

64 self.results['forces'] = calculate_numerical_forces( 

65 atoms, 

66 eps=self.eps_disp, 

67 force_consistent=self.force_consistent, 

68 ) 

69 if self.eps_strain is None: 

70 self.results['stress'] = self.calc.get_stress(atoms) 

71 else: 

72 self.results['stress'] = calculate_numerical_stress( 

73 atoms, 

74 eps=self.eps_strain, 

75 force_consistent=self.force_consistent, 

76 ) 

77 

78 

79def _numeric_force( 

80 atoms: Atoms, 

81 iatom: int, 

82 icart: int, 

83 eps: float = 1e-6, 

84 *, 

85 force_consistent: bool = False, 

86) -> float: 

87 """Calculate numerical force on a specific atom along a specific direction. 

88 

89 Parameters 

90 ---------- 

91 atoms : :class:`~ase.Atoms` 

92 ASE :class:`~ase.Atoms` object. 

93 iatom : int 

94 Index of atoms. 

95 icart : {0, 1, 2} 

96 Index of Cartesian component. 

97 eps : float, default 1e-6 

98 Displacement. 

99 force_consistent : bool, default :py:obj:`False` 

100 If :py:obj:`True`, the energies consistent with the forces are used for 

101 finite-difference calculations. 

102 

103 """ 

104 p0 = atoms.get_positions() 

105 p = p0.copy() 

106 p[iatom, icart] = p0[iatom, icart] + eps 

107 atoms.set_positions(p, apply_constraint=False) 

108 eplus = atoms.get_potential_energy(force_consistent=force_consistent) 

109 p[iatom, icart] = p0[iatom, icart] - eps 

110 atoms.set_positions(p, apply_constraint=False) 

111 eminus = atoms.get_potential_energy(force_consistent=force_consistent) 

112 atoms.set_positions(p0, apply_constraint=False) 

113 return (eminus - eplus) / (2 * eps) 

114 

115 

116def calculate_numerical_forces( 

117 atoms: Atoms, 

118 eps: float = 1e-6, 

119 iatoms: Iterable[int] | None = None, 

120 icarts: Iterable[int] | None = None, 

121 *, 

122 force_consistent: bool = False, 

123) -> np.ndarray: 

124 """Calculate forces numerically based on the finite-difference method. 

125 

126 Parameters 

127 ---------- 

128 atoms : :class:`~ase.Atoms` 

129 ASE :class:`~ase.Atoms` object. 

130 eps : float, default 1e-6 

131 Displacement. 

132 iatoms : Optional[Iterable[int]] 

133 Indices of atoms for which forces are computed. 

134 By default, all atoms are considered. 

135 icarts : Optional[Iterable[int]] 

136 Indices of Cartesian coordinates for which forces are computed. 

137 By default, all three coordinates are considered. 

138 force_consistent : bool, default :py:obj:`False` 

139 If :py:obj:`True`, the energies consistent with the forces are used for 

140 finite-difference calculations. 

141 

142 Returns 

143 ------- 

144 forces : np.ndarray 

145 Forces computed numerically based on the finite-difference method. 

146 

147 """ 

148 if iatoms is None: 

149 iatoms = range(len(atoms)) 

150 if icarts is None: 

151 icarts = [0, 1, 2] 

152 f = partial(_numeric_force, eps=eps, force_consistent=force_consistent) 

153 forces = [[f(atoms, iatom, icart) for icart in icarts] for iatom in iatoms] 

154 return np.array(forces) 

155 

156 

157def calculate_numerical_stress( 

158 atoms: Atoms, 

159 eps: float = 1e-6, 

160 voigt: bool = True, 

161 *, 

162 force_consistent: bool = True, 

163) -> np.ndarray: 

164 """Calculate stress numerically based on the finite-difference method. 

165 

166 Parameters 

167 ---------- 

168 atoms : :class:`~ase.Atoms` 

169 ASE :class:`~ase.Atoms` object. 

170 eps : float, default 1e-6 

171 Strain in the Voigt notation. 

172 voigt : bool, default :py:obj:`True` 

173 If :py:obj:`True`, the stress is returned in the Voigt notation. 

174 force_consistent : bool, default :py:obj:`True` 

175 If :py:obj:`True`, the energies consistent with the forces are used for 

176 finite-difference calculations. 

177 

178 Returns 

179 ------- 

180 stress : np.ndarray 

181 Stress computed numerically based on the finite-difference method. 

182 

183 """ 

184 stress = np.zeros((3, 3), dtype=float) 

185 

186 cell = atoms.cell.copy() 

187 volume = atoms.get_volume() 

188 for i in range(3): 

189 x = np.eye(3) 

190 x[i, i] = 1.0 + eps 

191 atoms.set_cell(cell @ x, scale_atoms=True) 

192 eplus = atoms.get_potential_energy(force_consistent=force_consistent) 

193 

194 x[i, i] = 1.0 - eps 

195 atoms.set_cell(cell @ x, scale_atoms=True) 

196 eminus = atoms.get_potential_energy(force_consistent=force_consistent) 

197 

198 stress[i, i] = (eplus - eminus) / (2 * eps * volume) 

199 x[i, i] = 1.0 

200 

201 j = i - 2 

202 x[i, j] = x[j, i] = +0.5 * eps 

203 atoms.set_cell(cell @ x, scale_atoms=True) 

204 eplus = atoms.get_potential_energy(force_consistent=force_consistent) 

205 

206 x[i, j] = x[j, i] = -0.5 * eps 

207 atoms.set_cell(cell @ x, scale_atoms=True) 

208 eminus = atoms.get_potential_energy(force_consistent=force_consistent) 

209 

210 stress[i, j] = stress[j, i] = (eplus - eminus) / (2 * eps * volume) 

211 

212 atoms.set_cell(cell, scale_atoms=True) 

213 

214 return stress.flat[[0, 4, 8, 5, 2, 1]] if voigt else stress