From 8b7e84568c108b2898a6c9db334e99bbcca69cc0 Mon Sep 17 00:00:00 2001 From: so2koo Date: Thu, 30 Oct 2025 16:22:04 -0700 Subject: [PATCH 01/10] Add InterfaceCalc --- src/matcalc/_interface.py | 245 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 src/matcalc/_interface.py diff --git a/src/matcalc/_interface.py b/src/matcalc/_interface.py new file mode 100644 index 00000000..c2acf4d6 --- /dev/null +++ b/src/matcalc/_interface.py @@ -0,0 +1,245 @@ +"""Interface structure/energy calculations.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import numpy as np +from pymatgen.analysis.interfaces.zsl import ZSLGenerator +from pymatgen.analysis.interfaces.coherent_interfaces import CoherentInterfaceBuilder +from pymatgen.analysis.structure_matcher import StructureMatcher + + +from ._base import PropCalc +from ._relaxation import RelaxCalc + +if TYPE_CHECKING: + from ase import Atoms + from ase.calculators.calculator import Calculator + from ase.optimize.optimize import Optimizer + from pymatgen.core import Structure + + +class InterfaceCalc(PropCalc): + """ + This class generates all possible coherent interfaces between two bulk structures + given their miller indices, relaxes them, and computes their interfacial energies. + """ + + def __init__( + self, + calculator: Calculator | str, + *, + relax_bulk: bool = True, + relax_interface: bool = True, + fmax: float = 0.1, + optimizer: str | Optimizer = "BFGS", + max_steps: int = 500, + relax_calc_kwargs: dict | None = None, + ) -> None: + """Initialize the instance of the class. + + Parameters: + calculator (Calculator | str): An ASE calculator object used to perform energy and force + calculations. If string is provided, the corresponding universal calculator is loaded. + relax_bulk (bool, optional): Whether to relax the bulk structures before interface calculations. Defaults to True. + relax_interface (bool, optional): Whether to relax the interface structures. Defaults to True. + fmax (float, optional): The maximum force tolerance for convergence. Defaults to 0.1. + optimizer (str | Optimizer, optional): The optimization algorithm to use. Defaults to "BFGS". + max_steps (int, optional): The maximum number of optimization steps. Defaults to 500. + relax_calc_kwargs: Additional keyword arguments passed to the + class:`RelaxCalc` constructor for both bulk and interface. Default is None. + + Returns: + None + """ + self.calculator = calculator + self.relax_bulk = relax_bulk + self.relax_interface = relax_interface + self.fmax = fmax + self.optimizer = optimizer + self.max_steps = max_steps + self.relax_calc_kwargs = relax_calc_kwargs + + def calc_interfaces( + self, + film_bulk: Structure, + substrate_bulk: Structure, + film_miller: tuple[int, int, int], + substrate_miller: tuple[int, int, int], + zslgen: ZSLGenerator | None = None, + zsl_kwargs: dict | None = None, + cib_kwargs: dict | None = None, + **kwargs: dict[str, Any], + ) -> list[dict[str, Any]]: + """Calculate all possible coherent interfaces between two bulk structures. + + Parameters: + film_bulk (Structure): The bulk structure of the film material. + substrate_bulk (Structure): The bulk structure of the substrate material. + film_miller (tuple[int, int, int]): The Miller index for the film surface. + substrate_miller (tuple[int, int, int]): The Miller index for the substrate surface. + zslgen (ZSLGenerator | None, optional): An instance of ZSLGenerator to use for generating supercells. + zsl_kwargs (dict | None, optional): Additional keyword arguments to pass to the ZSLGenerator. + cib_kwargs (dict | None, optional): Additional keyword arguments to pass to the CoherentInterfaceBuilder. + **kwargs (dict[str, Any]): Additional keyword arguments. + + Returns: + dict: A list of dictionaries containing the calculated film, substrate, interface. + """ + + cib = CoherentInterfaceBuilder( + film_structure=film_bulk, + substrate_structure=substrate_bulk, + film_miller=film_miller, + substrate_miller=substrate_miller, + zslgen=zslgen, + **(cib_kwargs or {}), + ) + + terminations = cib.terminations + all_interfaces: list = [] + for t in terminations: + interfaces_for_termination = cib.get_interfaces(termination=t) + all_interfaces.extend(list(interfaces_for_termination)) + + if not all_interfaces: + raise ValueError( + "No interfaces found with the given parameters. Adjust the ZSL parameters to find more matches." + ) + + # Group similar / duplicate interfaces using StructureMatcher and keep one representative per group + matcher = StructureMatcher() + groups: list[list] = [] + for i in all_interfaces: + placed = False + for g in groups: + if matcher.fit(i, g[0]): + g.append(i) + placed = True + break + if not placed: + groups.append([i]) + unique_interfaces = [g[0] for g in groups] + + film_bulk = film_bulk.to_conventional() + substrate_bulk = substrate_bulk.to_conventional() + + relaxer_bulk = RelaxCalc( + calculator=self.calculator, + fmax=self.fmax, + max_steps=self.max_steps, + relax_cell=self.relax_bulk, + relax_atoms=self.relax_bulk, + optimizer=self.optimizer, + **(self.relax_calc_kwargs or {}), + ) + film_opt = relaxer_bulk.calc(film_bulk) + substrate_opt = relaxer_bulk.calc(substrate_bulk) + + interfaces = [ + { + "interface": interface, + "num_atoms": len(interface), + "film_energy_per_atom": film_opt["energy"] / len(film_bulk), + "final_film": film_opt["final_structure"], + "substrate_energy_per_atom": substrate_opt["energy"] / len(substrate_bulk), + "final_substrate": substrate_opt["final_structure"], + } + for interface in unique_interfaces + ] + + return list(self.calc_many(interfaces, **kwargs)) + + + def calc( + self, + structure: Structure | Atoms | dict[str, Any], + ) -> dict[str, Any]: + """Calculate the interfacial energy of the given interface structures and sort by the energy. + + Parameters: + structure : A dictionary containing the film, substrate, interface structures + + Returns: + dict: + - "interface" (Structure): The initial interface structure. + - "final_interface" (Structure): The relaxed interface structure. + - "interface_energy_per_atom" (float): The final energy of the relaxed interface structure. + - "num_atoms" (int): The number of atoms in the interface structure. + - "interfacial_energy" (float): The calculated interfacial energy + + """ + if not (isinstance(structure, dict) and set(structure.keys()).intersection(("bulk", "bulk_energy_per_atom"))): + raise ValueError( + "For interface calculations, structure must be a dict in one of the following formats: " + "{'film': film_struct, 'interface': interface_struct} or {'film': film_energy, 'substrate_energy': energy}." + ) + + result_dict = structure.copy() + + if "film_energy_per_atom" in structure and "substrate_energy_per_atom" in structure: + film_energy_per_atom = structure["film_energy_per_atom"] + substrate_energy_per_atom = structure["substrate_energy_per_atom"] + else: + relaxer = RelaxCalc( + calculator=self.calculator, + fmax=self.fmax, + max_steps=self.max_steps, + relax_cell=self.relax_bulk, + relax_atoms=self.relax_bulk, + optimizer=self.optimizer, + **(self.relax_calc_kwargs or {}), + ) + film_opt = relaxer.calc(structure["film_bulk"]) + film_energy_per_atom = film_opt["energy"] / len(film_opt["final_structure"]) + substrate_opt = relaxer.calc(structure["substrate_bulk"]) + substrate_energy_per_atom = substrate_opt["energy"] / len(substrate_opt["final_structure"]) + + + interface = structure["interface"] + relaxer = RelaxCalc( + calculator=self.calculator, + fmax=self.fmax, + max_steps=self.max_steps, + relax_cell=False, + relax_atoms=self.relax_interface, + optimizer=self.optimizer, + **(self.relax_calc_kwargs or {}), + ) + interface_opt = relaxer.calc(interface) + final_interface = interface_opt["final_structure"] + interface_energy = interface_opt["energy"] + + # pymatgen interface object does not include interface properties for interfacial energy calculation, define them here + + matrix = interface.lattice.matrix + area = float(np.linalg.norm(np.cross(matrix[0], matrix[1]))) + + unique_in_film = set(film_opt.symbol_set) - set(substrate_opt.symbol_set) + unique_in_substrate = set(substrate_opt.symbol_set) - set(film_opt.symbol_set) + + if unique_in_film: + unique_element = list(unique_in_film)[0] + count = film_opt.composition[unique_element] + substrate_in_interface = (interface.composition[unique_element] / count) * film_opt.num_sites + elif unique_in_substrate: + unique_element = list(unique_in_substrate)[0] + count = substrate_opt.composition[unique_element] + film_in_interface = (interface.composition[unique_element] / count) * substrate_opt.num_sites + substrate_in_interface = interface.num_sites - film_in_interface + else: + print("No unique elements found in either structure") + film_in_interface = substrate_in_interface = None + + + gamma = (interface_energy - (film_in_interface * film_energy_per_atom + substrate_in_interface * substrate_energy_per_atom)) / (2 * area) + + return result_dict | { + "interface": interface, + "final_interface": final_interface, + "interface_energy_per_atom": interface_energy/len(interface), + "num_atoms": len(interface), + "interfacial_energy": gamma, + } + \ No newline at end of file From bbd27218b510a14745892a449a023406b7bcb395 Mon Sep 17 00:00:00 2001 From: so2koo Date: Thu, 30 Oct 2025 17:16:10 -0700 Subject: [PATCH 02/10] fix --- src/matcalc/_interface.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/matcalc/_interface.py b/src/matcalc/_interface.py index c2acf4d6..6fbbef8e 100644 --- a/src/matcalc/_interface.py +++ b/src/matcalc/_interface.py @@ -68,7 +68,6 @@ def calc_interfaces( film_miller: tuple[int, int, int], substrate_miller: tuple[int, int, int], zslgen: ZSLGenerator | None = None, - zsl_kwargs: dict | None = None, cib_kwargs: dict | None = None, **kwargs: dict[str, Any], ) -> list[dict[str, Any]]: From c0da96704ce3a98f162763b697e50753477200ed Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 31 Oct 2025 00:23:28 +0000 Subject: [PATCH 03/10] pre-commit auto-fixes --- src/matcalc/_interface.py | 45 ++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 24 deletions(-) diff --git a/src/matcalc/_interface.py b/src/matcalc/_interface.py index 6fbbef8e..ff61beb3 100644 --- a/src/matcalc/_interface.py +++ b/src/matcalc/_interface.py @@ -5,11 +5,10 @@ from typing import TYPE_CHECKING, Any import numpy as np -from pymatgen.analysis.interfaces.zsl import ZSLGenerator from pymatgen.analysis.interfaces.coherent_interfaces import CoherentInterfaceBuilder +from pymatgen.analysis.interfaces.zsl import ZSLGenerator from pymatgen.analysis.structure_matcher import StructureMatcher - from ._base import PropCalc from ._relaxation import RelaxCalc @@ -38,7 +37,7 @@ def __init__( relax_calc_kwargs: dict | None = None, ) -> None: """Initialize the instance of the class. - + Parameters: calculator (Calculator | str): An ASE calculator object used to perform energy and force calculations. If string is provided, the corresponding universal calculator is loaded. @@ -49,11 +48,11 @@ def __init__( max_steps (int, optional): The maximum number of optimization steps. Defaults to 500. relax_calc_kwargs: Additional keyword arguments passed to the class:`RelaxCalc` constructor for both bulk and interface. Default is None. - + Returns: None """ - self.calculator = calculator + self.calculator = calculator self.relax_bulk = relax_bulk self.relax_interface = relax_interface self.fmax = fmax @@ -72,7 +71,7 @@ def calc_interfaces( **kwargs: dict[str, Any], ) -> list[dict[str, Any]]: """Calculate all possible coherent interfaces between two bulk structures. - + Parameters: film_bulk (Structure): The bulk structure of the film material. substrate_bulk (Structure): The bulk structure of the substrate material. @@ -86,7 +85,6 @@ def calc_interfaces( Returns: dict: A list of dictionaries containing the calculated film, substrate, interface. """ - cib = CoherentInterfaceBuilder( film_structure=film_bulk, substrate_structure=substrate_bulk, @@ -95,7 +93,7 @@ def calc_interfaces( zslgen=zslgen, **(cib_kwargs or {}), ) - + terminations = cib.terminations all_interfaces: list = [] for t in terminations: @@ -107,8 +105,8 @@ def calc_interfaces( "No interfaces found with the given parameters. Adjust the ZSL parameters to find more matches." ) - # Group similar / duplicate interfaces using StructureMatcher and keep one representative per group - matcher = StructureMatcher() + # Group similar / duplicate interfaces using StructureMatcher and keep one representative per group + matcher = StructureMatcher() groups: list[list] = [] for i in all_interfaces: placed = False @@ -120,7 +118,7 @@ def calc_interfaces( if not placed: groups.append([i]) unique_interfaces = [g[0] for g in groups] - + film_bulk = film_bulk.to_conventional() substrate_bulk = substrate_bulk.to_conventional() @@ -135,7 +133,7 @@ def calc_interfaces( ) film_opt = relaxer_bulk.calc(film_bulk) substrate_opt = relaxer_bulk.calc(substrate_bulk) - + interfaces = [ { "interface": interface, @@ -148,18 +146,17 @@ def calc_interfaces( for interface in unique_interfaces ] - return list(self.calc_many(interfaces, **kwargs)) - + return list(self.calc_many(interfaces, **kwargs)) def calc( self, structure: Structure | Atoms | dict[str, Any], ) -> dict[str, Any]: """Calculate the interfacial energy of the given interface structures and sort by the energy. - + Parameters: structure : A dictionary containing the film, substrate, interface structures - + Returns: dict: - "interface" (Structure): The initial interface structure. @@ -195,7 +192,6 @@ def calc( substrate_opt = relaxer.calc(structure["substrate_bulk"]) substrate_energy_per_atom = substrate_opt["energy"] / len(substrate_opt["final_structure"]) - interface = structure["interface"] relaxer = RelaxCalc( calculator=self.calculator, @@ -209,12 +205,12 @@ def calc( interface_opt = relaxer.calc(interface) final_interface = interface_opt["final_structure"] interface_energy = interface_opt["energy"] - + # pymatgen interface object does not include interface properties for interfacial energy calculation, define them here - + matrix = interface.lattice.matrix area = float(np.linalg.norm(np.cross(matrix[0], matrix[1]))) - + unique_in_film = set(film_opt.symbol_set) - set(substrate_opt.symbol_set) unique_in_substrate = set(substrate_opt.symbol_set) - set(film_opt.symbol_set) @@ -231,14 +227,15 @@ def calc( print("No unique elements found in either structure") film_in_interface = substrate_in_interface = None - - gamma = (interface_energy - (film_in_interface * film_energy_per_atom + substrate_in_interface * substrate_energy_per_atom)) / (2 * area) + gamma = ( + interface_energy + - (film_in_interface * film_energy_per_atom + substrate_in_interface * substrate_energy_per_atom) + ) / (2 * area) return result_dict | { "interface": interface, "final_interface": final_interface, - "interface_energy_per_atom": interface_energy/len(interface), + "interface_energy_per_atom": interface_energy / len(interface), "num_atoms": len(interface), "interfacial_energy": gamma, } - \ No newline at end of file From 41f1eab06a8409e016181a3a92d200a616b77a47 Mon Sep 17 00:00:00 2001 From: Runze Liu <146490083+rul048@users.noreply.github.com> Date: Mon, 3 Nov 2025 17:44:41 -0800 Subject: [PATCH 04/10] Add InterfaceCalc into _init_.py Signed-off-by: Runze Liu <146490083+rul048@users.noreply.github.com> --- src/matcalc/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/matcalc/__init__.py b/src/matcalc/__init__.py index 262f2e2a..a8e3bc9e 100644 --- a/src/matcalc/__init__.py +++ b/src/matcalc/__init__.py @@ -12,6 +12,7 @@ from ._base import ChainedCalc, PropCalc from ._elasticity import ElasticityCalc from ._eos import EOSCalc +from ._interface import InterfaceCalc from ._lammps import LAMMPSMDCalc from ._md import MDCalc from ._neb import NEBCalc From 4a251fdf7b9653e9189e92996a7c91f8d5f42531 Mon Sep 17 00:00:00 2001 From: so2koo Date: Sun, 16 Nov 2025 15:07:28 -0800 Subject: [PATCH 05/10] Fix errors and Add test --- src/matcalc/_interface.py | 62 ++++++++++++++++++++++++++++----------- tests/conftest.py | 5 ++++ tests/test_interface.py | 62 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 17 deletions(-) create mode 100644 tests/test_interface.py diff --git a/src/matcalc/_interface.py b/src/matcalc/_interface.py index ff61beb3..9a3f598a 100644 --- a/src/matcalc/_interface.py +++ b/src/matcalc/_interface.py @@ -16,6 +16,7 @@ from ase import Atoms from ase.calculators.calculator import Calculator from ase.optimize.optimize import Optimizer + from pymatgen.analysis.interfaces.zsl import ZSLGenerator from pymatgen.core import Structure @@ -41,7 +42,8 @@ def __init__( Parameters: calculator (Calculator | str): An ASE calculator object used to perform energy and force calculations. If string is provided, the corresponding universal calculator is loaded. - relax_bulk (bool, optional): Whether to relax the bulk structures before interface calculations. Defaults to True. + relax_bulk (bool, optional): Whether to relax the bulk structures before interface + calculations. Defaults to True. relax_interface (bool, optional): Whether to relax the interface structures. Defaults to True. fmax (float, optional): The maximum force tolerance for convergence. Defaults to 0.1. optimizer (str | Optimizer, optional): The optimization algorithm to use. Defaults to "BFGS". @@ -68,7 +70,7 @@ def calc_interfaces( substrate_miller: tuple[int, int, int], zslgen: ZSLGenerator | None = None, cib_kwargs: dict | None = None, - **kwargs: dict[str, Any], + **kwargs: Any, ) -> list[dict[str, Any]]: """Calculate all possible coherent interfaces between two bulk structures. @@ -77,10 +79,13 @@ def calc_interfaces( substrate_bulk (Structure): The bulk structure of the substrate material. film_miller (tuple[int, int, int]): The Miller index for the film surface. substrate_miller (tuple[int, int, int]): The Miller index for the substrate surface. - zslgen (ZSLGenerator | None, optional): An instance of ZSLGenerator to use for generating supercells. - zsl_kwargs (dict | None, optional): Additional keyword arguments to pass to the ZSLGenerator. - cib_kwargs (dict | None, optional): Additional keyword arguments to pass to the CoherentInterfaceBuilder. - **kwargs (dict[str, Any]): Additional keyword arguments. + zslgen (ZSLGenerator | None, optional): An instance of ZSLGenerator to use for generating + supercells. + zsl_kwargs (dict | None, optional): Additional keyword arguments to pass to the + ZSLGenerator. + cib_kwargs (dict | None, optional): Additional keyword arguments to pass to the + CoherentInterfaceBuilder. + **kwargs (Any): Additional keyword arguments passed to calc_many. Returns: dict: A list of dictionaries containing the calculated film, substrate, interface. @@ -146,7 +151,11 @@ def calc_interfaces( for interface in unique_interfaces ] +<<<<<<< HEAD return list(self.calc_many(interfaces, **kwargs)) +======= + return [r for r in self.calc_many(interfaces, **kwargs) if r is not None] +>>>>>>> dafe5c2 (Fix errors and Add test) def calc( self, @@ -166,10 +175,11 @@ def calc( - "interfacial_energy" (float): The calculated interfacial energy """ - if not (isinstance(structure, dict) and set(structure.keys()).intersection(("bulk", "bulk_energy_per_atom"))): + if not isinstance(structure, dict): raise ValueError( "For interface calculations, structure must be a dict in one of the following formats: " - "{'film': film_struct, 'interface': interface_struct} or {'film': film_energy, 'substrate_energy': energy}." + "{'interface': interface_struct, 'film_energy_per_atom': energy, ...} from calc_interfaces or " + "{'interface': interface_struct, 'film_bulk': film_struct, 'substrate_bulk': substrate_struct}." ) result_dict = structure.copy() @@ -177,6 +187,8 @@ def calc( if "film_energy_per_atom" in structure and "substrate_energy_per_atom" in structure: film_energy_per_atom = structure["film_energy_per_atom"] substrate_energy_per_atom = structure["substrate_energy_per_atom"] + film_structure = structure["final_film"] + substrate_structure = structure["final_substrate"] else: relaxer = RelaxCalc( calculator=self.calculator, @@ -189,8 +201,13 @@ def calc( ) film_opt = relaxer.calc(structure["film_bulk"]) film_energy_per_atom = film_opt["energy"] / len(film_opt["final_structure"]) + film_structure = film_opt["final_structure"] substrate_opt = relaxer.calc(structure["substrate_bulk"]) substrate_energy_per_atom = substrate_opt["energy"] / len(substrate_opt["final_structure"]) +<<<<<<< HEAD +======= + substrate_structure = substrate_opt["final_structure"] +>>>>>>> dafe5c2 (Fix errors and Add test) interface = structure["interface"] relaxer = RelaxCalc( @@ -206,26 +223,37 @@ def calc( final_interface = interface_opt["final_structure"] interface_energy = interface_opt["energy"] +<<<<<<< HEAD # pymatgen interface object does not include interface properties for interfacial energy calculation, define them here +======= + # pymatgen interface object does not include interface properties for interfacial energy + # calculation, define them here +>>>>>>> dafe5c2 (Fix errors and Add test) matrix = interface.lattice.matrix area = float(np.linalg.norm(np.cross(matrix[0], matrix[1]))) +<<<<<<< HEAD unique_in_film = set(film_opt.symbol_set) - set(substrate_opt.symbol_set) unique_in_substrate = set(substrate_opt.symbol_set) - set(film_opt.symbol_set) +======= + unique_in_film = set(film_structure.symbol_set) - set(substrate_structure.symbol_set) + unique_in_substrate = set(substrate_structure.symbol_set) - set(film_structure.symbol_set) +>>>>>>> dafe5c2 (Fix errors and Add test) if unique_in_film: - unique_element = list(unique_in_film)[0] - count = film_opt.composition[unique_element] - substrate_in_interface = (interface.composition[unique_element] / count) * film_opt.num_sites - elif unique_in_substrate: - unique_element = list(unique_in_substrate)[0] - count = substrate_opt.composition[unique_element] - film_in_interface = (interface.composition[unique_element] / count) * substrate_opt.num_sites + unique_element = next(iter(unique_in_film)) + count = film_structure.composition[unique_element] + film_in_interface = (interface.composition[unique_element] / count) * film_structure.num_sites substrate_in_interface = interface.num_sites - film_in_interface + elif unique_in_substrate: + unique_element = next(iter(unique_in_substrate)) + count = substrate_structure.composition[unique_element] + substrate_in_interface = (interface.composition[unique_element] / count) * substrate_structure.num_sites + film_in_interface = interface.num_sites - substrate_in_interface else: - print("No unique elements found in either structure") - film_in_interface = substrate_in_interface = None + msg = "No unique elements found in either structure to determine atom counts in interface." + raise ValueError(msg) gamma = ( interface_energy diff --git a/tests/conftest.py b/tests/conftest.py index 7f935700..a327b304 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -51,6 +51,11 @@ def Si() -> Structure: """Si structure as session-scoped fixture.""" return PymatgenTest.get_structure("Si") +@pytest.fixture(scope="session") +def SiO2() -> Structure: + """Si structure as session-scoped fixture.""" + return PymatgenTest.get_structure("SiO2") + @pytest.fixture(scope="session") def Si_atoms() -> Atoms: diff --git a/tests/test_interface.py b/tests/test_interface.py new file mode 100644 index 00000000..1522bd64 --- /dev/null +++ b/tests/test_interface.py @@ -0,0 +1,62 @@ +"""Tests for the InterfaceCalc class.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from matcalc import InterfaceCalc + +if TYPE_CHECKING: + from matgl.ext.ase import PESCalculator + from pymatgen.core import Structure + + +def test_interface_calc_basic(Si: Structure, SiO2: Structure, m3gnet_calculator: PESCalculator) -> None: + """ + Test the basic workflow: + 1) calc_interfaces on SiO2 (film) and Si (substrate) + 2) calc on the resulting interfaces + 3) Check the final results + """ + interface_calc = InterfaceCalc( + calculator=m3gnet_calculator, + relax_bulk=True, + relax_interface=True, + fmax=0.1, + max_steps=100, + ) + + results = list( + interface_calc.calc_interfaces( + film_bulk=SiO2, + substrate_bulk=Si, + film_miller=(1, 0, 0), + substrate_miller=(1, 1, 1), + ) + ) + interface_res = results[0] + assert "final_film" in interface_res + assert "final_substrate" in interface_res + assert "final_interface" in interface_res + + assert interface_res["film_energy_per_atom"] == pytest.approx(-7.881573994954427, rel=1e-1) + assert interface_res["substrate_energy_per_atom"] == pytest.approx(-5.419038772583008, rel=1e-1) + assert interface_res["interfacial_energy"] == pytest.approx(0.14220127996544243, rel=1e-1) + + + +def test_interface_calc_invalid_input(Si: Structure, m3gnet_calculator: PESCalculator) -> None: + """ + If the user passes a non-dict to calc, it should raise ValueError. + """ + interface_calc = InterfaceCalc(calculator=m3gnet_calculator) + + with pytest.raises( + ValueError, + match="For interface calculations, structure must be a dict in one of the following formats:", + ): + interface_calc.calc(Si) + + From 75cd8fe43af6aff0a06205874a8c3c47b67d36aa Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 16 Nov 2025 23:18:53 +0000 Subject: [PATCH 06/10] pre-commit auto-fixes --- tests/conftest.py | 1 + tests/test_interface.py | 3 --- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index a327b304..2d117b48 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -51,6 +51,7 @@ def Si() -> Structure: """Si structure as session-scoped fixture.""" return PymatgenTest.get_structure("Si") + @pytest.fixture(scope="session") def SiO2() -> Structure: """Si structure as session-scoped fixture.""" diff --git a/tests/test_interface.py b/tests/test_interface.py index 1522bd64..0b5300eb 100644 --- a/tests/test_interface.py +++ b/tests/test_interface.py @@ -46,7 +46,6 @@ def test_interface_calc_basic(Si: Structure, SiO2: Structure, m3gnet_calculator: assert interface_res["interfacial_energy"] == pytest.approx(0.14220127996544243, rel=1e-1) - def test_interface_calc_invalid_input(Si: Structure, m3gnet_calculator: PESCalculator) -> None: """ If the user passes a non-dict to calc, it should raise ValueError. @@ -58,5 +57,3 @@ def test_interface_calc_invalid_input(Si: Structure, m3gnet_calculator: PESCalcu match="For interface calculations, structure must be a dict in one of the following formats:", ): interface_calc.calc(Si) - - From 43653732fef40475db03d88c982f0c1c3cdc20a9 Mon Sep 17 00:00:00 2001 From: so2koo Date: Sun, 16 Nov 2025 18:25:51 -0800 Subject: [PATCH 07/10] Fix lint --- src/matcalc/_interface.py | 22 +++------------------- tests/test_interface.py | 13 ------------- 2 files changed, 3 insertions(+), 32 deletions(-) diff --git a/src/matcalc/_interface.py b/src/matcalc/_interface.py index 9a3f598a..bce4e783 100644 --- a/src/matcalc/_interface.py +++ b/src/matcalc/_interface.py @@ -6,7 +6,6 @@ import numpy as np from pymatgen.analysis.interfaces.coherent_interfaces import CoherentInterfaceBuilder -from pymatgen.analysis.interfaces.zsl import ZSLGenerator from pymatgen.analysis.structure_matcher import StructureMatcher from ._base import PropCalc @@ -151,11 +150,7 @@ def calc_interfaces( for interface in unique_interfaces ] -<<<<<<< HEAD - return list(self.calc_many(interfaces, **kwargs)) -======= return [r for r in self.calc_many(interfaces, **kwargs) if r is not None] ->>>>>>> dafe5c2 (Fix errors and Add test) def calc( self, @@ -173,15 +168,16 @@ def calc( - "interface_energy_per_atom" (float): The final energy of the relaxed interface structure. - "num_atoms" (int): The number of atoms in the interface structure. - "interfacial_energy" (float): The calculated interfacial energy - """ if not isinstance(structure, dict): - raise ValueError( + msg = ( "For interface calculations, structure must be a dict in one of the following formats: " "{'interface': interface_struct, 'film_energy_per_atom': energy, ...} from calc_interfaces or " "{'interface': interface_struct, 'film_bulk': film_struct, 'substrate_bulk': substrate_struct}." ) + raise TypeError(msg) + # Type narrowing: at this point, structure is guaranteed to be dict[str, Any] result_dict = structure.copy() if "film_energy_per_atom" in structure and "substrate_energy_per_atom" in structure: @@ -204,10 +200,7 @@ def calc( film_structure = film_opt["final_structure"] substrate_opt = relaxer.calc(structure["substrate_bulk"]) substrate_energy_per_atom = substrate_opt["energy"] / len(substrate_opt["final_structure"]) -<<<<<<< HEAD -======= substrate_structure = substrate_opt["final_structure"] ->>>>>>> dafe5c2 (Fix errors and Add test) interface = structure["interface"] relaxer = RelaxCalc( @@ -223,23 +216,14 @@ def calc( final_interface = interface_opt["final_structure"] interface_energy = interface_opt["energy"] -<<<<<<< HEAD - # pymatgen interface object does not include interface properties for interfacial energy calculation, define them here -======= # pymatgen interface object does not include interface properties for interfacial energy # calculation, define them here ->>>>>>> dafe5c2 (Fix errors and Add test) matrix = interface.lattice.matrix area = float(np.linalg.norm(np.cross(matrix[0], matrix[1]))) -<<<<<<< HEAD - unique_in_film = set(film_opt.symbol_set) - set(substrate_opt.symbol_set) - unique_in_substrate = set(substrate_opt.symbol_set) - set(film_opt.symbol_set) -======= unique_in_film = set(film_structure.symbol_set) - set(substrate_structure.symbol_set) unique_in_substrate = set(substrate_structure.symbol_set) - set(film_structure.symbol_set) ->>>>>>> dafe5c2 (Fix errors and Add test) if unique_in_film: unique_element = next(iter(unique_in_film)) diff --git a/tests/test_interface.py b/tests/test_interface.py index 0b5300eb..2a4ca93e 100644 --- a/tests/test_interface.py +++ b/tests/test_interface.py @@ -44,16 +44,3 @@ def test_interface_calc_basic(Si: Structure, SiO2: Structure, m3gnet_calculator: assert interface_res["film_energy_per_atom"] == pytest.approx(-7.881573994954427, rel=1e-1) assert interface_res["substrate_energy_per_atom"] == pytest.approx(-5.419038772583008, rel=1e-1) assert interface_res["interfacial_energy"] == pytest.approx(0.14220127996544243, rel=1e-1) - - -def test_interface_calc_invalid_input(Si: Structure, m3gnet_calculator: PESCalculator) -> None: - """ - If the user passes a non-dict to calc, it should raise ValueError. - """ - interface_calc = InterfaceCalc(calculator=m3gnet_calculator) - - with pytest.raises( - ValueError, - match="For interface calculations, structure must be a dict in one of the following formats:", - ): - interface_calc.calc(Si) From 3072c5147825f5a09145ff3ad7e76d0319f23f72 Mon Sep 17 00:00:00 2001 From: Runze Liu <146490083+rul048@users.noreply.github.com> Date: Mon, 8 Dec 2025 16:12:25 -0800 Subject: [PATCH 08/10] Rename fixture description from 'Si' to 'SiO2' Signed-off-by: Runze Liu <146490083+rul048@users.noreply.github.com> --- tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 2d117b48..7222582c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -54,7 +54,7 @@ def Si() -> Structure: @pytest.fixture(scope="session") def SiO2() -> Structure: - """Si structure as session-scoped fixture.""" + """SiO2 structure as session-scoped fixture.""" return PymatgenTest.get_structure("SiO2") From 4080ebf1ff96efb5ba8b3b08f5905bd3d61f5dc6 Mon Sep 17 00:00:00 2001 From: Runze Liu <146490083+rul048@users.noreply.github.com> Date: Mon, 8 Dec 2025 16:28:40 -0800 Subject: [PATCH 09/10] Replace m3gnet_calculator with matpes_calculator in test_interface.py Signed-off-by: Runze Liu <146490083+rul048@users.noreply.github.com> --- tests/test_interface.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_interface.py b/tests/test_interface.py index 2a4ca93e..0b6039a8 100644 --- a/tests/test_interface.py +++ b/tests/test_interface.py @@ -13,7 +13,7 @@ from pymatgen.core import Structure -def test_interface_calc_basic(Si: Structure, SiO2: Structure, m3gnet_calculator: PESCalculator) -> None: +def test_interface_calc_basic(Si: Structure, SiO2: Structure, matpes_calculator: PESCalculator) -> None: """ Test the basic workflow: 1) calc_interfaces on SiO2 (film) and Si (substrate) @@ -21,7 +21,7 @@ def test_interface_calc_basic(Si: Structure, SiO2: Structure, m3gnet_calculator: 3) Check the final results """ interface_calc = InterfaceCalc( - calculator=m3gnet_calculator, + calculator=matpes_calculator, relax_bulk=True, relax_interface=True, fmax=0.1, From 781933bc8d9d66a082f24ae635c0056dad1ad69e Mon Sep 17 00:00:00 2001 From: Runze Liu <146490083+rul048@users.noreply.github.com> Date: Mon, 8 Dec 2025 16:56:14 -0800 Subject: [PATCH 10/10] Update expected value for interfacial_energy test Signed-off-by: Runze Liu <146490083+rul048@users.noreply.github.com> --- tests/test_interface.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_interface.py b/tests/test_interface.py index 0b6039a8..b0e4e122 100644 --- a/tests/test_interface.py +++ b/tests/test_interface.py @@ -43,4 +43,4 @@ def test_interface_calc_basic(Si: Structure, SiO2: Structure, matpes_calculator: assert interface_res["film_energy_per_atom"] == pytest.approx(-7.881573994954427, rel=1e-1) assert interface_res["substrate_energy_per_atom"] == pytest.approx(-5.419038772583008, rel=1e-1) - assert interface_res["interfacial_energy"] == pytest.approx(0.14220127996544243, rel=1e-1) + assert interface_res["interfacial_energy"] == pytest.approx(0.15930456535294427, rel=1e-1)