@mat3ra/made 2026.7.8-0 → 2026.7.18-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 +1 -1
- package/pyproject.toml +1 -1
- package/src/py/mat3ra/made/basis/__init__.py +6 -3
- package/src/py/mat3ra/made/tools/analyze/build_metadata_analyzer.py +2 -0
- package/src/py/mat3ra/made/tools/analyze/build_metadata_analyzer_factory.py +31 -0
- package/src/py/mat3ra/made/tools/analyze/interface_material.py +45 -0
- package/src/py/mat3ra/made/tools/analyze/solid_solution_analyzer.py +171 -0
- package/src/py/mat3ra/made/tools/analyze/utils.py +25 -0
- package/src/py/mat3ra/made/tools/build/compound_pristine_structures/two_dimensional/interface/__init__.py +5 -1
- package/src/py/mat3ra/made/tools/build/defective_structures/three_dimensional/__init__.py +1 -0
- package/src/py/mat3ra/made/tools/build/defective_structures/three_dimensional/solid_solution/__init__.py +9 -0
- package/src/py/mat3ra/made/tools/build/defective_structures/three_dimensional/solid_solution/builder.py +37 -0
- package/src/py/mat3ra/made/tools/build/defective_structures/three_dimensional/solid_solution/configuration.py +30 -0
- package/src/py/mat3ra/made/tools/build/defective_structures/three_dimensional/solid_solution/enums.py +8 -0
- package/src/py/mat3ra/made/tools/build/defective_structures/three_dimensional/solid_solution/helpers.py +76 -0
- package/src/py/mat3ra/made/tools/build_components/entities/auxiliary/zero_dimensional/crystal_site/crystal_site.py +3 -2
- package/src/py/mat3ra/made/tools/build_components/entities/auxiliary/zero_dimensional/point_defect_site/builder.py +11 -10
- package/src/py/mat3ra/made/tools/build_components/metadata/material_build_metadata.py +5 -0
- package/src/py/mat3ra/made/tools/build_components/operations/core/combinations/merge/builder.py +9 -0
- package/src/py/mat3ra/made/tools/calculate/__init__.py +10 -14
- package/src/py/mat3ra/made/tools/helpers.py +2 -0
- package/tests/py/unit/test_primitive_cell.py +1 -1
- package/tests/py/unit/test_tools_analyze_interface_material.py +63 -0
- package/tests/py/unit/test_tools_build_defect/test_solid_solution.py +90 -0
- package/tests/py/unit/test_tools_build_metadata.py +10 -0
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -175,9 +175,12 @@ class Basis(BasisSchema, InMemoryEntityPydantic):
|
|
|
175
175
|
other_basis (Basis): The other basis to add atoms from.
|
|
176
176
|
"""
|
|
177
177
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
178
|
+
for el in other_basis.elements.values:
|
|
179
|
+
self.elements.add_item(el)
|
|
180
|
+
for coord in other_basis.coordinates.values:
|
|
181
|
+
self.coordinates.add_item(coord)
|
|
182
|
+
for label in other_basis.labels.values:
|
|
183
|
+
self.labels.add_item(label)
|
|
181
184
|
|
|
182
185
|
def remove_atom_by_id(self, id: int):
|
|
183
186
|
self.elements.remove_item(id)
|
|
@@ -16,6 +16,8 @@ class BuildMetadataAnalyzer(BaseMaterialAnalyzer, Generic[TypeConfiguration, Typ
|
|
|
16
16
|
|
|
17
17
|
@property
|
|
18
18
|
def build_metadata(self) -> BuildMetadata:
|
|
19
|
+
if not self.material.metadata.build:
|
|
20
|
+
raise ValueError("No metadata.build on the material.")
|
|
19
21
|
metadata = self.material.metadata.get_build_metadata_of_type(self.configuration_cls.__name__)
|
|
20
22
|
if not metadata:
|
|
21
23
|
raise ValueError(f"Build metadata for {self.configuration_cls.__name__} not found.")
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from typing import Dict, Type
|
|
2
|
+
|
|
3
|
+
from ..build_components.metadata import MaterialWithBuildMetadata
|
|
4
|
+
from .build_metadata_analyzer import BuildMetadataAnalyzer
|
|
5
|
+
from .interface_material import (
|
|
6
|
+
InterfaceMaterialAnalyzer,
|
|
7
|
+
)
|
|
8
|
+
from .slab import SlabMaterialAnalyzer
|
|
9
|
+
|
|
10
|
+
BUILD_METADATA_ANALYZER_REGISTRY: Dict[str, Type[BuildMetadataAnalyzer]] = {
|
|
11
|
+
"SlabConfiguration": SlabMaterialAnalyzer,
|
|
12
|
+
"InterfaceConfiguration": InterfaceMaterialAnalyzer,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def create_build_metadata_analyzer(
|
|
17
|
+
material: MaterialWithBuildMetadata,
|
|
18
|
+
**analyzer_kwargs,
|
|
19
|
+
) -> BuildMetadataAnalyzer:
|
|
20
|
+
build_metadata = material.metadata.get_latest_build_metadata()
|
|
21
|
+
configuration_type = build_metadata.configuration.get("type")
|
|
22
|
+
if not configuration_type:
|
|
23
|
+
raise ValueError("Latest build step has no configuration type.")
|
|
24
|
+
analyzer_cls = BUILD_METADATA_ANALYZER_REGISTRY.get(configuration_type)
|
|
25
|
+
if analyzer_cls is None:
|
|
26
|
+
registered_types = ", ".join(sorted(BUILD_METADATA_ANALYZER_REGISTRY))
|
|
27
|
+
raise ValueError(
|
|
28
|
+
f"No build metadata analyzer registered for '{configuration_type}'. "
|
|
29
|
+
f"Registered types: {registered_types}."
|
|
30
|
+
)
|
|
31
|
+
return analyzer_cls(material=material, **analyzer_kwargs)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from typing import Type, Union
|
|
2
|
+
|
|
3
|
+
from ..build.compound_pristine_structures.two_dimensional.interface.base.build_parameters import (
|
|
4
|
+
InterfaceBuilderParameters,
|
|
5
|
+
)
|
|
6
|
+
from ..build.compound_pristine_structures.two_dimensional.interface.base.configuration import InterfaceConfiguration
|
|
7
|
+
from ..build.pristine_structures.two_dimensional.slab.configuration import SlabConfiguration
|
|
8
|
+
from ..build.pristine_structures.two_dimensional.slab_strained_supercell.configuration import (
|
|
9
|
+
SlabStrainedSupercellConfiguration,
|
|
10
|
+
)
|
|
11
|
+
from ..build_components.metadata import MaterialWithBuildMetadata
|
|
12
|
+
from ..convert.interface_parts_enum import INTERFACE_LABELS_MAP
|
|
13
|
+
from .build_metadata_analyzer import BuildMetadataAnalyzer
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class InterfaceMaterialAnalyzer(
|
|
17
|
+
BuildMetadataAnalyzer[InterfaceConfiguration, InterfaceBuilderParameters],
|
|
18
|
+
):
|
|
19
|
+
configuration_cls: Type[InterfaceConfiguration] = InterfaceConfiguration
|
|
20
|
+
build_parameters_cls: Type[InterfaceBuilderParameters] = InterfaceBuilderParameters
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def substrate_slab_configuration(self) -> Union[SlabStrainedSupercellConfiguration, SlabConfiguration]:
|
|
24
|
+
return self.build_configuration.substrate_configuration
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def film_slab_configuration(self) -> Union[SlabStrainedSupercellConfiguration, SlabConfiguration]:
|
|
28
|
+
return self.build_configuration.film_configuration
|
|
29
|
+
|
|
30
|
+
def get_bulk_crystal(self, part: str = "substrate") -> dict:
|
|
31
|
+
if part not in INTERFACE_LABELS_MAP:
|
|
32
|
+
raise ValueError(f"Unknown interface part '{part}'. Use 'substrate' or 'film'.")
|
|
33
|
+
slab_configuration = self.substrate_slab_configuration if part == "substrate" else self.film_slab_configuration
|
|
34
|
+
crystal = slab_configuration.atomic_layers.crystal
|
|
35
|
+
if crystal is None:
|
|
36
|
+
raise ValueError(f"No bulk crystal for {part} in interface build metadata.")
|
|
37
|
+
return crystal if isinstance(crystal, dict) else crystal.to_dict()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_interface_configuration(interface: MaterialWithBuildMetadata) -> InterfaceConfiguration:
|
|
41
|
+
return InterfaceMaterialAnalyzer(material=interface).build_configuration
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_interface_bulk_crystal(interface: MaterialWithBuildMetadata, part: str = "substrate") -> dict:
|
|
45
|
+
return InterfaceMaterialAnalyzer(material=interface).get_bulk_crystal(part=part)
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import random
|
|
2
|
+
from typing import List, Optional, Union
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
from mat3ra.made.material import Material
|
|
6
|
+
|
|
7
|
+
from ..analyze import BaseMaterialAnalyzer
|
|
8
|
+
from ..analyze.utils import minimum_image_distances
|
|
9
|
+
from ..build.defective_structures.three_dimensional.solid_solution.enums import SiteSelectionMethodEnum
|
|
10
|
+
from ..build_components import MaterialWithBuildMetadata
|
|
11
|
+
from ..build_components.entities.reusable.three_dimensional.supercell.helpers import create_supercell
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _most_isotropic_dimensions(total_cells: int) -> List[int]:
|
|
15
|
+
"""
|
|
16
|
+
Find [a, b, c] repeat factors whose product equals total_cells with minimum aspect ratio.
|
|
17
|
+
|
|
18
|
+
Enumerates factor triples with a <= b <= c and returns the triple with the
|
|
19
|
+
smallest spread (c - a).
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
total_cells (int): Target number of unit cells in the supercell.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
List[int]: Repeat factors [a, b, c] along the three lattice directions.
|
|
26
|
+
"""
|
|
27
|
+
best_dimensions = [1, 1, total_cells]
|
|
28
|
+
best_spread = total_cells - 1
|
|
29
|
+
for a in range(1, total_cells + 1):
|
|
30
|
+
if total_cells % a:
|
|
31
|
+
continue
|
|
32
|
+
for b in range(a, total_cells // a + 1):
|
|
33
|
+
if (total_cells // a) % b:
|
|
34
|
+
continue
|
|
35
|
+
c = total_cells // (a * b)
|
|
36
|
+
if c < b:
|
|
37
|
+
continue
|
|
38
|
+
spread = c - a
|
|
39
|
+
if spread < best_spread:
|
|
40
|
+
best_dimensions = [a, b, c]
|
|
41
|
+
best_spread = spread
|
|
42
|
+
return best_dimensions
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _select_sites_uniform(
|
|
46
|
+
material: Union[Material, MaterialWithBuildMetadata],
|
|
47
|
+
source_indices: List[int],
|
|
48
|
+
number_to_select: int,
|
|
49
|
+
seed: int = None,
|
|
50
|
+
) -> List[int]:
|
|
51
|
+
"""
|
|
52
|
+
Select number_to_select sites from source_indices using Farthest Point Sampling.
|
|
53
|
+
|
|
54
|
+
Iteratively picks the site whose minimum distance to all already-selected
|
|
55
|
+
sites is largest, producing a maximally dispersed subset under PBC.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
material (Union[Material, MaterialWithBuildMetadata]): Material with lattice and coordinates.
|
|
59
|
+
source_indices (List[int]): Indices of candidate sites in the material basis.
|
|
60
|
+
number_to_select (int): Number of sites to select.
|
|
61
|
+
seed (int, optional): Random seed for the initial site choice.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
List[int]: Sorted list of selected site indices.
|
|
65
|
+
"""
|
|
66
|
+
if number_to_select <= 0:
|
|
67
|
+
return []
|
|
68
|
+
if number_to_select >= len(source_indices):
|
|
69
|
+
return sorted(source_indices)
|
|
70
|
+
|
|
71
|
+
material.to_crystal()
|
|
72
|
+
fractional_coordinates = np.array(material.basis.coordinates.values)
|
|
73
|
+
lattice_vectors = np.array(material.lattice.vector_arrays)
|
|
74
|
+
pairwise_distances = minimum_image_distances(fractional_coordinates[source_indices], lattice_vectors)
|
|
75
|
+
|
|
76
|
+
random_generator = random.Random(seed)
|
|
77
|
+
first_pick = random_generator.randrange(len(source_indices))
|
|
78
|
+
selected = [first_pick]
|
|
79
|
+
distance_to_selected = pairwise_distances[first_pick].copy()
|
|
80
|
+
is_candidate = np.ones(len(source_indices), dtype=bool)
|
|
81
|
+
is_candidate[first_pick] = False
|
|
82
|
+
|
|
83
|
+
for _ in range(number_to_select - 1):
|
|
84
|
+
farthest_distance = np.max(distance_to_selected[is_candidate])
|
|
85
|
+
tied_candidates = np.where(is_candidate & np.isclose(distance_to_selected, farthest_distance))[0]
|
|
86
|
+
next_pick = int(random_generator.choice(tied_candidates))
|
|
87
|
+
selected.append(next_pick)
|
|
88
|
+
is_candidate[next_pick] = False
|
|
89
|
+
np.minimum(distance_to_selected, pairwise_distances[next_pick], out=distance_to_selected)
|
|
90
|
+
|
|
91
|
+
return sorted(source_indices[i] for i in selected)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class SolidSolutionAnalyzer(BaseMaterialAnalyzer):
|
|
95
|
+
"""
|
|
96
|
+
Analyzes a unit cell to plan supercell dimensions and select substitution sites.
|
|
97
|
+
|
|
98
|
+
Given a unit cell material and a desired substitution ratio, finds the
|
|
99
|
+
most isotropic supercell matching the target concentration within tolerance,
|
|
100
|
+
then selects specific sites for substitution.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
source_element: str
|
|
104
|
+
target_element: str
|
|
105
|
+
target_concentration: float
|
|
106
|
+
tolerance: float = 0.01
|
|
107
|
+
max_supercell_cells: int = 128
|
|
108
|
+
seed: Optional[int] = None
|
|
109
|
+
site_selection_method: SiteSelectionMethodEnum = SiteSelectionMethodEnum.UNIFORM
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def source_element_count_per_cell(self) -> int:
|
|
113
|
+
return sum(1 for element in self.material.basis.elements.values if element == self.source_element)
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def optimal_supercell_dimensions(self) -> List[int]:
|
|
117
|
+
source_count_per_cell = self.source_element_count_per_cell
|
|
118
|
+
if source_count_per_cell == 0:
|
|
119
|
+
raise ValueError(f"No {self.source_element} atoms found in the unit cell.")
|
|
120
|
+
|
|
121
|
+
best_dimensions = None
|
|
122
|
+
best_shape_spread = self.max_supercell_cells
|
|
123
|
+
for cell_count in range(1, self.max_supercell_cells + 1):
|
|
124
|
+
source_atoms = source_count_per_cell * cell_count
|
|
125
|
+
replacement_atoms = min(max(round(self.target_concentration * source_atoms), 0), source_atoms)
|
|
126
|
+
achieved_concentration = replacement_atoms / source_atoms
|
|
127
|
+
if abs(achieved_concentration - self.target_concentration) > self.tolerance:
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
dimensions = _most_isotropic_dimensions(cell_count)
|
|
131
|
+
shape_spread = dimensions[2] - dimensions[0]
|
|
132
|
+
if shape_spread < best_shape_spread:
|
|
133
|
+
best_dimensions = dimensions
|
|
134
|
+
best_shape_spread = shape_spread
|
|
135
|
+
if shape_spread == 0:
|
|
136
|
+
break
|
|
137
|
+
|
|
138
|
+
if best_dimensions is None:
|
|
139
|
+
raise ValueError(
|
|
140
|
+
f"Cannot achieve concentration {self.target_concentration} "
|
|
141
|
+
f"within tolerance {self.tolerance} for up to {self.max_supercell_cells} cells."
|
|
142
|
+
)
|
|
143
|
+
return best_dimensions
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def actual_concentration(self) -> float:
|
|
147
|
+
a, b, c = self.optimal_supercell_dimensions
|
|
148
|
+
source_atoms = self.source_element_count_per_cell * a * b * c
|
|
149
|
+
replacement_atoms = min(max(round(self.target_concentration * source_atoms), 0), source_atoms)
|
|
150
|
+
return replacement_atoms / source_atoms
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def selected_site_indices(self) -> List[int]:
|
|
154
|
+
supercell = create_supercell(material=self.material, scaling_factor=self.optimal_supercell_dimensions)
|
|
155
|
+
supercell_material = MaterialWithBuildMetadata.create(supercell.to_dict())
|
|
156
|
+
elements = supercell_material.basis.elements.values
|
|
157
|
+
source_indices = [index for index, element in enumerate(elements) if element == self.source_element]
|
|
158
|
+
replacement_atoms = min(
|
|
159
|
+
max(round(self.actual_concentration * len(source_indices)), 0),
|
|
160
|
+
len(source_indices),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
if self.site_selection_method == SiteSelectionMethodEnum.UNIFORM:
|
|
164
|
+
keep_atoms = len(source_indices) - replacement_atoms
|
|
165
|
+
if keep_atoms < replacement_atoms:
|
|
166
|
+
kept_indices = _select_sites_uniform(supercell_material, source_indices, keep_atoms, self.seed)
|
|
167
|
+
return sorted(set(source_indices) - set(kept_indices))
|
|
168
|
+
return _select_sites_uniform(supercell_material, source_indices, replacement_atoms, self.seed)
|
|
169
|
+
|
|
170
|
+
random_generator = random.Random(self.seed)
|
|
171
|
+
return sorted(random_generator.sample(source_indices, replacement_atoms))
|
|
@@ -135,3 +135,28 @@ def calculate_von_mises_strain(strain_matrix: np.ndarray) -> float:
|
|
|
135
135
|
e_von_mises = np.sqrt(exx**2 - exx * eyy + eyy**2 + 3 * exy**2)
|
|
136
136
|
|
|
137
137
|
return abs(e_von_mises) * 100.0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def minimum_image_distances(frac_coords: np.ndarray, lattice_vectors: np.ndarray) -> np.ndarray:
|
|
141
|
+
"""
|
|
142
|
+
Compute pairwise distances between fractional coordinates under periodic boundary conditions.
|
|
143
|
+
|
|
144
|
+
Applies the minimum image convention: for each pair, the shortest distance
|
|
145
|
+
across all periodic images is returned.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
frac_coords (np.ndarray): Fractional coordinates, shape (N, 3).
|
|
149
|
+
lattice_vectors (np.ndarray): Lattice vectors, shape (3, 3), rows are vectors.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
np.ndarray: Symmetric distance matrix, shape (N, N), in the same units as lattice_vectors.
|
|
153
|
+
"""
|
|
154
|
+
n = len(frac_coords)
|
|
155
|
+
distances = np.zeros((n, n))
|
|
156
|
+
for i in range(n):
|
|
157
|
+
delta = frac_coords[i] - frac_coords[i + 1 :]
|
|
158
|
+
delta -= np.round(delta)
|
|
159
|
+
dists = np.linalg.norm(delta @ lattice_vectors, axis=1)
|
|
160
|
+
distances[i, i + 1 :] = dists
|
|
161
|
+
distances[i + 1 :, i] = dists
|
|
162
|
+
return distances
|
|
@@ -13,7 +13,11 @@ from .twisted import (
|
|
|
13
13
|
TwistedNanoribbonsInterfaceConfiguration,
|
|
14
14
|
create_interface_twisted,
|
|
15
15
|
)
|
|
16
|
-
from .utils import
|
|
16
|
+
from .utils import (
|
|
17
|
+
get_optimal_film_displacement,
|
|
18
|
+
get_slab,
|
|
19
|
+
remove_duplicate_interfaces,
|
|
20
|
+
)
|
|
17
21
|
from .zsl import (
|
|
18
22
|
create_interface_zsl,
|
|
19
23
|
create_interface_zsl_between_slabs,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from typing import Dict, Type, Union
|
|
2
|
+
|
|
3
|
+
from mat3ra.made.material import Material
|
|
4
|
+
|
|
5
|
+
from .....analyze.other import get_chemical_formula_empirical
|
|
6
|
+
from .....build_components import MaterialWithBuildMetadata, TypeConfiguration
|
|
7
|
+
from .....build_components.entities.auxiliary.zero_dimensional.point_defect_site.builder import PointDefectSiteBuilder
|
|
8
|
+
from .....build_components.entities.auxiliary.zero_dimensional.point_defect_site.configuration import (
|
|
9
|
+
PointDefectSiteConfiguration,
|
|
10
|
+
)
|
|
11
|
+
from .....build_components.operations.core.combinations.merge.builder import MergeBuilder
|
|
12
|
+
from .configuration import SolidSolutionConfiguration
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SolidSolutionBuilder(MergeBuilder):
|
|
16
|
+
"""Builds a solid solution by merging substitution sites into a supercell."""
|
|
17
|
+
|
|
18
|
+
_ConfigurationType: Type[SolidSolutionConfiguration] = SolidSolutionConfiguration
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def merge_component_types_conversion_map(self) -> Dict[Type, Type]:
|
|
22
|
+
return {
|
|
23
|
+
PointDefectSiteConfiguration: PointDefectSiteBuilder,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
def _update_material_name(
|
|
27
|
+
self, material: Union[Material, MaterialWithBuildMetadata], configuration: TypeConfiguration
|
|
28
|
+
) -> MaterialWithBuildMetadata:
|
|
29
|
+
formula = get_chemical_formula_empirical(material)
|
|
30
|
+
concentration_percentage = configuration.actual_concentration * 100
|
|
31
|
+
source_element = configuration.source_element
|
|
32
|
+
target_element = configuration.target_element
|
|
33
|
+
material.name = (
|
|
34
|
+
f"{formula} - Solid Solution ({source_element}\u2192{target_element} "
|
|
35
|
+
f"{concentration_percentage:.4g}%)"
|
|
36
|
+
)
|
|
37
|
+
return material
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from typing import List, Union
|
|
2
|
+
|
|
3
|
+
from mat3ra.esse.models.materials_category.defective_structures.three_dimensional.solid_solution.configuration import (
|
|
4
|
+
SolidSolutionConfigurationSchema,
|
|
5
|
+
)
|
|
6
|
+
from mat3ra.esse.models.materials_category_components.operations.core.combinations.merge import MergeMethodsEnum
|
|
7
|
+
from mat3ra.made.material import Material
|
|
8
|
+
|
|
9
|
+
from .....build_components import MaterialWithBuildMetadata
|
|
10
|
+
from .....build_components.entities.auxiliary.zero_dimensional.point_defect_site.configuration import (
|
|
11
|
+
PointDefectSiteConfiguration,
|
|
12
|
+
)
|
|
13
|
+
from .....build_components.operations.core.combinations.merge.configuration import MergeConfiguration
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SolidSolutionConfiguration(MergeConfiguration, SolidSolutionConfigurationSchema):
|
|
17
|
+
"""Configuration for building a solid solution by merging substitution sites.
|
|
18
|
+
|
|
19
|
+
Physical specification: crystal, source_element, target_element, concentrations.
|
|
20
|
+
The merge_components hold the resolved substitution sites.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
type: str = "SolidSolutionConfiguration"
|
|
24
|
+
crystal: Union[Material, MaterialWithBuildMetadata]
|
|
25
|
+
source_element: str
|
|
26
|
+
target_element: str
|
|
27
|
+
target_concentration: float
|
|
28
|
+
actual_concentration: float
|
|
29
|
+
merge_components: List[Union[Material, MaterialWithBuildMetadata, PointDefectSiteConfiguration]]
|
|
30
|
+
merge_method: MergeMethodsEnum = MergeMethodsEnum.REPLACE
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from typing import Optional, Union
|
|
2
|
+
|
|
3
|
+
from mat3ra.esse.models.materials_category_components.entities.core.zero_dimensional.atom import AtomSchema
|
|
4
|
+
from mat3ra.esse.models.materials_category_components.operations.core.combinations.merge import MergeMethodsEnum
|
|
5
|
+
from mat3ra.made.material import Material
|
|
6
|
+
|
|
7
|
+
from .....analyze.solid_solution_analyzer import SolidSolutionAnalyzer
|
|
8
|
+
from .....build_components import MaterialWithBuildMetadata
|
|
9
|
+
from .....build_components.entities.auxiliary.zero_dimensional.point_defect_site.configuration import (
|
|
10
|
+
PointDefectSiteConfiguration,
|
|
11
|
+
)
|
|
12
|
+
from .....build_components.entities.reusable.three_dimensional.supercell.helpers import create_supercell
|
|
13
|
+
from .builder import SolidSolutionBuilder
|
|
14
|
+
from .configuration import SolidSolutionConfiguration
|
|
15
|
+
from .enums import SiteSelectionMethodEnum
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def create_solid_solution(
|
|
19
|
+
material: Union[Material, MaterialWithBuildMetadata],
|
|
20
|
+
source_element: str,
|
|
21
|
+
target_element: str,
|
|
22
|
+
concentration: float,
|
|
23
|
+
seed: Optional[int] = None,
|
|
24
|
+
tolerance: float = 0.01,
|
|
25
|
+
max_supercell_cells: int = 128,
|
|
26
|
+
site_selection_method: SiteSelectionMethodEnum = SiteSelectionMethodEnum.UNIFORM,
|
|
27
|
+
) -> MaterialWithBuildMetadata:
|
|
28
|
+
"""
|
|
29
|
+
Create a solid solution by partially substituting one element for another.
|
|
30
|
+
|
|
31
|
+
Automatically determines the optimal supercell size to achieve the target
|
|
32
|
+
concentration, builds the supercell, and performs substitution.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
material (Union[Material, MaterialWithBuildMetadata]): Unit cell crystal.
|
|
36
|
+
source_element (str): Element to partially replace (e.g. "Hf").
|
|
37
|
+
target_element (str): Replacement element (e.g. "Zr").
|
|
38
|
+
concentration (float): Fraction of source_element to replace (0.0-1.0).
|
|
39
|
+
seed (Optional[int]): Random seed for reproducible site selection.
|
|
40
|
+
tolerance (float): Acceptable deviation from target concentration.
|
|
41
|
+
max_supercell_cells (int): Maximum supercell size to search.
|
|
42
|
+
site_selection_method (SiteSelectionMethodEnum): RANDOM or UNIFORM (Farthest Point Sampling).
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
MaterialWithBuildMetadata: Solid solution with full build metadata.
|
|
46
|
+
"""
|
|
47
|
+
analyzer = SolidSolutionAnalyzer(
|
|
48
|
+
material=material,
|
|
49
|
+
source_element=source_element,
|
|
50
|
+
target_element=target_element,
|
|
51
|
+
target_concentration=concentration,
|
|
52
|
+
tolerance=tolerance,
|
|
53
|
+
max_supercell_cells=max_supercell_cells,
|
|
54
|
+
seed=seed,
|
|
55
|
+
site_selection_method=site_selection_method,
|
|
56
|
+
)
|
|
57
|
+
supercell = create_supercell(material, scaling_factor=analyzer.optimal_supercell_dimensions)
|
|
58
|
+
coordinates = supercell.basis.coordinates.values
|
|
59
|
+
site_configurations = [
|
|
60
|
+
PointDefectSiteConfiguration(
|
|
61
|
+
crystal=supercell,
|
|
62
|
+
coordinate=coordinates[site_index],
|
|
63
|
+
element=AtomSchema(chemical_element=target_element),
|
|
64
|
+
)
|
|
65
|
+
for site_index in analyzer.selected_site_indices
|
|
66
|
+
]
|
|
67
|
+
configuration = SolidSolutionConfiguration(
|
|
68
|
+
crystal=material,
|
|
69
|
+
source_element=source_element,
|
|
70
|
+
target_element=target_element,
|
|
71
|
+
target_concentration=concentration,
|
|
72
|
+
actual_concentration=analyzer.actual_concentration,
|
|
73
|
+
merge_components=[supercell] + site_configurations,
|
|
74
|
+
merge_method=MergeMethodsEnum.REPLACE,
|
|
75
|
+
)
|
|
76
|
+
return SolidSolutionBuilder().get_material(configuration)
|
|
@@ -6,13 +6,14 @@ from mat3ra.esse.models.materials_category_components.entities.auxiliary.zero_di
|
|
|
6
6
|
CrystalSiteSchema,
|
|
7
7
|
)
|
|
8
8
|
from mat3ra.made.material import Material
|
|
9
|
+
from pydantic import Field
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
class CrystalSite(CrystalSiteSchema, InMemoryEntityPydantic):
|
|
12
|
-
crystal: Optional[Material] = None
|
|
13
|
+
crystal: Optional[Material] = Field(None, exclude=True)
|
|
13
14
|
# element: str
|
|
14
15
|
coordinate: Optional[List[float]] = None
|
|
15
|
-
nearest_neighbor_vectors: List[np.ndarray] = []
|
|
16
|
+
nearest_neighbor_vectors: List[np.ndarray] = Field([], exclude=True)
|
|
16
17
|
# coordination_number: int = 0
|
|
17
18
|
# see https://www.cryst.ehu.es/cgi-bin/cryst/programs/nph-wp-list for an example
|
|
18
19
|
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
from mat3ra.code.array_with_ids import ArrayWithIds
|
|
2
|
+
from mat3ra.made.basis import Basis, Coordinates
|
|
3
|
+
|
|
1
4
|
from ......build_components import MaterialWithBuildMetadata
|
|
2
5
|
from ..... import BaseSingleBuilder
|
|
3
6
|
from .configuration import PointDefectSiteConfiguration
|
|
@@ -14,18 +17,16 @@ class PointDefectSiteBuilder(BaseSingleBuilder):
|
|
|
14
17
|
if configuration.crystal is None:
|
|
15
18
|
raise ValueError("Crystal configuration is required for PointDefectSiteBuilder")
|
|
16
19
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
crystal = configuration.crystal
|
|
21
|
+
basis = Basis(
|
|
22
|
+
elements=ArrayWithIds(values=[], ids=[]),
|
|
23
|
+
coordinates=Coordinates(values=[], ids=[]),
|
|
24
|
+
units=crystal.basis.units,
|
|
25
|
+
cell=crystal.basis.cell,
|
|
23
26
|
)
|
|
24
|
-
|
|
25
|
-
new_material.basis.remove_atoms_by_elements(elements)
|
|
26
|
-
new_material.basis.add_atom(
|
|
27
|
+
basis.add_atom(
|
|
27
28
|
element=configuration.element.chemical_element,
|
|
28
29
|
coordinate=configuration.coordinate,
|
|
29
30
|
label=configuration.host_atom_label,
|
|
30
31
|
)
|
|
31
|
-
return
|
|
32
|
+
return MaterialWithBuildMetadata(name=crystal.name, lattice=crystal.lattice, basis=basis)
|
|
@@ -10,6 +10,11 @@ class MaterialBuildMetadata(BaseMetadata):
|
|
|
10
10
|
# Other metadata fields can be added as needed
|
|
11
11
|
build: List[BuildMetadata] = Field([])
|
|
12
12
|
|
|
13
|
+
def get_latest_build_metadata(self) -> BuildMetadata:
|
|
14
|
+
if not self.build:
|
|
15
|
+
raise ValueError("No metadata.build on the material.")
|
|
16
|
+
return self.build[-1]
|
|
17
|
+
|
|
13
18
|
def get_build_metadata_of_type(self, configuration_type: str) -> Optional[BuildMetadata]:
|
|
14
19
|
"""
|
|
15
20
|
Returns the first build configuration of the specified type.
|
package/src/py/mat3ra/made/tools/build_components/operations/core/combinations/merge/builder.py
CHANGED
|
@@ -79,6 +79,15 @@ class MergeBuilder(BaseSingleBuilder):
|
|
|
79
79
|
if len(materials) == 1:
|
|
80
80
|
return materials[0]
|
|
81
81
|
|
|
82
|
+
# Optimization: when there are many materials to merge, combine all non-first
|
|
83
|
+
# materials into one before merging with the base to avoid N sequential merges.
|
|
84
|
+
if len(materials) > 2:
|
|
85
|
+
base = materials[0]
|
|
86
|
+
combined = materials[1]
|
|
87
|
+
for m in materials[2:]:
|
|
88
|
+
combined.basis.add_atoms_from_another_basis(m.basis)
|
|
89
|
+
materials = [base, combined]
|
|
90
|
+
|
|
82
91
|
parameters = self.build_parameters or self._DefaultBuildParameters
|
|
83
92
|
merge_method = getattr(configuration, "merge_method", None)
|
|
84
93
|
return self._merge_by_method(materials, parameters, merge_method)
|
|
@@ -2,8 +2,9 @@ from typing import Optional
|
|
|
2
2
|
|
|
3
3
|
from ...material import Material
|
|
4
4
|
from ..analyze.other import get_surface_area
|
|
5
|
-
from ..
|
|
6
|
-
from ..
|
|
5
|
+
from ..analyze.interface_material import get_interface_bulk_crystal
|
|
6
|
+
from ..build.compound_pristine_structures.two_dimensional.interface import get_slab
|
|
7
|
+
from ..build_components.metadata import MaterialWithBuildMetadata
|
|
7
8
|
from ..convert import decorator_convert_material_args_kwargs_to_atoms
|
|
8
9
|
from ..third_party import ASEAtoms, ASECalculator, ASECalculatorEMT
|
|
9
10
|
from .calculators import InterfaceMaterialCalculatorParameters
|
|
@@ -114,19 +115,14 @@ def calculate_interfacial_energy(
|
|
|
114
115
|
substrate_slab = get_slab(interface, part="substrate") if substrate_slab is None else substrate_slab
|
|
115
116
|
film_slab = get_slab(interface, part="film") if film_slab is None else film_slab
|
|
116
117
|
|
|
117
|
-
metadata = (
|
|
118
|
-
interface.metadata
|
|
119
|
-
if isinstance(interface, MaterialWithBuildMetadata)
|
|
120
|
-
else MaterialBuildMetadata(**interface.metadata)
|
|
121
|
-
)
|
|
122
|
-
build_configuration = metadata.build[-1].configuration if metadata.build else {}
|
|
123
|
-
config = InterfaceConfiguration(**build_configuration)
|
|
124
|
-
|
|
125
118
|
try:
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
119
|
+
interface_with_metadata = MaterialWithBuildMetadata.create(interface.to_dict())
|
|
120
|
+
substrate_bulk = Material.create(get_interface_bulk_crystal(interface_with_metadata, part="substrate"))
|
|
121
|
+
film_bulk = Material.create(get_interface_bulk_crystal(interface_with_metadata, part="film"))
|
|
122
|
+
except ValueError as exc:
|
|
123
|
+
raise ValueError(
|
|
124
|
+
"The substrate and film bulk materials must be provided or defined in the interface metadata."
|
|
125
|
+
) from exc
|
|
130
126
|
surface_energy_substrate = calculate_surface_energy(substrate_slab, substrate_bulk, calculator)
|
|
131
127
|
surface_energy_film = calculate_surface_energy(film_slab, film_bulk, calculator)
|
|
132
128
|
adhesion_energy = calculate_adhesion_energy(interface, substrate_slab, film_slab, calculator)
|
|
@@ -42,6 +42,7 @@ from .build.defective_structures.zero_dimensional.point_defect.substitutional.he
|
|
|
42
42
|
)
|
|
43
43
|
from .build.defective_structures.zero_dimensional.point_defect.types import PointDefectDict
|
|
44
44
|
from .build.defective_structures.zero_dimensional.point_defect.vacancy.helpers import create_defect_point_vacancy
|
|
45
|
+
from .build.defective_structures.three_dimensional.solid_solution.helpers import create_solid_solution
|
|
45
46
|
|
|
46
47
|
# Pristine Structures
|
|
47
48
|
from .build.pristine_structures.three_dimensional.ideal_crystal.helpers import create_monolayer
|
|
@@ -117,6 +118,7 @@ __all__ = [
|
|
|
117
118
|
"create_defect_point_substitution",
|
|
118
119
|
"create_defect_point_interstitial",
|
|
119
120
|
"create_multiple_defects",
|
|
121
|
+
"create_solid_solution",
|
|
120
122
|
# Surface Defect Functions
|
|
121
123
|
"create_defect_adatom",
|
|
122
124
|
"create_multiple_adatom_defects",
|
|
@@ -3,7 +3,7 @@ import pytest
|
|
|
3
3
|
|
|
4
4
|
from mat3ra.made.cell.primitive_cell import get_primitive_lattice_vectors_from_config
|
|
5
5
|
from mat3ra.made.lattice import Lattice, LatticeTypeEnum
|
|
6
|
-
from
|
|
6
|
+
from unit.utils import assert_two_entities_deep_almost_equal
|
|
7
7
|
|
|
8
8
|
EXPECTED_PRIMITIVE_CELL_NA4CL4 = [
|
|
9
9
|
[5.691694, 0.0, 0.0],
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from typing import Final, Type
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from mat3ra.made.tools.analyze.build_metadata_analyzer import BuildMetadataAnalyzer
|
|
5
|
+
from mat3ra.made.tools.analyze.build_metadata_analyzer_factory import create_build_metadata_analyzer
|
|
6
|
+
from mat3ra.made.tools.analyze.interface_material import (
|
|
7
|
+
InterfaceMaterialAnalyzer,
|
|
8
|
+
get_interface_bulk_crystal,
|
|
9
|
+
get_interface_configuration,
|
|
10
|
+
)
|
|
11
|
+
from mat3ra.made.tools.analyze.slab import SlabMaterialAnalyzer
|
|
12
|
+
from mat3ra.made.tools.build import MaterialWithBuildMetadata
|
|
13
|
+
from mat3ra.made.tools.build.compound_pristine_structures.two_dimensional.interface import InterfaceConfiguration
|
|
14
|
+
from unit.fixtures.interface.zsl import GRAPHENE_NICKEL_INTERFACE
|
|
15
|
+
from unit.fixtures.slab import SI_CONVENTIONAL_SLAB_001
|
|
16
|
+
|
|
17
|
+
INTERFACE_MATERIAL: Final = MaterialWithBuildMetadata.create(GRAPHENE_NICKEL_INTERFACE)
|
|
18
|
+
SLAB_MATERIAL: Final = MaterialWithBuildMetadata.create(SI_CONVENTIONAL_SLAB_001)
|
|
19
|
+
SUBSTRATE_CRYSTAL_NAME: Final = "Ni, Nickel, FCC (Fm-3m) 3D (Bulk), mp-23"
|
|
20
|
+
FILM_CRYSTAL_NAME: Final = "Graphene"
|
|
21
|
+
|
|
22
|
+
GET_BULK_CRYSTAL_CASES = [
|
|
23
|
+
("substrate", SUBSTRATE_CRYSTAL_NAME),
|
|
24
|
+
("film", FILM_CRYSTAL_NAME),
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
CREATE_BUILD_METADATA_ANALYZER_CASES = [
|
|
28
|
+
(INTERFACE_MATERIAL, InterfaceMaterialAnalyzer),
|
|
29
|
+
(SLAB_MATERIAL, SlabMaterialAnalyzer),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_build_configuration():
|
|
34
|
+
configuration = InterfaceMaterialAnalyzer(material=INTERFACE_MATERIAL).build_configuration
|
|
35
|
+
assert isinstance(configuration, InterfaceConfiguration)
|
|
36
|
+
assert configuration.type == "InterfaceConfiguration"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@pytest.mark.parametrize("part,expected_name", GET_BULK_CRYSTAL_CASES)
|
|
40
|
+
def test_get_bulk_crystal(part, expected_name):
|
|
41
|
+
crystal = InterfaceMaterialAnalyzer(material=INTERFACE_MATERIAL).get_bulk_crystal(part=part)
|
|
42
|
+
assert crystal["name"] == expected_name
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_get_interface_configuration():
|
|
46
|
+
configuration = get_interface_configuration(INTERFACE_MATERIAL)
|
|
47
|
+
assert isinstance(configuration, InterfaceConfiguration)
|
|
48
|
+
assert configuration.type == "InterfaceConfiguration"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@pytest.mark.parametrize("part,expected_name", GET_BULK_CRYSTAL_CASES)
|
|
52
|
+
def test_get_interface_bulk_crystal(part, expected_name):
|
|
53
|
+
crystal = get_interface_bulk_crystal(INTERFACE_MATERIAL, part=part)
|
|
54
|
+
assert crystal["name"] == expected_name
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@pytest.mark.parametrize("material,expected_analyzer_cls", CREATE_BUILD_METADATA_ANALYZER_CASES)
|
|
58
|
+
def test_create_build_metadata_analyzer(
|
|
59
|
+
material: MaterialWithBuildMetadata,
|
|
60
|
+
expected_analyzer_cls: Type[BuildMetadataAnalyzer],
|
|
61
|
+
):
|
|
62
|
+
analyzer = create_build_metadata_analyzer(material)
|
|
63
|
+
assert isinstance(analyzer, expected_analyzer_cls)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pytest
|
|
3
|
+
from mat3ra.made.material import Material
|
|
4
|
+
from mat3ra.made.tools.build.defective_structures.three_dimensional.solid_solution.helpers import (
|
|
5
|
+
create_solid_solution,
|
|
6
|
+
)
|
|
7
|
+
from mat3ra.standata.materials import Materials
|
|
8
|
+
|
|
9
|
+
BULK_HfO2 = Materials.get_by_name_first_match("HfO2")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _min_nearest_neighbor_distance(material, element):
|
|
13
|
+
material.to_crystal()
|
|
14
|
+
coords = np.array(material.basis.coordinates.values)
|
|
15
|
+
lv = np.array(material.lattice.vector_arrays)
|
|
16
|
+
idx = [i for i, e in enumerate(material.basis.elements.values) if e == element]
|
|
17
|
+
sub = coords[idx]
|
|
18
|
+
min_d = np.inf
|
|
19
|
+
for i in range(len(sub)):
|
|
20
|
+
for j in range(i + 1, len(sub)):
|
|
21
|
+
delta = sub[i] - sub[j]
|
|
22
|
+
delta -= np.round(delta)
|
|
23
|
+
min_d = min(min_d, np.linalg.norm(delta @ lv))
|
|
24
|
+
return min_d
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_create_solid_solution_uniform_element_counts():
|
|
28
|
+
material = Material.create(BULK_HfO2)
|
|
29
|
+
result = create_solid_solution(material, "Hf", "Zr", 0.5, seed=42, site_selection_method="uniform")
|
|
30
|
+
elements = result.basis.elements.values
|
|
31
|
+
assert elements == ["Hf", "Hf", "O", "O", "O", "O", "O", "O", "O", "O", "Zr", "Zr"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.mark.parametrize(
|
|
35
|
+
"concentration, expected_zr, expected_hf",
|
|
36
|
+
[
|
|
37
|
+
(0.0, 0, 4),
|
|
38
|
+
(0.25, 1, 3),
|
|
39
|
+
(0.5, 2, 2),
|
|
40
|
+
(1.0, 4, 0),
|
|
41
|
+
],
|
|
42
|
+
)
|
|
43
|
+
def test_create_solid_solution_concentration(concentration, expected_zr, expected_hf):
|
|
44
|
+
material = Material.create(BULK_HfO2)
|
|
45
|
+
result = create_solid_solution(material, "Hf", "Zr", concentration, seed=42, site_selection_method="uniform")
|
|
46
|
+
elements = result.basis.elements.values
|
|
47
|
+
assert elements.count("Zr") == expected_zr
|
|
48
|
+
assert elements.count("Hf") == expected_hf
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@pytest.mark.parametrize(
|
|
52
|
+
"concentration, expected_min_distance",
|
|
53
|
+
[
|
|
54
|
+
(0.1, 4.5),
|
|
55
|
+
(0.2, 3.5),
|
|
56
|
+
(0.33, 3.4),
|
|
57
|
+
],
|
|
58
|
+
)
|
|
59
|
+
def test_create_solid_solution_uniform(concentration, expected_min_distance):
|
|
60
|
+
material = Material.create(BULK_HfO2)
|
|
61
|
+
result = create_solid_solution(
|
|
62
|
+
material,
|
|
63
|
+
"Hf",
|
|
64
|
+
"Zr",
|
|
65
|
+
concentration,
|
|
66
|
+
seed=42,
|
|
67
|
+
site_selection_method="uniform",
|
|
68
|
+
tolerance=0.001,
|
|
69
|
+
)
|
|
70
|
+
assert _min_nearest_neighbor_distance(result, "Zr") > expected_min_distance
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_create_solid_solution_records_target_and_actual_concentration():
|
|
74
|
+
material = Material.create(BULK_HfO2)
|
|
75
|
+
target_concentration = 0.37
|
|
76
|
+
result = create_solid_solution(
|
|
77
|
+
material,
|
|
78
|
+
"Hf",
|
|
79
|
+
"Zr",
|
|
80
|
+
target_concentration,
|
|
81
|
+
seed=42,
|
|
82
|
+
tolerance=0.01,
|
|
83
|
+
site_selection_method="uniform",
|
|
84
|
+
)
|
|
85
|
+
build_step = result.metadata.get_build_metadata_of_type("SolidSolutionConfiguration")
|
|
86
|
+
assert build_step is not None
|
|
87
|
+
configuration = build_step.configuration
|
|
88
|
+
assert configuration["target_concentration"] == target_concentration
|
|
89
|
+
assert configuration["actual_concentration"] == pytest.approx(0.375)
|
|
90
|
+
assert configuration["target_concentration"] != configuration["actual_concentration"]
|
|
@@ -28,6 +28,16 @@ def test_metadata_empty_initialization():
|
|
|
28
28
|
assert metadata.model_dump(exclude_none=True) == {"build": []}
|
|
29
29
|
|
|
30
30
|
|
|
31
|
+
def test_get_latest_build_metadata():
|
|
32
|
+
metadata = MaterialBuildMetadata(build=[BuildMetadata(configuration={"type": "SlabConfiguration"})])
|
|
33
|
+
assert metadata.get_latest_build_metadata().configuration["type"] == "SlabConfiguration"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_get_latest_build_metadata_raises_when_empty():
|
|
37
|
+
with pytest.raises(ValueError, match="No metadata.build"):
|
|
38
|
+
MaterialBuildMetadata().get_latest_build_metadata()
|
|
39
|
+
|
|
40
|
+
|
|
31
41
|
@pytest.mark.parametrize(
|
|
32
42
|
"config_object, expected_dict",
|
|
33
43
|
[
|