diff --git a/src/py/mat3ra/made/lattice.py b/src/py/mat3ra/made/lattice.py index e6fe59a4..9ea5d600 100644 --- a/src/py/mat3ra/made/lattice.py +++ b/src/py/mat3ra/made/lattice.py @@ -12,6 +12,16 @@ DEFAULT_TYPE = "TRI" +class LatticeVectors(BaseModel): + """ + A class to represent the lattice vectors. + """ + + a: List[float] = [1.0, 0.0, 0.0] + b: List[float] = [0.0, 1.0, 0.0] + c: List[float] = [0.0, 0.0, 1.0] + + class Lattice(RoundNumericValuesMixin, BaseModel): a: float = 1.0 b: float = a @@ -23,7 +33,7 @@ class Lattice(RoundNumericValuesMixin, BaseModel): type: str = DEFAULT_TYPE @property - def vectors(self) -> List[List[float]]: + def vectors(self) -> LatticeVectors: a = self.a b = self.b c = self.c @@ -44,12 +54,12 @@ def vectors(self) -> List[List[float]]: cos_gamma_star = math.cos(gamma_star) sin_gamma_star = math.sin(gamma_star) - # Return the lattice matrix using the derived trigonometric values - return [ - [a * sin_beta, 0.0, a * cos_beta], - [-b * sin_alpha * cos_gamma_star, b * sin_alpha * sin_gamma_star, b * cos_alpha], - [0.0, 0.0, c], - ] + # Calculate the vectors + vector_a = [a * sin_beta, 0.0, a * cos_beta] + vector_b = [-b * sin_alpha * cos_gamma_star, b * sin_alpha * sin_gamma_star, b * cos_alpha] + vector_c = [0.0, 0.0, c] + + return LatticeVectors(a=vector_a, b=vector_b, c=vector_c) @classmethod def from_vectors_array( @@ -96,7 +106,9 @@ def clone(self, extra_context: Optional[Dict[str, Any]] = None) -> "Lattice": @property def vector_arrays(self) -> List[List[float]]: - return self.vectors + """Returns lattice vectors as a nested array""" + v = self.vectors + return [v.a, v.b, v.c] @property def cell(self) -> Cell: diff --git a/src/py/mat3ra/made/material.py b/src/py/mat3ra/made/material.py index 6af5e9a4..f413d115 100644 --- a/src/py/mat3ra/made/material.py +++ b/src/py/mat3ra/made/material.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List, Union +from typing import Any, Dict, List, Optional, Union from mat3ra.code.constants import AtomicCoordinateUnits, Units from mat3ra.code.entity import HasDescriptionHasMetadataNamedDefaultableInMemoryEntity @@ -112,3 +112,52 @@ def add_atom(self, element: str, coordinate: List[float], use_cartesian_coordina new_basis = self.basis.copy() new_basis.add_atom(element, coordinate, use_cartesian_coordinates) self.basis = new_basis + + @classmethod + def create_empty( + cls, + a: float = 1.0, + b: Optional[float] = None, + c: Optional[float] = None, + alpha: float = 90.0, + beta: float = 90.0, + gamma: float = 90.0, + lattice_type: str = "CUB", + name: str = "New Material", + ) -> "Material": + """ + Create an empty material with specified lattice parameters. + + Args: + a (float): First lattice constant. Defaults to 1.0. + b (float, optional): Second lattice constant. Defaults to value of 'a'. + c (float, optional): Third lattice constant. Defaults to value of 'a'. + alpha (float): First lattice angle in degrees. Defaults to 90.0. + beta (float): Second lattice angle in degrees. Defaults to 90.0. + gamma (float): Third lattice angle in degrees. Defaults to 90.0. + lattice_type (str): Type of lattice. Defaults to "CUB". + name (str): Name of the material. Defaults to "New Material". + + Returns: + Material: A new empty Material instance with specified lattice + """ + empty_config = { + **cls.default_config, + "name": name, + "basis": { + "elements": [], + "coordinates": [], + "units": AtomicCoordinateUnits.cartesian, + }, + "lattice": { + "type": lattice_type, + "a": a, + "b": b if b is not None else a, + "c": c if c is not None else a, + "alpha": alpha, + "beta": beta, + "gamma": gamma, + "units": cls.default_config["lattice"]["units"], + }, + } + return cls.create(empty_config) diff --git a/src/py/mat3ra/made/tools/build/nanoribbon/builders.py b/src/py/mat3ra/made/tools/build/nanoribbon/builders.py index 5bdb9b0c..5027db56 100644 --- a/src/py/mat3ra/made/tools/build/nanoribbon/builders.py +++ b/src/py/mat3ra/made/tools/build/nanoribbon/builders.py @@ -73,11 +73,11 @@ def _calculate_cartesian_dimensions(config: NanoribbonConfiguration, material: M nanoribbon_length, nanoribbon_width = nanoribbon_width, nanoribbon_length vacuum_width, vacuum_length = vacuum_length, vacuum_width - length_cartesian = nanoribbon_length * np.dot(np.array(material.lattice.vectors[0]), np.array([1, 0, 0])) - width_cartesian = nanoribbon_width * np.dot(np.array(material.lattice.vectors[1]), np.array([0, 1, 0])) - height_cartesian = np.dot(np.array(material.lattice.vectors[2]), np.array([0, 0, 1])) - vacuum_length_cartesian = vacuum_length * np.dot(np.array(material.lattice.vectors[0]), np.array([1, 0, 0])) - vacuum_width_cartesian = vacuum_width * np.dot(np.array(material.lattice.vectors[1]), np.array([0, 1, 0])) + length_cartesian = nanoribbon_length * np.dot(np.array(material.lattice.vectors.a), np.array([1, 0, 0])) + width_cartesian = nanoribbon_width * np.dot(np.array(material.lattice.vectors.b), np.array([0, 1, 0])) + height_cartesian = np.dot(np.array(material.lattice.vectors.c), np.array([0, 0, 1])) + vacuum_length_cartesian = vacuum_length * np.dot(np.array(material.lattice.vectors.a), np.array([1, 0, 0])) + vacuum_width_cartesian = vacuum_width * np.dot(np.array(material.lattice.vectors.b), np.array([0, 1, 0])) return length_cartesian, width_cartesian, height_cartesian, vacuum_length_cartesian, vacuum_width_cartesian diff --git a/src/py/mat3ra/made/tools/build/passivation/__init__.py b/src/py/mat3ra/made/tools/build/passivation/__init__.py index 9d467b1e..871e3448 100644 --- a/src/py/mat3ra/made/tools/build/passivation/__init__.py +++ b/src/py/mat3ra/made/tools/build/passivation/__init__.py @@ -1,4 +1,5 @@ -from typing import Union, List +from collections import Counter +from typing import Union, List, Dict from mat3ra.made.material import Material from .configuration import PassivationConfiguration @@ -36,3 +37,24 @@ def get_unique_coordination_numbers( material_with_crystal_sites = MaterialWithCrystalSites.from_material(configuration.slab) material_with_crystal_sites.analyze() return material_with_crystal_sites.get_unique_coordination_numbers(cutoff=cutoff) + + +def get_coordination_numbers_distribution( + configuration: PassivationConfiguration, + cutoff: float = 3.0, +) -> Dict[int, int]: + """ + Get the unique coordination numbers for the provided passivation configuration and cutoff radius. + + Args: + configuration (PassivationConfiguration): The configuration object. + cutoff (float): The cutoff radius for defining neighbors. + Returns: + set: The unique coordination numbers. + """ + material_with_crystal_sites = MaterialWithCrystalSites.from_material(configuration.slab) + material_with_crystal_sites.analyze() + coordinatation_numbers = material_with_crystal_sites.get_coordination_numbers(cutoff=cutoff) + sorted_coordinatation_numbers = sorted(coordinatation_numbers.values) + distribution = dict(Counter(sorted_coordinatation_numbers)) + return distribution diff --git a/src/py/mat3ra/made/tools/build/perturbation/builders.py b/src/py/mat3ra/made/tools/build/perturbation/builders.py index 31d2f407..36caa00f 100644 --- a/src/py/mat3ra/made/tools/build/perturbation/builders.py +++ b/src/py/mat3ra/made/tools/build/perturbation/builders.py @@ -65,7 +65,7 @@ def create_perturbed_slab(self, configuration: PerturbationConfiguration) -> Mat class CellMatchingDistancePreservingSlabPerturbationBuilder(DistancePreservingSlabPerturbationBuilder): def _transform_lattice_vectors(self, configuration: PerturbationConfiguration) -> List[List[float]]: - cell_vectors = configuration.material.lattice.vectors + cell_vectors = configuration.material.lattice.vector_arrays return [configuration.perturbation_function_holder.transform_coordinates(coord) for coord in cell_vectors] def create_perturbed_slab(self, configuration: PerturbationConfiguration) -> Material: diff --git a/src/py/mat3ra/made/tools/modify.py b/src/py/mat3ra/made/tools/modify.py index e128aab1..0ecd7546 100644 --- a/src/py/mat3ra/made/tools/modify.py +++ b/src/py/mat3ra/made/tools/modify.py @@ -40,7 +40,7 @@ def filter_by_label(material: Material, label: Union[int, str]) -> Material: def translate_to_z_level( - material: Material, z_level: Optional[Literal["top", "bottom", "center"]] = "bottom" + material: Material, z_level: Optional[Literal["top", "bottom", "center"]] = "bottom", tolerance: float = 1e-6 ) -> Material: """ Translate atoms to the specified z-level. @@ -48,15 +48,16 @@ def translate_to_z_level( Args: material (Material): The material object to normalize. z_level (str): The z-level to translate the atoms to (top, bottom, center) + tolerance (float): The tolerance value to avoid moving past unit cell. Returns: Material: The translated material object. """ min_z = get_atomic_coordinates_extremum(material, "min") max_z = get_atomic_coordinates_extremum(material) if z_level == "top": - material = translate_by_vector(material, vector=[0, 0, 1 - max_z]) + material = translate_by_vector(material, vector=[0, 0, 1 - max_z - tolerance]) elif z_level == "bottom": - material = translate_by_vector(material, vector=[0, 0, -min_z]) + material = translate_by_vector(material, vector=[0, 0, -min_z + tolerance]) elif z_level == "center": material = translate_by_vector(material, vector=[0, 0, (1 - min_z - max_z) / 2]) return material @@ -201,7 +202,7 @@ def filter_by_layers( if central_atom_id is not None: center_coordinate = material.basis.coordinates.get_element_value_by_index(central_atom_id) vectors = material.lattice.vectors - direction_vector = vectors[2] + direction_vector = vectors.c def condition(coordinate): return is_coordinate_within_layer(coordinate, center_coordinate, direction_vector, layer_thickness) @@ -240,6 +241,7 @@ def filter_by_sphere( center_coordinate: List[float] = [0, 0, 0], central_atom_id: Optional[int] = None, radius: float = 1, + tolerance: float = 0.0, invert: bool = False, ) -> Material: """ @@ -249,6 +251,7 @@ def filter_by_sphere( material (Material): The material object to filter. central_atom_id (int): Index of the central atom. radius (float): Radius of the sphere in angstroms. + tolerance (float): The tolerance value to include atoms on the edge of the sphere. invert (bool): Whether to invert the selection. Returns: @@ -258,7 +261,7 @@ def filter_by_sphere( material=material, atom_index=central_atom_id, coordinate=center_coordinate, - radius=radius, + radius=radius + tolerance, ) return filter_by_ids(material, ids, invert=invert) @@ -268,6 +271,7 @@ def filter_by_circle_projection( x: float = 0.5, y: float = 0.5, r: float = 0.25, + tolerance: float = 0.0, use_cartesian_coordinates: bool = False, invert_selection: bool = False, ) -> Material: @@ -279,6 +283,7 @@ def filter_by_circle_projection( x (float): The x-coordinate of the circle center. y (float): The y-coordinate of the circle center. r (float): The radius of the circle. + tolerance (float): The tolerance value to include atoms on the edge of the circle. use_cartesian_coordinates (bool): Whether to use cartesian coordinates invert_selection (bool): Whether to invert the selection. @@ -287,7 +292,7 @@ def filter_by_circle_projection( """ def condition(coordinate): - return is_coordinate_in_cylinder(coordinate, [x, y, 0], r, min_z=0, max_z=1) + return is_coordinate_in_cylinder(coordinate, [x, y, 0], r + tolerance, min_z=0, max_z=1) return filter_by_condition_on_coordinates( material, condition, use_cartesian_coordinates=use_cartesian_coordinates, invert_selection=invert_selection @@ -300,6 +305,7 @@ def filter_by_cylinder( min_z: Optional[float] = None, max_z: Optional[float] = None, radius: float = 0.25, + tolerance: float = 0.0, use_cartesian_coordinates: bool = False, invert_selection: bool = False, ) -> Material: @@ -331,7 +337,9 @@ def filter_by_cylinder( ) def condition(coordinate): - return is_coordinate_in_cylinder(coordinate, center_position, radius, min_z, max_z) + return is_coordinate_in_cylinder( + coordinate, center_position, radius + tolerance, min_z - tolerance, max_z + tolerance + ) return filter_by_condition_on_coordinates( material, condition, use_cartesian_coordinates=use_cartesian_coordinates, invert_selection=invert_selection @@ -342,6 +350,7 @@ def filter_by_rectangle_projection( material: Material, min_coordinate: List[float] = [0, 0], max_coordinate: List[float] = [1, 1], + tolerance: float = 0.0, use_cartesian_coordinates: bool = False, invert_selection: bool = False, ) -> Material: @@ -352,6 +361,7 @@ def filter_by_rectangle_projection( material (Material): The material object to filter. min_coordinate (List[float]): The minimum coordinate of the rectangle. max_coordinate (List[float]): The maximum coordinate of the rectangle. + tolerance (float): The tolerance value to include atoms on the edges of the rectangle. use_cartesian_coordinates (bool): Whether to use cartesian coordinates invert_selection (bool): Whether to invert the selection. @@ -360,8 +370,8 @@ def filter_by_rectangle_projection( """ min_z = get_atomic_coordinates_extremum(material, "min", "z", use_cartesian_coordinates=use_cartesian_coordinates) max_z = get_atomic_coordinates_extremum(material, "max", "z", use_cartesian_coordinates=use_cartesian_coordinates) - min_coordinate = min_coordinate[:2] + [min_z] - max_coordinate = max_coordinate[:2] + [max_z] + min_coordinate = [c - tolerance for c in min_coordinate[:2]] + [min_z] + max_coordinate = [c + tolerance for c in max_coordinate[:2]] + [max_z] def condition(coordinate): return is_coordinate_in_box(coordinate, min_coordinate, max_coordinate) @@ -375,6 +385,7 @@ def filter_by_box( material: Material, min_coordinate: Optional[List[float]] = None, max_coordinate: Optional[List[float]] = None, + tolerance: float = 0.0, use_cartesian_coordinates: bool = False, invert_selection: bool = False, ) -> Material: @@ -385,6 +396,7 @@ def filter_by_box( material (Material): The material to filter. min_coordinate (List[float], optional): The minimum coordinate of the box. Defaults to material's min. max_coordinate (List[float], optional): The maximum coordinate of the box. Defaults to material's max. + tolerance (float): The tolerance value to include atoms on the edges of the box. use_cartesian_coordinates (bool): Whether to use cartesian coordinates. invert_selection (bool): Whether to invert the selection. @@ -396,6 +408,9 @@ def filter_by_box( min_coordinate = min_coordinate if min_coordinate is not None else default_min max_coordinate = max_coordinate if max_coordinate is not None else default_max + min_coordinate = [c - tolerance for c in min_coordinate] + max_coordinate = [c + tolerance for c in max_coordinate] + def condition(coordinate): return is_coordinate_in_box(coordinate, min_coordinate, max_coordinate) @@ -411,6 +426,7 @@ def filter_by_triangle_projection( coordinate_3: Optional[List[float]] = None, min_z: Optional[float] = None, max_z: Optional[float] = None, + tolerance: float = 0.0, use_cartesian_coordinates: bool = False, invert_selection: bool = False, ) -> Material: @@ -424,6 +440,7 @@ def filter_by_triangle_projection( coordinate_3 (List[float], optional): Third vertex of the triangle. Defaults to material's corner. min_z (float, optional): Lower z-limit. Defaults to material's min z. max_z (float, optional): Upper z-limit. Defaults to material's max z. + tolerance (float): The tolerance value to include atoms on the top and bottom faces of the prism. use_cartesian_coordinates (bool): Whether to use cartesian coordinates. invert_selection (bool): Whether to invert the selection. @@ -440,13 +457,25 @@ def filter_by_triangle_projection( min_z = get_atomic_coordinates_extremum( material, "min", "z", use_cartesian_coordinates=use_cartesian_coordinates ) + else: + min_z -= tolerance + if max_z is None: max_z = get_atomic_coordinates_extremum( material, "max", "z", use_cartesian_coordinates=use_cartesian_coordinates ) + else: + max_z += tolerance def condition(coordinate): - return is_coordinate_in_triangular_prism(coordinate, coordinate_1, coordinate_2, coordinate_3, min_z, max_z) + return is_coordinate_in_triangular_prism( + coordinate, + coordinate_1, + coordinate_2, + coordinate_3, + min_z, + max_z, + ) return filter_by_condition_on_coordinates( material, condition, use_cartesian_coordinates=use_cartesian_coordinates, invert_selection=invert_selection @@ -535,7 +564,7 @@ def remove_vacuum(material: Material, from_top=True, from_bottom=True, fixed_pad new_basis.to_cartesian() new_lattice = translated_material.lattice new_lattice.c = get_atomic_coordinates_extremum(translated_material, use_cartesian_coordinates=True) + fixed_padding - new_basis.cell.vector3 = new_lattice.vectors[2] + new_basis.cell.vector3 = new_lattice.vectors.c new_basis.to_crystal() new_material = material.clone() diff --git a/src/py/mat3ra/made/tools/utils/__init__.py b/src/py/mat3ra/made/tools/utils/__init__.py index 7240dab7..23ed7d99 100644 --- a/src/py/mat3ra/made/tools/utils/__init__.py +++ b/src/py/mat3ra/made/tools/utils/__init__.py @@ -10,24 +10,6 @@ DEFAULT_TRANSLATION_VECTOR = 1 / DEFAULT_SCALING_FACTOR -# TODO: convert to accept ASE Atoms object -def translate_to_bottom_pymatgen_structure(structure: PymatgenStructure): - """ - Translate the structure to the bottom of the cell. - Args: - structure (PymatgenStructure): The pymatgen Structure object to translate. - - Returns: - PymatgenStructure: The translated pymatgen Structure object. - """ - min_c = min(site.c for site in structure) - translation_vector = [0, 0, -min_c] - translated_structure = structure.copy() - for site in translated_structure: - site.coords += translation_vector - return translated_structure - - def decorator_convert_2x2_to_3x3(func: Callable) -> Callable: """ Decorator to convert a 2x2 matrix to a 3x3 matrix. diff --git a/tests/py/unit/test_lattice.py b/tests/py/unit/test_lattice.py new file mode 100644 index 00000000..5810966b --- /dev/null +++ b/tests/py/unit/test_lattice.py @@ -0,0 +1,31 @@ +from mat3ra.made.lattice import Lattice +from mat3ra.made.material import Material + +def test_create_empty_custom_lattice(): + """Test custom lattice parameters when creating empty material""" + material = Material.create_empty( + a=2.0, b=3.0, c=4.0, alpha=80.0, beta=85.0, gamma=95.0, lattice_type="TRI" + ) + assert material.lattice.type == "TRI" + assert material.lattice.a == 2.0 + assert material.lattice.b == 3.0 + assert material.lattice.c == 4.0 + assert material.lattice.alpha == 80.0 + assert material.lattice.beta == 85.0 + assert material.lattice.gamma == 95.0 + + +def test_lattice_vectors_access(): + lattice = Lattice(a=2.0, b=3.0, c=4.0) + + # Test individual vector access + assert isinstance(lattice.vectors.a, list) + assert isinstance(lattice.vectors.b, list) + assert isinstance(lattice.vectors.c, list) + + # Test vector arrays access + arrays = lattice.vector_arrays + assert len(arrays) == 3 + assert arrays[0] == lattice.vectors.a + assert arrays[1] == lattice.vectors.b + assert arrays[2] == lattice.vectors.c diff --git a/tests/py/unit/test_material.py b/tests/py/unit/test_material.py index 359bdab9..f18d0acc 100644 --- a/tests/py/unit/test_material.py +++ b/tests/py/unit/test_material.py @@ -25,4 +25,31 @@ def test_basis_to_json(): assertion_utils.assert_deep_almost_equal(expected_basis_config, basis.to_json()) -# TODO: Add test to check if basis.cell is changed when lattice of material is changed, and vice versa +def test_basis_cell_lattice_sync(): + """Test synchronization between basis.cell and material.lattice""" + material = Material.create(Material.default_config) + + # Change lattice vectors + new_vectors = [[1.0, 0.0, 0.0], [0.0, 2.0, 0.0], [0.0, 0.0, 3.0]] + material.set_new_lattice_vectors(*new_vectors) + + # Verify basis.cell matches new lattice vectors + assertion_utils.assert_deep_almost_equal(new_vectors, material.basis.cell.vectors_as_array) + + assertion_utils.assert_deep_almost_equal(new_vectors, material.lattice.vector_arrays) + + +def test_create_empty(): + """Test that creating an empty material results in empty basis""" + material = Material.create_empty() + assert material.basis.elements.values == [] + assert material.basis.coordinates.values == [] + + +def test_create_empty_default_params(): + """Test default parameters when creating empty material""" + material = Material.create_empty() + assert material.name == "New Material" + assert material.lattice is not None + assert material.basis is not None + diff --git a/tests/py/unit/test_tools_build_interface.py b/tests/py/unit/test_tools_build_interface.py index 7305a04f..35f7a373 100644 --- a/tests/py/unit/test_tools_build_interface.py +++ b/tests/py/unit/test_tools_build_interface.py @@ -64,7 +64,7 @@ def test_create_twisted_nanoribbon_interface(): expected_cell_vectors = [[15.102811, 0.0, 0.0], [0.0, 16.108175208, 0.0], [0.0, 0.0, 20.0]] expected_coordinate = [0.704207885, 0.522108183, 0.65] - assertion_utils.assert_deep_almost_equal(expected_cell_vectors, interface.lattice.vectors) + assertion_utils.assert_deep_almost_equal(expected_cell_vectors, interface.lattice.vector_arrays) assertion_utils.assert_deep_almost_equal(expected_coordinate, interface.basis.coordinates.values[42]) diff --git a/tests/py/unit/test_tools_build_perturbation.py b/tests/py/unit/test_tools_build_perturbation.py index c358970d..ec41ebae 100644 --- a/tests/py/unit/test_tools_build_perturbation.py +++ b/tests/py/unit/test_tools_build_perturbation.py @@ -46,4 +46,4 @@ def test_distance_preserved_sine_perturbation(): [-12.043583, 21.367367, 0.0], [0.0, 0.0, 20.0], ] - assertion_utils.assert_deep_almost_equal(expected_cell, perturbed_slab.lattice.vectors) + assertion_utils.assert_deep_almost_equal(expected_cell, perturbed_slab.lattice.vector_arrays) diff --git a/tests/py/unit/test_tools_modify.py b/tests/py/unit/test_tools_modify.py index 18926dea..7da205bc 100644 --- a/tests/py/unit/test_tools_modify.py +++ b/tests/py/unit/test_tools_modify.py @@ -160,7 +160,7 @@ def test_remove_vacuum(): material = Material(SI_SLAB) material_down = translate_to_z_level(material, z_level="bottom") - assertion_utils.assert_deep_almost_equal(material_down.to_json(), material_with_set_vacuum.to_json()) + assertion_utils.assert_deep_almost_equal(material_down.to_json(), material_with_set_vacuum.to_json(), atol=1e-3) def test_rotate():