from typing import Dict, List
import numpy as np
from scm.plams import KFReader
from tcutility import constants, ensure_list
from tcutility.results import Result, cache
from tcutility.tc_typing import arrays
# ------------------------------------------------------------- #
# ------------------- Calculation Settings -------------------- #
# ------------------------------------------------------------- #
[docs]
def get_calc_settings(info: Result) -> Result:
"""Function to read calculation settings for an ADF calculation.
Args:
info: Result object containing ADF calculation settings.
Returns:
:Result object containing properties from the ADF calculation:
- **task (str)** – the task that was set for the calculation.
- **relativistic (bool)** – whether or not relativistic effects were enabled.
- **relativistic_type (str)** – the name of the relativistic approximation used.
- **unrestricted_sfos (bool)** – whether or not SFOs are treated in an unrestricted manner.
- **unrestricted_mos (bool)** – whether or not MOs are treated in an unrestricted manner.
- **symmetry.group (str)** – the symmetry group selected for the molecule.
- **symmetry.labels (list[str])** – the symmetry labels associated with the symmetry group.
- **used_regions (bool)** – whether regions were used in the calculation.
- **charge (int)** - the charge of the system.
- **spin_polarization (int)** - the spin-polarization of the system.
- **multiplicity (int)** - the multiplicity of the system. This is equal to 2|S|+1 for spin-polarization S.
"""
assert info.engine == "adf", f"This function reads ADF data, not {info.engine} data"
ret = Result()
# set the calculation task at a higher level
ret.task = info.input.task
# the VibrationalAnalysis task does not produce adf.rkf files
# in that case we end the reading here, since the following
# variables do not apply
if ret.task.lower() == "vibrationalanalysis":
return ret
reader_adf = cache.get(info.files["adf.rkf"])
relativistic_type_map = {
0: "None",
1: "scalar Pauli",
3: "scalar ZORA", # scalar ZORA + MAPA
4: "scalar ZORA + full pot.",
5: "scalar ZORA + APA",
6: "scalar X2C + MAPA",
7: "scalar X2C ZORA + MAPA",
11: "spin-orbit Pauli",
13: "spin-orbit ZORA", # spin-orbit ZORA + MAPA
14: "spin-orbit ZORA + full pot.",
15: "spin-orbit ZORA + APA",
16: "spin-orbit X2C + MAPA",
17: "spin-orbit X2C ZORA + MAPA",
}
# determine if calculation used relativistic corrections
# if it did, variable 'escale' will be present in 'SFOs'
# if it didnt, only variable 'energy' will be present
ret.relativistic = ("SFOs", "escale") in reader_adf
ret.relativistic_type = relativistic_type_map[reader_adf.read("General", "ioprel")]
# determine if MOs are unrestricted or not
# general, nspin is 1 for restricted and 2 for unrestricted calculations
ret.unrestricted_mos = reader_adf.read("General", "nspin") == 2
# determine if SFOs are unrestricted or not
ret.unrestricted_sfos = reader_adf.read("General", "nspinf") == 2
# get the symmetry group
ret.symmetry.group = reader_adf.read("Geometry", "grouplabel").strip()
# get the symmetry labels
ret.symmetry.labels = reader_adf.read("Symmetry", "symlab").strip().split()
# determine if the calculation used regions or not
frag_order = reader_adf.read("Geometry", "fragment and atomtype index")
frag_order = frag_order[: len(frag_order) // 2]
ret.used_regions = max(frag_order) != len(frag_order)
ret.charge = reader_adf.read("Molecule", "Charge")
ret.spin_polarization = 0
if ret.unrestricted_mos:
nalpha = 0
nbeta = 0
for label in ret.symmetry.labels:
nalpha += sum(ensure_list(reader_adf.read(label, "froc_A")))
nbeta += sum(ensure_list(reader_adf.read(label, "froc_B")))
ret.spin_polarization = nalpha - nbeta
ret.multiplicity = 2 * ret.spin_polarization + 1
return ret
# ------------------------------------------------------------- #
# ------------------------ Properties ------------------------- #
# ------------------------------------------------------------- #
# ----------------------- VDD charges ------------------------- #
def _read_vdd_charges(kf_reader: KFReader) -> arrays.Array1D[np.float64]:
"""Returns the VDD charges from the KFReader object."""
vdd_scf: List[float] = ensure_list(kf_reader.read("Properties", "AtomCharge_SCF Voronoi")) # type: ignore since plams does not include typing for KFReader. List[float] is returned
vdd_ini: List[float] = ensure_list(kf_reader.read("Properties", "AtomCharge_initial Voronoi")) # type: ignore since plams does not include typing for KFReader. List[float] is returned
# VDD charges are scf - initial charges. Note, these are in units of electrons while most often these are denoted in mili-electrons
return np.array([float((scf - ini)) for scf, ini in zip(vdd_scf, vdd_ini)])
def _get_vdd_charges_per_irrep(results_type: KFReader) -> Dict[str, arrays.Array1D[np.float64]]:
"""Extracts the Voronoi Deformation Density charges from the fragment calculation sorted per irrep."""
symlabels = str(results_type.read("Symmetry", "symlab")).split() # split on whitespace
# If there is only one irrep, there is no irrep decomposition
if len(symlabels) == 1:
return {}
vdd_irrep = np.array(results_type.read("Properties", "Voronoi chrg per irrep"))
n_atoms = int(results_type.read("Molecule", "nAtoms")) # type: ignore since no static typing. Returns an int
# NOTE: apparently, the irrep charges are minus the total VDD charges. That's why the minus sign in the second line below
vdd_irrep = vdd_irrep[-len(symlabels) * n_atoms :] # vdd_irrep = vdd_irrep.reshape((n_headers, len(symlabels), n_atoms)) # NOQA E203
vdd_per_irrep = {irrep: -vdd_irrep[i * n_atoms : (i + 1) * n_atoms] for i, irrep in enumerate(symlabels)} # NOQA E203
return vdd_per_irrep
# ----------------------- Vibrations ------------------------- #
def _read_vibrations(reader: cache.TrackKFReader) -> Result:
ret = Result()
ret.number_of_modes = reader.read("Vibrations", "nNormalModes")
ret.frequencies = ensure_list(reader.read("Vibrations", "Frequencies[cm-1]"))
if ("Vibrations", "Intensities[km/mol]") in reader:
ret.intensities = ensure_list(reader.read("Vibrations", "Intensities[km/mol]"))
ret.number_of_imag_modes = len([freq for freq in ret.frequencies if freq < 0])
ret.character = "minimum" if ret.number_of_imag_modes == 0 else "transitionstate"
ret.modes = []
for i in range(ret.number_of_modes):
ret.modes.append(reader.read("Vibrations", f"NoWeightNormalMode({i+1})"))
return ret
[docs]
def get_properties(info: Result) -> Result:
"""Function to get properties from an ADF calculation.
Args:
info: Result object containing ADF properties.
Returns:
:Result object containing properties from the ADF calculation:
- **energy.bond (float)** – bonding energy (|kcal/mol|).
- **energy.elstat.total (float)** – total electrostatic potential (|kcal/mol|).
- **energy.elstat.Vee (float)** – electron-electron repulsive term of the electrostatic potential (|kcal/mol|).
- **energy.elstat.Ven (float)** – electron-nucleus attractive term of the electrostatic potential (|kcal/mol|).
- **energy.elstat.Vnn (float)** – nucleus-nucleus repulsive term of the electrostatic potential (|kcal/mol|).
- **energy.orbint.total (float)** – total orbital interaction energy containing contributions from each symmetry label and correction energy(|kcal/mol|).
- **energy.orbint.{symmetry label} (float)** – orbital interaction energy from a specific symmetry label (|kcal/mol|).
- **energy.orbint.correction (float)** - orbital interaction correction energy, the difference between the total and the sum of the symmetrized interaction energies (|kcal/mol|)
- **energy.pauli.total (float)** – total Pauli repulsion energy (|kcal/mol|).
- **energy.dispersion (float)** – total dispersion energy (|kcal/mol|).
- **energy.gibbs (float)** – Gibb's free energy (|kcal/mol|). Only populated if vibrational modes were calculated.
- **energy.enthalpy (float)** – enthalpy (|kcal/mol|). Only populated if vibrational modes were calculated.
- **energy.nuclear_internal (float)** – nuclear internal energy (|kcal/mol|). Only populated if vibrational modes were calculated.
- **vibrations.number_of_modes (int)** – number of vibrational modes for this molecule, 3N-5 for non-linear molecules and 3N-6 for linear molecules, where N is the number of atoms.
- **vibrations.number_of_imag_modes (int)** – number of imaginary vibrational modes for this molecule.
- **vibrations.frequencies (float)** – vibrational frequencies associated with the vibrational modes, sorted from low to high (|cm-1|).
- **vibrations.intensities (float)** – vibrational intensities associated with the vibrational modes (|km/mol|).
- **vibrations.modes (list[float])** – list of vibrational modes sorted from low frequency to high frequency.
- **vibrations.character (str)** – Character of the molecule based on the number of imaginary vibrational modes. Can be "minimum" or "transition state".
- **vdd.charges (nparray[float] (1D))** - 1D array of Voronoi Deformation Denisty (VDD) charges in [electrons], being the difference between the final (SCF) and initial VDD charges.
- **vdd.charges.{symmetry label} (nparray[float] (1D))** - 1D array of Voronoi Deformation Denisty (VDD) charges in [electrons] per irrep.
- **s2** - expectation value of the :math:`S^2` operator.
- **s2_expected** - ideal expectation value of the :math:`S^2` operator. For restricted calculations this should always equal ``s2``.
- **spin_contamination** - the amount of spin-contamination observed in this calculation. It is equal to (s2 - s2_expected) / (s2_expected). Ideally this value should be below 0.1.
"""
assert info.engine == "adf", f"This function reads ADF data, not {info.engine} data"
ret = Result()
if info.adf.task.lower() == "vibrationalanalysis":
reader_ams = cache.get(info.files["ams.rkf"])
ret.vibrations = _read_vibrations(reader_ams)
return ret
reader_adf = cache.get(info.files["adf.rkf"])
# read energies (given in Ha in rkf files)
ret.energy.bond = reader_adf.read("Energy", "Bond Energy") * constants.HA2KCALMOL
# total electrostatic potential
ret.energy.elstat.total = reader_adf.read("Energy", "elstat") * constants.HA2KCALMOL
# we can further decompose elstat if it was enabled
if info.files.out:
with open(info.files.out) as output:
lines = output.readlines()
skip_next = -1
for line in lines:
if "Electrostatic Interaction Energies" in line:
skip_next = 4
continue
if skip_next == 0:
f1, f2, Vee, Ven, Vnn, total = line.strip().split()
ret.energy.elstat.Vee = float(Vee) * constants.HA2KCALMOL
ret.energy.elstat.Ven = float(Ven) * constants.HA2KCALMOL
ret.energy.elstat.Vnn = float(Vnn) * constants.HA2KCALMOL
skip_next -= 1
# print(info.files)
# read the total orbital interaction energy
ret.energy.orbint.total = reader_adf.read("Energy", "Orb.Int. Total") * constants.HA2KCALMOL
# to calculate the orbital interaction term:
# the difference between the total and the sum of the symmetrized interaction energies should be calculated
# therefore the correction is first set equal to the total orbital interaction.
ret.energy.orbint.correction = ret.energy.orbint.total
# looping over every symlabel, to get the energy per symmetry label
for symlabel in info.adf.symmetry.labels:
symlabel = symlabel.split(":")[0]
ret.energy.orbint[symlabel] = reader_adf.read("Energy", f"Orb.Int. {symlabel}") * constants.HA2KCALMOL
# the energy per symmetry label is abstracted from the "total orbital interaction"
# obtaining the correction to the orbital interaction term
ret.energy.orbint.correction -= ret.energy.orbint[symlabel]
ret.energy.pauli.total = reader_adf.read("Energy", "Pauli Total") * constants.HA2KCALMOL
ret.energy.dispersion = reader_adf.read("Energy", "Dispersion Energy") * constants.HA2KCALMOL
if ("Thermodynamics", "Gibbs free Energy") in reader_adf:
ret.energy.gibbs = reader_adf.read("Thermodynamics", "Gibbs free Energy") * constants.HA2KCALMOL
ret.energy.enthalpy = reader_adf.read("Thermodynamics", "Enthalpy") * constants.HA2KCALMOL
ret.energy.nuclear_internal = reader_adf.read("Thermodynamics", "Internal Energy total") * constants.HA2KCALMOL
# vibrational information
if ("Vibrations", "nNormalModes") in reader_adf:
ret.vibrations = _read_vibrations(reader_adf)
# read the Voronoi Deformation Charges Deformation (VDD) before and after SCF convergence (being "inital" and "SCF")
try:
ret.vdd.charges = _read_vdd_charges(reader_adf)
ret.vdd.update(_get_vdd_charges_per_irrep(reader_adf))
except KeyError:
pass
# read spin-squared operator info
# the total spin
S = info.adf.spin_polarization * 1 / 2
ret.s2_expected = S * (S + 1)
# this is the real expectation value
if ("Properties", "S2calc") in reader_adf:
ret.s2 = reader_adf.read("Properties", "S2calc")
else:
ret.s2 = 0
# calculate the contamination
# if S is 0 then we will get a divide by zero error, but spin-contamination should be 0
if S != 0:
ret.spin_contamination = (ret.s2 - ret.s2_expected) / (ret.s2_expected)
else:
ret.spin_contamination = 0
return ret
[docs]
def get_level_of_theory(info: Result) -> Result:
"""Function to get the level-of-theory from an input-file.
Args:
inp_path: Path to the input file. Can be a .run or .in file create for AMS
Returns:
:Dictionary containing information about the level-of-theory:
- **summary (str)** - a summary string that gives the level-of-theory in a human-readable format.
- **xc.functional (str)** - XC-functional used in the calculation.
- **xc.category (str)** - category the XC-functional belongs to. E.g. GGA, MetaHybrid, etc ...
- **xc.dispersion (str)** - the dispersion correction method used during the calculation.
- **xc.summary (str)** - a summary string that gives the XC-functional information in a human-readable format.
- **xc.empiricalScaling (str)** - which empirical scaling parameter was used. Useful for MP2 calculations.
- **basis.type (str)** - the size/type of the basis-set.
- **basis.core (str)** - the size of the frozen-core approximation.
- **quality (str)** - the numerical quality setting.
"""
sett = info.input
ret = Result()
# print(json.dumps(sett, indent=4))
xc_categories = ["GGA", "LDA", "MetaGGA", "MetaHybrid", "Model", "LibXC", "DoubleHybrid", "Hybrid", "MP2", "HartreeFock"]
ret.xc.functional = "VWN"
ret.xc.category = "LDA"
for cat in xc_categories:
if cat.lower() in [key.lower() for key in sett.adf.xc]:
ret.xc.functional = sett.adf.xc[cat]
ret.xc.category = cat
ret.basis.type = sett.adf.basis.type
ret.basis.core = sett.adf.basis.core
ret.quality = sett.adf.NumericalQuality or "Normal"
ret.xc.dispersion = None
if "dispersion" in [key.lower() for key in sett.adf.xc]:
ret.xc.dispersion = " ".join(sett.adf.xc.dispersion.split())
# the empirical scaling value is used for MP2 calculations
ret.xc.empirical_scaling = None
if "empiricalscaling" in [key.lower() for key in sett.adf.xc]:
ret.xc.empiricalscaling = sett.adf.xc.empiricalscaling
# MP2 and HF are a little bit different from the usual xc categories. They are not key-value pairs but only the key
# we start building the ret.xc.summary string here already. This will contain the human-readable functional name
if ret.xc.category == "MP2":
ret.xc.summary = "MP2"
if ret.xc.empiricalscaling:
ret.xc.summary += f"-{ret.xc.empiricalscaling}"
elif ret.xc.category == "HartreeFock":
ret.xc.summary = "HF"
else:
ret.xc.summary = ret.xc.functional
# If dispersion was used, we want to add it to the ret.xc.summary
if ret.xc.dispersion:
if ret.xc.dispersion.lower() == "grimme3":
ret.xc.summary += "-D3"
if ret.xc.dispersion.lower() == "grimme3 bjdamp":
ret.xc.summary += "-D3(BJ)"
if ret.xc.dispersion.lower() == "grimme4":
ret.xc.summary += "-D4"
if ret.xc.dispersion.lower() == "ddsc":
ret.xc.summary += "-dDsC"
if ret.xc.dispersion.lower() == "uff":
ret.xc.summary += "-dUFF"
if ret.xc.dispersion.lower() == "mbd":
ret.xc.summary += "-MBD@rsSC"
if ret.xc.dispersion.lower() == "default":
ret.xc.summary += "-D"
# ret.summary is simply the ret.xc.summary plus the basis set type
ret.summary = f"{ret.xc.summary}/{ret.basis.type}"
return ret