diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index 8b23f5f..71260e5 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -25,7 +25,7 @@ jobs: python -m pip install types-requests - name: mypy run: | - mypy --namespace-packages --explicit-package-bases pymatgen + mypy pymatgen - name: black run: | black --version diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 707c015..1bd31ed 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -52,4 +52,13 @@ repos: args: - --namespace-packages - --explicit-package-bases - additional_dependencies: ['types-requests'] + additional_dependencies: ['types-requests','pydantic>=2.10.0'] + + - repo: https://github.com/kynan/nbstripout + rev: 0.8.1 + hooks: + - id: nbstripout + args: + - --drop-empty-cells + - --strip-init-cells + - --extra-keys=metadata.kernelspec diff --git a/README.md b/README.md index b39782e..8b2f2b4 100644 --- a/README.md +++ b/README.md @@ -17,18 +17,11 @@ Usage For validating calculations from the raw files, run: ``` -from pymatgen.io.validation import ValidationDoc -validation_doc = ValidationDoc.from_directory(dir_name = path_to_vasp_calculation_directory) +from pymatgen.io.validation import VaspValidator +validation_doc = VaspValidator.from_directory(path_to_vasp_calculation_directory) ``` In the above case, whether a calculation passes the validator can be accessed via `validation_doc.valid`. Moreover, reasons for an invalidated calculation can be accessed via `validation_doc.reasons` (this will be empty for valid calculations). Last but not least, warnings for potential issues (sometimes minor, sometimes major) can be accessed via `validation_doc.warnings`. -\ -\ -For validating calculations from `TaskDoc` objects from the [Emmet](https://github.com/materialsproject/emmet) package, run: -``` -from pymatgen.io.validation import ValidationDoc -validation_doc = ValidationDoc.from_task_doc(task_doc = my_task_doc) -``` Contributors ===== diff --git a/examples/using_validation_docs.ipynb b/examples/using_validation_docs.ipynb index f68649f..4970be1 100644 --- a/examples/using_validation_docs.ipynb +++ b/examples/using_validation_docs.ipynb @@ -2,39 +2,43 @@ "cells": [ { "cell_type": "code", - "execution_count": 1, - "id": "b116c8b3-e927-401b-aed8-994fe5279b54", + "execution_count": null, + "id": "0", "metadata": {}, "outputs": [], "source": [ "from __future__ import annotations\n", "\n", - "from emmet.core.tasks import TaskDoc\n", + "from monty.os.path import zpath\n", "from monty.serialization import loadfn\n", "import os\n", + "from pathlib import Path\n", "\n", - "from pymatgen.io.validation import ValidationDoc\n", - "from pymatgen.io.validation.check_potcar import CheckPotcar\n", + "from pymatgen.io.validation.validation import VaspValidator\n", "\n", "from pymatgen.io.vasp import PotcarSingle, Potcar" ] }, { - "cell_type": "code", - "execution_count": 2, - "id": "80064515-1e98-43da-b075-5a4c41ede437", + "cell_type": "markdown", + "id": "1", "metadata": {}, - "outputs": [], "source": [ - "\"\"\"\n", "For copyright reasons, the POTCAR for these calculations cannot be distributed with this file, but its summary stats can.\n", - "If you have the POTCAR resources set up in pymatgen, you can regenerate the POTCARs used here by enabling `regen_potcars`\n", - "\"\"\"\n", "\n", + "If you have the POTCAR resources set up in pymatgen, you can regenerate the POTCARs used here by enabling `regen_potcars`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ "regen_potcars = True\n", "\n", "def get_potcar_from_spec(potcar_spec : dict) -> Potcar | None:\n", - " potcar_checker = CheckPotcar()\n", " \n", " for functional in PotcarSingle._potcar_summary_stats:\n", "\n", @@ -47,7 +51,7 @@ " \n", " for stats in PotcarSingle._potcar_summary_stats[functional].get(titel_no_spc,[]):\n", " \n", - " if potcar_checker.compare_potcar_stats(spec[\"summary_stats\"], stats):\n", + " if PotcarSingle.compare_potcar_stats(spec[\"summary_stats\"], stats):\n", " potcar.append(PotcarSingle.from_symbol_and_functional(symbol=symbol, functional=functional))\n", " matched[ispec] = True\n", " break\n", @@ -55,16 +59,29 @@ " if all(matched):\n", " return potcar\n", " \n", - "def check_calc(calc_dir : str) -> ValidationDoc:\n", + "def check_calc(calc_dir : str | Path) -> VaspValidator:\n", + "\n", + " calc_dir = Path(calc_dir)\n", " potcar_filename = None\n", " if regen_potcars:\n", - " potcar = get_potcar_from_spec(loadfn(os.path.join(calc_dir,\"POTCAR.spec.gz\")))\n", + " potcar = get_potcar_from_spec(loadfn(calc_dir / \"POTCAR.spec.gz\"))\n", " if potcar:\n", - " potcar_filename = os.path.join(calc_dir,\"POTCAR.gz\")\n", + " potcar_filename = calc_dir / \"POTCAR.gz\"\n", " potcar.write_file(potcar_filename)\n", " \n", - " valid_doc = ValidationDoc.from_directory(calc_dir, check_potcar=(regen_potcars and potcar))\n", + " vasp_files = {\n", + " k.lower().split(\".\")[0] : zpath(calc_dir / k) for k in (\n", + " \"INCAR\",\"KPOINTS\",\"POSCAR\",\"POTCAR\",\"OUTCAR\", \"vasprun.xml\"\n", + " )\n", + " }\n", " \n", + " valid_doc = VaspValidator.from_vasp_input(\n", + " vasp_file_paths={\n", + " k : v for k,v in vasp_files.items() if Path(v).exists()\n", + " },\n", + " check_potcar=(regen_potcars and potcar)\n", + " )\n", + "\n", " if potcar_filename and potcar:\n", " os.remove(potcar_filename)\n", " \n", @@ -73,107 +90,50 @@ ] }, { - "cell_type": "code", - "execution_count": 3, - "id": "0f660f54-ca8a-466c-b382-2f0fac46d8bf", + "cell_type": "markdown", + "id": "3", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "True\n" - ] - } - ], "source": [ - "\"\"\"\n", - "An example of an MP-compatible r2SCAN static calculation for GaAs is located in the `MP_compliant` directory.\n", - "\"\"\"\n", - "mp_compliant_doc = check_calc(\"MP_compliant\")\n", - "print(mp_compliant_doc.valid)" + "An example of an MP-compatible r2SCAN static calculation for GaAs is located in the `MP_compliant` directory. We also include `TaskDoc` objects generated with `atomate2`, the workflow software currently used by the Materials Project (MP) for high-throughput calculations. A `TaskDoc` is also the document schema for the MP `task` collection." ] }, { "cell_type": "code", - "execution_count": 4, - "id": "25b85de2", + "execution_count": null, + "id": "4", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "True\n" - ] - } - ], + "outputs": [], "source": [ - "\"\"\"\n", - "TaskDocs for these calculations (generated with atomate2) are also saved in these directories.\n", - "You can load in the TaskDocs like so:\n", - "\"\"\"\n", - "compliant_task_doc = TaskDoc(\n", - " **loadfn(os.path.join(\"MP_compliant\",\"MP_compatible_GaAs_r2SCAN_static.json.gz\"))\n", - ")\n", - "mp_compliant_doc = ValidationDoc.from_task_doc(compliant_task_doc)\n", + "mp_compliant_doc = check_calc(\"MP_compliant\")\n", "print(mp_compliant_doc.valid)" ] }, { - "cell_type": "code", - "execution_count": 5, - "id": "c919fedd-38ef-4cf7-a2ed-54544eec8d82", + "cell_type": "markdown", + "id": "5", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "False\n", - "INPUT SETTINGS --> KPOINTS or KSPACING: 64 kpoints were used, but it should have been at least 194.\n", - "INPUT SETTINGS --> ENAUG: is 900.0, but should be >= 1360.\n", - "INPUT SETTINGS --> ENCUT: is 450.0, but should be >= 680.\n", - "False\n", - "True\n" - ] - } - ], "source": [ - "\"\"\"\n", - "An example of an MP incompatible r2SCAN static calculation for GaAs is located in the `MP_non_compliant` directory.\n", + "An example of an MP incompatible r2SCAN static calculation for GaAs is located in the `MP_non_compliant` directory.\n", "\n", "This calculation uses a lower ENCUT, ENAUG, and k-point density (larger KSPACING) than is permitted by the appropriate input set, `pymatgen.io.vasp.sets.MPScanStaticSet`.\n", - "These reasons are reflected transparently in the output reasons.\n", - "\"\"\"\n", - "mp_non_compliant_doc = check_calc(\"MP_non_compliant\")\n", - "print(mp_non_compliant_doc.valid)\n", - "for reason in mp_non_compliant_doc.reasons:\n", - " print(reason)\n", - "\n", - "non_compliant_task_doc = TaskDoc(\n", - " **loadfn(os.path.join(\"MP_non_compliant\",\"MP_incompatible_GaAs_r2SCAN_static.json.gz\"))\n", - ")\n", - "mp_non_compliant_doc_from_taskdoc = ValidationDoc.from_task_doc(non_compliant_task_doc)\n", - "print(mp_non_compliant_doc_from_taskdoc.valid)\n", - "print(mp_non_compliant_doc_from_taskdoc.reasons == mp_non_compliant_doc_from_taskdoc.reasons)" + "These reasons are reflected transparently in the output reasons." ] }, { "cell_type": "code", "execution_count": null, - "id": "128e49d1", + "id": "6", "metadata": {}, "outputs": [], - "source": [] + "source": [ + "mp_non_compliant_doc = check_calc(\"MP_non_compliant\")\n", + "print(mp_non_compliant_doc.valid)\n", + "for reason in mp_non_compliant_doc.reasons:\n", + " print(reason)" + ] } ], "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, "language_info": { "codemirror_mode": { "name": "ipython", diff --git a/pymatgen/io/validation/__init__.py b/pymatgen/io/validation/__init__.py index 6fc6fdc..b81dbbc 100644 --- a/pymatgen/io/validation/__init__.py +++ b/pymatgen/io/validation/__init__.py @@ -4,11 +4,10 @@ to ensure that data is compatible with some standard. """ -from pymatgen.io.validation.validation import ValidationDoc # noqa: F401 +from pymatgen.io.validation.common import SETTINGS +from pymatgen.io.validation.validation import VaspValidator # noqa: F401 -from pymatgen.io.validation.settings import IOValidationSettings as _settings - -if _settings().CHECK_PYPI_AT_LOAD: +if SETTINGS.CHECK_PYPI_AT_LOAD: # Only check version at module load time, if specified in module settings. from pymatgen.io.validation.check_package_versions import package_version_check diff --git a/pymatgen/io/validation/check_common_errors.py b/pymatgen/io/validation/check_common_errors.py index f995870..f528371 100644 --- a/pymatgen/io/validation/check_common_errors.py +++ b/pymatgen/io/validation/check_common_errors.py @@ -1,109 +1,91 @@ """Check common issues with VASP calculations.""" from __future__ import annotations -from dataclasses import dataclass, field +from pydantic import Field import numpy as np - from typing import TYPE_CHECKING -from emmet.core.vasp.calc_types.enums import TaskType -from pymatgen.core import Structure - -from pymatgen.io.validation.common import BaseValidator +from pymatgen.io.validation.common import SETTINGS, BaseValidator if TYPE_CHECKING: - from emmet.core.tasks import TaskDoc - from emmet.core.vasp.calc_types.enums import RunType - from emmet.core.vasp.task_valid import TaskDocument - from pymatgen.io.vasp.inputs import Incar - from typing import Sequence + from collections.abc import Sequence from numpy.typing import ArrayLike + from pymatgen.io.validation.common import VaspFiles + -@dataclass class CheckCommonErrors(BaseValidator): """ Check for common calculation errors. - - Parameters - ----------- - reasons : list[str] - A list of error strings to update if a check fails. These are higher - severity and would deprecate a calculation. - warnings : list[str] - A list of warning strings to update if a check fails. These are lower - severity and would flag a calculation for possible review. - task_doc : emmet.core TaskDoc | TaskDocument - Task document parsed from the calculation directory. - parameters : dict[str,Any] - Dict of user-supplied/-parsed INCAR parameters. - structure: Pymatgen Structure - Structure used in the calculation. - run_type: RunType - Run type of the calculation - name : str = "Check common errors" - Name of the validator - fast : bool = False - True: stop validation when any single check fails - defaults : dict - Dict of default parameters - valid_max_magmoms : dict[str,float] - Dict of maximum magmoms corresponding to a given element. - exclude_elements : set[str] - Set of elements that cannot be added to the Materials Project's hull. - valid_max_allowed_scf_gradient : float - Largest permitted change in total energies between two SCF cycles. - num_ionic_steps_to_avg_drift_over : int - Number of ionic steps to average over to yield the drift in total energy. """ - reasons: list[str] - warnings: list[str] - task_doc: TaskDoc | TaskDocument = None - parameters: dict = None - structure: Structure = None - run_type: RunType = None name: str = "Check common errors" - fast: bool = False - defaults: dict | None = None - # TODO: make this also work for elements Gd and Eu, which have magmoms >5 in at least one of their pure structures - valid_max_magmoms: dict[str, float] = field(default_factory=lambda: {"Gd": 10.0, "Eu": 10.0}) - exclude_elements: set[str] = field(default_factory=lambda: {"Am", "Po"}) - valid_max_allowed_scf_gradient: float | None = None - num_ionic_steps_to_avg_drift_over: int | None = None - - def __post_init__(self): - self.incar = self.task_doc["calcs_reversed"][0]["input"]["incar"] - self.ionic_steps = self.task_doc["calcs_reversed"][0]["output"]["ionic_steps"] - - def _check_run_type(self) -> None: - if f"{self.run_type}".upper() not in {"GGA", "GGA+U", "PBE", "PBE+U", "R2SCAN"}: - self.reasons.append(f"FUNCTIONAL --> Functional {self.run_type} not currently accepted.") - - def _check_parse(self) -> None: - if self.parameters == {} or self.parameters is None: - self.reasons.append( - "CAN NOT PROPERLY PARSE CALCULATION --> Issue parsing input parameters from the vasprun.xml file." - ) + valid_max_magmoms: dict[str, float] = Field( + default_factory=lambda: {"Gd": 10.0, "Eu": 10.0}, + description="Dict of maximum magmoms corresponding to a given element.", + ) + exclude_elements: set[str] = Field( + default_factory=lambda: {"Am", "Po"}, + description="Set of elements that cannot be added to the Materials Project's hull.", + ) + valid_max_allowed_scf_gradient: float | None = Field( + SETTINGS.VASP_MAX_SCF_GRADIENT, description="Largest permitted change in total energies between two SCF cycles." + ) + num_ionic_steps_to_avg_drift_over: int | None = Field( + SETTINGS.VASP_NUM_IONIC_STEPS_FOR_DRIFT, + description="Number of ionic steps to average over to yield the drift in total energy.", + ) + valid_max_energy_per_atom: float | None = Field( + SETTINGS.VASP_MAX_POSITIVE_ENERGY, + description="The maximum permitted, self-consistent positive energy in eV/atom.", + ) + + def _check_vasp_version(self, vasp_files: VaspFiles, reasons: list[str], warnings: list[str]) -> None: + """ + Check for common errors related to the version of VASP used. + + reasons : list[str] + A list of error strings to update if a check fails. These are higher + severity and would deprecate a calculation. + warnings : list[str] + A list of warning strings to update if a check fails. These are lower + severity and would flag a calculation for possible review. + """ + + if not vasp_files.vasp_version: + # Skip if vasprun.xml not specified + return - def _check_gga_and_metagga(self) -> None: - # Check for cases where both GGA and METAGGA are set. This should *not* be allowed, as it can erroneously change - # the outputted energy significantly. See https://github.com/materialsproject/atomate2/issues/453#issuecomment-1699605867 - # for more details. - if self.incar.get("GGA", "--") != "--" and str(self.incar.get("METAGGA", None)).lower() not in {"--", "none"}: - self.reasons.append( - "KNOWN BUG --> GGA and METAGGA should never be specified together, as this can cause major errors in the " - "outputted energy. See https://github.com/materialsproject/atomate2/issues/453#issuecomment-1699605867 " - "for more information." + if ( + vasp_files.vasp_version[0] == 5 + and ( + vasp_files.user_input.incar.get("METAGGA", self.vasp_defaults["METAGGA"].value) + not in [None, "--", "None"] + ) + and vasp_files.user_input.incar.get("ISPIN", self.vasp_defaults["ISPIN"].value) == 2 + ): + reasons.append( + "POTENTIAL BUG --> We believe that there may be a bug with spin-polarized calculations for METAGGAs " + "in some versions of VASP 5. Please create a new GitHub issue if you believe this " + "is not the case and we will consider changing this check!" + ) + elif (list(vasp_files.vasp_version) != [5, 4, 4]) and (vasp_files.vasp_version[0] < 6): + vasp_version_str = ".".join([str(x) for x in vasp_files.vasp_version]) + reasons.append( + f"VASP VERSION --> This calculation is using VASP version {vasp_version_str}, " + "but we only allow versions 5.4.4 and >=6.0.0 (as of July 2023)." ) - def _check_electronic_convergence(self) -> None: + def _check_electronic_convergence(self, vasp_files: VaspFiles, reasons: list[str], warnings: list[str]) -> None: # check if structure electronically converged - if self.incar.get("ALGO", self.defaults["ALGO"]["value"]).lower() != "chi": + if ( + vasp_files.user_input.incar.get("ALGO", self.vasp_defaults["ALGO"].value).lower() != "chi" + and vasp_files.vasprun + ): # Response function calculations are non-self-consistent: only one ionic step, no electronic SCF - if self.parameters.get("LEPSILON", self.defaults["LEPSILON"]["value"]): - final_esteps = self.ionic_steps[-1]["electronic_steps"] + if vasp_files.user_input.incar.get("LEPSILON", self.vasp_defaults["LEPSILON"].value): + final_esteps = vasp_files.vasprun.ionic_steps[-1]["electronic_steps"] to_check = {"e_wo_entrp", "e_fr_energy", "e_0_energy"} for i in range(len(final_esteps)): @@ -111,27 +93,30 @@ def _check_electronic_convergence(self) -> None: break i += 1 - is_converged = i + 1 < self.parameters.get("NELM", self.defaults["NELM"]["value"]) + is_converged = i + 1 < vasp_files.user_input.incar.get("NELM", self.vasp_defaults["NELM"].value) n_non_conv = 1 else: conv_steps = [ - len(self.ionic_steps[i]["electronic_steps"]) - < self.parameters.get("NELM", self.defaults["NELM"]["value"]) - for i in range(len(self.ionic_steps)) + len(ionic_step["electronic_steps"]) + < vasp_files.user_input.incar.get("NELM", self.vasp_defaults["NELM"].value) + for ionic_step in vasp_files.vasprun.ionic_steps ] is_converged = all(conv_steps) n_non_conv = len([step for step in conv_steps if not step]) if not is_converged: - self.reasons.append( + reasons.append( f"CONVERGENCE --> Did not achieve electronic convergence in {n_non_conv} ionic step(s). NELM should be increased." ) - def _check_drift_forces(self) -> None: + def _check_drift_forces(self, vasp_files: VaspFiles, reasons: list[str], warnings: list[str]) -> None: # Check if drift force is too large - try: - all_drift_forces = self.task_doc["calcs_reversed"][0]["output"]["outcar"]["drift"] + + if not self.num_ionic_steps_to_avg_drift_over or not vasp_files.outcar: + return + + if all_drift_forces := vasp_files.outcar.drift: if len(all_drift_forces) < self.num_ionic_steps_to_avg_drift_over: drift_forces_to_avg_over = all_drift_forces else: @@ -142,32 +127,44 @@ def _check_drift_forces(self) -> None: valid_max_drift = 0.05 if cur_avg_drift_mag > valid_max_drift: - self.reasons.append( + warnings.append( f"CONVERGENCE --> Excessive drift of {round(cur_avg_drift_mag,4)} eV/A is greater than allowed " f"value of {valid_max_drift} eV/A." ) - except Exception: - self.warnings.append("Drift forces not contained in calcs_reversed! Can not check for excessive drift.") + else: + warnings.append( + "Could not determine drift forces from OUTCAR, and thus could not check for excessive drift." + ) - def _check_positive_energy(self) -> None: + def _check_positive_energy(self, vasp_files: VaspFiles, reasons: list[str], warnings: list[str]) -> None: # Check for excessively positive final energies (which usually indicates a bad structure) - valid_max_energy_per_atom = 50 - cur_final_energy_per_atom = self.task_doc["output"]["energy_per_atom"] - if cur_final_energy_per_atom > valid_max_energy_per_atom: - self.reasons.append( + if ( + vasp_files.vasprun + and self.valid_max_energy_per_atom + and (cur_final_energy_per_atom := vasp_files.vasprun.final_energy / len(vasp_files.user_input.structure)) + > self.valid_max_energy_per_atom + ): + reasons.append( f"LARGE POSITIVE FINAL ENERGY --> Final energy is {round(cur_final_energy_per_atom,4)} eV/atom, which is " - f"greater than the maximum allowed value of {valid_max_energy_per_atom} eV/atom." + f"greater than the maximum allowed value of {self.valid_max_energy_per_atom} eV/atom." ) - def _check_large_magmoms(self) -> None: + def _check_large_magmoms(self, vasp_files: VaspFiles, reasons: list[str], warnings: list[str]) -> None: # Check for excessively large final magnetic moments - cur_magmoms = [ - abs(mag["tot"]) for mag in self.task_doc["calcs_reversed"][0]["output"]["outcar"]["magnetization"] - ] + + if ( + not vasp_files.outcar + or not vasp_files.outcar.magnetization + or any(mag.get("tot") is None for mag in vasp_files.outcar.magnetization) + ): + warnings.append("MAGNETISM --> No OUTCAR file specified or data missing.") + return + + cur_magmoms = [abs(mag["tot"]) for mag in vasp_files.outcar.magnetization] bad_site_magmom_msgs = [] if len(cur_magmoms) > 0: - for site_num in range(0, len(self.structure)): - cur_site_ele = self.structure.sites[site_num].species_string + for site_num in range(0, len(vasp_files.user_input.structure)): + cur_site_ele = vasp_files.user_input.structure.sites[site_num].species_string cur_site_magmom = cur_magmoms[site_num] cur_site_max_allowed_magmom = self.valid_max_magmoms.get(cur_site_ele, 5.0) @@ -177,149 +174,71 @@ def _check_large_magmoms(self) -> None: ) if len(bad_site_magmom_msgs) > 0: - self.reasons.append( + reasons.append( "MAGNETISM --> Final structure contains sites with magnetic moments " "that are very likely erroneous. This includes: " f"{'; '.join(set(bad_site_magmom_msgs))}." ) - def _check_scf_grad(self) -> None: + def _check_scf_grad(self, vasp_files: VaspFiles, reasons: list[str], warnings: list[str]) -> None: # Check for a SCF gradient that is too large (usually indicates unstable calculations) # NOTE: do NOT use `e_0_energy`, as there is a bug in the vasprun.xml when printing that variable # (see https://www.vasp.at/forum/viewtopic.php?t=16942 for more details). - skip = abs(self.parameters.get("NELMDL", self.defaults["NELMDL"]["value"])) - 1 - energies = [d["e_fr_energy"] for d in self.ionic_steps[-1]["electronic_steps"]] + + if not vasp_files.vasprun or not self.valid_max_allowed_scf_gradient: + return + + skip = abs(vasp_files.user_input.incar.get("NELMDL", self.vasp_defaults["NELMDL"].value)) - 1 + + energies = [d["e_fr_energy"] for d in vasp_files.vasprun.ionic_steps[-1]["electronic_steps"]] if len(energies) > skip: cur_max_gradient = np.max(np.gradient(energies)[skip:]) - cur_max_gradient_per_atom = cur_max_gradient / self.structure.num_sites - if cur_max_gradient_per_atom > self.valid_max_allowed_scf_gradient: - self.warnings.append( + cur_max_gradient_per_atom = cur_max_gradient / vasp_files.user_input.structure.num_sites + if self.valid_max_allowed_scf_gradient and cur_max_gradient_per_atom > self.valid_max_allowed_scf_gradient: + warnings.append( f"STABILITY --> The max SCF gradient is {round(cur_max_gradient_per_atom,4)} eV/atom, " "which is larger than the typical max expected value of " f"{self.valid_max_allowed_scf_gradient} eV/atom. " f"This sometimes indicates an unstable calculation." ) else: - self.warnings.append( + warnings.append( "Not enough electronic steps to compute valid gradient and compare with max SCF gradient tolerance." ) - def _check_unused_elements(self) -> None: + def _check_unused_elements(self, vasp_files: VaspFiles, reasons: list[str], warnings: list[str]) -> None: # Check for Am and Po elements. These currently do not have proper elemental entries # and will not get treated properly by the thermo builder. - elements = set(self.task_doc["chemsys"].split("-")) + elements = set(vasp_files.user_input.structure.composition.chemical_system.split("-")) if excluded_elements := self.exclude_elements.intersection(elements): - self.reasons.append( + reasons.append( f"COMPOSITION --> Your structure contains the elements {' '.join(excluded_elements)}, " "which are not currently being accepted." ) -@dataclass -class CheckVaspVersion(BaseValidator): - """ - Check for common errors related to the version of VASP used. - - Parameters - ----------- - reasons : list[str] - A list of error strings to update if a check fails. These are higher - severity and would deprecate a calculation. - warnings : list[str] - A list of warning strings to update if a check fails. These are lower - severity and would flag a calculation for possible review. - vasp_version: Sequence[int] - Vasp version, e.g., 6.4.1 could be represented as (6,4,1) - parameters : dict[str,Any] - Dict of user-supplied/-parsed INCAR parameters. - incar : dict | Incar - INCAR corresponding to the calculation. - name : str = "Base validator class" - Name of the validator class - fast : bool = False - Whether to perform quick check. - True: stop validation if any check fails. - False: perform all checks. - defaults : dict - Dict of default parameters - """ - - reasons: list[str] - warnings: list[str] - vasp_version: Sequence[int] = None - parameters: dict = None - incar: dict | Incar = None - name: str = "VASP version validator" - defaults: dict | None = None - - def _check_vasp_version(self) -> None: - """ - Check for common errors related to the version of VASP used. - - reasons : list[str] - A list of error strings to update if a check fails. These are higher - severity and would deprecate a calculation. - warnings : list[str] - A list of warning strings to update if a check fails. These are lower - severity and would flag a calculation for possible review. - """ - if ( - self.vasp_version[0] == 5 - and (self.incar.get("METAGGA", self.defaults["METAGGA"]["value"]) not in [None, "--", "None"]) - and self.parameters.get("ISPIN", self.defaults["ISPIN"]["value"]) == 2 - ): - self.reasons.append( - "POTENTIAL BUG --> We believe that there may be a bug with spin-polarized calculations for METAGGAs " - "in some versions of VASP 5. Please create a new GitHub issue if you believe this " - "is not the case and we will consider changing this check!" - ) - elif (list(self.vasp_version) != [5, 4, 4]) and (self.vasp_version[0] < 6): - vasp_version_str = ".".join([str(x) for x in self.vasp_version]) - self.reasons.append( - f"VASP VERSION --> This calculation is using VASP version {vasp_version_str}, " - "but we only allow versions 5.4.4 and >=6.0.0 (as of July 2023)." - ) - - -@dataclass class CheckStructureProperties(BaseValidator): """Check structure for options that are not suitable for thermodynamic calculations.""" - reasons: list[str] - warnings: list[str] - structures: list[dict | Structure | None] = None - task_type: TaskType = None name: str = "VASP POSCAR properties validator" - site_properties_to_check: tuple[str, ...] = ("selective_dynamics", "velocities") - - def __post_init__(self) -> None: - """Extract required structure site properties.""" - - for idx, struct in enumerate(self.structures): - if isinstance(struct, dict): - self.structures[idx] = Structure.from_dict(struct) - - self._site_props = { - k: [struct.site_properties.get(k) for struct in self.structures if struct] # type: ignore[union-attr] - for k in self.site_properties_to_check - } + site_properties_to_check: tuple[str, ...] = Field( + ("selective_dynamics", "velocities"), description="Which site properties to check on a structure." + ) @staticmethod - def _has_frozen_degrees_of_freedom(selective_dynamics_array: ArrayLike[bool] | None) -> bool: + def _has_frozen_degrees_of_freedom(selective_dynamics_array: Sequence[bool] | None) -> bool: """Check selective dynamics array for False values.""" if selective_dynamics_array is None: return False return not np.all(selective_dynamics_array) - def _check_selective_dynamics(self) -> None: + def _check_selective_dynamics(self, vasp_files: VaspFiles, reasons: list[str], warnings: list[str]) -> None: """Check structure for inappropriate site properties.""" - - if (selec_dyn := self._site_props.get("selective_dynamics")) is not None and self.task_type in { - TaskType.Structure_Optimization, - TaskType.Deformation, - }: + if ( + selec_dyn := vasp_files.user_input.structure.site_properties.get("selective_dynamics") + ) is not None and vasp_files.run_type == "relax": if any(self._has_frozen_degrees_of_freedom(sd_array) for sd_array in selec_dyn): - self.reasons.append( + reasons.append( "Selective dynamics: certain degrees of freedom in the structure " "were not permitted to relax. To correctly place entries on the convex " "hull, all degrees of freedom should be allowed to relax." @@ -329,16 +248,17 @@ def _check_selective_dynamics(self) -> None: def _has_nonzero_velocities(velocities: ArrayLike | None, tol: float = 1.0e-8) -> bool: if velocities is None: return False - return np.any(np.abs(velocities) > tol) + return np.any(np.abs(velocities) > tol) # type: ignore [return-value] - def _check_velocities(self) -> None: + def _check_velocities(self, vasp_files: VaspFiles, reasons: list[str], warnings: list[str]) -> None: """Check structure for non-zero velocities.""" - - if (velos := self._site_props.get("velocities")) is not None and self.task_type != TaskType.Molecular_Dynamics: + if ( + velos := vasp_files.user_input.structure.site_properties.get("velocities") + ) is not None and vasp_files.run_type != "md": if any(self._has_nonzero_velocities(velo) for velo in velos): - self.warnings.append( + warnings.append( "At least one of the structures had non-zero velocities. " - f"While these are ignored by VASP for {self.task_type} " + f"While these are ignored by VASP for {vasp_files.run_type} " "calculations, please ensure that you intended to run a " "non-molecular dynamics calculation." ) diff --git a/pymatgen/io/validation/check_incar.py b/pymatgen/io/validation/check_incar.py index 7f3f598..9111f53 100644 --- a/pymatgen/io/validation/check_incar.py +++ b/pymatgen/io/validation/check_incar.py @@ -1,25 +1,21 @@ """Validate VASP INCAR files.""" from __future__ import annotations -import copy -from dataclasses import dataclass import numpy as np -from emmet.core.vasp.calc_types.enums import TaskType +from pydantic import Field -from pymatgen.io.validation.common import BaseValidator, BasicValidator +from pymatgen.io.validation.common import SETTINGS, BaseValidator from pymatgen.io.validation.vasp_defaults import InputCategory, VaspParam from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import Any, Sequence - from pymatgen.core import Structure - from pymatgen.io.vasp.sets import VaspInputSet + from typing import Any + from pymatgen.io.validation.common import VaspFiles # TODO: fix ISIF getting overwritten by MP input set. -@dataclass class CheckIncar(BaseValidator): """ Check calculation parameters related to INCAR input tags. @@ -28,51 +24,23 @@ class CheckIncar(BaseValidator): inherits from the `pymatgen.io.validation.common.BaseValidator` class, it also defines a custom `check` method. - reasons : list[str] - A list of error strings to update if a check fails. These are higher - severity and would deprecate a calculation. - warnings : list[str] - A list of warning strings to update if a check fails. These are lower - severity and would flag a calculation for possible review. - valid_input_set: VaspInputSet - Valid input set to compare user INCAR parameters to. - task_doc : dict - Task document parsed from the calculation directory, as a dict - parameters : dict[str,Any] - Dict of user-supplied/-parsed INCAR parameters. - structure: Pymatgen Structure - Structure used in the calculation. - vasp_version: Sequence[int] - Vasp version, e.g., 6.4.1 could be represented as (6,4,1) - task_type : TaskType - Task type of the calculation. - name : str = "Check INCAR tags" - Name of the validator. - defaults : dict - Dict of default parameters. - fft_grid_tolerance: float - Directly calculating the FFT grid defaults from VASP is actually impossible - without information on how VASP was compiled. This is because the FFT - params generated depend on whatever fft library used. So instead, we do our - best to calculate the FFT grid defaults and then lower it artificially by - `fft_grid_tolerance`. So if the user's FFT grid parameters are greater than - (fft_grid_tolerance x slightly-off defaults), the FFT params are marked - as valid. + Note about `fft_grid_tolerance`: + Directly calculating the FFT grid defaults from VASP is actually impossible + without information on how VASP was compiled. This is because the FFT + params generated depend on whatever fft library used. So instead, we do our + best to calculate the FFT grid defaults and then lower it artificially by + `fft_grid_tolerance`. So if the user's FFT grid parameters are greater than + (fft_grid_tolerance x slightly-off defaults), the FFT params are marked + as valid. """ - reasons: list[str] - warnings: list[str] - valid_input_set: VaspInputSet = None - task_doc: dict = None - parameters: dict[str, Any] = None - structure: Structure = None - vasp_version: Sequence[int] = None - task_type: TaskType = None name: str = "Check INCAR tags" - defaults: dict | None = None - fft_grid_tolerance: float | None = None + fft_grid_tolerance: float | None = Field( + SETTINGS.VASP_FFT_GRID_TOLERANCE, description="Tolerance for determining sufficient density of FFT grid." + ) + bandgap_tol: float = Field(1.0e-4, description="Tolerance for assuming a material has no gap.") - def check(self) -> None: + def check(self, vasp_files: VaspFiles, reasons: list[str], warnings: list[str]) -> None: """ Check calculation parameters related to INCAR input tags. @@ -91,229 +59,131 @@ def check(self) -> None: # Instantiate class that updates "dynamic" INCAR tags # (like NBANDS, or hybrid-related parameters) - working_params = UpdateParameterValues( - parameters=self.parameters, - defaults=self.defaults, - input_set=self.valid_input_set, - structure=self.structure, - task_doc=self.task_doc, - vasp_version=self.vasp_version, - task_type=self.task_type, - fft_grid_tolerance=self.fft_grid_tolerance, - ) - # Update values in the working parameters by adding - # defaults to unspecified INCAR tags, and by updating - # any INCAR tag that has a specified update method - working_params.update_parameters_and_defaults() - + user_incar_params, valid_incar_params = self.update_parameters_and_defaults(vasp_files) + msgs = { + "reason": reasons, + "warning": warnings, + } # Validate each parameter in the set of working parameters - simple_validator = BasicValidator() - for key in working_params.defaults: - if self.fast and len(self.reasons) > 0: + for vasp_param in self.vasp_defaults.values(): + if self.fast and len(reasons) > 0: # fast check: stop checking whenever a single check fails break + resp = vasp_param.check(user_incar_params[vasp_param.name], valid_incar_params[vasp_param.name]) + msgs[vasp_param.severity].extend(resp.get(vasp_param.severity, [])) - simple_validator.check_parameter( - reasons=self.reasons, - warnings=self.warnings, - input_tag=working_params.defaults[key]["alias"], - current_values=working_params.parameters[key], - reference_values=working_params.valid_values[key], - operations=working_params.defaults[key]["operation"], - tolerance=working_params.defaults[key]["tolerance"], - append_comments=working_params.defaults[key]["comment"], - severity=working_params.defaults[key]["severity"], - ) - + def update_parameters_and_defaults(self, vasp_files: VaspFiles) -> tuple[dict[str, Any], dict[str, Any]]: + """Update a set of parameters according to supplied rules and defaults. -class UpdateParameterValues: - """ - Update a set of parameters according to supplied rules and defaults. - - While many of the parameters in VASP need only a simple check to determine - validity with respect to Materials Project parameters, a few are updated - by VASP when other conditions are met. + While many of the parameters in VASP need only a simple check to determine + validity with respect to Materials Project parameters, a few are updated + by VASP when other conditions are met. - For example, if LDAU is set to False, none of the various LDAU* (LDAUU, LDAUJ, - LDAUL) tags need validation. But if LDAU is set to true, these all need validation. + For example, if LDAU is set to False, none of the various LDAU* (LDAUU, LDAUJ, + LDAUL) tags need validation. But if LDAU is set to true, these all need validation. - Another example is NBANDS, which VASP computes from a set of input tags. - This class allows one to mimic the VASP NBANDS functionality for computing - NBANDS dynamically, and update both the current and reference values for NBANDs. - - To do this in a simple, automatic fashion, each parameter in `VASP_DEFAULTS` has - a "tag" field. To update a set of parameters with a given tag, one then adds a function - to `GetParams` called `update_{tag}_params`. For example, the "dft plus u" - tag has an update function called `update_dft_plus_u_params`. If no such update method - exists, that tag is skipped. - """ + Another example is NBANDS, which VASP computes from a set of input tags. + This class allows one to mimic the VASP NBANDS functionality for computing + NBANDS dynamically, and update both the current and reference values for NBANDs. - def __init__( - self, - parameters: dict[str, Any], - defaults: dict[str, dict], - input_set: VaspInputSet, - structure: Structure, - task_doc: dict, - vasp_version: Sequence[int], - task_type: TaskType, - fft_grid_tolerance: float, - ) -> None: - """ - Given a set of user parameters, a valid input set, and defaults, update certain tagged parameters. - - Parameters - ----------- - parameters: dict[str,Any] - Dict of user-supplied parameters. - defaults: dict - Dict of default values for parameters, tags for parameters, and the operation to check them. - input_set: VaspInputSet - Valid input set to compare parameters to. - structure: Pymatgen Structure - Structure used in the calculation. - task_doc : dict - Task document parsed from the calculation directory, as a dict - vasp_version: Sequence[int] - Vasp version, e.g., 6.4.1 could be represented as (6,4,1) - task_type : TaskType - Task type of the calculation. - fft_grid_tolerance: float - See docstr for `_check_incar`. The FFT grid generation has been udpated frequently - in VASP, and determining the grid density with absolute certainty is not possible. - This tolerance allows for "reasonable" discrepancies from the ideal FFT grid density. + To do this in a simple, automatic fashion, each parameter in `VASP_DEFAULTS` has + a "tag" field. To update a set of parameters with a given tag, one then adds a function + to `GetParams` called `update_{tag}_params`. For example, the "dft plus u" + tag has an update function called `update_dft_plus_u_params`. If no such update method + exists, that tag is skipped. """ - self.parameters = copy.deepcopy(parameters) - self.defaults: dict = copy.deepcopy(defaults) - self.input_set = input_set - self.vasp_version = vasp_version - self.structure = structure - self.valid_values: dict[str, Any] = {} - - self.task_doc = task_doc - # Add some underscored values for convenience - self._fft_grid_tolerance = fft_grid_tolerance - self._calcs_reversed = self.task_doc["calcs_reversed"] - self._incar = self._calcs_reversed[0]["input"]["incar"] - self._ionic_steps = self._calcs_reversed[0]["output"]["ionic_steps"] - self._nionic_steps = len(self._ionic_steps) - self._potcar = self._calcs_reversed[0]["input"]["potcar_spec"] - self._task_type = task_type - - def update_parameters_and_defaults(self) -> None: - """Update user parameters and defaults for tags with a specified update method.""" - - self.categories: dict[str, list[str]] = {tag: [] for tag in InputCategory.__members__} - for key in self.defaults: - self.categories[self.defaults[key]["tag"]].append(key) - - # add defaults to parameters from the incar as needed - self.add_defaults_to_parameters(valid_values_source=self.input_set.incar) + # Note: we cannot make these INCAR objects because INCAR checks certain keys + # Like LREAL and forces them to bool when the validator expects them to be str + user_incar = {k: v for k, v in vasp_files.user_input.incar.as_dict().items() if not k.startswith("@")} + ref_incar = {k: v for k, v in vasp_files.valid_input_set.incar.as_dict().items() if not k.startswith("@")} + + self.add_defaults_to_parameters(user_incar, ref_incar) # collect list of tags in parameter defaults for tag in InputCategory.__members__: # check to see if update method for that tag exists, and if so, run it - update_method_str = f"update_{tag}_params" + update_method_str = f"_update_{tag}_params" if hasattr(self, update_method_str): - getattr(self, update_method_str)() + getattr(self, update_method_str)(user_incar, ref_incar, vasp_files) # add defaults to parameters from the defaults as needed - self.add_defaults_to_parameters() + self.add_defaults_to_parameters(user_incar, ref_incar) - for key, v in self.defaults.items(): - if isinstance(v, dict): - self.defaults[key] = VaspParam(**{"name": key, **v}) + return user_incar, ref_incar - def add_defaults_to_parameters(self, valid_values_source: dict | None = None) -> None: + def add_defaults_to_parameters(self, *incars) -> None: """ Update parameters with initial defaults. - - Parameters - ----------- - valid_values_source : dict or None (default) - If None, update missing values in `self.parameters` and `self.valid_values` - using self.defaults. If a dict, update from that dict. """ - valid_values_source = valid_values_source or self.valid_values - - for key in self.defaults: - self.parameters[key] = self.parameters.get(key, self.defaults[key]["value"]) - self.valid_values[key] = valid_values_source.get(key, self.defaults[key]["value"]) + for key in self.vasp_defaults: + for incar in incars: + if (incar.get(key)) is None: + incar[key] = self.vasp_defaults[key].value - def update_dft_plus_u_params(self) -> None: + def _update_dft_plus_u_params(self, user_incar: dict, ref_incar: dict, vasp_files: VaspFiles) -> None: """Update DFT+U params.""" - if not self.parameters["LDAU"]: + if not user_incar["LDAU"]: return - for key in self.categories["dft_plus_u"]: - valid_value = self.input_set.incar.get(key, self.defaults[key]["value"]) + for key in [v.name for v in self.vasp_defaults.values() if v.tag == "dft_plus_u"]: # TODO: ADK: is LDAUTYPE usually specified as a list?? if key == "LDAUTYPE": - self.parameters[key] = ( - self.parameters[key][0] if isinstance(self.parameters[key], list) else self.parameters[key] - ) - self.valid_values[key] = valid_value[0] if isinstance(valid_value, list) else valid_value - else: - self.parameters[key] = self._incar.get(key, self.defaults[key]["value"]) - self.defaults[key]["operation"] = "==" + user_incar[key] = user_incar[key][0] if isinstance(user_incar[key], list) else user_incar[key] + if isinstance(ref_incar[key], list): + ref_incar[key] = ref_incar[key][0] - def update_symmetry_params(self) -> None: + self.vasp_defaults[key].operation = "==" + + def _update_symmetry_params(self, user_incar: dict, ref_incar: dict, vasp_files: VaspFiles) -> None: """Update symmetry-related parameters.""" # ISYM. - self.valid_values["ISYM"] = [-1, 0, 1, 2] - if self.parameters["LHFCALC"]: - self.defaults["ISYM"]["value"] = 3 - self.valid_values["ISYM"].append(3) - self.defaults["ISYM"]["operation"] = "in" + ref_incar["ISYM"] = [-1, 0, 1, 2] + if user_incar["LHFCALC"]: + self.vasp_defaults["ISYM"].value = 3 + ref_incar["ISYM"].append(3) + self.vasp_defaults["ISYM"].operation = "in" # SYMPREC. # custodian will set SYMPREC to a maximum of 1e-3 (as of August 2023) - self.valid_values["SYMPREC"] = 1e-3 - self.defaults["SYMPREC"].update( - { - "operation": "<=", - "comment": ( - "If you believe that this SYMPREC value is necessary " - "(perhaps this calculation has a very large cell), please create " - "a GitHub issue and we will consider to admit your calculations." - ), - } + ref_incar["SYMPREC"] = 1e-3 + self.vasp_defaults["SYMPREC"].operation = "<=" + self.vasp_defaults["SYMPREC"].comment = ( + "If you believe that this SYMPREC value is necessary " + "(perhaps this calculation has a very large cell), please create " + "a GitHub issue and we will consider to admit your calculations." ) - def update_startup_params(self) -> None: + def _update_startup_params(self, user_incar: dict, ref_incar: dict, vasp_files: VaspFiles) -> None: """Update VASP initialization parameters.""" - self.valid_values["ISTART"] = [0, 1, 2] + ref_incar["ISTART"] = [0, 1, 2] # ICHARG. - if self.input_set.incar.get("ICHARG", self.defaults["ICHARG"]["value"]) < 10: - self.valid_values["ICHARG"] = 9 # should be <10 (SCF calcs) - self.defaults["ICHARG"]["operation"] = "<=" + if ref_incar.get("ICHARG", self.vasp_defaults["ICHARG"].value) < 10: + ref_incar["ICHARG"] = 9 # should be <10 (SCF calcs) + self.vasp_defaults["ICHARG"].operation = "<=" else: - self.valid_values["ICHARG"] = self.input_set.incar.get("ICHARG") - self.defaults["ICHARG"]["operation"] = "==" + ref_incar["ICHARG"] = ref_incar.get("ICHARG") + self.vasp_defaults["ICHARG"].operation = "==" - def update_precision_params(self) -> None: + def _update_precision_params(self, user_incar: dict, ref_incar: dict, vasp_files: VaspFiles) -> None: """Update VASP parameters related to precision.""" # LREAL. # Do NOT use the value for LREAL from the `Vasprun.parameters` object, as VASP changes these values # relative to the INCAR. Rather, check the LREAL value in the `Vasprun.incar` object. - if str(self.input_set.incar.get("LREAL")).upper() in ["AUTO", "A"]: - self.valid_values["LREAL"] = ["FALSE", "AUTO", "A"] - elif str(self.input_set.incar.get("LREAL")).upper() in ["FALSE"]: - self.valid_values["LREAL"] = ["FALSE"] + if str(ref_incar.get("LREAL")).upper() in ["AUTO", "A"]: + ref_incar["LREAL"] = ["FALSE", "AUTO", "A"] + elif str(ref_incar.get("LREAL")).upper() in ["FALSE"]: + ref_incar["LREAL"] = ["FALSE"] - self.parameters["LREAL"] = str(self._incar.get("LREAL", self.defaults["LREAL"]["value"])).upper() + user_incar["LREAL"] = str(user_incar["LREAL"]).upper() # PREC. - self.parameters["PREC"] = self.parameters["PREC"].upper() - if self.input_set.incar.get("PREC", self.defaults["PREC"]["value"]).upper() in [ - "ACCURATE", - "HIGH", - ]: - self.valid_values["PREC"] = ["ACCURATE", "ACCURA", "HIGH"] + user_incar["PREC"] = user_incar["PREC"].upper() + if ref_incar["PREC"].upper() in {"ACCURATE", "HIGH"}: + ref_incar["PREC"] = ["ACCURATE", "ACCURA", "HIGH"] else: raise ValueError("Validation code check for PREC tag needs to be updated to account for a new input set!") - self.defaults["PREC"]["operation"] = "in" + self.vasp_defaults["PREC"].operation = "in" # ROPT. Should be better than or equal to default for the PREC level. # This only matters if projectors are done in real-space. @@ -321,9 +191,9 @@ def update_precision_params(self) -> None: # up as "True" in the `parameters` object (hence we use the `parameters` object) # According to VASP wiki (https://www.vasp.at/wiki/index.php/ROPT), only # the magnitude of ROPT is relevant for precision. - if self.parameters["LREAL"] == "TRUE": + if user_incar["LREAL"] == "TRUE": # this only matters if projectors are done in real-space. - cur_prec = self.parameters["PREC"].upper() + cur_prec = user_incar["PREC"].upper() ropt_default = { "NORMAL": -5e-4, "ACCURATE": -2.5e-4, @@ -332,47 +202,44 @@ def update_precision_params(self) -> None: "MED": -0.002, "HIGH": -4e-4, } - self.parameters["ROPT"] = [abs(value) for value in self.parameters.get("ROPT", [ropt_default[cur_prec]])] - self.defaults["ROPT"] = VaspParam( + user_incar["ROPT"] = [abs(value) for value in user_incar.get("ROPT", [ropt_default[cur_prec]])] + self.vasp_defaults["ROPT"] = VaspParam( name="ROPT", - value=[abs(ropt_default[cur_prec]) for _ in self.parameters["ROPT"]], + value=[abs(ropt_default[cur_prec]) for _ in user_incar["ROPT"]], tag="startup", - operation=["<=" for _ in self.parameters["ROPT"]], + operation=["<=" for _ in user_incar["ROPT"]], ) - def update_misc_special_params(self) -> None: + def _update_misc_special_params(self, user_incar: dict, ref_incar: dict, vasp_files: VaspFiles) -> None: """Update miscellaneous parameters that do not fall into another category.""" # EFERMI. Only available for VASP >= 6.4. Should not be set to a numerical # value, as this may change the number of electrons. # self.vasp_version = (major, minor, patch) - if (self.vasp_version[0] >= 6) and (self.vasp_version[1] >= 4): + if vasp_files.vasp_version and (vasp_files.vasp_version[0] >= 6) and (vasp_files.vasp_version[1] >= 4): # Must check EFERMI in the *incar*, as it is saved as a numerical # value after VASP guesses it in the vasprun.xml `parameters` # (which would always cause this check to fail, even if the user # set EFERMI properly in the INCAR). - self.parameters["EFERMI"] = self._incar.get("EFERMI", self.defaults["EFERMI"]["value"]) - self.valid_values["EFERMI"] = ["LEGACY", "MIDGAP"] - self.defaults["EFERMI"]["operation"] = "in" + ref_incar["EFERMI"] = ["LEGACY", "MIDGAP"] + self.vasp_defaults["EFERMI"].operation = "in" # IWAVPR. - if self._incar.get("IWAVPR"): - self.parameters["IWAVPR"] = self._incar["IWAVPR"] if self._incar["IWAVPR"] is not None else 0 - self.defaults["IWAVPR"].update( - { - "operation": "==", - "comment": "VASP discourages users from setting the IWAVPR tag (as of July 2023).", - } + if user_incar.get("IWAVPR"): + self.vasp_defaults["IWAVPR"].operation = "==" + self.vasp_defaults["IWAVPR"].comment = ( + "VASP discourages users from setting the IWAVPR tag (as of July 2023)." ) # LCORR. - if self.parameters["IALGO"] != 58: - self.defaults["LCORR"]["operation"] = "==" + if user_incar["IALGO"] != 58: + self.vasp_defaults["LCORR"].operation = "==" if ( - self.parameters["ISPIN"] == 2 - and len(self._calcs_reversed[0]["output"]["outcar"]["magnetization"]) != self.structure.num_sites + user_incar["ISPIN"] == 2 + and vasp_files.outcar + and len(getattr(vasp_files.outcar, "magnetization", [])) != vasp_files.user_input.structure.num_sites ): - self.defaults["LORBIT"].update( + self.vasp_defaults["LORBIT"].update( { "operation": "auto fail", "comment": ( @@ -383,8 +250,13 @@ def update_misc_special_params(self) -> None: } ) - if self.parameters["LORBIT"] >= 11 and self.parameters["ISYM"] and (self.vasp_version[0] < 6): - self.defaults["LORBIT"]["warning"] = ( + if ( + vasp_files.vasp_version + and (vasp_files.vasp_version[0] < 6) + and user_incar["LORBIT"] >= 11 + and user_incar["ISYM"] + ): + self.vasp_defaults["LORBIT"]["warning"] = ( "For LORBIT >= 11 and ISYM = 2 the partial charge densities are not correctly symmetrized and can result " "in different charges for symmetrically equivalent partial charge densities. This issue is fixed as of version " ">=6. See the vasp wiki page for LORBIT for more details." @@ -395,63 +267,67 @@ def update_misc_special_params(self) -> None: aux_str = "" if key == "RWIGS": aux_str = " This is because it will change some outputs like the magmom on each site." - self.defaults[key].update( - { - "value": [self.defaults[key]["value"][0] for _ in self.parameters[key]], - "operation": ["==" for _ in self.parameters[key]], - "comment": f"{key} should not be set. {aux_str}", - } + self.vasp_defaults[key] = VaspParam( + name=key, + value=[self.vasp_defaults[key].value[0] for _ in user_incar[key]], + tag="misc_special", + operation=["==" for _ in user_incar[key]], + comment=f"{key} should not be set. {aux_str}", ) - self.valid_values[key] = self.defaults[key]["value"].copy() + ref_incar[key] = self.vasp_defaults[key].value - def update_hybrid_params(self) -> None: + def _update_hybrid_params(self, user_incar: dict, ref_incar: dict, vasp_files: VaspFiles) -> None: """Update params related to hybrid functionals.""" - self.valid_values["LHFCALC"] = self.input_set.incar.get("LHFCALC", self.defaults["LHFCALC"]["value"]) + ref_incar["LHFCALC"] = ref_incar.get("LHFCALC", self.vasp_defaults["LHFCALC"].value) - if self.valid_values["LHFCALC"]: - self.defaults["AEXX"]["value"] = 0.25 - self.parameters["AEXX"] = self.parameters.get("AEXX", self.defaults["AEXX"]["value"]) - self.defaults["AGGAC"]["value"] = 0.0 + if ref_incar["LHFCALC"]: + self.vasp_defaults["AEXX"].value = 0.25 + user_incar["AEXX"] = user_incar.get("AEXX", self.vasp_defaults["AEXX"].value) + self.vasp_defaults["AGGAC"].value = 0.0 for key in ("AGGAX", "ALDAX", "AMGGAX"): - self.defaults[key]["value"] = 1.0 - self.parameters["AEXX"] + self.vasp_defaults[key].value = 1.0 - user_incar["AEXX"] - if self.parameters.get("AEXX", self.defaults["AEXX"]["value"]) == 1.0: - self.defaults["ALDAC"]["value"] = 0.0 - self.defaults["AMGGAC"]["value"] = 0.0 + if user_incar.get("AEXX", self.vasp_defaults["AEXX"].value) == 1.0: + self.vasp_defaults["ALDAC"].value = 0.0 + self.vasp_defaults["AMGGAC"].value = 0.0 - for key in self.categories["hybrid"]: - self.defaults[key]["operation"] = "==" if isinstance(self.defaults[key]["value"], bool) else "approx" + for key in [v.name for v in self.vasp_defaults.values() if v.tag == "hybrid"]: + self.vasp_defaults[key]["operation"] = "==" if isinstance(self.vasp_defaults[key].value, bool) else "approx" - def update_fft_params(self) -> None: + def _update_fft_params(self, user_incar: dict, ref_incar: dict, vasp_files: VaspFiles) -> None: """Update ENCUT and parameters related to the FFT grid.""" # ensure that ENCUT is appropriately updated - self.valid_values["ENMAX"] = self.input_set.incar.get("ENCUT", self.defaults["ENMAX"]) + user_incar["ENMAX"] = user_incar.get("ENCUT", getattr(vasp_files.vasprun, "parameters", {}).get("ENMAX")) + + ref_incar["ENMAX"] = vasp_files.valid_input_set.incar.get("ENCUT", self.vasp_defaults["ENMAX"]) grid_keys = {"NGX", "NGXF", "NGY", "NGYF", "NGZ", "NGZF"} # NGX/Y/Z and NGXF/YF/ZF. Not checked if not in INCAR file (as this means the VASP default was used). - if any(i for i in grid_keys if i in self._incar.keys()): - self.valid_values["ENMAX"] = max(self.parameters["ENMAX"], self.valid_values["ENMAX"]) - - ( - [ - self.valid_values["NGX"], - self.valid_values["NGY"], - self.valid_values["NGZ"], - ], - [ - self.valid_values["NGXF"], - self.valid_values["NGYF"], - self.valid_values["NGZF"], - ], - ) = self.input_set.calculate_ng(custom_encut=self.valid_values["ENMAX"]) + if any(i for i in grid_keys if i in user_incar.keys()): + enmaxs = [user_incar["ENMAX"], ref_incar["ENMAX"]] + ref_incar["ENMAX"] = max([v for v in enmaxs if v < float("inf")]) + + if fft_grid := vasp_files.valid_input_set._calculate_ng(custom_encut=ref_incar["ENMAX"]): + ( + [ + ref_incar["NGX"], + ref_incar["NGY"], + ref_incar["NGZ"], + ], + [ + ref_incar["NGXF"], + ref_incar["NGYF"], + ref_incar["NGZF"], + ], + ) = fft_grid for key in grid_keys: - self.valid_values[key] = int(self.valid_values[key] * self._fft_grid_tolerance) + ref_incar[key] = int(ref_incar[key] * self.fft_grid_tolerance) - self.defaults[key] = VaspParam( + self.vasp_defaults[key] = VaspParam( name=key, - value=self.valid_values[key], + value=ref_incar[key], tag="fft", operation=">=", comment=( @@ -460,7 +336,7 @@ def update_fft_params(self) -> None: ), ) - def update_density_mixing_params(self) -> None: + def _update_density_mixing_params(self, user_incar: dict, ref_incar: dict, vasp_files: VaspFiles) -> None: """ Check that LMAXMIX and LMAXTAU are above the required value. @@ -468,18 +344,14 @@ def update_density_mixing_params(self) -> None: according to the VASP development team (as of August 2023). """ - self.valid_values["LMAXMIX"] = self.input_set.incar.get("LMAXMIX", self.defaults["LMAXMIX"]["value"]) - self.valid_values["LMAXTAU"] = min(self.valid_values["LMAXMIX"] + 2, 6) - self.parameters["LMAXTAU"] = self._incar.get("LMAXTAU", self.defaults["LMAXTAU"]["value"]) + ref_incar["LMAXTAU"] = min(ref_incar["LMAXMIX"] + 2, 6) for key in ["LMAXMIX", "LMAXTAU"]: - if key == "LMAXTAU" and ( - self._incar.get("METAGGA", self.defaults["METAGGA"]["value"]) in ["--", None, "None"] - ): + if key == "LMAXTAU" and user_incar["METAGGA"] in ["--", None, "None"]: continue - if self.parameters[key] > 6: - self.defaults[key]["comment"] = ( + if user_incar[key] > 6: + self.vasp_defaults[key].comment = ( f"From empirical testing, using {key} > 6 appears " "to introduce computational instabilities, and is currently inadvisable " "according to the VASP development team." @@ -491,94 +363,97 @@ def update_density_mixing_params(self) -> None: if ( not any( [ - self._task_type == TaskType.NSCF_Uniform, - self._task_type == TaskType.NSCF_Line, - self.parameters["ICHARG"] >= 10, + vasp_files.run_type == "nonscf", + user_incar["ICHARG"] >= 10, ] ) and key == "LMAXMIX" ): - self.defaults[key]["severity"] = "warning" + self.vasp_defaults[key].severity = "warning" - if self.valid_values[key] < 6: - self.valid_values[key] = [self.valid_values[key], 6] - self.defaults[key]["operation"] = [">=", "<="] - self.parameters[key] = [self.parameters[key], self.parameters[key]] + if ref_incar[key] < 6: + ref_incar[key] = [ref_incar[key], 6] + self.vasp_defaults[key].operation = [">=", "<="] + user_incar[key] = [user_incar[key], user_incar[key]] else: - self.defaults[key]["operation"] = "==" + self.vasp_defaults[key].operation = "==" - def update_smearing_params(self, bandgap_tol=1.0e-4) -> None: + def _update_smearing_params(self, user_incar: dict, ref_incar: dict, vasp_files: VaspFiles) -> None: """ Update parameters related to Fermi-level smearing. This is based on the final bandgap obtained in the calc. """ - bandgap = self.task_doc["output"]["bandgap"] + if vasp_files.bandgap is not None: - smearing_comment = f"This is flagged as incorrect because this calculation had a bandgap of {round(bandgap,3)}" + smearing_comment = ( + f"This is flagged as incorrect because this calculation had a bandgap of {round(vasp_files.bandgap,3)}" + ) - # bandgap_tol taken from - # https://github.com/materialsproject/pymatgen/blob/1f98fa21258837ac174105e00e7ac8563e119ef0/pymatgen/io/vasp/sets.py#L969 - if bandgap > bandgap_tol: - self.valid_values["ISMEAR"] = [-5, 0] - self.valid_values["SIGMA"] = 0.05 - else: - self.valid_values["ISMEAR"] = [0, 1, 2] - if self.parameters["NSW"] == 0: - # ISMEAR = -5 is valid for metals *only* when doing static calc - self.valid_values["ISMEAR"].append(-5) - smearing_comment += " and is a static calculation" + # bandgap_tol taken from + # https://github.com/materialsproject/pymatgen/blob/1f98fa21258837ac174105e00e7ac8563e119ef0/pymatgen/io/vasp/sets.py#L969 + if vasp_files.bandgap > self.bandgap_tol: + ref_incar["ISMEAR"] = [-5, 0] + ref_incar["SIGMA"] = 0.05 else: - smearing_comment += " and is a non-static calculation" - self.valid_values["SIGMA"] = 0.2 - - smearing_comment += "." - - for key in ["ISMEAR", "SIGMA"]: - self.defaults[key]["comment"] = smearing_comment - - # TODO: improve logic for SIGMA reasons given in the case where you - # have a material that should have been relaxed with ISMEAR in [-5, 0], - # but used ISMEAR in [1,2]. Because in such cases, the user wouldn't - # need to update the SIGMA if they use tetrahedron smearing. - if self.parameters["ISMEAR"] in [-5, -4, -2]: - self.defaults["SIGMA"]["warning"] = ( - f"SIGMA is not being directly checked, as an ISMEAR of {self.parameters['ISMEAR']} " - f"is being used. However, given the bandgap of {round(bandgap,3)}, " - f"the maximum SIGMA used should be {self.valid_values['ISMEAR']} " - "if using an ISMEAR *not* in [-5, -4, -2]." - ) + ref_incar["ISMEAR"] = [-1, 0, 1, 2] + if user_incar["NSW"] == 0: + # ISMEAR = -5 is valid for metals *only* when doing static calc + ref_incar["ISMEAR"].append(-5) + smearing_comment += " and is a static calculation" + else: + smearing_comment += " and is a non-static calculation" + ref_incar["SIGMA"] = 0.2 + + smearing_comment += "." + + for key in ["ISMEAR", "SIGMA"]: + self.vasp_defaults[key].comment = smearing_comment + + if user_incar["ISMEAR"] not in [-5, -4, -2]: + self.vasp_defaults["SIGMA"].operation = "<=" else: - self.defaults["SIGMA"]["operation"] = "<=" + # These are generally applicable in all cases. Loosen check to warning. + ref_incar["ISMEAR"] = [-1, 0] + if vasp_files.run_type == "static": + ref_incar["ISMEAR"] += [-5] + elif vasp_files.run_type == "relax": + self.vasp_defaults["ISMEAR"].comment = ( + "Performing relaxations in metals with the tetrahedron method " + "may lead to significant errors in forces. To enable this check, " + "supply a vasprun.xml file." + ) + self.vasp_defaults["ISMEAR"].severity = "warning" # Also check if SIGMA is too large according to the VASP wiki, # which occurs when the entropy term in the energy is greater than 1 meV/atom. - self.parameters["ELECTRONIC ENTROPY"] = -1e20 - for ionic_step in self._ionic_steps: - if eentropy := ionic_step["electronic_steps"][-1].get("eentropy"): - self.parameters["ELECTRONIC ENTROPY"] = max( - self.parameters["ELECTRONIC ENTROPY"], - abs(eentropy / self.structure.num_sites), - ) - - convert_eV_to_meV = 1000 - self.parameters["ELECTRONIC ENTROPY"] = round(self.parameters["ELECTRONIC ENTROPY"] * convert_eV_to_meV, 3) - self.valid_values["ELECTRONIC ENTROPY"] = 0.001 * convert_eV_to_meV - - self.defaults["ELECTRONIC ENTROPY"] = VaspParam( - name="ELECTRONIC ENTROPY", - value=0.0, - tag="smearing", - comment=( - "The entropy term (T*S) in the energy is suggested to be less than " - f"{round(self.valid_values['ELECTRONIC ENTROPY'], 1)} meV/atom " - f"in the VASP wiki. Thus, SIGMA should be decreased." - ), - operation="<=", - ) + user_incar["ELECTRONIC ENTROPY"] = -1e20 + if vasp_files.vasprun: + for ionic_step in vasp_files.vasprun.ionic_steps: + if eentropy := ionic_step["electronic_steps"][-1].get("eentropy"): + user_incar["ELECTRONIC ENTROPY"] = max( + user_incar["ELECTRONIC ENTROPY"], + abs(eentropy / vasp_files.user_input.structure.num_sites), + ) + + convert_eV_to_meV = 1000 + user_incar["ELECTRONIC ENTROPY"] = round(user_incar["ELECTRONIC ENTROPY"] * convert_eV_to_meV, 3) + ref_incar["ELECTRONIC ENTROPY"] = 0.001 * convert_eV_to_meV + + self.vasp_defaults["ELECTRONIC ENTROPY"] = VaspParam( + name="ELECTRONIC ENTROPY", + value=0.0, + tag="smearing", + comment=( + "The entropy term (T*S) in the energy is suggested to be less than " + f"{round(ref_incar['ELECTRONIC ENTROPY'], 1)} meV/atom " + f"in the VASP wiki. Thus, SIGMA should be decreased." + ), + operation="<=", + ) - def _get_default_nbands(self): + def _get_default_nbands(self, nelect: float, user_incar: dict, vasp_files: VaspFiles): """ Estimate number of bands used in calculation. @@ -586,171 +461,182 @@ def _get_default_nbands(self): The only noteworthy changes (should) be that there is no reliance on the user setting up the psp_resources for pymatgen. """ - nions = len(self.structure.sites) + nions = len(vasp_files.user_input.structure.sites) - if self.parameters["ISPIN"] == 1: + if user_incar["ISPIN"] == 1: nmag = 0 else: - nmag = sum(self.parameters.get("MAGMOM", [0])) + nmag = sum(user_incar.get("MAGMOM", [0])) nmag = np.floor((nmag + 1) / 2) - possible_val_1 = np.floor((self._NELECT + 2) / 2) + max(np.floor(nions / 2), 3) - possible_val_2 = np.floor(self._NELECT * 0.6) + possible_val_1 = np.floor((nelect + 2) / 2) + max(np.floor(nions / 2), 3) + possible_val_2 = np.floor(nelect * 0.6) default_nbands = max(possible_val_1, possible_val_2) + nmag - if self.parameters.get("LNONCOLLINEAR"): + if user_incar.get("LNONCOLLINEAR"): default_nbands = default_nbands * 2 - if self.parameters.get("NPAR"): - default_nbands = ( - np.floor((default_nbands + self.parameters["NPAR"] - 1) / self.parameters["NPAR"]) - ) * self.parameters["NPAR"] + if vasp_files.vasprun and (npar := vasp_files.vasprun.parameters.get("NPAR")): + default_nbands = (np.floor((default_nbands + npar - 1) / npar)) * npar return int(default_nbands) - def update_electronic_params(self): + def _update_electronic_params(self, user_incar: dict, ref_incar: dict, vasp_files: VaspFiles): """Update electronic self-consistency parameters.""" # ENINI. Only check for IALGO = 48 / ALGO = VeryFast, as this is the only algo that uses this tag. - if self.parameters["IALGO"] == 48: - self.valid_values["ENINI"] = self.valid_values["ENMAX"] - self.defaults["ENINI"]["operation"] = ">=" + if user_incar["IALGO"] == 48: + ref_incar["ENINI"] = ref_incar["ENMAX"] + self.vasp_defaults["ENINI"].operation = ">=" # ENAUG. Should only be checked for calculations where the relevant MP input set specifies ENAUG. # In that case, ENAUG should be the same or greater than in valid_input_set. - if self.input_set.incar.get("ENAUG"): - self.defaults["ENAUG"]["operation"] = ">=" + if ref_incar.get("ENAUG") and not np.isinf(ref_incar["ENAUG"]): + self.vasp_defaults["ENAUG"].operation = ">=" # IALGO. - self.valid_values["IALGO"] = [38, 58, 68, 90] + ref_incar["IALGO"] = [38, 58, 68, 90] # TODO: figure out if 'normal' algos every really affect results other than convergence # NELECT. - self._NELECT = self.parameters.get("NELECT") # Do not check for non-neutral NELECT if NELECT is not in the INCAR - if self._incar.get("NELECT"): - self.valid_values["NELECT"] = 0.0 + if vasp_files.vasprun and (nelect := vasp_files.vasprun.parameters.get("NELECT")): + ref_incar["NELECT"] = 0.0 try: - self.parameters["NELECT"] = float(self._calcs_reversed[0]["output"]["structure"]._charge) - self.defaults["NELECT"].update( - { - "operation": "approx", - "comment": ( - f"This causes the structure to have a charge of {self.parameters['NELECT']}. " - f"NELECT should be set to {self._NELECT + self.parameters['NELECT']} instead." - ), - } + user_incar["NELECT"] = float(vasp_files.vasprun.final_structure._charge or 0.0) + self.vasp_defaults["NELECT"].operation = "approx" + self.vasp_defaults["NELECT"].comment = ( + f"This causes the structure to have a charge of {user_incar['NELECT']}. " + f"NELECT should be set to {nelect + user_incar['NELECT']} instead." ) except Exception: - self.defaults["NELECT"].update( - { - "operation": "auto fail", - "alias": "NELECT / POTCAR", - "comment": "Issue checking whether NELECT was changed to make " + self.vasp_defaults["NELECT"] = VaspParam( + name="NELECT", + value=None, + tag="electronic", + operation="auto fail", + severity="warning", + alias="NELECT / POTCAR", + comment=( + "Issue checking whether NELECT was changed to make " "the structure have a non-zero charge. This is likely due to the " - "directory not having a POTCAR file.", - } + "directory not having a POTCAR file." + ), ) - # NBANDS. - min_nbands = int(np.ceil(self._NELECT / 2) + 1) - self.defaults["NBANDS"] = VaspParam( - name="NBANDS", - value=self._get_default_nbands(), - tag="electronic", - operation=[">=", "<="], - comment=( - "Too many or too few bands can lead to unphysical electronic structure " - "(see https://github.com/materialsproject/custodian/issues/224 " - "for more context.)" - ), - ) - self.valid_values["NBANDS"] = [min_nbands, 4 * self.defaults["NBANDS"]["value"]] - self.parameters["NBANDS"] = [self.parameters["NBANDS"] for _ in range(2)] + # NBANDS. + min_nbands = int(np.ceil(nelect / 2) + 1) + self.vasp_defaults["NBANDS"] = VaspParam( + name="NBANDS", + value=self._get_default_nbands(nelect, user_incar, vasp_files), + tag="electronic", + operation=[">=", "<="], + comment=( + "Too many or too few bands can lead to unphysical electronic structure " + "(see https://github.com/materialsproject/custodian/issues/224 " + "for more context.)" + ), + ) + ref_incar["NBANDS"] = [min_nbands, 4 * self.vasp_defaults["NBANDS"].value] + user_incar["NBANDS"] = [vasp_files.vasprun.parameters.get("NBANDS") for _ in range(2)] - def update_ionic_params(self): + def _update_ionic_params(self, user_incar: dict, ref_incar: dict, vasp_files: VaspFiles): """Update parameters related to ionic relaxation.""" - self.valid_values["ISIF"] = 2 + ref_incar["ISIF"] = 2 # IBRION. - self.valid_values["IBRION"] = [-1, 1, 2] - if self.input_set.incar.get("IBRION"): - if self.input_set.incar.get("IBRION") not in self.valid_values["IBRION"]: - self.valid_values["IBRION"] = [self.input_set.incar["IBRION"]] + ref_incar["IBRION"] = [-1, 1, 2] + if (inp_set_ibrion := vasp_files.valid_input_set.incar.get("IBRION")) and inp_set_ibrion not in ref_incar[ + "IBRION" + ]: + ref_incar["IBRION"].append(inp_set_ibrion) + + ionic_steps = [] + if vasp_files.vasprun is not None: + ionic_steps = vasp_files.vasprun.ionic_steps # POTIM. - if self.parameters["IBRION"] in [1, 2, 3, 5, 6]: + if user_incar["IBRION"] in [1, 2, 3, 5, 6]: # POTIM is only used for some IBRION values - self.valid_values["POTIM"] = 5 - self.defaults["POTIM"].update( - { - "operation": "<=", - "comment": "POTIM being so high will likely lead to erroneous results.", - } - ) + ref_incar["POTIM"] = 5 + self.vasp_defaults["POTIM"].operation = "<=" + self.vasp_defaults["POTIM"].comment = "POTIM being so high will likely lead to erroneous results." # Check for large changes in energy between ionic steps (usually indicates too high POTIM) - if self._nionic_steps > 1: + if len(ionic_steps) > 1: # Do not use `e_0_energy`, as there is a bug in the vasprun.xml when printing that variable # (see https://www.vasp.at/forum/viewtopic.php?t=16942 for more details). - cur_ionic_step_energies = [ionic_step["e_fr_energy"] for ionic_step in self._ionic_steps] + cur_ionic_step_energies = [ionic_step["e_fr_energy"] for ionic_step in ionic_steps] cur_ionic_step_energy_gradient = np.diff(cur_ionic_step_energies) - self.parameters["MAX ENERGY GRADIENT"] = round( - max(np.abs(cur_ionic_step_energy_gradient)) / self.structure.num_sites, + user_incar["MAX ENERGY GRADIENT"] = round( + max(np.abs(cur_ionic_step_energy_gradient)) / vasp_files.user_input.structure.num_sites, 3, ) - self.valid_values["MAX ENERGY GRADIENT"] = 1 - self.defaults["MAX ENERGY GRADIENT"] = { - "value": None, - "tag": "ionic", - "operation": "<=", - "comment": ( - f"The energy changed by a maximum of {self.parameters['MAX ENERGY GRADIENT']} eV/atom " + ref_incar["MAX ENERGY GRADIENT"] = 1 + self.vasp_defaults["MAX ENERGY GRADIENT"] = VaspParam( + name="MAX ENERGY GRADIENT", + value=None, + tag="ionic", + operation="<=", + comment=( + f"The energy changed by a maximum of {user_incar['MAX ENERGY GRADIENT']} eV/atom " "between ionic steps; this indicates that POTIM is too high." ), - } + ) + + if not ionic_steps: + return # EDIFFG. # Should be the same or smaller than in valid_input_set. Force-based cutoffs (not in every # every MP-compliant input set, but often have comparable or even better results) will also be accepted # I am **NOT** confident that this should be the final check. Perhaps I need convincing (or perhaps it does indeed need to be changed...) # TODO: -somehow identify if a material is a vdW structure, in which case force-convergence should maybe be more strict? - self.defaults["EDIFFG"] = VaspParam(name="EDIFFG", value=10 * self.valid_values["EDIFF"], tag="ionic") + self.vasp_defaults["EDIFFG"] = VaspParam( + name="EDIFFG", + value=10 * ref_incar["EDIFF"], + tag="ionic", + operation=None, + ) - self.valid_values["EDIFFG"] = self.input_set.incar.get("EDIFFG", self.defaults["EDIFFG"]["value"]) - self.defaults["EDIFFG"][ - "comment" - ] = f"Hence, structure is not converged according to EDIFFG, which should be {self.valid_values['EDIFFG']} or better." + ref_incar["EDIFFG"] = ref_incar.get("EDIFFG", self.vasp_defaults["EDIFFG"].value) + self.vasp_defaults["EDIFFG"].comment = ( + "The structure is not force-converged according " + f"to |EDIFFG|={abs(ref_incar['EDIFFG'])} (or smaller in magnitude)." + ) - if self.task_doc["output"]["forces"] is None: - self.defaults["EDIFFG"]["warning"] = "TaskDoc does not contain output forces!" - self.defaults["EDIFFG"]["operation"] = "auto fail" + if ionic_steps[-1].get("forces") is None: + self.vasp_defaults["EDIFFG"].comment = ( + "vasprun.xml does not contain forces, cannot check force convergence." + ) + self.vasp_defaults["EDIFFG"].severity = "warning" + self.vasp_defaults["EDIFFG"].operation = "auto fail" - elif self.valid_values["EDIFFG"] < 0.0: - self.parameters["EDIFFG"] = round( - max([np.linalg.norm(force_on_atom) for force_on_atom in self.task_doc["output"]["forces"]]), + elif ref_incar["EDIFFG"] < 0.0 and (vrun_forces := ionic_steps[-1].get("forces")) is not None: + user_incar["EDIFFG"] = round( + max([np.linalg.norm(force_on_atom) for force_on_atom in vrun_forces]), 3, ) - self.valid_values["EDIFFG"] = abs(self.valid_values["EDIFFG"]) - self.defaults["EDIFFG"].update( - { - "value": self.defaults["EDIFFG"]["value"], - "operation": "<=", - "alias": "MAX FINAL FORCE MAGNITUDE", - } + ref_incar["EDIFFG"] = abs(ref_incar["EDIFFG"]) + self.vasp_defaults["EDIFFG"] = VaspParam( + name="EDIFFG", + value=self.vasp_defaults["EDIFFG"].value, + tag="ionic", + operation="<=", + alias="MAX FINAL FORCE MAGNITUDE", ) # the latter two checks just ensure the code does not error by indexing out of range - elif self.valid_values["EDIFFG"] > 0.0 and self._nionic_steps > 1: - energy_of_last_step = self._calcs_reversed[0]["output"]["ionic_steps"][-1]["e_0_energy"] - energy_of_second_to_last_step = self._calcs_reversed[0]["output"]["ionic_steps"][-2]["e_0_energy"] - self.parameters["EDIFFG"] = abs(energy_of_last_step - energy_of_second_to_last_step) - self.defaults["EDIFFG"]["operation"] = "<=" - self.defaults["EDIFFG"]["alias"] = "ENERGY CHANGE BETWEEN LAST TWO IONIC STEPS" - - def update_post_init_params(self): + elif ref_incar["EDIFFG"] > 0.0 and vasp_files.vasprun and len(ionic_steps) > 1: + energy_of_last_step = ionic_steps[-1]["e_0_energy"] + energy_of_second_to_last_step = ionic_steps[-2]["e_0_energy"] + user_incar["EDIFFG"] = abs(energy_of_last_step - energy_of_second_to_last_step) + self.vasp_defaults["EDIFFG"].operation = "<=" + self.vasp_defaults["EDIFFG"].alias = "ENERGY CHANGE BETWEEN LAST TWO IONIC STEPS" + + def _update_post_init_params(self, user_incar: dict, ref_incar: dict, vasp_files: VaspFiles): """Update any params that depend on other params being set/updated.""" # EBREAK @@ -758,8 +644,8 @@ def update_post_init_params(self): # to see if the user set a value for EBREAK. # Note that the NBANDS estimation differs from VASP's documentation, # so we can't check the vasprun value directly - if self._incar.get("EBREAK"): - self.defaults["EBREAK"]["value"] = self.defaults["EDIFF"]["value"] / ( - 4.0 * self.defaults["NBANDS"]["value"] + if user_incar.get("EBREAK"): + self.vasp_defaults["EBREAK"].value = self.vasp_defaults["EDIFF"].value / ( + 4.0 * self.vasp_defaults["NBANDS"].value ) - self.defaults["EBREAK"]["operation"] = "auto fail" + self.vasp_defaults["EBREAK"].operation = "auto fail" diff --git a/pymatgen/io/validation/check_kpoints_kspacing.py b/pymatgen/io/validation/check_kpoints_kspacing.py index ff23ce1..5f021b2 100644 --- a/pymatgen/io/validation/check_kpoints_kspacing.py +++ b/pymatgen/io/validation/check_kpoints_kspacing.py @@ -1,67 +1,58 @@ """Validate VASP KPOINTS files or the KSPACING/KGAMMA INCAR settings.""" from __future__ import annotations -from dataclasses import dataclass +from pydantic import Field +from typing import TYPE_CHECKING import numpy as np -from pymatgen.io.vasp import Kpoints - -from pymatgen.io.validation.common import BaseValidator -from typing import TYPE_CHECKING +from pymatgen.io.validation.common import SETTINGS, BaseValidator if TYPE_CHECKING: from pymatgen.core import Structure - from pymatgen.io.vasp.sets import VaspInputSet + from pymatgen.io.validation.common import VaspFiles -@dataclass -class CheckKpointsKspacing(BaseValidator): +def get_kpoint_divisions_from_kspacing(structure: Structure, kspacing: float) -> tuple[int, int, int]: """ - Check that k-point density is sufficiently high and is compatible with lattice symmetry. + Determine the number of k-points generated by VASP when KSPACING is set. + + See https://www.vasp.at/wiki/index.php/KSPACING for a discussion. + The 2 pi factor on that page appears to be irrelevant. Parameters ----------- - reasons : list[str] - A list of error strings to update if a check fails. These are higher - severity and would deprecate a calculation. - warnings : list[str] - A list of warning strings to update if a check fails. These are lower - severity and would flag a calculation for possible review. - valid_input_set: VaspInputSet - Valid input set to compare user INCAR parameters to. - kpoints : Kpoints or dict - Kpoints object or its .as_dict() representation used in the calculation. - structure : pymatgen.core.Structure - The structure used in the calculation - name : str = "Check k-point density" - Name of the validator class - fast : bool = False - Whether to perform quick check. - True: stop validation if any check fails. - False: perform all checks. - defaults : dict - Dict of default parameters - kpts_tolerance : float - Tolerance for evaluating k-point density, as the k-point generation - scheme is inconsistent across VASP versions - allow_explicit_kpoint_mesh : str | bool - Whether to permit explicit generation of k-points (as for a bandstructure calculation). - allow_kpoint_shifts : bool - Whether to permit shifting the origin of the k-point mesh from Gamma. + structure : Structure + kspacing : float + + Returns + ----------- + tuple of int, int, int + The number of k-point divisions along each axis. """ + return tuple([max(1, int(np.ceil(structure.lattice.reciprocal_lattice.abc[ik] / kspacing))) for ik in range(3)]) # type: ignore[return-value] + + +class CheckKpointsKspacing(BaseValidator): + """Check that k-point density is sufficiently high and is compatible with lattice symmetry.""" - reasons: list[str] - warnings: list[str] name: str = "Check k-point density" - valid_input_set: VaspInputSet = None - kpoints: Kpoints | dict = None - structure: Structure = None - defaults: dict | None = None - kpts_tolerance: float | None = None - allow_explicit_kpoint_mesh: str | bool = False - allow_kpoint_shifts: bool = False - - def _get_valid_num_kpts(self) -> int: + kpts_tolerance: float = Field( + SETTINGS.VASP_KPTS_TOLERANCE, + description="Tolerance for evaluating k-point density, to accommodate different the k-point generation schemes across VASP versions.", + ) + allow_explicit_kpoint_mesh: bool | str | None = Field( + SETTINGS.VASP_ALLOW_EXPLICIT_KPT_MESH, + description="Whether to permit explicit generation of k-points (as for a bandstructure calculation).", + ) + allow_kpoint_shifts: bool = Field( + SETTINGS.VASP_ALLOW_KPT_SHIFT, + description="Whether to permit shifting the origin of the k-point mesh from Gamma.", + ) + + def _get_valid_num_kpts( + self, + vasp_files: VaspFiles, + ) -> int: """ Get the minimum permitted number of k-points for a structure according to an input set. @@ -70,72 +61,103 @@ def _get_valid_num_kpts(self) -> int: int, the minimum permitted number of k-points, consistent with self.kpts_tolerance """ # If MP input set specifies KSPACING in the INCAR - if ("KSPACING" in self.valid_input_set.incar.keys()) and (self.valid_input_set.kpoints is None): - valid_kspacing = self.valid_input_set.incar.get("KSPACING", self.defaults["KSPACING"]["value"]) + if (kspacing := vasp_files.valid_input_set.incar.get("KSPACING")) and ( + vasp_files.valid_input_set.kpoints is None + ): + valid_kspacing = kspacing # number of kpoints along each of the three lattice vectors - nk = [ - max(1, np.ceil(self.structure.lattice.reciprocal_lattice.abc[ik] / valid_kspacing)) for ik in range(3) - ] - valid_num_kpts = np.prod(nk) + valid_num_kpts = np.prod( + get_kpoint_divisions_from_kspacing(vasp_files.user_input.structure, valid_kspacing), dtype=int + ) # If MP input set specifies a KPOINTS file - else: - valid_num_kpts = self.valid_input_set.kpoints.num_kpts or np.prod(self.valid_input_set.kpoints.kpts[0]) + elif vasp_files.valid_input_set.kpoints: + valid_num_kpts = vasp_files.valid_input_set.kpoints.num_kpts or np.prod( + vasp_files.valid_input_set.kpoints.kpts[0], dtype=int + ) return int(np.floor(int(valid_num_kpts) * self.kpts_tolerance)) - def _check_user_shifted_mesh(self) -> None: + def _check_user_shifted_mesh(self, vasp_files: VaspFiles, reasons: list[str], warnings: list[str]) -> None: # Check for user shifts - if (not self.allow_kpoint_shifts) and any(shift_val != 0 for shift_val in self.kpoints["usershift"]): - self.reasons.append("INPUT SETTINGS --> KPOINTS: shifting the kpoint mesh is not currently allowed.") + if ( + (not self.allow_kpoint_shifts) + and vasp_files.actual_kpoints + and any(shift_val != 0 for shift_val in vasp_files.actual_kpoints.kpts_shift) + ): # type: ignore[union-attr] + reasons.append("INPUT SETTINGS --> KPOINTS: shifting the kpoint mesh is not currently allowed.") - def _check_explicit_mesh_permitted(self) -> None: + def _check_explicit_mesh_permitted(self, vasp_files: VaspFiles, reasons: list[str], warnings: list[str]) -> None: # Check for explicit kpoint meshes - if (not self.allow_explicit_kpoint_mesh) and len(self.kpoints["kpoints"]) > 1: - self.reasons.append( + if not vasp_files.actual_kpoints: + return + + if isinstance(self.allow_explicit_kpoint_mesh, bool): + allow_explicit = self.allow_explicit_kpoint_mesh + elif self.allow_explicit_kpoint_mesh == "auto": + allow_explicit = vasp_files.run_type == "nonscf" + else: + allow_explicit = False + + if (not allow_explicit) and len(vasp_files.actual_kpoints.kpts) > 1: # type: ignore[union-attr] + reasons.append( "INPUT SETTINGS --> KPOINTS: explicitly defining " "the k-point mesh is not currently allowed. " "Automatic k-point generation is required." ) - def _check_kpoint_density(self) -> None: + def _check_kpoint_density(self, vasp_files: VaspFiles, reasons: list[str], warnings: list[str]) -> None: """ Check that k-point density is sufficiently high and is compatible with lattice symmetry. """ # Check number of kpoints used - valid_num_kpts = self._get_valid_num_kpts() - - if isinstance(self.kpoints, Kpoints): - self.kpoints = self.kpoints.as_dict() + # Checks should work regardless of whether vasprun was supplied. + valid_num_kpts = self._get_valid_num_kpts(vasp_files) + if vasp_files.actual_kpoints: + if vasp_files.actual_kpoints.num_kpts <= 0: + cur_num_kpts = np.prod(vasp_files.actual_kpoints.kpts, dtype=int) + else: + cur_num_kpts = vasp_files.actual_kpoints.num_kpts + else: + cur_num_kpts = np.prod( + get_kpoint_divisions_from_kspacing( + vasp_files.user_input.structure, + vasp_files.user_input.incar.get("KSPACING", self.vasp_defaults["KSPACING"].value), + ), + dtype=int, + ) - cur_num_kpts = max( - self.kpoints.get("nkpoints", 0), - np.prod(self.kpoints.get("kpoints")), - len(self.kpoints.get("kpoints")), - ) if cur_num_kpts < valid_num_kpts: - self.reasons.append( + reasons.append( f"INPUT SETTINGS --> KPOINTS or KSPACING: {cur_num_kpts} kpoints were " f"used, but it should have been at least {valid_num_kpts}." ) - def _check_kpoint_mesh_symmetry(self) -> None: + def _check_kpoint_mesh_symmetry(self, vasp_files: VaspFiles, reasons: list[str], warnings: list[str]) -> None: # check for valid kpoint mesh (which depends on symmetry of the structure) - cur_kpoint_style = self.kpoints.get("generation_style").lower() - is_hexagonal = self.structure.lattice.is_hexagonal() - is_face_centered = self.structure.get_space_group_info()[0][0] == "F" + if vasp_files.actual_kpoints: + cur_kpoint_style = vasp_files.actual_kpoints.style.name.lower() # type: ignore[union-attr] + else: + cur_kpoint_style = ( + "gamma" + if vasp_files.user_input.incar.get("KGAMMA", self.vasp_defaults["KGAMMA"].value) + else "monkhorst" + ) + + is_hexagonal = vasp_files.user_input.structure.lattice.is_hexagonal() + is_face_centered = vasp_files.user_input.structure.get_space_group_info()[0][0] == "F" monkhorst_mesh_is_invalid = is_hexagonal or is_face_centered if ( cur_kpoint_style == "monkhorst" and monkhorst_mesh_is_invalid - and any(x % 2 == 0 for x in self.kpoints.get("kpoints")[0]) + and any(x % 2 == 0 for x in vasp_files.actual_kpoints.kpts[0]) # type: ignore[union-attr] ): # only allow Monkhorst with all odd number of subdivisions per axis. - kx, ky, kz = self.kpoints.get("kpoints")[0] - self.reasons.append( - f"INPUT SETTINGS --> KPOINTS or KGAMMA: ({kx}x{ky}x{kz}) " + kv = vasp_files.actual_kpoints.kpts[0] # type: ignore[union-attr] + reasons.append( + f"INPUT SETTINGS --> KPOINTS or KGAMMA: ({'×'.join([f'{_k}' for _k in kv])}) " "Monkhorst-Pack kpoint mesh was used." "To be compatible with the symmetry of the lattice, " "a Monkhorst-Pack mesh should have only odd number of " diff --git a/pymatgen/io/validation/check_package_versions.py b/pymatgen/io/validation/check_package_versions.py index 17da035..6fb9875 100644 --- a/pymatgen/io/validation/check_package_versions.py +++ b/pymatgen/io/validation/check_package_versions.py @@ -2,7 +2,7 @@ from __future__ import annotations from importlib.metadata import version -import requests +import requests # type: ignore[import-untyped] import warnings diff --git a/pymatgen/io/validation/check_potcar.py b/pymatgen/io/validation/check_potcar.py index a9418ff..1099ec5 100644 --- a/pymatgen/io/validation/check_potcar.py +++ b/pymatgen/io/validation/check_potcar.py @@ -1,107 +1,110 @@ """Check POTCAR against known POTCARs in pymatgen, without setting up psp_resources.""" from __future__ import annotations -from dataclasses import dataclass, field +from copy import deepcopy +from functools import cached_property +from pathlib import Path +from pydantic import Field from importlib.resources import files as import_resource_files from monty.serialization import loadfn -import numpy as np +from typing import TYPE_CHECKING -from pymatgen.io.validation.common import BaseValidator +from pymatgen.io.vasp import PotcarSingle -from typing import TYPE_CHECKING +from pymatgen.io.validation.common import BaseValidator, ValidationError if TYPE_CHECKING: - from pymatgen.core import Structure - from pymatgen.io.vasp.sets import VaspInputSet - -_potcar_summary_stats = loadfn(import_resource_files("pymatgen.io.vasp") / "potcar-summary-stats.json.bz2") + from typing import Any + from pymatgen.io.validation.common import VaspFiles -@dataclass class CheckPotcar(BaseValidator): """ Check POTCAR against library of known valid POTCARs. - - reasons : list[str] - A list of error strings to update if a check fails. These are higher - severity and would deprecate a calculation. - warnings : list[str] - A list of warning strings to update if a check fails. These are lower - severity and would flag a calculation for possible review. - valid_input_set: VaspInputSet - Valid input set to compare user INCAR parameters to. - structure: Pymatgen Structure - Structure used in the calculation. - potcar: dict - Spec (symbol, hash, and summary stats) for the POTCAR used in the calculation. - name : str = "Check POTCARs" - Name of the validator class - fast : bool = False - Whether to perform quick check. - True: stop validation if any check fails. - False: perform all checks. - potcar_summary_stats : dict - Dictionary of potcar summary data. Mapping is calculation type -> potcar symbol -> summary data. - data_match_tol : float = 1.e-6 - Tolerance for matching POTCARs to summary statistics data. - fast : bool = False - True: stop validation when any single check fails """ - reasons: list[str] - warnings: list[str] - valid_input_set: VaspInputSet = None - structure: Structure = None - potcars: dict = None - name: str = "Check POTCARs" - potcar_summary_stats: dict = field(default_factory=lambda: _potcar_summary_stats) - data_match_tol: float = 1.0e-6 - fast: bool = False - - def _check_potcar_spec(self): - """ - Checks to make sure the POTCAR is equivalent to the correct POTCAR from the pymatgen input set.""" - - if not self.potcar_summary_stats: + name: str = "Check POTCAR" + potcar_summary_stats_path: str | Path | None = Field( + str(import_resource_files("pymatgen.io.vasp") / "potcar-summary-stats.json.bz2"), + description="Path to potcar summary data. Mapping is calculation type -> potcar symbol -> summary data.", + ) + data_match_tol: float = Field(1.0e-6, description="Tolerance for matching POTCARs to summary statistics data.") + ignore_header_keys: set[str] | None = Field( + {"copyr", "sha256"}, description="POTCAR summary statistics keywords.header fields to ignore during validation" + ) + + @cached_property + def potcar_summary_stats(self) -> dict: + """Load POTCAR summary statistics file.""" + if self.potcar_summary_stats_path: + return loadfn(self.potcar_summary_stats_path, cls=None) + return {} + + def auto_fail(self, vasp_files: VaspFiles, reasons: list[str], warnings: list[str]) -> bool: + """Skip if no POTCAR was provided, or if summary stats file was unset.""" + + if self.potcar_summary_stats_path is None: # If no reference summary stats specified, or we're only doing a quick check, # and there are already failure reasons, return - return - - if self.potcars is None or any(potcar.get("summary_stats") is None for potcar in self.potcars): - self.reasons.append( + return True + elif vasp_files.user_input.potcar is None or any( + ps.keywords is None or ps.stats is None for ps in vasp_files.user_input.potcar + ): + reasons.append( "PSEUDOPOTENTIALS --> Missing POTCAR files. " "Alternatively, our potcar checker may have an issue--please create a GitHub issue if you " "know your POTCAR exists and can be read by Pymatgen." ) - return + return True + return False - psp_subset = self.potcar_summary_stats.get(self.valid_input_set._config_dict["POTCAR_FUNCTIONAL"], {}) + def _check_potcar_spec(self, vasp_files: VaspFiles, reasons: list[str], warnings: list[str]): + """ + Checks to make sure the POTCAR is equivalent to the correct POTCAR from the pymatgen input set.""" - valid_potcar_summary_stats = {} # type: ignore - for element in self.structure.composition.remove_charges().as_dict(): - potcar_symbol = self.valid_input_set._config_dict["POTCAR"][element] - for titel_no_spc in psp_subset: - for psp in psp_subset[titel_no_spc]: - if psp["symbol"] == potcar_symbol: - if titel_no_spc not in valid_potcar_summary_stats: - valid_potcar_summary_stats[titel_no_spc] = [] - valid_potcar_summary_stats[titel_no_spc].append(psp) + if vasp_files.valid_input_set.potcar: + # If the user has pymatgen set up, use the pregenerated POTCAR summary stats. + valid_potcar_summary_stats: dict[str, list[dict[str, Any]]] = { + p.titel.replace(" ", ""): [p.model_dump()] for p in vasp_files.valid_input_set.potcar + } + elif vasp_files.valid_input_set._pmg_vis: + # Fallback, use the stats from pymatgen - only load and cache summary stats here. + psp_subset = self.potcar_summary_stats.get(vasp_files.valid_input_set.potcar_functional, {}) + + valid_potcar_summary_stats = {} + for element in vasp_files.user_input.structure.composition.remove_charges().as_dict(): + potcar_symbol = vasp_files.valid_input_set._pmg_vis._config_dict["POTCAR"][element] + for titel_no_spc in psp_subset: + for psp in psp_subset[titel_no_spc]: + if psp["symbol"] == potcar_symbol: + if titel_no_spc not in valid_potcar_summary_stats: + valid_potcar_summary_stats[titel_no_spc] = [] + valid_potcar_summary_stats[titel_no_spc].append(psp) + else: + raise ValidationError("Could not determine reference POTCARs.") try: - incorrect_potcars = [] - for potcar in self.potcars: - reference_summary_stats = valid_potcar_summary_stats.get(potcar["titel"].replace(" ", ""), []) + incorrect_potcars: list[str] = [] + for potcar in vasp_files.user_input.potcar: # type: ignore[union-attr] + reference_summary_stats = valid_potcar_summary_stats.get(potcar.titel.replace(" ", ""), []) + potcar_symbol = potcar.titel.split(" ")[1] if len(reference_summary_stats) == 0: - incorrect_potcars.append(potcar["titel"].split(" ")[1]) + incorrect_potcars.append(potcar_symbol) continue - for ref_psp in reference_summary_stats: - if found_match := self.compare_potcar_stats(ref_psp, potcar["summary_stats"]): + for _ref_psp in reference_summary_stats: + user_summary_stats = potcar.model_dump() + ref_psp = deepcopy(_ref_psp) + for _set in (user_summary_stats, ref_psp): + _set["keywords"]["header"] = set(_set["keywords"]["header"]).difference(self.ignore_header_keys) # type: ignore[arg-type] + if found_match := PotcarSingle.compare_potcar_stats( + ref_psp, user_summary_stats, tolerance=self.data_match_tol + ): break if not found_match: - incorrect_potcars.append(potcar["titel"].split(" ")[1]) + incorrect_potcars.append(potcar_symbol) if self.fast: # quick return, only matters that one POTCAR didn't match break @@ -109,64 +112,22 @@ def _check_potcar_spec(self): if len(incorrect_potcars) > 0: # format error string incorrect_potcars = [potcar.split("_")[0] for potcar in incorrect_potcars] - if len(incorrect_potcars) == 2: - incorrect_potcars = ( - ", ".join(incorrect_potcars[:-1]) + f" and {incorrect_potcars[-1]}" - ) # type: ignore - elif len(incorrect_potcars) >= 3: - incorrect_potcars = ( - ", ".join(incorrect_potcars[:-1]) + "," + f" and {incorrect_potcars[-1]}" + if len(incorrect_potcars) == 1: + incorrect_potcar_str = incorrect_potcars[0] + else: + incorrect_potcar_str = ( + ", ".join(incorrect_potcars[:-1]) + f", and {incorrect_potcars[-1]}" ) # type: ignore - self.reasons.append( - f"PSEUDOPOTENTIALS --> Incorrect POTCAR files were used for {incorrect_potcars}. " + reasons.append( + f"PSEUDOPOTENTIALS --> Incorrect POTCAR files were used for {incorrect_potcar_str}. " "Alternatively, our potcar checker may have an issue--please create a GitHub issue if you " "believe the POTCARs used are correct." ) - except KeyError as e: - print(f"POTCAR check exception: {e}") - # Assume it is an old calculation without potcar_spec data and treat it as failing the POTCAR check - self.reasons.append( + except KeyError: + reasons.append( "Issue validating POTCARS --> Likely due to an old version of Emmet " "(wherein potcar summary_stats is not saved in TaskDoc), though " "other errors have been seen. Hence, it is marked as invalid." ) - - def compare_potcar_stats(self, potcar_stats_1: dict, potcar_stats_2: dict) -> bool: - """Utility function to compare PotcarSingle._summary_stats.""" - - if not all( - potcar_stats_1.get(key) - for key in ( - "keywords", - "stats", - ) - ) or ( - not all( - potcar_stats_2.get(key) - for key in ( - "keywords", - "stats", - ) - ) - ): - return False - - key_match = all( - set(potcar_stats_1["keywords"].get(key)) == set(potcar_stats_2["keywords"].get(key)) # type: ignore - for key in ["header", "data"] - ) - - data_match = False - if key_match: - data_diff = [ - abs( - potcar_stats_1["stats"].get(key, {}).get(stat) - potcar_stats_2["stats"].get(key, {}).get(stat) - ) # type: ignore - for stat in ["MEAN", "ABSMEAN", "VAR", "MIN", "MAX"] - for key in ["header", "data"] - ] - data_match = all(np.array(data_diff) < self.data_match_tol) - - return key_match and data_match diff --git a/pymatgen/io/validation/common.py b/pymatgen/io/validation/common.py index cddf71f..0a95dff 100644 --- a/pymatgen/io/validation/common.py +++ b/pymatgen/io/validation/common.py @@ -1,265 +1,499 @@ """Common class constructor for validation checks.""" from __future__ import annotations -from dataclasses import dataclass -from math import isclose -from typing import TYPE_CHECKING, Literal + +from functools import cached_property +import hashlib +from importlib import import_module +from monty.serialization import loadfn +import os +import numpy as np +from pathlib import Path +from pydantic import BaseModel, Field, model_validator, model_serializer, PrivateAttr +from typing import TYPE_CHECKING, Any, Optional + +from pymatgen.core import Structure +from pymatgen.io.vasp.inputs import POTCAR_STATS_PATH, Incar, Kpoints, Poscar, Potcar +from pymatgen.io.vasp.outputs import Outcar, Vasprun +from pymatgen.io.vasp.sets import VaspInputSet + +from pymatgen.io.validation.vasp_defaults import VaspParam, VASP_DEFAULTS_DICT +from pymatgen.io.validation.settings import IOValidationSettings if TYPE_CHECKING: - from typing import Any + from typing_extensions import Self -VALID_OPERATIONS: set[str | None] = { - "==", - ">", - ">=", - "<", - "<=", - "in", - "approx", - "auto fail", - None, -} +SETTINGS = IOValidationSettings() -class InvalidOperation(Exception): - """Define custom exception when checking valid operations.""" +class ValidationError(Exception): + """Define custom exception during validation.""" - def __init__(self, operation: str) -> None: - """Define custom exception when checking valid operations. - Args: - operation (str) : a symbolic string for an operation that is not valid. - """ - msg = f"Unknown operation type {operation}; valid values are: {VALID_OPERATIONS}" - super().__init__(msg) +class PotcarSummaryKeywords(BaseModel): + """Schematize `PotcarSingle._summary_stats["keywords"]` field.""" + header: set[str] = Field(description="The keywords in the POTCAR header.") + data: set[str] = Field(description="The keywords in the POTCAR body.") -class BasicValidator: - """ - Compare test and reference values according to one or more operations. + @model_serializer + def set_to_list(self) -> dict[str, list[str]]: + """Ensure JSON compliance of set fields.""" + return {k: list(getattr(self, k)) for k in ("header", "data")} - Parameters - ----------- - global_tolerance : float = 1.e-4 - Default tolerance for assessing approximate equality via math.isclose - """ - # avoiding dunder methods because these raise too many NotImplemented's +class PotcarSummaryStatisticsFields(BaseModel): + """Define statistics used in `PotcarSingle._summary_stats`.""" + + MEAN: float = Field(description="Data mean.") + ABSMEAN: float = Field(description="Data magnitude mean.") + VAR: float = Field(description="Mean of squares of data.") + MIN: float = Field(description="Data minimum.") + MAX: float = Field(description="Data maximum.") - def __init__(self, global_tolerance: float = 1.0e-4) -> None: - """Set math.isclose tolerance""" - self.tolerance = global_tolerance - @staticmethod - def _comparator(lhs: Any, operation: str, rhs: Any, **kwargs) -> bool: +class PotcarSummaryStatistics(BaseModel): + """Schematize `PotcarSingle._summary_stats["stats"]` field.""" + + header: PotcarSummaryStatisticsFields = Field(description="The keywords in the POTCAR header.") + data: PotcarSummaryStatisticsFields = Field(description="The keywords in the POTCAR body.") + + +class PotcarSummaryStats(BaseModel): + """Schematize `PotcarSingle._summary_stats`.""" + + keywords: Optional[PotcarSummaryKeywords] = None + stats: Optional[PotcarSummaryStatistics] = None + titel: str + lexch: str + + @classmethod + def from_file(cls, potcar_path: os.PathLike | Potcar) -> list[Self]: + """Create a list of PotcarSummaryStats from a POTCAR.""" + if isinstance(potcar_path, Potcar): + potcar: Potcar = potcar_path + else: + potcar = Potcar.from_file(str(potcar_path)) + return [cls(**p._summary_stats, titel=p.TITEL, lexch=p.LEXCH) for p in potcar] + + +class LightOutcar(BaseModel): + """Schematic of pymatgen's Outcar.""" + + drift: Optional[list[list[float]]] = Field(None, description="The drift forces.") + magnetization: Optional[list[dict[str, float]]] = Field( + None, description="The on-site magnetic moments, possibly with orbital resolution." + ) + + +class LightVasprun(BaseModel): + """Lightweight version of pymatgen Vasprun.""" + + vasp_version: str = Field(description="The dot-separated version of VASP used.") + ionic_steps: list[dict[str, Any]] = Field(description="The ionic steps in the calculation.") + final_energy: float = Field(description="The final total energy in eV.") + final_structure: Structure = Field(description="The final structure.") + kpoints: Kpoints = Field(description="The actual k-points used in the calculation.") + parameters: dict[str, Any] = Field(description="The default-padded input parameters interpreted by VASP.") + bandgap: float = Field(description="The bandgap - note that this field is derived from the Vasprun object.") + potcar_symbols: Optional[list[str]] = Field( + None, + description="Optional: if a POTCAR is unavailable, this is used to determine the functional used in the calculation.", + ) + + @classmethod + def from_vasprun(cls, vasprun: Vasprun) -> Self: """ - Compare different values using one of VALID_OPERATIONS. + Create a LightVasprun from a pymatgen Vasprun. Parameters ----------- - lhs : Any - Left-hand side of the operation. - operation : str - Operation acting on rhs from lhs. For example, if operation is ">", - this returns (lhs > rhs). - rhs : Any - Right-hand of the operation. - kwargs - If needed, kwargs to pass to operation. + vasprun : pymatgen Vasprun + + Returns + ----------- + LightVasprun """ - if operation is None: - c = True - elif operation == "auto fail": - c = False - elif operation == "==": - c = lhs == rhs - elif operation == ">": - c = lhs > rhs - elif operation == ">=": - c = lhs >= rhs - elif operation == "<": - c = lhs < rhs - elif operation == "<=": - c = lhs <= rhs - elif operation == "in": - c = lhs in rhs - elif operation == "approx": - c = isclose(lhs, rhs, **kwargs) - else: - raise InvalidOperation(operation) - return c - - def _check_parameter( - self, - error_list: list[str], - input_tag: str, - current_value: Any, - reference_value: Any, - operation: str, - tolerance: float | None = None, - append_comments: str | None = None, - ) -> None: + return cls( + **{k: getattr(vasprun, k) for k in cls.model_fields if k != "bandgap"}, + bandgap=vasprun.get_band_structure(efermi="smart").get_band_gap()["energy"], + ) + + @model_serializer + def deserialize_objects(self) -> dict[str, Any]: + """Ensure all pymatgen objects are deserialized.""" + model_dumped = {k: getattr(self, k) for k in self.__class__.model_fields} + for k in ("final_structure", "kpoints"): + model_dumped[k] = model_dumped[k].as_dict() + for iion, istep in enumerate(model_dumped["ionic_steps"]): + if (istruct := istep.get("structure")) and isinstance(istruct, Structure): + model_dumped["ionic_steps"][iion]["structure"] = istruct.as_dict() + for k in ("forces", "stress"): + if (val := istep.get(k)) is not None and isinstance(val, np.ndarray): + model_dumped["ionic_steps"][iion][k] = val.tolist() + return model_dumped + + +class VaspInputSafe(BaseModel): + """Stricter VaspInputSet with no POTCAR info.""" + + incar: Incar = Field(description="The INCAR used in the calculation.") + structure: Structure = Field(description="The structure associated with the calculation.") + kpoints: Optional[Kpoints] = Field(None, description="The optional KPOINTS or IBZKPT file used in the calculation.") + potcar: Optional[list[PotcarSummaryStats]] = Field(None, description="The optional POTCAR used in the calculation.") + potcar_functional: Optional[str] = Field(None, description="The pymatgen-labelled POTCAR library release.") + _pmg_vis: Optional[VaspInputSet] = PrivateAttr(None) + + @model_serializer + def deserialize_objects(self) -> dict[str, Any]: + """Ensure all pymatgen objects are deserialized.""" + model_dumped: dict[str, Any] = {} + if self.potcar: + model_dumped["potcar"] = [p.model_dump() for p in self.potcar] + for k in ( + "incar", + "structure", + "kpoints", + ): + if pmg_obj := getattr(self, k): + model_dumped[k] = pmg_obj.as_dict() + return model_dumped + + @classmethod + def from_vasp_input_set(cls, vis: VaspInputSet) -> Self: """ - Determine validity of parameter subject to a single specified operation. + Create a VaspInputSafe from a pymatgen VaspInputSet. Parameters ----------- - error_list : list[str] - A list of error/warning strings to update if a check fails. - input_tag : str - The name of the input tag which is being checked. - current_value : Any - The test value. - reference_value : Any - The value to compare the test value to. - operation : str - A valid operation in self.operations. For example, if operation = "<=", - this checks `current_value <= reference_value` (note order of values). - tolerance : float or None (default) - If None and operation == "approx", default tolerance to self.tolerance. - Otherwise, use the user-supplied tolerance. - append_comments : str or None (default) - Additional comments that may be helpful for the user to understand why - a check failed. + vasprun : pymatgen VaspInputSet + + Returns + ----------- + VaspInputSafe """ - append_comments = append_comments or "" + cls_config: dict[str, Any] = { + k: getattr(vis, k) + for k in ( + "incar", + "kpoints", + "structure", + ) + } + try: + # Cleaner solution (because these map one POTCAR symbol to one POTCAR) + # Requires POTCAR library to be available + potcar: list[PotcarSummaryStats] = PotcarSummaryStats.from_file(vis.potcar) + potcar_functional = vis.potcar_functional + + except FileNotFoundError: + # Fall back to pregenerated POTCAR meta + # Note that multiple POTCARs may use the same symbol / TITEL + # within a given release of VASP. + + potcar_stats = loadfn(POTCAR_STATS_PATH) + potcar_functional = vis._config_dict["POTCAR_FUNCTIONAL"] + potcar = [] + for ele in vis.structure.elements: + if potcar_symb := vis._config_dict["POTCAR"].get(ele.name): + for titel_no_spc, potcars in potcar_stats[potcar_functional].items(): + for entry in potcars: + if entry["symbol"] == potcar_symb: + titel_comp = titel_no_spc.split(potcar_symb) + + potcar += [ + PotcarSummaryStats( + titel=" ".join([titel_comp[0], potcar_symb, titel_comp[1]]), + lexch=entry.get("LEXCH"), + **entry, + ) + ] + + cls_config.update( + potcar=potcar, + potcar_functional=potcar_functional, + ) + new_vis = cls(**cls_config) + new_vis._pmg_vis = vis + return new_vis + + def _calculate_ng(self, **kwargs) -> tuple[list[int], list[int]] | None: + """Interface to pymatgen vasp input set as needed.""" + if self._pmg_vis: + return self._pmg_vis.calculate_ng(**kwargs) + return None + + +class VaspFiles(BaseModel): + """Define required and optional files for validation.""" + + user_input: VaspInputSafe = Field(description="The VASP input set used in the calculation.") + outcar: Optional[LightOutcar] = None + vasprun: Optional[LightVasprun] = None + + @model_validator(mode="before") + @classmethod + def coerce_to_lightweight(cls, config: Any) -> Any: + """Ensure that pymatgen objects are converted to minimal representations.""" + if isinstance(config.get("outcar"), Outcar): + config["outcar"] = LightOutcar( + drift=config["outcar"].drift, + magnetization=config["outcar"].magnetization, + ) - if isinstance(current_value, str): - current_value = current_value.upper() + if isinstance(config.get("vasprun"), Vasprun): + config["vasprun"] = LightVasprun.from_vasprun(config["vasprun"]) + return config + + @property + def md5(self) -> str: + """Get MD5 of VaspFiles for use in validation checks.""" + return hashlib.md5(self.model_dump_json().encode()).hexdigest() + + @property + def actual_kpoints(self) -> Kpoints | None: + """The actual KPOINTS / IBZKPT used in the calculation, if applicable.""" + if self.user_input.kpoints: + return self.user_input.kpoints + elif self.vasprun: + return self.vasprun.kpoints + return None + + @property + def vasp_version(self) -> tuple[int, int, int] | None: + """Return the VASP version as a tuple of int, if available.""" + if self.vasprun: + vvn = [int(x) for x in self.vasprun.vasp_version.split(".")] + return (vvn[0], vvn[1], vvn[2]) + return None + + @classmethod + def from_paths( + cls, + incar: str | Path | os.PathLike[str], + poscar: str | Path | os.PathLike[str], + kpoints: str | Path | os.PathLike[str] | None = None, + potcar: str | Path | os.PathLike[str] | None = None, + outcar: str | Path | os.PathLike[str] | None = None, + vasprun: str | Path | os.PathLike[str] | None = None, + ): + """Construct a set of VASP I/O from file paths.""" + config: dict[str, Any] = {"user_input": {}} + _vars = locals() + + to_obj = { + "incar": Incar, + "kpoints": Kpoints, + "poscar": Poscar, + "potcar": PotcarSummaryStats, + "outcar": Outcar, + "vasprun": Vasprun, + } + potcar_enmax = None + for file_name, file_cls in to_obj.items(): + if (path := _vars.get(file_name)) and Path(path).exists(): + if file_name == "poscar": + config["user_input"]["structure"] = Poscar.from_file(path).structure + elif hasattr(file_cls, "from_file"): + config["user_input"][file_name] = file_cls.from_file(path) + else: + config[file_name] = file_cls(path) + + if file_name == "potcar": + potcar_enmax = max(ps.ENMAX for ps in Potcar.from_file(path)) + + if not config.get("vasprun") and not config["user_input"]["incar"].get("ENCUT") and potcar_enmax: + config["user_input"]["incar"]["ENCUT"] = potcar_enmax + + return cls(**config) + + @cached_property + def run_type(self) -> str: + """Get the run type of a calculation.""" + + ibrion = self.user_input.incar.get("IBRION", VASP_DEFAULTS_DICT["IBRION"].value) + if self.user_input.incar.get("NSW", VASP_DEFAULTS_DICT["NSW"].value) > 0 and ibrion == -1: + ibrion = 0 + + run_type = { + -1: "static", + 0: "md", + **{k: "relax" for k in range(1, 4)}, + **{k: "phonon" for k in range(5, 9)}, + **{k: "ts" for k in (40, 44)}, + }.get(ibrion) + + if self.user_input.incar.get("ICHARG", VASP_DEFAULTS_DICT["ICHARG"].value) >= 10: + run_type = "nonscf" + if self.user_input.incar.get("LCHIMAG", VASP_DEFAULTS_DICT["LCHIMAG"].value): + run_type == "nmr" + + if run_type is None: + raise ValidationError( + "Could not determine a valid run type. We currently only validate " + "Geometry optimizations (relaxations), single-points (statics), " + "and non-self-consistent fixed charged density calculations. ", + ) - kwargs: dict[str, Any] = {} - if operation == "approx" and isinstance(current_value, float): - kwargs.update({"rel_tol": tolerance or self.tolerance, "abs_tol": 0.0}) - valid_value = self._comparator(current_value, operation, reference_value, **kwargs) + return run_type - if not valid_value: - error_list.append( - f"INPUT SETTINGS --> {input_tag}: is {current_value}, but should be " - f"{'' if operation == 'auto fail' else operation + ' '}{reference_value}." - f"{' ' if len(append_comments) > 0 else ''}{append_comments}" - ) + @cached_property + def functional(self) -> str: + """Determine the functional used in the calculation. - def check_parameter( - self, - reasons: list[str], - warnings: list[str], - input_tag: str, - current_values: Any, - reference_values: Any, - operations: str | list[str], - tolerance: float = None, - append_comments: str | None = None, - severity: Literal["reason", "warning"] = "reason", - ) -> None: + Note that this is not a complete determination. + Only the functionals used by MP are detected here. """ - Determine validity of parameter according to one or more operations. - Parameters - ----------- - reasons : list[str] - A list of error strings to update if a check fails. These are higher - severity and would deprecate a calculation. - warnings : list[str] - A list of warning strings to update if a check fails. These are lower - severity and would flag a calculation for possible review. - input_tag : str - The name of the input tag which is being checked. - current_values : Any - The test value(s). If multiple operations are specified, must be a Sequence - of test values. - reference_values : Any - The value(s) to compare the test value(s) to. If multiple operations are - specified, must be a Sequence of reference values. - operations : str - One or more valid operations in VALID_OPERATIONS. - For example, if operations = "<=", this checks - `current_values <= reference_values` - (note the order of values). - - Or, if operations == ["<=", ">"], this checks - ``` - ( - (current_values[0] <= reference_values[0]) - and (current_values[1] > reference_values[1]) + func = None + func_from_potcar = None + if self.user_input.potcar: + func_from_potcar = {"pe": "pbe", "ca": "lda"}.get(self.user_input.potcar[0].lexch.lower()) + elif self.vasprun and self.vasprun.potcar_symbols: + pot_func = self.vasprun.potcar_symbols[0].split()[0].split("_")[-1] + func_from_potcar = "pbe" if pot_func == "PBE" else "lda" + + if gga := self.user_input.incar.get("GGA"): + if gga.lower() == "pe": + func = "pbe" + elif gga.lower() == "ps": + func = "pbesol" + else: + func = gga.lower() + + if (metagga := self.user_input.incar.get("METAGGA")) and metagga.lower() != "none": + if gga: + raise ValidationError( + "Both the GGA and METAGGA tags were set, which can lead to large errors. " + "For context, see:\n" + "https://github.com/materialsproject/atomate2/issues/453#issuecomment-1699605867" + ) + if metagga.lower() == "scan": + func = "scan" + elif metagga.lower().startswith("r2sca"): + func = "r2scan" + else: + func = metagga.lower() + + if self.user_input.incar.get("LHFCALC", False): + if (func == "pbe" or func_from_potcar == "pbe") and (self.user_input.incar.get("HFSCREEN", 0.0) > 0.0): + func = "hse06" + else: + func = None + + func = func or func_from_potcar + if func is None: + raise ValidationError( + "Currently, we only validate calculations using the following functionals:\n" + "GGA : PBE, PBEsol\n" + "meta-GGA : SCAN, r2SCAN\n" + "Hybrids: HSE06" ) - ``` - tolerance : float or None (default) - Tolerance to use in math.isclose if any of operations is "approx". Defaults - to self.tolerance. - append_comments : str or None (default) - Additional comments that may be helpful for the user to understand why - a check failed. - severity : Literal["reason", "warning"] - If a calculation fails, the severity of failure. Directs output to - either reasons or warnings. + return func + + @property + def bandgap(self) -> float | None: + """Determine the bandgap from vasprun.xml.""" + if self.vasprun: + return self.vasprun.bandgap + return None + + @cached_property + def valid_input_set(self) -> VaspInputSafe: + """ + Determine the MP-compliant input set for a calculation. + + We need only determine a rough input set here. + The precise details of the input set do not matter. """ - severity_to_list = {"reason": reasons, "warning": warnings} - - if not isinstance(operations, list): - operations = [operations] - current_values = [current_values] - reference_values = [reference_values] - - for iop in range(len(operations)): - self._check_parameter( - error_list=severity_to_list[severity], - input_tag=input_tag, - current_value=current_values[iop], - reference_value=reference_values[iop], - operation=operations[iop], - tolerance=tolerance, - append_comments=append_comments, + incar_updates: dict[str, Any] = {} + set_name: str | None = None + if self.functional == "pbe": + if self.run_type == "nonscf": + set_name = "MPNonSCFSet" + elif self.run_type == "nmr": + set_name = "MPNMRSet" + elif self.run_type == "md": + set_name = None + else: + set_name = f"MP{self.run_type.capitalize()}Set" + elif self.functional in ("pbesol", "scan", "r2scan", "hse06"): + if self.functional == "pbesol": + incar_updates["GGA"] = "PS" + elif self.functional == "scan": + incar_updates["METAGGA"] = "SCAN" + elif self.functional == "hse06": + incar_updates.update( + LHFCALC=True, + HFSCREEN=0.2, + GGA="PE", + ) + set_name = f"MPScan{self.run_type.capitalize()}Set" + + if set_name is None: + raise ValidationError( + "Could not determine a valid input set from the specified " + f"functional = {self.functional} and calculation type {self.run_type}." ) + # Note that only the *previous* bandgap informs the k-point density + vis = getattr(import_module("pymatgen.io.vasp.sets"), set_name)( + structure=self.user_input.structure, + bandgap=None, + user_incar_settings=incar_updates, + ) -@dataclass -class BaseValidator: + return VaspInputSafe.from_vasp_input_set(vis) + + +class BaseValidator(BaseModel): """ Template for validation classes. This class will check any function with the name prefix `_check_`. + `_check_*` functions should take VaspFiles, and two lists of strings + (`reasons` and `warnings`) as args: - `_check_*` functions must take no args by default: - - def _check_example(self) -> None: + def _check_example(self, vasp_files : VaspFiles, reasons : list[str], warnings : list[str]) -> None: if self.name == "whole mango": - self.reasons.append("We only accept sliced or diced mango at this time.") + reasons.append("We only accept sliced or diced mango at this time.") elif self.name == "diced mango": - self.warnings.append("We prefer sliced mango, but will accept diced mango.") - - Attrs: - reasons : list[str] - A list of error strings to update if a check fails. These are higher - severity and would deprecate a calculation. - warnings : list[str] - A list of warning strings to update if a check fails. These are lower - severity and would flag a calculation for possible review. - name : str = "Base validator class" - Name of the validator class - fast : bool = False - Whether to perform quick check. - True: stop validation if any check fails. - False: perform all checks. + warnings.append("We prefer sliced mango, but will accept diced mango.") """ - reasons: list[str] - warnings: list[str] - name: str = "Base validator class" - fast: bool = False + name: str = Field("Base validator class", description="Name of the validator class.") + vasp_defaults: dict[str, VaspParam] = Field(VASP_DEFAULTS_DICT, description="Default VASP settings.") + fast: bool = Field(False, description="Whether to perform a quick check (True) or to perform all checks (False).") - def check(self) -> None: + def auto_fail(self, vasp_files: VaspFiles, reasons: list[str], warnings: list[str]) -> bool: + """Quick stop in case none of the checks can be performed.""" + return False + + def check(self, vasp_files: VaspFiles, reasons: list[str], warnings: list[str]) -> None: """ - Execute any checks on the class with a name prefix `_check_`. + Execute all methods on the class with a name prefix `_check_`. - See class docstr for an example. + Parameters + ----------- + reasons : VaspFiles + A set of required and optional VASP input and output objects. + reasons : list of str + A list of errors to update if a check fails. These are higher + severity and would deprecate a calculation. + warnings : list of str + A list of warnings to update if a check fails. These are lower + severity and would flag a calculation for possible review. """ + if self.auto_fail(vasp_files, reasons, warnings): + return + checklist = {attr for attr in dir(self) if attr.startswith("_check_")} for attr in checklist: - if self.fast and len(self.reasons) > 0: + if self.fast and len(reasons) > 0: # fast check: stop checking whenever a single check fails break - getattr(self, attr)() + getattr(self, attr)(vasp_files, reasons, warnings) diff --git a/pymatgen/io/validation/compare_to_MP_ehull.py b/pymatgen/io/validation/compare_to_MP_ehull.py index e5b2bcf..eb0d157 100644 --- a/pymatgen/io/validation/compare_to_MP_ehull.py +++ b/pymatgen/io/validation/compare_to_MP_ehull.py @@ -1,6 +1,6 @@ """Module for checking if a structure's energy is within a certain distance of the MPDB hull""" -from mp_api.client import MPRester +from mp_api.client import MPRester # type: ignore[import-untyped] from pymatgen.analysis.phase_diagram import PhaseDiagram from pymatgen.entries.mixing_scheme import MaterialsProjectDFTMixingScheme from pymatgen.entries.computed_entries import ComputedStructureEntry diff --git a/pymatgen/io/validation/py.typed b/pymatgen/io/validation/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/pymatgen/io/validation/settings.py b/pymatgen/io/validation/settings.py index 2d12951..6806a43 100644 --- a/pymatgen/io/validation/settings.py +++ b/pymatgen/io/validation/settings.py @@ -95,6 +95,10 @@ class IOValidationSettings(BaseSettings): description="Number of ionic steps to average over when validating drift forces", ) + VASP_MAX_POSITIVE_ENERGY: float = Field( + 50.0, description="Maximum allowable positive energy at the end of a calculation." + ) + model_config = SettingsConfigDict(env_prefix="pymatgen_io_validation_", extra="ignore") FAST_VALIDATION: bool = Field( diff --git a/pymatgen/io/validation/validation.py b/pymatgen/io/validation/validation.py index 9972825..56e7e31 100644 --- a/pymatgen/io/validation/validation.py +++ b/pymatgen/io/validation/validation.py @@ -1,372 +1,173 @@ -"""Validate VASP calculations using emmet.""" +"""Define core validation schema.""" from __future__ import annotations - -from datetime import datetime -from pydantic import Field -from pydantic.types import ImportString # replacement for PyObject from pathlib import Path +from pydantic import BaseModel, Field, PrivateAttr +from typing import TYPE_CHECKING -from pymatgen.io.vasp.sets import VaspInputSet - -# TODO: AK: why MPMetalRelaxSet -# TODO: MK: because more kpoints are needed for metals given the more complicated Fermi surfaces, and MPMetalRelaxSet uses more kpoints -from pymatgen.io.vasp.sets import MPMetalRelaxSet +from monty.os.path import zpath -from emmet.core.tasks import TaskDoc -from emmet.core.vasp.task_valid import TaskDocument -from emmet.core.base import EmmetBaseModel -from emmet.core.mpid import MPID -from emmet.core.utils import jsanitize -from emmet.core.vasp.calc_types.enums import CalcType, TaskType -from emmet.core.vasp.calc_types import ( - RunType, - calc_type as emmet_calc_type, - run_type as emmet_run_type, - task_type as emmet_task_type, -) -from pymatgen.core import Structure -from pymatgen.io.validation.check_incar import CheckIncar -from pymatgen.io.validation.check_common_errors import ( - CheckCommonErrors, - CheckVaspVersion, - CheckStructureProperties, -) +from pymatgen.io.validation.common import VaspFiles +from pymatgen.io.validation.check_common_errors import CheckStructureProperties, CheckCommonErrors from pymatgen.io.validation.check_kpoints_kspacing import CheckKpointsKspacing from pymatgen.io.validation.check_potcar import CheckPotcar -from pymatgen.io.validation.settings import IOValidationSettings -from pymatgen.io.validation.vasp_defaults import VASP_DEFAULTS_DICT - -from typing import Optional, TYPE_CHECKING +from pymatgen.io.validation.check_incar import CheckIncar if TYPE_CHECKING: - from typing import Any + from collections.abc import Mapping + import os + from typing_extensions import Self + -SETTINGS = IOValidationSettings() +DEFAULT_CHECKS = [CheckStructureProperties, CheckPotcar, CheckCommonErrors, CheckKpointsKspacing, CheckIncar] # TODO: check for surface/slab calculations. Especially necessary for external calcs. # TODO: implement check to make sure calcs are within some amount (e.g. 250 meV) of the convex hull in the MPDB -class ValidationDoc(EmmetBaseModel): - """ - Validation document for a VASP calculation - """ +class VaspValidator(BaseModel): + """Validate a VASP calculation.""" - task_id: Optional[MPID] = Field(None, description="The task_id for this validation document") + vasp_files: VaspFiles = Field(description="The VASP I/O.") + reasons: list[str] = Field([], description="List of deprecation tags detailing why this task isn't valid") + warnings: list[str] = Field([], description="List of warnings about this calculation") - valid: bool = Field(False, description="Whether this task is valid or not") + _validated_md5: str | None = PrivateAttr(None) - last_updated: datetime = Field( - description="Last updated date for this document", - default_factory=datetime.utcnow, - ) + @property + def valid(self) -> bool: + """Determine if the calculation is valid after ensuring inputs have not changed.""" + self.recheck() + return len(self.reasons) == 0 - reasons: list[str] = Field(None, description="List of deprecation tags detailing why this task isn't valid") + @property + def has_warnings(self) -> bool: + """Determine if any warnings were incurred.""" + return len(self.warnings) > 0 - warnings: list[str] = Field([], description="List of potential warnings about this calculation") + def recheck(self) -> None: + """Rerun validation, prioritizing speed.""" + new_md5 = None + if self._validated_md5 is None or (new_md5 := self.vasp_files.md5) != self._validated_md5: - # data: Dict = Field( - # description="Dictionary of data used to perform validation." - # " Useful for post-mortem analysis" - # ) - - def model_post_init(self, context: Any) -> None: - """ - Optionally check whether package versions are up to date with PyPI. + if self.vasp_files.user_input.potcar: + check_list = DEFAULT_CHECKS + else: + check_list = [c for c in DEFAULT_CHECKS if c.__name__ != "CheckPotcar"] + self.reasons, self.warnings = self.run_checks(self.vasp_files, check_list=check_list, fast=True) + self._validated_md5 = new_md5 or self.vasp_files.md5 + + @staticmethod + def run_checks( + vasp_files: VaspFiles, + check_list: list | tuple = DEFAULT_CHECKS, + fast: bool = False, + ) -> tuple[list[str], list[str]]: + """Perform validation. Parameters ----------- - context : .Any - Has no effect at present, kept to retain structure of pydantic .BaseModel - """ - - self.valid = len(self.reasons) == 0 - - class Config: # noqa - extra = "allow" - - @classmethod - def from_task_doc(cls, task_doc: TaskDoc | TaskDocument, **kwargs) -> ValidationDoc: - """ - Assess if a calculation is valid based on a pymatgen input set. - - Args: - task_doc: the task document to process - Possible kwargs for `from_dict` method: - input_sets: a dictionary of task_types -> pymatgen input set for validation - potcar_summary_stats: Dictionary of potcar summary data. Mapping is calculation type -> potcar symbol -> summary data. - kpts_tolerance: the tolerance to allow kpts to lag behind the input set settings - allow_kpoint_shifts: Whether to consider a task valid if kpoints are shifted by the user - allow_explicit_kpoint_mesh: Whether to consider a task valid if the user defines an explicit kpoint mesh - fft_grid_tolerance: Relative tolerance for FFT grid parameters to still be a valid - num_ionic_steps_to_avg_drift_over: Number of ionic steps to average over when validating drift forces - max_allowed_scf_gradient: maximum uphill gradient allowed for SCF steps after the - initial equilibriation period. Note this is in eV/atom. - fast : whether to stop validation when any check fails + vasp_files : VaspFiles + The VASP I/O to validate. + check_list : list or tuple of BaseValidator. + The list of checks to perform. Defaults to `DEFAULT_CHECKS`. + fast : bool (default = False) + Whether to stop validation at the first validation failure (True) + or compile a list of all failure reasons. + + Returns + ----------- + tuple of list of str + The first list are all reasons for validation failure, + the second list contains all warnings. """ - - if isinstance(task_doc, TaskDocument): - task_doc = TaskDoc(**{k: v for k, v in task_doc.model_dump().items() if k != "run_stats"}) - - return cls.from_dict(jsanitize(task_doc), **kwargs) + reasons: list[str] = [] + warnings: list[str] = [] + for check in check_list: + check(fast=fast).check(vasp_files, reasons, warnings) # type: ignore[arg-type] + if fast and len(reasons) > 0: + break + return reasons, warnings @classmethod - def from_dict( + def from_vasp_input( cls, - task_doc: dict, - input_sets: dict[str, ImportString] = SETTINGS.VASP_DEFAULT_INPUT_SETS, + vasp_file_paths: Mapping[str, str | Path | os.PathLike[str]] | None = None, + vasp_files: VaspFiles | None = None, + fast: bool = False, check_potcar: bool = True, - kpts_tolerance: float = SETTINGS.VASP_KPTS_TOLERANCE, - allow_kpoint_shifts: bool = SETTINGS.VASP_ALLOW_KPT_SHIFT, - allow_explicit_kpoint_mesh: str | bool = SETTINGS.VASP_ALLOW_EXPLICIT_KPT_MESH, - fft_grid_tolerance: float = SETTINGS.VASP_FFT_GRID_TOLERANCE, - num_ionic_steps_to_avg_drift_over: int = SETTINGS.VASP_NUM_IONIC_STEPS_FOR_DRIFT, - max_allowed_scf_gradient: float = SETTINGS.VASP_MAX_SCF_GRADIENT, - fast: bool = SETTINGS.FAST_VALIDATION, - ) -> ValidationDoc: + **kwargs, + ) -> Self: """ - Determines if a calculation is valid based on expected input parameters from a pymatgen inputset + Validate a VASP calculation from VASP files or their object representation. - Args: - task_doc: the task document to process - Kwargs: - input_sets: a dictionary of task_types -> pymatgen input set for validation - potcar_summary_stats: Dictionary of potcar summary data. Mapping is calculation type -> potcar symbol -> summary data. - kpts_tolerance: the tolerance to allow kpts to lag behind the input set settings - allow_kpoint_shifts: Whether to consider a task valid if kpoints are shifted by the user - allow_explicit_kpoint_mesh: Whether to consider a task valid if the user defines an explicit kpoint mesh - fft_grid_tolerance: Relative tolerance for FFT grid parameters to still be a valid - num_ionic_steps_to_avg_drift_over: Number of ionic steps to average over when validating drift forces - max_allowed_scf_gradient: maximum uphill gradient allowed for SCF steps after the - initial equillibriation period. Note this is in eV per atom. - fast : whether to stop validation when any check fails + Parameters + ----------- + vasp_file_paths : dict of str to os.PathLike, optional + If specified, a dict of the form: + { + "incar": < path to INCAR>, + "poscar": < path to POSCAR>, + ... + } + where keys are taken by `VaspFiles.from_paths`. + vasp_files : VaspFiles, optional + This takes higher precendence than `vasp_file_paths`, and + allows the user to specify VASP input/output from a VaspFiles + object. + fast : bool (default = False) + Whether to stop validation at the first failure (True) + or to list all reasons why a calculation failed (False) + check_potcar : bool (default = True) + Whether to check the POTCAR for validity. + **kwargs + kwargs to pass to `VaspValidator` """ - bandgap = task_doc["output"]["bandgap"] - calcs_reversed = task_doc["calcs_reversed"] - - # used for most input tag checks (as this is more reliable than examining the INCAR file directly in most cases) - parameters = task_doc["input"]["parameters"] + if vasp_files: + vf: VaspFiles = vasp_files + elif vasp_file_paths: + vf = VaspFiles.from_paths(**vasp_file_paths) - # used for INCAR tag checks where you need to look at the actual INCAR (semi-rare) - incar = calcs_reversed[0]["input"]["incar"] - - orig_inputs = {} if (task_doc["orig_inputs"] is None) else task_doc["orig_inputs"] - - cls_kwargs: dict[str, Any] = { - "task_id": task_doc["task_id"] if task_doc["task_id"] else None, - "calc_type": _get_calc_type(calcs_reversed, orig_inputs), - "task_type": _get_task_type(calcs_reversed, orig_inputs), - "run_type": _get_run_type(calcs_reversed), + config: dict[str, list[str]] = { "reasons": [], "warnings": [], } - vasp_version = [int(x) for x in calcs_reversed[0]["vasp_version"].split(".")[:3]] - CheckVaspVersion( - reasons=cls_kwargs["reasons"], - warnings=cls_kwargs["warnings"], - vasp_version=vasp_version, - parameters=parameters, - incar=incar, - defaults=VASP_DEFAULTS_DICT, - fast=fast, - ).check() - - CheckStructureProperties( - **{k: cls_kwargs[k] for k in ("reasons", "warnings", "task_type")}, - fast=fast, - structures=[ - task_doc["input"]["structure"], - task_doc["output"]["structure"], - task_doc["calcs_reversed"][0]["output"]["structure"], - ], - ).check() - - if len(cls_kwargs["reasons"]) > 0 and fast: - return cls(**cls_kwargs) - - if allow_explicit_kpoint_mesh == "auto": - allow_explicit_kpoint_mesh = True if "NSCF" in cls_kwargs["calc_type"].name else False - - if calcs_reversed[0].get("input", {}).get("structure", None): - structure = calcs_reversed[0]["input"]["structure"] + if check_potcar: + check_list = DEFAULT_CHECKS else: - structure = task_doc["input"]["structure"] or task_doc["output"]["structure"] - structure = Structure.from_dict(structure) - - try: - valid_input_set = _get_input_set( - cls_kwargs["run_type"], - cls_kwargs["task_type"], - cls_kwargs["calc_type"], - structure, - input_sets, - bandgap, - ) - except Exception as e: - cls_kwargs["reasons"].append( - "NO MATCHING MP INPUT SET --> no matching MP input set was found. If you believe this to be a mistake, please create a GitHub issue." - ) - valid_input_set = None - print(f"Error while finding MP input set: {e}.") + check_list = [c for c in DEFAULT_CHECKS if c.__name__ != "CheckPotcar"] - if valid_input_set: - # Tests ordered by expected computational burden - help optimize `fast` check - # Intuitively, more important checks (INCAR, KPOINTS, and POTCAR settings) would come first - # But to optimize speed in fast mode (relevant for validating a large batch of calculations) - # the faster checks have to come first: - # 1. VASP version - # 2. Common errors (known bugs in VASP, erratic SCF convergence, etc.) - # 3. KPOINTS or KSPACING (from INCAR) - # 4. INCAR (many sequential checks of possible INCAR tags + updating defaults) - - # TODO: check for surface/slab calculations!!!!!! - - CheckCommonErrors( - reasons=cls_kwargs["reasons"], - warnings=cls_kwargs["warnings"], - task_doc=task_doc, - parameters=parameters, - structure=structure, - run_type=cls_kwargs["run_type"], - fast=fast, - defaults=VASP_DEFAULTS_DICT, - valid_max_allowed_scf_gradient=max_allowed_scf_gradient, - num_ionic_steps_to_avg_drift_over=num_ionic_steps_to_avg_drift_over, - ).check() - - CheckKpointsKspacing( - reasons=cls_kwargs["reasons"], - warnings=cls_kwargs["warnings"], - valid_input_set=valid_input_set, - kpoints=calcs_reversed[0]["input"]["kpoints"], - structure=structure, - defaults=VASP_DEFAULTS_DICT, - kpts_tolerance=kpts_tolerance, - allow_explicit_kpoint_mesh=allow_explicit_kpoint_mesh, - allow_kpoint_shifts=allow_kpoint_shifts, - fast=fast, - ).check() - - if check_potcar: - CheckPotcar( - reasons=cls_kwargs["reasons"], - warnings=cls_kwargs["warnings"], - valid_input_set=valid_input_set, - structure=structure, - potcars=calcs_reversed[0]["input"]["potcar_spec"], - fast=fast, - ).check() - - CheckIncar( - reasons=cls_kwargs["reasons"], - warnings=cls_kwargs["warnings"], - valid_input_set=valid_input_set, - task_doc=task_doc, - parameters=parameters, - structure=structure, - vasp_version=vasp_version, - task_type=cls_kwargs["task_type"], - defaults=VASP_DEFAULTS_DICT, - fft_grid_tolerance=fft_grid_tolerance, - fast=fast, - ).check() - - return cls(**cls_kwargs) + config["reasons"], config["warnings"] = cls.run_checks(vf, check_list=check_list, fast=fast) + validated = cls(**config, vasp_files=vf, **kwargs) + validated._validated_md5 = vf.md5 + return validated @classmethod - def from_directory(cls, dir_name: Path | str, **kwargs) -> ValidationDoc: - """ - Determines if a calculation is valid based on expected input parameters from a pymatgen inputset - - Args: - dir_name: the directory containing the calculation files to process - Possible kwargs for `from_dict` method: - input_sets: a dictionary of task_types -> pymatgen input set for validation - check_potcar: Whether to check POTCARs against known libraries. - kpts_tolerance: the tolerance to allow kpts to lag behind the input set settings - allow_kpoint_shifts: Whether to consider a task valid if kpoints are shifted by the user - allow_explicit_kpoint_mesh: Whether to consider a task valid if the user defines an explicit kpoint mesh - fft_grid_tolerance: Relative tolerance for FFT grid parameters to still be a valid - num_ionic_steps_to_avg_drift_over: Number of ionic steps to average over when validating drift forces - max_allowed_scf_gradient: maximum uphill gradient allowed for SCF steps after the - initial equillibriation period. Note this is in eV per atom. - """ - try: - task_doc = TaskDoc.from_directory( - dir_name=dir_name, - volumetric_files=(), - ) - - return cls.from_task_doc(task_doc=task_doc, **kwargs) - - except Exception as e: - if "no vasp files found" in str(e).lower(): - raise Exception(f"NO CALCULATION FOUND --> {dir_name} is not a VASP calculation directory.") - else: - raise Exception( - f"CANNOT PARSE CALCULATION --> Issue parsing results. This often means your calculation did not complete. The error stack reads: \n {e}" - ) - - -def _get_input_set(run_type, task_type, calc_type, structure, input_sets, bandgap): - # TODO: For every input set key in emmet.core.settings.VASP_DEFAULT_INPUT_SETS, - # with "GGA" in it, create an equivalent dictionary item with "PBE" instead. - # In the mean time, the below workaround is used. - gga_pbe_structure_opt_calc_types = [ - CalcType.GGA_Structure_Optimization, - CalcType.GGA_U_Structure_Optimization, - CalcType.PBE_Structure_Optimization, - CalcType.PBE_U_Structure_Optimization, - ] + def from_directory(cls, dir_name: str | Path, **kwargs) -> Self: + """Convenience method to validate a calculation from a directory. - # Ensure input sets get proper additional input values - if "SCAN" in run_type.value: - valid_input_set: VaspInputSet = input_sets[str(calc_type)](structure, bandgap=bandgap) # type: ignore + This method is intended solely for use cases where VASP input/output + files are not renamed, beyond the compression methods supported by + monty.os.zpath. - elif task_type == TaskType.NSCF_Uniform: - valid_input_set = input_sets[str(calc_type)](structure, mode="uniform") - elif task_type == TaskType.NSCF_Line: - valid_input_set = input_sets[str(calc_type)](structure, mode="line") + Thus, INCAR, INCAR.gz, INCAR.bz2, INCAR.lzma are all acceptable, but + INCAR.relax1.gz is not. - elif "dielectric" in str(task_type).lower(): - valid_input_set = input_sets[str(calc_type)](structure, lepsilon=True) + For finer-grained control of which files are validated, explicitly + pass file names to `VaspValidator.from_vasp_input`. - elif task_type == TaskType.NMR_Electric_Field_Gradient: - valid_input_set = input_sets[str(calc_type)](structure, mode="efg") - elif task_type == TaskType.NMR_Nuclear_Shielding: - valid_input_set = input_sets[str(calc_type)]( - structure, mode="cs" - ) # Is this correct? Someone more knowledgeable either fix this or remove this comment if it is correct please! - - elif calc_type in gga_pbe_structure_opt_calc_types: - if bandgap == 0: - valid_input_set = MPMetalRelaxSet(structure) - else: - valid_input_set = input_sets[str(calc_type)](structure) - - else: - valid_input_set = input_sets[str(calc_type)](structure) - - return valid_input_set - - -def _get_run_type(calcs_reversed) -> RunType: - params = calcs_reversed[0].get("input", {}).get("parameters", {}) - incar = calcs_reversed[0].get("input", {}).get("incar", {}) - return emmet_run_type({**params, **incar}) - - -def _get_task_type(calcs_reversed, orig_inputs): - inputs = calcs_reversed[0].get("input", {}) if len(calcs_reversed) > 0 else orig_inputs - return emmet_task_type(inputs) - - -def _get_calc_type(calcs_reversed, orig_inputs): - inputs = calcs_reversed[0].get("input", {}) if len(calcs_reversed) > 0 else orig_inputs - params = calcs_reversed[0].get("input", {}).get("parameters", {}) - incar = calcs_reversed[0].get("input", {}).get("incar", {}) - - return emmet_calc_type(inputs, {**params, **incar}) + Parameters + ----------- + dir_name : str or Path + The path to the calculation directory. + **kwargs + kwargs to pass to `VaspValidator` + """ + dir_name = Path(dir_name) + vasp_file_paths = {} + for file_name in ("INCAR", "KPOINTS", "POSCAR", "POTCAR", "OUTCAR", "vasprun.xml"): + if (file_path := Path(zpath(str(dir_name / file_name)))).exists(): + vasp_file_paths[file_name.lower().split(".")[0]] = file_path + return cls.from_vasp_input(vasp_file_paths=vasp_file_paths, **kwargs) diff --git a/pymatgen/io/validation/vasp_defaults.py b/pymatgen/io/validation/vasp_defaults.py index 4b4f783..3ff8e27 100644 --- a/pymatgen/io/validation/vasp_defaults.py +++ b/pymatgen/io/validation/vasp_defaults.py @@ -1,13 +1,36 @@ """Define VASP defaults and input categories to check.""" from __future__ import annotations -from typing import TYPE_CHECKING +from typing import Any, Literal, Optional +import math +from pathlib import Path +from pydantic import BaseModel, Field, field_validator from enum import Enum -from pymatgen.io.validation.common import VALID_OPERATIONS, InvalidOperation +VALID_OPERATIONS: set[str | None] = { + "==", + ">", + ">=", + "<", + "<=", + "in", + "approx", + "auto fail", + None, +} -if TYPE_CHECKING: - from typing import Any, Literal, Sequence + +class InvalidOperation(Exception): + """Define custom exception when checking valid operations.""" + + def __init__(self, operation: str) -> None: + """Define custom exception when checking valid operations. + + Args: + operation (str) : a symbolic string for an operation that is not valid. + """ + msg = f"Unknown operation type {operation}; valid values are: {VALID_OPERATIONS}" + super().__init__(msg) class InputCategory(Enum): @@ -37,83 +60,56 @@ class InputCategory(Enum): write = "write" -class VaspParam: +class VaspParam(BaseModel): """Define a schema for validating VASP parameters.""" - __slots__: tuple[str, ...] = ( - "name", - "value", - "operation", - "alias", - "tag", - "tolerance", - "comment", - "warning", - "severity", + name: str = Field(description="The name of the INCAR keyword") + value: Any = Field( + description="The default value of this parameter if statically assigned by VASP. If this parameter is dynamically assigned by VASP, set the default to None." + ) + tag: str = Field( + description="the general category of input the tag belongs to. Used only to properly update INCAR fields in the same way VASP does." + ) + operation: Optional[str | list[str] | tuple[str]] = Field( + None, description="One or more of VALID_OPERATIONS to apply in validating this parameter." + ) + alias: Optional[str] = Field( + None, + description="If a str, an alternate name for a parameter to use when reporting invalid values, e.g., ENMAX instead of ENCUT.", ) + tolerance: float = Field(1e-4, description="The tolerance used when evaluating approximate float equality.") + comment: Optional[str] = Field(None, description="Additional information to pass to the user if a check fails.") + warning: Optional[str] = Field(None, description="Additional warnings to pass to the user if a check fails.") + severity: Literal["reason", "warning"] = Field("reason", description="The severity of failing this check.") - def __init__( - self, - name: str, - value: Any, - tag: InputCategory | str, - operation: str | Sequence[str] | None = None, - alias: str | None = None, - tolerance: float = 1.0e-4, - comment: str | None = None, - warning: str | None = None, - severity: Literal["reason", "warning"] = "reason", - ) -> None: - """ - Define a schema for validating VASP parameters. + @staticmethod + def listify(val: Any) -> list[Any]: + """Return scalars as list of single scalar. - Args: - name (str) : the name of the INCAR keyword - value (Any) : the default value of this parameter if - statically assigned by VASP. If this parameter is dynamically assigned - by VASP, set the default to None. - tag (InputCategory, str) : the general category of input the tag belongs to. - Used only to properly update INCAR fields in the same way VASP does. - operation : str, Sequence of str, or None - Mathematical operation used to determine if an input value is valid. - See VALID_OPERATIONS for a list of possible operators. - If a single str, this specifies one operation. - Can be a list of valid operations. - alias : str or None - If a str, an alternate name for a parameter to use when reporting - invalid values. A good example is ENCUT, which is set by the - user, but is overwritten to ENMAX in the vasprun.xml parameters. - In this case, `name = "ENMAX"` but `alias = "ENCUT"` to be informative. - If None, it is set to `name`. - tolerance : float, default = 1.e-4 - The tolerance used when evaluating approximate float equality. - commment : str or None - Additional information to pass to the user if a check fails. + Parameters + ----------- + val (Any) : scalar or vector-like + + Returns + ----------- + list containing val if val was a scalar, + otherwise the list version of val. """ - self.name = name - self.value = value - if (isinstance(operation, str) and operation not in VALID_OPERATIONS) or ( - isinstance(operation, list | tuple) and any(op not in VALID_OPERATIONS for op in operation) - ): - if isinstance(operation, list | tuple): - operation = f"[{', '.join(operation)}]" - raise InvalidOperation(operation) + if hasattr(val, "__len__"): + if isinstance(val, str): + return [val] + return list(val) + return [val] + + @field_validator("operation", mode="after") + @classmethod + def set_operation(cls, v): + """Check operations.""" - self.operation = operation - self.alias = alias or name - if isinstance(tag, str): - if tag in InputCategory.__members__: - tag = InputCategory[tag] - else: - tag = InputCategory(tag) - self.tag = tag.name - self.tolerance = tolerance - self.comment = comment - self.warning = warning - - if severity not in {"reason", "warning"}: - raise ValueError(f"`severity` must either be 'reason' or 'warning', not {severity}") - self.severity = severity + list_v = cls.listify(v) + if not all(v in VALID_OPERATIONS for v in list_v): + raise InvalidOperation(f"[{', '.join(v for v in list_v if v not in VALID_OPERATIONS)}]") + return v def __getitem__(self, name: str) -> Any: """Make attributes subscriptable.""" @@ -128,112 +124,1066 @@ def update(self, dct: dict[str, Any]) -> None: for k, v in dct.items(): self[k] = v - def as_dict(self) -> dict[str, Any]: - """Convert to a dict.""" - return {k: getattr(self, k) for k in self.__slots__} + @staticmethod + def _comparator(lhs: Any, operation: str | None, rhs: Any, **kwargs) -> bool: + """ + Compare different values using one of VALID_OPERATIONS. + + Parameters + ----------- + lhs : Any + Left-hand side of the operation. + operation : str or None + Operation acting on rhs from lhs. For example, if operation is ">", + this returns (lhs > rhs). + Check is skipped if operation is None + rhs : Any + Right-hand of the operation. + kwargs + If needed, kwargs to pass to operation. + """ + if operation is None: + c = True + elif operation == "auto fail": + c = False + elif operation == "==": + c = lhs == rhs + elif operation == ">": + c = lhs > rhs + elif operation == ">=": + c = lhs >= rhs + elif operation == "<": + c = lhs < rhs + elif operation == "<=": + c = lhs <= rhs + elif operation == "in": + c = lhs in rhs + elif operation == "approx": + c = math.isclose(lhs, rhs, **kwargs) + else: + raise InvalidOperation(operation) + return c + + def check( + self, + current_values: Any, + reference_values: Any, + ) -> dict[str, list[str]]: + """ + Determine validity of parameter according to one or more operations. + + Parameters + ----------- + current_values : Any + The test value(s). If multiple operations are specified, must be a Sequence + of test values. + reference_values : Any + The value(s) to compare the test value(s) to. If multiple operations are + specified, must be a Sequence of reference values. + """ + + checks: dict[str, list[str]] = {self.severity: []} + + if not isinstance(self.operation, list | tuple): + operations: list[str | None] = [self.operation] + current_values = [current_values] + reference_values = [reference_values] + else: + operations = list(self.operation) + + for iop, operation in enumerate(operations): + + cval = current_values[iop] + if isinstance(cval, str): + cval = cval.upper() + + kwargs: dict[str, Any] = {} + if operation == "approx" and isinstance(cval, float): + kwargs.update({"rel_tol": self.tolerance, "abs_tol": 0.0}) + valid_value = self._comparator(cval, operation, reference_values[iop], **kwargs) + + if not valid_value: + comment_str = ( + f"INPUT SETTINGS --> {self.alias or self.name}: is {cval}, but should be " + f"{'' if operation == 'auto fail' else f'{operation} '}{reference_values[iop]}." + ) + if self.comment: + comment_str += f"{' ' if len(self.comment) > 0 else ''}{self.comment}" + checks[self.severity].append(comment_str) + return checks + + +def _make_pythonic_defaults(config_path: str | Path | None = None) -> str: + """Rerun this to regenerate VASP_DEFAULTS_LIST.""" + + from monty.serialization import loadfn + + def format_val(val: Any) -> Any: + if isinstance(val, str): + return f'"{val}"' + elif isinstance(val, float) and math.isinf(val): + return 'float("inf")' + return val + + config_path = config_path or Path(__file__).parent / "vasp_defaults.yaml" + config = loadfn(config_path) + + return ( + "VASP_DEFAULTS_LIST = [\n" + + ",\n".join( + [f" VaspParam({', '.join(f'{k} = {format_val(v)}' for k, v in param.items())})" for param in config] + ) + + "\n]" + ) VASP_DEFAULTS_LIST = [ - VaspParam("ADDGRID", False, "fft", operation="=="), - VaspParam("AEXX", 0.0, "hybrid", tolerance=0.0001), - VaspParam("AGGAC", 1.0, "hybrid", tolerance=0.0001), - VaspParam("AGGAX", 1.0, "hybrid", tolerance=0.0001), - VaspParam("ALDAC", 1.0, "hybrid", tolerance=0.0001), - VaspParam("ALDAX", 1.0, "hybrid", tolerance=0.0001), - VaspParam("ALGO", "normal", "electronic self consistency"), - VaspParam("AMGGAC", 1.0, "hybrid", tolerance=0.0001), - VaspParam("AMGGAX", 1.0, "hybrid", tolerance=0.0001), - VaspParam( - "DEPER", - 0.3, - "misc", + VaspParam( + name="ADDGRID", + value=False, + operation="==", + alias="ADDGRID", + tag="fft", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="AEXX", + value=0.0, + operation=None, + alias="AEXX", + tag="hybrid", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="AGGAC", + value=1.0, + operation=None, + alias="AGGAC", + tag="hybrid", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="AGGAX", + value=1.0, + operation=None, + alias="AGGAX", + tag="hybrid", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="ALDAC", + value=1.0, + operation=None, + alias="ALDAC", + tag="hybrid", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="ALDAX", + value=1.0, + operation=None, + alias="ALDAX", + tag="hybrid", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="ALGO", + value="normal", + operation=None, + alias="ALGO", + tag="electronic_self_consistency", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="AMGGAC", + value=1.0, + operation=None, + alias="AMGGAC", + tag="hybrid", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="AMGGAX", + value=1.0, + operation=None, + alias="AMGGAX", + tag="hybrid", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="DEPER", + value=0.3, operation="==", + alias="DEPER", + tag="misc", + tolerance=0.0001, comment="According to the VASP manual, DEPER should not be set by the user.", + warning=None, + severity="reason", + ), + VaspParam( + name="EBREAK", + value=None, + operation=None, + alias="EBREAK", + tag="post_init", + tolerance=0.0001, + comment="According to the VASP manual, EBREAK should not be set by the user.", + warning=None, + severity="reason", ), VaspParam( - "EBREAK", None, "post init", comment="According to the VASP manual, EBREAK should not be set by the user." - ), - VaspParam("EDIFF", 0.0001, "electronic", operation="<="), - VaspParam("EFERMI", "LEGACY", "misc special"), - VaspParam("EFIELD", 0.0, "dipole", operation="=="), - VaspParam("ENAUG", float("inf"), "electronic"), - VaspParam("ENINI", 0, "electronic"), - VaspParam("ENMAX", float("inf"), "fft", operation=">=", alias="ENCUT"), - VaspParam("EPSILON", 1.0, "dipole", operation="=="), - VaspParam("GGA_COMPAT", True, "misc", operation="=="), - VaspParam("IALGO", 38, "misc special", operation="in"), - VaspParam("IBRION", 0, "ionic", operation="in"), - VaspParam("ICHARG", 2, "startup"), - VaspParam("ICORELEVEL", 0, "misc", operation="=="), - VaspParam("IDIPOL", 0, "dipole", operation="=="), - VaspParam("IMAGES", 0, "misc", operation="=="), - VaspParam("INIWAV", 1, "startup", operation="=="), - VaspParam("ISIF", 2, "ionic", operation=">=", comment="ISIF values < 2 do not output the complete stress tensor."), - VaspParam("ISMEAR", 1, "smearing", operation="in"), - VaspParam("ISPIN", 1, "misc special"), - VaspParam("ISTART", 0, "startup", operation="in"), - VaspParam("ISYM", 2, "symmetry", operation="in"), - VaspParam("IVDW", 0, "misc", operation="=="), - VaspParam("IWAVPR", None, "misc special"), - VaspParam("KGAMMA", True, "k mesh"), - VaspParam("KSPACING", 0.5, "k mesh"), - VaspParam("LASPH", True, "misc", operation="=="), - VaspParam("LBERRY", False, "misc", operation="=="), - VaspParam("LCALCEPS", False, "misc", operation="=="), - VaspParam("LCALCPOL", False, "misc", operation="=="), - VaspParam("LCHIMAG", False, "chemical shift", operation="=="), - VaspParam("LCORR", True, "misc special"), - VaspParam("LDAU", False, "dft plus u"), - VaspParam("LDAUJ", [], "dft plus u"), - VaspParam("LDAUL", [], "dft plus u"), - VaspParam("LDAUTYPE", 2, "dft plus u"), - VaspParam("LDAUU", [], "dft plus u"), - VaspParam("LDIPOL", False, "dipole", operation="=="), - VaspParam("LEFG", False, "write", operation="=="), - VaspParam("LEPSILON", False, "misc", operation="=="), - VaspParam("LHFCALC", False, "hybrid"), - VaspParam("LHYPERFINE", False, "misc", operation="=="), - VaspParam("LKPOINTS_OPT", False, "misc", operation="=="), - VaspParam("LKPROJ", False, "misc", operation="=="), - VaspParam("LMAXMIX", 2, "density mixing"), - VaspParam("LMAXPAW", -100, "electronic projector", operation="=="), - VaspParam("LMAXTAU", 6, "density mixing"), - VaspParam("LMONO", False, "dipole", operation="=="), - VaspParam("LMP2LT", False, "misc", operation="=="), - VaspParam("LNMR_SYM_RED", False, "chemical shift", operation="=="), - VaspParam("LNONCOLLINEAR", False, "ncl", operation="=="), - VaspParam("LOCPROJ", "NONE", "misc", operation="=="), - VaspParam("LOPTICS", False, "tddft", operation="=="), - VaspParam("LORBIT", None, "misc special"), - VaspParam("LREAL", "false", "precision", operation="in"), - VaspParam("LRPA", False, "misc", operation="=="), - VaspParam("LSMP2LT", False, "misc", operation="=="), - VaspParam("LSORBIT", False, "ncl", operation="=="), - VaspParam("LSPECTRAL", False, "misc", operation="=="), - VaspParam("LSUBROT", False, "misc", operation="=="), - VaspParam("METAGGA", None, "dft"), - VaspParam("ML_LMLFF", False, "misc", operation="=="), - VaspParam("NELECT", None, "electronic"), - VaspParam("NELM", 60, "electronic self consistency"), - VaspParam("NELMDL", -5, "electronic self consistency"), - VaspParam("NLSPLINE", False, "electronic projector", operation="=="), - VaspParam("NSW", 0, "startup"), - VaspParam( - "NWRITE", - 2, - "write", + name="EDIFF", + value=0.0001, + operation="<=", + alias="EDIFF", + tag="electronic", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="EFERMI", + value="LEGACY", + operation=None, + alias="EFERMI", + tag="misc_special", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="EFIELD", + value=0.0, + operation="==", + alias="EFIELD", + tag="dipole", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="ENAUG", + value=float("inf"), + operation=None, + alias="ENAUG", + tag="electronic", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="ENINI", + value=0, + operation=None, + alias="ENINI", + tag="electronic", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="ENMAX", + value=float("inf"), + operation=">=", + alias="ENCUT", + tag="fft", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="EPSILON", + value=1.0, + operation="==", + alias="EPSILON", + tag="dipole", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="GGA_COMPAT", + value=True, + operation="==", + alias="GGA_COMPAT", + tag="misc", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="IALGO", + value=38, + operation="in", + alias="IALGO", + tag="misc_special", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="IBRION", + value=-1, + operation="in", + alias="IBRION", + tag="ionic", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="ICHARG", + value=2, + operation=None, + alias="ICHARG", + tag="startup", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="ICORELEVEL", + value=0, + operation="==", + alias="ICORELEVEL", + tag="misc", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="IDIPOL", + value=0, + operation="==", + alias="IDIPOL", + tag="dipole", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="IMAGES", + value=0, + operation="==", + alias="IMAGES", + tag="misc", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="INIWAV", + value=1, + operation="==", + alias="INIWAV", + tag="startup", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="ISIF", + value=2, operation=">=", + alias="ISIF", + tag="ionic", + tolerance=0.0001, + comment="ISIF values < 2 do not output the complete stress tensor.", + warning=None, + severity="reason", + ), + VaspParam( + name="ISMEAR", + value=1, + operation="in", + alias="ISMEAR", + tag="smearing", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="ISPIN", + value=1, + operation=None, + alias="ISPIN", + tag="misc_special", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="ISTART", + value=0, + operation="in", + alias="ISTART", + tag="startup", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="ISYM", + value=2, + operation="in", + alias="ISYM", + tag="symmetry", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="IVDW", + value=0, + operation="==", + alias="IVDW", + tag="misc", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="IWAVPR", + value=None, + operation=None, + alias="IWAVPR", + tag="misc_special", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="KGAMMA", + value=True, + operation=None, + alias="KGAMMA", + tag="k_mesh", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="KSPACING", + value=0.5, + operation=None, + alias="KSPACING", + tag="k_mesh", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="LASPH", + value=True, + operation="==", + alias="LASPH", + tag="misc", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="LBERRY", + value=False, + operation="==", + alias="LBERRY", + tag="misc", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="LCALCEPS", + value=False, + operation="==", + alias="LCALCEPS", + tag="misc", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="LCALCPOL", + value=False, + operation="==", + alias="LCALCPOL", + tag="misc", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="LCHIMAG", + value=False, + operation="==", + alias="LCHIMAG", + tag="chemical_shift", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="LCORR", + value=True, + operation=None, + alias="LCORR", + tag="misc_special", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="LDAU", + value=False, + operation=None, + alias="LDAU", + tag="dft_plus_u", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="LDAUJ", + value=[], + operation=None, + alias="LDAUJ", + tag="dft_plus_u", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="LDAUL", + value=[], + operation=None, + alias="LDAUL", + tag="dft_plus_u", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="LDAUTYPE", + value=2, + operation=None, + alias="LDAUTYPE", + tag="dft_plus_u", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="LDAUU", + value=[], + operation=None, + alias="LDAUU", + tag="dft_plus_u", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="LDIPOL", + value=False, + operation="==", + alias="LDIPOL", + tag="dipole", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="LEFG", + value=False, + operation="==", + alias="LEFG", + tag="write", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="LEPSILON", + value=False, + operation="==", + alias="LEPSILON", + tag="misc", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="LHFCALC", + value=False, + operation=None, + alias="LHFCALC", + tag="hybrid", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="LHYPERFINE", + value=False, + operation="==", + alias="LHYPERFINE", + tag="misc", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="LKPOINTS_OPT", + value=False, + operation="==", + alias="LKPOINTS_OPT", + tag="misc", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="LKPROJ", + value=False, + operation="==", + alias="LKPROJ", + tag="misc", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="LMAXMIX", + value=2, + operation=None, + alias="LMAXMIX", + tag="density_mixing", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="LMAXPAW", + value=-100, + operation="==", + alias="LMAXPAW", + tag="electronic_projector", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="LMAXTAU", + value=6, + operation=None, + alias="LMAXTAU", + tag="density_mixing", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="LMONO", + value=False, + operation="==", + alias="LMONO", + tag="dipole", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="LMP2LT", + value=False, + operation="==", + alias="LMP2LT", + tag="misc", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="LNMR_SYM_RED", + value=False, + operation="==", + alias="LNMR_SYM_RED", + tag="chemical_shift", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="LNONCOLLINEAR", + value=False, + operation="==", + alias="LNONCOLLINEAR", + tag="ncl", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="LOCPROJ", + value=None, + operation="==", + alias="LOCPROJ", + tag="misc", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="LOPTICS", + value=False, + operation="==", + alias="LOPTICS", + tag="tddft", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="LORBIT", + value=None, + operation=None, + alias="LORBIT", + tag="misc_special", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="LREAL", + value="false", + operation="in", + alias="LREAL", + tag="precision", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="LRPA", + value=False, + operation="==", + alias="LRPA", + tag="misc", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="LSMP2LT", + value=False, + operation="==", + alias="LSMP2LT", + tag="misc", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="LSORBIT", + value=False, + operation="==", + alias="LSORBIT", + tag="ncl", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="LSPECTRAL", + value=False, + operation="==", + alias="LSPECTRAL", + tag="misc", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="LSUBROT", + value=False, + operation="==", + alias="LSUBROT", + tag="misc", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="METAGGA", + value=None, + operation=None, + alias="METAGGA", + tag="dft", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="ML_LMLFF", + value=False, + operation="==", + alias="ML_LMLFF", + tag="misc", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="NELECT", + value=None, + operation=None, + alias="NELECT", + tag="electronic", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="NELM", + value=60, + operation=None, + alias="NELM", + tag="electronic_self_consistency", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="NELMDL", + value=-5, + operation=None, + alias="NELMDL", + tag="electronic_self_consistency", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="NLSPLINE", + value=False, + operation="==", + alias="NLSPLINE", + tag="electronic_projector", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="NSW", + value=0, + operation=None, + alias="NSW", + tag="startup", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="NWRITE", + value=2, + operation=">=", + alias="NWRITE", + tag="write", + tolerance=0.0001, comment="The specified value of NWRITE does not output all needed information.", + warning=None, + severity="reason", + ), + VaspParam( + name="POTIM", + value=0.5, + operation=None, + alias="POTIM", + tag="ionic", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="PREC", + value="NORMAL", + operation=None, + alias="PREC", + tag="precision", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="PSTRESS", + value=0.0, + operation="approx", + alias="PSTRESS", + tag="ionic", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="RWIGS", + value=[-1.0], + operation=None, + alias="RWIGS", + tag="misc_special", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="SCALEE", + value=1.0, + operation="approx", + alias="SCALEE", + tag="ionic", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="SIGMA", + value=0.2, + operation=None, + alias="SIGMA", + tag="smearing", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="SYMPREC", + value=1e-05, + operation=None, + alias="SYMPREC", + tag="symmetry", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="VCA", + value=[1.0], + operation=None, + alias="VCA", + tag="misc_special", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", + ), + VaspParam( + name="WEIMIN", + value=0.001, + operation="<=", + alias="WEIMIN", + tag="misc", + tolerance=0.0001, + comment=None, + warning=None, + severity="reason", ), - VaspParam("POTIM", 0.5, "ionic"), - VaspParam("PREC", "NORMAL", "precision"), - VaspParam("PSTRESS", 0.0, "ionic", operation="approx", tolerance=0.0001), - VaspParam("RWIGS", [-1.0], "misc special"), - VaspParam("SCALEE", 1.0, "ionic", operation="approx"), - VaspParam("SIGMA", 0.2, "smearing"), - VaspParam("SYMPREC", 1e-05, "symmetry"), - VaspParam("VCA", [1.0], "misc special"), - VaspParam("WEIMIN", 0.001, "misc", operation="<="), ] VASP_DEFAULTS_DICT = {v.name: v for v in VASP_DEFAULTS_LIST} diff --git a/pymatgen/io/validation/vasp_defaults.yaml b/pymatgen/io/validation/vasp_defaults.yaml new file mode 100644 index 0000000..f559606 --- /dev/null +++ b/pymatgen/io/validation/vasp_defaults.yaml @@ -0,0 +1,776 @@ +- name: ADDGRID + value: false + operation: == + alias: ADDGRID + tag: fft + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: AEXX + value: 0.0 + operation: + alias: AEXX + tag: hybrid + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: AGGAC + value: 1.0 + operation: + alias: AGGAC + tag: hybrid + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: AGGAX + value: 1.0 + operation: + alias: AGGAX + tag: hybrid + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: ALDAC + value: 1.0 + operation: + alias: ALDAC + tag: hybrid + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: ALDAX + value: 1.0 + operation: + alias: ALDAX + tag: hybrid + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: ALGO + value: normal + operation: + alias: ALGO + tag: electronic_self_consistency + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: AMGGAC + value: 1.0 + operation: + alias: AMGGAC + tag: hybrid + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: AMGGAX + value: 1.0 + operation: + alias: AMGGAX + tag: hybrid + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: DEPER + value: 0.3 + operation: == + alias: DEPER + tag: misc + tolerance: 0.0001 + comment: According to the VASP manual, DEPER should not be set by the user. + warning: + severity: reason +- name: EBREAK + value: + operation: + alias: EBREAK + tag: post_init + tolerance: 0.0001 + comment: According to the VASP manual, EBREAK should not be set by the user. + warning: + severity: reason +- name: EDIFF + value: 0.0001 + operation: <= + alias: EDIFF + tag: electronic + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: EFERMI + value: LEGACY + operation: + alias: EFERMI + tag: misc_special + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: EFIELD + value: 0.0 + operation: == + alias: EFIELD + tag: dipole + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: ENAUG + value: .inf + operation: + alias: ENAUG + tag: electronic + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: ENINI + value: 0 + operation: + alias: ENINI + tag: electronic + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: ENMAX + value: .inf + operation: '>=' + alias: ENCUT + tag: fft + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: EPSILON + value: 1.0 + operation: == + alias: EPSILON + tag: dipole + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: GGA_COMPAT + value: true + operation: == + alias: GGA_COMPAT + tag: misc + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: IALGO + value: 38 + operation: in + alias: IALGO + tag: misc_special + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: IBRION + value: 0 + operation: in + alias: IBRION + tag: ionic + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: ICHARG + value: 2 + operation: + alias: ICHARG + tag: startup + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: ICORELEVEL + value: 0 + operation: == + alias: ICORELEVEL + tag: misc + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: IDIPOL + value: 0 + operation: == + alias: IDIPOL + tag: dipole + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: IMAGES + value: 0 + operation: == + alias: IMAGES + tag: misc + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: INIWAV + value: 1 + operation: == + alias: INIWAV + tag: startup + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: ISIF + value: 2 + operation: '>=' + alias: ISIF + tag: ionic + tolerance: 0.0001 + comment: ISIF values < 2 do not output the complete stress tensor. + warning: + severity: reason +- name: ISMEAR + value: 1 + operation: in + alias: ISMEAR + tag: smearing + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: ISPIN + value: 1 + operation: + alias: ISPIN + tag: misc_special + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: ISTART + value: 0 + operation: in + alias: ISTART + tag: startup + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: ISYM + value: 2 + operation: in + alias: ISYM + tag: symmetry + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: IVDW + value: 0 + operation: == + alias: IVDW + tag: misc + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: IWAVPR + value: + operation: + alias: IWAVPR + tag: misc_special + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: KGAMMA + value: true + operation: + alias: KGAMMA + tag: k_mesh + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: KSPACING + value: 0.5 + operation: + alias: KSPACING + tag: k_mesh + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: LASPH + value: true + operation: == + alias: LASPH + tag: misc + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: LBERRY + value: false + operation: == + alias: LBERRY + tag: misc + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: LCALCEPS + value: false + operation: == + alias: LCALCEPS + tag: misc + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: LCALCPOL + value: false + operation: == + alias: LCALCPOL + tag: misc + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: LCHIMAG + value: false + operation: == + alias: LCHIMAG + tag: chemical_shift + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: LCORR + value: true + operation: + alias: LCORR + tag: misc_special + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: LDAU + value: false + operation: + alias: LDAU + tag: dft_plus_u + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: LDAUJ + value: [] + operation: + alias: LDAUJ + tag: dft_plus_u + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: LDAUL + value: [] + operation: + alias: LDAUL + tag: dft_plus_u + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: LDAUTYPE + value: 2 + operation: + alias: LDAUTYPE + tag: dft_plus_u + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: LDAUU + value: [] + operation: + alias: LDAUU + tag: dft_plus_u + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: LDIPOL + value: false + operation: == + alias: LDIPOL + tag: dipole + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: LEFG + value: false + operation: == + alias: LEFG + tag: write + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: LEPSILON + value: false + operation: == + alias: LEPSILON + tag: misc + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: LHFCALC + value: false + operation: + alias: LHFCALC + tag: hybrid + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: LHYPERFINE + value: false + operation: == + alias: LHYPERFINE + tag: misc + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: LKPOINTS_OPT + value: false + operation: == + alias: LKPOINTS_OPT + tag: misc + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: LKPROJ + value: false + operation: == + alias: LKPROJ + tag: misc + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: LMAXMIX + value: 2 + operation: + alias: LMAXMIX + tag: density_mixing + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: LMAXPAW + value: -100 + operation: == + alias: LMAXPAW + tag: electronic_projector + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: LMAXTAU + value: 6 + operation: + alias: LMAXTAU + tag: density_mixing + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: LMONO + value: false + operation: == + alias: LMONO + tag: dipole + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: LMP2LT + value: false + operation: == + alias: LMP2LT + tag: misc + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: LNMR_SYM_RED + value: false + operation: == + alias: LNMR_SYM_RED + tag: chemical_shift + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: LNONCOLLINEAR + value: false + operation: == + alias: LNONCOLLINEAR + tag: ncl + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: LOCPROJ + value: NONE + operation: == + alias: LOCPROJ + tag: misc + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: LOPTICS + value: false + operation: == + alias: LOPTICS + tag: tddft + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: LORBIT + value: + operation: + alias: LORBIT + tag: misc_special + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: LREAL + value: 'false' + operation: in + alias: LREAL + tag: precision + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: LRPA + value: false + operation: == + alias: LRPA + tag: misc + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: LSMP2LT + value: false + operation: == + alias: LSMP2LT + tag: misc + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: LSORBIT + value: false + operation: == + alias: LSORBIT + tag: ncl + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: LSPECTRAL + value: false + operation: == + alias: LSPECTRAL + tag: misc + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: LSUBROT + value: false + operation: == + alias: LSUBROT + tag: misc + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: METAGGA + value: + operation: + alias: METAGGA + tag: dft + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: ML_LMLFF + value: false + operation: == + alias: ML_LMLFF + tag: misc + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: NELECT + value: + operation: + alias: NELECT + tag: electronic + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: NELM + value: 60 + operation: + alias: NELM + tag: electronic_self_consistency + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: NELMDL + value: -5 + operation: + alias: NELMDL + tag: electronic_self_consistency + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: NLSPLINE + value: false + operation: == + alias: NLSPLINE + tag: electronic_projector + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: NSW + value: 0 + operation: + alias: NSW + tag: startup + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: NWRITE + value: 2 + operation: '>=' + alias: NWRITE + tag: write + tolerance: 0.0001 + comment: The specified value of NWRITE does not output all needed information. + warning: + severity: reason +- name: POTIM + value: 0.5 + operation: + alias: POTIM + tag: ionic + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: PREC + value: NORMAL + operation: + alias: PREC + tag: precision + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: PSTRESS + value: 0.0 + operation: approx + alias: PSTRESS + tag: ionic + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: RWIGS + value: + - -1.0 + operation: + alias: RWIGS + tag: misc_special + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: SCALEE + value: 1.0 + operation: approx + alias: SCALEE + tag: ionic + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: SIGMA + value: 0.2 + operation: + alias: SIGMA + tag: smearing + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: SYMPREC + value: 1e-05 + operation: + alias: SYMPREC + tag: symmetry + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: VCA + value: + - 1.0 + operation: + alias: VCA + tag: misc_special + tolerance: 0.0001 + comment: + warning: + severity: reason +- name: WEIMIN + value: 0.001 + operation: <= + alias: WEIMIN + tag: misc + tolerance: 0.0001 + comment: + warning: + severity: reason diff --git a/pyproject.toml b/pyproject.toml index c5c15d3..ac15a0d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,8 @@ dependencies = [ "pymatgen", "numpy", "requests", + "pydantic>=2.0.1", + "pydantic-settings>=2.0.0", ] description = "A comprehensive I/O validator for electronic structure calculations" dynamic = ["version"] @@ -68,7 +70,7 @@ line-length = 120 line-length = 120 [tool.flake8] -extend-ignore = "E203, W503, E501, F401, RST21" +extend-ignore = "E203, Wv503, E501, F401, RST21" max-line-length = 120 max-doc-length = 120 min-python-version = "3.8.0" @@ -76,8 +78,11 @@ rst-roles = "class, func, ref, obj" select = "C, E, F, W, B, B950" [tool.mypy] +explicit_package_bases = true +namespace_packages = true ignore_missing_imports = true no_strict_optional = true +plugins = ["pydantic.mypy"] [tool.coverage.run] branch = true diff --git a/requirements-dev.txt b/requirements-dev.txt index eee34bb..5a71bc1 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -6,3 +6,5 @@ pydocstyle==6.1.1 flake8==7.2.0 pylint==3.3.7 black==25.1.0 +pydantic==2.11.5 +pydantic-settings==2.9.1 diff --git a/requirements.txt b/requirements.txt index 80b907e..80874eb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,6 @@ -pymatgen==2024.11.13 -emmet-core==0.84.5 -pydantic==2.4.2 -pydantic-core==2.10.1 -pydantic-settings==2.2.1 +pymatgen==2025.4.17 +pydantic==2.11.5 +pydantic-settings==2.9.1 typing-extensions==4.13.2 monty==2025.3.3 numpy==1.26.1 diff --git a/setup.cfg b/setup.cfg index 2ba3912..fc9152e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -11,9 +11,6 @@ addopts = --durations=30 --quiet ignore = D105,D2,D4 match-dir=(?!(tests)).* -[mypy] -ignore_missing_imports = True - [coverage:run] omit = *tests* relative_files = True diff --git a/setup.py b/setup.py index 5e16f68..6995bea 100644 --- a/setup.py +++ b/setup.py @@ -17,8 +17,8 @@ version="0.0.3", install_requires=[ "pymatgen>=2024.5.1", - "emmet-core>=0.83.6", "pydantic>=2.4.2", + "pydantic-settings>=2.0.0", "requests>=2.28.1", ], extras_require={}, diff --git a/tests/conftest.py b/tests/conftest.py index 1970404..e7e2009 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,256 +1,30 @@ -from emmet.core.tasks import TaskDoc from pathlib import Path import pytest -_test_dir = Path(__file__).parent.joinpath("test_files").resolve() - - -@pytest.fixture(scope="session") -def test_dir(): - return _test_dir - - -def assert_schemas_equal(test_schema, valid_schema): - """ - Recursively test all items in valid_schema are present and equal in test_schema. - - While test_schema can be a pydantic schema or dictionary, the valid schema must - be a (nested) dictionary. This function automatically handles accessing the - attributes of classes in the test_schema. - - Args: - test_schema: A pydantic schema or dictionary of the schema. - valid_schema: A (nested) dictionary specifying the key and values that must be - present in test_schema. - """ - from pydantic import BaseModel - - if isinstance(valid_schema, dict): - for key, sub_valid_schema in valid_schema.items(): - if isinstance(key, str) and hasattr(test_schema, key): - sub_test_schema = getattr(test_schema, key) - elif not isinstance(test_schema, BaseModel): - sub_test_schema = test_schema[key] - else: - raise ValueError(f"{type(test_schema)} does not have field: {key}") - return assert_schemas_equal(sub_test_schema, sub_valid_schema) - - elif isinstance(valid_schema, list): - for i, sub_valid_schema in enumerate(valid_schema): - return assert_schemas_equal(test_schema[i], sub_valid_schema) +from monty.serialization import loadfn +from pymatgen.core import SETTINGS as PMG_SETTINGS - elif isinstance(valid_schema, float): - assert test_schema == pytest.approx(valid_schema) - else: - assert test_schema == valid_schema - - -class SchemaTestData: - """Dummy class to be used to contain all test data information.""" - - -class SiOptimizeDouble(SchemaTestData): - folder = "Si_old_double_relax" - task_files = { - "relax2": { - "vasprun_file": "vasprun.xml.relax2.gz", - "outcar_file": "OUTCAR.relax2.gz", - "volumetric_files": ["CHGCAR.relax2.gz"], - "contcar_file": "CONTCAR.relax2.gz", - }, - "relax1": { - "vasprun_file": "vasprun.xml.relax1.gz", - "outcar_file": "OUTCAR.relax1.gz", - "volumetric_files": ["CHGCAR.relax1.gz"], - "contcar_file": "CONTCAR.relax1.gz", - }, - } - objects = {"relax2": []} - task_doc = { - "calcs_reversed": [ - { - "output": { - "vbm": 5.6147, - "cbm": 6.2652, - "bandgap": 0.6505, - "is_gap_direct": False, - "is_metal": False, - "transition": "(0.000,0.000,0.000)-(0.375,0.375,0.000)", - "direct_gap": 2.5561, - "run_stats": { - "average_memory": 0, - "max_memory": 28096.0, - "cores": 16, - }, - }, - "input": { - "incar": {"NSW": 99}, - "nkpoints": 29, - "potcar_spec": [{"titel": "PAW_PBE Si 05Jan2001"}], - "structure": {"volume": 40.036816205493494}, - "is_hubbard": False, - "hubbards": None, - }, - } - ], - "analysis": {"delta_volume": 0.8638191769757384, "max_force": 0}, - "input": { - "structure": {"volume": 40.036816205493494}, - "potcar_spec": [{"titel": "PAW_PBE Si 05Jan2001"}], - "parameters": {"NSW": 99}, - "is_hubbard": False, - "hubbards": None, - }, - "output": { - "structure": {"volume": 40.90063538246923}, - "energy": -10.84687704, - "bandgap": 0.6505, - }, - "custodian": [{"job": {"settings_override": None, "suffix": ".relax1"}}], - "included_objects": (), - } - - -class SiNonSCFUniform(SchemaTestData): - from emmet.core.vasp.calculation import VaspObject - - folder = "Si_uniform" - task_files = { - "standard": { - "vasprun_file": "vasprun.xml.gz", - "outcar_file": "OUTCAR.gz", - "volumetric_files": ["CHGCAR.gz"], - "contcar_file": "CONTCAR.gz", - } - } - objects = {"standard": []} - task_doc = { - "calcs_reversed": [ - { - "output": { - "vbm": 5.6162, - "cbm": 6.2243, - "bandgap": 0.6103, - "is_gap_direct": False, - "is_metal": False, - "transition": "(0.000,0.000,0.000)-(0.000,0.421,0.000)", - "direct_gap": 2.5563, - "run_stats": { - "average_memory": 0, - "max_memory": 31004.0, - "cores": 16, - }, - }, - "input": { - "incar": {"NSW": 0}, - "nkpoints": 220, - "potcar_spec": [{"titel": "PAW_PBE Si 05Jan2001"}], - "structure": {"volume": 40.88829843008916}, - "is_hubbard": False, - "hubbards": None, - }, - } - ], - "analysis": {"delta_volume": 0, "max_force": 0.5350159115036506}, - "input": { - "structure": {"volume": 40.88829843008916}, - "potcar_spec": [{"titel": "PAW_PBE Si 05Jan2001"}], - "parameters": {"NSW": 0}, - "is_hubbard": False, - "hubbards": None, - }, - "output": { - "structure": {"volume": 40.88829843008916}, - "energy": -10.85064059, - "bandgap": 0.6103, - }, - "custodian": [{"job": {"settings_override": None, "suffix": ""}}], - "included_objects": (VaspObject.DOS, VaspObject.BANDSTRUCTURE), - } +from pymatgen.io.validation.common import VaspFiles +_test_dir = Path(__file__).parent.joinpath("test_files").resolve() -class SiStatic(SchemaTestData): - from emmet.core.vasp.calculation import VaspObject - folder = "Si_static" - task_files = { - "standard": { - "vasprun_file": "vasprun.xml.gz", - "outcar_file": "OUTCAR.gz", - "volumetric_files": ["CHGCAR.gz"], - "contcar_file": "CONTCAR.gz", - } - } - objects = {"standard": []} - task_doc = { - "calcs_reversed": [ - { - "output": { - "vbm": 5.6163, - "cbm": 6.2644, - "bandgap": 0.6506, - "is_gap_direct": False, - "is_metal": False, - "transition": "(0.000,0.000,0.000)-(0.000,0.375,0.000)", - "direct_gap": 2.5563, - "run_stats": { - "average_memory": 0, - "max_memory": 28124.0, - "cores": 16, - }, - }, - "input": { - "incar": {"NSW": 1}, - "nkpoints": 29, - "potcar_spec": [{"titel": "PAW_PBE Si 05Jan2001"}], - "structure": {"volume": 40.88829843008916}, - }, - } - ], - "analysis": {"delta_volume": 0, "max_force": 0.0}, - "input": { - "structure": {"volume": 40.88829843008916}, - "potcar_spec": [{"titel": "PAW_PBE Si 05Jan2001"}], - "parameters": {"NSW": 0}, - "is_hubbard": False, - "hubbards": None, - }, - "output": { - "structure": {"volume": 40.88829843008916}, - "energy": -10.84678256, - "bandgap": 0.6506, - "dos_properties": { - "Si": { - "s": { - "filling": 0.624669545020562, - "center": -2.5151284433409815, - "bandwidth": 7.338662205126851, - "skewness": 0.6261990748648925, - "kurtosis": 2.0074877073276904, - "upper_edge": -8.105469079999999, - }, - "p": { - "filling": 0.3911927710592045, - "center": 3.339269798287516, - "bandwidth": 5.999449671419663, - "skewness": 0.0173776678056677, - "kurtosis": 1.907790411890831, - "upper_edge": -0.7536690799999999, - }, - } - }, - }, - "custodian": [{"job": {"settings_override": None, "suffix": ""}}], - "included_objects": (), - } +def set_fake_potcar_dir() -> None: + FAKE_POTCAR_DIR = _test_dir / "vasp" / "fake_potcar" + pytest.MonkeyPatch().setitem(PMG_SETTINGS, "PMG_VASP_PSP_DIR", str(FAKE_POTCAR_DIR)) -objects = {cls.__name__: cls for cls in SchemaTestData.__subclasses__()} +@pytest.fixture(scope="session") +def test_dir(): + return _test_dir -def get_test_object(object_name): - """Get the schema test data object from the class name.""" - return objects[object_name] +vasp_calc_data: dict[str, VaspFiles] = { + k: VaspFiles(**loadfn(_test_dir / "vasp" / f"{k}.json.gz")) + for k in ("Si_uniform", "Si_static", "Si_old_double_relax") +} -test_data_task_docs = {k: TaskDoc.from_directory(dir_name=_test_dir / "vasp" / v.folder) for k, v in objects.items()} +def incar_check_list(): + """Pre-defined list of pass/fail tests.""" + return loadfn(_test_dir / "vasp" / "scf_incar_check_list.yaml") diff --git a/tests/test_files/vasp/Si_old_double_relax.json.gz b/tests/test_files/vasp/Si_old_double_relax.json.gz new file mode 100644 index 0000000..28e342f Binary files /dev/null and b/tests/test_files/vasp/Si_old_double_relax.json.gz differ diff --git a/tests/test_files/vasp/Si_old_double_relax/CONTCAR.relax1.gz b/tests/test_files/vasp/Si_old_double_relax/CONTCAR.relax1.gz deleted file mode 100644 index 83882f5..0000000 Binary files a/tests/test_files/vasp/Si_old_double_relax/CONTCAR.relax1.gz and /dev/null differ diff --git a/tests/test_files/vasp/Si_old_double_relax/CONTCAR.relax2.gz b/tests/test_files/vasp/Si_old_double_relax/CONTCAR.relax2.gz deleted file mode 100644 index 22cb0e7..0000000 Binary files a/tests/test_files/vasp/Si_old_double_relax/CONTCAR.relax2.gz and /dev/null differ diff --git a/tests/test_files/vasp/Si_old_double_relax/IBZKPT.relax1.gz b/tests/test_files/vasp/Si_old_double_relax/IBZKPT.relax1.gz deleted file mode 100644 index 669c8d4..0000000 Binary files a/tests/test_files/vasp/Si_old_double_relax/IBZKPT.relax1.gz and /dev/null differ diff --git a/tests/test_files/vasp/Si_old_double_relax/IBZKPT.relax2.gz b/tests/test_files/vasp/Si_old_double_relax/IBZKPT.relax2.gz deleted file mode 100644 index ea04943..0000000 Binary files a/tests/test_files/vasp/Si_old_double_relax/IBZKPT.relax2.gz and /dev/null differ diff --git a/tests/test_files/vasp/Si_old_double_relax/INCAR.orig.gz b/tests/test_files/vasp/Si_old_double_relax/INCAR.orig.gz deleted file mode 100644 index f937751..0000000 Binary files a/tests/test_files/vasp/Si_old_double_relax/INCAR.orig.gz and /dev/null differ diff --git a/tests/test_files/vasp/Si_old_double_relax/INCAR.relax1.gz b/tests/test_files/vasp/Si_old_double_relax/INCAR.relax1.gz deleted file mode 100644 index 774f8b9..0000000 Binary files a/tests/test_files/vasp/Si_old_double_relax/INCAR.relax1.gz and /dev/null differ diff --git a/tests/test_files/vasp/Si_old_double_relax/INCAR.relax2.gz b/tests/test_files/vasp/Si_old_double_relax/INCAR.relax2.gz deleted file mode 100644 index efedc2d..0000000 Binary files a/tests/test_files/vasp/Si_old_double_relax/INCAR.relax2.gz and /dev/null differ diff --git a/tests/test_files/vasp/Si_old_double_relax/KPOINTS.orig.gz b/tests/test_files/vasp/Si_old_double_relax/KPOINTS.orig.gz deleted file mode 100644 index f30e9c8..0000000 Binary files a/tests/test_files/vasp/Si_old_double_relax/KPOINTS.orig.gz and /dev/null differ diff --git a/tests/test_files/vasp/Si_old_double_relax/KPOINTS.relax1.gz b/tests/test_files/vasp/Si_old_double_relax/KPOINTS.relax1.gz deleted file mode 100644 index 943b72e..0000000 Binary files a/tests/test_files/vasp/Si_old_double_relax/KPOINTS.relax1.gz and /dev/null differ diff --git a/tests/test_files/vasp/Si_old_double_relax/KPOINTS.relax2.gz b/tests/test_files/vasp/Si_old_double_relax/KPOINTS.relax2.gz deleted file mode 100644 index fca43ef..0000000 Binary files a/tests/test_files/vasp/Si_old_double_relax/KPOINTS.relax2.gz and /dev/null differ diff --git a/tests/test_files/vasp/Si_old_double_relax/OUTCAR.relax1.gz b/tests/test_files/vasp/Si_old_double_relax/OUTCAR.relax1.gz deleted file mode 100644 index f160c1f..0000000 Binary files a/tests/test_files/vasp/Si_old_double_relax/OUTCAR.relax1.gz and /dev/null differ diff --git a/tests/test_files/vasp/Si_old_double_relax/OUTCAR.relax2.gz b/tests/test_files/vasp/Si_old_double_relax/OUTCAR.relax2.gz deleted file mode 100644 index 5f1cb47..0000000 Binary files a/tests/test_files/vasp/Si_old_double_relax/OUTCAR.relax2.gz and /dev/null differ diff --git a/tests/test_files/vasp/Si_old_double_relax/POSCAR.orig.gz b/tests/test_files/vasp/Si_old_double_relax/POSCAR.orig.gz deleted file mode 100644 index 1fc8a37..0000000 Binary files a/tests/test_files/vasp/Si_old_double_relax/POSCAR.orig.gz and /dev/null differ diff --git a/tests/test_files/vasp/Si_old_double_relax/POSCAR.relax1.gz b/tests/test_files/vasp/Si_old_double_relax/POSCAR.relax1.gz deleted file mode 100644 index 11704c3..0000000 Binary files a/tests/test_files/vasp/Si_old_double_relax/POSCAR.relax1.gz and /dev/null differ diff --git a/tests/test_files/vasp/Si_old_double_relax/POSCAR.relax2.gz b/tests/test_files/vasp/Si_old_double_relax/POSCAR.relax2.gz deleted file mode 100644 index c2e6113..0000000 Binary files a/tests/test_files/vasp/Si_old_double_relax/POSCAR.relax2.gz and /dev/null differ diff --git a/tests/test_files/vasp/Si_old_double_relax/custodian.json.gz b/tests/test_files/vasp/Si_old_double_relax/custodian.json.gz deleted file mode 100644 index 83dda9a..0000000 Binary files a/tests/test_files/vasp/Si_old_double_relax/custodian.json.gz and /dev/null differ diff --git a/tests/test_files/vasp/Si_old_double_relax/vasp.out.relax1.gz b/tests/test_files/vasp/Si_old_double_relax/vasp.out.relax1.gz deleted file mode 100644 index e600bcf..0000000 Binary files a/tests/test_files/vasp/Si_old_double_relax/vasp.out.relax1.gz and /dev/null differ diff --git a/tests/test_files/vasp/Si_old_double_relax/vasp.out.relax2.gz b/tests/test_files/vasp/Si_old_double_relax/vasp.out.relax2.gz deleted file mode 100644 index 0198ff1..0000000 Binary files a/tests/test_files/vasp/Si_old_double_relax/vasp.out.relax2.gz and /dev/null differ diff --git a/tests/test_files/vasp/Si_old_double_relax/vasprun.xml.relax1.gz b/tests/test_files/vasp/Si_old_double_relax/vasprun.xml.relax1.gz deleted file mode 100644 index a7b6828..0000000 Binary files a/tests/test_files/vasp/Si_old_double_relax/vasprun.xml.relax1.gz and /dev/null differ diff --git a/tests/test_files/vasp/Si_old_double_relax/vasprun.xml.relax2.gz b/tests/test_files/vasp/Si_old_double_relax/vasprun.xml.relax2.gz deleted file mode 100644 index a85e8ea..0000000 Binary files a/tests/test_files/vasp/Si_old_double_relax/vasprun.xml.relax2.gz and /dev/null differ diff --git a/tests/test_files/vasp/Si_potcar_spec.json.gz b/tests/test_files/vasp/Si_potcar_spec.json.gz deleted file mode 100644 index 12f9692..0000000 Binary files a/tests/test_files/vasp/Si_potcar_spec.json.gz and /dev/null differ diff --git a/tests/test_files/vasp/Si_static.json.gz b/tests/test_files/vasp/Si_static.json.gz new file mode 100644 index 0000000..ebdc3a8 Binary files /dev/null and b/tests/test_files/vasp/Si_static.json.gz differ diff --git a/tests/test_files/vasp/Si_static/CONTCAR.gz b/tests/test_files/vasp/Si_static/CONTCAR.gz deleted file mode 100644 index cc7193b..0000000 Binary files a/tests/test_files/vasp/Si_static/CONTCAR.gz and /dev/null differ diff --git a/tests/test_files/vasp/Si_static/INCAR.gz b/tests/test_files/vasp/Si_static/INCAR.gz deleted file mode 100644 index c6b58c2..0000000 Binary files a/tests/test_files/vasp/Si_static/INCAR.gz and /dev/null differ diff --git a/tests/test_files/vasp/Si_static/INCAR.orig.gz b/tests/test_files/vasp/Si_static/INCAR.orig.gz deleted file mode 100644 index a7823bc..0000000 Binary files a/tests/test_files/vasp/Si_static/INCAR.orig.gz and /dev/null differ diff --git a/tests/test_files/vasp/Si_static/OUTCAR.gz b/tests/test_files/vasp/Si_static/OUTCAR.gz deleted file mode 100644 index 96a445a..0000000 Binary files a/tests/test_files/vasp/Si_static/OUTCAR.gz and /dev/null differ diff --git a/tests/test_files/vasp/Si_static/POSCAR.gz b/tests/test_files/vasp/Si_static/POSCAR.gz deleted file mode 100644 index e258f41..0000000 Binary files a/tests/test_files/vasp/Si_static/POSCAR.gz and /dev/null differ diff --git a/tests/test_files/vasp/Si_static/POSCAR.orig.gz b/tests/test_files/vasp/Si_static/POSCAR.orig.gz deleted file mode 100644 index 2f140ba..0000000 Binary files a/tests/test_files/vasp/Si_static/POSCAR.orig.gz and /dev/null differ diff --git a/tests/test_files/vasp/Si_static/custodian.json.gz b/tests/test_files/vasp/Si_static/custodian.json.gz deleted file mode 100644 index 99aa6c3..0000000 Binary files a/tests/test_files/vasp/Si_static/custodian.json.gz and /dev/null differ diff --git a/tests/test_files/vasp/Si_static/vasprun.xml.gz b/tests/test_files/vasp/Si_static/vasprun.xml.gz deleted file mode 100644 index 90fd9f7..0000000 Binary files a/tests/test_files/vasp/Si_static/vasprun.xml.gz and /dev/null differ diff --git a/tests/test_files/vasp/Si_uniform.json.gz b/tests/test_files/vasp/Si_uniform.json.gz new file mode 100644 index 0000000..e50af3e Binary files /dev/null and b/tests/test_files/vasp/Si_uniform.json.gz differ diff --git a/tests/test_files/vasp/TaskDocuments/MP_compatible_GaAs_r2SCAN_static_TaskDocument.json.gz b/tests/test_files/vasp/TaskDocuments/MP_compatible_GaAs_r2SCAN_static_TaskDocument.json.gz deleted file mode 100644 index acac038..0000000 Binary files a/tests/test_files/vasp/TaskDocuments/MP_compatible_GaAs_r2SCAN_static_TaskDocument.json.gz and /dev/null differ diff --git a/tests/test_files/vasp/TaskDocuments/MP_incompatible_GaAs_r2SCAN_static_TaskDocument.json.gz b/tests/test_files/vasp/TaskDocuments/MP_incompatible_GaAs_r2SCAN_static_TaskDocument.json.gz deleted file mode 100644 index 2aed011..0000000 Binary files a/tests/test_files/vasp/TaskDocuments/MP_incompatible_GaAs_r2SCAN_static_TaskDocument.json.gz and /dev/null differ diff --git a/tests/test_files/vasp/fake_Si_potcar_spec.json.gz b/tests/test_files/vasp/fake_Si_potcar_spec.json.gz new file mode 100644 index 0000000..689ca1d Binary files /dev/null and b/tests/test_files/vasp/fake_Si_potcar_spec.json.gz differ diff --git a/tests/test_files/vasp/fake_potcar/POT_GGA_PAW_PBE/POTCAR.Al.gz b/tests/test_files/vasp/fake_potcar/POT_GGA_PAW_PBE/POTCAR.Al.gz new file mode 100644 index 0000000..6d83d07 Binary files /dev/null and b/tests/test_files/vasp/fake_potcar/POT_GGA_PAW_PBE/POTCAR.Al.gz differ diff --git a/tests/test_files/vasp/fake_potcar/POT_GGA_PAW_PBE/POTCAR.Eu.gz b/tests/test_files/vasp/fake_potcar/POT_GGA_PAW_PBE/POTCAR.Eu.gz new file mode 100644 index 0000000..23ad3df Binary files /dev/null and b/tests/test_files/vasp/fake_potcar/POT_GGA_PAW_PBE/POTCAR.Eu.gz differ diff --git a/tests/test_files/vasp/fake_potcar/POT_GGA_PAW_PBE/POTCAR.Gd.gz b/tests/test_files/vasp/fake_potcar/POT_GGA_PAW_PBE/POTCAR.Gd.gz new file mode 100644 index 0000000..05be7af Binary files /dev/null and b/tests/test_files/vasp/fake_potcar/POT_GGA_PAW_PBE/POTCAR.Gd.gz differ diff --git a/tests/test_files/vasp/fake_potcar/POT_GGA_PAW_PBE/POTCAR.H.gz b/tests/test_files/vasp/fake_potcar/POT_GGA_PAW_PBE/POTCAR.H.gz new file mode 100644 index 0000000..40d187d Binary files /dev/null and b/tests/test_files/vasp/fake_potcar/POT_GGA_PAW_PBE/POTCAR.H.gz differ diff --git a/tests/test_files/vasp/fake_potcar/POT_GGA_PAW_PBE/POTCAR.O.gz b/tests/test_files/vasp/fake_potcar/POT_GGA_PAW_PBE/POTCAR.O.gz new file mode 100644 index 0000000..0ba5c29 Binary files /dev/null and b/tests/test_files/vasp/fake_potcar/POT_GGA_PAW_PBE/POTCAR.O.gz differ diff --git a/tests/test_files/vasp/fake_potcar/POT_GGA_PAW_PBE/POTCAR.Si.gz b/tests/test_files/vasp/fake_potcar/POT_GGA_PAW_PBE/POTCAR.Si.gz new file mode 100644 index 0000000..f816323 Binary files /dev/null and b/tests/test_files/vasp/fake_potcar/POT_GGA_PAW_PBE/POTCAR.Si.gz differ diff --git a/tests/test_files/vasp/fake_potcar/POT_GGA_PAW_PBE_54/POTCAR.La.gz b/tests/test_files/vasp/fake_potcar/POT_GGA_PAW_PBE_54/POTCAR.La.gz new file mode 100644 index 0000000..adeb536 Binary files /dev/null and b/tests/test_files/vasp/fake_potcar/POT_GGA_PAW_PBE_54/POTCAR.La.gz differ diff --git a/tests/test_files/vasp/fake_potcar/POT_GGA_PAW_PBE_54/POTCAR.Si.gz b/tests/test_files/vasp/fake_potcar/POT_GGA_PAW_PBE_54/POTCAR.Si.gz new file mode 100644 index 0000000..f816323 Binary files /dev/null and b/tests/test_files/vasp/fake_potcar/POT_GGA_PAW_PBE_54/POTCAR.Si.gz differ diff --git a/tests/test_files/vasp/magnetic_run/CONTCAR.gz b/tests/test_files/vasp/magnetic_run/CONTCAR.gz deleted file mode 100644 index 9c651b7..0000000 Binary files a/tests/test_files/vasp/magnetic_run/CONTCAR.gz and /dev/null differ diff --git a/tests/test_files/vasp/magnetic_run/OUTCAR.gz b/tests/test_files/vasp/magnetic_run/OUTCAR.gz deleted file mode 100644 index aeba819..0000000 Binary files a/tests/test_files/vasp/magnetic_run/OUTCAR.gz and /dev/null differ diff --git a/tests/test_files/vasp/magnetic_run/vasprun.xml.gz b/tests/test_files/vasp/magnetic_run/vasprun.xml.gz deleted file mode 100644 index ecd7d5a..0000000 Binary files a/tests/test_files/vasp/magnetic_run/vasprun.xml.gz and /dev/null differ diff --git a/tests/test_files/vasp/mp-1245223_site_props_check.json.gz b/tests/test_files/vasp/mp-1245223_site_props_check.json.gz index e30000f..631e821 100644 Binary files a/tests/test_files/vasp/mp-1245223_site_props_check.json.gz and b/tests/test_files/vasp/mp-1245223_site_props_check.json.gz differ diff --git a/tests/test_files/vasp/scf_incar_check_list.yaml b/tests/test_files/vasp/scf_incar_check_list.yaml index 37a2bad..2151761 100644 --- a/tests/test_files/vasp/scf_incar_check_list.yaml +++ b/tests/test_files/vasp/scf_incar_check_list.yaml @@ -1,83 +1,80 @@ - err_msg: LCHIMAG should_pass: false - vasprun: + incar: LCHIMAG: true - incar: {} + vasprun: {} - err_msg: LNMR_SYM_RED should_pass: false - vasprun: + incar: LNMR_SYM_RED: true - incar: {} + vasprun: {} - err_msg: LDIPOL should_pass: false - vasprun: + incar: LDIPOL: true - incar: {} + vasprun: {} - err_msg: IDIPOL should_pass: false - vasprun: + incar: IDIPOL: 2 - incar: {} + vasprun: {} - err_msg: EPSILON should_pass: false - vasprun: + incar: EPSILON: 1.5 - incar: {} + vasprun: {} - err_msg: EPSILON should_pass: true - vasprun: + incar: EPSILON: 1 - incar: {} + vasprun: {} - err_msg: EFIELD should_pass: false - vasprun: + incar: EFIELD: 1 - incar: {} + vasprun: {} - err_msg: EFIELD should_pass: true - vasprun: + incar: EFIELD: 0 - incar: {} + vasprun: {} - err_msg: EDIFF should_pass: false - vasprun: + incar: EDIFF: 0.01 - incar: {} + vasprun: {} - err_msg: EDIFF should_pass: true - vasprun: + incar: EDIFF: 1e-08 - incar: {} + vasprun: {} - err_msg: ENINI should_pass: false - vasprun: + incar: ENINI: 1 IALGO: 48 - incar: {} + vasprun: {} - err_msg: IALGO should_pass: false - vasprun: + incar: ENINI: 1 IALGO: 48 - incar: {} + vasprun: {} - err_msg: NBANDS should_pass: false vasprun: NBANDS: 1 - incar: {} -- err_msg: NBANDS - should_pass: true - vasprun: - NBANDS: 40 - incar: {} +# TODO: This test seems wrong +# - err_msg: NBANDS +# should_pass: true +# vasprun: +# NBANDS: 40 - err_msg: NBANDS should_pass: false vasprun: NBANDS: 1000 - incar: {} - err_msg: LREAL should_pass: false - vasprun: {} incar: LREAL: true - err_msg: LREAL @@ -87,199 +84,199 @@ LREAL: false - err_msg: LMAXPAW should_pass: false - vasprun: + incar: LMAXPAW: 0 - incar: {} + vasprun: {} - err_msg: NLSPLINE should_pass: false - vasprun: + incar: NLSPLINE: true - incar: {} + vasprun: {} - err_msg: ADDGRID should_pass: false - vasprun: + incar: ADDGRID: true - incar: {} + vasprun: {} - err_msg: LHFCALC should_pass: false - vasprun: + incar: LHFCALC: true - incar: {} + vasprun: {} - err_msg: AEXX should_pass: false - vasprun: + incar: AEXX: 1 - incar: {} + vasprun: {} - err_msg: AGGAC should_pass: false - vasprun: + incar: AGGAC: 0.5 - incar: {} + vasprun: {} - err_msg: AGGAX should_pass: false - vasprun: + incar: AGGAX: 0.5 - incar: {} + vasprun: {} - err_msg: ALDAX should_pass: false - vasprun: + incar: ALDAX: 0.5 - incar: {} + vasprun: {} - err_msg: AMGGAX should_pass: false - vasprun: + incar: AMGGAX: 0.5 - incar: {} + vasprun: {} - err_msg: ALDAC should_pass: false - vasprun: + incar: ALDAC: 0.5 - incar: {} + vasprun: {} - err_msg: AMGGAC should_pass: false - vasprun: + incar: AMGGAC: 0.5 - incar: {} + vasprun: {} - err_msg: IBRION should_pass: false - vasprun: + incar: IBRION: 3 - incar: {} + vasprun: {} - err_msg: IBRION should_pass: true - vasprun: + incar: IBRION: 1 - incar: {} + vasprun: {} - err_msg: IBRION should_pass: true - vasprun: + incar: IBRION: -1 - incar: {} + NSW: 0 # This is required as VASP auto-sets IBRION = 0 if NSW > 0 and IBRION not set + vasprun: {} - err_msg: PSTRESS should_pass: false - vasprun: + incar: PSTRESS: 1 - incar: {} + vasprun: {} - err_msg: SCALEE should_pass: false - vasprun: + incar: SCALEE: 0.9 - incar: {} + vasprun: {} - err_msg: LNONCOLLINEAR should_pass: false - vasprun: + incar: LNONCOLLINEAR: true - incar: {} + vasprun: {} - err_msg: LSORBIT should_pass: false - vasprun: + incar: LSORBIT: true - incar: {} + vasprun: {} - err_msg: DEPER should_pass: false - vasprun: + incar: DEPER: 0.5 - incar: {} + vasprun: {} - err_msg: EBREAK should_pass: false - vasprun: {} incar: EBREAK: 0.1 - err_msg: GGA_COMPAT should_pass: false - vasprun: + incar: GGA_COMPAT: false - incar: {} + vasprun: {} - err_msg: ICORELEVEL should_pass: false - vasprun: + incar: ICORELEVEL: 1 - incar: {} + vasprun: {} - err_msg: IMAGES should_pass: false - vasprun: + incar: IMAGES: 1 - incar: {} + vasprun: {} - err_msg: IVDW should_pass: false - vasprun: + incar: IVDW: 1 - incar: {} + vasprun: {} - err_msg: LBERRY should_pass: false - vasprun: + incar: LBERRY: true - incar: {} + vasprun: {} - err_msg: LCALCEPS should_pass: false - vasprun: + incar: LCALCEPS: true - incar: {} + vasprun: {} - err_msg: LCALCPOL should_pass: false - vasprun: + incar: LCALCPOL: true - incar: {} + vasprun: {} - err_msg: LHYPERFINE should_pass: false - vasprun: + incar: LHYPERFINE: true - incar: {} + vasprun: {} - err_msg: LKPOINTS_OPT should_pass: false - vasprun: + incar: LKPOINTS_OPT: true - incar: {} + vasprun: {} - err_msg: LKPROJ should_pass: false - vasprun: + incar: LKPROJ: true - incar: {} + vasprun: {} - err_msg: LMP2LT should_pass: false - vasprun: + incar: LMP2LT: true - incar: {} + vasprun: {} - err_msg: LSMP2LT should_pass: false - vasprun: + incar: LSMP2LT: true - incar: {} + vasprun: {} - err_msg: LOCPROJ should_pass: false - vasprun: + incar: LOCPROJ: '1 : s : Hy' - incar: {} + vasprun: {} - err_msg: LRPA should_pass: false - vasprun: + incar: LRPA: true - incar: {} + vasprun: {} - err_msg: LSPECTRAL should_pass: false - vasprun: + incar: LSPECTRAL: true - incar: {} + vasprun: {} - err_msg: LSUBROT should_pass: false - vasprun: + incar: LSUBROT: true - incar: {} + vasprun: {} - err_msg: ML_LMLFF should_pass: false - vasprun: + incar: ML_LMLFF: true - incar: {} + vasprun: {} - err_msg: WEIMIN should_pass: false - vasprun: + incar: WEIMIN: 0.01 - incar: {} + vasprun: {} - err_msg: WEIMIN should_pass: true - vasprun: + incar: WEIMIN: 0.0001 - incar: {} + vasprun: {} - err_msg: IWAVPR should_pass: false vasprun: {} @@ -287,159 +284,222 @@ IWAVPR: 1 - err_msg: LASPH should_pass: false - vasprun: + incar: LASPH: false - incar: {} + vasprun: {} - err_msg: LCORR should_pass: false - vasprun: + incar: LCORR: false IALGO: 38 - incar: {} + vasprun: {} - err_msg: LCORR should_pass: true - vasprun: + incar: LCORR: false IALGO: 58 - incar: {} + vasprun: {} - err_msg: RWIGS should_pass: false - vasprun: + incar: RWIGS: - 1 - incar: {} + vasprun: {} - err_msg: VCA should_pass: false - vasprun: + incar: VCA: - 0.5 - incar: {} + vasprun: {} - err_msg: PREC should_pass: false - vasprun: + incar: PREC: NORMAL - incar: {} + vasprun: {} - err_msg: ROPT should_pass: false - vasprun: + incar: ROPT: - -0.001 - incar: LREAL: true -- err_msg: ICHARG - should_pass: false - vasprun: - ICHARG: 11 - incar: {} +# TODO: This test seems wrong +# - err_msg: ICHARG +# should_pass: false +# incar: +# ICHARG: 11 +# vasprun: {} - err_msg: INIWAV should_pass: false - vasprun: + incar: INIWAV: 0 - incar: {} + vasprun: {} - err_msg: ISTART should_pass: false - vasprun: + incar: ISTART: 3 - incar: {} + vasprun: {} - err_msg: ISYM should_pass: false - vasprun: + incar: ISYM: 3 - incar: {} + vasprun: {} - err_msg: ISYM should_pass: true - vasprun: + incar: ISYM: 3 LHFCALC: true - incar: {} + vasprun: {} - err_msg: SYMPREC should_pass: false - vasprun: + incar: SYMPREC: 0.01 - incar: {} + vasprun: {} - err_msg: LDAUU should_pass: false - vasprun: - LDAU: true incar: + LDAU: true LDAUU: - 5 - 5 - err_msg: LDAUJ should_pass: false - vasprun: - LDAU: true incar: + LDAU: true LDAUJ: - 5 - 5 - err_msg: LDAUL should_pass: false - vasprun: - LDAU: true incar: + LDAU: true LDAUL: - 5 - 5 - err_msg: LDAUTYPE should_pass: false - vasprun: + incar: LDAU: true LDAUTYPE: - 1 - incar: {} - err_msg: NWRITE should_pass: false - vasprun: + incar: NWRITE: 1 - incar: {} + vasprun: {} - err_msg: LEFG should_pass: false - vasprun: + incar: LEFG: true - incar: {} + vasprun: {} - err_msg: LOPTICS should_pass: false - vasprun: + incar: LOPTICS: true - incar: {} + vasprun: {} - should_pass: true err_msg: ISIF - vasprun: + incar: ISIF: 2 - incar: {} + vasprun: {} - should_pass: true err_msg: ISIF - vasprun: + incar: ISIF: 3 - incar: {} + vasprun: {} - should_pass: true err_msg: ISIF - vasprun: + incar: ISIF : 4 - incar: {} + vasprun: {} - should_pass: true err_msg: ISIF - vasprun: + incar: ISIF : 5 - incar: {} + vasprun: {} - should_pass: true err_msg: ISIF - vasprun: + incar: ISIF : 6 - incar: {} + vasprun: {} - should_pass: true err_msg: ISIF - vasprun: + incar: ISIF : 7 - incar: {} + vasprun: {} - should_pass: true err_msg: ISIF - vasprun: + incar: ISIF : 8 - incar: {} + vasprun: {} - should_pass: false err_msg: ISIF - vasprun: + incar: ISIF: 1 - incar: {} + vasprun: {} +- should_pass : false + err_msg : ENCUT + incar : + ENCUT : 1. +# Check that ENCUT is appropriately updated to be finite +- should_pass : true + err_msg : "should be >= inf" + incar : + ENCUT : 1. +- should_pass : false + err_msg : NGX + incar: + NGX : 1 +- should_pass : false + err_msg : NGXF + incar: + NGXF : 1 +- should_pass : false + err_msg : NGY + incar: + NGY : 1 +- should_pass : false + err_msg : NGYF + incar: + NGYF : 1 +- should_pass : false + err_msg : NGZ + incar: + NGZ : 1 +- should_pass : false + err_msg : NGZF + incar: + NGZF : 1 +- should_pass : false + err_msg : POTIM + incar: + POTIM : 10. + IBRION : 1 +- should_pass : false + err_msg : LMAXTAU + incar: + LMAXTAU: 2 + METAGGA: R2SCA + ICHARG: 1 +- should_pass : true + err_msg : LMAXTAU + incar: + LMAXTAU: 2 + ICHARG: 1 + METAGGA: "NONE" +- should_pass : false + err_msg : ENAUG + incar: + ENAUG: 1 + ICHARG: 1 + METAGGA: "R2SCA" +- should_pass : true + err_msg : "ENERGY CHANGE BETWEEN LAST TWO IONIC STEPS" +- should_pass : true + err_msg : KNOWN BUG + incar: + GGA : PE + METAGGA : NONE +- should_pass : false + err_msg : CONVERGENCE --> Did not achieve electronic + incar: + NELM : 1 \ No newline at end of file diff --git a/tests/test_validation.py b/tests/test_validation.py index b9a2870..be44999 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -1,354 +1,295 @@ import pytest import copy -from conftest import get_test_object, test_data_task_docs -from pymatgen.io.validation import ValidationDoc -from emmet.core.tasks import TaskDoc + from monty.serialization import loadfn from pymatgen.core.structure import Structure from pymatgen.io.vasp import Kpoints +from pymatgen.io.validation.validation import VaspValidator +from pymatgen.io.validation.common import ValidationError, VaspFiles, PotcarSummaryStats + +from conftest import vasp_calc_data, incar_check_list, set_fake_potcar_dir + + ### TODO: add tests for many other MP input sets (e.g. MPNSCFSet, MPNMRSet, MPScanRelaxSet, Hybrid sets, etc.) ### TODO: add check for an MP input set that uses an IBRION other than [-1, 1, 2] ### TODO: add in check for MP set where LEFG = True ### TODO: add in check for MP set where LOPTICS = True ### TODO: fix logic for calc_type / run_type identification in Emmet!!! Or handle how we interpret them... +set_fake_potcar_dir() + def run_check( - task_doc, + vasp_files: VaspFiles, error_message_to_search_for: str, should_the_check_pass: bool, vasprun_parameters_to_change: dict = {}, # for changing the parameters read from vasprun.xml incar_settings_to_change: dict = {}, # for directly changing the INCAR file, - validation_doc_kwargs: dict = {}, # any kwargs to pass to the ValidationDoc class + validation_doc_kwargs: dict = {}, # any kwargs to pass to the VaspValidator class ): - for key, value in vasprun_parameters_to_change.items(): - task_doc.input.parameters[key] = value + _new_vf = vasp_files.model_dump() + _new_vf["vasprun"]["parameters"].update(**vasprun_parameters_to_change) - for key, value in incar_settings_to_change.items(): - task_doc.calcs_reversed[0].input.incar[key] = value + _new_vf["user_input"]["incar"].update(**incar_settings_to_change) - validation_doc = ValidationDoc.from_task_doc(task_doc, **validation_doc_kwargs) - has_specified_error = any([error_message_to_search_for in reason for reason in validation_doc.reasons]) + validator = VaspValidator.from_vasp_input(vasp_files=VaspFiles(**_new_vf), **validation_doc_kwargs) + has_specified_error = any([error_message_to_search_for in reason for reason in validator.reasons]) assert (not has_specified_error) if should_the_check_pass else has_specified_error -@pytest.mark.parametrize( - "object_name", - [ - pytest.param("SiOptimizeDouble", id="SiOptimizeDouble"), - ], -) -def test_validation_doc_from_directory(test_dir, object_name): - test_object = get_test_object(object_name) - dir_name = test_dir / "vasp" / test_object.folder - test_validation_doc = ValidationDoc.from_directory(dir_name=dir_name) +def test_validation_from_files(test_dir): - task_doc = test_data_task_docs[object_name] - valid_validation_doc = ValidationDoc.from_task_doc(task_doc) + dir_name = test_dir / "vasp" / "Si_uniform" + validator_from_paths = VaspValidator.from_directory(dir_name) + validator_from_vasp_files = VaspValidator.from_vasp_input(vasp_files=vasp_calc_data["Si_uniform"]) + + # Note: because the POTCAR info cannot be distributed, `validator_from_paths` + # is missing POTCAR checks. + assert set([r for r in validator_from_paths.reasons if "POTCAR" not in r]) == set(validator_from_vasp_files.reasons) + assert set([r for r in validator_from_paths.warnings if "POTCAR" not in r]) == set( + validator_from_vasp_files.warnings + ) + assert all( + getattr(validator_from_paths.vasp_files.user_input, k) == getattr(validator_from_paths.vasp_files.user_input, k) + for k in ("incar", "structure", "kpoints") + ) - # The attributes below will always be different because the objects are created at - # different times. Hence, ignore before checking. - delattr(test_validation_doc.builder_meta, "build_date") - delattr(test_validation_doc, "last_updated") - delattr(valid_validation_doc.builder_meta, "build_date") - delattr(valid_validation_doc, "last_updated") + # Ensure that user modifcation to inputs after submitting valid + # input leads to subsequent validation failures. + # Re-instantiate VaspValidator to ensure pointers don't get messed up + validated = VaspValidator(**validator_from_paths.model_dump()) + og_md5 = validated.vasp_files.md5 + assert validated.valid + assert validated._validated_md5 == og_md5 - assert test_validation_doc == valid_validation_doc + validated.vasp_files.user_input.incar["ENCUT"] = 1.0 + new_md5 = validated.vasp_files.md5 + assert new_md5 != og_md5 + assert not validated.valid + assert validated._validated_md5 == new_md5 @pytest.mark.parametrize( "object_name", [ - pytest.param("SiOptimizeDouble", id="SiOptimizeDouble"), + "Si_old_double_relax", ], ) def test_potcar_validation(test_dir, object_name): - task_doc = test_data_task_docs[object_name] + vf_og = vasp_calc_data[object_name] - correct_potcar_summary_stats = loadfn(test_dir / "vasp" / "Si_potcar_spec.json.gz") + correct_potcar_summary_stats = [ + PotcarSummaryStats(**ps) for ps in loadfn(test_dir / "vasp" / "fake_Si_potcar_spec.json.gz") + ] # Check POTCAR (this test should PASS, as we ARE using a MP-compatible pseudopotential) - temp_task_doc = copy.deepcopy(task_doc) - temp_task_doc.calcs_reversed[0].input.potcar_spec = correct_potcar_summary_stats - run_check(temp_task_doc, "PSEUDOPOTENTIALS", True) + vf = copy.deepcopy(vf_og) + assert vf.user_input.potcar == correct_potcar_summary_stats + run_check(vf, "PSEUDOPOTENTIALS", True) # Check POTCAR (this test should FAIL, as we are NOT using an MP-compatible pseudopotential) - temp_task_doc = copy.deepcopy(task_doc) + vf = copy.deepcopy(vf_og) incorrect_potcar_summary_stats = copy.deepcopy(correct_potcar_summary_stats) - incorrect_potcar_summary_stats[0].summary_stats["stats"]["data"]["MEAN"] = 999999999 - temp_task_doc.calcs_reversed[0].input.potcar_spec = incorrect_potcar_summary_stats - run_check(temp_task_doc, "PSEUDOPOTENTIALS", False) + incorrect_potcar_summary_stats[0].stats.data.MEAN = 999999999 + vf.user_input.potcar = incorrect_potcar_summary_stats + run_check(vf, "PSEUDOPOTENTIALS", False) -@pytest.mark.parametrize( - "object_name", - [ - pytest.param("SiOptimizeDouble", id="SiOptimizeDouble"), - pytest.param("SiStatic", id="SiStatic"), - ], -) +@pytest.mark.parametrize("object_name", ["Si_static", "Si_old_double_relax"]) def test_scf_incar_checks(test_dir, object_name): - task_doc = test_data_task_docs[object_name] - task_doc.calcs_reversed[0].output.structure._charge = 0.0 # patch for old test files + vf_og = vasp_calc_data[object_name] + vf_og.vasprun.final_structure._charge = 0.0 # patch for old test files # Pay *very* close attention to whether a tag is modified in the incar or in the vasprun.xml's parameters! # Some parameters are validated from one or the other of these items, depending on whether VASP # changes the value between the INCAR and the vasprun.xml (which it often does) - list_of_checks = loadfn(test_dir / "vasp" / "scf_incar_check_list.yaml") - - for check_info in list_of_checks: - temp_task_doc = copy.deepcopy(task_doc) + for incar_check in incar_check_list(): run_check( - temp_task_doc, - check_info["err_msg"], - check_info["should_pass"], - vasprun_parameters_to_change=check_info["vasprun"], - incar_settings_to_change=check_info["incar"], + vf_og, + incar_check["err_msg"], + incar_check["should_pass"], + vasprun_parameters_to_change=incar_check.get("vasprun", {}), + incar_settings_to_change=incar_check.get("incar", {}), ) - ### Most all of the tests below are too specific to use the kwargs in the # run_check() method. Hence, the calcs are manually modified. Apologies. - # ENMAX / ENCUT checks - # Also assert that the ENCUT warning does not assert that ENCUT >= inf - # This checks that ENCUT is appropriately updated to be finite, and - # not just ENMAX - temp_task_doc = copy.deepcopy(task_doc) - temp_task_doc.input.parameters["ENMAX"] = 1 - run_check(temp_task_doc, "ENCUT", False) - run_check(temp_task_doc, "should be >= inf.", True) - # NELECT check - temp_task_doc = copy.deepcopy(task_doc) + vf = copy.deepcopy(vf_og) # must set NELECT in `incar` for NELECT checks! - temp_task_doc.calcs_reversed[0].input.incar["NELECT"] = 9 - temp_task_doc.calcs_reversed[0].output.structure._charge = 1.0 - run_check(temp_task_doc, "NELECT", False) - - # FFT grid check (NGX, NGY, NGZ, NGXF, NGYF, NGZF) - # Must change `incar` *and* `parameters` for NG_ checks! - ng_keys = [] - for direction in ["X", "Y", "Z"]: - for mod in ["", "F"]: - ng_keys.append(f"NG{direction}{mod}") - - for key in ng_keys: - temp_task_doc = copy.deepcopy(task_doc) - temp_task_doc.calcs_reversed[0].input.incar[key] = 1 - temp_task_doc.input.parameters[key] = 1 - run_check(temp_task_doc, key, False) - - # POTIM check #1 (checks parameter itself) - ### TODO: add in second check for POTIM that checks for large energy changes between ionic steps - temp_task_doc = copy.deepcopy(task_doc) - temp_task_doc.input.parameters["POTIM"] = 10 - run_check(temp_task_doc, "POTIM", False) + vf.user_input.incar["NELECT"] = 9 + vf.vasprun.final_structure._charge = 1.0 + run_check(vf, "NELECT", False) # POTIM check #2 (checks energy change between steps) - temp_task_doc = copy.deepcopy(task_doc) - temp_task_doc.input.parameters["IBRION"] = 2 - temp_ionic_step_1 = copy.deepcopy(temp_task_doc.calcs_reversed[0].output.ionic_steps[0]) + vf = copy.deepcopy(vf_og) + vf.user_input.incar["IBRION"] = 2 + temp_ionic_step_1 = copy.deepcopy(vf.vasprun.ionic_steps[0]) temp_ionic_step_2 = copy.deepcopy(temp_ionic_step_1) - temp_ionic_step_1.e_fr_energy = 0 - temp_ionic_step_2.e_fr_energy = 10000 - temp_task_doc.calcs_reversed[0].output.ionic_steps = [ + temp_ionic_step_1["e_fr_energy"] = 0 + temp_ionic_step_2["e_fr_energy"] = 10000 + vf.vasprun.ionic_steps = [ temp_ionic_step_1, temp_ionic_step_2, ] - run_check(temp_task_doc, "POTIM", False) - - # EDIFFG energy convergence check (this check should not raise any invalid reasons) - temp_task_doc = copy.deepcopy(task_doc) - run_check(temp_task_doc, "ENERGY CHANGE BETWEEN LAST TWO IONIC STEPS", True) + run_check(vf, "POTIM", False) # EDIFFG energy convergence check (this check SHOULD fail) - temp_task_doc = copy.deepcopy(task_doc) - temp_ionic_step_1 = copy.deepcopy(temp_task_doc.calcs_reversed[0].output.ionic_steps[0]) + vf = copy.deepcopy(vf_og) + temp_ionic_step_1 = copy.deepcopy(vf.vasprun.ionic_steps[0]) temp_ionic_step_2 = copy.deepcopy(temp_ionic_step_1) - temp_ionic_step_1.e_0_energy = -1 - temp_ionic_step_2.e_0_energy = -2 - temp_task_doc.calcs_reversed[0].output.ionic_steps = [ + temp_ionic_step_1["e_0_energy"] = -1 + temp_ionic_step_2["e_0_energy"] = -2 + vf.vasprun.ionic_steps = [ temp_ionic_step_1, temp_ionic_step_2, ] - run_check(temp_task_doc, "ENERGY CHANGE BETWEEN LAST TWO IONIC STEPS", False) + run_check(vf, "ENERGY CHANGE BETWEEN LAST TWO IONIC STEPS", False) # EDIFFG / force convergence check (the MP input set for R2SCAN has force convergence criteria) # (the below test should NOT fail, because final forces are 0) - temp_task_doc = copy.deepcopy(task_doc) - temp_task_doc.calcs_reversed[0].input.incar["METAGGA"] = "R2SCAN" - temp_task_doc.output.forces = [[0, 0, 0], [0, 0, 0]] - run_check(temp_task_doc, "MAX FINAL FORCE MAGNITUDE", True) + vf = copy.deepcopy(vf_og) + vf.user_input.incar.update(METAGGA="R2SCA", ICHARG=1) + vf.vasprun.ionic_steps[-1]["forces"] = [[0, 0, 0], [0, 0, 0]] + run_check(vf, "MAX FINAL FORCE MAGNITUDE", True) # EDIFFG / force convergence check (the MP input set for R2SCAN has force convergence criteria) # (the below test SHOULD fail, because final forces are high) - temp_task_doc = copy.deepcopy(task_doc) - temp_task_doc.calcs_reversed[0].input.incar["METAGGA"] = "R2SCAN" - temp_task_doc.output.forces = [[10, 10, 10], [10, 10, 10]] - run_check(temp_task_doc, "MAX FINAL FORCE MAGNITUDE", False) + vf = copy.deepcopy(vf_og) + vf.user_input.incar.update(METAGGA="R2SCA", ICHARG=1, IBRION=1, NSW=1) + vf.vasprun.ionic_steps[-1]["forces"] = [[10, 10, 10], [10, 10, 10]] + run_check(vf, "MAX FINAL FORCE MAGNITUDE", False) # ISMEAR wrong for nonmetal check - temp_task_doc = copy.deepcopy(task_doc) - temp_task_doc.input.parameters["ISMEAR"] = 1 - temp_task_doc.output.bandgap = 1 - run_check(temp_task_doc, "ISMEAR", False) + vf = copy.deepcopy(vf_og) + vf.user_input.incar["ISMEAR"] = 1 + vf.vasprun.bandgap = 1 + run_check(vf, "ISMEAR", False) # ISMEAR wrong for metal relaxation check - temp_task_doc = copy.deepcopy(task_doc) - # make ionic_steps be length 2, meaning this gets classified as a relaxation calculation - temp_task_doc.calcs_reversed[0].output.ionic_steps = 2 * temp_task_doc.calcs_reversed[0].output.ionic_steps - temp_task_doc.input.parameters["ISMEAR"] = -5 - temp_task_doc.output.bandgap = 0 - run_check(temp_task_doc, "ISMEAR", False) + vf = copy.deepcopy(vf_og) + vf.user_input.incar.update(ISMEAR=-5, NSW=1, IBRION=1, ICHARG=9) + vf.vasprun.bandgap = 0 + run_check(vf, "ISMEAR", False) # SIGMA too high for nonmetal with ISMEAR = 0 check - temp_task_doc = copy.deepcopy(task_doc) - temp_task_doc.input.parameters["ISMEAR"] = 0 - temp_task_doc.input.parameters["SIGMA"] = 0.2 - temp_task_doc.output.bandgap = 1 - run_check(temp_task_doc, "SIGMA", False) + vf = copy.deepcopy(vf_og) + vf.user_input.incar.update(ISMEAR=0, SIGMA=0.2) + vf.vasprun.bandgap = 1 + run_check(vf, "SIGMA", False) # SIGMA too high for nonmetal with ISMEAR = -5 check (should not error) - temp_task_doc = copy.deepcopy(task_doc) - temp_task_doc.input.parameters["ISMEAR"] = -5 - temp_task_doc.input.parameters["SIGMA"] = 1000 # should not matter - temp_task_doc.output.bandgap = 1 - run_check(temp_task_doc, "SIGMA", True) + vf = copy.deepcopy(vf_og) + vf.user_input.incar.update(ISMEAR=-5, SIGMA=1e3) + vf.vasprun.bandgap = 1 + run_check(vf, "SIGMA", True) # SIGMA too high for metal check - temp_task_doc = copy.deepcopy(task_doc) - temp_task_doc.input.parameters["ISMEAR"] = 1 - temp_task_doc.input.parameters["SIGMA"] = 0.5 - temp_task_doc.output.bandgap = 0 - run_check(temp_task_doc, "SIGMA", False) + vf = copy.deepcopy(vf_og) + vf.user_input.incar.update(ISMEAR=1, SIGMA=0.5) + vf.vasprun.bandgap = 0 + run_check(vf, "SIGMA", False) # SIGMA too large check (i.e. eentropy term is > 1 meV/atom) - temp_task_doc = copy.deepcopy(task_doc) - temp_task_doc.calcs_reversed[0].output.ionic_steps[0].electronic_steps[-1].eentropy = 1 - run_check(temp_task_doc, "The entropy term (T*S)", False) + vf = copy.deepcopy(vf_og) + vf.vasprun.ionic_steps[0]["electronic_steps"][-1]["eentropy"] = 1 + run_check(vf, "The entropy term (T*S)", False) # LMAXMIX check for SCF calc - temp_task_doc = copy.deepcopy(task_doc) - temp_task_doc.input.parameters["LMAXMIX"] = 0 - temp_validation_doc = ValidationDoc.from_task_doc(temp_task_doc) + vf = copy.deepcopy(vf_og) + vf.user_input.incar.update( + LMAXMIX=0, + ICHARG=1, + ) + validated = VaspValidator.from_vasp_input(vasp_files=vf) # should not invalidate SCF calcs based on LMAXMIX - assert not any(["LMAXMIX" in reason for reason in temp_validation_doc.reasons]) + assert not any(["LMAXMIX" in reason for reason in validated.reasons]) # rather should add a warning - assert any(["LMAXMIX" in warning for warning in temp_validation_doc.warnings]) + assert any(["LMAXMIX" in warning for warning in validated.warnings]) # EFERMI check (does not matter for VASP versions before 6.4) # must check EFERMI in the *incar*, as it is saved as a numerical value after VASP # guesses it in the vasprun.xml `parameters` - temp_task_doc = copy.deepcopy(task_doc) - temp_task_doc.calcs_reversed[0].vasp_version = "5.4.4" - temp_task_doc.calcs_reversed[0].input.incar["EFERMI"] = 5 - run_check(temp_task_doc, "EFERMI", True) + vf = copy.deepcopy(vf_og) + vf.vasprun.vasp_version = "5.4.4" + vf.user_input.incar["EFERMI"] = 5 + run_check(vf, "EFERMI", True) # EFERMI check (matters for VASP versions 6.4 and beyond) # must check EFERMI in the *incar*, as it is saved as a numerical value after VASP # guesses it in the vasprun.xml `parameters` - temp_task_doc = copy.deepcopy(task_doc) - temp_task_doc.calcs_reversed[0].vasp_version = "6.4.0" - temp_task_doc.calcs_reversed[0].input.incar["EFERMI"] = 5 - run_check(temp_task_doc, "EFERMI", False) + vf = copy.deepcopy(vf_og) + vf.vasprun.vasp_version = "6.4.0" + vf.user_input.incar["EFERMI"] = 5 + run_check(vf, "EFERMI", False) # LORBIT check (should have magnetization values for ISPIN=2) # Should be valid for this case, as no magmoms are expected for ISPIN = 1 - temp_task_doc = copy.deepcopy(task_doc) - temp_task_doc.input.parameters["ISPIN"] = 1 - temp_task_doc.calcs_reversed[0].output.outcar["magnetization"] = [] - run_check(temp_task_doc, "LORBIT", True) + vf = copy.deepcopy(vf_og) + vf.user_input.incar["ISPIN"] = 1 + vf.outcar.magnetization = [] + run_check(vf, "LORBIT", True) # LORBIT check (should have magnetization values for ISPIN=2) # Should be valid in this case, as magmoms are present for ISPIN = 2 - temp_task_doc = copy.deepcopy(task_doc) - temp_task_doc.input.parameters["ISPIN"] = 2 - temp_task_doc.calcs_reversed[0].output.outcar["magnetization"] = ( + vf = copy.deepcopy(vf_og) + vf.user_input.incar["ISPIN"] = 2 + vf.outcar.magnetization = ( {"s": -0.0, "p": 0.0, "d": 0.0, "tot": 0.0}, {"s": -0.0, "p": 0.0, "d": 0.0, "tot": -0.0}, ) - run_check(temp_task_doc, "LORBIT", True) + run_check(vf, "LORBIT", True) # LORBIT check (should have magnetization values for ISPIN=2) # Should be invalid in this case, as no magmoms are present for ISPIN = 2 - temp_task_doc = copy.deepcopy(task_doc) - temp_task_doc.input.parameters["ISPIN"] = 2 - temp_task_doc.calcs_reversed[0].output.outcar["magnetization"] = [] - run_check(temp_task_doc, "LORBIT", False) + vf = copy.deepcopy(vf_og) + vf.user_input.incar["ISPIN"] = 2 + vf.outcar.magnetization = [] + run_check(vf, "LORBIT", False) # LMAXTAU check for METAGGA calcs (A value of 4 should fail for the `La` chemsys (has f electrons)) - temp_task_doc = copy.deepcopy(task_doc) - temp_task_doc.chemsys = "La" - temp_task_doc.calcs_reversed[0].input.structure = Structure( + vf = copy.deepcopy(vf_og) + vf.user_input.structure = Structure( lattice=[[2.9, 0, 0], [0, 2.9, 0], [0, 0, 2.9]], species=["La", "La"], coords=[[0, 0, 0], [0.5, 0.5, 0.5]], ) - temp_task_doc.calcs_reversed[0].input.incar["LMAXTAU"] = 4 - temp_task_doc.calcs_reversed[0].input.incar["METAGGA"] = "R2SCAN" - run_check(temp_task_doc, "LMAXTAU", False) - - # LMAXTAU check for METAGGA calcs (A value of 2 should fail for the `Si` chemsys) - temp_task_doc = copy.deepcopy(task_doc) - temp_task_doc.calcs_reversed[0].input.incar["LMAXTAU"] = 2 - temp_task_doc.calcs_reversed[0].input.incar["METAGGA"] = "R2SCAN" - run_check(temp_task_doc, "LMAXTAU", False) - - # LMAXTAU should always pass for non-METAGGA calcs - temp_task_doc = copy.deepcopy(task_doc) - temp_task_doc.calcs_reversed[0].input.incar["LMAXTAU"] = 0 - temp_task_doc.calcs_reversed[0].input.incar["METAGGA"] = "None" - run_check(temp_task_doc, "LMAXTAU", True) - - # ENAUG check for r2SCAN calcs - temp_task_doc = copy.deepcopy(task_doc) - temp_task_doc.input.parameters["ENAUG"] = 1 - temp_task_doc.calcs_reversed[0].input.incar["METAGGA"] = "R2SCAN" - run_check(temp_task_doc, "ENAUG", False) + vf.user_input.incar.update( + LMAXTAU=4, + METAGGA="R2SCA", + ICHARG=1, + ) + run_check(vf, "LMAXTAU", False) @pytest.mark.parametrize( "object_name", [ - pytest.param("SiNonSCFUniform", id="SiNonSCFUniform"), + "Si_uniform", ], ) -def test_nscf_incar_checks(object_name): - task_doc = test_data_task_docs[object_name] - task_doc.calcs_reversed[0].output.structure._charge = 0.0 # patch for old test files +def test_nscf_checks(object_name): + vf_og = vasp_calc_data[object_name] + vf_og.vasprun.final_structure._charge = 0.0 # patch for old test files # ICHARG check - temp_task_doc = copy.deepcopy(task_doc) - temp_task_doc.input.parameters["ICHARG"] = 11 - run_check(temp_task_doc, "ICHARG", True) + run_check(vf_og, "ICHARG", True, incar_settings_to_change={"ICHARG": 11}) # LMAXMIX check for NSCF calc - temp_task_doc = copy.deepcopy(task_doc) - temp_task_doc.input.parameters["LMAXMIX"] = 0 - temp_validation_doc = ValidationDoc.from_task_doc(temp_task_doc) + vf = copy.deepcopy(vf_og) + vf.user_input.incar["LMAXMIX"] = 0 + validated = VaspValidator.from_vasp_input(vasp_files=vf) # should invalidate NSCF calcs based on LMAXMIX - assert any(["LMAXMIX" in reason for reason in temp_validation_doc.reasons]) + assert any(["LMAXMIX" in reason for reason in validated.reasons]) # and should *not* create a warning for NSCF calcs - assert not any(["LMAXMIX" in warning for warning in temp_validation_doc.warnings]) - - -@pytest.mark.parametrize( - "object_name", - [ - pytest.param("SiNonSCFUniform", id="SiNonSCFUniform"), - ], -) -def test_nscf_kpoints_checks(object_name): - task_doc = test_data_task_docs[object_name] - task_doc.calcs_reversed[0].output.structure._charge = 0.0 # patch for old test files + assert not any(["LMAXMIX" in warning for warning in validated.warnings]) # Explicit kpoints for NSCF calc check (this should not raise any flags for NSCF calcs) - temp_task_doc = copy.deepcopy(task_doc) - _update_kpoints_for_test( - temp_task_doc, + vf = copy.deepcopy(vf_og) + vf.user_input.kpoints = Kpoints.from_dict( { "kpoints": [[0, 0, 0], [0, 0, 0.5]], "nkpoints": 2, @@ -356,123 +297,104 @@ def test_nscf_kpoints_checks(object_name): "labels": ["Gamma", "X"], "style": "line_mode", "generation_style": "line_mode", - }, + } ) - run_check(temp_task_doc, "INPUT SETTINGS --> KPOINTS: explicitly", True) + run_check(vf, "INPUT SETTINGS --> KPOINTS: explicitly", True) @pytest.mark.parametrize( "object_name", [ - pytest.param("SiOptimizeDouble", id="SiOptimizeDouble"), - # pytest.param("SiStatic", id="SiStatic"), + "Si_uniform", ], ) def test_common_error_checks(object_name): - task_doc = test_data_task_docs[object_name] - task_doc.calcs_reversed[0].output.structure._charge = 0.0 # patch for old test files + vf_og = vasp_calc_data[object_name] + vf_og.vasprun.final_structure._charge = 0.0 # patch for old test files # METAGGA and GGA tag check (should never be set together) - temp_task_doc = copy.deepcopy(task_doc) - temp_task_doc.calcs_reversed[0].input.incar["METAGGA"] = "R2SCAN" - temp_task_doc.calcs_reversed[0].input.incar["GGA"] = "PE" - run_check(temp_task_doc, "KNOWN BUG", False) - - # METAGGA and GGA tag check (should not flag any reasons when METAGGA set to None) - temp_task_doc = copy.deepcopy(task_doc) - temp_task_doc.calcs_reversed[0].input.incar["METAGGA"] = "None" - temp_task_doc.calcs_reversed[0].input.incar["GGA"] = "PE" - run_check(temp_task_doc, "KNOWN BUG", True) - - # No electronic convergence check (i.e. more electronic steps than NELM) - temp_task_doc = copy.deepcopy(task_doc) - temp_task_doc.input.parameters["NELM"] = 1 - run_check(temp_task_doc, "CONVERGENCE --> Did not achieve electronic", False) - - # Drift forces too high check - temp_task_doc = copy.deepcopy(task_doc) - temp_task_doc.calcs_reversed[0].output.outcar["drift"] = [[1, 1, 1]] - run_check(temp_task_doc, "CONVERGENCE --> Excessive drift", False) + with pytest.raises(ValidationError): + vfd = vf_og.model_dump() + vfd["user_input"]["incar"].update( + GGA="PE", + METAGGA="R2SCAN", + ) + VaspFiles(**vfd).valid_input_set + + # Drift forces too high check - a warning + vf = copy.deepcopy(vf_og) + vf.outcar.drift = [[1, 1, 1]] + validated = VaspValidator.from_vasp_input(vasp_files=vf) + assert any("CONVERGENCE --> Excessive drift" in w for w in validated.warnings) # Final energy too high check - temp_task_doc = copy.deepcopy(task_doc) - temp_task_doc.output.energy_per_atom = 100 - run_check(temp_task_doc, "LARGE POSITIVE FINAL ENERGY", False) + vf = copy.deepcopy(vf_og) + vf.vasprun.final_energy = 1e8 + run_check(vf, "LARGE POSITIVE FINAL ENERGY", False) # Excessive final magmom check (no elements Gd or Eu present) - temp_task_doc = copy.deepcopy(task_doc) - temp_task_doc.input.parameters["ISPIN"] = 2 - temp_task_doc.calcs_reversed[0].output.outcar["magnetization"] = ( + vf = copy.deepcopy(vf_og) + vf.user_input.incar["ISPIN"] = 2 + vf.outcar.magnetization = [ {"s": 9.0, "p": 0.0, "d": 0.0, "tot": 9.0}, {"s": 9.0, "p": 0.0, "d": 0.0, "tot": 9.0}, - ) - run_check(temp_task_doc, "MAGNETISM", False) + ] + run_check(vf, "MAGNETISM", False) # Excessive final magmom check (elements Gd or Eu present) # Should pass here, as it has a final magmom < 10 - temp_task_doc = copy.deepcopy(task_doc) - temp_task_doc.input.parameters["ISPIN"] = 2 - temp_task_doc.calcs_reversed[0].input.structure = Structure( + vf = copy.deepcopy(vf_og) + vf.user_input.incar["ISPIN"] = 2 + vf.user_input.structure = Structure( lattice=[[2.9, 0, 0], [0, 2.9, 0], [0, 0, 2.9]], species=["Gd", "Eu"], coords=[[0, 0, 0], [0.5, 0.5, 0.5]], ) - temp_task_doc.calcs_reversed[0].output.outcar["magnetization"] = ( + vf.outcar.magnetization = ( {"s": 9.0, "p": 0.0, "d": 0.0, "tot": 9.0}, {"s": 9.0, "p": 0.0, "d": 0.0, "tot": 9.0}, ) - run_check(temp_task_doc, "MAGNETISM", True) + run_check(vf, "MAGNETISM", True) # Excessive final magmom check (elements Gd or Eu present) # Should not pass here, as it has a final magmom > 10 - temp_task_doc = copy.deepcopy(task_doc) - temp_task_doc.input.parameters["ISPIN"] = 2 - temp_task_doc.calcs_reversed[0].input.structure = Structure( + vf = copy.deepcopy(vf_og) + vf.user_input.incar["ISPIN"] = 2 + vf.user_input.structure = Structure( lattice=[[2.9, 0, 0], [0, 2.9, 0], [0, 0, 2.9]], species=["Gd", "Eu"], coords=[[0, 0, 0], [0.5, 0.5, 0.5]], ) - temp_task_doc.calcs_reversed[0].output.outcar["magnetization"] = ( + vf.outcar.magnetization = ( {"s": 11.0, "p": 0.0, "d": 0.0, "tot": 11.0}, {"s": 11.0, "p": 0.0, "d": 0.0, "tot": 11.0}, ) - run_check(temp_task_doc, "MAGNETISM", False) + run_check(vf, "MAGNETISM", False) - # Element Po present - temp_task_doc = copy.deepcopy(task_doc) - temp_task_doc.chemsys = "Po" - run_check(temp_task_doc, "COMPOSITION", False) + # Element Po / Am present + for unsupported_ele in ("Po", "Am"): + vf = copy.deepcopy(vf_og) + vf.user_input.structure.replace_species({ele: unsupported_ele for ele in vf.user_input.structure.elements}) + with pytest.raises(KeyError): + run_check(vf, "COMPOSITION", False) - # Elements Am present check - temp_task_doc = copy.deepcopy(task_doc) - temp_task_doc.chemsys = "Am" - run_check(temp_task_doc, "COMPOSITION", False) - -def _update_kpoints_for_test(task_doc: TaskDoc, kpoints_updates: dict): - if isinstance(task_doc.calcs_reversed[0].input.kpoints, Kpoints): - kpoints = task_doc.calcs_reversed[0].input.kpoints.as_dict() - elif isinstance(task_doc.calcs_reversed[0].input.kpoints, dict): - kpoints = task_doc.calcs_reversed[0].input.kpoints.copy() +def _update_kpoints_for_test(vf: VaspFiles, kpoints_updates: dict | Kpoints) -> None: + orig_kpoints = vf.user_input.kpoints.as_dict() if vf.user_input.kpoints else {} if isinstance(kpoints_updates, Kpoints): kpoints_updates = kpoints_updates.as_dict() - kpoints.update(kpoints_updates) - task_doc.calcs_reversed[0].input.kpoints = Kpoints.from_dict(kpoints) + orig_kpoints.update(kpoints_updates) + vf.user_input.kpoints = Kpoints.from_dict(orig_kpoints) -@pytest.mark.parametrize( - "object_name", - [ - pytest.param("SiOptimizeDouble", id="SiOptimizeDouble"), - ], -) +@pytest.mark.parametrize("object_name", ["Si_old_double_relax"]) def test_kpoints_checks(object_name): - task_doc = test_data_task_docs[object_name] - task_doc.calcs_reversed[0].output.structure._charge = 0.0 # patch for old test files + vf_og = vasp_calc_data[object_name] + vf_og.vasprun.final_structure._charge = 0.0 # patch for old test files # Valid mesh type check (should flag HCP structures) - temp_task_doc = copy.deepcopy(task_doc) - temp_task_doc.calcs_reversed[0].input.structure = Structure( + vf = copy.deepcopy(vf_og) + vf.user_input.structure = Structure( lattice=[ [0.5, -0.866025403784439, 0], [0.5, 0.866025403784439, 0], @@ -481,38 +403,38 @@ def test_kpoints_checks(object_name): coords=[[0, 0, 0], [0.333333333333333, -0.333333333333333, 0.5]], species=["H", "H"], ) # HCP structure - _update_kpoints_for_test(temp_task_doc, {"generation_style": "monkhorst"}) - run_check(temp_task_doc, "INPUT SETTINGS --> KPOINTS or KGAMMA:", False) + _update_kpoints_for_test(vf, {"generation_style": "monkhorst"}) + run_check(vf, "INPUT SETTINGS --> KPOINTS or KGAMMA:", False) # Valid mesh type check (should flag FCC structures) - temp_task_doc = copy.deepcopy(task_doc) - temp_task_doc.calcs_reversed[0].input.structure = Structure( + vf = copy.deepcopy(vf_og) + vf.user_input.structure = Structure( lattice=[[0.0, 0.5, 0.5], [0.5, 0.0, 0.5], [0.5, 0.5, 0.0]], coords=[[0, 0, 0]], species=["H"], ) # FCC structure - _update_kpoints_for_test(temp_task_doc, {"generation_style": "monkhorst"}) - run_check(temp_task_doc, "INPUT SETTINGS --> KPOINTS or KGAMMA:", False) + _update_kpoints_for_test(vf, {"generation_style": "monkhorst"}) + run_check(vf, "INPUT SETTINGS --> KPOINTS or KGAMMA:", False) # Valid mesh type check (should *not* flag BCC structures) - temp_task_doc = copy.deepcopy(task_doc) - temp_task_doc.calcs_reversed[0].input.structure = Structure( + vf = copy.deepcopy(vf_og) + vf.user_input.structure = Structure( lattice=[[2.9, 0, 0], [0, 2.9, 0], [0, 0, 2.9]], species=["H", "H"], coords=[[0, 0, 0], [0.5, 0.5, 0.5]], ) # BCC structure - _update_kpoints_for_test(temp_task_doc, {"generation_style": "monkhorst"}) - run_check(temp_task_doc, "INPUT SETTINGS --> KPOINTS or KGAMMA:", True) + _update_kpoints_for_test(vf, {"generation_style": "monkhorst"}) + run_check(vf, "INPUT SETTINGS --> KPOINTS or KGAMMA:", True) # Too few kpoints check - temp_task_doc = copy.deepcopy(task_doc) - _update_kpoints_for_test(temp_task_doc, {"kpoints": [[3, 3, 3]]}) - run_check(temp_task_doc, "INPUT SETTINGS --> KPOINTS or KSPACING:", False) + vf = copy.deepcopy(vf_og) + _update_kpoints_for_test(vf, {"kpoints": [[3, 3, 3]]}) + run_check(vf, "INPUT SETTINGS --> KPOINTS or KSPACING:", False) # Explicit kpoints for SCF calc check - temp_task_doc = copy.deepcopy(task_doc) + vf = copy.deepcopy(vf_og) _update_kpoints_for_test( - temp_task_doc, + vf, { "kpoints": [[0, 0, 0], [0, 0, 0.5]], "nkpoints": 2, @@ -521,23 +443,18 @@ def test_kpoints_checks(object_name): "generation_style": "Reciprocal", }, ) - run_check(temp_task_doc, "INPUT SETTINGS --> KPOINTS: explicitly", False) + run_check(vf, "INPUT SETTINGS --> KPOINTS: explicitly", False) # Shifting kpoints for SCF calc check - temp_task_doc = copy.deepcopy(task_doc) - _update_kpoints_for_test(temp_task_doc, {"usershift": [0.5, 0, 0]}) - run_check(temp_task_doc, "INPUT SETTINGS --> KPOINTS: shifting", False) + vf = copy.deepcopy(vf_og) + _update_kpoints_for_test(vf, {"usershift": [0.5, 0, 0]}) + run_check(vf, "INPUT SETTINGS --> KPOINTS: shifting", False) -@pytest.mark.parametrize( - "object_name", - [ - pytest.param("SiOptimizeDouble", id="SiOptimizeDouble"), - ], -) +@pytest.mark.parametrize("object_name", ["Si_old_double_relax"]) def test_vasp_version_check(object_name): - task_doc = test_data_task_docs[object_name] - task_doc.calcs_reversed[0].output.structure._charge = 0.0 # patch for old test files + vf_og = vasp_calc_data[object_name] + vf_og.vasprun.final_structure._charge = 0.0 # patch for old test files vasp_version_list = [ {"vasp_version": "4.0.0", "should_pass": False}, @@ -550,51 +467,26 @@ def test_vasp_version_check(object_name): ] for check_info in vasp_version_list: - temp_task_doc = copy.deepcopy(task_doc) - temp_task_doc.calcs_reversed[0].vasp_version = check_info["vasp_version"] - run_check(temp_task_doc, "VASP VERSION", check_info["should_pass"]) + vf = copy.deepcopy(vf_og) + vf.vasprun.vasp_version = check_info["vasp_version"] + run_check(vf, "VASP VERSION", check_info["should_pass"]) # Check for obscure VASP 5 bug with spin-polarized METAGGA calcs (should fail) - temp_task_doc = copy.deepcopy(task_doc) - temp_task_doc.calcs_reversed[0].vasp_version = "5.0.0" - temp_task_doc.calcs_reversed[0].input.incar["METAGGA"] = "R2SCAN" - temp_task_doc.input.parameters["ISPIN"] = 2 - run_check(temp_task_doc, "POTENTIAL BUG --> We believe", False) - - -def test_task_document(test_dir): - from emmet.core.vasp.task_valid import TaskDocument - - calcs = {} - calcs["compliant"] = loadfn( - str(test_dir / "vasp" / "TaskDocuments" / "MP_compatible_GaAs_r2SCAN_static_TaskDocument.json.gz"), - cls=None, + vf = copy.deepcopy(vf_og) + vf.vasprun.vasp_version = "5.0.0" + vf.user_input.incar.update( + METAGGA="R2SCAN", + ISPIN=2, ) - calcs["non-compliant"] = loadfn( - str(test_dir / "vasp" / "TaskDocuments" / "MP_incompatible_GaAs_r2SCAN_static_TaskDocument.json.gz"), - cls=None, - ) - - valid_docs = {} - for calc in calcs: - valid_docs[calc] = ValidationDoc.from_task_doc(TaskDocument(**calcs[calc])) - # quickly check that `from_dict` and `from_task_doc` give same document - assert set(ValidationDoc.from_dict(calcs[calc]).reasons) == set(valid_docs[calc].reasons) - - assert valid_docs["compliant"].valid - assert not valid_docs["non-compliant"].valid - - expected_reasons = ["KPOINTS", "ENCUT", "ENAUG"] - for expected_reason in expected_reasons: - assert any(expected_reason in reason for reason in valid_docs["non-compliant"].reasons) + run_check(vf, "POTENTIAL BUG --> We believe", False) def test_fast_mode(): - task_doc = test_data_task_docs["SiStatic"] - valid_doc = ValidationDoc.from_task_doc(task_doc, check_potcar=False) + vf = vasp_calc_data["Si_uniform"] + validated = VaspValidator.from_vasp_input(vasp_files=vf, check_potcar=False) # Without POTCAR check, this doc is valid - assert valid_doc.valid + assert validated.valid # Now introduce sequence of changes to test how fast validation works # Check order: @@ -604,59 +496,64 @@ def test_fast_mode(): # 4. POTCAR check # 5. INCAR check - og_kpoints = task_doc.calcs_reversed[0].input.kpoints + og_kpoints = vf.user_input.kpoints # Introduce series of errors, then ablate them # use unacceptable version and set METAGGA and GGA simultaneously -> # should only get version error in reasons - task_doc.calcs_reversed[0].vasp_version = "4.0.0" - task_doc.input.parameters["NBANDS"] = -5 - bad_incar_updates = { - "METAGGA": "R2SCAN", - "GGA": "PE", - } - task_doc.calcs_reversed[0].input.incar.update(bad_incar_updates) - - _update_kpoints_for_test(task_doc, {"kpoints": [[1, 1, 2]]}) - - valid_doc = ValidationDoc.from_task_doc(task_doc, check_potcar=True, fast=True) - assert len(valid_doc.reasons) == 1 - assert "VASP VERSION" in valid_doc.reasons[0] + vf.vasprun.vasp_version = "4.0.0" + vf.vasprun.parameters["NBANDS"] = -5 + # bad_incar_updates = { + # "METAGGA": "R2SCAN", + # "GGA": "PE", + # } + # vf.user_input.incar.update(bad_incar_updates) + # print(vf.user_input.kpoints.as_dict) + _update_kpoints_for_test(vf, {"kpoints": [[1, 1, 2]]}) + + validated = VaspValidator.from_vasp_input(vasp_files=vf, check_potcar=True, fast=True) + assert len(validated.reasons) == 1 + assert "VASP VERSION" in validated.reasons[0] # Now correct version, should just get METAGGA / GGA bug - task_doc.calcs_reversed[0].vasp_version = "6.3.2" - valid_doc = ValidationDoc.from_task_doc(task_doc, check_potcar=True, fast=True) - assert len(valid_doc.reasons) == 1 - assert "KNOWN BUG" in valid_doc.reasons[0] + vf.vasprun.vasp_version = "6.3.2" + # validated = VaspValidator.from_vasp_input(vf, check_potcar=True, fast=True) + # assert len(validated.reasons) == 1 + # assert "KNOWN BUG" in validated.reasons[0] # Now remove GGA tag, get k-point density error - task_doc.calcs_reversed[0].input.incar.pop("GGA") - valid_doc = ValidationDoc.from_task_doc(task_doc, check_potcar=True, fast=True) - assert len(valid_doc.reasons) == 1 - assert "INPUT SETTINGS --> KPOINTS or KSPACING:" in valid_doc.reasons[0] + # vf.user_input.incar.pop("GGA") + validated = VaspValidator.from_vasp_input(vasp_files=vf, check_potcar=True, fast=True) + assert len(validated.reasons) == 1 + assert "INPUT SETTINGS --> KPOINTS or KSPACING:" in validated.reasons[0] + + # Now restore k-points and don't check POTCAR --> get error + _update_kpoints_for_test(vf, og_kpoints) + validated = VaspValidator.from_vasp_input(vasp_files=vf, check_potcar=False, fast=True) + assert len(validated.reasons) == 1 + assert "NBANDS" in validated.reasons[0] - # Now restore k-points and check POTCAR --> get error - _update_kpoints_for_test(task_doc, og_kpoints) - valid_doc = ValidationDoc.from_task_doc(task_doc, check_potcar=True, fast=True) - assert len(valid_doc.reasons) == 1 - assert "PSEUDOPOTENTIALS" in valid_doc.reasons[0] + # Fix NBANDS, get no errors + vf.vasprun.parameters["NBANDS"] = 10 + validated = VaspValidator.from_vasp_input(vasp_files=vf, check_potcar=True, fast=True) + assert len(validated.reasons) == 0 - # Without POTCAR check, should get INCAR check error for NGX - valid_doc = ValidationDoc.from_task_doc(task_doc, check_potcar=False, fast=True) - assert len(valid_doc.reasons) == 1 - assert "NBANDS" in valid_doc.reasons[0] + # Remove POTCAR, should fail validation + vf.user_input.potcar = None + validated = VaspValidator.from_vasp_input(vasp_files=vf, check_potcar=True, fast=True) + assert "PSEUDOPOTENTIALS" in validated.reasons[0] def test_site_properties(test_dir): - task_doc = TaskDoc(**loadfn(test_dir / "vasp" / "mp-1245223_site_props_check.json.gz")) - vd = ValidationDoc.from_task_doc(task_doc) + vf = VaspFiles(**loadfn(test_dir / "vasp" / "mp-1245223_site_props_check.json.gz")) + vd = VaspValidator.from_vasp_input(vasp_files=vf) assert not vd.valid assert any("selective dynamics" in reason.lower() for reason in vd.reasons) # map non-zero velocities to input structure and re-check - task_doc.input.structure.add_site_property( - "velocities", task_doc.orig_inputs.poscar.structure.site_properties["velocities"] + vf.user_input.structure.add_site_property( + "velocities", [[1.0, 2.0, 3.0] for _ in range(len(vf.user_input.structure))] ) - vd = ValidationDoc.from_task_doc(task_doc) + vd = VaspValidator.from_vasp_input(vasp_files=vf) assert any("non-zero velocities" in warning.lower() for warning in vd.warnings) diff --git a/tests/test_validation_without_potcar.py b/tests/test_validation_without_potcar.py new file mode 100644 index 0000000..d788797 --- /dev/null +++ b/tests/test_validation_without_potcar.py @@ -0,0 +1,32 @@ +"""Test validation without using a library of fake POTCARs.""" + +from tempfile import TemporaryDirectory + +from monty.serialization import loadfn +import pytest +from pymatgen.io.vasp import PotcarSingle +from pymatgen.core import SETTINGS as PMG_SETTINGS + +from pymatgen.io.validation.validation import VaspValidator +from pymatgen.io.validation.common import VaspFiles, PotcarSummaryStats + + +def test_validation_without_potcars(test_dir): + with TemporaryDirectory() as tmp_dir: + + pytest.MonkeyPatch().setitem(PMG_SETTINGS, "PMG_VASP_PSP_DIR", tmp_dir) + + # ensure that potcar library is unset to empty temporary directory + with pytest.raises(FileNotFoundError): + PotcarSingle.from_symbol_and_functional(symbol="Si", functional="PBE") + + # Add summary stats to input files + ref_titel = "PAW_PBE Si 05Jan2001" + ref_pspec = PotcarSingle._potcar_summary_stats["PBE"][ref_titel.replace(" ", "")][0] + vf = loadfn(test_dir / "vasp" / "Si_uniform.json.gz") + vf["user_input"]["potcar"] = [PotcarSummaryStats(titel=ref_titel, lexch="PE", **ref_pspec)] + vf["user_input"]["potcar_functional"] = "PBE" + vasp_files = VaspFiles(**vf) + + validated = VaspValidator(vasp_files=vasp_files) + assert validated.valid