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

168 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-14 16:36 +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 (CalculationFailed, FileIOCalculator, OldShellProfile, 

11 all_changes) 

12from ase.io import read as ase_read 

13from ase.units import GPa 

14 

15from calorine.nep.model import _get_nep_contents 

16from ..env import calorine_getenv 

17from ..gpumd import write_xyz 

18 

19 

20# Files that a previous run in the same directory leaves behind: the input files 

21# GPUNEP writes itself, the result files it reads back, and the redirected stdout. 

22# Any file ending in `.out` is counted as well, since that covers the output of 

23# every GPUMD keyword without having to enumerate them. 

24_PREVIOUS_RUN_FILES = ('run.in', 'model.xyz', 'movie.xyz', 'charges_and_bec.xyz', 'stdout') 

25 

26# Where the standard error of GPUMD is kept. It is deliberately not part of _PREVIOUS_RUN_FILES, 

27# since the shell creates it on every run and an empty one says nothing about an earlier 

28# calculation. 

29_STDERR_FILE = 'stderr' 

30 

31 

32def _find_previous_run_files(directory: str) -> List[str]: 

33 """Return the names of the files in :attr:`directory` that indicate an 

34 earlier calculation was run there. 

35 

36 The presence of such files, rather than a non-empty directory, is what 

37 signals that results may be read back from an earlier calculation by 

38 mistake. A directory holding only unrelated files, such as the ``nep.txt`` 

39 the model is loaded from, is not reported. 

40 

41 Parameters 

42 ---------- 

43 directory 

44 Directory to inspect. 

45 

46 Returns 

47 ------- 

48 Sorted names of the files that indicate an earlier calculation, 

49 empty if there are none. 

50 

51 Example 

52 ------- 

53 >>> _find_previous_run_files('some_directory_with_a_thermo_out_file') 

54 ['thermo.out'] 

55 """ 

56 return sorted(filename for filename in os.listdir(directory) 

57 if filename in _PREVIOUS_RUN_FILES or filename.endswith('.out')) 

58 

59 

60class GPUMDShellProfile(OldShellProfile): 

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

62 GPUMD. 

63 

64 Parameters 

65 ---------- 

66 command : str 

67 Command to run GPUMD with. 

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

69 environment variable if set. 

70 gpu_identifier_index : int, None 

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

72 Typically, NVIDIA GPUs are enumerated with integer indices. 

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

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

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

76 Default: None 

77 """ 

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

79 if gpu_identifier_index is not None: 

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

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

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

83 else: 

84 command_with_gpus = command 

85 super().__init__(command_with_gpus) 

86 

87 

88class GPUNEP(FileIOCalculator): 

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

90 GPUMD. 

91 

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

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

94 are to be performed. 

95 

96 Parameters 

97 ---------- 

98 model_filename : str 

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

100 directory : str 

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

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

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

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

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

106 label : str 

107 Label for this calculator. 

108 atoms : Atoms 

109 Atoms to attach to this calculator. 

110 command : str 

111 Command to run GPUMD with. 

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

113 environment variable if set. 

114 gpu_identifier_index : int 

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

116 Typically, NVIDIA GPUs are enumerated with integer indices. 

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

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

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

120 Default: 0 

121 

122 

123 Example 

124 ------- 

125 

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

127 >>> atoms.calc = calc 

128 >>> atoms.get_potential_energy() 

129 """ 

130 

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

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

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

134 command = 'gpumd' 

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

136 discard_results_on_any_change = True 

137 

138 # We use list of tuples to define parameters for 

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

140 # we want to repeat the same keyword. 

141 # A single dump_xyz writes both the positions and the forces to movie.xyz. 

142 # `precision double` is needed because dump_xyz writes nine significant digits by default, 

143 # which is fewer than the forces carry. 

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

145 ('dump_xyz', (1, 'movie.xyz', 'precision', 'double', 'force')), 

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

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

148 ('ensemble', 'nve'), 

149 ('run', 1)] 

150 

151 def __init__(self, 

152 model_filename: str, 

153 directory: str = None, 

154 label: str = 'GPUNEP', 

155 atoms: Atoms = None, 

156 command: str = None, 

157 gpu_identifier_index: Union[int, None] = 0 

158 ): 

159 if command is None: 

160 command = calorine_getenv('GPUMD_COMMAND') 

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

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

163 self.model_filename = str(model_filename) 

164 

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

166 header, _ = _get_nep_contents(self.model_filename) 

167 self.model_type = header['model_type'] 

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

169 self.nep_version = header['version'] 

170 self.model_filename = model_filename 

171 

172 self.implemented_properties = list(self.base_implemented_properties) 

173 self.single_point_parameters = self.base_single_point_parameters 

174 if 'charge' in self.model_type: 

175 # Only available for charge models 

176 self.implemented_properties.extend( 

177 ['charges', 'born_effective_charges']) 

178 qnep_parameters = [('dump_xyz', (1, 'charges_and_bec.xyz', 'precision', 'double', 

179 'charge', 'bec'))] 

180 self.single_point_parameters = qnep_parameters + self.base_single_point_parameters 

181 

182 # Determine run command 

183 # Determine whether to save stdout or not 

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

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

186 command += ' > /dev/null' 

187 elif '>' not in command: 

188 command += ' > stdout' 

189 # GPUMD reports input and CUDA errors on stderr (see PRINT_INPUT_ERROR in 

190 # src/utilities/error.cuh), while stdout only carries the banner. Keeping stderr in its own 

191 # file is what lets a failure be reported with the reason GPUMD gave for it. 

192 if '2>' not in command: 192 ↛ 194line 192 didn't jump to line 194 because the condition on line 192 was always true

193 command += f' 2> {_STDERR_FILE}' 

194 self.command = command 

195 

196 # Determine directory to run in 

197 self._use_temporary_directory = directory is None 

198 self._directory = directory 

199 if self._use_temporary_directory: 

200 self._make_new_tmp_directory() 

201 else: 

202 self._potential_path = os.path.relpath( 

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

204 

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

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

207 profile = GPUMDShellProfile(command, gpu_identifier_index) 

208 FileIOCalculator.__init__(self, 

209 directory=self._directory, 

210 label=label, 

211 atoms=atoms, 

212 profile=profile) 

213 

214 def run_custom_md( 

215 self, 

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

217 return_last_atoms: bool = False, 

218 only_prepare: bool = False, 

219 ): 

220 """ 

221 Run a custom MD simulation. 

222 

223 Parameters 

224 ---------- 

225 parameters 

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

227 The potential keyword is set automatically, all other 

228 keywords need to be set via this argument. 

229 Example:: 

230 

231 [('dump_thermo', 100), 

232 ('dump_xyz', (1000, 'movie.xyz')), 

233 ('velocity', 300), 

234 ('time_step', 1), 

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

236 ('run', 10000)] 

237 

238 return_last_atoms 

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

240 This requires :attr:`parameters` to contain a ``dump_xyz`` keyword writing to 

241 ``movie.xyz``, since that is the file read back. 

242 only_prepare 

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

244 but the MD run will not be executed. 

245 

246 Returns 

247 ------- 

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

249 """ 

250 if self._use_temporary_directory: 

251 self._make_new_tmp_directory() 

252 

253 if self._use_temporary_directory and not return_last_atoms: 

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

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

256 

257 if self._use_temporary_directory and only_prepare: 

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

259 'all files will be removed.') 

260 

261 # Write files and run 

262 FileIOCalculator.write_input(self, self.atoms) 

263 self._write_runfile(parameters) 

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

265 structure=self.atoms) 

266 

267 if only_prepare: 

268 return None 

269 

270 # Execute the calculation. 

271 self.execute() 

272 

273 # Extract last snapshot if needed 

274 if return_last_atoms: 

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

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

277 

278 if self._use_temporary_directory: 

279 self._clean() 

280 

281 if return_last_atoms: 

282 return last_atoms 

283 else: 

284 return None 

285 

286 def execute(self): 

287 """ 

288 Run GPUMD, reporting what it wrote to standard error if it fails. 

289 

290 :program:`ase` raises :class:`CalculationFailed` carrying only the exit code, which is not 

291 enough to tell an input error apart from a missing GPU or an unusable model. GPUMD explains 

292 itself on standard error, so that text is attached to the exception. 

293 """ 

294 try: 

295 FileIOCalculator.execute(self) 

296 except CalculationFailed as exception: 

297 message = self._read_stderr() 

298 if message is None: 

299 raise 

300 raise CalculationFailed(f'{exception}\nGPUMD reported:\n{message}') from exception 

301 

302 def _read_stderr(self): 

303 """Return what GPUMD wrote to standard error during the last run. 

304 

305 Returns 

306 ------- 

307 The captured text with surrounding whitespace removed, or ``None`` if the file is 

308 absent, unreadable, or empty. 

309 """ 

310 try: 

311 with open(os.path.join(self._directory, _STDERR_FILE)) as handle: 

312 message = handle.read().strip() 

313 except OSError: 

314 return None 

315 return message if message else None 

316 

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

318 """ 

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

320 """ 

321 if self._use_temporary_directory: 

322 self._make_new_tmp_directory() 

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

324 self._write_runfile(parameters=self.single_point_parameters) 

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

326 structure=atoms) 

327 

328 def _write_runfile(self, parameters): 

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

330 

331 Parameters 

332 ---------- 

333 parameters : dict 

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

335 (see GPUMD documentation for a complete list). 

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

337 """ 

338 previous_run_files = _find_previous_run_files(self._directory) 

339 if previous_run_files: 

340 warnings.warn(f'{self._directory} already contains files from an earlier ' 

341 f'calculation: {", ".join(previous_run_files)}. Results may be ' 

342 'read from those rather than from the calculation about to run.') 

343 

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

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

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

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

348 # Write all keywords with parameter(s) 

349 for key, val in parameters: 

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

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

352 for v in val: 

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

354 else: 

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

356 f.write('\n') 

357 

358 def get_potential_energy_and_stresses_from_file(self): 

359 """ 

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

361 from thermo.out 

362 """ 

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

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

365 line = data 

366 else: 

367 line = data[-1, :] 

368 

369 # Energy 

370 energy = line[2] 

371 

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

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

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

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

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

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

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

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

380 # producing a stress response concentrated at the expected 

381 # component. 

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

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

384 

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

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

387 return energy, stress 

388 

389 def _read_potential_energy_and_stresses(self): 

390 """Reads potential energy and stresses.""" 

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

392 self.get_potential_energy_and_stresses_from_file() 

393 

394 def get_forces_from_file(self): 

395 """ 

396 Extract forces (in eV/A) from last snapshot in movie.xyz 

397 """ 

398 # GPUMD writes the energy and the stress on the comment line, so ase attaches a 

399 # SinglePointCalculator and the forces are reached through get_forces rather than arrays. 

400 structure = ase_read(os.path.join(self._directory, 'movie.xyz'), 

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

402 return structure.get_forces() 

403 

404 def _read_forces(self): 

405 """Reads forces (the last snapshot in movie.xyz) in eV/A""" 

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

407 

408 def get_charges_and_becs_from_file(self): 

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

410 snapshot in `charges_and_bec.xyz`""" 

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

412 charges = structure.get_charges() 

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

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

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

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

417 # same convention CPUNEP uses for its own BEC. 

418 becs = structure.get_array('bec') 

419 return charges, becs 

420 

421 def _read_charges_and_becs(self): 

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

423 charges, becs = self.get_charges_and_becs_from_file() 

424 self.results['charges'] = charges 

425 self.results['born_effective_charges'] = becs 

426 

427 def read_results(self): 

428 """ 

429 Read results from last step of MD calculation. 

430 """ 

431 self._read_potential_energy_and_stresses() 

432 self._read_forces() 

433 

434 if 'charge' in self.model_type: 

435 self._read_charges_and_becs() 

436 if self._use_temporary_directory: 

437 self._clean() 

438 

439 def _clean(self): 

440 """ 

441 Remove directory with calculations. 

442 """ 

443 shutil.rmtree(self._directory) 

444 

445 def _make_new_tmp_directory(self): 

446 """ 

447 Create a new temporary directory. 

448 """ 

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

450 # if the current one is empty 

451 if self._directory is None or \ 

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

453 self._directory = tempfile.mkdtemp() 

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

455 self._directory) 

456 

457 def set_atoms(self, atoms): 

458 """ 

459 Set Atoms object. 

460 Used also when attaching calculator to Atoms object. 

461 """ 

462 self.atoms = atoms 

463 self.results = {} 

464 

465 def set_directory(self, directory): 

466 """ 

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

468 several calculations with the same calculator while saving 

469 all results 

470 """ 

471 self._directory = directory 

472 self._use_temporary_directory = False 

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

474 self._directory) 

475 

476 def get_born_effective_charges( 

477 self, 

478 atoms: Atoms = None, 

479 properties: List[str] = None, 

480 system_changes: List[str] = all_changes, 

481 ) -> np.ndarray: 

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

483 Note that this requires a qNEP model. 

484 

485 Parameters 

486 ---------- 

487 atoms 

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

489 properties 

490 Properties to calculate, by default `None`. 

491 system_changes 

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

493 """ 

494 if 'born_effective_charges' not in self.implemented_properties: 

495 raise ValueError( 

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

497 self.calculate(atoms, properties, system_changes) 

498 return self.results['born_effective_charges']