Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/py/mat3ra/made/basis/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/py/mat3ra/made/lattice.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions src/py/mat3ra/made/material.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import hashlib
from typing import Any, List, Optional, Union

from mat3ra.code.constants import AtomicCoordinateUnits, Units
Expand Down Expand Up @@ -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)

4 changes: 2 additions & 2 deletions tests/fixtures/Graphene.json
Git LFS file not shown
4 changes: 2 additions & 2 deletions tests/fixtures/si-standata.json
Git LFS file not shown
15 changes: 14 additions & 1 deletion tests/js/material.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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);
});
});
});
});
18 changes: 18 additions & 0 deletions tests/py/unit/test_material.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import json
from pathlib import Path

import numpy as np
import pytest
from mat3ra.made.basis import Basis, Coordinates
Expand All @@ -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()
Expand Down Expand Up @@ -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"]
Loading