diff --git a/src/py/mat3ra/made/basis/__init__.py b/src/py/mat3ra/made/basis/__init__.py index 0ba98555c..0fed228f1 100644 --- a/src/py/mat3ra/made/basis/__init__.py +++ b/src/py/mat3ra/made/basis/__init__.py @@ -2,6 +2,7 @@ import numpy as np from mat3ra.code.array_with_ids import ArrayWithIds +from mat3ra.code.constants import HASH_TOLERANCE from mat3ra.code.entity import InMemoryEntityPydantic from mat3ra.esse.models.core.abstract.matrix_3x3 import Matrix3x3Schema from mat3ra.esse.models.material import BasisSchema, BasisUnitsEnum @@ -87,6 +88,24 @@ def from_dict( constraints=ArrayWithIds.from_list_of_dicts(constraints), ) + @property + def hash_string(self) -> str: + """ + Mirrors JS Basis.hashString (getAsSortedString in crystal units). + Converts to crystal, applies mod 1 to bring coords into [0,1), builds sorted atom strings. + """ + original_is_in_cartesian = self.is_in_cartesian_units + self.to_crystal() + labels_map = {lbl["id"]: str(lbl["value"]) for lbl in self.labels.to_dict()} + parts = [] + for elem, coord in zip(self.elements.to_dict(), self.coordinates.to_dict()): + label = labels_map.get(elem["id"], "") + rounded = [f"{round(v % 1, HASH_TOLERANCE):g}" for v in coord["value"]] + parts.append(f"{elem['value']}{label} {','.join(rounded)}") + if original_is_in_cartesian: + self.to_cartesian() + return ";".join(sorted(parts)) + ";" + @property def is_in_crystal_units(self): return self.units == BasisUnitsEnum.crystal diff --git a/src/py/mat3ra/made/lattice.py b/src/py/mat3ra/made/lattice.py index be7bec382..3eb95fe8f 100644 --- a/src/py/mat3ra/made/lattice.py +++ b/src/py/mat3ra/made/lattice.py @@ -2,6 +2,7 @@ from typing import List, Optional import numpy as np +from mat3ra.code.constants import HASH_TOLERANCE from mat3ra.code.entity import InMemoryEntityPydantic from mat3ra.esse.models.properties_directory.structural.lattice import ( LatticeSchema, @@ -127,6 +128,12 @@ def cell_volume(self) -> float: def cell_volume_rounded(self) -> float: return self.vectors.volume_rounded + def get_hash_string(self, is_scaled: bool = False) -> str: + """Mirrors JS Lattice.getHashString(isScaled). Rounds to HASH_TOLERANCE decimal places.""" + scale = self.a if is_scaled else 1 + values = [self.a / scale, self.b / scale, self.c / scale, self.alpha, self.beta, self.gamma] + return ";".join(f"{round(v, HASH_TOLERANCE):g}" for v in values) + ";" + def get_scaled_by_matrix(self, matrix: List[List[float]]): """ Scale the lattice by a matrix. diff --git a/src/py/mat3ra/made/material.py b/src/py/mat3ra/made/material.py index 792956f7f..dbeb5a9f1 100644 --- a/src/py/mat3ra/made/material.py +++ b/src/py/mat3ra/made/material.py @@ -1,3 +1,4 @@ +import hashlib from typing import Any, List, Optional, Union from mat3ra.code.constants import AtomicCoordinateUnits, Units @@ -111,3 +112,17 @@ def set_labels_from_list(self, labels: Optional[List[Union[int, str]]]) -> None: def set_labels_from_value(self, value: Union[int, str]) -> None: self.basis.set_labels_from_list([value] * self.basis.number_of_atoms) + + def calculate_hash(self, salt: str = "", is_scaled: bool = False) -> str: + """Mirrors JS materialMixin.calculateHash(). MD5 of basis + lattice hash strings.""" + message = f"{self.basis.hash_string}#{self.lattice.get_hash_string(is_scaled)}#{salt}" + return hashlib.md5(message.encode()).hexdigest() + + @property + def hash(self) -> str: + return self.calculate_hash() + + @property + def scaled_hash(self) -> str: + return self.calculate_hash(is_scaled=True) + diff --git a/tests/fixtures/Graphene.json b/tests/fixtures/Graphene.json index d4caff88d..b8c65821f 100644 --- a/tests/fixtures/Graphene.json +++ b/tests/fixtures/Graphene.json @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3fbf76a64fe44dccbad8e5cbd625e7b3a0fe3f0496c0fd447622632dca0fde62 -size 1733 +oid sha256:43712ea8ada9350751be35d18f0ef92880a095be6536615f86b0c9ffd3b10dae +size 1835 diff --git a/tests/fixtures/si-standata.json b/tests/fixtures/si-standata.json index ccd52ce05..dd195f26b 100644 --- a/tests/fixtures/si-standata.json +++ b/tests/fixtures/si-standata.json @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d096e1c2598dee0e809902646b28b38be4e2be1b40a2fc0b40f23fbf8b9f158e -size 1031 +oid sha256:62175309558d4a75da4fd723ccb47dc558cdbb89fa224e61921c4ee2d5b48094 +size 1133 diff --git a/tests/js/material.test.ts b/tests/js/material.test.ts index 564eee398..f593784e1 100644 --- a/tests/js/material.test.ts +++ b/tests/js/material.test.ts @@ -1,7 +1,7 @@ import { expect } from "chai"; import { Material } from "../../src/js/material"; -import { Na4Cl4, Silicon } from "./fixtures"; +import { Graphene, Na4Cl4, Silicon } from "./fixtures"; const newBasisXYZ = `Si 0.000000 0.000000 0.000000 Ge 0.250000 0.250000 0.250000 @@ -19,4 +19,17 @@ describe("Material", () => { clonedMaterial.setBasis(newBasisXYZ, "xyz", clonedMaterial.Basis.units); expect(clonedMaterial.Basis.elements).to.have.lengthOf(2); }); + + describe("calculateHash", () => { + [ + { name: "Silicon", fixture: Silicon }, + { name: "Graphene", fixture: Graphene }, + ].forEach(({ name, fixture }) => { + it(`should match expected hash for ${name}`, () => { + const material = new Material(fixture); + expect(material.calculateHash()).to.equal((fixture as any).hash); + expect(material.scaledHash).to.equal((fixture as any).scaledHash); + }); + }); + }); }); diff --git a/tests/py/unit/test_material.py b/tests/py/unit/test_material.py index d3da2cacf..2fcb79c0e 100644 --- a/tests/py/unit/test_material.py +++ b/tests/py/unit/test_material.py @@ -1,3 +1,6 @@ +import json +from pathlib import Path + import numpy as np import pytest from mat3ra.made.basis import Basis, Coordinates @@ -8,6 +11,13 @@ from unit.fixtures.slab import BULK_Si_CONVENTIONAL from unit.utils import assert_two_entities_deep_almost_equal +FIXTURES_DIR = Path(__file__).parents[2] / "fixtures" + + +def load_fixture(name: str) -> dict: + with open(FIXTURES_DIR / name) as f: + return json.load(f) + def test_create_default(): material = Material.create_default() @@ -118,3 +128,11 @@ def test_set_labels_from_list(initial_labels, reset_labels, expected_final): assert len(material.basis.labels.values) == len(expected_final) assert material.basis.labels.values == expected_final + + +@pytest.mark.parametrize("fixture_file", ["si-standata.json", "Graphene.json"]) +def test_calculate_hash(fixture_file): + fixture = load_fixture(fixture_file) + material = Material.create(fixture) + assert material.hash == fixture["hash"] + assert material.scaled_hash == fixture["scaledHash"]