Files
VASP_calc/electrochemistry/echem/core/structure.py

322 lines
13 KiB
Python
Raw Normal View History

2026-01-08 19:47:32 +03:00
from __future__ import annotations
import numpy as np
from typing import Iterable
from termcolor import colored
from nptyping import NDArray, Shape, Number
class Structure:
"""
Basic class for structure of unit/supercell.
Args:
lattice: 2D array that contains lattice vectors. Each row should correspond to a lattice vector.
E.g., [[5, 5, 0], [7, 4, 0], [0, 0, 25]].
species: List of species on each site. Usually list of elements, e.g., ['Al', 'Al', 'O', 'H'].
coords: List of lists or np.ndarray (Nx3 dimension) that contains coords of each species.
coords_are_cartesian: True if coords are cartesian, False if coords are fractional
"""
def __init__(self,
lattice: NDArray[Shape[3, 3], Number] | list[list[float]],
species: list[str],
coords: NDArray[Shape['Natoms, 3'], Number] | list[list[float]],
coords_are_cartesian: bool = True):
if len(species) != len(coords):
raise StructureError('Number of species and its coords must be the same')
self.species = species
if isinstance(lattice, np.ndarray):
self.lattice = lattice
else:
self.lattice = np.array(lattice)
if isinstance(coords, np.ndarray):
self.coords = coords
else:
self.coords = np.array(coords)
self.coords_are_cartesian = coords_are_cartesian
def __repr__(self) -> str:
lines = ['\nLattice:']
width = len(str(int(np.max(self.lattice)))) + 6
for axis in self.lattice:
lines.append(' '.join([f'{axis_coord:{width}.5f}' for axis_coord in axis]))
width = len(str(int(np.max(self.coords)))) + 6
lines.append('\nSpecies:')
unique, counts = np.unique(self.species, return_counts=True)
if len(self.species) < 10:
lines.append(' '.join([s for s in self.species]))
for u, c in zip(unique, counts):
lines.append(f'{u}:\t{c}')
lines.append(f'\nCoords are cartesian: {self.coords_are_cartesian}')
lines.append('\nCoords:')
for coord in self.coords:
lines.append(' '.join([f'{c:{width}.5f}' for c in coord]))
else:
part_1 = ' '.join([s for s in self.species[:5]])
part_2 = ' '.join([s for s in self.species[-5:]])
lines.append(part_1 + ' ... ' + part_2)
for u, c in zip(unique, counts):
lines.append(f'{u}:\t{c}')
lines.append(f'\nCoords are cartesian: {self.coords_are_cartesian}')
lines.append('\nCoords:')
for coord in self.coords[:5]:
lines.append(' '.join([f'{c:{width}.5f}' for c in coord]))
lines.append('...')
for coord in self.coords[-5:]:
lines.append(' '.join([f'{c:{width}.5f}' for c in coord]))
return '\n'.join(lines)
def __eq__(self, other: Structure) -> bool:
assert isinstance(other, Structure), 'Other object be a Structure class'
if not self.coords_are_cartesian == other.coords_are_cartesian:
if self.coords_are_cartesian:
other.mod_coords_to_cartesian()
print(colored('Coords of other were modified into Cartesian', color='green'))
else:
other.mod_coords_to_direct()
print(colored('Coords of other were modified into Direct', color='green'))
return np.allclose(self.lattice, other.lattice, atol=1e-10, rtol=1e-10) and \
(self.species == other.species) and \
np.allclose(self.coords, other.coords, atol=1e-10, rtol=1e-10)
@property
def natoms(self) -> int:
return len(self.species)
@property
def natoms_by_type(self) -> dict[str: int]:
unique, counts = np.unique(self.species, return_counts=True)
return {i: j for i, j in zip(unique, counts)}
def mod_add_atoms(self, coords, species) -> None:
"""
Adds atoms in the Structure
Args:
coords: List or np.ndarray (Nx3 dimension) that contains coords of each species
species: List of species on each site. Usually list of elements, e.g., ['Al', 'Al', 'O', 'H']
"""
if not isinstance(coords, np.ndarray):
coords = np.array(coords)
self.coords = np.vstack((self.coords, coords))
if isinstance(species, str):
self.species += [species]
else:
self.species += species
def mod_delete_atoms(self,
ids: int | list[int]) -> None:
"""
Deletes selected atoms by ids
Args:
ids: sequence of atoms ids
"""
self.coords = np.delete(self.coords, ids, axis=0)
self.species = np.delete(self.species, ids)
def mod_change_atoms(self,
ids: int | list[int],
coords: NDArray[Shape['Nids, 3'], Number] | None,
species: str | list[str] | None) -> None:
"""
Change selected atom by id
Args:
ids: List or int. First id is zero.
coords: None or np.array with new coords, e.g. np.array([1, 2, 4.6])
species: None or str or List[str]. New types of changed atoms
Returns:
"""
if coords is not None:
self.coords[ids] = coords
if species is not None:
if isinstance(ids, Iterable):
for i, sp in zip(ids, species):
self.species[i] = sp
else:
self.species[ids] = species
def mod_coords_to_cartesian(self) -> None | str:
"""
Converts species coordinates to Cartesian coordination system.
"""
if self.coords_are_cartesian is True:
return 'Coords are already cartesian'
else:
self.coords = np.matmul(self.coords, self.lattice)
self.coords_are_cartesian = True
def mod_coords_to_direct(self) -> None | str:
"""
Converts species coordinates to Direct coordination system.
"""
if self.coords_are_cartesian is False:
return 'Coords are already direct'
else:
transform = np.linalg.inv(self.lattice)
self.coords = np.matmul(self.coords, transform)
self.coords_are_cartesian = False
def mod_add_vector(self,
vector: NDArray[Shape['3'], Number],
cartesian: bool = True) -> None:
"""
Adds a vector to all atoms.
Args:
vector (np.ndarray): a vector which will be added to coordinates of all species
cartesian (bool): determine in which coordination system the vector defined. Cartesian=True means that
the vector is defined in Cartesian coordination system. Cartesian=False means that the vector is defined
in Direct coordination system.
"""
self.mod_coords_to_direct()
if cartesian:
transform = np.linalg.inv(self.lattice)
vector = np.matmul(vector, transform)
self.coords += vector
self.coords %= np.array([1, 1, 1])
def mod_propagate_unit_cell(self, a, b, c) -> None:
"""Extend the unit cell by propagation on lattice vector direction. The resulted unit cell will be the size
of [a * lattice[0], b * lattice[1], c * lattice[2]]. All atoms will be replicated with accordance of new
unit cell size
Args:
a (int): the number of replicas in the directions of the first unit cell vector
b (int): the number of replicas in the directions of the second unit cell vector
c (int): the number of replicas in the directions of the third unit cell vector.
"""
if not (isinstance(a, int) and isinstance(b, int) and isinstance(c, int)):
raise ValueError(f'a, b and c must be integers. But a, b, c have types {type(a), type(b), type(c)}')
if a * b * c > 1:
self.mod_coords_to_cartesian()
ref_coords = self.coords.copy()
ref_species = self.species.copy()
for i in range(a):
for j in range(b):
for k in range(c):
if i != 0 or j != 0 or k != 0:
vector = np.sum(np.array([i, j, k]).reshape(-1, 1) * self.lattice, axis=0)
self.coords = np.vstack((self.coords, ref_coords + vector))
self.species += ref_species
self.lattice = np.array([a, b, c]).reshape(-1, 1) * self.lattice
else:
raise ValueError(f'a, b and c must be positive. But {a=} {b=} {c=}')
def get_vector(self,
id_1: int,
id_2: int,
unit: bool = True) -> NDArray[Shape['3'], Number]:
"""
Returns a vector (unit vector by default) which starts in the atom with id_1 and points to the atom with id_2
Args:
id_1: id of the first atom (vector origin)
id_2: id of the second atom
unit: Defines whether the vector will be normed or not. Unit=True means that the vector norm will be equal 1
Returns (np.ndarray): vector from atom with id_1 to atom with id_2
"""
vector = self.coords[id_2] - self.coords[id_1]
if unit:
return vector / np.linalg.norm(vector)
else:
return vector
def get_distance_matrix(self) -> NDArray[Shape['Natoms, Natoms, 3'], Number]:
"""
Returns distance matrix R, where R[i,j] is the vector from atom i to atom j.
Returns:
np.ndarray (NxNx3 dimensions) which is a distance matrix in Cartesian coordination system
"""
self.mod_coords_to_direct()
r1 = np.broadcast_to(self.coords.reshape((self.natoms, 1, 3)), (self.natoms, self.natoms, 3))
r2 = np.broadcast_to(self.coords.reshape((1, self.natoms, 3)), (self.natoms, self.natoms, 3))
R = r2 - r1
R = (R + 0.5) % 1 - 0.5
assert np.all(R >= - 0.5) and np.all(R <= 0.5)
return np.matmul(R, self.lattice)
def get_distance_matrix_scalar(self) -> NDArray[Shape['Natoms, Natoms'], Number]:
"""
Returns distance matrix R, where R[i, j] is the Euclidean norm of a vector from atom i to atom j.
Returns:
np.ndarray (NxN dimensions) which is a distance matrix containing scalars
"""
R = self.get_distance_matrix()
return np.sqrt(np.sum(R * R, axis=2))
def get_filtered_ids(self, **kwargs):
"""
Returns np.ndarray that contains atom ids according to filter rules
Args:
species (str): Define which atom type will be selected. E.g. 'C H N' means select all C, H, and N atoms.
'!C' means select all atoms except C.
Returns:
np.ndarray contains ids of atoms according to selecting rules
"""
filter_mask = np.array([True for _ in range(self.natoms)], dtype=np.bool_)
if 'species' in kwargs:
species = kwargs['species'].split()
species_select = []
species_not_select = []
for specie in species:
if '!' in specie:
species_not_select.append(specie.replace('!', ''))
else:
species_select.append(specie)
if len(species_select):
fm_local = np.array([False for _ in range(self.natoms)], dtype=np.bool_)
for specie in species_select:
fm_local += np.array([True if atom_name == specie else False for atom_name in self.species])
filter_mask *= fm_local
if len(species_not_select):
fm_local = np.array([True for _ in range(self.natoms)], dtype=np.bool_)
for specie in species_not_select:
fm_local *= np.array([False if atom_name == specie else True for atom_name in self.species])
filter_mask *= fm_local
if 'x' in kwargs:
left, right = kwargs['x']
fm_local = np.array([False for _ in range(self.natoms)], dtype=np.bool_)
for i, atom_coord in enumerate(self.coords):
if left < atom_coord[0] < right:
fm_local[i] = True
filter_mask *= fm_local
if 'y' in kwargs:
left, right = kwargs['y']
fm_local = np.array([False for _ in range(self.natoms)], dtype=np.bool_)
for i, atom_coord in enumerate(self.coords):
if left < atom_coord[1] < right:
fm_local[i] = True
filter_mask *= fm_local
if 'z' in kwargs:
left, right = kwargs['z']
fm_local = np.array([False for _ in range(self.natoms)], dtype=np.bool_)
for i, atom_coord in enumerate(self.coords):
if left < atom_coord[2] < right:
fm_local[i] = True
filter_mask *= fm_local
return np.array(range(self.natoms))[filter_mask]
class StructureError(Exception):
"""
Exception class for Structure.
Raised when the structure has problems, e.g., atoms that are too close.
"""
pass