From 7546bede64ba0fd1781d955c6b2ab3776fe1dd4b Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Mon, 24 Aug 2026 19:39:34 -0700 Subject: [PATCH 1/8] Fix: preserve the input crystal in build metadata [SOF-8034] Builders recorded a conventionalized copy of the material instead of the one that was passed in, so a Total Energy job run on a primitive cell could not be reached from a slab built from it. Store the input untransformed and move the conventionalization to build time, where the use_conventional_cell flag already lives. - CrystalLatticePlanesBuilder._generate applies the transform - SlabConfiguration.from_parameters forwards the flag instead of rebinding - four helpers stop conventionalizing before building their configuration - create_atomic_layers pins the flag False, preserving its behaviour twisted is deliberately untouched: its analyzer reads atomic_layers.crystal as geometry without building, so the transform cannot reach it there. Co-Authored-By: Claude Opus 5 (1M context) --- .../interface/commensurate/helpers.py | 10 +- .../two_dimensional/interface/zsl/helpers.py | 5 - .../grain_boundary_linear/helpers.py | 6 +- .../grain_boundary_planar/helpers.py | 9 +- .../two_dimensional/slab/configuration.py | 6 +- .../atomic_layers_unique_repeated/helpers.py | 2 + .../crystal_lattice_planes/builder.py | 8 +- ...st_build_metadata_records_input_crystal.py | 167 ++++++++++++++++++ tests/py/unit/test_tools_build_interface.py | 8 +- tests/py/unit/test_tools_build_slab.py | 2 + 10 files changed, 195 insertions(+), 28 deletions(-) create mode 100644 tests/py/unit/test_build_metadata_records_input_crystal.py diff --git a/src/py/mat3ra/made/tools/build/compound_pristine_structures/two_dimensional/interface/commensurate/helpers.py b/src/py/mat3ra/made/tools/build/compound_pristine_structures/two_dimensional/interface/commensurate/helpers.py index c39489b23..9dd63783b 100644 --- a/src/py/mat3ra/made/tools/build/compound_pristine_structures/two_dimensional/interface/commensurate/helpers.py +++ b/src/py/mat3ra/made/tools/build/compound_pristine_structures/two_dimensional/interface/commensurate/helpers.py @@ -10,7 +10,6 @@ SlabStrainedSupercellConfiguration, ) from ......analyze.interface import CommensurateLatticeInterfaceAnalyzer -from ......analyze.lattice import get_material_with_conventional_lattice from mat3ra.made.tools.build.pristine_structures.two_dimensional.slab.configuration import SlabConfiguration from ......build_components.metadata import MaterialWithBuildMetadata @@ -45,6 +44,8 @@ def get_commensurate_strained_configurations( number_of_layers (int): Number of atomic layers in the slab. vacuum (float): Size of the vacuum layer in Angstroms. match_id (int): ID of the match to use (0 for first match). + use_conventional_cell (bool): Accepted for signature compatibility but not honored -- + the slab configuration takes the schema default and conventionalizes either way. Returns: Tuple[List[SlabStrainedSupercellConfiguration], float]: @@ -53,9 +54,6 @@ def get_commensurate_strained_configurations( Raises: ValueError: If no commensurate lattice matches are found. """ - if use_conventional_cell: - material = get_material_with_conventional_lattice(material) - slab_config = SlabConfiguration.from_parameters( material_or_dict=material, miller_indices=miller_indices, @@ -120,7 +118,9 @@ def create_interface_commensurate( number_of_layers (int): Number of atomic layers in the slab. vacuum (float): Size of the vacuum layer in Angstroms. match_id (int): ID of the match to use (0 for first match). - use_conventional_cell (bool): Whether to use the conventional cell for the material. + use_conventional_cell (bool): Accepted for signature compatibility but not honored -- + the slab configuration below takes the schema default, which conventionalizes + either way. Making it live changes built geometry; see SOF-8034. remove_overlapping_atoms (bool): Whether to resolve overlapping atoms in the interface after creation. tolerance_for_overlap (float): Tolerance for resolving overlapping atoms, in Angstroms. Returns: diff --git a/src/py/mat3ra/made/tools/build/compound_pristine_structures/two_dimensional/interface/zsl/helpers.py b/src/py/mat3ra/made/tools/build/compound_pristine_structures/two_dimensional/interface/zsl/helpers.py index 7d067de4c..1e990ec8f 100644 --- a/src/py/mat3ra/made/tools/build/compound_pristine_structures/two_dimensional/interface/zsl/helpers.py +++ b/src/py/mat3ra/made/tools/build/compound_pristine_structures/two_dimensional/interface/zsl/helpers.py @@ -4,7 +4,6 @@ from mat3ra.made.material import Material from ......analyze.interface import ZSLInterfaceAnalyzer -from ......analyze.lattice import get_material_with_conventional_lattice from ......analyze.slab import SlabMaterialAnalyzer from ......build.compound_pristine_structures.two_dimensional.interface import ( InterfaceBuilderParameters, @@ -37,10 +36,6 @@ def create_interface_zsl( reduce_result_cell: bool = True, reduce_result_cell_to_primitive: bool = False, ) -> MaterialWithBuildMetadata: - if use_conventional_cell: - substrate_crystal = get_material_with_conventional_lattice(substrate_crystal) - film_crystal = get_material_with_conventional_lattice(film_crystal) - substrate_slab = create_slab( crystal=substrate_crystal, miller_indices=substrate_miller_indices, diff --git a/src/py/mat3ra/made/tools/build/defective_structures/one_dimensional/grain_boundary_linear/helpers.py b/src/py/mat3ra/made/tools/build/defective_structures/one_dimensional/grain_boundary_linear/helpers.py index ef493d4e4..e9a335e22 100644 --- a/src/py/mat3ra/made/tools/build/defective_structures/one_dimensional/grain_boundary_linear/helpers.py +++ b/src/py/mat3ra/made/tools/build/defective_structures/one_dimensional/grain_boundary_linear/helpers.py @@ -7,7 +7,6 @@ from .builder import GrainBoundaryLinearBuilder from .configuration import GrainBoundaryLinearConfiguration from ....compound_pristine_structures.two_dimensional.interface import get_commensurate_strained_configurations -from .....analyze.lattice import get_material_with_conventional_lattice from .....build_components import MaterialWithBuildMetadata @@ -46,6 +45,8 @@ def create_grain_boundary_linear( miller_indices (Tuple[int, int, int]): Miller indices for the slab surface. number_of_layers (int): Number of atomic layers in the slab. vacuum (float): Size of the vacuum layer in Angstroms. + use_conventional_cell (bool): Accepted for signature compatibility but not honored -- + the slab configuration takes the schema default and conventionalizes either way. Returns: Material: The grain boundary material. @@ -53,9 +54,6 @@ def create_grain_boundary_linear( Raises: ValueError: If no commensurate lattice matches are found. """ - if use_conventional_cell: - material = get_material_with_conventional_lattice(material) - strained_configs, actual_angle = get_commensurate_strained_configurations( material=material, target_angle=target_angle, diff --git a/src/py/mat3ra/made/tools/build/defective_structures/two_dimensional/grain_boundary_planar/helpers.py b/src/py/mat3ra/made/tools/build/defective_structures/two_dimensional/grain_boundary_planar/helpers.py index 49e51356f..c5ad3c187 100644 --- a/src/py/mat3ra/made/tools/build/defective_structures/two_dimensional/grain_boundary_planar/helpers.py +++ b/src/py/mat3ra/made/tools/build/defective_structures/two_dimensional/grain_boundary_planar/helpers.py @@ -4,7 +4,6 @@ from .builder import GrainBoundaryPlanarBuilder from .configuration import GrainBoundaryPlanarConfiguration from .....analyze.interface import GrainBoundaryPlanarAnalyzer -from .....analyze.lattice import get_material_with_conventional_lattice from .....build_components import MaterialWithBuildMetadata @@ -29,6 +28,7 @@ def create_grain_boundary_planar( Args: phase_1_material: The material to use for each phase of the grain boundary + phase_2_material: Material for the second phase; defaults to phase_1_material phase_1_miller_indices: Miller indices for phase 1 phase_2_miller_indices: Miller indices for phase 2 phase_1_thickness: Number of layers for phase 1 @@ -40,15 +40,14 @@ def create_grain_boundary_planar( max_area_ratio_tol: Area ratio tolerance for ZSL matching max_length_tol: Length tolerance for ZSL matching max_angle_tol: Angle tolerance for ZSL matching + use_conventional_cell: Accepted for signature compatibility but not honored -- the + analyzer's slab configurations take the schema default, which conventionalizes + either way. Making it live changes built geometry; see SOF-8034. Returns: Material: The grain boundary material """ phase_2_material = phase_2_material or phase_1_material - if use_conventional_cell: - phase_1_material = get_material_with_conventional_lattice(phase_1_material) - phase_2_material = get_material_with_conventional_lattice(phase_2_material) - analyzer = GrainBoundaryPlanarAnalyzer( phase_1_material=phase_1_material, phase_2_material=phase_2_material, diff --git a/src/py/mat3ra/made/tools/build/pristine_structures/two_dimensional/slab/configuration.py b/src/py/mat3ra/made/tools/build/pristine_structures/two_dimensional/slab/configuration.py index 9604e28c0..444cd4100 100644 --- a/src/py/mat3ra/made/tools/build/pristine_structures/two_dimensional/slab/configuration.py +++ b/src/py/mat3ra/made/tools/build/pristine_structures/two_dimensional/slab/configuration.py @@ -65,6 +65,9 @@ def from_parameters( termination_bottom_formula (Optional[str]): Formula of the bottom termination to use for the slab. vacuum (float): Size of the vacuum layer in Angstroms. + use_conventional_cell (bool): Whether the slab is built in the conventional cell. + Stored on the configuration and applied at build time; `crystal` always keeps + the material that was passed in. Returns: SlabConfiguration: The created slab configuration. @@ -81,11 +84,10 @@ def from_parameters( termination_top = select_slab_termination(terminations, termination_top_formula) termination_bottom = select_slab_termination(terminations, termination_bottom_formula) - if use_conventional_cell: - material = crystal_lattice_planes_analyzer.material_with_conventional_lattice atomic_layers_repeated_configuration = AtomicLayersUniqueRepeatedConfiguration( crystal=material, miller_indices=miller_indices, + use_conventional_cell=use_conventional_cell, termination_top=termination_top, termination_bottom=termination_bottom, number_of_repetitions=number_of_layers, diff --git a/src/py/mat3ra/made/tools/build_components/entities/reusable/two_dimensional/atomic_layers_unique_repeated/helpers.py b/src/py/mat3ra/made/tools/build_components/entities/reusable/two_dimensional/atomic_layers_unique_repeated/helpers.py index 27d7300aa..70e4a5b06 100644 --- a/src/py/mat3ra/made/tools/build_components/entities/reusable/two_dimensional/atomic_layers_unique_repeated/helpers.py +++ b/src/py/mat3ra/made/tools/build_components/entities/reusable/two_dimensional/atomic_layers_unique_repeated/helpers.py @@ -32,6 +32,8 @@ def create_atomic_layers( atomic_layers_config = AtomicLayersUniqueRepeatedConfiguration( crystal=material, miller_indices=miller_indices, + # Explicit: the schema defaults this True, and this path has never conventionalized. + use_conventional_cell=False, termination_top=termination, number_of_repetitions=number_of_layers, ) diff --git a/src/py/mat3ra/made/tools/build_components/entities/reusable/two_dimensional/crystal_lattice_planes/builder.py b/src/py/mat3ra/made/tools/build_components/entities/reusable/two_dimensional/crystal_lattice_planes/builder.py index 2e4718088..531417388 100644 --- a/src/py/mat3ra/made/tools/build_components/entities/reusable/two_dimensional/crystal_lattice_planes/builder.py +++ b/src/py/mat3ra/made/tools/build_components/entities/reusable/two_dimensional/crystal_lattice_planes/builder.py @@ -21,7 +21,13 @@ def get_analyzer(self, configuration: CrystalLatticePlanesConfiguration) -> Crys def _generate(self, configuration: CrystalLatticePlanesConfiguration) -> MaterialWithBuildMetadata: crystal_lattice_planes_analyzer = self.get_analyzer(configuration) miller_supercell_matrix = crystal_lattice_planes_analyzer.miller_supercell_matrix - miller_supercell_material = supercell(configuration.crystal, miller_supercell_matrix) + # The configuration stores the crystal that was passed in, untransformed, so that build + # metadata records the caller's own material. Conventionalizing happens here instead -- + # any consumer reading configuration.crystal as geometry must do its own transform. + crystal = configuration.crystal + if configuration.use_conventional_cell: + crystal = crystal_lattice_planes_analyzer.material_with_conventional_lattice + miller_supercell_material = supercell(crystal, miller_supercell_matrix) return miller_supercell_material def _enforce_convention(self, material: MaterialWithBuildMetadata) -> MaterialWithBuildMetadata: diff --git a/tests/py/unit/test_build_metadata_records_input_crystal.py b/tests/py/unit/test_build_metadata_records_input_crystal.py new file mode 100644 index 000000000..c3377a2a6 --- /dev/null +++ b/tests/py/unit/test_build_metadata_records_input_crystal.py @@ -0,0 +1,167 @@ +""" +The crystal recorded in build metadata must be the material that was passed in. + +A bulk reference resolved from build metadata (surface energy, defect formation energy, +interfacial energy) is only comparable with a Total Energy job run on the same cell, so +primitive input has to stay primitive and conventional input has to stay conventional. +""" + +from typing import Any, Dict, List + +import pytest +from mat3ra.made.material import Material +from mat3ra.made.tools.analyze.lattice_planes import CrystalLatticePlanesMaterialAnalyzer +from mat3ra.made.tools.analyze.slab import SlabMaterialAnalyzer +from mat3ra.made.tools.build import MaterialWithBuildMetadata +from mat3ra.made.tools.build.pristine_structures.two_dimensional.slab import SlabBuilder +from mat3ra.made.tools.helpers import ( + create_atomic_layers, + create_defect_point_vacancy, + create_grain_boundary_linear, + create_grain_boundary_planar, + create_interface_commensurate, + create_interface_twisted, + create_interface_zsl, + create_slab, + get_slab_terminations, +) + +from .fixtures.bulk import BULK_Ni_PRIMITIVE +from .fixtures.slab import SI_CONVENTIONAL_SLAB_001 +from .utils import assert_two_entities_deep_almost_equal + +INPUT_ID = "platform-id-abc" +MILLER_INDICES = (0, 0, 1) + + +def make_input(config: Dict[str, Any]) -> Material: + # _id names one exact material, so it has to survive alongside the hashes. + return Material.create({**config, "_id": INPUT_ID}) + + +def get_conventional_lattice_hash(crystal: Material) -> str: + analyzer = CrystalLatticePlanesMaterialAnalyzer(material=crystal, miller_indices=MILLER_INDICES) + return analyzer.material_with_conventional_lattice.hash + + +def collect_source_crystals(node: Any, crystals: List[Dict[str, Any]]) -> None: + """ + A source crystal is one recorded next to Miller indices (a lattice-planes configuration) or as + the host of a defect configuration. A vacuum's `crystal` is a built intermediate, not a source. + """ + if isinstance(node, dict): + if isinstance(node.get("crystal"), dict) and "miller_indices" in node: + crystals.append(node["crystal"]) + if node.get("merge_components"): + # Defect configurations record the host material first; the rest are the defect parts. + crystals.append(node["merge_components"][0]) + for value in node.values(): + collect_source_crystals(value, crystals) + elif isinstance(node, list): + for value in node: + collect_source_crystals(value, crystals) + + +def get_recorded_source_crystals(material: Material) -> List[Dict[str, Any]]: + crystals: List[Dict[str, Any]] = [] + collect_source_crystals(material.model_dump()["metadata"]["build"], crystals) + return crystals + + +# Every input is a cell whose conventional form differs from itself, so each case can actually fail +# the criterion it asserts. +BUILDERS = { + "slab": lambda c: create_slab(crystal=c, miller_indices=MILLER_INDICES, number_of_layers=3), + "interface_zsl": lambda c: create_interface_zsl(substrate_crystal=c, film_crystal=c, max_area=50.0), + "interface_commensurate": lambda c: create_interface_commensurate( + material=c, target_angle=13.0, angle_tolerance=0.5, max_repetition_int=5 + ), + "grain_boundary_planar": lambda c: create_grain_boundary_planar( + phase_1_material=c, + phase_1_miller_indices=MILLER_INDICES, + phase_2_miller_indices=(0, 1, 1), + max_area=100.0, + ), + "grain_boundary_linear": lambda c: create_grain_boundary_linear( + material=c, target_angle=13.0, angle_tolerance=0.5, max_repetition_int=5, gap=1.0 + ), + # Already correct before this ticket -- here as a regression guard. + "point_defect_vacancy": lambda c: create_defect_point_vacancy(c, [0.0, 0.0, 0.0], "closest_site"), +} + + +@pytest.mark.parametrize("build", BUILDERS.values(), ids=BUILDERS.keys()) +def test_recorded_crystal_is_the_input(build): + crystal = make_input(BULK_Ni_PRIMITIVE) + assert get_conventional_lattice_hash(crystal) != crystal.hash, "input must differ from its conventional cell" + + recorded = get_recorded_source_crystals(build(crystal)) + + assert recorded, "builder recorded no source crystal" + for entry in recorded: + assert entry["hash"] == crystal.hash + assert entry["scaledHash"] == crystal.scaled_hash + assert entry["_id"] == INPUT_ID + + +def test_twisted_interface_still_honours_use_conventional_cell(): + # TwistedNanoribbonsInterfaceAnalyzer reads atomic_layers.crystal as geometry and never calls + # the builder, so create_interface_twisted has to conventionalize up front. If these two ever + # produce the same structure, that transform was moved somewhere the analyzer cannot see it. + crystal = Material.create(BULK_Ni_PRIMITIVE) + + from_conventional = create_interface_twisted( + material1=crystal, material2=crystal, angle=10.0, use_conventional_cell=True + ) + from_input = create_interface_twisted(material1=crystal, material2=crystal, angle=10.0, use_conventional_cell=False) + + assert from_conventional.hash != from_input.hash + + +def test_create_atomic_layers_does_not_conventionalize_the_input(): + # create_atomic_layers builds the configuration directly, where the schema default is True. + # Without the explicit False it would silently start conventionalizing. + crystal = Material.create(BULK_Ni_PRIMITIVE) + conventional = CrystalLatticePlanesMaterialAnalyzer( + material=crystal, miller_indices=MILLER_INDICES + ).material_with_conventional_lattice + termination = get_slab_terminations(crystal, MILLER_INDICES)[0] + + assert ( + create_atomic_layers(crystal, MILLER_INDICES, termination=termination).hash + != create_atomic_layers(conventional, MILLER_INDICES, termination=termination).hash + ) + + +def test_configuration_saved_before_this_change_does_not_round_trip(): + # Before this change `from_parameters` never forwarded the flag, so EVERY stored configuration + # reads "use_conventional_cell": true -- including slabs built from the primitive cell. Now that + # _generate honors the flag, those stored configurations rebuild conventionalized instead of + # reproducing themselves. Accepted consequence of fixing the ticket's root cause 2, pinned here + # so it is a recorded decision rather than a surprise: rebuild such a slab from its crystal, + # not from its metadata. + crystal = Material.create(BULK_Ni_PRIMITIVE) + slab = create_slab(crystal=crystal, miller_indices=MILLER_INDICES, number_of_layers=3, use_conventional_cell=False) + stored = slab.model_dump(mode="json") + stored["metadata"]["build"][-1]["configuration"]["stack_components"][0]["use_conventional_cell"] = True + + rebuilt = SlabBuilder().get_material( + SlabMaterialAnalyzer(material=MaterialWithBuildMetadata.create(stored)).build_configuration + ) + + conventionalized = create_slab( + crystal=crystal, miller_indices=MILLER_INDICES, number_of_layers=3, use_conventional_cell=True + ) + assert rebuilt.hash != slab.hash + assert rebuilt.hash == conventionalized.hash + + +def test_rebuilding_a_slab_from_its_own_metadata_is_stable(): + # A slab saved before this change records the conventional cell next to use_conventional_cell + # True; conventionalizing an already-conventional cell has to be a no-op. + slab = MaterialWithBuildMetadata.create(SI_CONVENTIONAL_SLAB_001) + + rebuilt = SlabBuilder().get_material(SlabMaterialAnalyzer(material=slab).build_configuration) + + assert_two_entities_deep_almost_equal(rebuilt.basis, slab.basis) + assert_two_entities_deep_almost_equal(rebuilt.lattice, slab.lattice) diff --git a/tests/py/unit/test_tools_build_interface.py b/tests/py/unit/test_tools_build_interface.py index 8c8095e66..2d7242ede 100644 --- a/tests/py/unit/test_tools_build_interface.py +++ b/tests/py/unit/test_tools_build_interface.py @@ -5,7 +5,6 @@ from mat3ra.esse.models.core.reusable.axis_enum import AxisEnum from mat3ra.made.material import Material from mat3ra.made.tools.analyze.interface.simple import InterfaceAnalyzer -from mat3ra.made.tools.analyze.lattice_planes import CrystalLatticePlanesMaterialAnalyzer from mat3ra.made.tools.build import MaterialWithBuildMetadata from mat3ra.made.tools.build.compound_pristine_structures.two_dimensional.interface.base.build_parameters import ( InterfaceBuilderParameters, @@ -259,9 +258,6 @@ def test_commensurate_interface_creation(material_config, analyzer_params, direc def test_create_slab_with_conventional_cell_stores_crystal_hashes_in_metadata(): miller_indices = (0, 0, 1) material = Material.create(BULK_Ni_PRIMITIVE) - expected_crystal = CrystalLatticePlanesMaterialAnalyzer( - material=material, miller_indices=miller_indices - ).material_with_conventional_lattice slab = create_slab( crystal=material, @@ -274,8 +270,8 @@ def test_create_slab_with_conventional_cell_stores_crystal_hashes_in_metadata(): serialized_slab = slab.model_dump() crystal = serialized_slab["metadata"]["build"][-1]["configuration"]["stack_components"][0]["crystal"] - assert crystal[HASH_KEY] == expected_crystal.hash - assert crystal[SCALED_HASH_KEY] == expected_crystal.scaled_hash + assert crystal[HASH_KEY] == material.hash + assert crystal[SCALED_HASH_KEY] == material.scaled_hash assert "bulkId" not in serialized_slab["metadata"] diff --git a/tests/py/unit/test_tools_build_slab.py b/tests/py/unit/test_tools_build_slab.py index c491f17c9..9bcf3bc4d 100644 --- a/tests/py/unit/test_tools_build_slab.py +++ b/tests/py/unit/test_tools_build_slab.py @@ -144,6 +144,8 @@ def get_slab_with_builder( atomic_layers_repeated_configuration = AtomicLayersUniqueRepeatedConfiguration( crystal=material, miller_indices=miller_indices, + # Explicit: the schema defaults this True, and this helper builds primitive slabs. + use_conventional_cell=False, termination_top=termination_top, termination_bottom=termination_bottom, number_of_repetitions=number_of_layers, From 69fe78d0cfc10d062d9b7e1b1634fb24ee4d7a26 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Mon, 24 Aug 2026 20:41:11 -0700 Subject: [PATCH 2/8] Update: simplify the build-metadata tests [SOF-8034] Rewrite them the way the rest of the suite is written: call the helper and compare against a fixture, instead of asserting invariants between two invocations. - one parametrized test over the six builders, comparing the recorded crystal to the input material - create_atomic_layers pinned against a fixture like its siblings - drop the twisted test: that helper is unchanged from main, so it was testing code this branch does not touch 167 -> 73 lines. Co-Authored-By: Claude Opus 5 (1M context) --- tests/py/unit/fixtures/slab.py | 24 +++ ...st_build_metadata_records_input_crystal.py | 154 ++++-------------- 2 files changed, 54 insertions(+), 124 deletions(-) diff --git a/tests/py/unit/fixtures/slab.py b/tests/py/unit/fixtures/slab.py index f4f9ea42b..27dee0c7a 100644 --- a/tests/py/unit/fixtures/slab.py +++ b/tests/py/unit/fixtures/slab.py @@ -604,3 +604,27 @@ "type": "TRI", }, } + + +# create_atomic_layers builds its configuration directly, where use_conventional_cell defaults True; +# this pins the primitive cell it has always produced. +ATOMIC_LAYERS_NI_001: Dict[str, Any] = { + "name": "Ni(001), termination Ni_P4/mmm_2", + "basis": { + "elements": [{"id": 0, "value": "Ni"}], + "coordinates": [{"id": 0, "value": [0.0, 0.0, 1e-06]}], + "units": "crystal", + "labels": [], + "constraints": [], + }, + "lattice": { + "a": 2.478974, + "b": 2.478974, + "c": 2.478974, + "alpha": 60.0, + "beta": 60.0, + "gamma": 60.0, + "units": {"length": "angstrom", "angle": "degree"}, + "type": "TRI", + }, +} diff --git a/tests/py/unit/test_build_metadata_records_input_crystal.py b/tests/py/unit/test_build_metadata_records_input_crystal.py index c3377a2a6..971d1046a 100644 --- a/tests/py/unit/test_build_metadata_records_input_crystal.py +++ b/tests/py/unit/test_build_metadata_records_input_crystal.py @@ -1,75 +1,26 @@ -""" -The crystal recorded in build metadata must be the material that was passed in. - -A bulk reference resolved from build metadata (surface energy, defect formation energy, -interfacial energy) is only comparable with a Total Energy job run on the same cell, so -primitive input has to stay primitive and conventional input has to stay conventional. -""" - from typing import Any, Dict, List import pytest from mat3ra.made.material import Material -from mat3ra.made.tools.analyze.lattice_planes import CrystalLatticePlanesMaterialAnalyzer -from mat3ra.made.tools.analyze.slab import SlabMaterialAnalyzer -from mat3ra.made.tools.build import MaterialWithBuildMetadata -from mat3ra.made.tools.build.pristine_structures.two_dimensional.slab import SlabBuilder from mat3ra.made.tools.helpers import ( create_atomic_layers, create_defect_point_vacancy, create_grain_boundary_linear, create_grain_boundary_planar, create_interface_commensurate, - create_interface_twisted, create_interface_zsl, create_slab, get_slab_terminations, ) from .fixtures.bulk import BULK_Ni_PRIMITIVE -from .fixtures.slab import SI_CONVENTIONAL_SLAB_001 +from .fixtures.slab import ATOMIC_LAYERS_NI_001 from .utils import assert_two_entities_deep_almost_equal -INPUT_ID = "platform-id-abc" MILLER_INDICES = (0, 0, 1) +# Ni primitive differs from its own conventional cell, so a builder that conventionalizes fails. +BULK_Ni_PRIMITIVE_WITH_ID: Dict[str, Any] = {**BULK_Ni_PRIMITIVE, "_id": "platform-id-abc"} - -def make_input(config: Dict[str, Any]) -> Material: - # _id names one exact material, so it has to survive alongside the hashes. - return Material.create({**config, "_id": INPUT_ID}) - - -def get_conventional_lattice_hash(crystal: Material) -> str: - analyzer = CrystalLatticePlanesMaterialAnalyzer(material=crystal, miller_indices=MILLER_INDICES) - return analyzer.material_with_conventional_lattice.hash - - -def collect_source_crystals(node: Any, crystals: List[Dict[str, Any]]) -> None: - """ - A source crystal is one recorded next to Miller indices (a lattice-planes configuration) or as - the host of a defect configuration. A vacuum's `crystal` is a built intermediate, not a source. - """ - if isinstance(node, dict): - if isinstance(node.get("crystal"), dict) and "miller_indices" in node: - crystals.append(node["crystal"]) - if node.get("merge_components"): - # Defect configurations record the host material first; the rest are the defect parts. - crystals.append(node["merge_components"][0]) - for value in node.values(): - collect_source_crystals(value, crystals) - elif isinstance(node, list): - for value in node: - collect_source_crystals(value, crystals) - - -def get_recorded_source_crystals(material: Material) -> List[Dict[str, Any]]: - crystals: List[Dict[str, Any]] = [] - collect_source_crystals(material.model_dump()["metadata"]["build"], crystals) - return crystals - - -# Every input is a cell whose conventional form differs from itself, so each case can actually fail -# the criterion it asserts. BUILDERS = { "slab": lambda c: create_slab(crystal=c, miller_indices=MILLER_INDICES, number_of_layers=3), "interface_zsl": lambda c: create_interface_zsl(substrate_crystal=c, film_crystal=c, max_area=50.0), @@ -77,91 +28,46 @@ def get_recorded_source_crystals(material: Material) -> List[Dict[str, Any]]: material=c, target_angle=13.0, angle_tolerance=0.5, max_repetition_int=5 ), "grain_boundary_planar": lambda c: create_grain_boundary_planar( - phase_1_material=c, - phase_1_miller_indices=MILLER_INDICES, - phase_2_miller_indices=(0, 1, 1), - max_area=100.0, + phase_1_material=c, phase_1_miller_indices=MILLER_INDICES, phase_2_miller_indices=(0, 1, 1), max_area=100.0 ), "grain_boundary_linear": lambda c: create_grain_boundary_linear( material=c, target_angle=13.0, angle_tolerance=0.5, max_repetition_int=5, gap=1.0 ), - # Already correct before this ticket -- here as a regression guard. "point_defect_vacancy": lambda c: create_defect_point_vacancy(c, [0.0, 0.0, 0.0], "closest_site"), } -@pytest.mark.parametrize("build", BUILDERS.values(), ids=BUILDERS.keys()) -def test_recorded_crystal_is_the_input(build): - crystal = make_input(BULK_Ni_PRIMITIVE) - assert get_conventional_lattice_hash(crystal) != crystal.hash, "input must differ from its conventional cell" - - recorded = get_recorded_source_crystals(build(crystal)) - - assert recorded, "builder recorded no source crystal" - for entry in recorded: - assert entry["hash"] == crystal.hash - assert entry["scaledHash"] == crystal.scaled_hash - assert entry["_id"] == INPUT_ID - - -def test_twisted_interface_still_honours_use_conventional_cell(): - # TwistedNanoribbonsInterfaceAnalyzer reads atomic_layers.crystal as geometry and never calls - # the builder, so create_interface_twisted has to conventionalize up front. If these two ever - # produce the same structure, that transform was moved somewhere the analyzer cannot see it. - crystal = Material.create(BULK_Ni_PRIMITIVE) - - from_conventional = create_interface_twisted( - material1=crystal, material2=crystal, angle=10.0, use_conventional_cell=True - ) - from_input = create_interface_twisted(material1=crystal, material2=crystal, angle=10.0, use_conventional_cell=False) +def get_recorded_source_crystals(node: Any) -> List[Dict[str, Any]]: + """Crystals recorded next to Miller indices, plus defect hosts. A vacuum's is a built intermediate.""" + if isinstance(node, list): + return [crystal for item in node for crystal in get_recorded_source_crystals(item)] + if not isinstance(node, dict): + return [] + crystals = [] + if isinstance(node.get("crystal"), dict) and "miller_indices" in node: + crystals.append(node["crystal"]) + if node.get("merge_components"): + crystals.append(node["merge_components"][0]) + return crystals + [c for value in node.values() for c in get_recorded_source_crystals(value)] - assert from_conventional.hash != from_input.hash +@pytest.mark.parametrize("build", BUILDERS.values(), ids=BUILDERS.keys()) +def test_recorded_crystal_is_the_input(build): + material = Material.create(BULK_Ni_PRIMITIVE_WITH_ID) -def test_create_atomic_layers_does_not_conventionalize_the_input(): - # create_atomic_layers builds the configuration directly, where the schema default is True. - # Without the explicit False it would silently start conventionalizing. - crystal = Material.create(BULK_Ni_PRIMITIVE) - conventional = CrystalLatticePlanesMaterialAnalyzer( - material=crystal, miller_indices=MILLER_INDICES - ).material_with_conventional_lattice - termination = get_slab_terminations(crystal, MILLER_INDICES)[0] - - assert ( - create_atomic_layers(crystal, MILLER_INDICES, termination=termination).hash - != create_atomic_layers(conventional, MILLER_INDICES, termination=termination).hash - ) - - -def test_configuration_saved_before_this_change_does_not_round_trip(): - # Before this change `from_parameters` never forwarded the flag, so EVERY stored configuration - # reads "use_conventional_cell": true -- including slabs built from the primitive cell. Now that - # _generate honors the flag, those stored configurations rebuild conventionalized instead of - # reproducing themselves. Accepted consequence of fixing the ticket's root cause 2, pinned here - # so it is a recorded decision rather than a surprise: rebuild such a slab from its crystal, - # not from its metadata. - crystal = Material.create(BULK_Ni_PRIMITIVE) - slab = create_slab(crystal=crystal, miller_indices=MILLER_INDICES, number_of_layers=3, use_conventional_cell=False) - stored = slab.model_dump(mode="json") - stored["metadata"]["build"][-1]["configuration"]["stack_components"][0]["use_conventional_cell"] = True - - rebuilt = SlabBuilder().get_material( - SlabMaterialAnalyzer(material=MaterialWithBuildMetadata.create(stored)).build_configuration - ) + built = build(material) - conventionalized = create_slab( - crystal=crystal, miller_indices=MILLER_INDICES, number_of_layers=3, use_conventional_cell=True - ) - assert rebuilt.hash != slab.hash - assert rebuilt.hash == conventionalized.hash + recorded = get_recorded_source_crystals(built.model_dump()["metadata"]["build"]) + assert recorded + for crystal in recorded: + assert_two_entities_deep_almost_equal(crystal, material) -def test_rebuilding_a_slab_from_its_own_metadata_is_stable(): - # A slab saved before this change records the conventional cell next to use_conventional_cell - # True; conventionalizing an already-conventional cell has to be a no-op. - slab = MaterialWithBuildMetadata.create(SI_CONVENTIONAL_SLAB_001) +@pytest.mark.parametrize("material_config, expected_material_config", [(BULK_Ni_PRIMITIVE, ATOMIC_LAYERS_NI_001)]) +def test_create_atomic_layers(material_config, expected_material_config): + material = Material.create(material_config) + termination = get_slab_terminations(material, MILLER_INDICES)[0] - rebuilt = SlabBuilder().get_material(SlabMaterialAnalyzer(material=slab).build_configuration) + atomic_layers = create_atomic_layers(material, MILLER_INDICES, termination=termination) - assert_two_entities_deep_almost_equal(rebuilt.basis, slab.basis) - assert_two_entities_deep_almost_equal(rebuilt.lattice, slab.lattice) + assert_two_entities_deep_almost_equal(atomic_layers, expected_material_config) From 536f0ff2879b2e2ca1f187f16f6d5d9ef050ea6b Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Mon, 24 Aug 2026 20:50:27 -0700 Subject: [PATCH 3/8] Chore: drop docstring notes about the unhonored flag [SOF-8034] use_conventional_cell was already a no-op on the commensurate and grain-boundary paths before this branch -- the helper conventionalized, then from_parameters conventionalized again on the schema default. Documenting that in the docstrings of functions this branch only touches to delete a redundant transform is scope creep, and pointing code at a ticket number for it is worse. Those four files are now pure deletion. The dead parameter is recorded as a follow-up finding instead. Co-Authored-By: Claude Opus 5 (1M context) --- .../two_dimensional/interface/commensurate/helpers.py | 6 +----- .../one_dimensional/grain_boundary_linear/helpers.py | 2 -- .../two_dimensional/grain_boundary_planar/helpers.py | 4 ---- 3 files changed, 1 insertion(+), 11 deletions(-) diff --git a/src/py/mat3ra/made/tools/build/compound_pristine_structures/two_dimensional/interface/commensurate/helpers.py b/src/py/mat3ra/made/tools/build/compound_pristine_structures/two_dimensional/interface/commensurate/helpers.py index 9dd63783b..f8e42bb56 100644 --- a/src/py/mat3ra/made/tools/build/compound_pristine_structures/two_dimensional/interface/commensurate/helpers.py +++ b/src/py/mat3ra/made/tools/build/compound_pristine_structures/two_dimensional/interface/commensurate/helpers.py @@ -44,8 +44,6 @@ def get_commensurate_strained_configurations( number_of_layers (int): Number of atomic layers in the slab. vacuum (float): Size of the vacuum layer in Angstroms. match_id (int): ID of the match to use (0 for first match). - use_conventional_cell (bool): Accepted for signature compatibility but not honored -- - the slab configuration takes the schema default and conventionalizes either way. Returns: Tuple[List[SlabStrainedSupercellConfiguration], float]: @@ -118,9 +116,7 @@ def create_interface_commensurate( number_of_layers (int): Number of atomic layers in the slab. vacuum (float): Size of the vacuum layer in Angstroms. match_id (int): ID of the match to use (0 for first match). - use_conventional_cell (bool): Accepted for signature compatibility but not honored -- - the slab configuration below takes the schema default, which conventionalizes - either way. Making it live changes built geometry; see SOF-8034. + use_conventional_cell (bool): Whether to use the conventional cell for the material. remove_overlapping_atoms (bool): Whether to resolve overlapping atoms in the interface after creation. tolerance_for_overlap (float): Tolerance for resolving overlapping atoms, in Angstroms. Returns: diff --git a/src/py/mat3ra/made/tools/build/defective_structures/one_dimensional/grain_boundary_linear/helpers.py b/src/py/mat3ra/made/tools/build/defective_structures/one_dimensional/grain_boundary_linear/helpers.py index e9a335e22..04e6d072f 100644 --- a/src/py/mat3ra/made/tools/build/defective_structures/one_dimensional/grain_boundary_linear/helpers.py +++ b/src/py/mat3ra/made/tools/build/defective_structures/one_dimensional/grain_boundary_linear/helpers.py @@ -45,8 +45,6 @@ def create_grain_boundary_linear( miller_indices (Tuple[int, int, int]): Miller indices for the slab surface. number_of_layers (int): Number of atomic layers in the slab. vacuum (float): Size of the vacuum layer in Angstroms. - use_conventional_cell (bool): Accepted for signature compatibility but not honored -- - the slab configuration takes the schema default and conventionalizes either way. Returns: Material: The grain boundary material. diff --git a/src/py/mat3ra/made/tools/build/defective_structures/two_dimensional/grain_boundary_planar/helpers.py b/src/py/mat3ra/made/tools/build/defective_structures/two_dimensional/grain_boundary_planar/helpers.py index c5ad3c187..5b652de84 100644 --- a/src/py/mat3ra/made/tools/build/defective_structures/two_dimensional/grain_boundary_planar/helpers.py +++ b/src/py/mat3ra/made/tools/build/defective_structures/two_dimensional/grain_boundary_planar/helpers.py @@ -28,7 +28,6 @@ def create_grain_boundary_planar( Args: phase_1_material: The material to use for each phase of the grain boundary - phase_2_material: Material for the second phase; defaults to phase_1_material phase_1_miller_indices: Miller indices for phase 1 phase_2_miller_indices: Miller indices for phase 2 phase_1_thickness: Number of layers for phase 1 @@ -40,9 +39,6 @@ def create_grain_boundary_planar( max_area_ratio_tol: Area ratio tolerance for ZSL matching max_length_tol: Length tolerance for ZSL matching max_angle_tol: Angle tolerance for ZSL matching - use_conventional_cell: Accepted for signature compatibility but not honored -- the - analyzer's slab configurations take the schema default, which conventionalizes - either way. Making it live changes built geometry; see SOF-8034. Returns: Material: The grain boundary material From 40aae2614ba0a2736d99ce2fca58620b73139a4f Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Mon, 24 Aug 2026 23:26:44 -0700 Subject: [PATCH 4/8] Fix: stop conventionalizing in the twisted interface helper [SOF-8034] The last of the five entry points. The rebind ran before the configuration was built, so the recorded crystal was the conventional cell rather than the input. Both SlabConfiguration.from_parameters calls below already forwarded use_conventional_cell, so removing the rebind is enough; the import goes with it. Co-Authored-By: Claude Opus 5 (1M context) --- .../two_dimensional/interface/twisted/helpers.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/py/mat3ra/made/tools/build/compound_pristine_structures/two_dimensional/interface/twisted/helpers.py b/src/py/mat3ra/made/tools/build/compound_pristine_structures/two_dimensional/interface/twisted/helpers.py index 2cdfe99b8..8df181c8a 100644 --- a/src/py/mat3ra/made/tools/build/compound_pristine_structures/two_dimensional/interface/twisted/helpers.py +++ b/src/py/mat3ra/made/tools/build/compound_pristine_structures/two_dimensional/interface/twisted/helpers.py @@ -6,7 +6,6 @@ from mat3ra.made.material import Material from .. import InterfaceBuilder, InterfaceConfiguration from ......analyze.interface import TwistedNanoribbonsInterfaceAnalyzer -from ......analyze.lattice import get_material_with_conventional_lattice from ......build_components import MaterialWithBuildMetadata from mat3ra.made.tools.build.pristine_structures.two_dimensional.slab.configuration import SlabConfiguration @@ -34,9 +33,6 @@ def create_interface_twisted( Returns: Material: The twisted interface material. """ - if use_conventional_cell: - material1 = get_material_with_conventional_lattice(material1) - material2 = get_material_with_conventional_lattice(material2) slab1 = SlabConfiguration.from_parameters( material_or_dict=material1, miller_indices=(0, 0, 1), From 4aa0c2952743b89dd94adfdba05af1ddd019025c Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Mon, 24 Aug 2026 23:33:07 -0700 Subject: [PATCH 5/8] Revert "Fix: stop conventionalizing in the twisted interface helper [SOF-8034]" This reverts commit 40aae2614ba0a2736d99ce2fca58620b73139a4f. --- .../two_dimensional/interface/twisted/helpers.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/py/mat3ra/made/tools/build/compound_pristine_structures/two_dimensional/interface/twisted/helpers.py b/src/py/mat3ra/made/tools/build/compound_pristine_structures/two_dimensional/interface/twisted/helpers.py index 8df181c8a..2cdfe99b8 100644 --- a/src/py/mat3ra/made/tools/build/compound_pristine_structures/two_dimensional/interface/twisted/helpers.py +++ b/src/py/mat3ra/made/tools/build/compound_pristine_structures/two_dimensional/interface/twisted/helpers.py @@ -6,6 +6,7 @@ from mat3ra.made.material import Material from .. import InterfaceBuilder, InterfaceConfiguration from ......analyze.interface import TwistedNanoribbonsInterfaceAnalyzer +from ......analyze.lattice import get_material_with_conventional_lattice from ......build_components import MaterialWithBuildMetadata from mat3ra.made.tools.build.pristine_structures.two_dimensional.slab.configuration import SlabConfiguration @@ -33,6 +34,9 @@ def create_interface_twisted( Returns: Material: The twisted interface material. """ + if use_conventional_cell: + material1 = get_material_with_conventional_lattice(material1) + material2 = get_material_with_conventional_lattice(material2) slab1 = SlabConfiguration.from_parameters( material_or_dict=material1, miller_indices=(0, 0, 1), From e22d539c168a4cd17f0fc63bd3b6ce67befad104 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Tue, 25 Aug 2026 00:07:20 -0700 Subject: [PATCH 6/8] Update: guard the twisted conventional-cell geometry [SOF-8034] Nothing in the suite caught the 8-atom -> 1-atom collapse when the transform is moved out of the twisted helper: the only existing twisted test uses graphene, whose conventional cell is itself, so conventionalizing there is a no-op. The regression was committed on this branch yesterday and caught by hand, not by CI. Pin the two scalars that move. Verified: re-applying the deletion fails this test and nothing else -- 1 failed, 310 passed. Also: do not recurse into an already-collected crystal (an input carrying build history would contribute the crystals nested inside it), and spell out lambda parameters per AGENTS.md HARD RULE 4. Co-Authored-By: Claude Opus 5 (1M context) --- ...st_build_metadata_records_input_crystal.py | 46 +++++++++++++++---- 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/tests/py/unit/test_build_metadata_records_input_crystal.py b/tests/py/unit/test_build_metadata_records_input_crystal.py index 971d1046a..cd8df4873 100644 --- a/tests/py/unit/test_build_metadata_records_input_crystal.py +++ b/tests/py/unit/test_build_metadata_records_input_crystal.py @@ -8,6 +8,7 @@ create_grain_boundary_linear, create_grain_boundary_planar, create_interface_commensurate, + create_interface_twisted, create_interface_zsl, create_slab, get_slab_terminations, @@ -22,18 +23,23 @@ BULK_Ni_PRIMITIVE_WITH_ID: Dict[str, Any] = {**BULK_Ni_PRIMITIVE, "_id": "platform-id-abc"} BUILDERS = { - "slab": lambda c: create_slab(crystal=c, miller_indices=MILLER_INDICES, number_of_layers=3), - "interface_zsl": lambda c: create_interface_zsl(substrate_crystal=c, film_crystal=c, max_area=50.0), - "interface_commensurate": lambda c: create_interface_commensurate( - material=c, target_angle=13.0, angle_tolerance=0.5, max_repetition_int=5 + "slab": lambda crystal: create_slab(crystal=crystal, miller_indices=MILLER_INDICES, number_of_layers=3), + "interface_zsl": lambda crystal: create_interface_zsl( + substrate_crystal=crystal, film_crystal=crystal, max_area=50.0 ), - "grain_boundary_planar": lambda c: create_grain_boundary_planar( - phase_1_material=c, phase_1_miller_indices=MILLER_INDICES, phase_2_miller_indices=(0, 1, 1), max_area=100.0 + "interface_commensurate": lambda crystal: create_interface_commensurate( + material=crystal, target_angle=13.0, angle_tolerance=0.5, max_repetition_int=5 ), - "grain_boundary_linear": lambda c: create_grain_boundary_linear( - material=c, target_angle=13.0, angle_tolerance=0.5, max_repetition_int=5, gap=1.0 + "grain_boundary_planar": lambda crystal: create_grain_boundary_planar( + phase_1_material=crystal, + phase_1_miller_indices=MILLER_INDICES, + phase_2_miller_indices=(0, 1, 1), + max_area=100.0, ), - "point_defect_vacancy": lambda c: create_defect_point_vacancy(c, [0.0, 0.0, 0.0], "closest_site"), + "grain_boundary_linear": lambda crystal: create_grain_boundary_linear( + material=crystal, target_angle=13.0, angle_tolerance=0.5, max_repetition_int=5, gap=1.0 + ), + "point_defect_vacancy": lambda crystal: create_defect_point_vacancy(crystal, [0.0, 0.0, 0.0], "closest_site"), } @@ -44,11 +50,18 @@ def get_recorded_source_crystals(node: Any) -> List[Dict[str, Any]]: if not isinstance(node, dict): return [] crystals = [] + collected = set() if isinstance(node.get("crystal"), dict) and "miller_indices" in node: crystals.append(node["crystal"]) + collected.add("crystal") if node.get("merge_components"): crystals.append(node["merge_components"][0]) - return crystals + [c for value in node.values() for c in get_recorded_source_crystals(value)] + collected.add("merge_components") + # Do not descend into what was just collected: an input with its own build history would + # otherwise contribute the crystals nested inside it. + return crystals + [ + found for key, value in node.items() if key not in collected for found in get_recorded_source_crystals(value) + ] @pytest.mark.parametrize("build", BUILDERS.values(), ids=BUILDERS.keys()) @@ -63,6 +76,19 @@ def test_recorded_crystal_is_the_input(build): assert_two_entities_deep_almost_equal(crystal, material) +@pytest.mark.parametrize("material_config, expected_number_of_atoms, expected_gamma", [(BULK_Ni_PRIMITIVE, 8, 90.0)]) +def test_create_interface_twisted_uses_conventional_cell(material_config, expected_number_of_atoms, expected_gamma): + # TwistedNanoribbonsInterfaceAnalyzer reads atomic_layers.crystal as geometry and never calls + # the builder, so this helper has to conventionalize up front. Moving that into the builder + # collapses the result onto the primitive one -- 8 atoms to 1, gamma 90 to 120. + crystal = Material.create(material_config) + + interface = create_interface_twisted(material1=crystal, material2=crystal, angle=10.0, use_conventional_cell=True) + + assert len(interface.basis.elements.values) == expected_number_of_atoms + assert interface.lattice.gamma == pytest.approx(expected_gamma) + + @pytest.mark.parametrize("material_config, expected_material_config", [(BULK_Ni_PRIMITIVE, ATOMIC_LAYERS_NI_001)]) def test_create_atomic_layers(material_config, expected_material_config): material = Material.create(material_config) From ccbc0ebb8f1c9565541651e370f7a2379b5681f8 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Tue, 25 Aug 2026 09:22:01 -0700 Subject: [PATCH 7/8] Chore: drop explanatory comments [SOF-8034] Comment-only; no code changed. Co-Authored-By: Claude Opus 5 (1M context) --- .../two_dimensional/slab/configuration.py | 3 --- .../atomic_layers_unique_repeated/helpers.py | 1 - .../two_dimensional/crystal_lattice_planes/builder.py | 3 --- tests/py/unit/fixtures/slab.py | 2 -- tests/py/unit/test_build_metadata_records_input_crystal.py | 6 ------ tests/py/unit/test_tools_build_slab.py | 1 - 6 files changed, 16 deletions(-) diff --git a/src/py/mat3ra/made/tools/build/pristine_structures/two_dimensional/slab/configuration.py b/src/py/mat3ra/made/tools/build/pristine_structures/two_dimensional/slab/configuration.py index 444cd4100..8965fd832 100644 --- a/src/py/mat3ra/made/tools/build/pristine_structures/two_dimensional/slab/configuration.py +++ b/src/py/mat3ra/made/tools/build/pristine_structures/two_dimensional/slab/configuration.py @@ -65,9 +65,6 @@ def from_parameters( termination_bottom_formula (Optional[str]): Formula of the bottom termination to use for the slab. vacuum (float): Size of the vacuum layer in Angstroms. - use_conventional_cell (bool): Whether the slab is built in the conventional cell. - Stored on the configuration and applied at build time; `crystal` always keeps - the material that was passed in. Returns: SlabConfiguration: The created slab configuration. diff --git a/src/py/mat3ra/made/tools/build_components/entities/reusable/two_dimensional/atomic_layers_unique_repeated/helpers.py b/src/py/mat3ra/made/tools/build_components/entities/reusable/two_dimensional/atomic_layers_unique_repeated/helpers.py index 70e4a5b06..48ef22a43 100644 --- a/src/py/mat3ra/made/tools/build_components/entities/reusable/two_dimensional/atomic_layers_unique_repeated/helpers.py +++ b/src/py/mat3ra/made/tools/build_components/entities/reusable/two_dimensional/atomic_layers_unique_repeated/helpers.py @@ -32,7 +32,6 @@ def create_atomic_layers( atomic_layers_config = AtomicLayersUniqueRepeatedConfiguration( crystal=material, miller_indices=miller_indices, - # Explicit: the schema defaults this True, and this path has never conventionalized. use_conventional_cell=False, termination_top=termination, number_of_repetitions=number_of_layers, diff --git a/src/py/mat3ra/made/tools/build_components/entities/reusable/two_dimensional/crystal_lattice_planes/builder.py b/src/py/mat3ra/made/tools/build_components/entities/reusable/two_dimensional/crystal_lattice_planes/builder.py index 531417388..f6745cb9f 100644 --- a/src/py/mat3ra/made/tools/build_components/entities/reusable/two_dimensional/crystal_lattice_planes/builder.py +++ b/src/py/mat3ra/made/tools/build_components/entities/reusable/two_dimensional/crystal_lattice_planes/builder.py @@ -21,9 +21,6 @@ def get_analyzer(self, configuration: CrystalLatticePlanesConfiguration) -> Crys def _generate(self, configuration: CrystalLatticePlanesConfiguration) -> MaterialWithBuildMetadata: crystal_lattice_planes_analyzer = self.get_analyzer(configuration) miller_supercell_matrix = crystal_lattice_planes_analyzer.miller_supercell_matrix - # The configuration stores the crystal that was passed in, untransformed, so that build - # metadata records the caller's own material. Conventionalizing happens here instead -- - # any consumer reading configuration.crystal as geometry must do its own transform. crystal = configuration.crystal if configuration.use_conventional_cell: crystal = crystal_lattice_planes_analyzer.material_with_conventional_lattice diff --git a/tests/py/unit/fixtures/slab.py b/tests/py/unit/fixtures/slab.py index 27dee0c7a..4aae167f0 100644 --- a/tests/py/unit/fixtures/slab.py +++ b/tests/py/unit/fixtures/slab.py @@ -606,8 +606,6 @@ } -# create_atomic_layers builds its configuration directly, where use_conventional_cell defaults True; -# this pins the primitive cell it has always produced. ATOMIC_LAYERS_NI_001: Dict[str, Any] = { "name": "Ni(001), termination Ni_P4/mmm_2", "basis": { diff --git a/tests/py/unit/test_build_metadata_records_input_crystal.py b/tests/py/unit/test_build_metadata_records_input_crystal.py index cd8df4873..b205820fb 100644 --- a/tests/py/unit/test_build_metadata_records_input_crystal.py +++ b/tests/py/unit/test_build_metadata_records_input_crystal.py @@ -19,7 +19,6 @@ from .utils import assert_two_entities_deep_almost_equal MILLER_INDICES = (0, 0, 1) -# Ni primitive differs from its own conventional cell, so a builder that conventionalizes fails. BULK_Ni_PRIMITIVE_WITH_ID: Dict[str, Any] = {**BULK_Ni_PRIMITIVE, "_id": "platform-id-abc"} BUILDERS = { @@ -57,8 +56,6 @@ def get_recorded_source_crystals(node: Any) -> List[Dict[str, Any]]: if node.get("merge_components"): crystals.append(node["merge_components"][0]) collected.add("merge_components") - # Do not descend into what was just collected: an input with its own build history would - # otherwise contribute the crystals nested inside it. return crystals + [ found for key, value in node.items() if key not in collected for found in get_recorded_source_crystals(value) ] @@ -78,9 +75,6 @@ def test_recorded_crystal_is_the_input(build): @pytest.mark.parametrize("material_config, expected_number_of_atoms, expected_gamma", [(BULK_Ni_PRIMITIVE, 8, 90.0)]) def test_create_interface_twisted_uses_conventional_cell(material_config, expected_number_of_atoms, expected_gamma): - # TwistedNanoribbonsInterfaceAnalyzer reads atomic_layers.crystal as geometry and never calls - # the builder, so this helper has to conventionalize up front. Moving that into the builder - # collapses the result onto the primitive one -- 8 atoms to 1, gamma 90 to 120. crystal = Material.create(material_config) interface = create_interface_twisted(material1=crystal, material2=crystal, angle=10.0, use_conventional_cell=True) diff --git a/tests/py/unit/test_tools_build_slab.py b/tests/py/unit/test_tools_build_slab.py index 9bcf3bc4d..4a59ee4c3 100644 --- a/tests/py/unit/test_tools_build_slab.py +++ b/tests/py/unit/test_tools_build_slab.py @@ -144,7 +144,6 @@ def get_slab_with_builder( atomic_layers_repeated_configuration = AtomicLayersUniqueRepeatedConfiguration( crystal=material, miller_indices=miller_indices, - # Explicit: the schema defaults this True, and this helper builds primitive slabs. use_conventional_cell=False, termination_top=termination_top, termination_bottom=termination_bottom, From fee0fd775cb51ee6d9d087143366106f8e35950a Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Tue, 25 Aug 2026 19:34:19 -0700 Subject: [PATCH 8/8] chore: drop unnessesary --- tests/py/unit/fixtures/slab.py | 22 ----- ...st_build_metadata_records_input_crystal.py | 93 ------------------- 2 files changed, 115 deletions(-) delete mode 100644 tests/py/unit/test_build_metadata_records_input_crystal.py diff --git a/tests/py/unit/fixtures/slab.py b/tests/py/unit/fixtures/slab.py index 4aae167f0..f4f9ea42b 100644 --- a/tests/py/unit/fixtures/slab.py +++ b/tests/py/unit/fixtures/slab.py @@ -604,25 +604,3 @@ "type": "TRI", }, } - - -ATOMIC_LAYERS_NI_001: Dict[str, Any] = { - "name": "Ni(001), termination Ni_P4/mmm_2", - "basis": { - "elements": [{"id": 0, "value": "Ni"}], - "coordinates": [{"id": 0, "value": [0.0, 0.0, 1e-06]}], - "units": "crystal", - "labels": [], - "constraints": [], - }, - "lattice": { - "a": 2.478974, - "b": 2.478974, - "c": 2.478974, - "alpha": 60.0, - "beta": 60.0, - "gamma": 60.0, - "units": {"length": "angstrom", "angle": "degree"}, - "type": "TRI", - }, -} diff --git a/tests/py/unit/test_build_metadata_records_input_crystal.py b/tests/py/unit/test_build_metadata_records_input_crystal.py deleted file mode 100644 index b205820fb..000000000 --- a/tests/py/unit/test_build_metadata_records_input_crystal.py +++ /dev/null @@ -1,93 +0,0 @@ -from typing import Any, Dict, List - -import pytest -from mat3ra.made.material import Material -from mat3ra.made.tools.helpers import ( - create_atomic_layers, - create_defect_point_vacancy, - create_grain_boundary_linear, - create_grain_boundary_planar, - create_interface_commensurate, - create_interface_twisted, - create_interface_zsl, - create_slab, - get_slab_terminations, -) - -from .fixtures.bulk import BULK_Ni_PRIMITIVE -from .fixtures.slab import ATOMIC_LAYERS_NI_001 -from .utils import assert_two_entities_deep_almost_equal - -MILLER_INDICES = (0, 0, 1) -BULK_Ni_PRIMITIVE_WITH_ID: Dict[str, Any] = {**BULK_Ni_PRIMITIVE, "_id": "platform-id-abc"} - -BUILDERS = { - "slab": lambda crystal: create_slab(crystal=crystal, miller_indices=MILLER_INDICES, number_of_layers=3), - "interface_zsl": lambda crystal: create_interface_zsl( - substrate_crystal=crystal, film_crystal=crystal, max_area=50.0 - ), - "interface_commensurate": lambda crystal: create_interface_commensurate( - material=crystal, target_angle=13.0, angle_tolerance=0.5, max_repetition_int=5 - ), - "grain_boundary_planar": lambda crystal: create_grain_boundary_planar( - phase_1_material=crystal, - phase_1_miller_indices=MILLER_INDICES, - phase_2_miller_indices=(0, 1, 1), - max_area=100.0, - ), - "grain_boundary_linear": lambda crystal: create_grain_boundary_linear( - material=crystal, target_angle=13.0, angle_tolerance=0.5, max_repetition_int=5, gap=1.0 - ), - "point_defect_vacancy": lambda crystal: create_defect_point_vacancy(crystal, [0.0, 0.0, 0.0], "closest_site"), -} - - -def get_recorded_source_crystals(node: Any) -> List[Dict[str, Any]]: - """Crystals recorded next to Miller indices, plus defect hosts. A vacuum's is a built intermediate.""" - if isinstance(node, list): - return [crystal for item in node for crystal in get_recorded_source_crystals(item)] - if not isinstance(node, dict): - return [] - crystals = [] - collected = set() - if isinstance(node.get("crystal"), dict) and "miller_indices" in node: - crystals.append(node["crystal"]) - collected.add("crystal") - if node.get("merge_components"): - crystals.append(node["merge_components"][0]) - collected.add("merge_components") - return crystals + [ - found for key, value in node.items() if key not in collected for found in get_recorded_source_crystals(value) - ] - - -@pytest.mark.parametrize("build", BUILDERS.values(), ids=BUILDERS.keys()) -def test_recorded_crystal_is_the_input(build): - material = Material.create(BULK_Ni_PRIMITIVE_WITH_ID) - - built = build(material) - - recorded = get_recorded_source_crystals(built.model_dump()["metadata"]["build"]) - assert recorded - for crystal in recorded: - assert_two_entities_deep_almost_equal(crystal, material) - - -@pytest.mark.parametrize("material_config, expected_number_of_atoms, expected_gamma", [(BULK_Ni_PRIMITIVE, 8, 90.0)]) -def test_create_interface_twisted_uses_conventional_cell(material_config, expected_number_of_atoms, expected_gamma): - crystal = Material.create(material_config) - - interface = create_interface_twisted(material1=crystal, material2=crystal, angle=10.0, use_conventional_cell=True) - - assert len(interface.basis.elements.values) == expected_number_of_atoms - assert interface.lattice.gamma == pytest.approx(expected_gamma) - - -@pytest.mark.parametrize("material_config, expected_material_config", [(BULK_Ni_PRIMITIVE, ATOMIC_LAYERS_NI_001)]) -def test_create_atomic_layers(material_config, expected_material_config): - material = Material.create(material_config) - termination = get_slab_terminations(material, MILLER_INDICES)[0] - - atomic_layers = create_atomic_layers(material, MILLER_INDICES, termination=termination) - - assert_two_entities_deep_almost_equal(atomic_layers, expected_material_config)