@mat3ra/made 2025.11.4-0 → 2025.11.22-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.
@@ -108,6 +108,14 @@ export declare function materialMixin<T extends Base = Base>(item: T): {
108
108
  * Calculates hash from basis and lattice as above + scales lattice properties to make lattice.a = 1
109
109
  */
110
110
  readonly scaledHash: string;
111
+ external: {
112
+ id: string | number;
113
+ source: string;
114
+ origin: boolean;
115
+ data?: {} | undefined;
116
+ doi?: string | undefined;
117
+ url?: string | undefined;
118
+ } | undefined;
111
119
  /**
112
120
  * Converts basis to crystal/fractional coordinates.
113
121
  */
@@ -232,6 +232,12 @@ function materialMixin(item) {
232
232
  get scaledHash() {
233
233
  return this.calculateHash("", true);
234
234
  },
235
+ get external() {
236
+ return item.prop("external");
237
+ },
238
+ set external(external) {
239
+ item.setProp("external", external);
240
+ },
235
241
  /**
236
242
  * Converts basis to crystal/fractional coordinates.
237
243
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mat3ra/made",
3
- "version": "2025.11.4-0",
3
+ "version": "2025.11.22-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
@@ -90,7 +90,8 @@ extend-exclude = '''
90
90
  # Exclude a variety of commonly ignored directories.
91
91
  extend-exclude = [
92
92
  "src/js",
93
- "tests/fixtures"
93
+ "tests/fixtures",
94
+ "tests/py/unit/fixtures"
94
95
  ]
95
96
  line-length = 120
96
97
  target-version = "py38"
@@ -290,6 +290,13 @@ export function materialMixin<T extends Base = Base>(item: T) {
290
290
  return this.calculateHash("", true);
291
291
  },
292
292
 
293
+ get external() {
294
+ return item.prop<MaterialSchema["external"]>("external");
295
+ },
296
+ set external(external: MaterialSchema["external"]) {
297
+ item.setProp("external", external);
298
+ },
299
+
293
300
  /**
294
301
  * Converts basis to crystal/fractional coordinates.
295
302
  */
@@ -1,4 +1,3 @@
1
1
  from .analyzer import BasisMaterialAnalyzer
2
- from .fingerprint import LayeredFingerprintAlongAxis
3
2
 
4
- __all__ = ["BasisMaterialAnalyzer", "LayeredFingerprintAlongAxis"]
3
+ __all__ = ["BasisMaterialAnalyzer"]
@@ -1,9 +1,8 @@
1
1
  from mat3ra.esse.models.core.reusable.axis_enum import AxisEnum
2
2
  from mat3ra.made.utils import AXIS_TO_INDEX_MAP
3
3
 
4
- from ...build_components.metadata import MaterialWithBuildMetadata
5
4
  from .. import BaseMaterialAnalyzer
6
- from .fingerprint import LayeredFingerprintAlongAxis, LayerFingerprint
5
+ from ..fingerprint import LayeredFingerprintAlongAxis, UniqueElementStringsPerLayer
7
6
 
8
7
 
9
8
  class BasisMaterialAnalyzer(BaseMaterialAnalyzer):
@@ -43,40 +42,9 @@ class BasisMaterialAnalyzer(BaseMaterialAnalyzer):
43
42
  layer_elements.append(elements[i])
44
43
 
45
44
  unique_elements = sorted(list(set(layer_elements))) if layer_elements else []
46
- layer = LayerFingerprint(min_coord=layer_min, max_coord=layer_max, elements=unique_elements)
45
+ layer = UniqueElementStringsPerLayer(min_coord=layer_min, max_coord=layer_max, elements=unique_elements)
47
46
  fingerprint.layers.append(layer)
48
47
 
49
48
  current_coord += layer_thickness
50
49
 
51
50
  return fingerprint
52
-
53
- def is_orientation_flipped(
54
- self, original_material: MaterialWithBuildMetadata, layer_thickness: float = 1.0
55
- ) -> bool:
56
- """
57
- Detect if the material orientation is flipped compared to the original.
58
- Uses Jaccard similarity to compare fingerprints in normal and flipped orientations.
59
-
60
- Args:
61
- original_material: The original material before primitivization
62
- layer_thickness: Thickness of layers for fingerprint comparison
63
-
64
- Returns:
65
- bool: True if orientation is flipped, False otherwise
66
- """
67
- original_analyzer = BasisMaterialAnalyzer(material=original_material)
68
- original_fingerprint = original_analyzer.get_layer_fingerprint(layer_thickness)
69
- current_fingerprint = self.get_layer_fingerprint(layer_thickness)
70
-
71
- normal_score = original_fingerprint.get_similarity_score(current_fingerprint)
72
- flipped_score = original_fingerprint.get_similarity_score(self._reverse_fingerprint(current_fingerprint))
73
-
74
- # If flipped orientation has significantly higher similarity, material is flipped
75
- threshold = 0.1
76
- return flipped_score > normal_score + threshold
77
-
78
- def _reverse_fingerprint(self, fingerprint: LayeredFingerprintAlongAxis) -> LayeredFingerprintAlongAxis:
79
- reversed_layers = list(reversed(fingerprint.layers))
80
- return LayeredFingerprintAlongAxis(
81
- layers=reversed_layers, axis=fingerprint.axis, layer_thickness=fingerprint.layer_thickness
82
- )
@@ -0,0 +1,33 @@
1
+ POSSIBLE_TRANSFORMATION_MATRICES = [
2
+ [[1, 0, 0], [0, 1, 0], [0, 0, 1]], # Identity - no transformation
3
+ # Direct swaps: new[i] = old[j]
4
+ [[0, 1, 0], [1, 0, 0], [0, 0, 1]],
5
+ [[0, 0, 1], [0, 1, 0], [1, 0, 0]],
6
+ [[1, 0, 0], [0, 0, 1], [0, 1, 0]],
7
+ # Swaps with sign flips
8
+ [[0, 0, 1], [1, 0, 0], [0, -1, 0]],
9
+ [[0, 0, -1], [1, 0, 0], [0, 1, 0]],
10
+ [[0, 1, 0], [0, 0, 1], [-1, 0, 0]],
11
+ [[0, -1, 0], [0, 0, 1], [1, 0, 0]],
12
+ # Inverted swaps
13
+ [[0, -1, 0], [-1, 0, 0], [0, 0, 1]],
14
+ [[0, 0, -1], [0, 1, 0], [-1, 0, 0]],
15
+ [[1, 0, 0], [0, 0, -1], [0, -1, 0]],
16
+ # Rotations around x-axis: -90 and +90 degrees
17
+ [[1, 0, 0], [0, 0, 1], [0, -1, 0]], # -90° around x
18
+ [[1, 0, 0], [0, 0, -1], [0, 1, 0]], # +90° around x
19
+ # Rotations around y-axis: -90 and +90 degrees
20
+ [[0, 0, -1], [0, 1, 0], [1, 0, 0]], # -90° around y
21
+ [[0, 0, 1], [0, 1, 0], [-1, 0, 0]], # +90° around y
22
+ # Rotations around z-axis: -90 and +90 degrees
23
+ [[0, 1, 0], [-1, 0, 0], [0, 0, 1]], # -90° around z
24
+ [[0, -1, 0], [1, 0, 0], [0, 0, 1]], # +90° around z
25
+ # 180° rotations around axes
26
+ [[1, 0, 0], [0, -1, 0], [0, 0, -1]], # 180° around x
27
+ [[-1, 0, 0], [0, 1, 0], [0, 0, -1]], # 180° around y
28
+ [[-1, 0, 0], [0, -1, 0], [0, 0, 1]], # 180° around z
29
+ # Mirrors (reflections)
30
+ [[1, 0, 0], [0, 1, 0], [0, 0, -1]], # Mirror along z
31
+ [[1, 0, 0], [0, -1, 0], [0, 0, 1]], # Mirror along y
32
+ [[-1, 0, 0], [0, 1, 0], [0, 0, 1]], # Mirror along x
33
+ ]
@@ -0,0 +1,4 @@
1
+ from mat3ra.made.tools.analyze.fingerprint.layers.unique_element_string_per_layer import UniqueElementStringsPerLayer
2
+ from mat3ra.made.tools.analyze.fingerprint.layers.layered_fingerprint_along_axis import LayeredFingerprintAlongAxis
3
+
4
+ __all__ = ["UniqueElementStringsPerLayer", "LayeredFingerprintAlongAxis"]
@@ -0,0 +1,84 @@
1
+ from itertools import cycle, islice
2
+ from typing import List
3
+
4
+ from mat3ra.utils.array import jaccard_similarity_for_strings
5
+ from mat3ra.esse.models.core.reusable.axis_enum import AxisEnum
6
+ from pydantic import BaseModel, Field
7
+
8
+ from mat3ra.made.tools.analyze.fingerprint.layers.unique_element_string_per_layer import UniqueElementStringsPerLayer
9
+
10
+
11
+ class LayeredFingerprintAlongAxis(BaseModel):
12
+ layers: List[UniqueElementStringsPerLayer] = Field(default_factory=list, description="List of layer fingerprints")
13
+ axis: AxisEnum = Field(default=AxisEnum.z, description="Axis along which the fingerprint is computed")
14
+ layer_thickness: float = Field(default=1.0, gt=0, description="Thickness of each layer in Angstroms")
15
+
16
+ @property
17
+ def non_empty_layers(self) -> List[UniqueElementStringsPerLayer]:
18
+ return [layer for layer in self.layers if layer.elements]
19
+
20
+ @property
21
+ def element_sequence(self) -> List[List[str]]:
22
+ return [layer.elements for layer in self.layers]
23
+
24
+ def get_similarity_score(self, other: "LayeredFingerprintAlongAxis") -> float:
25
+ """
26
+ Calculate Jaccard similarity score between this and another fingerprint.
27
+
28
+ The Jaccard similarity coefficient measures the similarity between two sets by comparing
29
+ the size of their intersection to the size of their union: J(A, B) = |A ∩ B| / |A ∪ B|.
30
+ In this implementation, we compare the sets of chemical elements in corresponding layers
31
+ between two fingerprints. For example, if layer 1 contains {Si, O} and the corresponding
32
+ layer contains {Si, Ge}, the Jaccard score is 1/3 (one common element divided by three
33
+ unique elements total). The final score is the average across all layers, providing a
34
+ measure of compositional similarity along the axis (0.0 = completely different,
35
+ 1.0 = identical composition in all layers).
36
+
37
+ Args:
38
+ other: Another LayeredFingerprintAlongAxis to compare with
39
+
40
+ Returns:
41
+ float: Average Jaccard similarity score (0.0 to 1.0). Returns 0.0 if the number of layers doesn't match.
42
+ """
43
+ if not self.layers or not other.layers:
44
+ return 0.0
45
+ if len(self.layers) != len(other.layers):
46
+ return 0.0
47
+
48
+ seq1, seq2 = self.element_sequence, other.element_sequence
49
+ return sum(jaccard_similarity_for_strings(a, b) for a, b in zip(seq1, seq2)) / len(seq1)
50
+
51
+ def get_similarity_score_ignore_periodicity(self, other: "LayeredFingerprintAlongAxis") -> float:
52
+ """
53
+ Calculate Jaccard similarity score ignoring periodicity differences.
54
+
55
+ Handles cases where one fingerprint is a periodic repetition of another.
56
+ For example, if self has 6 layers and other has 2 layers, this method checks if
57
+ self.layers is a 3× repetition of other.layers (i.e., layers 0–1 match other,
58
+ layers 2–3 match other, etc.).
59
+
60
+ Args:
61
+ other: Another LayeredFingerprintAlongAxis to compare with
62
+
63
+ Returns:
64
+ float: Average Jaccard similarity score (0.0 to 1.0). Returns 0.0 if the number
65
+ of layers is not a multiple relationship or if fingerprints don't match.
66
+ """
67
+ if not self.layers or not other.layers:
68
+ return 0.0
69
+
70
+ len_a, len_b = len(self.layers), len(other.layers)
71
+ if len_a == len_b:
72
+ return self.get_similarity_score(other)
73
+
74
+ # Identify shorter/longer and ensure multiplicity
75
+ if len_a < len_b:
76
+ short_seq, long_seq = self.element_sequence, other.element_sequence
77
+ else:
78
+ short_seq, long_seq = other.element_sequence, self.element_sequence
79
+
80
+ if len(long_seq) % len(short_seq) != 0:
81
+ return 0.0
82
+
83
+ cycled_short = islice(cycle(short_seq), len(long_seq))
84
+ return sum(jaccard_similarity_for_strings(a, b) for a, b in zip(cycled_short, long_seq)) / len(short_seq)
@@ -0,0 +1,9 @@
1
+ from typing import List
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ class UniqueElementStringsPerLayer(BaseModel):
7
+ min_coord: float = Field(..., description="Minimum coordinate value for the layer")
8
+ max_coord: float = Field(..., description="Maximum coordinate value for the layer")
9
+ elements: List[str] = Field(default_factory=list, description="Sorted unique chemical elements in the layer")
@@ -1,10 +1,9 @@
1
- from ....lattice import LatticeTypeEnum
1
+ from .. import BaseMaterialAnalyzer
2
+ from ..lattice_swap_analyzer import MaterialLatticeSwapAnalyzer
2
3
  from ...build_components.metadata import MaterialWithBuildMetadata
3
4
  from ...convert import from_pymatgen, to_pymatgen
4
- from ...operations.core.unary import rotate
5
5
  from ...third_party import PymatgenSpacegroupAnalyzer
6
- from .. import BaseMaterialAnalyzer
7
- from ..basis import BasisMaterialAnalyzer
6
+ from ....lattice import LatticeTypeEnum
8
7
 
9
8
 
10
9
  class LatticeMaterialAnalyzer(BaseMaterialAnalyzer):
@@ -88,15 +87,20 @@ class LatticeMaterialAnalyzer(BaseMaterialAnalyzer):
88
87
  )
89
88
 
90
89
  def get_material_with_primitive_lattice_standard(
91
- self, return_original_if_not_reduced: bool = False, keep_orientation: bool = True, layer_thickness: float = 1.0
90
+ self,
91
+ return_original_if_not_reduced: bool = False,
92
+ keep_orientation: bool = True,
93
+ layer_thickness: float = 1.0,
94
+ rotation_detection_threshold: float = 0.05,
92
95
  ) -> MaterialWithBuildMetadata:
93
96
  """
94
- Get material with primitive lattice and optional orientation correction to be standardized.
97
+ Get material with primitive lattice standardized according to IUCr conventions.
95
98
 
96
99
  Args:
97
100
  return_original_if_not_reduced: If True, return original material when no reduction occurs
98
- keep_orientation: If True, correct orientation after primitive conversion
99
- layer_thickness: Thickness of layers for orientation detection
101
+ keep_orientation: If True, detect and reverse lattice parameter swaps to preserve original orientation
102
+ layer_thickness: Unused (kept for compatibility)
103
+ rotation_detection_threshold: Unused (kept for compatibility)
100
104
 
101
105
  Returns:
102
106
  MaterialWithBuildMetadata: Material with primitive lattice
@@ -110,11 +114,11 @@ class LatticeMaterialAnalyzer(BaseMaterialAnalyzer):
110
114
  return self.material
111
115
 
112
116
  if keep_orientation:
113
- basis_analyzer = BasisMaterialAnalyzer(material=material_with_primitive_lattice)
114
- if basis_analyzer.is_orientation_flipped(self.material, layer_thickness):
115
- material_with_primitive_lattice = rotate(
116
- material_with_primitive_lattice, axis=[1, 0, 0], angle=180, rotate_cell=False
117
- )
118
- print("Orientation corrected after primitive conversion.")
117
+ swap_analyzer = MaterialLatticeSwapAnalyzer(material=material_with_primitive_lattice)
118
+ material_with_primitive_lattice = swap_analyzer.get_corrected_material(
119
+ self.material, layer_thickness=layer_thickness, threshold=rotation_detection_threshold
120
+ )
121
+
122
+ material_with_primitive_lattice.metadata = self.material.metadata
119
123
 
120
124
  return material_with_primitive_lattice
@@ -0,0 +1,104 @@
1
+ from typing import Union
2
+
3
+ import numpy as np
4
+ from mat3ra.esse.models.core.abstract.matrix_3x3 import Matrix3x3Schema
5
+ from pydantic import BaseModel, Field
6
+
7
+ from mat3ra.made.material import Material
8
+ from .basis import BasisMaterialAnalyzer
9
+ from .enums import POSSIBLE_TRANSFORMATION_MATRICES
10
+ from ..build_components.metadata.material_with_build_metadata import MaterialWithBuildMetadata
11
+ from ..operations.reusable.unary import transform_material_by_matrix
12
+
13
+
14
+ class LatticeSwapDetectionResult(BaseModel):
15
+ model_config = {"arbitrary_types_allowed": True}
16
+ is_swapped: bool = Field(..., description="Whether a lattice swap was detected")
17
+ permutation: Matrix3x3Schema = Field(..., description="Transformation matrix representing the swap")
18
+ confidence: float = Field(..., description="Confidence score (0.0 to 1.0)")
19
+
20
+
21
+ class MaterialLatticeSwapAnalyzer(BaseModel):
22
+ """
23
+ Analyzer to detect lattice vector swaps/permutations between materials.
24
+
25
+ This detects when lattice vectors have been reoriented (e.g., a->a, b->c, c->-b)
26
+ rather than the basis being rotated within the lattice.
27
+ """
28
+
29
+ material: Union[Material, MaterialWithBuildMetadata]
30
+ tolerance: float = 0.01
31
+
32
+ def _compute_transformation_score(self, matrix: np.ndarray, target_fingerprint) -> float:
33
+ transformed_material = transform_material_by_matrix(self.material, matrix)
34
+ new_analyzer = BasisMaterialAnalyzer(material=transformed_material)
35
+ new_fingerprint = new_analyzer.get_layer_fingerprint(target_fingerprint.layer_thickness)
36
+ return target_fingerprint.get_similarity_score(new_fingerprint)
37
+
38
+ def _create_detection_result(self, matrix: np.ndarray, score: float) -> LatticeSwapDetectionResult:
39
+ is_identity = np.allclose(matrix, np.eye(3), atol=self.tolerance)
40
+ return LatticeSwapDetectionResult(
41
+ is_swapped=not is_identity,
42
+ permutation=Matrix3x3Schema(root=matrix.tolist()),
43
+ confidence=score,
44
+ )
45
+
46
+ def detect_swap_from_original(
47
+ self, original_material: MaterialWithBuildMetadata, layer_thickness: float = 1.0, threshold: float = 0.1
48
+ ) -> LatticeSwapDetectionResult:
49
+ """
50
+ Detect lattice swap from the original material.
51
+
52
+ We apply every transformation matrix to lattice and basis, compute the fingerprint along z, then compare
53
+ to the original material's fingerprint. The best matching transformation (if above threshold) is considered
54
+ a detected swap.
55
+
56
+
57
+ Args:
58
+ original_material: The original material before transformation
59
+ layer_thickness: Thickness of layers for fingerprint comparison
60
+ threshold: Minimum improvement threshold to consider a swap detected
61
+
62
+ Returns:
63
+ LatticeSwapDetectionResult: Swap detection results with permutation and new lattice
64
+ """
65
+ target_analyzer = BasisMaterialAnalyzer(material=original_material)
66
+ target_fingerprint = target_analyzer.get_layer_fingerprint(layer_thickness)
67
+ possible_matrices = list(map(np.array, POSSIBLE_TRANSFORMATION_MATRICES))
68
+
69
+ best_match = None
70
+ max_score = 0
71
+
72
+ for matrix in possible_matrices:
73
+ score = self._compute_transformation_score(matrix, target_fingerprint)
74
+ if score > max_score:
75
+ best_match = self._create_detection_result(matrix, score)
76
+ max_score = score
77
+
78
+ if best_match and best_match.is_swapped and best_match.confidence >= threshold:
79
+ return best_match
80
+ return best_match
81
+
82
+ def get_corrected_material(
83
+ self,
84
+ target: MaterialWithBuildMetadata,
85
+ layer_thickness: float = 1.0,
86
+ threshold: float = 0.1,
87
+ ) -> MaterialWithBuildMetadata:
88
+ """
89
+ Correct the material's lattice to match the target material's orientation if a swap is detected.
90
+
91
+ Args:
92
+ target: The target material to match
93
+ layer_thickness: Thickness of layers for fingerprint comparison
94
+ threshold: Minimum improvement threshold to consider a swap detected
95
+
96
+ Returns:
97
+ MaterialWithBuildMetadata: Corrected material with lattice matching the target orientation
98
+ """
99
+ swap_info = self.detect_swap_from_original(target, layer_thickness, threshold)
100
+ if swap_info.is_swapped:
101
+ matrix_list = [list(row.root) if hasattr(row, "root") else row for row in swap_info.permutation.root]
102
+ matrix_array = np.array(matrix_list)
103
+ return transform_material_by_matrix(self.material, matrix_array)
104
+ return self.material
@@ -101,23 +101,24 @@ def perturb(
101
101
  return new_material
102
102
 
103
103
 
104
- def rotate(material: Material, axis: List[int], angle: float, wrap: bool = True, rotate_cell=False) -> Material:
104
+ def rotate(material: Material, axis: List[int], angle: float, wrap: bool = True) -> Material:
105
105
  """
106
- Rotate the material around a given axis by a specified angle.
106
+ Rotate the basis of the material relative to the lattice.
107
+ This operation breaks symmetry and does not modify lattice vectors.
107
108
 
108
109
  Args:
109
110
  material (Material): The material to rotate.
110
111
  axis (List[int]): The axis to rotate around, expressed as [x, y, z].
111
112
  angle (float): The angle of rotation in degrees.
112
113
  wrap (bool): Whether to wrap the material to the unit cell.
113
- rotate_cell (bool): Whether to rotate the cell.
114
114
  Returns:
115
115
  Atoms: The rotated material.
116
116
  """
117
- original_is_in_cartesian_units = material.basis.is_in_cartesian_units
118
- material.to_crystal()
119
- atoms = to_ase(material)
120
- atoms.rotate(v=axis, a=angle, center="COU", rotate_cell=rotate_cell)
117
+ new_material = material.clone()
118
+ original_is_in_cartesian_units = new_material.basis.is_in_cartesian_units
119
+ new_material.to_crystal()
120
+ atoms = to_ase(new_material)
121
+ atoms.rotate(v=axis, a=angle, center="COU", rotate_cell=False)
121
122
  if wrap:
122
123
  atoms.wrap()
123
124
  new_material = MaterialWithBuildMetadata.create(from_ase(atoms))
@@ -1,8 +1,32 @@
1
1
  import numpy as np
2
2
  from mat3ra.esse.models.core.abstract.matrix_3x3 import Matrix3x3Schema
3
- from mat3ra.made.material import Material
4
3
 
4
+ from mat3ra.made.material import Material
5
5
  from ..core.unary import strain
6
+ from ...modify import wrap_to_unit_cell
7
+
8
+
9
+ def transform_material_by_matrix(material: Material, matrix: np.ndarray) -> Material:
10
+ """
11
+ Transforms a material by applying a transformation matrix to lattice vectors and coordinates.
12
+
13
+ Args:
14
+ material: The material to be transformed.
15
+ matrix: The 3x3 transformation matrix as a numpy array.
16
+
17
+ Returns:
18
+ A new material instance with transformed lattice and coordinates, wrapped to unit cell.
19
+ """
20
+ current_lattice_vectors = np.array(material.lattice.vector_arrays)
21
+ current_coordinates = material.basis.coordinates.values
22
+
23
+ new_lattice_vectors = (matrix @ current_lattice_vectors.T).tolist()
24
+ new_coordinates = (np.linalg.inv(matrix) @ np.array(current_coordinates).T).T.tolist()
25
+
26
+ transformed_material = material.clone()
27
+ transformed_material.set_lattice_vectors_from_array(new_lattice_vectors)
28
+ transformed_material.basis.coordinates.values = new_coordinates
29
+ return wrap_to_unit_cell(transformed_material)
6
30
 
7
31
 
8
32
  def strain_to_match_lattice(material_to_strain: Material, target_material: Material) -> Material: