Coverage for /builds/hweiske/ase/ase/calculators/lammpsrun.py: 87.68%

211 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2024-04-22 11:22 +0000

1"""ASE calculator for the LAMMPS classical MD code""" 

2# lammps.py (2011/03/29) 

3# 

4# Copyright (C) 2009 - 2011 Joerg Meyer, joerg.meyer@ch.tum.de 

5# 

6# This library is free software; you can redistribute it and/or 

7# modify it under the terms of the GNU Lesser General Public 

8# License as published by the Free Software Foundation; either 

9# version 2.1 of the License, or (at your option) any later version. 

10# 

11# This library is distributed in the hope that it will be useful, 

12# but WITHOUT ANY WARRANTY; without even the implied warranty of 

13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 

14# Lesser General Public License for more details. 

15# 

16# You should have received a copy of the GNU Lesser General Public 

17# License along with this file; if not, write to the Free Software 

18# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 

19# USA or see <http://www.gnu.org/licenses/>. 

20 

21 

22import os 

23import shlex 

24import shutil 

25import subprocess 

26import warnings 

27from re import IGNORECASE 

28from re import compile as re_compile 

29from tempfile import NamedTemporaryFile, mkdtemp 

30from tempfile import mktemp as uns_mktemp 

31from threading import Thread 

32from typing import Any, Dict 

33 

34import numpy as np 

35 

36from ase.calculators.calculator import Calculator, all_changes 

37from ase.calculators.lammps import (CALCULATION_END_MARK, Prism, convert, 

38 write_lammps_in) 

39from ase.data import atomic_masses, chemical_symbols 

40from ase.io.lammpsdata import write_lammps_data 

41from ase.io.lammpsrun import read_lammps_dump 

42 

43__all__ = ["LAMMPS"] 

44 

45 

46class LAMMPS(Calculator): 

47 """LAMMPS (https://lammps.sandia.gov/) calculator 

48 

49 The environment variable :envvar:`ASE_LAMMPSRUN_COMMAND` must be defined to 

50 tell ASE how to call a LAMMPS binary. This should contain the path to the 

51 lammps binary, or more generally, a command line possibly also including an 

52 MPI-launcher command. 

53 

54 For example (in a Bourne-shell compatible environment): 

55 

56 .. code-block:: bash 

57 

58 export ASE_LAMMPSRUN_COMMAND=/path/to/lmp_binary 

59 

60 or possibly something similar to 

61 

62 .. code-block:: bash 

63 

64 export ASE_LAMMPSRUN_COMMAND="/path/to/mpirun --np 4 lmp_binary" 

65 

66 Parameters 

67 ---------- 

68 files : list[str] 

69 List of files needed by LAMMPS. Typically a list of potential files. 

70 parameters : dict[str, Any] 

71 Dictionary of settings to be passed into the input file for calculation. 

72 specorder : list[str] 

73 Within LAAMPS, atoms are identified by an integer value starting from 1. 

74 This variable allows the user to define the order of the indices 

75 assigned to the atoms in the calculation, with the default 

76 if not given being alphabetical 

77 keep_tmp_files : bool, default: False 

78 Retain any temporary files created. Mostly useful for debugging. 

79 tmp_dir : str, default: None 

80 path/dirname (default None -> create automatically). 

81 Explicitly control where the calculator object should create 

82 its files. Using this option implies 'keep_tmp_files=True'. 

83 no_data_file : bool, default: False 

84 Controls whether an explicit data file will be used for feeding 

85 atom coordinates into lammps. Enable it to lessen the pressure on 

86 the (tmp) file system. THIS OPTION MIGHT BE UNRELIABLE FOR CERTAIN 

87 CORNER CASES (however, if it fails, you will notice...). 

88 keep_alive : bool, default: True 

89 When using LAMMPS as a spawned subprocess, keep the subprocess 

90 alive (but idling when unused) along with the calculator object. 

91 always_triclinic : bool, default: False 

92 Force LAMMPS to treat the cell as tilted, even if the cell is not 

93 tilted, by printing ``xy``, ``xz``, ``yz`` in the data file. 

94 reduce_cell : bool, default: False 

95 If True, reduce cell to have shorter lattice vectors. 

96 write_velocities : bool, default: False 

97 If True, forward ASE velocities to LAMMPS. 

98 verbose: bool, default: False 

99 If True, print additional debugging output to STDOUT. 

100 

101 Examples 

102 -------- 

103 Provided that the respective potential file is in the working directory, 

104 one can simply run (note that LAMMPS needs to be compiled to work with EAM 

105 potentials) 

106 

107 :: 

108 

109 from ase import Atom, Atoms 

110 from ase.build import bulk 

111 from ase.calculators.lammpsrun import LAMMPS 

112 

113 parameters = {'pair_style': 'eam/alloy', 

114 'pair_coeff': ['* * NiAlH_jea.eam.alloy H Ni']} 

115 

116 files = ['NiAlH_jea.eam.alloy'] 

117 

118 Ni = bulk('Ni', cubic=True) 

119 H = Atom('H', position=Ni.cell.diagonal()/2) 

120 NiH = Ni + H 

121 

122 lammps = LAMMPS(parameters=parameters, files=files) 

123 

124 NiH.calc = lammps 

125 print("Energy ", NiH.get_potential_energy()) 

126 """ 

127 

128 name = "lammpsrun" 

129 implemented_properties = ["energy", "free_energy", "forces", "stress", 

130 "energies"] 

131 

132 # parameters to choose options in LAMMPSRUN 

133 ase_parameters: Dict[str, Any] = dict( 

134 specorder=None, 

135 atorder=True, 

136 always_triclinic=False, 

137 reduce_cell=False, 

138 keep_alive=True, 

139 keep_tmp_files=False, 

140 no_data_file=False, 

141 tmp_dir=None, 

142 files=[], # usually contains potential parameters 

143 verbose=False, 

144 write_velocities=False, 

145 binary_dump=True, # bool - use binary dump files (full 

146 # precision but long long ids are casted to 

147 # double) 

148 lammps_options="-echo log -screen none -log /dev/stdout", 

149 trajectory_out=None, # file object, if is not None the 

150 # trajectory will be saved in it 

151 ) 

152 

153 # parameters forwarded to LAMMPS 

154 lammps_parameters = dict( 

155 boundary=None, # bounadry conditions styles 

156 units="metal", # str - Which units used; some potentials 

157 # require certain units 

158 atom_style="atomic", 

159 special_bonds=None, 

160 # potential informations 

161 pair_style="lj/cut 2.5", 

162 pair_coeff=["* * 1 1"], 

163 masses=None, 

164 pair_modify=None, 

165 # variables controlling the output 

166 thermo_args=[ 

167 "step", "temp", "press", "cpu", "pxx", "pyy", "pzz", 

168 "pxy", "pxz", "pyz", "ke", "pe", "etotal", "vol", "lx", 

169 "ly", "lz", "atoms", ], 

170 dump_properties=["id", "type", "x", "y", "z", "vx", "vy", 

171 "vz", "fx", "fy", "fz", ], 

172 dump_period=1, # period of system snapshot saving (in MD steps) 

173 ) 

174 

175 default_parameters = dict(ase_parameters, **lammps_parameters) 

176 

177 def __init__(self, label="lammps", **kwargs): 

178 super().__init__(label=label, **kwargs) 

179 

180 self.prism = None 

181 self.calls = 0 

182 self.forces = None 

183 # thermo_content contains data "written by" thermo_style. 

184 # It is a list of dictionaries, each dict (one for each line 

185 # printed by thermo_style) contains a mapping between each 

186 # custom_thermo_args-argument and the corresponding 

187 # value as printed by lammps. thermo_content will be 

188 # re-populated by the read_log method. 

189 self.thermo_content = [] 

190 

191 if self.parameters['tmp_dir'] is not None: 

192 # If tmp_dir is pointing somewhere, don't remove stuff! 

193 self.parameters['keep_tmp_files'] = True 

194 self._lmp_handle = None # To handle the lmp process 

195 

196 if self.parameters['tmp_dir'] is None: 

197 self.parameters['tmp_dir'] = mkdtemp(prefix="LAMMPS-") 

198 else: 

199 self.parameters['tmp_dir'] = os.path.realpath( 

200 self.parameters['tmp_dir']) 

201 if not os.path.isdir(self.parameters['tmp_dir']): 

202 os.mkdir(self.parameters['tmp_dir'], 0o755) 

203 

204 for f in self.parameters['files']: 

205 shutil.copy( 

206 f, os.path.join(self.parameters['tmp_dir'], 

207 os.path.basename(f))) 

208 

209 def get_lammps_command(self): 

210 cmd = self.parameters.get('command') 

211 

212 if cmd is None: 

213 from ase.config import cfg 

214 envvar = f'ASE_{self.name.upper()}_COMMAND' 

215 cmd = cfg.get(envvar) 

216 

217 if cmd is None: 

218 # TODO deprecate and remove guesswork 

219 cmd = 'lammps' 

220 

221 opts = self.parameters.get('lammps_options') 

222 

223 if opts is not None: 

224 cmd = f'{cmd} {opts}' 

225 

226 return cmd 

227 

228 def clean(self, force=False): 

229 

230 self._lmp_end() 

231 

232 if not self.parameters['keep_tmp_files'] or force: 

233 shutil.rmtree(self.parameters['tmp_dir']) 

234 

235 def check_state(self, atoms, tol=1.0e-10): 

236 # Transforming the unit cell to conform to LAMMPS' convention for 

237 # orientation (c.f. https://lammps.sandia.gov/doc/Howto_triclinic.html) 

238 # results in some precision loss, so we use bit larger tolerance than 

239 # machine precision here. Note that there can also be precision loss 

240 # related to how many significant digits are specified for things in 

241 # the LAMMPS input file. 

242 return Calculator.check_state(self, atoms, tol) 

243 

244 def calculate(self, atoms=None, properties=None, system_changes=None): 

245 if properties is None: 

246 properties = self.implemented_properties 

247 if system_changes is None: 

248 system_changes = all_changes 

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

250 self.run() 

251 

252 def _lmp_alive(self): 

253 # Return True if this calculator is currently handling a running 

254 # lammps process 

255 return self._lmp_handle and not isinstance( 

256 self._lmp_handle.poll(), int 

257 ) 

258 

259 def _lmp_end(self): 

260 # Close lammps input and wait for lammps to end. Return process 

261 # return value 

262 if self._lmp_alive(): 

263 # !TODO: handle lammps error codes 

264 try: 

265 self._lmp_handle.communicate(timeout=5) 

266 except subprocess.TimeoutExpired: 

267 self._lmp_handle.kill() 

268 self._lmp_handle.communicate() 

269 err = self._lmp_handle.poll() 

270 assert err is not None 

271 return err 

272 

273 def set_missing_parameters(self): 

274 """Verify that all necessary variables are set. 

275 """ 

276 symbols = self.atoms.get_chemical_symbols() 

277 # If unspecified default to atom types in alphabetic order 

278 if not self.parameters.get('specorder'): 

279 self.parameters['specorder'] = sorted(set(symbols)) 

280 

281 # !TODO: handle cases were setting masses actual lead to errors 

282 if not self.parameters.get('masses'): 

283 self.parameters['masses'] = [] 

284 for type_id, specie in enumerate(self.parameters['specorder']): 

285 mass = atomic_masses[chemical_symbols.index(specie)] 

286 self.parameters['masses'] += [ 

287 f"{type_id + 1:d} {mass:f}" 

288 ] 

289 

290 # set boundary condtions 

291 if not self.parameters.get('boundary'): 

292 b_str = " ".join(["fp"[int(x)] for x in self.atoms.pbc]) 

293 self.parameters['boundary'] = b_str 

294 

295 def run(self, set_atoms=False): 

296 # !TODO: split this function 

297 """Method which explicitly runs LAMMPS.""" 

298 pbc = self.atoms.get_pbc() 

299 if all(pbc): 

300 cell = self.atoms.get_cell() 

301 elif not any(pbc): 

302 # large enough cell for non-periodic calculation - 

303 # LAMMPS shrink-wraps automatically via input command 

304 # "periodic s s s" 

305 # below 

306 cell = 2 * np.max(np.abs(self.atoms.get_positions())) * np.eye(3) 

307 else: 

308 warnings.warn( 

309 "semi-periodic ASE cell detected - translation " 

310 + "to proper LAMMPS input cell might fail" 

311 ) 

312 cell = self.atoms.get_cell() 

313 self.prism = Prism(cell) 

314 

315 self.set_missing_parameters() 

316 self.calls += 1 

317 

318 # change into subdirectory for LAMMPS calculations 

319 tempdir = self.parameters['tmp_dir'] 

320 

321 # setup file names for LAMMPS calculation 

322 label = f"{self.label}{self.calls:>06}" 

323 lammps_in = uns_mktemp( 

324 prefix="in_" + label, dir=tempdir 

325 ) 

326 lammps_log = uns_mktemp( 

327 prefix="log_" + label, dir=tempdir 

328 ) 

329 lammps_trj_fd = NamedTemporaryFile( 

330 prefix="trj_" + label, 

331 suffix=(".bin" if self.parameters['binary_dump'] else ""), 

332 dir=tempdir, 

333 delete=(not self.parameters['keep_tmp_files']), 

334 ) 

335 lammps_trj = lammps_trj_fd.name 

336 if self.parameters['no_data_file']: 

337 lammps_data = None 

338 else: 

339 lammps_data_fd = NamedTemporaryFile( 

340 prefix="data_" + label, 

341 dir=tempdir, 

342 delete=(not self.parameters['keep_tmp_files']), 

343 mode='w', 

344 encoding='ascii' 

345 ) 

346 write_lammps_data( 

347 lammps_data_fd, 

348 self.atoms, 

349 specorder=self.parameters['specorder'], 

350 force_skew=self.parameters['always_triclinic'], 

351 reduce_cell=self.parameters['reduce_cell'], 

352 velocities=self.parameters['write_velocities'], 

353 prismobj=self.prism, 

354 units=self.parameters['units'], 

355 atom_style=self.parameters['atom_style'], 

356 ) 

357 lammps_data = lammps_data_fd.name 

358 lammps_data_fd.flush() 

359 

360 # see to it that LAMMPS is started 

361 if not self._lmp_alive(): 

362 command = self.get_lammps_command() 

363 # Attempt to (re)start lammps 

364 self._lmp_handle = subprocess.Popen( 

365 shlex.split(command, posix=(os.name == "posix")), 

366 stdin=subprocess.PIPE, 

367 stdout=subprocess.PIPE, 

368 encoding='ascii', 

369 ) 

370 lmp_handle = self._lmp_handle 

371 

372 # Create thread reading lammps stdout (for reference, if requested, 

373 # also create lammps_log, although it is never used) 

374 if self.parameters['keep_tmp_files']: 

375 lammps_log_fd = open(lammps_log, "w") 

376 fd = SpecialTee(lmp_handle.stdout, lammps_log_fd) 

377 else: 

378 fd = lmp_handle.stdout 

379 thr_read_log = Thread(target=self.read_lammps_log, args=(fd,)) 

380 thr_read_log.start() 

381 

382 # write LAMMPS input (for reference, also create the file lammps_in, 

383 # although it is never used) 

384 if self.parameters['keep_tmp_files']: 

385 lammps_in_fd = open(lammps_in, "w") 

386 fd = SpecialTee(lmp_handle.stdin, lammps_in_fd) 

387 else: 

388 fd = lmp_handle.stdin 

389 write_lammps_in( 

390 lammps_in=fd, 

391 parameters=self.parameters, 

392 atoms=self.atoms, 

393 prismobj=self.prism, 

394 lammps_trj=lammps_trj, 

395 lammps_data=lammps_data, 

396 ) 

397 

398 if self.parameters['keep_tmp_files']: 

399 lammps_in_fd.close() 

400 

401 # Wait for log output to be read (i.e., for LAMMPS to finish) 

402 # and close the log file if there is one 

403 thr_read_log.join() 

404 if self.parameters['keep_tmp_files']: 

405 lammps_log_fd.close() 

406 

407 if not self.parameters['keep_alive']: 

408 self._lmp_end() 

409 

410 exitcode = lmp_handle.poll() 

411 if exitcode and exitcode != 0: 

412 raise RuntimeError( 

413 "LAMMPS exited in {} with exit code: {}." 

414 "".format(tempdir, exitcode) 

415 ) 

416 

417 # A few sanity checks 

418 if len(self.thermo_content) == 0: 

419 raise RuntimeError("Failed to retrieve any thermo_style-output") 

420 if int(self.thermo_content[-1]["atoms"]) != len(self.atoms): 

421 # This obviously shouldn't happen, but if prism.fold_...() fails, 

422 # it could 

423 raise RuntimeError("Atoms have gone missing") 

424 

425 trj_atoms = read_lammps_dump( 

426 infileobj=lammps_trj, 

427 order=self.parameters['atorder'], 

428 index=-1, 

429 prismobj=self.prism, 

430 specorder=self.parameters['specorder'], 

431 ) 

432 

433 if set_atoms: 

434 self.atoms = trj_atoms.copy() 

435 

436 self.forces = trj_atoms.get_forces() 

437 # !TODO: trj_atoms is only the last snapshot of the system; Is it 

438 # desirable to save also the inbetween steps? 

439 if self.parameters['trajectory_out'] is not None: 

440 # !TODO: is it advisable to create here temporary atoms-objects 

441 self.trajectory_out.write(trj_atoms) 

442 

443 tc = self.thermo_content[-1] 

444 self.results["energy"] = convert( 

445 tc["pe"], "energy", self.parameters["units"], "ASE" 

446 ) 

447 self.results["free_energy"] = self.results["energy"] 

448 self.results['forces'] = convert(self.forces.copy(), 

449 'force', 

450 self.parameters['units'], 

451 'ASE') 

452 stress = np.array( 

453 [-tc[i] for i in ("pxx", "pyy", "pzz", "pyz", "pxz", "pxy")] 

454 ) 

455 

456 # We need to apply the Lammps rotation stuff to the stress: 

457 xx, yy, zz, yz, xz, xy = stress 

458 stress_tensor = np.array([[xx, xy, xz], 

459 [xy, yy, yz], 

460 [xz, yz, zz]]) 

461 stress_atoms = self.prism.tensor2_to_ase(stress_tensor) 

462 stress_atoms = stress_atoms[[0, 1, 2, 1, 0, 0], 

463 [0, 1, 2, 2, 2, 1]] 

464 stress = stress_atoms 

465 

466 self.results["stress"] = convert( 

467 stress, "pressure", self.parameters["units"], "ASE" 

468 ) 

469 

470 lammps_trj_fd.close() 

471 if not self.parameters['no_data_file']: 

472 lammps_data_fd.close() 

473 

474 def __enter__(self): 

475 return self 

476 

477 def __exit__(self, *args): 

478 self._lmp_end() 

479 

480 def read_lammps_log(self, fileobj): 

481 # !TODO: somehow communicate 'thermo_content' explicitly 

482 """Method which reads a LAMMPS output log file.""" 

483 

484 # read_log depends on that the first (three) thermo_style custom args 

485 # can be capitalized and matched against the log output. I.e. 

486 # don't use e.g. 'ke' or 'cpu' which are labeled KinEng and CPU. 

487 mark_re = r"^\s*" + r"\s+".join( 

488 [x.capitalize() for x in self.parameters['thermo_args'][0:3]] 

489 ) 

490 _custom_thermo_mark = re_compile(mark_re) 

491 

492 # !TODO: regex-magic necessary? 

493 # Match something which can be converted to a float 

494 f_re = r"([+-]?(?:(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?|nan|inf))" 

495 n_args = len(self.parameters["thermo_args"]) 

496 # Create a re matching exactly N white space separated floatish things 

497 _custom_thermo_re = re_compile( 

498 r"^\s*" + r"\s+".join([f_re] * n_args) + r"\s*$", flags=IGNORECASE 

499 ) 

500 

501 thermo_content = [] 

502 line = fileobj.readline() 

503 while line and line.strip() != CALCULATION_END_MARK: 

504 # check error 

505 if 'ERROR:' in line: 

506 raise RuntimeError(f'LAMMPS exits with error message: {line}') 

507 

508 # get thermo output 

509 if _custom_thermo_mark.match(line): 

510 while True: 

511 line = fileobj.readline() 

512 if 'WARNING:' in line: 

513 continue 

514 

515 bool_match = _custom_thermo_re.match(line) 

516 if not bool_match: 

517 break 

518 

519 # create a dictionary between each of the 

520 # thermo_style args and it's corresponding value 

521 thermo_content.append( 

522 dict( 

523 zip( 

524 self.parameters['thermo_args'], 

525 map(float, bool_match.groups()), 

526 ) 

527 ) 

528 ) 

529 else: 

530 line = fileobj.readline() 

531 

532 self.thermo_content = thermo_content 

533 

534 

535class SpecialTee: 

536 """A special purpose, with limited applicability, tee-like thing. 

537 

538 A subset of stuff read from, or written to, orig_fd, 

539 is also written to out_fd. 

540 It is used by the lammps calculator for creating file-logs of stuff 

541 read from, or written to, stdin and stdout, respectively. 

542 """ 

543 

544 def __init__(self, orig_fd, out_fd): 

545 self._orig_fd = orig_fd 

546 self._out_fd = out_fd 

547 self.name = orig_fd.name 

548 

549 def write(self, data): 

550 self._orig_fd.write(data) 

551 self._out_fd.write(data) 

552 self.flush() 

553 

554 def read(self, *args, **kwargs): 

555 data = self._orig_fd.read(*args, **kwargs) 

556 self._out_fd.write(data) 

557 return data 

558 

559 def readline(self, *args, **kwargs): 

560 data = self._orig_fd.readline(*args, **kwargs) 

561 self._out_fd.write(data) 

562 return data 

563 

564 def readlines(self, *args, **kwargs): 

565 data = self._orig_fd.readlines(*args, **kwargs) 

566 self._out_fd.write("".join(data)) 

567 return data 

568 

569 def flush(self): 

570 self._orig_fd.flush() 

571 self._out_fd.flush()