Source code for ccu.adsorption.complexes

"""Generate adsorbate complexes.

Specifically, this module defines the :class:`AdsorbateComplexFactory` class
which combines the functionalities of the
:class:`~ccu.adsorption.sites.SiteFinder`,
:class:`~ccu.adsorption.orientation.OrientationFactory`, and
:class:`~ccu.adsorption.orientation.CenterFactory` classes to generate
adsorbate complexes.

Examples:
    1. A simplified interface to
       :class:`~ccu.adsorption.complexes.AdsorbateComplexFactory`
       using :func:`generate_complexes`.

    >>> import numpy as np
    >>> from ase.atoms import Atoms
    >>> from ase.build import fcc100
    >>> from ccu.adsorption.complexes import generate_complexes
    >>> from ccu.adsorption.sites import AdsorptionSite, SiteAlignment
    ... # Create a 3 x 3 x 1 Cu(100) surface
    >>> cu100 = fcc100("Cu", (3, 3, 1))
    ... # "Dope" the surface with Ag
    >>> cu100.set_chemical_symbols(
    ...     [(a.symbol if a.index % 2 == 0 else "Ag") for a in cu100]
    ... )
    >>> cu100.center(vacuum=10, axis=2)
    >>> # This site-finder always returns an adsorption site on the first atom
    >>> def finder(structure: Atoms) -> list[AdsorptionSite]:
    ...     return [
    ...         AdsorptionSite(
    ...             position=structure[0].position,
    ...             description=f"on {structure[0].symbol}",
    ...             alignments=[SiteAlignment(np.array([1.0, 0.0, 0.0]), "x")],
    ...             norm=np.array([0, 0.0, 1.0]),
    ...         )
    ...     ]
    >>> complexes = generate_complexes(
    ...     structure=cu100,
    ...     adsorbate="H",
    ...     finder=finder,
    ...     symmetry=True,
    ... )
    >>> len(complexes)
    1

    2. Fine-tuned control over all aspects of adsorbate complex generation
       using :class:`~ccu.adsorption.complexes.AdsorbateComplexFactory`.

    >>> from ase.build import fcc100
    >>> from ccu.adsorption.adsorbates import get_adsorbate
    >>> from ccu.adsorption.complexes import AdsorbateComplexFactory
    >>> from ccu.adsorption.orientation import Transformer, atomic_centerer
    >>> from ccu.adsorption.sites import HubSpokeFinder, HUB_TAG, SPOKE_TAG
    >>> from ccu.structure.symmetry import Rotation
    ... # Create a 3 x 3 x 1 Cu(100) surface
    >>> cu100 = fcc100("Cu", (3, 3, 1))
    >>> tags = [0] * len(cu100)
    ... # Tag hub and spoke atoms
    >>> tags[4] = HUB_TAG
    >>> tags[1] = tags[3] = tags[5] = tags[7] = SPOKE_TAG
    >>> cu100.set_tags(tags)
    ... # Create two adsorbate orientations per site alignment
    >>> transformer = Transformer([Rotation(0), Rotation(180)])
    >>> factory = AdsorbateComplexFactory(
    ...     site_finder=HubSpokeFinder(),
    ...     orientation_factory=transformer,
    ...     # Create adsorbate complexes centered on each atom in adsorbate
    ...     center_factory=atomic_centerer,
    ...     # Reduce minimum surface-adsorbate separation to 1.5 Angstroms
    ...     separation=1.5,
    ...     # Tag all adsorbate atoms with -50
    ...     adsorbate_tag=-50,
    ... )
    >>> co2 = get_adsorbate("CO2")
    >>> complexes = factory.get_complexes(cu100, co2)
    >>> len(complexes)
    24
"""

from pathlib import Path
from typing import Literal

from ase.atoms import Atoms
from ase.io import read

from ccu.adsorption import adsorbates
from ccu.adsorption.orientation import CenterFactory
from ccu.adsorption.orientation import OctahedralFactory
from ccu.adsorption.orientation import OrientationFactory
from ccu.adsorption.orientation import Transformer
from ccu.adsorption.orientation import atomic_centerer
from ccu.adsorption.orientation import com_centerer
from ccu.adsorption.orientation import special_centerer
from ccu.adsorption.sites import AdsorptionSite
from ccu.adsorption.sites import SiteFinder
from ccu.adsorption.sites import Triangulator
from ccu.structure import geometry
from ccu.structure.geometry import align

#: The default tag to use for adsorbates when creating adsorbate complexes
DEFAULT_ADSORBATE_TAG = -99


[docs] class AdsorbateComplexFactory: r"""Generate adsorption complexes from structures and adsorbates. Given an adsorbate and a structure, an `AdsorbateComplexFactory` generates all adsorption complexes on all sites of all orientations, alignments, and centers. Attributes: site_finder: A callable that accepts an :class:`~ase.Atoms` object and returns an iterable of sites on the structure. orientation_factory: An :class:`~ccu.adsorption.orientation.OrientationFactory` responsible for generating :class:`MolecularOrientations <ccu.structure.geometry.MolecularOrientation>` from :class:`AdsorptionSites <ccu.adsorption.sites.AdsorptionSite>` and an adsorbate. center_factory: A :class:`~ccu.adsorption.orientation.CenterFactory` that will generate displacements from adsorbates. separation: The distance (in Angstroms) that the adsorbate should be placed from the surface. adsorbate_tag: An integer to be used to tag adsorbate atoms in adsorbate complexes. """ def __init__( self, site_finder: SiteFinder, orientation_factory: OrientationFactory | None = None, center_factory: CenterFactory | None = None, separation: float = 1.8, adsorbate_tag: int = DEFAULT_ADSORBATE_TAG, ) -> None: r"""Create an `AdsorbateComplexFactory`. Args: site_finder: A callable that accepts an :class:`~ase.Atoms` object and returns an iterable of sites on the structure. orientation_factory: An :class:`~ccu.adsorption.orientation.OrientationFactory` responsible for generating :class:`AdsorbateOrientations <ccu.structure.geometry.MolecularOrientation>` from :class:`AdsorptionSites <ccu.adsorption.sites.AdsorptionSite>`, :class:`AdsorptionCenters <ccu.adsorption.orientation.AdsorptionCenter>`, and adsorbates. The default will align adsorbates to site aligments using their primary axis. center_factory: A :class:`~ccu.adsorption.orientation.CenterFactory` that will generate displacements from adsorbates. The default will place adsorbates using their center-of-mass. separation: The distance (in Angstroms) that the adsorbate should be placed from the surface. Defaults to 1.8. adsorbate_tag: An integer to be used to tag adsorbate atoms in adsorbate complexes. Defaults to :data:`DEFAULT_ADSORBATE_TAG`. """ self.site_finder = site_finder self.orientation_factory = orientation_factory or Transformer() self.center_factory = center_factory or com_centerer self.separation = separation self.adsorbate_tag = adsorbate_tag
[docs] def get_complexes(self, structure: Atoms, adsorbate: Atoms) -> list[Atoms]: """Generate adsorbate-surface complexes on a given site. Args: structure: An :class:`~ase.Atoms` object representing the structure. adsorbate: An :class:`~ase.Atoms` object representing the adsorbate. Returns: A list of adsorption complexes for the site. """ complexes: list[Atoms] = [] for site in self.site_finder(structure): for center in self.center_factory(adsorbate): for orientation in self.orientation_factory( site, adsorbate, center ): oriented = align( adsorbate=adsorbate, directions=orientation.directions, center=center.position, ) oriented.positions -= center.position # Tags to distinguish adsorbate from surface atoms (useful for # vibrational calculations) oriented.set_tags(self.adsorbate_tag) oriented.set_cell(structure.cell[:]) self.place_adsorbate( structure, oriented, site, ) # Add adsorbate to structure adsorbate_complex = structure.copy() adsorbate_complex.extend(oriented) structure_metadata = { "adsorbate": oriented.info.get( "structure", str(oriented.symbols) ), "site": site.description, "orientation": orientation.description, "center": center.description, } adsorbate_complex.info.update(structure_metadata) complexes.append(adsorbate_complex) return complexes
[docs] def place_adsorbate( self, structure: Atoms, adsorbate: Atoms, site: AdsorptionSite, ) -> None: """Center an adsorbate onto a site using the adsorbate origin. The adsorbate is placed on the specified site while respecting the minimum specified separation. Args: structure: An :class:`~ase.Atoms` instance representing the structure on which to place the adsorbate. adsorbate: An :class:`~ase.Atoms` instance representing the adsorbate to be placed. site: An :class:`~ccu.adsorption.sites.AdsorptionSite` instance representing the site on which the adsorbate is to be placed. """ adsorbate.positions += site.position separation = geometry.calculate_separation(adsorbate, structure) # TODO: add check if adsorbate moved out of cell and throw error while separation < self.separation: adsorbate.positions += 0.1 * site.norm separation = geometry.calculate_separation(adsorbate, structure)
[docs] def _get_structure_with_name( structure: str | Path, *, preserve_info: bool = False ) -> Atoms: """Load an :class:`~ase.Atoms` object from a file and stores filename. The plain text description is stored in the ``info`` dictionary of the structure under the key ``"structure"`` and can be accessed as follows:: atoms = _get_structure_with_name(structure) structure_description = atoms.info["structure"] Args: structure: The path to the structure to be loaded. Note that if loading the structure returns more than one structure, the last structure will be loaded. preserve_info: Whether or not to preserve the structure information in the info dictionary. If False, the ``"structure"`` key will be overriden if set. Defaults to False. Returns: The loaded :class:`~ase.Atoms` object. """ structure = Path(structure) atoms = read(structure) atoms = atoms[-1] if isinstance(atoms, list) else atoms if not preserve_info or "structure" not in atoms.info: atoms.info["structure"] = structure.stem return atoms
[docs] def generate_complexes( adsorbate: str | Path | Atoms, structure: str | Path | Atoms, *, separation: float = 1.8, centers: Literal["com", "special", "all"] = "com", symmetry: bool = False, finder: SiteFinder | None = None, adsorbate_tag: int = DEFAULT_ADSORBATE_TAG, ) -> list[Atoms]: r"""A convenience wrapper around :meth:`AdsorbateComplexFactory.get_complexes`. Args: adsorbate: The adsorbate to place on `structure`. This can be passed as a string, path, or :class:`~ase.Atoms` object. If passed as a string, the string will be used to retrieve the corresponding adsorbate using :func:`ccu.adsorption.adsorbates.get_adsorbate`. If passed as a path, an :class:`~ase.Atoms` object will be read from the associated file. If passed as an :class:`~ase.Atoms` object, then the `"structure"` key of `adsorbate.info` must map to a string. structure: The structure on which `adsorbate` is to be be placed. If passed as a string or path, the structure will be read from the indicated file. If passed as an :class:`~ase.Atoms` object, then the `"structure"` key of `structure.info` must map to a str. separation: A float indicating how far (in Angstroms) the adsorbate should be placed from the surface. Defaults to 1.8. centers: A string indicating what kind of centers will be used to place `adsorbate`. `"com"` indicates that `adsorbate` will be placed using its center-of-mass. `"special"` indicates that the atoms indicated by the `"special_centers"` key in `adsorbate.info`. `"all"` indicates that all atomic centers will be used. symmetry: A bool indicating whether or not the symmetry of the adsorbate is to be considered when generating complexes. Defaults to False. finder: A callable that accepts an :class:`~ase.Atoms` object and returns an iterable of sites on the structure. Defaults to `Triangulator()`. adsorbate_tag: The tag to give to adsorbate atoms. Defaults to :data:`DEFAULT_ADSORBATE_TAG`. Returns: A list of :class:`~ase.Atoms` objects representing adsorption complexes. .. seealso:: :class:`ccu.adsorption.sites.HubSpokeFinder`, :meth:`AdsorbateComplexFactory.get_complexes` """ if isinstance(adsorbate, str): adsorbate = adsorbates.get_adsorbate(adsorbate) elif isinstance(adsorbate, Path): adsorbate = _get_structure_with_name(adsorbate) if isinstance(structure, str | Path): structure = _get_structure_with_name(structure) match centers: case "all": center_factory = atomic_centerer case "special": center_factory = special_centerer case _: center_factory = com_centerer factory = AdsorbateComplexFactory( site_finder=finder if finder else Triangulator(), orientation_factory=OctahedralFactory(check_symmetry=symmetry), center_factory=center_factory, separation=separation, adsorbate_tag=adsorbate_tag, ) return factory.get_complexes(structure, adsorbate)
[docs] def write_complexes(complexes: list[Atoms], dir_name: Path) -> list[Path]: """A utility function for automatically saving adsorption complexes. Adsorption complexes are saved with the generic format: `structure_adsorbate_site_center_orientation__N.traj` The "structure", "adsorbate", "site", "center", and "orientation" components are derived from the corresponding values in :attr:`!Atoms.info`. `N` is a zero-indexed label that is incremented so as to avoid filename clashes. Args: complexes: A list of :class:`~ase.Atoms` objects representing complexes, such as that created by :meth:`.AdsorbateComplexFactory.get_complexes`. In order for the trajectory files to be templated correctly, the keys, `"structure"`, `"adsorbate"`, and `"site"` must be present in the :attr:`!Atoms.info` dictionary of each :class:`~ase.Atoms` object in `complexes`. `"orientation"` may optionally be present. dir_name: The directory in which to save the complexes. Returns: The list of files to which the adsorption complexes were written. """ dir_name.mkdir(parents=True, exist_ok=True) written_complexes: list[Path] = [] for complex_to_write in complexes: info = complex_to_write.info description = [ info["structure"].replace(" ", "_"), info["adsorbate"].replace(" ", "_"), info["site"].replace(" ", "_"), info["center"].replace(" ", "_"), info["orientation"].replace(" ", "_"), ] stem = "_".join(description) index = 0 filename = Path(dir_name, f"{stem}_{index}.traj") while filename.exists(): index += 1 filename = Path(dir_name, f"{stem}_{index}.traj") complex_to_write.write(filename) written_complexes.append(filename) return written_complexes