from functools import reduce
from copy import deepcopy
from typing import Optional
import warnings
import math
import re
import numpy as np
from .units import angstrom_to_bohr, ev_to_hartree
from .band import Band
[docs]class KPoint:
def __init__(self, index, frac_coords, weight):
self.index = index
self.frac_coords = frac_coords
self.weight = weight
[docs] def cart_coords(self, reciprocal_lattice):
"""Convert the reciprocal fractional coordinates for this k-point to \
reciprocal Cartesian coordinates.
Args:
reciprocal_lattice (np.array(float)): 3x3 numpy array containing the \
Cartesian reciprocal lattice.
Returns:
np.array: The reciprocal Cartesian coordinates of this k-point.
"""
return np.dot(self.frac_coords, reciprocal_lattice)
def __eq__(self, other):
return (
(self.index == other.index)
& (self.frac_coords == other.frac_coords).all()
& (self.weight == other.weight)
)
def __repr__(self) -> str:
return "k-point {}: {} weight = {}".format(
self.index, " ".join([str(c) for c in self.frac_coords]), self.weight
)
[docs]def get_numbers_from_string(string: str) -> list[float]:
p = re.compile(r"-?\d+[.\d]*")
return [float(s) for s in p.findall(string)]
[docs]def k_point_parser(string: str) -> list[KPoint]:
"""Parse k-point data from a PROCAR string.
Finds all lines of the form::
k-point 1 : 0.50000000 0.25000000 0.75000000 weight = 0.00806452
and extracts the k-point index, reciprocal fractional coordinates, and weight
into a :obj:`procar.KPoint` object.
Args:
string (str): String containing a full PROCAR file.
Returns:
list(:obj:`procar.KPoint`): A list of :obj:`procar.KPoint` objects.
"""
regex = re.compile(
r"k-point\s+(\d+)\s*:\s+([- ][01].\d{8})([- ][01].\d{8})([- ][01].\d{8})\s+weight = ([01].\d+)"
)
captured = regex.findall(string)
k_points = []
for kp in captured:
index = int(kp[0])
frac_coords = np.array([float(s) for s in kp[1:4]])
weight = float(kp[4])
k_points.append(KPoint(index=index, frac_coords=frac_coords, weight=weight))
return k_points
[docs]def projections_parser(string):
regex = re.compile(r"([-.\d\se]+tot.+)\n")
data = regex.findall(string)
data = [x.replace("tot", "0") for x in data]
data = np.array([x.split() for x in data], dtype=float)
return data
[docs]def area_of_a_triangle_in_cartesian_space(
a: np.ndarray, b: np.ndarray, c: np.ndarray
) -> float:
"""Returns the area of a triangle defined by three points in Cartesian space.
Args:
a (np.array): Cartesian coordinates of point A.
b (np.array): Cartesian coordinates of point B.
c (np.array): Cartesian coordinates of point C.
Returns:
float: the area of the triangle.
"""
return float(0.5 * np.linalg.norm(np.cross(b - a, c - a)))
[docs]def points_are_in_a_straight_line(points, tolerance=1e-7):
"""
Check whether a set of points fall on a straight line.
Calculates the areas of triangles formed by triplets of the points.
Returns False is any of these areas are larger than the tolerance.
Args:
points (list(np.array)): list of Cartesian coordinates for each point.
tolerance (optional:float): the maximum triangle size for these points to be considered colinear. Default is 1e-7.
Returns:
(bool): True if all points fall on a straight line (within the allowed tolerance).
"""
a = points[0]
b = points[1]
for c in points[2:]:
if area_of_a_triangle_in_cartesian_space(a, b, c) > tolerance:
return False
return True
[docs]def two_point_effective_mass(
cartesian_k_points: np.ndarray, eigenvalues: np.ndarray
) -> float:
"""Calculate the effective mass given eigenvalues at two k-points.
Reimplemented from Aron Walsh's original effective mass Fortran code.
Args:
cartesian_k_points (np.array): 2D numpy array containing the k-points in (reciprocal)
Cartesian coordinates.
eigenvalues (np.array): numpy array containing the eigenvalues at each k-point.
Returns:
float: The effective mass.
"""
assert cartesian_k_points.shape[0] == 2
assert eigenvalues.size == 2
dk = cartesian_k_points[1] - cartesian_k_points[0]
mod_dk = np.sqrt(np.dot(dk, dk))
delta_e = (eigenvalues[1] - eigenvalues[0]) * ev_to_hartree * 2.0
effective_mass = mod_dk * mod_dk / delta_e
return effective_mass
[docs]def least_squares_effective_mass(
cartesian_k_points: np.ndarray, eigenvalues: np.ndarray
) -> float:
"""Calculate the effective mass using a least squares quadratic fit.
Args:
cartesian_k_points (np.array): Cartesian reciprocal coordinates for the k-points.
eigenvalues (np.array): Energy eigenvalues at each k-point to be used in the fit.
Returns:
float: The fitted effective mass.
Raises:
ValueError: If the k-points do not sit on a straight line.
"""
if not points_are_in_a_straight_line(cartesian_k_points):
raise ValueError("k-points are not collinear")
dk = cartesian_k_points - cartesian_k_points[0]
mod_dk = np.linalg.norm(dk, axis=1)
eigenvalues - eigenvalues[0]
effective_mass = 1.0 / (np.polyfit(mod_dk, eigenvalues, 2)[0] * ev_to_hartree * 2.0)
return effective_mass
[docs]class Procar:
"""
Object for working with PROCAR data.
Attributes:
data (numpy.array(float)): A 5D numpy array that stores the projection data.
Axes are k-points, bands, spin-channels, ions and sum over ions, lm-projections.
bands (numpy.array(:obj:`Band`)): A numpy array of ``Band`` objects, that contain band index, energy, and occupancy data.
k_points (numpy.array(:obj:`KPoint`)): A numpy array of ``KPoint`` objects, that contain fractional coordinates and weights for each k-point.
number_of_k_points (int): The number of k-points.
number_of_bands (int): The number of bands.
spin_channels (int): Number of spin channels in the PROCAR data:
- 1 for non-spin-polarised calculations.
- 2 for spin-polarised calculations.
- 4 for non-collinear calculations.
number_of_ions (int): The number of ions.
number_of_projections (int): The number of projections, e.g. TODO
calculation (dict): Dictionary of True | False values describing the calculation type.
Dictionary keys are 'non_spin_polarised', 'non_collinear', and 'spin_polarised'.
"""
def __init__(self, spin=1, negative_occupancies="warn"):
self._spin_channels = spin # should be determined from PROCAR
self._number_of_k_points = None
self._number_of_ions = None
self._number_of_bands = None
self._number_of_projections = None
self._k_point_blocks = None
self._data = None
self._bands = None
self._k_points = None
self.calculation = {
"non_spin_polarised": False,
"non_collinear": False,
"spin_polarised": False,
}
if negative_occupancies not in ["warn", "raise", "zero"]:
raise ValueError(
"negative_occupancies can be one of [ 'warn', 'raise', 'zero' ]"
)
self.negative_occupancies = negative_occupancies
# self.non_spin_polarised = None
@property
def occupancy(self):
return np.array([[band.index, band.occupancy] for band in self._bands])
def __add__(self, other):
if self.spin_channels != other.spin_channels:
raise ValueError(
"Can only concatenate Procars with equal spin_channels: {}, {}".format(
self.spin_channels, other.spin_channels
)
)
if self.number_of_ions != other.number_of_ions:
raise ValueError(
"Can only concatenate Procars with equal number_of_ions: {}, {}".format(
self.number_of_ions, other.number_of_ions
)
)
if self.number_of_bands != other.number_of_bands:
raise ValueError(
"Can only concatenate Procars with equal number_of_bands: {}, {}".format(
self.number_of_bands, other.number_of_bands
)
)
if self.number_of_projections != other.number_of_projections:
raise ValueError(
"Can only concatenate Procars with equal number_of_projections: {}, {}".format(
self.number_of_projections, other.number_of_projections
)
)
if self._k_point_blocks != other._k_point_blocks:
raise ValueError(
"Can only concatenate Procars with equal k_point_blocks: {}, {}".format(
self._k_point_blocks, other._k_point_blocks
)
)
if self.calculation != other.calculation:
raise ValueError(
"Can only concatenate Procars from equal calculations: {}, {}".format(
self.calculation, other.calculation
)
)
new_procar = deepcopy(self)
new_procar._data = np.concatenate((self._data, other._data), axis=0)
new_procar._number_of_k_points = (
self.number_of_k_points + other.number_of_k_points
)
new_procar._bands = []
new_procar._bands = np.ravel(np.concatenate([self.bands, other.bands], axis=1))
new_procar._k_points = self._k_points + other._k_points
for i, kp in enumerate(new_procar._k_points, 1):
kp.index = i
new_procar.sanity_check()
return new_procar
[docs] def parse_projections(self):
self.projection_data = projections_parser(self.read_in)
try:
assert self._number_of_bands * self._number_of_k_points == len(
self.projection_data
)
self._spin_channels = 1 # non-magnetic, non-spin-polarised
self._k_point_blocks = 1
self.calculation["non_spin_polarised"] = True
except Exception:
if self._number_of_bands * self._number_of_k_points * 4 == len(
self.projection_data
):
self._spin_channels = 4 # non-collinear (spin-orbit coupling)
self._k_point_blocks = 1
self.calculation["non_collinear"] = True
pass
elif self._number_of_bands * self._number_of_k_points * 2 == len(
self.projection_data
):
self._spin_channels = 2 # spin-polarised
self._k_point_blocks = 2
self.calculation["spin_polarised"] = True
pass
else:
raise
self._number_of_projections = (
int(self.projection_data.shape[1] / (self._number_of_ions + 1)) - 1
)
[docs] def parse_k_points(self):
self._k_points = k_point_parser(self.read_in)[: self._number_of_k_points]
[docs] def parse_bands(self):
band_data = re.findall(
r"band\s*(\d+)\s*#\s*energy\s*([-.\d]+)\s?\s*#\s" r"*occ.\s*([-.\d]+)",
self.read_in,
)
self._bands = np.array(
[
Band(
float(i),
float(e),
float(o),
negative_occupancies=self.negative_occupancies,
)
for i, e, o in band_data
]
)
[docs] def sanity_check(self):
assert self._number_of_k_points == len(
self._k_points
), "k-point number mismatch: {} in header; {} in file".format(
self._number_of_k_points, len(self._k_points)
)
read_bands = len(self._bands) / self._number_of_k_points / self._k_point_blocks
assert (
self._number_of_bands == read_bands
), "band mismatch: {} in header; {} in file".format(
self._number_of_bands, read_bands
)
[docs] @classmethod
def from_files(cls, filenames, **kwargs):
"""Create a :obj:`Procar` object by reading the projected wavefunction character of each band
from a series of VASP ``PROCAR`` files.
Useful when e.g. a band-structure calculation has been split over multiple VASP calculations,
for example, when using hybrid functionals.
Args:
filename (str): Filename of the ``PROCAR`` file.
**kwargs: See the ``from_file()`` method for a description of keyword arguments.
Returns:
(:obj:`vasppy.Procar`)
"""
pcars = [cls.from_file(f, **kwargs) for f in filenames]
return reduce(cls.__add__, pcars)
[docs] @classmethod
def from_file(
cls, filename, negative_occupancies="warn", select_zero_weighted_k_points=False
):
"""Create a :obj:`Procar` object by reading the projected wavefunction character of each band
from a VASP ``PROCAR`` file.
Args:
filename (str): Filename of the ``PROCAR`` file.
negative_occupancies (:obj:`Str`, optional): Select how negative occupancies are handled.
Options are:
- ``warn`` (default): Warn that some partial occupancies are negative.
- ``raise``: Raise an AttributeError.
- ``ignore``: Do nothing.
- ``zero``: Negative partial occupancies will be set to zero.
select_zero_weighted_k_points (:obj:`bool`, optional): Set to ``True`` to only
read in zero-weighted k-points from the ``PROCAR`` file. Default is ``False``.
Returns:
(:obj:`vasppy.Procar`)
"""
pcar = cls(negative_occupancies=negative_occupancies)
pcar._read_from_file(filename=filename)
if select_zero_weighted_k_points:
k_point_indices = [
i for i, kp in enumerate(pcar.k_points) if kp.weight == 0.0
]
pcar = pcar.select_k_points(k_point_indices)
return pcar
[docs] def read_from_file(self, filename):
warnings.warn(
"read_from_file() is deprecated as a part of the public API.\nPlease use Procar.from_file() or Procar.from_files() instead",
stacklevel=2,
)
return self._read_from_file(filename=filename)
def _read_from_file(self, filename):
"""Reads the projected wavefunction character of each band from a VASP PROCAR file.
Args:
filename (str): Filename of the PROCAR file.
Returns:
None
"""
with open(filename, "r") as file_in:
file_in.readline()
self._number_of_k_points, self._number_of_bands, self._number_of_ions = [
int(f) for f in get_numbers_from_string(file_in.readline())
]
self.read_in = file_in.read()
self.parse_k_points()
self.parse_bands()
self.parse_projections()
self.sanity_check()
self.read_in = None # clear memory
if self.calculation["spin_polarised"]:
self._data = (
self.projection_data.reshape(
(
self._spin_channels,
self._number_of_k_points,
self._number_of_bands,
self._number_of_ions + 1,
self._number_of_projections + 1,
)
)[:, :, :, :, 1:]
.swapaxes(0, 1)
.swapaxes(1, 2)
)
else:
self._data = self.projection_data.reshape(
(
self._number_of_k_points,
self._number_of_bands,
self._spin_channels,
self._number_of_ions + 1,
self._number_of_projections + 1,
)
)[:, :, :, :, 1:]
@property
def number_of_k_points(self):
"""The number of k-points described by this :obj:`Procar` object."""
assert (
self._number_of_k_points == self._data.shape[0]
), "Number of k-points in metadata ({}) not equal to number in PROCAR data ({})".format(
self._number_of_k_points, self._data.shape[0]
)
return self._number_of_k_points
@property
def number_of_bands(self):
"""The number of bands described by this :obj:`Procar` object."""
assert (
self._number_of_bands == self._data.shape[1]
), "Number of bands in metadata ({}) not equal to number in PROCAR data ({})".format(
self._number_of_bands, self._data.shape[1]
)
return self._number_of_bands
@property
def spin_channels(self):
"""The number of spin-channels described by this :obj:`Procar` object."""
assert (
self._spin_channels == self._data.shape[2]
), "Number of spin channels in metadata ({}) not equal to number in PROCAR data ({})".format(
self._spin_channels, self._data.shape[2]
)
return self._spin_channels
@property
def number_of_ions(self):
"""The number of ions described by thie :obj:`Procar` object."""
assert (
self._number_of_ions == self._data.shape[3] - 1
), "Number of ions in metadata ({}) not equal to number in PROCAR data ({})".format(
self._number_of_ions, self._data.shape[3] - 1
)
return self._number_of_ions
@property
def number_of_projections(self):
"""The number of lm-projections described by this :obj:`Procar` object."""
assert (
self._number_of_projections == self._data.shape[4]
), "Number of projections in metadata ({}) not equal to number in PROCAR data ({})".format(
self._number_of_projections, self._data.shape[4]
)
return self._number_of_projections
[docs] def print_weighted_band_structure(
self,
spins=None,
ions=None,
orbitals=None,
scaling=1.0,
e_fermi=0.0,
reciprocal_lattice=None,
):
band_structure_data = self.weighted_band_structure(
spins=spins,
ions=ions,
orbitals=orbitals,
scaling=scaling,
e_fermi=e_fermi,
reciprocal_lattice=reciprocal_lattice,
)
for i, band_data in enumerate(band_structure_data, 1):
print("# band: {}".format(i))
for k_point_data in band_data:
print(" ".join([str(f) for f in k_point_data]))
print()
[docs] def weighted_band_structure(
self,
spins=None,
ions=None,
orbitals=None,
scaling=1.0,
e_fermi=0.0,
reciprocal_lattice=None,
):
if spins:
spins = [s - 1 for s in spins]
else:
spins = list(range(self.spin_channels))
if not ions:
ions = [self.number_of_ions] # nions+1 is the `tot` index
if not orbitals:
orbitals = list(range(self.number_of_projections))
if self.calculation["spin_polarised"]:
band_energies = (
np.array([band.energy for band in self._bands])
.reshape(
(self.spin_channels, self.number_of_k_points, self.number_of_bands)
)[spins[0]]
.T
)
else:
band_energies = (
np.array([band.energy for band in self._bands])
.reshape((self.number_of_k_points, self.number_of_bands))
.T
)
orbital_projection = np.sum(self._data[:, :, :, :, orbitals], axis=4)
ion_projection = np.sum(orbital_projection[:, :, :, ions], axis=3)
spin_projection = np.sum(ion_projection[:, :, spins], axis=2)
x_axis = self.x_axis(reciprocal_lattice)
to_return = []
for i in range(self.number_of_bands):
for k, (e, p) in enumerate(
zip(band_energies[i], spin_projection.T[i], strict=None)
):
to_return.append([x_axis[k], e - e_fermi, p * scaling])
to_return = np.array(to_return).reshape((self.number_of_bands, -1, 3))
return to_return
[docs] def effective_mass_calc(
self, k_point_indices, band_index, reciprocal_lattice, spin=1, printing=False
):
assert spin <= self._k_point_blocks
assert len(k_point_indices) > 1 # we need at least 2 k-points
band_energies = self._bands[:, 1:].reshape(
self._k_point_blocks, self.number_of_k_points, self.number_of_bands
)
frac_k_point_coords = np.array(
[self._k_points[k - 1].frac_coords for k in k_point_indices]
)
eigenvalues = np.array(
[band_energies[spin - 1][k - 1][band_index - 1] for k in k_point_indices]
)
if printing:
print("# h k l e")
for row in np.concatenate(
(frac_k_point_coords, np.array([eigenvalues]).T), axis=1
):
print(" ".join([str(f) for f in row]))
reciprocal_lattice = reciprocal_lattice * 2 * math.pi * angstrom_to_bohr
cart_k_point_coords = np.array(
[k.cart_coords(reciprocal_lattice) for k in self._k_points]
) # convert k-points to cartesian
if len(k_point_indices) == 2:
effective_mass_function = two_point_effective_mass
else:
effective_mass_function = least_squares_effective_mass
return effective_mass_function(cart_k_point_coords, eigenvalues)
[docs] def x_axis(self, reciprocal_lattice: Optional[np.ndarray] = None) -> np.ndarray:
"""Generate the x-axis values for a band-structure plot.
Returns an array of cumulative distances in reciprocal space between sequential k-points.
Args:
reciprocal_lattice (:obj:`np.array`, optional): 3x3 Cartesian reciprocal lattice.
Default is ``None``. If no reciprocal lattice is provided, the returned x-axis
values will be sequential integers, giving even spacings between sequential
k-points.
Returns:
np.array: An array of x-axis values.
"""
if reciprocal_lattice is not None:
cartesian_k_points = np.array(
[k.cart_coords(reciprocal_lattice) for k in self._k_points]
)
x_axis = [0.0]
for i in range(1, len(cartesian_k_points)):
dk = cartesian_k_points[i - 1] - cartesian_k_points[i]
mod_dk = np.sqrt(np.dot(dk, dk))
x_axis.append(mod_dk + x_axis[-1])
x_axis_array = np.array(x_axis)
else:
x_axis_array = np.arange(len(self._k_points))
return x_axis_array
@property
def bands(self):
return self._bands.reshape(
self._k_point_blocks, self._number_of_k_points, self.number_of_bands
)
@property
def k_points(self):
return self._k_points
[docs] def select_bands_by_kpoint(self, band_indices):
return np.ravel(self.bands[:, band_indices, :])
[docs] def select_k_points(self, band_indices):
new_procar = deepcopy(self)
new_procar._bands = np.ravel(new_procar.bands[:, band_indices, :])
new_procar._data = np.array(
[kp for i, kp in enumerate(new_procar._data) if i in band_indices]
)
new_procar._number_of_k_points = len(band_indices)
new_procar._k_points = [
kp for i, kp in enumerate(new_procar._k_points) if i in band_indices
]
for i, kp in enumerate(new_procar._k_points, 1):
kp.index = i
new_procar.sanity_check()
return new_procar
# TODO Need complete set of example PROCAR files before we start to write
# something that can write these all back out in the appropriate format
# def write_file( self, filename ):
# if self.calculation['non_collinear']:
# raise NotImplementedError
# with open( filename, 'w' ) as f:
# f.write( 'PROCAR lm decomposed' )
# for s in range( self.spin_channels ): # not sure what happens for non-collinear calculations
# line = ff.FortranRecordWriter("/'# of k-points:',I5,9X,'# of bands:',I5,9X,'# of ions:',I5")
# f.write( line.write( [ self.number_of_k_points, self.number_of_bands, self.number_of_ions ] )+'\n' )
# for k_point in self.k_points:
# line = ff.FortranRecordWriter("/' k-point ',I5,' :',3X,3F11.8,' weight = ',F10.8/")
# f.write( line.write( [ k_point.index, *k_point.frac_coords, k_point.weight ] ) )
# for band in self.bands:
# line = ff.FortranRecordWriter("/'band ',I5,' # energy',F14.8,' # occ.',F12.8/")
# f.write( line.write( [ band.index, band.energy, band.occupancy ] ) )
# f.write( '\nion s py pz px dxy dyz dz2 dxz dx2 tot\n' )