From 67321bdf95351124f139e36a3e5530cef677acd2 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Thu, 19 Feb 2026 20:01:27 -0800 Subject: [PATCH 1/4] update: add hash calculation to py --- src/py/mat3ra/made/basis/__init__.py | 20 ++++++++++++++++++++ src/py/mat3ra/made/lattice.py | 13 +++++++++++++ src/py/mat3ra/made/material.py | 14 ++++++++++++++ 3 files changed, 47 insertions(+) diff --git a/src/py/mat3ra/made/basis/__init__.py b/src/py/mat3ra/made/basis/__init__.py index 0ba98555c..f34a7e6be 100644 --- a/src/py/mat3ra/made/basis/__init__.py +++ b/src/py/mat3ra/made/basis/__init__.py @@ -2,7 +2,9 @@ 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.made.lattice import _fmt_hash_value from mat3ra.esse.models.core.abstract.matrix_3x3 import Matrix3x3Schema from mat3ra.esse.models.material import BasisSchema, BasisUnitsEnum from mat3ra.made.basis.coordinates import Coordinates @@ -87,6 +89,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 = [_fmt_hash_value(v % 1) 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..1916c92b7 100644 --- a/src/py/mat3ra/made/lattice.py +++ b/src/py/mat3ra/made/lattice.py @@ -2,7 +2,14 @@ from typing import List, Optional import numpy as np +from mat3ra.code.constants import HASH_TOLERANCE from mat3ra.code.entity import InMemoryEntityPydantic + + +def _fmt_hash_value(v: float) -> str: + """Format a float for hash strings to match JS number.toString() behavior.""" + rounded = round(v, HASH_TOLERANCE) + return str(int(rounded)) if rounded == int(rounded) else str(rounded) from mat3ra.esse.models.properties_directory.structural.lattice import ( LatticeSchema, LatticeTypeEnum, @@ -127,6 +134,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(_fmt_hash_value(v) 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..084cd70c4 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,16 @@ 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) From fcd7d532486baf3648255e3d4bbf23cce24b6789 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Fri, 20 Feb 2026 13:10:53 -0800 Subject: [PATCH 2/4] update: hash in py --- pyproject.toml | 2 +- src/py/mat3ra/made/basis/__init__.py | 3 +- src/py/mat3ra/made/lattice.py | 8 +- src/py/mat3ra/made/material.py | 1 + tests/py/unit/test_material_hash.py | 134 +++++++++++++++++++++++++++ 5 files changed, 138 insertions(+), 10 deletions(-) create mode 100644 tests/py/unit/test_material_hash.py diff --git a/pyproject.toml b/pyproject.toml index ae35d94e6..0ce7e6479 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ dependencies = [ "numpy<=1.26.4", "mat3ra-utils", "mat3ra-esse", - "mat3ra-code" + "mat3ra-code @ git+https://github.com/Exabyte-io/code.git@94858302cb2eadceabba23071c0a3190fda6e238" ] [project.optional-dependencies] diff --git a/src/py/mat3ra/made/basis/__init__.py b/src/py/mat3ra/made/basis/__init__.py index f34a7e6be..0fed228f1 100644 --- a/src/py/mat3ra/made/basis/__init__.py +++ b/src/py/mat3ra/made/basis/__init__.py @@ -4,7 +4,6 @@ from mat3ra.code.array_with_ids import ArrayWithIds from mat3ra.code.constants import HASH_TOLERANCE from mat3ra.code.entity import InMemoryEntityPydantic -from mat3ra.made.lattice import _fmt_hash_value from mat3ra.esse.models.core.abstract.matrix_3x3 import Matrix3x3Schema from mat3ra.esse.models.material import BasisSchema, BasisUnitsEnum from mat3ra.made.basis.coordinates import Coordinates @@ -101,7 +100,7 @@ def hash_string(self) -> str: parts = [] for elem, coord in zip(self.elements.to_dict(), self.coordinates.to_dict()): label = labels_map.get(elem["id"], "") - rounded = [_fmt_hash_value(v % 1) for v in coord["value"]] + 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() diff --git a/src/py/mat3ra/made/lattice.py b/src/py/mat3ra/made/lattice.py index 1916c92b7..3eb95fe8f 100644 --- a/src/py/mat3ra/made/lattice.py +++ b/src/py/mat3ra/made/lattice.py @@ -4,12 +4,6 @@ import numpy as np from mat3ra.code.constants import HASH_TOLERANCE from mat3ra.code.entity import InMemoryEntityPydantic - - -def _fmt_hash_value(v: float) -> str: - """Format a float for hash strings to match JS number.toString() behavior.""" - rounded = round(v, HASH_TOLERANCE) - return str(int(rounded)) if rounded == int(rounded) else str(rounded) from mat3ra.esse.models.properties_directory.structural.lattice import ( LatticeSchema, LatticeTypeEnum, @@ -138,7 +132,7 @@ 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(_fmt_hash_value(v) for v in values) + ";" + return ";".join(f"{round(v, HASH_TOLERANCE):g}" for v in values) + ";" def get_scaled_by_matrix(self, matrix: List[List[float]]): """ diff --git a/src/py/mat3ra/made/material.py b/src/py/mat3ra/made/material.py index 084cd70c4..dbeb5a9f1 100644 --- a/src/py/mat3ra/made/material.py +++ b/src/py/mat3ra/made/material.py @@ -125,3 +125,4 @@ def hash(self) -> str: @property def scaled_hash(self) -> str: return self.calculate_hash(is_scaled=True) + diff --git a/tests/py/unit/test_material_hash.py b/tests/py/unit/test_material_hash.py new file mode 100644 index 000000000..f3878cd55 --- /dev/null +++ b/tests/py/unit/test_material_hash.py @@ -0,0 +1,134 @@ +""" +Tests that Python Material.hash / Material.calculate_hash produce values identical to +the JavaScript implementation (@mat3ra/made Material.calculateHash / scaledHash). + +Expected values are cross-verified against the JS distribution: + node -e "const {Material} = require('.../dist/js/made.js'); console.log(m.calculateHash())" +""" +import pytest +from mat3ra.made.material import Material +from unit.fixtures.bulk import BULK_Si_PRIMITIVE + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +GRAPHENE_CONFIG = { + "name": "Graphene", + "basis": { + "units": "crystal", + "elements": [{"id": 0, "value": "C"}, {"id": 1, "value": "C"}], + "coordinates": [ + {"id": 0, "value": [0.333333, 0.666667, 0.5]}, + {"id": 1, "value": [0.666667, 0.333333, 0.5]}, + ], + "labels": [], + "constraints": [], + }, + "lattice": { + "type": "HEX", + "a": 2.464955, + "b": 2.464956, + "c": 19.996729, + "alpha": 90, + "beta": 90, + "gamma": 120, + "units": {"length": "angstrom", "angle": "degree"}, + }, +} + +SRTIO3_CONFIG = { + "name": "SrTiO3", + "basis": { + "units": "crystal", + "elements": [ + {"id": 0, "value": "Sr"}, + {"id": 1, "value": "Ti"}, + {"id": 2, "value": "O"}, + {"id": 3, "value": "O"}, + {"id": 4, "value": "O"}, + ], + "coordinates": [ + {"id": 0, "value": [0, 0, 0]}, + {"id": 1, "value": [0.5, 0.5, 0.5]}, + {"id": 2, "value": [0.5, 0, 0.5]}, + {"id": 3, "value": [0.5, 0.5, 0]}, + {"id": 4, "value": [0, 0.5, 0.5]}, + ], + "labels": [], + "constraints": [], + }, + "lattice": { + "type": "CUB", + "a": 3.912701, + "b": 3.912701, + "c": 3.912701, + "alpha": 90, + "beta": 90, + "gamma": 90, + "units": {"length": "angstrom", "angle": "degree"}, + }, +} + +# --------------------------------------------------------------------------- +# Expected values – cross-verified against JS dist/js/made.js +# --------------------------------------------------------------------------- + +SI_FCC_HASH = "a665723ef7429caef6ca89385fe25bae" +SI_FCC_SCALED_HASH = "87e416358789a7c435eaaba0344af51d" + +GRAPHENE_HASH = "6f1e125d8985705657c0fc45601f4b99" +GRAPHENE_SCALED_HASH = "3545f3a304cea50c2dd6b685aac48c83" + +SRTIO3_HASH = "a36ecf32ad03149f3103913cda94f9a7" +SRTIO3_SCALED_HASH = "d104cc0e9c857c6fdab4447a7ae13071" + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "config, expected_hash", + [ + (BULK_Si_PRIMITIVE, SI_FCC_HASH), + (GRAPHENE_CONFIG, GRAPHENE_HASH), + (SRTIO3_CONFIG, SRTIO3_HASH), + ], + ids=["Si_FCC", "Graphene", "SrTiO3"], +) +def test_calculate_hash(config, expected_hash): + material = Material.create(config) + material.basis.set_labels_from_list([]) + assert material.calculate_hash() == expected_hash + + +@pytest.mark.parametrize( + "config, expected_hash", + [ + (BULK_Si_PRIMITIVE, SI_FCC_HASH), + (GRAPHENE_CONFIG, GRAPHENE_HASH), + (SRTIO3_CONFIG, SRTIO3_HASH), + ], + ids=["Si_FCC", "Graphene", "SrTiO3"], +) +def test_hash_property(config, expected_hash): + material = Material.create(config) + material.basis.set_labels_from_list([]) + assert material.hash == expected_hash + + +@pytest.mark.parametrize( + "config, expected_scaled_hash", + [ + (BULK_Si_PRIMITIVE, SI_FCC_SCALED_HASH), + (GRAPHENE_CONFIG, GRAPHENE_SCALED_HASH), + (SRTIO3_CONFIG, SRTIO3_SCALED_HASH), + ], + ids=["Si_FCC", "Graphene", "SrTiO3"], +) +def test_scaled_hash_property(config, expected_scaled_hash): + material = Material.create(config) + material.basis.set_labels_from_list([]) + assert material.scaled_hash == expected_scaled_hash + From 7ccc83197f8223e152ea76c2010032671407e818 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Fri, 20 Feb 2026 13:46:24 -0800 Subject: [PATCH 3/4] update: compare py and js hash --- tests/fixtures/Graphene.json | 4 +- tests/fixtures/si-standata.json | 4 +- tests/js/material.test.ts | 15 +++- tests/py/unit/test_material.py | 18 ++++ tests/py/unit/test_material_hash.py | 134 ---------------------------- 5 files changed, 36 insertions(+), 139 deletions(-) delete mode 100644 tests/py/unit/test_material_hash.py 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"] diff --git a/tests/py/unit/test_material_hash.py b/tests/py/unit/test_material_hash.py deleted file mode 100644 index f3878cd55..000000000 --- a/tests/py/unit/test_material_hash.py +++ /dev/null @@ -1,134 +0,0 @@ -""" -Tests that Python Material.hash / Material.calculate_hash produce values identical to -the JavaScript implementation (@mat3ra/made Material.calculateHash / scaledHash). - -Expected values are cross-verified against the JS distribution: - node -e "const {Material} = require('.../dist/js/made.js'); console.log(m.calculateHash())" -""" -import pytest -from mat3ra.made.material import Material -from unit.fixtures.bulk import BULK_Si_PRIMITIVE - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - -GRAPHENE_CONFIG = { - "name": "Graphene", - "basis": { - "units": "crystal", - "elements": [{"id": 0, "value": "C"}, {"id": 1, "value": "C"}], - "coordinates": [ - {"id": 0, "value": [0.333333, 0.666667, 0.5]}, - {"id": 1, "value": [0.666667, 0.333333, 0.5]}, - ], - "labels": [], - "constraints": [], - }, - "lattice": { - "type": "HEX", - "a": 2.464955, - "b": 2.464956, - "c": 19.996729, - "alpha": 90, - "beta": 90, - "gamma": 120, - "units": {"length": "angstrom", "angle": "degree"}, - }, -} - -SRTIO3_CONFIG = { - "name": "SrTiO3", - "basis": { - "units": "crystal", - "elements": [ - {"id": 0, "value": "Sr"}, - {"id": 1, "value": "Ti"}, - {"id": 2, "value": "O"}, - {"id": 3, "value": "O"}, - {"id": 4, "value": "O"}, - ], - "coordinates": [ - {"id": 0, "value": [0, 0, 0]}, - {"id": 1, "value": [0.5, 0.5, 0.5]}, - {"id": 2, "value": [0.5, 0, 0.5]}, - {"id": 3, "value": [0.5, 0.5, 0]}, - {"id": 4, "value": [0, 0.5, 0.5]}, - ], - "labels": [], - "constraints": [], - }, - "lattice": { - "type": "CUB", - "a": 3.912701, - "b": 3.912701, - "c": 3.912701, - "alpha": 90, - "beta": 90, - "gamma": 90, - "units": {"length": "angstrom", "angle": "degree"}, - }, -} - -# --------------------------------------------------------------------------- -# Expected values – cross-verified against JS dist/js/made.js -# --------------------------------------------------------------------------- - -SI_FCC_HASH = "a665723ef7429caef6ca89385fe25bae" -SI_FCC_SCALED_HASH = "87e416358789a7c435eaaba0344af51d" - -GRAPHENE_HASH = "6f1e125d8985705657c0fc45601f4b99" -GRAPHENE_SCALED_HASH = "3545f3a304cea50c2dd6b685aac48c83" - -SRTIO3_HASH = "a36ecf32ad03149f3103913cda94f9a7" -SRTIO3_SCALED_HASH = "d104cc0e9c857c6fdab4447a7ae13071" - - -# --------------------------------------------------------------------------- -# Tests -# --------------------------------------------------------------------------- - -@pytest.mark.parametrize( - "config, expected_hash", - [ - (BULK_Si_PRIMITIVE, SI_FCC_HASH), - (GRAPHENE_CONFIG, GRAPHENE_HASH), - (SRTIO3_CONFIG, SRTIO3_HASH), - ], - ids=["Si_FCC", "Graphene", "SrTiO3"], -) -def test_calculate_hash(config, expected_hash): - material = Material.create(config) - material.basis.set_labels_from_list([]) - assert material.calculate_hash() == expected_hash - - -@pytest.mark.parametrize( - "config, expected_hash", - [ - (BULK_Si_PRIMITIVE, SI_FCC_HASH), - (GRAPHENE_CONFIG, GRAPHENE_HASH), - (SRTIO3_CONFIG, SRTIO3_HASH), - ], - ids=["Si_FCC", "Graphene", "SrTiO3"], -) -def test_hash_property(config, expected_hash): - material = Material.create(config) - material.basis.set_labels_from_list([]) - assert material.hash == expected_hash - - -@pytest.mark.parametrize( - "config, expected_scaled_hash", - [ - (BULK_Si_PRIMITIVE, SI_FCC_SCALED_HASH), - (GRAPHENE_CONFIG, GRAPHENE_SCALED_HASH), - (SRTIO3_CONFIG, SRTIO3_SCALED_HASH), - ], - ids=["Si_FCC", "Graphene", "SrTiO3"], -) -def test_scaled_hash_property(config, expected_scaled_hash): - material = Material.create(config) - material.basis.set_labels_from_list([]) - assert material.scaled_hash == expected_scaled_hash - From 04f044be0b4eaf478b34b06898621e695b070de7 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Sun, 1 Mar 2026 16:36:18 -0800 Subject: [PATCH 4/4] update: code --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0ce7e6479..ae35d94e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ dependencies = [ "numpy<=1.26.4", "mat3ra-utils", "mat3ra-esse", - "mat3ra-code @ git+https://github.com/Exabyte-io/code.git@94858302cb2eadceabba23071c0a3190fda6e238" + "mat3ra-code" ] [project.optional-dependencies]