Source code for loop_step.loop

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

"""Non-graphical part of the Loop step in a SEAMM flowchart"""

import json
import logging
import os
from pathlib import Path
import re
import shlex
import shutil
import sqlite3
import sys
import traceback

import psutil
import pprint

import loop_step
import seamm
import seamm_util
import seamm_util.printing as printing
from seamm_util.printing import FormattedText as __

logger = logging.getLogger(__name__)
job = printing.getPrinter()


def _plain(value):
    """A plain Python value for JSON, e.g. from a numpy scalar."""
    if hasattr(value, "item") and not isinstance(value, (list, tuple, dict)):
        return value.item()
    return value


printer = printing.getPrinter("loop")

# How many times an iteration of a parallel loop whose evaluator stopped early
# (killed, out of memory) is run again, resuming from its checkpoint
MAX_RETRIES = 2

#: Workspace variables that steps add records to, one after another, such as
#: ``_md_runs``: a record of each MD run and its data file, which analysis steps
#: read. A parallel loop appends each iteration's records, in iteration order, as a
#: serial loop leaves them; the records of a skipped iteration, whose directory is
#: removed, are dropped in both.
APPENDED_VARIABLES = ("_md_runs",)


#: The structure handling that writes a step's structure over the current
#: configuration, which the iterations of a parallel loop over one structure share
OVERWRITE = "Overwrite the current configuration"


def _static(parameters, key):
    """A parameter's value as given, or None if it is a variable or expression."""
    if parameters is None or key not in parameters:
        return None
    value = parameters[key].value
    if isinstance(value, str) and value.strip().startswith(("$", "=")):
        return None
    return value


def _added_records(before, after):
    """The records an iteration added to a list that it started as ``before``."""
    before = list(before or [])
    after = list(after or [])
    if after[: len(before)] == before:
        return after[len(before) :]
    return [record for record in after if record not in before]


def _checkpoint_variable(path, name):
    """A variable as the checkpoint in the database ``path`` holds it, or None."""
    from seamm.checkpoint import decode_value

    checkpoint = seamm.read_checkpoint(path)
    if checkpoint is None:
        return None
    data = checkpoint.get("variables", {}).get(name)
    return None if data is None else decode_value(data)


def _inside(path, directory):
    """Whether ``path`` is in ``directory``."""
    try:
        Path(path).resolve().relative_to(Path(directory).resolve())
    except (TypeError, ValueError):
        return False
    return True


class _Collect(logging.Handler):
    """Collects the warnings of a merge, for job.out."""

    def __init__(self):
        super().__init__(level=logging.WARNING)
        self.messages = []

    def emit(self, record):
        self.messages.append(record.getMessage())


[docs] def iteration_cores( setting, job_cores, n_iterations, at_once=None, job_memory=0, memory=None ): """The cores of each iteration of a parallel loop. Parameters ---------- setting : int or str "Cores per iteration": a number, or "automatic" to share the job's cores among the iterations that run at once. job_cores : int The job's cores (its tasks). n_iterations : int The iterations to run. at_once : int or None "Iterations at once", or None for as many as fit. job_memory, memory : int The job's memory and each iteration's, bytes; 0 or None if not known. Returns ------- (int, int or None) The cores per iteration, and how many iterations share the job's cores (None when the number was given). A parallel loop gave each iteration one core by default, so 4 replicas ran on 4 of a job's 64 cores (SEAMM job 5368, 2026-10-22). """ if not (isinstance(setting, str) and setting.strip().lower() == "automatic"): return max(1, int(setting)), None shared = max(1, n_iterations) if at_once: shared = min(shared, int(at_once)) if job_memory and memory: shared = min(shared, max(1, int(job_memory // memory))) return max(1, int(job_cores) // shared), shared
def _yes(value): """A boolean parameter's value, as text or as a bool.""" if isinstance(value, str): return value.strip().lower() in ("yes", "true", "1") return bool(value) def _in_units(value, units): """A quantity's magnitude in ``units``; None when not given.""" if value is None: return None if isinstance(value, str): text = value.strip() if text == "" or text.startswith("not"): return None from seamm_util import Q_ value = Q_(text) if hasattr(value, "to"): return float(value.to(units).magnitude) return float(value) def _get_decimal_places(value): """Get the number of decimal places in a float.""" if not isinstance(value, float): return 0 parts = str(value).split(".") if len(parts) == 2: return len(parts[1]) return 0
[docs] class BreakLoop(Exception): """Indicates that SEAMM should break from the loop""" def __init__(self, message="break from the loop"): super().__init__(message)
[docs] def break_loop(): """Break from the loop and continue on.""" raise BreakLoop()
[docs] class ContinueLoop(Exception): """Indicates that SEAMM should continue from the loop""" def __init__(self, message="continue with next iteration of loop"): super().__init__(message)
[docs] def continue_loop(): """Continue to the next iteration of the loop""" raise ContinueLoop()
[docs] class SkipIteration(Exception): """Indicates that SEAMM should skip this iteration of the loop, removing any directories, etc.""" def __init__(self, message="skip iteration of loop"): super().__init__(message)
[docs] def skip_iteration(): """Entirely skip this iteration, removing any files, etc.""" raise SkipIteration()
[docs] class Loop(seamm.Node): def __init__(self, flowchart=None, extension=None): """Setup the non-graphical part of the Loop step in a SEAMM flowchart. Keyword arguments: """ logger.debug("Creating Loop {}".format(self)) self.table = None self._loop_count = None self._loop_value = None self._loop_length = None self._file_handler = None self._custom_directory_name = None super().__init__( flowchart=flowchart, title="Loop", extension=extension, logger=logger ) # This needs to be after initializing subclasses... self.parameters = loop_step.LoopParameters() @property def version(self): """The semantic version of this module.""" return loop_step.__version__ @property def all_options(self): """The complete set of all options.""" return self._all_options @all_options.setter def all_options(self, value): self._all_options = value # and set for the subnodes node = self.loop_node() while node is not None and node != self: node.all_options = value node = node.next() @property def iter_format(self): if self._loop_length is None: return "07" else: n = len(str(self._loop_length)) return f"0{n}" @property def git_revision(self): """The git version of this module.""" return loop_step.__git_revision__ @property def working_path(self): if self._custom_directory_name is not None: tmp = Path(self.directory) / self._custom_directory_name else: tmp = Path(self.directory) / f"iter_{self._loop_value:{self.iter_format}}" return tmp
[docs] def describe(self): """Write out information about what this node will do""" self.visited = True # The description job.job(__(self.description_text(), indent=self.indent)) return self.exit_node()
[docs] def description_text(self, P=None): """Return a short description of this step. Return a nicely formatted string describing what this step will do. Keyword arguments: P: a dictionary of parameter values, which may be variables or final values. If None, then the parameters values will be used as is. """ if not P: P = self.parameters.values_to_dict() text = "" if P["type"] == "For": subtext = "For {variable} from {start} to {end} by {step}\n" elif P["type"] == "Foreach": if self.is_expr(P["values"]): subtext = f"Foreach {P['variable']} in {P['values']}\n" else: if isinstance(P["values"], str): values = [str(v) for v in shlex.split(P["values"])] else: values = [str(v) for v in P["values"]] if len(values) > 5: last = values[-1] values = values[0:6] values.append("...") values.append(last) tmp = ", ".join(values) if len(tmp) < 50: subtext = f"Foreach {P['variable']} in {tmp}\n" else: tmp = "\n ".join(values) subtext = f"Foreach {P['variable']} in\n {tmp}\n" elif P["type"] == "For rows in table": subtext = "For rows in table {table}\n" elif P["type"] == "For systems in the database": subtext = seamm.standard_parameters.structure_selection_description( P ).replace("will be used", "will be looped over") subtext += "\n" else: subtext = "Loop type defined by {type}\n" text += self.header + "\n" + __(subtext, **P, indent=4 * " ").__str__() # Print the body of the loop join_node = self.previous() next_node = self.loop_node() while next_node is not None and next_node != join_node: text += "\n\n" text += str(__(next_node.description_text(), indent=4 * " ", wrap=False)) next_node = next_node.next() return text
[docs] def run(self): """Run a Loop step.""" # If the loop is empty, just go on if self.loop_node() is None: return self.exit_node() # Set up the directory, etc. super().run() P = self.parameters.current_values_to_dict( context=seamm.flowchart_variables._data ) # Reset variables to initial state. self._custom_directory_name = None # Resuming a job part way through this loop? Then the checkpoint holds # the loop's state and the variables, so the set-up below is skipped and # the iteration it was in is set up again rather than advanced to. checkpointer = seamm.checkpoint.get_checkpointer() resume = None if checkpointer is not None: resume = checkpointer.loop_resume(self) state = None resume_node = None if resume is not None: state, resume_node = resume # The evaluator of one iteration of a parallel loop runs just that one only = state is not None and state.get("only", False) parallel = not only and self._parallel(P, checkpointer) if state is not None and state.get("parallel") and not parallel: raise seamm.CheckpointError( "This loop was running its iterations in parallel when the job " "stopped, and cannot resume them one after another." ) if state is not None and not state.get("parallel"): self._restore_state(state) # Print out header to the main output printer.important(__(self.description_text(P), indent=self.indent)) context = self._prepare(P, state) # Remove any redirection of printing. if self._file_handler is not None: job.removeHandler(self._file_handler) self._file_handler = None # Find the handler for job.out and set the level up job_handler = None out_handler = None for handler in job.handlers: if ( isinstance(handler, logging.FileHandler) and "job.out" in handler.baseFilename ): job_handler = handler job_level = job_handler.level job_handler.setLevel(printing.JOB) elif isinstance(handler, logging.StreamHandler): out_handler = handler out_level = out_handler.level out_handler.setLevel(printing.JOB) try: if parallel: self._run_parallel(P, context, checkpointer, state) else: self._run_serial( P, context, checkpointer, state, resume_node, only=only ) finally: # Remove any redirection of printing. if self._file_handler is not None: self._file_handler.close() job.removeHandler(self._file_handler) self._file_handler = None if job_handler is not None: job_handler.setLevel(job_level) if out_handler is not None: out_handler.setLevel(out_level) return self.exit_node()
# ------------------------------------------------------------------ # Setting the loop and its iterations up # ------------------------------------------------------------------ def _prepare(self, P, state=None): """What the iterations are: a dict with their number, ``length``. On a fresh start (``state`` None) also initializes the loop's variables; resuming, the items of a loop over rows or systems are the ones in the checkpoint, frozen when the loop started. """ fresh = state is None context = {} if P["type"] == "For": # See if loop variables are all integers start = P["start"] if isinstance(start, str): start = float(start) if start.is_integer(): start = int(start) step = P["step"] if isinstance(step, str): step = float(step) if step.is_integer(): step = int(step) end = P["end"] if isinstance(end, str): end = float(end) if end.is_integer(): end = int(end) # If floating point, remove nasty extra digits ndigits = max( _get_decimal_places(start), _get_decimal_places(step), _get_decimal_places(end), ) if ndigits > 0: fmt = f".{ndigits}f" else: fmt = f"0{len(str(end))}d" # The values, as adding the step each iteration gives them (range # doesn't work for nonintegers) values = [] tmp = start while tmp <= end: values.append(tmp) tmp += step if ndigits > 0: tmp = round(tmp, ndigits) context.update( start=start, step=step, end=end, ndigits=ndigits, fmt=fmt, values=values ) context["past"] = tmp context["length"] = len(values) if fresh: self.logger.info( "For {} from {} to {} by {}".format( P["variable"], P["start"], P["end"], P["step"] ) ) self.logger.info("Initializing loop") self._loop_count = 0 self._loop_value = start self.set_variable(P["variable"], self._loop_value) self._loop_length = len(values) self._push_loop_indices(self._loop_value) elif P["type"] == "Foreach": if isinstance(P["values"], str): values = shlex.split(P["values"]) else: values = list(P["values"]) context["values"] = values context["length"] = len(values) if fresh: self._loop_value = 0 self._loop_length = len(values) self._push_loop_indices(None) elif P["type"] == "For rows in table": self.table = self.get_table(P["table"], create=False) if not fresh: table_rows = state["items"] table_indices = state["indices"] else: self.table["loop index"] = True self.logger.info( "Initialize loop over {} rows in table {}".format( self.table.n_rows, P["table"] ) ) self._loop_value = 0 self._loop_length = self.table.n_rows self._push_loop_indices(None) table_rows = self._select_rows(P) table_indices = [self.table.label(row) for row in table_rows] n_table_indices = len(table_indices) index_is_int = False fmt = None if n_table_indices > 0: index_is_int = isinstance(table_indices[0], int) if index_is_int: fmt = f"0{len(str(max(table_indices) + 1))}d" context.update( rows=table_rows, indices=table_indices, index_is_int=index_is_int, fmt=fmt, length=n_table_indices, ) elif P["type"] == "For systems in the database": # The configurations to loop over: the standard SEAMM selection. An # empty selection is simply a loop with no iterations. if not fresh: system_db = self.get_variable("_system_db") configurations = [ system_db.get_configuration(cid) for cid in state["items"] ] else: configurations = self.select_configurations(P, errors=False) self._loop_value = 0 self._loop_length = len(configurations) self._push_loop_indices(None) context["configurations"] = configurations context["length"] = len(configurations) else: raise RuntimeError(f"Don't recognize the loop type {P['type']}") if fresh: printer.important( __( f"The loop will have {context['length']} iterations.\n\n", indent=self.indent + 4 * " ", ) ) return context def _push_loop_indices(self, value): """Add this loop's index to the loop indices of the enclosing loops.""" if self.variable_exists("_loop_indices"): tmp = self.get_variable("_loop_indices") self.set_variable("_loop_indices", (*tmp, value)) else: self.set_variable("_loop_indices", (value,)) if value is not None: self.set_variable("_loop_index", value) def _pop_loop_indices(self): """Revert the loop index variables to the next outer loop, if any.""" tmp = self.get_variable("_loop_indices") if len(tmp) <= 1: self.delete_variable("_loop_indices") self.delete_variable("_loop_index") else: self.set_variable("_loop_indices", tmp[0:-1]) self.set_variable("_loop_index", tmp[-2]) def _set_loop_index(self, value): tmp = self.get_variable("_loop_indices") self.set_variable("_loop_indices", (*tmp[0:-1], value)) self.set_variable("_loop_index", value) def _select_rows(self, P): """The rows of the table to loop over.""" where = P["where"] if where == "Use all rows": return [row for row, _ in self.table.rows()] if where == "Select rows where column": op = P["query-op"] if op not in seamm.table.operators: raise NotImplementedError(f"Loop query '{op}' not implemented") try: selection = ( P["query-column"], op, P["query-value"], P["query-value2"], ) return [row for row, _ in self.table.rows(where=selection)] except ValueError as e: if "has no column" not in str(e): raise column = P["query-column"] raise ValueError( f"Looping over table with criterion on column '{column}': " "that column does not exist." ) raise NotImplementedError(f"Loop cannot handle '{where}'") def _iteration_number(self, P): """The number (1, 2, ...) of the current iteration, 0 before the first.""" if P["type"] == "For": return self._loop_count return self._loop_value def _setup_iteration(self, P, context, k): """Set the loop's variables, directory name, etc. for iteration ``k``.""" loop_type = P["type"] self._custom_directory_name = None if loop_type == "For": self._loop_count = k self._loop_value = context["values"][k - 1] self.set_variable(P["variable"], self._loop_value) # Use the value for the directory names self._custom_directory_name = f"iter_{self._loop_value:{context['fmt']}}" self._set_loop_index(self._loop_value) self.logger.info(" Loop value = {}".format(self._loop_value)) elif loop_type == "Foreach": self.logger.debug(f"Foreach {P['variable']} in {P['values']}") self._loop_value = k value = context["values"][k - 1] self.set_variable(P["variable"], value) self._set_loop_index(self._loop_value) self.logger.info(" Loop value = {}".format(value)) elif loop_type == "For rows in table": self._loop_value = k index = context["indices"][k - 1] self._set_loop_index(index) self.table.current_row = context["rows"][k - 1] # Name of directory is the index (+1 since tends to be 0 based) if context["index_is_int"]: self._custom_directory_name = f"iter_{index + 1:{context['fmt']}}" else: self._custom_directory_name = self.safe_filename(str(index)) row = { k: v for k, v in self.table.get_row().items() if k != self.table.index_column } self.set_variable("_row", row) if P["as variables"]: for key, value in row.items(): # Make a safe variable name key = re.sub(r"[-\\ / \+\*()]", "_", key) self.set_variable(key, value) self.logger.debug(" _row = {}".format(row)) elif loop_type == "For systems in the database": self._loop_value = k # Set the default system and configuration configuration = context["configurations"][k - 1] system_db = configuration.system_db system = configuration.system system_db.system = configuration.system system.configuration = configuration if P["directory name"] == "system name": self._custom_directory_name = self.safe_filename(system.name) elif P["directory name"] == "configuration name": self._custom_directory_name = self.safe_filename(configuration.name) else: self._custom_directory_name = None self._set_loop_index(self._loop_value) self.logger.info(f" system = {system.name}") self.logger.info(f"configuration = {configuration.name}") def _end_loop(self, P, context): """Tidy the loop's variables away after its last iteration.""" loop_type = P["type"] if loop_type == "For": # As always: the loop variable is left one step past the end self.set_variable(P["variable"], context["past"]) self._loop_value = None self._custom_directory_name = None self._pop_loop_indices() self.logger.info( f"The loop over {P['variable']} from {context['start']} to " f"{context['end']} by {context['step']} finished successfully" ) elif loop_type == "Foreach": self._loop_value = None self._loop_length = None self._pop_loop_indices() self.logger.info("The loop over value finished successfully") elif loop_type == "For rows in table": self._loop_value = None self.delete_variable("_row") self._pop_loop_indices() # and the other info in the table self.table["loop index"] = False self.table = None self.logger.info( "The loop over table " + self.parameters["table"].value + " finished successfully" ) elif loop_type == "For systems in the database": self._loop_value = None self._loop_length = None self._custom_directory_name = None self._pop_loop_indices() self.logger.info("The loop over value finished successfully") def _items(self, P, context): """The frozen items of the loop, for the checkpoint.""" if P["type"] == "For rows in table": return (context["rows"], context["indices"]) elif P["type"] == "For systems in the database": return ([c.id for c in context["configurations"]], None) return (None, None) def _open_iteration_output(self, iter_dir, keep): """Direct most output to the iteration's iteration.out.""" if self._file_handler is not None: self._file_handler.close() job.removeHandler(self._file_handler) path = iter_dir / "iteration.out" if not keep: path.unlink(missing_ok=True) self._file_handler = logging.FileHandler(path) self._file_handler.setLevel(printing.NORMAL) formatter = logging.Formatter(fmt="{message:s}", style="{") self._file_handler.setFormatter(formatter) job.addHandler(self._file_handler) def _close_iteration_output(self): if self._file_handler is not None: self._file_handler.close() job.removeHandler(self._file_handler) self._file_handler = None # ------------------------------------------------------------------ # One iteration after another # ------------------------------------------------------------------ def _run_serial(self, P, context, checkpointer, state, resume_node, only=False): """Run the iterations in this evaluator, one after another. ``only``: this evaluator runs one iteration of a parallel loop, the one it resumed into, and stops when it ends (seamm.IterationDone). """ length = context["length"] resuming = state is not None if only and resume_node is None: # The iteration had finished, but the evaluator had not. raise seamm.IterationDone() # Cycle through the iterations. Resuming in the middle of an iteration, # set it up again without advancing, then start its body at the step # that had not finished. next_node = self advance = not resuming or resume_node is None ran = False iter_dir = None while next_node is not None: if next_node is self: if only and ran: raise seamm.IterationDone() next_node = self.loop_node() k = self._iteration_number(P) if advance: k += 1 if k > length: self._end_loop(P, context) break self._setup_iteration(P, context, k) # Resuming this iteration: its directory is the one it had, not # a new unique name next to it. if not advance: self._custom_directory_name = state["directory"] # Add the iteration to the ids so the directory structure is # reasonable self.flowchart.reset_visited() tmp = self.working_path.name self.set_subids((*self._id, tmp)) # Checkpoint the start of the iteration, with what is needed to # set it up again -- before its directory exists, so a kill in # between cannot leave a directory the checkpoint does not know. if checkpointer is not None: if not advance: next_node = self._find_body_node(resume_node) iteration_state = self._checkpoint_state( P, *self._items(P, context) ) if only: iteration_state.update(only=True, done=False) checkpointer.enter_iteration(self, iteration_state, next_node) resuming_iteration = not advance advance = True ran = True iter_dir = self.working_path iter_dir.mkdir(parents=True, exist_ok=True) self._open_iteration_output(iter_dir, keep=resuming_iteration) # Run through the steps in the loop body try: node = next_node next_node = next_node.run() seamm.step_completed(node, next_node) except DeprecationWarning as e: printer.normal("\nDeprecation warning: " + str(e)) traceback.print_exc(file=sys.stderr) traceback.print_exc(file=sys.stdout) except BreakLoop: if only: raise seamm.IterationDone(broke=True) break except ContinueLoop: next_node = self except SkipIteration: if only: # The parent removes the iteration's directory raise seamm.IterationDone(skipped=True) next_node = self self._drop_records_in(iter_dir) shutil.rmtree(iter_dir, ignore_errors=True) except Exception as e: tmp = self.working_path.name printer.job(f"Caught exception in loop iteration {tmp}: {str(e)}") with open(iter_dir / "stderr.out", "a") as fd: traceback.print_exc(file=fd) if only: # The parent decides what a failed iteration means raise if "continue" in P["errors"]: next_node = self elif "exit" in P["errors"]: if checkpointer is not None: checkpointer.iteration_failed(self, node) break else: raise # Keep what the failed iteration wrote, as before; a resume # carries on with the next iteration. if checkpointer is not None: checkpointer.iteration_failed(self, node) if self.logger.isEnabledFor(logging.DEBUG): p = psutil.Process() self.logger.debug(pprint.pformat(p.open_files())) self.logger.debug(f"Bottom of loop {next_node}") # Return to the normally scheduled step, i.e. fall out of the loop. if checkpointer is not None: checkpointer.leave_loop(self) # ------------------------------------------------------------------ # Iterations in parallel # ------------------------------------------------------------------ def _parallel(self, P, checkpointer): """Whether to run the iterations in parallel.""" if not _yes(P["parallel"]): return False if checkpointer is None: printer.important( __( "The iterations run one after another: running them in " "parallel needs the job's database in a file in the job, with " "checkpoints.", indent=self.indent + 4 * " ", ) ) return False return True def _job_database_path(self, system_db): """The file of the job's database.""" name = system_db.filename if name.startswith("file:"): name = name[5:] name = name.split("?")[0] path = Path(name) if not path.is_absolute(): path = Path(self.flowchart.job_directory) / path return path def _run_parallel(self, P, context, checkpointer, state): """Run each iteration in an evaluator of its own, then merge them in order. See the phase 6 notes in seamm_exec's developer guide. The Loop's checkpoint frame holds the frozen items, the directories given out, the next iteration to merge, the failed ones, and what the merge needs from one iteration to the next; each iteration is merged in one transaction, committed with that frame. """ from molsystem.snapshot import baseline, snapshot from seamm_exec import LocalPool, TaskSet from seamm_exec import iteration as iterations length = context["length"] system_db = self.get_variable("_system_db") loop_directory = Path(self.directory) job_directory = Path(self.flowchart.job_directory) first = self.loop_node() if self._loop_length is None: self._loop_length = length later_wins = "later" in P["same-cell writes"] if state is None: items, indices = self._items(P, context) current = None if system_db.system is not None: configuration = system_db.system.configuration if configuration is not None: current = configuration.id state = { "type": P["type"], "parallel": True, "count": 0, "length": length, "next": 1, "directories": {}, "failed": [], "merge": {}, "exports": {}, "current": current, "directory": None, "loop_count": None, "loop_value": None, } if items is not None: state["items"] = [_plain(x) for x in items] if indices is not None: state["indices"] = [_plain(x) for x in indices] for problem in self._overwriting_steps(): printer.important( __(f"Warning: {problem}", indent=self.indent + 4 * " ") ) else: state = json.loads(json.dumps(state)) printer.important( __( f"Resuming the parallel loop: {state['next'] - 1} of {length} " "iterations were done and merged.", indent=self.indent + 4 * " ", ) ) merge_state = iterations.decode_merge_state(state.get("merge")) # Commit the loop's set-up with its frame, then keep the database as the # loop starts: every iteration's snapshot is taken from it. checkpointer.parallel_loop(self, state) entry = loop_directory / "loop_entry.db" if state["next"] <= length and not entry.exists(): loop_directory.mkdir(parents=True, exist_ok=True) tmp = entry.with_name(entry.name + ".tmp") snapshot(self._job_database_path(system_db), tmp, whole=True) # Only ever read: no write-ahead log files beside it db = sqlite3.connect(str(tmp)) try: db.execute("PRAGMA journal_mode=DELETE") finally: db.close() os.replace(tmp, entry) # A merge stopped after its database part: finish its files pending = state.pop("files", None) if pending is not None: self._merge_iteration_files(pending, loop_directory, job_directory) # The resources of each iteration memory = _in_units(P["memory per iteration"], "byte") walltime = _in_units(P["time per iteration"], "s") at_once = P["iterations at once"] if isinstance(at_once, str): at_once = None if "as many" in at_once else int(at_once) placement = "inline" if P["placement"] == "inline" else "separate" whole = P["snapshot"] == "whole database" root = None try: root = self.global_options.get("root") except Exception: pass pool = LocalPool(self.flowchart.executor, root=root, max_concurrent=at_once) task_set = TaskSet(self, local=pool, root=root) # A loop that had stopped (a break, or an error) dispatches nothing more stopped = None if state.get("stopped") is not None: stopped = tuple(state["stopped"]) last = length if stopped is None else 0 cores, shared = iteration_cores( P["cores per iteration"], pool.cores, max(0, last - state["next"] + 1), at_once, pool.memory, memory, ) # Set each iteration up, write its snapshot and checkpoint, add its task keys = {} for k in range(state["next"], last + 1): self._setup_iteration(P, context, k) name = state["directories"].get(str(k)) if name is None: # On record before the directory exists, as for a serial loop name = self.working_path.name state["directories"][str(k)] = name checkpointer.parallel_loop(self, state) self._custom_directory_name = name self.flowchart.reset_visited() self.set_subids((*self._id, name)) evaluator = loop_directory / name / iterations.EVALUATOR_DIRECTORY if not (evaluator / "seamm.db").exists(): shutil.rmtree(evaluator, ignore_errors=True) evaluator.mkdir(parents=True) configurations = [state["current"]] if P["type"] == "For systems in the database": configurations.append(context["configurations"][k - 1].id) tmp = evaluator / "seamm.db.tmp" snapshot( entry, tmp, configurations=[c for c in configurations if c is not None], whole=whole, ) checkpointer.write_child( tmp, self, self._checkpoint_state(P, *self._items(P, context)), first, ) baseline(tmp, evaluator / "baseline.db") os.replace(tmp, evaluator / "seamm.db") task = iterations.iteration_task( f"iteration_{k}", evaluator, job_directory, command_line=checkpointer.command_line, cores=cores, memory=memory, walltime=walltime, placement=placement, root_directory=self.flowchart.root_directory, # A file the iteration has not written: this evaluator's, then # those it reads itself read_directories=[ job_directory, *getattr(self.flowchart, "job_read_directories", []), ], ) task_set.add(task) keys[task.key] = k n = len(keys) if n > 0: printer.important( __( f"Running {n} iterations in parallel, each with {cores} cores " f"and {P['memory per iteration']} of memory" + ( f": the job's {pool.cores} cores shared among the " f"{shared} that run at once." if shared else "." ), indent=self.indent + 4 * " ", ) ) # Merge the iterations in order as they finish. An iteration whose # evaluator stopped before the iteration ended (killed, out of memory, # a lost node) runs again, resuming from its own checkpoint. results = {} retries = {} to_run = task_set if n > 0 else None while to_run is not None: again = [] runs = to_run.run() try: for result in runs: k = keys[result.key] if self._stopped_early(state, k, loop_directory): if retries.get(k, 0) < MAX_RETRIES: retries[k] = retries.get(k, 0) + 1 printer.job( f" Loop iteration {state['directories'][str(k)]} " "stopped before it ended; running it again." ) again.append(task_set.tasks[result.key]) continue results[k] = result while state["next"] in results: k = state["next"] stopped = self._merge_iteration( P, context, state, merge_state, k, results.pop(k), checkpointer, system_db, loop_directory, job_directory, later_wins, ) if stopped is not None: break if stopped is not None: break finally: # Cancels the iterations still running runs.close() to_run = None if stopped is None and len(again) > 0: to_run = TaskSet(self, local=pool, root=root) for task in again: to_run.add(task) if stopped is not None: k, why = stopped unmerged = sorted(results) + [ keys[key] for key in task_set.tasks if keys[key] > k and keys[key] not in results ] if len(unmerged) > 0: names = ", ".join( state["directories"][str(i)] for i in sorted(unmerged) ) printer.important( __( f"The iterations after {state['directories'][str(k)]} are " f"not merged; their directories are kept: {names}.", indent=self.indent + 4 * " ", ) ) # Tables the iterations exported, written again from the merged ones for name, filename in state.get("exports", {}).items(): try: self.get_table(name, create=False).export(filename) except Exception as e: printer.important(f"Could not export the table '{name}': {e}") if stopped is None or stopped[1] != "raise": # Nothing more is dispatched (a resume after "stop the job" runs the # failed iteration again, from the loop's entry) for suffix in ("", "-wal", "-shm"): Path(str(entry) + suffix).unlink(missing_ok=True) n_failed = len(state["failed"]) text = f"Merged {state['next'] - 1 - n_failed} of {length} iterations" if n_failed > 0: failed = ", ".join(state["directories"][str(i)] for i in state["failed"]) text += f"; {n_failed} failed and were not merged: {failed}" printer.important(__(text + ".", indent=self.indent + 4 * " ")) if stopped is None: self._end_loop(P, context) else: k, why = stopped # The loop's variables as the iteration that ended it left them self._setup_iteration(P, context, k) if why == "raise": raise RuntimeError( f"Iteration {state['directories'][str(k)]} of the loop failed." ) checkpointer.leave_loop(self) def _evaluator_directory(self, state, k, loop_directory): from seamm_exec import iteration as iterations return ( loop_directory / state["directories"][str(k)] / iterations.EVALUATOR_DIRECTORY ) def _stopped_early(self, state, k, loop_directory): """Whether iteration ``k``'s evaluator stopped before the iteration ended. It did if its checkpoint is still 'running': an evaluator that ends, with an error or not, says so in its checkpoint. """ path = self._evaluator_directory(state, k, loop_directory) / "seamm.db" if not path.exists(): return False try: checkpoint = seamm.read_checkpoint(path) except Exception: return False return checkpoint is not None and checkpoint.get("state") == "running" def _merge_iteration( self, P, context, state, merge_state, k, result, checkpointer, system_db, loop_directory, job_directory, later_wins, ): """Merge iteration ``k``, or record that it failed. Returns ------- (int, str) or None ``(k, why)`` if the loop stops here ("break", "exit" or "raise"). """ from seamm_exec import iteration as iterations name = state["directories"][str(k)] iter_dir = loop_directory / name evaluator = iter_dir / iterations.EVALUATOR_DIRECTORY # The iteration's own final checkpoint says whether it ended: an # evaluator killed as it exited, after finishing, did its work. outcome = iterations.iteration_outcome(evaluator) if outcome is None: reason = result.reason or f"return code {result.returncode}" printer.job( f"Caught an error in loop iteration {name} ({reason}); see " f"{evaluator / 'job.out'}." ) if "stop" in P["errors"]: # The job fails here; a resume runs the iteration again return (k, "raise") state["failed"].append(k) state["next"] = k + 1 state["count"] = k if "continue" in P["errors"]: checkpointer.parallel_loop(self, state) return None state["stopped"] = [k, "exit"] checkpointer.parallel_loop(self, state) return (k, "exit") from molsystem.snapshot import MergeConflict added = {} if not outcome.get("skip"): for variable in APPENDED_VARIABLES: records = _added_records( _checkpoint_variable(evaluator / "baseline.db", variable), _checkpoint_variable(evaluator / "seamm.db", variable), ) if len(records) > 0: added[variable] = records if P["type"] == "For systems in the database": # As a serial loop leaves it: the iteration's configuration current, # unless its body chose another (merged below). The merge cannot see # a choice of the configuration that was current at the loop's entry. configuration = context["configurations"][k - 1] system_db.system = configuration.system configuration.system.configuration = configuration tables_before = set(system_db.user_tables) warnings = _Collect() merge_logger = logging.getLogger("molsystem.snapshot") merge_logger.addHandler(warnings) try: merged = iterations.merge_database( system_db, evaluator, merge_state, k, later_wins=later_wins ) except MergeConflict as e: text = str(e).replace(" (The Loop can let the later one win.)", "") raise MergeConflict( f"{text} Set the Loop's 'Two iterations writing one table cell' to " "'the later iteration wins' to keep the later value instead." ) from None finally: merge_logger.removeHandler(warnings) for message in warnings.messages: printer.job(f" Warning: {message}") # Tables made in the body are variables, as a serial loop leaves them for table in set(system_db.user_tables) - tables_before: if not self.variable_exists(table): self.set_variable(table, seamm.Table(system_db, table)) if P["type"] == "For rows in table": # As a serial loop leaves it: the iteration's row, unless its body # moved on (below). The merge cannot see a move to the row that was # current when the loop started. self.table.current_row = context["rows"][k - 1] for table, row in merged["current_rows"].items(): system_db.user_tables[table].current_row = row for table, filename in merged["exports"].items(): state["exports"][table] = str( iterations.export_path(filename, evaluator, job_directory) ) system_id = outcome.get("system_id") if system_id is not None: system_id = merged["maps"].get("system", {}).get(system_id, system_id) if system_id in system_db.system_ids: system_db.system = system_id # Committed with the database merge, in the checkpoint below for variable, records in added.items(): current = [] if self.variable_exists(variable): current = list(self.get_variable(variable)) self.set_variable(variable, current + records) plan = iterations.plan_files(evaluator, job_directory, outcome.get("run_id")) state["files"] = {"iteration": k, "directory": name, "plan": plan} state["next"] = k + 1 state["count"] = k state["merge"] = iterations.encode_merge_state(merge_state) stop = None if outcome.get("break"): stop = (k, "break") state["stopped"] = [k, "break"] checkpointer.parallel_loop(self, state) self._merge_iteration_files(state.pop("files"), loop_directory, job_directory) if outcome.get("skip"): shutil.rmtree(iter_dir, ignore_errors=True) printer.job(f" Merged loop iteration {name}.") return stop
[docs] def check_flowchart(self, preceding): """Check, before the job runs, that a parallel loop's iterations can merge. The steps in the body of a parallel loop over one structure must not all write their structure over it: two iterations changing the same configuration cannot be merged. Each such step is reported as a warning -- some steps that may write the structure do not change it -- with fixes that set its structure handling. Parameters ---------- preceding : [seamm.Node] The steps that run before this one (not used). Returns ------- [str] The warnings, as seamm.Problem with their fixes; empty if none. """ return self._overwriting_steps() + self._fixed_seeds()
def _overwriting_steps(self): """The warnings for the body's steps that overwrite the shared structure.""" try: from seamm.problems import WARNING, Edit, Fix, Problem except ImportError: # seamm before 2026.10.17 return [] P = self.parameters if not _yes(_static(P, "parallel") or "no"): return [] if _static(P, "type") in (None, "For systems in the database"): return [] if "later" in str(_static(P, "same-cell writes")): return [] problems = [] for node in self._body_nodes(): if _static(node.parameters, "structure handling") != OVERWRITE: continue fixes = [ Fix( f"Discard the structure in '{node.title}'", [Edit(node, "structure handling", "Discard the structure")], ), Fix( f"Create a new configuration in '{node.title}'", [Edit(node, "structure handling", "Create a new configuration")], ), ] problems.append( Problem( f"'{node.title}' in the parallel loop '{self.title}' writes its " "structure over the current configuration, which all the " "iterations share; if it changes the structure, two iterations " "changing it cannot be merged and the job stops after the " "iterations have run. Discard the structure for independent " "runs such as MD replicas, or create a new configuration to " "keep each iteration's structure -- or run the loop serially.", severity=WARNING, fixes=fixes, ) ) return problems def _fixed_seeds(self): """Warnings for steps in the body that seed their random numbers with a fixed value: every iteration gets the same random numbers, so runs that start from the same structure are the same run.""" try: from seamm.problems import WARNING, Edit, Fix, Problem except ImportError: # seamm before 2026.10.17 return [] if _static(self.parameters, "type") in (None, "For systems in the database"): return [] problems = [] for node in self._body_nodes(): seed = _static(node.parameters, "seed") if seed is None or str(seed).strip().lower() == "random": continue if node.parameters is not None and hasattr(node.parameters, "applies"): values = {k: p.value for k, p in node.parameters.items()} if not node.parameters.applies("seed", values): continue fix = Fix( f"Use a random seed in '{node.title}'", [Edit(node, "seed", "random")], ) problems.append( Problem( f"'{node.title}' in the loop '{self.title}' uses the fixed random " f"seed {seed}, so every iteration gets the same random numbers: " "iterations that start from the same structure are the same run, " "not independent replicas. Use a random seed, or one that depends " "on the loop variable.", severity=WARNING, fixes=[fix], ) ) return problems def _body_nodes(self): """The steps in the loop's body, including those inside sub-flowcharts.""" from seamm import format3 def find(items): for item in items: if item.node is self: return item.body or [] for inner in (item.body or [], _sub_items(item.node)): found = find(inner) if found is not None: return found return None def _sub_items(node): sub = format3.subflowchart(node) return [] if sub is None else format3.tree(sub)[0] nodes = [] def collect(items): for item in items: nodes.append(item.node) collect(item.body or []) collect(_sub_items(item.node)) collect(find(format3.tree(self.flowchart)[0]) or []) return nodes def _drop_records_in(self, directory): """Drop the records whose files are in a directory about to be removed.""" for variable in APPENDED_VARIABLES: if not self.variable_exists(variable): continue records = list(self.get_variable(variable)) kept = [ r for r in records if not (isinstance(r, dict) and _inside(r.get("path"), directory)) ] if len(kept) < len(records): self.set_variable(variable, kept) def _merge_iteration_files(self, pending, loop_directory, job_directory): """The job-level files and citations of a merged iteration. ``pending`` is the record in the Loop's frame, committed with the iteration's database merge; doing it twice gives the same files. """ from seamm_exec import iteration as iterations evaluator = ( loop_directory / pending["directory"] / iterations.EVALUATOR_DIRECTORY ) if not evaluator.exists(): # Merged and removed (a skipped iteration) before the job stopped return iterations.merge_files(evaluator, job_directory, pending["plan"]) iterations.merge_citations(evaluator, self.references) def _checkpoint_state(self, P, items=None, indices=None): """The loop's state at the start of an iteration, for the checkpoint.""" if P["type"] == "For": count = self._loop_count else: count = self._loop_value state = { "type": P["type"], "count": count, "length": self._loop_length, "loop_count": self._loop_count, "loop_value": _plain(self._loop_value), "directory": self._custom_directory_name, } if items is not None: state["items"] = [_plain(x) for x in items] if indices is not None: state["indices"] = [_plain(x) for x in indices] return state def _restore_state(self, state): """Restore the loop's state from the checkpoint, to resume it.""" self._loop_count = state["loop_count"] self._loop_value = state["loop_value"] self._loop_length = state["length"] self._custom_directory_name = state["directory"] def _find_body_node(self, node_id): """The node of the loop's body with the given id (a list of strings).""" node = seamm.checkpoint.find_node(self.flowchart, node_id) if node is not None: return node raise seamm.CheckpointError( f"Cannot find step {'.'.join(node_id)} in the loop to resume at." )
[docs] def default_edge_subtype(self): """Return the default subtype of the edge. Usually this is 'next' but for nodes with two or more edges leaving them, such as a loop, this method will return an appropriate default for the current edge. For example, by default the first edge emanating from a loop-node is the 'loop' edge; the second, the 'exit' edge. A return value of 'too many' indicates that the node exceeds the number of allowed exit edges. """ # how many outgoing edges are there? n_edges = len(self.flowchart.edges(self, direction="out")) self.logger.debug(f"loop.default_edge_subtype, n_edges = {n_edges}") if n_edges == 0: return "loop" elif n_edges == 1: return "exit" else: return "too many"
[docs] def create_parser(self): """Setup the command-line / config file parser""" parser_name = "loop-step" parser = seamm_util.getParser() # Remember if the parser exists ... this type of step may have been # found before parser_exists = parser.exists(parser_name) # Create the standard options, e.g. log-level super().create_parser(name=parser_name) if not parser_exists: # Any options for loop itself pass # Now need to walk through the steps in the loop... for edge in self.flowchart.edges(self, direction="out"): if edge.edge_subtype == "loop": self.logger.debug("Loop, first node of loop is: {}".format(edge.node2)) next_node = edge.node2 while next_node and next_node != self: next_node = next_node.create_parser() return self.exit_node()
[docs] def set_id(self, node_id=()): """Sequentially number the loop subnodes""" self.logger.debug("Setting ids for loop {}".format(self)) if self.visited: return None else: self.visited = True self._id = node_id self.set_subids(self._id) return self.exit_node()
[docs] def set_subids(self, node_id=()): """Set the ids of the nodes in the loop""" next_node = self.loop_node() n = 0 while next_node and next_node != self: next_node = next_node.set_id((*node_id, str(n))) n += 1
[docs] def exit_node(self): """The next node after the loop, if any""" for edge in self.flowchart.edges(self, direction="out"): if edge.edge_subtype == "exit": self.logger.debug(f"Loop, node after loop is: {edge.node2}") return edge.node2 # loop is the last node in the flowchart self.logger.debug("There is no node after the loop") return None
[docs] def loop_node(self): """The first node in the loop body""" for edge in self.flowchart.edges(self, direction="out"): if edge.edge_subtype == "loop": self.logger.debug(f"Loop, first node in loop is: {edge.node2}") return edge.node2 # There is no body of the loop! self.logger.debug("There is no loop body") return None
[docs] def safe_filename(self, filename): clean = re.sub(r"[/\\?%*:|\"<>\x7F\x00-\x1F]", "-", filename) # Check for duplicates... path = Path(self.directory) / clean count = 1 while path.exists(): count += 1 path = Path(self.directory) / f"{clean}_{count}" return path.name