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

146 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-23 12:50 +0000

1import os 

2import shutil 

3import warnings 

4import tempfile 

5from collections.abc import Iterable 

6from typing import Any, List, Tuple, Union 

7 

8import numpy as np 

9from ase import Atoms 

10from ase.calculators.calculator import FileIOCalculator, OldShellProfile, all_changes 

11from ase.io import read as ase_read 

12from ase.units import GPa 

13 

14from calorine.nep.model import _get_nep_contents 

15from ..env import calorine_getenv 

16from ..gpumd import write_xyz 

17 

18 

19class GPUMDShellProfile(OldShellProfile): 

20 """This class provides an ASE calculator for NEP calculations with 

21 GPUMD. 

22 

23 Parameters 

24 ---------- 

25 command : str 

26 Command to run GPUMD with. 

27 Default: ``gpumd``, or the value of the ``CALORINE_GPUMD_COMMAND`` 

28 environment variable if set. 

29 gpu_identifier_index : int, None 

30 Index that identifies the GPU that GPUNEP should be run with. 

31 Typically, NVIDIA GPUs are enumerated with integer indices. 

32 See https://docs.nvidia.com/cuda/cuda-c-programming-guide/index.html#env-vars. 

33 Set to None in order to use all available GPUs. Note that GPUMD exit with an error 

34 when running with more than one GPU if your system is not large enough. 

35 Default: None 

36 """ 

37 def __init__(self, command : str, gpu_identifier_index: Union[int, None]): 

38 if gpu_identifier_index is not None: 

39 # Do not set a specific device to use = use all available GPUs 

40 self.cuda_environment_variables = f'CUDA_VISIBLE_DEVICES={gpu_identifier_index}' 

41 command_with_gpus = f'export {self.cuda_environment_variables} && ' + command 

42 else: 

43 command_with_gpus = command 

44 super().__init__(command_with_gpus) 

45 

46 

47class GPUNEP(FileIOCalculator): 

48 """This class provides an ASE calculator for NEP calculations with 

49 GPUMD. 

50 

51 This calculator writes files that are input to the `gpumd` 

52 executable. It is thus likely to be slow if many calculations 

53 are to be performed. 

54 

55 Parameters 

56 ---------- 

57 model_filename : str 

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

59 directory : str 

60 Directory to run GPUMD in. If None, a temporary directory 

61 will be created and removed once the calculations are finished. 

62 If specified, the directory will not be deleted. In the latter 

63 case, it is advisable to do no more than one calculation with 

64 this calculator (unless you know exactly what you are doing). 

65 label : str 

66 Label for this calculator. 

67 atoms : Atoms 

68 Atoms to attach to this calculator. 

69 command : str 

70 Command to run GPUMD with. 

71 Default: ``gpumd``, or the value of the ``CALORINE_GPUMD_COMMAND`` 

72 environment variable if set. 

73 gpu_identifier_index : int 

74 Index that identifies the GPU that GPUNEP should be run with. 

75 Typically, NVIDIA GPUs are enumerated with integer indices. 

76 See https://docs.nvidia.com/cuda/cuda-c-programming-guide/index.html#env-vars. 

77 Set to None in order to use all available GPUs. Note that GPUMD exit with an error 

78 when running with more than one GPU if your system is not large enough. 

79 Default: 0 

80 

81 

82 Example 

83 ------- 

84 

85 >>> calc = GPUNEP('nep.txt') 

86 >>> atoms.calc = calc 

87 >>> atoms.get_potential_energy() 

88 """ 

89 

90 # Shadows the `command` property inherited from FileIOCalculator, which 

91 # otherwise routes `self.command = ...` through `self.profile` before 

92 # `self.profile` has been set up (see __init__ below). 

93 command = 'gpumd' 

94 base_implemented_properties = ['energy', 'forces', 'stress'] 

95 discard_results_on_any_change = True 

96 

97 # We use list of tuples to define parameters for 

98 # MD simulations. Looks like a dictionary, but sometimes 

99 # we want to repeat the same keyword. 

100 base_single_point_parameters = [('dump_thermo', 1), 

101 ('dump_force', 1), 

102 ('dump_position', 1), 

103 ('velocity', 1e-24), 

104 ('time_step', 1e-6), # 1 zeptosecond 

105 ('ensemble', 'nve'), 

106 ('run', 1)] 

107 

108 def __init__(self, 

109 model_filename: str, 

110 directory: str = None, 

111 label: str = 'GPUNEP', 

112 atoms: Atoms = None, 

113 command: str = None, 

114 gpu_identifier_index: Union[int, None] = 0 

115 ): 

116 if command is None: 

117 command = calorine_getenv('GPUMD_COMMAND') 

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

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

120 self.model_filename = str(model_filename) 

121 

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

123 header, _ = _get_nep_contents(self.model_filename) 

124 self.model_type = header['model_type'] 

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

126 self.nep_version = header['version'] 

127 self.model_filename = model_filename 

128 

129 self.implemented_properties = list(self.base_implemented_properties) 

130 self.single_point_parameters = self.base_single_point_parameters 

131 if 'charge' in self.model_type: 

132 # Only available for charge models 

133 self.implemented_properties.extend( 

134 ['charges', 'born_effective_charges']) 

135 qnep_parameters = [('dump_xyz', (-1, 1, 1, 'charges_and_bec.xyz', 'charge', 'bec'))] 

136 self.single_point_parameters = qnep_parameters + self.base_single_point_parameters 

137 

138 # Determine run command 

139 # Determine whether to save stdout or not 

140 if directory is None and '>' not in command: 

141 # No need to save stdout if we run in temporary directory 

142 command += ' > /dev/null' 

143 elif '>' not in command: 

144 command += ' > stdout' 

145 self.command = command 

146 

147 # Determine directory to run in 

148 self._use_temporary_directory = directory is None 

149 self._directory = directory 

150 if self._use_temporary_directory: 

151 self._make_new_tmp_directory() 

152 else: 

153 self._potential_path = os.path.relpath( 

154 os.path.abspath(self.model_filename), self._directory) 

155 

156 # Override the profile in ~/.config/ase/config.ini. 

157 # See https://docs.ase-lib.org/ase/calculators/calculators.html#calculator-configuration 

158 profile = GPUMDShellProfile(command, gpu_identifier_index) 

159 FileIOCalculator.__init__(self, 

160 directory=self._directory, 

161 label=label, 

162 atoms=atoms, 

163 profile=profile) 

164 

165 def run_custom_md( 

166 self, 

167 parameters: List[Tuple[str, Any]], 

168 return_last_atoms: bool = False, 

169 only_prepare: bool = False, 

170 ): 

171 """ 

172 Run a custom MD simulation. 

173 

174 Parameters 

175 ---------- 

176 parameters 

177 Parameters to be specified in the run.in file. 

178 The potential keyword is set automatically, all other 

179 keywords need to be set via this argument. 

180 Example:: 

181 

182 [('dump_thermo', 100), 

183 ('dump_position', 1000), 

184 ('velocity', 300), 

185 ('time_step', 1), 

186 ('ensemble', ['nvt_ber', 300, 300, 100]), 

187 ('run', 10000)] 

188 

189 return_last_atoms 

190 If ``True`` the last saved snapshot will be returned. 

191 only_prepare 

192 If ``True`` the necessary input files will be written 

193 but the MD run will not be executed. 

194 

195 Returns 

196 ------- 

197 The last snapshot if :attr:`return_last_atoms` is ``True``. 

198 """ 

199 if self._use_temporary_directory: 

200 self._make_new_tmp_directory() 

201 

202 if self._use_temporary_directory and not return_last_atoms: 

203 raise ValueError('Refusing to run in temporary directory ' 

204 'and not returning atoms; all results will be gone.') 

205 

206 if self._use_temporary_directory and only_prepare: 

207 raise ValueError('Refusing to only prepare in temporary directory, ' 

208 'all files will be removed.') 

209 

210 # Write files and run 

211 FileIOCalculator.write_input(self, self.atoms) 

212 self._write_runfile(parameters) 

213 write_xyz(filename=os.path.join(self._directory, 'model.xyz'), 

214 structure=self.atoms) 

215 

216 if only_prepare: 

217 return None 

218 

219 # Execute the calculation. 

220 self.execute() 

221 

222 # Extract last snapshot if needed 

223 if return_last_atoms: 

224 last_atoms = ase_read(os.path.join(self._directory, 'movie.xyz'), 

225 format='extxyz', index=-1) 

226 

227 if self._use_temporary_directory: 

228 self._clean() 

229 

230 if return_last_atoms: 

231 return last_atoms 

232 else: 

233 return None 

234 

235 def write_input(self, atoms, properties=None, system_changes=None): 

236 """ 

237 Write the input files necessary for a single-point calculation. 

238 """ 

239 if self._use_temporary_directory: 

240 self._make_new_tmp_directory() 

241 FileIOCalculator.write_input(self, atoms, properties, system_changes) 

242 self._write_runfile(parameters=self.single_point_parameters) 

243 write_xyz(filename=os.path.join(self._directory, 'model.xyz'), 

244 structure=atoms) 

245 

246 def _write_runfile(self, parameters): 

247 """Write run.in file to define input parameters for MD simulation. 

248 

249 Parameters 

250 ---------- 

251 parameters : dict 

252 Defines all key-value pairs used in run.in file 

253 (see GPUMD documentation for a complete list). 

254 Values can be either floats, integers, or lists/tuples. 

255 """ 

256 if len(os.listdir(self._directory)) > 0: 

257 warnings.warn(f'{self._directory} is not empty.') 

258 

259 with open(os.path.join(self._directory, 'run.in'), 'w') as f: 

260 # Custom potential is allowed but normally it can be deduced 

261 if 'potential' not in [keyval[0] for keyval in parameters]: 

262 f.write(f'potential {self._potential_path} \n') 

263 # Write all keywords with parameter(s) 

264 for key, val in parameters: 

265 f.write(f'{key} ') 

266 if isinstance(val, Iterable) and not isinstance(val, str): 

267 for v in val: 

268 f.write(f'{v} ') 

269 else: 

270 f.write(f'{val}') 

271 f.write('\n') 

272 

273 def get_potential_energy_and_stresses_from_file(self): 

274 """ 

275 Extract potential energy (third column of last line in thermo.out) and stresses 

276 from thermo.out 

277 """ 

278 data = np.loadtxt(os.path.join(self._directory, 'thermo.out')) 

279 if len(data.shape) == 1: 

280 line = data 

281 else: 

282 line = data[-1, :] 

283 

284 # Energy 

285 energy = line[2] 

286 

287 # Stress. GPUMD's src/measure/dump_thermo.cu (and dump_observer.cu) 

288 # explicitly re-permutes its internal thermo array (xx,yy,zz,xy,xz,yz) 

289 # into true ASE-Voigt order (xx,yy,zz,yz,xz,xy) before writing 

290 # thermo.out, so these columns need no further permutation here -- 

291 # unlike the `nep` executable's virial_*.out/stress_*.out training 

292 # output, which uses a different native order. Independently 

293 # confirmed empirically (ad hoc, not a standing test) via isolated 

294 # shear strains applied to a GPUNEP-attached structure, each 

295 # producing a stress response concentrated at the expected 

296 # component. 

297 stress = [v for v in line[3:9]] 

298 stress = -GPa * np.array(stress) # to eV/A^3 

299 

300 if np.any(np.isnan(stress)) or np.isnan(energy): 

301 raise ValueError(f'Failed to extract energy and/or stresses:\n {line}') 

302 return energy, stress 

303 

304 def _read_potential_energy_and_stresses(self): 

305 """Reads potential energy and stresses.""" 

306 self.results['energy'], self.results['stress'] = \ 

307 self.get_potential_energy_and_stresses_from_file() 

308 

309 def get_forces_from_file(self): 

310 """ 

311 Extract forces (in eV/A) from last snapshot in force.out 

312 """ 

313 data = np.loadtxt(os.path.join(self._directory, 'force.out')) 

314 return data[-len(self.atoms):, :] 

315 

316 def _read_forces(self): 

317 """Reads forces (the last snapshot in force.out) in eV/A""" 

318 self.results['forces'] = self.get_forces_from_file() 

319 

320 def get_charges_and_becs_from_file(self): 

321 """Extract charges and Born Effective Charges from last 

322 snapshot in `charges_and_bec.xyz`""" 

323 structure = ase_read(os.path.join(self._directory, 'charges_and_bec.xyz'), '-1') 

324 charges = structure.get_charges() 

325 # Raw, row-major full-3x3 per atom (xx,xy,xz,yx,yy,yz,zx,zy,zz); BEC 

326 # is not symmetric, so no reduced-6 form applies. GPUMD's 

327 # src/measure/dump_xyz.cu writes `bec` with no reindexing, straight 

328 # from src/force/nep_charge.cu's row-major per-atom buffer -- the 

329 # same convention CPUNEP uses for its own BEC. 

330 becs = structure.get_array('bec') 

331 return charges, becs 

332 

333 def _read_charges_and_becs(self): 

334 """Reads charges and Born Effective Charges from file.""" 

335 charges, becs = self.get_charges_and_becs_from_file() 

336 self.results['charges'] = charges 

337 self.results['born_effective_charges'] = becs 

338 

339 def read_results(self): 

340 """ 

341 Read results from last step of MD calculation. 

342 """ 

343 self._read_potential_energy_and_stresses() 

344 self._read_forces() 

345 

346 if 'charge' in self.model_type: 

347 self._read_charges_and_becs() 

348 if self._use_temporary_directory: 

349 self._clean() 

350 

351 def _clean(self): 

352 """ 

353 Remove directory with calculations. 

354 """ 

355 shutil.rmtree(self._directory) 

356 

357 def _make_new_tmp_directory(self): 

358 """ 

359 Create a new temporary directory. 

360 """ 

361 # We do not need to create a new temporary directory 

362 # if the current one is empty 

363 if self._directory is None or \ 

364 (os.path.isdir(self._directory) and len(os.listdir(self._directory)) > 0): 

365 self._directory = tempfile.mkdtemp() 

366 self._potential_path = os.path.relpath(os.path.abspath(self.model_filename), 

367 self._directory) 

368 

369 def set_atoms(self, atoms): 

370 """ 

371 Set Atoms object. 

372 Used also when attaching calculator to Atoms object. 

373 """ 

374 self.atoms = atoms 

375 self.results = {} 

376 

377 def set_directory(self, directory): 

378 """ 

379 Set path to a new directory. This makes it possible to run 

380 several calculations with the same calculator while saving 

381 all results 

382 """ 

383 self._directory = directory 

384 self._use_temporary_directory = False 

385 self._potential_path = os.path.relpath(os.path.abspath(self.model_filename), 

386 self._directory) 

387 

388 def get_born_effective_charges( 

389 self, 

390 atoms: Atoms = None, 

391 properties: List[str] = None, 

392 system_changes: List[str] = all_changes, 

393 ) -> np.ndarray: 

394 """Calculates (if needed) and returns the Born effective charges. 

395 Note that this requires a qNEP model. 

396 

397 Parameters 

398 ---------- 

399 atoms 

400 System for which to calculate properties, by default `None`. 

401 properties 

402 Properties to calculate, by default `None`. 

403 system_changes 

404 Changes to the system since last call, by default all_changes. 

405 """ 

406 if 'born_effective_charges' not in self.implemented_properties: 

407 raise ValueError( 

408 'This model does not support the calculation of Born effective charges.') 

409 self.calculate(atoms, properties, system_changes) 

410 return self.results['born_effective_charges']