Source code for berny.solvers

# Any copyright is dedicated to the Public Domain.
# http://creativecommons.org/publicdomain/zero/1.0/
from __future__ import annotations

from collections.abc import Callable, Generator
from typing import Any, Optional

import numpy as np
from numpy.typing import NDArray

from .coords import angstrom
from .species_data import get_property

__all__ = ['XTBSolver']

FloatArray = NDArray[np.floating[Any]]

# NB: ``Optional[X]`` is used instead of ``X | None`` because these aliases
# are evaluated at import time, and Sphinx's autodoc mocks numpy (see
# ``doc/conf.py``), making ``FloatArray`` a Mock whose ``__or__`` raises.
#: Geometry sent to a solver: per-atom ``(symbol, xyz)`` pairs and optional
#: lattice vectors (``None`` for a molecule).
SolverInput = tuple[list[tuple[str, FloatArray]], Optional[FloatArray]]  # noqa: UP045
#: Energy and gradients yielded by a solver (gradients in atomic units).
SolverOutput = tuple[float, FloatArray]
#: Generator type of a solver — yields ``None`` once before the first send.
Solver = Generator[Optional[SolverOutput], SolverInput, None]  # noqa: UP045


#: Maps an ``XTBSolver`` method name to the corresponding ``tblite`` method.
_TBLITE_METHODS = {
    'gfn1': 'GFN1-xTB',
    'gfn2': 'GFN2-xTB',
    'ipea1': 'IPEA1-xTB',
}


def _tblite_method(method: str) -> str:
    """Normalise an ``XTBSolver`` method name to a ``tblite`` method string."""
    key = str(method).lower().replace('-', '').replace('_', '').replace(' ', '')
    if key in {'1', '2'}:
        key = f'gfn{key}'
    try:
        return _TBLITE_METHODS[key]
    except KeyError as e:
        raise ValueError(
            f'unsupported xtb method: {method!r} '
            f'(choose from {", ".join(sorted(_TBLITE_METHODS))})'
        ) from e


def _tblite_geometry(
    atoms: list[tuple[str, FloatArray]],
) -> tuple[FloatArray, FloatArray]:
    """Convert ``(symbol, xyz_in_angstrom)`` atoms to the inputs tblite expects.

    Returns integer atomic numbers and Cartesian positions in bohr (tblite works
    in atomic units, unlike the Angstrom geometry pyberny uses).
    """
    numbers = np.array([int(get_property(sp, 'number')) for sp, _ in atoms])
    positions = np.array([coord for _, coord in atoms]) * angstrom
    return numbers, positions


def _tblite_singlepoint(
    method: str,
    atoms: list[tuple[str, FloatArray]],
    charge: int,
    mult: int,
    accuracy: float | None,
) -> SolverOutput:
    """Run a single tblite energy+gradient evaluation via the Python bindings.

    Energy (Hartree) and gradient (Hartree/bohr) come back in atomic units and
    are returned unchanged -- no unit conversion needed.
    """
    try:
        from tblite.interface import Calculator
    except ImportError as e:
        raise ImportError(
            'XTBSolver requires the tblite package; install it with '
            f'`pip install pyberny[benchmark]` (underlying import error: {e})'
        ) from e
    numbers, positions = _tblite_geometry(atoms)
    calc = Calculator(method, numbers, positions, charge=float(charge), uhf=mult - 1)
    calc.set('verbosity', 0)
    if accuracy is not None:
        calc.set('accuracy', accuracy)
    res = calc.singlepoint()
    # tblite returns the energy as a 0-d array; coerce to a plain float so the
    # SolverOutput contract holds (and the value stays JSON-serialisable).
    return float(res.get('energy')), res.get('gradient')


[docs] def XTBSolver( method: str = 'gfn2', *, charge: int = 0, mult: int = 1, accuracy: float | None = None, ) -> Solver: """ Create a solver for the `xTB <https://tblite.readthedocs.io>`_ family of semiempirical tight-binding methods, evaluated through the `tblite <https://tblite.readthedocs.io>`_ library. The ``tblite`` package must be installed (``pip install pyberny[benchmark]``). GFN2-xTB has a smooth potential-energy surface, which makes it well behaved even near flat minima. :param str method: xTB parametrisation -- ``'gfn2'`` (default), ``'gfn1'`` or ``'ipea1'`` :param int charge: total charge (keyword-only) :param int mult: spin multiplicity, keyword-only (1 = singlet, 2 = doublet, ...); passed to tblite as ``mult - 1`` unpaired electrons :param accuracy: tblite numerical accuracy (smaller is tighter); the tblite default is used when ``None`` (keyword-only) """ if mult < 1: raise ValueError(f'multiplicity must be >= 1, got {mult}') tblite_method = _tblite_method(method) atoms, lattice = yield None while True: if lattice is not None: raise NotImplementedError( 'XTBSolver does not support periodic systems (lattice vectors)' ) energy, gradients = _tblite_singlepoint( tblite_method, atoms, charge, mult, accuracy ) atoms, lattice = yield energy, gradients
def GenericSolver(f: Callable[..., float], *args: Any, **kwargs: Any) -> Solver: delta: float = kwargs.pop('delta', 1e-3) atoms, lattice = yield None while True: energy = f(atoms, lattice, *args, **kwargs) coords = np.array([coord for _, coord in atoms]) gradients = np.zeros(coords.shape) for i_atom in range(coords.shape[0]): for i_xyz in range(3): ene: dict[int, float] = {} for step in [-2, -1, 1, 2]: coords_diff = coords.copy() coords_diff[i_atom, i_xyz] += step * delta atoms_diff = list(zip([sp for sp, _, in atoms], coords_diff)) ene[step] = f(atoms_diff, lattice, *args, **kwargs) gradients[i_atom, i_xyz] = _diff5(ene, delta) if lattice is not None: lattice_grads = np.zeros((3, 3)) for i_vec in range(3): for i_xyz in range(3): ene = {} for step in [-2, -1, 1, 2]: lattice_diff = lattice.copy() lattice_diff[i_vec, i_xyz] += step * delta ene[step] = f(atoms, lattice_diff, *args, **kwargs) lattice_grads[i_vec, i_xyz] = _diff5(ene, delta) gradients = np.vstack((gradients, lattice_grads)) atoms, lattice = yield energy, gradients / angstrom def _diff5(x: dict[int, float], delta: float) -> float: return (1 / 12 * x[-2] - 2 / 3 * x[-1] + 2 / 3 * x[1] - 1 / 12 * x[2]) / delta