-
Notifications
You must be signed in to change notification settings - Fork 867
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Make Incar
keys case insensitive, fix init Incar
from dict val processing for str/float/int
#4122
Changes from 31 commits
b9b3804
a5ba6ca
60e1a48
945368b
f0ebb34
37ac9f5
13b5a67
adcdba7
bae2320
44a06c3
6799044
966bb91
532cad0
a24ac8e
92bfc7e
f1fb45b
fdc40b8
552c006
536eae5
ba0f349
198ce83
a2da974
435320d
51f5922
3b231be
dbaaa98
f8b223c
f30e740
259124d
96ad945
fa58d0d
8d5768e
c350714
48d2755
b2e9a63
9dc98f9
837a011
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -14,6 +14,7 @@ | |
import re | ||
import subprocess | ||
import warnings | ||
from collections import UserDict | ||
from enum import Enum, unique | ||
from glob import glob | ||
from hashlib import sha256 | ||
|
@@ -709,43 +710,64 @@ class BadPoscarWarning(UserWarning): | |
"""Warning class for bad POSCAR entries.""" | ||
|
||
|
||
class Incar(dict, MSONable): | ||
class Incar(UserDict, MSONable): | ||
""" | ||
Read and write INCAR files. | ||
Essentially a dictionary with some helper functions. | ||
Essentially a dictionary with helper functions, where keys are | ||
case-insensitive by being stored in uppercase. This ensures | ||
case-insensitive access for set, get, del, update, and setdefault operations. | ||
""" | ||
|
||
def __init__(self, params: dict[str, Any] | None = None) -> None: | ||
""" | ||
Create an Incar object. | ||
Clean up params and create an Incar object. | ||
|
||
Args: | ||
params (dict): Input parameters as a dictionary. | ||
params (dict): INCAR parameters as a dictionary. | ||
""" | ||
super().__init__() | ||
if params is not None: | ||
# If INCAR contains vector-like MAGMOMS given as a list | ||
# of floats, convert to a list of lists | ||
if (params.get("MAGMOM") and isinstance(params["MAGMOM"][0], int | float)) and ( | ||
params.get("LSORBIT") or params.get("LNONCOLLINEAR") | ||
): | ||
val = [] | ||
for idx in range(len(params["MAGMOM"]) // 3): | ||
val.append(params["MAGMOM"][idx * 3 : (idx + 1) * 3]) | ||
params["MAGMOM"] = val | ||
|
||
self.update(params) | ||
params = params or {} | ||
|
||
# If INCAR contains vector-like MAGMOMS given as a list | ||
# of floats, convert to a list of lists | ||
if (params.get("MAGMOM") and isinstance(params["MAGMOM"][0], int | float)) and ( | ||
params.get("LSORBIT") or params.get("LNONCOLLINEAR") | ||
): | ||
val: list[list] = [] | ||
for idx in range(len(params["MAGMOM"]) // 3): | ||
val.append(params["MAGMOM"][idx * 3 : (idx + 1) * 3]) | ||
params["MAGMOM"] = val | ||
|
||
super().__init__(params) | ||
|
||
def __setitem__(self, key: str, val: Any) -> None: | ||
""" | ||
Add parameter-val pair to Incar. Warn if parameter is not in list of | ||
valid INCAR tags. Also clean the parameter and val by stripping | ||
leading and trailing white spaces. | ||
Add parameter-val pair to Incar. | ||
- Clean the parameter and val by stripping leading | ||
and trailing white spaces. | ||
- Cast keys to upper case. | ||
|
||
Warnings: | ||
BadIncarWarning: If there are duplicate in keys (case insensitive). | ||
""" | ||
super().__setitem__( | ||
key.strip().upper(), | ||
type(self).proc_val(key.strip(), val.strip()) if isinstance(val, str) else val, | ||
) | ||
# Check for case-insensitive duplicate keys | ||
if (key := key.strip().upper()) in self: | ||
warnings.warn(f"Duplicate key found (case-insensitive): {key}", BadIncarWarning, stacklevel=2) | ||
|
||
# Cast float/int to str such that proc_val would clean up their types | ||
val = self.proc_val(key, str(val)) if isinstance(val, str | float | int) else val | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This change: - val = self.proc_val(key, str(val)) if isinstance(val, str) else val
+ val = self.proc_val(key, str(val)) if isinstance(val, str | float | int) else val should have a healthy dose of stupidity as I believe casting |
||
super().__setitem__(key, val) | ||
|
||
def __getitem__(self, key: str) -> Any: | ||
""" | ||
Get value using a case-insensitive key. | ||
""" | ||
return super().__getitem__(key.strip().upper()) | ||
|
||
def __delitem__(self, key: str) -> None: | ||
super().__delitem__(key.strip().upper()) | ||
|
||
def __contains__(self, key: str) -> bool: | ||
return super().__contains__(key.upper().strip()) | ||
|
||
def __str__(self) -> str: | ||
return self.get_str(sort_keys=True, pretty=False) | ||
|
@@ -762,6 +784,12 @@ def __add__(self, other: Self) -> Self: | |
params[key] = val | ||
return type(self)(params) | ||
|
||
def get(self, key: str, default: Any = None) -> Any: | ||
""" | ||
Get a value for a case-insensitive key, return default if not found. | ||
""" | ||
return super().get(key.strip().upper(), default) | ||
|
||
def as_dict(self) -> dict: | ||
"""MSONable dict.""" | ||
dct = dict(self) | ||
|
@@ -854,24 +882,23 @@ def from_str(cls, string: str) -> Self: | |
Returns: | ||
Incar object | ||
""" | ||
lines: list[str] = list(clean_lines(string.splitlines())) | ||
params: dict[str, Any] = {} | ||
for line in lines: | ||
for line in clean_lines(string.splitlines()): | ||
for sline in line.split(";"): | ||
if match := re.match(r"(\w+)\s*=\s*(.*)", sline.strip()): | ||
key: str = match[1].strip() | ||
val: Any = match[2].strip() | ||
val: str = match[2].strip() | ||
params[key] = cls.proc_val(key, val) | ||
return cls(params) | ||
|
||
@staticmethod | ||
def proc_val(key: str, val: Any) -> list | bool | float | int | str: | ||
def proc_val(key: str, val: str) -> list | bool | float | int | str: | ||
"""Helper method to convert INCAR parameters to proper types | ||
like ints, floats, lists, etc. | ||
|
||
Args: | ||
key (str): INCAR parameter key | ||
val (Any): Value of INCAR parameter. | ||
key (str): INCAR parameter key. | ||
val (str): Value of INCAR parameter. | ||
""" | ||
list_keys = ( | ||
"LDAUU", | ||
|
@@ -906,6 +933,7 @@ def proc_val(key: str, val: Any) -> list | bool | float | int | str: | |
"AGGAC", | ||
"PARAM1", | ||
"PARAM2", | ||
"ENCUT", | ||
) | ||
int_keys = ( | ||
"NSW", | ||
|
@@ -921,7 +949,6 @@ def proc_val(key: str, val: Any) -> list | bool | float | int | str: | |
"NPAR", | ||
"LDAUPRINT", | ||
"LMAXMIX", | ||
"ENCUT", | ||
"NSIM", | ||
"NKRED", | ||
"NUPDOWN", | ||
|
@@ -931,7 +958,7 @@ def proc_val(key: str, val: Any) -> list | bool | float | int | str: | |
) | ||
lower_str_keys = ("ML_MODE",) | ||
|
||
def smart_int_or_float(num_str: str) -> str | float: | ||
def smart_int_or_float(num_str: str) -> float: | ||
"""Determine whether a string represents an integer or a float.""" | ||
if "." in num_str or "e" in num_str.lower(): | ||
return float(num_str) | ||
|
@@ -1032,7 +1059,7 @@ def check_params(self) -> None: | |
warnings.warn(f"Cannot find {tag} in the list of INCAR tags", BadIncarWarning, stacklevel=2) | ||
continue | ||
|
||
# Check value and its type | ||
# Check value type | ||
param_type: str = incar_params[tag].get("type") | ||
allowed_values: list[Any] = incar_params[tag].get("values") | ||
|
||
|
@@ -1041,8 +1068,19 @@ def check_params(self) -> None: | |
|
||
# Only check value when it's not None, | ||
# meaning there is recording for corresponding value | ||
if allowed_values is not None and val not in allowed_values: | ||
warnings.warn(f"{tag}: Cannot find {val} in the list of values", BadIncarWarning, stacklevel=2) | ||
if allowed_values is not None: | ||
original_val = val | ||
|
||
# Remove case sensitivitiy for string keywords | ||
# Note: param_type could be a Union type, e.g. "str | bool" | ||
if "str" in param_type: | ||
val = val.lower() if isinstance(val, str) else val | ||
allowed_values = [item.lower() if isinstance(item, str) else item for item in allowed_values] | ||
|
||
if val not in allowed_values: | ||
warnings.warn( | ||
f"{tag}: Cannot find {original_val} in the list of values", BadIncarWarning, stacklevel=2 | ||
) | ||
|
||
|
||
class BadIncarWarning(UserWarning): | ||
|
@@ -1712,6 +1750,7 @@ def _parse_int(string: str) -> int: | |
|
||
|
||
def _parse_list(string: str) -> list[float]: | ||
"""Parse a list of floats from a string.""" | ||
return [float(y) for y in re.split(r"\s+", string.strip()) if not y.isalpha()] | ||
|
||
|
||
|
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
@@ -140,7 +140,7 @@ def _parse_from_incar(filename: PathLike, key: str) -> Any: | |||||||||||||||||||||||||||||||||||||||||
dirname = os.path.dirname(filename) | ||||||||||||||||||||||||||||||||||||||||||
for fn in os.listdir(dirname): | ||||||||||||||||||||||||||||||||||||||||||
if re.search("INCAR", fn): | ||||||||||||||||||||||||||||||||||||||||||
warnings.warn(f"INCAR found. Using {key} from INCAR.") | ||||||||||||||||||||||||||||||||||||||||||
warnings.warn(f"INCAR found. Using {key} from INCAR.", stacklevel=2) | ||||||||||||||||||||||||||||||||||||||||||
incar = Incar.from_file(os.path.join(dirname, fn)) | ||||||||||||||||||||||||||||||||||||||||||
return incar.get(key, None) | ||||||||||||||||||||||||||||||||||||||||||
return None | ||||||||||||||||||||||||||||||||||||||||||
|
@@ -345,7 +345,7 @@ def __init__( | |||||||||||||||||||||||||||||||||||||||||
self.update_potcar_spec(parse_potcar_file) | ||||||||||||||||||||||||||||||||||||||||||
self.update_charge_from_potcar(parse_potcar_file) | ||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||
if self.incar.get("ALGO") not in {"CHI", "BSE"} and not self.converged and self.parameters.get("IBRION") != 0: | ||||||||||||||||||||||||||||||||||||||||||
if self.incar.get("ALGO") not in {"Chi", "Bse"} and not self.converged and self.parameters.get("IBRION") != 0: | ||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Before migrating to subclassing pymatgen/src/pymatgen/io/vasp/inputs.py Lines 718 to 737 in ed8542d
|
||||||||||||||||||||||||||||||||||||||||||
msg = f"{filename} is an unconverged VASP run.\n" | ||||||||||||||||||||||||||||||||||||||||||
msg += f"Electronic convergence reached: {self.converged_electronic}.\n" | ||||||||||||||||||||||||||||||||||||||||||
msg += f"Ionic convergence reached: {self.converged_ionic}." | ||||||||||||||||||||||||||||||||||||||||||
|
@@ -364,10 +364,10 @@ def _parse( | |||||||||||||||||||||||||||||||||||||||||
self.projected_magnetisation: NDArray | None = None | ||||||||||||||||||||||||||||||||||||||||||
self.dielectric_data: dict[str, tuple] = {} | ||||||||||||||||||||||||||||||||||||||||||
self.other_dielectric: dict[str, tuple] = {} | ||||||||||||||||||||||||||||||||||||||||||
self.incar: dict[str, Any] = {} | ||||||||||||||||||||||||||||||||||||||||||
self.incar: Incar = {} | ||||||||||||||||||||||||||||||||||||||||||
self.kpoints_opt_props: KpointOptProps | None = None | ||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||
ionic_steps: list = [] | ||||||||||||||||||||||||||||||||||||||||||
ionic_steps: list[dict] = [] | ||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||
md_data: list[dict] = [] | ||||||||||||||||||||||||||||||||||||||||||
parsed_header: bool = False | ||||||||||||||||||||||||||||||||||||||||||
|
@@ -1355,9 +1355,9 @@ def as_dict(self) -> dict: | |||||||||||||||||||||||||||||||||||||||||
dct["output"] = vout | ||||||||||||||||||||||||||||||||||||||||||
return jsanitize(dct, strict=True) | ||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||
def _parse_params(self, elem: XML_Element) -> dict: | ||||||||||||||||||||||||||||||||||||||||||
"""Parse INCAR parameters.""" | ||||||||||||||||||||||||||||||||||||||||||
params: dict = {} | ||||||||||||||||||||||||||||||||||||||||||
def _parse_params(self, elem: XML_Element) -> Incar[str, Any]: | ||||||||||||||||||||||||||||||||||||||||||
"""Parse INCAR parameters and more.""" | ||||||||||||||||||||||||||||||||||||||||||
params: dict[str, Any] = {} | ||||||||||||||||||||||||||||||||||||||||||
for c in elem: | ||||||||||||||||||||||||||||||||||||||||||
# VASP 6.4.3 can add trailing whitespace | ||||||||||||||||||||||||||||||||||||||||||
# for example, <i type="string" name="GGA ">PE</i> | ||||||||||||||||||||||||||||||||||||||||||
|
@@ -1369,6 +1369,7 @@ def _parse_params(self, elem: XML_Element) -> dict: | |||||||||||||||||||||||||||||||||||||||||
# which overrides the values in the root params. | ||||||||||||||||||||||||||||||||||||||||||
p = {k: v for k, v in p.items() if k not in params} | ||||||||||||||||||||||||||||||||||||||||||
params |= p | ||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||
else: | ||||||||||||||||||||||||||||||||||||||||||
ptype = c.attrib.get("type", "") | ||||||||||||||||||||||||||||||||||||||||||
val = c.text.strip() if c.text else "" | ||||||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Subclassing
UserDict
would make our life much easier, otherwise some methods likeupdate/setdefault
would not call the overridden setter method, and I believe that's the recommended way to create a custom dict:Similar to our previous discussion on
Lobsterin
which is also a case-insensitive dict:pymatgen/src/pymatgen/io/lobster/inputs.py
Line 56 in ed8542d
Perhaps we should consider writing a reusable key case-insensitive dict at some point instead of writing this over and over again.
Or use (now that we do have requests as a dependency):