Coverage for calorine/calculators/cpunep.py: 100%

114 statements  

« prev     ^ index     » next       coverage.py v7.2.5, created at 2024-07-26 09:34 +0000

1from __future__ import annotations 

2 

3import contextlib 

4import os 

5from tempfile import TemporaryFile 

6from typing import List 

7 

8import numpy as np 

9from ase import Atoms 

10from ase.calculators.calculator import Calculator, all_changes 

11from ase.stress import full_3x3_to_voigt_6_stress 

12 

13import _nepy 

14from calorine.nep.model import _get_nep_contents 

15 

16 

17class CPUNEP(Calculator): 

18 """This class provides an ASE calculator for `nep_cpu`, 

19 the in-memory CPU implementation of GPUMD. 

20 

21 Parameters 

22 ---------- 

23 model_filename : str 

24 Path to file in ``nep.txt`` format with model parameters 

25 atoms : Atoms 

26 Atoms to attach the calculator to 

27 label : str 

28 Label for this calclator 

29 debug : bool, optional 

30 Flag to toggle debug mode. Prints GPUMD output. Defaults to False. 

31 

32 Raises 

33 ------ 

34 FileNotFoundError 

35 Raises :class:`FileNotFoundError` if :attr:`model_filename` does not point to a valid file. 

36 ValueError 

37 Raises :class:`ValueError` atoms are not defined when trying to get energies and forces. 

38 Example 

39 ------- 

40 

41 >>> calc = CPUNEP('nep.txt') 

42 >>> atoms.calc = calc 

43 >>> atoms.get_potential_energy() 

44 """ 

45 

46 implemented_properties = [ 

47 'energy', 

48 'energies', 

49 'forces', 

50 'stress', 

51 ] 

52 debug = False 

53 nepy = None 

54 

55 def __init__( 

56 self, 

57 model_filename: str, 

58 atoms: Atoms | None = None, 

59 label: str | None = None, 

60 debug: bool = False, 

61 ): 

62 self.debug = debug 

63 

64 if not os.path.exists(model_filename): 

65 raise FileNotFoundError(f'{model_filename} does not exist.') 

66 self.model_filename = str(model_filename) 

67 

68 # Get model type from first row in nep.txt 

69 header, _ = _get_nep_contents(self.model_filename) 

70 self.model_type = header['model_type'] 

71 self.supported_species = set(header['types']) 

72 self.nep_version = header['version'] 

73 

74 if self.model_type == 'dipole': 

75 # Only available for dipole models 

76 self.implemented_properties = ['dipole'] 

77 

78 # Initialize atoms, results and nepy - note that this is also done in Calculator.__init__() 

79 if atoms is not None: 

80 self.set_atoms(atoms) 

81 parameters = {'model_filename': model_filename} 

82 Calculator.__init__(self, label=label, atoms=atoms, **parameters) 

83 if atoms is not None: 

84 self._setup_nepy() 

85 

86 def __str__(self) -> str: 

87 def indent(s: str, i: int) -> str: 

88 s = '\n'.join([i * ' ' + line for line in s.split('\n')]) 

89 return s 

90 

91 parameters = '\n'.join( 

92 [f'{key}: {value}' for key, value in self.parameters.items()] 

93 ) 

94 parameters = indent(parameters, 4) 

95 using_debug = '\nIn debug mode' if self.debug else '' 

96 

97 s = f'{self.__class__.__name__}\n{parameters}{using_debug}' 

98 return s 

99 

100 def _setup_nepy(self): 

101 """ 

102 Creates an instance of the NEPY class and attaches it to the calculator object. 

103 The output from `nep.cpp` is only written to STDOUT if debug == True 

104 """ 

105 if self.atoms is None: 

106 raise ValueError('Atoms must be defined to get energies and forces.') 

107 if self.atoms.cell.rank == 0: 

108 raise ValueError('Atoms must have a defined cell.') 

109 

110 natoms = len(self.atoms) 

111 self.natoms = natoms 

112 c = self.atoms.get_cell(complete=True).flatten() 

113 cell = [c[0], c[3], c[6], c[1], c[4], c[7], c[2], c[5], c[8]] 

114 symbols = self.atoms.get_chemical_symbols() 

115 positions = list( 

116 self.atoms.get_positions().T.flatten() 

117 ) # [x1, ..., xN, y1, ... yN,...] 

118 masses = self.atoms.get_masses() 

119 

120 # Disable output from C++ code by default 

121 if self.debug: 

122 self.nepy = _nepy.NEPY( 

123 self.model_filename, self.natoms, cell, symbols, positions, masses 

124 ) 

125 else: 

126 with TemporaryFile('w') as f: 

127 with contextlib.redirect_stdout(f): 

128 self.nepy = _nepy.NEPY( 

129 self.model_filename, 

130 self.natoms, 

131 cell, 

132 symbols, 

133 positions, 

134 masses, 

135 ) 

136 

137 def set_atoms(self, atoms: Atoms): 

138 """Updates the Atoms object. 

139 

140 Parameters 

141 ---------- 

142 atoms : Atoms 

143 Atoms to attach the calculator to 

144 """ 

145 species_in_atoms_object = set(np.unique(atoms.get_chemical_symbols())) 

146 if not species_in_atoms_object.issubset(self.supported_species): 

147 raise ValueError('Structure contains species that are not supported by the NEP model.') 

148 self.atoms = atoms 

149 self.results = {} 

150 self.nepy = None 

151 

152 def _update_symbols(self): 

153 """Update atom symbols in NEPY.""" 

154 symbols = self.atoms.get_chemical_symbols() 

155 self.nepy.set_symbols(symbols) 

156 

157 def _update_masses(self): 

158 """Update atom masses in NEPY""" 

159 masses = self.atoms.get_masses() 

160 self.nepy.set_masses(masses) 

161 

162 def _update_cell(self): 

163 """Update cell parameters in NEPY.""" 

164 c = self.atoms.get_cell(complete=True).flatten() 

165 cell = [c[0], c[3], c[6], c[1], c[4], c[7], c[2], c[5], c[8]] 

166 self.nepy.set_cell(cell) 

167 

168 def _update_positions(self): 

169 """Update atom positions in NEPY.""" 

170 positions = list( 

171 self.atoms.get_positions().T.flatten() 

172 ) # [x1, ..., xN, y1, ... yN,...] 

173 self.nepy.set_positions(positions) 

174 

175 def calculate( 

176 self, 

177 atoms: Atoms = None, 

178 properties: List[str] = None, 

179 system_changes: List[str] = all_changes, 

180 ): 

181 """Calculate energy, per atom energies, forces, stress and dipole. 

182 

183 Parameters 

184 ---------- 

185 atoms : Atoms, optional 

186 System for which to calculate properties, by default None 

187 properties : List[str], optional 

188 Properties to calculate, by default None 

189 system_changes : List[str], optional 

190 Changes to the system since last call, by default all_changes 

191 """ 

192 if properties is None: 

193 properties = self.implemented_properties 

194 

195 Calculator.calculate(self, atoms, properties, system_changes) 

196 

197 if self.nepy is None: 

198 # Create new NEPY interface 

199 self._setup_nepy() 

200 # Update existing NEPY interface 

201 for change in system_changes: 

202 if change == 'positions': 

203 self._update_positions() 

204 elif change == 'numbers': 

205 self._update_symbols() 

206 self._update_masses() 

207 elif change == 'cell': 

208 self._update_cell() 

209 

210 if 'dipole' in self.implemented_properties: 

211 dipole = np.array(self.nepy.get_dipole()) 

212 self.results['dipole'] = dipole 

213 else: 

214 energies, forces, virials = self.nepy.get_potential_forces_and_virials() 

215 energies_per_atom = np.array(energies) 

216 energy = energies_per_atom.sum() 

217 forces_per_atom = np.array(forces).reshape(-1, self.natoms).T 

218 virials_per_atom = np.array(virials).reshape(-1, self.natoms).T 

219 stress = -(np.sum(virials_per_atom, axis=0) / self.atoms.get_volume()).reshape((3, 3)) 

220 stress = full_3x3_to_voigt_6_stress(stress) 

221 

222 self.results['energy'] = energy 

223 self.results['forces'] = forces_per_atom 

224 self.results['stress'] = stress 

225 

226 def get_dipole_gradient( 

227 self, 

228 displacement: float = 0.01, 

229 method: str = 'central difference', 

230 charge: float = 1.0, 

231 ): 

232 """Calculates the dipole gradient using finite differences. 

233 

234 Parameters 

235 ---------- 

236 structure 

237 Input structure 

238 model_filename 

239 Path to NEP model. Defaults to ``None``. 

240 method 

241 Method for computing gradient with finite differences. 

242 One of 'forward difference' and 'central difference'. 

243 Defaults to 'central difference' 

244 displacement 

245 Displacement in Å to use for finite differences. Defaults to 0.01 Å. 

246 charge 

247 System charge in units of the elemental charge. 

248 Used for correcting the dipoles before computing the gradient. 

249 Defaults to 1.0. 

250 

251 Returns 

252 ------- dipole gradient with shape `(N, 3, 3)` 

253 """ 

254 if 'dipole' not in self.implemented_properties: 

255 raise ValueError('Dipole gradients are only defined for dipole NEP models.') 

256 

257 if displacement <= 0: 

258 raise ValueError('displacement must be > 0 Å') 

259 

260 implemented_methods = { 

261 'forward difference': 0, 

262 'central difference': 1, 

263 'second order central difference': 2, 

264 } 

265 

266 if method not in implemented_methods.keys(): 

267 raise ValueError(f'Invalid method {method} for calculating gradient') 

268 

269 if self.nepy is None: 

270 # Create new NEPY interface 

271 self._setup_nepy() 

272 

273 dipole_gradient = np.array( 

274 self.nepy.get_dipole_gradient( 

275 displacement, implemented_methods[method], charge 

276 ) 

277 ).reshape(self.natoms, 3, 3) 

278 return dipole_gradient