From 34420c4db35b8cf71d7316657a5259cfa021416a Mon Sep 17 00:00:00 2001 From: zulissimeta <122578103+zulissimeta@users.noreply.github.com> Date: Mon, 11 Mar 2024 09:43:43 -0700 Subject: [PATCH] Make custodian threadsafe (explicit file paths) (#317) * First pass at a threadsafe implementation * small typos, cwd->directory everywhere it makes sense * pre-commit auto-fixes * lint * lint * explicit loadfn target * fix second vasp popen, make sure all directories go to vaspmodder init * more small edits, including ansible modder * fix ansible mods * typo in vasp handler VaspModder * fix custodian examplejob tests * lint fix * remove vaspjob directory attribute * bug in backup * small bugs in vasp jobs * stab at lobster updates * Retrigger test suite * stab at nwchem * Retrigger test * stab at feff * Retrigger test * stab at cp2k * Retrigger test * remove vestigial nwchem _mod_input * pre-commit auto-fixes --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Andrew S. Rosen --- custodian/ansible/actions.py | 55 ++++--- custodian/ansible/interpreter.py | 5 +- custodian/cp2k/handlers.py | 92 ++++++------ custodian/cp2k/interpreter.py | 11 +- custodian/cp2k/jobs.py | 62 +++++--- custodian/cp2k/validators.py | 19 +-- custodian/custodian.py | 88 ++++++------ custodian/feff/handlers.py | 25 ++-- custodian/feff/interpreter.py | 10 +- custodian/feff/jobs.py | 23 +-- custodian/lobster/handlers.py | 16 +-- custodian/lobster/jobs.py | 33 +++-- custodian/nwchem/handlers.py | 26 ++-- custodian/nwchem/jobs.py | 13 +- custodian/utils.py | 7 +- custodian/vasp/handlers.py | 240 +++++++++++++++---------------- custodian/vasp/interpreter.py | 9 +- custodian/vasp/jobs.py | 187 +++++++++++++++--------- custodian/vasp/validators.py | 36 ++--- tests/test_custodian.py | 28 ++-- 20 files changed, 539 insertions(+), 446 deletions(-) diff --git a/custodian/ansible/actions.py b/custodian/ansible/actions.py index 11c535d1..a975fb53 100644 --- a/custodian/ansible/actions.py +++ b/custodian/ansible/actions.py @@ -46,39 +46,42 @@ class DictActions: """ @staticmethod - def set(input_dict, settings): + def set(input_dict, settings, directory=None): """ Sets a value using MongoDB syntax. Args: input_dict (dict): The input dictionary to be modified. settings (dict): The specification of the modification to be made. + directory (None): dummy parameter for compatibility with FileActions """ for k, v in settings.items(): (d, key) = get_nested_dict(input_dict, k) d[key] = v @staticmethod - def unset(input_dict, settings): + def unset(input_dict, settings, directory=None): """ Unset a value using MongoDB syntax. Args: input_dict (dict): The input dictionary to be modified. settings (dict): The specification of the modification to be made. + directory (None): dummy parameter for compatibility with FileActions """ for key in settings: dct, inner_key = get_nested_dict(input_dict, key) del dct[inner_key] @staticmethod - def push(input_dict, settings): + def push(input_dict, settings, directory=None): """ Push to a list using MongoDB syntax. Args: input_dict (dict): The input dictionary to be modified. settings (dict): The specification of the modification to be made. + directory (None): dummy parameter for compatibility with FileActions """ for k, v in settings.items(): (d, key) = get_nested_dict(input_dict, k) @@ -88,13 +91,14 @@ def push(input_dict, settings): d[key] = [v] @staticmethod - def push_all(input_dict, settings): + def push_all(input_dict, settings, directory=None): """ Push multiple items to a list using MongoDB syntax. Args: input_dict (dict): The input dictionary to be modified. settings (dict): The specification of the modification to be made. + directory (None): dummy parameter for compatibility with FileActions """ for k, v in settings.items(): (d, key) = get_nested_dict(input_dict, k) @@ -104,13 +108,14 @@ def push_all(input_dict, settings): d[key] = v @staticmethod - def inc(input_dict, settings): + def inc(input_dict, settings, directory=None): """ Increment a value using MongdoDB syntax. Args: input_dict (dict): The input dictionary to be modified. settings (dict): The specification of the modification to be made. + directory (None): dummy parameter for compatibility with FileActions """ for k, v in settings.items(): (d, key) = get_nested_dict(input_dict, k) @@ -120,26 +125,28 @@ def inc(input_dict, settings): d[key] = v @staticmethod - def rename(input_dict, settings): + def rename(input_dict, settings, directory=None): """ Rename a key using MongoDB syntax. Args: input_dict (dict): The input dictionary to be modified. settings (dict): The specification of the modification to be made. + directory (None): dummy parameter for compatibility with FileActions """ for key, v in settings.items(): if val := input_dict.pop(key, None): input_dict[v] = val @staticmethod - def add_to_set(input_dict, settings): + def add_to_set(input_dict, settings, directory=None): """ Add to set using MongoDB syntax. Args: input_dict (dict): The input dictionary to be modified. settings (dict): The specification of the modification to be made. + directory (None): dummy parameter for compatibility with FileActions """ for k, v in settings.items(): (d, key) = get_nested_dict(input_dict, k) @@ -151,13 +158,14 @@ def add_to_set(input_dict, settings): d[key] = v @staticmethod - def pull(input_dict, settings): + def pull(input_dict, settings, directory=None): """ Pull an item using MongoDB syntax. Args: input_dict (dict): The input dictionary to be modified. settings (dict): The specification of the modification to be made. + directory (None): dummy parameter for compatibility with FileActions """ for k, v in settings.items(): (d, key) = get_nested_dict(input_dict, k) @@ -167,13 +175,14 @@ def pull(input_dict, settings): d[key] = [i for i in d[key] if i != v] @staticmethod - def pull_all(input_dict, settings): + def pull_all(input_dict, settings, directory=None): """ Pull multiple items to a list using MongoDB syntax. Args: input_dict (dict): The input dictionary to be modified. settings (dict): The specification of the modification to be made. + directory (None): dummy parameter for compatibility with FileActions """ for k, v in settings.items(): if k in input_dict and (not isinstance(input_dict[k], list)): @@ -182,13 +191,14 @@ def pull_all(input_dict, settings): DictActions.pull(input_dict, {k: i}) @staticmethod - def pop(input_dict, settings): + def pop(input_dict, settings, directory=None): """ Pop item from a list using MongoDB syntax. Args: input_dict (dict): The input dictionary to be modified. settings (dict): The specification of the modification to be made. + directory (None): dummy parameter for compatibility with FileActions """ for k, v in settings.items(): (d, key) = get_nested_dict(input_dict, k) @@ -208,13 +218,14 @@ class FileActions: """ @staticmethod - def file_create(filename, settings): + def file_create(filename, settings, directory): """ Creates a file. Args: filename (str): Filename. settings (dict): Must be {"content": actual_content} + directory (str): Directory to create file in """ if len(settings) != 1: raise ValueError("Settings must only contain one item with key 'content'.") @@ -224,22 +235,23 @@ def file_create(filename, settings): f.write(v) @staticmethod - def file_move(filename, settings): + def file_move(filename, settings, directory): """ Moves a file. {'_file_move': {'dest': 'new_file_name'}}. Args: filename (str): Filename. settings (dict): Must be {"dest": path of new file} + directory (str): Directory to move file from and to """ if len(settings) != 1: raise ValueError("Settings must only contain one item with key 'dest'.") for k, v in settings.items(): if k == "dest": - shutil.move(filename, v) + shutil.move(os.path.join(directory, filename), os.path.join(directory, v)) @staticmethod - def file_delete(filename, settings): + def file_delete(filename, settings, directory): """ Deletes a file. {'_file_delete': {'mode': "actual"}}. @@ -247,13 +259,14 @@ def file_delete(filename, settings): filename (str): Filename. settings (dict): Must be {"mode": actual/simulated}. Simulated mode only prints the action without performing it. + directory (str): Directory to delete file in """ if len(settings) != 1: raise ValueError("Settings must only contain one item with key 'mode'.") for k, v in settings.items(): if k == "mode" and v == "actual": try: - os.remove(filename) + os.remove(os.path.join(directory, filename)) except OSError: # Skip file not found error. pass @@ -261,29 +274,31 @@ def file_delete(filename, settings): print(f"Simulated removal of {filename}") @staticmethod - def file_copy(filename, settings): + def file_copy(filename, settings, directory): """ Copies a file. {'_file_copy': {'dest': 'new_file_name'}}. Args: filename (str): Filename. settings (dict): Must be {"dest": path of new file} + directory (str): Directory to copy file to/from """ for k, v in settings.items(): if k.startswith("dest"): - shutil.copyfile(filename, v) + shutil.copyfile(os.path.join(directory, filename), os.path.join(directory, v)) @staticmethod - def file_modify(filename, settings): + def file_modify(filename, settings, directory): """ Modifies file access. Args: filename (str): Filename. settings (dict): Can be "mode" or "owners" + directory (str): Directory to modify file in """ for k, v in settings.items(): if k == "mode": - os.chmod(filename, v) + os.chmod(os.path.join(directory, filename), v) if k == "owners": - os.chown(filename, v) + os.chown(os.path.join(directory, filename), v) diff --git a/custodian/ansible/interpreter.py b/custodian/ansible/interpreter.py index b78a0f2c..e53bbef4 100644 --- a/custodian/ansible/interpreter.py +++ b/custodian/ansible/interpreter.py @@ -30,7 +30,7 @@ class Modder: 'Universe' """ - def __init__(self, actions=None, strict=True): + def __init__(self, actions=None, strict=True, directory="./"): """Initialize a Modder from a list of supported actions. Args: @@ -49,6 +49,7 @@ def __init__(self, actions=None, strict=True): if (not re.match(r"__\w+__", i)) and callable(getattr(action, i)): self.supported_actions["_" + i] = getattr(action, i) self.strict = strict + self.directory = directory def modify(self, modification, obj): """ @@ -65,7 +66,7 @@ def modify(self, modification, obj): """ for action, settings in modification.items(): if action in self.supported_actions: - self.supported_actions[action](obj, settings) + self.supported_actions[action](obj, settings, directory=self.directory) elif self.strict: raise ValueError(f"{action} is not a supported action!") diff --git a/custodian/cp2k/handlers.py b/custodian/cp2k/handlers.py index 38cbdaa0..2ffa6a23 100644 --- a/custodian/cp2k/handlers.py +++ b/custodian/cp2k/handlers.py @@ -70,7 +70,7 @@ def __init__(self, std_err="std_err.txt"): self.std_err = std_err self.errors = set() - def check(self): + def check(self, directory="./"): """Check for error in std_err file.""" self.errors = set() with open(self.std_err) as file: @@ -82,7 +82,7 @@ def check(self): self.errors.add(err) return len(self.errors) > 0 - def correct(self): + def correct(self, directory="./"): """Log error, perform no corrections.""" return {"errors": [f"System error(s): {self.errors}"], "actions": []} @@ -147,12 +147,12 @@ def __init__(self, input_file="cp2k.inp", output_file="cp2k.out"): self.scf = None self.restart = None - def check(self): + def check(self, directory="./"): """Check output file for failed SCF convergence.""" # Checks output file for errors. - out = Cp2kOutput(self.output_file, auto_load=False, verbose=False) + out = Cp2kOutput(os.path.join(directory, self.output_file), auto_load=False, verbose=False) out.convergence() - ci = Cp2kInput.from_file(zpath(self.input_file)) + ci = Cp2kInput.from_file(zpath(os.path.join(directory, self.input_file))) self.is_ot = ci.check("FORCE_EVAL/DFT/SCF/OT") if out.filenames.get("restart"): self.restart = out.filenames["restart"][-1] @@ -164,14 +164,14 @@ def check(self): return True return False - def correct(self): - """Apply corrections to aid convergence, if possible.""" - ci = Cp2kInput.from_file(self.input_file) + def correct(self, directory="./"): + """Apply corrections to aid convergence if possible.""" + ci = Cp2kInput.from_file(os.path.join(directory, self.input_file)) actions = self.__correct_ot(ci=ci) if self.is_ot else self.__correct_diag(ci=ci) - restart(actions, self.output_file, self.input_file) - Cp2kModder(ci=ci, filename=self.input_file).apply_actions(actions) + restart(actions, os.path.join(directory, self.output_file), os.path.join(directory, self.input_file)) + Cp2kModder(ci=ci, filename=self.input_file, directory=directory).apply_actions(actions) return {"errors": ["Non-converging Job"], "actions": actions} def __correct_ot(self, ci): @@ -411,17 +411,17 @@ def __init__(self, output_file="cp2k.out", input_file="cp2k.inp"): self.output_file = output_file self.input_file = input_file - def check(self): + def check(self, directory="./"): """Check for diverging SCF.""" - conv = get_conv(self.output_file) + conv = get_conv(os.path.join(directory, self.output_file)) tmp = np.diff(conv[-10:]) if len(conv) > 10 and all(_ > 0 for _ in tmp) and any(_ > 1 for _ in conv): return True return False - def correct(self): + def correct(self, directory="./"): """Correct issue if possible.""" - ci = Cp2kInput.from_file(self.input_file) + ci = Cp2kInput.from_file(os.path.join(directory, self.input_file)) actions = [] p = ci["force_eval"]["dft"]["qs"].get("EPS_DEFAULT", Keyword("EPS_DEFAULT", 1e-10)).values[0] @@ -434,7 +434,7 @@ def correct(self): actions.append( {"dict": self.input_file, "action": {"_set": {"FORCE_EVAL": {"DFT": {"QS": {"EPS_PGF_ORB": 1e-12}}}}}} ) - Cp2kModder(ci=ci, filename=self.input_file).apply_actions(actions) + Cp2kModder(ci=ci, filename=self.input_file, directory=directory).apply_actions(actions) return {"errors": ["Diverging SCF"], "actions": actions} @@ -483,10 +483,10 @@ def __init__(self, input_file="cp2k.inp", output_file="cp2k.out", timeout=3600): self.frozen_preconditioner = False self.restart = None - def check(self): + def check(self, directory="./"): """Check for frozen jobs.""" - st = os.stat(self.output_file) - out = Cp2kOutput(self.output_file, auto_load=False, verbose=False) + st = os.stat(os.path.join(directory, self.output_file)) + out = Cp2kOutput(os.path.join(directory, self.output_file), auto_load=False, verbose=False) try: out.ran_successfully() # If job finished, then hung, don't need to wait very long to confirm frozen @@ -504,9 +504,9 @@ def check(self): return False - def correct(self): + def correct(self, directory="./"): """Correct issue if possible.""" - ci = Cp2kInput.from_file(self.input_file) + ci = Cp2kInput.from_file(os.path.join(directory, self.input_file)) actions = [] errors = [] @@ -583,8 +583,8 @@ def correct(self): else: errors.append("Frozen job") - restart(actions, self.output_file, self.input_file) - Cp2kModder(ci=ci, filename=self.input_file).apply_actions(actions) + restart(actions, os.path.join(directory, self.output_file), os.path.join(self.input_file)) + Cp2kModder(ci=ci, filename=self.input_file, directory=directory).apply_actions(actions) return {"errors": errors, "actions": actions} @@ -622,19 +622,23 @@ def __init__(self, input_file="cp2k.inp", output_file="cp2k.out"): } self.responses = [] - def check(self): + def check(self, directory="./"): """Check for abort messages.""" matches = regrep( - self.output_file, patterns=self.messages, reverse=True, terminate_on_match=True, postprocess=str + os.path.join(directory, self.output_file), + patterns=self.messages, + reverse=True, + terminate_on_match=True, + postprocess=str, ) for m in matches: self.responses.append(m) return True return False - def correct(self): + def correct(self, directory="./"): """Correct issue if possible.""" - ci = Cp2kInput.from_file(self.input_file) + ci = Cp2kInput.from_file(os.path.join(directory, self.input_file)) actions = [] if self.responses[-1] == "cholesky": @@ -736,8 +740,8 @@ def correct(self): } ) - restart(actions, self.output_file, self.input_file) - Cp2kModder(ci=ci, filename=self.input_file).apply_actions(actions) + restart(actions, os.path.join(directory, self.output_file), os.path.join(self.input_file)) + Cp2kModder(ci=ci, filename=self.input_file, directory=directory).apply_actions(actions) return {"errors": [self.responses[-1]], "actions": actions} @@ -819,17 +823,17 @@ def __init__( self.eps_default_strict = eps_default_strict self.eps_gvg_strict = eps_gvg_strict - def check(self): + def check(self, directory="./"): """Check for stuck SCF convergence.""" - conv = get_conv(self.output_file) + conv = get_conv(os.path.join(directory, self.output_file)) counts = [sum(1 for i in g) for k, g in itertools.groupby(conv)] if any(c > self.max_same for c in counts): return True return False - def correct(self): + def correct(self, directory="/."): """Correct issue if possible.""" - ci = Cp2kInput.from_file(self.input_file) + ci = Cp2kInput.from_file(os.path.join(directory, self.input_file)) actions = [] if ci.check("FORCE_EVAL/DFT/XC/HF"): # Hybrid has special considerations @@ -913,8 +917,8 @@ def correct(self): } ) - restart(actions, self.output_file, self.input_file) - Cp2kModder(ci=ci, filename=self.input_file).apply_actions(actions) + restart(actions, os.path.join(directory, self.output_file), os.path.join(self.input_file)) + Cp2kModder(ci=ci, filename=self.input_file, directory=directory).apply_actions(actions) return {"errors": ["Insufficient precision"], "actions": actions} def __set_pgf_orb(self): @@ -968,17 +972,17 @@ def __init__( self.optimizers = optimizers self.optimizer_id = 0 - def check(self): + def check(self, directory="./"): """Check for unconverged geometry optimization.""" - o = Cp2kOutput(self.output_file) + o = Cp2kOutput(os.path.join(directory, self.output_file)) o.convergence() if o.data.get("geo_opt_not_converged"): return True return False - def correct(self): + def correct(self, directory): """Correct issue if possible.""" - ci = Cp2kInput.from_file(self.input_file) + ci = Cp2kInput.from_file(os.path.join(directory, self.input_file)) actions = [] max_iter = ci["motion"]["geo_opt"].get("MAX_ITER", Keyword("", 200)).values[0] @@ -1013,8 +1017,8 @@ def correct(self): ) self.optimizer_id += 1 - restart(actions, self.output_file, self.input_file) - Cp2kModder(ci=ci, filename=self.input_file).apply_actions(actions) + restart(actions, os.path.join(directory, self.output_file), os.path.join(self.input_file)) + Cp2kModder(ci=ci, filename=self.input_file, directory=directory).apply_actions(actions) return {"errors": ["Unsuccessful relaxation"], "actions": actions} @@ -1042,10 +1046,10 @@ def __init__(self, output_file="cp2k.out", enable_checkpointing=True): self.output_file = output_file self.enable_checkpointing = enable_checkpointing - def check(self): + def check(self, directory="./"): """Check if internal CP2K walltime handler was tripped.""" if regrep( - filename=self.output_file, + filename=os.path.join(directory, self.output_file), patterns={"walltime": r"(exceeded requested execution time)"}, reverse=True, terminate_on_match=True, @@ -1054,8 +1058,8 @@ def check(self): return True return False - def correct(self): + def correct(self, directory="./"): """Dump checkpoint info if requested.""" if self.enable_checkpointing: - dumpfn({"_path": os.getcwd()}, fn="checkpoint.json") + dumpfn({"_path": directory}, fn=(os.path.join(directory, "checkpoint.json"))) return {"errors": ["Walltime error"], "actions": []} diff --git a/custodian/cp2k/interpreter.py b/custodian/cp2k/interpreter.py index d6f0871b..f0608e8c 100644 --- a/custodian/cp2k/interpreter.py +++ b/custodian/cp2k/interpreter.py @@ -1,5 +1,7 @@ """CP2K adapted interpreter and modder for custodian.""" +import os + from pymatgen.io.cp2k.inputs import Cp2kInput from custodian.ansible.actions import DictActions, FileActions @@ -18,7 +20,7 @@ class Cp2kModder(Modder): also supports modifications that are file operations (e.g. copying). """ - def __init__(self, filename="cp2k.inp", actions=None, strict=True, ci=None): + def __init__(self, filename="cp2k.inp", actions=None, strict=True, ci=None, directory="./"): """Initialize a Modder for Cp2kInput sets. Args: @@ -35,7 +37,8 @@ def __init__(self, filename="cp2k.inp", actions=None, strict=True, ci=None): Initialized automatically if not passed (but passing it will avoid having to reparse the directory). """ - self.ci = ci or Cp2kInput.from_file(filename) + self.directory = directory + self.ci = ci or Cp2kInput.from_file(os.path.join(self.directory, filename)) self.filename = filename actions = actions or [FileActions, DictActions] super().__init__(actions, strict) @@ -58,11 +61,11 @@ def apply_actions(self, actions): Cp2kModder._modify(a["action"], self.ci) elif "file" in a: self.modify(a["action"], a["file"]) - self.ci = Cp2kInput.from_file(self.filename) + self.ci = Cp2kInput.from_file(os.path.join(self.directory, self.filename)) else: raise ValueError(f"Unrecognized format: {a}") cleanup_input(self.ci) - self.ci.write_file(self.filename) + self.ci.write_file(os.path.join(self.directory, self.filename)) @staticmethod def _modify(modification, obj): diff --git a/custodian/cp2k/jobs.py b/custodian/cp2k/jobs.py index 716a41c6..46228862 100644 --- a/custodian/cp2k/jobs.py +++ b/custodian/cp2k/jobs.py @@ -80,34 +80,34 @@ def __init__( self.settings_override = settings_override if settings_override else [] self.restart = restart - def setup(self): + def setup(self, directory="./"): """ Performs initial setup for Cp2k in three stages. First, if custodian is running in restart mode, then the restart function will copy the restart file to self.input_file, and remove any previous WFN initialization if present. Second, any additional user specified settings will be applied. Lastly, a backup of the input file will be made for reference. """ - decompress_dir(".") + decompress_dir(directory) - self.ci = Cp2kInput.from_file(zpath(self.input_file)) + self.ci = Cp2kInput.from_file(zpath(os.path.join(directory, self.input_file))) cleanup_input(self.ci) if self.restart: restart( actions=self.settings_override, - output_file=self.output_file, - input_file=self.input_file, + output_file=os.path.join(directory, self.output_file), + input_file=os.path.join(directory, self.input_file), no_actions_needed=True, ) if self.settings_override or self.restart: - modder = Cp2kModder(filename=self.input_file, actions=[], ci=self.ci) + modder = Cp2kModder(filename=os.path.join(directory, self.input_file), actions=[], ci=self.ci) modder.apply_actions(self.settings_override) if self.backup: - shutil.copy(self.input_file, f"{self.input_file}.orig") + shutil.copy(os.path.join(directory, self.input_file), os.path.join(directory, f"{self.input_file}.orig")) - def run(self): + def run(self, directory="./"): """ Perform the actual CP2K run. @@ -119,32 +119,35 @@ def run(self): cmd.extend(["-i", self.input_file]) cmd_str = " ".join(cmd) logger.info(f"Running {cmd_str}") - with open(self.output_file, "w") as f_std, open(self.stderr_file, "w", buffering=1) as f_err: + with ( + open(os.path.join(directory, self.output_file), "w") as f_std, + open(os.path.join(directory, self.stderr_file), "w", buffering=1) as f_err, + ): # use line buffering for stderr - return subprocess.Popen(cmd, stdout=f_std, stderr=f_err, shell=False) + return subprocess.Popen(cmd, cwd=directory, stdout=f_std, stderr=f_err, shell=False) # TODO double jobs, file manipulations, etc. should be done in atomate in the future # and custodian should only run the job itself - def postprocess(self): + def postprocess(self, directory="./"): """Postprocessing includes renaming and gzipping where necessary.""" - files = os.listdir(".") + files = os.listdir(directory) if os.path.isfile(self.output_file) and self.suffix != "": os.mkdir(f"run{self.suffix}") for file in files: if "json" in file: continue - if not os.path.isdir(file): + if not os.path.isdir(os.path.join(directory, file)): if self.final: - shutil.move(file, f"run{self.suffix}/{file}") + shutil.move(os.path.join(directory, file), os.path.join(directory, f"run{self.suffix}/{file}")) else: - shutil.copy(file, f"run{self.suffix}/{file}") + shutil.copy(os.path.join(directory, file), os.path.join(directory, f"run{self.suffix}/{file}")) # Remove continuation so if a subsequent job is run in # the same directory, will not restart this job. - if os.path.isfile("continue.json"): - os.remove("continue.json") + if os.path.isfile(os.path.join(directory, "continue.json")): + os.remove(os.path.join(directory, "continue.json")) - def terminate(self): + def terminate(self, directory="./"): """Terminate cp2k.""" for cmd in self.cp2k_cmd: if "cp2k" in cmd: @@ -163,6 +166,7 @@ def gga_static_to_hybrid( backup=True, settings_override_gga=None, settings_override_hybrid=None, + directory="./", ): """ A bare GGA to hybrid calculation. Removes all unnecessary features @@ -194,7 +198,7 @@ def gga_static_to_hybrid( settings_override=job1_settings_override, ) - ci = Cp2kInput.from_file(zpath(input_file)) + ci = Cp2kInput.from_file(zpath(os.path.join(directory, input_file))) run_type = ci["global"].get("run_type", Keyword("RUN_TYPE", "ENERGY_FORCE")).values[0] if run_type in {"ENERGY", "WAVEFUNCTION_OPTIMIZATION", "WFN_OPT", "ENERGY_FORCE"}: # no need for double job return [job1] @@ -238,7 +242,13 @@ def gga_static_to_hybrid( @classmethod def double_job( - cls, cp2k_cmd, input_file="cp2k.inp", output_file="cp2k.out", stderr_file="std_err.txt", backup=True + cls, + cp2k_cmd, + input_file="cp2k.inp", + output_file="cp2k.out", + stderr_file="std_err.txt", + backup=True, + directory="./", ): """ This creates a sequence of two jobs. The first of which is an "initialization" of the @@ -256,7 +266,7 @@ def double_job( suffix="1", settings_override={}, ) - ci = Cp2kInput.from_file(zpath(input_file)) + ci = Cp2kInput.from_file(zpath(os.path.join(directory, input_file))) run_type = ci["global"].get("run_type", Keyword("RUN_TYPE", "ENERGY_FORCE")).values[0] if run_type not in {"ENERGY", "WAVEFUNCTION_OPTIMIZATION", "WFN_OPT"}: job1.settings_override = [ @@ -279,7 +289,13 @@ def double_job( @classmethod def pre_screen_hybrid( - cls, cp2k_cmd, input_file="cp2k.inp", output_file="cp2k.out", stderr_file="std_err.txt", backup=True + cls, + cp2k_cmd, + input_file="cp2k.inp", + output_file="cp2k.out", + stderr_file="std_err.txt", + backup=True, + directory="./", ): """ Build a job where the first job is an unscreened hybrid static calculation, then the second one @@ -319,7 +335,7 @@ def pre_screen_hybrid( settings_override=job1_settings_override, ) - ci = Cp2kInput.from_file(zpath(input_file)) + ci = Cp2kInput.from_file(zpath(os.path.join(directory, input_file))) r = ci["global"].get("run_type", Keyword("RUN_TYPE", "ENERGY_FORCE")).values[0] if r in {"ENERGY", "WAVEFUNCTION_OPTIMIZATION", "WFN_OPT", "ENERGY_FORCE"}: # no need for double job return [job1] diff --git a/custodian/cp2k/validators.py b/custodian/cp2k/validators.py index a31730d7..70c2af11 100644 --- a/custodian/cp2k/validators.py +++ b/custodian/cp2k/validators.py @@ -1,5 +1,6 @@ """Validators for CP2K calculations.""" +import os from abc import abstractmethod, abstractproperty from pymatgen.io.cp2k.outputs import Cp2kOutput @@ -16,22 +17,22 @@ class Cp2kValidator(Validator): """Base validator.""" @abstractmethod - def check(self): + def check(self, directory="./"): """ Check whether validation failed. Here, True means validation failed. """ @abstractproperty - def kill(self): + def kill(self, directory="./"): """Kill the job with raise error.""" @abstractproperty - def exit(self): + def exit(self, directory="./"): """Don't raise error, but exit the job.""" @abstractproperty - def no_children(self): + def no_children(self, directory="./"): """Job should not have children.""" @@ -46,14 +47,14 @@ def __init__(self, output_file="cp2k.out"): self.output_file = output_file self._check = False - def check(self): + def check(self, directory="./"): """ Check for valid output. Checks that the end of the program was reached, and that convergence was achieved. """ try: - o = Cp2kOutput(self.output_file) + o = Cp2kOutput(os.path.join(directory, self.output_file)) o.ran_successfully() o.convergence() if (not o.data.get("geo_opt_converged") and not o.data.get("geo_opt_not_converged")) or o.data.get( @@ -71,16 +72,16 @@ def check(self): return True @property - def kill(self): + def kill(self, directory="./"): """Kill the job with raise error.""" return True @property - def exit(self): + def exit(self, directory="./"): """Don't raise error, but exit the job.""" return True @property - def no_children(self): + def no_children(self, directory="./"): """Job should not have children.""" return True diff --git a/custodian/custodian.py b/custodian/custodian.py index 2925eca6..00651135 100644 --- a/custodian/custodian.py +++ b/custodian/custodian.py @@ -122,6 +122,7 @@ def __init__( checkpoint=False, terminate_func=None, terminate_on_nonzero_returncode=True, + directory=None, **kwargs, ): """Initialize a Custodian from a list of jobs and error handlers. @@ -189,9 +190,11 @@ def __init__( self.scratch_dir = scratch_dir self.gzipped_output = gzipped_output self.checkpoint = checkpoint - cwd = os.getcwd() + if directory is None: + directory = os.getcwd() + self.directory = directory if self.checkpoint: - self.restart, self.run_log = Custodian._load_checkpoint(cwd) + self.restart, self.run_log = Custodian._load_checkpoint(directory) else: self.restart = 0 self.run_log = [] @@ -202,10 +205,10 @@ def __init__( self.finished = False @staticmethod - def _load_checkpoint(cwd): + def _load_checkpoint(directory): restart = 0 run_log = [] - chkpts = glob(os.path.join(cwd, "custodian.chk.*.tar.gz")) + chkpts = glob(os.path.join(directory, "custodian.chk.*.tar.gz")) if chkpts: chkpt = sorted(chkpts, key=lambda c: int(c.split(".")[-3]))[0] restart = int(chkpt.split(".")[-3]) @@ -220,7 +223,7 @@ def is_within_directory(directory, target): return prefix == abs_directory - def safe_extract(tar, path=".", members=None, *, numeric_owner=False): + def safe_extract(tar, path=directory, members=None, *, numeric_owner=False): for member in tar.getmembers(): member_path = os.path.join(path, member.name) if not is_within_directory(path, member_path): @@ -228,24 +231,24 @@ def safe_extract(tar, path=".", members=None, *, numeric_owner=False): tar.extractall(path, members, numeric_owner=numeric_owner) - safe_extract(t) + safe_extract(t, directory) # Log the corrections to a json file. - run_log = loadfn(Custodian.LOG_FILE, cls=MontyDecoder) + run_log = loadfn(os.path.join(directory, Custodian.LOG_FILE), cls=MontyDecoder) return restart, run_log @staticmethod - def _delete_checkpoints(cwd): - for file in glob(os.path.join(cwd, "custodian.chk.*.tar.gz")): + def _delete_checkpoints(directory): + for file in glob(os.path.join(directory, "custodian.chk.*.tar.gz")): os.remove(file) @staticmethod - def _save_checkpoint(cwd, index): + def _save_checkpoint(directory, index): try: - Custodian._delete_checkpoints(cwd) - n = os.path.join(cwd, f"custodian.chk.{index}.tar.gz") + Custodian._delete_checkpoints(directory) + n = os.path.join(directory, f"custodian.chk.{index}.tar.gz") with tarfile.open(n, mode="w:gz", compresslevel=3) as f: - f.add(cwd, arcname=".") + f.add(directory, arcname=".") logger.info(f"Checkpoint written to {n}") except Exception: logger.info("Checkpointing failed") @@ -358,8 +361,6 @@ def run(self): MaxCorrectionsError: if max_errors is reached MaxCorrectionsPerHandlerError: if max_errors_per_handler is reached """ - cwd = os.getcwd() - with ScratchDir( self.scratch_dir, create_symbolic_link=True, @@ -379,20 +380,20 @@ def run(self): for job_n, job in islice(enumerate(self.jobs, 1), self.restart, None): self._run_job(job_n, job) # We do a dump of the run log after each job. - dumpfn(self.run_log, Custodian.LOG_FILE, cls=MontyEncoder, indent=4) + dumpfn(self.run_log, os.path.join(self.directory, Custodian.LOG_FILE), cls=MontyEncoder, indent=4) # Checkpoint after each job so that we can recover from last # point and remove old checkpoints if self.checkpoint: self.restart = job_n - Custodian._save_checkpoint(cwd, job_n) + Custodian._save_checkpoint(self.directory, job_n) except CustodianError as ex: logger.error(ex.message) if ex.raises: raise finally: # Log the corrections to a json file. - logger.info(f"Logging to {Custodian.LOG_FILE}...") - dumpfn(self.run_log, Custodian.LOG_FILE, cls=MontyEncoder, indent=4) + logger.info(f"Logging to {os.path.join(self.directory, Custodian.LOG_FILE)}...") + dumpfn(self.run_log, os.path.join(self.directory, Custodian.LOG_FILE), cls=MontyEncoder, indent=4) end = datetime.datetime.now() logger.info(f"Run ended at {end}.") run_time = end - start @@ -401,7 +402,7 @@ def run(self): gzip_dir(".") # Cleanup checkpoint files (if any) if run is successful. - Custodian._delete_checkpoints(cwd) + Custodian._delete_checkpoints(self.directory) return self.run_log @@ -440,7 +441,7 @@ def _run_job(self, job_n, job): for handler in self.handlers: handler.n_applied_corrections = 0 - job.setup() + job.setup(self.directory) attempt = 0 while self.total_errors < self.max_errors and self.errors_current_job < self.max_errors_per_job: @@ -450,7 +451,7 @@ def _run_job(self, job_n, job): f"errors in job thus far = {self.total_errors}, {self.errors_current_job}." ) - p = job.run() + p = job.run(directory=self.directory) # Check for errors using the error handlers and perform # corrections. has_error = False @@ -502,7 +503,7 @@ def _run_job(self, job_n, job): # postprocessing and exit. if not has_error: for validator in self.validators: - if validator.check(): + if validator.check(self.directory): self.run_log[-1]["validator"] = validator msg = f"Validation failed: {type(validator).__name__}" raise ValidationError(msg, raises=True, validator=validator) @@ -513,7 +514,7 @@ def _run_job(self, job_n, job): logger.info(msg) raise ReturnCodeError(msg, raises=True) warnings.warn("subprocess returned a non-zero return code. Check outputs carefully...") - job.postprocess() + job.postprocess(directory=self.directory) return # Check that all errors could be handled @@ -557,21 +558,20 @@ def run_interrupted(self): """ start = datetime.datetime.now() try: - cwd = os.getcwd() v = sys.version.replace("\n", " ") - logger.info(f"Custodian started in singleshot mode at {start} in {cwd}.") + logger.info(f"Custodian started in singleshot mode at {start} in {self.directory}.") logger.info(f"Custodian running on Python version {v}") # load run log if os.path.isfile(Custodian.LOG_FILE): - self.run_log = loadfn(Custodian.LOG_FILE, cls=MontyDecoder) + self.run_log = loadfn(os.path.join(self.directory, Custodian.LOG_FILE), cls=MontyDecoder) if len(self.run_log) == 0: # starting up an initial job - setup input and quit job_n = 0 job = self.jobs[job_n] logger.info(f"Setting up job no. 1 ({job.name}) ") - job.setup() + job.setup(directory=self.directory) self.run_log.append({"job": job.as_dict(), "corrections": [], "job_n": job_n}) return len(self.jobs) @@ -602,14 +602,14 @@ def run_interrupted(self): # check validators logger.info(f"Checking validator for {job.name}.run") for v in self.validators: - if v.check(): + if v.check(directory=self.directory): self.run_log[-1]["validator"] = v logger.info("Failed validation based on validator") s = f"Validation failed: {v}" raise ValidationError(s, raises=True, validator=v) logger.info(f"Postprocessing for {job.name}.run") - job.postprocess() + job.postprocess(directory=self.directory) # IF DONE WITH ALL JOBS - DELETE ALL CHECKPOINTS AND RETURN # VALIDATED @@ -621,7 +621,7 @@ def run_interrupted(self): job_n += 1 job = self.jobs[job_n] self.run_log.append({"job": job.as_dict(), "corrections": [], "job_n": job_n}) - job.setup() + job.setup(directory=self.directory) return len(self.jobs) - job_n except CustodianError as ex: @@ -632,13 +632,13 @@ def run_interrupted(self): finally: # Log the corrections to a json file. logger.info(f"Logging to {Custodian.LOG_FILE}...") - dumpfn(self.run_log, Custodian.LOG_FILE, cls=MontyEncoder, indent=4) + dumpfn(self.run_log, os.path.join(self.directory, Custodian.LOG_FILE), cls=MontyEncoder, indent=4) end = datetime.datetime.now() logger.info(f"Run ended at {end}.") run_time = end - start logger.info(f"Run completed. Total time taken = {run_time}.") if self.finished and self.gzipped_output: - gzip_dir(".") + gzip_dir(self.directory) return None def _do_check(self, handlers, terminate_func=None): @@ -646,7 +646,7 @@ def _do_check(self, handlers, terminate_func=None): corrections = [] for handler in handlers: try: - if handler.check(): + if handler.check(directory=self.directory): if ( handler.max_num_corrections is not None and handler.n_applied_corrections >= handler.max_num_corrections @@ -664,10 +664,10 @@ def _do_check(self, handlers, terminate_func=None): continue if terminate_func is not None and handler.is_terminating: logger.info("Terminating job") - terminate_func() + terminate_func(directory=self.directory) # make sure we don't terminate twice terminate_func = None - dct = handler.correct() + dct = handler.correct(directory=self.directory) logger.error(type(handler).__name__, extra=dct) dct["handler"] = handler corrections.append(dct) @@ -684,7 +684,7 @@ def _do_check(self, handlers, terminate_func=None): self.errors_current_job += len(corrections) self.run_log[-1]["corrections"].extend(corrections) # We do a dump of the run log after each check. - dumpfn(self.run_log, Custodian.LOG_FILE, cls=MontyEncoder, indent=4) + dumpfn(self.run_log, os.path.join(self.directory, Custodian.LOG_FILE), cls=MontyEncoder, indent=4) # Clear all the cached values to avoid reusing them in a subsequent check tracked_lru_cache.tracked_cache_clear() return len(corrections) > 0 @@ -694,28 +694,28 @@ class Job(MSONable): """Abstract base class defining the interface for a Job.""" @abstractmethod - def setup(self): + def setup(self, directory="./"): """ This method is run before the start of a job. Allows for some pre-processing. """ @abstractmethod - def run(self): + def run(self, directory="./"): """ This method perform the actual work for the job. If parallel error checking (monitoring) is desired, this must return a Popen process. """ @abstractmethod - def postprocess(self): + def postprocess(self, directory="./"): """ This method is called at the end of a job, *after* error detection. This allows post-processing, such as cleanup, analysis of results, etc. """ - def terminate(self): + def terminate(self, directory="./"): """Implement termination function.""" return @@ -772,7 +772,7 @@ class ErrorHandler(MSONable): """ @abstractmethod - def check(self): + def check(self, directory="./"): """ This method is called during the job (for monitors) or at the end of the job to check for errors. @@ -782,7 +782,7 @@ def check(self): """ @abstractmethod - def correct(self): + def correct(self, directory="./"): """ This method is called at the end of a job when an error is detected. It should perform any corrective measures relating to the detected @@ -830,7 +830,7 @@ class Validator(MSONable): """ @abstractmethod - def check(self): + def check(self, directory="./"): """ This method is called at the end of a job. diff --git a/custodian/feff/handlers.py b/custodian/feff/handlers.py index 52397ff0..896c06c3 100644 --- a/custodian/feff/handlers.py +++ b/custodian/feff/handlers.py @@ -1,6 +1,7 @@ """This module implements specific error handler for FEFF runs.""" import logging +import os import re from pymatgen.io.feff.sets import FEFFDictSet @@ -44,18 +45,18 @@ def __init__(self, output_filename="log1.dat"): """ self.output_filename = output_filename - def check(self): + def check(self, directory="./"): """ If the FEFF run does not converge, the check will return "TRUE". """ - return self._notconverge_check() + return self._notconverge_check(directory) - def _notconverge_check(self): + def _notconverge_check(self, directory): # Process the output file and get converge information not_converge_pattern = re.compile("Convergence not reached.*") converge_pattern = re.compile("Convergence reached.*") - with open(self.output_filename) as f: + with open(os.path.join(directory, self.output_filename)) as f: for line in f: if len(not_converge_pattern.findall(line)) > 0: return True @@ -64,10 +65,10 @@ def _notconverge_check(self): return False return None - def correct(self): + def correct(self, directory="./"): """Perform the corrections.""" - backup(FEFF_BACKUP_FILES) - feff_input = FEFFDictSet.from_directory(".") + backup(FEFF_BACKUP_FILES, directory=directory) + feff_input = FEFFDictSet.from_directory(directory) scf_values = feff_input.tags.get("SCF") nscmt = scf_values[2] ca = scf_values[3] @@ -82,14 +83,14 @@ def correct(self): scf_values[2] = 100 scf_values[4] = 3 # Set nmix = 3 actions.append({"dict": "PARAMETERS", "action": {"_set": {"SCF": scf_values}}}) - FeffModder().apply_actions(actions) + FeffModder(directory=directory).apply_actions(actions) return {"errors": ["Non-converging job"], "actions": actions} if nscmt == 100 and nmix == 3 and ca > 0.01: # Reduce the convergence accelerator factor scf_values[3] = round(ca / 2, 2) actions.append({"dict": "PARAMETERS", "action": {"_set": {"SCF": scf_values}}}) - FeffModder().apply_actions(actions) + FeffModder(directory=directory).apply_actions(actions) return {"errors": ["Non-converging job"], "actions": actions} if nmix == 3 and ca == 0.01: @@ -97,7 +98,7 @@ def correct(self): scf_values[3] = 0.05 scf_values[4] = 5 actions.append({"dict": "PARAMETERS", "action": {"_set": {"SCF": scf_values}}}) - FeffModder().apply_actions(actions) + FeffModder(directory=directory).apply_actions(actions) return {"errors": ["Non-converging job"], "actions": actions} if nmix == 5 and ca == 0.05: @@ -105,14 +106,14 @@ def correct(self): scf_values[3] = 0.05 scf_values[4] = 10 actions.append({"dict": "PARAMETERS", "action": {"_set": {"SCF": scf_values}}}) - FeffModder().apply_actions(actions) + FeffModder(directory=directory).apply_actions(actions) return {"errors": ["Non-converging job"], "actions": actions} if nmix == 10 and ca < 0.2: # loop through ca with nmix = 10 scf_values[3] = round(ca * 2, 2) actions.append({"dict": "PARAMETERS", "action": {"_set": {"SCF": scf_values}}}) - FeffModder().apply_actions(actions) + FeffModder(directory=directory).apply_actions(actions) return {"errors": ["Non-converging job"], "actions": actions} # Unfixable error. Just return None for actions. diff --git a/custodian/feff/interpreter.py b/custodian/feff/interpreter.py index 02e90510..f921aa7a 100644 --- a/custodian/feff/interpreter.py +++ b/custodian/feff/interpreter.py @@ -11,7 +11,7 @@ class FeffModder(Modder): """A Modder for FeffInput sets.""" - def __init__(self, actions=None, strict=True, feffinp=None): + def __init__(self, actions=None, strict=True, feffinp=None, directory="./"): """ Args: actions ([Action]): A sequence of supported actions. See @@ -25,8 +25,10 @@ def __init__(self, actions=None, strict=True, feffinp=None): feffinp (FEFFInput): A FeffInput object from the current directory. Initialized automatically if not passed (but passing it will avoid having to reparse the directory). + directory (str): Directory to run in """ - self.feffinp = feffinp or FEFFDictSet.from_directory(".") + self.directory = directory + self.feffinp = feffinp or FEFFDictSet.from_directory(self.directory) self.feffinp = self.feffinp.all_input() actions = actions or [FileActions, DictActions] super().__init__(actions, strict) @@ -55,8 +57,8 @@ def apply_actions(self, actions): feff = self.feffinp feff_input = "\n\n".join(str(feff[k]) for k in ["HEADER", "PARAMETERS", "POTENTIALS", "ATOMS"] if k in feff) for k, v in feff.items(): - with open(os.path.join(".", k), "w") as file: + with open(os.path.join(self.directory, k), "w") as file: file.write(str(v)) - with open(os.path.join(".", "feff.inp"), "w") as file: + with open(os.path.join(self.directory, "feff.inp"), "w") as file: file.write(feff_input) diff --git a/custodian/feff/jobs.py b/custodian/feff/jobs.py index b74b3d04..8796245f 100644 --- a/custodian/feff/jobs.py +++ b/custodian/feff/jobs.py @@ -59,30 +59,33 @@ def __init__( self.gzipped = gzipped self.gzipped_prefix = gzipped_prefix - def setup(self) -> None: + def setup(self, directory="./") -> None: """Performs initial setup for FeffJob, do backing up.""" - decompress_dir(".") + decompress_dir(directory) if self.backup: for file in FEFF_INPUT_FILES: - shutil.copy(file, f"{file}.orig") + shutil.copy(os.path.join(directory, file), os.path.join(directory, f"{file}.orig")) for file in FEFF_BACKUP_FILES: - if os.path.isfile(file): - shutil.copy(file, f"{file}.orig") + if os.path.isfile(os.path.join(directory, file)): + shutil.copy(os.path.join(directory, file), os.path.join(directory, f"{file}.orig")) - def run(self): + def run(self, directory="./"): """ Performs the actual FEFF run Returns: (subprocess.Popen) Used for monitoring. """ - with open(self.output_file, "w") as f_std, open(self.stderr_file, "w", buffering=1) as f_err: + with ( + open(os.path.join(directory, self.output_file), "w") as f_std, + open(os.path.join(directory, self.stderr_file), "w", buffering=1) as f_err, + ): # Use line buffering for stderr # On TSCC, need to run shell command - return subprocess.Popen(self.feff_cmd, stdout=f_std, stderr=f_err, shell=True) # pylint: disable=R1732 + return subprocess.Popen(self.feff_cmd, cwd=directory, stdout=f_std, stderr=f_err, shell=True) # pylint: disable=R1732 - def postprocess(self): + def postprocess(self, directory="./"): """Renaming or gzipping all the output as needed.""" if self.gzipped: - backup("*", prefix=self.gzipped_prefix) + backup("*", directory=directory, prefix=self.gzipped_prefix) diff --git a/custodian/lobster/handlers.py b/custodian/lobster/handlers.py index 0cb7ffab..20aa9f39 100644 --- a/custodian/lobster/handlers.py +++ b/custodian/lobster/handlers.py @@ -25,7 +25,7 @@ def __init__(self, output_filename: str = "lobsterout"): """ self.output_filename = output_filename - def check(self) -> bool: + def check(self, directory: str = "./") -> bool: """ checks if the VASP calculation had enough bands Returns: @@ -33,7 +33,7 @@ def check(self) -> bool: """ # checks if correct number of bands is available try: - with open(self.output_filename) as f: + with open(os.path.join(directory, self.output_filename)) as f: data = f.read() return "You are employing too few bands in your PAW calculation." in data except OSError: @@ -50,12 +50,12 @@ class LobsterFilesValidator(Validator): def __init__(self): """Dummy init.""" - def check(self) -> bool: + def check(self, directory: str = "./") -> bool: """Check for errors.""" for filename in ["lobsterout"]: - if not os.path.isfile(filename): + if not os.path.isfile(os.path.join(directory, filename)): return True - with open("lobsterout") as file: + with open(os.path.join(directory, "lobsterout")) as file: data = file.read() return "finished" not in data @@ -73,10 +73,10 @@ def __init__(self, output_filename: str = "lobsterout", charge_spilling_limit: f self.output_filename = output_filename self.charge_spilling_limit = charge_spilling_limit - def check(self) -> bool: + def check(self, directory: str = "./") -> bool: """Open lobsterout and find charge spilling.""" - if os.path.isfile(self.output_filename): - lobsterout = Lobsterout(self.output_filename) + if os.path.isfile(os.path.join(directory, self.output_filename)): + lobsterout = Lobsterout(os.path.join(directory, self.output_filename)) if lobsterout.charge_spilling[0] > self.charge_spilling_limit: return True if len(lobsterout.charge_spilling) > 1 and lobsterout.charge_spilling[1] > self.charge_spilling_limit: diff --git a/custodian/lobster/jobs.py b/custodian/lobster/jobs.py index 379ab25c..f2a79ac9 100644 --- a/custodian/lobster/jobs.py +++ b/custodian/lobster/jobs.py @@ -70,35 +70,38 @@ def __init__( self.add_files_to_gzip = add_files_to_gzip self.backup = backup - def setup(self): + def setup(self, directory="./"): """Will backup lobster input files.""" if self.backup: for file in LOBSTERINPUT_FILES: - shutil.copy(file, f"{file}.orig") + shutil.copy(os.path.join(directory, file), os.path.join(directory, f"{file}.orig")) - def run(self): + def run(self, directory="./"): """Runs the job.""" cmd = self.lobster_cmd logger.info(f"Running {' '.join(cmd)}") - with zopen(self.output_file, "w") as f_std, zopen(self.stderr_file, "w", buffering=1) as f_err: + with ( + zopen(os.path.join(directory, self.output_file), "w") as f_std, + zopen(os.path.join(directory, self.stderr_file), "w", buffering=1) as f_err, + ): # use line buffering for stderr - return subprocess.Popen(cmd, stdout=f_std, stderr=f_err) # pylint: disable=R1732 + return subprocess.Popen(cmd, cwd=directory, stdout=f_std, stderr=f_err) # pylint: disable=R1732 - def postprocess(self): + def postprocess(self, directory="./"): """Will gzip relevant files (won't gzip custodian.json and other output files from the cluster).""" if self.gzipped: for file in LOBSTEROUTPUT_FILES: - if os.path.isfile(file): - compress_file(file, compression="gz") + if os.path.isfile(os.path.join(directory, file)): + compress_file(os.path.join(directory, file), compression="gz") for file in LOBSTERINPUT_FILES: - if os.path.isfile(file): - compress_file(file, compression="gz") - if self.backup and os.path.isfile("lobsterin.orig"): - compress_file("lobsterin.orig", compression="gz") + if os.path.isfile(os.path.join(directory, file)): + compress_file(os.path.join(directory, file), compression="gz") + if self.backup and os.path.isfile(os.path.join(directory, "lobsterin.orig")): + compress_file(os.path.join(directory, "lobsterin.orig"), compression="gz") for file in FW_FILES: - if os.path.isfile(file): - compress_file(file, compression="gz") + if os.path.isfile(os.path.join(directory, file)): + compress_file(os.path.join(directory, file), compression="gz") for file in self.add_files_to_gzip: - compress_file(file, compression="gz") + compress_file(os.path.join(directory, file), compression="gz") diff --git a/custodian/nwchem/handlers.py b/custodian/nwchem/handlers.py index 1ef8c9f2..6c1571da 100644 --- a/custodian/nwchem/handlers.py +++ b/custodian/nwchem/handlers.py @@ -3,6 +3,8 @@ for B3LYP DFT jobs. """ +import os + from pymatgen.io.nwchem import NwInput, NwOutput from custodian.ansible.interpreter import Modder @@ -28,9 +30,9 @@ def __init__(self, output_filename="mol.nwout"): """ self.output_filename = output_filename - def check(self): + def check(self, directory="./"): """Check for errors.""" - out = NwOutput(self.output_filename) + out = NwOutput(os.path.join(directory, self.output_filename)) self.errors = [] self.input_file = out.job_info["input"] if out.data[-1]["has_error"]: @@ -39,23 +41,11 @@ def check(self): self.ntasks = len(out.data) return len(self.errors) > 0 - def _mod_input(self, search_string_func, mod_string_func): - with open(self.input_file) as file: - lines = [] - for line in file: - if search_string_func(line): - lines.append(mod_string_func(line)) - else: - lines.append(line) - - with open(self.input_file, "w") as fout: - fout.write("".join(lines)) - - def correct(self): + def correct(self, directory="./"): """Correct errors.""" - backup("*.nw*") + backup("*.nw*", directory=directory) actions = [] - nwi = NwInput.from_file(self.input_file) + nwi = NwInput.from_file(os.path.join(directory, self.input_file)) for e in self.errors: if e == "autoz error": action = {"_set": {"geometry_options": ["units", "angstroms", "noautoz"]}} @@ -77,7 +67,7 @@ def correct(self): # die. return {"errors": self.errors, "actions": None} - m = Modder() + m = Modder(directory=directory) for action in actions: nwi = m.modify_object(action, nwi) nwi.write_file(self.input_file) diff --git a/custodian/nwchem/jobs.py b/custodian/nwchem/jobs.py index 22596cab..fc88f8e4 100644 --- a/custodian/nwchem/jobs.py +++ b/custodian/nwchem/jobs.py @@ -1,5 +1,6 @@ """This module implements basic kinds of jobs for Nwchem runs.""" +import os import shutil import subprocess @@ -52,17 +53,17 @@ def __init__( self.gzipped = gzipped self.settings_override = settings_override - def setup(self): + def setup(self, directory="./"): """Performs backup if necessary.""" if self.backup: - shutil.copy(self.input_file, f"{self.input_file}.orig") + shutil.copy(os.path.join(directory, self.input_file), os.path.join(directory, f"{self.input_file}.orig")) - def run(self): + def run(self, directory="./"): """Performs actual nwchem run.""" with zopen(self.output_file, "w") as fout: - return subprocess.Popen([*self.nwchem_cmd, self.input_file], stdout=fout) # pylint: disable=R1732 + return subprocess.Popen([*self.nwchem_cmd, self.input_file], cwd=directory, stdout=fout) # pylint: disable=R1732 - def postprocess(self): + def postprocess(self, directory="./"): """Renaming or gzipping as needed.""" if self.gzipped: - gzip_dir(".") + gzip_dir(directory) diff --git a/custodian/utils.py b/custodian/utils.py index 664279fb..532bb26d 100644 --- a/custodian/utils.py +++ b/custodian/utils.py @@ -7,7 +7,7 @@ from glob import glob -def backup(filenames, prefix="error"): +def backup(filenames, prefix="error", directory="./"): """ Backup files to a tar.gz file. Used, for example, in backing up the files of an errored run before performing corrections. @@ -17,9 +17,10 @@ def backup(filenames, prefix="error"): *.*. prefix (str): prefix to the files. Defaults to error, which means a series of error.1.tar.gz, error.2.tar.gz, ... will be generated. + directory (str): directory where the files exist """ - num = max([0] + [int(file.split(".")[1]) for file in glob(f"{prefix}.*.tar.gz")]) - filename = f"{prefix}.{num + 1}.tar.gz" + num = max([0] + [int(file.split(".")[-3]) for file in glob(os.path.join(directory, f"{prefix}.*.tar.gz"))]) + filename = os.path.join(directory, f"{prefix}.{num + 1}.tar.gz") logging.info(f"Backing up run to {filename}.") with tarfile.open(filename, "w:gz") as tar: for fname in filenames: diff --git a/custodian/vasp/handlers.py b/custodian/vasp/handlers.py index e2718f2d..9115b713 100644 --- a/custodian/vasp/handlers.py +++ b/custodian/vasp/handlers.py @@ -161,12 +161,12 @@ def __init__( self.vtst_fixes = vtst_fixes self.logger = logging.getLogger(type(self).__name__) - def check(self): + def check(self, directory="./"): """Check for error.""" - incar = Incar.from_file("INCAR") + incar = Incar.from_file(os.path.join(directory, "INCAR")) self.errors = set() error_msgs = set() - with zopen(self.output_filename, mode="rt") as file: + with zopen(os.path.join(directory, self.output_filename), mode="rt") as file: text = file.read() # Check for errors @@ -185,11 +185,11 @@ def check(self): self.logger.error(msg, extra={"incar": incar.as_dict()}) return len(self.errors) > 0 - def correct(self): + def correct(self, directory="./"): """Perform corrections.""" - backup(VASP_BACKUP_FILES | {self.output_filename}) + backup(VASP_BACKUP_FILES | {self.output_filename}, directory=directory) actions = [] - vi = VaspInput.from_directory(".") + vi = VaspInput.from_directory(directory) if self.errors.intersection(["tet", "dentet"]): # follow advice in this thread @@ -227,7 +227,7 @@ def correct(self): # error count to 1 to skip first fix if self.error_count["brmix"] == 0: try: - assert load_outcar(zpath(os.path.join(os.getcwd(), "OUTCAR"))).is_stopped is False + assert load_outcar(zpath(os.path.join(directory, "OUTCAR"))).is_stopped is False except Exception: self.error_count["brmix"] += 1 @@ -315,7 +315,7 @@ def correct(self): # is set to a large value for a small structure. try: - oszicar = Oszicar("OSZICAR") + oszicar = Oszicar(os.path.join(directory, "OSZICAR")) nsteps = len(oszicar.ionic_steps) except Exception: nsteps = 0 @@ -460,7 +460,7 @@ def correct(self): if "NBANDS" in vi["INCAR"]: nbands = vi["INCAR"]["NBANDS"] else: - with open("OUTCAR") as file: + with open(os.path.join(directory, "OUTCAR")) as file: for line in file: # Have to take the last NBANDS line since sometimes VASP # updates it automatically even if the user specifies it. @@ -483,7 +483,7 @@ def correct(self): # RMM algorithm is not stable for this calculation # Copy CONTCAR to POSCAR if CONTCAR has already been populated. try: - is_contcar = Poscar.from_file("CONTCAR") + is_contcar = Poscar.from_file(os.path.join(directory, "CONTCAR")) except Exception: is_contcar = False if is_contcar: @@ -503,7 +503,7 @@ def correct(self): if "edddav" in self.errors: # Copy CONTCAR to POSCAR if CONTCAR has already been populated. try: - is_contcar = Poscar.from_file("CONTCAR") + is_contcar = Poscar.from_file(os.path.join(directory, "CONTCAR")) except Exception: is_contcar = False if is_contcar: @@ -523,7 +523,7 @@ def correct(self): # resources, seems to be to just increase NCORE slightly. That's what I do here. nprocs = multiprocessing.cpu_count() try: - nelect = load_outcar(os.path.join(os.getcwd(), "OUTCAR")).nelect + nelect = load_outcar(os.path.join(directory, "OUTCAR")).nelect except Exception: nelect = 1 # dummy value if nelect < nprocs: @@ -551,7 +551,7 @@ def correct(self): if self.errors & {"zheev", "eddiag"}: # Copy CONTCAR to POSCAR if CONTCAR has already been populated. try: - is_contcar = Poscar.from_file("CONTCAR") + is_contcar = Poscar.from_file(os.path.join(directory, "CONTCAR")) except Exception: is_contcar = False if is_contcar: @@ -678,7 +678,7 @@ def correct(self): ) self.error_count["algo_tet"] += 1 - VaspModder(vi=vi).apply_actions(actions) + VaspModder(vi=vi, directory=directory).apply_actions(actions) return {"errors": list(self.errors), "actions": actions} @@ -706,10 +706,10 @@ def __init__(self, output_filename: str = "std_err.txt"): self.errors: set[str] = set() self.error_count: Counter = Counter() - def check(self): + def check(self, directory="./"): """Check for error.""" self.errors = set() - with open(self.output_filename) as f: + with open(os.path.join(directory, self.output_filename)) as f: for line in f: line = line.strip() for err, msgs in LrfCommutatorHandler.error_msgs.items(): @@ -718,20 +718,20 @@ def check(self): self.errors.add(err) return len(self.errors) > 0 - def correct(self): + def correct(self, directory="./"): """Perform corrections.""" - backup(VASP_BACKUP_FILES | {self.output_filename}) + backup(VASP_BACKUP_FILES | {self.output_filename}, directory=directory) actions = [] - vi = VaspInput.from_directory(".") + vi = VaspInput.from_directory(directory) if ( "lrf_comm" in self.errors - and load_outcar(zpath(os.path.join(os.getcwd(), "OUTCAR"))).is_stopped is False + and load_outcar(zpath(os.path.join(directory, "OUTCAR"))).is_stopped is False and not vi["INCAR"].get("LPEAD") ): actions.append({"dict": "INCAR", "action": {"_set": {"LPEAD": True}}}) - VaspModder(vi=vi).apply_actions(actions) + VaspModder(vi=vi, directory=directory).apply_actions(actions) return {"errors": list(self.errors), "actions": actions} @@ -762,10 +762,10 @@ def __init__(self, output_filename: str = "std_err.txt"): self.errors: set[str] = set() self.error_count: Counter = Counter() - def check(self): + def check(self, directory="./"): """Check for error.""" self.errors = set() - with open(self.output_filename) as file: + with open(os.path.join(directory, self.output_filename)) as file: for line in file: line = line.strip() for err, msgs in StdErrHandler.error_msgs.items(): @@ -774,11 +774,11 @@ def check(self): self.errors.add(err) return len(self.errors) > 0 - def correct(self): + def correct(self, directory="./"): """Perform corrections.""" - backup(VASP_BACKUP_FILES | {self.output_filename}) + backup(VASP_BACKUP_FILES | {self.output_filename}, directory=directory) actions = [] - vi = VaspInput.from_directory(".") + vi = VaspInput.from_directory(directory) if "kpoints_trans" in self.errors and self.error_count["kpoints_trans"] == 0: m = prod(vi["KPOINTS"].kpts[0]) @@ -792,7 +792,7 @@ def correct(self): reduced_kpar = max(vi["INCAR"].get("KPAR", 1) // 2, 1) actions.append({"dict": "INCAR", "action": {"_set": {"KPAR": reduced_kpar}}}) - VaspModder(vi=vi).apply_actions(actions) + VaspModder(vi=vi, directory=directory).apply_actions(actions) return {"errors": list(self.errors), "actions": actions} @@ -821,11 +821,11 @@ def __init__(self, output_filename: str = "vasp.out"): self.output_filename = output_filename self.errors: set[str] = set() - def check(self): + def check(self, directory="./"): """Check for error.""" - incar = Incar.from_file("INCAR") + incar = Incar.from_file(os.path.join(directory, "INCAR")) self.errors = set() - with open(self.output_filename) as f: + with open(os.path.join(directory, self.output_filename)) as f: for line in f: line = line.strip() for err, msgs in AliasingErrorHandler.error_msgs.items(): @@ -840,14 +840,14 @@ def check(self): self.errors.add(err) return len(self.errors) > 0 - def correct(self): + def correct(self, directory="./"): """Perform corrections.""" - backup(VASP_BACKUP_FILES | {self.output_filename}) + backup(VASP_BACKUP_FILES | {self.output_filename}, directory=directory) actions = [] - vi = VaspInput.from_directory(".") + vi = VaspInput.from_directory(directory) if "aliasing" in self.errors: - with open("OUTCAR") as file: + with open(os.path.join(directory, "OUTCAR")) as file: grid_adjusted = False changes_dict = {} r = re.compile(r".+aliasing errors.*(NG.)\s*to\s*(\d+)") @@ -885,7 +885,7 @@ def correct(self): ] ) - VaspModder(vi=vi).apply_actions(actions) + VaspModder(vi=vi, directory=directory).apply_actions(actions) return {"errors": list(self.errors), "actions": actions} @@ -902,9 +902,9 @@ def __init__(self, max_drift=None, to_average=3, enaug_multiply=2): self.to_average = int(to_average) self.enaug_multiply = enaug_multiply - def check(self): + def check(self, directory="./"): """Check for error.""" - incar = Incar.from_file("INCAR") + incar = Incar.from_file(os.path.join(directory, "INCAR")) if incar.get("EDIFFG", 0.1) >= 0 or incar.get("NSW", 0) <= 1: # Only activate when force relaxing and ionic steps # NSW check prevents accidental effects when running DFPT @@ -914,7 +914,7 @@ def check(self): self.max_drift = incar["EDIFFG"] * -1 try: - outcar = load_outcar(os.path.join(os.getcwd(), "OUTCAR")) + outcar = load_outcar(os.path.join(directory, "OUTCAR")) except Exception: # Can't perform check if Outcar not valid return False @@ -927,14 +927,14 @@ def check(self): curr_drift = np.average([np.linalg.norm(dct) for dct in curr_drift]) return curr_drift > self.max_drift - def correct(self): + def correct(self, directory="./"): """Perform corrections.""" - backup(VASP_BACKUP_FILES) + backup(VASP_BACKUP_FILES, directory=directory) actions = [] - vi = VaspInput.from_directory(".") + vi = VaspInput.from_directory(directory) incar = vi["INCAR"] - outcar = load_outcar(os.path.join(os.getcwd(), "OUTCAR")) + outcar = load_outcar(os.path.join(directory, "OUTCAR")) # Move CONTCAR to POSCAR actions.append({"file": "CONTCAR", "action": {"_file_copy": {"dest": "POSCAR"}}}) @@ -956,7 +956,7 @@ def correct(self): curr_drift = outcar.data.get("drift", [])[::-1][: self.to_average] curr_drift = np.average([np.linalg.norm(dct) for dct in curr_drift]) - VaspModder(vi=vi).apply_actions(actions) + VaspModder(vi=vi, directory=directory).apply_actions(actions) return { "errors": f"Excessive drift {curr_drift} > {self.max_drift}", "actions": actions, @@ -986,11 +986,11 @@ def __init__(self, output_filename: str = "vasp.out", output_vasprun="vasprun.xm self.output_filename = output_filename self.output_vasprun = output_vasprun - def check(self): + def check(self, directory="./"): """Check for error.""" msg = "Reciprocal lattice and k-lattice belong to different class of lattices." - vi = VaspInput.from_directory(".") + vi = VaspInput.from_directory(directory) # disregard this error if KSPACING is set and no KPOINTS file is generated if vi["INCAR"].get("KSPACING", False): return False @@ -1004,28 +1004,28 @@ def check(self): return False try: - v = load_vasprun(os.path.join(os.getcwd(), self.output_vasprun)) + v = load_vasprun(os.path.join(directory, self.output_vasprun)) if v.converged: return False except Exception: pass - with open(self.output_filename) as f: + with open(os.path.join(directory, self.output_filename)) as f: for line in f: line = line.strip() if line.find(msg) != -1: return True return False - def correct(self): + def correct(self, directory="./"): """Perform corrections.""" - backup(VASP_BACKUP_FILES | {self.output_filename}) - vi = VaspInput.from_directory(".") + backup(VASP_BACKUP_FILES | {self.output_filename}, directory=directory) + vi = VaspInput.from_directory(directory) m = prod(vi["KPOINTS"].kpts[0]) m = max(int(round(m ** (1 / 3))), 1) if vi["KPOINTS"] and vi["KPOINTS"].style.name.lower().startswith("m"): m += m % 2 actions = [{"dict": "KPOINTS", "action": {"_set": {"kpoints": [[m] * 3]}}}] - VaspModder(vi=vi).apply_actions(actions) + VaspModder(vi=vi, directory=directory).apply_actions(actions) return {"errors": ["mesh_symmetry"], "actions": actions} @@ -1043,19 +1043,19 @@ def __init__(self, output_filename: str = "vasprun.xml"): """ self.output_filename = output_filename - def check(self): + def check(self, directory="./"): """Check for error.""" try: - v = load_vasprun(os.path.join(os.getcwd(), self.output_filename)) + v = load_vasprun(os.path.join(directory, self.output_filename)) if not v.converged: return True except Exception: pass return False - def correct(self): + def correct(self, directory="./"): """Perform corrections.""" - v = load_vasprun(os.path.join(os.getcwd(), self.output_filename)) + v = load_vasprun(os.path.join(directory, self.output_filename)) algo = v.incar.get("ALGO", "Normal").lower() actions = [] errors = ["Unconverged"] @@ -1124,13 +1124,13 @@ def correct(self): ] if actions: - vi = VaspInput.from_directory(".") + vi = VaspInput.from_directory(directory) # Check for PSMAXN errors - see extensive discussion here # https://github.com/materialsproject/custodian/issues/133 # Only correct PSMAXN when run didn't converge for fixable reasons - if os.path.isfile("OUTCAR"): - with open("OUTCAR") as file: + if os.path.isfile(os.path.join(directory, "OUTCAR")): + with open(os.path.join(directory, "OUTCAR")) as file: outcar_as_str = file.read() if "PSMAXN for non-local potential too small" in outcar_as_str: if vi["INCAR"].get("LREAL", False) not in [False, "False", "false"]: @@ -1139,8 +1139,8 @@ def correct(self): ] errors += ["psmaxn"] - backup(VASP_BACKUP_FILES) - VaspModder(vi=vi).apply_actions(actions) + backup(VASP_BACKUP_FILES, directory=directory) + VaspModder(vi=vi, directory=directory).apply_actions(actions) return {"errors": errors, "actions": actions} # Unfixable error. Just return None for actions. @@ -1166,10 +1166,10 @@ def __init__(self, output_filename: str = "vasprun.xml"): """ self.output_filename = output_filename - def check(self): + def check(self, directory="./"): """Check for error.""" try: - v = load_vasprun(os.path.join(os.getcwd(), self.output_filename)) + v = load_vasprun(os.path.join(directory, self.output_filename)) # check whether bandgap is zero, tetrahedron smearing was used # and relaxation is performed. if v.eigenvalue_band_properties[0] == 0 and v.incar.get("ISMEAR", 1) < -3 and v.incar.get("NSW", 0) > 1: @@ -1178,17 +1178,17 @@ def check(self): pass return False - def correct(self): + def correct(self, directory="./"): """Perform corrections.""" - backup(VASP_BACKUP_FILES | {self.output_filename}) - vi = VaspInput.from_directory(".") + backup(VASP_BACKUP_FILES | {self.output_filename}, directory=directory) + vi = VaspInput.from_directory(directory) actions = [ {"dict": "INCAR", "action": {"_set": {"ISMEAR": 2}}}, {"dict": "INCAR", "action": {"_set": {"SIGMA": 0.2}}}, ] - VaspModder(vi=vi).apply_actions(actions) + VaspModder(vi=vi, directory=directory).apply_actions(actions) return {"errors": ["IncorrectSmearing"], "actions": actions} @@ -1212,10 +1212,10 @@ def __init__(self, output_filename: str = "vasprun.xml"): """ self.output_filename = output_filename - def check(self): + def check(self, directory="./"): """Check for error.""" try: - v = load_vasprun(os.path.join(os.getcwd(), self.output_filename)) + v = load_vasprun(os.path.join(directory, self.output_filename)) # check whether bandgap is zero and KSPACING is too large # using 0 as fallback value for KSPACING so that this handler does not trigger if KSPACING is not set if v.eigenvalue_band_properties[0] == 0 and v.incar.get("KSPACING", 0) > 0.22: @@ -1224,10 +1224,10 @@ def check(self): pass return False - def correct(self): + def correct(self, directory="./"): """Perform corrections.""" - backup(VASP_BACKUP_FILES | {self.output_filename}) - vi = VaspInput.from_directory(".") + backup(VASP_BACKUP_FILES | {self.output_filename}, directory=directory) + vi = VaspInput.from_directory(directory) _dummy_structure = Structure( [1, 0, 0, 0, 1, 0, 0, 0, 1], @@ -1239,7 +1239,7 @@ def correct(self): actions = [] actions.append({"dict": "INCAR", "action": {"_set": {"KSPACING": new_vis.incar["KSPACING"]}}}) - VaspModder(vi=vi).apply_actions(actions) + VaspModder(vi=vi, directory=directory).apply_actions(actions) return {"errors": ["ScanMetal"], "actions": actions} @@ -1280,11 +1280,11 @@ class LargeSigmaHandler(ErrorHandler): def __init__(self): """Initializes the handler with a buffer time.""" - def check(self): + def check(self, directory="./"): """Check for error.""" - incar = Incar.from_file("INCAR") + incar = Incar.from_file(os.path.join(directory, "INCAR")) try: - outcar = load_outcar(os.path.join(os.getcwd(), "OUTCAR")) + outcar = load_outcar(os.path.join(directory, "OUTCAR")) except Exception: # Can't perform check if Outcar not valid return False @@ -1294,7 +1294,7 @@ def check(self): outcar.read_pattern( {"entropy": r"entropy T\*S.*= *(\D\d*\.\d*)"}, postprocess=float, reverse=True, terminate_on_match=True ) - n_atoms = Structure.from_file("POSCAR").num_sites + n_atoms = Structure.from_file(os.path.join(directory, "POSCAR")).num_sites if outcar.data.get("entropy", []): entropy_per_atom = abs(np.max(outcar.data.get("entropy"))) / n_atoms @@ -1304,11 +1304,11 @@ def check(self): return False - def correct(self): + def correct(self, directory="./"): """Perform corrections.""" - backup(VASP_BACKUP_FILES) + backup(VASP_BACKUP_FILES, directory=directory) actions = [] - vi = VaspInput.from_directory(".") + vi = VaspInput.from_directory(directory) sigma = vi["INCAR"].get("SIGMA", 0.2) # Reduce SIGMA by 0.06 if larger than 0.08 @@ -1331,7 +1331,7 @@ def correct(self): } ) - VaspModder(vi=vi).apply_actions(actions) + VaspModder(vi=vi, directory=directory).apply_actions(actions) return {"errors": ["LargeSigma"], "actions": actions} @@ -1360,10 +1360,10 @@ def __init__(self, input_filename="POSCAR", output_filename="OSZICAR", dE_thresh self.output_filename = output_filename self.dE_threshold = dE_threshold - def check(self): + def check(self, directory="./"): """Check for error.""" try: - oszicar = Oszicar(self.output_filename) + oszicar = Oszicar(os.path.join(directory, self.output_filename)) n = len(Poscar.from_file(self.input_filename).structure) max_dE = max(s["dE"] for s in oszicar.ionic_steps[1:]) / n if max_dE > self.dE_threshold: @@ -1372,10 +1372,10 @@ def check(self): return False return None - def correct(self): + def correct(self, directory="./"): """Perform corrections.""" - backup(VASP_BACKUP_FILES) - vi = VaspInput.from_directory(".") + backup(VASP_BACKUP_FILES, directory=directory) + vi = VaspInput.from_directory(directory) potim = vi["INCAR"].get("POTIM", 0.5) ibrion = vi["INCAR"].get("IBRION", 0) if potim < 0.2 and ibrion != 3: @@ -1385,7 +1385,7 @@ def correct(self): else: actions = [{"dict": "INCAR", "action": {"_set": {"POTIM": potim * 0.5}}}] - VaspModder(vi=vi).apply_actions(actions) + VaspModder(vi=vi, directory=directory).apply_actions(actions) return {"errors": ["POTIM"], "actions": actions} @@ -1412,25 +1412,25 @@ def __init__(self, output_filename: str = "vasp.out", timeout=21_600) -> None: self.output_filename = output_filename self.timeout = timeout - def check(self): + def check(self, directory="./"): """Check for error.""" - st = os.stat(self.output_filename) + st = os.stat(os.path.join(directory, self.output_filename)) if time.time() - st.st_mtime > self.timeout: return True return None - def correct(self): + def correct(self, directory="./"): """Perform corrections.""" - backup(VASP_BACKUP_FILES | {self.output_filename}) + backup(VASP_BACKUP_FILES | {self.output_filename}, directory=directory) - vi = VaspInput.from_directory(".") + vi = VaspInput.from_directory(directory) actions = [] if vi["INCAR"].get("ALGO", "Normal").lower() == "fast": actions.append({"dict": "INCAR", "action": {"_set": {"ALGO": "Normal"}}}) else: actions.append({"dict": "INCAR", "action": {"_set": {"SYMPREC": 1e-8}}}) - VaspModder(vi=vi).apply_actions(actions) + VaspModder(vi=vi, directory=directory).apply_actions(actions) return {"errors": ["Frozen job"], "actions": actions} @@ -1457,12 +1457,12 @@ def __init__(self, output_filename: str = "OSZICAR", nionic_steps=10): self.output_filename = output_filename self.nionic_steps = nionic_steps - def check(self): + def check(self, directory="./"): """Check for error.""" - vi = VaspInput.from_directory(".") + vi = VaspInput.from_directory(directory) n_elm = vi["INCAR"].get("NELM", 60) # number of electronic steps try: - oszicar = Oszicar(self.output_filename) + oszicar = Oszicar(os.path.join(directory, self.output_filename)) elec_steps = oszicar.electronic_steps if len(elec_steps) > self.nionic_steps: return all(len(e) == n_elm for e in elec_steps[-(self.nionic_steps + 1) : -1]) @@ -1470,9 +1470,9 @@ def check(self): pass return False - def correct(self): + def correct(self, directory="./"): """Perform corrections.""" - incar = (vi := VaspInput.from_directory("."))["INCAR"] + incar = (vi := VaspInput.from_directory(directory))["INCAR"] algo = incar.get("ALGO", "Normal").lower() amix = incar.get("AMIX", 0.4) bmix = incar.get("BMIX", 1.0) @@ -1501,7 +1501,7 @@ def correct(self): # NOTE: This is the amin error handler # Sometimes an AMIN warning can appear with large unit cell dimensions, so we'll address it now - if max(Structure.from_file("CONTCAR").lattice.abc) > 50 and amin > 0.01: + if max(Structure.from_file(os.path.join(directory, "CONTCAR")).lattice.abc) > 50 and amin > 0.01: actions.append({"dict": "INCAR", "action": {"_set": {"AMIN": 0.01}}}) # If a hybrid is used, do not set Algo = Fast or VeryFast. Hybrid calculations do not @@ -1542,8 +1542,8 @@ def correct(self): ) if actions: - backup(VASP_BACKUP_FILES) - VaspModder(vi=vi).apply_actions(actions) + backup(VASP_BACKUP_FILES, directory=directory) + VaspModder(vi=vi, directory=directory).apply_actions(actions) return {"errors": ["Non-converging job"], "actions": actions} # Unfixable error. Just return None for actions. return {"errors": ["Non-converging job"], "actions": None} @@ -1632,12 +1632,12 @@ def __init__(self, wall_time=None, buffer_time=300, electronic_step_stop=False): self.electronic_steps_timings = [0] self.prev_check_time = self.start_time - def check(self): + def check(self, directory="./"): """Check for error.""" if self.wall_time: run_time = datetime.datetime.now() - self.start_time total_secs = run_time.total_seconds() - outcar = load_outcar(os.path.join(os.getcwd(), "OUTCAR")) + outcar = load_outcar(os.path.join(directory, "OUTCAR")) if not self.electronic_step_stop: # Determine max time per ionic step. outcar.read_pattern({"timings": r"LOOP\+.+real time(.+)"}, postprocess=float) @@ -1655,13 +1655,13 @@ def check(self): return False - def correct(self): + def correct(self, directory="./"): """Perform corrections.""" content = "LSTOP = .TRUE." if not self.electronic_step_stop else "LABORT = .TRUE." # Write STOPCAR actions = [{"file": "STOPCAR", "action": {"_file_create": {"content": content}}}] - m = Modder(actions=[FileActions]) + m = Modder(actions=[FileActions], directory=directory) for a in actions: m.modify(a["action"], a["file"]) return {"errors": ["Walltime reached"], "actions": None} @@ -1696,7 +1696,7 @@ def __init__(self, interval=3600): self.start_time = datetime.datetime.now() self.chk_counter = 0 - def check(self): + def check(self, directory="./"): """Check for error.""" run_time = datetime.datetime.now() - self.start_time total_secs = run_time.seconds + run_time.days * 3600 * 24 @@ -1704,7 +1704,7 @@ def check(self): return True return False - def correct(self): + def correct(self, directory="./"): """Perform corrections.""" content = "LSTOP = .TRUE." chkpt_content = f'Index: {self.chk_counter}\nTime: "{datetime.datetime.now()}"' @@ -1719,7 +1719,7 @@ def correct(self): }, ] - m = Modder(actions=[FileActions]) + m = Modder(actions=[FileActions], directory=directory) for a in actions: m.modify(a["action"], a["file"]) @@ -1753,19 +1753,19 @@ class StoppedRunHandler(ErrorHandler): def __init__(self): """Dummy init.""" - def check(self): + def check(self, directory="./"): """Check for error.""" - return os.path.isfile("chkpt.yaml") + return os.path.isfile(os.path.join(directory, "chkpt.yaml")) - def correct(self): + def correct(self, directory="./"): """Perform corrections.""" - d = loadfn("chkpt.yaml") + d = loadfn(os.path.join(directory, "chkpt.yaml")) i = d["Index"] - name = shutil.make_archive(os.path.join(os.getcwd(), f"vasp.chk.{i}"), "gztar") + name = shutil.make_archive(os.path.join(directory, f"vasp.chk.{i}"), "gztar") actions = [{"file": "CONTCAR", "action": {"_file_copy": {"dest": "POSCAR"}}}] - m = Modder(actions=[FileActions]) + m = Modder(actions=[FileActions], directory=directory) for a in actions: m.modify(a["action"], a["file"]) @@ -1791,31 +1791,31 @@ def __init__(self, output_filename: str = "OSZICAR"): """ self.output_filename = output_filename - def check(self): + def check(self, directory="./"): """Check for error.""" try: - oszicar = Oszicar(self.output_filename) + oszicar = Oszicar(os.path.join(directory, self.output_filename)) if oszicar.final_energy > 0: return True except Exception: pass return False - def correct(self): + def correct(self, directory="./"): """Perform corrections.""" # change ALGO = Fast to Normal if ALGO is !Normal - vi = VaspInput.from_directory(".") + vi = VaspInput.from_directory(directory) algo = vi["INCAR"].get("ALGO", "Normal").lower() if algo not in {"normal", "n"}: - backup(VASP_BACKUP_FILES) + backup(VASP_BACKUP_FILES | {self.output_filename}, directory=directory) actions = [{"dict": "INCAR", "action": {"_set": {"ALGO": "Normal"}}}] - VaspModder(vi=vi).apply_actions(actions) + VaspModder(vi=vi, directory=directory).apply_actions(actions) return {"errors": ["Positive energy"], "actions": actions} # decrease POTIM if ALGO is 'normal' and IBRION != -1 (i.e. it's not a static calculation) if algo == "normal" and vi["INCAR"].get("IBRION", 1) > -1: potim = round(vi["INCAR"].get("POTIM", 0.5) / 2.0, 2) actions = [{"dict": "INCAR", "action": {"_set": {"POTIM": potim}}}] - VaspModder(vi=vi).apply_actions(actions) + VaspModder(vi=vi, directory=directory).apply_actions(actions) return {"errors": ["Positive energy"], "actions": actions} # Unfixable error. Just return None for actions. return {"errors": ["Positive energy"], "actions": None} diff --git a/custodian/vasp/interpreter.py b/custodian/vasp/interpreter.py index 89192716..b02eed36 100644 --- a/custodian/vasp/interpreter.py +++ b/custodian/vasp/interpreter.py @@ -1,5 +1,7 @@ """Implements various interpreters and modders for VASP.""" +import os + from pymatgen.io.vasp.inputs import VaspInput from custodian.ansible.actions import DictActions, FileActions @@ -9,7 +11,7 @@ class VaspModder(Modder): """A Modder for VaspInputSets.""" - def __init__(self, actions=None, strict=True, vi=None): + def __init__(self, actions=None, strict=True, vi=None, directory="./"): """Initialize a Modder for VaspInput sets. Args: @@ -24,7 +26,8 @@ def __init__(self, actions=None, strict=True, vi=None): Initialized automatically if not passed (but passing it will avoid having to reparse the directory). """ - self.vi = vi or VaspInput.from_directory(".") + self.vi = vi or VaspInput.from_directory(directory) + self.directory = directory actions = actions or [FileActions, DictActions] super().__init__(actions, strict) @@ -49,4 +52,4 @@ def apply_actions(self, actions): else: raise ValueError(f"Unrecognized format: {a}") for file in modified: - self.vi[file].write_file(file) + self.vi[file].write_file(os.path.join(self.directory, file)) diff --git a/custodian/vasp/jobs.py b/custodian/vasp/jobs.py index f60d4592..13de45e4 100644 --- a/custodian/vasp/jobs.py +++ b/custodian/vasp/jobs.py @@ -166,24 +166,24 @@ def __init__( logger.exception(f"Failed to detect VASP path: {vasp_cmd}") scope.set_tag("vasp_cmd", vasp_cmd) - def setup(self): + def setup(self, directory="./"): """ Performs initial setup for VaspJob, including overriding any settings and backing up. """ - decompress_dir(".") + decompress_dir(directory) if self.backup: for file in VASP_INPUT_FILES: try: - shutil.copy(file, f"{file}.orig") + shutil.copy(os.path.join(directory, file), os.path.join(directory, f"{file}.orig")) except FileNotFoundError: # handle the situation when there is no KPOINTS file if file == "KPOINTS": pass if self.auto_npar: try: - incar = Incar.from_file("INCAR") + incar = Incar.from_file(os.path.join(directory, "INCAR")) # Only optimized NPAR for non-HF and non-RPA calculations. if not (incar.get("LHFCALC") or incar.get("LRPA") or incar.get("LEPSILON")): if incar.get("IBRION") in {5, 6, 7, 8}: @@ -202,16 +202,16 @@ def setup(self): if ncores % npar == 0: incar["NPAR"] = npar break - incar.write_file("INCAR") + incar.write_file(os.path.join(directory, "INCAR")) except Exception: pass if self.auto_continue: - if os.path.isfile("continue.json"): - actions = loadfn("continue.json").get("actions") + if os.path.isfile(os.path.join(directory, "continue.json")): + actions = loadfn(os.path.join(directory, "continue.json")).get("actions") logger.info(f"Continuing previous VaspJob. Actions: {actions}") - backup(VASP_BACKUP_FILES, prefix="prev_run") - VaspModder().apply_actions(actions) + backup(VASP_BACKUP_FILES, prefix="prev_run", directory=directory) + VaspModder(directory=directory).apply_actions(actions) else: # Default functionality is to copy CONTCAR to POSCAR and set @@ -226,12 +226,12 @@ def setup(self): ] else: actions = self.auto_continue - dumpfn({"actions": actions}, "continue.json") + dumpfn({"actions": actions}, os.path.join(directory, "continue.json")) if self.settings_override is not None: - VaspModder().apply_actions(self.settings_override) + VaspModder(directory=directory).apply_actions(self.settings_override) - def run(self): + def run(self, directory="./"): """ Perform the actual VASP run. @@ -240,7 +240,7 @@ def run(self): """ cmd = list(self.vasp_cmd) if self.auto_gamma: - vi = VaspInput.from_directory(".") + vi = VaspInput.from_directory(directory) kpts = vi["KPOINTS"] if kpts is not None and kpts.style == Kpoints.supported_modes.Gamma and tuple(kpts.kpts[0]) == (1, 1, 1): if self.gamma_vasp_cmd is not None and which(self.gamma_vasp_cmd[-1]): # pylint: disable=E1136 @@ -248,16 +248,20 @@ def run(self): elif which(cmd[-1] + ".gamma"): cmd[-1] += ".gamma" logger.info(f"Running {' '.join(cmd)}") - with open(self.output_file, "w") as f_std, open(self.stderr_file, "w", buffering=1) as f_err: + with ( + open(os.path.join(directory, self.output_file), "w") as f_std, + open(os.path.join(directory, self.stderr_file), "w", buffering=1) as f_err, + ): # use line buffering for stderr - return subprocess.Popen(cmd, stdout=f_std, stderr=f_err, start_new_session=True) # pylint: disable=R1732 + return subprocess.Popen(cmd, cwd=directory, stdout=f_std, stderr=f_err, start_new_session=True) # pylint: disable=R1732 - def postprocess(self): + def postprocess(self, directory="./"): """ Postprocessing includes renaming and gzipping where necessary. Also copies the magmom to the incar if necessary. """ for file in [*VASP_OUTPUT_FILES, self.output_file]: + file = os.path.join(directory, file) if os.path.isfile(file): if self.final and self.suffix != "": shutil.move(file, f"{file}{self.suffix}") @@ -266,18 +270,18 @@ def postprocess(self): if self.copy_magmom and not self.final: try: - outcar = Outcar("OUTCAR") + outcar = Outcar(os.path.join(directory, "OUTCAR")) magmom = [m["tot"] for m in outcar.magnetization] - incar = Incar.from_file("INCAR") + incar = Incar.from_file(os.path.join(directory, "INCAR")) incar["MAGMOM"] = magmom - incar.write_file("INCAR") + incar.write_file(os.path.join(directory, "INCAR")) except Exception: logger.error("MAGMOM copy from OUTCAR to INCAR failed") # Remove continuation so if a subsequent job is run in # the same directory, will not restart this job. - if os.path.isfile("continue.json"): - os.remove("continue.json") + if os.path.isfile(os.path.join(directory, "continue.json")): + os.remove(os.path.join(directory, "continue.json")) @classmethod def double_relaxation_run( @@ -287,6 +291,7 @@ def double_relaxation_run( ediffg=-0.05, half_kpts_first_relax=False, auto_continue=False, + directory="./", ): """ Returns a list of two jobs corresponding to an AFLOW style double @@ -322,8 +327,12 @@ def double_relaxation_run( {"dict": "INCAR", "action": {"_set": incar_update}}, {"file": "CONTCAR", "action": {"_file_copy": {"dest": "POSCAR"}}}, ] - if half_kpts_first_relax and os.path.isfile("KPOINTS") and os.path.isfile("POSCAR"): - kpts = Kpoints.from_file("KPOINTS") + if ( + half_kpts_first_relax + and os.path.isfile(os.path.join(directory, "KPOINTS")) + and os.path.isfile(os.path.join(directory, "POSCAR")) + ): + kpts = Kpoints.from_file(os.path.join(directory, "KPOINTS")) orig_kpts_dict = kpts.as_dict() # lattice vectors with length < 8 will get >1 KPOINT kpts.kpts = np.round(np.maximum(np.array(kpts.kpts) / 2, 1)).astype(int).tolist() @@ -359,6 +368,7 @@ def metagga_opt_run( ediffg=-0.05, half_kpts_first_relax=False, auto_continue=False, + directory="./", ): """ Returns a list of three jobs to perform an optimization for any @@ -367,7 +377,7 @@ def metagga_opt_run( to precondition the electronic structure optimizer. The metaGGA optimization is performed using the double relaxation scheme. """ - incar = Incar.from_file("INCAR") + incar = Incar.from_file(os.path.join(directory, "INCAR")) # Defaults to using the SCAN metaGGA metaGGA = incar.get("METAGGA", "SCAN") @@ -395,6 +405,7 @@ def metagga_opt_run( auto_npar=auto_npar, ediffg=ediffg, half_kpts_first_relax=half_kpts_first_relax, + directory=directory, ) ) @@ -424,7 +435,14 @@ def metagga_opt_run( @classmethod def full_opt_run( - cls, vasp_cmd, vol_change_tol=0.02, max_steps=10, ediffg=-0.05, half_kpts_first_relax=False, **vasp_job_kwargs + cls, + vasp_cmd, + vol_change_tol=0.02, + max_steps=10, + ediffg=-0.05, + half_kpts_first_relax=False, + directory="./", + **vasp_job_kwargs, ): r""" Returns a generator of jobs for a full optimization run. Basically, @@ -454,16 +472,20 @@ def full_opt_run( if i == 0: settings = None backup = True - if half_kpts_first_relax and os.path.isfile("KPOINTS") and os.path.isfile("POSCAR"): - kpts = Kpoints.from_file("KPOINTS") + if ( + half_kpts_first_relax + and os.path.isfile(os.path.join(directory, "KPOINTS")) + and os.path.isfile(os.path.join(directory, "POSCAR")) + ): + kpts = Kpoints.from_file(os.path.join(directory, "KPOINTS")) orig_kpts_dict = kpts.as_dict() kpts.kpts = np.maximum(np.array(kpts.kpts) / 2, 1).tolist() low_kpts_dict = kpts.as_dict() settings = [{"dict": "KPOINTS", "action": {"_set": low_kpts_dict}}] else: backup = False - initial = Poscar.from_file("POSCAR").structure - final = Poscar.from_file("CONTCAR").structure + initial = Poscar.from_file(os.path.join(directory, "POSCAR")).structure + final = Poscar.from_file(os.path.join(directory, "CONTCAR")).structure vol_change = (final.volume - initial.volume) / initial.volume logger.info(f"Vol change = {vol_change:.1%}!") @@ -494,7 +516,15 @@ def full_opt_run( @classmethod def constrained_opt_run( - cls, vasp_cmd, lattice_direction, initial_strain, atom_relax=True, max_steps=20, algo="bfgs", **vasp_job_kwargs + cls, + vasp_cmd, + lattice_direction, + initial_strain, + atom_relax=True, + max_steps=20, + algo="bfgs", + directory="./", + **vasp_job_kwargs, ): r""" Returns a generator of jobs for a constrained optimization run. Typical @@ -539,7 +569,7 @@ def constrained_opt_run( """ nsw = 99 if atom_relax else 0 - incar = Incar.from_file("INCAR") + incar = Incar.from_file(os.path.join(directory, "INCAR")) # Set the energy convergence criteria as the EDIFFG (if present) or # 10 x EDIFF (which itself defaults to 1e-4 if not present). @@ -557,7 +587,7 @@ def constrained_opt_run( for i in range(max_steps): if i == 0: settings = [{"dict": "INCAR", "action": {"_set": {"ISIF": 2, "NSW": nsw}}}] - structure = Poscar.from_file("POSCAR").structure + structure = Poscar.from_file(os.path.join(directory, "POSCAR")).structure x = structure.lattice.abc[lattice_index] backup = True else: @@ -631,7 +661,7 @@ def constrained_opt_run( lattice[lattice_index] = lattice[lattice_index] / np.linalg.norm(lattice[lattice_index]) * x s = Structure(lattice, structure.species, structure.frac_coords) - fname = f"POSCAR.{x}" + fname = os.path.join(directory, f"POSCAR.{x}") s.to(filename=fname) incar_update = {"ISTART": 1, "NSW": nsw, "ISIF": 2} @@ -651,12 +681,12 @@ def constrained_opt_run( **vasp_job_kwargs, ) - with open("EOS.txt", "w") as file: + with open(os.path.join(directory, "EOS.txt"), "w") as file: file.write(f"# {lattice_direction} energy\n") for key in sorted(energies): file.write(f"{key} {energies[key]}\n") - def terminate(self): + def terminate(self, directory="./"): """ Kill all VASP processes associated with the current job. This is done by looping over all processes and selecting the ones @@ -667,7 +697,7 @@ def terminate(self): simultaneously executed on the same node). However, this should never happen. """ - workdir = os.getcwd() + workdir = directory logger.info(f"Killing VASP processes in workdir {workdir}.") for proc in psutil.process_iter(): try: @@ -779,40 +809,35 @@ def __init__( self.auto_continue = auto_continue self.settings_override = settings_override - self.neb_dirs = sorted( # 00, 01, etc. - path for path in os.listdir(".") if os.path.isdir(path) and path.isdigit() - ) - self.neb_sub = self.neb_dirs[1:-1] # 01, 02, etc. - - def setup(self): + def setup(self, directory="./"): """ Performs initial setup for VaspNEBJob, including overriding any settings and backing up. """ - neb_dirs = self.neb_dirs + neb_dirs, neb_sub = self._get_neb_dirs(directory) if self.backup: # Back up KPOINTS, INCAR, POTCAR for file in VASP_NEB_INPUT_FILES: - shutil.copy(file, f"{file}.orig") + shutil.copy(os.path.join(directory, file), os.path.join(directory, f"{file}.orig")) # Back up POSCARs for path in neb_dirs: poscar = os.path.join(path, "POSCAR") shutil.copy(poscar, f"{poscar}.orig") - if self.half_kpts and os.path.isfile("KPOINTS"): - kpts = Kpoints.from_file("KPOINTS") + if self.half_kpts and os.path.isfile(os.path.join(directory, "KPOINTS")): + kpts = Kpoints.from_file(os.path.join(directory, "KPOINTS")) kpts.kpts = np.maximum(np.array(kpts.kpts) / 2, 1) kpts.kpts = kpts.kpts.astype(int).tolist() if tuple(kpts.kpts[0]) == (1, 1, 1): kpt_dic = kpts.as_dict() kpt_dic["generation_style"] = "Gamma" kpts = Kpoints.from_dict(kpt_dic) - kpts.write_file("KPOINTS") + kpts.write_file(os.path.join(directory, "KPOINTS")) if self.auto_npar: try: - incar = Incar.from_file("INCAR") + incar = Incar.from_file(os.path.join(directory, "INCAR")) import multiprocessing # Try sge environment variable first @@ -824,25 +849,29 @@ def setup(self): if ncores % npar == 0: incar["NPAR"] = npar break - incar.write_file("INCAR") + incar.write_file(os.path.join(directory, "INCAR")) except Exception: pass - if self.auto_continue and os.path.isfile("STOPCAR") and not os.access("STOPCAR", os.W_OK): + if ( + self.auto_continue + and os.path.isfile(os.path.join(directory, "STOPCAR")) + and not os.access(os.path.join(directory, "STOPCAR"), os.W_OK) + ): # Remove STOPCAR - os.chmod("STOPCAR", 0o644) - os.remove("STOPCAR") + os.chmod(os.path.join(directory, "STOPCAR"), 0o644) + os.remove(os.path.join(directory, "STOPCAR")) # Copy CONTCAR to POSCAR - for path in self.neb_sub: + for path in neb_sub: contcar = os.path.join(path, "CONTCAR") poscar = os.path.join(path, "POSCAR") shutil.copy(contcar, poscar) if self.settings_override is not None: - VaspModder().apply_actions(self.settings_override) + VaspModder(directory=directory).apply_actions(self.settings_override) - def run(self): + def run(self, directory="./"): """ Perform the actual VASP run. @@ -851,7 +880,7 @@ def run(self): """ cmd = list(self.vasp_cmd) if self.auto_gamma: - kpts = Kpoints.from_file("KPOINTS") + kpts = Kpoints.from_file(os.path.join(directory, "KPOINTS")) if kpts.style == Kpoints.supported_modes.Gamma and tuple(kpts.kpts[0]) == ( 1, 1, @@ -862,14 +891,26 @@ def run(self): elif which(cmd[-1] + ".gamma"): cmd[-1] += ".gamma" logger.info(f"Running {' '.join(cmd)}") - with open(self.output_file, "w") as f_std, open(self.stderr_file, "w", buffering=1) as f_err: + with ( + open(os.path.join(directory, self.output_file), "w") as f_std, + open(os.path.join(directory, self.stderr_file), "w", buffering=1) as f_err, + ): # Use line buffering for stderr - return subprocess.Popen(cmd, stdout=f_std, stderr=f_err, start_new_session=True) # pylint: disable=R1732 - - def postprocess(self): + return subprocess.Popen( + cmd, + cwd=directory, + stdout=f_std, + stderr=f_err, + start_new_session=True, + ) # pylint: disable=R1732 + + def postprocess(self, directory="./"): """Postprocessing includes renaming and gzipping where necessary.""" # Add suffix to all sub_dir/{items} - for path in self.neb_dirs: + + neb_dirs, neb_sub = self._get_neb_dirs(directory) + + for path in neb_dirs: for file in VASP_NEB_OUTPUT_SUB_FILES: file = os.path.join(path, file) if os.path.isfile(file): @@ -880,12 +921,20 @@ def postprocess(self): # Add suffix to all output files for file in [*VASP_NEB_OUTPUT_FILES, self.output_file]: + file = os.path.join(directory, file) if os.path.isfile(file): if self.final and self.suffix != "": shutil.move(file, f"{file}{self.suffix}") elif self.suffix != "": shutil.copy(file, f"{file}{self.suffix}") + def _get_neb_dirs(self, directory): + neb_dirs = sorted( # 00, 01, etc. + path for path in os.listdir(directory) if os.path.isdir(path) and path.isdigit() + ) + neb_sub = neb_dirs[1:-1] # 01, 02, etc. + return neb_dirs, neb_sub + class GenerateVaspInputJob(Job): """ @@ -906,21 +955,21 @@ def __init__(self, input_set, contcar_only=True, **kwargs): self.contcar_only = contcar_only self.kwargs = kwargs - def setup(self): + def setup(self, directory="./"): """Dummy setup.""" - def run(self): + def run(self, directory="./"): """Run the calculation.""" - if os.path.isfile("CONTCAR"): - structure = Structure.from_file("CONTCAR") - elif (not self.contcar_only) and os.path.isfile("POSCAR"): - structure = Structure.from_file("POSCAR") + if os.path.isfile(os.path.join(directory, "CONTCAR")): + structure = Structure.from_file(os.path.join(directory, "CONTCAR")) + elif (not self.contcar_only) and os.path.isfile(os.path.join(directory, "POSCAR")): + structure = Structure.from_file(os.path.join(directory, "POSCAR")) else: raise RuntimeError("No CONTCAR/POSCAR detected to generate input!") modname, classname = self.input_set.rsplit(".", 1) mod = __import__(modname, globals(), locals(), [classname], 0) vis = getattr(mod, classname)(structure, **self.kwargs) - vis.write_input(".") + vis.write_input(directory) - def postprocess(self): + def postprocess(self, directory="./"): """Dummy postprocess.""" diff --git a/custodian/vasp/validators.py b/custodian/vasp/validators.py index c6979ee3..af1aef36 100644 --- a/custodian/vasp/validators.py +++ b/custodian/vasp/validators.py @@ -25,31 +25,31 @@ def __init__(self, output_file: str = "vasp.out", stderr_file: str = "std_err.tx self.stderr_file = stderr_file self.logger = logging.getLogger(type(self).__name__) - def check(self): - """Check for error.""" + def check(self, directory="./"): + """Check for errors.""" try: - load_vasprun(os.path.join(os.getcwd(), "vasprun.xml")) + load_vasprun(os.path.join(directory, "vasprun.xml")) except Exception: exception_context = {} - if os.path.isfile(self.output_file): - with open(self.output_file) as output_file: + if os.path.isfile(os.path.join(directory, self.output_file)): + with open(os.path.join(directory, self.output_file)) as output_file: output_file_tail = deque(output_file, maxlen=10) exception_context["output_file_tail"] = "".join(output_file_tail) - if os.path.isfile(self.stderr_file): - with open(self.stderr_file) as stderr_file: + if os.path.isfile(os.path.join(directory, self.stderr_file)): + with open(os.path.join(directory, self.stderr_file)) as stderr_file: stderr_file_tail = deque(stderr_file, maxlen=10) exception_context["stderr_file_tail"] = "".join(stderr_file_tail) - if os.path.isfile("vasprun.xml"): - stat = os.stat("vasprun.xml") + if os.path.isfile(os.path.join(directory, "vasprun.xml")): + stat = os.stat(os.path.join(directory, "vasprun.xml")) exception_context["vasprun_st_size"] = stat.st_size exception_context["vasprun_st_atime"] = stat.st_atime exception_context["vasprun_st_mtime"] = stat.st_mtime exception_context["vasprun_st_ctime"] = stat.st_ctime - with open("vasprun.xml") as vasprun: + with open(os.path.join(directory, "vasprun.xml")) as vasprun: vasprun_tail = deque(vasprun, maxlen=10) exception_context["vasprun_tail"] = "".join(vasprun_tail) @@ -68,9 +68,9 @@ class VaspFilesValidator(Validator): def __init__(self): """Dummy init.""" - def check(self): + def check(self, directory="./"): """Check for error.""" - return any(not os.path.isfile(vfile) for vfile in ["CONTCAR", "OSZICAR", "OUTCAR"]) + return any(not os.path.isfile(os.path.join(directory, vfile)) for vfile in ["CONTCAR", "OSZICAR", "OUTCAR"]) class VaspNpTMDValidator(Validator): @@ -82,14 +82,14 @@ class VaspNpTMDValidator(Validator): def __init__(self): """Dummy init.""" - def check(self): + def check(self, directory="./"): """Check for error.""" - incar = Incar.from_file("INCAR") + incar = Incar.from_file(os.path.join(directory, "INCAR")) is_npt = incar.get("MDALGO") == 3 if not is_npt: return False - outcar = load_outcar(os.path.join(os.getcwd(), "OUTCAR")) + outcar = load_outcar(os.path.join(directory, "OUTCAR")) patterns = {"MDALGO": r"MDALGO\s+=\s+([\d]+)"} outcar.read_pattern(patterns=patterns) if outcar.data["MDALGO"] == [["3"]]: @@ -103,10 +103,10 @@ class VaspAECCARValidator(Validator): def __init__(self): """Dummy init.""" - def check(self): + def check(self, directory="./"): """Check for error.""" - aeccar0 = Chgcar.from_file("AECCAR0") - aeccar2 = Chgcar.from_file("AECCAR2") + aeccar0 = Chgcar.from_file(os.path.join(directory, "AECCAR0")) + aeccar2 = Chgcar.from_file(os.path.join(directory, "AECCAR2")) aeccar = aeccar0 + aeccar2 return check_broken_chgcar(aeccar) diff --git a/tests/test_custodian.py b/tests/test_custodian.py index 649fe1cd..9c87ac40 100644 --- a/tests/test_custodian.py +++ b/tests/test_custodian.py @@ -25,13 +25,13 @@ class ExitCodeJob(Job): def __init__(self, exitcode=0): self.exitcode = exitcode - def setup(self): + def setup(self, directory="./"): pass - def run(self): - return subprocess.Popen(f"exit {self.exitcode}", shell=True) + def run(self, directory="./"): + return subprocess.Popen(f"exit {self.exitcode}", cwd=directory, shell=True) - def postprocess(self): + def postprocess(self, directory="./"): pass @@ -42,15 +42,15 @@ def __init__(self, jobid, params=None): self.jobid = jobid self.params = params - def setup(self): + def setup(self, directory="./"): self.params["initial"] = 0 self.params["total"] = 0 - def run(self): + def run(self, directory="./"): sequence = [random.uniform(0, 1) for i in range(100)] self.params["total"] = self.params["initial"] + sum(sequence) - def postprocess(self): + def postprocess(self, directory="./"): pass @property @@ -62,10 +62,10 @@ class ExampleHandler(ErrorHandler): def __init__(self, params): self.params = params - def check(self): + def check(self, directory="./"): return self.params["total"] < 50 - def correct(self): + def correct(self, directory="./"): self.params["initial"] += 1 return {"errors": "total < 50", "actions": "increment by 1"} @@ -97,10 +97,10 @@ def __init__(self, params): self.params = params self.has_error = False - def check(self): + def check(self, directory="./"): return True - def correct(self): + def correct(self, directory="./"): self.has_error = True return {"errors": "Unrecoverable error", "actions": None} @@ -112,7 +112,7 @@ class ExampleHandler2b(ExampleHandler2): raises_runtime_error = False - def correct(self): + def correct(self, directory="./"): self.has_error = True return {"errors": "Unrecoverable error", "actions": []} @@ -121,7 +121,7 @@ class ExampleValidator1(Validator): def __init__(self): pass - def check(self): + def check(self, directory="./"): return False @@ -129,7 +129,7 @@ class ExampleValidator2(Validator): def __init__(self): pass - def check(self): + def check(self, directory="./"): return True