Source code for qmflows.packages._scm

"""Interface to the SCM funcionality."""

from __future__ import annotations

import os
import struct
from os.path import join, basename, normpath
from typing import Any, ClassVar, Final
from warnings import warn

import numpy as np
from scm import plams

from ._packages import Package, Result, load_properties
from .._settings import Settings
from ..type_hints import WarnMap, _Settings
from ..warnings_qmflows import Key_Warning, QMFlows_Warning
from ..utils import get_tmpfile_name

__all__ = ['ADF_Result', 'DFTB_Result', 'ADF', 'DFTB', 'adf', 'dftb']

# ========================= ADF ============================


def handle_SCM_special_keywords(settings: Settings, key: str,
                                value: Any, mol: plams.Molecule, package: str) -> None:
    """Handle special ADF/DFTB specific keywords."""
    def freeze() -> None:
        settings.specific[package].geometry.optim = "cartesian"
        if not isinstance(value, list):
            msg = 'freeze ' + str(value) + ' is not a list'
            raise RuntimeError(msg)
        if isinstance(value[0], int):
            for a in value:
                at = 'atom ' + str(a)
                settings.specific[package].constraints[at] = ""
        else:
            for a in range(len(mol)):
                if mol[a + 1].symbol in value:
                    at = 'atom ' + str(a + 1)
                    settings.specific[package].constraints[at] = ""

    def selected_atoms() -> None:
        settings.specific[package].geometry.optim = "cartesian"
        if not isinstance(value, list):
            msg = f'selected_atoms {value} is not a list'
            raise RuntimeError(msg)
        if isinstance(value[0], int):
            for a in range(len(mol)):
                if a + 1 not in value:
                    at = 'atom ' + str(a + 1)
                    settings.specific[package].constraints[at] = ""
        else:
            for a in range(len(mol)):
                if mol[a + 1].symbol not in value:
                    at = 'atom ' + str(a + 1)
                    settings.specific[package].constraints[at] = ""

    def inithess() -> None:
        hess_path = get_tmpfile_name("ADF_hessian_")
        hess_file = open(hess_path, "w", encoding="utf8")
        hess_file.write(" ".join(['{:.6f}'.format(v) for v in value]))
        settings.specific[package].geometry.inithess = hess_path.as_posix()

    def constraint() -> None:
        if isinstance(value, Settings):
            for k, v in value.items():
                ks = k.split()
                # print('--->', ks, type(ks[2]), type(value), v)
                if ks[0] == 'dist' and len(ks) == 3:
                    name = 'dist {:d} {:d}'.format(int(ks[1]),
                                                   int(ks[2]))
                    settings.specific[package].constraints[name] = v
                elif ks[0] == 'angle' and len(ks) == 4:
                    name = 'angle {:d} {:d} {:d}'.format(int(ks[1]),
                                                         int(ks[2]),
                                                         int(ks[3]))
                    settings.specific[package].constraints[name] = v
                elif ks[0] == 'dihed' and len(ks) == 5:
                    name = 'dihed {:d} {:d} {:d} {:d}'.\
                        format(int(ks[1]), int(ks[2]),
                               int(ks[3]), int(ks[4]))
                    settings.specific[package].constraints[name] = v
                else:
                    warn(
                        f'Invalid constraint key: {k}', category=Key_Warning)

    # Available translations
    functions = {'freeze': freeze,
                 'selected_atoms': selected_atoms,
                 'inithess': inithess,
                 'constraint': constraint}
    if key in functions:
        functions[key]()
    else:
        warn(f'Generic keyword {key!r} not implemented for package ADF',
             category=Key_Warning)


[docs]class ADF_Result(Result): """Class providing access to PLAMS ADFJob result results.""" prop_mapping: ClassVar[_Settings] = load_properties('ADF', prefix='properties') # Attributes accessed via `__getattr__` charges: None | Any dipole: None | Any energy: None | Any enthalpy: None | Any free_energy: None | Any frequencies: None | Any hessian: None | Any homo: None | Any lumo: None | Any optcycles: None | Any runtime: None | Any def __init__(self, settings: None | Settings, molecule: None | plams.Molecule, job_name: str, dill_path: None | str | os.PathLike[str] = None, plams_dir: None | str | os.PathLike[str] = None, work_dir: None | str | os.PathLike[str] = None, status: str = 'successful', warnings: None | WarnMap = None) -> None: # Load available property parser from yaml file. super().__init__(settings, molecule, job_name, dill_path, plams_dir=plams_dir, status=status, warnings=warnings) # Create a KF reader instance if work_dir is not None and plams_dir is not None: # The t21 path has to be absolute: use workdir instead of plams_dir name_t21 = basename(normpath(work_dir)) path_t21 = join(plams_dir, f'{name_t21}.t21') self.kf: None | plams.KFFile = plams.KFFile(path_t21) else: self.kf = None def get_property_kf(self, prop: str, section: None | str = None) -> None | Any: """Interface for :meth:`plams.KFFile.read()<scm.plams.tools.kftools.KFFile.read>`.""" if self.kf is None: return None else: return self.kf.read(section, prop) @property def molecule(self) -> None | plams.Molecule: """WARNING: Cheap copy from PLAMS, do not keep this!!!.""" if self._molecule is None or self.kf is None: return None else: m = self._molecule.copy() # Find out correct location coords = self.kf.read('Geometry', 'xyz InputOrder') coords = np.array([coords[i: i + 3] for i in range(0, len(coords), 3)]) m.from_array(coords) return m @property def geometry(self) -> None | plams.Molecule: """An alias for :attr:`ADF_Result.molecule`.""" return self.molecule
[docs]class DFTB_Result(Result): """Class providing access to PLAMS DFTBJob result results.""" prop_mapping: ClassVar[_Settings] = load_properties('DFTB', prefix='properties') # Attributes accessed via `__getattr__` charges: None | Any dipole: None | Any energy: None | Any enthalpy: None | Any free_energy: None | Any frequencies: None | Any hessian: None | Any def __init__(self, settings: None | Settings, molecule: None | plams.Molecule, job_name: str, dill_path: None | str | os.PathLike[str] = None, plams_dir: None | str | os.PathLike[str] = None, work_dir: None | str | os.PathLike[str] = None, status: str = 'successful', warnings: None | WarnMap = None) -> None: # Read available propiety parsers from a yaml file super().__init__(settings, molecule, job_name, dill_path, plams_dir=plams_dir, status=status, warnings=warnings) if plams_dir is not None: kf_filename = join(plams_dir, 'dftb.rkf') # create a kf reader instance self.kf: None | plams.KFFile = plams.KFFile(kf_filename) else: self.kf = None @property def molecule(self) -> None | plams.Molecule: """Read molecule from output.""" if self._molecule is None or self.kf is None: return None else: m = self._molecule.copy() coords = self.kf.read('Molecule', 'Coords') coords = np.array([coords[i: i + 3] for i in range(0, len(coords), 3)]) m.from_array(coords) return m @property def geometry(self) -> None | plams.Molecule: """An alias for :attr:`DFTB_Result.molecule`.""" return self.molecule
[docs]class ADF(Package): """:class:`~qmflows.packages.Package` subclass for ADF. This class takes care of calling the *ADF* quantum package. it uses both Plams and the Templates module to create the input files, while Plams takes care of running the :class:`plams.ADFJob<scm.plams.interfaces.adfsuite.adf.ADFJob>`. It returns a :class:`ADF_Result` instance containing the output data. """ generic_mapping: ClassVar[_Settings] = load_properties('ADF', prefix='generic2') result_type: ClassVar[type[ADF_Result]] = ADF_Result def __init__(self, pkg_name: str = "adf") -> None: super().__init__(pkg_name) @classmethod def run_job(cls, settings: Settings, mol: plams.Molecule, job_name: str = 'ADFjob', nproc: None | int = None, validate_output: bool = True, **kwargs: Any) -> ADF_Result: """Execute ADF job. :param settings: user input settings. :type settings: :class:`~qmflows.Settings` :param mol: Molecule to run the simulation :type mol: Plams Molecule :parameter input_file_name: The user can provide a name for the job input. :type input_file_name: String :parameter out_file_name: The user can provide a name for the job output. :type out_file_name: String :returns: :class:`~qmflows.packages.ADF_Result` """ adf_settings = Settings() if nproc: adf_settings.runscript.nproc = nproc adf_settings.input = settings.specific.adf job = plams.ADFJob(name=job_name, molecule=mol, settings=adf_settings) result = job.run() # Relative job path relative_plams_path = join(*str(result.job.path).split(os.sep)[-2:]) # Absolute path to the .dill file dill_path = join(job.path, f'{job.name}.dill') adf_result = cls.result_type( adf_settings, mol, result.job.name, dill_path, plams_dir=relative_plams_path, work_dir=result.job.path, status=job.status) return adf_result @staticmethod def handle_special_keywords(settings: Settings, key: str, value: Any, mol: plams.Molecule) -> None: """Handle special ADF/DFTB specific keywords. Some keywords provided by the user do not have a straightforward translation to *ADF* input and require some hooks that handles the special behaviour of the following keywords: * ``freeze`` * ``selected_atoms`` * ``initHess`` * ``Constraint`` """ handle_SCM_special_keywords(settings, key, value, mol, "adf")
[docs]class DFTB(Package): """:class:`~qmflows.packages.Package` subclass for DFTB.""" generic_mapping: ClassVar[_Settings] = load_properties('DFTB', prefix='generic2') result_type: ClassVar[type[DFTB_Result]] = DFTB_Result def __init__(self, pkg_name: str = "dftb") -> None: super().__init__(pkg_name) @classmethod def run_job(cls, settings: Settings, mol: plams.Molecule, job_name: str = "DFTBJob", work_dir: None | str | os.PathLike[str] = None, validate_output: bool = True, **kwargs: Any) -> DFTB_Result: """Execute an DFTB job with the AMS driver. In order to run a DFTB calculation we need both an AMS and DFTB sections. The AMS sections, specifies which tasks to run: single point, optimization, etc. While the DFTB section only set the input for the method. For more information, see: `AMS <https://www.scm.com/doc/plams/interfaces/ams.html>` """ dftb_settings = Settings() dftb_settings.input = settings.specific.dftb dftb_settings.input += settings.specific.ams job = plams.AMSJob(name=job_name, molecule=mol, settings=dftb_settings) # Check RKF status try: result = job.run() name = result.job.name path: None | str = result.job.path except struct.error: job.status = 'failed' name = job_name path = None warn(f"job:{job_name} has failed.\nRKF is corrupted", category=QMFlows_Warning) if job.status in ['failed', 'crashed']: plams.config.default_jobmanager.remove_job(job) # Absolute path to the .dill file dill_path = join(job.path, f'{job.name}.dill') return cls.result_type(dftb_settings, mol, name, dill_path, plams_dir=path, status=job.status) @staticmethod def handle_special_keywords(settings: Settings, key: str, value: Any, mol: plams.Molecule) -> None: """Translate generic keywords to their corresponding Orca keywords.""" handle_SCM_special_keywords(settings, key, value, mol, "dftb")
adf: Final[ADF] = ADF() dftb: Final[DFTB] = DFTB()