Skip to content

Commit bd6d488

Browse files
authored
Merge pull request #117 from so2koo/main
Add `InterfaceCalc` Class for Interface Structure and Energy Calculation
2 parents aaf15f9 + 781933b commit bd6d488

File tree

4 files changed

+306
-0
lines changed

4 files changed

+306
-0
lines changed

src/matcalc/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from ._base import ChainedCalc, PropCalc
1414
from ._elasticity import ElasticityCalc
1515
from ._eos import EOSCalc
16+
from ._interface import InterfaceCalc
1617
from ._lammps import LAMMPSMDCalc
1718
from ._md import MDCalc
1819
from ._neb import MEP, NEBCalc

src/matcalc/_interface.py

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
"""Interface structure/energy calculations."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING, Any
6+
7+
import numpy as np
8+
from pymatgen.analysis.interfaces.coherent_interfaces import CoherentInterfaceBuilder
9+
from pymatgen.analysis.structure_matcher import StructureMatcher
10+
11+
from ._base import PropCalc
12+
from ._relaxation import RelaxCalc
13+
14+
if TYPE_CHECKING:
15+
from ase import Atoms
16+
from ase.calculators.calculator import Calculator
17+
from ase.optimize.optimize import Optimizer
18+
from pymatgen.analysis.interfaces.zsl import ZSLGenerator
19+
from pymatgen.core import Structure
20+
21+
22+
class InterfaceCalc(PropCalc):
23+
"""
24+
This class generates all possible coherent interfaces between two bulk structures
25+
given their miller indices, relaxes them, and computes their interfacial energies.
26+
"""
27+
28+
def __init__(
29+
self,
30+
calculator: Calculator | str,
31+
*,
32+
relax_bulk: bool = True,
33+
relax_interface: bool = True,
34+
fmax: float = 0.1,
35+
optimizer: str | Optimizer = "BFGS",
36+
max_steps: int = 500,
37+
relax_calc_kwargs: dict | None = None,
38+
) -> None:
39+
"""Initialize the instance of the class.
40+
41+
Parameters:
42+
calculator (Calculator | str): An ASE calculator object used to perform energy and force
43+
calculations. If string is provided, the corresponding universal calculator is loaded.
44+
relax_bulk (bool, optional): Whether to relax the bulk structures before interface
45+
calculations. Defaults to True.
46+
relax_interface (bool, optional): Whether to relax the interface structures. Defaults to True.
47+
fmax (float, optional): The maximum force tolerance for convergence. Defaults to 0.1.
48+
optimizer (str | Optimizer, optional): The optimization algorithm to use. Defaults to "BFGS".
49+
max_steps (int, optional): The maximum number of optimization steps. Defaults to 500.
50+
relax_calc_kwargs: Additional keyword arguments passed to the
51+
class:`RelaxCalc` constructor for both bulk and interface. Default is None.
52+
53+
Returns:
54+
None
55+
"""
56+
self.calculator = calculator
57+
self.relax_bulk = relax_bulk
58+
self.relax_interface = relax_interface
59+
self.fmax = fmax
60+
self.optimizer = optimizer
61+
self.max_steps = max_steps
62+
self.relax_calc_kwargs = relax_calc_kwargs
63+
64+
def calc_interfaces(
65+
self,
66+
film_bulk: Structure,
67+
substrate_bulk: Structure,
68+
film_miller: tuple[int, int, int],
69+
substrate_miller: tuple[int, int, int],
70+
zslgen: ZSLGenerator | None = None,
71+
cib_kwargs: dict | None = None,
72+
**kwargs: Any,
73+
) -> list[dict[str, Any]]:
74+
"""Calculate all possible coherent interfaces between two bulk structures.
75+
76+
Parameters:
77+
film_bulk (Structure): The bulk structure of the film material.
78+
substrate_bulk (Structure): The bulk structure of the substrate material.
79+
film_miller (tuple[int, int, int]): The Miller index for the film surface.
80+
substrate_miller (tuple[int, int, int]): The Miller index for the substrate surface.
81+
zslgen (ZSLGenerator | None, optional): An instance of ZSLGenerator to use for generating
82+
supercells.
83+
zsl_kwargs (dict | None, optional): Additional keyword arguments to pass to the
84+
ZSLGenerator.
85+
cib_kwargs (dict | None, optional): Additional keyword arguments to pass to the
86+
CoherentInterfaceBuilder.
87+
**kwargs (Any): Additional keyword arguments passed to calc_many.
88+
89+
Returns:
90+
dict: A list of dictionaries containing the calculated film, substrate, interface.
91+
"""
92+
cib = CoherentInterfaceBuilder(
93+
film_structure=film_bulk,
94+
substrate_structure=substrate_bulk,
95+
film_miller=film_miller,
96+
substrate_miller=substrate_miller,
97+
zslgen=zslgen,
98+
**(cib_kwargs or {}),
99+
)
100+
101+
terminations = cib.terminations
102+
all_interfaces: list = []
103+
for t in terminations:
104+
interfaces_for_termination = cib.get_interfaces(termination=t)
105+
all_interfaces.extend(list(interfaces_for_termination))
106+
107+
if not all_interfaces:
108+
raise ValueError(
109+
"No interfaces found with the given parameters. Adjust the ZSL parameters to find more matches."
110+
)
111+
112+
# Group similar / duplicate interfaces using StructureMatcher and keep one representative per group
113+
matcher = StructureMatcher()
114+
groups: list[list] = []
115+
for i in all_interfaces:
116+
placed = False
117+
for g in groups:
118+
if matcher.fit(i, g[0]):
119+
g.append(i)
120+
placed = True
121+
break
122+
if not placed:
123+
groups.append([i])
124+
unique_interfaces = [g[0] for g in groups]
125+
126+
film_bulk = film_bulk.to_conventional()
127+
substrate_bulk = substrate_bulk.to_conventional()
128+
129+
relaxer_bulk = RelaxCalc(
130+
calculator=self.calculator,
131+
fmax=self.fmax,
132+
max_steps=self.max_steps,
133+
relax_cell=self.relax_bulk,
134+
relax_atoms=self.relax_bulk,
135+
optimizer=self.optimizer,
136+
**(self.relax_calc_kwargs or {}),
137+
)
138+
film_opt = relaxer_bulk.calc(film_bulk)
139+
substrate_opt = relaxer_bulk.calc(substrate_bulk)
140+
141+
interfaces = [
142+
{
143+
"interface": interface,
144+
"num_atoms": len(interface),
145+
"film_energy_per_atom": film_opt["energy"] / len(film_bulk),
146+
"final_film": film_opt["final_structure"],
147+
"substrate_energy_per_atom": substrate_opt["energy"] / len(substrate_bulk),
148+
"final_substrate": substrate_opt["final_structure"],
149+
}
150+
for interface in unique_interfaces
151+
]
152+
153+
return [r for r in self.calc_many(interfaces, **kwargs) if r is not None]
154+
155+
def calc(
156+
self,
157+
structure: Structure | Atoms | dict[str, Any],
158+
) -> dict[str, Any]:
159+
"""Calculate the interfacial energy of the given interface structures and sort by the energy.
160+
161+
Parameters:
162+
structure : A dictionary containing the film, substrate, interface structures
163+
164+
Returns:
165+
dict:
166+
- "interface" (Structure): The initial interface structure.
167+
- "final_interface" (Structure): The relaxed interface structure.
168+
- "interface_energy_per_atom" (float): The final energy of the relaxed interface structure.
169+
- "num_atoms" (int): The number of atoms in the interface structure.
170+
- "interfacial_energy" (float): The calculated interfacial energy
171+
"""
172+
if not isinstance(structure, dict):
173+
msg = (
174+
"For interface calculations, structure must be a dict in one of the following formats: "
175+
"{'interface': interface_struct, 'film_energy_per_atom': energy, ...} from calc_interfaces or "
176+
"{'interface': interface_struct, 'film_bulk': film_struct, 'substrate_bulk': substrate_struct}."
177+
)
178+
raise TypeError(msg)
179+
180+
# Type narrowing: at this point, structure is guaranteed to be dict[str, Any]
181+
result_dict = structure.copy()
182+
183+
if "film_energy_per_atom" in structure and "substrate_energy_per_atom" in structure:
184+
film_energy_per_atom = structure["film_energy_per_atom"]
185+
substrate_energy_per_atom = structure["substrate_energy_per_atom"]
186+
film_structure = structure["final_film"]
187+
substrate_structure = structure["final_substrate"]
188+
else:
189+
relaxer = RelaxCalc(
190+
calculator=self.calculator,
191+
fmax=self.fmax,
192+
max_steps=self.max_steps,
193+
relax_cell=self.relax_bulk,
194+
relax_atoms=self.relax_bulk,
195+
optimizer=self.optimizer,
196+
**(self.relax_calc_kwargs or {}),
197+
)
198+
film_opt = relaxer.calc(structure["film_bulk"])
199+
film_energy_per_atom = film_opt["energy"] / len(film_opt["final_structure"])
200+
film_structure = film_opt["final_structure"]
201+
substrate_opt = relaxer.calc(structure["substrate_bulk"])
202+
substrate_energy_per_atom = substrate_opt["energy"] / len(substrate_opt["final_structure"])
203+
substrate_structure = substrate_opt["final_structure"]
204+
205+
interface = structure["interface"]
206+
relaxer = RelaxCalc(
207+
calculator=self.calculator,
208+
fmax=self.fmax,
209+
max_steps=self.max_steps,
210+
relax_cell=False,
211+
relax_atoms=self.relax_interface,
212+
optimizer=self.optimizer,
213+
**(self.relax_calc_kwargs or {}),
214+
)
215+
interface_opt = relaxer.calc(interface)
216+
final_interface = interface_opt["final_structure"]
217+
interface_energy = interface_opt["energy"]
218+
219+
# pymatgen interface object does not include interface properties for interfacial energy
220+
# calculation, define them here
221+
222+
matrix = interface.lattice.matrix
223+
area = float(np.linalg.norm(np.cross(matrix[0], matrix[1])))
224+
225+
unique_in_film = set(film_structure.symbol_set) - set(substrate_structure.symbol_set)
226+
unique_in_substrate = set(substrate_structure.symbol_set) - set(film_structure.symbol_set)
227+
228+
if unique_in_film:
229+
unique_element = next(iter(unique_in_film))
230+
count = film_structure.composition[unique_element]
231+
film_in_interface = (interface.composition[unique_element] / count) * film_structure.num_sites
232+
substrate_in_interface = interface.num_sites - film_in_interface
233+
elif unique_in_substrate:
234+
unique_element = next(iter(unique_in_substrate))
235+
count = substrate_structure.composition[unique_element]
236+
substrate_in_interface = (interface.composition[unique_element] / count) * substrate_structure.num_sites
237+
film_in_interface = interface.num_sites - substrate_in_interface
238+
else:
239+
msg = "No unique elements found in either structure to determine atom counts in interface."
240+
raise ValueError(msg)
241+
242+
gamma = (
243+
interface_energy
244+
- (film_in_interface * film_energy_per_atom + substrate_in_interface * substrate_energy_per_atom)
245+
) / (2 * area)
246+
247+
return result_dict | {
248+
"interface": interface,
249+
"final_interface": final_interface,
250+
"interface_energy_per_atom": interface_energy / len(interface),
251+
"num_atoms": len(interface),
252+
"interfacial_energy": gamma,
253+
}

tests/conftest.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@ def Si() -> Structure:
5252
return PymatgenTest.get_structure("Si")
5353

5454

55+
@pytest.fixture(scope="session")
56+
def SiO2() -> Structure:
57+
"""SiO2 structure as session-scoped fixture."""
58+
return PymatgenTest.get_structure("SiO2")
59+
60+
5561
@pytest.fixture(scope="session")
5662
def Si_atoms() -> Atoms:
5763
"""Si atoms as session-scoped fixture."""

tests/test_interface.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""Tests for the InterfaceCalc class."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING
6+
7+
import pytest
8+
9+
from matcalc import InterfaceCalc
10+
11+
if TYPE_CHECKING:
12+
from matgl.ext.ase import PESCalculator
13+
from pymatgen.core import Structure
14+
15+
16+
def test_interface_calc_basic(Si: Structure, SiO2: Structure, matpes_calculator: PESCalculator) -> None:
17+
"""
18+
Test the basic workflow:
19+
1) calc_interfaces on SiO2 (film) and Si (substrate)
20+
2) calc on the resulting interfaces
21+
3) Check the final results
22+
"""
23+
interface_calc = InterfaceCalc(
24+
calculator=matpes_calculator,
25+
relax_bulk=True,
26+
relax_interface=True,
27+
fmax=0.1,
28+
max_steps=100,
29+
)
30+
31+
results = list(
32+
interface_calc.calc_interfaces(
33+
film_bulk=SiO2,
34+
substrate_bulk=Si,
35+
film_miller=(1, 0, 0),
36+
substrate_miller=(1, 1, 1),
37+
)
38+
)
39+
interface_res = results[0]
40+
assert "final_film" in interface_res
41+
assert "final_substrate" in interface_res
42+
assert "final_interface" in interface_res
43+
44+
assert interface_res["film_energy_per_atom"] == pytest.approx(-7.881573994954427, rel=1e-1)
45+
assert interface_res["substrate_energy_per_atom"] == pytest.approx(-5.419038772583008, rel=1e-1)
46+
assert interface_res["interfacial_energy"] == pytest.approx(0.15930456535294427, rel=1e-1)

0 commit comments

Comments
 (0)