# author: Rui-Hao Bi (biruihao@westlake.edu.cn)
# summary: rutile (110)-water interface toolkit
# summary: this contains the general methods for rutile-water(110)
# 2021-2024
# general modules
import os
import glob
import numpy as np
from ase.geometry import cellpar_to_cell
from ase import Atoms
# distance caculation library functions
from MDAnalysis.lib.distances import *
from MDAnalysis.lib.c_distances import _minimize_vectors_triclinic
[docs]def get_pair(xyz, idx1, idx2, cutoff_hi, cutoff_lo=None, cell=None, **kwargs):
"""
search possible pairs between idx1 and idx2,
whose distance between are smaller than cutoff_hi
"""
atoms1 = xyz[idx1]
atoms2 = xyz[idx2]
pairs, distances = capped_distance(reference=atoms1, configuration=atoms2,
max_cutoff=cutoff_hi, min_cutoff=cutoff_lo,
box=cell)
return np.vstack((idx1[pairs[:, 0]], idx2[pairs[:, 1]])).T
[docs]def minimize_vectors_triclinic(v: np.ndarray, box: np.ndarray, ):
"""
computes the mic vector
"""
res = np.zeros_like(v, dtype=np.float32)
v_32 = v.astype(np.float32)
box_32 = box.flatten().astype(np.float32)
_minimize_vectors_triclinic(vectors=v_32, box=box_32, output=res)
return res
[docs]def normalized_vector(v: np.ndarray):
"""calculate the normalized vector
Args:
v (np.ndarray): a vector
Returns:
np.ndarray : normalized vector
"""
return v / np.linalg.norm(v)
[docs]def d_unique_vecs(vecs):
"""get the unique distance vectors
Args:
vecs (np.ndarray): distance vectors
Returns:
dict: a dictionary holds the unique distance vectors.
"""
u = {}
count = 1
for vec in vecs:
flag = 0
if len(u.keys()) == 0:
u.update({count: [vec]})
else:
for k in u.keys():
if np.linalg.norm(np.cross(vec, u[k][0])) > 0.5:
flag += 1
else:
u[k].append(vec)
if len(u.keys()) == flag:
count += 1
u.update({count: [vec]})
return u
[docs]def g_unique_vecs(d: dict):
"""ensures the unique vectors calculated by `d_unique_vecs`.
Handles the case when the each key in the `d` dictonary has multiple numerically close values. Take the average of these vectors as the "bond" vector.
Args:
d (dict): dictionary calculated from `d_unique_vecs`
Returns:
np.ndarray: a numpy array holds the "bond" vectors. For an octahedral center metal, there are 6 distinct vectors.
"""
for k in d.keys():
tmp = np.array(d[k])
tmp = np.where((tmp[:, -1]>0)[:, np.newaxis], tmp, -tmp)
d[k] = tmp.mean(axis=0)
vecs = np.empty((len(d), 3), dtype=float)
count = 0
for k in d.keys():
vecs[count] = d[k]
count+=1
return vecs
[docs]def get_octahedral_bonds(tio2_cut: Atoms, octahedral_bonds_upper_bound: float=2.4):
"""calculate the octahedral bonds (vectors) in an \\hkl(1-11) edge model
Args:
tio2_cut (Atoms): the edge model cut from the bulk structure. (the output of `cut_edge_from_bulk`.)
octahedral_bonds_upper_bound (float, optional): The longest length to recogonize an oxygen as a octahedral ligand. Defaults to 2.4.
Returns:
np.ndarray: a coordinates array for the octahedral vectors.
"""
# compute the distance matrix under PBC
xyz = tio2_cut.positions
o_idx = np.where(tio2_cut.symbols=="O")[0]
ti_idx = np.where(tio2_cut.symbols=="Ti")[0]
pair = get_pair(xyz, ti_idx, o_idx, cutoff_hi=2.4, cutoff_lo=None, cell=tio2_cut.cell.cellpar())
diff = xyz[pair[:, 1]] - xyz[pair[:, 0]]
vecs = minimize_vectors_triclinic(diff, tio2_cut.get_cell().array)
uniqe_dist_vec = d_unique_vecs(vecs)
octahedral_bonds = g_unique_vecs(uniqe_dist_vec)
return octahedral_bonds
[docs]def get_rotM_edged_rutile110(tio2: Atoms, octahedral_bonds_upper_bound: float=2.4, bridge_along="x"):
"""compute the unit xyz vectors for a slab of \\hkl(1-11) edged tio2.
!!!!! TODO ---> This method is currently very adhoc and prone to failure
!!!!! WE NEED TO IMPROVE THIS
Detailed algorithm:
First, one will get all the 6-"octahedral bonds", (as detailed in `get_octahedral_bonds`)
Second, notice the two of the enlogated octahedral bonds roughly aligns with:
- unit z direction <the direction of the terminal water -> Ti bond >
- unit x direction <perpendicular to the bridge water row>
With unit z and unit x, one can reconstruct the unit x by simply cross product
Args:
tio2_cut (Atoms): any tio2 \\hkl(1-11) edge model (hopefully it will work for all input)
octahedral_bonds_upper_bound (float, optional): The longest length to recogonize an oxygen as a octahedral ligand. Defaults to 2.4.
bridge_along (str, optional): The direction of the bridge oxygens, could be either "x" or "y". Defaults to "y".
Returns:
np.array: the normal vectors, which could be used as the rotM matrix for the coordinates
"""
def is_v_dominant(v, ind):
abs_val = np.abs(v)
if np.max(abs_val) == abs_val[ind]:
i = np.arange(3)
iother = i[i!=ind]
if (abs_val[iother][0] / abs_val[ind] < 0.5) and (abs_val[iother][1] / abs_val[ind] < 0.5) and (abs_val[ind] > 1.1):
return True
return False
def do_average(v_list, ind):
abs_val = np.abs(v_list)
avg_amplitude = abs_val.mean(axis=0)
i = np.arange(3)
iother = i[i!=ind]
arg_second_largest = np.argmax(avg_amplitude[iother])
values = v_list[:, iother[arg_second_largest]]
signs = np.sign(values)
if len(signs)==1:
return v_list
# Check if all signs are the same
if (signs[1:] == signs[:-1]).all():
return np.mean(v_list, axis=0)
else:
# Find the majority sign
positive_count = np.sum(signs > 0)
negative_count = np.sum(signs < 0)
if positive_count >= negative_count:
# If positive signs are the majority, average the positive vectors
positive_vectors = v_list[signs > 0]
return np.mean(positive_vectors, axis=0) if len(positive_vectors) > 2 else positive_vectors
else:
# If negative signs are the majority or if counts are equal, average the negative vectors
negative_vectors = v_list[signs < 0]
return np.mean(negative_vectors, axis=0) if len(negative_vectors) > 2 else negative_vectors
def get_averaged(octahedral_bonds, ind):
v_list = []
for v in octahedral_bonds:
if is_v_dominant(v, ind):
out = v if v[ind] > 0 else -v
v_list.append(out)
v_list = np.array(v_list)
return do_average(v_list, ind)
octahedral_bonds = get_octahedral_bonds(tio2, octahedral_bonds_upper_bound)
if bridge_along == "y":
x_up = normalized_vector(get_averaged(octahedral_bonds, 0))
z_up = normalized_vector(get_averaged(octahedral_bonds, 2))
y_up = np.cross(z_up, x_up)
elif bridge_along== "x":
y_up = normalized_vector(get_averaged(octahedral_bonds, 1))
z_up = normalized_vector(get_averaged(octahedral_bonds, 2))
x_up = np.cross(y_up, z_up)
return np.array([x_up, y_up, z_up])
[docs]def find_cn_idx(atoms1, atoms2, cutoff_hi, cutoff_lo=None, cell=None, **kwargs):
"""count the coordination number(CN) for atoms1 (center atoms), where atoms2 are coordinate atom.
This function will calculate CN within range cutoff_lo < d < cutoff_lo, where d is the distance
between atoms1 and atoms2. Minimum image convention is applied if cell is not `None`
Args:
atoms1 (numpy.ndarray):
Array with shape (N, 3), where N is the number of center atoms. 'atoms1' are the
position of center atoms.
atoms2 (numpy.ndarray):
Array with shape (M, 3), where M is the number of coordination atoms. 'atoms2' are
the positions of coordination atoms.
cutoff_hi (float):
Max cutoff for calculating coordination number.
cutoff_lo (float or None, optional):
Min cutoff for calculating coordination number. This function will calculate CN within
range cutoff_lo < d < cutoff_lo, where d is the distance between atoms1 and atoms2.
Defaults to None.
cell (numpy.ndarray, optional):
Array with shape (6,), Array([a, b, c, alpha, beta, gamma]). Simulation cell parameters.
If it's not None, the CN calculation will use minimum image convention. Defaults to `None`.
Returns:
results:
Array with shape (N,), CN of each atoms atoms1
"""
cell = np.array(cell).astype(np.float32)
pairs, _ = capped_distance(reference=atoms1,
configuration=atoms2,
max_cutoff=cutoff_hi,
min_cutoff=cutoff_lo,
box=cell)
# to avoid double counting
cn_idx = np.unique(pairs[:, 1])
return cn_idx
[docs]def count_cn(atoms1, atoms2, cutoff_hi, cutoff_lo=None, cell=None, **kwargs):
"""count the coordination number(CN) for atoms1 (center atoms), where atoms2 are coordinate atom.
This function will calculate CN within range cutoff_lo < d < cutoff_lo, where d is the distance
between atoms1 and atoms2. Minimum image convention is applied if cell is not `None`
Args:
atoms1 (numpy.ndarray):
Array with shape (N, 3), where N is the number of center atoms. 'atoms1' are the
position of center atoms.
atoms2 (numpy.ndarray):
Array with shape (M, 3), where M is the number of coordination atoms. 'atoms2' are
the positions of coordination atoms.
cutoff_hi (float):
Max cutoff for calculating coordination number.
cutoff_lo (float or None, optional):
Min cutoff for calculating coordination number. This function will calculate CN within
range cutoff_lo < d < cutoff_lo, where d is the distance between atoms1 and atoms2.
Defaults to None.
cell (numpy.ndarray, optional):
Array with shape (6,), Array([a, b, c, alpha, beta, gamma]). Simulation cell parameters.
If it's not None, the CN calculation will use minimum image convention. Defaults to `None`.
Returns:
results:
Array with shape (N,), CN of each atoms atoms1
"""
# cell = np.array(cell).astype(np.float32)
pairs, _ = capped_distance(reference=atoms1,
configuration=atoms2,
max_cutoff=cutoff_hi,
min_cutoff=cutoff_lo,
box=cell)
_minlength = atoms1.shape[0]
results = np.bincount(pairs[:, 0], minlength=_minlength)
return results
[docs]def cellpar2volume(cellpar):
cell = cellpar_to_cell(cellpar)
return np.abs(np.dot(np.cross(cell[0], cell[1]), cell[2]))
[docs]def get_watOidx(atoms, M="Ti", d_OH_cutoff=1.2, d_MO_cutoff=2.8, cn_M_cutoff=1):
"""gets all the water oxygen indices in rutile (110)-water interface
Args:
atoms (ase.Atoms):
ASE atoms. One snapshot of rutile (110)-water structure.
M (str, optional):
The metal atom in rutile structrue. Defaults to "Ti".
Returns:
watOidx (numpy.ndarray): 0-based indices for water oxygen atoms.
watHidx (numpy.ndarray): 0-based indices for water hydrogen atoms. (all the hydrogens)
"""
xyz = atoms.positions
cell = atoms.cell.cellpar()
idx_M = np.where(atoms.symbols == M)[0]
idx_O = np.where(atoms.symbols == 'O')[0]
idx_H = np.where(atoms.symbols == 'H')[0]
cn_H = count_cn(xyz[idx_O, :], xyz[idx_H, :], d_OH_cutoff, None, cell)
cn_M = count_cn(xyz[idx_O, :], xyz[idx_M, :], d_MO_cutoff, None, cell)
watOidx = idx_O[(cn_H >= 0) * (cn_M <= cn_M_cutoff)]
hcn_idx = find_cn_idx(xyz[watOidx, :], xyz[idx_H, :], d_OH_cutoff, None, cell)
watHidx = idx_H[hcn_idx]
return watOidx, watHidx
[docs]def interface_2_slab(atoms, M="Ti"):
"""transform rutile (110)-water interface model to only the slab, i.e., deleting all the
water molecules.
Args:
atoms (ase.Atoms):
ASE atoms. One snapshot of rutile (110)-water structure.
M (str, optional):
The metal atom in rutile structrue. Defaults to "Ti".
Returns:
idx_slab(numpy.ndarray):
The indices for the slab model.
atoms_slab(ase.atoms):
Slab model atoms object.
"""
indices = np.arange(atoms.get_global_number_of_atoms())
idx_ow, idx_hw = get_watOidx(atoms, M="Ti")
idx_wat = np.append(idx_ow, idx_hw)
idx_slab = np.setdiff1d(indices, idx_wat)
atoms_slab = atoms[idx_slab]
return idx_slab, atoms_slab
[docs]def get_rotM(vecy, vecz):
r"""get the rotation matrix for rotating a rutile model. Specifically, rotating a step
edge model s.t x is parrallel to [001], y is parrallel to [1-10], and z is parallel
to \hkl<110>.
reference:
https://www.cnblogs.com/armme/p/10596697.html#:~:text=旋转坐标系的方法又有两种:%20Proper%20Euler%20angles%2C%20第一次与第三次旋转相同的坐标轴(z-x-z%2Cx-y-x%2C%20y-z-y%2Cz-y-z%2C%20x-z-x%2C,y-x-y)%E3%80%82%20Tait–Bryan%20angles%2C%20依次旋转三个不同的坐标轴(x-y-z%2Cy-z-x%2C%20z-x-y%2Cx-z-y%2C%20z-y-x%2C%20y-x-z);
or see https://shorturl.at/pvxyE
Args:
vecy (numpy.ndarray):
Array with shape (3, ). This direction parallels to Obr/Ti5c direction of the
original simulation cell.
vecz (numpy.ndarray):
Array with shape (3, ). This direction parallels to the [110] of the original
simulation cell.
"""
def e_vec(vec):
return vec/np.linalg.norm(vec)
vecx = np.cross(vecz, vecy)
vecx, vecy, vecz = list(map(e_vec, [vecx, vecy, vecz]))
vece1, vece2, vece3 = [1, 0, 0], [0, 1, 0], [0, 0, 1]
refj = [vecx, vecy, vecz]
refi = [vece1, vece2, vece3]
rotM = np.empty((3, 3), dtype=float)
rotM[:] = np.nan
def get_cos(vec1, vec2):
return np.dot(vec1, vec2)/(np.linalg.norm(vec1)*np.linalg.norm(vec2))
for ii in range(3):
for jj in range(3):
rotM[ii, jj] = get_cos(refj[ii], refi[jj])
return rotM
[docs]def sep_upper_lower(z, indices):
"""given indices, seperate them to upper and lower. More specifically, from
[<indices>] to [[<idx_upper>], [<idx_lower>]]
Args:
z (numpy.ndarray):
z coordinates
indices (numpy.ndarray):
0-based indices
Returns:
numpy.ndarray: [[<idx_upper>], [<idx_lower>]]
"""
z = z[indices]
zmean = z.mean()
i1 = indices[z > zmean]
i2 = indices[z < zmean]
return np.array([i1, i2])
[docs]def pair_M5c_n_obr(atoms, idx_cn5, idx_obrs, M="Ti"):
"""This methods will find the Ti5c's neighboring Obr atoms.
Args:
atoms (ASE.Atoms):
ase atoms of your interface model.
idx_cn5 (np.ndarray):
1-d integer array, which contains indices for Ti5c atoms.
idx_obrs (np.ndarray):
1-d integer array, which contains indices for all the Obr atoms.
M (str, optional):
Metal elements in the rutile structure. Defaults to "Ti".
Returns:
tuple: (<idx_cn5>, <res_obr>).
Paired Ti5c and Obr indices. Check if they are really paired before you use.
"""
# obtain the distance matrix between Ti5c and Obr
xyz1 = atoms.positions[idx_cn5]
xyz2 = atoms.positions[idx_obrs]
dm = distance_array(xyz1, xyz2, box=atoms.cell.cellpar())
# Find the two nearest Obr's to cn5s, and
# they are exactly the left and right obr
sort = np.argsort(dm, axis=1)[:, :2]
res_idx = idx_obrs[sort]
# filp the array s.t.
# the first column -> obr POSY
# the second column -> obr NEGY
v = atoms.positions[res_idx[:, 0]] - atoms.positions[res_idx[:, 1]]
# apply minimum image convention
mic_v = minimize_vectors(v, box=atoms.cell.cellpar())
sel = mic_v[:, 1] < 0 # selective flipping
res_idx[sel] = np.flip(res_idx[sel], axis=1)
return idx_cn5, res_idx
# def group_by_row(atoms, idx, ngroup=2):
# """Group the atoms by row. To be more specific, rutile (110) surface has rows of Ti5c and Obrs. This method group surface atoms.
#
# Args:
# atoms (ase.Atoms): _description_
# idx (numpy.ndarray): _description_
# ngroup (int, optional): . Defaults to 2.
#
# Raises:
# ValueError: This method will group the atoms by rows. Throw this error if calculated group number is not consistent with 'ngroup'.
#
# Returns:
# numpy.ndarray: [[<row_1>], [<row_2>], ..., [<row_ngroup>]]
# """
# xyz = atoms.positions
# # group the atoms according to their distances
# dm = np.round(distance_array(xyz[idx], xyz[idx], box=atoms.cell.cellpar()))
# groups = np.unique(dm < dm.min()+2, axis=0)
#
# if groups.shape[0] != ngroup:
# raise ValueError("grouping result does not match 'ngroup'. First check if the row number match 'ngroup'. Consider move your structure")
#
# rows = idx[groups[0]], idx[groups[1]]
# return rows
#
# def sort_by_row(atoms, idx, ngroup=2):
# """Group the atoms by row. To be more specific, rutile (110) surface has rows of Ti5c and Obrs. This method group surface atoms.
#
# Args:
# atoms (ase.Atoms): _description_
# idx (numpy.ndarray): _description_
# rotM (numpy.ndarray, optional): The roational matrix. Defaults to None.
# ngroup (int, optional): . Defaults to 2.
#
# Raises:
# ValueError: This method will group the atoms by rows. Throw this error if calculated group number is not consistent with 'ngroup'.
#
# Returns:
# _type_: _description_
# """
# xyz = atoms.positions
# # FIRST: rotate xyz
# if rotM is not None:
# xyz = np.matmul(atoms.positions, rotM)
# # THEN: group the atoms having identical x value
# #xx, yy = np.round(xyz[:, 0]), np.round(xyz[:, 1])
# dm = np.round(distance_array(xyz[idx], xyz[idx], box=atoms.cell.cellpar()))
# groups = np.unique(dm < dm.min()+2, axis=0)
#
# flag = False
# if groups.shape[0] != ngroup:
# dm = distance_array(yy[idx].reshape(-1, 1), yy[idx].reshape(-1, 1))
# groups = np.unique(dm < 2, axis=0)
# if groups.shape[0] != ngroup:
# raise ValueError("grouping result does not match 'ngroup'. First check if the row number match 'ngroup'. Consider move your structure")
# else:
# flag = True
# else:
# pass
# # LAST: sort ti5c according to rows
# def sort_row(row, dat=yy):
# return row[np.argsort(dat[row])]
# rows = idx[groups[0]], idx[groups[1]]
# if flag:
# st = partial(sort_row, dat=xx)
# else:
# st = partial(sort_row, dat=yy)
# res = np.array(list(map(st, rows)))
# return res
# tricks
[docs]def get_sym_edge(atoms, idx_l_edge4=0):
"""Translate the rutile <1-11> edge-water interface model s.t. it looks
pretty and symmetric.
The trick is, when using experimental rutile structrue and ase 'surface'
method generates the edge model, the lower surface 4-coordinated edge Ti has index 1
(0-based). Setting this paticular atom to [0.3, 0.3, 3.5] will get the model
look symmetric in the simulation box, thus the model becomes easier to
handle. Here symmetric and pretty mean the model looks like:
*****************************
*****************************
*****************************
*****************************
*****************************
*****************************
*****************************
*****************************
00***************************
000**************************
ooooooooooooooooooooooooooooo
ooooooooooooooooooooooooooooo
ooooooooooooooooooooooooooooo
ooooooooooooooooooooooooooooo
ooooooooooooooooooooooooooooo [110]
ooooooooooooooooooooooooooooo ^
**************************000 | o: TiO2 slab
***************************00 | 0: <1-11>-edge
***************************** -----> [001] *: water
Args:
atoms (ase.Atoms):
the edge model ASE atoms.
idx_l_edge4 (int, optional):
index of lower surface edge M_4c atom.
Returns:
ase.Atoms:
symmetric & pretty-looking edge model
"""
target_pos = np.array([1.5, 1.5, 3.0])
trans = target_pos - atoms.positions[idx_l_edge4]
atoms.translate(trans)
atoms.wrap()
return atoms