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        )
class Grace(pyiron_atomistics.atomistics.job.atomistic.AtomisticGenericJob):
 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.
Grace(project, job_name)
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.
structure
147    @property
148    def structure(self):
149        """
150
151        Returns:
152
153        """
154        return self._structure

Returns:

input
fmax: float
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/Å).
max_iter: int
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.
model: str
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.
def available_models(self):
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.
def calc_static( self, grace_model: Literal['GRACE-1L-MP-r6', 'GRACE-2L-MP-r5', 'GRACE-2L-MP-r6', 'GRACE-FS-OAM', 'GRACE-1L-OAM', 'GRACE-2L-OAM', 'GRACE-FS-OMAT', 'GRACE-1L-OMAT', 'GRACE-2L-OMAT', 'GRACE-1L-OMAT-medium-base', 'GRACE-1L-OMAT-medium-ft-E', 'GRACE-1L-OMAT-large-base', 'GRACE-1L-OMAT-large-ft-E', 'GRACE-2L-OMAT-medium-base', 'GRACE-2L-OMAT-medium-ft-E', 'GRACE-2L-OMAT-large-base', 'GRACE-2L-OMAT-large-ft-E'] = 'GRACE-2L-OMAT'):
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 via available_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
def calc_minimize( self, grace_model: Literal['GRACE-1L-MP-r6', 'GRACE-2L-MP-r5', 'GRACE-2L-MP-r6', 'GRACE-FS-OAM', 'GRACE-1L-OAM', 'GRACE-2L-OAM', 'GRACE-FS-OMAT', 'GRACE-1L-OMAT', 'GRACE-2L-OMAT', 'GRACE-1L-OMAT-medium-base', 'GRACE-1L-OMAT-medium-ft-E', 'GRACE-1L-OMAT-large-base', 'GRACE-1L-OMAT-large-ft-E', 'GRACE-2L-OMAT-medium-base', 'GRACE-2L-OMAT-medium-ft-E', 'GRACE-2L-OMAT-large-base', 'GRACE-2L-OMAT-large-ft-E'] = 'GRACE-2L-OMAT', algorithm: Literal['FIRE', 'BFGS', 'LBFGS'] = 'FIRE', algorithm_kwargs: dict | None = None, relax_cell: bool = False, relax_cell_kwargs: dict | None = None, ionic_force_tolerance: float = 0.001, max_iter: int = 500, n_print: int = 1, save_path: str | None = 'relax.traj') -> 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 via available_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 is None.
  • relax_cell (bool, optional): If True, optimize the simulation cell in addition to ionic positions.
    Default is False.
  • relax_cell_kwargs (dict, optional): Additional keyword arguments for cell relaxation.
    Default is None.
  • ionic_force_tolerance (float, optional): Force convergence criterion in eV/Å.
    Default is 0.001.
  • max_iter (int, optional): Maximum number of optimization steps.
    Default is 500.
  • n_print (int, optional): Logging frequency for structures and stresses.
    Default is 1.
  • save_path (str or None, optional): Path to save the relaxation trajectory.
    If None, no trajectory is written.
    Default is "relax.traj".
Returns
  • None
def calc_md( self, grace_model: Literal['GRACE-1L-MP-r6', 'GRACE-2L-MP-r5', 'GRACE-2L-MP-r6', 'GRACE-FS-OAM', 'GRACE-1L-OAM', 'GRACE-2L-OAM', 'GRACE-FS-OMAT', 'GRACE-1L-OMAT', 'GRACE-2L-OMAT', 'GRACE-1L-OMAT-medium-base', 'GRACE-1L-OMAT-medium-ft-E', 'GRACE-1L-OMAT-large-base', 'GRACE-1L-OMAT-large-ft-E', 'GRACE-2L-OMAT-medium-base', 'GRACE-2L-OMAT-medium-ft-E', 'GRACE-2L-OMAT-large-base', 'GRACE-2L-OMAT-large-ft-E'] = 'GRACE-2L-OMAT', ensemble: Literal['NVT', 'nvt', 'Langevin', 'langevin', 'NPT', 'npt'] = 'NVT', temperature: float = 300.0, starting_temperature: int | None = None, pressure: float | None = None, time_step: float = 2.0, n_ionic_steps: int = 100, n_print: int = 10, temperature_damping_timescale: float = 100.0, pressure_damping_timescale: float | None = None, trajectory: str = 'md_run.traj', logfile: str = 'md_run.log', random_seed: int | None = None, mask: Union[Tuple[int], numpy.ndarray, NoneType] = 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 via available_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.
def write_input(self) -> None:
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
def collect_output(self) -> 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
def get_structure(self, frame: int = -1) -> ase.atoms.Atoms:
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.
def compress(self, files_to_compress=None):
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)

class CalcParams(pyiron_base.storage.parameters.GenericParameters):
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.

CalcParams(table_name: str = 'grace')
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".