@mat3ra/made 2026.7.4-0 → 2026.7.13-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.
Files changed (47) hide show
  1. package/dist/js/basis/basis.js +8 -8
  2. package/dist/js/basis/coordinates.js +2 -2
  3. package/dist/js/cell/cell.js +11 -10
  4. package/dist/js/cell/primitive_cell.js +18 -19
  5. package/dist/js/lattice/lattice.js +25 -23
  6. package/dist/js/lattice/reciprocal/lattice_reciprocal.js +16 -16
  7. package/dist/js/parsers/poscar.js +1 -2
  8. package/dist/js/parsers/xyz_combinatorial_basis.js +2 -2
  9. package/dist/js/tools/basis.js +3 -3
  10. package/dist/js/tools/cell.js +3 -3
  11. package/dist/js/tools/material.js +3 -3
  12. package/dist/js/tools/supercell.js +3 -3
  13. package/dist/js/tools/surface.js +14 -12
  14. package/package.json +3 -2
  15. package/pyproject.toml +1 -1
  16. package/src/js/basis/basis.ts +9 -8
  17. package/src/js/basis/coordinates.ts +3 -2
  18. package/src/js/cell/cell.ts +12 -10
  19. package/src/js/cell/primitive_cell.ts +23 -19
  20. package/src/js/lattice/lattice.ts +25 -23
  21. package/src/js/lattice/reciprocal/lattice_reciprocal.ts +19 -18
  22. package/src/js/parsers/poscar.ts +2 -2
  23. package/src/js/parsers/xyz_combinatorial_basis.ts +3 -2
  24. package/src/js/tools/basis.ts +4 -3
  25. package/src/js/tools/cell.ts +4 -3
  26. package/src/js/tools/material.ts +5 -4
  27. package/src/js/tools/supercell.ts +4 -3
  28. package/src/js/tools/surface.ts +18 -12
  29. package/src/py/mat3ra/made/basis/__init__.py +6 -3
  30. package/src/py/mat3ra/made/tools/analyze/solid_solution_analyzer.py +171 -0
  31. package/src/py/mat3ra/made/tools/analyze/utils.py +25 -0
  32. package/src/py/mat3ra/made/tools/build/defective_structures/three_dimensional/__init__.py +1 -0
  33. package/src/py/mat3ra/made/tools/build/defective_structures/three_dimensional/solid_solution/__init__.py +9 -0
  34. package/src/py/mat3ra/made/tools/build/defective_structures/three_dimensional/solid_solution/builder.py +37 -0
  35. package/src/py/mat3ra/made/tools/build/defective_structures/three_dimensional/solid_solution/configuration.py +30 -0
  36. package/src/py/mat3ra/made/tools/build/defective_structures/three_dimensional/solid_solution/enums.py +8 -0
  37. package/src/py/mat3ra/made/tools/build/defective_structures/three_dimensional/solid_solution/helpers.py +76 -0
  38. package/src/py/mat3ra/made/tools/build_components/entities/auxiliary/zero_dimensional/crystal_site/crystal_site.py +3 -2
  39. package/src/py/mat3ra/made/tools/build_components/entities/auxiliary/zero_dimensional/point_defect_site/builder.py +11 -10
  40. package/src/py/mat3ra/made/tools/build_components/operations/core/combinations/merge/builder.py +9 -0
  41. package/src/py/mat3ra/made/tools/helpers.py +2 -0
  42. package/tests/fixtures/hashes.json +3 -0
  43. package/tests/js/cell/cell.ts +1 -1
  44. package/tests/js/fixtures.ts +7 -1
  45. package/tests/js/material.test.ts +8 -1
  46. package/tests/py/unit/test_material.py +10 -0
  47. package/tests/py/unit/test_tools_build_defect/test_solid_solution.py +90 -0
@@ -1,21 +1,21 @@
1
- import { math } from "@mat3ra/code/dist/js/math";
2
1
  import {
3
2
  Coordinate3DSchema,
4
3
  MaterialSchema,
5
4
  Matrix3X3Schema,
6
5
  Vector3DSchema,
7
6
  } from "@mat3ra/esse/dist/js/types";
7
+ import { Utils } from "@mat3ra/utils";
8
8
 
9
9
  import { Cell } from "../cell/cell";
10
10
  import { Lattice } from "../lattice/lattice";
11
11
  import { Material } from "../material";
12
12
  import SupercellTools from "./supercell";
13
13
 
14
- const MULT = math.multiply;
15
- const ADD = math.add;
16
- const DOT = math.product;
14
+ const MULT = Utils.math.multiply;
15
+ const ADD = Utils.math.add;
16
+ const DOT = Utils.math.product;
17
17
  const getMatrixInLeftHandedRepresentation = (matrix: Matrix3X3Schema) => {
18
- return math.det(matrix) < 0 ? MULT(matrix, -1) : matrix;
18
+ return Utils.math.det(matrix) < 0 ? MULT(matrix, -1) : matrix;
19
19
  };
20
20
 
21
21
  /**
@@ -27,10 +27,10 @@ const getMatrixInLeftHandedRepresentation = (matrix: Matrix3X3Schema) => {
27
27
  */
28
28
  function extGCD(a: number, b: number): [number, number] {
29
29
  if (b === 0) return [1, 0];
30
- if (math.mod(a, b) === 0) return [0, 1];
30
+ if (Utils.math.mod(a, b) === 0) return [0, 1];
31
31
 
32
- const [x, y] = extGCD(b, math.mod(a, b));
33
- return [y, x - y * math.floor(a / b)];
32
+ const [x, y] = extGCD(b, Utils.math.mod(a, b));
33
+ return [y, x - y * Utils.math.floor(a / b)];
34
34
  }
35
35
 
36
36
  /**
@@ -46,7 +46,7 @@ function getMillerScalingMatrix(
46
46
  millerIndices: Coordinate3DSchema,
47
47
  tol = 1e-8,
48
48
  ): Matrix3X3Schema {
49
- if (!millerIndices.reduce((a, b) => math.abs(a) + math.abs(b)))
49
+ if (!millerIndices.reduce((a, b) => Utils.math.abs(a) + Utils.math.abs(b)))
50
50
  throw new Error("Miller indices are zeros.");
51
51
 
52
52
  let scalingMatrix;
@@ -94,20 +94,26 @@ function getMillerScalingMatrix(
94
94
  // @ts-ignore
95
95
  const k2 = DOT(ADD(MULT(l, z1), -MULT(k, z2)), z3);
96
96
 
97
- if (math.abs(k2) > tol) {
97
+ // @ts-ignore
98
+ if (Utils.math.abs(k2) > tol) {
98
99
  // For mathjs version 3.20: round(-0.5) = -0
99
100
  // For mathjs version 5.10: round(-0.5) = -1
100
101
  // Here we specify rounding method to Bankers
101
102
  // For Python 3.11: round(-0.5) = 0
103
+ // @ts-ignore - mathjs v12 dot return type
102
104
  const value = k1 / k2;
103
- const roundedValue = math.roundCustom(value, 0, math.RoundingMethod.Bankers);
105
+ const roundedValue = Utils.math.roundCustom(
106
+ value,
107
+ 0,
108
+ Utils.math.RoundingMethod.Bankers,
109
+ );
104
110
  const i = -roundedValue;
105
111
  [p, q] = [p + i * l, q - i * k];
106
112
  }
107
113
 
108
114
  const [a, b] = extGCD(p * k + q * l, h);
109
115
  const c1 = [p * k + q * l, -p * h, -q * h];
110
- const c2 = [0, l, -k].map((c) => math.trunc(c / math.gcd(l, k))); // floor division
116
+ const c2 = [0, l, -k].map((c) => Utils.math.trunc(c / Utils.math.gcd(l, k))); // floor division
111
117
  const c3 = [b, a * p, a * q];
112
118
 
113
119
  scalingMatrix = [c1, c2, c3];
@@ -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
- self.elements.add_items(other_basis.elements.values)
179
- self.coordinates.add_items(other_basis.coordinates.values)
180
- self.labels.add_items(other_basis.labels.values)
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)
@@ -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
@@ -0,0 +1,9 @@
1
+ from .builder import SolidSolutionBuilder
2
+ from .configuration import SolidSolutionConfiguration
3
+ from .enums import SiteSelectionMethodEnum
4
+
5
+ __all__ = [
6
+ "SiteSelectionMethodEnum",
7
+ "SolidSolutionBuilder",
8
+ "SolidSolutionConfiguration",
9
+ ]
@@ -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,8 @@
1
+ from enum import Enum
2
+
3
+
4
+ class SiteSelectionMethodEnum(str, Enum):
5
+ # Selects sites purely at random (seeded for reproducibility).
6
+ RANDOM = "random"
7
+ # Selects sites using Farthest Point Sampling for maximal dispersion under PBC.
8
+ UNIFORM = "uniform"
@@ -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
- new_material = MaterialWithBuildMetadata.create(
18
- {
19
- "name": configuration.crystal.name,
20
- "lattice": configuration.crystal.lattice.to_dict(),
21
- "basis": configuration.crystal.basis.to_dict(),
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
- elements = configuration.crystal.basis.elements.values
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 new_material
32
+ return MaterialWithBuildMetadata(name=crystal.name, lattice=crystal.lattice, basis=basis)
@@ -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)
@@ -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",
@@ -0,0 +1,3 @@
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:823cf1e89c00beb0df0799a86573d038f725f7d7612bab4e9e40df5dab3330d8
3
+ size 132
@@ -41,7 +41,7 @@ const VECTORS_EQUAL_UP_TO_PRECISION_4: Matrix3X3Schema = [
41
41
  [0.0, 0.0, 3.00001],
42
42
  ];
43
43
 
44
- const VECTORS_EQUAL_UP_TO_PRECISION_4_VOLUME = 6.000110000600002;
44
+ const VECTORS_EQUAL_UP_TO_PRECISION_4_VOLUME = 6.000110000600001;
45
45
  const VECTORS_EQUAL_UP_TO_PRECISION_4_VOLUME_ROUNDED_TO_PRECISION_4 = 6.0001;
46
46
  const VECTORS_EQUAL_UP_TO_PRECISION_4_VOLUME_ROUNDED_TO_PRECISION_5 = 6.00011;
47
47
 
@@ -1,14 +1,15 @@
1
1
  import { AtomicConstraintsSchema, BasisSchema, MaterialSchema } from "@mat3ra/esse/dist/js/types";
2
+ import { MaterialStandata } from "@mat3ra/standata";
2
3
  import fs from "fs";
3
4
  import path from "path";
4
5
 
5
- // TODO: use @mat3ra/standata
6
6
  import AsGeBasis_JSON from "../fixtures/AsGe-basis.json";
7
7
  import atomicConstraints_JSON from "../fixtures/atomic-constraints.json";
8
8
  import C2H4_JSON from "../fixtures/C2H4.json";
9
9
  import C2H4Translated_JSON from "../fixtures/C2H4-translated.json";
10
10
  import FeLiSiBasis_JSON from "../fixtures/FeLiSi-basis.json";
11
11
  import FeO_JSON from "../fixtures/FeO.json";
12
+
12
13
  import Ge2Basis_JSON from "../fixtures/Ge2-basis.json";
13
14
  import Graphene_JSON from "../fixtures/Graphene.json";
14
15
  import H2HFinal_JSON from "../fixtures/H2+H-final.json";
@@ -27,6 +28,7 @@ import SiSlab111Gamma120_JSON from "../fixtures/si-slab-111-0.5-vacuum-gamma-120
27
28
  import SiSlab111_JSON from "../fixtures/si-slab-111-0.5-vacuum-ratio.json";
28
29
  import SiSlab111NoVacuum_JSON from "../fixtures/si-slab-111-0-vacuum.json";
29
30
  import Silicon_JSON from "../fixtures/si-standata.json";
31
+
30
32
  import SiSupercell_JSON from "../fixtures/Si-supercell.json";
31
33
  import Si2Basis_JSON from "../fixtures/Si2-basis.json";
32
34
  import Si2BasisRepeated_JSON from "../fixtures/Si2-basis-repeated.json";
@@ -41,6 +43,9 @@ const SiSlab111 = SiSlab111_JSON as unknown as MaterialSchema;
41
43
  const SiSlab111Gamma120 = SiSlab111Gamma120_JSON as unknown as MaterialSchema;
42
44
  const SiSlab111NoVacuum = SiSlab111NoVacuum_JSON as unknown as MaterialSchema;
43
45
  const FeO = FeO_JSON as unknown as MaterialSchema;
46
+ const FeOStandata = MaterialStandata.getRuntimeDataConfigs().find(
47
+ (m: any) => m.name?.includes("FeO"),
48
+ ) as MaterialSchema;
44
49
  const Na4Cl4 = Na4Cl4_JSON as unknown as MaterialSchema;
45
50
  const Na4Cl4Cartesian = Na4Cl4Cartesian_JSON as unknown as MaterialSchema;
46
51
  const C2H4 = C2H4_JSON as unknown as MaterialSchema;
@@ -79,6 +84,7 @@ export {
79
84
  Si2BasisRepeated,
80
85
  // Other
81
86
  FeO,
87
+ FeOStandata,
82
88
  Na4Cl4,
83
89
  Na4Cl4Cartesian,
84
90
  C2H4,
@@ -1,7 +1,7 @@
1
1
  import { expect } from "chai";
2
2
 
3
3
  import { Material } from "../../src/js/material";
4
- import { Graphene, Na4Cl4, Silicon } from "./fixtures";
4
+ import { FeOStandata, Graphene, Na4Cl4, Silicon } from "./fixtures";
5
5
 
6
6
  const newBasisXYZ = `Si 0.000000 0.000000 0.000000
7
7
  Ge 0.250000 0.250000 0.250000
@@ -31,5 +31,12 @@ describe("Material", () => {
31
31
  expect(material.scaledHash).to.equal((fixture as any).scaledHash);
32
32
  });
33
33
  });
34
+
35
+ it("should match expected hash for FeO (standata)", () => {
36
+ const expectedHashes = require("../../fixtures/hashes.json");
37
+ const material = new Material(FeOStandata);
38
+ expect(material.calculateHash()).to.equal(expectedHashes.FeO.hash);
39
+ expect(material.scaledHash).to.equal(expectedHashes.FeO.scaledHash);
40
+ });
34
41
  });
35
42
  });
@@ -140,6 +140,16 @@ def test_calculate_hash(fixture_file):
140
140
  assert material.scaled_hash == fixture["scaledHash"]
141
141
 
142
142
 
143
+ def test_calculate_hash_feo_standata():
144
+ from mat3ra.standata.materials import Materials
145
+
146
+ expected_hashes = load_fixture("hashes.json")["FeO"]
147
+ data = Materials.get_by_name_first_match("FeO")
148
+ material = Material.create(data)
149
+ assert material.hash == expected_hashes["hash"]
150
+ assert material.scaled_hash == expected_hashes["scaledHash"]
151
+
152
+
143
153
  def test_model_dump_includes_hashes():
144
154
  material = Material.create_default()
145
155
  serialized_material = material.model_dump()