Source code for mlfsm.utils
"""Utility functions for reading input and checking numeric types."""
from pathlib import Path
from typing import Any, cast
import numpy as np
from ase import Atoms
from ase.constraints import FixAtoms
from ase.io import read
from numpy.typing import NDArray
from mlfsm.geom import project_trans_rot, project_trans_rot_fixed
[docs]
def load_xyz(reaction_dir: Path | str) -> tuple[Atoms, Atoms]:
"""Load reactant and product geometries from a reaction directory.
Reads the first and last frames of ``initial.xyz`` and aligns them by
minimizing rigid-body rotation and translation via the Kabsch algorithm.
Parameters
----------
reaction_dir : path-like
Directory containing ``initial.xyz``. The file must have at least two
XYZ frames: the reactant (first) and the product (last).
Returns
-------
reactant : ase.Atoms
Reactant geometry after alignment.
product : ase.Atoms
Product geometry after alignment to the reactant.
Raises
------
Exception
If ``initial.xyz`` does not exist in ``reaction_dir``.
"""
xyz = Path(reaction_dir) / "initial.xyz"
if not xyz.is_file():
raise Exception(f"Input file {xyz} not found.")
atoms = read(xyz, format="xyz", index=":")
reactant = cast("Atoms", atoms[0])
product = cast("Atoms", atoms[-1])
r_xyz, p_xyz = project_trans_rot(reactant.get_positions(), product.get_positions())
reactant.set_positions(r_xyz.reshape(-1, 3))
product.set_positions(p_xyz.reshape(-1, 3))
return reactant, product
[docs]
def load_xyz_fixed(reaction_dir: Path | str, fixed: NDArray[np.integer[Any]]) -> tuple[Atoms, Atoms]:
"""Load reactant and product geometries with fixed-atom constraints applied.
Reads ``initial.xyz``, aligns the structures, and attaches an ASE
:class:`~ase.constraints.FixAtoms` constraint to both atoms objects so
that the specified atoms are held stationary during FSM optimization.
When ``fixed`` is empty the behaviour is identical to :func:`load_xyz`.
Parameters
----------
reaction_dir : path-like
Directory containing ``initial.xyz``.
fixed : NDArray[int]
Zero-indexed array of atom indices to freeze. Pass an empty array
(``np.array([], dtype=int)``) to apply no constraints.
Returns
-------
reactant : ase.Atoms
Reactant geometry with :class:`~ase.constraints.FixAtoms` applied.
product : ase.Atoms
Product geometry with :class:`~ase.constraints.FixAtoms` applied.
Raises
------
Exception
If ``initial.xyz`` does not exist in ``reaction_dir``.
"""
xyz = Path(reaction_dir) / "initial.xyz"
if not xyz.is_file():
raise Exception(f"Input file {xyz} not found.")
atoms = read(xyz, format="xyz", index=":")
reactant = cast("Atoms", atoms[0])
product = cast("Atoms", atoms[-1])
if len(fixed) == 0:
r_xyz, p_xyz = project_trans_rot(reactant.get_positions(), product.get_positions())
reactant.set_positions(r_xyz.reshape(-1, 3))
product.set_positions(p_xyz.reshape(-1, 3))
else:
c = FixAtoms(indices=fixed)
r_xyz, p_xyz = project_trans_rot_fixed(reactant.get_positions(), product.get_positions(), fixed)
reactant.set_positions(r_xyz.reshape(-1, 3))
reactant.set_constraint(c)
product.set_positions(p_xyz.reshape(-1, 3))
product.set_constraint(c)
return reactant, product
[docs]
def float_check(x: float) -> float:
"""Convert a scalar, 0-D array, or single-element container to a Python float.
Parameters
----------
x : float, ndarray, list, or tuple
Value to convert. Must be a plain float, a 0-D NumPy array, or a
length-1 sequence.
Returns
-------
float
The converted value.
Raises
------
TypeError
If ``x`` is a multi-element container or an unsupported type.
"""
if isinstance(x, float):
return x
elif isinstance(x, (np.ndarray, list, tuple)) and len(x) == 1:
return float(x[0])
raise TypeError(f"Cannot convert safely to float: {x} ({type(x)})")