@mat3ra/made 2024.7.31-1 → 2024.8.16-0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mat3ra/made",
3
- "version": "2024.7.31-1",
3
+ "version": "2024.8.16-0",
4
4
  "description": "MAterials DEsign library",
5
5
  "scripts": {
6
6
  "lint": "eslint --cache src/js tests/js && prettier --write src/js tests/js",
@@ -24,14 +24,14 @@ class Basis(RoundNumericValuesMixin, BaseModel):
24
24
  coordinates: List[Dict],
25
25
  units: str,
26
26
  labels: Optional[List[Dict]] = None,
27
- cell: Optional[Dict] = None,
27
+ cell: Optional[List[List[float]]] = None,
28
28
  constraints: Optional[List[Dict]] = None,
29
29
  ) -> "Basis":
30
30
  return Basis(
31
31
  elements=ArrayWithIds.from_list_of_dicts(elements),
32
32
  coordinates=ArrayWithIds.from_list_of_dicts(coordinates),
33
33
  units=units,
34
- cell=Cell.from_nested_array(cell),
34
+ cell=Cell.from_vectors_array(cell),
35
35
  labels=ArrayWithIds.from_list_of_dicts(labels) if labels else ArrayWithIds(values=[]),
36
36
  constraints=ArrayWithIds.from_list_of_dicts(constraints) if constraints else ArrayWithIds(values=[]),
37
37
  )
@@ -1,4 +1,4 @@
1
- from typing import List
1
+ from typing import List, Optional
2
2
 
3
3
  import numpy as np
4
4
  from mat3ra.utils.mixins import RoundNumericValuesMixin
@@ -13,13 +13,13 @@ class Cell(RoundNumericValuesMixin, BaseModel):
13
13
  __round_precision__ = 6
14
14
 
15
15
  @classmethod
16
- def from_nested_array(cls, nested_array):
17
- if nested_array is None:
18
- nested_array = [[1, 0, 0], [0, 1, 0], [0, 0, 1]]
19
- return cls(vector1=nested_array[0], vector2=nested_array[1], vector3=nested_array[2])
16
+ def from_vectors_array(cls, vectors_array: Optional[List[List[float]]] = None) -> "Cell":
17
+ if vectors_array is None:
18
+ vectors_array = [[1, 0, 0], [0, 1, 0], [0, 0, 1]]
19
+ return cls(vector1=vectors_array[0], vector2=vectors_array[1], vector3=vectors_array[2])
20
20
 
21
21
  @property
22
- def vectors_as_nested_array(self, skip_rounding=False) -> List[List[float]]:
22
+ def vectors_as_array(self, skip_rounding=False) -> List[List[float]]:
23
23
  if skip_rounding:
24
24
  return [self.vector1, self.vector2, self.vector3]
25
25
  return self.round_array_or_number([self.vector1, self.vector2, self.vector3])
@@ -32,22 +32,22 @@ class Cell(RoundNumericValuesMixin, BaseModel):
32
32
  self.vector3 if skip_rounding else _(self.vector3),
33
33
  ]
34
34
 
35
- def clone(self):
36
- return self.from_nested_array(self.vectors_as_nested_array)
35
+ def clone(self) -> "Cell":
36
+ return self.from_vectors_array(self.vectors_as_array)
37
37
 
38
- def clone_and_scale_by_matrix(self, matrix):
38
+ def clone_and_scale_by_matrix(self, matrix: List[List[float]]) -> "Cell":
39
39
  new_cell = self.clone()
40
40
  new_cell.scale_by_matrix(matrix)
41
41
  return new_cell
42
42
 
43
- def convert_point_to_cartesian(self, point):
44
- np_vector = np.array(self.vectors_as_nested_array)
43
+ def convert_point_to_cartesian(self, point: List[float]) -> List[float]:
44
+ np_vector = np.array(self.vectors_as_array)
45
45
  return np.dot(point, np_vector)
46
46
 
47
- def convert_point_to_crystal(self, point):
48
- np_vector = np.array(self.vectors_as_nested_array)
47
+ def convert_point_to_crystal(self, point: List[float]) -> List[float]:
48
+ np_vector = np.array(self.vectors_as_array)
49
49
  return np.dot(point, np.linalg.inv(np_vector))
50
50
 
51
- def scale_by_matrix(self, matrix):
52
- np_vector = np.array(self.vectors_as_nested_array)
53
- self.vector1, self.vector2, self.vector3 = np.dot(matrix, np_vector).tolist()
51
+ def scale_by_matrix(self, matrix: List[List[float]]):
52
+ np_vector = np.array(self.vectors_as_array)
53
+ self.vector1, self.vector2, self.vector3 = np.dot(np.array(matrix), np_vector).tolist()
@@ -8,6 +8,8 @@ from pydantic import BaseModel
8
8
  from .cell import Cell
9
9
 
10
10
  HASH_TOLERANCE = 3
11
+ DEFAULT_UNITS = {"length": "angstrom", "angle": "degree"}
12
+ DEFAULT_TYPE = "TRI"
11
13
 
12
14
 
13
15
  class Lattice(RoundNumericValuesMixin, BaseModel):
@@ -17,11 +19,8 @@ class Lattice(RoundNumericValuesMixin, BaseModel):
17
19
  alpha: float = 90.0
18
20
  beta: float = 90.0
19
21
  gamma: float = 90.0
20
- units: Dict[str, str] = {
21
- "length": "angstrom",
22
- "angle": "degree",
23
- }
24
- type: str = "TRI"
22
+ units: Dict[str, str] = DEFAULT_UNITS
23
+ type: str = DEFAULT_TYPE
25
24
 
26
25
  @property
27
26
  def vectors(self) -> List[List[float]]:
@@ -52,6 +51,29 @@ class Lattice(RoundNumericValuesMixin, BaseModel):
52
51
  [0.0, 0.0, c],
53
52
  ]
54
53
 
54
+ @classmethod
55
+ def from_vectors_array(
56
+ cls, vectors: List[List[float]], units: Optional[Dict[str, str]] = None, type: Optional[str] = None
57
+ ) -> "Lattice":
58
+ """
59
+ Create a Lattice object from a nested array of vectors.
60
+ Args:
61
+ vectors (List[List[float]]): A nested array of vectors.
62
+ Returns:
63
+ Lattice: A Lattice object.
64
+ """
65
+ a = np.linalg.norm(vectors[0])
66
+ b = np.linalg.norm(vectors[1])
67
+ c = np.linalg.norm(vectors[2])
68
+ alpha = np.degrees(np.arccos(np.dot(vectors[1], vectors[2]) / (b * c)))
69
+ beta = np.degrees(np.arccos(np.dot(vectors[0], vectors[2]) / (a * c)))
70
+ gamma = np.degrees(np.arccos(np.dot(vectors[0], vectors[1]) / (a * b)))
71
+ if units is None:
72
+ units = DEFAULT_UNITS
73
+ if type is None:
74
+ type = DEFAULT_TYPE
75
+ return cls(a=float(a), b=float(b), c=float(c), alpha=alpha, beta=beta, gamma=gamma, units=units, type=type)
76
+
55
77
  def to_json(self, skip_rounding: bool = False) -> Dict[str, Any]:
56
78
  __round__ = RoundNumericValuesMixin.round_array_or_number
57
79
  round_func = __round__ if not skip_rounding else lambda x: x
@@ -78,7 +100,7 @@ class Lattice(RoundNumericValuesMixin, BaseModel):
78
100
 
79
101
  @property
80
102
  def cell(self) -> Cell:
81
- return Cell.from_nested_array(self.vector_arrays)
103
+ return Cell.from_vectors_array(self.vector_arrays)
82
104
 
83
105
  def volume(self) -> float:
84
106
  np_vector = np.array(self.vector_arrays)
@@ -90,3 +90,8 @@ class Material(HasDescriptionHasMetadataNamedDefaultableInMemoryEntity):
90
90
  new_basis = self.basis.copy()
91
91
  new_basis.to_crystal()
92
92
  self.basis = new_basis
93
+
94
+ def set_coordinates(self, coordinates: List[List[float]]) -> None:
95
+ new_basis = self.basis.copy()
96
+ new_basis.coordinates.values = coordinates
97
+ self.basis = new_basis
@@ -30,7 +30,8 @@ from ...analyze import (
30
30
  get_closest_site_id_from_coordinate_and_element,
31
31
  )
32
32
  from ....utils import get_center_of_coordinates
33
- from ...utils import transform_coordinate_to_supercell, CoordinateConditionBuilder
33
+ from ...utils import transform_coordinate_to_supercell
34
+ from ...utils import coordinate as CoordinateCondition
34
35
  from ..utils import merge_materials
35
36
  from ..slab import SlabConfiguration, create_slab, Termination
36
37
  from ..supercell import create_supercell
@@ -436,7 +437,7 @@ class IslandSlabDefectBuilder(SlabDefectBuilder):
436
437
  return self.merge_slab_and_defect(island_material, new_material)
437
438
 
438
439
  def _generate(self, configuration: _ConfigurationType) -> List[_GeneratedItemType]:
439
- condition_callable, _ = configuration.condition
440
+ condition_callable = configuration.condition.condition
440
441
  return [
441
442
  self.create_island(
442
443
  material=configuration.crystal,
@@ -463,7 +464,7 @@ class TerraceSlabDefectBuilder(SlabDefectBuilder):
463
464
  The normalized cut direction vector in Cartesian coordinates.
464
465
  """
465
466
  np_cut_direction = np.array(cut_direction)
466
- direction_vector = np.dot(np.array(material.basis.cell.vectors_as_nested_array), np_cut_direction)
467
+ direction_vector = np.dot(np.array(material.basis.cell.vectors_as_array), np_cut_direction)
467
468
  normalized_direction_vector = direction_vector / np.linalg.norm(direction_vector)
468
469
  return normalized_direction_vector
469
470
 
@@ -499,7 +500,7 @@ class TerraceSlabDefectBuilder(SlabDefectBuilder):
499
500
  """
500
501
  height_cartesian = self._calculate_height_cartesian(original_material, new_material)
501
502
  cut_direction_xy_proj_cart = np.linalg.norm(
502
- np.dot(np.array(new_material.basis.cell.vectors_as_nested_array), normalized_direction_vector)
503
+ np.dot(np.array(new_material.basis.cell.vectors_as_array), normalized_direction_vector)
503
504
  )
504
505
  # Slope of the terrace along the cut direction
505
506
  hypotenuse = np.linalg.norm([height_cartesian, cut_direction_xy_proj_cart])
@@ -592,10 +593,10 @@ class TerraceSlabDefectBuilder(SlabDefectBuilder):
592
593
  )
593
594
 
594
595
  normalized_direction_vector = self._calculate_cut_direction_vector(material, cut_direction)
595
- condition, _ = CoordinateConditionBuilder.plane(
596
+ condition = CoordinateCondition.PlaneCoordinateCondition(
596
597
  plane_normal=normalized_direction_vector,
597
598
  plane_point_coordinate=pivot_coordinate,
598
- )
599
+ ).condition
599
600
  atoms_within_terrace = filter_by_condition_on_coordinates(
600
601
  material=material_with_additional_layers,
601
602
  condition=condition,
@@ -1,17 +1,25 @@
1
- from typing import Optional, List, Any, Callable, Dict, Tuple, Union
1
+ from typing import Optional, List, Union
2
2
  from pydantic import BaseModel
3
3
 
4
4
  from mat3ra.code.entity import InMemoryEntity
5
5
  from mat3ra.made.material import Material
6
6
 
7
7
  from ...analyze import get_closest_site_id_from_coordinate, get_atomic_coordinates_extremum
8
- from ...utils import CoordinateConditionBuilder
8
+ from ...utils.coordinate import (
9
+ CylinderCoordinateCondition,
10
+ SphereCoordinateCondition,
11
+ BoxCoordinateCondition,
12
+ TriangularPrismCoordinateCondition,
13
+ PlaneCoordinateCondition,
14
+ )
9
15
  from .enums import PointDefectTypeEnum, SlabDefectTypeEnum, AtomPlacementMethodEnum, ComplexDefectTypeEnum
10
16
 
11
17
 
12
18
  class BaseDefectConfiguration(BaseModel):
13
- # TODO: fix arbitrary_types_allowed error and set Material class type
14
- crystal: Any = None
19
+ crystal: Material = None
20
+
21
+ class Config:
22
+ arbitrary_types_allowed = True
15
23
 
16
24
  @property
17
25
  def _json(self):
@@ -169,16 +177,21 @@ class IslandSlabDefectConfiguration(SlabDefectConfiguration):
169
177
  """
170
178
 
171
179
  defect_type: SlabDefectTypeEnum = SlabDefectTypeEnum.ISLAND
172
- condition: Optional[Tuple[Callable[[List[float]], bool], Dict]] = CoordinateConditionBuilder().cylinder()
180
+ condition: Union[
181
+ CylinderCoordinateCondition,
182
+ SphereCoordinateCondition,
183
+ BoxCoordinateCondition,
184
+ TriangularPrismCoordinateCondition,
185
+ PlaneCoordinateCondition,
186
+ ] = CylinderCoordinateCondition()
173
187
 
174
188
  @property
175
189
  def _json(self):
176
- _, condition_json = self.condition
177
190
  return {
178
191
  **super()._json,
179
192
  "type": self.get_cls_name(),
180
193
  "defect_type": self.defect_type.name,
181
- "condition": condition_json,
194
+ "condition": self.condition.get_json(),
182
195
  }
183
196
 
184
197
 
@@ -1,6 +1,7 @@
1
1
  from typing import Any, List, Optional
2
2
 
3
3
  import numpy as np
4
+ from mat3ra.made.tools.modify import translate_to_z_level
4
5
  from pydantic import BaseModel
5
6
  from ase.build.tools import niggli_reduce
6
7
  from pymatgen.analysis.interfaces.coherent_interfaces import (
@@ -144,9 +145,13 @@ class ZSLStrainMatchingInterfaceBuilder(ConvertGeneratedItemsPymatgenStructureMi
144
145
 
145
146
  def _generate(self, configuration: InterfaceConfiguration) -> List[PymatgenInterface]:
146
147
  generator = ZSLGenerator(**self.build_parameters.strain_matching_parameters.dict())
148
+ substrate_with_atoms_translated_to_bottom = translate_to_z_level(
149
+ configuration.substrate_configuration.bulk, "bottom"
150
+ )
151
+ film_with_atoms_translated_to_bottom = translate_to_z_level(configuration.film_configuration.bulk, "bottom")
147
152
  builder = CoherentInterfaceBuilder(
148
- substrate_structure=to_pymatgen(configuration.substrate_configuration.bulk),
149
- film_structure=to_pymatgen(configuration.film_configuration.bulk),
153
+ substrate_structure=to_pymatgen(substrate_with_atoms_translated_to_bottom),
154
+ film_structure=to_pymatgen(film_with_atoms_translated_to_bottom),
150
155
  substrate_miller=configuration.substrate_configuration.miller_indices,
151
156
  film_miller=configuration.film_configuration.miller_indices,
152
157
  zslgen=generator,
@@ -0,0 +1,37 @@
1
+ from typing import Union, Optional
2
+
3
+ from mat3ra.made.material import Material
4
+ from .builders import (
5
+ SlabPerturbationBuilder,
6
+ DistancePreservingSlabPerturbationBuilder,
7
+ CellMatchingDistancePreservingSlabPerturbationBuilder,
8
+ )
9
+ from .configuration import PerturbationConfiguration
10
+
11
+
12
+ def create_perturbation(
13
+ configuration: PerturbationConfiguration,
14
+ preserve_distance: Optional[bool] = False,
15
+ builder: Union[
16
+ SlabPerturbationBuilder,
17
+ DistancePreservingSlabPerturbationBuilder,
18
+ CellMatchingDistancePreservingSlabPerturbationBuilder,
19
+ None,
20
+ ] = None,
21
+ ) -> Material:
22
+ """
23
+ Return a material with a perturbation applied.
24
+
25
+ Args:
26
+ configuration: The configuration of the perturbation to be applied.
27
+ preserve_distance: If True, the builder that preserves the distance between atoms is used.
28
+ builder: The builder to be used to create the perturbation.
29
+
30
+ Returns:
31
+ The material with the perturbation applied.
32
+ """
33
+ if builder is None:
34
+ builder = SlabPerturbationBuilder()
35
+ if preserve_distance:
36
+ builder = CellMatchingDistancePreservingSlabPerturbationBuilder()
37
+ return builder.get_material(configuration)
@@ -0,0 +1,83 @@
1
+ from typing import List, Optional, Any
2
+
3
+ from mat3ra.made.material import Material
4
+ from mat3ra.made.tools.build import BaseBuilder
5
+
6
+ from .configuration import PerturbationConfiguration
7
+ from ...modify import wrap_to_unit_cell, translate_to_z_level
8
+
9
+
10
+ class PerturbationBuilder(BaseBuilder):
11
+ _ConfigurationType: type(PerturbationConfiguration) = PerturbationConfiguration # type: ignore
12
+ _GeneratedItemType: Material = Material
13
+ _PostProcessParametersType: Any = None
14
+
15
+ @staticmethod
16
+ def _prepare_material(configuration: _ConfigurationType) -> _GeneratedItemType:
17
+ new_material = configuration.material.clone()
18
+ new_material = translate_to_z_level(new_material, "center")
19
+ if configuration.use_cartesian_coordinates:
20
+ new_material.to_cartesian()
21
+ return new_material
22
+
23
+ def _generate(self, configuration: _ConfigurationType) -> List[_GeneratedItemType]:
24
+ """Generate materials with applied continuous perturbation based on the given configuration."""
25
+ new_material = self.create_perturbed_slab(configuration)
26
+ return [new_material]
27
+
28
+ def _post_process(
29
+ self,
30
+ items: List[_GeneratedItemType],
31
+ post_process_parameters: Optional[_PostProcessParametersType],
32
+ ) -> List[Material]:
33
+ return [wrap_to_unit_cell(item) for item in items]
34
+
35
+ def _update_material_name(self, material: Material, configuration: _ConfigurationType) -> Material:
36
+ perturbation_details = f"Perturbation: {configuration.perturbation_function_holder.get_json().get('type')}"
37
+ material.name = f"{material.name} ({perturbation_details})"
38
+ return material
39
+
40
+
41
+ class SlabPerturbationBuilder(PerturbationBuilder):
42
+ def create_perturbed_slab(self, configuration: PerturbationConfiguration) -> Material:
43
+ new_material = self._prepare_material(configuration)
44
+ new_coordinates = [
45
+ configuration.perturbation_function_holder.apply_perturbation(coord)
46
+ for coord in new_material.basis.coordinates.values
47
+ ]
48
+ new_material.set_coordinates(new_coordinates)
49
+ return new_material
50
+
51
+
52
+ class DistancePreservingSlabPerturbationBuilder(SlabPerturbationBuilder):
53
+ def create_perturbed_slab(self, configuration: PerturbationConfiguration) -> Material:
54
+ new_material = self._prepare_material(configuration)
55
+ new_coordinates = [
56
+ configuration.perturbation_function_holder.transform_coordinates(coord)
57
+ for coord in new_material.basis.coordinates.values
58
+ ]
59
+ new_coordinates = [
60
+ configuration.perturbation_function_holder.apply_perturbation(coord) for coord in new_coordinates
61
+ ]
62
+ new_material.set_coordinates(new_coordinates)
63
+ return new_material
64
+
65
+
66
+ class CellMatchingDistancePreservingSlabPerturbationBuilder(DistancePreservingSlabPerturbationBuilder):
67
+ def _transform_lattice_vectors(self, configuration: PerturbationConfiguration) -> List[List[float]]:
68
+ cell_vectors = configuration.material.basis.cell.vectors_as_array
69
+ return [configuration.perturbation_function_holder.transform_coordinates(coord) for coord in cell_vectors]
70
+
71
+ def create_perturbed_slab(self, configuration: PerturbationConfiguration) -> Material:
72
+ new_material = super().create_perturbed_slab(configuration)
73
+ new_lattice_vectors = self._transform_lattice_vectors(configuration)
74
+ new_lattice = new_material.lattice.copy()
75
+ new_lattice = new_lattice.from_vectors_array(new_lattice_vectors)
76
+ new_material.lattice = new_lattice
77
+
78
+ new_basis = new_material.basis.copy()
79
+ new_basis.to_cartesian()
80
+ new_basis.cell = new_basis.cell.from_vectors_array(new_lattice_vectors)
81
+ new_basis.to_crystal()
82
+ new_material.basis = new_basis
83
+ return new_material
@@ -0,0 +1,28 @@
1
+ from typing import Union
2
+
3
+ from mat3ra.code.entity import InMemoryEntity
4
+ from mat3ra.made.material import Material
5
+ from pydantic import BaseModel
6
+
7
+ from ...utils.perturbation import SineWavePerturbationFunctionHolder, PerturbationFunctionHolder
8
+
9
+
10
+ class PerturbationConfiguration(BaseModel, InMemoryEntity):
11
+ material: Material
12
+ perturbation_function_holder: Union[
13
+ SineWavePerturbationFunctionHolder, PerturbationFunctionHolder
14
+ ] = SineWavePerturbationFunctionHolder()
15
+ use_cartesian_coordinates: bool = True
16
+
17
+ class Config:
18
+ arbitrary_types_allowed = True
19
+
20
+ @property
21
+ def _json(self):
22
+ perturbation_function_json = self.perturbation_function_holder.get_json()
23
+ return {
24
+ "type": self.get_cls_name(),
25
+ "material": self.material.to_json(),
26
+ "perturbation_function": perturbation_function_json,
27
+ "use_cartesian_coordinates": self.use_cartesian_coordinates,
28
+ }
@@ -2,28 +2,32 @@ from typing import List, Optional
2
2
 
3
3
  import numpy as np
4
4
  from mat3ra.made.material import Material
5
- from ..third_party import ASEAtoms, ase_make_supercell
5
+ from ..third_party import ase_make_supercell
6
6
  from ..utils import decorator_convert_2x2_to_3x3
7
- from ..convert import from_ase, decorator_convert_material_args_kwargs_to_atoms
7
+ from ..convert import from_ase, to_ase
8
8
 
9
9
 
10
10
  @decorator_convert_2x2_to_3x3
11
- @decorator_convert_material_args_kwargs_to_atoms
12
11
  def create_supercell(
13
- atoms: ASEAtoms, supercell_matrix: Optional[List[List[int]]] = None, scaling_factor: Optional[List[int]] = None
12
+ material: Material, supercell_matrix: Optional[List[List[int]]] = None, scaling_factor: Optional[List[int]] = None
14
13
  ) -> Material:
15
14
  """
16
15
  Create a supercell of the atoms.
17
16
 
18
17
  Args:
19
- atoms (Material): The atoms to create a supercell of.
18
+ material (Material): The atoms to create a supercell of.
20
19
  supercell_matrix (List[List[int]]): The supercell matrix (e.g. [[3,0,0],[0,3,0],[0,0,1]]).
21
20
  scaling_factor (List[int], optional): The scaling factor instead of matrix (e.g. [3,3,1]).
22
21
 
23
22
  Returns:
24
23
  Material: The supercell of the atoms.
25
24
  """
25
+ atoms = to_ase(material)
26
26
  if scaling_factor is not None:
27
27
  supercell_matrix = np.multiply(scaling_factor, np.eye(3)).tolist()
28
28
  supercell_atoms = ase_make_supercell(atoms, supercell_matrix)
29
- return Material(from_ase(supercell_atoms))
29
+ new_material = Material(from_ase(supercell_atoms))
30
+ if material.metadata:
31
+ new_material.metadata = material.metadata
32
+ new_material.name = material.name
33
+ return new_material
@@ -7,9 +7,9 @@ from .analyze import (
7
7
  get_atom_indices_within_radius_pbc,
8
8
  get_atomic_coordinates_extremum,
9
9
  )
10
- from .convert import decorator_convert_material_args_kwargs_to_structure, from_ase, to_ase
11
- from .third_party import PymatgenStructure, ase_add_vacuum
12
- from .utils import (
10
+ from .convert import from_ase, to_ase
11
+ from .third_party import ase_add_vacuum
12
+ from .utils.coordinate import (
13
13
  is_coordinate_in_box,
14
14
  is_coordinate_in_cylinder,
15
15
  is_coordinate_in_triangular_prism,
@@ -87,18 +87,18 @@ def translate_by_vector(
87
87
  return Material(from_ase(atoms))
88
88
 
89
89
 
90
- @decorator_convert_material_args_kwargs_to_structure
91
- def wrap_to_unit_cell(structure: PymatgenStructure):
90
+ def wrap_to_unit_cell(material: Material) -> Material:
92
91
  """
93
- Wrap atoms to the cell
92
+ Wrap the material to the unit cell.
94
93
 
95
94
  Args:
96
- structure (PymatgenStructure): The pymatgen PymatgenStructure object to normalize.
95
+ material (Material): The material to wrap.
97
96
  Returns:
98
- PymatgenStructure: The wrapped pymatgen PymatgenStructure object.
97
+ Material: The wrapped material.
99
98
  """
100
- structure.make_supercell((1, 1, 1), to_unit_cell=True)
101
- return structure
99
+ atoms = to_ase(material)
100
+ atoms.wrap()
101
+ return Material(from_ase(atoms))
102
102
 
103
103
 
104
104
  def filter_material_by_ids(material: Material, ids: List[int], invert: bool = False) -> Material:
@@ -0,0 +1,108 @@
1
+ from functools import wraps
2
+ from typing import Callable, List, Optional
3
+
4
+ import numpy as np
5
+ from mat3ra.utils.matrix import convert_2x2_to_3x3
6
+
7
+ from ..third_party import PymatgenStructure
8
+ from .coordinate import (
9
+ is_coordinate_behind_plane,
10
+ is_coordinate_in_box,
11
+ is_coordinate_in_cylinder,
12
+ is_coordinate_in_sphere,
13
+ is_coordinate_in_triangular_prism,
14
+ )
15
+ from .factories import PerturbationFunctionHolderFactory
16
+
17
+ DEFAULT_SCALING_FACTOR = np.array([3, 3, 3])
18
+ DEFAULT_TRANSLATION_VECTOR = 1 / DEFAULT_SCALING_FACTOR
19
+
20
+
21
+ # TODO: convert to accept ASE Atoms object
22
+ def translate_to_bottom_pymatgen_structure(structure: PymatgenStructure):
23
+ """
24
+ Translate the structure to the bottom of the cell.
25
+ Args:
26
+ structure (PymatgenStructure): The pymatgen Structure object to translate.
27
+
28
+ Returns:
29
+ PymatgenStructure: The translated pymatgen Structure object.
30
+ """
31
+ min_c = min(site.c for site in structure)
32
+ translation_vector = [0, 0, -min_c]
33
+ translated_structure = structure.copy()
34
+ for site in translated_structure:
35
+ site.coords += translation_vector
36
+ return translated_structure
37
+
38
+
39
+ def decorator_convert_2x2_to_3x3(func: Callable) -> Callable:
40
+ """
41
+ Decorator to convert a 2x2 matrix to a 3x3 matrix.
42
+ """
43
+
44
+ @wraps(func)
45
+ def wrapper(*args, **kwargs):
46
+ new_args = [convert_2x2_to_3x3(arg) if isinstance(arg, list) and len(arg) == 2 else arg for arg in args]
47
+ return func(*new_args, **kwargs)
48
+
49
+ return wrapper
50
+
51
+
52
+ def get_distance_between_coordinates(coordinate1: List[float], coordinate2: List[float]) -> float:
53
+ """
54
+ Get the distance between two coordinates.
55
+ Args:
56
+ coordinate1 (List[float]): The first coordinate.
57
+ coordinate2 (List[float]): The second coordinate.
58
+
59
+ Returns:
60
+ float: The distance between the two coordinates.
61
+ """
62
+ return float(np.linalg.norm(np.array(coordinate1) - np.array(coordinate2)))
63
+
64
+
65
+ def get_norm(vector: List[float]) -> float:
66
+ """
67
+ Get the norm of a vector.
68
+ Args:
69
+ vector (List[float]): The vector.
70
+
71
+ Returns:
72
+ float: The norm of the vector.
73
+ """
74
+ return float(np.linalg.norm(vector))
75
+
76
+
77
+ def transform_coordinate_to_supercell(
78
+ coordinate: List[float],
79
+ scaling_factor: Optional[List[int]] = None,
80
+ translation_vector: Optional[List[float]] = None,
81
+ reverse: bool = False,
82
+ ) -> List[float]:
83
+ """
84
+ Convert a crystal coordinate of unit cell to a coordinate in a supercell.
85
+ Args:
86
+ coordinate (List[float]): The coordinates to convert.
87
+ scaling_factor (List[int]): The scaling factor for the supercell.
88
+ translation_vector (List[float]): The translation vector for the supercell.
89
+ reverse (bool): Whether to convert in the reverse transformation.
90
+
91
+ Returns:
92
+ List[float]: The converted coordinates.
93
+ """
94
+ if scaling_factor is None:
95
+ np_scaling_factor = np.array([3, 3, 3])
96
+ else:
97
+ np_scaling_factor = np.array(scaling_factor)
98
+
99
+ if translation_vector is None:
100
+ np_translation_vector = np.array([0, 0, 0])
101
+ else:
102
+ np_translation_vector = np.array(translation_vector)
103
+
104
+ np_coordinate = np.array(coordinate)
105
+ converted_array = np_coordinate * (1 / np_scaling_factor) + np_translation_vector
106
+ if reverse:
107
+ converted_array = (np_coordinate - np_translation_vector) * np_scaling_factor
108
+ return converted_array.tolist()
@@ -1,72 +1,8 @@
1
- from functools import wraps
2
- from typing import Callable, Dict, List, Optional, Tuple
1
+ # Place all functions acting on coordinates
2
+ from typing import Dict, List
3
3
 
4
4
  import numpy as np
5
- from mat3ra.utils.matrix import convert_2x2_to_3x3
6
-
7
- from .third_party import PymatgenStructure
8
-
9
- DEFAULT_SCALING_FACTOR = np.array([3, 3, 3])
10
- DEFAULT_TRANSLATION_VECTOR = 1 / DEFAULT_SCALING_FACTOR
11
-
12
-
13
- # TODO: convert to accept ASE Atoms object
14
- def translate_to_bottom_pymatgen_structure(structure: PymatgenStructure):
15
- """
16
- Translate the structure to the bottom of the cell.
17
- Args:
18
- structure (PymatgenStructure): The pymatgen Structure object to translate.
19
-
20
- Returns:
21
- PymatgenStructure: The translated pymatgen Structure object.
22
- """
23
- min_c = min(site.c for site in structure)
24
- translation_vector = [0, 0, -min_c]
25
- translated_structure = structure.copy()
26
- for site in translated_structure:
27
- site.coords += translation_vector
28
- return translated_structure
29
-
30
-
31
- def decorator_convert_2x2_to_3x3(func: Callable) -> Callable:
32
- """
33
- Decorator to convert a 2x2 matrix to a 3x3 matrix.
34
- """
35
-
36
- @wraps(func)
37
- def wrapper(*args, **kwargs):
38
- new_args = [convert_2x2_to_3x3(arg) if isinstance(arg, list) and len(arg) == 2 else arg for arg in args]
39
- return func(*new_args, **kwargs)
40
-
41
- return wrapper
42
-
43
-
44
- def get_distance_between_coordinates(coordinate1: List[float], coordinate2: List[float]) -> float:
45
- """
46
- Get the distance between two coordinates.
47
- Args:
48
- coordinate1 (List[float]): The first coordinate.
49
- coordinate2 (List[float]): The second coordinate.
50
-
51
- Returns:
52
- float: The distance between the two coordinates.
53
- """
54
- return float(np.linalg.norm(np.array(coordinate1) - np.array(coordinate2)))
55
-
56
-
57
- def get_norm(vector: List[float]) -> float:
58
- """
59
- Get the norm of a vector.
60
- Args:
61
- vector (List[float]): The vector.
62
-
63
- Returns:
64
- float: The norm of the vector.
65
- """
66
- return float(np.linalg.norm(vector))
67
-
68
-
69
- # Condition functions:
5
+ from pydantic import BaseModel, Field
70
6
 
71
7
 
72
8
  def is_coordinate_in_cylinder(
@@ -219,106 +155,63 @@ def is_coordinate_behind_plane(
219
155
  return np.dot(np_plane_normal, np_coordinate - np_plane_point) < 0
220
156
 
221
157
 
222
- def transform_coordinate_to_supercell(
223
- coordinate: List[float],
224
- scaling_factor: Optional[List[int]] = None,
225
- translation_vector: Optional[List[float]] = None,
226
- reverse: bool = False,
227
- ) -> List[float]:
228
- """
229
- Convert a crystal coordinate of unit cell to a coordinate in a supercell.
230
- Args:
231
- coordinate (List[float]): The coordinates to convert.
232
- scaling_factor (List[int]): The scaling factor for the supercell.
233
- translation_vector (List[float]): The translation vector for the supercell.
234
- reverse (bool): Whether to convert in the reverse transformation.
158
+ class CoordinateCondition(BaseModel):
159
+ def condition(self, coordinate: List[float]) -> bool:
160
+ raise NotImplementedError
235
161
 
236
- Returns:
237
- List[float]: The converted coordinates.
238
- """
239
- if scaling_factor is None:
240
- np_scaling_factor = np.array([3, 3, 3])
241
- else:
242
- np_scaling_factor = np.array(scaling_factor)
162
+ def get_json(self) -> Dict:
163
+ json = {"type": self.__class__.__name__}
164
+ json.update(self.dict())
165
+ return json
243
166
 
244
- if translation_vector is None:
245
- np_translation_vector = np.array([0, 0, 0])
246
- else:
247
- np_translation_vector = np.array(translation_vector)
248
167
 
249
- np_coordinate = np.array(coordinate)
250
- converted_array = np_coordinate * (1 / np_scaling_factor) + np_translation_vector
251
- if reverse:
252
- converted_array = (np_coordinate - np_translation_vector) * np_scaling_factor
253
- return converted_array.tolist()
254
-
255
-
256
- class CoordinateConditionBuilder:
257
- @staticmethod
258
- def create_condition(condition_type: str, evaluation_func: Callable, **kwargs) -> Tuple[Callable, Dict]:
259
- condition_json = {"type": condition_type, **kwargs}
260
- return lambda coordinate: evaluation_func(coordinate, **kwargs), condition_json
261
-
262
- @staticmethod
263
- def cylinder(center_position=None, radius: float = 0.25, min_z: float = 0, max_z: float = 1):
264
- if center_position is None:
265
- center_position = [0.5, 0.5]
266
- return CoordinateConditionBuilder.create_condition(
267
- condition_type="cylinder",
268
- evaluation_func=is_coordinate_in_cylinder,
269
- center_position=center_position,
270
- radius=radius,
271
- min_z=min_z,
272
- max_z=max_z,
273
- )
168
+ class CylinderCoordinateCondition(CoordinateCondition):
169
+ center_position: List[float] = Field(default_factory=lambda: [0.5, 0.5])
170
+ radius: float = 0.25
171
+ min_z: float = 0
172
+ max_z: float = 1
274
173
 
275
- @staticmethod
276
- def sphere(center_position=None, radius: float = 0.25):
277
- if center_position is None:
278
- center_position = [0.5, 0.5, 0.5]
279
- return CoordinateConditionBuilder.create_condition(
280
- condition_type="sphere",
281
- evaluation_func=is_coordinate_in_sphere,
282
- center_position=center_position,
283
- radius=radius,
284
- )
174
+ def condition(self, coordinate: List[float]) -> bool:
175
+ return is_coordinate_in_cylinder(coordinate, self.center_position, self.radius, self.min_z, self.max_z)
285
176
 
286
- @staticmethod
287
- def triangular_prism(
288
- position_on_surface_1: List[float] = [0, 0],
289
- position_on_surface_2: List[float] = [1, 0],
290
- position_on_surface_3: List[float] = [0, 1],
291
- min_z: float = 0,
292
- max_z: float = 1,
293
- ):
294
- return CoordinateConditionBuilder.create_condition(
295
- condition_type="prism",
296
- evaluation_func=is_coordinate_in_triangular_prism,
297
- coordinate_1=position_on_surface_1,
298
- coordinate_2=position_on_surface_2,
299
- coordinate_3=position_on_surface_3,
300
- min_z=min_z,
301
- max_z=max_z,
302
- )
303
177
 
304
- @staticmethod
305
- def box(min_coordinate=None, max_coordinate=None):
306
- if max_coordinate is None:
307
- max_coordinate = [1, 1, 1]
308
- if min_coordinate is None:
309
- min_coordinate = [0, 0, 0]
310
- return CoordinateConditionBuilder.create_condition(
311
- condition_type="box",
312
- evaluation_func=is_coordinate_in_box,
313
- min_coordinate=min_coordinate,
314
- max_coordinate=max_coordinate,
315
- )
178
+ class SphereCoordinateCondition(CoordinateCondition):
179
+ center_position: List[float] = Field(default_factory=lambda: [0.5, 0.5])
180
+ radius: float = 0.25
181
+
182
+ def condition(self, coordinate: List[float]) -> bool:
183
+ return is_coordinate_in_sphere(coordinate, self.center_position, self.radius)
184
+
185
+
186
+ class BoxCoordinateCondition(CoordinateCondition):
187
+ min_coordinate: List[float] = Field(default_factory=lambda: [0, 0, 0])
188
+ max_coordinate: List[float] = Field(default_factory=lambda: [1, 1, 1])
316
189
 
317
- @staticmethod
318
- def plane(plane_normal: List[float], plane_point_coordinate: List[float]):
319
- return CoordinateConditionBuilder.create_condition(
320
- condition_type="plane",
321
- evaluation_func=is_coordinate_behind_plane,
322
- plane_normal=plane_normal,
323
- plane_point_coordinate=plane_point_coordinate,
190
+ def condition(self, coordinate: List[float]) -> bool:
191
+ return is_coordinate_in_box(coordinate, self.min_coordinate, self.max_coordinate)
192
+
193
+
194
+ class TriangularPrismCoordinateCondition(CoordinateCondition):
195
+ position_on_surface_1: List[float] = [0, 0]
196
+ position_on_surface_2: List[float] = [1, 0]
197
+ position_on_surface_3: List[float] = [0, 1]
198
+ min_z: float = 0
199
+ max_z: float = 1
200
+
201
+ def condition(self, coordinate: List[float]) -> bool:
202
+ return is_coordinate_in_triangular_prism(
203
+ coordinate,
204
+ self.position_on_surface_1,
205
+ self.position_on_surface_2,
206
+ self.position_on_surface_3,
207
+ self.min_z,
208
+ self.max_z,
324
209
  )
210
+
211
+
212
+ class PlaneCoordinateCondition(CoordinateCondition):
213
+ plane_normal: List[float]
214
+ plane_point_coordinate: List[float]
215
+
216
+ def condition(self, coordinate: List[float]) -> bool:
217
+ return is_coordinate_behind_plane(coordinate, self.plane_normal, self.plane_point_coordinate)
@@ -0,0 +1,7 @@
1
+ from mat3ra.utils.factory import BaseFactory
2
+
3
+
4
+ class PerturbationFunctionHolderFactory(BaseFactory):
5
+ __class_registry__ = {
6
+ "sine_wave": "mat3ra.made.tools.utils.functions.SineWaveFunctionHolder",
7
+ }
@@ -0,0 +1,29 @@
1
+ from typing import List
2
+
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class FunctionHolder(BaseModel):
7
+ def apply_function(self, coordinate: List[float]) -> float:
8
+ """
9
+ Get the value of the function at the given coordinate.
10
+ """
11
+ raise NotImplementedError
12
+
13
+ def calculate_derivative(self, coordinate: List[float], axis: str) -> float:
14
+ """
15
+ Get the derivative of the function at the given coordinate
16
+ """
17
+ raise NotImplementedError
18
+
19
+ def calculate_arc_length(self, a: float, b: float) -> float:
20
+ """
21
+ Get the arc length of the function between a and b.
22
+ """
23
+ raise NotImplementedError
24
+
25
+ def get_json(self) -> dict:
26
+ """
27
+ Get the json representation of the function holder.
28
+ """
29
+ raise NotImplementedError
@@ -0,0 +1,138 @@
1
+ from typing import Any, Callable, List, Optional
2
+
3
+ import numpy as np
4
+ import sympy as sp
5
+ from scipy.integrate import quad
6
+ from scipy.optimize import root_scalar
7
+
8
+ from .functions import FunctionHolder
9
+
10
+ AXIS_TO_INDEX_MAP = {"x": 0, "y": 1, "z": 2}
11
+ EQUATION_RANGE_COEFFICIENT = 5
12
+
13
+
14
+ def default_function() -> sp.Expr:
15
+ return sp.Symbol("f")
16
+
17
+
18
+ class PerturbationFunctionHolder(FunctionHolder):
19
+ variables: List[str] = ["x"]
20
+ symbols: List[sp.Symbol] = [sp.Symbol(var) for var in variables]
21
+ function: sp.Expr = sp.Symbol("f")
22
+ function_numeric: Callable = default_function
23
+ derivatives_numeric: dict = {}
24
+
25
+ class Config:
26
+ arbitrary_types_allowed = True
27
+
28
+ def __init__(self, function: Optional[sp.Expr] = None, variables: Optional[List[str]] = None, **data: Any):
29
+ """
30
+ Initializes with a function involving multiple variables.
31
+ """
32
+ if function is None:
33
+ function = default_function()
34
+ if variables is None:
35
+ variables = ["x", "y", "z"]
36
+ super().__init__(**data)
37
+ self.variables = variables
38
+ self.symbols = sp.symbols(variables)
39
+ self.function = function
40
+ self.function_numeric = sp.lambdify(self.symbols, self.function, modules=["numpy"])
41
+ self.derivatives_numeric = {
42
+ var: sp.lambdify(self.symbols, sp.diff(self.function, var), modules=["numpy"]) for var in variables
43
+ }
44
+
45
+ def apply_function(self, coordinate: List[float]) -> float:
46
+ values = [coordinate[AXIS_TO_INDEX_MAP[var]] for var in self.variables]
47
+ return self.function_numeric(*values)
48
+
49
+ def calculate_derivative(self, coordinate: List[float], axis: str) -> float:
50
+ if axis in self.variables:
51
+ values = [coordinate[AXIS_TO_INDEX_MAP[var]] for var in self.variables]
52
+ return self.derivatives_numeric[axis](*values)
53
+ else:
54
+ return 0
55
+
56
+ def _integrand(self, t: float, coordinate: List[float], axis: str) -> float:
57
+ temp_coordinate = coordinate[:]
58
+ temp_coordinate[AXIS_TO_INDEX_MAP[axis]] = t
59
+ return np.sqrt(1 + self.calculate_derivative(temp_coordinate, axis) ** 2)
60
+
61
+ def get_arc_length_equation(self, w_prime: float, coordinate: List[float], axis: str) -> float:
62
+ """
63
+ Calculate arc length considering a change along one specific axis.
64
+ """
65
+ a, b = 0, w_prime
66
+ arc_length = quad(self._integrand, a, b, args=(coordinate, axis))[0]
67
+ return arc_length - coordinate[AXIS_TO_INDEX_MAP[axis]]
68
+
69
+ def transform_coordinates(self, coordinate: List[float]) -> List[float]:
70
+ """
71
+ Transform coordinates to preserve the distance between points on a sine wave when perturbation is applied.
72
+ Achieved by calculating the integral of the length between [0,0,0] and given coordinate.
73
+
74
+ Returns:
75
+ Callable[[List[float]], List[float]]: The coordinates transformation function.
76
+ """
77
+ for i, var in enumerate(self.variables):
78
+ index = AXIS_TO_INDEX_MAP[var]
79
+ w = coordinate[index]
80
+ result = root_scalar(
81
+ self.get_arc_length_equation,
82
+ args=(coordinate, var),
83
+ bracket=[0, EQUATION_RANGE_COEFFICIENT * w],
84
+ method="brentq",
85
+ )
86
+ coordinate[index] = result.root
87
+ return coordinate
88
+
89
+ def apply_perturbation(self, coordinate: List[float]) -> List[float]:
90
+ """
91
+ Apply the perturbation to the given coordinate by adding the function's value to the third coordinate (z-axis).
92
+ """
93
+ perturbation_value = self.apply_function(coordinate)
94
+ perturbed_coordinate = coordinate[:]
95
+ perturbed_coordinate[2] += perturbation_value
96
+ return perturbed_coordinate
97
+
98
+ def get_json(self) -> dict:
99
+ return {
100
+ "type": self.__class__.__name__,
101
+ "function": str(self.function),
102
+ "variables": self.variables,
103
+ }
104
+
105
+
106
+ class SineWavePerturbationFunctionHolder(PerturbationFunctionHolder):
107
+ amplitude: float = 0.05
108
+ wavelength: float = 1
109
+ phase: float = 0
110
+ axis: str = "x"
111
+
112
+ def __init__(
113
+ self,
114
+ amplitude: float = 0.05,
115
+ wavelength: float = 1,
116
+ phase: float = 0,
117
+ axis: str = "x",
118
+ **data: Any,
119
+ ):
120
+ w = sp.Symbol(axis)
121
+ function = amplitude * sp.sin(2 * sp.pi * w / wavelength + phase)
122
+ variables = [axis]
123
+ super().__init__(function=function, variables=variables, **data)
124
+ self.amplitude = amplitude
125
+ self.wavelength = wavelength
126
+ self.phase = phase
127
+ self.axis = axis
128
+
129
+ def get_json(self) -> dict:
130
+ return {
131
+ "type": self.__class__.__name__,
132
+ "function": str(self.function),
133
+ "variables": self.variables,
134
+ "amplitude": self.amplitude,
135
+ "wavelength": self.wavelength,
136
+ "phase": self.phase,
137
+ "axis": self.axis,
138
+ }
@@ -114,7 +114,7 @@ SI_CONVENTIONAL_CELL: Dict[str, Any] = {
114
114
  }
115
115
 
116
116
  SI_SUPERCELL_2X2X1: Dict[str, Any] = {
117
- "name": "Si8",
117
+ "name": "Silicon FCC",
118
118
  "basis": {
119
119
  "elements": [
120
120
  {"id": 0, "value": "Si"},
@@ -260,3 +260,32 @@ slab_001_config = SlabConfiguration(
260
260
  )
261
261
  t_001 = get_terminations(slab_001_config)[0]
262
262
  SLAB_001 = create_slab(slab_001_config, t_001)
263
+
264
+ GRAPHENE = {
265
+ "name": "Graphene",
266
+ "basis": {
267
+ "elements": [{"id": 0, "value": "C"}, {"id": 1, "value": "C"}],
268
+ "coordinates": [{"id": 0, "value": [0, 0, 0]}, {"id": 1, "value": [0.333333, 0.666667, 0]}],
269
+ "units": "crystal",
270
+ "cell": [[2.467291, 0, 0], [-1.2336454999, 2.1367366845, 0], [0, 0, 20]],
271
+ "constraints": [],
272
+ },
273
+ "lattice": {
274
+ "a": 2.467291,
275
+ "b": 2.467291,
276
+ "c": 20,
277
+ "alpha": 90,
278
+ "beta": 90,
279
+ "gamma": 120,
280
+ "units": {"length": "angstrom", "angle": "degree"},
281
+ "type": "HEX",
282
+ "vectors": {
283
+ "a": [2.467291, 0, 0],
284
+ "b": [-1.233645, 2.136737, 0],
285
+ "c": [0, 0, 20],
286
+ "alat": 1,
287
+ "units": "angstrom",
288
+ },
289
+ },
290
+ "isNonPeriodic": False,
291
+ }
@@ -19,7 +19,7 @@ from mat3ra.made.tools.build.defect.configuration import (
19
19
  PointDefectPairConfiguration,
20
20
  TerraceSlabDefectConfiguration,
21
21
  )
22
- from mat3ra.made.tools.utils import CoordinateConditionBuilder
22
+ from mat3ra.made.tools.utils import coordinate as CoordinateCondition
23
23
  from mat3ra.utils import assertion as assertion_utils
24
24
 
25
25
  from .fixtures import SLAB_001, SLAB_111
@@ -114,7 +114,9 @@ def test_create_crystal_site_adatom():
114
114
 
115
115
 
116
116
  def test_create_island():
117
- condition = CoordinateConditionBuilder.cylinder(center_position=[0.625, 0.5], radius=0.25, min_z=0, max_z=1)
117
+ condition = CoordinateCondition.CylinderCoordinateCondition(
118
+ center_position=[0.625, 0.5], radius=0.25, min_z=0, max_z=1
119
+ )
118
120
  island_config = IslandSlabDefectConfiguration(
119
121
  crystal=SLAB_111,
120
122
  defect_type="island",
@@ -0,0 +1,50 @@
1
+ from mat3ra.made.cell import Cell
2
+ from mat3ra.made.material import Material
3
+ from mat3ra.made.tools.build.perturbation import create_perturbation
4
+ from mat3ra.made.tools.build.perturbation.builders import SlabPerturbationBuilder
5
+ from mat3ra.made.tools.build.perturbation.configuration import PerturbationConfiguration
6
+ from mat3ra.made.tools.build.supercell import create_supercell
7
+ from mat3ra.made.tools.utils.perturbation import SineWavePerturbationFunctionHolder
8
+ from mat3ra.utils import assertion as assertion_utils
9
+
10
+ from .fixtures import GRAPHENE
11
+
12
+
13
+ def test_sine_perturbation():
14
+ material = Material(GRAPHENE)
15
+ slab = create_supercell(material, [[10, 0, 0], [0, 10, 0], [0, 0, 1]])
16
+
17
+ perturbation_config = PerturbationConfiguration(
18
+ material=slab,
19
+ perturbation_function=SineWavePerturbationFunctionHolder(amplitude=0.05, wavelength=1),
20
+ use_cartesian_coordinates=False,
21
+ )
22
+ builder = SlabPerturbationBuilder()
23
+ perturbed_slab = builder.get_material(perturbation_config)
24
+ # Check selected atoms to avoid using 100+ atoms fixture
25
+ assertion_utils.assert_deep_almost_equal([0.0, 0.0, 0.5], perturbed_slab.basis.coordinates.values[0])
26
+ assertion_utils.assert_deep_almost_equal([0.2, 0.1, 0.547552826], perturbed_slab.basis.coordinates.values[42])
27
+
28
+
29
+ def test_distance_preserved_sine_perturbation():
30
+ material = Material(GRAPHENE)
31
+ slab = create_supercell(material, [[10, 0, 0], [0, 10, 0], [0, 0, 1]])
32
+
33
+ perturbation_config = PerturbationConfiguration(
34
+ material=slab,
35
+ perturbation_function=SineWavePerturbationFunctionHolder(amplitude=0.05, wavelength=1, phase=0.25, axis="y"),
36
+ use_cartesian_coordinates=False,
37
+ )
38
+ perturbed_slab = create_perturbation(configuration=perturbation_config, preserve_distance=True)
39
+ # Check selected atoms to avoid using 100+ atoms fixture
40
+ assertion_utils.assert_deep_almost_equal([0.0, 0.0, 0.5], perturbed_slab.basis.coordinates.values[0])
41
+ assertion_utils.assert_deep_almost_equal(
42
+ [0.197552693, 0.1, 0.546942315], perturbed_slab.basis.coordinates.values[42]
43
+ )
44
+ # Value taken from visually inspected notebook
45
+ expected_cell = Cell(
46
+ vector1=[24.087442, 0.0, 0.0],
47
+ vector2=[-12.043583, 21.367367, 0.0],
48
+ vector3=[0.0, 0.0, 20.0],
49
+ )
50
+ assertion_utils.assert_deep_almost_equal(expected_cell.vectors_as_array, perturbed_slab.basis.cell.vectors_as_array)