@mat3ra/made 2025.12.16-0 → 2026.3.3-0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mat3ra/made",
3
- "version": "2025.12.16-0",
3
+ "version": "2026.3.3-0",
4
4
  "description": "MAterials DEsign library",
5
5
  "scripts": {
6
6
  "lint": "eslint --cache src/js tests/js && prettier --write src/js tests/js",
package/pyproject.toml CHANGED
@@ -21,7 +21,6 @@ dependencies = [
21
21
  "mat3ra-utils",
22
22
  "mat3ra-esse",
23
23
  "mat3ra-code"
24
-
25
24
  ]
26
25
 
27
26
  [project.optional-dependencies]
@@ -31,6 +30,7 @@ tools = [
31
30
  "pymatgen==2024.4.13",
32
31
  "ase",
33
32
  "pymatgen-analysis-defects==2024.4.23",
33
+ "mat3ra-periodic-table>=2025.12.26",
34
34
  ]
35
35
  dev = [
36
36
  "pre-commit",
@@ -2,6 +2,7 @@ from typing import Any, Dict, List, Optional, Union
2
2
 
3
3
  import numpy as np
4
4
  from mat3ra.code.array_with_ids import ArrayWithIds
5
+ from mat3ra.code.constants import HASH_TOLERANCE
5
6
  from mat3ra.code.entity import InMemoryEntityPydantic
6
7
  from mat3ra.esse.models.core.abstract.matrix_3x3 import Matrix3x3Schema
7
8
  from mat3ra.esse.models.material import BasisSchema, BasisUnitsEnum
@@ -87,6 +88,24 @@ class Basis(BasisSchema, InMemoryEntityPydantic):
87
88
  constraints=ArrayWithIds.from_list_of_dicts(constraints),
88
89
  )
89
90
 
91
+ @property
92
+ def hash_string(self) -> str:
93
+ """
94
+ Mirrors JS Basis.hashString (getAsSortedString in crystal units).
95
+ Converts to crystal, applies mod 1 to bring coords into [0,1), builds sorted atom strings.
96
+ """
97
+ original_is_in_cartesian = self.is_in_cartesian_units
98
+ self.to_crystal()
99
+ labels_map = {lbl["id"]: str(lbl["value"]) for lbl in self.labels.to_dict()}
100
+ parts = []
101
+ for elem, coord in zip(self.elements.to_dict(), self.coordinates.to_dict()):
102
+ label = labels_map.get(elem["id"], "")
103
+ rounded = [f"{round(v % 1, HASH_TOLERANCE):g}" for v in coord["value"]]
104
+ parts.append(f"{elem['value']}{label} {','.join(rounded)}")
105
+ if original_is_in_cartesian:
106
+ self.to_cartesian()
107
+ return ";".join(sorted(parts)) + ";"
108
+
90
109
  @property
91
110
  def is_in_crystal_units(self):
92
111
  return self.units == BasisUnitsEnum.crystal
@@ -2,6 +2,7 @@ import math
2
2
  from typing import List, Optional
3
3
 
4
4
  import numpy as np
5
+ from mat3ra.code.constants import HASH_TOLERANCE
5
6
  from mat3ra.code.entity import InMemoryEntityPydantic
6
7
  from mat3ra.esse.models.properties_directory.structural.lattice import (
7
8
  LatticeSchema,
@@ -127,6 +128,12 @@ class Lattice(RoundNumericValuesMixin, LatticeSchemaVectorless, InMemoryEntityPy
127
128
  def cell_volume_rounded(self) -> float:
128
129
  return self.vectors.volume_rounded
129
130
 
131
+ def get_hash_string(self, is_scaled: bool = False) -> str:
132
+ """Mirrors JS Lattice.getHashString(isScaled). Rounds to HASH_TOLERANCE decimal places."""
133
+ scale = self.a if is_scaled else 1
134
+ values = [self.a / scale, self.b / scale, self.c / scale, self.alpha, self.beta, self.gamma]
135
+ return ";".join(f"{round(v, HASH_TOLERANCE):g}" for v in values) + ";"
136
+
130
137
  def get_scaled_by_matrix(self, matrix: List[List[float]]):
131
138
  """
132
139
  Scale the lattice by a matrix.
@@ -1,3 +1,4 @@
1
+ import hashlib
1
2
  from typing import Any, List, Optional, Union
2
3
 
3
4
  from mat3ra.code.constants import AtomicCoordinateUnits, Units
@@ -111,3 +112,17 @@ class Material(MaterialSchema, HasDescriptionHasMetadataNamedDefaultableInMemory
111
112
 
112
113
  def set_labels_from_value(self, value: Union[int, str]) -> None:
113
114
  self.basis.set_labels_from_list([value] * self.basis.number_of_atoms)
115
+
116
+ def calculate_hash(self, salt: str = "", is_scaled: bool = False) -> str:
117
+ """Mirrors JS materialMixin.calculateHash(). MD5 of basis + lattice hash strings."""
118
+ message = f"{self.basis.hash_string}#{self.lattice.get_hash_string(is_scaled)}#{salt}"
119
+ return hashlib.md5(message.encode()).hexdigest()
120
+
121
+ @property
122
+ def hash(self) -> str:
123
+ return self.calculate_hash()
124
+
125
+ @property
126
+ def scaled_hash(self) -> str:
127
+ return self.calculate_hash(is_scaled=True)
128
+
@@ -1,7 +1,15 @@
1
- from .functions import FunctionHolder, PerturbationFunctionHolder, SineWavePerturbationFunctionHolder
1
+ from .functions import (
2
+ AtomicMassDependentFunctionHolder,
3
+ FunctionHolder,
4
+ MaxwellBoltzmannDisplacementHolder,
5
+ PerturbationFunctionHolder,
6
+ SineWavePerturbationFunctionHolder,
7
+ )
2
8
 
3
9
  __all__ = [
10
+ "AtomicMassDependentFunctionHolder",
4
11
  "FunctionHolder",
12
+ "MaxwellBoltzmannDisplacementHolder",
5
13
  "PerturbationFunctionHolder",
6
14
  "SineWavePerturbationFunctionHolder",
7
15
  ]
@@ -1,9 +1,13 @@
1
+ from .atomic_mass_dependent_function_holder import AtomicMassDependentFunctionHolder
1
2
  from .function_holder import FunctionHolder
3
+ from .maxwell_boltzmann import MaxwellBoltzmannDisplacementHolder
2
4
  from .perturbation_function_holder import PerturbationFunctionHolder
3
5
  from .sine_wave_perturbation_function_holder import SineWavePerturbationFunctionHolder
4
6
 
5
7
  __all__ = [
8
+ "AtomicMassDependentFunctionHolder",
6
9
  "FunctionHolder",
10
+ "MaxwellBoltzmannDisplacementHolder",
7
11
  "PerturbationFunctionHolder",
8
12
  "SineWavePerturbationFunctionHolder",
9
13
  ]
@@ -0,0 +1,32 @@
1
+ from typing import Any, List, Optional, Union
2
+
3
+ import sympy as sp
4
+ from mat3ra.periodic_table.helpers import get_atomic_mass_from_element
5
+
6
+ from .function_holder import FunctionHolder
7
+
8
+
9
+ class AtomicMassDependentFunctionHolder(FunctionHolder):
10
+ variables: List[str] = ["x", "y", "z", "m"]
11
+
12
+ def __init__(
13
+ self,
14
+ function: Union[sp.Expr, str] = sp.Symbol("f"),
15
+ variables: Optional[List[str]] = None,
16
+ **data: Any,
17
+ ):
18
+ if variables is None:
19
+ expr = self._to_expr(function)
20
+ vs = sorted(expr.free_symbols, key=lambda s: s.name)
21
+ variables = [str(v) for v in vs] or ["x", "y", "z", "m"]
22
+
23
+ super().__init__(function=function, variables=variables, **data)
24
+
25
+ @staticmethod
26
+ def get_atomic_mass(coordinate: List[float], material) -> float:
27
+ if material is None:
28
+ raise ValueError("Material is required to extract atomic mass")
29
+
30
+ atom_id = material.basis.coordinates.get_element_id_by_value(coordinate)
31
+ element = material.basis.elements.get_element_value_by_index(atom_id)
32
+ return get_atomic_mass_from_element(element)
@@ -52,7 +52,7 @@ class FunctionHolder(InMemoryEntityPydantic):
52
52
  def function_str(self) -> str:
53
53
  return str(self.function)
54
54
 
55
- def apply_function(self, coordinate: List[float]) -> float:
55
+ def apply_function(self, coordinate: List[float], **kwargs: Any) -> float:
56
56
  values = [coordinate[AXIS_TO_INDEX_MAP[var]] for var in self.variables]
57
57
  return self.function_numeric(*values)
58
58
 
@@ -0,0 +1,39 @@
1
+ from typing import Any, List, Optional
2
+
3
+ import numpy as np
4
+ from pydantic import Field, model_validator
5
+
6
+ from .atomic_mass_dependent_function_holder import AtomicMassDependentFunctionHolder
7
+
8
+ DEFAULT_DISORDER_PARAMETER = 1.0
9
+
10
+
11
+ class MaxwellBoltzmannDisplacementHolder(AtomicMassDependentFunctionHolder):
12
+ disorder_parameter: float = Field(
13
+ default=DEFAULT_DISORDER_PARAMETER,
14
+ exclude=True,
15
+ description="Disorder parameter. Can be viewed as effective temperature in eV.",
16
+ )
17
+ random_seed: Optional[int] = Field(default=None, exclude=True)
18
+ random_state: Any = Field(default=None, exclude=True)
19
+ is_mass_used: bool = Field(default=True, exclude=True)
20
+
21
+ @model_validator(mode="after")
22
+ def setup_random_state(self):
23
+ if self.random_state is None:
24
+ self.random_state = np.random.RandomState(self.random_seed) if self.random_seed is not None else np.random
25
+ return self
26
+
27
+ def apply_function(self, coordinate, material=None) -> List[float]:
28
+ if material is None:
29
+ raise ValueError("MaxwellBoltzmannDisplacementHolder requires 'material' kwargs")
30
+
31
+ if self.is_mass_used:
32
+ mass = self.get_atomic_mass(coordinate, material)
33
+ variance = self.disorder_parameter / mass
34
+ else:
35
+ variance = self.disorder_parameter
36
+
37
+ std_dev = np.sqrt(variance)
38
+ displacement = self.random_state.normal(0.0, std_dev, size=3)
39
+ return displacement.tolist()
@@ -1,13 +1,14 @@
1
- from typing import Union
1
+ from typing import Optional, Union
2
2
 
3
3
  import sympy as sp
4
- from mat3ra.made.material import Material
5
4
 
6
- from ..... import MaterialWithBuildMetadata
5
+ from mat3ra.made.material import Material
7
6
  from .builders.base import PerturbationBuilder
8
7
  from .builders.isometric import IsometricPerturbationBuilder
9
8
  from .configuration import PerturbationConfiguration
10
9
  from .functions import PerturbationFunctionHolder
10
+ from .functions.maxwell_boltzmann import MaxwellBoltzmannDisplacementHolder
11
+ from ..... import MaterialWithBuildMetadata
11
12
 
12
13
 
13
14
  def create_perturbation(
@@ -39,3 +40,44 @@ def create_perturbation(
39
40
  else:
40
41
  builder = PerturbationBuilder()
41
42
  return builder.get_material(configuration)
43
+
44
+
45
+ def create_maxwell_displacement(
46
+ material: Union[Material, MaterialWithBuildMetadata],
47
+ disorder_parameter: float,
48
+ random_seed: Optional[int] = None,
49
+ is_mass_used: bool = True,
50
+ ) -> Material:
51
+ """
52
+ Apply Maxwell-Boltzmann random displacements to a material.
53
+
54
+ Generates random 3D displacement vectors where each component follows a normal
55
+ distribution with variance proportional to disorder_parameter/m (if is_mass_used=True)
56
+ or disorder_parameter (if is_mass_used=False), where m is atomic mass.
57
+
58
+ Args:
59
+ material: The material to be perturbed.
60
+ disorder_parameter: Disorder parameter controlling displacement magnitude,
61
+ can be viewed as effective temperature in eV.
62
+ random_seed: Optional random seed for reproducibility for the same material and parameters.
63
+ is_mass_used: If True, displacement variance is disorder_parameter/m (mass-dependent).
64
+ If False, displacement variance is disorder_parameter (mass-independent).
65
+
66
+ Returns:
67
+ Material with applied Maxwell-Boltzmann displacements.
68
+ """
69
+ displacement_holder = MaxwellBoltzmannDisplacementHolder(
70
+ disorder_parameter=disorder_parameter,
71
+ random_seed=random_seed,
72
+ is_mass_used=is_mass_used,
73
+ )
74
+
75
+ configuration = PerturbationConfiguration(
76
+ material=material,
77
+ perturbation_function_holder=displacement_holder,
78
+ use_cartesian_coordinates=True,
79
+ )
80
+
81
+ builder = PerturbationBuilder()
82
+
83
+ return builder.get_material(configuration)
@@ -84,12 +84,11 @@ def perturb(
84
84
  perturbed_coordinates: List[List[float]] = []
85
85
 
86
86
  for coordinate in original_coordinates:
87
- # If func_holder returns a scalar, assume z-axis; otherwise vector
88
- displacement = perturbation_function.apply_function(coordinate)
87
+ displacement = perturbation_function.apply_function(coordinate, material=new_material)
88
+
89
89
  if isinstance(displacement, (list, tuple, np.ndarray)):
90
90
  delta = np.array(displacement)
91
91
  else:
92
- # scalar: apply to z-axis
93
92
  delta = np.array([0.0, 0.0, displacement])
94
93
 
95
94
  new_coordinate = np.array(coordinate) + delta
@@ -1,3 +1,3 @@
1
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:3fbf76a64fe44dccbad8e5cbd625e7b3a0fe3f0496c0fd447622632dca0fde62
3
- size 1733
2
+ oid sha256:43712ea8ada9350751be35d18f0ef92880a095be6536615f86b0c9ffd3b10dae
3
+ size 1835
@@ -1,3 +1,3 @@
1
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:d096e1c2598dee0e809902646b28b38be4e2be1b40a2fc0b40f23fbf8b9f158e
3
- size 1031
2
+ oid sha256:62175309558d4a75da4fd723ccb47dc558cdbb89fa224e61921c4ee2d5b48094
3
+ size 1133
@@ -1,7 +1,7 @@
1
1
  import { expect } from "chai";
2
2
 
3
3
  import { Material } from "../../src/js/material";
4
- import { Na4Cl4, Silicon } from "./fixtures";
4
+ import { 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
@@ -19,4 +19,17 @@ describe("Material", () => {
19
19
  clonedMaterial.setBasis(newBasisXYZ, "xyz", clonedMaterial.Basis.units);
20
20
  expect(clonedMaterial.Basis.elements).to.have.lengthOf(2);
21
21
  });
22
+
23
+ describe("calculateHash", () => {
24
+ [
25
+ { name: "Silicon", fixture: Silicon },
26
+ { name: "Graphene", fixture: Graphene },
27
+ ].forEach(({ name, fixture }) => {
28
+ it(`should match expected hash for ${name}`, () => {
29
+ const material = new Material(fixture);
30
+ expect(material.calculateHash()).to.equal((fixture as any).hash);
31
+ expect(material.scaledHash).to.equal((fixture as any).scaledHash);
32
+ });
33
+ });
34
+ });
22
35
  });
@@ -1,3 +1,6 @@
1
+ import json
2
+ from pathlib import Path
3
+
1
4
  import numpy as np
2
5
  import pytest
3
6
  from mat3ra.made.basis import Basis, Coordinates
@@ -8,6 +11,13 @@ from unit.fixtures.bulk import BULK_Si_PRIMITIVE
8
11
  from unit.fixtures.slab import BULK_Si_CONVENTIONAL
9
12
  from unit.utils import assert_two_entities_deep_almost_equal
10
13
 
14
+ FIXTURES_DIR = Path(__file__).parents[2] / "fixtures"
15
+
16
+
17
+ def load_fixture(name: str) -> dict:
18
+ with open(FIXTURES_DIR / name) as f:
19
+ return json.load(f)
20
+
11
21
 
12
22
  def test_create_default():
13
23
  material = Material.create_default()
@@ -118,3 +128,11 @@ def test_set_labels_from_list(initial_labels, reset_labels, expected_final):
118
128
 
119
129
  assert len(material.basis.labels.values) == len(expected_final)
120
130
  assert material.basis.labels.values == expected_final
131
+
132
+
133
+ @pytest.mark.parametrize("fixture_file", ["si-standata.json", "Graphene.json"])
134
+ def test_calculate_hash(fixture_file):
135
+ fixture = load_fixture(fixture_file)
136
+ material = Material.create(fixture)
137
+ assert material.hash == fixture["hash"]
138
+ assert material.scaled_hash == fixture["scaledHash"]
@@ -0,0 +1,123 @@
1
+ import numpy as np
2
+ import pytest
3
+ from mat3ra.made.material import Material
4
+ from mat3ra.made.tools.build_components.operations.core.modifications.perturb.functions.maxwell_boltzmann import (
5
+ MaxwellBoltzmannDisplacementHolder,
6
+ )
7
+ from mat3ra.made.tools.build_components.operations.core.modifications.perturb.helpers import create_maxwell_displacement
8
+ from mat3ra.made.tools.helpers import create_supercell
9
+ from mat3ra.periodic_table.helpers import get_atomic_mass_from_element
10
+
11
+ from .fixtures.bulk import BULK_Si_PRIMITIVE
12
+ from .fixtures.slab import SI_CONVENTIONAL_SLAB_001
13
+
14
+ DISORDER_PARAMETER = 1.0 # Temperature-like
15
+ RANDOM_SEED = 42
16
+ NUM_SAMPLES_FOR_MSD = 1000
17
+
18
+
19
+ @pytest.mark.parametrize("random_seed", [None, 42, 123])
20
+ def test_maxwell_displacement_deterministic(random_seed):
21
+ material = Material.create(BULK_Si_PRIMITIVE)
22
+ displacement_func1 = MaxwellBoltzmannDisplacementHolder(
23
+ disorder_parameter=DISORDER_PARAMETER, random_seed=random_seed
24
+ )
25
+ displacement_func2 = MaxwellBoltzmannDisplacementHolder(
26
+ disorder_parameter=DISORDER_PARAMETER, random_seed=random_seed
27
+ )
28
+
29
+ coord = [0.0, 0.0, 0.0]
30
+
31
+ if random_seed is not None:
32
+ disp1 = displacement_func1.apply_function(coord, material=material)
33
+ disp2 = displacement_func2.apply_function(coord, material=material)
34
+ assert np.allclose(disp1, disp2)
35
+
36
+ # Different seed should give different results
37
+ displacement_func3 = MaxwellBoltzmannDisplacementHolder(
38
+ disorder_parameter=DISORDER_PARAMETER, random_seed=random_seed + 1
39
+ )
40
+ disp3 = displacement_func3.apply_function(coord, material=material)
41
+ assert not np.allclose(disp1, disp3) or np.allclose(disp1, [0, 0, 0], atol=1e-10)
42
+ else:
43
+ # No seed: different instances should give different results (non-deterministic)
44
+ disp1 = displacement_func1.apply_function(coord, material=material)
45
+ disp2 = displacement_func2.apply_function(coord, material=material)
46
+ assert not np.allclose(disp1, disp2) or np.allclose(disp1, [0, 0, 0], atol=1e-10)
47
+
48
+
49
+ def test_maxwell_displacement_perturb_integration():
50
+ material = Material.create(BULK_Si_PRIMITIVE)
51
+ original_coords = [coord[:] for coord in material.basis.coordinates.values]
52
+
53
+ perturbed_material = create_maxwell_displacement(
54
+ material, disorder_parameter=DISORDER_PARAMETER, random_seed=RANDOM_SEED
55
+ )
56
+
57
+ assert len(perturbed_material.basis.coordinates.values) == len(original_coords)
58
+ for i, (orig, pert) in enumerate(zip(original_coords, perturbed_material.basis.coordinates.values)):
59
+ delta = np.array(pert) - np.array(orig)
60
+ assert np.linalg.norm(delta) > 0 or np.allclose(delta, 0, atol=1e-10)
61
+
62
+
63
+ def test_maxwell_displacement_msd_expectation():
64
+ material = Material.create(BULK_Si_PRIMITIVE)
65
+ si_mass = get_atomic_mass_from_element("Si")
66
+ disorder_parameter = DISORDER_PARAMETER
67
+ expected_variance = disorder_parameter / si_mass
68
+ expected_msd = 3 * expected_variance
69
+
70
+ displacements = []
71
+ coord = [0.0, 0.0, 0.0]
72
+ for _ in range(NUM_SAMPLES_FOR_MSD):
73
+ displacement_func = MaxwellBoltzmannDisplacementHolder(disorder_parameter=disorder_parameter, random_seed=None)
74
+ disp = displacement_func.apply_function(coord, material=material)
75
+ displacements.append(disp)
76
+
77
+ displacements_array = np.array(displacements)
78
+ msd = np.mean(np.sum(displacements_array**2, axis=1))
79
+
80
+ assert abs(msd - expected_msd) / expected_msd < 0.3
81
+
82
+
83
+ @pytest.mark.parametrize(
84
+ "slab_config, temperature_k, random_seed",
85
+ [
86
+ (SI_CONVENTIONAL_SLAB_001, 1300.0, 42),
87
+ (SI_CONVENTIONAL_SLAB_001, 1300.0, 42),
88
+ ],
89
+ )
90
+ def test_maxwell_boltzmann_on_slab(slab_config, temperature_k, random_seed):
91
+ material = Material.create(slab_config)
92
+ material = create_supercell(material, scaling_factor=[4, 4, 1])
93
+ original_coords = [coord[:] for coord in material.basis.coordinates.values]
94
+ original_lattice = material.lattice.vector_arrays.copy()
95
+
96
+ perturbed_material = create_maxwell_displacement(
97
+ material, disorder_parameter=temperature_k, random_seed=random_seed
98
+ )
99
+
100
+ assert len(perturbed_material.basis.coordinates.values) == len(original_coords)
101
+ assert len(perturbed_material.basis.elements.values) == len(material.basis.elements.values)
102
+
103
+ coordinate_changes = []
104
+ for i, (orig, pert) in enumerate(zip(original_coords, perturbed_material.basis.coordinates.values)):
105
+ delta = np.array(pert) - np.array(orig)
106
+ displacement_magnitude = np.linalg.norm(delta)
107
+ coordinate_changes.append(displacement_magnitude)
108
+
109
+ max_displacement = max(coordinate_changes)
110
+ mean_displacement = np.mean(coordinate_changes)
111
+
112
+ assert max_displacement > 0
113
+ assert mean_displacement > 0
114
+
115
+ si_mass = get_atomic_mass_from_element("Si")
116
+ expected_std = np.sqrt(temperature_k / si_mass)
117
+
118
+ assert mean_displacement < 5 * expected_std
119
+
120
+ assert np.allclose(perturbed_material.lattice.vector_arrays, original_lattice, atol=1e-10)
121
+
122
+ for i, element in enumerate(material.basis.elements.values):
123
+ assert perturbed_material.basis.elements.values[i] == element