@mat3ra/made 2026.5.7-0 → 2026.5.21-2

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": "2026.5.7-0",
3
+ "version": "2026.5.21-2",
4
4
  "description": "MAterials DEsign library",
5
5
  "scripts": {
6
6
  "lint": "eslint --cache src/js tests/js && prettier --write src/js tests/js",
@@ -132,6 +132,7 @@ class Basis(BasisSchema, InMemoryEntityPydantic):
132
132
  coordinate: Optional[List[float]] = None,
133
133
  use_cartesian_coordinates: bool = False,
134
134
  force: bool = False,
135
+ label: Optional[Union[int, str]] = None,
135
136
  ):
136
137
  """
137
138
  Add an atom to the basis at a specified coordinate. Check that no other atom is overlapping with it.
@@ -141,6 +142,7 @@ class Basis(BasisSchema, InMemoryEntityPydantic):
141
142
  coordinate (List[float]): Coordinate of the atom to be added.
142
143
  use_cartesian_coordinates (bool): Whether the coordinate is in Cartesian units (or crystal by default).
143
144
  force (bool): Whether to force adding the atom even if it overlaps with another atom.
145
+ label (int|str|None): Per-atom label when the basis already uses labels; omit if the basis has none.
144
146
  """
145
147
  if coordinate is None:
146
148
  coordinate = [0, 0, 0]
@@ -162,6 +164,8 @@ class Basis(BasisSchema, InMemoryEntityPydantic):
162
164
  return
163
165
  self.elements.add_item(element)
164
166
  self.coordinates.add_item(coordinate)
167
+ if label is not None:
168
+ self.labels.add_item(label)
165
169
 
166
170
  def add_atoms_from_another_basis(self, other_basis: "Basis"):
167
171
  """
@@ -4,6 +4,7 @@ from .simple import InterfaceAnalyzer
4
4
  from .twisted_nanoribbons import TwistedNanoribbonsInterfaceAnalyzer
5
5
  from .utils.holders import MatchedSubstrateFilmConfigurationHolder
6
6
  from .zsl import ZSLInterfaceAnalyzer, ZSLMatchHolder
7
+ from .utils import calculate_interfacial_distance_from_rdf
7
8
 
8
9
  __all__ = [
9
10
  "InterfaceAnalyzer",
@@ -15,4 +16,5 @@ __all__ = [
15
16
  "GrainBoundaryPlanarMatchHolder",
16
17
  "TwistedNanoribbonsInterfaceAnalyzer",
17
18
  "MatchedSubstrateFilmConfigurationHolder",
19
+ "calculate_interfacial_distance_from_rdf",
18
20
  ]
@@ -1,5 +1,77 @@
1
+ from typing import Union
2
+
3
+ from mat3ra.made.material import Material
4
+
5
+ from ....build.pristine_structures.two_dimensional.slab import SlabConfiguration
6
+ from ....build_components.entities.reusable.three_dimensional.supercell.helpers import create_supercell
7
+ from ...rdf import RadialDistributionFunction
1
8
  from .holders import MatchedSubstrateFilmConfigurationHolder
2
9
 
10
+
11
+ def calculate_interfacial_distance_from_rdf(
12
+ substrate_material: Union[Material, dict, "SlabConfiguration"],
13
+ film_material: Union[Material, dict, "SlabConfiguration"],
14
+ rdf_cutoff: float = 10.0,
15
+ rdf_bin_size: float = 0.1,
16
+ supercell_size: tuple = (3, 3, 3),
17
+ ) -> float:
18
+ """
19
+ Calculate interfacial distance based on RDF analysis of bulk materials.
20
+
21
+ Creates temporary supercells of substrate and film bulk materials,
22
+ calculates their RDFs to find the first peak (nearest neighbor distance),
23
+ and returns the average of these distances as the initial guess for interfacial distance.
24
+
25
+ Args:
26
+ substrate_material: Material, dict, or SlabConfiguration for the substrate
27
+ film_material: Material, dict, or SlabConfiguration for the film
28
+ rdf_cutoff: Maximum distance for RDF calculation in Angstroms
29
+ rdf_bin_size: Bin size for RDF histogram in Angstroms
30
+ supercell_size: Size of supercell for RDF analysis (default: 3x3x3)
31
+
32
+ Returns:
33
+ float: Calculated interfacial distance in Angstroms
34
+ """
35
+
36
+ if isinstance(substrate_material, SlabConfiguration):
37
+ substrate_bulk = substrate_material.atomic_layers.crystal
38
+ elif isinstance(substrate_material, dict):
39
+ substrate_bulk = Material.create(substrate_material)
40
+ else:
41
+ substrate_bulk = substrate_material
42
+
43
+ if isinstance(film_material, SlabConfiguration):
44
+ film_bulk = film_material.atomic_layers.crystal
45
+ elif isinstance(film_material, dict):
46
+ film_bulk = Material.create(film_material)
47
+ else:
48
+ film_bulk = film_material
49
+
50
+ substrate_supercell = create_supercell(material=substrate_bulk, scaling_factor=list(supercell_size))
51
+
52
+ film_supercell = create_supercell(material=film_bulk, scaling_factor=list(supercell_size))
53
+
54
+ substrate_rdf = RadialDistributionFunction.from_material(
55
+ substrate_supercell,
56
+ cutoff=rdf_cutoff,
57
+ bin_size=rdf_bin_size,
58
+ )
59
+
60
+ film_rdf = RadialDistributionFunction.from_material(
61
+ film_supercell,
62
+ cutoff=rdf_cutoff,
63
+ bin_size=rdf_bin_size,
64
+ )
65
+
66
+ substrate_first_peak = substrate_rdf.first_peak_distance
67
+ film_first_peak = film_rdf.first_peak_distance
68
+
69
+ interfacial_distance = (substrate_first_peak + film_first_peak) / 2.0
70
+
71
+ return interfacial_distance
72
+
73
+
3
74
  __all__ = [
4
75
  "MatchedSubstrateFilmConfigurationHolder",
76
+ "calculate_interfacial_distance_from_rdf",
5
77
  ]
@@ -7,6 +7,7 @@ from mat3ra.esse.models.materials_category_components.entities.core.zero_dimensi
7
7
  from mat3ra.esse.models.materials_category_components.operations.core.combinations.merge import MergeMethodsEnum
8
8
 
9
9
  from mat3ra.made.material import Material
10
+ from mat3ra.made.tools.analyze.other import get_closest_site_id_from_coordinate
10
11
  from ..base.configuration import PointDefectConfiguration
11
12
  from ......build_components import MaterialWithBuildMetadata
12
13
  from ......build_components.entities.auxiliary.zero_dimensional.point_defect_site.configuration import (
@@ -14,6 +15,16 @@ from ......build_components.entities.auxiliary.zero_dimensional.point_defect_sit
14
15
  )
15
16
 
16
17
 
18
+ def _host_atom_label_at_coordinate(
19
+ crystal: Union[Material, MaterialWithBuildMetadata], coordinate: List[float]
20
+ ) -> Union[int, str, None]:
21
+ host_labels = crystal.basis.labels.values
22
+ if not host_labels:
23
+ return None
24
+ site_index = get_closest_site_id_from_coordinate(crystal, coordinate)
25
+ return host_labels[site_index]
26
+
27
+
17
28
  class SubstitutionalDefectConfiguration(PointDefectConfiguration, SubstitutionalPointDefectSchema):
18
29
  type: str = "SubstitutionalDefectConfiguration"
19
30
 
@@ -25,5 +36,6 @@ class SubstitutionalDefectConfiguration(PointDefectConfiguration, Substitutional
25
36
  crystal=crystal,
26
37
  element=AtomSchema(chemical_element=element),
27
38
  coordinate=coordinate,
39
+ host_atom_label=_host_atom_label_at_coordinate(crystal, coordinate),
28
40
  )
29
41
  return cls(merge_components=[crystal, substitution_site], merge_method=MergeMethodsEnum.REPLACE, **kwargs)
@@ -26,5 +26,6 @@ class PointDefectSiteBuilder(BaseSingleBuilder):
26
26
  new_material.basis.add_atom(
27
27
  element=configuration.element.chemical_element,
28
28
  coordinate=configuration.coordinate,
29
+ label=configuration.host_atom_label,
29
30
  )
30
31
  return new_material
@@ -1,4 +1,4 @@
1
- from typing import Union
1
+ from typing import Optional, Union
2
2
 
3
3
  from mat3ra.esse.models.materials_category.defective_structures.two_dimensional.adatom.configuration import (
4
4
  PointDefectSiteSchema,
@@ -11,3 +11,4 @@ from ..crystal_site import CrystalSite
11
11
 
12
12
  class PointDefectSiteConfiguration(CrystalSite, PointDefectSiteSchema):
13
13
  element: Union[VacancySchema, AtomSchema]
14
+ host_atom_label: Optional[Union[int, str]] = None
@@ -3,8 +3,10 @@ from typing import Final
3
3
 
4
4
  import numpy as np
5
5
  import pytest
6
+ from mat3ra.made.material import Material
6
7
  from mat3ra.made.tools.analyze.interface import InterfaceAnalyzer
7
8
  from mat3ra.made.tools.analyze.interface.commensurate import CommensurateLatticeInterfaceAnalyzer
9
+ from mat3ra.made.tools.analyze.interface.utils import calculate_interfacial_distance_from_rdf
8
10
  from mat3ra.made.tools.build.pristine_structures.two_dimensional.slab import SlabConfiguration
9
11
  from unit.fixtures.bulk import BULK_GRAPHENE, BULK_Ge_CONVENTIONAL, BULK_Si_CONVENTIONAL
10
12
 
@@ -195,3 +197,27 @@ def test_optimal_supercell_functions(substrate, film, expected_n, expected_m):
195
197
 
196
198
  assert optimal_n == expected_n
197
199
  assert optimal_m == expected_m
200
+
201
+
202
+ @pytest.mark.parametrize(
203
+ "substrate_config, film_config, expected_distance_range",
204
+ [
205
+ (BULK_Si_CONVENTIONAL, BULK_Si_CONVENTIONAL, (3.8, 3.9)),
206
+ (BULK_Si_CONVENTIONAL, BULK_Ge_CONVENTIONAL, (3.1, 3.2)),
207
+ ],
208
+ )
209
+ def test_calculate_interfacial_distance_from_rdf(substrate_config, film_config, expected_distance_range):
210
+ """Test RDF-based interfacial distance calculation with different material types."""
211
+ substrate_material = Material.create(substrate_config)
212
+ film_material = Material.create(film_config)
213
+
214
+ distance = calculate_interfacial_distance_from_rdf(
215
+ substrate_material=substrate_material,
216
+ film_material=film_material,
217
+ rdf_cutoff=10.0,
218
+ rdf_bin_size=0.1,
219
+ supercell_size=(3, 3, 3),
220
+ )
221
+
222
+ assert isinstance(distance, float)
223
+ assert expected_distance_range[0] <= distance <= expected_distance_range[1]
@@ -16,6 +16,7 @@ from mat3ra.made.tools.build.defective_structures.zero_dimensional.point_defect.
16
16
  )
17
17
  from mat3ra.made.tools.build.defective_structures.zero_dimensional.point_defect.types import PointDefectDict
18
18
  from unit.fixtures.bulk import BULK_Si_CONVENTIONAL, BULK_Si_PRIMITIVE
19
+ from unit.fixtures.interface.zsl import GRAPHENE_NICKEL_INTERFACE
19
20
  from unit.fixtures.point_defects import (
20
21
  INTERSTITIAL_DEFECT_BULK_PRIMITIVE_Si,
21
22
  INTERSTITIAL_VORONOI_DEFECT_BULK_PRIMITIVE_Si,
@@ -25,6 +26,19 @@ from unit.fixtures.point_defects import (
25
26
  )
26
27
  from unit.utils import assert_two_entities_deep_almost_equal, get_platform_specific_value
27
28
 
29
+ FILM_LABEL = 1
30
+ SUBSTRATE_LABEL = 0
31
+ SUBSTITUTION_ELEMENT = "Ge"
32
+ INTERFACE_SUBSTITUTION_PLACEMENT_METHOD = SubstitutionPlacementMethodEnum.CLOSEST_SITE.value
33
+
34
+
35
+ def _film_atom_coordinate(material: Material) -> list:
36
+ labels_by_id = {entry["id"]: entry["value"] for entry in material.basis.labels.to_dict()}
37
+ for coordinate_entry in material.basis.coordinates.to_dict():
38
+ if labels_by_id.get(coordinate_entry["id"]) == FILM_LABEL:
39
+ return coordinate_entry["value"]
40
+ raise ValueError("No film atom found in fixture material")
41
+
28
42
 
29
43
  @pytest.mark.parametrize(
30
44
  "material_config, defect_params, expected_material_config",
@@ -91,6 +105,25 @@ def test_point_defect_helpers(material_config, defect_params, expected_material_
91
105
  assert_two_entities_deep_almost_equal(defect, expected_material_config)
92
106
 
93
107
 
108
+ @pytest.mark.parametrize(
109
+ "expected_label_count, expected_unique_labels",
110
+ [(5, {SUBSTRATE_LABEL, FILM_LABEL})],
111
+ )
112
+ def test_create_defect_point_substitution_preserves_interface_labels(
113
+ expected_label_count, expected_unique_labels
114
+ ):
115
+ material = Material.create(GRAPHENE_NICKEL_INTERFACE)
116
+ coordinate = _film_atom_coordinate(material)
117
+
118
+ defect_material = create_defect_point_substitution(
119
+ material, coordinate, SUBSTITUTION_ELEMENT, INTERFACE_SUBSTITUTION_PLACEMENT_METHOD
120
+ )
121
+
122
+ assert len(defect_material.basis.elements.values) == expected_label_count
123
+ assert len(defect_material.basis.labels.values) == expected_label_count
124
+ assert set(defect_material.basis.labels.values) == expected_unique_labels
125
+
126
+
94
127
  @pytest.mark.parametrize(
95
128
  "material_config, defect_params_list, expected_material_config",
96
129
  [