diff --git a/src/py/mat3ra/made/tools/analyze/lattice.py b/src/py/mat3ra/made/tools/analyze/lattice.py new file mode 100644 index 00000000..976b2dde --- /dev/null +++ b/src/py/mat3ra/made/tools/analyze/lattice.py @@ -0,0 +1,25 @@ +from mat3ra.made.material import Material +from mat3ra.made.tools.analyze import BaseMaterialAnalyzer +from mat3ra.made.tools.convert import from_pymatgen, to_pymatgen + +from ..third_party import PymatgenSpacegroupAnalyzer + + +class LatticeMaterialAnalyzer(BaseMaterialAnalyzer): + def __init__(self, material: Material): + super().__init__(material) + self.spacegroup_analyzer = PymatgenSpacegroupAnalyzer(to_pymatgen(self.material)) + + @property + def get_with_primitive_lattice(self: Material) -> Material: + """ + Convert a structure to its primitive cell. + """ + return Material(from_pymatgen(self.spacegroup_analyzer.get_primitive_standard_structure())) + + @property + def get_with_conventional_lattice(self: Material) -> Material: + """ + Convert a structure to its conventional cell. + """ + return Material(from_pymatgen(self.spacegroup_analyzer.get_conventional_standard_structure())) diff --git a/src/py/mat3ra/made/tools/build/nanoparticle/__init__.py b/src/py/mat3ra/made/tools/build/nanoparticle/__init__.py new file mode 100644 index 00000000..f0cae122 --- /dev/null +++ b/src/py/mat3ra/made/tools/build/nanoparticle/__init__.py @@ -0,0 +1,19 @@ +from typing import Union + +from mat3ra.made.material import Material + +from .builders import SlabBasedNanoparticleBuilder, ASEBasedNanoparticleBuilder +from .configuration import ( + ASEBasedNanoparticleConfiguration, + SlabBasedNanoparticleConfiguration, + SphereSlabBasedNanoparticleConfiguration, +) + + +def create_nanoparticle( + configuration: Union[ + SlabBasedNanoparticleBuilder, SphereSlabBasedNanoparticleConfiguration, ASEBasedNanoparticleConfiguration + ], + builder: Union[SlabBasedNanoparticleBuilder, ASEBasedNanoparticleBuilder] = SlabBasedNanoparticleBuilder(), +) -> Material: + return builder.get_material(configuration) diff --git a/src/py/mat3ra/made/tools/build/nanoparticle/builders.py b/src/py/mat3ra/made/tools/build/nanoparticle/builders.py new file mode 100644 index 00000000..e8d60681 --- /dev/null +++ b/src/py/mat3ra/made/tools/build/nanoparticle/builders.py @@ -0,0 +1,123 @@ +from typing import List, Callable, Dict, Type + +from mat3ra.made.material import Material +from ...analyze.other import get_chemical_formula +from ...build import BaseBuilder +from ...build.mixins import ConvertGeneratedItemsASEAtomsMixin +from ...build.slab import SlabConfiguration +from ...modify import filter_by_condition_on_coordinates +from ...utils.coordinate import SphereCoordinateCondition +from ...analyze.other import get_closest_site_id_from_coordinate +from ...third_party import ASEAtoms +from ..slab import create_slab +from .configuration import ASEBasedNanoparticleConfiguration, SphereSlabBasedNanoparticleConfiguration +from .enums import ASENanoparticleShapesEnum + + +class SlabBasedNanoparticleBuilder(BaseBuilder): + """ + Builder for creating nanoparticles by cutting from bulk materials supercells. + """ + + _ConfigurationType: type(ASEBasedNanoparticleConfiguration) = ASEBasedNanoparticleConfiguration # type: ignore + _GeneratedItemType: type(Material) = Material # type: ignore + + def create_nanoparticle(self, config: _ConfigurationType) -> _GeneratedItemType: + slab = self._create_slab(config) + center_coordinate = self._find_slab_center_coordinate(slab) + condition = self._build_coordinate_condition(config, center_coordinate) + nanoparticle = filter_by_condition_on_coordinates(slab, condition, use_cartesian_coordinates=True) + return nanoparticle + + def _build_coordinate_condition(self, config: _ConfigurationType, center_coordinate: List[float]) -> Callable: + coordinate_condition = config.condition_builder(center_coordinate) + return coordinate_condition.condition + + def _create_slab(self, config: _ConfigurationType) -> Material: + slab_config = SlabConfiguration( + bulk=config.material, + miller_indices=config.orientation_z, + thickness=config.supercell_size, + use_conventional_cell=True, + use_orthogonal_z=True, + make_primitive=False, + vacuum=0, + xy_supercell_matrix=[[config.supercell_size, 0], [0, config.supercell_size]], + ) + slab = create_slab(slab_config) + return slab + + def _find_slab_center_coordinate(self, slab: Material) -> List[float]: + slab.to_cartesian() + center_coordinate = slab.basis.cell.convert_point_to_cartesian([0.5, 0.5, 0.5]) + center_id_at_site = get_closest_site_id_from_coordinate(slab, center_coordinate, use_cartesian_coordinates=True) + center_coordinate_at_site = slab.basis.coordinates.get_element_value_by_index(center_id_at_site) + return center_coordinate_at_site + + def _finalize(self, materials: List[Material], configuration: _ConfigurationType) -> List[Material]: + for material in materials: + material.name = f"{get_chemical_formula(material)} Nanoparticle" + return materials + + +class SphereSlabBasedNanoparticleBuilder(SlabBasedNanoparticleBuilder): + """ + Builder for creating spherical nanoparticles by cutting from bulk materials supercells. + """ + + _ConfigurationType: Type[ASEBasedNanoparticleConfiguration] = SphereSlabBasedNanoparticleConfiguration + + def _build_coordinate_condition( + self, config: SphereSlabBasedNanoparticleConfiguration, center_coordinate: List[float] + ) -> Callable: + return SphereCoordinateCondition(center_position=center_coordinate, radius=config.radius).condition + + +class ASEBasedNanoparticleBuilder(ConvertGeneratedItemsASEAtomsMixin, BaseBuilder): + """ + Generalized builder for creating nanoparticles based on ASE cluster tools. + Passes configuration parameters directly to the ASE constructors. + """ + + _ConfigurationType: type(ASEBasedNanoparticleConfiguration) = ASEBasedNanoparticleConfiguration # type: ignore + _GeneratedItemType: type(ASEAtoms) = ASEAtoms # type: ignore + + def create_nanoparticle(self, config: ASEBasedNanoparticleConfiguration) -> _GeneratedItemType: + parameters = self._get_ase_nanoparticle_parameters(config) + constructor = self._get_ase_nanoparticle_constructor(config) + nanoparticle_without_cell = constructor(**parameters) + nanoparticle = self._set_ase_cell(nanoparticle_without_cell, config.vacuum_padding) + + return nanoparticle + + @staticmethod + def _get_ase_nanoparticle_parameters(config: ASEBasedNanoparticleConfiguration) -> Dict: + parameters = config.parameters or {} + parameters["symbol"] = config.element + parameters.setdefault("latticeconstant", config.lattice_constant) + return parameters + + @classmethod + def _get_ase_nanoparticle_constructor(cls, config: ASEBasedNanoparticleConfiguration) -> Callable[..., ASEAtoms]: + constructor = ASENanoparticleShapesEnum.get_ase_constructor(config.shape.value) + return constructor + + @staticmethod + def _set_ase_cell(atoms: ASEAtoms, vacuum: float) -> _GeneratedItemType: + """ + Set the cell of an ASE atoms object to a cubic box with vacuum padding around the nanoparticle. + """ + max_dimension_along_x = abs(atoms.positions[:, 0]).max() + box_size = 2 * max_dimension_along_x + vacuum + atoms.set_cell([box_size, box_size, box_size], scale_atoms=False) + atoms.center() + return atoms + + def _generate(self, configuration: ASEBasedNanoparticleConfiguration) -> List[_GeneratedItemType]: + nanoparticle = self.create_nanoparticle(configuration) + return [nanoparticle] + + def _finalize(self, materials: List[Material], configuration: _ConfigurationType) -> List[Material]: + for material in materials: + material.name = f"{get_chemical_formula(material)} {configuration.shape.value.capitalize()}" + return materials diff --git a/src/py/mat3ra/made/tools/build/nanoparticle/configuration.py b/src/py/mat3ra/made/tools/build/nanoparticle/configuration.py new file mode 100644 index 00000000..762ede8c --- /dev/null +++ b/src/py/mat3ra/made/tools/build/nanoparticle/configuration.py @@ -0,0 +1,91 @@ +from typing import Dict, Optional, Tuple, Callable + +import numpy as np +from mat3ra.made.material import Material + + +from .enums import ASENanoparticleShapesEnum +from ...build import BaseConfiguration + + +class BaseNanoparticleConfiguration(BaseConfiguration): + material: Material # Material to use for the nanoparticle + vacuum_padding: float = 10.0 # Padding for vacuum space around the nanoparticle + + @property + def lattice_type(self) -> str: + return self.material.lattice.type + + @property + def lattice_constant(self) -> float: + material = self.material + conventional_material = ASEBasedNanoparticleConfiguration.convert_to_conventional(material) + lattice_constants = [ + conventional_material.lattice.a, + conventional_material.lattice.b, + conventional_material.lattice.c, + ] + # If lattice constants are not equal within a tolerance, raise an error + if not np.all(np.isclose(lattice_constants, lattice_constants[0], atol=1e-6)): + raise ValueError("Lattice constants must be equal for isotropic materials") + lattice_constant = lattice_constants[0] + return lattice_constant + + @property + def _json(self): + return { + "material": self.material.to_json(), + "vacuum_padding": self.vacuum_padding, + } + + +class SlabBasedNanoparticleConfiguration(BaseNanoparticleConfiguration): + condition_builder: Callable # Function of the center coordinate to build the condition for filtering the slab + supercell_size: int = 1 # Size of the supercell in the xy-plane + orientation_z: Tuple[int, int, int] = (0, 0, 1) # Orientation of the crystallographic axis in the z-direction + + @property + def _json(self): + return { + **super()._json, + "shape": self.shape.value, + "orientation_z": self.orientation_z, + } + + +class SphereSlabBasedNanoparticleConfiguration(SlabBasedNanoparticleConfiguration): + radius: float = 5.0 # Radius of the nanoparticle, in Angstroms + + @property + def _json(self): + return { + **super()._json, + "radius": self.radius, + } + + +class ASEBasedNanoparticleConfiguration(BaseNanoparticleConfiguration): + """ + Configuration for building a nanoparticle. + + Attributes: + material (Material): The base material for the nanoparticle. + shape (NanoparticleShapes): The desired shape of the nanoparticle. + parameters (dict): Dictionary of parameters to pass to the corresponding ASE constructor. + vacuum_padding (float): Vacuum padding around the nanoparticle. + """ + + shape: ASENanoparticleShapesEnum + parameters: Optional[Dict] = None # Shape-specific parameters (e.g., layers, size) + + @property + def element(self) -> str: + return self.material.basis.elements.get_element_value_by_index(0) + + @property + def _json(self): + return { + **super()._json, + "shape": self.shape.value, + "parameters": self.parameters, + } diff --git a/src/py/mat3ra/made/tools/build/nanoparticle/enums.py b/src/py/mat3ra/made/tools/build/nanoparticle/enums.py new file mode 100644 index 00000000..6e4524df --- /dev/null +++ b/src/py/mat3ra/made/tools/build/nanoparticle/enums.py @@ -0,0 +1,41 @@ +from enum import Enum +from typing import Callable +from ...third_party import ( + ASEAtoms, + ASESimpleCubic, + ASEBodyCenteredCubic, + ASEFaceCenteredCubic, + ASEIcosahedron, + ASEOctahedron, + ASEDecahedron, + ASEHexagonalClosedPacked, + ASEWulffConstruction, +) + + +class ASENanoparticleShapesEnum(str, Enum): + """ + Enum for supported nanoparticle shapes. + """ + + ICOSAHEDRON = "icosahedron" + OCTAHEDRON = "octahedron" + DECAHEDRON = "decahedron" + SIMPLE_CUBIC = "simple_cubic" + FACE_CENTERED_CUBIC = "face_centered_cubic" + BODY_CENTERED_CUBIC = "body_centered_cubic" + HEXAGONAL_CLOSED_PACKED = "hexagonal_closed_packed" + WULFF_CONSTRUCTION = "wulff_construction" + + @staticmethod + def get_ase_constructor(shape: str) -> Callable[..., ASEAtoms]: + return { + "icosahedron": ASEIcosahedron, + "octahedron": ASEOctahedron, + "decahedron": ASEDecahedron, + "simple_cubic": ASESimpleCubic, + "face_centered_cubic": ASEFaceCenteredCubic, + "body_centered_cubic": ASEBodyCenteredCubic, + "hexagonal_closed_packed": ASEHexagonalClosedPacked, + "ase_wulff_construction": ASEWulffConstruction, + }[shape] diff --git a/src/py/mat3ra/made/tools/third_party.py b/src/py/mat3ra/made/tools/third_party.py index 9858d917..e862e2e7 100644 --- a/src/py/mat3ra/made/tools/third_party.py +++ b/src/py/mat3ra/made/tools/third_party.py @@ -4,6 +4,14 @@ from ase.calculators.calculator import Calculator as ASECalculator from ase.calculators.calculator import all_changes as ase_all_changes from ase.calculators.emt import EMT as ASECalculatorEMT +from ase.cluster import BodyCenteredCubic as ASEBodyCenteredCubic +from ase.cluster import Decahedron as ASEDecahedron +from ase.cluster import FaceCenteredCubic as ASEFaceCenteredCubic +from ase.cluster import HexagonalClosedPacked as ASEHexagonalClosedPacked +from ase.cluster import Icosahedron as ASEIcosahedron +from ase.cluster import Octahedron as ASEOctahedron +from ase.cluster import SimpleCubic as ASESimpleCubic +from ase.cluster.wulff import wulff_construction as ASEWulffConstruction from ase.constraints import FixAtoms as ASEFixAtoms from ase.constraints import FixedPlane as ASEFixedPlane from pymatgen.analysis.defects.core import Interstitial as PymatgenInterstitial @@ -48,4 +56,12 @@ "PymatgenAseAtomsAdaptor", "PymatgenPoscar", "PymatgenVoronoiNN", + "ASESimpleCubic", + "ASEBodyCenteredCubic", + "ASEFaceCenteredCubic", + "ASEIcosahedron", + "ASEOctahedron", + "ASEDecahedron", + "ASEHexagonalClosedPacked", + "ASEWulffConstruction", ]