ADF fragment job

In this module a dedicated job type for ADF fragment analysis is defined. Such an analysis is performed on a molecular system divided into 2 fragments and consists of 3 separate ADF runs: one for each fragment and one for full system.

We define a new job type ADFFragmentJob by extending MultiJob. The constructor (__init__) of this new job takes 2 more arguments (fragment1 and fragment2) and one optional argument full_settings for additional input keywords that are used only in the full system calculation.

In the prerun() method two fragment jobs and the full system job are created with the proper settings and molecules. They are then added to the children list.

The dedicated Results subclass for ADFFragmentJob does not provide too much additional functionality. It simply redirects the usual AMSResults methods to the results of the full system calculation.

from scm.plams.core.basejob import MultiJob
from scm.plams.core.results import Results
from scm.plams.core.settings import Settings
from scm.plams.interfaces.adfsuite.ams import AMSJob
from scm.plams.tools.units import Units
from scm.plams.core.errors import FileError
from scm.plams.mol.molecule import Molecule

import numpy as np

from os.path import join as opj
from os.path import abspath, isdir, basename
from typing import Dict, List, Tuple, Union, Optional

__all__ = ["ADFFragmentJob", "ADFFragmentResults"]


class ADFFragmentResults(Results):

    def check(self):
        """Check if the calculation finished successfully.

        Overwriting the method of |MultiJob| because it only checks if the job
        finished, not how it finished.
        """
        return all(job.check() for job in self.job.children)

    def get_properties(self):
        """Redirect to |AMSResults| of the full calculation."""
        return self.job.full.results.get_properties()

    def get_main_molecule(self):
        """Redirect to |AMSResults| of the full calculation."""
        return self.job.full.results.get_main_molecule()

    def get_input_molecule(self):
        """Redirect to |AMSResults| of the full calculation."""
        return self.job.full.results.get_input_molecule()

    def get_energy(self, unit="au"):
        """Redirect to |AMSResults| of the full calculation."""
        return self.job.full.results.get_energy(unit)

    def get_dipole_vector(self, unit="au"):
        """Redirect to |AMSResults| of the full calculation."""
        return self.job.full.results.get_dipole_vector(unit)

    def get_energy_decomposition(self, unit="au") -> Dict[str, float]:
        """Get the energy decomposition of the fragment calculation.

        Args:
            unit (str, optional): The unit of the energy. Defaults to 'au'.

        Returns:
            Dict[str, float]: The energy decomposition.
        """
        energy_section = self.job.full.results.read_rkf_section("Energy", file="adf")
        ret = {}
        for k in [
            "Electrostatic Energy",
            "Kinetic Energy",
            "Elstat Interaction",
            "XC Energy",
        ]:
            ret[k] = Units.convert(energy_section[k], "au", unit)

        # most information not available from the KF file
        res = self.job.full.results
        res1 = self.job.f1.results
        res2 = self.job.f2.results

        # remaining information on the output file
        def try_grep_result(key, pattern, match_position=-1, split_position=4):
            match = res.grep_output(pattern)
            if match:
                match = Units.convert(float(match[match_position].split()[split_position]), "au", unit)
                ret[key] = match

        try_grep_result("E_int", "Total Bonding Energy:", match_position=-2)
        try_grep_result("E_int_disp", "Dispersion Energy:")
        try_grep_result("E_Pauli", "Pauli Repulsion (Delta")
        try_grep_result("E_elstat", "Electrostatic Interaction:")
        try_grep_result("E_orb", "Total Orbital Interactions:")

        ret["E_1"] = res1.get_energy(unit=unit)
        ret["E_2"] = res2.get_energy(unit=unit)

        if hasattr(self.job, "f1_opt"):
            ret["E_1_opt"] = self.job.f1_opt.results.get_energy(unit=unit)
            ret["E_prep_1"] = ret["E_1"] - ret["E_1_opt"]
        if hasattr(self.job, "f2_opt"):
            ret["E_2_opt"] = self.job.f2_opt.results.get_energy(unit=unit)
            ret["E_prep_2"] = ret["E_2"] - ret["E_2_opt"]

        if ("E_1_opt" in ret) and ("E_2_opt" in ret):
            ret["E_prep"] = ret["E_prep_1"] + ret["E_prep_2"]
            ret["E_bond"] = ret["E_int"] + ret["E_prep"]

        return ret

    def get_nocv_eigenvalues(self) -> Union[List[float], Tuple[List[float], List[float]]]:
        """Get the NOCV eigenvalues of the full calculation."""
        keys = self.job.full.results.get_rkf_skeleton(file="adf")
        if "NOCV" not in keys:
            raise KeyError("NOCV section not found in the rkf file")
        keys = keys["NOCV"]
        # restricted calculation
        if "NOCV_eigenvalues_restricted" in keys:
            return self.job.full.results.readrkf("NOCV", "NOCV_eigenvalues_restricted", file="adf")
        # unrestricted calculation
        elif "NOCV_eigenvalues_alpha" in keys:
            alpha = self.job.full.results.readrkf("NOCV", "NOCV_eigenvalues_alpha", file="adf")
            beta = self.job.full.results.readrkf("NOCV", "NOCV_eigenvalues_beta", file="adf")
            tpl = (
                [float(x) for x in alpha],
                [float(x) for x in beta],  #  make mypy happy by ensuring return is tuple of list of floats
            )
            return tpl
        else:
            raise KeyError("NOCV eigenvalues not found in the rkf file")

    def check_nocv_eigenvalues(self) -> bool:
        """Basic NOCV check.

        The eigenvalue #i should be equal to -eigenvalue #-i.

        Returns:
            bool: True if the check passes, False otherwise.
        """

        def _check_list(eigenvalues):
            for i in range(int(len(eigenvalues) / 2)):
                if not np.isclose(eigenvalues[i], -1 * eigenvalues[-i - 1], atol=1e-4):
                    return False
            return True

        NOCV_eigenvalues = self.job.results.get_nocv_eigenvalues()
        # spin-restricted calculation
        if isinstance(NOCV_eigenvalues, list):
            return _check_list(NOCV_eigenvalues)
        elif isinstance(NOCV_eigenvalues, tuple):
            for eigenvalues in NOCV_eigenvalues:
                if not _check_list(eigenvalues):
                    return False
            return True
        else:
            raise TypeError("Unexpected type for NOCV eigenvalues")

    def get_nocv_orbital_interaction(self, unit="kcal/mol") -> Union[List[float], Tuple[List[float]]]:
        """Get the NOCV orbital interactions of the full calculation."""

        def _calc_energies(oi: List[float]) -> List[float]:
            ret = []
            for i in range(int(len(oi) / 2)):
                a = oi[i]
                b = oi[len(oi) - i - 1]
                ret.append(a + b)
            return ret

        keys = self.job.full.results.get_rkf_skeleton(file="adf")
        if "NOCV" not in keys:
            raise KeyError("NOCV section not found in the rkf file")
        keys = keys["NOCV"]
        # restricted calculation
        if "NOCV_oi_restricted" in keys:
            oi = self.job.full.results.readrkf("NOCV", "NOCV_oi_restricted", file="adf")
        # unrestricted calculation
        elif "NOCV_oi_alpha" in keys:
            tpl = (
                self.job.full.results.readrkf("NOCV", "NOCV_oi_alpha", file="adf"),
                self.job.full.results.readrkf("NOCV", "NOCV_oi_beta", file="adf"),
            )
        else:
            raise KeyError("NOCV orbital interaction not found in rkf file")
        energies: Union[List[float], Tuple[List[float], List[float]]]  #  make mypy happy
        if isinstance(oi, tuple):
            energies = (_calc_energies(tpl[0]), _calc_energies(tpl[1]))
        elif isinstance(oi, list):
            energies = _calc_energies(oi)
        return Units.convert(energies, "kcal/mol", unit)


class ADFFragmentJob(MultiJob):
    """Subclass of |MultiJob| for ADF fragment calculations."""

    _result_type = ADFFragmentResults

    def __init__(
        self,
        fragment1: Optional[Molecule] = None,
        fragment2: Optional[Molecule] = None,
        fragment1_opt: Optional[Molecule] = None,
        fragment2_opt: Optional[Molecule] = None,
        full_settings: Optional[Settings] = None,
        frag1_settings: Optional[Settings] = None,
        frag2_settings: Optional[Settings] = None,
        **kwargs,
    ):
        """Create an ADFFragmentJob with the given fragments.

        The optimized fragment structures can be given as arguments.
        Single point calculations on these structures will then be performed
        to obtain the preparation energy.

        Subclass of |MultiJob|.

        Args:
            fragment1 (|Molecule|): The first fragment.
            fragment2 (|Molecule|): The second fragment.
            fragment1_opt (|Molecule|, optional): The optimized first fragment.
            fragment2_opt (|Molecule|, optional): The optimized second fragment.
            full_settings (Settings): The settings for the full calculation.
            frag1_settings (Settings): Specific settings for the first fragment calculation.
            frag2_settings (Settings): Specific settings for the second fragment calculation.
            **kwargs: Further keyword arguments for |MultiJob|.
        """
        MultiJob.__init__(self, **kwargs)
        self.fragment1 = fragment1.copy() if isinstance(fragment1, Molecule) else fragment1
        self.frag1_settings = frag1_settings or Settings()
        self.fragment2 = fragment2.copy() if isinstance(fragment2, Molecule) else fragment2
        self.frag2_settings = frag2_settings or Settings()
        self.full_settings = full_settings or Settings()

        if isinstance(fragment1_opt, Molecule):
            self.fragment1_opt = fragment1_opt.copy()
        elif fragment1_opt is not None:
            raise TypeError("fragment1_opt should be a Molecule object or None")
        if isinstance(fragment2_opt, Molecule):
            self.fragment2_opt = fragment2_opt.copy()
        elif fragment2_opt is not None:
            raise TypeError("fragment2_opt should be a Molecule object or None")

    def prerun(self):  # noqa F811
        """Prepare the fragments and the full calculation."""
        self.f1 = AMSJob(
            name="frag1",
            molecule=self.fragment1,
            settings=self.settings + self.frag1_settings,
        )
        self.f2 = AMSJob(
            name="frag2",
            molecule=self.fragment2,
            settings=self.settings + self.frag2_settings,
        )

        for at in self.fragment1:
            at.properties.suffix = "adf.f=subsystem1"
        for at in self.fragment2:
            at.properties.suffix = "adf.f=subsystem2"

        self.full = AMSJob(
            name="full",
            molecule=self.fragment1 + self.fragment2,
            settings=self.settings + self.full_settings,
        )

        self.full.settings.input.adf.fragments.subsystem1 = (self.f1, "adf")
        self.full.settings.input.adf.fragments.subsystem2 = (self.f2, "adf")
        self.children = [self.f1, self.f2, self.full]

        if hasattr(self, "fragment1_opt"):
            self.f1_opt = AMSJob(
                name="frag1_opt",
                molecule=self.fragment1_opt,
                settings=self.settings + self.frag1_settings,
            )
            self.children.append(self.f1_opt)
        if hasattr(self, "fragment2_opt"):
            self.f2_opt = AMSJob(
                name="frag2_opt",
                molecule=self.fragment2_opt,
                settings=self.settings + self.frag2_settings,
            )
            self.children.append(self.f2_opt)

    @classmethod
    def load_external(cls, path: str, jobname: Optional[str] = None) -> "ADFFragmentJob":
        """Load the results of the ADFFragmentJob job from an external path.

        Args:
            path (str): The path to the job. It should at least have the
                        subfolders 'frag1', 'frag2' and 'full'.
            jobname (str, optional): The name of the job. Defaults to None.

        Returns:
            ADFFragmentJob: The job with the loaded results.
        """
        if not isdir(path):
            raise FileError("Path {} does not exist, cannot load from it.".format(path))
        path = abspath(path)
        jobname = basename(path) if jobname is None else str(jobname)

        job = cls(name=jobname)
        job.path = path
        job.status = "copied"

        job.f1 = AMSJob.load_external(opj(path, "frag1"))
        job.f2 = AMSJob.load_external(opj(path, "frag2"))
        job.full = AMSJob.load_external(opj(path, "full"))
        job.children = [job.f1, job.f2, job.full]

        if isdir(opj(path, "frag1_opt")):
            job.f1_opt = AMSJob.load_external(opj(path, "frag1_opt"))
            job.children.append(job.f1_opt)
        if isdir(opj(path, "frag2_opt")):
            job.f2_opt = AMSJob.load_external(opj(path, "frag2_opt"))
            job.children.append(job.f2_opt)

        return job

To follow along, either

Worked Example

Initialization

from scm.plams import Settings, Molecule, init, AMSJob, Units
from scm.plams.recipes.adffragment import ADFFragmentJob

# this line is not required in AMS2025+
init()
PLAMS working folder: /path/plams/examples/ADFFrag/plams_workdir

Define the molecules

For convenience we define here two molecules, normally you would read them from xyz files

def get_molecule(input_string):
    job = AMSJob.from_input(input_string)
    return job.molecule[""]


mol1 = get_molecule(
    """
System
    Atoms
        C      -0.75086900       1.37782400      -2.43303700
        C      -0.05392100       2.51281000      -2.41769100
        H      -1.78964800       1.33942600      -2.09651100
        H      -0.30849400       0.43896500      -2.76734700
        H      -0.49177100       3.45043100      -2.06789100
        H       0.98633900       2.54913500      -2.74329400
    End
End
"""
)


mol2 = get_molecule(
    """
System
    Atoms
        C       0.14667300      -0.21503500       0.40053800
        C       1.45297400      -0.07836900       0.12424400
        C       2.23119700       1.15868100       0.12912100
        C       1.78331500       2.39701500       0.38779700
        H      -0.48348000       0.63110600       0.67664100
        H      -0.33261900      -1.19332100       0.35411600
        H       2.01546300      -0.97840100      -0.14506700
        H       3.29046200       1.03872500      -0.12139700
        H       2.45728900       3.25301000       0.35150400
        H       0.74193400       2.60120700       0.64028800
    End
End
"""
)

Setup and run the job

common = Settings()  # common settings for all 3 jobs
common.input.ams.Task = "SinglePoint"
common.input.adf.basis.type = "DZP"
common.input.adf.xc.gga = "PBE"
common.input.adf.symmetry = "NOSYM"

full = Settings()  # additional settings for full system calculation
full.input.adf.etsnocv  # empty block
full.input.adf.print = "etslowdin"

# normally you would read here the two molecules from xyz files.
# mol1 = Molecule("ethene.xyz")
# mol2 = Molecule("butadiene.xyz")

j = ADFFragmentJob(fragment1=mol1, fragment2=mol2, settings=common, full_settings=full)
r = j.run()
[25.03|17:15:00] JOB plamsjob STARTED
[25.03|17:15:00] JOB plamsjob RUNNING
[25.03|17:15:00] JOB plamsjob/frag1 STARTED
[25.03|17:15:00] JOB plamsjob/frag1 RUNNING
[25.03|17:15:11] JOB plamsjob/frag1 FINISHED
[25.03|17:15:12] JOB plamsjob/frag1 SUCCESSFUL
[25.03|17:15:12] JOB plamsjob/frag2 STARTED
[25.03|17:15:12] JOB plamsjob/frag2 RUNNING
[25.03|17:15:23] JOB plamsjob/frag2 FINISHED
[25.03|17:15:23] JOB plamsjob/frag2 SUCCESSFUL
... (PLAMS log lines truncated) ...

Complete Python code

#!/usr/bin/env amspython
# coding: utf-8

# ## Initialization

from scm.plams import Settings, Molecule, init, AMSJob, Units
from scm.plams.recipes.adffragment import ADFFragmentJob

# this line is not required in AMS2025+
init()


# ## Define the molecules
# For convenience we define here two molecules, normally you would read them from xyz files


def get_molecule(input_string):
    job = AMSJob.from_input(input_string)
    return job.molecule[""]


mol1 = get_molecule(
    """
System
    Atoms
        C      -0.75086900       1.37782400      -2.43303700
        C      -0.05392100       2.51281000      -2.41769100
        H      -1.78964800       1.33942600      -2.09651100
        H      -0.30849400       0.43896500      -2.76734700
        H      -0.49177100       3.45043100      -2.06789100
        H       0.98633900       2.54913500      -2.74329400
    End
End
"""
)


mol2 = get_molecule(
    """
System
    Atoms
        C       0.14667300      -0.21503500       0.40053800
        C       1.45297400      -0.07836900       0.12424400
        C       2.23119700       1.15868100       0.12912100
        C       1.78331500       2.39701500       0.38779700
        H      -0.48348000       0.63110600       0.67664100
        H      -0.33261900      -1.19332100       0.35411600
        H       2.01546300      -0.97840100      -0.14506700
        H       3.29046200       1.03872500      -0.12139700
        H       2.45728900       3.25301000       0.35150400
        H       0.74193400       2.60120700       0.64028800
    End
End
"""
)


# ## Setup and run the job

common = Settings()  # common settings for all 3 jobs
common.input.ams.Task = "SinglePoint"
common.input.adf.basis.type = "DZP"
common.input.adf.xc.gga = "PBE"
common.input.adf.symmetry = "NOSYM"

full = Settings()  # additional settings for full system calculation
full.input.adf.etsnocv  # empty block
full.input.adf.print = "etslowdin"

# normally you would read here the two molecules from xyz files.
# mol1 = Molecule("ethene.xyz")
# mol2 = Molecule("butadiene.xyz")

j = ADFFragmentJob(fragment1=mol1, fragment2=mol2, settings=common, full_settings=full)
r = j.run()


# ## Print the results


def print_eterm(energy_term, energy):
    print(
        f'{energy_term:>30s} {energy:16.4f} {Units.convert(energy, "au", "eV"):16.3f} {Units.convert(energy, "au", "kcal/mol"):16.2f} {Units.convert(energy, "au", "kJ/mol"):16.2f}'
    )


def print_bonding_energy_terms(r):
    print("Energy terms contributing to the bond energy (with respect to the fragments):")

    bond_energy = r.get_energy()
    decom = r.get_energy_decomposition()
    print(f'\n{"term":>30s} {"Hartree":>16s} {"eV":>16s} {"kcal/mol":>16s} {"kJ/mol":>16s}')
    for energy_term, energy in decom.items():
        print_eterm(energy_term, energy)

    print_eterm("total bond energy", bond_energy)
    print("")


def print_eda_terms(job):
    bond_energy = job.full.results.readrkf("Energy", "Bond Energy", "adf")
    steric_interaction = job.full.results.readrkf("Energy", "Steric Total", "adf")
    orbital_interaction = job.full.results.readrkf("Energy", "Orb.Int. Total", "adf")
    print("\nFragment based energy decomposition analysis of the bond energy:")
    print(f'\n{"term":>30s} {"Hartree":>16s} {"eV":>16s} {"kcal/mol":>16s} {"kJ/mol":>16s}')
    print_eterm("Steric interaction", steric_interaction)
    print_eterm("Orbital interaction", orbital_interaction)
    print_eterm("total bond energy", bond_energy)
    print("")


def print_nocv_decomposition():
    print("NOCV decomposition of the orbital interaction term\n")

    print("The NOCV eigenvalues are occupation numbers, they should come in pairs,")
    print("with one negative value mirrored by a positive value.")
    print("The orbital interaction energy contribution is calculated for each NOCV pair.")
    print("")

    nocv_eigenvalues = j.full.results.readrkf("NOCV", "NOCV_eigenvalues_restricted", "engine")
    nocv_orbitalinteraction = j.full.results.readrkf("NOCV", "NOCV_oi_restricted", "engine")

    n_pairs = int(len(nocv_eigenvalues) / 2)
    threshold = 0.001

    print(f'{"index":>9s} {"neg":>9s} {"pos":>9s} {"kcal/mol":>10s}')
    for index in range(n_pairs):
        pop1 = nocv_eigenvalues[index]
        pop2 = nocv_eigenvalues[len(nocv_eigenvalues) - index - 1]

        if (abs(pop1) + abs(pop2)) < threshold:
            continue

        orbitalinteraction = (
            nocv_orbitalinteraction[index] + nocv_orbitalinteraction[len(nocv_orbitalinteraction) - index - 1]
        )
        print(f"{index:9d} {pop1:9.3f} {pop2:9.3f} {orbitalinteraction:10.2f}")


print_bonding_energy_terms(r)

print_eda_terms(j)

print_nocv_decomposition()

API

class ADFFragmentJob[source]

Subclass of MultiJob for ADF fragment calculations.

Parameters:
_result_type

alias of ADFFragmentResults

__init__(fragment1=None, fragment2=None, fragment1_opt=None, fragment2_opt=None, full_settings=None, frag1_settings=None, frag2_settings=None, **kwargs)[source]

Create an ADFFragmentJob with the given fragments.

The optimized fragment structures can be given as arguments. Single point calculations on these structures will then be performed to obtain the preparation energy.

Subclass of MultiJob.

Parameters:
  • fragment1 (Molecule) – The first fragment.

  • fragment2 (Molecule) – The second fragment.

  • fragment1_opt (Molecule, optional) – The optimized first fragment.

  • fragment2_opt (Molecule, optional) – The optimized second fragment.

  • full_settings (Settings) – The settings for the full calculation.

  • frag1_settings (Settings) – Specific settings for the first fragment calculation.

  • frag2_settings (Settings) – Specific settings for the second fragment calculation.

  • **kwargs – Further keyword arguments for MultiJob.

prerun()[source]

Prepare the fragments and the full calculation.

classmethod load_external(path, jobname=None)[source]

Load the results of the ADFFragmentJob job from an external path.

Parameters:
  • path (str) – The path to the job. It should at least have the subfolders ‘frag1’, ‘frag2’ and ‘full’.

  • jobname (str, optional) – The name of the job. Defaults to None.

Returns:

The job with the loaded results.

Return type:

ADFFragmentJob

class ADFFragmentResults[source]
check()[source]

Check if the calculation finished successfully.

Overwriting the method of MultiJob because it only checks if the job finished, not how it finished.

get_properties()[source]

Redirect to AMSResults of the full calculation.

get_main_molecule()[source]

Redirect to AMSResults of the full calculation.

get_input_molecule()[source]

Redirect to AMSResults of the full calculation.

get_energy(unit='au')[source]

Redirect to AMSResults of the full calculation.

get_dipole_vector(unit='au')[source]

Redirect to AMSResults of the full calculation.

get_energy_decomposition(unit='au')[source]

Get the energy decomposition of the fragment calculation.

Parameters:

unit (str, optional) – The unit of the energy. Defaults to ‘au’.

Returns:

The energy decomposition.

Return type:

Dict[str, float]

get_nocv_eigenvalues()[source]

Get the NOCV eigenvalues of the full calculation.

Return type:

List[float] | Tuple[List[float], List[float]]

check_nocv_eigenvalues()[source]

Basic NOCV check.

The eigenvalue #i should be equal to -eigenvalue #-i.

Returns:

True if the check passes, False otherwise.

Return type:

bool

get_nocv_orbital_interaction(unit='kcal/mol')[source]

Get the NOCV orbital interactions of the full calculation.

Return type:

List[float] | Tuple[List[float]]