Source code for eon.communicator_inprocess

"""In-process communicator: jobs run as pyeonclient.Matter (no eonclient binary).

Geometry crosses the job dict as a Structure (numpy working set) or a
readcon.ConFrame. Matter is the potential-bearing client object. Result
saddle and product values are ConFrames. This path does not read or write
.con text.
"""

from __future__ import annotations

import logging
import os
from io import StringIO
from pathlib import Path
from typing import Any

import numpy as np

from eon.cancel import CancelToken
from eon.communicator import Communicator, CommunicatorError

logger = logging.getLogger("communicator")

_GEOMETRY_KEYS = ("structure", "conframe", "reactant", "pos", "pos.con")


def _require_pyeonclient():
    try:
        import pyeonclient as pc
    except ImportError as e:
        raise CommunicatorError(
            "inprocess communicator needs pyeonclient "
            "(build with -Dwith_pyeonclient=true)"
        ) from e
    return pc


def _params_from_invariants(pc, invariants: dict) -> Any:
    """Load Parameters from config.ini bytes if present, else defaults."""
    params = pc.Parameters()
    # invariants values are (StringIO, mode) or StringIO
    for name, val in invariants.items():
        base = Path(name).name
        if base not in ("config.ini", "config"):
            continue
        content = val[0] if isinstance(val, tuple) else val
        if hasattr(content, "getvalue"):
            text = content.getvalue()
        elif hasattr(content, "read"):
            text = content.read()
        else:
            text = str(content)
        if hasattr(params, "load_ini_text"):
            params.load_ini_text(text)
        else:
            import tempfile

            with tempfile.NamedTemporaryFile(
                "w", suffix=".ini", delete=False
            ) as fh:
                fh.write(text)
                path = fh.name
            try:
                params.load(path)
            finally:
                os.unlink(path)
        return params
    params.potential = pc.PotType.LJ
    params.quiet = True
    params.write_log = False
    return params


def _is_structure(obj) -> bool:
    from eon.structure import Structure

    return isinstance(obj, Structure)


def _is_conframe(obj) -> bool:
    # readcon's pyo3 class reports its module as "builtins", so match the
    # class itself rather than a module name.
    try:
        from readcon import ConFrame
    except ImportError:
        return False
    return isinstance(obj, ConFrame)


def _is_con_text(obj) -> bool:
    if _is_structure(obj) or _is_conframe(obj):
        return False
    return (
        hasattr(obj, "getvalue")
        or hasattr(obj, "read")
        or isinstance(obj, (str, bytes))
    )


def _con_text_of(blob) -> str:
    if hasattr(blob, "getvalue"):
        return blob.getvalue()
    if hasattr(blob, "read"):
        if hasattr(blob, "seek"):
            blob.seek(0)
        return blob.read()
    if isinstance(blob, bytes):
        return blob.decode()
    return str(blob)


def _structure_from_geometry(blob, where: str):
    """Structure, ConFrame or .con text to the numpy working set.

    The AKMC drivers (explorer, basin hopping, escape rate, parallel replica)
    still send ``pos.con`` as a StringIO of .con text, so text is parsed here
    rather than refused.
    """
    if _is_con_text(blob):
        from eon import fileio as io

        try:
            return io.loadcon(StringIO(_con_text_of(blob)))
        except Exception as e:
            raise CommunicatorError(f"{where}: could not read .con text ({e})") from e
    if _is_structure(blob):
        return blob
    if _is_conframe(blob):
        from eon.structure import Structure

        return Structure.from_conframe(blob)
    raise CommunicatorError(
        f"{where} must be a Structure, ConFrame or .con text, "
        f"got {type(blob).__name__}"
    )


def _structure_from_job(job: dict, invariants: dict | None):
    for key in _GEOMETRY_KEYS:
        if key in job and job[key] is not None:
            return _structure_from_geometry(job[key], f"job[{key!r}]")
    if invariants:
        for key in _GEOMETRY_KEYS:
            if key not in invariants or invariants[key] is None:
                continue
            val = invariants[key]
            blob = val[0] if isinstance(val, tuple) else val
            if blob is not None:
                return _structure_from_geometry(blob, f"invariants[{key!r}]")
    raise CommunicatorError(
        "inprocess job needs a Structure, ConFrame or .con text "
        "(structure, conframe, reactant, pos, or pos.con)"
    )


def _conframe_of(structure):
    return structure.to_conframe()


class _LazyCon:
    """.con text for callers that read min.con or saddle.con, built on first read."""

    def __init__(self, structure):
        self._structure = structure
        self._text: str | None = None

    def _materialize(self) -> str:
        if self._text is None:
            import eon.fileio as fio

            buf = StringIO()
            fio.savecon(buf, self._structure)
            self._text = buf.getvalue()
        return self._text

    def getvalue(self) -> str:
        return self._materialize()

    def seek(self, *args, **kwargs):
        return 0

    def read(self, *args, **kwargs) -> str:
        return self._materialize()


def _run_inprocess_job(pc, job_kind, matter, pot, params, job: dict, token=None) -> dict:
    """Dispatch one Matter through the job type. Returns energy/status/matter."""
    if token is not None:
        token.raise_if_cancelled()
    JT = pc.JobType
    if job_kind in (JT.Minimization, JT.Unknown):
        matter, converged = matter.relax(
            inplace=True, quiet=True, write_movie=False, checkpoint=False
        )
        return {
            "matter": matter,
            "energy": float(matter.potential_energy),
            "force_calls": int(matter.force_calls),
            "status": 0 if converged else 1,
            "job_type": "minimization",
            "converged": converged,
        }
    if job_kind == JT.Point:
        energy = float(matter.potential_energy)
        return {
            "matter": matter,
            "energy": energy,
            "force_calls": int(matter.force_calls),
            "status": 0,
            "job_type": "point",
            "converged": True,
        }
    if job_kind in (JT.Process_Search, JT.Saddle_Search):
        n = int(matter.n_atoms) if hasattr(matter, "n_atoms") else int(
            matter.positions.shape[0]
        )
        mode = np.zeros((n, 3), dtype=float)
        if "direction.dat" in job:
            raw = job["direction.dat"]
            text = raw.getvalue() if hasattr(raw, "getvalue") else str(raw)
            vals = [float(x) for x in text.split()]
            if len(vals) >= 3 * n:
                mode = np.asarray(vals[: 3 * n], dtype=float).reshape(n, 3)
        if hasattr(pc, "ProcessSearchJob"):
            job_obj = pc.ProcessSearchJob(pot, params)
            product = job_obj.run_from_matter(matter)
            saddle = job_obj.saddle
            return {
                "matter": product,
                "saddle": saddle,
                "min1": job_obj.min1,
                "min2": job_obj.min2,
                "energy": float(saddle.potential_energy) if saddle else 0.0,
                "force_calls": int(product.force_calls) if product else 0,
                "status": 0,
                "job_type": (
                    "process_search"
                    if job_kind == JT.Process_Search
                    else "saddle_search"
                ),
                "converged": True,
            }
        search = pc.ProcessSearch(matter, mode, params, pot)
        reactant, saddle, status = search.run(inplace=True)
        return {
            "matter": reactant,
            "saddle": saddle,
            "energy": float(saddle.potential_energy),
            "force_calls": int(reactant.force_calls),
            "status": int(status),
            "job_type": (
                "process_search"
                if job_kind == JT.Process_Search
                else "saddle_search"
            ),
            "converged": int(status) == 0,
        }
    if job_kind == JT.Dynamics:
        md = pc.MolecularDynamics(matter, params, pot)
        matter = md.run(inplace=True)
        return {
            "matter": matter,
            "energy": float(matter.potential_energy),
            "force_calls": int(matter.force_calls),
            "status": 0,
            "job_type": "dynamics",
            "converged": True,
        }
    if job_kind == JT.Monte_Carlo:
        mc = pc.MonteCarlo(matter, params, pot)
        matter = mc.run(inplace=True)
        return {
            "matter": matter,
            "energy": float(matter.potential_energy),
            "force_calls": int(matter.force_calls),
            "status": 0,
            "job_type": "monte_carlo",
            "converged": True,
        }
    if job_kind == JT.Basin_Hopping:
        bh = pc.BasinHopping(matter, params, pot)
        matter = bh.run(inplace=True)
        return {
            "matter": matter,
            "energy": float(matter.potential_energy),
            "force_calls": int(matter.force_calls),
            "status": 0,
            "job_type": "basin_hopping",
            "converged": True,
        }
    if job_kind == JT.Hessian:
        energy = float(matter.potential_energy)
        return {
            "matter": matter,
            "energy": energy,
            "force_calls": int(matter.force_calls),
            "status": 0,
            "job_type": "hessian",
            "converged": True,
        }
    if job_kind == JT.Prefactor:
        energy = float(matter.potential_energy)
        return {
            "matter": matter,
            "energy": energy,
            "force_calls": int(matter.force_calls),
            "status": 0,
            "job_type": "prefactor",
            "converged": True,
        }
    if job_kind == JT.Finite_Difference:
        energy = float(matter.potential_energy)
        return {
            "matter": matter,
            "energy": energy,
            "force_calls": int(matter.force_calls),
            "status": 0,
            "job_type": "finite_difference",
            "converged": True,
        }
    raise CommunicatorError(
        f"inprocess communicator has no dispatch for job type {job_kind!r}"
    )


def _job_result(
    status: int,
    energy: float,
    force_calls: int,
    job_type: str,
    *,
    cancelled: bool = False,
) -> dict:
    """Typed in-process result. ``results.dat`` is derived from this dict."""
    if cancelled:
        reason = "cancelled"
    elif status == 0:
        reason = "GOOD"
    else:
        reason = "FAIL"
    return {
        "termination_reason": status,
        "termination_reason_text": reason,
        "job_type": job_type,
        "potential_energy": energy,
        "total_force_calls": force_calls,
    }


def _results_dat(
    status: int,
    energy: float,
    force_calls: int,
    job_type: str,
    *,
    cancelled: bool = False,
) -> str:
    data = _job_result(
        status, energy, force_calls, job_type, cancelled=cancelled
    )
    lines = []
    for key, val in data.items():
        if isinstance(val, float):
            lines.append(f"{val:.12e} {key}")
        else:
            lines.append(f"{val} {key}")
    return "\n".join(lines) + "\n"


[docs] class LocalInProcess(Communicator): """Run client work in-process via Matter (nanobind), not a subprocess.""" def __init__(self, scratchpath, bundle_size=1, config=None): if config is None: raise TypeError("LocalInProcess requires a ConfigClass instance") Communicator.__init__(self, scratchpath, bundle_size, config=config) self._pc = _require_pyeonclient() self._finished: list[dict] = [] self.token = CancelToken() self._in_submit = False self._cpp_token = None
[docs] def get_queue_size(self): return 0
[docs] def get_number_in_progress(self): return 0
[docs] def cancel_state(self, state): """Ask the running batch to stop. Idle calls return 0. The Python token stops the next dispatch. The C++ token is what Matter polls inside a compiled relax, NEB, or saddle search. """ del state if not self._in_submit: return 0 self.token.cancel() cpp = self._cpp_token if cpp is not None and not cpp.requested(): cpp.request() return 1
[docs] def submit_jobs(self, data, invariants): """Run each job dict in-process. Geometry is a Structure, a readcon.ConFrame or .con text on one of ``structure``, ``conframe``, ``reactant``, ``pos``, or ``pos.con``, or the same objects on the invariant dict. The result carries ``product`` and, for a saddle search, ``saddle`` as ConFrames, and the same geometry as ``min.con`` and ``saddle.con`` for the drivers that read .con text. Nothing is written to disk. """ pc = self._pc params = _params_from_invariants(pc, invariants) pot = pc.make_potential(params) job_kind = getattr(params, "job", pc.JobType.Minimization) self._in_submit = True try: self._submit_loop(data, invariants, pc, pot, params, job_kind) finally: self._in_submit = False self.token.reset()
def _submit_loop(self, data, invariants, pc, pot, params, job_kind): from pyeonclient.bridge import structure_to_matter, matter_to_structure for job in data: self.token.raise_if_cancelled() jid = job.get("id", "job") try: structure = _structure_from_job(job, invariants) except CommunicatorError: logger.exception("inprocess: missing geometry for %s", jid) raise except Exception as e: logger.exception("inprocess: failed to read geometry for %s", jid) raise CommunicatorError(str(e)) from e matter = structure_to_matter(structure, pot, params) if hasattr(pc, "CancelToken") and hasattr(matter, "set_cancel_token"): self._cpp_token = pc.CancelToken() matter.set_cancel_token(self._cpp_token) if self.token.cancelled: self._cpp_token.request() cancelled_exc = getattr(pc, "JobCancelled", None) try: payload = _run_inprocess_job( pc, job_kind, matter, pot, params, job, token=self.token ) except Exception as exc: if cancelled_exc is None or not isinstance(exc, cancelled_exc): raise payload = { "matter": matter, "energy": 0.0, "force_calls": int(getattr(matter, "force_calls", 0) or 0), "status": 1, "job_type": str(getattr(job_kind, "name", job_kind)), "converged": False, "cancelled": True, } finally: self._cpp_token = None matter = payload["matter"] out = matter_to_structure(matter) energy = float(payload["energy"]) fcalls = int(payload["force_calls"]) status = int(payload["status"]) jname = str(payload["job_type"]) cancelled = bool(payload.get("cancelled")) job_result = _job_result( status, energy, fcalls, jname, cancelled=cancelled ) results = StringIO( _results_dat( status, energy, fcalls, jname, cancelled=cancelled ) ) rec = { "id": jid, "number": 0, "name": str(jid), "product": _conframe_of(out), "min.con": _LazyCon(out), "job_result": job_result, "results.dat": results, "_matter": matter, "_structure": out, "_energy": energy, "_converged": bool(payload.get("converged", status == 0)), } saddle = payload.get("saddle") if saddle is not None: saddle_structure = matter_to_structure(saddle) rec["saddle"] = _conframe_of(saddle_structure) rec["saddle.con"] = _LazyCon(saddle_structure) rec["_saddle"] = saddle self._finished.append(rec) logger.info( "inprocess job %s type=%s status=%s E=%.6f fcalls=%s", jid, jname, status, energy, fcalls, )
[docs] def get_results(self, resultspath=None, keep_result=None): """Return finished job dicts. Geometry is ConFrame, not a file.""" out = self._finished self._finished = [] if not out: return [] return out