Source code for vasp_step.batch

# -*- coding: utf-8 -*-

"""VASP's side of the Model Chemistry batch contract.

``get_task`` writes the same inputs the Energy substep writes for the same
settings (both use :mod:`vasp_step.inputs`), and ``analyze_task`` reads the
energy, forces and stress back. The model chemistries are

    VASP:DFT@<functional>/<potentials>@<ENCUT in eV>

for example ``VASP:DFT@r2SCAN-D4/PAW-hard@1200``. The potentials are a PAW set,
optionally a named variant (``PAW`` = potpaw_PBE.64 with VASP's recommended
potentials, ``PAW-hard`` = the same with the hard potentials where they exist,
``PAW-LDA`` = potpaw_LDA.64). Without a cutoff, ENCUT is 1.3 × the largest
ENMAX of the potentials.

Dispersion. A functional with ``-D4`` is the functional plus the dftd4 program's
D4 correction for it, computed in the same task after VASP (``dftd4`` in
vasp.ini), because VASP builds are often compiled without D4 (IVDW = 13). A
periodic cell gets the periodic D4; a fragment in a box gets the D4 of the
isolated fragment, as its molecular counterparts do, without the dispersion
with its images.

``options`` for a structure:

* ``grid``: ``{"max_spacing": Å}`` sets the cell's FFT grid explicitly (NGX,
  NGXF = 2·NGX, ...), the cell's atoms shifted by whole grid steps. With
  ``"reference_cell"`` (3x3) a molecular structure is a fragment registered on
  that parent cell's grid (see :mod:`vasp_step.grid`), in a box of its extent
  plus ``"padding"`` (7.5 Å), with the dipole correction. Without ``grid`` a
  molecular structure cannot run.
* ``charge``, ``multiplicity``: override the structure's.
* ``dipole``: False to leave out the dipole correction of a fragment.

The stress comes back in GPa as a stress (sigma = -P, tensile positive), as the
Energy substep stores it, so the model chemistries declare
``stress_convention = "stress"``; with D4 it includes D4's.

The POTCARs are read where the task is built, from the VASP potential library
(``<SEAMM>/Parameters/VASP``, catalogued by the VASP step) on that machine.
"""

import dataclasses
import json
import math

import numpy as np

import seamm_exec
from seamm_exec.evaluator import AnalysisError, check_properties, structure_data
from seamm_util import Q_

from . import grid as grid_
from . import inputs

#: The PAW sets the model chemistries name: name -> (set, variant)
POTENTIAL_SETS = {
    "PAW": ("potpaw_PBE.64", None),
    "PAW-hard": ("potpaw_PBE.64", "hard"),
    "PAW-LDA": ("potpaw_LDA.64", None),
}

#: dftd4's name for the functionals that have D4 parameters
DFTD4_FUNCTIONALS = {
    "PBE": "pbe",
    "RPBE": "rpbe",
    "revPBE": "revpbe",
    "TPSS": "tpss",
    "SCAN": "scan",
    "r2SCAN": "r2scan",
    "B3LYP": "b3lyp",
    "PBE0": "pbe0",
    "HSE06": "hse06",
}

#: Energy-parameter settings of a batch calculation (on top of the defaults)
SETTINGS = {
    "k-grid method": "𝚪-point",
    "precision": "accurate",
    "electronic method": "all",
    "ediff": 1.0e-7,
    "nelm": 200,
    "smearing width": 0.01,
    "lreal": "no",
    "lorbit": "no",
    "kpar": 1,
    "use hdf5 files": "no",
}

#: Narrower cells need k-points: refused at the Gamma point alone (Å)
GAMMA_ONLY_WIDTH = 10.0

HARTREE_EV = Q_(1.0, "E_h").m_as("eV")
EV_KJ = Q_(1.0, "eV").m_as("kJ/mol")
BOHR = Q_(1.0, "bohr").m_as("Å")


[docs] def functionals(): """The functionals: name -> (model, submodel, dftd4 name or None).""" import vasp_step dft = vasp_step.metadata["computational models"]["Density Functional Theory (DFT)"] result = {} for model, data in dft["models"].items(): for submodel in data["parameterizations"]: name = submodel.split(" : ")[0].strip() result[name] = (model, submodel, None) if name in DFTD4_FUNCTIONALS: result[f"{name}-D4"] = (model, submodel, DFTD4_FUNCTIONALS[name]) return result
[docs] def get_model_chemistry_options(periodic_only=False, mdi_only=False): """VASP's model chemistries (all periodic, none through MDI).""" if mdi_only: return {} options = {} for name, (model, _, _) in functionals().items(): if name.endswith("-D4BJ"): # VASP's own D4 (IVDW = 13) needs a build with DFTD4; the -D4 levels # (dftd4 run in the task) work with any build. continue potentials = "PAW-LDA" if model.startswith("Local-density") else "PAW" options[name] = { "model_chemistry": f"VASP:DFT@{name}/{potentials}", "type": "DFT", "description": "", "periodic_native": True, "periodic_mdi": False, "elements": "1-94", "mdi_capable": False, "mdi_method_arg": None, "prefers_batch": True, "stress_convention": "stress", } return options
[docs] def potential_catalog(): """The catalogue of the VASP potential library on this machine.""" from seamm_util import installation_path index = installation_path("Parameters", "VASP") / "index.json" if not index.exists(): raise RuntimeError( f"The VASP potential library has no catalogue ({index}). Run a VASP " "step once on this machine to create it." ) return json.loads(index.read_text())
def _level(model_chemistry): """(functional name, set, variant, ENCUT or None) of a model chemistry.""" method = model_chemistry.get("method") table = functionals() if method not in table: raise ValueError( f"VASP has no functional '{method}'. The functionals are: " + ", ".join(sorted(table)) ) basis = model_chemistry.get("basis") or "PAW" if basis not in POTENTIAL_SETS: raise ValueError( f"Unknown VASP potentials '{basis}': one of {sorted(POTENTIAL_SETS)}." ) potential_set, variant = POTENTIAL_SETS[basis] cutoff = model_chemistry.get("cutoff") encut = None if cutoff not in (None, ""): encut = float(str(cutoff).lower().replace("ev", "")) return method, potential_set, variant, encut
[docs] def can_run_task(configuration, model_chemistry, *, options=None): """A periodic structure, or a molecule registered on a parent cell.""" options = options or {} data = structure_data(configuration) if data["periodicity"] == 3: return True return data["periodicity"] == 0 and "reference_cell" in (options.get("grid") or {})
#: The cost model, fitted to the VASP step's timing records on ARC's TinkerCliffs #: (~/.seamm.d/timing/vasp.csv: 396,528 Gamma-point single points on 8 ranks, #: r2SCAN(-D3BJ), 1-432 atoms, 2025-12 to 2026-04):: #: #: log t = a + b log(Ne) + c log(V (ENCUT/500 eV)^1.5) #: #: with Ne the valence electrons and V the cell volume (ų). R² = 0.67 in log t; #: 68% of the runs within a factor of 1.3, 95% within 2. The 64,656 VASP runs of #: the MBE prototype (EDIFF 1e-7, ALGO All, 1200 eV, hard PAW) fall at 1.09 #: (monomers) and 0.86 (pairs) of it on 8 ranks, so no settings factor is needed. COST_FIT = (-7.209, 0.636, 1.342) #: Above 8 ranks the speed grows as ranks**0.5 (the prototype's 16-rank runs were #: 1.4-1.6x faster than its 8-rank ones); below, in proportion to the ranks. RANK_EXPONENT = 0.5
[docs] def estimated_seconds(nelect, volume, encut, ntasks, kpoints=1): """The expected wall time (s) of one VASP calculation on TinkerCliffs-like nodes: see :data:`COST_FIT`. Parameters ---------- nelect : float Valence electrons. volume : float The cell (or box) volume, ų. encut : float Plane-wave cutoff, eV. ntasks : int MPI ranks. kpoints : int k-points in the mesh (an upper bound on the irreducible ones). """ a, b, c = COST_FIT grid = volume * (encut / 500.0) ** 1.5 t8 = math.exp(a + b * math.log(max(nelect, 1.0)) + c * math.log(grid)) ntasks = max(1, int(ntasks)) if ntasks <= 8: scale = 8.0 / ntasks else: scale = (8.0 / ntasks) ** RANK_EXPONENT return t8 * scale * max(1, int(kpoints))
[docs] def cell_walltime(estimate): """The time limit (s) for a cell's calculation: 3x the estimate, at least an hour, in quarter hours. The prototype's slowest cell (a dense frame) took 2.8x the median.""" return max(3600.0, math.ceil(3.0 * estimate / 900.0) * 900.0)
def _zval(potcar): """The valence charge of each potential in a POTCAR, in order.""" values = [] for line in potcar.splitlines(): if "ZVAL" in line: values.append(float(line.split("ZVAL")[1].split("=")[1].split()[0])) return values
[docs] def get_task( configuration, model_chemistry, *, key, properties=("energy", "gradients"), options=None, resources=None, ): """A :class:`seamm_exec.Task` computing VASP's energy, forces and (for a cell, if asked) stress. See the module docstring.""" import vasp_step options = dict(options or {}) data = structure_data(configuration) method, potential_set, variant, encut = _level(model_chemistry) model, submodel, d4 = functionals()[method] grid_options = dict(options.get("grid") or {}) periodic = data["periodicity"] == 3 if not periodic and "reference_cell" not in grid_options: raise ValueError( "VASP calculations are periodic: a molecule needs grid['reference_cell'] " "to be placed in a box registered on its parent cell." ) charge = int(options.get("charge", data["charge"])) multiplicity = int(options.get("multiplicity", data["multiplicity"])) atnos = list(data["atomic_numbers"]) xyz = np.asarray(data["coordinates"], dtype=float) max_spacing = float(grid_options.get("max_spacing", grid_.DEFAULT_MAX_SPACING)) ng = None dipole = False k_spacing = options.get("k_spacing") if periodic: cell = np.asarray(data["cell"], dtype=float) if k_spacing is None: widths = abs(np.linalg.det(cell)) / np.linalg.norm( np.cross(cell[[1, 2, 0]], cell[[2, 0, 1]]), axis=1 ) if widths.min() < GAMMA_ONLY_WIDTH: raise ValueError( f"The cell is {widths.min():.2f} Å across at its narrowest: " "the Gamma point alone would not sample it. Give " "options['k_spacing'] (1/Å), or use a cell of at least " f"{GAMMA_ONLY_WIDTH:.0f} Å." ) if grid_options: placed = grid_.register_cell(xyz, cell, max_spacing) xyz, ng = placed["coordinates"], placed["ng"] else: placed = grid_.register( xyz, grid_options["reference_cell"], max_spacing, float(grid_options.get("padding", grid_.DEFAULT_PADDING)), ) xyz, ng = placed["coordinates"], placed["ng"] cell = np.diag(placed["box"]) dipole = options.get("dipole", True) catalog = potential_catalog()[potential_set] potcar, names = inputs.potcar_text(atnos, potential_set, catalog, variant=variant) enmax = inputs.enmax(atnos, potential_set, catalog, variant=variant) if encut is None: encut = 1.3 * enmax elif encut < enmax: raise ValueError( f"ENCUT {encut:.0f} eV is below the largest ENMAX of the potentials " f"({enmax:.1f} eV, {' '.join(names)}): the basis would be incomplete." ) ntasks = 1 if resources is None or not resources.ntasks else int(resources.ntasks) # The same conversion of the values as the substep's (e.g. "no" -> False) parameters = vasp_step.EnergyParameters() values = dict(SETTINGS) values["model"], values["submodel"] = model, submodel values["calculate stress"] = ( "yes" if (periodic and "stress" in properties) else "no" ) values["ncore"] = 4 if ntasks % 4 == 0 else 1 if multiplicity != 1: values["spin polarization"] = "collinear" if k_spacing is not None and periodic: values["k-grid method"] = "grid spacing" values["k-spacing"] = float(k_spacing) values["centering"] = "Gamma" for name, value in values.items(): parameters[name].value = value P = parameters.current_values_to_dict(context={}) extra = [("ISYM", 0), ("LWAVE", ".FALSE."), ("LCHARG", ".FALSE.")] if charge != 0: counts = inputs.atom_order(atnos)[2] unique = sorted(set(atnos), reverse=True) nelect = sum(z * counts[a] for z, a in zip(_zval(potcar), unique)) - charge extra.append(("NELECT", f"{nelect:.4f}")) if multiplicity != 1: extra.append(("NUPDOWN", multiplicity - 1)) if ng is not None: extra += [("NGX", ng[0]), ("NGY", ng[1]), ("NGZ", ng[2])] extra += [("NGXF", 2 * ng[0]), ("NGYF", 2 * ng[1]), ("NGZF", 2 * ng[2])] if dipole: extra += [("IDIPOL", 4), ("LDIPOL", ".TRUE."), ("DIPOL", "0.5 0.5 0.5")] functional = vasp_step.metadata["computational models"][ "Density Functional Theory (DFT)" ]["models"][model]["parameterizations"][submodel] if d4: # dftd4 adds the dispersion: VASP must not add its own as well (plain # revPBE's metadata carries IVDW = 12, for one) functional = dict(functional) functional["keywords"] = { k: v for k, v in functional["keywords"].items() if k != "IVDW" and not k.startswith("VDW_") } keywords, descriptions = inputs.keywords( P, functional=functional, istart=0, encut=encut, extra=extra, keyword_metadata=vasp_step.metadata["keywords"], ) lengths = None if P["k-grid method"] == "grid spacing": lengths = 2 * np.pi * np.linalg.norm(np.linalg.inv(cell).T, axis=1) kpoints, gamma_only = inputs.kpoints_text(P, lengths) files = { "INCAR": inputs.incar_text( keywords, descriptions, vasp_step.metadata["keywords"] ), "POTCAR": potcar, "KPOINTS": kpoints, "POSCAR": inputs.poscar_text(key, cell, atnos, xyz, cartesian=True, digits=10), } cmd = ["{gamma_code}" if gamma_only else "{code}", ">", "vasp.out", "2>&1"] success = {"OUTCAR": "General timing"} return_files = [ "INCAR", "KPOINTS", "POSCAR", "OUTCAR", "OSZICAR", "vasprun.xml", "vasp.out", ] if d4: d4_input = "POSCAR" if not periodic: symbols = data["symbols"] files["fragment.xyz"] = f"{len(atnos)}\n{key}\n" + "".join( f"{s} {x:.10f} {y:.10f} {z:.10f}\n" for s, (x, y, z) in zip(symbols, xyz) ) d4_input = "fragment.xyz" cmd += [ "&&", "{dftd4}", d4_input, "--func", d4, "--grad", "--json", "dftd4.json", "--noedisp", "--charge", str(charge), ">", "dftd4.out", ] success["dftd4.json"] = "energy" return_files += ["dftd4.json", "dftd4.out", "fragment.xyz"] # The cost estimate, and a time limit for a cell, which runs alone counts = inputs.atom_order(atnos)[2] unique = sorted(set(atnos), reverse=True) nelect = sum(z * counts[a] for z, a in zip(_zval(potcar), unique)) - charge volume = abs(np.linalg.det(np.asarray(cell, dtype=float))) mesh = [int(x) for x in kpoints.splitlines()[3].split()[:3]] estimate = estimated_seconds(nelect, volume, encut, ntasks, int(np.prod(mesh))) # The licensed POTCAR is not left behind in the task's directory cmd += ["&&", "rm", "-f", "POTCAR"] if resources is None: resources = seamm_exec.Resources(ntasks=ntasks, mem_per_cpu=2_000_000_000) if periodic and resources.walltime is None: resources = dataclasses.replace(resources, walltime=cell_walltime(estimate)) return seamm_exec.Task( key=key, program="vasp", cmd=cmd, shell=True, files=files, return_files=return_files, resources=resources, estimated_seconds=estimate, success_text=success, fingerprint=fingerprint(cmd, files), )
#: INCAR keywords that depend only on how many ranks run the task _PARALLEL = ("NCORE", "KPAR", "NPAR", "NSIM")
[docs] def fingerprint(cmd, files): """The task's restart identity: its command and files, without the INCAR's parallelization keywords, so a rerun with another number of ranks reuses the finished calculations.""" import hashlib digest = hashlib.sha256() digest.update("\0".join(cmd).encode()) for name in sorted(files): text = files[name] if name == "INCAR": text = "\n".join( line for line in text.splitlines() if line.split("=")[0].strip() not in _PARALLEL ) digest.update(name.encode() + b"\0" + text.encode() + b"\0") return digest.hexdigest()
[docs] def parse_vasprun(text): """The final energy (sigma -> 0, eV), forces (eV/Å, VASP order) and stress (kB as VASP prints it, a pressure, or None) from vasprun.xml.""" from lxml import etree root = etree.fromstring(text.encode() if isinstance(text, str) else text) calculation = root.findall("calculation")[-1] # The calculation's own energy block, not one of its SCF steps' energy = calculation.find("energy") e0 = float(energy.find("i[@name='e_0_energy']").text) def array(name): node = calculation.find(f"varray[@name='{name}']") if node is None: return None return np.array([[float(x) for x in v.text.split()] for v in node.findall("v")]) return e0, array("forces"), array("stress")
[docs] def converged(outcar): """Whether VASP's SCF reached EDIFF (an unconverged run must not count).""" return "aborting loop because EDIFF is reached" in outcar
[docs] def analyze_task( result, model_chemistry, configuration, *, properties=("energy", "gradients"), options=None, ): """{"energy": kJ/mol, "gradients": (n, 3) kJ/mol/Å, "stress": (3, 3) GPa, sigma = -P} of a finished task, in the structure's atom order.""" data = structure_data(configuration) atnos = list(data["atomic_numbers"]) periodic = data["periodicity"] == 3 outcar = _text(result, "OUTCAR") if not converged(outcar): raise AnalysisError(f"'{result.key}': the SCF did not converge (EDIFF)") energy, forces, stress = parse_vasprun(_text(result, "vasprun.xml")) _, to_seamm, _ = inputs.atom_order(atnos) ordered = np.zeros_like(forces) ordered[to_seamm] = forces out = {"energy": energy * EV_KJ, "gradients": -ordered * EV_KJ} sigma = None if stress is not None and periodic: sigma = -0.1 * stress # kB pressure -> GPa stress method, *_ = _level(model_chemistry) if functionals()[method][2]: d4 = json.loads(_text(result, "dftd4.json")) out["energy"] += d4["energy"] * HARTREE_EV * EV_KJ gradient = np.array(d4["gradient"]).reshape(-1, 3) * HARTREE_EV / BOHR if periodic: # dftd4 read the POSCAR: VASP order g = np.zeros_like(gradient) g[to_seamm] = gradient gradient = g out["gradients"] = out["gradients"] + gradient * EV_KJ if sigma is not None: volume = abs(np.linalg.det(np.asarray(data["cell"], dtype=float))) virial = np.array(d4["virial"]).reshape(3, 3) * HARTREE_EV # dE/dstrain sigma = sigma + virial / volume * Q_(1.0, "eV/Å^3").m_as("GPa") if sigma is not None and "stress" in properties: out["stress"] = sigma.tolist() check_properties(out, properties, f"'{result.key}'", periodic=periodic) return out
def _text(result, name): value = result.files.get(name) if value is None: raise AnalysisError(f"'{result.key}': no {name} came back") return value.decode() if isinstance(value, bytes) else value