/
interface.py
531 lines (446 loc) · 19.4 KB
/
interface.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
# Copyright (c) Pymatgen Development Team.
# Distributed under the terms of the MIT License.
"""
This module provides classes to store, generate, and manipulate material interfaces.
"""
from __future__ import annotations
from itertools import chain, combinations, product
import numpy as np
from scipy.cluster.hierarchy import fcluster, linkage
from scipy.spatial.distance import squareform
from pymatgen.analysis.adsorption import AdsorbateSiteFinder
from pymatgen.core.lattice import Lattice
from pymatgen.core.sites import PeriodicSite
from pymatgen.core.structure import Site, Structure
from pymatgen.core.surface import Slab
from pymatgen.symmetry.analyzer import SpacegroupAnalyzer
class Interface(Structure):
"""
This class stores data for defining an interface between two structures.
It is a subclass of pymatgen.core.structure.Structure.
"""
def __init__(
self,
lattice,
species,
coords,
site_properties,
validate_proximity=False,
to_unit_cell=False,
coords_are_cartesian=False,
in_plane_offset: tuple[float, float] = (0, 0),
gap: float = 0,
vacuum_over_film: float = 0.0,
interface_properties: dict | None = None,
):
"""
Makes an interface structure, a structure object with additional information
and methods pertaining to interfaces.
Args:
lattice (Lattice/3x3 array): The lattice, either as a
:class:`pymatgen.core.lattice.Lattice` or
simply as any 2D array. Each row should correspond to a lattice
vector. E.g., [[10,0,0], [20,10,0], [0,0,30]] specifies a
lattice with lattice vectors [10,0,0], [20,10,0] and [0,0,30].
species ([Species]): Sequence of species on each site. Can take in
flexible input, including:
i. A sequence of element / species specified either as string
symbols, e.g. ["Li", "Fe2+", "P", ...] or atomic numbers,
e.g., (3, 56, ...) or actual Element or Species objects.
ii. List of dict of elements/species and occupancies, e.g.,
[{"Fe" : 0.5, "Mn":0.5}, ...]. This allows the setup of
disordered structures.
coords (Nx3 array): list of fractional/cartesian coordinates of
each species.
validate_proximity (bool): Whether to check if there are sites
that are less than 0.01 Ang apart. Defaults to False.
coords_are_cartesian (bool): Set to True if you are providing
coordinates in Cartesian coordinates. Defaults to False.
site_properties (dict): Properties associated with the sites as a
dict of sequences, e.g., {"magmom":[5,5,5,5]}. The sequences
have to be the same length as the atomic species and
fractional_coords. Defaults to None for no properties.
in_plane_offset: fractional shift in plane for the film with respect
to the substrate
gap: gap between substrate and film in Angstroms; zero corresponds to
the original distance between substrate and film sites
vacuum_over_film: vacuum space above the film in Angstroms
"""
assert (
"interface_label" in site_properties
), "Must provide labeling of substrate and film sites in site properties"
self._in_plane_offset = np.array(in_plane_offset, dtype="float")
self._gap = gap
self._vacuum_over_film = vacuum_over_film
self.interface_properties = interface_properties or {}
super().__init__(
lattice,
species,
coords,
validate_proximity=validate_proximity,
to_unit_cell=to_unit_cell,
coords_are_cartesian=coords_are_cartesian,
site_properties=site_properties,
)
self.sort()
@property
def in_plane_offset(self) -> np.ndarray:
"""
The shift between the film and substrate in fractional
coordinates
"""
return self._in_plane_offset
@in_plane_offset.setter
def in_plane_offset(self, new_shift: np.ndarray) -> None:
if len(new_shift) != 2:
raise ValueError("In-plane shifts require two floats for a and b vectors")
new_shift = np.mod(new_shift, 1)
delta = new_shift - np.array(self.in_plane_offset)
self._in_plane_offset = new_shift
self.translate_sites(self.film_indices, [delta[0], delta[1], 0], to_unit_cell=True)
@property
def gap(self) -> float:
"""
The gap in Cartesian units between the film and the substrate
"""
return self._gap
@gap.setter
def gap(self, new_gap: float) -> None:
if new_gap < 0:
raise ValueError("Can't reduce interface gap below 0")
delta = new_gap - self.gap
self._gap = new_gap
self.__update_c(self.lattice.c + delta)
self.translate_sites(self.film_indices, [0, 0, delta], frac_coords=False, to_unit_cell=True)
@property
def vacuum_over_film(self) -> float:
"""
The vacuum space over the film in Cartesian units
"""
return self._vacuum_over_film
@vacuum_over_film.setter
def vacuum_over_film(self, new_vacuum: float) -> None:
if new_vacuum < 0:
raise ValueError("The vacuum over the film can not be less then 0")
delta = new_vacuum - self.vacuum_over_film
self._vacuum_over_film = new_vacuum
self.__update_c(self.lattice.c + delta)
@property
def substrate_indices(self) -> list[int]:
"""
Site indices for the substrate atoms
"""
sub_indices = [i for i, tag in enumerate(self.site_properties["interface_label"]) if "substrate" in tag]
return sub_indices
@property
def substrate_sites(self) -> list[Site]:
"""
The site objects in the substrate
"""
sub_sites = [site for site, tag in zip(self, self.site_properties["interface_label"]) if "substrate" in tag]
return sub_sites
@property
def substrate(self) -> Structure:
"""
A pymatgen Structure for just the substrate
"""
return Structure.from_sites(self.substrate_sites)
@property
def film_indices(self) -> list[int]:
"""
Site indices of the film sites
"""
f_indices = [i for i, tag in enumerate(self.site_properties["interface_label"]) if "film" in tag]
return f_indices
@property
def film_sites(self) -> list[Site]:
"""
Return the film sites of the interface.
"""
film_sites = [site for site, tag in zip(self, self.site_properties["interface_label"]) if "film" in tag]
return film_sites
@property
def film(self) -> Structure:
"""
A pymatgen Structure for just the film
"""
return Structure.from_sites(self.film_sites)
def copy(self):
"""
Returns:
Interface: A copy of the Interface.
"""
return Interface.from_dict(self.as_dict())
def get_sorted_structure(self, key=None, reverse=False) -> Structure:
"""
Get a sorted structure for the interface. The parameters have the same
meaning as in list.sort. By default, sites are sorted by the
electronegativity of the species.
Args:
key: Specifies a function of one argument that is used to extract
a comparison key from each list element: key=str.lower. The
default value is None (compare the elements directly).
reverse (bool): If set to True, then the list elements are sorted
as if each comparison were reversed.
"""
struct_copy = Structure.from_sites(self)
struct_copy.sort(key=key, reverse=reverse)
return struct_copy
def get_shifts_based_on_adsorbate_sites(self, tolerance: float = 0.1) -> list[tuple[float, float]]:
"""
Computes possible in-plane shifts based on an adsorbate site algorithm
Args:
tolerance: tolerance for "uniqueness" for shifts in Cartesian unit
This is usually Angstroms.
"""
substrate = self.substrate
film = self.film
substrate_surface_sites = np.dot(
list(chain.from_iterable(AdsorbateSiteFinder(substrate).find_adsorption_sites().values())),
substrate.lattice.inv_matrix,
)
# Film gets forced into substrate lattice anyways, so shifts can be computed in fractional coords
film_surface_sites = np.dot(
list(chain.from_iterable(AdsorbateSiteFinder(film).find_adsorption_sites().values())),
film.lattice.inv_matrix,
)
pos_shift = np.array(
[
np.add(np.multiply(-1, film_shift), sub_shift)
for film_shift, sub_shift in product(film_surface_sites, substrate_surface_sites)
]
)
def _base_round(x, base=0.05):
return base * (np.array(x) / base).round()
# Round shifts to tolerance
pos_shift[:, 0] = _base_round(pos_shift[:, 0], base=tolerance / substrate.lattice.a)
pos_shift[:, 1] = _base_round(pos_shift[:, 1], base=tolerance / substrate.lattice.b)
# C-axis is not useful
pos_shift = pos_shift[:, 0:2]
return list(np.unique(pos_shift, axis=0))
@property
def film_termination(self) -> str:
"""Label for the film termination chemistry"""
return label_termination(self.film)
@property
def substrate_termination(self) -> str:
"""Label for the substrate termination chemistry"""
return label_termination(self.substrate)
@property
def film_layers(self) -> int:
"""Number of layers of the minimum element in the film composition"""
sorted_element_list = sorted(
self.film.composition.element_composition.items(), key=lambda x: x[1], reverse=True
)
return count_layers(self.film, sorted_element_list[0][0])
@property
def substrate_layers(self) -> int:
"""Number of layers of the minimum element in the substrate composition"""
sorted_element_list = sorted(
self.substrate.composition.element_composition.items(), key=lambda x: x[1], reverse=True
)
return count_layers(self.substrate, sorted_element_list[0][0])
def __update_c(self, new_c: float) -> None:
"""
Modifies the c-direction of the lattice without changing the site Cartesian coordinates
Be careful you can mess up the interface by setting a c-length that can't accommodate all the sites
"""
if new_c <= 0:
raise ValueError("New c-length must be greater than 0")
new_latt_matrix = self.lattice.matrix[:2].tolist() + [[0, 0, new_c]]
new_latice = Lattice(new_latt_matrix)
self._lattice = new_latice
for site, c_coords in zip(self, self.cart_coords):
site._lattice = new_latice # Update the lattice
site.coords = c_coords # Put back into original Cartesian space
def as_dict(self):
"""
:return: MSONAble dict
"""
d = super().as_dict()
d["in_plane_offset"] = self.in_plane_offset.tolist()
d["gap"] = self.gap
d["vacuum_over_film"] = self.vacuum_over_film
d["interface_properties"] = self.interface_properties
return d
@classmethod
def from_dict(cls, d):
"""
:param d: dict
:return: Creates slab from dict.
"""
lattice = Lattice.from_dict(d["lattice"])
sites = [PeriodicSite.from_dict(sd, lattice) for sd in d["sites"]]
s = Structure.from_sites(sites)
optional = dict(
in_plane_offset=d.get("in_plane_offset"),
gap=d.get("gap"),
vacuum_over_film=d.get("vacuum_over_film"),
interface_properties=d.get("interface_properties"),
)
return Interface(
lattice=lattice,
species=s.species_and_occu,
coords=s.frac_coords,
site_properties=s.site_properties,
**{k: v for k, v in optional.items() if v is not None},
)
@classmethod
def from_slabs(
cls,
substrate_slab: Slab,
film_slab: Slab,
in_plane_offset: tuple[float, float] = (0, 0),
gap: float = 1.6,
vacuum_over_film: float = 0.0,
interface_properties: dict | None = None,
center_slab: bool = True,
) -> Interface:
"""
Makes an interface structure by merging a substrate and film slabs
The film a- and b-vectors will be forced to be the substrate slab's
a- and b-vectors.
For now, it's suggested to use a factory method that will ensure the
appropriate interface structure is already met.
Args:
sub_slab: slab for the substrate
film_slab: slab for the film
in_plane_offset: fractional shift in plane
for the film with respect to the substrate
gap: gap between substrate and film in Angstroms
vacuum_over_film: vacuum space above the film in Angstroms
structure_properties: dictionary of misc properties for this structure
center_slab: center the slab
"""
interface_properties = interface_properties or {}
# Ensure c-axis is orthogonal to a/b plane
if isinstance(substrate_slab, Slab):
substrate_slab = substrate_slab.get_orthogonal_c_slab()
if isinstance(film_slab, Slab):
film_slab = film_slab.get_orthogonal_c_slab()
assert np.allclose(film_slab.lattice.alpha, 90, 0.1)
assert np.allclose(film_slab.lattice.beta, 90, 0.1)
assert np.allclose(substrate_slab.lattice.alpha, 90, 0.1)
assert np.allclose(substrate_slab.lattice.beta, 90, 0.1)
# Ensure sub is right-handed
# IE sub has surface facing "up"
sub_vecs = substrate_slab.lattice.matrix.copy()
if np.dot(np.cross(*sub_vecs[:2]), sub_vecs[2]) < 0:
sub_vecs[2] *= -1.0
substrate_slab.lattice = Lattice(sub_vecs)
# Find the limits of C-coords
sub_coords = substrate_slab.frac_coords
film_coords = film_slab.frac_coords
sub_min_c = np.min(sub_coords[:, 2]) * substrate_slab.lattice.c
sub_max_c = np.max(sub_coords[:, 2]) * substrate_slab.lattice.c
film_min_c = np.min(film_coords[:, 2]) * film_slab.lattice.c
film_max_c = np.max(film_coords[:, 2]) * film_slab.lattice.c
min_height = np.abs(film_max_c - film_min_c) + np.abs(sub_max_c - sub_min_c)
# construct new lattice
abc = substrate_slab.lattice.abc[:2] + (min_height + gap + vacuum_over_film,)
angles = substrate_slab.lattice.angles
lattice = Lattice.from_parameters(*abc, *angles)
# Get the species
species = substrate_slab.species + film_slab.species
# Get the coords
# Shift substrate to bottom in new lattice
sub_coords = np.subtract(sub_coords, [0, 0, np.min(sub_coords[:, 2])])
sub_coords[:, 2] *= substrate_slab.lattice.c / lattice.c
# Flip the film over
film_coords[:, 2] *= -1.0
film_coords[:, 2] *= film_slab.lattice.c / lattice.c
# Shift the film coords to right over the substrate + gap
film_coords = np.subtract(film_coords, [0, 0, np.min(film_coords[:, 2])])
film_coords = np.add(film_coords, [0, 0, gap / lattice.c + np.max(sub_coords[:, 2])])
# Build coords
coords = np.concatenate([sub_coords, film_coords])
# Shift coords to center
if center_slab:
coords = np.add(coords, [0, 0, 0.5 - np.average(coords[:, 2])])
# Only merge site properties in both slabs
site_properties = {}
site_props_in_both = set(substrate_slab.site_properties.keys()) & set(film_slab.site_properties.keys())
for key in site_props_in_both:
site_properties[key] = [
*substrate_slab.site_properties[key],
*film_slab.site_properties[key],
]
site_properties["interface_label"] = ["substrate"] * len(substrate_slab) + ["film"] * len(film_slab)
iface = cls(
lattice=lattice,
species=species,
coords=coords,
to_unit_cell=False,
coords_are_cartesian=False,
site_properties=site_properties,
validate_proximity=False,
in_plane_offset=in_plane_offset,
gap=gap,
vacuum_over_film=vacuum_over_film,
interface_properties=interface_properties,
)
iface.sort()
return iface
def label_termination(slab: Structure) -> str:
"""Labels the slab surface termination"""
frac_coords = slab.frac_coords
n = len(frac_coords)
if n == 1:
# Clustering does not work when there is only one data point.
form = slab.composition.reduced_formula
sp_symbol = SpacegroupAnalyzer(slab, symprec=0.1).get_space_group_symbol()
return f"{form}_{sp_symbol}_{len(slab)}"
dist_matrix = np.zeros((n, n))
h = slab.lattice.c
# Projection of c lattice vector in
# direction of surface normal.
for i, j in combinations(list(range(n)), 2):
if i != j:
cdist = frac_coords[i][2] - frac_coords[j][2]
cdist = abs(cdist - round(cdist)) * h
dist_matrix[i, j] = cdist
dist_matrix[j, i] = cdist
condensed_m = squareform(dist_matrix)
z = linkage(condensed_m)
clusters = fcluster(z, 0.25, criterion="distance")
clustered_sites: dict[int, list[Site]] = {c: [] for c in clusters}
for i, c in enumerate(clusters):
clustered_sites[c].append(slab[i])
plane_heights = {
np.average(np.mod([s.frac_coords[2] for s in sites], 1)): c for c, sites in clustered_sites.items()
}
top_plane_cluster = sorted(plane_heights.items(), key=lambda x: x[0])[-1][1]
top_plane_sites = clustered_sites[top_plane_cluster]
top_plane = Structure.from_sites(top_plane_sites)
sp_symbol = SpacegroupAnalyzer(top_plane, symprec=0.1).get_space_group_symbol()
form = top_plane.composition.reduced_formula
return f"{form}_{sp_symbol}_{len(top_plane)}"
def count_layers(struc: Structure, el=None) -> int:
"""
Counts the number of 'layers' along the c-axis
"""
el = el if el else struc.composition.elements[0]
frac_coords = [site.frac_coords for site in struc if site.species_string == str(el)]
n = len(frac_coords)
if n == 1:
return 1
dist_matrix = np.zeros((n, n))
h = struc.lattice.c
# Projection of c lattice vector in
# direction of surface normal.
for i, j in combinations(list(range(n)), 2):
if i != j:
cdist = frac_coords[i][2] - frac_coords[j][2]
cdist = abs(cdist - round(cdist)) * h
dist_matrix[i, j] = cdist
dist_matrix[j, i] = cdist
condensed_m = squareform(dist_matrix)
z = linkage(condensed_m)
clusters = fcluster(z, 0.25, criterion="distance")
clustered_sites: dict[int, list[Site]] = {c: [] for c in clusters}
for i, c in enumerate(clusters):
clustered_sites[c].append(struc[i])
plane_heights = {
np.average(np.mod([s.frac_coords[2] for s in sites], 1)): c for c, sites in clustered_sites.items()
}
return len(plane_heights)