wmaee.codes.pyiron.pyiron_GRACE_job
1# Metadata 2__author__ = "David Holec, Amin Sakic" 3__maintainer__ = "David Holec" 4__email__ = "david.holec@unileoben.ac.at" 5__date__ = "2025-08-03" 6__version__ = "0.11" 7__license__ = "MIT" 8 9from pyiron_atomistics.atomistics.job.atomistic import AtomisticGenericJob 10from pyiron_atomistics import pyiron_to_ase, ase_to_pyiron 11from pyiron_base import GenericParameters 12 13# from tensorpotential.calculator import grace_fm 14# from tensorpotential.calculator import TPCalculator 15 16 17import io 18import ast 19import os 20from ase import Atoms 21from ase.io import write, read 22from ase.io.trajectory import Trajectory 23from ase.constraints import ExpCellFilter 24from typing import Tuple 25 26import pandas as pd 27import numpy as np 28from typing import Literal, Optional 29 30from wmaee.units import EV_PER_ANG3_TO_GPA 31 32_grace_models = [ 33 "GRACE-1L-MP-r6", "GRACE-2L-MP-r5", "GRACE-2L-MP-r6", "GRACE-FS-OAM", 34 "GRACE-FS-OAM", "GRACE-1L-OAM", "GRACE-2L-OAM", 35 "GRACE-FS-OMAT", "GRACE-1L-OMAT", "GRACE-2L-OMAT", 36 "GRACE-1L-OMAT-medium-base", "GRACE-1L-OMAT-medium-ft-E", "GRACE-1L-OMAT-large-base", "GRACE-1L-OMAT-large-ft-E", 37 "GRACE-2L-OMAT-medium-base", "GRACE-2L-OMAT-medium-ft-E", "GRACE-2L-OMAT-large-base", "GRACE-2L-OMAT-large-ft-E", 38] 39 40class Grace(AtomisticGenericJob): 41 """ 42 Custom job class for using Grace within the pyiron framework. 43 44 This class provides methods to perform static calculations, structure relaxation, 45 and molecular dynamics simulations using Grace UMLIPs. 46 47 Attributes 48 ---------- 49 structure : pyiron.atomistics.structure.atoms.Atoms 50 Structure associated with the job. 51 input : CalcParams 52 Input parameters for the Grace calculations. 53 """ 54 55 def __init__(self, project, job_name): 56 """ 57 Initialize the Grace job. 58 59 Parameters 60 ---------- 61 project : pyiron.Project 62 The pyiron project in which the job resides. 63 job_name : str 64 Name of the job. 65 """ 66 super(Grace, self).__init__(project, job_name) 67 self.structure = None 68 self.input = CalcParams() 69 if self.project_hdf5.file_exists: 70 self.input.from_hdf(self.project_hdf5, group_name='input') 71 # executable is in pyiron_resources/grace/bin 72 self._executable_activate(codename='grace') 73 74 @property 75 def fmax(self) -> float: 76 """ 77 Maximum force tolerance for ionic relaxation. 78 79 This property controls the maximum force tolerance (in eV/Å) used during ionic relaxation. 80 81 Returns 82 ------- 83 float 84 Maximum force tolerance (eV/Å). 85 """ 86 return self._generic_input['ionic_force_tolerance'] 87 88 @fmax.setter 89 def fmax(self, fmax: float): 90 """ 91 Set the maximum force tolerance for ionic relaxation. 92 93 Parameters 94 ---------- 95 fmax : float 96 Maximum force tolerance (eV/Å). 97 """ 98 self._generic_input['ionic_force_tolerance'] = fmax 99 100 @property 101 def max_iter(self) -> int: 102 """ 103 Get the maximum number of iterations for relaxation or MD run. 104 105 Returns 106 ------- 107 int 108 Maximum number of iterations. 109 """ 110 return self._generic_input['max_iter'] 111 112 @max_iter.setter 113 def max_iter(self, max_iter: int): 114 """ 115 Set the maximum number of iterations for relaxation or MD run. 116 117 Parameters 118 ---------- 119 max_iter : int 120 Maximum number of iterations. 121 """ 122 self._generic_input['max_iter'] = max_iter 123 124 @property 125 def model(self) -> str: 126 """ 127 Return the current GRACE model. 128 129 Returns 130 ------- 131 str 132 The selected GRACE model name. 133 """ 134 return self.input['model'] 135 136 137 @model.setter 138 def model(self, model: str): 139 """ 140 Set the GRACE model name. 141 142 Parameters 143 ---------- 144 model : str 145 Name of the GRACE model to use. Must be one of the supported 146 GRACE model identifiers: 147 148 - "GRACE-1L-MP-r6" 149 - "GRACE-2L-MP-r5" 150 - "GRACE-2L-MP-r6" 151 - "GRACE-FS-OAM" 152 - "GRACE-1L-OAM" 153 - "GRACE-2L-OAM" 154 - "GRACE-FS-OMAT" 155 - "GRACE-1L-OMAT" 156 - "GRACE-2L-OMAT" 157 - "GRACE-1L-OMAT-medium-base" 158 - "GRACE-1L-OMAT-medium-ft-E" 159 - "GRACE-1L-OMAT-large-base" 160 - "GRACE-1L-OMAT-large-ft-E" 161 - "GRACE-2L-OMAT-medium-base" 162 - "GRACE-2L-OMAT-medium-ft-E" 163 - "GRACE-2L-OMAT-large-base" 164 - "GRACE-2L-OMAT-large-ft-E" 165 166 Raises 167 ------ 168 ValueError 169 If `model` is not a valid GRACE model identifier. 170 """ 171 if model not in _grace_models: 172 raise ValueError(f"Unknown grace model: {model}") 173 self.input["model"] = model 174 175 176 def available_models(self): 177 """ 178 Return the list of supported GRACE models. 179 180 Returns 181 ------- 182 list of str 183 All valid GRACE model identifiers. 184 """ 185 return _grace_models 186 187 188 #################################################### 189 ### --- IMPLEMENTATION OF STATIC CALCULATION --- ### 190 #################################################### 191 def calc_static( 192 self, 193 grace_model: Literal[*_grace_models] = "GRACE-2L-OMAT", 194 ): 195 """ 196 Perform a static calculation. 197 198 Parameters 199 ---------- 200 grace_model : Literal[*_grace_models], optional 201 GRACE model to use for the relaxation. 202 Must be one of the supported models, which can be obtained via 203 :meth:`available_models`. Default is ``"GRACE-2L-OMAT"``. 204 205 Raises 206 ------ 207 ValueError 208 If the structure has not been set before calling this method. 209 210 Notes 211 ----- 212 This method configures the GRACE calculator for a static calculation 213 using the provided GRACE model. 214 A structure must be assigned to the instance prior to calling 215 ``calc_static``. 216 217 Returns 218 ------- 219 None 220 """ 221 if self.structure is None: 222 raise ValueError( 223 'Structure must be set before calling calc_static().') 224 self._generic_input['calc_mode'] = 'static' 225 self.model = grace_model 226 227 228 def _write_calc_static(self): 229 """ 230 Write the calculation script for performing static calculations. 231 232 This method generates a Python script that uses Grace for static calculations 233 and writes it to a file in the working directory. 234 """ 235 model = self.input["model"] 236 237 script = [ 238 'from tensorpotential.calculator import grace_fm', 239 'from ase.io import read', 240 'from ase.io.cif import write_cif', 241 '', 242 'initial_structure = read("structure.cif")', 243 f'initial_structure.calc = grace_fm("{model}")', 244 '', 245 '# perform the static calculation', 246 'energy_pot = initial_structure.get_total_energy()', 247 'forces = initial_structure.get_forces()', 248 'stresses = initial_structure.get_stress()', 249 '', 250 '# write the results to an output file', 251 'with open("log.out", "w") as f:', 252 ' f.write("step\tenergy_pot\tforces\tstresses\\n")', 253 ' f.write(f"1\\t{energy_pot}\\t{forces.tolist()}\\t{stresses.tolist()}\\n")', 254 '', 255 '# write the final structure to a CIF file', 256 'write_cif("final_structure.cif", initial_structure)', 257 ] 258 with open(os.path.join(self.working_directory, 'calc_script.py'), 'w') as f: 259 f.writelines("\n".join(script)) 260 261 def _parse_calc_static( 262 self, 263 output: str = 'log.out', 264 skip: int = 0 265 ) -> Optional[pd.DataFrame]: 266 """ 267 Parse the output of a static calculation and return optimization results. 268 269 This method reads a static calculation output file and extracts the 270 optimization steps and energy information, returning them as a DataFrame. 271 272 Parameters 273 ---------- 274 output : str, optional 275 Name of the output file to parse. Default is 'log.out'. 276 skip : int, optional 277 Number of rows to skip at the start of the file before parsing. Default is 0. 278 279 Returns 280 ------- 281 pd.DataFrame or None 282 A DataFrame containing the optimization results if parsing succeeds, 283 otherwise None. 284 """ 285 286 try: 287 df = pd.read_csv( 288 os.path.join(self.working_directory, output), 289 sep=r"\t+", 290 engine='python' 291 ) 292 # convert strings to np.array 293 df['forces'] = df['forces'].apply(lambda x: np.array(ast.literal_eval(x))) 294 df['stresses'] = df['stresses'].apply(lambda x: np.array(ast.literal_eval(x))) 295 return df 296 except: 297 self.logger.warning(f'Cannot parse output from {output} file.') 298 return None 299 300 301 ########################################################## 302 ### --- IMPLEMENTATION OF MINIMIZATION CALCULATION --- ### 303 ########################################################## 304 305 def calc_minimize( 306 self, 307 grace_model: Literal[*_grace_models] = "GRACE-2L-OMAT", 308 algorithm: Literal["FIRE", "BFGS", "LBFGS"] = "FIRE", 309 algorithm_kwargs: dict | None = None, 310 relax_cell: bool = False, 311 relax_cell_kwargs: dict | None = None, 312 ionic_force_tolerance: float = 0.001, 313 max_iter: int = 500, 314 n_print: int = 1, 315 save_path: str | None = "relax.traj", 316 ) -> None: 317 """ 318 Perform a structure relaxation (geometry optimization). 319 320 Parameters 321 ---------- 322 grace_model : Literal[*_grace_models], optional 323 GRACE model to use for the relaxation. 324 Must be one of the supported models, which can be obtained via 325 :meth:`available_models`. Default is ``"GRACE-2L-OMAT"``. 326 327 algorithm : {"FIRE", "BFGS", "LBFGS"}, optional 328 Ionic relaxation algorithm from ASE. 329 Default is ``"FIRE"``. 330 331 algorithm_kwargs : dict, optional 332 Additional keyword arguments passed to the relaxation algorithm. 333 Default is ``None``. 334 335 relax_cell : bool, optional 336 If ``True``, optimize the simulation cell in addition to ionic positions. 337 Default is ``False``. 338 339 relax_cell_kwargs : dict, optional 340 Additional keyword arguments for cell relaxation. 341 Default is ``None``. 342 343 ionic_force_tolerance : float, optional 344 Force convergence criterion in eV/Å. 345 Default is ``0.001``. 346 347 max_iter : int, optional 348 Maximum number of optimization steps. 349 Default is ``500``. 350 351 n_print : int, optional 352 Logging frequency for structures and stresses. 353 Default is ``1``. 354 355 save_path : str or None, optional 356 Path to save the relaxation trajectory. 357 If ``None``, no trajectory is written. 358 Default is ``"relax.traj"``. 359 360 Returns 361 ------- 362 None 363 """ 364 365 super().calc_minimize( 366 ionic_energy_tolerance=0, 367 ionic_force_tolerance=ionic_force_tolerance, 368 max_iter=max_iter, 369 ) 370 371 # Saving the parameters in dictionary 372 self._generic_input['calc_mode'] = 'minimize' 373 self.input['model'] = grace_model 374 self.input['algo'] = algorithm 375 self.input['algo_kwargs'] = algorithm_kwargs or {} 376 self.input['relax_cell'] = relax_cell 377 self.input['relax_cell_kwargs'] = relax_cell_kwargs or {} 378 self._generic_input['ionic_force_tolerance'] = ionic_force_tolerance 379 self._generic_input['n_print'] = n_print 380 self._generic_input['max_iter'] = max_iter 381 self.input['save_path'] = save_path 382 383 def _write_calc_minimize(self): 384 """ 385 Write the calculation script for performing structure relaxation. 386 387 This method generates a Python script that uses Grace for structure 388 relaxation and writes it to a file in the working directory. 389 """ 390 391 model = self.input['model'] # Grace model 392 algo = self.input.get('algo', 'FIRE') # ASE optimizer 393 if algo not in ['FIRE', 'BFGS', 'LBFGS']: 394 raise ValueError( 395 f"Unsupported optimizer: {algo}. Supported optimizers are: FIRE, BFGS, LBFGS.") 396 # ASE optimizer kwargs 397 algo_kwargs = self.input.get('algo_kwargs', {}).copy() 398 save_path = self.input.get('save_path', 'relax.traj') # Add trajectory parameter 399 algo_kwargs['trajectory'] = f"{save_path}" 400 algo_kwargs['loginterval'] = self._generic_input['n_print'] 401 # Reformat dictionary to string 402 algo_kwargs_str = ", ".join( 403 f'{k}="{v}"' if isinstance(v, str) else f"{k}={v}" 404 for k, v in algo_kwargs.items() 405 ) 406 407 # Cell relaxation 408 relax_cell = self.input.get('relax_cell', False) 409 # Cell relaxation kwargs ---> see ase.constraints.ExpCellFilter 410 relax_cell_kwargs = self.input.get('relax_cell_kwargs', {}) 411 # Reformat dictionary to string 412 relax_cell_kwargs_str = ", ".join( 413 f'{k}="{v}"' if isinstance(v, str) else f"{k}={v}" 414 for k, v in relax_cell_kwargs.items() 415 ) 416 417 # Set parameters for minimization 418 params_run = f"fmax={self._generic_input['ionic_force_tolerance']}, steps={self._generic_input['max_iter']}" 419 420 # Save parameters in 421 self._generic_input['relax_cell'] = relax_cell 422 self._generic_input['save_path'] = save_path 423 424 # Somewhat hidden feature of adding custom fixes 425 # TODO: Document? Make it a more "official" paramerer? 426 fixes = self.input.get('fixes', []) 427 if isinstance(fixes, str): 428 fixes = [fixes] 429 elif isinstance(fixes, list): 430 pass 431 else: 432 self.logger.warning('Fixes are neither string or array of strings. Ignoring') 433 fixes = [] 434 fix_imports = self.input.get('fix_imports', []) 435 if isinstance(fix_imports, str): 436 fix_imports = [fix_imports] 437 elif isinstance(fix_imports, list): 438 pass 439 else: 440 self.logger.warning('Fix imports are neither string or array of strings. Ignoring') 441 fix_imports = [] 442 443 script = [ 444 'from tensorpotential.calculator import grace_fm', 445 'from ase.io import read', 446 'from ase.io.cif import write_cif', 447 'from ase.io.trajectory import Trajectory', 448 f'from ase.optimize import {algo}', 449 ] 450 if relax_cell: 451 script += ['from ase.constraints import ExpCellFilter as ECF'] 452 for f in fix_imports: 453 script += [f] 454 script += [''] 455 456 script += [ 457 f'struct = read("structure.cif")', 458 f'struct.calc = grace_fm("{model}")' 459 ] 460 for f in fixes: 461 script.append(f'struct.set_constraint({f})') 462 script += [''] 463 464 if not relax_cell: 465 script += [ 466 f'relaxation = {algo.upper()}(atoms=struct, {algo_kwargs_str}).run({params_run})',] 467 else: 468 script += [ 469 f'relaxation = {algo.upper()}(ECF(atoms=struct, {relax_cell_kwargs_str}), {algo_kwargs_str}).run({params_run})'] 470 script += [''] 471 472 script += ['write_cif("final_structure.cif", struct)'] 473 474 with open(os.path.join(self.working_directory, 'calc_script.py'), 'w') as f: 475 f.writelines("\n".join(script)) 476 477 def _parse_calc_minimize(self, output: str = 'error.out') -> Optional[pd.DataFrame]: 478 """ 479 Parse the output of a minimization. 480 481 This function reads the minimization output file and returns the 482 corresponding DataFrame containing the optimization step and energy information. 483 484 Parameters 485 ---------- 486 output : str, optional 487 The name of the output file to parse, by default 'error.out'. 488 489 Returns 490 ------- 491 pd.DataFrame or None 492 A DataFrame containing the optimization results, or None if parsing fails. 493 """ 494 # ASE optimizer 495 algo = self.input.get('algo', 'FIRE') 496 497 try: 498 # Find the header line dynamically 499 with open(os.path.join(self.working_directory, output), 'r') as f: 500 lines = f.readlines() 501 for i, line in enumerate(lines): 502 if line.strip().startswith('Step'): 503 header_idx = i 504 break 505 else: 506 self.logger.warning(f'No table header found in {output}.') 507 return None 508 509 table_lines = [] 510 # Ensure that the line contains the expected algorithm 511 for line in lines[header_idx:]: 512 if algo in line.strip() or "Step" in line.strip(): 513 table_lines.append(line) 514 515 # Read table into DataFrame 516 table_str = ''.join(table_lines) 517 df = pd.read_csv(io.StringIO(table_str), 518 sep=r'\s+', engine='python') 519 520 return df 521 except Exception as e: 522 self.logger.warning(f'Cannot parse output from {output} file: {e}') 523 return None 524 525 526 ################################################ 527 ### --- IMPLEMENTATION OF MD CALCULATION --- ### 528 ################################################ 529 def calc_md( 530 self, 531 grace_model: Literal[*_grace_models] = "GRACE-2L-OMAT", 532 ensemble: Literal["NVT", "nvt", "Langevin", "langevin", "NPT", "npt"] = "NVT", 533 temperature: float = 300.0, 534 starting_temperature: int | None = None, 535 pressure: float | None = None, 536 time_step: float = 2.0, 537 n_ionic_steps: int = 100, 538 n_print: int = 10, 539 temperature_damping_timescale: float = 100.0, 540 pressure_damping_timescale: float | None = None, 541 trajectory: str = "md_run.traj", 542 logfile: str = "md_run.log", 543 random_seed: int | None = None, 544 mask: Tuple[int] | np.ndarray | None = None 545 ): 546 """ 547 Run a molecular dynamics simulation using a GRACE foundation model. 548 549 Parameters 550 ---------- 551 grace_model : Literal[*_grace_models], optional 552 GRACE model to use for the relaxation. 553 Must be one of the supported models, which can be obtained via 554 :meth:`available_models`. Default is ``"GRACE-2L-OMAT"``. 555 556 ensemble : {"NVT", "nvt", "Langevin", "langevin", "NPT", "npt"}, optional 557 Type of statistical ensemble: 558 - "NVT" or "Langevin": constant volume and temperature. 559 - "NPT": constant pressure and temperature. 560 Case-insensitive (default is "NVT"). 561 562 temperature : float, optional 563 Target simulation temperature in Kelvin (default is 300.0). 564 565 starting_temperature : int or None, optional 566 Initial temperature of the system in Kelvin. If None, uses 567 the target `temperature` (default is None). 568 569 pressure : float or None, optional 570 Target external pressure in GPa for NPT runs. Ignored if ensemble 571 is NVT or Langevin (default is None). 572 573 time_step : float, optional 574 Integration time step in femtoseconds (default is 2.0 fs). 575 576 n_ionic_steps : int, optional 577 Number of MD steps to perform (default is 100). 578 579 n_print : int, optional 580 Frequency of log printing and trajectory writing (default is 10). 581 582 temperature_damping_timescale : float, optional 583 Thermostat coupling time in femtoseconds (default is 100.0 fs). 584 585 pressure_damping_timescale : float or None, optional 586 Barostat coupling time in femtoseconds. Only relevant for NPT runs 587 (default is None). 588 589 trajectory : str, optional 590 Output trajectory file name (default is "md_run.traj"). 591 592 logfile : str, optional 593 Output log file name (default is "md_run.log"). 594 595 random_seed : int or None, optional 596 Seed for random velocity initialization. If None, system clock 597 is used (default is None). 598 599 mask : None, sequence of 3 ints (0/1), or (3,3) array-like, optional 600 Specify which components of the computational box (strain tensor) 601 are allowed to change under the barostat (default is None equalling 602 to *No mask*). Only relevant for NPT simulations; it has no effect 603 for NVT / Langevin ensembles. 604 """ 605 self._generic_input['calc_mode'] = 'md' 606 ensemble = ensemble.lower() 607 if ensemble in ['langevin', 'nvt']: #requested NVT, ignore NPT settings 608 pressure = None 609 mask = None 610 if pressure == None: 611 pressure_damping_timescale = None 612 super().calc_md( 613 temperature=temperature, 614 pressure=pressure, 615 n_ionic_steps=n_ionic_steps, 616 time_step=time_step, 617 n_print=n_print, 618 temperature_damping_timescale=temperature_damping_timescale, 619 pressure_damping_timescale=pressure_damping_timescale, 620 ) 621 self.input['model'] = grace_model 622 self.input['ensemble'] = ensemble.lower() 623 self.input['trajectory'] = trajectory 624 self.input['logfile'] = logfile 625 if starting_temperature is None: 626 starting_temperature = temperature 627 self.input['starting_temperature'] = starting_temperature 628 self.input['random_seed'] = random_seed 629 self.input['mask'] = mask 630 631 def _write_calc_md(self): 632 """ 633 Write the calculation script for performing molecular dynamics (MD). 634 635 This method generates a Python script that uses CHGNet for running MD 636 simulations and writes it to a file in the working directory. 637 """ 638 # Somewhat hidden feature of adding custom fixes 639 # TODO: Document? Make it a more "official" paramerer? 640 fixes = self.input.get('fixes', []) 641 if isinstance(fixes, str): 642 fixes = [fixes] 643 elif isinstance(fixes, list): 644 pass 645 else: 646 self.logger.warning( 647 'Fixes are neither string or array of strings. Ignoring') 648 fixes = [] 649 fix_imports = self.input.get('fix_imports', []) 650 if isinstance(fix_imports, str): 651 fix_imports = [fix_imports] 652 elif isinstance(fix_imports, list): 653 pass 654 else: 655 self.logger.warning( 656 'fix_imports are neither string or array of strings. Ignoring') 657 fix_imports = [] 658 659 script = [ 660 'from tensorpotential.calculator import grace_fm', 661 'from ase.md.velocitydistribution import MaxwellBoltzmannDistribution', 662 'from ase.io import read', 663 'from ase.io.cif import write_cif', 664 'from ase.io.trajectory import Trajectory', 665 'from ase import units' 666 ] 667 for f in fix_imports: 668 script += [f] 669 script += [ 670 '', 671 'struct = read("structure.cif")',] 672 for f in fixes: 673 script.append(f'struct.set_constraint({f})') 674 script += [ 675 f'calc = grace_fm("{self.input["model"]}")', 676 'struct.calc = calc', 677 '', 678 ] 679 if not self.input['random_seed'] == None: 680 script += [ 681 'import numpy as np', 682 f'np.random.seed({self.input["random_seed"]})', 683 ] 684 script += [ 685 f'MaxwellBoltzmannDistribution(struct, temperature_K={self.input["starting_temperature"]})', 686 '', 687 ] 688 if self.input['ensemble'] == 'langevin': 689 script += [ 690 'from ase.md.langevin import Langevin', 691 f'dyn = Langevin(struct, timestep={self._generic_input["time_step"]}, temperature_K={self._generic_input["temperature"]}, friction={self._generic_input["temperature_damping_timescale"]})', 692 ] 693 else: 694 pressure = self._generic_input["pressure"] 695 if not pressure == None: 696 pressure /= EV_PER_ANG3_TO_GPA 697 pfactor = self._generic_input["pressure_damping_timescale"] 698 if not pfactor == None: 699 pfactor = f'{pfactor}*units.fs' 700 script += [ 701 'from ase.md.npt import NPT', 702 f'dyn = NPT(struct, timestep={self._generic_input["time_step"]}*units.fs, temperature_K={self._generic_input["temperature"]}, ttime={self._generic_input["temperature_damping_timescale"]}*units.fs, externalstress={pressure}, pfactor={pfactor} , mask={self.input["mask"]})', 703 ] 704 script += [ 705 f'traj = Trajectory("{self.input["trajectory"]}", "w", struct)', 706 f'dyn.attach(traj.write, interval={self._generic_input["n_print"]})', 707 '', 708 f'logfile = open("{self.input["logfile"]}", "w")', 709 'logfile.write("step\\tTime\\tEtot\\tEpot\\tEkin\\tT\\tVol\\ta\\tb\\tc\\tsigma_xx\\tsigma_yy\\tsigma_zz\\tsigma_yz\\tsigma_xz\\tsigma_xy\\n")', 710 'def print_status():', 711 ' values = [dyn.nsteps, dyn.nsteps*dyn.dt]', 712 ' values += [struct.get_potential_energy()+struct.get_kinetic_energy()]', 713 ' values += [struct.get_potential_energy(), struct.get_kinetic_energy()]', 714 ' values += [struct.get_temperature()]', 715 ' values += [struct.get_volume()]+list(struct.cell.lengths())', 716 ' values += list(struct.get_stress())', 717 ' status = "\\t".join(str(v) for v in values)', 718 ' print(status)', 719 ' logfile.write(status+"\\n")', 720 ' logfile.flush()', 721 f'dyn.attach(print_status, interval={self._generic_input["n_print"]})', 722 '', 723 f'dyn.run({self._generic_input["n_ionic_steps"]})', 724 '', 725 'write_cif("final_structure.cif", struct)' 726 ] 727 728 with open(os.path.join(self.working_directory, 'calc_script.py'), 'w') as f: 729 f.writelines("\n".join(script)) 730 731 def _parse_calc_md(self, output: str = 'md_run.log') -> Optional[pd.DataFrame]: 732 """ 733 Parse the output of a molecular dynamics (MD) simulation. 734 735 This function reads the MD log file and returns the corresponding DataFrame 736 containing time, total energy, potential energy, kinetic energy, and temperature. 737 738 Parameters 739 ---------- 740 output : str, optional 741 The name of the MD log file to parse, by default 'md_run.log'. 742 743 Returns 744 ------- 745 pd.DataFrame or None 746 A DataFrame containing the MD simulation results, or None if parsing fails. 747 """ 748 try: 749 # df = pd.read_csv( 750 # os.path.join(self.working_directory, output), 751 # skiprows=1, 752 # sep=r'\s+' 753 # ) 754 df = pd.read_csv( 755 os.path.join(self.working_directory, output), 756 sep=r"\t+", 757 engine='python' 758 ) 759 return df 760 except: 761 self.logger.warning(f'Cannot parse output from {output} file.') 762 return None 763 764 ############################### 765 ### --- COMMON FUNCTION --- ### 766 ############################### 767 def write_input(self) -> None: 768 """ 769 Write input files required for the Grace calculation. 770 771 This function writes the structure file (`structure.cif`), stores the input 772 dictionary to the HDF5 format, and delegates further writing depending on the 773 calculation mode (MD or static/minimize). 774 775 Returns 776 ------- 777 None 778 """ 779 write(os.path.join(self.working_directory, 'structure.cif'), 780 pyiron_to_ase(self.structure)) 781 self.input.to_hdf(self.project_hdf5, group_name='input') 782 if self._generic_input['calc_mode'] == 'static': 783 self._write_calc_static() 784 elif self._generic_input['calc_mode'] == 'minimize': 785 self._write_calc_minimize() 786 elif self._generic_input['calc_mode'] == 'md': 787 self._write_calc_md() 788 else: 789 raise ValueError( 790 "No calculation type set. Use calc_static(), calc_minimize() or calc_md() first.") 791 792 def collect_output(self) -> None: 793 """ 794 Collect and store the results of a calculation. 795 796 This method parses the final structure, trajectory, and other output properties 797 from the calculation and stores them in HDF5 format. The internal structure 798 is updated, and various properties such as energies, structure, forces, and 799 stresses are saved to the output group. 800 801 Returns 802 ------- 803 None 804 """ 805 806 def voigt_to_tensor(s): 807 # s is a 6-element array from atoms.get_stress() 808 return np.array([ 809 [s[0], s[5], s[4]], 810 [s[5], s[1], s[3]], 811 [s[4], s[3], s[2]] 812 ]) 813 814 relaxed_ase_structure = read( 815 os.path.join(self.working_directory, 'final_structure.cif'), format='cif') 816 relaxed_pyiron_structure = ase_to_pyiron(relaxed_ase_structure) 817 818 # Store the relaxed structure in the HDF5 group 819 with self.project_hdf5.open('output') as h5out: 820 h5out['structure'] = relaxed_pyiron_structure 821 self.structure = relaxed_pyiron_structure # Update the job's structure 822 823 # Parse trajectory from minimize 824 if self._generic_input['calc_mode'] in ['minimize', 'md']: 825 # For minimize and md, we expect a trajectory file with the relaxed structure 826 if self._generic_input['calc_mode'] == 'minimize': 827 traj_file = self.input['save_path'] 828 traj = Trajectory(os.path.join(self.working_directory, traj_file)) 829 else: 830 traj_file = self.input['trajectory'] 831 traj = read(os.path.join(self.working_directory, traj_file), index=":") 832 try: 833 with self.project_hdf5.open('output/generic') as h5out: 834 h5out['cells'] = np.array([s.get_cell() for s in traj]) 835 h5out['positions'] = np.array([s.positions for s in traj]) 836 h5out['stresses'] = np.array( 837 [voigt_to_tensor(s.get_stress())*EV_PER_ANG3_TO_GPA for s in traj]) 838 h5out['forces'] = np.array([s.get_forces() for s in traj]) 839 except: 840 self.logger.warning( 841 f'Cannot parse output from {traj_file} file.') 842 843 # Parse other properties 844 if self._generic_input['calc_mode'] == 'static': 845 df = self._parse_calc_static() 846 df['max_force'] = df['forces'].apply(lambda x: np.max(x)) 847 with self.project_hdf5.open('output/generic') as h5out: 848 h5out['energy_pot'] = df['energy_pot'].values 849 h5out['energy_tot'] = df['energy_pot'].values 850 h5out['forces'] = df['forces'].values 851 h5out['max_force'] = df['max_force'].values 852 h5out['stresses'] = np.array( 853 [voigt_to_tensor(s)*EV_PER_ANG3_TO_GPA for s in df['stresses'].values]) 854 h5out['steps'] = df['step'].values 855 h5out['cells'] = np.array([relaxed_pyiron_structure.get_cell()]) 856 h5out['positions'] = np.array([relaxed_pyiron_structure.positions]) 857 elif self._generic_input['calc_mode'] == 'minimize': 858 df = self._parse_calc_minimize() 859 with self.project_hdf5.open('output/generic') as h5out: 860 h5out['energy_pot'] = df['Energy'].values 861 h5out['energy_tot'] = df['Energy'].values 862 h5out['max_force'] = df['fmax'].values 863 h5out['steps'] = df['Step'].values 864 elif self._generic_input['calc_mode'] == 'md': 865 df = self._parse_calc_md(self.input['logfile']) 866 with self.project_hdf5.open('output/generic') as h5out: 867 h5out['energy_pot'] = df['Epot'].values 868 h5out['energy_tot'] = df['Etot'].values 869 h5out['energy_kin'] = df['Ekin'].values 870 h5out['temperature'] = df['T'].values 871 h5out['time'] = df['Time'].values 872 h5out['steps'] = df.index.values 873 874 self.compress() 875 self.status.finished = True 876 877 def get_structure(self, frame: int = -1) -> Atoms: 878 """ 879 Retrieve the structure at a given frame from the calculation output. 880 881 This function returns the atomic structure (positions, cell, species) at the 882 specified frame index. The frame index is adjusted to ensure it is valid. 883 884 Parameters 885 ---------- 886 frame : int, optional 887 The frame index to retrieve. If negative, it is counted from the last frame, 888 by default -1. 889 890 Returns 891 ------- 892 Atoms 893 The ASE Atoms object representing the structure at the given frame. 894 895 Raises 896 ------ 897 ValueError 898 If no structures are found in the job output. 899 IndexError 900 If the frame index is out of range. 901 """ 902 num_structures = self.number_of_structures 903 if num_structures == 0: 904 raise ValueError("No structures found in the job output.") 905 906 # Ensure frame is valid 907 if frame < 0: 908 frame += num_structures 909 if frame >= num_structures: 910 raise IndexError( 911 f"Frame {frame} is out of range for {num_structures} structures.") 912 913 with self.project_hdf5.open('output/generic') as h5out: 914 positions = h5out["positions"][frame] 915 cell = h5out["cells"][frame] 916 # Read positions and cell 917 with self.project_hdf5.open('output/structure') as h5out: 918 species = h5out["species"] 919 indices = h5out["indices"] 920 921 # Generate symbols list 922 symbols = [species[i] for i in indices] 923 return ase_to_pyiron(Atoms(symbols=symbols, positions=positions, cell=cell, pbc=True)) 924 925 def compress(self, files_to_compress=None): 926 """ 927 Compress the output files of a job object. 928 929 Args: 930 files_to_compress (list): A list of files to compress (optional) 931 """ 932 import os 933 # delete empty files 934 for f in self.files.list(): 935 filename = os.path.join(self.working_directory, f) 936 if (os.path.exists(filename) and os.stat(filename).st_size == 0): 937 os.remove(filename) 938 # delete slurm scripts 939 if "run_queue.sh" in self.files.list(): 940 filename = os.path.join(self.working_directory, "run_queue.sh") 941 os.remove(filename) 942 # compress all remaining files 943 if files_to_compress is None: 944 files_to_compress = [ 945 f 946 for f in self.files.list() 947 if f 948 not in [ 949 "final_structure.cif", 950 ] 951 ] 952 953 super(Grace, self).compress(files_to_compress=files_to_compress) 954 955 956class CalcParams(GenericParameters): 957 """ 958 Class to manage the calculation parameters for Grace calculations. 959 960 This class extends from `GenericParameters` and is used to define and manage 961 parameters specific to Grace calculations. It allows for initialization of 962 parameters and stores them in a table, with a default table name of "grace". 963 The class can be used to load and store various Grace calculation settings and 964 other related configurations. 965 966 Parameters 967 ---------- 968 table_name : str, optional 969 The name of the table where the parameters are stored. By default, it is set 970 to "grace", but it can be customized if necessary. 971 972 Methods 973 ------- 974 __init__(self, table_name="grace") 975 Initializes the parameters for the calculation. 976 """ 977 978 def __init__(self, table_name: str = "grace") -> None: 979 """ 980 Initialize the CalcParams object with the given table name. 981 982 Parameters 983 ---------- 984 table_name : str, optional 985 The name of the table where the parameters are stored. By default, 986 it is set to "grace". 987 """ 988 super(CalcParams, self).__init__( 989 table_name=table_name, 990 )
41class Grace(AtomisticGenericJob): 42 """ 43 Custom job class for using Grace within the pyiron framework. 44 45 This class provides methods to perform static calculations, structure relaxation, 46 and molecular dynamics simulations using Grace UMLIPs. 47 48 Attributes 49 ---------- 50 structure : pyiron.atomistics.structure.atoms.Atoms 51 Structure associated with the job. 52 input : CalcParams 53 Input parameters for the Grace calculations. 54 """ 55 56 def __init__(self, project, job_name): 57 """ 58 Initialize the Grace job. 59 60 Parameters 61 ---------- 62 project : pyiron.Project 63 The pyiron project in which the job resides. 64 job_name : str 65 Name of the job. 66 """ 67 super(Grace, self).__init__(project, job_name) 68 self.structure = None 69 self.input = CalcParams() 70 if self.project_hdf5.file_exists: 71 self.input.from_hdf(self.project_hdf5, group_name='input') 72 # executable is in pyiron_resources/grace/bin 73 self._executable_activate(codename='grace') 74 75 @property 76 def fmax(self) -> float: 77 """ 78 Maximum force tolerance for ionic relaxation. 79 80 This property controls the maximum force tolerance (in eV/Å) used during ionic relaxation. 81 82 Returns 83 ------- 84 float 85 Maximum force tolerance (eV/Å). 86 """ 87 return self._generic_input['ionic_force_tolerance'] 88 89 @fmax.setter 90 def fmax(self, fmax: float): 91 """ 92 Set the maximum force tolerance for ionic relaxation. 93 94 Parameters 95 ---------- 96 fmax : float 97 Maximum force tolerance (eV/Å). 98 """ 99 self._generic_input['ionic_force_tolerance'] = fmax 100 101 @property 102 def max_iter(self) -> int: 103 """ 104 Get the maximum number of iterations for relaxation or MD run. 105 106 Returns 107 ------- 108 int 109 Maximum number of iterations. 110 """ 111 return self._generic_input['max_iter'] 112 113 @max_iter.setter 114 def max_iter(self, max_iter: int): 115 """ 116 Set the maximum number of iterations for relaxation or MD run. 117 118 Parameters 119 ---------- 120 max_iter : int 121 Maximum number of iterations. 122 """ 123 self._generic_input['max_iter'] = max_iter 124 125 @property 126 def model(self) -> str: 127 """ 128 Return the current GRACE model. 129 130 Returns 131 ------- 132 str 133 The selected GRACE model name. 134 """ 135 return self.input['model'] 136 137 138 @model.setter 139 def model(self, model: str): 140 """ 141 Set the GRACE model name. 142 143 Parameters 144 ---------- 145 model : str 146 Name of the GRACE model to use. Must be one of the supported 147 GRACE model identifiers: 148 149 - "GRACE-1L-MP-r6" 150 - "GRACE-2L-MP-r5" 151 - "GRACE-2L-MP-r6" 152 - "GRACE-FS-OAM" 153 - "GRACE-1L-OAM" 154 - "GRACE-2L-OAM" 155 - "GRACE-FS-OMAT" 156 - "GRACE-1L-OMAT" 157 - "GRACE-2L-OMAT" 158 - "GRACE-1L-OMAT-medium-base" 159 - "GRACE-1L-OMAT-medium-ft-E" 160 - "GRACE-1L-OMAT-large-base" 161 - "GRACE-1L-OMAT-large-ft-E" 162 - "GRACE-2L-OMAT-medium-base" 163 - "GRACE-2L-OMAT-medium-ft-E" 164 - "GRACE-2L-OMAT-large-base" 165 - "GRACE-2L-OMAT-large-ft-E" 166 167 Raises 168 ------ 169 ValueError 170 If `model` is not a valid GRACE model identifier. 171 """ 172 if model not in _grace_models: 173 raise ValueError(f"Unknown grace model: {model}") 174 self.input["model"] = model 175 176 177 def available_models(self): 178 """ 179 Return the list of supported GRACE models. 180 181 Returns 182 ------- 183 list of str 184 All valid GRACE model identifiers. 185 """ 186 return _grace_models 187 188 189 #################################################### 190 ### --- IMPLEMENTATION OF STATIC CALCULATION --- ### 191 #################################################### 192 def calc_static( 193 self, 194 grace_model: Literal[*_grace_models] = "GRACE-2L-OMAT", 195 ): 196 """ 197 Perform a static calculation. 198 199 Parameters 200 ---------- 201 grace_model : Literal[*_grace_models], optional 202 GRACE model to use for the relaxation. 203 Must be one of the supported models, which can be obtained via 204 :meth:`available_models`. Default is ``"GRACE-2L-OMAT"``. 205 206 Raises 207 ------ 208 ValueError 209 If the structure has not been set before calling this method. 210 211 Notes 212 ----- 213 This method configures the GRACE calculator for a static calculation 214 using the provided GRACE model. 215 A structure must be assigned to the instance prior to calling 216 ``calc_static``. 217 218 Returns 219 ------- 220 None 221 """ 222 if self.structure is None: 223 raise ValueError( 224 'Structure must be set before calling calc_static().') 225 self._generic_input['calc_mode'] = 'static' 226 self.model = grace_model 227 228 229 def _write_calc_static(self): 230 """ 231 Write the calculation script for performing static calculations. 232 233 This method generates a Python script that uses Grace for static calculations 234 and writes it to a file in the working directory. 235 """ 236 model = self.input["model"] 237 238 script = [ 239 'from tensorpotential.calculator import grace_fm', 240 'from ase.io import read', 241 'from ase.io.cif import write_cif', 242 '', 243 'initial_structure = read("structure.cif")', 244 f'initial_structure.calc = grace_fm("{model}")', 245 '', 246 '# perform the static calculation', 247 'energy_pot = initial_structure.get_total_energy()', 248 'forces = initial_structure.get_forces()', 249 'stresses = initial_structure.get_stress()', 250 '', 251 '# write the results to an output file', 252 'with open("log.out", "w") as f:', 253 ' f.write("step\tenergy_pot\tforces\tstresses\\n")', 254 ' f.write(f"1\\t{energy_pot}\\t{forces.tolist()}\\t{stresses.tolist()}\\n")', 255 '', 256 '# write the final structure to a CIF file', 257 'write_cif("final_structure.cif", initial_structure)', 258 ] 259 with open(os.path.join(self.working_directory, 'calc_script.py'), 'w') as f: 260 f.writelines("\n".join(script)) 261 262 def _parse_calc_static( 263 self, 264 output: str = 'log.out', 265 skip: int = 0 266 ) -> Optional[pd.DataFrame]: 267 """ 268 Parse the output of a static calculation and return optimization results. 269 270 This method reads a static calculation output file and extracts the 271 optimization steps and energy information, returning them as a DataFrame. 272 273 Parameters 274 ---------- 275 output : str, optional 276 Name of the output file to parse. Default is 'log.out'. 277 skip : int, optional 278 Number of rows to skip at the start of the file before parsing. Default is 0. 279 280 Returns 281 ------- 282 pd.DataFrame or None 283 A DataFrame containing the optimization results if parsing succeeds, 284 otherwise None. 285 """ 286 287 try: 288 df = pd.read_csv( 289 os.path.join(self.working_directory, output), 290 sep=r"\t+", 291 engine='python' 292 ) 293 # convert strings to np.array 294 df['forces'] = df['forces'].apply(lambda x: np.array(ast.literal_eval(x))) 295 df['stresses'] = df['stresses'].apply(lambda x: np.array(ast.literal_eval(x))) 296 return df 297 except: 298 self.logger.warning(f'Cannot parse output from {output} file.') 299 return None 300 301 302 ########################################################## 303 ### --- IMPLEMENTATION OF MINIMIZATION CALCULATION --- ### 304 ########################################################## 305 306 def calc_minimize( 307 self, 308 grace_model: Literal[*_grace_models] = "GRACE-2L-OMAT", 309 algorithm: Literal["FIRE", "BFGS", "LBFGS"] = "FIRE", 310 algorithm_kwargs: dict | None = None, 311 relax_cell: bool = False, 312 relax_cell_kwargs: dict | None = None, 313 ionic_force_tolerance: float = 0.001, 314 max_iter: int = 500, 315 n_print: int = 1, 316 save_path: str | None = "relax.traj", 317 ) -> None: 318 """ 319 Perform a structure relaxation (geometry optimization). 320 321 Parameters 322 ---------- 323 grace_model : Literal[*_grace_models], optional 324 GRACE model to use for the relaxation. 325 Must be one of the supported models, which can be obtained via 326 :meth:`available_models`. Default is ``"GRACE-2L-OMAT"``. 327 328 algorithm : {"FIRE", "BFGS", "LBFGS"}, optional 329 Ionic relaxation algorithm from ASE. 330 Default is ``"FIRE"``. 331 332 algorithm_kwargs : dict, optional 333 Additional keyword arguments passed to the relaxation algorithm. 334 Default is ``None``. 335 336 relax_cell : bool, optional 337 If ``True``, optimize the simulation cell in addition to ionic positions. 338 Default is ``False``. 339 340 relax_cell_kwargs : dict, optional 341 Additional keyword arguments for cell relaxation. 342 Default is ``None``. 343 344 ionic_force_tolerance : float, optional 345 Force convergence criterion in eV/Å. 346 Default is ``0.001``. 347 348 max_iter : int, optional 349 Maximum number of optimization steps. 350 Default is ``500``. 351 352 n_print : int, optional 353 Logging frequency for structures and stresses. 354 Default is ``1``. 355 356 save_path : str or None, optional 357 Path to save the relaxation trajectory. 358 If ``None``, no trajectory is written. 359 Default is ``"relax.traj"``. 360 361 Returns 362 ------- 363 None 364 """ 365 366 super().calc_minimize( 367 ionic_energy_tolerance=0, 368 ionic_force_tolerance=ionic_force_tolerance, 369 max_iter=max_iter, 370 ) 371 372 # Saving the parameters in dictionary 373 self._generic_input['calc_mode'] = 'minimize' 374 self.input['model'] = grace_model 375 self.input['algo'] = algorithm 376 self.input['algo_kwargs'] = algorithm_kwargs or {} 377 self.input['relax_cell'] = relax_cell 378 self.input['relax_cell_kwargs'] = relax_cell_kwargs or {} 379 self._generic_input['ionic_force_tolerance'] = ionic_force_tolerance 380 self._generic_input['n_print'] = n_print 381 self._generic_input['max_iter'] = max_iter 382 self.input['save_path'] = save_path 383 384 def _write_calc_minimize(self): 385 """ 386 Write the calculation script for performing structure relaxation. 387 388 This method generates a Python script that uses Grace for structure 389 relaxation and writes it to a file in the working directory. 390 """ 391 392 model = self.input['model'] # Grace model 393 algo = self.input.get('algo', 'FIRE') # ASE optimizer 394 if algo not in ['FIRE', 'BFGS', 'LBFGS']: 395 raise ValueError( 396 f"Unsupported optimizer: {algo}. Supported optimizers are: FIRE, BFGS, LBFGS.") 397 # ASE optimizer kwargs 398 algo_kwargs = self.input.get('algo_kwargs', {}).copy() 399 save_path = self.input.get('save_path', 'relax.traj') # Add trajectory parameter 400 algo_kwargs['trajectory'] = f"{save_path}" 401 algo_kwargs['loginterval'] = self._generic_input['n_print'] 402 # Reformat dictionary to string 403 algo_kwargs_str = ", ".join( 404 f'{k}="{v}"' if isinstance(v, str) else f"{k}={v}" 405 for k, v in algo_kwargs.items() 406 ) 407 408 # Cell relaxation 409 relax_cell = self.input.get('relax_cell', False) 410 # Cell relaxation kwargs ---> see ase.constraints.ExpCellFilter 411 relax_cell_kwargs = self.input.get('relax_cell_kwargs', {}) 412 # Reformat dictionary to string 413 relax_cell_kwargs_str = ", ".join( 414 f'{k}="{v}"' if isinstance(v, str) else f"{k}={v}" 415 for k, v in relax_cell_kwargs.items() 416 ) 417 418 # Set parameters for minimization 419 params_run = f"fmax={self._generic_input['ionic_force_tolerance']}, steps={self._generic_input['max_iter']}" 420 421 # Save parameters in 422 self._generic_input['relax_cell'] = relax_cell 423 self._generic_input['save_path'] = save_path 424 425 # Somewhat hidden feature of adding custom fixes 426 # TODO: Document? Make it a more "official" paramerer? 427 fixes = self.input.get('fixes', []) 428 if isinstance(fixes, str): 429 fixes = [fixes] 430 elif isinstance(fixes, list): 431 pass 432 else: 433 self.logger.warning('Fixes are neither string or array of strings. Ignoring') 434 fixes = [] 435 fix_imports = self.input.get('fix_imports', []) 436 if isinstance(fix_imports, str): 437 fix_imports = [fix_imports] 438 elif isinstance(fix_imports, list): 439 pass 440 else: 441 self.logger.warning('Fix imports are neither string or array of strings. Ignoring') 442 fix_imports = [] 443 444 script = [ 445 'from tensorpotential.calculator import grace_fm', 446 'from ase.io import read', 447 'from ase.io.cif import write_cif', 448 'from ase.io.trajectory import Trajectory', 449 f'from ase.optimize import {algo}', 450 ] 451 if relax_cell: 452 script += ['from ase.constraints import ExpCellFilter as ECF'] 453 for f in fix_imports: 454 script += [f] 455 script += [''] 456 457 script += [ 458 f'struct = read("structure.cif")', 459 f'struct.calc = grace_fm("{model}")' 460 ] 461 for f in fixes: 462 script.append(f'struct.set_constraint({f})') 463 script += [''] 464 465 if not relax_cell: 466 script += [ 467 f'relaxation = {algo.upper()}(atoms=struct, {algo_kwargs_str}).run({params_run})',] 468 else: 469 script += [ 470 f'relaxation = {algo.upper()}(ECF(atoms=struct, {relax_cell_kwargs_str}), {algo_kwargs_str}).run({params_run})'] 471 script += [''] 472 473 script += ['write_cif("final_structure.cif", struct)'] 474 475 with open(os.path.join(self.working_directory, 'calc_script.py'), 'w') as f: 476 f.writelines("\n".join(script)) 477 478 def _parse_calc_minimize(self, output: str = 'error.out') -> Optional[pd.DataFrame]: 479 """ 480 Parse the output of a minimization. 481 482 This function reads the minimization output file and returns the 483 corresponding DataFrame containing the optimization step and energy information. 484 485 Parameters 486 ---------- 487 output : str, optional 488 The name of the output file to parse, by default 'error.out'. 489 490 Returns 491 ------- 492 pd.DataFrame or None 493 A DataFrame containing the optimization results, or None if parsing fails. 494 """ 495 # ASE optimizer 496 algo = self.input.get('algo', 'FIRE') 497 498 try: 499 # Find the header line dynamically 500 with open(os.path.join(self.working_directory, output), 'r') as f: 501 lines = f.readlines() 502 for i, line in enumerate(lines): 503 if line.strip().startswith('Step'): 504 header_idx = i 505 break 506 else: 507 self.logger.warning(f'No table header found in {output}.') 508 return None 509 510 table_lines = [] 511 # Ensure that the line contains the expected algorithm 512 for line in lines[header_idx:]: 513 if algo in line.strip() or "Step" in line.strip(): 514 table_lines.append(line) 515 516 # Read table into DataFrame 517 table_str = ''.join(table_lines) 518 df = pd.read_csv(io.StringIO(table_str), 519 sep=r'\s+', engine='python') 520 521 return df 522 except Exception as e: 523 self.logger.warning(f'Cannot parse output from {output} file: {e}') 524 return None 525 526 527 ################################################ 528 ### --- IMPLEMENTATION OF MD CALCULATION --- ### 529 ################################################ 530 def calc_md( 531 self, 532 grace_model: Literal[*_grace_models] = "GRACE-2L-OMAT", 533 ensemble: Literal["NVT", "nvt", "Langevin", "langevin", "NPT", "npt"] = "NVT", 534 temperature: float = 300.0, 535 starting_temperature: int | None = None, 536 pressure: float | None = None, 537 time_step: float = 2.0, 538 n_ionic_steps: int = 100, 539 n_print: int = 10, 540 temperature_damping_timescale: float = 100.0, 541 pressure_damping_timescale: float | None = None, 542 trajectory: str = "md_run.traj", 543 logfile: str = "md_run.log", 544 random_seed: int | None = None, 545 mask: Tuple[int] | np.ndarray | None = None 546 ): 547 """ 548 Run a molecular dynamics simulation using a GRACE foundation model. 549 550 Parameters 551 ---------- 552 grace_model : Literal[*_grace_models], optional 553 GRACE model to use for the relaxation. 554 Must be one of the supported models, which can be obtained via 555 :meth:`available_models`. Default is ``"GRACE-2L-OMAT"``. 556 557 ensemble : {"NVT", "nvt", "Langevin", "langevin", "NPT", "npt"}, optional 558 Type of statistical ensemble: 559 - "NVT" or "Langevin": constant volume and temperature. 560 - "NPT": constant pressure and temperature. 561 Case-insensitive (default is "NVT"). 562 563 temperature : float, optional 564 Target simulation temperature in Kelvin (default is 300.0). 565 566 starting_temperature : int or None, optional 567 Initial temperature of the system in Kelvin. If None, uses 568 the target `temperature` (default is None). 569 570 pressure : float or None, optional 571 Target external pressure in GPa for NPT runs. Ignored if ensemble 572 is NVT or Langevin (default is None). 573 574 time_step : float, optional 575 Integration time step in femtoseconds (default is 2.0 fs). 576 577 n_ionic_steps : int, optional 578 Number of MD steps to perform (default is 100). 579 580 n_print : int, optional 581 Frequency of log printing and trajectory writing (default is 10). 582 583 temperature_damping_timescale : float, optional 584 Thermostat coupling time in femtoseconds (default is 100.0 fs). 585 586 pressure_damping_timescale : float or None, optional 587 Barostat coupling time in femtoseconds. Only relevant for NPT runs 588 (default is None). 589 590 trajectory : str, optional 591 Output trajectory file name (default is "md_run.traj"). 592 593 logfile : str, optional 594 Output log file name (default is "md_run.log"). 595 596 random_seed : int or None, optional 597 Seed for random velocity initialization. If None, system clock 598 is used (default is None). 599 600 mask : None, sequence of 3 ints (0/1), or (3,3) array-like, optional 601 Specify which components of the computational box (strain tensor) 602 are allowed to change under the barostat (default is None equalling 603 to *No mask*). Only relevant for NPT simulations; it has no effect 604 for NVT / Langevin ensembles. 605 """ 606 self._generic_input['calc_mode'] = 'md' 607 ensemble = ensemble.lower() 608 if ensemble in ['langevin', 'nvt']: #requested NVT, ignore NPT settings 609 pressure = None 610 mask = None 611 if pressure == None: 612 pressure_damping_timescale = None 613 super().calc_md( 614 temperature=temperature, 615 pressure=pressure, 616 n_ionic_steps=n_ionic_steps, 617 time_step=time_step, 618 n_print=n_print, 619 temperature_damping_timescale=temperature_damping_timescale, 620 pressure_damping_timescale=pressure_damping_timescale, 621 ) 622 self.input['model'] = grace_model 623 self.input['ensemble'] = ensemble.lower() 624 self.input['trajectory'] = trajectory 625 self.input['logfile'] = logfile 626 if starting_temperature is None: 627 starting_temperature = temperature 628 self.input['starting_temperature'] = starting_temperature 629 self.input['random_seed'] = random_seed 630 self.input['mask'] = mask 631 632 def _write_calc_md(self): 633 """ 634 Write the calculation script for performing molecular dynamics (MD). 635 636 This method generates a Python script that uses CHGNet for running MD 637 simulations and writes it to a file in the working directory. 638 """ 639 # Somewhat hidden feature of adding custom fixes 640 # TODO: Document? Make it a more "official" paramerer? 641 fixes = self.input.get('fixes', []) 642 if isinstance(fixes, str): 643 fixes = [fixes] 644 elif isinstance(fixes, list): 645 pass 646 else: 647 self.logger.warning( 648 'Fixes are neither string or array of strings. Ignoring') 649 fixes = [] 650 fix_imports = self.input.get('fix_imports', []) 651 if isinstance(fix_imports, str): 652 fix_imports = [fix_imports] 653 elif isinstance(fix_imports, list): 654 pass 655 else: 656 self.logger.warning( 657 'fix_imports are neither string or array of strings. Ignoring') 658 fix_imports = [] 659 660 script = [ 661 'from tensorpotential.calculator import grace_fm', 662 'from ase.md.velocitydistribution import MaxwellBoltzmannDistribution', 663 'from ase.io import read', 664 'from ase.io.cif import write_cif', 665 'from ase.io.trajectory import Trajectory', 666 'from ase import units' 667 ] 668 for f in fix_imports: 669 script += [f] 670 script += [ 671 '', 672 'struct = read("structure.cif")',] 673 for f in fixes: 674 script.append(f'struct.set_constraint({f})') 675 script += [ 676 f'calc = grace_fm("{self.input["model"]}")', 677 'struct.calc = calc', 678 '', 679 ] 680 if not self.input['random_seed'] == None: 681 script += [ 682 'import numpy as np', 683 f'np.random.seed({self.input["random_seed"]})', 684 ] 685 script += [ 686 f'MaxwellBoltzmannDistribution(struct, temperature_K={self.input["starting_temperature"]})', 687 '', 688 ] 689 if self.input['ensemble'] == 'langevin': 690 script += [ 691 'from ase.md.langevin import Langevin', 692 f'dyn = Langevin(struct, timestep={self._generic_input["time_step"]}, temperature_K={self._generic_input["temperature"]}, friction={self._generic_input["temperature_damping_timescale"]})', 693 ] 694 else: 695 pressure = self._generic_input["pressure"] 696 if not pressure == None: 697 pressure /= EV_PER_ANG3_TO_GPA 698 pfactor = self._generic_input["pressure_damping_timescale"] 699 if not pfactor == None: 700 pfactor = f'{pfactor}*units.fs' 701 script += [ 702 'from ase.md.npt import NPT', 703 f'dyn = NPT(struct, timestep={self._generic_input["time_step"]}*units.fs, temperature_K={self._generic_input["temperature"]}, ttime={self._generic_input["temperature_damping_timescale"]}*units.fs, externalstress={pressure}, pfactor={pfactor} , mask={self.input["mask"]})', 704 ] 705 script += [ 706 f'traj = Trajectory("{self.input["trajectory"]}", "w", struct)', 707 f'dyn.attach(traj.write, interval={self._generic_input["n_print"]})', 708 '', 709 f'logfile = open("{self.input["logfile"]}", "w")', 710 'logfile.write("step\\tTime\\tEtot\\tEpot\\tEkin\\tT\\tVol\\ta\\tb\\tc\\tsigma_xx\\tsigma_yy\\tsigma_zz\\tsigma_yz\\tsigma_xz\\tsigma_xy\\n")', 711 'def print_status():', 712 ' values = [dyn.nsteps, dyn.nsteps*dyn.dt]', 713 ' values += [struct.get_potential_energy()+struct.get_kinetic_energy()]', 714 ' values += [struct.get_potential_energy(), struct.get_kinetic_energy()]', 715 ' values += [struct.get_temperature()]', 716 ' values += [struct.get_volume()]+list(struct.cell.lengths())', 717 ' values += list(struct.get_stress())', 718 ' status = "\\t".join(str(v) for v in values)', 719 ' print(status)', 720 ' logfile.write(status+"\\n")', 721 ' logfile.flush()', 722 f'dyn.attach(print_status, interval={self._generic_input["n_print"]})', 723 '', 724 f'dyn.run({self._generic_input["n_ionic_steps"]})', 725 '', 726 'write_cif("final_structure.cif", struct)' 727 ] 728 729 with open(os.path.join(self.working_directory, 'calc_script.py'), 'w') as f: 730 f.writelines("\n".join(script)) 731 732 def _parse_calc_md(self, output: str = 'md_run.log') -> Optional[pd.DataFrame]: 733 """ 734 Parse the output of a molecular dynamics (MD) simulation. 735 736 This function reads the MD log file and returns the corresponding DataFrame 737 containing time, total energy, potential energy, kinetic energy, and temperature. 738 739 Parameters 740 ---------- 741 output : str, optional 742 The name of the MD log file to parse, by default 'md_run.log'. 743 744 Returns 745 ------- 746 pd.DataFrame or None 747 A DataFrame containing the MD simulation results, or None if parsing fails. 748 """ 749 try: 750 # df = pd.read_csv( 751 # os.path.join(self.working_directory, output), 752 # skiprows=1, 753 # sep=r'\s+' 754 # ) 755 df = pd.read_csv( 756 os.path.join(self.working_directory, output), 757 sep=r"\t+", 758 engine='python' 759 ) 760 return df 761 except: 762 self.logger.warning(f'Cannot parse output from {output} file.') 763 return None 764 765 ############################### 766 ### --- COMMON FUNCTION --- ### 767 ############################### 768 def write_input(self) -> None: 769 """ 770 Write input files required for the Grace calculation. 771 772 This function writes the structure file (`structure.cif`), stores the input 773 dictionary to the HDF5 format, and delegates further writing depending on the 774 calculation mode (MD or static/minimize). 775 776 Returns 777 ------- 778 None 779 """ 780 write(os.path.join(self.working_directory, 'structure.cif'), 781 pyiron_to_ase(self.structure)) 782 self.input.to_hdf(self.project_hdf5, group_name='input') 783 if self._generic_input['calc_mode'] == 'static': 784 self._write_calc_static() 785 elif self._generic_input['calc_mode'] == 'minimize': 786 self._write_calc_minimize() 787 elif self._generic_input['calc_mode'] == 'md': 788 self._write_calc_md() 789 else: 790 raise ValueError( 791 "No calculation type set. Use calc_static(), calc_minimize() or calc_md() first.") 792 793 def collect_output(self) -> None: 794 """ 795 Collect and store the results of a calculation. 796 797 This method parses the final structure, trajectory, and other output properties 798 from the calculation and stores them in HDF5 format. The internal structure 799 is updated, and various properties such as energies, structure, forces, and 800 stresses are saved to the output group. 801 802 Returns 803 ------- 804 None 805 """ 806 807 def voigt_to_tensor(s): 808 # s is a 6-element array from atoms.get_stress() 809 return np.array([ 810 [s[0], s[5], s[4]], 811 [s[5], s[1], s[3]], 812 [s[4], s[3], s[2]] 813 ]) 814 815 relaxed_ase_structure = read( 816 os.path.join(self.working_directory, 'final_structure.cif'), format='cif') 817 relaxed_pyiron_structure = ase_to_pyiron(relaxed_ase_structure) 818 819 # Store the relaxed structure in the HDF5 group 820 with self.project_hdf5.open('output') as h5out: 821 h5out['structure'] = relaxed_pyiron_structure 822 self.structure = relaxed_pyiron_structure # Update the job's structure 823 824 # Parse trajectory from minimize 825 if self._generic_input['calc_mode'] in ['minimize', 'md']: 826 # For minimize and md, we expect a trajectory file with the relaxed structure 827 if self._generic_input['calc_mode'] == 'minimize': 828 traj_file = self.input['save_path'] 829 traj = Trajectory(os.path.join(self.working_directory, traj_file)) 830 else: 831 traj_file = self.input['trajectory'] 832 traj = read(os.path.join(self.working_directory, traj_file), index=":") 833 try: 834 with self.project_hdf5.open('output/generic') as h5out: 835 h5out['cells'] = np.array([s.get_cell() for s in traj]) 836 h5out['positions'] = np.array([s.positions for s in traj]) 837 h5out['stresses'] = np.array( 838 [voigt_to_tensor(s.get_stress())*EV_PER_ANG3_TO_GPA for s in traj]) 839 h5out['forces'] = np.array([s.get_forces() for s in traj]) 840 except: 841 self.logger.warning( 842 f'Cannot parse output from {traj_file} file.') 843 844 # Parse other properties 845 if self._generic_input['calc_mode'] == 'static': 846 df = self._parse_calc_static() 847 df['max_force'] = df['forces'].apply(lambda x: np.max(x)) 848 with self.project_hdf5.open('output/generic') as h5out: 849 h5out['energy_pot'] = df['energy_pot'].values 850 h5out['energy_tot'] = df['energy_pot'].values 851 h5out['forces'] = df['forces'].values 852 h5out['max_force'] = df['max_force'].values 853 h5out['stresses'] = np.array( 854 [voigt_to_tensor(s)*EV_PER_ANG3_TO_GPA for s in df['stresses'].values]) 855 h5out['steps'] = df['step'].values 856 h5out['cells'] = np.array([relaxed_pyiron_structure.get_cell()]) 857 h5out['positions'] = np.array([relaxed_pyiron_structure.positions]) 858 elif self._generic_input['calc_mode'] == 'minimize': 859 df = self._parse_calc_minimize() 860 with self.project_hdf5.open('output/generic') as h5out: 861 h5out['energy_pot'] = df['Energy'].values 862 h5out['energy_tot'] = df['Energy'].values 863 h5out['max_force'] = df['fmax'].values 864 h5out['steps'] = df['Step'].values 865 elif self._generic_input['calc_mode'] == 'md': 866 df = self._parse_calc_md(self.input['logfile']) 867 with self.project_hdf5.open('output/generic') as h5out: 868 h5out['energy_pot'] = df['Epot'].values 869 h5out['energy_tot'] = df['Etot'].values 870 h5out['energy_kin'] = df['Ekin'].values 871 h5out['temperature'] = df['T'].values 872 h5out['time'] = df['Time'].values 873 h5out['steps'] = df.index.values 874 875 self.compress() 876 self.status.finished = True 877 878 def get_structure(self, frame: int = -1) -> Atoms: 879 """ 880 Retrieve the structure at a given frame from the calculation output. 881 882 This function returns the atomic structure (positions, cell, species) at the 883 specified frame index. The frame index is adjusted to ensure it is valid. 884 885 Parameters 886 ---------- 887 frame : int, optional 888 The frame index to retrieve. If negative, it is counted from the last frame, 889 by default -1. 890 891 Returns 892 ------- 893 Atoms 894 The ASE Atoms object representing the structure at the given frame. 895 896 Raises 897 ------ 898 ValueError 899 If no structures are found in the job output. 900 IndexError 901 If the frame index is out of range. 902 """ 903 num_structures = self.number_of_structures 904 if num_structures == 0: 905 raise ValueError("No structures found in the job output.") 906 907 # Ensure frame is valid 908 if frame < 0: 909 frame += num_structures 910 if frame >= num_structures: 911 raise IndexError( 912 f"Frame {frame} is out of range for {num_structures} structures.") 913 914 with self.project_hdf5.open('output/generic') as h5out: 915 positions = h5out["positions"][frame] 916 cell = h5out["cells"][frame] 917 # Read positions and cell 918 with self.project_hdf5.open('output/structure') as h5out: 919 species = h5out["species"] 920 indices = h5out["indices"] 921 922 # Generate symbols list 923 symbols = [species[i] for i in indices] 924 return ase_to_pyiron(Atoms(symbols=symbols, positions=positions, cell=cell, pbc=True)) 925 926 def compress(self, files_to_compress=None): 927 """ 928 Compress the output files of a job object. 929 930 Args: 931 files_to_compress (list): A list of files to compress (optional) 932 """ 933 import os 934 # delete empty files 935 for f in self.files.list(): 936 filename = os.path.join(self.working_directory, f) 937 if (os.path.exists(filename) and os.stat(filename).st_size == 0): 938 os.remove(filename) 939 # delete slurm scripts 940 if "run_queue.sh" in self.files.list(): 941 filename = os.path.join(self.working_directory, "run_queue.sh") 942 os.remove(filename) 943 # compress all remaining files 944 if files_to_compress is None: 945 files_to_compress = [ 946 f 947 for f in self.files.list() 948 if f 949 not in [ 950 "final_structure.cif", 951 ] 952 ] 953 954 super(Grace, self).compress(files_to_compress=files_to_compress)
Custom job class for using Grace within the pyiron framework.
This class provides methods to perform static calculations, structure relaxation, and molecular dynamics simulations using Grace UMLIPs.
Attributes
- structure (pyiron.atomistics.structure.atoms.Atoms): Structure associated with the job.
- input (CalcParams): Input parameters for the Grace calculations.
56 def __init__(self, project, job_name): 57 """ 58 Initialize the Grace job. 59 60 Parameters 61 ---------- 62 project : pyiron.Project 63 The pyiron project in which the job resides. 64 job_name : str 65 Name of the job. 66 """ 67 super(Grace, self).__init__(project, job_name) 68 self.structure = None 69 self.input = CalcParams() 70 if self.project_hdf5.file_exists: 71 self.input.from_hdf(self.project_hdf5, group_name='input') 72 # executable is in pyiron_resources/grace/bin 73 self._executable_activate(codename='grace')
Initialize the Grace job.
Parameters
- project (pyiron.Project): The pyiron project in which the job resides.
- job_name (str): Name of the job.
147 @property 148 def structure(self): 149 """ 150 151 Returns: 152 153 """ 154 return self._structure
Returns:
75 @property 76 def fmax(self) -> float: 77 """ 78 Maximum force tolerance for ionic relaxation. 79 80 This property controls the maximum force tolerance (in eV/Å) used during ionic relaxation. 81 82 Returns 83 ------- 84 float 85 Maximum force tolerance (eV/Å). 86 """ 87 return self._generic_input['ionic_force_tolerance']
Maximum force tolerance for ionic relaxation.
This property controls the maximum force tolerance (in eV/Å) used during ionic relaxation.
Returns
- float: Maximum force tolerance (eV/Å).
101 @property 102 def max_iter(self) -> int: 103 """ 104 Get the maximum number of iterations for relaxation or MD run. 105 106 Returns 107 ------- 108 int 109 Maximum number of iterations. 110 """ 111 return self._generic_input['max_iter']
Get the maximum number of iterations for relaxation or MD run.
Returns
- int: Maximum number of iterations.
125 @property 126 def model(self) -> str: 127 """ 128 Return the current GRACE model. 129 130 Returns 131 ------- 132 str 133 The selected GRACE model name. 134 """ 135 return self.input['model']
Return the current GRACE model.
Returns
- str: The selected GRACE model name.
177 def available_models(self): 178 """ 179 Return the list of supported GRACE models. 180 181 Returns 182 ------- 183 list of str 184 All valid GRACE model identifiers. 185 """ 186 return _grace_models
Return the list of supported GRACE models.
Returns
- list of str: All valid GRACE model identifiers.
192 def calc_static( 193 self, 194 grace_model: Literal[*_grace_models] = "GRACE-2L-OMAT", 195 ): 196 """ 197 Perform a static calculation. 198 199 Parameters 200 ---------- 201 grace_model : Literal[*_grace_models], optional 202 GRACE model to use for the relaxation. 203 Must be one of the supported models, which can be obtained via 204 :meth:`available_models`. Default is ``"GRACE-2L-OMAT"``. 205 206 Raises 207 ------ 208 ValueError 209 If the structure has not been set before calling this method. 210 211 Notes 212 ----- 213 This method configures the GRACE calculator for a static calculation 214 using the provided GRACE model. 215 A structure must be assigned to the instance prior to calling 216 ``calc_static``. 217 218 Returns 219 ------- 220 None 221 """ 222 if self.structure is None: 223 raise ValueError( 224 'Structure must be set before calling calc_static().') 225 self._generic_input['calc_mode'] = 'static' 226 self.model = grace_model
Perform a static calculation.
Parameters
- grace_model (Literal[*_grace_models], optional):
GRACE model to use for the relaxation.
Must be one of the supported models, which can be obtained viaavailable_models(). Default is"GRACE-2L-OMAT".
Raises
- ValueError: If the structure has not been set before calling this method.
Notes
This method configures the GRACE calculator for a static calculation
using the provided GRACE model.
A structure must be assigned to the instance prior to calling
calc_static.
Returns
- None
306 def calc_minimize( 307 self, 308 grace_model: Literal[*_grace_models] = "GRACE-2L-OMAT", 309 algorithm: Literal["FIRE", "BFGS", "LBFGS"] = "FIRE", 310 algorithm_kwargs: dict | None = None, 311 relax_cell: bool = False, 312 relax_cell_kwargs: dict | None = None, 313 ionic_force_tolerance: float = 0.001, 314 max_iter: int = 500, 315 n_print: int = 1, 316 save_path: str | None = "relax.traj", 317 ) -> None: 318 """ 319 Perform a structure relaxation (geometry optimization). 320 321 Parameters 322 ---------- 323 grace_model : Literal[*_grace_models], optional 324 GRACE model to use for the relaxation. 325 Must be one of the supported models, which can be obtained via 326 :meth:`available_models`. Default is ``"GRACE-2L-OMAT"``. 327 328 algorithm : {"FIRE", "BFGS", "LBFGS"}, optional 329 Ionic relaxation algorithm from ASE. 330 Default is ``"FIRE"``. 331 332 algorithm_kwargs : dict, optional 333 Additional keyword arguments passed to the relaxation algorithm. 334 Default is ``None``. 335 336 relax_cell : bool, optional 337 If ``True``, optimize the simulation cell in addition to ionic positions. 338 Default is ``False``. 339 340 relax_cell_kwargs : dict, optional 341 Additional keyword arguments for cell relaxation. 342 Default is ``None``. 343 344 ionic_force_tolerance : float, optional 345 Force convergence criterion in eV/Å. 346 Default is ``0.001``. 347 348 max_iter : int, optional 349 Maximum number of optimization steps. 350 Default is ``500``. 351 352 n_print : int, optional 353 Logging frequency for structures and stresses. 354 Default is ``1``. 355 356 save_path : str or None, optional 357 Path to save the relaxation trajectory. 358 If ``None``, no trajectory is written. 359 Default is ``"relax.traj"``. 360 361 Returns 362 ------- 363 None 364 """ 365 366 super().calc_minimize( 367 ionic_energy_tolerance=0, 368 ionic_force_tolerance=ionic_force_tolerance, 369 max_iter=max_iter, 370 ) 371 372 # Saving the parameters in dictionary 373 self._generic_input['calc_mode'] = 'minimize' 374 self.input['model'] = grace_model 375 self.input['algo'] = algorithm 376 self.input['algo_kwargs'] = algorithm_kwargs or {} 377 self.input['relax_cell'] = relax_cell 378 self.input['relax_cell_kwargs'] = relax_cell_kwargs or {} 379 self._generic_input['ionic_force_tolerance'] = ionic_force_tolerance 380 self._generic_input['n_print'] = n_print 381 self._generic_input['max_iter'] = max_iter 382 self.input['save_path'] = save_path
Perform a structure relaxation (geometry optimization).
Parameters
- grace_model (Literal[*_grace_models], optional):
GRACE model to use for the relaxation.
Must be one of the supported models, which can be obtained viaavailable_models(). Default is"GRACE-2L-OMAT". - algorithm ({"FIRE", "BFGS", "LBFGS"}, optional):
Ionic relaxation algorithm from ASE.
Default is"FIRE". - algorithm_kwargs (dict, optional):
Additional keyword arguments passed to the relaxation algorithm.
Default isNone. - relax_cell (bool, optional):
If
True, optimize the simulation cell in addition to ionic positions.
Default isFalse. - relax_cell_kwargs (dict, optional):
Additional keyword arguments for cell relaxation.
Default isNone. - ionic_force_tolerance (float, optional):
Force convergence criterion in eV/Å.
Default is0.001. - max_iter (int, optional):
Maximum number of optimization steps.
Default is500. - n_print (int, optional):
Logging frequency for structures and stresses.
Default is1. - save_path (str or None, optional):
Path to save the relaxation trajectory.
IfNone, no trajectory is written.
Default is"relax.traj".
Returns
- None
530 def calc_md( 531 self, 532 grace_model: Literal[*_grace_models] = "GRACE-2L-OMAT", 533 ensemble: Literal["NVT", "nvt", "Langevin", "langevin", "NPT", "npt"] = "NVT", 534 temperature: float = 300.0, 535 starting_temperature: int | None = None, 536 pressure: float | None = None, 537 time_step: float = 2.0, 538 n_ionic_steps: int = 100, 539 n_print: int = 10, 540 temperature_damping_timescale: float = 100.0, 541 pressure_damping_timescale: float | None = None, 542 trajectory: str = "md_run.traj", 543 logfile: str = "md_run.log", 544 random_seed: int | None = None, 545 mask: Tuple[int] | np.ndarray | None = None 546 ): 547 """ 548 Run a molecular dynamics simulation using a GRACE foundation model. 549 550 Parameters 551 ---------- 552 grace_model : Literal[*_grace_models], optional 553 GRACE model to use for the relaxation. 554 Must be one of the supported models, which can be obtained via 555 :meth:`available_models`. Default is ``"GRACE-2L-OMAT"``. 556 557 ensemble : {"NVT", "nvt", "Langevin", "langevin", "NPT", "npt"}, optional 558 Type of statistical ensemble: 559 - "NVT" or "Langevin": constant volume and temperature. 560 - "NPT": constant pressure and temperature. 561 Case-insensitive (default is "NVT"). 562 563 temperature : float, optional 564 Target simulation temperature in Kelvin (default is 300.0). 565 566 starting_temperature : int or None, optional 567 Initial temperature of the system in Kelvin. If None, uses 568 the target `temperature` (default is None). 569 570 pressure : float or None, optional 571 Target external pressure in GPa for NPT runs. Ignored if ensemble 572 is NVT or Langevin (default is None). 573 574 time_step : float, optional 575 Integration time step in femtoseconds (default is 2.0 fs). 576 577 n_ionic_steps : int, optional 578 Number of MD steps to perform (default is 100). 579 580 n_print : int, optional 581 Frequency of log printing and trajectory writing (default is 10). 582 583 temperature_damping_timescale : float, optional 584 Thermostat coupling time in femtoseconds (default is 100.0 fs). 585 586 pressure_damping_timescale : float or None, optional 587 Barostat coupling time in femtoseconds. Only relevant for NPT runs 588 (default is None). 589 590 trajectory : str, optional 591 Output trajectory file name (default is "md_run.traj"). 592 593 logfile : str, optional 594 Output log file name (default is "md_run.log"). 595 596 random_seed : int or None, optional 597 Seed for random velocity initialization. If None, system clock 598 is used (default is None). 599 600 mask : None, sequence of 3 ints (0/1), or (3,3) array-like, optional 601 Specify which components of the computational box (strain tensor) 602 are allowed to change under the barostat (default is None equalling 603 to *No mask*). Only relevant for NPT simulations; it has no effect 604 for NVT / Langevin ensembles. 605 """ 606 self._generic_input['calc_mode'] = 'md' 607 ensemble = ensemble.lower() 608 if ensemble in ['langevin', 'nvt']: #requested NVT, ignore NPT settings 609 pressure = None 610 mask = None 611 if pressure == None: 612 pressure_damping_timescale = None 613 super().calc_md( 614 temperature=temperature, 615 pressure=pressure, 616 n_ionic_steps=n_ionic_steps, 617 time_step=time_step, 618 n_print=n_print, 619 temperature_damping_timescale=temperature_damping_timescale, 620 pressure_damping_timescale=pressure_damping_timescale, 621 ) 622 self.input['model'] = grace_model 623 self.input['ensemble'] = ensemble.lower() 624 self.input['trajectory'] = trajectory 625 self.input['logfile'] = logfile 626 if starting_temperature is None: 627 starting_temperature = temperature 628 self.input['starting_temperature'] = starting_temperature 629 self.input['random_seed'] = random_seed 630 self.input['mask'] = mask
Run a molecular dynamics simulation using a GRACE foundation model.
Parameters
- grace_model (Literal[*_grace_models], optional):
GRACE model to use for the relaxation.
Must be one of the supported models, which can be obtained viaavailable_models(). Default is"GRACE-2L-OMAT". - ensemble ({"NVT", "nvt", "Langevin", "langevin", "NPT", "npt"}, optional):
Type of statistical ensemble:
- "NVT" or "Langevin": constant volume and temperature.
- "NPT": constant pressure and temperature. Case-insensitive (default is "NVT").
- temperature (float, optional): Target simulation temperature in Kelvin (default is 300.0).
- starting_temperature (int or None, optional):
Initial temperature of the system in Kelvin. If None, uses
the target
temperature(default is None). - pressure (float or None, optional): Target external pressure in GPa for NPT runs. Ignored if ensemble is NVT or Langevin (default is None).
- time_step (float, optional): Integration time step in femtoseconds (default is 2.0 fs).
- n_ionic_steps (int, optional): Number of MD steps to perform (default is 100).
- n_print (int, optional): Frequency of log printing and trajectory writing (default is 10).
- temperature_damping_timescale (float, optional): Thermostat coupling time in femtoseconds (default is 100.0 fs).
- pressure_damping_timescale (float or None, optional): Barostat coupling time in femtoseconds. Only relevant for NPT runs (default is None).
- trajectory (str, optional): Output trajectory file name (default is "md_run.traj").
- logfile (str, optional): Output log file name (default is "md_run.log").
- random_seed (int or None, optional): Seed for random velocity initialization. If None, system clock is used (default is None).
- mask (None, sequence of 3 ints (0/1), or (3,3) array-like, optional): Specify which components of the computational box (strain tensor) are allowed to change under the barostat (default is None equalling to No mask). Only relevant for NPT simulations; it has no effect for NVT / Langevin ensembles.
768 def write_input(self) -> None: 769 """ 770 Write input files required for the Grace calculation. 771 772 This function writes the structure file (`structure.cif`), stores the input 773 dictionary to the HDF5 format, and delegates further writing depending on the 774 calculation mode (MD or static/minimize). 775 776 Returns 777 ------- 778 None 779 """ 780 write(os.path.join(self.working_directory, 'structure.cif'), 781 pyiron_to_ase(self.structure)) 782 self.input.to_hdf(self.project_hdf5, group_name='input') 783 if self._generic_input['calc_mode'] == 'static': 784 self._write_calc_static() 785 elif self._generic_input['calc_mode'] == 'minimize': 786 self._write_calc_minimize() 787 elif self._generic_input['calc_mode'] == 'md': 788 self._write_calc_md() 789 else: 790 raise ValueError( 791 "No calculation type set. Use calc_static(), calc_minimize() or calc_md() first.")
Write input files required for the Grace calculation.
This function writes the structure file (structure.cif), stores the input
dictionary to the HDF5 format, and delegates further writing depending on the
calculation mode (MD or static/minimize).
Returns
- None
793 def collect_output(self) -> None: 794 """ 795 Collect and store the results of a calculation. 796 797 This method parses the final structure, trajectory, and other output properties 798 from the calculation and stores them in HDF5 format. The internal structure 799 is updated, and various properties such as energies, structure, forces, and 800 stresses are saved to the output group. 801 802 Returns 803 ------- 804 None 805 """ 806 807 def voigt_to_tensor(s): 808 # s is a 6-element array from atoms.get_stress() 809 return np.array([ 810 [s[0], s[5], s[4]], 811 [s[5], s[1], s[3]], 812 [s[4], s[3], s[2]] 813 ]) 814 815 relaxed_ase_structure = read( 816 os.path.join(self.working_directory, 'final_structure.cif'), format='cif') 817 relaxed_pyiron_structure = ase_to_pyiron(relaxed_ase_structure) 818 819 # Store the relaxed structure in the HDF5 group 820 with self.project_hdf5.open('output') as h5out: 821 h5out['structure'] = relaxed_pyiron_structure 822 self.structure = relaxed_pyiron_structure # Update the job's structure 823 824 # Parse trajectory from minimize 825 if self._generic_input['calc_mode'] in ['minimize', 'md']: 826 # For minimize and md, we expect a trajectory file with the relaxed structure 827 if self._generic_input['calc_mode'] == 'minimize': 828 traj_file = self.input['save_path'] 829 traj = Trajectory(os.path.join(self.working_directory, traj_file)) 830 else: 831 traj_file = self.input['trajectory'] 832 traj = read(os.path.join(self.working_directory, traj_file), index=":") 833 try: 834 with self.project_hdf5.open('output/generic') as h5out: 835 h5out['cells'] = np.array([s.get_cell() for s in traj]) 836 h5out['positions'] = np.array([s.positions for s in traj]) 837 h5out['stresses'] = np.array( 838 [voigt_to_tensor(s.get_stress())*EV_PER_ANG3_TO_GPA for s in traj]) 839 h5out['forces'] = np.array([s.get_forces() for s in traj]) 840 except: 841 self.logger.warning( 842 f'Cannot parse output from {traj_file} file.') 843 844 # Parse other properties 845 if self._generic_input['calc_mode'] == 'static': 846 df = self._parse_calc_static() 847 df['max_force'] = df['forces'].apply(lambda x: np.max(x)) 848 with self.project_hdf5.open('output/generic') as h5out: 849 h5out['energy_pot'] = df['energy_pot'].values 850 h5out['energy_tot'] = df['energy_pot'].values 851 h5out['forces'] = df['forces'].values 852 h5out['max_force'] = df['max_force'].values 853 h5out['stresses'] = np.array( 854 [voigt_to_tensor(s)*EV_PER_ANG3_TO_GPA for s in df['stresses'].values]) 855 h5out['steps'] = df['step'].values 856 h5out['cells'] = np.array([relaxed_pyiron_structure.get_cell()]) 857 h5out['positions'] = np.array([relaxed_pyiron_structure.positions]) 858 elif self._generic_input['calc_mode'] == 'minimize': 859 df = self._parse_calc_minimize() 860 with self.project_hdf5.open('output/generic') as h5out: 861 h5out['energy_pot'] = df['Energy'].values 862 h5out['energy_tot'] = df['Energy'].values 863 h5out['max_force'] = df['fmax'].values 864 h5out['steps'] = df['Step'].values 865 elif self._generic_input['calc_mode'] == 'md': 866 df = self._parse_calc_md(self.input['logfile']) 867 with self.project_hdf5.open('output/generic') as h5out: 868 h5out['energy_pot'] = df['Epot'].values 869 h5out['energy_tot'] = df['Etot'].values 870 h5out['energy_kin'] = df['Ekin'].values 871 h5out['temperature'] = df['T'].values 872 h5out['time'] = df['Time'].values 873 h5out['steps'] = df.index.values 874 875 self.compress() 876 self.status.finished = True
Collect and store the results of a calculation.
This method parses the final structure, trajectory, and other output properties from the calculation and stores them in HDF5 format. The internal structure is updated, and various properties such as energies, structure, forces, and stresses are saved to the output group.
Returns
- None
878 def get_structure(self, frame: int = -1) -> Atoms: 879 """ 880 Retrieve the structure at a given frame from the calculation output. 881 882 This function returns the atomic structure (positions, cell, species) at the 883 specified frame index. The frame index is adjusted to ensure it is valid. 884 885 Parameters 886 ---------- 887 frame : int, optional 888 The frame index to retrieve. If negative, it is counted from the last frame, 889 by default -1. 890 891 Returns 892 ------- 893 Atoms 894 The ASE Atoms object representing the structure at the given frame. 895 896 Raises 897 ------ 898 ValueError 899 If no structures are found in the job output. 900 IndexError 901 If the frame index is out of range. 902 """ 903 num_structures = self.number_of_structures 904 if num_structures == 0: 905 raise ValueError("No structures found in the job output.") 906 907 # Ensure frame is valid 908 if frame < 0: 909 frame += num_structures 910 if frame >= num_structures: 911 raise IndexError( 912 f"Frame {frame} is out of range for {num_structures} structures.") 913 914 with self.project_hdf5.open('output/generic') as h5out: 915 positions = h5out["positions"][frame] 916 cell = h5out["cells"][frame] 917 # Read positions and cell 918 with self.project_hdf5.open('output/structure') as h5out: 919 species = h5out["species"] 920 indices = h5out["indices"] 921 922 # Generate symbols list 923 symbols = [species[i] for i in indices] 924 return ase_to_pyiron(Atoms(symbols=symbols, positions=positions, cell=cell, pbc=True))
Retrieve the structure at a given frame from the calculation output.
This function returns the atomic structure (positions, cell, species) at the specified frame index. The frame index is adjusted to ensure it is valid.
Parameters
- frame (int, optional): The frame index to retrieve. If negative, it is counted from the last frame, by default -1.
Returns
- Atoms: The ASE Atoms object representing the structure at the given frame.
Raises
- ValueError: If no structures are found in the job output.
- IndexError: If the frame index is out of range.
926 def compress(self, files_to_compress=None): 927 """ 928 Compress the output files of a job object. 929 930 Args: 931 files_to_compress (list): A list of files to compress (optional) 932 """ 933 import os 934 # delete empty files 935 for f in self.files.list(): 936 filename = os.path.join(self.working_directory, f) 937 if (os.path.exists(filename) and os.stat(filename).st_size == 0): 938 os.remove(filename) 939 # delete slurm scripts 940 if "run_queue.sh" in self.files.list(): 941 filename = os.path.join(self.working_directory, "run_queue.sh") 942 os.remove(filename) 943 # compress all remaining files 944 if files_to_compress is None: 945 files_to_compress = [ 946 f 947 for f in self.files.list() 948 if f 949 not in [ 950 "final_structure.cif", 951 ] 952 ] 953 954 super(Grace, self).compress(files_to_compress=files_to_compress)
Compress the output files of a job object.
Args: files_to_compress (list): A list of files to compress (optional)
957class CalcParams(GenericParameters): 958 """ 959 Class to manage the calculation parameters for Grace calculations. 960 961 This class extends from `GenericParameters` and is used to define and manage 962 parameters specific to Grace calculations. It allows for initialization of 963 parameters and stores them in a table, with a default table name of "grace". 964 The class can be used to load and store various Grace calculation settings and 965 other related configurations. 966 967 Parameters 968 ---------- 969 table_name : str, optional 970 The name of the table where the parameters are stored. By default, it is set 971 to "grace", but it can be customized if necessary. 972 973 Methods 974 ------- 975 __init__(self, table_name="grace") 976 Initializes the parameters for the calculation. 977 """ 978 979 def __init__(self, table_name: str = "grace") -> None: 980 """ 981 Initialize the CalcParams object with the given table name. 982 983 Parameters 984 ---------- 985 table_name : str, optional 986 The name of the table where the parameters are stored. By default, 987 it is set to "grace". 988 """ 989 super(CalcParams, self).__init__( 990 table_name=table_name, 991 )
Class to manage the calculation parameters for Grace calculations.
This class extends from GenericParameters and is used to define and manage
parameters specific to Grace calculations. It allows for initialization of
parameters and stores them in a table, with a default table name of "grace".
The class can be used to load and store various Grace calculation settings and
other related configurations.
Parameters
- table_name (str, optional): The name of the table where the parameters are stored. By default, it is set to "grace", but it can be customized if necessary.
Methods
__init__(self, table_name="grace") Initializes the parameters for the calculation.
979 def __init__(self, table_name: str = "grace") -> None: 980 """ 981 Initialize the CalcParams object with the given table name. 982 983 Parameters 984 ---------- 985 table_name : str, optional 986 The name of the table where the parameters are stored. By default, 987 it is set to "grace". 988 """ 989 super(CalcParams, self).__init__( 990 table_name=table_name, 991 )
Initialize the CalcParams object with the given table name.
Parameters
- table_name (str, optional): The name of the table where the parameters are stored. By default, it is set to "grace".