"""
CASTEP HELPER
Check for errors in input dictionary
"""
import json
import logging
import os
from glob import glob
logger = logging.getLogger(__name__)
path = os.path.abspath(__file__)
module_path = os.path.dirname(path)
incompatible_keys = [
("backup_interval", "num_backup_iter"),
("opt_strategy", "opt_strategy_bias"),
("basis_precision", "cut_off_energy"),
("fine_grid_scale", "fine_gmax", "find_cut_off_energy"),
("nelectrons", "charge"),
("nelectrons", "nup"),
("nelectrons", "ndown"),
("charge", "ndown"),
("charge", "nup"),
("spin", "nup"),
("spin", "ndown"),
("nextra_bands", "perc_extra_bands"),
("metals_method", "elec_method"),
]
[docs]class HelperCheckError(RuntimeError):
pass
[docs]class CastepHelper:
"""
A class for helping castep inputs
"""
[docs] def __init__(self, version=None):
# Instance level parameter for by passing any check at all
self.BY_PASS = False
self._HELP_DICT = None
self.version = version
# Try to load th helper dictionary
self.load_helper_dict()
[docs] def load_helper_dict(self):
"""
Attempts to load a dictionary containing the helper information.
This dictionary should be generated by the .generate module
We look for .castep_helpinfo.json in the $HOME directory.
"""
pairs = find_help_info()
path = None
for p, v in pairs:
if v == self.version:
path = p
break
# If we did not found a suitable version
if path is None:
if self.version is not None:
raise RuntimeError(
"Cannot found help info for requested version {}".format(
self.version
)
)
try:
path = pairs[0][0] # No explicitly requested - use the first one
except IndexError:
print("No CASTEP help info detected")
self.BY_PASS = True
help_dict = {}
else:
help_dict = load_json(path)
else:
help_dict = load_json(path)
# Save the help_dict as a class attribute
self._HELP_DICT = help_dict
@property
def help_dict(self):
"""A dictionary containing information of the helper"""
return self._HELP_DICT
@property
def castep_help_version(self):
"""Version number of CASTEP that the help info is for"""
return self.help_dict["_CASTEP_VERSION"]
[docs] def save_helper_dict(self, help_dict, file_path):
"""
Save the helper json file in to sensible location. By default it is here this module is
"""
try:
with open(file_path, "w") as fp:
json.dump(help_dict, fp)
except OSError:
try:
with ("castep_helpinfo.json", "w") as fp:
json.dump(help_dict, fp)
file_path = os.path.realpath("castep_helpinfo.json")
print(
"Saving in current path. "
"Please move it to {}".format(file_path)
)
except OSError:
print("Cannot save the retrieved help information")
return
print(f"\n\nJSON file saved in {file_path}")
[docs] def _check_dict(self, input_dict):
"""
Check a dictionary of inputs. Return invalid and wrong keys
:param dict input_dict: A dictonary with "PARAM" and "CELL" keys.
:return: A list of [invalid_keys, wrong_keys].
wrong_keys is a tuple of where the key should have being.
(key, "PARAM") or (key, "CELL")
"""
invalid_keys = []
wrong_keys = [] # a list of tulple (key, "PARAM") or (key, "CELL")
if self.BY_PASS:
return invalid_keys, wrong_keys
for kwtype in input_dict:
# Check each key
for key in input_dict[kwtype]:
# key maybe be both lower or upper case
info = self.help_dict.get(key.lower(), None)
if info is None:
invalid_keys.append(key)
continue
# Check if the type is correct
if info["key_type"] != kwtype:
wrong_keys.append((key, info["key_type"]))
continue
return invalid_keys, wrong_keys
[docs] def _from_flat_dict(self, input_dict):
"""
Construct a {"PARAM":{}, "CELL":{}} dictionary from dictionary with top-level keys other
than PARAM and CELL
:returns: a list of [out_dict, keys_not_found]
"""
if self.BY_PASS:
raise RuntimeError("Cannot construct dictionary - No help info found")
hinfo = self.help_dict
# extract copies of CELL and PARAM fields from the input
cell_dict = dict(input_dict.get("CELL", {}))
param_dict = dict(input_dict.get("PARAM", {}))
not_found = []
for key in input_dict:
key_entry = hinfo.get(key, None)
if key_entry is None:
not_found.append(key)
else:
kwtype = key_entry.get("key_type")
if kwtype == "CELL":
cell_dict.update({key: input_dict[key]})
elif kwtype == "PARAM":
param_dict.update({key: input_dict[key]})
else:
raise RuntimeError(f"Entry {key} does not have key_type value")
out_dict = {"CELL": cell_dict, "PARAM": param_dict}
return out_dict, not_found
[docs] def check_dict(self, input_dict, auto_fix=True, allow_flat=False):
"""
Check input dictionary. Apply and warn about errors
:param input_dict dictionary: a dictionary as the input, contain "CELL", "PARAM" and other keywords
:param auto_fix bool: Whether we should fix error automatically
:param allow_flat: Accept that the input dictionary is flat.
:returns dict: A structured dictionary
"""
input_dict = input_dict.copy() # this is a shallow copy
# construct what to be checked
cell_dict = input_dict.pop("CELL", {})
param_dict = input_dict.pop("PARAM", {})
if input_dict and not auto_fix and not allow_flat:
raise HelperCheckError(
"keywords: {} at top level".format(", ".join(input_dict))
)
# Following functions require help info to be defined
if self.BY_PASS:
logger.warning("No help info found - input not checked")
return input_dict
# process what's left
re_structured, not_found = self._from_flat_dict(input_dict)
if not_found:
suggests = [self.get_suggestion(s) for s in not_found]
# Warnings
not_reco = [
f"keyword '{s}' is not recognized at top level" for s in not_found
]
# Suggestions
sugst_str = [a + "\n" + b for a, b in zip(not_reco, suggests)]
# Combine warnings and suggestions together
sugst_str = "\n\n".join(sugst_str)
raise HelperCheckError(sugst_str)
# Now construct a dictionary
cell_dict.update(re_structured["CELL"])
param_dict.update(re_structured["PARAM"])
# Construct an new dictionary
input_dict = dict(CELL=cell_dict, PARAM=param_dict)
# Check the restructed dictionary
invalid, wrong = self._check_dict(input_dict)
if invalid:
suggests = [self.get_suggestion(s) for s in invalid]
not_founds = [f"keyword '{s}' is not found" for s in invalid]
sugst_str = [a + "\n" + b for a, b in zip(not_founds, suggests)]
sugst_str = "\n\n".join(sugst_str)
raise HelperCheckError(sugst_str)
# if there are still wrong keywords, fix them
if wrong:
if auto_fix is True:
for key, should_be in wrong:
if should_be == "PARAM":
logger.warning(f"Key {key} moved to PARAM")
value = input_dict["CELL"].pop(key)
input_dict["PARAM"].update({key: value})
else:
logger.warning(f"Key {key} moved to CELL")
value = input_dict["PARAM"].pop(key)
input_dict["CELL"].update({key: value})
else:
raise HelperCheckError(
"Keywords: {} are in "
"the wrong sub-dictionary".format(
", ".join([f"'{k[0]}'" for k in wrong])
)
)
# Check incompatible keys
incomp = check_incompatible(input_dict["PARAM"], incompatible_keys)
if incomp:
raise HelperCheckError(
"Incompatible keys found: {}" " - only one is allowed.".format(incomp)
)
return input_dict
[docs] def get_suggestion(self, string):
"""
Return string for suggestion of the string
"""
return _get_suggestion(string, list(self.help_dict.keys()))
[docs]def _get_suggestion(provided_string, allowed_strings):
"""
Given a string and a list of allowed_strings, it returns a string to print
on screen, with sensible text depending on whether no suggestion is found,
or one or more than one suggestions are found.
:param provided_string: the string to compare
:param allowed_strings: a list of valid strings
:return: A string to print on output, to suggest to the user
a possible valid value.
"""
import difflib
similar_kws = difflib.get_close_matches(provided_string, allowed_strings)
if len(similar_kws) == 1:
return f"(Maybe you wanted to specify {similar_kws[0]}?)"
elif len(similar_kws) > 1:
return "(Maybe you wanted to specify one of these: {}?)".format(
", ".join(similar_kws)
)
else:
return "(No similar keywords found...)"
[docs]def load_json(path):
"""
Load a json via file path
"""
with open(path) as f:
res = json.load(f)
return res
[docs]def check_incompatible(dict_in, inc_list):
"""
Check any conflicting keys in the dictionary
"""
for incomp in inc_list:
c = 0
for k in dict_in:
if k in incomp:
c += 1
if c > 1:
return incomp
return
[docs]def find_help_info():
"""
Return possible paths of helper dict
The diction should store in the format:
castep_help_info_<version>.json
Search paths are the module path and the $HOME
:returns: [path1, path2], [verion1, version2].....
"""
home = os.getenv("HOME")
default_file_path = os.path.join(module_path, "*castep_help_info_*.json")
others = glob(os.path.join(home, ".*castep_help_info_*.json"))
paths = glob(default_file_path) + others
vs = []
for p in paths:
tmp = p.replace(".json", "").split("_")
if len(tmp) == 4:
vs.append(float(tmp[-1]))
else:
vs.append(0)
comb = list(zip(paths, vs))
comb.sort(key=lambda x: x[1], reverse=True)
return comb