"""
Implements functions for extracting ('isolating') a low-dimensional material
component in its own unit cell.
This uses the rank-determination method described in:
Definition of a scoring parameter to identify low-dimensional materials
components
P.M. Larsen, M. Pandey, M. Strange, and K. W. Jacobsen
Phys. Rev. Materials 3 034003, 2019
https://doi.org/10.1103/PhysRevMaterials.3.034003
"""
import itertools
import collections
import numpy as np
from ase import Atoms
from ase.geometry.cell import complete_cell
from ase.geometry.dimensionality import analyze_dimensionality
from ase.geometry.dimensionality import rank_determination
from ase.geometry.dimensionality.bond_generator import next_bond
from ase.geometry.dimensionality.interval_analysis import merge_intervals
def orthogonal_basis(X, Y=None):
is_1d = Y is None
b = np.zeros((3, 3))
b[0] = X
if not is_1d:
b[1] = Y
b = complete_cell(b)
Q = np.linalg.qr(b.T)[0].T
if np.dot(b[0], Q[0]) < 0:
Q[0] = -Q[0]
if np.dot(b[2], Q[1]) < 0:
Q[1] = -Q[1]
if np.linalg.det(Q) < 0:
Q[2] = -Q[2]
if is_1d:
Q = Q[[1, 2, 0]]
return Q
def select_cutoff(atoms):
intervals = analyze_dimensionality(atoms, method='RDA', merge=False)
dimtype = max(merge_intervals(intervals), key=lambda x: x.score).dimtype
m = next(e for e in intervals if e.dimtype == dimtype)
if m.b == float("inf"):
return m.a + 0.1
else:
return (m.a + m.b) / 2
def traverse_graph(atoms, kcutoff):
if kcutoff is None:
kcutoff = select_cutoff(atoms)
rda = rank_determination.RDA(len(atoms))
for (k, i, j, offset) in next_bond(atoms):
if k > kcutoff:
break
rda.insert_bond(i, j, offset)
rda.check()
return rda.graph.find_all(), rda.all_visited, rda.ranks
def build_supercomponent(atoms, components, k, v, anchor=True):
# build supercomponent by mapping components into visited cells
positions = []
numbers = []
for c, offset in dict(v[::-1]).items():
indices = np.where(components == c)[0]
ps = atoms.positions + np.dot(offset, atoms.get_cell())
positions.extend(ps[indices])
numbers.extend(atoms.numbers[indices])
positions = np.array(positions)
numbers = np.array(numbers)
# select an 'anchor' atom, which will lie at the origin
anchor_index = next((i for i in range(len(atoms)) if components[i] == k))
if anchor:
positions -= atoms.positions[anchor_index]
return positions, numbers
def select_chain_rotation(scaled):
best = (-1, [1, 0, 0])
for s in scaled:
vhat = np.array([s[0], s[1], 0])
norm = np.linalg.norm(vhat)
if norm < 1E-6:
continue
vhat /= norm
obj = np.sum(np.dot(scaled, vhat)**2)
best = max(best, (obj, vhat), key=lambda x: x[0])
_, vhat = best
cost, sint, _ = vhat
rot = np.array([[cost, -sint, 0], [sint, cost, 0], [0, 0, 1]])
return np.dot(scaled, rot)
def isolate_chain(atoms, components, k, v):
# identify the vector along the chain; this is the new cell vector
basis_points = np.array([offset for c, offset in v if c == k])
assert len(basis_points) >= 2
assert (0, 0, 0) in [tuple(e) for e in basis_points]
sizes = np.linalg.norm(basis_points, axis=1)
index = np.argsort(sizes)[1]
basis = basis_points[index]
vector = np.dot(basis, atoms.get_cell())
norm = np.linalg.norm(vector)
vhat = vector / norm
# project atoms into new basis
positions, numbers = build_supercomponent(atoms, components, k, v)
scaled = np.dot(positions, orthogonal_basis(vhat).T / norm)
# move atoms into new cell
scaled[:, 2] %= 1.0
# subtract barycentre in x and y directions
scaled[:, :2] -= np.mean(scaled, axis=0)[:2]
# pick a good chain rotation (i.e. non-random)
scaled = select_chain_rotation(scaled)
# make cell large enough in x and y directions
init_cell = norm * np.eye(3)
pos = np.dot(scaled, init_cell)
rmax = np.max(np.linalg.norm(pos[:, :2], axis=1))
rmax = max(1, rmax)
cell = np.diag([4 * rmax, 4 * rmax, norm])
# construct a new atoms object containing the isolated chain
return Atoms(numbers=numbers, positions=pos, cell=cell, pbc=[0, 0, 1])
def construct_inplane_basis(atoms, k, v):
basis_points = np.array([offset for c, offset in v if c == k])
assert len(basis_points) >= 3
assert (0, 0, 0) in [tuple(e) for e in basis_points]
sizes = np.linalg.norm(basis_points, axis=1)
indices = np.argsort(sizes)
basis_points = basis_points[indices]
# identify primitive basis
best = (float("inf"), None)
for u, v in itertools.combinations(basis_points, 2):
basis = np.array([[0, 0, 0], u, v])
if np.linalg.matrix_rank(basis) < 2:
continue
a = np.dot(u, atoms.get_cell())
b = np.dot(v, atoms.get_cell())
norm = np.linalg.norm(np.cross(a, b))
best = min(best, (norm, a, b), key=lambda x: x[0])
_, a, b = best
return a, b, orthogonal_basis(a, b)
def isolate_monolayer(atoms, components, k, v):
a, b, basis = construct_inplane_basis(atoms, k, v)
# project atoms into new basis
c = np.cross(a, b)
c /= np.linalg.norm(c)
init_cell = np.dot(np.array([a, b, c]), basis.T)
positions, numbers = build_supercomponent(atoms, components, k, v)
scaled = np.linalg.solve(init_cell.T, np.dot(positions, basis.T).T).T
# move atoms into new cell
scaled[:, :2] %= 1.0
# subtract barycentre in z-direction
scaled[:, 2] -= np.mean(scaled, axis=0)[2]
# make cell large enough in z-direction
pos = np.dot(scaled, init_cell)
zmax = np.max(np.abs(pos[:, 2]))
cell = np.copy(init_cell)
cell[2] *= 4 * zmax
# construct a new atoms object containing the isolated chain
return Atoms(numbers=numbers, positions=pos, cell=cell, pbc=[1, 1, 0])
def isolate_bulk(atoms, components, k, v):
positions, numbers = build_supercomponent(atoms, components, k, v,
anchor=False)
atoms = Atoms(numbers=numbers, positions=positions, cell=atoms.cell,
pbc=[1, 1, 1])
atoms.wrap(eps=0)
return atoms
def isolate_cluster(atoms, components, k, v):
positions, numbers = build_supercomponent(atoms, components, k, v)
positions -= np.min(positions, axis=0)
cell = np.diag(np.max(positions, axis=0))
return Atoms(numbers=numbers, positions=positions, cell=cell, pbc=False)
[docs]def isolate_components(atoms, kcutoff=None):
"""Isolates components by dimensionality type.
Given a k-value cutoff the components (connected clusters) are
identified. For each component an Atoms object is created, which contains
that component only. The geometry of the resulting Atoms object depends
on the component dimensionality type:
0D: The cell is a tight box around the atoms. pbc=[0, 0, 0].
The cell has no physical meaning.
1D: The chain is aligned along the z-axis. pbc=[0, 0, 1].
The x and y cell directions have no physical meaning.
2D: The layer is aligned in the x-y plane. pbc=[1, 1, 0].
The z cell direction has no physical meaning.
3D: The original cell is used. pbc=[1, 1, 1].
Parameters:
atoms: ASE atoms object
The system to analyze.
kcutoff: float
The k-value cutoff to use. Default=None, in which case the
dimensionality scoring parameter is used to select the cutoff.
Returns:
components: dict
key: the component dimenionalities.
values: a list of Atoms objects for each dimensionality type.
"""
data = {}
components, all_visited, ranks = traverse_graph(atoms, kcutoff)
for k, v in all_visited.items():
v = sorted(list(v))
# identify the components which constitute the component
key = tuple(np.unique([c for c, offset in v]))
dim = ranks[k]
if dim == 0:
data[('0D', key)] = isolate_cluster(atoms, components, k, v)
elif dim == 1:
data[('1D', key)] = isolate_chain(atoms, components, k, v)
elif dim == 2:
data[('2D', key)] = isolate_monolayer(atoms, components, k, v)
elif dim == 3:
data[('3D', key)] = isolate_bulk(atoms, components, k, v)
result = collections.defaultdict(list)
for (dim, _), atoms in data.items():
result[dim].append(atoms)
return result