@mat3ra/made 2026.4.2-1 → 2026.5.21-1

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.4.2-1",
3
+ "version": "2026.5.21-1",
4
4
  "description": "MAterials DEsign library",
5
5
  "scripts": {
6
6
  "lint": "eslint --cache src/js tests/js && prettier --write src/js tests/js",
@@ -4,7 +4,7 @@ from typing import Any, List, Optional, Union
4
4
  from mat3ra.code.constants import AtomicCoordinateUnits, Units
5
5
  from mat3ra.code.entity import HasDescriptionHasMetadataNamedDefaultableInMemoryEntityPydantic
6
6
  from mat3ra.esse.models.material import MaterialSchema
7
- from pydantic import ConfigDict, SkipValidation
7
+ from pydantic import ConfigDict, SkipValidation, computed_field, field_serializer
8
8
 
9
9
  from .basis import Basis
10
10
  from .lattice import Lattice
@@ -120,10 +120,15 @@ class Material(MaterialSchema, HasDescriptionHasMetadataNamedDefaultableInMemory
120
120
  message = f"{self.basis.hash_string}#{self.lattice.get_hash_string(is_scaled)}#{salt}"
121
121
  return hashlib.md5(message.encode()).hexdigest()
122
122
 
123
+ @computed_field
123
124
  @property
124
125
  def hash(self) -> str:
125
126
  return self.calculate_hash()
126
127
 
128
+ @field_serializer("scaledHash")
129
+ def serialize_scaled_hash(self, _scaled_hash: Optional[str]) -> str:
130
+ return self.scaled_hash
131
+
127
132
  @property
128
133
  def scaled_hash(self) -> str:
129
134
  return self.calculate_hash(is_scaled=True)
@@ -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
  ]
@@ -40,14 +40,6 @@ def create_slab(
40
40
  Returns:
41
41
  Material: The generated slab material.
42
42
  """
43
- material_to_use = crystal
44
-
45
- if use_conventional_cell:
46
- crystal_lattice_planes_analyzer = CrystalLatticePlanesMaterialAnalyzer(
47
- material=crystal, miller_indices=miller_indices
48
- )
49
- material_to_use = crystal_lattice_planes_analyzer.material_with_conventional_lattice
50
-
51
43
  if termination_top is not None:
52
44
  termination_top_formula = termination_top.formula
53
45
  if termination_bottom is not None:
@@ -58,7 +50,7 @@ def create_slab(
58
50
  use_orthogonal_c=use_orthogonal_c,
59
51
  )
60
52
  slab_configuration = SlabConfiguration.from_parameters(
61
- material_or_dict=material_to_use,
53
+ material_or_dict=crystal,
62
54
  miller_indices=miller_indices,
63
55
  number_of_layers=number_of_layers,
64
56
  termination_top_formula=termination_top_formula,
@@ -12,6 +12,8 @@ from unit.fixtures.slab import BULK_Si_CONVENTIONAL
12
12
  from unit.utils import assert_two_entities_deep_almost_equal
13
13
 
14
14
  FIXTURES_DIR = Path(__file__).parents[2] / "fixtures"
15
+ HASH_KEY = "hash"
16
+ SCALED_HASH_KEY = "scaledHash"
15
17
 
16
18
 
17
19
  def load_fixture(name: str) -> dict:
@@ -136,3 +138,11 @@ def test_calculate_hash(fixture_file):
136
138
  material = Material.create(fixture)
137
139
  assert material.hash == fixture["hash"]
138
140
  assert material.scaled_hash == fixture["scaledHash"]
141
+
142
+
143
+ def test_model_dump_includes_hashes():
144
+ material = Material.create_default()
145
+ serialized_material = material.model_dump()
146
+
147
+ assert serialized_material[HASH_KEY] == material.hash
148
+ assert serialized_material[SCALED_HASH_KEY] == material.scaled_hash
@@ -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]
@@ -5,6 +5,7 @@ import pytest
5
5
  from mat3ra.esse.models.core.reusable.axis_enum import AxisEnum
6
6
  from mat3ra.made.material import Material
7
7
  from mat3ra.made.tools.analyze.interface.simple import InterfaceAnalyzer
8
+ from mat3ra.made.tools.analyze.lattice_planes import CrystalLatticePlanesMaterialAnalyzer
8
9
  from mat3ra.made.tools.build import MaterialWithBuildMetadata
9
10
  from mat3ra.made.tools.build.compound_pristine_structures.two_dimensional.interface.base.build_parameters import (
10
11
  InterfaceBuilderParameters,
@@ -39,6 +40,9 @@ from .fixtures.interface.twisted_nanoribbons import TWISTED_INTERFACE_GRAPHENE_G
39
40
  from .fixtures.monolayer import GRAPHENE
40
41
  from .utils import OSPlatform, assert_two_entities_deep_almost_equal
41
42
 
43
+ HASH_KEY = "hash"
44
+ SCALED_HASH_KEY = "scaledHash"
45
+
42
46
  Si_Ge_SIMPLE_INTERFACE_TEST_CASE = (
43
47
  SimpleNamespace(
44
48
  bulk_config=BULK_Si_CONVENTIONAL,
@@ -253,6 +257,29 @@ def test_commensurate_interface_creation(material_config, analyzer_params, direc
253
257
  assert_two_entities_deep_almost_equal(interface, expected_interface, atol=PRECISION)
254
258
 
255
259
 
260
+ def test_create_slab_with_conventional_cell_stores_crystal_hashes_in_metadata():
261
+ miller_indices = (0, 0, 1)
262
+ material = Material.create(BULK_Ni_PRIMITIVE)
263
+ expected_crystal = CrystalLatticePlanesMaterialAnalyzer(
264
+ material=material, miller_indices=miller_indices
265
+ ).material_with_conventional_lattice
266
+
267
+ slab = create_slab(
268
+ crystal=material,
269
+ miller_indices=miller_indices,
270
+ use_conventional_cell=True,
271
+ use_orthogonal_c=True,
272
+ number_of_layers=1,
273
+ vacuum=0.0,
274
+ )
275
+ serialized_slab = slab.model_dump()
276
+ crystal = serialized_slab["metadata"]["build"][-1]["configuration"]["stack_components"][0]["crystal"]
277
+
278
+ assert crystal[HASH_KEY] == expected_crystal.hash
279
+ assert crystal[SCALED_HASH_KEY] == expected_crystal.scaled_hash
280
+ assert "bulkId" not in serialized_slab["metadata"]
281
+
282
+
256
283
  @pytest.mark.parametrize(
257
284
  "interface_config, expected_coordinate_level",
258
285
  [(GRAPHENE_NICKEL_INTERFACE_TOP_HCP, 12.048)],