# -*- coding: utf-8 -*-
"""Evaluate a model chemistry at many structures, over MDI or as tasks.
A step that needs energies (and gradients, and stress) for many structures
submits them to an :class:`Evaluator` and iterates over the results. The
evaluator, not the step or the user, chooses how they are computed:
- the **batch path**: each structure is a :class:`~seamm_exec.tasks.Task` from
the program's ``get_task``, run by a :class:`~seamm_exec.tasks.TaskSet` (on
the job's target: the local pool or a queue, with restart), and read back by
the program's ``analyze_task``;
- the **MDI path**: one warm MDI engine per group of structures with the same
elements, charge, multiplicity and periodicity, from the program's
``get_mdi_engine_command``.
The rule (see :func:`choose_path`): the batch path when the job's tasks go to a
queue and the program has ``get_task``; else MDI when the program has an MDI
engine, unless it prefers the batch path (``options["prefers_batch"]``, set by
ORCA, whose engine runs a subprocess per structure); else the batch path in the
local pool. Both paths return the same numbers for the same model chemistry.
The program's contract (classmethods beside ``get_model_chemistry_options``)::
get_task(configuration, model_chemistry, *, key, properties, options, resources)
-> seamm_exec.Task
analyze_task(result, model_chemistry, configuration, *, properties, options)
-> {"energy": kJ/mol, "gradients": (n, 3) kJ/mol/Å, "stress": GPa, ...}
can_run_task(configuration, model_chemistry, *, options) -> bool (optional)
A structure for which ``can_run_task`` is False (e.g. a periodic system for
ORCA or MOPAC) goes to the program's MDI engine, if it has one, even when the
others run as tasks. If there is no engine, or it cannot start here (a queue
target with the code only on the cluster), that structure gets a failed result;
so does one whose ``get_task`` raises. Neither stops the rest.
``analyze_task`` raises :class:`AnalysisError` when a required property is
missing; it never returns partial numbers. ``options`` passes what a consumer
needs for a fragment: ``atom_indices``, ``ghost_atoms``, ``charge``,
``multiplicity``, an initial guess.
"""
from __future__ import annotations
from dataclasses import dataclass, field
import logging
import time
import numpy as np
logger = logging.getLogger("seamm-exec")
E_UNITS = "kJ/mol"
G_UNITS = "kJ/mol/Å"
S_UNITS = "GPa"
#: Properties a result must have when requested. Stress is required only for a
#: periodic structure (``check_properties(..., periodic=True)``, and the
#: Evaluator checks it for every task); a molecule has none.
REQUIRED = ("energy", "gradients")
[docs]
class AnalysisError(RuntimeError):
"""A task's results lack a requested property."""
@dataclass
class EvaluatorResult:
"""One structure's results.
Attributes
----------
key : str
ok : bool
energy : float or None
kJ/mol
gradients : numpy.ndarray or None
(n, 3), kJ/mol/Å
stress : list or None
GPa, as the program gives it
reason : str or None
Why it failed.
restored : bool
From an earlier run (batch path).
path : str
"mdi" or "batch"
data : dict
Everything the program returned.
elapsed : float
Seconds spent on it in this run (0 if restored).
"""
key: str
ok: bool
energy: float | None = None
gradients: object = None
stress: object = None
reason: str | None = None
restored: bool = False
path: str = "batch"
data: dict = field(default_factory=dict)
elapsed: float = 0.0
class _Atoms:
def __init__(self, atomic_numbers, coordinates):
self.atomic_numbers = [int(z) for z in atomic_numbers]
self._coordinates = np.asarray(coordinates, dtype=float).reshape(-1, 3)
@property
def symbols(self):
return [_SYMBOLS[z] for z in self.atomic_numbers]
def get_coordinates(self, fractionals=False, as_array=False):
if fractionals:
raise NotImplementedError("Geometry holds Cartesian coordinates only")
return self._coordinates.copy() if as_array else self._coordinates.tolist()
def __len__(self):
return len(self.atomic_numbers)
class _Cell:
def __init__(self, vectors):
self._vectors = np.asarray(vectors, dtype=float).reshape(3, 3)
def vectors(self, as_array=False):
return self._vectors.copy() if as_array else self._vectors.tolist()
class Geometry:
"""A light stand-in for a molsystem configuration: elements, Cartesian
coordinates (Å), charge, multiplicity and an optional cell. For structures
a step makes on the fly, such as finite-difference displacements."""
def __init__(
self,
atomic_numbers,
coordinates,
charge=0,
multiplicity=1,
cell=None,
name=None,
):
self.atoms = _Atoms(atomic_numbers, coordinates)
self.charge = int(charge)
self.spin_multiplicity = int(multiplicity)
self.cell = _Cell(cell) if cell is not None else None
self.periodicity = 3 if cell is not None else 0
self.name = name
@property
def n_atoms(self):
return len(self.atoms)
def structure_data(configuration):
"""What a program needs from a configuration (or a :class:`Geometry`).
Returns
-------
dict
``atomic_numbers``, ``symbols``, ``coordinates`` ((n, 3) Å), ``charge``,
``multiplicity``, ``periodicity`` and ``cell`` ((3, 3) Å or None).
"""
atomic_numbers = [int(z) for z in configuration.atoms.atomic_numbers]
coordinates = np.asarray(
configuration.atoms.get_coordinates(fractionals=False, as_array=True),
dtype=float,
).reshape(-1, 3)
periodicity = int(getattr(configuration, "periodicity", 0) or 0)
cell = None
if periodicity != 0:
cell = np.asarray(configuration.cell.vectors(as_array=True), dtype=float)
return {
"atomic_numbers": atomic_numbers,
"symbols": [_SYMBOLS[z] for z in atomic_numbers],
"coordinates": coordinates,
"charge": int(configuration.charge),
"multiplicity": int(configuration.spin_multiplicity),
"periodicity": periodicity,
"cell": cell,
}
def mdi_method_and_basis(model_chemistry):
"""The (method, basis) an MDI engine is launched with.
``method`` is the program's own keyword (``options["mdi_method_arg"]``),
falling back to the model chemistry's method; ``basis`` is
``options["mdi_basis_arg"]`` (the user's basis, for programs that take one)
or the model chemistry's basis, or None for programs that take a method
alone (MOPAC, xTB, an MLFF).
"""
options = model_chemistry.get("options") or {}
method = options.get("mdi_method_arg") or model_chemistry.get("method")
basis = options.get("mdi_basis_arg") or model_chemistry.get("basis")
return method, basis
def choose_path(model_chemistry, provider, target=None):
""" "mdi" or "batch" for a model chemistry, a program and the job's target.
Raises
------
ValueError
If the program offers neither path.
"""
options = model_chemistry.get("options") or {}
has_batch = hasattr(provider, "get_task") and hasattr(provider, "analyze_task")
has_mdi = bool(options.get("mdi_capable", False)) and hasattr(
provider, "get_mdi_engine_command"
)
on_queue = target is not None and getattr(target, "tasks", None) in (
"queue",
"taskserver",
)
if on_queue and has_batch:
return "batch"
if has_mdi and not (options.get("prefers_batch") and has_batch):
return "mdi"
if has_batch:
return "batch"
raise ValueError(
f"The model chemistry '{model_chemistry.get('level')}' can be evaluated "
"neither over MDI nor as tasks."
)
class Evaluator:
"""Evaluate a model chemistry at many structures.
Parameters
----------
node : seamm.Node
The step: its directory, flowchart (plug-ins, executor) and options.
model_chemistry : dict, optional
The ``_model_chemistry`` wrapper. Default: the node's variable.
properties : [str]
"energy", "gradients", "stress".
path : str, optional
"mdi" or "batch", for tests only; the evaluator chooses otherwise.
directory : str or Path, optional
The batch path's step directory (``<directory>/tasks/...``). Default:
the node's.
target : seamm_scheduler.TargetSection, optional
The job's target. Default: found for the job.
task_set_options : dict, optional
Extra arguments for the ``TaskSet`` (``bundle_tasks``, ``archive``, ...).
resources : seamm_exec.Resources, optional
The resources of each calculation on the batch path (ranks, memory per
rank), passed to the provider's ``get_task``. Default: the provider's.
name : str
A name for the MDI engine.
"""
def __init__(
self,
node,
model_chemistry=None,
*,
properties=("energy", "gradients"),
path=None,
directory=None,
target=None,
task_set_options=None,
resources=None,
name="SEAMM",
):
self.node = node
if model_chemistry is None:
if not node.variable_exists("_model_chemistry"):
raise ValueError(
"No model chemistry: add a 'Model Chemistry' step to the "
"flowchart before this step."
)
model_chemistry = node.get_variable("_model_chemistry")
self.model_chemistry = model_chemistry
self.options = model_chemistry.get("options") or {}
self.properties = tuple(properties)
self.provider = node.flowchart.plugin_manager.get(model_chemistry["step"])
self.directory = directory
self.task_set_options = dict(task_set_options or {})
self.resources = resources
self.name = name
if target is None and path is None:
from .targets import find_target
try:
job_directory = node.flowchart.root_directory
except Exception:
job_directory = None
try:
root = node.global_options.get("root")
except Exception:
root = None
target = find_target(job_directory=job_directory, root=root)
self.target = target
self.path = path or choose_path(model_chemistry, self.provider, target)
self._submitted = {} # key -> (configuration, options), in order
self._done = set()
# ------------------------------------------------------------------
def __enter__(self):
return self
def __exit__(self, *args):
self.close()
return False
def close(self):
pass
def submit(self, configuration, key=None, *, options=None):
"""Add a structure; returns its key (unique, filesystem-safe)."""
if key is None:
key = f"s{len(self._submitted) + 1:06d}"
key = str(key)
if key in self._submitted:
raise ValueError(f"Duplicate key '{key}'")
self._submitted[key] = (configuration, dict(options or {}))
return key
def results(self):
"""Compute what has been submitted; yield an :class:`EvaluatorResult`
for each, as it is ready."""
pending = {k: v for k, v in self._submitted.items() if k not in self._done}
if not pending:
return
if self.path == "mdi":
iterators = [self._mdi_results(pending)]
else:
# A structure the program cannot run as a task (e.g. periodic MOPAC)
# goes to its MDI engine, if it has one, whatever the target.
batch, mdi = {}, {}
for key, (configuration, options) in pending.items():
if self._can_run_task(configuration, options):
batch[key] = (configuration, options)
else:
mdi[key] = (configuration, options)
iterators = []
if batch:
iterators.append(self._batch_results(batch))
if mdi:
if self.mdi_capable:
iterators.append(self._mdi_results(mdi, fallback=True))
else:
iterators.append(self._cannot_run(mdi))
for iterator in iterators:
for result in iterator:
self._done.add(result.key)
yield result
@property
def mdi_capable(self):
"""Whether the program has an MDI engine for this model chemistry."""
return bool(self.options.get("mdi_capable", False)) and hasattr(
self.provider, "get_mdi_engine_command"
)
def _can_run_task(self, configuration, options):
"""The program's ``can_run_task`` hook: True if it has none."""
check = getattr(self.provider, "can_run_task", None)
if check is None:
return True
try:
return bool(check(configuration, self.model_chemistry, options=options))
except Exception:
# A broken hook must not reroute silently
logger.exception(
f"can_run_task of '{self.model_chemistry.get('step')}' failed; "
"treating the structure as one it cannot run as a task"
)
return False
def _cannot_run(self, pending):
for key in pending:
yield EvaluatorResult(
key=key,
ok=False,
reason=(
f"'{self.model_chemistry.get('level')}' cannot evaluate this "
"structure as a task and has no MDI engine"
),
path="batch",
)
# ------------------------------------------------------------------
# MDI
# ------------------------------------------------------------------
@staticmethod
def topology_key(configuration, options=None):
"""What must stay fixed for one MDI engine session."""
options = options or {}
return (
tuple(int(z) for z in configuration.atoms.atomic_numbers),
int(options.get("charge", configuration.charge)),
int(options.get("multiplicity", configuration.spin_multiplicity)),
int(getattr(configuration, "periodicity", 0) or 0),
)
def _mdi_results(self, pending, fallback=False):
"""The MDI path. With ``fallback`` (structures the program cannot run as
tasks while the rest do), a structure the local engine cannot take, or an
engine that cannot start here (the code may live only on the job's
cluster), gives failed results instead of stopping the others."""
pending = dict(pending)
for key, (configuration, options) in list(pending.items()):
for name in ("atom_indices", "ghost_atoms", "guess"):
if options.get(name) is not None:
if not fallback:
raise ValueError(
f"The MDI path cannot take '{name}'; this model "
"chemistry must run as tasks."
)
del pending[key]
yield EvaluatorResult(
key=key,
ok=False,
reason=f"cannot run here: the MDI engine cannot take '{name}'",
path="mdi",
)
break
groups = {}
for key, (configuration, options) in pending.items():
groups.setdefault(self.topology_key(configuration, options), []).append(
(key, configuration)
)
want_gradients = "gradients" in self.properties
want_stress = "stress" in self.properties
for topology, members in groups.items():
elements, charge, multiplicity, periodicity = topology
periodic = periodicity != 0
try:
engine = self._open_engine(members[0][1], charge, multiplicity)
except Exception as e:
if not fallback:
raise
for key, _ in members:
yield EvaluatorResult(
key=key,
ok=False,
reason=f"cannot run here: no MDI engine on this machine ({e})",
path="mdi",
)
continue
with engine:
if periodic and not engine.supports(">CELL"):
raise ValueError(
f"The model chemistry '{self.model_chemistry['level']}' MDI "
"engine does not accept a periodic cell (>CELL), so it "
"cannot evaluate periodic structures."
)
do_stress = periodic and want_stress and engine.supports("<STRESS")
for key, configuration in members:
t0 = time.perf_counter()
if periodic:
engine.set_cell(
configuration.cell.vectors(as_array=True), units="Å"
)
xyz = configuration.atoms.get_coordinates(
fractionals=False, as_array=True
)
engine.set_coordinates(np.asarray(xyz, dtype=float), units="Å")
data = {"energy": float(engine.energy(units=E_UNITS))}
gradients = stress = None
if want_gradients:
forces = np.asarray(engine.forces(units=G_UNITS), dtype=float)
gradients = -forces
data["gradients"] = gradients.tolist()
if do_stress:
stress = np.asarray(
engine.stress(units=S_UNITS), dtype=float
).tolist()
data["stress"] = stress
yield EvaluatorResult(
key=key,
ok=True,
energy=data["energy"],
gradients=gradients,
stress=stress,
path="mdi",
data=data,
elapsed=time.perf_counter() - t0,
)
def _open_engine(self, configuration, charge, multiplicity):
"""A started ``seamm_mdi.MDIEngine`` for this topology."""
from seamm_mdi import MDIEngine # only here: batch needs no pymdi
node = self.node
provider = self.provider
executor = node.flowchart.executor
seamm_options = node.global_options
method, basis = mdi_method_and_basis(self.model_chemistry)
n_atoms = configuration.n_atoms
def build_argv(hostname, port):
kwargs = {
"method": method,
"port": port,
"hostname": hostname,
"charge": charge,
"multiplicity": multiplicity,
"n_atoms": n_atoms,
}
if basis is not None:
kwargs["basis"] = basis
return provider.get_mdi_engine_command(executor, seamm_options, **kwargs)
engine = MDIEngine(
build_argv,
elements=list(configuration.atoms.atomic_numbers),
name=self.name,
logger=getattr(node, "logger", logger),
)
engine.start()
return engine
# ------------------------------------------------------------------
# Batch
# ------------------------------------------------------------------
def _batch_results(self, pending):
from .tasks import TaskSet
task_set = TaskSet(
self.node,
target=self.target,
directory=self.directory,
**self.task_set_options,
)
refused = []
for key, (configuration, options) in pending.items():
try:
extra = {}
if self.resources is not None:
extra["resources"] = self.resources
task = self.provider.get_task(
configuration,
self.model_chemistry,
key=key,
properties=self.properties,
options=options,
**extra,
)
except Exception as e:
# One structure the program refuses must not stop the others.
refused.append(
EvaluatorResult(
key=key, ok=False, reason=f"no task: {e}", path="batch"
)
)
continue
task_set.add(task)
yield from refused
if not task_set.tasks:
return
for result in task_set.run():
configuration, options = pending[result.key]
if not result.ok:
yield EvaluatorResult(
key=result.key,
ok=False,
reason=result.reason or result.state,
restored=result.restored,
path="batch",
)
continue
try:
data = self.provider.analyze_task(
result,
self.model_chemistry,
configuration,
properties=self.properties,
options=options,
)
except AnalysisError as e:
yield EvaluatorResult(
key=result.key,
ok=False,
reason=str(e),
restored=result.restored,
path="batch",
)
continue
periodic = int(getattr(configuration, "periodicity", 0) or 0) != 0
if periodic and "stress" in self.properties and data.get("stress") is None:
yield EvaluatorResult(
key=result.key,
ok=False,
reason="the calculation of a periodic structure returned no stress",
restored=result.restored,
path="batch",
)
continue
gradients = data.get("gradients")
if gradients is not None:
gradients = np.asarray(gradients, dtype=float).reshape(-1, 3)
elapsed = 0.0
if not result.restored and result.history:
last = result.history[-1]
if last.get("started") and last.get("finished"):
elapsed = last["finished"] - last["started"]
elif last.get("submitted") and last.get("finished"):
elapsed = last["finished"] - last["submitted"]
yield EvaluatorResult(
key=result.key,
ok=True,
energy=data.get("energy"),
gradients=gradients,
stress=data.get("stress"),
restored=result.restored,
path="batch",
data=data,
elapsed=elapsed,
)
def check_properties(data, properties, what, periodic=False):
"""Raise :class:`AnalysisError` unless ``data`` has every required property
in ``properties`` (and the stress, if requested, for a periodic structure).
For programs' ``analyze_task``."""
required = REQUIRED + (("stress",) if periodic else ())
missing = [p for p in properties if p in required and data.get(p) is None]
if missing:
raise AnalysisError(f"{what} has no {', '.join(missing)}")
_SYMBOLS = [
"X",
"H", "He",
"Li", "Be", "B", "C", "N", "O", "F", "Ne",
"Na", "Mg", "Al", "Si", "P", "S", "Cl", "Ar",
"K", "Ca", "Sc", "Ti", "V", "Cr", "Mn", "Fe", "Co", "Ni", "Cu", "Zn",
"Ga", "Ge", "As", "Se", "Br", "Kr",
"Rb", "Sr", "Y", "Zr", "Nb", "Mo", "Tc", "Ru", "Rh", "Pd", "Ag", "Cd",
"In", "Sn", "Sb", "Te", "I", "Xe",
"Cs", "Ba",
"La", "Ce", "Pr", "Nd", "Pm", "Sm", "Eu", "Gd", "Tb", "Dy", "Ho", "Er", "Tm",
"Yb", "Lu",
"Hf", "Ta", "W", "Re", "Os", "Ir", "Pt", "Au", "Hg",
"Tl", "Pb", "Bi", "Po", "At", "Rn",
"Fr", "Ra",
"Ac", "Th", "Pa", "U", "Np", "Pu", "Am", "Cm", "Bk", "Cf", "Es", "Fm", "Md",
"No", "Lr",
"Rf", "Db", "Sg", "Bh", "Hs", "Mt", "Ds", "Rg", "Cn", "Nh", "Fl", "Mc", "Lv",
"Ts", "Og",
] # fmt: skip