Source code for pytopomat.analyzer

"""
Compute topological invariants.

This module offers a high level framework for analyzing topological materials in a 
high-throughput context with VASP, Z2Pack, irrep, irvsp, and Vasp2Trace.

"""

import numpy as np

import warnings

from monty.json import MSONable

from pymatgen.analysis.local_env import CrystalNN
from pymatgen.analysis.dimensionality import (
    get_dimensionality_larsen,
    get_dimensionality_cheon,
    get_dimensionality_gorai,
)

from pytopomat.vasp2trace_caller import Vasp2TraceOutput
from pytopomat.irvsp_caller import IRVSPOutput
from pytopomat.irrep_caller import IrrepOutput

__author__ = "Nathan C. Frey, Jason Munro"
__copyright__ = "MIT License"
__version__ = "0.0.1"
__maintainer__ = "Nathan C. Frey, Jason Munro"
__email__ = "ncfrey@lbl.gov, jmunro@lbl.gov"
__status__ = "Development"
__date__ = "August 2019"


[docs]class BandParity(MSONable): def __init__( self, calc_output=None, trim_data=None, spin_polarized=None, efermi=None, nelect=None, eigenval_tol=0.03, ): """ Determine parity of occupied bands at TRIM points with vasp2trace or irvsp output to calculate the Z2 topological invariant for centrosymmetric materials. Must give either Vasp2TraceOutput (non-spin-polarized) OR (up & down) for spin-polarized, or IRVSPOutput. Requires a VASP band structure run over the 8 TRIM points with: ICHARG=11; ISYM=2; LWAVE=.TRUE. This module depends on the vasp2trace and irvsp script available in the path. Please download at https://github.com/zjwang11/irvsp and consult the README.pdf for further help. If you use this module please cite: [1] Barry Bradlyn, L. Elcoro, Jennifer Cano, M. G. Vergniory, Zhijun Wang, C. Felser, M. I. Aroyo & B. Andrei Bernevig, Nature volume 547, pages 298–305 (20 July 2017). [2] M.G. Vergniory, L. Elcoro, C. Felser, N. Regnault, B.A. Bernevig, Z. Wang Nature (2019) 566, 480-485. doi:10.1038/s41586-019-0954-4. Args: calc_output (dict or IRVSPOutput or IrrepOutput): Dict of {'up': Vasp2TraceOutput object} or {'up': v2to, 'down': v2to}, or IRVSPOutput object. trim_data (dict): Maps TRIM point labels to band eigenvals and energies. Contains dict of {"energies": List, "iden": List, "parity": List} spin_polarized (bool): Spin-polarized or not. efermi (float): Fermi level. Only necessary if IrrepOutput or IRVSPOutput objectis given. nelect (int): Total number of electrons. eigenval_tol (float): Tolerance (eV) on rounding for fractional parity eigenvalues. """ if type(calc_output) == dict: self.calc_output = calc_output self.trim_data = trim_data self.spin_polarized = spin_polarized self.efermi = efermi self.nelect = nelect self.eigenval_tol = eigenval_tol # Check if spin-polarized or not if "down" in self.calc_output.keys(): # spin polarized if type(calc_output["down"]) != Vasp2TraceOutput: raise TypeError( "Calc output dictionary must contain Vasp2TraceOutput objects" ) self.spin_polarized = True else: if type(calc_output["up"]) != Vasp2TraceOutput: raise TypeError( "Calc output dictionary must contain Vasp2TraceOutput objects" ) self.spin_polarized = False if self.spin_polarized: self.trim_data = {} # Up spin parity_op_index_up = self._get_parity_op( self.calc_output["up"].symm_ops ) self.trim_data["up"] = self.get_trim_data_v2t( parity_op_index_up, self.calc_output["up"] ) # Down spin parity_op_index_dn = self._get_parity_op( self.calc_output["down"].symm_ops ) self.trim_data["down"] = self.get_trim_data_v2t( parity_op_index_dn, self.calc_output["down"] ) else: self.trim_data = {} parity_op_index = self._get_parity_op(self.calc_output["up"].symm_ops) self.trim_data["up"] = self.get_trim_data_v2t( parity_op_index, self.calc_output["up"] ) elif type(calc_output) == IRVSPOutput: self.calc_output = calc_output self.trim_data = trim_data self.spin_polarized = spin_polarized self.efermi = efermi self.nelect = nelect self.eigenval_tol = eigenval_tol if self.efermi is None: raise RuntimeError( "Fermi level required if IRVSPOutput object is given!" ) self.trim_data = self.get_trim_data_irvsp(self.calc_output) elif type(calc_output) == IrrepOutput: self.calc_output = calc_output self.trim_data = trim_data self.spin_polarized = spin_polarized self.efermi = efermi self.nelect = nelect self.eigenval_tol = eigenval_tol if self.efermi is None: raise RuntimeError( "Fermi level required if IrrepOutput object is given!" ) self.trim_data = self.get_trim_data_irrep(self.calc_output) else: raise TypeError( "Calculation output data must be generated from IrrepOutput, Vasp2TraceOutput or IRVSPOutput." ) @staticmethod def _get_parity_op(symm_ops): """ Find parity in the list of SymmOps. Args: symm_ops (list): List of symmetry operations from v2t output. Returns: parity_op_index (int): Index of parity operator in the v2t output. """ # x,y,z -> -x,-y,-z parity_mat = np.array([-1, 0, 0, 0, -1, 0, 0, 0, -1]) parity_op_index = None for idx, symm_op in enumerate(symm_ops): try: rot_mat = symm_op[0:9] # Rotation matrix trans_mat = symm_op[9:12] # Translation matrix except TypeError: raise RuntimeError( "No non-trivial symmetry operations in vasp2trace output!" ) # Find the parity operator if np.array_equal(rot_mat, parity_mat): parity_op_index = idx + 1 # SymmOps are 1 indexed if parity_op_index is None: raise RuntimeError("Parity operation not found in vasp2trace output!") else: return parity_op_index
[docs] @staticmethod def get_trim_data_irrep(irrep_output): """ Tabulate parity and identity eigenvals, as well as energies for all occupied bands over all TRIM points. Args: irvsp_output (obj): IrrepOutput object. Returns: trim_parities (dict): Maps TRIM label to band parities. """ # Check dimension of material and define TRIMs accordingly if len(irrep_output.parity_eigenvals.keys()) == 8: # 3D # Define TRIM labels in units of primitive reciprocal vectors trim_labels = ["gamma", "x", "y", "z", "s", "t", "u", "r"] trim_pts = [ (0, 0, 0), (0.5, 0, 0), (0, 0.5, 0), (0, 0, 0.5), (0.5, 0.5, 0), (0, 0.5, 0.5), (0.5, 0, 0.5), (0.5, 0.5, 0.5), ] elif len(irrep_output.parity_eigenvals.keys()) == 4: # 2D trim_labels = ["gamma", "x", "y", "s"] trim_pts = [(0, 0, 0), (0.5, 0, 0), (0, 0.5, 0), (0.5, 0.5, 0)] trims = { trim_pt: trim_label for trim_pt, trim_label in zip(trim_pts, trim_labels) } if irrep_output.spin_polarized: warnings.warn( "Note that for irrep outputs the spin-channel data is not separated when parsed." ) trim_data = { "up": { trim_label: {"energies": [], "iden": [], "parity": []} for trim_label in trim_labels } } for trim_pt, trim_label in trims.items(): irrep_data = irrep_output.parity_eigenvals[trim_label] # Real part of parity eigenval trim_data["up"][trim_label]["parity"] = irrep_data["inversion_eigenval"] trim_data["up"][trim_label]["iden"] = irrep_data["band_degeneracy"] trim_data["up"][trim_label]["energies"] = irrep_data["band_eigenval"] return trim_data
[docs] @staticmethod def get_trim_data_irvsp(irvsp_output): """ Tabulate parity and identity eigenvals, as well as energies for all occupied bands over all TRIM points. Args: irvsp_output (obj): IRVSPOutput object. Returns: trim_parities (dict): Maps TRIM label to band parities. """ # Check dimension of material and define TRIMs accordingly if len(irvsp_output.parity_eigenvals.keys()) == 8: # 3D # Define TRIM labels in units of primitive reciprocal vectors trim_labels = ["gamma", "x", "y", "z", "s", "t", "u", "r"] trim_pts = [ (0, 0, 0), (0.5, 0, 0), (0, 0.5, 0), (0, 0, 0.5), (0.5, 0.5, 0), (0, 0.5, 0.5), (0.5, 0, 0.5), (0.5, 0.5, 0.5), ] elif len(irvsp_output.parity_eigenvals.keys()) == 4: # 2D trim_labels = ["gamma", "x", "y", "s"] trim_pts = [(0, 0, 0), (0.5, 0, 0), (0, 0.5, 0), (0.5, 0.5, 0)] trims = { trim_pt: trim_label for trim_pt, trim_label in zip(trim_pts, trim_labels) } if irvsp_output.spin_polarized: trim_data = { spin: { trim_label: {"energies": [], "iden": [], "parity": []} for trim_label in trim_labels } for spin in ["up", "down"] } else: trim_data = { "up": { trim_label: {"energies": [], "iden": [], "parity": []} for trim_label in trim_labels } } for trim_pt, trim_label in trims.items(): if not irvsp_output.spin_polarized: irvsp_data = irvsp_output.parity_eigenvals[trim_label] # Real part of parity eigenval trim_data["up"][trim_label]["parity"] = irvsp_data["inversion_eigenval"] trim_data["up"][trim_label]["iden"] = irvsp_data["band_degeneracy"] trim_data["up"][trim_label]["energies"] = irvsp_data["band_eigenval"] else: irvsp_data = irvsp_output.parity_eigenvals[trim_label] for spin in ["up", "down"]: trim_data[spin][trim_label]["parity"] = irvsp_data[spin][ "inversion_eigenval" ] trim_data[spin][trim_label]["iden"] = irvsp_data[spin][ "band_degeneracy" ] trim_data[spin][trim_label]["energies"] = irvsp_data[spin][ "band_eigenval" ] return trim_data
[docs] @staticmethod def get_trim_data_v2t(parity_op_index, v2t_output): """ Tabulate parity and identity eigenvals, as well as energies for all occupied bands over all TRIM points. Args: parity_op_index (int): Index of parity op in list of SymmOps. v2t_output (obj): V2TO object. Returns: trim_parities (dict): Maps TRIM label to band parities. """ v2to = v2t_output # Check dimension of material and define TRIMs accordingly if v2to.num_max_kvec == 8: # 3D # Define TRIM labels in units of primitive reciprocal vectors trim_labels = ["gamma", "x", "y", "z", "s", "t", "u", "r"] trim_pts = [ (0, 0, 0), (0.5, 0, 0), (0, 0.5, 0), (0, 0, 0.5), (0.5, 0.5, 0), (0, 0.5, 0.5), (0.5, 0, 0.5), (0.5, 0.5, 0.5), ] elif v2to.num_max_kvec == 4: # 2D trim_labels = ["gamma", "x", "y", "s"] trim_pts = [(0, 0, 0), (0.5, 0, 0), (0, 0.5, 0), (0.5, 0.5, 0)] trims = { trim_pt: trim_label for trim_pt, trim_label in zip(trim_pts, trim_labels) } trim_data = { trim_label: {"energies": [], "iden": [], "parity": []} for trim_label in trim_labels } for idx, kvec in enumerate(v2to.kvecs): for trim_pt, trim_label in trims.items(): if np.array_equal(kvec, trim_pt): # Check if a TRIM # Index of parity op for this kvec kvec_parity_idx = v2to.symm_ops_in_little_cogroup[str(idx)].index( parity_op_index ) kvec_traces = v2to.traces[str(idx)] for band_index, band in enumerate(kvec_traces): # Real part of parity eigenval band_parity_eigenval = band[3 + 2 * kvec_parity_idx] band_iden_eigenval = band[3] band_energy = band[2] trim_data[trim_label]["parity"].append(band_parity_eigenval) trim_data[trim_label]["iden"].append(band_iden_eigenval) trim_data[trim_label]["energies"].append(band_energy) return trim_data
[docs] def compute_z2(self, tol=2): """ Compute Z2 topological indices (index) in 3D (2D) from TRIM band parities. Args: tol (float): Tolerance for average energy difference between bands at TRIM points to define independent band group. Returns: Z2 (list): List of integer Z2 indices (index) in 3D (2D). """ trim_labels = [key for key in self.trim_data["up"].keys()] trim_parities_set, trim_energies_set = self._format_parity_data() trim_parities = trim_parities_set["up"] trim_energies = trim_energies_set["up"] iband = self._get_band_subspace(tol=tol, trim_energies_formatted=trim_energies) print("Only considering last %i pairs of bands." % (iband - 1)) if len(trim_labels) == 8: Z2 = np.ones(4, dtype=int) for label in trim_labels: delta = 1 for parity in trim_parities[label][: (-1 * iband) : -1]: delta *= parity Z2[0] *= delta if label in ["x", "s", "u", "r"]: Z2[1] *= delta if label in ["y", "s", "t", "r"]: Z2[2] *= delta if label in ["z", "t", "u", "r"]: Z2[3] *= delta return ((Z2 - 1) / -2) + 0 elif len(trim_labels) == 4: Z2 = np.ones(1, dtype=int) for label in trim_labels: delta = 1 for parity in trim_parities[label][: (-1 * iband) : -1]: delta *= parity Z2 *= delta return ((Z2 - 1) / -2) + 0 else: raise RuntimeError("Incorrect number of k-points in data output.")
def _format_parity_data(self): """ Format parity data to account for degeneracies. For non-spin polarized calcs, each parity eigenvalue represents a single Kramer's pair. For spin-polarized calcs, each parity eigenvalue represents a single electron. Formatted data only contains occupied bands determined using nelect and efermi. """ spin_polarized = self.spin_polarized eigenval_tol = self.eigenval_tol trim_labels = [key for key in self.trim_data["up"].keys()] nelect = self.nelect if nelect is None: warnings.warn( "Number of electrons not provided. Will try and infer total from identity eigenvalues." ) if ( type(self.calc_output) == IRVSPOutput or type(self.calc_output) == IrrepOutput ): nelect = 0 gamma_energies = self.trim_data["up"]["gamma"]["energies"] gamma_degeneracy = self.trim_data["up"]["gamma"]["iden"] for index, degeneracy in enumerate(gamma_degeneracy): if gamma_energies[index] - self.efermi <= 0.0: nelect += degeneracy else: nelect = self.calc_output["up"].num_occ_bands if not spin_polarized: spins = ["up"] criteria = int(nelect / 2) elif spin_polarized and type(self.calc_output) == IrrepOutput: spins = ["up"] criteria = int(nelect) else: spins = ["up", "down"] criteria = int(nelect) trim_parities_formatted = {spin: {} for spin in spins} trim_energies_formatted = {spin: {} for spin in spins} for label in trim_labels: for spin in spins: trim_parities_formatted[spin][label] = np.ones(int(criteria)) trim_energies_formatted[spin][label] = np.ones(int(criteria)) count_ele = 0 if ( type(self.calc_output) == IRVSPOutput or type(self.calc_output) == IrrepOutput ): efermi = self.efermi else: efermi = 0 # number of lines of occupied bands nocc = len( [e for e in self.trim_data[spin][label]["energies"] if e < efermi] ) if not spin_polarized: if np.any( [ int(i) == 1 for i in self.trim_data[spin][label]["iden"][:nocc] ] ): raise RuntimeError( "Non-spin polarized selected, but trace data does not show doubly degenerate bands at %s." % label ) iden_sum = int(np.sum(self.trim_data[spin][label]["iden"][:nocc])) if ( nelect < iden_sum and int(self.trim_data["up"][label]["parity"][nocc - 1]) == 0 ): raise RuntimeError( "Cannot tell the parity of the highest occupied state at %s." % label ) else: if np.all( [ int(i) != 1.0 for i in self.trim_data[spin][label]["iden"][:nocc] ] ): warnings.warn( "Spin polarized selected, but at least one TRIM point shows all doubly degenerate bands." ) iden_sum = int(np.sum(self.trim_data[spin][label]["iden"][:nocc])) if ( nelect < iden_sum and int(self.trim_data[spin][label]["parity"][nocc - 1]) > 1 ): raise RuntimeError( "Cannot tell the parity of the highest occupied state at %s." % label ) formatted_parity_eig = [] formatted_energy_eig = [] for i in range(len(self.trim_data[spin][label]["energies"])): iden = int(self.trim_data[spin][label]["iden"][i]) parity = int(self.trim_data[spin][label]["parity"][i]) energy = self.trim_data[spin][label]["energies"][i] if not spin_polarized: iden = int(iden / 2) parity = parity / 2 temp_parity_eig = np.ones(iden) temp_energy_eig = energy * np.ones(iden) for j in range(0, iden): if np.isclose( np.sum(temp_parity_eig), parity, rtol=0, atol=eigenval_tol ): break else: temp_parity_eig[j] = -1.0 formatted_parity_eig += list(temp_parity_eig) formatted_energy_eig += list(temp_energy_eig) count_ele += iden if count_ele >= criteria: break trim_parities_formatted[spin][label] = formatted_parity_eig[:criteria] trim_energies_formatted[spin][label] = formatted_energy_eig[:criteria] return trim_parities_formatted, trim_energies_formatted @staticmethod def _get_band_subspace(tol=2, trim_energies_formatted=None): """ Find a subgroup of valence bands for topology analysis that are gapped from lower lying bands topologically trivial bands. Args: tol (float): Tolerance for average energy difference between bands at TRIM points to define independent band group. """ points = [key for key in trim_energies_formatted.keys()] mark = None band_energies = trim_energies_formatted[points[0]] nbands = len(trim_energies_formatted[points[0]]) if tol == -1: return nbands + 1 else: points = [key for key in trim_energies_formatted.keys()] mark = None band_energies = trim_energies_formatted[points[0]] nbands = len(trim_energies_formatted[points[0]]) for band_num in reversed(range(nbands - 1)): diff = abs(band_energies[band_num + 1] - band_energies[band_num]) if diff >= tol: for point in points: band_energies2 = trim_energies_formatted[point] diff2 = abs( band_energies2[band_num + 1] - band_energies2[band_num] ) if diff2 < tol: break else: if point == points[-1]: mark = band_num if mark is not None: break return nbands - mark
[docs] def screen_magnetic_parity(self): """ Screen candidate inversion-symmetric magnetic topological materials from band parity criteria. Returns a dictionary of *allowed* magnetic topological properties where their bool values indicate if the property is allowed. Requires a spin-polarized VASP calculation and trace_up and trace_dn from vasp2trace v2. REF: Turner et al., PRB 85, 165120 (2012). Args: trim_parities (dict): 'up' and 'down' spin channel occupied band parities at TRIM points. Returns: mag_screen (dict): Magnetic topological properties from band parities. """ if not self.spin_polarized: raise RuntimeError( "Cannot run magnetic parity screening if spin_polarized set to False." ) trim_parities, trim_energies = self._format_parity_data() mag_screen = { "insulator": False, "semimetal_candidate": False, "polarization_bqhc": False, "magnetoelectric": False, } # Count total number of odd parity states over all TRIMs num_odd_states = 0 # Check if any individual TRIM pt has an odd num of odd states odd_total_at_trim = False if not self.spin_polarized: spins = ["up"] elif self.spin_polarized and type(self.calc_output) == IrrepOutput: spins = ["up"] else: spins = ["up", "down"] for spin in spins: for trim_label, band_parities in trim_parities[spin].items(): num_odd_at_trim = np.sum( np.fromiter((1 for i in band_parities if i < 0), dtype=int) ) num_odd_states += num_odd_at_trim if num_odd_at_trim % 2 == 1: odd_total_at_trim = True # Odd number of odd states -> CAN'T be an insulator # Might be a Weyl semimetal if num_odd_states % 2 == 1: mag_screen["insulator"] = False mag_screen["semimetal_candidate"] = True mag_screen["polarization_bqhc"] = False mag_screen["magnetoelectric"] = False else: mag_screen["insulator"] = True # Further screening for magnetic insulators if mag_screen["insulator"]: # Check if any individual TRIM pt has an odd num of odd states # Either electrostatic polarization OR bulk quantized Hall # conductivity if odd_total_at_trim: mag_screen["polarization_bqhc"] = True # If no BQHC # num_odd_states = 2*k for any odd integer k k = num_odd_states / 2 if k.is_integer(): if int(k) % 2 == 1: # odd mag_screen["magnetoelectric"] = True return mag_screen
[docs] def compute_z4(self): """ Compute Z4 topological index from TRIM band parities. Returns: Z4 (int): Z4 index """ trim_labels = [key for key in self.trim_data["up"].keys()] trim_parities_set, trim_energies_set = self._format_parity_data() trim_parities_up = trim_parities_set["up"] if self.spin_polarized and type(self.calc_output) != IrrepOutput: trim_parities_down = trim_parities_set["down"] if len(trim_labels) == 8: Z4 = 0 for label in trim_labels: for parity_index in range(len(trim_parities_up[label])): if self.spin_polarized and type(self.calc_output) != IrrepOutput: Z4 += ( 1 + trim_parities_up[label][parity_index] + trim_parities_down[label][parity_index] ) else: Z4 += (1 + trim_parities_up[label][parity_index]) / 2 return Z4 % 4 else: raise RuntimeError("Incorrect number of k-points in data output.")
[docs]class StructureDimensionality(MSONable): def __init__( self, structure, structure_graph=None, larsen_dim=None, cheon_dim=None, gorai_dim=None, ): """ This class uses 3 algorithms implemented in pymatgen to automate recognition of low-dimensional materials. Args: structure (object): pmg Structure object. structure_graph (object): pmg StructureGraph object. larsen_dim (int): As defined in P. M. Larsen et al., Phys. Rev. Materials 3, 034003 (2019). cheon_dim (int): As defined in Cheon, G. et al., Nano Lett. 2017. gorai_dim (int): As defined in Gorai, P. et al., J. Mater. Chem. A 2, 4136 (2016). """ self.structure = structure self.structure_graph = structure_graph self.larsen_dim = larsen_dim self.cheon_dim = cheon_dim self.gorai_dim = gorai_dim # Default to CrystalNN for generating structure graph. sgraph = CrystalNN().get_bonded_structure(structure) self.structure_graph = sgraph # Get Larsen dimensionality self.larsen_dim = get_dimensionality_larsen(self.structure_graph)
[docs] def get_cheon_gorai_dim(self): """Convenience method for getting Cheon and Gorai dims. These algorithms take some time. Returns: None: (sets instance variables). """ cheon_dim_str = get_dimensionality_cheon(self.structure) if cheon_dim_str == "0D": cheon_dim = 0 elif cheon_dim_str == "1D": cheon_dim = 1 elif cheon_dim_str == "2D": cheon_dim = 2 elif cheon_dim_str == "3D": cheon_dim = 3 else: cheon_dim = None self.cheon_dim = cheon_dim self.gorai_dim = get_dimensionality_gorai(self.structure)