diff --git a/docs/User_Guide/Structure_Generation/adsorption.md b/docs/User_Guide/Structure_Generation/adsorption.md index a20bfd57..1d1829f8 100644 --- a/docs/User_Guide/Structure_Generation/adsorption.md +++ b/docs/User_Guide/Structure_Generation/adsorption.md @@ -4,9 +4,11 @@ Tools within are geared towards generating structures with adsorbates placed on a candidate catalyst surface. +## Placing a single adsorbate + The core function of this module is [`generate_adsorbed_structures`](../../API/Structure_Generation/adsorption.md#autocat.adsorption.generate_adsorbed_structures) - for generating multiple adsorbed structures with a single function call. + for generating multiple structures with one adsorbate placed via a single function call. For the oxygen reduction (ORR) and nitrogen reduction (NRR) reactions, AutoCat has default starting geometries for all of these intermediates @@ -177,4 +179,15 @@ inputs. The example below illustrates this capability, where can be used to spec ... adsorption_sites=sites, ... write_to_disk=True, ... ) -``` \ No newline at end of file +``` + +## Placing more than one adsorbate on a surface + +At present, if you wanted to place multiple adsorbates on a surface, there is not +an equivalent function to `generate_adsorbed_structures`, as this would require +structure enumeration (stay tuned!). + +However, if you wanted to place multiple adsorbates on a surface, *and you know where +you want to place each of them*, then you can use [`place_multiple_adsorbates`](../../API/Structure_Generation/adsorption.md#autocat.adsorption.place_multiple_adsorbates). For this function, all adsorbates +provided must be `ase.Atoms` objects. The structure with the adsorbates placed will be returned +as an `ase.Atoms` ojbect as well. (This is identical behavior to the lower-level [`place_adsorbate`](../../API/Structure_Generation/adsorption.md#autocat.adsorption.place_multiple_adsorbates) function) \ No newline at end of file diff --git a/src/autocat/adsorption.py b/src/autocat/adsorption.py index 4f2070cd..2338751a 100644 --- a/src/autocat/adsorption.py +++ b/src/autocat/adsorption.py @@ -137,6 +137,194 @@ def generate_molecule( return {"structure": m, "traj_file_path": traj_file_path} +def place_multiple_adsorbates( + surface: Atoms = None, + adsorbates: Union[Dict[str, Union[str, Atoms]], Sequence[str]] = None, + adsorbates_at_each_site: Sequence[str] = None, + adsorption_sites_list: Sequence[Sequence[float]] = None, + heights: Union[Dict[str, float], float] = None, + anchor_atom_indices: Union[Dict[str, int], int] = None, + rotations: Union[Dict[str, RotationOperations], RotationOperations] = None, +) -> Atoms: + """ + Given a list of adsorbates for each desired site and a corresponding + site list, place the adsorbates at each site + + Parameters + ---------- + + surface (REQUIRED): + Atoms object for the structure of the host surface. + + adsorbates (REQUIRED): + Dictionary of adsorbate molecule/intermediate names and corresponding + `ase.Atoms` object or string to be placed on the host surface. + + Note that the strings that appear as values must be in the list of + supported molecules in `autocat.data.intermediates` or in the `ase` g2 + database. Predefined data in `autocat.data` will take priority over that + in `ase`. + + Alternatively, a list of strings can be provided as input. + Note that each string has to be *unique* and available in + `autocat.data.intermediates` or the `ase` g2 database. + + Example: + { + "NH": "NH", + "N*": "N", + "NNH": NNH_atoms_obj, + ... + } + + OR + + ["NH", "NNH"] + + adsorbates_at_each_site (REQUIRED): + List of adsorbates to be placed at each site specified in `sites_list`. + Must be the same length as `sites_list` with all adsorbates + specified in `adsorbates`. + + Example: + ["OH", "H", "OH"] + + sites_list (REQUIRED): + List of xy-coords of each site corresponding to the list + given by `adsorbates_at_each_site` + + Example: + [(0.0, 0.0), (0.25, 0.25), (0.7, 0.6)] + + rotations: + Dictionary of the list of rotation operations to be applied to each + adsorbate molecule/intermediate type before being placed on the host surface. + Alternatively, a single list of rotation operations can be provided as + input to be used for all adsorbates. + + Rotating 90 degrees around the z axis followed by 45 degrees + around the y-axis can be specified as + [(90.0, "z"), (45.0, "y")] + + Example: + { + "NH": [(90.0, "z"), (45.0, "y")], + "NNH": ... + } + + Defaults to [(0, "x")] (i.e., no rotations applied) for each adsorbate + molecule. + + heights: + Dictionary of the height above surface where each adsorbate type should be + placed. + Alternatively, a single float value can be provided as input to be + used for all adsorbates. + If None, will estimate initial height based on covalent radii of the + nearest neighbor atoms for each adsorbate. + + anchor_atom_indices: + Dictionary of the integer index of the atom in each adsorbate molecule + that should be used as anchor when placing it on the surface. + Alternatively, a single integer index can be provided as input to be + used for all adsorbates. + Defaults to the atom at index 0 for each adsorbate molecule. + + Returns + ------- + + Atoms object of surface structure with adsorbates placed at specified sites + + """ + # input wrangling + if surface is None: + msg = "Surface must be provided" + raise AutocatAdsorptionGenerationError(msg) + + if adsorbates is None: + msg = "Adsorbates must be provided" + raise AutocatAdsorptionGenerationError(msg) + + if adsorption_sites_list is None: + msg = "List of sites must be provided" + raise AutocatAdsorptionGenerationError(msg) + elif len(adsorption_sites_list) > len(np.unique(adsorption_sites_list, axis=0)): + msg = "Cannot place multiple adsorbates simultaneously at the same site" + raise AutocatAdsorptionGenerationError(msg) + + if adsorbates_at_each_site is None: + msg = "List of adsorbates to place at each site must be provided" + raise AutocatAdsorptionGenerationError(msg) + elif not isinstance(adsorbates_at_each_site, list): + msg = f"Unsupported type for adsorbates_at_each_site {type(adsorbates_at_each_site)}" + raise AutocatAdsorptionGenerationError(msg) + elif not all(isinstance(ads, str) for ads in adsorbates_at_each_site): + msg = "List of adsorbates to place at each site must be flat and contain only strings" + raise AutocatAdsorptionGenerationError(msg) + elif len(adsorbates_at_each_site) != len(adsorption_sites_list): + msg = "List of adsorbates to be placed must be same length as sites list" + raise AutocatAdsorptionGenerationError(msg) + + # get Atoms objects for each adsorbate molecule + if isinstance(adsorbates, dict): + ads_mols = [] + for ads in adsorbates_at_each_site: + if isinstance(adsorbates[ads], str): + mol = generate_molecule(adsorbates[ads]).get("structure") + elif isinstance(adsorbates[ads], Atoms): + mol = adsorbates[ads] + else: + msg = f"Unrecognized format for adsorbate {ads}" + raise AutocatAdsorptionGenerationError(msg) + ads_mols.append(mol) + elif isinstance(adsorbates, list): + ads_mols = [ + generate_molecule(ads_str).get("structure") + for ads_str in adsorbates_at_each_site + ] + else: + msg = f"Unrecognized format for `adsorbates` {type(adsorbates)}" + raise AutocatAdsorptionGenerationError(msg) + + # get lists of height, anchor atoms, and rotations for each site + if heights is None: + heights_list = [None] * len(adsorption_sites_list) + elif isinstance(heights, dict): + heights_list = [heights.get(ads, None) for ads in adsorbates_at_each_site] + elif isinstance(heights, float): + heights_list = [heights] * len(adsorption_sites_list) + + if anchor_atom_indices is None: + anchor_list = [0] * len(adsorption_sites_list) + elif isinstance(anchor_atom_indices, dict): + anchor_list = [ + anchor_atom_indices.get(ads, 0) for ads in adsorbates_at_each_site + ] + elif isinstance(anchor_atom_indices, int): + anchor_list = [anchor_atom_indices] * len(adsorption_sites_list) + + if rotations is None: + rots_list = [None] * len(adsorption_sites_list) + elif isinstance(rotations, dict): + rots_list = [rotations.get(ads, None) for ads in adsorbates_at_each_site] + elif isinstance(rotations, (list, tuple)): + rots_list = [rotations] * len(adsorption_sites_list) + + ads_surface = surface.copy() + for mol, site, height, anchor_idx, rotation in zip( + ads_mols, adsorption_sites_list, heights_list, anchor_list, rots_list + ): + ads_surface = place_adsorbate( + surface=ads_surface, + adsorbate=mol, + adsorption_site=site, + height=height, + rotations=rotation, + anchor_atom_index=anchor_idx, + ) + return ads_surface + + def generate_adsorbed_structures( surface: Union[str, Atoms] = None, adsorbates: Union[Dict[str, Union[str, Atoms]], Sequence[str]] = None, diff --git a/tests/test_adsorption.py b/tests/test_adsorption.py index b5d5bb15..3a9840be 100644 --- a/tests/test_adsorption.py +++ b/tests/test_adsorption.py @@ -3,11 +3,13 @@ import os import tempfile -import pytest from pytest import approx from pytest import raises +import numpy as np + from ase.build import molecule +from ase import Atoms from autocat.saa import generate_saa_structures from autocat.surface import generate_surface_structures @@ -18,12 +20,13 @@ from autocat.adsorption import get_adsorbate_slab_nn_list from autocat.adsorption import get_adsorption_sites from autocat.adsorption import AutocatAdsorptionGenerationError +from autocat.adsorption import place_multiple_adsorbates def test_generate_molecule_from_name_error(): - with pytest.raises(AutocatAdsorptionGenerationError): + with raises(AutocatAdsorptionGenerationError): generate_molecule() - with pytest.raises(NotImplementedError): + with raises(NotImplementedError): generate_molecule(molecule_name="N6H7") @@ -52,6 +55,186 @@ def test_generate_molecule_disk_io(): assert os.path.samefile(mol["traj_file_path"], traj_file_path) +def test_place_multi_adsorbates_invalid_inputs(): + surf = generate_surface_structures(species_list=["Fe"])["Fe"]["bcc100"]["structure"] + # no surface + with raises(AutocatAdsorptionGenerationError): + place_multiple_adsorbates(surface=None) + # no adsorbates + with raises(AutocatAdsorptionGenerationError): + place_multiple_adsorbates(surface=surf, adsorbates=None) + # no adsorbate sites list + with raises(AutocatAdsorptionGenerationError): + place_multiple_adsorbates( + surface=surf, adsorbates=["OH", "O"], adsorption_sites_list=None + ) + # no adsorbates at each site list + with raises(AutocatAdsorptionGenerationError): + place_multiple_adsorbates( + surface=surf, + adsorbates=["OH", "O"], + adsorption_sites_list=[(0.0, 0.0), (0.3, 0.4)], + adsorbates_at_each_site=None, + ) + # wrong adsorbate type + with raises(AutocatAdsorptionGenerationError): + place_multiple_adsorbates( + surface=surf, + adsorbates={"OH", "O"}, + adsorption_sites_list=[(0.0, 0.0), (0.3, 0.4)], + adsorbates_at_each_site=["OH", "O"], + ) + # adsorbates at each site given in incorrect fmt + with raises(AutocatAdsorptionGenerationError): + place_multiple_adsorbates( + surface=surf, + adsorbates=["OH", "O"], + adsorption_sites_list=[(0.0, 0.0), (0.3, 0.4)], + adsorbates_at_each_site={"OH": [(0.0, 0.0)], "O": [(0.3, 0.4)]}, + ) + # some adsorbates in adsorbates at each site given as Atoms + with raises(AutocatAdsorptionGenerationError): + place_multiple_adsorbates( + surface=surf, + adsorbates=["OH", "O"], + adsorption_sites_list=[(0.0, 0.0), (0.3, 0.4)], + adsorbates_at_each_site=["OH", Atoms("O")], + ) + # sites and list of adsorbates at each site do not match + with raises(AutocatAdsorptionGenerationError): + place_multiple_adsorbates( + surface=surf, + adsorbates=["OH", "O"], + adsorption_sites_list=[(0.0, 0.0), (0.3, 0.4)], + adsorbates_at_each_site=["OH"], + ) + # wrong fmt of element in adsorbates + with raises(AutocatAdsorptionGenerationError): + place_multiple_adsorbates( + surface=surf, + adsorbates={"OH": "OH", "C*": ["C"]}, + adsorption_sites_list=[(0.0, 0.0), (0.3, 0.4)], + adsorbates_at_each_site=["OH", "C*"], + ) + # multiple adsorbates placed at same site + with raises(AutocatAdsorptionGenerationError): + place_multiple_adsorbates( + surface=surf, + adsorbates=["OH", "N"], + adsorption_sites_list=[(0.0, 0.0), (0.3, 0.4), (0.0, 0.0)], + adsorbates_at_each_site=["OH", "OH", "N"], + ) + + +def test_place_multi_adsorbates_placement(): + # test that adsorbates are placed in the right locations + surf = generate_surface_structures(species_list=["Fe"])["Fe"]["bcc100"]["structure"] + # adsorbates given as list + ads_multi = place_multiple_adsorbates( + surface=surf, + adsorbates=["O", "H"], + adsorbates_at_each_site=["O", "H"], + adsorption_sites_list=[(0.0, 0.0), (0.5, 0.5)], + ) + assert "O" in ads_multi.get_chemical_symbols() + assert "H" in ads_multi.get_chemical_symbols() + assert ads_multi[-2].symbol == "O" + assert np.isclose(ads_multi[-2].x, 0.0) + assert np.isclose(ads_multi[-2].y, 0.0) + assert ads_multi[-1].symbol == "H" + assert np.isclose(ads_multi[-1].x, 0.5) + assert np.isclose(ads_multi[-1].y, 0.5) + # adsorbates given as dict + ads_multi = place_multiple_adsorbates( + surface=surf, + adsorbates={"O*": "O", "H*": Atoms("H")}, + adsorbates_at_each_site=["O*", "H*"], + adsorption_sites_list=[(0.1, 0.2), (0.7, 0.0)], + ) + assert "O" in ads_multi.get_chemical_symbols() + assert "H" in ads_multi.get_chemical_symbols() + assert ads_multi[-2].symbol == "O" + assert np.isclose(ads_multi[-2].x, 0.1) + assert np.isclose(ads_multi[-2].y, 0.2) + assert ads_multi[-1].symbol == "H" + assert np.isclose(ads_multi[-1].x, 0.7) + assert np.isclose(ads_multi[-1].y, 0.0) + + +def test_place_multi_ads_height_and_anchor_idx(): + surf = generate_surface_structures(species_list=["Fe"])["Fe"]["bcc100"]["structure"] + # height given as float + ads_multi = place_multiple_adsorbates( + surface=surf, + adsorbates=["O", "C"], + adsorbates_at_each_site=["O", "C"], + adsorption_sites_list=[(0.0, 0.0), (2.87, 2.87)], + heights=0.5, + ) + assert np.isclose(ads_multi[-1].z, 14.805) + assert np.isclose(ads_multi[-2].z, 14.805) + # height given as dict + ads_multi = place_multiple_adsorbates( + surface=surf, + adsorbates=["O", "C"], + adsorbates_at_each_site=["O", "C"], + adsorption_sites_list=[(0.0, 0.0), (2.87, 2.87)], + heights={"O": 1.0, "C": 1.5}, + ) + assert np.isclose(ads_multi[-1].z, 15.805) + assert np.isclose(ads_multi[-2].z, 15.305) + # anchor idx specified + ads_multi = place_multiple_adsorbates( + surface=surf, + adsorbates=["OH", "CO"], + adsorbates_at_each_site=["OH", "CO"], + adsorption_sites_list=[(0.0, 0.0), (2.87, 2.87)], + heights={"OH": 1.0, "CO": 2.5}, + anchor_atom_indices={"CO": 1}, + ) + assert np.isclose(ads_multi[-4].z, 15.305) + assert np.isclose(ads_multi[-1].z, 16.805) + # anchor idx given for all adsorbates + ads_multi = place_multiple_adsorbates( + surface=surf, + adsorbates=["OH", "CO"], + adsorbates_at_each_site=["OH", "CO"], + adsorption_sites_list=[(0.0, 0.0), (2.87, 2.87)], + heights=1.5, + anchor_atom_indices=1, + ) + assert np.isclose(ads_multi[-3].z, 15.805) + assert np.isclose(ads_multi[-1].z, 15.805) + + +def test_place_multi_ads_rotations(): + surf = generate_surface_structures(species_list=["Fe"])["Fe"]["bcc100"]["structure"] + # same rotation applied to all adsorbate types + ads_multi = place_multiple_adsorbates( + surface=surf, + adsorbates=["OH", "NH"], + adsorbates_at_each_site=["OH", "NH"], + adsorption_sites_list=[(0.0, 0.0), (2.87, 2.87)], + rotations=[(45.0, "x")], + ) + assert np.isclose(ads_multi[-3].y, -0.4879036790187179) + assert np.isclose(ads_multi[-3].z, 16.772903679018718) + assert np.isclose(ads_multi[-1].y, 2.3679541853575516) + assert np.isclose(ads_multi[-1].x, 3.58) + # rotations given by dict + ads_multi = place_multiple_adsorbates( + surface=surf, + adsorbates=["OH", "NH"], + adsorbates_at_each_site=["OH", "NH"], + adsorption_sites_list=[(0.0, 0.0), (2.87, 2.87)], + rotations={"OH": [(45.0, "x")], "NH": [(90.0, "z")]}, + ) + assert np.isclose(ads_multi[-3].y, -0.4879036790187179) + assert np.isclose(ads_multi[-3].z, 16.772903679018718) + assert np.isclose(ads_multi[-1].z, 17.045) + assert np.isclose(ads_multi[-1].x, 2.87) + + def test_generate_adsorbed_structures_invalid_inputs(): surf = generate_surface_structures(species_list=["Fe"])["Fe"]["bcc100"]["structure"] # no surface input