diff --git a/src/pymatgen/electronic_structure/cohp.py b/src/pymatgen/electronic_structure/cohp.py new file mode 100644 index 00000000000..2fb23ee1c8e --- /dev/null +++ b/src/pymatgen/electronic_structure/cohp.py @@ -0,0 +1,1575 @@ +"""This module defines classes to represent: + - Crystal orbital Hamilton population (COHP) and integrated COHP (ICOHP). + - Crystal orbital overlap population (COOP). + - Crystal orbital bond index (COBI). + +If you use this module, please cite: +J. George, G. Petretto, A. Naik, M. Esters, A. J. Jackson, R. Nelson, R. Dronskowski, G.-M. Rignanese, G. Hautier, +"Automated Bonding Analysis with Crystal Orbital Hamilton Populations", +ChemPlusChem 2022, e202200123, +DOI: 10.1002/cplu.202200123. +""" + +from __future__ import annotations + +import re +import sys +import warnings +from typing import TYPE_CHECKING + +import numpy as np +from monty.json import MSONable +from scipy.interpolate import InterpolatedUnivariateSpline + +from pymatgen.core.sites import PeriodicSite +from pymatgen.core.structure import Structure +from pymatgen.electronic_structure.core import Orbital, Spin +from pymatgen.io.lmto import LMTOCopl +from pymatgen.io.lobster import Cohpcar +from pymatgen.util.coord import get_linear_interpolated_value +from pymatgen.util.due import Doi, due +from pymatgen.util.num import round_to_sigfigs + +if TYPE_CHECKING: + from collections.abc import Mapping + from typing import Any, Literal, Self + + from numpy.typing import ArrayLike, NDArray + + from pymatgen.util.typing import PathLike, SpinLike + +__author__ = "Marco Esters, Janine George" +__copyright__ = "Copyright 2017, The Materials Project" +__version__ = "0.2" +__maintainer__ = "Janine George" +__email__ = "janinegeorge.ulfen@gmail.com" +__date__ = "Dec 13, 2017" + +due.cite( + Doi("10.1002/cplu.202200123"), + description="Automated Bonding Analysis with Crystal Orbital Hamilton Populations", +) + + +class Cohp(MSONable): + """Basic COHP object.""" + + def __init__( + self, + efermi: float, + energies: ArrayLike, + cohp: Mapping[Spin, NDArray], + are_coops: bool = False, + are_cobis: bool = False, + are_multi_center_cobis: bool = False, + icohp: Mapping[Spin, NDArray] | None = None, + ) -> None: + """ + Args: + efermi (float): The Fermi level. + energies (Sequence[float]): Energies. + cohp ({Spin: NDArrary}): The COHP for each spin. + are_coops (bool): Whether this object describes COOPs. + are_cobis (bool): Whether this object describes COBIs. + are_multi_center_cobis (bool): Whether this object describes multi-center COBIs. + icohp ({Spin: NDArrary}): The ICOHP for each spin. + """ + self.are_coops = are_coops + self.are_cobis = are_cobis + self.are_multi_center_cobis = are_multi_center_cobis + self.efermi = efermi + self.energies = np.array(energies) + self.cohp = cohp + self.icohp = icohp + + def __repr__(self) -> str: + """A string that can be easily plotted (e.g. using gnuplot).""" + if self.are_coops: + cohp_str = "COOP" + elif self.are_cobis or self.are_multi_center_cobis: + cohp_str = "COBI" + else: + cohp_str = "COHP" + + header = ["Energy", f"{cohp_str}Up"] + data = [self.energies, self.cohp[Spin.up]] + if Spin.down in self.cohp: + header.append(f"{cohp_str}Down") + data.append(self.cohp[Spin.down]) + if self.icohp: + header.append(f"I{cohp_str}Up") + data.append(self.icohp[Spin.up]) + if Spin.down in self.cohp: + header.append(f"I{cohp_str}Down") + data.append(self.icohp[Spin.down]) + format_header = "#" + " ".join("{:15s}" for __ in header) + format_data = " ".join("{:.5f}" for __ in header) + str_arr = [format_header.format(*header)] + str_arr.extend(format_data.format(*(d[idx] for d in data)) for idx in range(len(self.energies))) + return "\n".join(str_arr) + + def as_dict(self) -> dict[str, Any]: + """JSON-serializable dict representation of COHP.""" + dct = { + "@module": type(self).__module__, + "@class": type(self).__name__, + "are_coops": self.are_coops, + "are_cobis": self.are_cobis, + "are_multi_center_cobis": self.are_multi_center_cobis, + "efermi": self.efermi, + "energies": self.energies.tolist(), + "COHP": {str(spin): pops.tolist() for spin, pops in self.cohp.items()}, + } + if self.icohp: + dct["ICOHP"] = {str(spin): pops.tolist() for spin, pops in self.icohp.items()} + return dct + + def get_cohp( + self, + spin: SpinLike | None = None, + integrated: bool = False, + ) -> dict[Spin, NDArray] | None: + """Get the COHP or ICOHP for a particular spin. + + Args: + spin (SpinLike): Selected spin. If is None and both + spins are present, both will be returned. + integrated: Return ICOHP (True) or COHP (False). + + Returns: + dict: The COHP or ICOHP for the selected spin. + """ + populations = self.icohp if integrated else self.cohp + + if populations is None: + return None + if spin is None: + return populations # type: ignore[return-value] + if isinstance(spin, int): + spin = Spin(spin) + elif isinstance(spin, str): + spin = Spin({"up": 1, "down": -1}[spin.lower()]) + return {spin: populations[spin]} + + def get_icohp( + self, + spin: SpinLike | None = None, + ) -> dict[Spin, NDArray] | None: + """Convenient wrapper to get the ICOHP for a particular spin.""" + return self.get_cohp(spin=spin, integrated=True) + + def get_interpolated_value( + self, + energy: float, + integrated: bool = False, + ) -> dict[Spin, float]: + """Get the interpolated COHP for a particular energy. + + Args: + energy (float): Energy to get the COHP value for. + integrated (bool): Return ICOHP (True) or COHP (False). + """ + inters = {} + for spin in self.cohp: + if not integrated: + inters[spin] = get_linear_interpolated_value(self.energies, self.cohp[spin], energy) + elif self.icohp is not None: + inters[spin] = get_linear_interpolated_value(self.energies, self.icohp[spin], energy) + else: + raise ValueError("ICOHP is empty.") + return inters + + def has_antibnd_states_below_efermi( + self, + spin: SpinLike | None = None, + limit: float = 0.01, + ) -> dict[Spin, bool] | None: + """Get dict of antibonding states below the Fermi level for the spin. + + Args: + spin (SpinLike): Selected spin. + limit (float): Only COHP higher than this value will be considered. + """ + populations = self.cohp + n_energies_below_efermi = sum(energy <= self.efermi for energy in self.energies) + + if populations is None: + return None + + dict_to_return = {} + if spin is None: + for sp, cohp_vals in populations.items(): + # NOTE: Casting to bool is necessary, otherwise ended up + # getting "bool_" instead of "bool" from NumPy + dict_to_return[sp] = bool((max(cohp_vals[:n_energies_below_efermi])) > limit) + + else: + if isinstance(spin, int): + spin = Spin(spin) + elif isinstance(spin, str): + spin = Spin({"up": 1, "down": -1}[spin.lower()]) + dict_to_return[spin] = bool((max(populations[spin][:n_energies_below_efermi])) > limit) + + return dict_to_return + + @classmethod + def from_dict(cls, dct: dict[str, Any]) -> Self: + """Generate Cohp from a dict representation.""" + icohp = {Spin(int(key)): np.array(val) for key, val in dct["ICOHP"].items()} if "ICOHP" in dct else None + + return cls( + dct["efermi"], + dct["energies"], + {Spin(int(key)): np.array(val) for key, val in dct["COHP"].items()}, + icohp=icohp, + are_coops=dct["are_coops"], + are_cobis=dct.get("are_cobis", False), + are_multi_center_cobis=dct.get("are_multi_center_cobis", False), + ) + + +class CompleteCohp(Cohp): + """A wrapper that defines an average COHP, and individual COHPs. + + Attributes: + are_coops (bool): Whether the object consists of COOPs. + are_cobis (bool): Whether the object consists of COBIs. + efermi (float): The Fermi level. + energies (Sequence[float]): Sequence of energies. + structure (Structure): Structure associated with the COHPs. + cohp (Sequence[float]): The average COHP. + icohp (Sequence[float]): The average ICOHP. + all_cohps (dict[str, Sequence[float]]): COHPs for individual bonds of the form {label: COHP}. + orb_res_cohp (dict[str, Dict[str, Sequence[float]]]): Orbital-resolved COHPs. + """ + + def __init__( + self, + structure: Structure, + avg_cohp: Cohp, + cohp_dict: dict[str, Cohp], + bonds: dict[str, Any] | None = None, + are_coops: bool = False, + are_cobis: bool = False, + are_multi_center_cobis: bool = False, + orb_res_cohp: dict[str, dict] | None = None, + ) -> None: + """ + Args: + structure (Structure): Structure associated with this COHP. + avg_cohp (Cohp): The average COHP. + cohp_dict (dict[str, Cohp]): COHP for individual bonds of the form + {label: COHP}. + bonds (dict[str, Any]): Information on the bonds of the form + {label: {key: val}}. The value can be any information, + but typically contains the sites, the bond length, + and the number of bonds. If nothing is + supplied, it will default to an empty dict. + are_coops (bool): Whether the Cohp objects are COOPs. + Defaults to False for COHPs. + are_cobis (bool): Whether the Cohp objects are COBIs. + Defaults to False for COHPs. + are_multi_center_cobis (bool): Whether the Cohp objects are multi-center COBIs. + Defaults to False for COHPs. + orb_res_cohp (dict): Orbital-resolved COHPs. + """ + if ( + (are_coops and are_cobis) + or (are_coops and are_multi_center_cobis) + or (are_cobis and are_multi_center_cobis) + ): + raise ValueError("You cannot have info about COOPs, COBIs and/or multi-center COBIS in the same file.") + + super().__init__( + avg_cohp.efermi, + avg_cohp.energies, + avg_cohp.cohp, + are_coops=are_coops, + are_cobis=are_cobis, + are_multi_center_cobis=are_multi_center_cobis, + icohp=avg_cohp.icohp, + ) + self.structure = structure + self.are_coops = are_coops + self.are_cobis = are_cobis + self.are_multi_center_cobis = are_multi_center_cobis + self.all_cohps = cohp_dict + self.orb_res_cohp = orb_res_cohp + self.bonds = bonds or {label: {} for label in self.all_cohps} + + def __str__(self) -> str: + if self.are_coops: + header = "COOPs" + elif self.are_cobis: + header = "COBIs" + else: + header = "COHPs" + + return f"Complete {header} for {self.structure}" + + def as_dict(self) -> dict[str, Any]: + """JSON-serializable dict representation of CompleteCohp.""" + dct = { + "@module": type(self).__module__, + "@class": type(self).__name__, + "are_coops": self.are_coops, + "are_cobis": self.are_cobis, + "are_multi_center_cobis": self.are_multi_center_cobis, + "efermi": self.efermi, + "structure": self.structure.as_dict(), + "energies": self.energies.tolist(), + "COHP": {"average": {str(spin): pops.tolist() for spin, pops in self.cohp.items()}}, + } + + if self.icohp is not None: + dct["ICOHP"] = {"average": {str(spin): pops.tolist() for spin, pops in self.icohp.items()}} + + for label in self.all_cohps: + dct["COHP"] |= {label: {str(spin): pops.tolist() for spin, pops in self.all_cohps[label].cohp.items()}} + icohp = self.all_cohps[label].icohp + if icohp is not None: + if "ICOHP" not in dct: + dct["ICOHP"] = {label: {str(spin): pops.tolist() for spin, pops in icohp.items()}} + else: + dct["ICOHP"] |= {label: {str(spin): pops.tolist() for spin, pops in icohp.items()}} + + if False in [bond_dict == {} for bond_dict in self.bonds.values()]: + dct["bonds"] = { + bond: { + "length": self.bonds[bond]["length"], + "sites": [site.as_dict() for site in self.bonds[bond]["sites"]], + } + for bond in self.bonds + } + + if self.orb_res_cohp: + orb_dict: dict[str, Any] = {} + for label in self.orb_res_cohp: + orb_dict[label] = {} + for orbs in self.orb_res_cohp[label]: + orb_dict[label][orbs] = { + "COHP": { + str(spin): pops.tolist() for spin, pops in self.orb_res_cohp[label][orbs]["COHP"].items() + }, + "ICOHP": { + str(spin): pops.tolist() for spin, pops in self.orb_res_cohp[label][orbs]["ICOHP"].items() + }, + "orbitals": [[orb[0], orb[1].name] for orb in self.orb_res_cohp[label][orbs]["orbitals"]], + } + + dct["orb_res_cohp"] = orb_dict + + return dct + + def get_cohp_by_label( + self, + label: str, + summed_spin_channels: bool = False, + ) -> Cohp: + """Get specific Cohp by the label, to simplify plotting. + + Args: + label (str): Label for the interaction. + summed_spin_channels (bool): Sum the spin channels and return the sum as Spin.up. + + Returns: + The Cohp. + """ + if label.lower() == "average": + divided_cohp: Mapping[Spin, NDArray] | None = self.cohp + divided_icohp: Mapping[Spin, NDArray] | None = self.icohp + else: + divided_cohp = self.all_cohps[label].get_cohp(spin=None, integrated=False) + divided_icohp = self.all_cohps[label].get_icohp(spin=None) + + if divided_cohp is None: + raise ValueError("divided_cohp is None") + + if summed_spin_channels and Spin.down in self.cohp: + if divided_icohp is None: + raise ValueError("divided_icohp is None") + final_cohp: Mapping[Spin, NDArray] = { + Spin.up: np.sum([divided_cohp[Spin.up], divided_cohp[Spin.down]], axis=0) + } + final_icohp: Mapping[Spin, NDArray] | None = { + Spin.up: np.sum([divided_icohp[Spin.up], divided_icohp[Spin.down]], axis=0) + } + else: + final_cohp = divided_cohp + final_icohp = divided_icohp + + return Cohp( + efermi=self.efermi, + energies=self.energies, + cohp=final_cohp, + are_coops=self.are_coops, + are_cobis=self.are_cobis, + icohp=final_icohp, + ) + + def get_summed_cohp_by_label_list( + self, + label_list: list[str], + divisor: float = 1, + summed_spin_channels: bool = False, + ) -> Cohp: + """Get a Cohp object that includes a summed COHP divided by divisor. + + Args: + label_list (list[str]): Labels for the COHP to include. + divisor (float): The summed COHP will be divided by this divisor. + summed_spin_channels (bool): Sum the spin channels and return the sum in Spin.up. + + Returns: + A Cohp object for the summed COHP. + """ + # Check if COHPs are spin polarized + first_cohpobject = self.get_cohp_by_label(label_list[0]) + summed_cohp = first_cohpobject.cohp.copy() + if first_cohpobject.icohp is None: + raise ValueError("icohp of first_cohpobject is None") + summed_icohp = first_cohpobject.icohp.copy() + for label in label_list[1:]: + cohp = self.get_cohp_by_label(label) + icohp = cohp.icohp + if icohp is None: + raise ValueError("icohp is None") + + summed_cohp[Spin.up] = np.sum([summed_cohp[Spin.up], cohp.cohp[Spin.up]], axis=0) + + if Spin.down in summed_cohp: + summed_cohp[Spin.down] = np.sum([summed_cohp[Spin.down], cohp.cohp[Spin.down]], axis=0) + + summed_icohp[Spin.up] = np.sum([summed_icohp[Spin.up], icohp[Spin.up]], axis=0) + + if Spin.down in summed_icohp: + summed_icohp[Spin.down] = np.sum([summed_icohp[Spin.down], icohp[Spin.down]], axis=0) + + divided_cohp = {Spin.up: np.divide(summed_cohp[Spin.up], divisor)} + divided_icohp = {Spin.up: np.divide(summed_icohp[Spin.up], divisor)} + if Spin.down in summed_cohp: + divided_cohp[Spin.down] = np.divide(summed_cohp[Spin.down], divisor) + divided_icohp[Spin.down] = np.divide(summed_icohp[Spin.down], divisor) + + if summed_spin_channels and Spin.down in summed_cohp: + final_cohp = {Spin.up: np.sum([divided_cohp[Spin.up], divided_cohp[Spin.down]], axis=0)} + final_icohp = {Spin.up: np.sum([divided_icohp[Spin.up], divided_icohp[Spin.down]], axis=0)} + else: + final_cohp = divided_cohp + final_icohp = divided_icohp + + return Cohp( + efermi=first_cohpobject.efermi, + energies=first_cohpobject.energies, + cohp=final_cohp, + are_coops=first_cohpobject.are_coops, + are_cobis=first_cohpobject.are_coops, + icohp=final_icohp, + ) + + def get_summed_cohp_by_label_and_orbital_list( + self, + label_list: list[str], + orbital_list: list[str], + divisor: float = 1, + summed_spin_channels: bool = False, + ) -> Cohp: + """Get a Cohp object that includes a summed COHP divided by divisor. + + Args: + label_list (list[str]): Labels for the COHP that should be included. + orbital_list (list[str]): Orbitals for the COHPs that should be included + (same order as label_list). + divisor (float): The summed COHP will be divided by this divisor. + summed_spin_channels (bool): Sum the spin channels and return the sum in Spin.up. + + Returns: + A Cohp object including the summed COHP. + """ + # Check length of label_list and orbital_list + if not len(label_list) == len(orbital_list): + raise ValueError("label_list and orbital_list don't have the same length!") + + # Check if COHPs are spin polarized + first_cohpobject = self.get_orbital_resolved_cohp(label_list[0], orbital_list[0]) + if first_cohpobject is None: + raise ValueError("first_cohpobject is None") + if first_cohpobject.icohp is None: + raise ValueError("icohp of first_cohpobject is None") + summed_cohp = first_cohpobject.cohp.copy() + summed_icohp = first_cohpobject.icohp.copy() + + for idx, label in enumerate(label_list[1:], start=1): + cohp = self.get_orbital_resolved_cohp(label, orbital_list[idx]) + if cohp is None: + raise ValueError("cohp is None.") + if cohp.icohp is None: + raise ValueError("icohp of cohp is None.") + summed_cohp[Spin.up] = np.sum([summed_cohp[Spin.up], cohp.cohp.copy()[Spin.up]], axis=0) + if Spin.down in summed_cohp: + summed_cohp[Spin.down] = np.sum([summed_cohp[Spin.down], cohp.cohp.copy()[Spin.down]], axis=0) + + summed_icohp[Spin.up] = np.sum([summed_icohp[Spin.up], cohp.icohp.copy()[Spin.up]], axis=0) + if Spin.down in summed_icohp: + summed_icohp[Spin.down] = np.sum([summed_icohp[Spin.down], cohp.icohp.copy()[Spin.down]], axis=0) + + divided_cohp = {Spin.up: np.divide(summed_cohp[Spin.up], divisor)} + divided_icohp = {Spin.up: np.divide(summed_icohp[Spin.up], divisor)} + if Spin.down in summed_cohp: + divided_cohp[Spin.down] = np.divide(summed_cohp[Spin.down], divisor) + divided_icohp[Spin.down] = np.divide(summed_icohp[Spin.down], divisor) + + if summed_spin_channels and Spin.down in divided_cohp: + final_cohp = {Spin.up: np.sum([divided_cohp[Spin.up], divided_cohp[Spin.down]], axis=0)} + final_icohp = {Spin.up: np.sum([divided_icohp[Spin.up], divided_icohp[Spin.down]], axis=0)} + else: + final_cohp = divided_cohp + final_icohp = divided_icohp + + return Cohp( + efermi=first_cohpobject.efermi, + energies=first_cohpobject.energies, + cohp=final_cohp, + are_coops=first_cohpobject.are_coops, + are_cobis=first_cohpobject.are_cobis, + icohp=final_icohp, + ) + + def get_orbital_resolved_cohp( + self, + label: str, + orbitals: str | list[tuple[str, Orbital]] | tuple[tuple[str, Orbital], ...], + summed_spin_channels: bool = False, + ) -> Cohp | None: + """Get orbital-resolved COHP. + + Args: + label (str): Bond labels as in ICOHPLIST/ICOOPLIST.lobster. + orbitals: The orbitals as a label, or list/tuple of + [(n1, orbital1), (n2, orbital2), ...]. + Where each orbital can either be str, int, or Orbital. + summed_spin_channels (bool): Sum the spin channels and return the sum as Spin.up. + + Returns: + A Cohp object if CompleteCohp contains orbital-resolved COHP, + or None if it doesn't. + + Note: It currently assumes that orbitals are str if they aren't the + other valid types. This is not ideal, but is the easiest way to + avoid unicode issues between Python 2 and Python 3. + """ + if self.orb_res_cohp is None: + return None + + if isinstance(orbitals, list | tuple): + cohp_orbs = [val["orbitals"] for val in self.orb_res_cohp[label].values()] + orbs = [] + for orbital in orbitals: + if isinstance(orbital[1], int): + orbs.append((orbital[0], Orbital(orbital[1]))) + elif isinstance(orbital[1], Orbital): + orbs.append((orbital[0], orbital[1])) + elif isinstance(orbital[1], str): + orbs.append((orbital[0], Orbital[orbital[1]])) + else: + raise TypeError("Orbital must be str, int, or Orbital.") + orb_index = cohp_orbs.index(orbs) + orb_label = list(self.orb_res_cohp[label])[orb_index] + + elif isinstance(orbitals, str): + orb_label = orbitals + else: + raise TypeError("Orbitals must be str, list, or tuple.") + + try: + icohp = self.orb_res_cohp[label][orb_label]["ICOHP"] + except KeyError: + icohp = None + + start_cohp = self.orb_res_cohp[label][orb_label]["COHP"] + start_icohp = icohp + + if summed_spin_channels and Spin.down in start_cohp: + final_cohp = {} + final_icohp = {} + final_cohp[Spin.up] = np.sum([start_cohp[Spin.up], start_cohp[Spin.down]], axis=0) + if start_icohp is not None: + final_icohp[Spin.up] = np.sum([start_icohp[Spin.up], start_icohp[Spin.down]], axis=0) + else: + final_cohp = start_cohp + final_icohp = start_icohp + + return Cohp( + self.efermi, + self.energies, + final_cohp, + icohp=final_icohp, + are_coops=self.are_coops, + are_cobis=self.are_cobis, + ) + + @classmethod + def from_dict(cls, dct: dict[str, Any]) -> Self: + """Get CompleteCohp object from a dict representation. + + TODO: Clean this up. + """ + cohp_dict = {} + efermi = dct["efermi"] + energies = dct["energies"] + structure = Structure.from_dict(dct["structure"]) + are_cobis = dct.get("are_cobis", False) + are_multi_center_cobis = dct.get("are_multi_center_cobis", False) + are_coops = dct["are_coops"] + avg_cohp = None + + if "bonds" in dct: + bonds = { + bond: { + "length": dct["bonds"][bond]["length"], + "sites": tuple(PeriodicSite.from_dict(site) for site in dct["bonds"][bond]["sites"]), + "cells": dct["bonds"][bond].get("cells", None), + } + for bond in dct["bonds"] + } + else: + bonds = None + + for label in dct["COHP"]: + cohp = {Spin(int(spin)): np.array(dct["COHP"][label][spin]) for spin in dct["COHP"][label]} + try: + icohp = {Spin(int(spin)): np.array(dct["ICOHP"][label][spin]) for spin in dct["ICOHP"][label]} + except KeyError: + icohp = None + if label == "average": + avg_cohp = Cohp( + efermi, + energies, + cohp, + icohp=icohp, + are_coops=are_coops, + are_cobis=are_cobis, + are_multi_center_cobis=are_multi_center_cobis, + ) + else: + cohp_dict[label] = Cohp(efermi, energies, cohp, icohp=icohp) + + if "orb_res_cohp" in dct: + orb_cohp: dict[str, dict[Orbital, dict[str, Any]]] = {} + for label in dct["orb_res_cohp"]: + orb_cohp[label] = {} + for orb in dct["orb_res_cohp"][label]: + cohp = { + Spin(int(s)): np.array(dct["orb_res_cohp"][label][orb]["COHP"][s], dtype=float) + for s in dct["orb_res_cohp"][label][orb]["COHP"] + } + try: + icohp = { + Spin(int(s)): np.array(dct["orb_res_cohp"][label][orb]["ICOHP"][s], dtype=float) + for s in dct["orb_res_cohp"][label][orb]["ICOHP"] + } + except KeyError: + icohp = None + orbitals = [(int(o[0]), Orbital[o[1]]) for o in dct["orb_res_cohp"][label][orb]["orbitals"]] + orb_cohp[label][orb] = { + "COHP": cohp, + "ICOHP": icohp, + "orbitals": orbitals, + } + # If no total COHPs are present, calculate the total + # COHPs from the single-orbital populations. + # Total COHPs may not be present when the COHP generator keyword + # is used in LOBSTER versions 2.2.0 and earlier. + if label not in dct["COHP"] or dct["COHP"][label] is None: + cohp = { + Spin.up: np.sum( + np.array([orb_cohp[label][orb]["COHP"][Spin.up] for orb in orb_cohp[label]]), + axis=0, + ) + } + try: + cohp[Spin.down] = np.sum( + np.array([orb_cohp[label][orb]["COHP"][Spin.down] for orb in orb_cohp[label]]), + axis=0, + ) + except KeyError: + pass + + orb_res_icohp = None in [orb_cohp[label][orb]["ICOHP"] for orb in orb_cohp[label]] + if (label not in dct["ICOHP"] or dct["ICOHP"][label] is None) and orb_res_icohp: + icohp = { + Spin.up: np.sum( + np.array([orb_cohp[label][orb]["ICOHP"][Spin.up] for orb in orb_cohp[label]]), + axis=0, + ) + } + try: + icohp[Spin.down] = np.sum( + np.array([orb_cohp[label][orb]["ICOHP"][Spin.down] for orb in orb_cohp[label]]), + axis=0, + ) + except KeyError: + pass + else: + orb_cohp = {} + + if avg_cohp is None: + raise ValueError("avg_cohp is None") + return cls( + structure, + avg_cohp, + cohp_dict, + bonds=bonds, + are_coops=dct["are_coops"], + are_cobis=dct.get("are_cobis", False), + are_multi_center_cobis=are_multi_center_cobis, + orb_res_cohp=orb_cohp, + ) + + @classmethod + def from_file( + cls, + fmt: Literal["LMTO", "LOBSTER"], + filename: PathLike | None = None, + structure_file: PathLike | None = None, + are_coops: bool = False, + are_cobis: bool = False, + are_multi_center_cobis: bool = False, + ) -> Self: + """Create CompleteCohp from an output file of a COHP calculation. + + Args: + fmt (Literal["LMTO", "LOBSTER"]): The code used to calculate COHPs. + filename (PathLike): The COHP output file. Defaults to "COPL" + for LMTO and "COHPCAR.lobster/COOPCAR.lobster" for LOBSTER. + structure_file (PathLike): The file containing the structure. + If None, use "CTRL" for LMTO and "POSCAR" for LOBSTER. + are_coops (bool): Whether the populations are COOPs or COHPs. + Defaults to False for COHPs. + are_cobis (bool): Whether the populations are COBIs or COHPs. + Defaults to False for COHPs. + are_multi_center_cobis (bool): Whether this file + includes information on multi-center COBIs. + + Returns: + A CompleteCohp object. + """ + if are_coops and are_cobis: + raise ValueError("You cannot have info about COOPs and COBIs in the same file.") + + fmt = fmt.upper() # type: ignore[assignment] + if fmt == "LMTO": + # TODO: LMTO COOPs and orbital-resolved COHP cannot be handled yet + are_coops = are_cobis = False + orb_res_cohp = None + if structure_file is None: + structure_file = "CTRL" + if filename is None: + filename = "COPL" + + cohp_file: LMTOCopl | Cohpcar = LMTOCopl(filename=filename, to_eV=True) + + elif fmt == "LOBSTER": + if ( + (are_coops and are_cobis) + or (are_coops and are_multi_center_cobis) + or (are_cobis and are_multi_center_cobis) + ): + raise ValueError("You cannot have info about COOPs, COBIs and/or multi-center COBIS in the same file.") + if structure_file is None: + structure_file = "POSCAR" + if filename is None and filename is None: + if are_coops: + filename = "COOPCAR.lobster" + elif are_cobis or are_multi_center_cobis: + filename = "COBICAR.lobster" + else: + filename = "COHPCAR.lobster" + cohp_file = Cohpcar( + filename=filename, + are_coops=are_coops, + are_cobis=are_cobis, + are_multi_center_cobis=are_multi_center_cobis, + ) + orb_res_cohp = cohp_file.orb_res_cohp + + else: + raise ValueError(f"Unknown format {fmt}. Valid formats are LMTO and LOBSTER.") + + structure = Structure.from_file(structure_file) + efermi = cohp_file.efermi + cohp_data = cohp_file.cohp_data + energies = cohp_file.energies + + # LOBSTER shifts the energies so that the Fermi level is at zero. + # Shifting should be done by the plotter object though. + spins = [Spin.up, Spin.down] if cohp_file.is_spin_polarized else [Spin.up] + if fmt == "LOBSTER": + energies += efermi + + if orb_res_cohp is not None: + # If no total COHPs are present, calculate the total + # COHPs from the single-orbital populations. Total COHPs + # may not be present when the cohpgenerator keyword is used + # in LOBSTER versions 2.2.0 and earlier. + + # TODO: Test this more extensively + + for label in orb_res_cohp: + if cohp_file.cohp_data[label]["COHP"] is None: + cohp_data[label]["COHP"] = { + sp: np.sum( + [orb_res_cohp[label][orbs]["COHP"][sp] for orbs in orb_res_cohp[label]], + axis=0, + ) + for sp in spins + } + if cohp_file.cohp_data[label]["ICOHP"] is None: + cohp_data[label]["ICOHP"] = { + sp: np.sum( + [orb_res_cohp[label][orbs]["ICOHP"][sp] for orbs in orb_res_cohp[label]], + axis=0, + ) + for sp in spins + } + + if fmt == "LMTO": + # Calculate the average COHP for the LMTO file to be consistent with LOBSTER + avg_data: dict[Literal["COHP", "ICOHP"], dict] = {"COHP": {}, "ICOHP": {}} + for dtype in avg_data: + for spin in spins: + rows = np.array([v[dtype][spin] for v in cohp_data.values()]) + avg = np.mean(rows, axis=0) + # LMTO COHPs have 5 significant digits + avg_data[dtype] |= {spin: np.array([round_to_sigfigs(a, 5) for a in avg], dtype=float)} + avg_cohp = Cohp(efermi, energies, avg_data["COHP"], icohp=avg_data["ICOHP"]) + + elif not are_multi_center_cobis: + avg_cohp = Cohp( + efermi, + energies, + cohp_data["average"]["COHP"], + icohp=cohp_data["average"]["ICOHP"], + are_coops=are_coops, + are_cobis=are_cobis, + are_multi_center_cobis=are_multi_center_cobis, + ) + del cohp_data["average"] + + else: + # Only include two-center COBIs in average for both spin channels + cohp = {} + cohp[Spin.up] = np.array( + [np.array(c["COHP"][Spin.up]) for c in cohp_file.cohp_data.values() if len(c["sites"]) <= 2] + ).mean(axis=0) + try: + cohp[Spin.down] = np.array( + [np.array(c["COHP"][Spin.down]) for c in cohp_file.cohp_data.values() if len(c["sites"]) <= 2] + ).mean(axis=0) + except KeyError: + pass + + try: + icohp = {} + icohp[Spin.up] = np.array( + [np.array(c["ICOHP"][Spin.up]) for c in cohp_file.cohp_data.values() if len(c["sites"]) <= 2] + ).mean(axis=0) + try: + icohp[Spin.down] = np.array( + [np.array(c["ICOHP"][Spin.down]) for c in cohp_file.cohp_data.values() if len(c["sites"]) <= 2] + ).mean(axis=0) + except KeyError: + pass + except KeyError: + icohp = None + + avg_cohp = Cohp( + efermi, + energies, + cohp, + icohp=icohp, + are_coops=are_coops, + are_cobis=are_cobis, + are_multi_center_cobis=are_multi_center_cobis, + ) + + cohp_dict = { + key: Cohp( + efermi, + energies, + dct["COHP"], + icohp=dct["ICOHP"], + are_coops=are_coops, + are_cobis=are_cobis, + are_multi_center_cobis=are_multi_center_cobis, + ) + for key, dct in cohp_data.items() + } + + bond_dict = { + key: { + "length": dct["length"], + "sites": [structure[site] for site in dct["sites"]], + } + for key, dct in cohp_data.items() + } + + return cls( + structure, + avg_cohp, + cohp_dict, + bonds=bond_dict, + are_coops=are_coops, + are_cobis=are_cobis, + orb_res_cohp=orb_res_cohp, + ) + + +class IcohpValue(MSONable): + """Information for an ICOHP or ICOOP value. + + Attributes: + energies (NDArray): Energy values for the COHP/ICOHP/COOP/ICOOP. + densities (NDArray): Density of states for the COHP/ICOHP/COOP/ICOOP. + energies_are_cartesian (bool): Whether the energies are cartesian. + are_coops (bool): Whether the object is COOP/ICOOP. + are_cobis (bool): Whether the object is COBIS/ICOBIS. + icohp (dict): The ICOHP/COHP values, whose keys are Spin.up and Spin.down. + summed_icohp (float): The summed ICOHP/COHP values. + num_bonds (int): The number of bonds used for the average COHP (for LOBSTER versions <3.0). + """ + + def __init__( + self, + label: str, + atom1: str, + atom2: str, + length: float, + translation: tuple[int, int, int], + num: int, + icohp: dict[Spin, float], + are_coops: bool = False, + are_cobis: bool = False, + orbitals: dict[str, dict[Literal["icohp", "orbitals"], Any]] | None = None, + ) -> None: + """ + Args: + label (str): Label for the ICOHP. + atom1 (str): The first atom that contributes to the bond. + atom2 (str): The second atom that contributes to the bond. + length (float): Bond length. + translation (tuple[int, int, int]): cell translation vector, e.g. (0, 0, 0). + num (int): The number of equivalent bonds. + icohp (dict[Spin, float]): {Spin.up: ICOHP_up, Spin.down: ICOHP_down} + are_coops (bool): Whether these are COOPs. + are_cobis (bool): Whether these are COBIs. + orbitals (dict): {[str(Orbital1)-str(Orbital2)]: { + "icohp": { + Spin.up: IcohpValue for spin.up, + Spin.down: IcohpValue for spin.down + }, + "orbitals": [Orbital1, Orbital2, ...]}. + """ + if are_coops and are_cobis: + raise ValueError("You cannot have info about COOPs and COBIs in the same file.") + + self._are_coops = are_coops + self._are_cobis = are_cobis + self._label = label + self._atom1 = atom1 + self._atom2 = atom2 + self._length = length + self._translation = translation + self._num = num + self._icohp = icohp + self._orbitals = orbitals + self._is_spin_polarized = Spin.down in self._icohp + + def __str__(self) -> str: + """String representation of the ICOHP/ICOOP.""" + # (are_coops and are_cobis) is never True + if self._are_coops: + header = "ICOOP" + elif self._are_cobis: + header = "ICOBI" + else: + header = "ICOHP" + + if self._is_spin_polarized: + return ( + f"{header} {self._label} between {self._atom1} and {self._atom2} ({self._translation}): " + f"{self._icohp[Spin.up]} eV (Spin up) and {self._icohp[Spin.down]} eV (Spin down)" + ) + + return ( + f"{header} {self._label} between {self._atom1} and {self._atom2} ({self._translation}): " + f"{self._icohp[Spin.up]} eV (Spin up)" + ) + + @property + def num_bonds(self) -> int: + """The number of bonds for which the ICOHP value is an average. + + Returns: + int + """ + return self._num + + @property + def are_coops(self) -> bool: + """Whether these are ICOOPs. + + Returns: + bool + """ + return self._are_coops + + @property + def are_cobis(self) -> bool: + """Whether these are ICOBIs. + + Returns: + bool + """ + return self._are_cobis + + @property + def is_spin_polarized(self) -> bool: + """Whether this is a spin polarized calculation. + + Returns: + bool + """ + return self._is_spin_polarized + + @property + def translation(self) -> tuple[int, int, int]: + """ + Returns the translation vector with respect to the origin cell + as defined in LOBSTER. + + Returns: + tuple[int, int, int] + """ + return self._translation + + def icohpvalue(self, spin: Spin = Spin.up) -> float: + """ + Args: + spin: Spin.up or Spin.down. + + Returns: + float: ICOHP value corresponding to chosen spin. + """ + if not self.is_spin_polarized and spin == Spin.down: + raise ValueError("The calculation was not performed with spin polarization") + + return self._icohp[spin] + + def icohpvalue_orbital( + self, + orbitals: tuple[Orbital, Orbital] | str, + spin: Spin = Spin.up, + ) -> float: + """ + Args: + orbitals: tuple[Orbital, Orbital] or "str(Orbital0)-str(Orbital1)". + spin (Spin): Spin.up or Spin.down. + + Returns: + float: ICOHP value corresponding to chosen spin. + """ + if not self.is_spin_polarized and spin == Spin.down: + raise ValueError("The calculation was not performed with spin polarization") + + if isinstance(orbitals, tuple | list): + orbitals = f"{orbitals[0]}-{orbitals[1]}" + + if self._orbitals is None: + raise ValueError("self._orbitals is None") + return self._orbitals[orbitals]["icohp"][spin] + + @property + def icohp(self) -> dict[Spin, float]: + """Dict with ICOHPs for spin up and spin down. + + Returns: + dict[Spin, float]: {Spin.up: ICOHP_up, Spin.down: ICOHP_down}. + """ + return self._icohp + + @property + def summed_icohp(self) -> float: + """Summed ICOHPs of both spin channels if spin polarized. + + Returns: + float: ICOHP value in eV. + """ + return self._icohp[Spin.down] + self._icohp[Spin.up] if self._is_spin_polarized else self._icohp[Spin.up] + + @property + def summed_orbital_icohp(self) -> dict[str, float]: + """Summed orbital-resolved ICOHPs of both spin channels if spin-polarized. + + Returns: + dict[str, float]: "str(Orbital1)-str(Ortibal2)": ICOHP value in eV. + """ + if self._orbitals is None: + raise ValueError("_orbitals attrib is None.") + + orbital_icohp = {} + for orb, item in self._orbitals.items(): + orbital_icohp[orb] = ( + item["icohp"][Spin.up] + item["icohp"][Spin.down] if self._is_spin_polarized else item["icohp"][Spin.up] + ) + return orbital_icohp + + +class IcohpCollection(MSONable): + """Collection of IcohpValues. + + Attributes: + are_coops (bool): Whether these are ICOOPs. + are_cobis (bool): Whether these are ICOOPs. + is_spin_polarized (bool): Whether the calculation is spin polarized. + """ + + def __init__( + self, + list_labels: list[str], + list_atom1: list[str], + list_atom2: list[str], + list_length: list[float], + list_translation: list[tuple[int, int, int]], + list_num: list[int], + list_icohp: list[dict[Spin, float]], + is_spin_polarized: bool, + list_orb_icohp: (list[dict[str, dict[Literal["icohp", "orbitals"], Any]]] | None) = None, + are_coops: bool = False, + are_cobis: bool = False, + ) -> None: + """ + Args: + list_labels (list[str]): Labels for ICOHP/ICOOP values. + list_atom1 (list[str]): Atom names, e.g. "O1". + list_atom2 (list[str]): Atom names, e.g. "O1". + list_length (list[float]): Bond lengths in Angstrom. + list_translation (list[tuple[int, int, int]]): Cell translation vectors. + list_num (list[int]): Numbers of equivalent bonds, usually 1 starting from LOBSTER 3.0.0. + list_icohp (list[dict]): Dicts as {Spin.up: ICOHP_up, Spin.down: ICOHP_down}. + is_spin_polarized (bool): Whether the calculation is spin polarized. + list_orb_icohp (list[dict]): Dicts as {[str(Orbital1)-str(Orbital2)]: { + "icohp": {Spin.up: IcohpValue for spin.up, Spin.down: IcohpValue for spin.down}, + "orbitals": [Orbital1, Orbital2]}. + are_coops (bool): Whether ICOOPs are stored. + are_cobis (bool): Whether ICOBIs are stored. + """ + if are_coops and are_cobis: + raise ValueError("You cannot have info about COOPs and COBIs in the same file.") + + self._are_coops = are_coops + self._are_cobis = are_cobis + self._is_spin_polarized = is_spin_polarized + self._list_labels = list_labels + self._list_atom1 = list_atom1 + self._list_atom2 = list_atom2 + self._list_length = list_length + self._list_translation = list_translation + self._list_num = list_num + self._list_icohp = list_icohp + self._list_orb_icohp = list_orb_icohp + + # TODO: DanielYang: self._icohplist name is misleading + # (not list), and confuses with self._list_icohp + self._icohplist: dict[str, IcohpValue] = {} + for idx, label in enumerate(list_labels): + self._icohplist[label] = IcohpValue( + label=label, + atom1=list_atom1[idx], + atom2=list_atom2[idx], + length=list_length[idx], + translation=list_translation[idx], + num=list_num[idx], + icohp=list_icohp[idx], + are_coops=are_coops, + are_cobis=are_cobis, + orbitals=None if list_orb_icohp is None else list_orb_icohp[idx], + ) + + def __str__(self) -> str: + return "\n".join([str(value) for value in self._icohplist.values()]) + + def get_icohp_by_label( + self, + label: str, + summed_spin_channels: bool = True, + spin: Spin = Spin.up, + orbitals: str | tuple[Orbital, Orbital] | None = None, + ) -> float: + """Get an ICOHP value for a certain bond indicated by the label. + + Args: + label (str): The bond number in Icohplist.lobster/Icooplist.lobster, + starting from "1". + summed_spin_channels (bool): Whether the ICOHPs/ICOOPs of both + spin channels should be summed. + spin (Spin): If not summed_spin_channels, indicate + which spin channel should be returned. + orbitals: List of Orbital or "str(Orbital1)-str(Orbital2)". + + Returns: + float: ICOHP/ICOOP value. + """ + icohp: IcohpValue = self._icohplist[label] + + if orbitals is None: + return icohp.summed_icohp if summed_spin_channels else icohp.icohpvalue(spin) + + if isinstance(orbitals, tuple | list): + orbitals = f"{orbitals[0]}-{orbitals[1]}" + + if summed_spin_channels: + return icohp.summed_orbital_icohp[orbitals] + + return icohp.icohpvalue_orbital(spin=spin, orbitals=orbitals) + + def get_summed_icohp_by_label_list( + self, + label_list: list[str], + divisor: float = 1.0, + summed_spin_channels: bool = True, + spin: Spin = Spin.up, + ) -> float: + """Get the sum of ICOHP values. + + Args: + label_list (list[str]): Labels of the ICOHPs/ICOOPs that should be summed, + the same as in ICOHPLIST/ICOOPLIST. + divisor (float): Divisor used to divide the sum. + summed_spin_channels (bool): Whether the ICOHPs/ICOOPs of both + spin channels should be summed. + spin (Spin): If not summed_spin_channels, indicate + which spin channel should be returned. + + Returns: + float: Sum of ICOHPs selected with label_list. + """ + sum_icohp: float = 0 + for label in label_list: + icohp = self._icohplist[label] + if icohp.num_bonds != 1: + warnings.warn( + "One of the ICOHP values is an average over bonds. This is currently not considered.", stacklevel=2 + ) + + if icohp._is_spin_polarized and summed_spin_channels: + sum_icohp += icohp.summed_icohp + else: + sum_icohp += icohp.icohpvalue(spin) + + return sum_icohp / divisor + + def get_icohp_dict_by_bondlengths( + self, + minbondlength: float = 0.0, + maxbondlength: float = 8.0, + ) -> dict[str, IcohpValue]: + """Get IcohpValues within certain bond length range. + + Args: + minbondlength (float): The minimum bond length. + maxbondlength (float): The maximum bond length. + + Returns: + dict[str, IcohpValue]: Keys are the labels from the initial list_labels. + """ + new_icohp_dict = {} + for value in self._icohplist.values(): + if minbondlength <= value._length <= maxbondlength: + new_icohp_dict[value._label] = value + return new_icohp_dict + + def get_icohp_dict_of_site( + self, + site: int, + minsummedicohp: float | None = None, + maxsummedicohp: float | None = None, + minbondlength: float = 0.0, + maxbondlength: float = 8.0, + only_bonds_to: list[str] | None = None, + ) -> dict[str, IcohpValue]: + """Get IcohpValues for a certain site. + + Args: + site (int): The site of interest, ordered as in Icohplist.lobster/Icooplist.lobster, + starts from 0. + minsummedicohp (float): Minimal ICOHP/ICOOP of the bonds that are considered. + It is the summed ICOHP value from both spin channels for spin polarized cases + maxsummedicohp (float): Maximal ICOHP/ICOOP of the bonds that are considered. + It is the summed ICOHP value from both spin channels for spin polarized cases + minbondlength (float): The minimum bond length. + maxbondlength (float): The maximum bond length. + only_bonds_to (list[str]): The bonding partners that are allowed, e.g. ["O"]. + + Returns: + Dict of IcohpValues, the keys correspond to the values from the initial list_labels. + """ + new_icohp_dict = {} + for key, value in self._icohplist.items(): + atomnumber1 = int(re.split(r"(\d+)", value._atom1)[1]) - 1 + atomnumber2 = int(re.split(r"(\d+)", value._atom2)[1]) - 1 + if site in (atomnumber1, atomnumber2): + # Swap order of atoms so that searched one is always atom1 + if site == atomnumber2: + save = value._atom1 + value._atom1 = value._atom2 + value._atom2 = save + + second_test = True if only_bonds_to is None else re.split("(\\d+)", value._atom2)[0] in only_bonds_to + if minbondlength <= value._length <= maxbondlength and second_test: + # TODO: DanielYang: merge the following condition blocks + if minsummedicohp is not None: + if value.summed_icohp >= minsummedicohp: + if maxsummedicohp is not None: + if value.summed_icohp <= maxsummedicohp: + new_icohp_dict[key] = value + else: + new_icohp_dict[key] = value + elif maxsummedicohp is not None: + if value.summed_icohp <= maxsummedicohp: + new_icohp_dict[key] = value + else: + new_icohp_dict[key] = value + + return new_icohp_dict + + def extremum_icohpvalue( + self, + summed_spin_channels: bool = True, + spin: Spin = Spin.up, + ) -> float: + """Get ICOHP/ICOOP of the strongest bond. + + Args: + summed_spin_channels (bool): Whether the ICOHPs/ICOOPs of both + spin channels should be summed. + spin (Spin): If not summed_spin_channels, this indicates which + spin channel should be returned. + + Returns: + Lowest ICOHP/largest ICOOP value (i.e. ICOHP/ICOOP value of strongest bond). + """ + extremum = -sys.float_info.max if self._are_coops or self._are_cobis else sys.float_info.max + + if not self._is_spin_polarized: + if spin == Spin.down: + warnings.warn("This spin channel does not exist. I am switching to Spin.up", stacklevel=2) + spin = Spin.up + + for value in self._icohplist.values(): + if not value.is_spin_polarized or not summed_spin_channels: + if not self._are_coops and not self._are_cobis: + extremum = min(value.icohpvalue(spin), extremum) + elif value.icohpvalue(spin) > extremum: + extremum = value.icohpvalue(spin) + + elif not self._are_coops and not self._are_cobis: + extremum = min(value.summed_icohp, extremum) + + elif value.summed_icohp > extremum: + extremum = value.summed_icohp + + return extremum + + @property + def is_spin_polarized(self) -> bool: + """Whether this is spin polarized.""" + return self._is_spin_polarized + + @property + def are_coops(self) -> bool: + """Whether this is COOP.""" + return self._are_coops + + @property + def are_cobis(self) -> bool: + """Whether this is COBI.""" + return self._are_cobis + + def as_dict(self) -> dict[str, Any]: + """JSON-serializable dict representation of COHP.""" + return { + "@module": type(self).__module__, + "@class": type(self).__name__, + "are_coops": self._are_coops, + "are_cobis": self._are_cobis, + "list_labels": self._list_labels, + "list_atom1": self._list_atom1, + "list_atom2": self._list_atom2, + "list_length": self._list_length, + "list_translation": self._list_translation, + "list_num": self._list_num, + "list_icohp": [{str(spin): value for spin, value in icohp.items()} for icohp in self._list_icohp], + "is_spin_polarized": self._is_spin_polarized, + "list_orb_icohp": [ + { + key: { + "icohp": {str(spin): value for spin, value in val["icohp"].items()}, + "orbitals": ( + [[n, int(orb)] for n, orb in val["orbitals"]] + if isinstance(val["orbitals"][0], (list, tuple)) + else list(val["orbitals"]) # Handle LCFO orbitals + ), + } + for key, val in entry.items() + } + for entry in self._list_orb_icohp + ], + } + + @classmethod + def from_dict(cls, dct: dict[str, Any]) -> Self: + """Generate IcohpCollection from a dict representation.""" + list_icohp = [] + for icohp_dict in dct["list_icohp"]: + new_icohp_dict = {} + for spin_key, value in icohp_dict.items(): + # Convert string/int to Spin enum + spin_enum = ( + Spin(int(spin_key)) if isinstance(spin_key, int) or spin_key in ("1", "-1") else Spin[spin_key] + ) + new_icohp_dict[spin_enum] = value + list_icohp.append(new_icohp_dict) + + new_list_orb = [{orb_label: {} for orb_label in bond} for bond in dct["list_orb_icohp"]] # type:ignore[var-annotated] + for bond_num, lab_orb_icohp in enumerate(dct["list_orb_icohp"]): + for orb in lab_orb_icohp: + for key in lab_orb_icohp[orb]: + sub_dict = {} + if key == "icohp": + if dct.get("is_spin_polarized"): + sub_dict[key] = { + Spin.up: lab_orb_icohp[orb][key]["1"], + Spin.down: lab_orb_icohp[orb][key]["-1"], + } + else: + sub_dict[key] = {Spin.up: lab_orb_icohp[orb][key]["1"]} + + if key == "orbitals": + orb_temp = [] + + for item in lab_orb_icohp[orb][key]: + # Handle LCFO orbitals + if isinstance(item, (list, tuple)): + item[1] = Orbital(item[1]) + orb_temp.append(tuple(item)) + else: + orb_temp.append(item) + sub_dict[key] = orb_temp # type: ignore[assignment] + + new_list_orb[bond_num][orb].update(sub_dict) + + dct["list_icohp"] = list_icohp + dct["list_orb_icohp"] = new_list_orb + + init_dict = {k: v for k, v in dct.items() if "@" not in k} + + return cls(**init_dict) + + +def get_integrated_cohp_in_energy_range( + cohp: CompleteCohp, + label: str, + orbital: str | None = None, + energy_range: float | tuple[float, float] | None = None, + relative_E_Fermi: bool = True, + summed_spin_channels: bool = True, +) -> float | dict[Spin, float]: + """Integrate CompleteCohps which include data of integrated COHPs (ICOHPs). + + Args: + cohp (CompleteCohp): CompleteCohp object. + label (str): Label of the COHP data. + orbital (str): If not None, a orbital resolved integrated COHP will be returned. + energy_range: If None, return the ICOHP value at Fermi level. + If float, integrate from this value up to Fermi level. + If (float, float), integrate in between. + relative_E_Fermi (bool): Whether energy scale with Fermi level at 0 eV is chosen. + summed_spin_channels (bool): Whether Spin channels will be summed. + + Returns: + If summed_spin_channels: + float: the ICOHP. + else: + dict: {Spin.up: float, Spin.down: float} + """ + if orbital is None: + icohps = cohp.all_cohps[label].get_icohp(spin=None) + else: + _icohps = cohp.get_orbital_resolved_cohp(label=label, orbitals=orbital) + if _icohps is None: + raise ValueError("_icohps is None") + icohps = _icohps.icohp # type: ignore[assignment] + + if icohps is None: + raise ValueError("ichops is None") + + summedicohp = {} + if summed_spin_channels and Spin.down in icohps: + summedicohp[Spin.up] = icohps[Spin.up] + icohps[Spin.down] + else: + summedicohp = icohps + + if energy_range is None: + energies_corrected = cohp.energies - cohp.efermi + spl_spinup = InterpolatedUnivariateSpline(energies_corrected, summedicohp[Spin.up], ext=0) + + if not summed_spin_channels and Spin.down in icohps: + spl_spindown = InterpolatedUnivariateSpline(energies_corrected, summedicohp[Spin.down], ext=0) + return {Spin.up: spl_spinup(0.0), Spin.down: spl_spindown(0.0)} + + return spl_spinup(0.0) if summed_spin_channels else {Spin.up: spl_spinup(0.0)} + + # Return ICOHP value at the Fermi level + if isinstance(energy_range, float): + if relative_E_Fermi: + energies_corrected = cohp.energies - cohp.efermi + spl_spinup = InterpolatedUnivariateSpline(energies_corrected, summedicohp[Spin.up], ext=0) + + if not summed_spin_channels and Spin.down in icohps: + spl_spindown = InterpolatedUnivariateSpline(energies_corrected, summedicohp[Spin.down], ext=0) + return { + Spin.up: spl_spinup(0) - spl_spinup(energy_range), + Spin.down: spl_spindown(0) - spl_spindown(energy_range), + } + if summed_spin_channels: + return spl_spinup(0) - spl_spinup(energy_range) + return {Spin.up: spl_spinup(0) - spl_spinup(energy_range)} + + energies_corrected = cohp.energies + spl_spinup = InterpolatedUnivariateSpline(energies_corrected, summedicohp[Spin.up], ext=0) + + if not summed_spin_channels and Spin.down in icohps: + spl_spindown = InterpolatedUnivariateSpline(energies_corrected, summedicohp[Spin.down], ext=0) + return { + Spin.up: spl_spinup(cohp.efermi) - spl_spinup(energy_range), + Spin.down: spl_spindown(cohp.efermi) - spl_spindown(energy_range), + } + if summed_spin_channels: + return spl_spinup(cohp.efermi) - spl_spinup(energy_range) + return {Spin.up: spl_spinup(cohp.efermi) - spl_spinup(energy_range)} + + energies_corrected = cohp.energies - cohp.efermi if relative_E_Fermi else cohp.energies + + spl_spinup = InterpolatedUnivariateSpline(energies_corrected, summedicohp[Spin.up], ext=0) + + if not summed_spin_channels and Spin.down in icohps: + spl_spindown = InterpolatedUnivariateSpline(energies_corrected, summedicohp[Spin.down], ext=0) + return { + Spin.up: spl_spinup(energy_range[1]) - spl_spinup(energy_range[0]), + Spin.down: spl_spindown(energy_range[1]) - spl_spindown(energy_range[0]), + } + if summed_spin_channels: + return spl_spinup(energy_range[1]) - spl_spinup(energy_range[0]) + + return {Spin.up: spl_spinup(energy_range[1]) - spl_spinup(energy_range[0])} diff --git a/src/pymatgen/io/lobster/__init__.py b/src/pymatgen/io/lobster/__init__.py new file mode 100644 index 00000000000..c88d495aacb --- /dev/null +++ b/src/pymatgen/io/lobster/__init__.py @@ -0,0 +1,27 @@ +""" +This package implements modules for input and output to and from LOBSTER. It +imports the key classes form both lobster.inputs and lobster.outputs to allow most +classes to be simply called as pymatgen.io.lobster.Lobsterin for example, to retain +backwards compatibility. +""" + +from __future__ import annotations + +from .inputs import Lobsterin +from .outputs import ( + Bandoverlaps, + Bwdf, + Charge, + Cohpcar, + Doscar, + Fatband, + Grosspop, + Icohplist, + LobsterMatrices, + Lobsterout, + MadelungEnergies, + NciCobiList, + Polarization, + SitePotential, + Wavefunction, +) diff --git a/src/pymatgen/io/lobster/future/lobsterenv.py b/src/pymatgen/io/lobster/future/lobsterenv.py index 1f4d5ffcaee..8662155c527 100644 --- a/src/pymatgen/io/lobster/future/lobsterenv.py +++ b/src/pymatgen/io/lobster/future/lobsterenv.py @@ -1,1523 +1,12 @@ -"""This module provides classes to perform analyses of the local -environments (e.g., finding near neighbors) of single sites in molecules -and structures based on bonding analysis with LOBSTER. - -If you use this module, please cite: -J. George, G. Petretto, A. Naik, M. Esters, A. J. Jackson, R. Nelson, R. Dronskowski, G.-M. Rignanese, G. Hautier, -"Automated Bonding Analysis with Crystal Orbital Hamilton Populations", -ChemPlusChem 2022, e202200123, -DOI: 10.1002/cplu.202200123. -""" +"""Deprecated class for analyzing NearNeighbors using ICOHPs/ICOOPs/ICOBIs.""" from __future__ import annotations -import collections -import copy -import math -import tempfile -from typing import TYPE_CHECKING, NamedTuple - -import matplotlib as mpl -import numpy as np from monty.dev import deprecated -from pymatgen.analysis.bond_valence import BVAnalyzer -from pymatgen.analysis.chemenv.coordination_environments.coordination_geometry_finder import LocalGeometryFinder -from pymatgen.analysis.chemenv.coordination_environments.structure_environments import LightStructureEnvironments -from pymatgen.analysis.local_env import NearNeighbors -from pymatgen.electronic_structure.cohp import CompleteCohp -from pymatgen.electronic_structure.core import Spin -from pymatgen.electronic_structure.plotter import CohpPlotter -from pymatgen.io.lobster import Charge, Icohplist -from pymatgen.util.due import Doi, due - -if TYPE_CHECKING: - from typing import Any, Literal, Self - - import matplotlib as mpl - from numpy.typing import NDArray - from pymatgen.core import IStructure, PeriodicNeighbor, PeriodicSite, Structure - from pymatgen.core.periodic_table import Element - from pymatgen.electronic_structure.cohp import IcohpCollection, IcohpValue - from pymatgen.util.typing import PathLike - -__author__ = "Janine George" -__copyright__ = "Copyright 2021, The Materials Project" -__version__ = "1.0" -__maintainer__ = "J. George" -__email__ = "janinegeorge.ulfen@gmail.com" -__status__ = "Production" -__date__ = "February 2, 2021" - -due.cite( - Doi("10.1002/cplu.202200123"), - description="Automated Bonding Analysis with Crystal Orbital Hamilton Populations", +@deprecated( + replacement="`pymatgen.analysis.lobster_env.LobsterNeighbors`", category=DeprecationWarning, deadline=(2026, 3, 31) ) - - -class LobsterNeighbors(NearNeighbors): - """ - This class combines capabilities from LocalEnv and ChemEnv to determine - coordination environments based on bonding analysis. - """ - - def __init__( - self, - structure: Structure | IStructure, - filename_icohp: PathLike | None = "ICOHPLIST.lobster", - obj_icohp: Icohplist | None = None, - are_coops: bool = False, - are_cobis: bool = False, - valences: list[float] | None = None, - limits: tuple[float, float] | None = None, - additional_condition: Literal[0, 1, 2, 3, 4, 5, 6] = 0, - only_bonds_to: list[str] | None = None, - perc_strength_icohp: float = 0.15, - noise_cutoff: float = 0.1, - valences_from_charges: bool = False, - filename_charge: PathLike | None = None, - obj_charge: Charge | None = None, - which_charge: Literal["Mulliken", "Loewdin"] = "Mulliken", - adapt_extremum_to_add_cond: bool = False, - add_additional_data_sg: bool = False, - filename_blist_sg1: PathLike | None = None, - filename_blist_sg2: PathLike | None = None, - id_blist_sg1: Literal["icoop", "icobi"] = "icoop", - id_blist_sg2: Literal["icoop", "icobi"] = "icobi", - ) -> None: - """ - Args: - filename_icohp (PathLike): Path to ICOHPLIST.lobster or - ICOOPLIST.lobster or ICOBILIST.lobster. - obj_icohp (Icohplist): Icohplist object. - structure (Structure): Typically constructed by Structure.from_file("POSCAR"). - are_coops (bool): Whether the file is a ICOOPLIST.lobster (True) or a - ICOHPLIST.lobster (False). Only tested for ICOHPLIST.lobster so far. - are_cobis (bool): Whether the file is a ICOBILIST.lobster (True) or - a ICOHPLIST.lobster (False). - valences (list[float]): Valence/charge for each element. - limits (tuple[float, float]): Range to decide which ICOHPs (ICOOP - or ICOBI) should be considered. - additional_condition (int): Additional condition that decides - which kind of bonds will be considered: - 0 - NO_ADDITIONAL_CONDITION - 1 - ONLY_ANION_CATION_BONDS - 2 - NO_ELEMENT_TO_SAME_ELEMENT_BONDS - 3 - ONLY_ANION_CATION_BONDS_AND_NO_ELEMENT_TO_SAME_ELEMENT_BONDS - 4 - ONLY_ELEMENT_TO_OXYGEN_BONDS - 5 - DO_NOT_CONSIDER_ANION_CATION_BONDS - 6 - ONLY_CATION_CATION_BONDS - only_bonds_to (list[str]): Only consider bonds to certain elements (e.g. ["O"] for oxygen). - perc_strength_icohp (float): If no "limits" are given, this will decide - which ICOHPs will be considered (relative to the strongest ICOHP/ICOOP/ICOBI). - noise_cutoff (float): The lower limit of ICOHPs considered. - valences_from_charges (bool): If True and path to CHARGE.lobster is provided, - will use LOBSTER charges (Mulliken) instead of valences. - filename_charge (PathLike): Path to Charge.lobster. - obj_charge (Charge): Charge object. - which_charge ("Mulliken" | "Loewdin"): Source of charge. - adapt_extremum_to_add_cond (bool): Whether to adapt the limits to only - focus on the bonds determined by the additional condition. - add_additional_data_sg (bool): Add the information from filename_add_bondinglist_sg1. - filename_blist_sg1 (PathLike): Path to additional ICOOP, ICOBI data for structure graphs. - filename_blist_sg2 (PathLike): Path to additional ICOOP, ICOBI data for structure graphs. - id_blist_sg1 ("icoop" | "icobi"): Identity of data in filename_blist_sg1. - id_blist_sg2 ("icoop" | "icobi"): Identity of data in filename_blist_sg2. - """ - if filename_icohp is not None: - self.ICOHP = Icohplist(are_coops=are_coops, are_cobis=are_cobis, filename=filename_icohp) - elif obj_icohp is not None: - self.ICOHP = obj_icohp - else: - raise ValueError("Please provide either filename_icohp or obj_icohp") - - self.Icohpcollection = self.ICOHP.icohpcollection - self.structure = structure - self.limits = limits - self.only_bonds_to = only_bonds_to - self.adapt_extremum_to_add_cond = adapt_extremum_to_add_cond - self.are_coops = are_coops - self.are_cobis = are_cobis - self.add_additional_data_sg = add_additional_data_sg - self.filename_blist_sg1 = filename_blist_sg1 - self.filename_blist_sg2 = filename_blist_sg2 - self.noise_cutoff = noise_cutoff - - self.id_blist_sg1 = id_blist_sg1.lower() - self.id_blist_sg2 = id_blist_sg2.lower() - - allowed_arguments = {"icoop", "icobi"} - if self.id_blist_sg1 not in allowed_arguments or self.id_blist_sg2 not in allowed_arguments: - raise ValueError("Algorithm can only work with ICOOPs, ICOBIs") - - if add_additional_data_sg: - if self.id_blist_sg1 == "icoop": - are_coops_id1 = True - are_cobis_id1 = False - else: - are_coops_id1 = False - are_cobis_id1 = True - - self.bonding_list_1 = Icohplist( - filename=self.filename_blist_sg1, - are_coops=are_coops_id1, - are_cobis=are_cobis_id1, - ) - - if self.id_blist_sg2 == "icoop": - are_coops_id2 = True - are_cobis_id2 = False - else: - are_coops_id2 = False - are_cobis_id2 = True - - self.bonding_list_2 = Icohplist( - filename=self.filename_blist_sg2, - are_coops=are_coops_id2, - are_cobis=are_cobis_id2, - ) - - # Check the additional condition - if additional_condition not in range(7): - raise ValueError(f"Unexpected {additional_condition=}, must be one of {list(range(7))}") - self.additional_condition = additional_condition - - # Read in valences, will prefer manual setting of valences - self.valences: list[float] | None - if valences is None: - if valences_from_charges and filename_charge is not None: - chg = Charge(filename=filename_charge) - if which_charge == "Mulliken": - self.valences = chg.mulliken - elif which_charge == "Loewdin": - self.valences = chg.loewdin - - elif valences_from_charges and obj_charge is not None: - chg = obj_charge - if which_charge == "Mulliken": - self.valences = chg.mulliken - elif which_charge == "Loewdin": - self.valences = chg.loewdin - - else: - bv_analyzer = BVAnalyzer() - try: - self.valences = bv_analyzer.get_valences(structure=self.structure) # type:ignore[arg-type] - except ValueError as exc: - self.valences = None - if additional_condition in {1, 3, 5, 6}: - raise ValueError( - "Valences cannot be assigned, additional_conditions 1, 3, 5 and 6 will not work" - ) from exc - else: - self.valences = valences - - if np.allclose(self.valences or [], np.zeros_like(self.valences)) and additional_condition in {1, 3, 5, 6}: - raise ValueError("All valences are equal to 0, additional_conditions 1, 3, 5 and 6 will not work") - - if limits is None: - self.lowerlimit = self.upperlimit = None - else: - self.lowerlimit, self.upperlimit = limits - - # Evaluate coordination environments - self._evaluate_ce( - lowerlimit=self.lowerlimit, - upperlimit=self.upperlimit, - only_bonds_to=only_bonds_to, - additional_condition=self.additional_condition, - perc_strength_icohp=perc_strength_icohp, - adapt_extremum_to_add_cond=adapt_extremum_to_add_cond, - ) - - @property - def structures_allowed(self) -> Literal[True]: - """Whether this LobsterNeighbors class can be used with Structure objects.""" - return True - - @property - def molecules_allowed(self) -> Literal[False]: - """Whether this LobsterNeighbors class can be used with Molecule objects.""" - return False - - @property - def anion_types(self) -> set[Element]: - """The set of anion types in crystal structure. - - Returns: - set[Element]: Anions in the crystal structure. - """ - if self.valences is None: - raise ValueError("No cations and anions defined") - - anion_species = [] - for site, val in zip(self.structure, self.valences, strict=True): - if val < 0.0: - anion_species.append(site.specie) - - return set(anion_species) - - @deprecated(anion_types) - def get_anion_types(self) -> set[Element]: - return self.anion_types - - def get_nn_info( - self, - structure: Structure | IStructure, - n: int, - use_weights: bool = False, - ) -> dict[str, Any]: - """Get coordination number (CN) of site by index. - - Args: - structure (Structure): Input structure. - n (int): Index of site for which to determine CN. - use_weights (bool): Whether to use weights for computing - the CN (True), or each coordinated site has equal weight (False). - The former is not implemented yet. - - Raises: - ValueError: If use_weights is True, or if arg "structure" and structure - used to initialize LobsterNeighbors have different lengths. - - Returns: - dict[str, Any]: coordination number and a list of nearest neighbors. - """ - if use_weights: - raise ValueError("LobsterEnv cannot use weights") - - if len(structure) != len(self.structure): - raise ValueError( - f"Length of structure ({len(structure)}) and LobsterNeighbors ({len(self.structure)}) differ" - ) - - return self.sg_list[n] # type: ignore[return-value] - - def get_light_structure_environment( - self, - only_cation_environments: bool = False, - only_indices: list[int] | None = None, - ) -> LobsterLightStructureEnvironments: - """Get a LobsterLightStructureEnvironments object if the structure - only contains coordination environments smaller 13. - - Args: - only_cation_environments (bool): Only return data for cations. - only_indices (list[int]): Only evaluate indexes in this list. - - Returns: - LobsterLightStructureEnvironments - """ - lgf = LocalGeometryFinder() - lgf.setup_structure(structure=self.structure) # type:ignore[arg-type] - list_ce_symbols = [] - list_csm = [] - list_permut = [] - for idx, _neigh_coords in enumerate(self.list_coords): - if (len(_neigh_coords)) > 13: - raise ValueError("Environment cannot be determined. Number of neighbors is larger than 13.") - # Avoid problems if _neigh_coords is empty - if _neigh_coords != []: - lgf.setup_local_geometry(isite=idx, coords=_neigh_coords, optimization=2) - cncgsm = lgf.get_coordination_symmetry_measures(optimization=2) - list_ce_symbols.append(min(cncgsm.items(), key=lambda t: t[1]["csm_wcs_ctwcc"])[0]) - list_csm.append(min(cncgsm.items(), key=lambda t: t[1]["csm_wcs_ctwcc"])[1]["csm_wcs_ctwcc"]) - list_permut.append(min(cncgsm.items(), key=lambda t: t[1]["csm_wcs_ctwcc"])[1]["indices"]) - else: - list_ce_symbols.append(None) - list_csm.append(None) - list_permut.append(None) - - new_list_ce_symbols = [] - new_list_csm = [] - new_list_permut = [] - new_list_neighsite = [] - new_list_neighisite = [] - - if only_indices is None: - if not only_cation_environments: - return LobsterLightStructureEnvironments.from_Lobster( - list_ce_symbol=list_ce_symbols, - list_csm=list_csm, - list_permutation=list_permut, - list_neighsite=self.list_neighsite, # type:ignore[arg-type] - list_neighisite=self.list_neighisite, - structure=self.structure, - valences=self.valences, - ) - - if self.valences is None: - raise ValueError(f"{self.valences=}") - - for idx, val in enumerate(self.valences): - if val >= 0.0: - new_list_ce_symbols.append(list_ce_symbols[idx]) - new_list_csm.append(list_csm[idx]) - new_list_permut.append(list_permut[idx]) - new_list_neighisite.append(self.list_neighisite[idx]) - new_list_neighsite.append(self.list_neighsite[idx]) - else: - new_list_ce_symbols.append(None) - new_list_csm.append(None) - new_list_permut.append([]) - new_list_neighisite.append([]) - new_list_neighsite.append([]) - - else: - for site_idx, _site in enumerate(self.structure): - if site_idx in only_indices: - new_list_ce_symbols.append(list_ce_symbols[site_idx]) - new_list_csm.append(list_csm[site_idx]) - new_list_permut.append(list_permut[site_idx]) - new_list_neighisite.append(self.list_neighisite[site_idx]) - new_list_neighsite.append(self.list_neighsite[site_idx]) - else: - new_list_ce_symbols.append(None) - new_list_csm.append(None) - new_list_permut.append([]) - new_list_neighisite.append([]) - new_list_neighsite.append([]) - - return LobsterLightStructureEnvironments.from_Lobster( - list_ce_symbol=new_list_ce_symbols, - list_csm=new_list_csm, - list_permutation=new_list_permut, - list_neighsite=new_list_neighsite, # type:ignore[arg-type] - list_neighisite=new_list_neighisite, - structure=self.structure, - valences=self.valences, - ) - - def get_info_icohps_to_neighbors( - self, - isites: list[int] | None = None, - onlycation_isites: bool = True, - ) -> ICOHPNeighborsInfo: - """Get information on the ICOHPs of neighbors for certain sites - as identified by their site id. - - This is useful for plotting the COHPs (ICOOPLIST.lobster/ - ICOHPLIST.lobster/ICOBILIST.lobster) of a site in the structure. - - - Args: - isites (list[int]): Site IDs. If is None, all isites will be used - to add the ICOHPs of the neighbors. - onlycation_isites (bool): If True and if isite is None, will - only analyse the cations sites. - - Returns: - ICOHPNeighborsInfo - """ - if self.valences is None and onlycation_isites: - raise ValueError("No valences are provided") - - if isites is None: - if onlycation_isites: - if self.valences is None: - raise ValueError(f"{self.valences}=") - - isites = [idx for idx in range(len(self.structure)) if self.valences[idx] >= 0.0] - else: - isites = list(range(len(self.structure))) - - if self.Icohpcollection is None: - raise ValueError(f"{self.Icohpcollection=}") - - summed_icohps: float = 0.0 - list_icohps: list[float] = [] - number_bonds: int = 0 - labels: list[str] = [] - atoms: list[list[str]] = [] - final_isites: list[int] = [] - for idx, _site in enumerate(self.structure): - if idx in isites: - for key, icohpsum in zip(self.list_keys[idx], self.list_icohps[idx], strict=True): - summed_icohps += icohpsum - list_icohps.append(icohpsum) # type:ignore[arg-type] - labels.append(key) - atoms.append( - [ - self.Icohpcollection._list_atom1[int(key) - 1], - self.Icohpcollection._list_atom2[int(key) - 1], - ] - ) - number_bonds += 1 - final_isites.append(idx) - return ICOHPNeighborsInfo(summed_icohps, list_icohps, number_bonds, labels, atoms, final_isites) - - def plot_cohps_of_neighbors( - self, - path_to_cohpcar: PathLike | None = "COHPCAR.lobster", - obj_cohpcar: CompleteCohp | None = None, - isites: list[int] | None = None, - onlycation_isites: bool = True, - only_bonds_to: list[str] | None = None, - per_bond: bool = False, - summed_spin_channels: bool = False, - xlim: tuple[float, float] | None = None, - ylim: tuple[float, float] = (-10, 6), - integrated: bool = False, - ) -> mpl.axes.Axes: - """Plot summed COHPs or COBIs or COOPs. - - Please be careful in the spin polarized case (plots might overlap). - - Args: - path_to_cohpcar (PathLike): Path to COHPCAR or COOPCAR or COBICAR. - obj_cohpcar (CompleteCohp): CompleteCohp object - isites (list[int]): Site IDs. If empty, all sites will be used to add the ICOHPs of the neighbors. - onlycation_isites (bool): Only use cations, if isite is empty. - only_bonds_to (list[str]): Only anions in this list will be considered. - per_bond (bool): Whether to plot a normalization of the plotted COHP - per number of bond (True), or the sum (False). - xlim (tuple[float, float]): Limits of x values. - ylim (tuple[float, float]): Limits of y values. - integrated (bool): Whether to show integrated COHP instead of COHP. - - Returns: - plt of the COHPs or COBIs or COOPs. - """ - cp = CohpPlotter(are_cobis=self.are_cobis, are_coops=self.are_coops) - - plotlabel, summed_cohp = self.get_info_cohps_to_neighbors( - path_to_cohpcar, - obj_cohpcar, - isites, - only_bonds_to, - onlycation_isites, - per_bond, - summed_spin_channels=summed_spin_channels, - ) - - cp.add_cohp(plotlabel, summed_cohp) - ax = cp.get_plot(integrated=integrated) - if xlim is not None: - ax.set_xlim(xlim) - - if ylim is not None: - ax.set_ylim(ylim) - - return ax - - def get_info_cohps_to_neighbors( - self, - path_to_cohpcar: PathLike | None = "COHPCAR.lobster", - obj_cohpcar: CompleteCohp | None = None, - isites: list[int] | None = None, - only_bonds_to: list[str] | None = None, - onlycation_isites: bool = True, - per_bond: bool = True, - summed_spin_channels: bool = False, - ) -> tuple[str | None, CompleteCohp | None]: - """Get the COHPs (COOPs or COBIs) as a summed Cohp object - and a label from all sites mentioned in isites with neighbors. - - Args: - path_to_cohpcar (PathLike): Path to COHPCAR/COOPCAR/COBICAR. - obj_cohpcar (CompleteCohp): CompleteCohp object. - isites (list[int]): The indexes of the sites. - only_bonds_to (list[str]): Only show COHPs to selected element, e.g. ["O"]. - onlycation_isites (bool): If isites is None, only cation sites will be returned. - per_bond (bool): Whether to normalize per bond. - summed_spin_channels (bool): Whether to sum both spin channels. - - Returns: - str: Label for COHP. - CompleteCohp: Describe all COHPs/COOPs/COBIs of the sites - as given by isites and the other arguments. - """ - # TODO: add options for orbital-resolved COHPs - _summed_icohps, _list_icohps, _number_bonds, labels, atoms, final_isites = self.get_info_icohps_to_neighbors( - isites=isites, onlycation_isites=onlycation_isites - ) - - with tempfile.TemporaryDirectory() as tmp_dir: - path = f"{tmp_dir}/POSCAR.vasp" - - self.structure.to(filename=path, fmt="poscar") - - if not hasattr(self, "completecohp"): - if path_to_cohpcar is not None and obj_cohpcar is None: - self.completecohp = CompleteCohp.from_file( - fmt="LOBSTER", - filename=path_to_cohpcar, - structure_file=path, - are_coops=self.are_coops, - are_cobis=self.are_cobis, - ) - elif obj_cohpcar is not None: - self.completecohp = obj_cohpcar - else: - raise ValueError("Please provide either path_to_cohpcar or obj_cohpcar") - - # Check that the number of bonds in ICOHPLIST and COHPCAR are identical - # TODO: Further checks could be implemented - if self.Icohpcollection is None: - raise ValueError(f"{self.Icohpcollection=}") - - if len(self.Icohpcollection._list_atom1) != len(self.completecohp.bonds): - raise ValueError("COHPCAR and ICOHPLIST do not fit together") - - is_spin_completecohp = Spin.down in self.completecohp.get_cohp_by_label("1").cohp - if self.Icohpcollection.is_spin_polarized != is_spin_completecohp: - raise ValueError("COHPCAR and ICOHPLIST do not fit together") - - if only_bonds_to is None: - # Sort by anion type - divisor = len(labels) if per_bond else 1 - - plot_label = self._get_plot_label(atoms, per_bond) - summed_cohp = self.completecohp.get_summed_cohp_by_label_list( - label_list=labels, - divisor=divisor, - summed_spin_channels=summed_spin_channels, - ) - - else: - # Labels of the COHPs that will be summed - # Iterate through labels and atoms and check which bonds can be included - new_labels = [] - new_atoms = [] - if final_isites is None: - raise ValueError(f"{final_isites=}") - - for key, atompair, isite in zip(labels, atoms, final_isites, strict=True): - present = False - for atomtype in only_bonds_to: - # This is necessary to identify also bonds between the same elements correctly - if str(self.structure[isite].species.elements[0]) != atomtype: - if atomtype in { - self._split_string(atompair[0])[0], - self._split_string(atompair[1])[0], - }: - present = True - elif ( - atomtype == self._split_string(atompair[0])[0] - and atomtype == self._split_string(atompair[1])[0] - ): - present = True - - if present: - new_labels.append(key) - new_atoms.append(atompair) - if new_labels: - divisor = len(new_labels) if per_bond else 1 - - plot_label = self._get_plot_label(new_atoms, per_bond) - summed_cohp = self.completecohp.get_summed_cohp_by_label_list( - label_list=new_labels, - divisor=divisor, - summed_spin_channels=summed_spin_channels, - ) - else: - plot_label = None - - summed_cohp = None - - return plot_label, summed_cohp # type:ignore[return-value] - - def _get_plot_label(self, atoms: list[list[str]], per_bond: bool) -> str: - """Count the types of bonds and append a label.""" - all_labels = [] - for atoms_names in atoms: - new = [ - self._split_string(atoms_names[0])[0], - self._split_string(atoms_names[1])[0], - ] - new.sort() - string_here = f"{new[0]}-{new[1]}" - all_labels.append(string_here) - - counter = collections.Counter(all_labels) - plotlabels = [f"{item} x {key}" for key, item in counter.items()] - label = ", ".join(plotlabels) - if per_bond: - label += " (per bond)" - return label - - def get_info_icohps_between_neighbors( - self, - isites: list[int] | None = None, - onlycation_isites: bool = True, - ) -> ICOHPNeighborsInfo: - """Get interactions between neighbors of certain sites. - - Args: - isites (list[int]): Site IDs. If is None, all sites will be used. - onlycation_isites (bool): Only use cations, if isite is None. - - Returns: - ICOHPNeighborsInfo - """ - lowerlimit = self.lowerlimit - upperlimit = self.upperlimit - - if self.valences is None and onlycation_isites: - raise ValueError("No valences are provided") - - if isites is None: - if onlycation_isites: - if self.valences is None: - raise ValueError(f"{self.valences=}") - - isites = [idx for idx in range(len(self.structure)) if self.valences[idx] >= 0.0] - else: - isites = list(range(len(self.structure))) - - summed_icohps: float = 0.0 - list_icohps: list[float] = [] - number_bonds: int = 0 - labels: list[str] = [] - atoms: list[list[str]] = [] - if self.Icohpcollection is None: - raise ValueError(f"{self.Icohpcollection=}") - - for isite in isites: - for site1_idx, n_site in enumerate(self.list_neighsite[isite]): - for site2_idx, n_site2 in enumerate(self.list_neighsite[isite]): - if site1_idx < site2_idx: - unitcell1 = self._determine_unit_cell(n_site) - unitcell2 = self._determine_unit_cell(n_site2) - - index_n_site = self._get_original_site(self.structure, n_site) # type:ignore[arg-type] - index_n_site2 = self._get_original_site(self.structure, n_site2) # type:ignore[arg-type] - - if index_n_site < index_n_site2: - translation = list(np.array(unitcell1) - np.array(unitcell2)) - elif index_n_site2 < index_n_site: - translation = list(np.array(unitcell2) - np.array(unitcell1)) - else: - translation = list(np.array(unitcell1) - np.array(unitcell2)) - - icohps = self._get_icohps( - icohpcollection=self.Icohpcollection, - site_idx=index_n_site, - lowerlimit=lowerlimit, - upperlimit=upperlimit, - only_bonds_to=self.only_bonds_to, - ) - - done = False - for icohp in icohps.values(): - atomnr1 = self._get_atomnumber(icohp._atom1) - atomnr2 = self._get_atomnumber(icohp._atom2) - label = icohp._label - - if (index_n_site == atomnr1 and index_n_site2 == atomnr2) or ( - index_n_site == atomnr2 and index_n_site2 == atomnr1 - ): - if atomnr1 != atomnr2: - if np.all(np.asarray(translation) == np.asarray(icohp._translation)): - summed_icohps += icohp.summed_icohp - list_icohps.append(icohp.summed_icohp) - number_bonds += 1 - labels.append(label) - atoms.append( - [ - self.Icohpcollection._list_atom1[int(label) - 1], - self.Icohpcollection._list_atom2[int(label) - 1], - ] - ) - - elif not done: - icohp_trans = -np.asarray( - [ - icohp._translation[0], - icohp._translation[1], - icohp._translation[2], - ] - ) - - if (np.all(np.asarray(translation) == np.asarray(icohp._translation))) or ( - np.all(np.asarray(translation) == icohp_trans) - ): - summed_icohps += icohp.summed_icohp - list_icohps.append(icohp.summed_icohp) - number_bonds += 1 - labels.append(label) - atoms.append( - [ - self.Icohpcollection._list_atom1[int(label) - 1], - self.Icohpcollection._list_atom2[int(label) - 1], - ] - ) - done = True - - return ICOHPNeighborsInfo(summed_icohps, list_icohps, number_bonds, labels, atoms, None) - - def _evaluate_ce( - self, - lowerlimit: float | None, - upperlimit: float | None, - only_bonds_to: list[str] | None = None, - additional_condition: Literal[0, 1, 2, 3, 4, 5, 6] = 0, - perc_strength_icohp: float = 0.15, - adapt_extremum_to_add_cond: bool = False, - ) -> None: - """ - Args: - lowerlimit (float): Lower limit which determines the ICOHPs - that are considered for the determination of the neighbors. - upperlimit (float): Upper limit which determines the ICOHPs - that are considered for the determination of the neighbors. - only_bonds_to (list[str]): Restrict the types of bonds that will be considered. - additional_condition (int): Additional condition for the evaluation. - perc_strength_icohp (float): Determine how strong the ICOHPs - (percentage * strongest_ICOHP) will be that are still considered. - adapt_extremum_to_add_cond (bool): Whether to recalculate the limit - based on the bonding type and not on the overall extremum. - """ - # Get extremum - if lowerlimit is None and upperlimit is None: - if self.Icohpcollection is None: - raise ValueError(f"{self.Icohpcollection=}") - - limits = self._get_limit_from_extremum( - self.Icohpcollection, - percentage=perc_strength_icohp, - adapt_extremum_to_add_cond=adapt_extremum_to_add_cond, - additional_condition=additional_condition, - ) - - if limits is None: - raise ValueError(f"{limits=}") - lowerlimit, upperlimit = limits - - elif upperlimit is None or lowerlimit is None: - raise ValueError("Please give two limits or leave them both at None") - - # Find environments based on ICOHP values - ( - list_icohps, - list_keys, - list_lengths, - list_neighisite, - list_neighsite, - list_coords, - ) = self._find_environments(additional_condition, lowerlimit, upperlimit, only_bonds_to) - - self.list_icohps = list_icohps - self.list_lengths = list_lengths - self.list_keys = list_keys - self.list_neighsite = list_neighsite - self.list_neighisite = list_neighisite - self.list_coords = list_coords - - # Make a structure graph - # Make sure everything is relative to the given Structure and - # not just the atoms in the unit cell - if self.add_additional_data_sg: - if self.bonding_list_1.icohpcollection is None: - raise ValueError(f"{self.bonding_list_1.icohpcollection=}") - if self.bonding_list_2.icohpcollection is None: - raise ValueError(f"{self.bonding_list_2.icohpcollection=}") - - self.sg_list = [ - [ - { - "site": neighbor, - "image": tuple( - round(idx) - for idx in ( - neighbor.frac_coords - - self.structure[ - next( - site_idx - for site_idx, site in enumerate(self.structure) - if neighbor.is_periodic_image(site) - ) - ].frac_coords - ) - ), - "weight": 1, - # Here, the ICOBIs and ICOOPs are added based on the bond - # strength cutoff of the ICOHP - # More changes are necessary here if we use ICOBIs for cutoffs - "edge_properties": { - "ICOHP": self.list_icohps[neighbors_idx][nbr_idx], - "bond_length": self.list_lengths[neighbors_idx][nbr_idx], - "bond_label": self.list_keys[neighbors_idx][nbr_idx], - self.id_blist_sg1.upper(): self.bonding_list_1.icohpcollection.get_icohp_by_label( - self.list_keys[neighbors_idx][nbr_idx] - ), - self.id_blist_sg2.upper(): self.bonding_list_2.icohpcollection.get_icohp_by_label( - self.list_keys[neighbors_idx][nbr_idx] - ), - }, - "site_index": next( - site_idx for site_idx, site in enumerate(self.structure) if neighbor.is_periodic_image(site) - ), - } - for nbr_idx, neighbor in enumerate(neighbors) - ] - for neighbors_idx, neighbors in enumerate(self.list_neighsite) - ] - else: - self.sg_list = [ - [ - { - "site": neighbor, - "image": tuple( - round(idx) - for idx in ( - neighbor.frac_coords - - self.structure[ - next( - site_idx - for site_idx, site in enumerate(self.structure) - if neighbor.is_periodic_image(site) - ) - ].frac_coords - ) - ), - "weight": 1, - "edge_properties": { - "ICOHP": self.list_icohps[neighbors_idx][nbr_idx], - "bond_length": self.list_lengths[neighbors_idx][nbr_idx], - "bond_label": self.list_keys[neighbors_idx][nbr_idx], - }, - "site_index": next( - site_idx for site_idx, site in enumerate(self.structure) if neighbor.is_periodic_image(site) - ), - } - for nbr_idx, neighbor in enumerate(neighbors) - ] - for neighbors_idx, neighbors in enumerate(self.list_neighsite) - ] - - def _find_environments( - self, - additional_condition: Literal[0, 1, 2, 3, 4, 5, 6], - lowerlimit: float, - upperlimit: float, - only_bonds_to: list[str] | None, - ) -> tuple[ - list[list[IcohpValue]], - list[list[str]], - list[list[float]], - list[list[int]], - list[list[PeriodicNeighbor]], - list[list[NDArray]], - ]: - """Find all relevant neighbors based on certain restrictions. - - Args: - additional_condition (int): Additional condition. - lowerlimit (float): Lower limit that ICOHPs are considered. - upperlimit (float): Upper limit that ICOHPs are considered. - only_bonds_to (list[str]): Only bonds to these elements will be considered. - - Returns: - Tuple of ICOHPs, keys, lengths, neighisite, neighsite, coords. - """ - list_icohps: list[list[float]] = [] - list_keys: list[list[str]] = [] - list_lengths: list[list[float]] = [] - list_neighisite: list[list[int]] = [] - list_neighsite: list[list[PeriodicNeighbor]] = [] - list_coords: list[list[NDArray]] = [] - - # Run over structure - if self.Icohpcollection is None: - raise ValueError(f"{self.Icohpcollection=}") - - for idx, site in enumerate(self.structure): - icohps = self._get_icohps( - icohpcollection=self.Icohpcollection, - site_idx=idx, - lowerlimit=lowerlimit, - upperlimit=upperlimit, - only_bonds_to=only_bonds_to, - ) - - additional_conds = self._find_relevant_atoms_additional_condition(idx, icohps, additional_condition) - ( - keys_from_ICOHPs, - lengths_from_ICOHPs, - neighbors_from_ICOHPs, - selected_ICOHPs, - ) = additional_conds - - if len(neighbors_from_ICOHPs) > 0: - centralsite = site - - neighbors_by_distance_start = self.structure.get_sites_in_sphere( - pt=centralsite.coords, - r=np.max(lengths_from_ICOHPs) + 0.5, - include_image=True, - include_index=True, - ) - - neighbors_by_distance = [] - list_distances = [] - index_here_list = [] - coords = [] - for neigh_new in sorted(neighbors_by_distance_start, key=lambda x: x[1]): - site_here = neigh_new[0].to_unit_cell() - index_here = neigh_new[2] - index_here_list.append(index_here) - cell_here = neigh_new[3] - new_coords = [ - site_here.frac_coords[0] + float(cell_here[0]), - site_here.frac_coords[1] + float(cell_here[1]), - site_here.frac_coords[2] + float(cell_here[2]), - ] - coords.append(site_here.lattice.get_cartesian_coords(new_coords)) - - # new_site = PeriodicSite( - # species=site_here.species_string, - # coords=site_here.lattice.get_cartesian_coords(new_coords), - # lattice=site_here.lattice, - # to_unit_cell=False, - # coords_are_cartesian=True, - # ) - neighbors_by_distance.append(neigh_new[0]) - list_distances.append(neigh_new[1]) - _list_neighsite = [] - _list_neighisite = [] - copied_neighbors_from_ICOHPs = copy.copy(neighbors_from_ICOHPs) - copied_distances_from_ICOHPs = copy.copy(lengths_from_ICOHPs) - _neigh_coords = [] - _neigh_frac_coords = [] - - for neigh_idx, neigh in enumerate(neighbors_by_distance): - index_here2 = index_here_list[neigh_idx] - - for dist_idx, dist in enumerate(copied_distances_from_ICOHPs): - if ( - np.isclose(dist, list_distances[neigh_idx], rtol=1e-4) - and copied_neighbors_from_ICOHPs[dist_idx] == index_here2 - ): - _list_neighsite.append(neigh) - _list_neighisite.append(index_here2) - _neigh_coords.append(coords[neigh_idx]) - _neigh_frac_coords.append(neigh.frac_coords) - del copied_distances_from_ICOHPs[dist_idx] - del copied_neighbors_from_ICOHPs[dist_idx] - break - - list_neighisite.append(_list_neighisite) - list_neighsite.append(_list_neighsite) - list_lengths.append(lengths_from_ICOHPs) - list_keys.append(keys_from_ICOHPs) - list_coords.append(_neigh_coords) - list_icohps.append(selected_ICOHPs) - - else: - list_neighsite.append([]) - list_neighisite.append([]) - list_icohps.append([]) - list_lengths.append([]) - list_keys.append([]) - list_coords.append([]) - return ( # type:ignore[return-value] - list_icohps, - list_keys, - list_lengths, - list_neighisite, - list_neighsite, - list_coords, - ) - - def _find_relevant_atoms_additional_condition( - self, - site_idx: int, - icohps: dict[str, IcohpValue], - additional_condition: Literal[0, 1, 2, 3, 4, 5, 6], - ) -> tuple[list[str], list[float], list[int], list[float]]: - """Find all relevant atoms that fulfill the additional condition. - - Args: - site_idx (int): Site index in structure (start from 0). - icohps (dict[str, IcohpValue]): ICOHP values. - additional_condition (int): Additional condition. - - Returns: - tuple: keys, lengths, neighbors from selected ICOHPs and selected ICOHPs. - """ - keys_from_ICOHPs: list[str] = [] - lengths_from_ICOHPs: list[float] = [] - neighbors_from_ICOHPs: list[int] = [] - icohps_from_ICOHPs: list[float] = [] - - for key, icohp in icohps.items(): - atomnr1 = self._get_atomnumber(icohp._atom1) - atomnr2 = self._get_atomnumber(icohp._atom2) - - # Check additional conditions - val1 = val2 = None - if self.valences is None: - raise ValueError(f"{self.valences=}") - if additional_condition in {1, 3, 5, 6}: - val1 = self.valences[atomnr1] - val2 = self.valences[atomnr2] - - # NO_ADDITIONAL_CONDITION - if additional_condition == 0: - if atomnr1 == site_idx: - neighbors_from_ICOHPs.append(atomnr2) - lengths_from_ICOHPs.append(icohp._length) - icohps_from_ICOHPs.append(icohp.summed_icohp) - keys_from_ICOHPs.append(key) - elif atomnr2 == site_idx: - neighbors_from_ICOHPs.append(atomnr1) - lengths_from_ICOHPs.append(icohp._length) - icohps_from_ICOHPs.append(icohp.summed_icohp) - keys_from_ICOHPs.append(key) - - # ONLY_ANION_CATION_BONDS - elif additional_condition == 1: - if (val1 < 0.0 < val2) or (val2 < 0.0 < val1): # type: ignore[operator] - if atomnr1 == site_idx: - neighbors_from_ICOHPs.append(atomnr2) - lengths_from_ICOHPs.append(icohp._length) - icohps_from_ICOHPs.append(icohp.summed_icohp) - keys_from_ICOHPs.append(key) - - elif atomnr2 == site_idx: - neighbors_from_ICOHPs.append(atomnr1) - lengths_from_ICOHPs.append(icohp._length) - icohps_from_ICOHPs.append(icohp.summed_icohp) - keys_from_ICOHPs.append(key) - - # NO_ELEMENT_TO_SAME_ELEMENT_BONDS - elif additional_condition == 2: - if icohp._atom1.rstrip("0123456789") != icohp._atom2.rstrip("0123456789"): - if atomnr1 == site_idx: - neighbors_from_ICOHPs.append(atomnr2) - lengths_from_ICOHPs.append(icohp._length) - icohps_from_ICOHPs.append(icohp.summed_icohp) - keys_from_ICOHPs.append(key) - - elif atomnr2 == site_idx: - neighbors_from_ICOHPs.append(atomnr1) - lengths_from_ICOHPs.append(icohp._length) - icohps_from_ICOHPs.append(icohp.summed_icohp) - keys_from_ICOHPs.append(key) - - # ONLY_ANION_CATION_BONDS_AND_NO_ELEMENT_TO_SAME_ELEMENT_BONDS - elif additional_condition == 3: - if ((val1 < 0.0 < val2) or (val2 < 0.0 < val1)) and icohp._atom1.rstrip( # type: ignore[operator] - "0123456789" - ) != icohp._atom2.rstrip("0123456789"): - if atomnr1 == site_idx: - neighbors_from_ICOHPs.append(atomnr2) - lengths_from_ICOHPs.append(icohp._length) - icohps_from_ICOHPs.append(icohp.summed_icohp) - keys_from_ICOHPs.append(key) - - elif atomnr2 == site_idx: - neighbors_from_ICOHPs.append(atomnr1) - lengths_from_ICOHPs.append(icohp._length) - icohps_from_ICOHPs.append(icohp.summed_icohp) - keys_from_ICOHPs.append(key) - - # ONLY_ELEMENT_TO_OXYGEN_BONDS - elif additional_condition == 4: - if icohp._atom1.rstrip("0123456789") == "O" or icohp._atom2.rstrip("0123456789") == "O": - if atomnr1 == site_idx: - neighbors_from_ICOHPs.append(atomnr2) - lengths_from_ICOHPs.append(icohp._length) - icohps_from_ICOHPs.append(icohp.summed_icohp) - keys_from_ICOHPs.append(key) - - elif atomnr2 == site_idx: - neighbors_from_ICOHPs.append(atomnr1) - lengths_from_ICOHPs.append(icohp._length) - icohps_from_ICOHPs.append(icohp.summed_icohp) - keys_from_ICOHPs.append(key) - - # DO_NOT_CONSIDER_ANION_CATION_BONDS - elif additional_condition == 5: - if (val1 > 0.0 and val2 > 0.0) or (val1 < 0.0 and val2 < 0.0): # type: ignore[operator] - if atomnr1 == site_idx: - neighbors_from_ICOHPs.append(atomnr2) - lengths_from_ICOHPs.append(icohp._length) - icohps_from_ICOHPs.append(icohp.summed_icohp) - keys_from_ICOHPs.append(key) - - elif atomnr2 == site_idx: - neighbors_from_ICOHPs.append(atomnr1) - lengths_from_ICOHPs.append(icohp._length) - icohps_from_ICOHPs.append(icohp.summed_icohp) - keys_from_ICOHPs.append(key) - - # ONLY_CATION_CATION_BONDS - elif additional_condition == 6 and val1 > 0.0 and val2 > 0.0: # type: ignore[operator] - if atomnr1 == site_idx: - neighbors_from_ICOHPs.append(atomnr2) - lengths_from_ICOHPs.append(icohp._length) - icohps_from_ICOHPs.append(icohp.summed_icohp) - keys_from_ICOHPs.append(key) - - elif atomnr2 == site_idx: - neighbors_from_ICOHPs.append(atomnr1) - lengths_from_ICOHPs.append(icohp._length) - icohps_from_ICOHPs.append(icohp.summed_icohp) - keys_from_ICOHPs.append(key) - - return ( - keys_from_ICOHPs, - lengths_from_ICOHPs, - neighbors_from_ICOHPs, - icohps_from_ICOHPs, - ) - - @staticmethod - def _get_icohps( - icohpcollection: IcohpCollection, - site_idx: int, - lowerlimit: float | None, - upperlimit: float | None, - only_bonds_to: list[str] | None, - ) -> dict[str, IcohpValue]: - """Get ICOHP dict for certain site. - - Args: - icohpcollection (IcohpCollection): IcohpCollection object. - site_idx (int): Site index. - lowerlimit (float): Lower limit that ICOHPs are considered. - upperlimit (float): Upper limit that ICOHPs are considered. - only_bonds_to (list[str]): Only bonds to these elements will be considered, e.g. ["O"]. - - Returns: - dict of IcohpValues. The keys correspond to the initial list_labels. - """ - return icohpcollection.get_icohp_dict_of_site( - site=site_idx, - maxbondlength=6.0, - minsummedicohp=lowerlimit, - maxsummedicohp=upperlimit, - only_bonds_to=only_bonds_to, - ) - - @staticmethod - def _get_atomnumber(atomstring: str) -> int: - """Get the index of the atom within the POSCAR (e.g., Return 0 for "Na1"). - - Args: - atomstring (str): Atom as str, such as "Na1". - - Returns: - int: Index of the atom in the POSCAR. - """ - return int(LobsterNeighbors._split_string(atomstring)[1]) - 1 - - @staticmethod - def _split_string(s) -> tuple[str, str]: - """Split strings such as "Na1" into ["Na", "1"] and return "1". - - Args: - s (str): String to split. - """ - head = s.rstrip("0123456789") - tail = s[len(head) :] - return head, tail - - @staticmethod - def _determine_unit_cell(site: PeriodicSite) -> list[int]: - """Determine the unit cell based on the site. - - Args: - site (PeriodicSite): The site. - """ - unitcell = [] - for coord in site.frac_coords: - value = math.floor(round(coord, 4)) - unitcell.append(value) - - return unitcell - - def _adapt_extremum_to_add_cond( - self, - list_icohps: list[float], - percentage: float, - ) -> float: - """Get the extremum from the given ICOHPs or ICOOPs or ICOBIs. - - Args: - list_icohps (list): ICOHPs or ICOOPs or ICOBIs. - percentage (float): The percentage to scale extremum. - - Returns: - float: Min value of ICOHPs, or max value of ICOOPs/ICOBIs. - """ - - which_extr = min if not self.are_coops and not self.are_cobis else max - return which_extr(list_icohps) * percentage - - def _get_limit_from_extremum( - self, - icohpcollection: IcohpCollection, - percentage: float = 0.15, - adapt_extremum_to_add_cond: bool = False, - additional_condition: Literal[0, 1, 2, 3, 4, 5, 6] = 0, - ) -> tuple[float, float] | None: - """Get range for the ICOHP values from an IcohpCollection. - - Currently only work for ICOHPs. - - Args: - icohpcollection (IcohpCollection): IcohpCollection object. - percentage (float): Determine which ICOHPs/ICOOP/ICOBI will be considered. - adapt_extremum_to_add_cond (bool): Whether the extrumum be adapted to - the additional condition. - additional_condition (int): Additional condition to determine which bonds to include. - - Returns: - tuple[float, float]: [-inf, min(strongest_icohp*0.15, -noise_cutoff)] - or [max(strongest_icohp*0.15, noise_cutoff), inf]. - """ - extremum_based = None - - if self.valences is None: - raise ValueError(f"{self.valences=}") - - if not adapt_extremum_to_add_cond or additional_condition == 0: - extremum_based = icohpcollection.extremum_icohpvalue(summed_spin_channels=True) * percentage - - elif additional_condition == 1: - # ONLY_ANION_CATION_BONDS - list_icohps = [] - for value in icohpcollection._icohplist.values(): - atomnr1 = type(self)._get_atomnumber(value._atom1) - atomnr2 = type(self)._get_atomnumber(value._atom2) - - val1 = self.valences[atomnr1] - val2 = self.valences[atomnr2] - if (val1 < 0.0 < val2) or (val2 < 0.0 < val1): - list_icohps.append(value.summed_icohp) - - extremum_based = self._adapt_extremum_to_add_cond(list_icohps, percentage) - - elif additional_condition == 2: - # NO_ELEMENT_TO_SAME_ELEMENT_BONDS - list_icohps = [] - for value in icohpcollection._icohplist.values(): - if value._atom1.rstrip("0123456789") != value._atom2.rstrip("0123456789"): - list_icohps.append(value.summed_icohp) - - extremum_based = self._adapt_extremum_to_add_cond(list_icohps, percentage) - - elif additional_condition == 3: - # ONLY_ANION_CATION_BONDS_AND_NO_ELEMENT_TO_SAME_ELEMENT_BONDS - list_icohps = [] - for value in icohpcollection._icohplist.values(): - atomnr1 = type(self)._get_atomnumber(value._atom1) - atomnr2 = type(self)._get_atomnumber(value._atom2) - val1 = self.valences[atomnr1] - val2 = self.valences[atomnr2] - - if ((val1 < 0.0 < val2) or (val2 < 0.0 < val1)) and value._atom1.rstrip( - "0123456789" - ) != value._atom2.rstrip("0123456789"): - list_icohps.append(value.summed_icohp) - - extremum_based = self._adapt_extremum_to_add_cond(list_icohps, percentage) - - elif additional_condition == 4: - # ONLY_ELEMENT_TO_OXYGEN_BONDS - list_icohps = [] - for value in icohpcollection._icohplist.values(): - if value._atom1.rstrip("0123456789") == "O" or value._atom2.rstrip("0123456789") == "O": - list_icohps.append(value.summed_icohp) - - extremum_based = self._adapt_extremum_to_add_cond(list_icohps, percentage) - - elif additional_condition == 5: - # DO_NOT_CONSIDER_ANION_CATION_BONDS - list_icohps = [] - for value in icohpcollection._icohplist.values(): - atomnr1 = type(self)._get_atomnumber(value._atom1) - atomnr2 = type(self)._get_atomnumber(value._atom2) - val1 = self.valences[atomnr1] - val2 = self.valences[atomnr2] - - if (val1 > 0.0 and val2 > 0.0) or (val1 < 0.0 and val2 < 0.0): - list_icohps.append(value.summed_icohp) - - extremum_based = self._adapt_extremum_to_add_cond(list_icohps, percentage) - - elif additional_condition == 6: - # ONLY_CATION_CATION_BONDS - list_icohps = [] - for value in icohpcollection._icohplist.values(): - atomnr1 = type(self)._get_atomnumber(value._atom1) - atomnr2 = type(self)._get_atomnumber(value._atom2) - val1 = self.valences[atomnr1] - val2 = self.valences[atomnr2] - - if val1 > 0.0 and val2 > 0.0: - list_icohps.append(value.summed_icohp) - - extremum_based = self._adapt_extremum_to_add_cond(list_icohps, percentage) - - if not self.are_coops and not self.are_cobis: - max_here = min(extremum_based, -self.noise_cutoff) if self.noise_cutoff is not None else extremum_based - return -float("inf"), max_here - - if self.are_coops or self.are_cobis: - min_here = max(extremum_based, self.noise_cutoff) if self.noise_cutoff is not None else extremum_based - return min_here, float("inf") - - return None - - -class LobsterLightStructureEnvironments(LightStructureEnvironments): - """Store LightStructureEnvironments based on LOBSTER outputs.""" - - @classmethod - def from_Lobster( - cls, - list_ce_symbol: list[str], - list_csm: list[float], - list_permutation: list, - list_neighsite: list[PeriodicSite], - list_neighisite: list[list[int]], - structure: Structure | IStructure, - valences: list[float] | None = None, - ) -> Self: - """Set up a LightStructureEnvironments from LOBSTER. - - Args: - list_ce_symbol (list[str]): Coordination environments symbols. - list_csm (list[float]): Continuous symmetry measures. - list_permutation (list): Permutations. - list_neighsite (list[PeriodicSite]): Neighboring sites. - list_neighisite (list[list[int]]): Neighboring sites indexes. - structure (Structure): Structure object. - valences (list[float]): Valences. - - Returns: - LobsterLightStructureEnvironments - """ - strategy = None - valences_origin = "user-defined" - coordination_environments = [] - all_nbs_sites = [] - all_nbs_sites_indices = [] - neighbors_sets = [] - counter = 0 - - for site_idx in range(len(structure)): - # Coordination environment - if list_ce_symbol is not None: - ce_dict = { - "ce_symbol": list_ce_symbol[site_idx], - "ce_fraction": 1.0, - "csm": list_csm[site_idx], - "permutation": list_permutation[site_idx], - } - else: - ce_dict = None - - if list_neighisite[site_idx] is not None: - all_nbs_sites_indices_here = [] - for neigh_site_idx, neigh_site in enumerate(list_neighsite[site_idx]): - diff = neigh_site.frac_coords - structure[list_neighisite[site_idx][neigh_site_idx]].frac_coords - round_diff = np.round(diff) - if not np.allclose(diff, round_diff): - raise ValueError( - "Weird, differences between one site in a periodic image cell is not integer ..." - ) - nb_image_cell = np.array(round_diff, int) - - all_nbs_sites_indices_here.append(counter) - - neighbor = { - "site": neigh_site, - "index": list_neighisite[site_idx][neigh_site_idx], - "image_cell": nb_image_cell, - } - all_nbs_sites.append(neighbor) - counter += 1 - - all_nbs_sites_indices.append(all_nbs_sites_indices_here) - - else: - all_nbs_sites.append({"site": None, "index": None, "image_cell": None}) - all_nbs_sites_indices.append([]) - - if list_neighisite[site_idx] is not None: - nb_set = cls.NeighborsSet( - structure=structure, # type:ignore[arg-type] - isite=site_idx, - all_nbs_sites=all_nbs_sites, - all_nbs_sites_indices=all_nbs_sites_indices[site_idx], - ) - - else: - nb_set = cls.NeighborsSet( - structure=structure, # type:ignore[arg-type] - isite=site_idx, - all_nbs_sites=[], - all_nbs_sites_indices=[], - ) - - coordination_environments.append([ce_dict]) - neighbors_sets.append([nb_set]) - - return cls( - strategy=strategy, - coordination_environments=coordination_environments, - all_nbs_sites=all_nbs_sites, - neighbors_sets=neighbors_sets, - structure=structure, - valences=valences, - valences_origin=valences_origin, - ) - - @property - def uniquely_determines_coordination_environments(self) -> Literal[True]: - """Whether the coordination environments are uniquely determined.""" - return True - - def as_dict(self) -> dict[str, Any]: - """Bson-serializable dict representation of the object. - - Returns: - Bson-serializable dict representation. - """ - return { - "@module": type(self).__module__, - "@class": type(self).__name__, - "strategy": self.strategy, - "structure": self.structure.as_dict(), - "coordination_environments": self.coordination_environments, - "all_nbs_sites": [ - { - "site": nb_site["site"].as_dict(), - "index": nb_site["index"], - "image_cell": [int(ii) for ii in nb_site["image_cell"]], - } - for nb_site in self._all_nbs_sites - ], - "neighbors_sets": [ - [nb_set.as_dict() for nb_set in site_nb_sets] or None for site_nb_sets in self.neighbors_sets - ], - "valences": self.valences, - } - - -class ICOHPNeighborsInfo(NamedTuple): - """Tuple to record information on relevant bonds. - - Args: - total_icohp (float): Sum of ICOHP values of neighbors to the selected - sites (given by the index in structure). - list_icohps (list): Summed ICOHP values for all identified interactions with neighbors. - n_bonds (int): Number of identified bonds to the selected sites. - labels (list[str]): Labels (from ICOHPLIST) for all identified bonds. - atoms (list[list[str]]): Lists describing the species present (from ICOHPLIST) - in the identified interactions , e.g. ["Ag3", "O5"]. - central_isites (list[int]): The central site indexes for each identified interaction. - """ - - total_icohp: float - list_icohps: list[float] - n_bonds: int - labels: list[str] - atoms: list[list[str]] - central_isites: list[int] | None +class LobsterNeighbors: + """Deprecated LobsterNeighbors class for analyzing NearNeighbor interactions using ICOHPs/ICOOPs/ICOBIs.""" diff --git a/src/pymatgen/io/lobster/inputs.py b/src/pymatgen/io/lobster/inputs.py new file mode 100644 index 00000000000..c62d4d98b25 --- /dev/null +++ b/src/pymatgen/io/lobster/inputs.py @@ -0,0 +1,901 @@ +"""Module for reading LOBSTER input files. +For more information on LOBSTER see www.cohp.de. + +If you use this module, please cite: +J. George, G. Petretto, A. Naik, M. Esters, A. J. Jackson, R. Nelson, R. Dronskowski, G.-M. Rignanese, G. Hautier, +"Automated Bonding Analysis with Crystal Orbital Hamilton Populations", +ChemPlusChem 2022, e202200123, +DOI: 10.1002/cplu.202200123. +""" + +from __future__ import annotations + +import itertools +import os +import re +import warnings +from collections import UserDict +from typing import TYPE_CHECKING + +import numpy as np +import spglib +from monty.io import zopen +from monty.json import MSONable +from monty.serialization import loadfn + +from pymatgen.core.structure import Structure +from pymatgen.io.vasp import Vasprun +from pymatgen.io.vasp.inputs import Incar, Kpoints, Potcar +from pymatgen.symmetry.bandstructure import HighSymmKpath +from pymatgen.util.due import Doi, due + +if TYPE_CHECKING: + from typing import Any, ClassVar, Literal, Self + + from pymatgen.core.composition import Composition + from pymatgen.core.structure import IStructure + from pymatgen.util.typing import PathLike + +MODULE_DIR = os.path.dirname(os.path.abspath(__file__)) + +__author__ = "Janine George, Marco Esters" +__copyright__ = "Copyright 2017, The Materials Project" +__version__ = "0.2" +__maintainer__ = "Janine George" +__email__ = "janinegeorge.ulfen@gmail.com" +__date__ = "Dec 13, 2017" + + +due.cite( + Doi("10.1002/cplu.202200123"), + description="Automated Bonding Analysis with Crystal Orbital Hamilton Populations", +) + + +class Lobsterin(UserDict, MSONable): + """Handle and generate lobsterin files. + Furthermore, it can also modify INCAR files for LOBSTER, generate KPOINTS files for fatband calculations in LOBSTER, + and generate the standard primitive cells in a POSCAR file that are needed for the fatband calculations. + There are also several standard lobsterin files that can be easily generated. + + Reminder: lobsterin keywords are not case sensitive. + """ + + # These keywords need an additional float suffix + _FLOAT_KEYWORDS: tuple[str, ...] = ( + "COHPstartEnergy", + "COHPendEnergy", + "gaussianSmearingWidth", + "useDecimalPlaces", + "COHPSteps", + "basisRotation", + "gridDensityForPrinting", + "gridBufferForPrinting", + ) + + # These keywords need an additional string suffix + _STRING_KEYWORDS: tuple[str, ...] = ( + "basisSet", + "cohpGenerator", + "realspaceHamiltonian", + "realspaceOverlap", + "printPAWRealSpaceWavefunction", + "printLCAORealSpaceWavefunction", + "kSpaceCOHP", + "EwaldSum", + ) + + # The keywords themselves (without suffix) can trigger additional functionalities + _BOOLEAN_KEYWORDS: tuple[str, ...] = ( + "saveProjectionToFile", + "skipCar", + "skipdos", + "skipcohp", + "skipcoop", + "skipcobi", + "skipMOFE", + "skipMolecularOrbitals", + "skipMadelungEnergy", + "loadProjectionFromFile", + "printTotalSpilling", + "forceEnergyRange", + "DensityOfEnergy", + "BWDF", + "BWDFCOHP", + "skipPopulationAnalysis", + "skipGrossPopulation", + "userecommendedbasisfunctions", + "skipProjection", + "printLmosOnAtoms", + "printMofeAtomWise", + "printMofeMoleculeWise", + "writeAtomicOrbitals", + "writeBasisFunctions", + "writeMatricesToFile", + "noFFTforVisualization", + "RMSp", + "onlyReadVasprun.xml", + "noMemoryMappedFiles", + "skipPAWOrthonormalityTest", + "doNotIgnoreExcessiveBands", + "doNotUseAbsoluteSpilling", + "skipReOrthonormalization", + "forceV1HMatrix", + "useOriginalTetrahedronMethod", + "forceEnergyRange", + "bandwiseSpilling", + "kpointwiseSpilling", + "LSODOS", + "autoRotate", + "doNotOrthogonalizeBasis", + ) + + # These keywords need additional string suffixes. + # They could be used multiple times within one lobsterin. + _LIST_KEYWORDS: tuple[str, ...] = ( + "basisfunctions", + "cohpbetween", + "createFatband", + "customSTOforAtom", + "cobiBetween", + "molecule", + "printLmosOnAtomswriteAtomicDensities", + ) + + # Generate {lowered: original} mappings + FLOAT_KEYWORDS: ClassVar[dict[str, str]] = {key.lower(): key for key in _FLOAT_KEYWORDS} + STRING_KEYWORDS: ClassVar[dict[str, str]] = {key.lower(): key for key in _STRING_KEYWORDS} + BOOLEAN_KEYWORDS: ClassVar[dict[str, str]] = {key.lower(): key for key in _BOOLEAN_KEYWORDS} + LIST_KEYWORDS: ClassVar[dict[str, str]] = {key.lower(): key for key in _LIST_KEYWORDS} + + # All known keywords + AVAILABLE_KEYWORDS: ClassVar[dict[str, str]] = { + **FLOAT_KEYWORDS, + **STRING_KEYWORDS, + **BOOLEAN_KEYWORDS, + **LIST_KEYWORDS, + } + + def __init__(self, settingsdict: dict) -> None: + """ + Args: + settingsdict: dict to initialize Lobsterin. + """ + super().__init__() + + # Check for duplicates from case sensitivity + keys = tuple(map(str.lower, settingsdict.keys())) + if len(keys) != len(set(keys)): + raise KeyError("There are duplicates for the keywords!") + + self.update(settingsdict) + + def __setitem__(self, key: str, val: Any) -> None: + """ + Necessary due to the missing case sensitivity of lobsterin + keywords. Also clean the keys and values by stripping white spaces. + + Raises: + KeyError: if keyword is not available. + """ + key = key.strip().lower() + + if key not in type(self).AVAILABLE_KEYWORDS: + raise KeyError(f"Key {key} is currently not available") + + super().__setitem__(key, val.strip() if isinstance(val, str) else val) + + def __getitem__(self, key: str) -> Any: + """To avoid cases sensitivity problems.""" + try: + return super().__getitem__(key.strip().lower()) + + except KeyError as exc: + raise KeyError(f"{key=} is not available") from exc + + def __contains__(self, key: str) -> bool: + """To avoid cases sensitivity problems.""" + return super().__contains__(key.lower().strip()) + + def __delitem__(self, key: str) -> None: + """To avoid cases sensitivity problems.""" + super().__delitem__(key.lower().strip()) + + def diff(self, other: Self) -> dict[str, dict[str, Any]]: + """Compare two Lobsterin and find which parameters are the same. + Similar to the diff method of Incar. + + Args: + other (Lobsterin): Lobsterin object to compare to. + + Returns: + dict: {"Same": same_params, "Different": diff_params} + """ + same_params = {} + diff_params = {} + + # Check self + for k1, v1 in self.items(): + if k1 not in other: + diff_params[k1] = {"lobsterin1": v1, "lobsterin2": None} + + # String keywords + elif isinstance(v1, str): + if v1 != other[k1]: + diff_params[k1] = {"lobsterin1": v1, "lobsterin2": other[k1]} + else: + same_params[k1] = v1 + + # List keywords + elif isinstance(v1, list): + new_set1 = {value.strip().lower() for value in v1} + new_set2 = {value.strip().lower() for value in other[k1]} + if new_set1 != new_set2: + diff_params[k1] = {"lobsterin1": v1, "lobsterin2": other[k1]} + + # Float/boolean keywords + elif v1 != other[k1]: + diff_params[k1] = {"lobsterin1": v1, "lobsterin2": other[k1]} + else: + same_params[k1] = v1 + + # Check other + for k2, v2 in other.items(): + if k2 not in self and k2 not in diff_params: + diff_params[k2] = {"lobsterin1": None, "lobsterin2": v2} + + return {"Same": same_params, "Different": diff_params} + + def write_lobsterin( + self, + path: PathLike = "lobsterin", + overwritedict: dict | None = None, + ) -> None: + """Write a lobsterin file, and recover keys to Camel case. + + Args: + path (str): filename of the output lobsterin file + overwritedict (dict): dict that can be used to update lobsterin, e.g. {"skipdos": True} + """ + # Update previous entries + if overwritedict is not None: + self.update(overwritedict) + + with open(path, mode="w", encoding="utf-8") as file: + for key in self: + if key in type(self).FLOAT_KEYWORDS or key in type(self).STRING_KEYWORDS: + file.write(f"{type(self).AVAILABLE_KEYWORDS[key]} {self.get(key)}\n") + + elif key in type(self).BOOLEAN_KEYWORDS: + file.write(f"{type(self).BOOLEAN_KEYWORDS[key]}\n") + + elif key in type(self).LIST_KEYWORDS: + file.writelines(f"{type(self).LIST_KEYWORDS[key]} {value}\n" for value in self.get(key)) + + def as_dict(self) -> dict: + """MSONable dict.""" + dct = dict(self) + dct["@module"] = type(self).__module__ + dct["@class"] = type(self).__name__ + return dct + + @classmethod + def from_dict(cls, dct: dict) -> Self: + """ + Args: + dct (dict): Dict representation. + + Returns: + Lobsterin + """ + return cls({key: val for key, val in dct.items() if key not in {"@module", "@class"}}) + + def _get_nbands(self, structure: Structure) -> int: + """Get number of bands.""" + if self.get("basisfunctions") is None: + raise ValueError("No basis functions are provided. The program cannot calculate nbands.") + + basis_functions: list[str] = [] + for string_basis in self["basisfunctions"]: + string_basis_raw = string_basis.strip().split(" ") + while "" in string_basis_raw: + string_basis_raw.remove("") + for _idx in range(int(structure.composition.element_composition[string_basis_raw[0]])): + basis_functions.extend(string_basis_raw[1:]) + + num_basis_functions = 0 + for basis in basis_functions: + if "s" in basis: + num_basis_functions += 1 + elif "p" in basis: + num_basis_functions += 3 + elif "d" in basis: + num_basis_functions += 5 + elif "f" in basis: + num_basis_functions += 7 + + return int(num_basis_functions) + + def write_INCAR( + self, + incar_input: PathLike = "INCAR", + incar_output: PathLike = "INCAR.lobster", + poscar_input: PathLike = "POSCAR", + isym: Literal[-1, 0] = 0, + further_settings: dict | None = None, + ) -> None: + """Write INCAR file. Will only make the run static, insert NBANDS, + set ISYM=0, LWAVE=True and you have to check for the rest. + + Args: + incar_input (PathLike): path to input INCAR + incar_output (PathLike): path to output INCAR + poscar_input (PathLike): path to input POSCAR + isym (-1 | 0): ISYM value. + further_settings (dict): A dict can be used to include further settings, e.g. {"ISMEAR":-5} + """ + # Read INCAR from file, which will be modified + incar = Incar.from_file(incar_input) + warnings.warn( + "Please check your incar_input before using it. This method only changes three settings!", + stacklevel=2, + ) + if isym in {-1, 0}: + incar["ISYM"] = isym + else: + raise ValueError(f"Got {isym=}, must be -1 or 0") + + incar["NSW"] = 0 + incar["LWAVE"] = True + + # Get NBANDS from _get_nbands (use basis set that is inserted) + incar["NBANDS"] = self._get_nbands(Structure.from_file(poscar_input)) + if further_settings is not None: + for key, item in further_settings.items(): + incar[key] = item + incar.write_file(incar_output) + + @staticmethod + def get_basis( + structure: Structure | IStructure, + potcar_symbols: list[str], + address_basis_file: PathLike | None = None, + ) -> list[str]: + """Get the basis functions from given potcar_symbols, e.g., ["Fe_pv", "Si"]. + + Args: + structure (Structure): Structure object + potcar_symbols: list of potcar symbols + address_basis_file (PathLike): path to the basis file + + Returns: + basis + """ + if address_basis_file is None: + address_basis_file = f"{MODULE_DIR}/lobster_basis/BASIS_PBE_54_standard.yaml" + + atom_types_potcar = [name.split("_")[0] for name in potcar_symbols] + + if set(structure.symbol_set) != set(atom_types_potcar): + raise ValueError("Your POSCAR does not correspond to your POTCAR!") + + basis = loadfn(address_basis_file)["BASIS"] + + basis_functions = [] + list_forin = [] + for idx, name in enumerate(potcar_symbols): + if name not in basis: + raise ValueError( + f"Missing basis information for POTCAR symbol: {name}. Please provide the basis manually." + ) + basis_functions.append(basis[name].split()) + list_forin.append(f"{atom_types_potcar[idx]} {basis[name]}") + return list_forin + + @staticmethod + def get_all_possible_basis_functions( + structure: Structure | IStructure, + potcar_symbols: list[str], + address_basis_file_min: PathLike | None = None, + address_basis_file_max: PathLike | None = None, + ) -> list[dict]: + """ + Args: + structure: Structure object + potcar_symbols: list of the potcar symbols + address_basis_file_min: path to file with the minimum required basis by the POTCAR + address_basis_file_max: path to file with the largest possible basis of the POTCAR. + + Returns: + list[dict]: Can be used to create new Lobsterin objects in + standard_calculations_from_vasp_files as dict_for_basis + """ + max_basis = Lobsterin.get_basis( + structure=structure, + potcar_symbols=potcar_symbols, + address_basis_file=address_basis_file_max or f"{MODULE_DIR}/lobster_basis/BASIS_PBE_54_max.yaml", + ) + min_basis = Lobsterin.get_basis( + structure=structure, + potcar_symbols=potcar_symbols, + address_basis_file=address_basis_file_min or f"{MODULE_DIR}/lobster_basis/BASIS_PBE_54_min.yaml", + ) + all_basis = get_all_possible_basis_combinations(min_basis=min_basis, max_basis=max_basis) + list_basis_dict = [] + for basis in all_basis: + basis_dict = {} + + for elba in basis: + basplit = elba.split() + basis_dict[basplit[0]] = " ".join(basplit[1:]) + list_basis_dict.append(basis_dict) + return list_basis_dict + + @staticmethod + def write_POSCAR_with_standard_primitive( + POSCAR_input: PathLike = "POSCAR", + POSCAR_output: PathLike = "POSCAR.lobster", + symprec: float = 0.01, + ) -> None: + """Write a POSCAR with the standard primitive cell. + This is needed to arrive at the correct kpath. + + Args: + POSCAR_input (PathLike): Input POSCAR file + POSCAR_output (PathLike): Output POSCAR file + symprec (float): precision to find symmetry + """ + structure = Structure.from_file(POSCAR_input) + kpath = HighSymmKpath(structure, symprec=symprec) + new_structure = kpath.prim + new_structure.to(fmt="POSCAR", filename=POSCAR_output) + + @staticmethod + def write_KPOINTS( + POSCAR_input: PathLike = "POSCAR", + KPOINTS_output: PathLike = "KPOINTS.lobster", + reciprocal_density: int = 100, + isym: Literal[-1, 0] = 0, + from_grid: bool = False, + input_grid: tuple[int, int, int] = (5, 5, 5), + line_mode: bool = True, + kpoints_line_density: int = 20, + symprec: float = 0.01, + ) -> None: + """Write a gamma-centered KPOINTS file for LOBSTER. + + Args: + POSCAR_input (PathLike): path to POSCAR + KPOINTS_output (PathLike): path to output KPOINTS + reciprocal_density (int): Grid density + isym (-1 | 0): ISYM value. + from_grid (bool): If True KPOINTS will be generated with the help of a grid given in input_grid. + Otherwise, they will be generated from the reciprocal_density + input_grid (tuple): grid to generate the KPOINTS file + line_mode (bool): If True, band structure will be generated + kpoints_line_density (int): density of the lines in the band structure + symprec (float): precision to determine symmetry + """ + structure = Structure.from_file(POSCAR_input) + if not from_grid: + kpoint_grid = Kpoints.automatic_density_by_vol(structure, reciprocal_density).kpts + mesh = kpoint_grid[0] + else: + mesh = input_grid + + # The following code is taken from SpacegroupAnalyzer + # We need to switch off symmetry here + matrix = structure.lattice.matrix + positions = structure.frac_coords + unique_species: list[Composition] = [] + zs = [] + magmoms = [] + + for species, group in itertools.groupby(structure, key=lambda s: s.species): + if species in unique_species: + ind = unique_species.index(species) + zs.extend([ind + 1] * len(tuple(group))) + else: + unique_species.append(species) + zs.extend([len(unique_species)] * len(tuple(group))) + + for site in structure: + if hasattr(site, "magmom"): + magmoms.append(site.magmom) + elif site.is_ordered and hasattr(site.specie, "spin"): + magmoms.append(site.specie.spin) + else: + magmoms.append(0) + + # For now, we are setting MAGMOM to zero. (Taken from INCAR class) + cell = matrix, positions, zs, magmoms + # TODO: what about this shift? + mapping, grid = spglib.get_ir_reciprocal_mesh(mesh, cell, is_shift=[0, 0, 0]) # type:ignore[arg-type] + + # Get the KPOINTS for the grid + if isym == -1: + kpts = [] + weights = [] + all_labels = [] + for gp in grid: + kpts.append(gp.astype(float) / mesh) + weights.append(float(1)) + all_labels.append("") + + # Time reversal symmetry: k and -k are equivalent + elif isym == 0: + kpts = [] + weights = [] + all_labels = [] + newlist = [list(gp) for gp in list(grid)] + mapping = [] # type:ignore[assignment] + for gp in newlist: + minus_gp = [-k for k in gp] + if minus_gp in newlist and minus_gp != [0, 0, 0]: + mapping.append(newlist.index(minus_gp)) + else: + mapping.append(newlist.index(gp)) + + for igp, gp in enumerate(newlist): + if mapping[igp] > igp: + kpts.append(np.array(gp).astype(float) / mesh) + weights.append(float(2)) + all_labels.append("") + elif mapping[igp] == igp: + kpts.append(np.array(gp).astype(float) / mesh) + weights.append(float(1)) + all_labels.append("") + + else: + raise ValueError(f"Got {isym=}, must be -1 or 0") + + # Line mode + if line_mode: + kpath = HighSymmKpath(structure, symprec=symprec) + if not np.allclose(kpath.prim.lattice.matrix, structure.lattice.matrix): + raise ValueError( + "You are not using the standard primitive cell. The k-path is not correct. Please generate a " + "standard primitive cell first." + ) + + frac_k_points, labels = kpath.get_kpoints(line_density=kpoints_line_density, coords_are_cartesian=False) + + for k, f in enumerate(frac_k_points): + kpts.append(f) + weights.append(0.0) + all_labels.append(labels[k]) + comment = f"{isym=}, grid: {mesh} plus kpoint path" if line_mode else f"{isym=}, grid: {mesh}" + + kpoints_instance = Kpoints( + comment=comment, + style=Kpoints.supported_modes.Reciprocal, + num_kpts=len(kpts), + kpts=tuple(kpts), + kpts_weights=weights, + labels=all_labels, + ) + + kpoints_instance.write_file(filename=KPOINTS_output) + + @classmethod + def from_file(cls, lobsterin: PathLike) -> Self: + """Create Lobsterin from lobsterin file. + + Args: + lobsterin (PathLike): path to lobsterin. + + Returns: + Lobsterin object + """ + with zopen(lobsterin, mode="rt", encoding="utf-8") as file: + lines: list[str] = file.read().split("\n") # type:ignore[arg-type,assignment] + if not lines: + raise RuntimeError("lobsterin file contains no data.") + + lobsterin_dict: dict[str, Any] = {} + for line in lines: + # Remove comment lines and in-line comments + if line := re.split(r"[!#//]", line)[0].strip(): + # Extract keywords + line_parts = line.replace("\t", " ").strip().split() + if line_parts: + key = line_parts[0].lower() + else: + continue + + # Avoid duplicates for float/string keywords + if (key in cls.FLOAT_KEYWORDS or key in cls.STRING_KEYWORDS) and key in lobsterin_dict: + raise ValueError(f"Same keyword {key} twice!") + + # Parse by keyword type + if key in cls.BOOLEAN_KEYWORDS: + lobsterin_dict[key] = True + + elif key in cls.FLOAT_KEYWORDS: + lobsterin_dict[key] = float(line_parts[1]) + + elif key in cls.STRING_KEYWORDS: + lobsterin_dict[key] = " ".join(line_parts[1:]) + + elif key in cls.LIST_KEYWORDS: + if key in lobsterin_dict: + lobsterin_dict[key].append(" ".join(line_parts[1:])) + else: + lobsterin_dict[key] = [" ".join(line_parts[1:])] + + else: + raise ValueError(f"Invalid {key=}.") + + return cls(lobsterin_dict) + + @staticmethod + def _get_potcar_symbols(POTCAR_input: PathLike) -> list[str]: + """Get the name of the species in the POTCAR. + + Args: + POTCAR_input (PathLike): path to potcar file + + Returns: + list[str]: names of the species + """ + potcar = Potcar.from_file(POTCAR_input) # type:ignore[arg-type] + for pot in potcar: + if pot.potential_type != "PAW": + raise ValueError("Lobster only works with PAW! Use different POTCARs") + + # Warning about a bug in LOBSTER-4.1.0 + with zopen(POTCAR_input, mode="rt", encoding="utf-8") as file: + data = file.read() + + if isinstance(data, bytes): + data = data.decode("utf-8") + + if "SHA256" in data or "COPYR" in data: + warnings.warn( + "These POTCARs are not compatible with " + "Lobster up to version 4.1.0." + "\n The keywords SHA256 and COPYR " + "cannot be handled by Lobster" + " \n and will lead to wrong results.", + stacklevel=2, + ) + + if potcar.functional != "PBE": + raise RuntimeError("We only have BASIS options for PBE so far") + + return [name["symbol"] for name in potcar.spec] + + @classmethod + def standard_calculations_from_vasp_files( + cls, + POSCAR_input: PathLike = "POSCAR", + INCAR_input: PathLike = "INCAR", + POTCAR_input: PathLike | None = None, + Vasprun_output: PathLike = "vasprun.xml", + dict_for_basis: dict | None = None, + option: str = "standard", + ) -> Self: + """Generate lobsterin with standard settings. + + Args: + POSCAR_input (PathLike): path to POSCAR + INCAR_input (PathLike): path to INCAR + POTCAR_input (PathLike): path to POTCAR + Vasprun_output (PathLike): path to vasprun.xml + dict_for_basis (dict): can be provided: it should look the following: + dict_for_basis={"Fe":'3p 3d 4s 4f', "C": '2s 2p'} and will overwrite all settings from POTCAR_input + option (str): 'standard' will start a normal LOBSTER run where COHPs, COOPs, DOS, CHARGE etc. will be + calculated + 'standard_with_energy_range_from_vasprun' will start a normal LOBSTER run for entire energy range + of VASP static run. vasprun.xml file needs to be in current directory. + 'standard_from_projection' will start a normal LOBSTER run from a projection + 'standard_with_fatband' will do a fatband calculation, run over all orbitals + 'onlyprojection' will only do a projection + 'onlydos' will only calculate a projected dos + 'onlycohp' will only calculate cohp + 'onlycoop' will only calculate coop + 'onlycohpcoop' will only calculate cohp and coop + + Returns: + Lobsterin with standard settings + """ + warnings.warn( + "Always check and test the provided basis functions. The spilling of your Lobster calculation might help", + stacklevel=2, + ) + + if option not in { + "standard", + "standard_from_projection", + "standard_with_fatband", + "standard_with_energy_range_from_vasprun", + "onlyprojection", + "onlydos", + "onlycohp", + "onlycoop", + "onlycobi", + "onlycohpcoop", + "onlycohpcoopcobi", + "onlymadelung", + }: + raise ValueError("The option is not valid!") + + lobsterin_dict: dict[str, Any] = { + # This basis set covers elements up to Lr (Z = 103) + "basisSet": "pbeVaspFit2015", + # Energies around e-fermi + "COHPstartEnergy": -35.0, + "COHPendEnergy": 5.0, + } + + if option in { + "standard", + "standard_with_energy_range_from_vasprun", + "onlycohp", + "onlycoop", + "onlycobi", + "onlycohpcoop", + "onlycohpcoopcobi", + "standard_with_fatband", + }: + # Every interaction with a distance of 6.0 Ã… is checked + lobsterin_dict["cohpGenerator"] = "from 0.1 to 6.0 orbitalwise" + # Save the projection + lobsterin_dict["saveProjectionToFile"] = True + + if option == "standard_from_projection": + lobsterin_dict["cohpGenerator"] = "from 0.1 to 6.0 orbitalwise" + lobsterin_dict["loadProjectionFromFile"] = True + + elif option == "standard_with_energy_range_from_vasprun": + vasp_run = Vasprun(Vasprun_output) + lobsterin_dict["COHPstartEnergy"] = round( + min(vasp_run.complete_dos.energies - vasp_run.complete_dos.efermi), 4 + ) + lobsterin_dict["COHPendEnergy"] = round( + max(vasp_run.complete_dos.energies - vasp_run.complete_dos.efermi), 4 + ) + lobsterin_dict["COHPSteps"] = len(vasp_run.complete_dos.energies) + + # TODO: add COBI here! might be relevant LOBSTER version + elif option == "onlycohp": + lobsterin_dict["skipdos"] = True + lobsterin_dict["skipcoop"] = True + lobsterin_dict["skipPopulationAnalysis"] = True + lobsterin_dict["skipGrossPopulation"] = True + # LOBSTER-4.1.0 + lobsterin_dict["skipcobi"] = True + lobsterin_dict["skipMadelungEnergy"] = True + + elif option == "onlycoop": + lobsterin_dict["skipdos"] = True + lobsterin_dict["skipcohp"] = True + lobsterin_dict["skipPopulationAnalysis"] = True + lobsterin_dict["skipGrossPopulation"] = True + # LOBSTER-4.1.0 + lobsterin_dict["skipcobi"] = True + lobsterin_dict["skipMadelungEnergy"] = True + + elif option == "onlycohpcoop": + lobsterin_dict["skipdos"] = True + lobsterin_dict["skipPopulationAnalysis"] = True + lobsterin_dict["skipGrossPopulation"] = True + # LOBSTER-4.1.0 + lobsterin_dict["skipcobi"] = True + lobsterin_dict["skipMadelungEnergy"] = True + + elif option == "onlycohpcoopcobi": + lobsterin_dict["skipdos"] = True + lobsterin_dict["skipPopulationAnalysis"] = True + lobsterin_dict["skipGrossPopulation"] = True + lobsterin_dict["skipMadelungEnergy"] = True + + elif option == "onlydos": + lobsterin_dict["skipcohp"] = True + lobsterin_dict["skipcoop"] = True + lobsterin_dict["skipPopulationAnalysis"] = True + lobsterin_dict["skipGrossPopulation"] = True + # LOBSTER-4.1.0 + lobsterin_dict["skipcobi"] = True + lobsterin_dict["skipMadelungEnergy"] = True + + elif option == "onlyprojection": + lobsterin_dict["skipdos"] = True + lobsterin_dict["skipcohp"] = True + lobsterin_dict["skipcoop"] = True + lobsterin_dict["skipPopulationAnalysis"] = True + lobsterin_dict["skipGrossPopulation"] = True + lobsterin_dict["saveProjectionToFile"] = True + # LOBSTER-4.1.0 + lobsterin_dict["skipcobi"] = True + lobsterin_dict["skipMadelungEnergy"] = True + + elif option == "onlycobi": + lobsterin_dict["skipdos"] = True + lobsterin_dict["skipcohp"] = True + lobsterin_dict["skipPopulationAnalysis"] = True + lobsterin_dict["skipGrossPopulation"] = True + # LOBSTER-4.1.0 + lobsterin_dict["skipcobi"] = True + lobsterin_dict["skipMadelungEnergy"] = True + + elif option == "onlymadelung": + lobsterin_dict["skipdos"] = True + lobsterin_dict["skipcohp"] = True + lobsterin_dict["skipcoop"] = True + lobsterin_dict["skipPopulationAnalysis"] = True + lobsterin_dict["skipGrossPopulation"] = True + lobsterin_dict["saveProjectionToFile"] = True + # LOBSTER-4.1.0 + lobsterin_dict["skipcobi"] = True + + incar = Incar.from_file(INCAR_input) + + if incar["ISMEAR"] == 0: + lobsterin_dict["gaussianSmearingWidth"] = incar["SIGMA"] + + if incar["ISMEAR"] != 0 and option == "standard_with_fatband": + raise ValueError("ISMEAR has to be 0 for a fatband calculation with Lobster") + + if dict_for_basis is not None: + # dict_for_basis = {"Fe":"3p 3d 4s 4f", "C": "2s 2p"} + # Will just insert this basis and not check with poscar + basis = [f"{key} {value}" for key, value in dict_for_basis.items()] + elif POTCAR_input is not None: + # Get basis functions from POTCAR + potcar_names = cls._get_potcar_symbols(POTCAR_input=POTCAR_input) + + basis = cls.get_basis(structure=Structure.from_file(POSCAR_input), potcar_symbols=potcar_names) + else: + raise ValueError("basis cannot be generated") + + lobsterin_dict["basisfunctions"] = basis + + if option == "standard_with_fatband": + lobsterin_dict["createFatband"] = basis + + return cls(lobsterin_dict) + + +def get_all_possible_basis_combinations(min_basis: list, max_basis: list) -> list[list[str]]: + """Get all possible basis combinations. + + Args: + min_basis: list of basis entries: e.g., ["Si 3p 3s"] + max_basis: list of basis entries: e.g., ["Si 3p 3s"]. + + Returns: + list[list[str]]: all possible combinations of basis functions, e.g. [["Si 3p 3s"]] + """ + max_basis_lists = [x.split() for x in max_basis] + min_basis_lists = [x.split() for x in min_basis] + + # Get all possible basis functions + basis_dict: dict[str, dict] = {} + for iel, el in enumerate(max_basis_lists): + basis_dict[el[0]] = {"fixed": [], "variable": [], "combinations": []} + for basis in el[1:]: + if basis in min_basis_lists[iel]: + basis_dict[el[0]]["fixed"].append(basis) + if basis not in min_basis_lists[iel]: + basis_dict[el[0]]["variable"].append(basis) + for L in range(len(basis_dict[el[0]]["variable"]) + 1): + for subset in itertools.combinations(basis_dict[el[0]]["variable"], L): + basis_dict[el[0]]["combinations"].append(" ".join([el[0]] + basis_dict[el[0]]["fixed"] + list(subset))) + + list_basis = [item["combinations"] for item in basis_dict.values()] + + # Get all combinations + start_basis = list_basis[0] + if len(list_basis) > 1: + for el in list_basis[1:]: + new_start_basis = [] + for elbasis in start_basis: + for elbasis2 in el: + if not isinstance(elbasis, list): + new_start_basis.append([elbasis, elbasis2]) + else: + new_start_basis.append([*elbasis.copy(), elbasis2]) + start_basis = new_start_basis + return start_basis + return [[basis] for basis in start_basis] diff --git a/src/pymatgen/io/lobster/lobster_basis/BASIS_PBE_54_max.yaml b/src/pymatgen/io/lobster/lobster_basis/BASIS_PBE_54_max.yaml new file mode 100644 index 00000000000..e4ed957f2a6 --- /dev/null +++ b/src/pymatgen/io/lobster/lobster_basis/BASIS_PBE_54_max.yaml @@ -0,0 +1,189 @@ +BASIS: + Ac: '5f 6d 6p 6s 7s ' + Ag: '4d 5p 5s ' + Ag_pv: '4d 4p 5p 5s ' + Al: '3p 3s ' + Am: '5f 6d 6p 6s 7s ' + Ar: '3p 3s ' + As: '4p 4s ' + As_d: '3d 4p 4s ' + At: '6p 6s ' + Au: '5d 6p 6s ' + B: '2p 2s ' + B_h: '2p 2s ' + B_s: '2p 2s ' + Ba_sv: '5p 5s 6s ' + Be: '2p 2s ' + Be_sv: '1s 2p 2s ' + Bi: '6p 6s ' + Bi_d: '5d 6p 6s ' + Br: '4p 4s ' + C: '2p 2s ' + C_h: '2p 2s ' + C_s: '2p 2s ' + Ca_pv: '3p 4s ' + Ca_sv: '3p 3s 4s ' + Cd: '4d 5p 5s ' + Ce: '4f 5d 5p 5s 6s ' + Ce_3: '5d 5p 5s 6s ' + Ce_h: '4f 5d 5p 5s 6s ' + Cf: '5f 6p 6s 7s ' + Cl: '3p 3s ' + Cl_h: '3p 3s ' + Cm: '5f 6d 6p 6s 7s ' + Co: '3d 4p 4s ' + Co_pv: '3d 3p 4s ' + Co_sv: '3d 3p 3s 4p 4s ' + Cr: '3d 4p 4s ' + Cr_pv: '3d 3p 4s ' + Cr_sv: '3d 3p 3s 4s ' + Cs_sv: '5p 5s 6s ' + Cu: '3d 4p 4s ' + Cu_pv: '3d 3p 4s ' + Dy: '4f 5d 5p 5s 6s ' + Dy_3: '5d 5p 6s ' + Er: '4f 5d 5p 5s 6s ' + Er_2: '5d 5p 6s ' + Er_3: '5d 5p 6s ' + Eu: '4f 5d 5p 5s 6s ' + Eu_2: '5d 5p 6s ' + Eu_3: '5d 5p 6s ' + F: '2p 2s ' + F_h: '2p 2s ' + F_s: '2p 2s ' + Fe: '3d 4p 4s ' + Fe_pv: '3d 3p 4s ' + Fe_sv: '3d 3p 3s 4s ' + Fr_sv: '6p 6s 7s ' + Ga: '4p 4s ' + Ga_d: '3d 4p 4s ' + Ga_h: '3d 4p 4s ' + Gd: '4f 5d 5p 5s 6s ' + Gd_3: '5d 5p 6s ' + Ge: '4p 4s ' + Ge_d: '3d 4p 4s ' + Ge_h: '3d 4p 4s ' + H: '1s ' + H_h: '1s ' + H_s: '1s ' + He: '1s ' + Hf: '5d 6p 6s ' + Hf_pv: '5d 5p 6s ' + Hf_sv: '5d 5p 5s 6s ' + Hg: '5d 6p 6s ' + Ho: '4f 5d 5p 5s 6s ' + Ho_3: '5d 5p 6s ' + I: '5p 5s ' + In: '5p 5s ' + In_d: '4d 5p 5s ' + Ir: '5d 6p 6s ' + K_pv: '3p 4s ' + K_sv: '3p 3s 4s ' + Kr: '4p 4s ' + La: '4f 5d 5p 5s 6s ' + La_s: '4f 5d 5p 6s ' + Li: '2p 2s ' + Li_sv: '1s 2p 2s ' + Lu: '4f 5d 5p 5s 6s ' + Lu_3: '5d 5p 6s ' + Mg: '3p 3s ' + Mg_pv: '2p 3s ' + Mg_sv: '2p 2s 3s ' + Mn: '3d 4p 4s ' + Mn_pv: '3d 3p 4s ' + Mn_sv: '3d 3p 3s 4s ' + Mo: '4d 5p 5s ' + Mo_pv: '4d 4p 5s ' + Mo_sv: '4d 4p 4s 5s ' + N: '2p 2s ' + N_h: '2p 2s ' + N_s: '2p 2s ' + Na: '3p 3s ' + Na_pv: '2p 3s ' + Na_sv: '2p 2s 3s ' + Nb_pv: '4d 4p 5s ' + Nb_sv: '4d 4p 4s 5s ' + Nd: '4f 5d 5p 5s 6s ' + Nd_3: '5d 5p 5s 6s ' + Ne: '2p 2s ' + Ni: '3d 4p 4s ' + Ni_pv: '3d 3p 4s ' + Np: '5f 6d 6p 6s 7s ' + Np_s: '5f 6d 6p 6s 7s ' + O: '2p 2s ' + O_h: '2p 2s ' + O_s: '2p 2s ' + Os: '5d 6p 6s ' + Os_pv: '5d 5p 6s ' + P: '3p 3s ' + P_h: '3p 3s ' + Pa: '5f 6d 6p 6s 7s ' + Pa_s: '5f 6d 6p 7s ' + Pb: '6p 6s ' + Pb_d: '5d 6p 6s ' + Pd: '4d 5p 5s ' + Pd_pv: '4d 4p 5s ' + Pm: '4f 5d 5p 5s 6s ' + Pm_3: '5d 5p 5s 6s ' + Po: '6p 6s ' + Po_d: '5d 6p 6s ' + Pr: '4f 5d 5p 5s 6s ' + Pr_3: '5d 5p 5s 6s ' + Pt: '5d 6p 6s ' + Pt_pv: '5d 5p 6p 6s ' + Pu: '5f 6d 6p 6s 7s ' + Pu_s: '5f 6d 6p 6s 7s ' + Ra_sv: '6p 6s 7s ' + Rb_pv: '4p 5s ' + Rb_sv: '4p 4s 5s ' + Re: '5d 6s ' + Re_pv: '5d 5p 6s ' + Rh: '4d 5p 5s ' + Rh_pv: '4d 4p 5s ' + Rn: '6p 6s ' + Ru: '4d 5p 5s ' + Ru_pv: '4d 4p 5s ' + Ru_sv: '4d 4p 4s 5s ' + S: '3p 3s ' + S_h: '3p 3s ' + Sb: '5p 5s ' + Sc: '3d 4p 4s ' + Sc_sv: '3d 3p 3s 4s ' + Se: '4p 4s ' + Si: '3p 3s ' + Sm: '4f 5d 5p 5s 6s ' + Sm_3: '5d 5p 5s 6s ' + Sn: '5p 5s ' + Sn_d: '4d 5p 5s ' + Sr_sv: '4p 4s 5s ' + Ta: '5d 6p 6s ' + Ta_pv: '5d 5p 6s ' + Tb: '4f 5d 5p 5s 6s ' + Tb_3: '5d 5p 6s ' + Tc: '4d 5p 5s ' + Tc_pv: '4d 4p 5s ' + Tc_sv: '4d 4p 4s 5s ' + Te: '5p 5s ' + Th: '5f 6d 6p 6s 7s ' + Th_s: '5f 6d 6p 7s ' + Ti: '3d 4p 4s ' + Ti_pv: '3d 3p 4s ' + Ti_sv: '3d 3p 3s 4s ' + Tl: '6p 6s ' + Tl_d: '5d 6p 6s ' + Tm: '4f 5d 5p 5s 6s ' + Tm_3: '5d 5p 6s ' + U: '5f 6d 6p 6s 7s ' + U_s: '5f 6d 6p 6s 7s ' + V: '3d 4p 4s ' + V_pv: '3d 3p 4s ' + V_sv: '3d 3p 3s 4s ' + W: '5d 6p 6s ' + W_sv: '5d 5p 5s 6s ' + Xe: '5p 5s ' + Y_sv: '4d 4p 4s 5s ' + Yb: '4f 5d 5p 5s 6s ' + Yb_2: '5d 5p 6s ' + Yb_3: '5d 5p 6s ' + Zn: '3d 4p 4s ' + Zr_sv: '4d 4p 4s 5s ' diff --git a/src/pymatgen/io/lobster/lobster_basis/BASIS_PBE_54_min.yaml b/src/pymatgen/io/lobster/lobster_basis/BASIS_PBE_54_min.yaml new file mode 100644 index 00000000000..99fa68ba995 --- /dev/null +++ b/src/pymatgen/io/lobster/lobster_basis/BASIS_PBE_54_min.yaml @@ -0,0 +1,189 @@ +BASIS: + Ac: '6d 6p 6s 7s ' + Ag: '4d 5s ' + Ag_pv: '4d 4p 5s ' + Al: '3p 3s ' + Am: '5f 6d 6p 6s 7s ' + Ar: '3p 3s ' + As: '4p 4s ' + As_d: '3d 4p 4s ' + At: '6p 6s ' + Au: '5d 6s ' + B: '2p 2s ' + B_h: '2p 2s ' + B_s: '2p 2s ' + Ba_sv: '5p 5s 6s ' + Be: '2s ' + Be_sv: '1s 2s ' + Bi: '6p 6s ' + Bi_d: '5d 6p 6s ' + Br: '4p 4s ' + C: '2p 2s ' + C_h: '2p 2s ' + C_s: '2p 2s ' + Ca_pv: '3p 4s ' + Ca_sv: '3p 3s 4s ' + Cd: '4d 5s ' + Ce: '4f 5d 5p 5s 6s ' + Ce_3: '5d 5p 5s 6s ' + Ce_h: '4f 5d 5p 5s 6s ' + Cf: '5f 6p 6s 7s ' + Cl: '3p 3s ' + Cl_h: '3p 3s ' + Cm: '5f 6d 6p 6s 7s ' + Co: '3d 4s ' + Co_pv: '3d 3p 4s ' + Co_sv: '3d 3p 3s 4s ' + Cr: '3d 4s ' + Cr_pv: '3d 3p 4s ' + Cr_sv: '3d 3p 3s 4s ' + Cs_sv: '5p 5s 6s ' + Cu: '3d 4s ' + Cu_pv: '3d 3p 4s ' + Dy: '4f 5d 5p 5s 6s ' + Dy_3: '5d 5p 6s ' + Er: '4f 5d 5p 5s 6s ' + Er_2: '5p 6s ' + Er_3: '5d 5p 6s ' + Eu: '4f 5p 5s 6s ' + Eu_2: '5p 6s ' + Eu_3: '5d 5p 6s ' + F: '2p 2s ' + F_h: '2p 2s ' + F_s: '2p 2s ' + Fe: '3d 4s ' + Fe_pv: '3d 3p 4s ' + Fe_sv: '3d 3p 3s 4s ' + Fr_sv: '6p 6s 7s ' + Ga: '4p 4s ' + Ga_d: '3d 4p 4s ' + Ga_h: '3d 4p 4s ' + Gd: '4f 5d 5p 5s 6s ' + Gd_3: '5d 5p 6s ' + Ge: '4p 4s ' + Ge_d: '3d 4p 4s ' + Ge_h: '3d 4p 4s ' + H: '1s ' + H_h: '1s ' + H_s: '1s ' + He: '1s ' + Hf: '5d 6s ' + Hf_pv: '5d 5p 6s ' + Hf_sv: '5d 5p 5s ' + Hg: '5d 6s ' + Ho: '4f 5d 5p 5s 6s ' + Ho_3: '5d 5p 6s ' + I: '5p 5s ' + In: '5p 5s ' + In_d: '4d 5p 5s ' + Ir: '5d 6s ' + K_pv: '3p 4s ' + K_sv: '3p 3s 4s ' + Kr: '4p 4s ' + La: '5d 5p 5s 6s ' + La_s: '5d 5p 6s ' + Li: '2s ' + Li_sv: '1s 2s ' + Lu: '4f 5d 5p 5s 6s ' + Lu_3: '5d 5p 6s ' + Mg: '3s ' + Mg_pv: '2p 3s ' + Mg_sv: '2p 2s 3s ' + Mn: '3d 4s ' + Mn_pv: '3d 3p 4s ' + Mn_sv: '3d 3p 3s 4s ' + Mo: '4d 5s ' + Mo_pv: '4d 4p 5s ' + Mo_sv: '4d 4p 4s 5s ' + N: '2p 2s ' + N_h: '2p 2s ' + N_s: '2p 2s ' + Na: '3s ' + Na_pv: '2p 3s ' + Na_sv: '2p 2s 3s ' + Nb_pv: '4d 4p 5s ' + Nb_sv: '4d 4p 4s 5s ' + Nd: '4f 5d 5p 5s 6s ' + Nd_3: '5d 5p 5s 6s ' + Ne: '2p 2s ' + Ni: '3d 4s ' + Ni_pv: '3d 3p 4s ' + Np: '5f 6d 6p 6s 7s ' + Np_s: '5f 6d 6p 6s 7s ' + O: '2p 2s ' + O_h: '2p 2s ' + O_s: '2p 2s ' + Os: '5d 6s ' + Os_pv: '5d 5p 6s ' + P: '3p 3s ' + P_h: '3p 3s ' + Pa: '5f 6d 6p 6s 7s ' + Pa_s: '5f 6d 6p 7s ' + Pb: '6p 6s ' + Pb_d: '5d 6p 6s ' + Pd: '4d 5s ' + Pd_pv: '4d 4p 5s ' + Pm: '4f 5d 5p 5s 6s ' + Pm_3: '5d 5p 5s 6s ' + Po: '6p 6s ' + Po_d: '5d 6p 6s ' + Pr: '4f 5d 5p 5s 6s ' + Pr_3: '5d 5p 5s 6s ' + Pt: '5d 6s ' + Pt_pv: '5d 5p 6s ' + Pu: '5f 6d 6p 6s 7s ' + Pu_s: '5f 6d 6p 6s 7s ' + Ra_sv: '6p 6s 7s ' + Rb_pv: '4p 5s ' + Rb_sv: '4p 4s 5s ' + Re: '5d 6s ' + Re_pv: '5d 5p 6s ' + Rh: '4d 5s ' + Rh_pv: '4d 4p 5s ' + Rn: '6p 6s ' + Ru: '4d 5s ' + Ru_pv: '4d 4p 5s ' + Ru_sv: '4d 4p 4s 5s ' + S: '3p 3s ' + S_h: '3p 3s ' + Sb: '5p 5s ' + Sc: '3d 4s ' + Sc_sv: '3d 3p 3s 4s ' + Se: '4p 4s ' + Si: '3p 3s ' + Sm: '4f 5d 5p 5s 6s ' + Sm_3: '5d 5p 5s 6s ' + Sn: '5p 5s ' + Sn_d: '4d 5p 5s ' + Sr_sv: '4p 4s 5s ' + Ta: '5d 6s ' + Ta_pv: '5d 5p 6s ' + Tb: '4f 5d 5p 5s 6s ' + Tb_3: '5d 5p 6s ' + Tc: '4d 5s ' + Tc_pv: '4d 4p 5s ' + Tc_sv: '4d 4p 4s 5s ' + Te: '5p 5s ' + Th: '5f 6d 6p 6s 7s ' + Th_s: '5f 6d 6p 7s ' + Ti: '3d 4s ' + Ti_pv: '3d 3p 4s ' + Ti_sv: '3d 3p 3s 4s ' + Tl: '6p 6s ' + Tl_d: '5d 6p 6s ' + Tm: '4f 5d 5p 5s 6s ' + Tm_3: '5d 5p 6s ' + U: '5f 6d 6p 6s 7s ' + U_s: '5f 6d 6p 6s 7s ' + V: '3d 4s ' + V_pv: '3d 3p 4s ' + V_sv: '3d 3p 3s 4s ' + W: '5d 6s ' + W_sv: '5d 5p 5s 6s ' + Xe: '5p 5s ' + Y_sv: '4d 4p 4s 5s ' + Yb: '4f 5p 5s 6s ' + Yb_2: '5p 6s ' + Yb_3: '5d 5p 6s ' + Zn: '3d 4s ' + Zr_sv: '4d 4p 4s 5s ' diff --git a/src/pymatgen/io/lobster/lobster_basis/BASIS_PBE_54_standard.yaml b/src/pymatgen/io/lobster/lobster_basis/BASIS_PBE_54_standard.yaml new file mode 100644 index 00000000000..b65b59dfac5 --- /dev/null +++ b/src/pymatgen/io/lobster/lobster_basis/BASIS_PBE_54_standard.yaml @@ -0,0 +1,189 @@ +BASIS: + Ac: '5f 6d 6p 6s 7s ' + Ag: '4d 5p 5s ' + Ag_pv: '4d 4p 5p 5s ' + Al: '3p 3s ' + Am: '5f 6d 6p 6s 7s ' + Ar: '3p 3s ' + As: '4p 4s ' + As_d: '3d 4p 4s ' + At: '6p 6s ' + Au: '5d 6p 6s ' + B: '2p 2s ' + B_h: '2p 2s ' + B_s: '2p 2s ' + Ba_sv: '5p 5s 6s ' + Be: '2p 2s ' + Be_sv: '1s 2p 2s ' + Bi: '6p 6s ' + Bi_d: '5d 6p 6s ' + Br: '4p 4s ' + C: '2p 2s ' + C_h: '2p 2s ' + C_s: '2p 2s ' + Ca_pv: '3p 4s ' + Ca_sv: '3p 3s 4s ' + Cd: '4d 5p 5s ' + Ce: '4f 5d 5p 5s 6s ' + Ce_3: '5d 5p 5s 6s ' + Ce_h: '4f 5d 5p 5s 6s ' + Cf: '5f 6p 6s 7s ' + Cl: '3p 3s ' + Cl_h: '3p 3s ' + Cm: '5f 6d 6p 6s 7s ' + Co: '3d 4p 4s ' + Co_pv: '3d 3p 4s ' + Co_sv: '3d 3p 3s 4p 4s ' + Cr: '3d 4p 4s ' + Cr_pv: '3d 3p 4s ' + Cr_sv: '3d 3p 3s 4s ' + Cs_sv: '5p 5s 6s ' + Cu: '3d 4p 4s ' + Cu_pv: '3d 3p 4s ' + Dy: '4f 5d 5p 5s 6s ' + Dy_3: '5d 5p 6s ' + Er: '4f 5d 5p 5s 6s ' + Er_2: '5d 5p 6s ' + Er_3: '5d 5p 6s ' + Eu: '4f 5d 5p 5s 6s ' + Eu_2: '5d 5p 6s ' + Eu_3: '5d 5p 6s ' + F: '2p 2s ' + F_h: '2p 2s ' + F_s: '2p 2s ' + Fe: '3d 4p 4s ' + Fe_pv: '3d 3p 4s ' + Fe_sv: '3d 3p 3s 4s ' + Fr_sv: '6p 6s 7s ' + Ga: '4p 4s ' + Ga_d: '3d 4p 4s ' + Ga_h: '3d 4p 4s ' + Gd: '4f 5d 5p 5s 6s ' + Gd_3: '5d 5p 6s ' + Ge: '4p 4s ' + Ge_d: '3d 4p 4s ' + Ge_h: '3d 4p 4s ' + H: '1s ' + H_h: '1s ' + H_s: '1s ' + He: '1s ' + Hf: '5d 6p 6s ' + Hf_pv: '5d 5p 6s ' + Hf_sv: '5d 5p 5s 6s ' + Hg: '5d 6p 6s ' + Ho: '4f 5d 5p 5s 6s ' + Ho_3: '5d 5p 6s ' + I: '5p 5s ' + In: '5p 5s ' + In_d: '4d 5p 5s ' + Ir: '5d 6p 6s ' + K_pv: '3p 4s ' + K_sv: '3p 3s 4s ' + Kr: '4p 4s ' + La: '4f 5d 5p 5s 6s ' + La_s: '4f 5d 5p 6s ' + Li: '2p 2s ' + Li_sv: '1s 2s 2p ' + Lu: '4f 5d 5p 5s 6s ' + Lu_3: '5d 5p 6s ' + Mg: '3p 3s ' + Mg_pv: '2p 3s ' + Mg_sv: '2p 2s 3s ' + Mn: '3d 4p 4s ' + Mn_pv: '3d 3p 4s ' + Mn_sv: '3d 3p 3s 4s ' + Mo: '4d 5p 5s ' + Mo_pv: '4d 4p 5s ' + Mo_sv: '4d 4p 4s 5s ' + N: '2p 2s ' + N_h: '2p 2s ' + N_s: '2p 2s ' + Na: '3p 3s ' + Na_pv: '2p 3s ' + Na_sv: '2p 2s 3s ' + Nb_pv: '4d 4p 5s ' + Nb_sv: '4d 4p 4s 5s ' + Nd: '4f 5d 5p 5s 6s ' + Nd_3: '5d 5p 5s 6s ' + Ne: '2p 2s ' + Ni: '3d 4p 4s ' + Ni_pv: '3d 3p 4s ' + Np: '5f 6d 6p 6s 7s ' + Np_s: '5f 6d 6p 6s 7s ' + O: '2p 2s ' + O_h: '2p 2s ' + O_s: '2p 2s ' + Os: '5d 6p 6s ' + Os_pv: '5d 5p 6s ' + P: '3p 3s ' + P_h: '3p 3s ' + Pa: '5f 6d 6p 6s 7s ' + Pa_s: '5f 6d 6p 7s ' + Pb: '6p 6s ' + Pb_d: '5d 6p 6s ' + Pd: '4d 5p 5s ' + Pd_pv: '4d 4p 5s ' + Pm: '4f 5d 5p 5s 6s ' + Pm_3: '5d 5p 5s 6s ' + Po: '6p 6s ' + Po_d: '5d 6p 6s ' + Pr: '4f 5d 5p 5s 6s ' + Pr_3: '5d 5p 5s 6s ' + Pt: '5d 6p 6s ' + Pt_pv: '5d 5p 6p 6s ' + Pu: '5f 6d 6p 6s 7s ' + Pu_s: '5f 6d 6p 6s 7s ' + Ra_sv: '6p 6s 7s ' + Rb_pv: '4p 5s ' + Rb_sv: '4p 4s 5s ' + Re: '5d 6s ' + Re_pv: '5d 5p 6s ' + Rh: '4d 5p 5s ' + Rh_pv: '4d 4p 5s ' + Rn: '6p 6s ' + Ru: '4d 5p 5s ' + Ru_pv: '4d 4p 5s ' + Ru_sv: '4d 4p 4s 5s ' + S: '3p 3s ' + S_h: '3p 3s ' + Sb: '5p 5s ' + Sc: '3d 4p 4s ' + Sc_sv: '3d 3p 3s 4s ' + Se: '4p 4s ' + Si: '3p 3s ' + Sm: '4f 5d 5p 5s 6s ' + Sm_3: '5d 5p 5s 6s ' + Sn: '5p 5s ' + Sn_d: '4d 5p 5s ' + Sr_sv: '4p 4s 5s ' + Ta: '5d 6p 6s ' + Ta_pv: '5d 5p 6s ' + Tb: '4f 5d 5p 5s 6s ' + Tb_3: '5d 5p 6s ' + Tc: '4d 5p 5s ' + Tc_pv: '4d 4p 5s ' + Tc_sv: '4d 4p 4s 5s ' + Te: '5p 5s ' + Th: '5f 6d 6p 6s 7s ' + Th_s: '5f 6d 6p 7s ' + Ti: '3d 4p 4s ' + Ti_pv: '3d 3p 4s ' + Ti_sv: '3d 3p 3s 4s ' + Tl: '6p 6s ' + Tl_d: '5d 6p 6s ' + Tm: '4f 5d 5p 5s 6s ' + Tm_3: '5d 5p 6s ' + U: '5f 6d 6p 6s 7s ' + U_s: '5f 6d 6p 6s 7s ' + V: '3d 4p 4s ' + V_pv: '3d 3p 4s ' + V_sv: '3d 3p 3s 4s ' + W: '5d 6p 6s ' + W_sv: '5d 5p 5s 6s ' + Xe: '5p 5s ' + Y_sv: '4d 4p 4s 5s ' + Yb: '4f 5d 5p 5s 6s ' + Yb_2: '5d 5p 6s ' + Yb_3: '5d 5p 6s ' + Zn: '3d 4p 4s ' + Zr_sv: '4d 4p 4s 5s ' diff --git a/src/pymatgen/io/lobster/lobsterenv.py b/src/pymatgen/io/lobster/lobsterenv.py new file mode 100644 index 00000000000..8662155c527 --- /dev/null +++ b/src/pymatgen/io/lobster/lobsterenv.py @@ -0,0 +1,12 @@ +"""Deprecated class for analyzing NearNeighbors using ICOHPs/ICOOPs/ICOBIs.""" + +from __future__ import annotations + +from monty.dev import deprecated + + +@deprecated( + replacement="`pymatgen.analysis.lobster_env.LobsterNeighbors`", category=DeprecationWarning, deadline=(2026, 3, 31) +) +class LobsterNeighbors: + """Deprecated LobsterNeighbors class for analyzing NearNeighbor interactions using ICOHPs/ICOOPs/ICOBIs.""" diff --git a/src/pymatgen/io/lobster/outputs.py b/src/pymatgen/io/lobster/outputs.py new file mode 100644 index 00000000000..086440e2181 --- /dev/null +++ b/src/pymatgen/io/lobster/outputs.py @@ -0,0 +1,2505 @@ +"""Module for reading Lobster output files. +For more information on LOBSTER see www.cohp.de. + +If you use this module, please cite: +J. George, G. Petretto, A. Naik, M. Esters, A. J. Jackson, R. Nelson, R. Dronskowski, G.-M. Rignanese, G. Hautier, +"Automated Bonding Analysis with Crystal Orbital Hamilton Populations", +ChemPlusChem 2022, e202200123, +DOI: 10.1002/cplu.202200123. +""" + +from __future__ import annotations + +import collections +import fnmatch +import itertools +import os +import re +import warnings +from collections import defaultdict +from typing import TYPE_CHECKING, cast + +import numpy as np +from monty.dev import deprecated +from monty.io import zopen +from monty.json import MSONable + +from pymatgen.core.structure import Structure +from pymatgen.electronic_structure.bandstructure import LobsterBandStructureSymmLine +from pymatgen.electronic_structure.core import Orbital, Spin +from pymatgen.electronic_structure.dos import Dos, LobsterCompleteDos +from pymatgen.io.vasp.inputs import Kpoints +from pymatgen.io.vasp.outputs import Vasprun, VolumetricData +from pymatgen.util.due import Doi, due + +if TYPE_CHECKING: + from typing import Any, ClassVar, Literal + + from numpy.typing import NDArray + + from pymatgen.core.structure import IStructure + from pymatgen.electronic_structure.cohp import IcohpCollection + from pymatgen.util.typing import PathLike + +__author__ = "Janine George, Marco Esters" +__copyright__ = "Copyright 2017, The Materials Project" +__version__ = "0.2" +__maintainer__ = "Janine George " +__email__ = "janinegeorge.ulfen@gmail.com" +__date__ = "Dec 13, 2017" + + +due.cite( + Doi("10.1002/cplu.202200123"), + description="Automated Bonding Analysis with Crystal Orbital Hamilton Populations", +) + + +def _get_lines(filename) -> list[str]: + with zopen(filename, mode="rt", encoding="utf-8") as file: + return cast("list[str]", file.read().splitlines()) + + +class Cohpcar: + """Read COXXCAR.lobster/COXXCAR.LCFO.lobster files generated by LOBSTER. + + Attributes: + cohp_data (dict[str, Dict[str, Any]]): The COHP data of the form: + {bond: {"COHP": {Spin.up: cohps, Spin.down:cohps}, + "ICOHP": {Spin.up: icohps, Spin.down: icohps}, + "length": bond length, + "sites": sites corresponding to the bond} + Also contains an entry for the average, which does not have a "length" key. + efermi (float): The Fermi level in eV. + energies (Sequence[float]): Sequence of energies in eV. Note that LOBSTER + shifts the energies so that the Fermi level is at zero. + is_spin_polarized (bool): True if the calculation is spin polarized. + orb_res_cohp (dict[str, Dict[str, Dict[str, Any]]]): The orbital-resolved COHPs of the form: + orb_res_cohp[label] = {bond_data["orb_label"]: { + "COHP": {Spin.up: cohps, Spin.down:cohps}, + "ICOHP": {Spin.up: icohps, Spin.down: icohps}, + "orbitals": orbitals, + "length": bond lengths, + "sites": sites corresponding to the bond}, + } + """ + + def __init__( + self, + are_coops: bool = False, + are_cobis: bool = False, + are_multi_center_cobis: bool = False, + is_lcfo: bool = False, + filename: PathLike | None = None, + ) -> None: + """ + Args: + are_coops (bool): Whether the file includes COOPs (True) or COHPs (False). + Default is False. + are_cobis (bool): Whether the file is COBIs (True) or COHPs (False). + Default is False. + are_multi_center_cobis (bool): Whether the file include multi-center COBIs (True) + or two-center COBIs (False). Default is False. + is_lcfo (bool): Whether the COXXCAR file is from LCFO analysis. + filename (PathLike): The COHPCAR file. If it is None, the default + file name will be chosen, depending on the value of are_coops. + """ + if ( + (are_coops and are_cobis) + or (are_coops and are_multi_center_cobis) + or (are_cobis and are_multi_center_cobis) + ): + raise ValueError("You cannot have info about COOPs, COBIs and/or multi-center COBIs in the same file.") + + self.are_coops = are_coops + self.are_cobis = are_cobis + self.are_multi_center_cobis = are_multi_center_cobis + self.is_lcfo = is_lcfo + self._filename = filename + + if self._filename is None: + if are_coops: + self._filename = "COOPCAR.lobster" + elif are_cobis or are_multi_center_cobis: + self._filename = "COBICAR.lobster" + else: + self._filename = "COHPCAR.lobster" + + lines: list[str] = _get_lines(self._filename) + + # The parameters line is the second line in a COHPCAR file. + # It contains all parameters that are needed to map the file. + parameters = lines[1].split() + # Subtract 1 to skip the average + num_bonds = int(parameters[0]) if self.are_multi_center_cobis else int(parameters[0]) - 1 + self.efermi = float(parameters[-1]) + self.is_spin_polarized = int(parameters[1]) == 2 + spins = [Spin.up, Spin.down] if int(parameters[1]) == 2 else [Spin.up] + cohp_data: dict[str, dict[str, Any]] = {} + + # The COHP/COBI data start from line num_bonds + 3 + data = np.array([np.array(line.split(), dtype=float) for line in lines[num_bonds + 3 :]]).transpose() + + if not self.are_multi_center_cobis: + cohp_data = { + "average": { + "COHP": {spin: data[1 + 2 * s * (num_bonds + 1)] for s, spin in enumerate(spins)}, + "ICOHP": {spin: data[2 + 2 * s * (num_bonds + 1)] for s, spin in enumerate(spins)}, + } + } + + self.energies = data[0] + + orb_cohp: dict[str, Any] = {} + # Present for LOBSTER versions older than 2.2.0 + older_than_2_2_0: bool = False + + # The label has to be changed: there are more than one COHP for each atom combination + # this is done to make the labeling consistent with ICOHPLIST.lobster + bond_num = 0 + bond_data = {} + label = "" + for bond in range(num_bonds): + if not self.are_multi_center_cobis: + bond_data = self._get_bond_data(lines[3 + bond], is_lcfo=self.is_lcfo) + label = str(bond_num) + orbs = bond_data["orbitals"] + cohp = {spin: data[2 * (bond + s * (num_bonds + 1)) + 3] for s, spin in enumerate(spins)} + icohp = {spin: data[2 * (bond + s * (num_bonds + 1)) + 4] for s, spin in enumerate(spins)} + if orbs is None: + bond_num += 1 + label = str(bond_num) + cohp_data[label] = { + "COHP": cohp, + "ICOHP": icohp, + "length": bond_data["length"], + "sites": bond_data["sites"], + "cells": None, + } + + elif label in orb_cohp: + orb_cohp[label] |= { + bond_data["orb_label"]: { + "COHP": cohp, + "ICOHP": icohp, + "orbitals": orbs, + "length": bond_data["length"], + "sites": bond_data["sites"], + "cells": bond_data["cells"], + } + } + else: + # Present for LOBSTER versions older than 2.2.0 + if bond_num == 0: + older_than_2_2_0 = True + if older_than_2_2_0: + bond_num += 1 + label = str(bond_num) + + orb_cohp[label] = { + bond_data["orb_label"]: { + "COHP": cohp, + "ICOHP": icohp, + "orbitals": orbs, + "length": bond_data["length"], + "sites": bond_data["sites"], + "cells": bond_data["cells"], + } + } + + else: + bond_data = self._get_bond_data( + lines[2 + bond], + is_lcfo=self.is_lcfo, + are_multi_center_cobis=self.are_multi_center_cobis, + ) + + label = str(bond_num) + orbs = bond_data["orbitals"] + + cohp = {spin: data[2 * (bond + s * (num_bonds)) + 1] for s, spin in enumerate(spins)} + icohp = {spin: data[2 * (bond + s * (num_bonds)) + 2] for s, spin in enumerate(spins)} + + if orbs is None: + bond_num += 1 + label = str(bond_num) + cohp_data[label] = { + "COHP": cohp, + "ICOHP": icohp, + "length": bond_data["length"], + "sites": bond_data["sites"], + "cells": bond_data["cells"], + } + + elif label in orb_cohp: + orb_cohp[label] |= { + bond_data["orb_label"]: { + "COHP": cohp, + "ICOHP": icohp, + "orbitals": orbs, + "length": bond_data["length"], + "sites": bond_data["sites"], + } + } + else: + # Present for LOBSTER versions older than 2.2.0 + if bond_num == 0: + older_than_2_2_0 = True + if older_than_2_2_0: + bond_num += 1 + label = str(bond_num) + + orb_cohp[label] = { + bond_data["orb_label"]: { + "COHP": cohp, + "ICOHP": icohp, + "orbitals": orbs, + "length": bond_data["length"], + "sites": bond_data["sites"], + } + } + + # Present for LOBSTER older than 2.2.0 + if older_than_2_2_0: + for bond_str in orb_cohp: + cohp_data[bond_str] = { + "COHP": None, + "ICOHP": None, + "length": bond_data["length"], + "sites": bond_data["sites"], + } + self.orb_res_cohp = orb_cohp or None + self.cohp_data = cohp_data + + @staticmethod + def _get_bond_data(line: str, is_lcfo: bool, are_multi_center_cobis: bool = False) -> dict[str, Any]: + """Extract bond label, site indices, and length from + a LOBSTER header line. The site indices are zero-based, so they + can be easily used with a Structure object. + + Example header line: + No.4:Fe1->Fe9(2.4524893531900283) + Example header line for orbital-resolved COHP: + No.1:Fe1[3p_x]->Fe2[3d_x^2-y^2](2.456180552772262) + + Args: + line: line in the COHPCAR header describing the bond. + is_lcfo: indicates whether the COXXCAR file is from LCFO analysis. + are_multi_center_cobis: indicates multi-center COBIs + + Returns: + Dict with the bond label, the bond length, a tuple of the site + indices, a tuple containing the orbitals (if orbital-resolved), + and a label for the orbitals (if orbital-resolved). + """ + + if not are_multi_center_cobis: + line_new = line.rsplit("(", 1) + length = float(line_new[-1][:-1]) + + sites = line_new[0].replace("->", ":").split(":")[1:3] + site_indices = tuple(int(re.split(r"\D+", site)[1]) - 1 for site in sites) + # TODO: get cells here as well + + if "[" in sites[0] and not is_lcfo: + orbs = [re.findall(r"\[(.*)\]", site)[0] for site in sites] + orb_label, orbitals = get_orb_from_str(orbs) + elif "[" in sites[0] and is_lcfo: + orbs = [re.findall(r"\[(\d+[a-zA-Z]+\d*)", site)[0] for site in sites] + orb_label = "-".join(orbs) + orbitals = orbs + else: + orbitals = None + orb_label = None + + return { + "length": length, + "sites": site_indices, + "cells": None, + "orbitals": orbitals, + "orb_label": orb_label, + } + + line_new = line.rsplit(sep="(", maxsplit=1) + + sites = line_new[0].replace("->", ":").split(":")[1:] + site_indices = tuple(int(re.split(r"\D+", site)[1]) - 1 for site in sites) + cells = [[int(i) for i in re.split(r"\[(.*?)\]", site)[1].split(" ") if i != ""] for site in sites] + + if sites[0].count("[") > 1: + orbs = [re.findall(r"\]\[(.*)\]", site)[0] for site in sites] + orb_label, orbitals = get_orb_from_str(orbs) + else: + orbitals = orb_label = None + + return { + "sites": site_indices, + "cells": cells, + "length": None, + "orbitals": orbitals, + "orb_label": orb_label, + } + + +class Icohplist(MSONable): + """Read ICOXXLIST/ICOXXLIST.LCFO.lobster files generated by LOBSTER. + + Attributes: + are_coops (bool): Whether the file includes COOPs (True) or COHPs (False). + is_lcfo (bool): Whether the ICOXXLIST file is from LCFO analysis. + is_spin_polarized (bool): Whether the calculation is spin polarized. + Icohplist (dict[str, Dict[str, Union[float, int, Dict[Spin, float]]]]): + The listfile data of the form: { + bond: { + "length": Bond length, + "number_of_bonds": Number of bonds, + "icohp": {Spin.up: ICOHP(Ef)_up, Spin.down: ...}, + } + } + IcohpCollection (IcohpCollection): IcohpCollection Object. + """ + + def __init__( + self, + is_lcfo: bool = False, + are_coops: bool = False, + are_cobis: bool = False, + filename: PathLike | None = None, + is_spin_polarized: bool = False, + orbitalwise: bool = False, + icohpcollection: IcohpCollection | None = None, + ) -> None: + """ + Args: + is_lcfo (bool): Whether the ICOHPLIST file is from LCFO analysis. + are_coops (bool): Whether the file includes COOPs (True) or COHPs (False). + Default is False. + are_cobis (bool): Whether the file is COBIs (True) or COHPs (False). + Default is False. + filename (PathLike): The ICOHPLIST file. If it is None, the default + file name will be chosen, depending on the value of are_coops + is_spin_polarized (bool): Whether the calculation is spin polarized. + orbitalwise (bool): Whether the calculation is orbitalwise. + icohpcollection (IcohpCollection): IcohpCollection Object. + + """ + # Avoid circular import + from pymatgen.electronic_structure.cohp import IcohpCollection + + self._filename = filename + self.is_lcfo = is_lcfo + self.is_spin_polarized = is_spin_polarized + self.orbitalwise = orbitalwise + self._icohpcollection = icohpcollection + if are_coops and are_cobis: + raise ValueError("You cannot have info about COOPs and COBIs in the same file.") + + self.are_coops = are_coops + self.are_cobis = are_cobis + if self._filename is None: + if are_coops: + self._filename = "ICOOPLIST.lobster" + elif are_cobis: + self._filename = "ICOBILIST.lobster" + else: + self._filename = "ICOHPLIST.lobster" + + if self._icohpcollection is None: + with zopen(self._filename, mode="rt", encoding="utf-8") as file: + all_lines: list[str] = cast("list[str]", file.read().splitlines()) + + # --- detect header length robustly --- + header_len = 0 + try: + int(all_lines[0].split()[0]) + except ValueError: + header_len += 1 + if header_len < len(all_lines) and "spin" in all_lines[header_len].lower(): + header_len += 1 + lines = all_lines[header_len:] + if not lines: + raise RuntimeError("ICOHPLIST file contains no data.") + # --- version by column count only --- + ncol = len(lines[0].split()) + if ncol == 6: + version = "2.2.1" + warnings.warn( + "Please consider using a newer LOBSTER version. See www.cohp.de.", + stacklevel=2, + ) + elif ncol == 8: + version = "3.1.1" + elif ncol == 9: + version = "5.1.0" + else: + raise ValueError(f"Unsupported LOBSTER version ({ncol} columns).") + + # If the calculation is spin polarized, the line in the middle + # of the file will be another header line. + # TODO: adapt this for orbital-wise stuff + if version in {"3.1.1", "2.2.1"}: + self.is_spin_polarized = "distance" in lines[len(lines) // 2] + else: # if version == "5.1.0": + self.is_spin_polarized = len(lines[0].split()) == 9 + + # Check if is orbital-wise ICOHPLIST + # TODO: include case where there is only one ICOHP + if not self.is_lcfo: # data consists of atomic orbital interactions + self.orbitalwise = len(lines) > 2 and "_" in lines[1].split()[1] + else: # data consists of molecule or fragment orbital interactions + self.orbitalwise = len(lines) > 2 and lines[1].split()[1].count("_") >= 2 + + data_orbitals: list[str] = [] + if self.orbitalwise: + data_without_orbitals = [] + data_orbitals = [] + for line in lines: + if ( + ("_" not in line.split()[1] and version != "5.1.0") + or ("_" not in line.split()[1] and version == "5.1.0") + or ((line.split()[1].count("_") == 1) and version == "5.1.0" and self.is_lcfo) + ): + data_without_orbitals.append(line) + elif line.split()[1].count("_") >= 2 and version == "5.1.0": + data_orbitals.append(line) + else: + data_orbitals.append(line) + + else: + data_without_orbitals = lines + + if "distance" in data_without_orbitals[len(data_without_orbitals) // 2]: + # TODO: adapt this for orbital-wise stuff + n_bonds = len(data_without_orbitals) // 2 + if n_bonds == 0: + raise RuntimeError("ICOHPLIST file contains no data.") + else: + n_bonds = len(data_without_orbitals) + + labels: list[str] = [] + atom1_list: list[str] = [] + atom2_list: list[str] = [] + lens: list[float] = [] + translations: list[tuple[int, int, int]] = [] + nums: list[int] = [] + icohps: list[dict[Spin, float]] = [] + + for bond in range(n_bonds): + line_parts = data_without_orbitals[bond].split() + icohp: dict[Spin, float] = {} + + label = line_parts[0] + atom1 = line_parts[1] + atom2 = line_parts[2] + length = float(line_parts[3]) + + if version == "5.1.0": + num = 1 + translation = ( + int(line_parts[4]), + int(line_parts[5]), + int(line_parts[6]), + ) + icohp[Spin.up] = float(line_parts[7]) + if self.is_spin_polarized: + icohp[Spin.down] = float(line_parts[8]) + elif version == "3.1.1": + num = 1 + translation = ( + int(line_parts[4]), + int(line_parts[5]), + int(line_parts[6]), + ) + icohp[Spin.up] = float(line_parts[7]) + if self.is_spin_polarized: + icohp[Spin.down] = float(data_without_orbitals[bond + n_bonds + 1].split()[7]) + + else: # if version == "2.2.1": + num = int(line_parts[5]) + translation = (0, 0, 0) + icohp[Spin.up] = float(line_parts[4]) + if self.is_spin_polarized: + icohp[Spin.down] = float(data_without_orbitals[bond + n_bonds + 1].split()[4]) + + labels.append(label) + atom1_list.append(atom1) + atom2_list.append(atom2) + lens.append(length) + translations.append(translation) + nums.append(num) + icohps.append(icohp) + + list_orb_icohp: list[dict] | None = None + if self.orbitalwise: + list_orb_icohp = [] + if version != "5.1.0": + n_orbs = len(data_orbitals) // 2 if self.is_spin_polarized else len(data_orbitals) + else: + n_orbs = len(data_orbitals) + + for i_orb in range(n_orbs): + data_orb = data_orbitals[i_orb] + icohp = {} + line_parts = data_orb.split() + label = line_parts[0] + if not self.is_lcfo: # data consists of atomic orbital interactions + orbs = re.findall(r"_(.*?)(?=\s)", data_orb) + orb_label, orbitals = get_orb_from_str(orbs) + icohp[Spin.up] = float(line_parts[7]) + else: # data consists of molecule or fragment orbital interactions + orbs = re.findall(r"_(\d+[a-zA-Z]+\d*)", data_orb) + orb_label = "-".join(orbs) + orbitals = orbs + icohp[Spin.up] = float(line_parts[7]) + + if self.is_spin_polarized and version != "5.1.0": + icohp[Spin.down] = float(data_orbitals[n_orbs + i_orb].split()[7]) + elif self.is_spin_polarized and version == "5.1.0": + icohp[Spin.down] = float(data_orbitals[i_orb].split()[8]) + + if len(list_orb_icohp) < int(label): + list_orb_icohp.append({orb_label: {"icohp": icohp, "orbitals": orbitals}}) + else: + list_orb_icohp[int(label) - 1][orb_label] = { + "icohp": icohp, + "orbitals": orbitals, + } + + # Avoid circular import + from pymatgen.electronic_structure.cohp import IcohpCollection + + self._icohpcollection = IcohpCollection( + are_coops=are_coops, + are_cobis=are_cobis, + list_labels=labels, + list_atom1=atom1_list, + list_atom2=atom2_list, + list_length=lens, + list_translation=translations, + list_num=nums, + list_icohp=icohps, + is_spin_polarized=self.is_spin_polarized, + list_orb_icohp=list_orb_icohp, + ) + + @property + def icohplist(self) -> dict[Any, dict[str, Any]]: + """The ICOHP list compatible with older version of this class.""" + icohp_dict = {} + if self._icohpcollection is None: + raise ValueError(f"{self._icohpcollection=}") + + for key, value in self._icohpcollection._icohplist.items(): + icohp_dict[key] = { + "length": value._length, + "number_of_bonds": value._num, + "icohp": value._icohp, + "translation": value._translation, + "orbitals": value._orbitals, + } + + # for LCFO only files drop the single orbital resolved entry when not in orbitalwise mode + if self.is_lcfo and not self.orbitalwise: + icohp_dict = {k: d for k, d in icohp_dict.items() if d.get("orbitals") is None} + return icohp_dict + + @property + def icohpcollection(self) -> IcohpCollection | None: + """The IcohpCollection object.""" + return self._icohpcollection + + +class NciCobiList: + """Read NcICOBILIST (multi-center ICOBI) files generated by LOBSTER. + + Attributes: + is_spin_polarized (bool): Whether the calculation is spin polarized. + NciCobiList (dict): The listfile data of the form: + { + bond: { + "number_of_atoms": Number of atoms involved in the multi-center interaction, + "ncicobi": {Spin.up: Nc-ICOBI(Ef)_up, Spin.down: ...}, + "interaction_type": Type of the multi-center interaction, + } + } + """ + + def __init__(self, filename: PathLike = "NcICOBILIST.lobster") -> None: + """ + + LOBSTER < 4.1.0: no COBI/ICOBI/NcICOBI + + Args: + filename: Name of the NcICOBILIST file. + """ + # We don't need the header + lines = _get_lines(filename)[1:] + if len(lines) == 0: + raise RuntimeError("NcICOBILIST file contains no data.") + + # If the calculation is spin-polarized, the line in the middle + # of the file will be another header line. + # TODO: adapt this for orbitalwise case + self.is_spin_polarized = "spin" in lines[len(lines) // 2] + + # Check if orbitalwise NcICOBILIST + # include case when there is only one NcICOBI + self.orbital_wise = False # set as default + for entry in lines: # NcICOBIs orbitalwise and non-orbitalwise can be mixed + if len(lines) > 2 and "s]" in str(entry.split()[3:]): + self.orbital_wise = True + warnings.warn( + "This is an orbitalwise NcICOBILIST.lobster file. " + "Currently, the orbitalwise information is not read!", + stacklevel=2, + ) + break # condition has only to be met once + + if self.orbital_wise: + data_without_orbitals = [ + line for line in lines if "_" not in str(line.split()[3:]) and "s]" not in str(line.split()[3:]) + ] + + else: + data_without_orbitals = lines + + if "spin" in data_without_orbitals[len(data_without_orbitals) // 2]: + # TODO: adapt this for orbitalwise case + n_bonds = len(data_without_orbitals) // 2 + if n_bonds == 0: + raise RuntimeError("NcICOBILIST file contains no data.") + else: + n_bonds = len(data_without_orbitals) + + self.list_labels = [] + self.list_n_atoms = [] + self.list_ncicobi = [] + self.list_interaction_type = [] + self.list_num = [] + + for bond in range(n_bonds): + line_parts = data_without_orbitals[bond].split() + ncicobi = {} + + label = line_parts[0] + n_atoms = line_parts[1] + ncicobi[Spin.up] = float(line_parts[2]) + interaction_type = str(line_parts[3:]).replace("'", "").replace(" ", "") + num = 1 + + if self.is_spin_polarized: + ncicobi[Spin.down] = float(data_without_orbitals[bond + n_bonds + 1].split()[2]) + + self.list_labels.append(label) + self.list_n_atoms.append(n_atoms) + self.list_ncicobi.append(ncicobi) + self.list_interaction_type.append(interaction_type) + self.list_num.append(num) + + # TODO: add functions to get orbital resolved NcICOBIs + + @property + def ncicobi_list(self) -> dict[Any, dict[str, Any]]: + """ + Returns: + dict: ncicobilist. + """ + ncicobi_list = {} + for idx in range(len(self.list_labels)): + ncicobi_list[str(idx + 1)] = { + "number_of_atoms": int(self.list_n_atoms[idx]), + "ncicobi": self.list_ncicobi[idx], + "interaction_type": self.list_interaction_type[idx], + } + + return ncicobi_list + + +class Doscar: + """Store LOBSTER's projected DOS and local projected DOS. + The beforehand quantum-chemical calculation was performed with VASP. + + Attributes: + completedos (LobsterCompleteDos): LobsterCompleteDos Object. + pdos (list): List of Dict including NumPy arrays with pdos. Access as + pdos[atomindex]['orbitalstring']['Spin.up/Spin.down']. + tdos (Dos): Dos Object of the total density of states. + energies (NDArray): Numpy array of the energies at which the DOS was calculated + (in eV, relative to Efermi). + tdensities (dict): tdensities[Spin.up]: NumPy array of the total density of states for + the Spin.up contribution at each of the energies. tdensities[Spin.down]: NumPy array + of the total density of states for the Spin.down contribution at each of the energies. + If is_spin_polarized=False, tdensities[Spin.up]: NumPy array of the total density of states. + itdensities (dict): itdensities[Spin.up]: NumPy array of the total density of states for + the Spin.up contribution at each of the energies. itdensities[Spin.down]: NumPy array + of the total density of states for the Spin.down contribution at each of the energies. + If is_spin_polarized=False, itdensities[Spin.up]: NumPy array of the total density of states. + is_spin_polarized (bool): Whether the system is spin polarized. + """ + + def __init__( + self, + doscar: PathLike = "DOSCAR.lobster", + is_lcfo: bool = False, + structure_file: PathLike | None = "POSCAR", + structure: IStructure | Structure | None = None, + ) -> None: + """ + Args: + doscar (PathLike): The DOSCAR file, typically "DOSCAR.lobster". + is_lcfo (bool): Whether the DOSCAR file is from LCFO analysis. + structure_file (PathLike): For VASP, this is typically "POSCAR". + structure (Structure): Instead of a structure file (preferred), + the Structure can be given directly. + """ + self._doscar = doscar + self._is_lcfo = is_lcfo + + self._final_structure = Structure.from_file(structure_file) if structure_file is not None else structure + + self._parse_doscar() + + def _parse_doscar(self): + doscar = self._doscar + + tdensities = {} + itdensities = {} + with zopen(doscar, mode="rt", encoding="utf-8") as file: + file.readline() # Skip the first line + efermi = float([file.readline() for nn in range(4)][3].split()[17]) + dos = [] + orbitals = [] + line = file.readline() # Read the next line containing dos data + while line.strip(): + if line.split(): + ndos = int(line.split()[2]) + orbitals += [line.split(";")[-1].split()] + + line = file.readline().split() + cdos = np.zeros((ndos, len(line))) + cdos[0] = np.array(line) + + for idx_dos in range(1, ndos): + line_parts = file.readline().split() + cdos[idx_dos] = np.array(line_parts) + dos.append(cdos) + + line = file.readline() # Read the next line to continue the loop + + doshere = np.array(dos[0]) + if len(doshere[0, :]) == 5: + self._is_spin_polarized = True + elif len(doshere[0, :]) == 3: + self._is_spin_polarized = False + else: + raise ValueError("There is something wrong with the DOSCAR. Can't extract spin polarization.") + + energies = doshere[:, 0] + if not self._is_spin_polarized: + tdensities[Spin.up] = doshere[:, 1] + itdensities[Spin.up] = doshere[:, 2] + pdoss = [] + spin = Spin.up + for atom in range(len(dos) - 1): + pdos = defaultdict(dict) + data = dos[atom + 1] + _, ncol = data.shape + + for orb_num, j in enumerate(range(1, ncol)): + orb = orbitals[atom + 1][orb_num] + pdos[orb][spin] = data[:, j] + pdoss.append(pdos) + else: + tdensities[Spin.up] = doshere[:, 1] + tdensities[Spin.down] = doshere[:, 2] + itdensities[Spin.up] = doshere[:, 3] + itdensities[Spin.down] = doshere[:, 4] + pdoss = [] + for atom in range(len(dos) - 1): + pdos = defaultdict(dict) + data = dos[atom + 1] + _, ncol = data.shape + orb_num = 0 + for j in range(1, ncol): + spin = Spin.down if j % 2 == 0 else Spin.up + orb = orbitals[atom + 1][orb_num] + pdos[orb][spin] = data[:, j] + if j % 2 == 0: + orb_num += 1 + pdoss.append(pdos) + + self._efermi = efermi + self._pdos = pdoss + self._tdos = Dos(efermi, energies, tdensities) + self._energies = energies + self._tdensities = tdensities + self._itdensities = itdensities + final_struct = self._final_structure + + # for DOCAR.LCFO.lobster, pdos is different than for non-LCFO DOSCAR so we need to handle it differently + # for now we just set pdos_dict to be empty if LCFO is in the filename + # Todo: handle LCFO pdos properly in future when we have complete set of orbitals + if not self._is_lcfo: + pdoss_dict = {final_struct[i]: pdos for i, pdos in enumerate(self._pdos)} + else: + pdoss_dict = {final_struct[i]: {} for i, _ in enumerate(self._pdos)} + + self._completedos = LobsterCompleteDos(final_struct, self._tdos, pdoss_dict) + + @property + def completedos(self) -> LobsterCompleteDos: + """LobsterCompleteDos.""" + return self._completedos + + @property + def pdos(self) -> list[dict]: + """Projected DOS (PDOS).""" + return self._pdos + + @property + def tdos(self) -> Dos: + """Total DOS (TDOS).""" + return self._tdos + + @property + def energies(self) -> NDArray: + """Energies.""" + return self._energies + + @property + def tdensities(self) -> dict[Spin, NDArray]: + """Total DOS as a np.array.""" + return self._tdensities + + @property + def itdensities(self) -> dict[Spin, NDArray]: + """Integrated total DOS as a np.array.""" + return self._itdensities + + @property + def is_spin_polarized(self) -> bool: + """Whether run is spin polarized.""" + return self._is_spin_polarized + + +class Charge(MSONable): + """Read CHARGE.lobster/ CHARGE.LCFO.lobster files generated by LOBSTER. + + Attributes: + atomlist (list[str]): List of atoms in CHARGE.lobster. + is_lcfo (bool): Whether the CHARGE file is from LCFO analysis. Default is False. + types (list[str]): List of types of atoms in CHARGE.lobster. + mulliken (list[float]): List of Mulliken charges of atoms in CHARGE.lobster. + loewdin (list[float]): List of Loewdin charges of atoms in CHARGE.Loewdin. + num_atoms (int): Number of atoms in CHARGE.lobster. + """ + + def __init__( + self, + filename: PathLike = "CHARGE.lobster", + is_lcfo: bool = False, + num_atoms: int | None = None, + atomlist: list[str] | None = None, + types: list[str] | None = None, + mulliken: list[float] | None = None, + loewdin: list[float] | None = None, + ) -> None: + """ + Args: + filename (PathLike): The CHARGE file, typically "CHARGE.lobster". + is_lcfo (bool): Whether the CHARGE file is from LCFO analysis. Default is False. + num_atoms (int): Number of atoms in the structure. + atomlist (list[str]): Atoms in the structure. + types (list[str]): Unique species in the structure. + mulliken (list[float]): Mulliken charges. + loewdin (list[float]): Loewdin charges. + """ + self._filename = filename + self.is_lcfo = is_lcfo + self.num_atoms = num_atoms + self.types = [] if types is None else types + self.atomlist = [] if atomlist is None else atomlist + self.mulliken = [] if mulliken is None else mulliken + self.loewdin = [] if loewdin is None else loewdin + + if self.num_atoms is None: + lines = _get_lines(filename)[3:-2] + if len(lines) == 0: + raise RuntimeError("CHARGES file contains no data.") + + self.num_atoms = len(lines) + for atom_idx in range(self.num_atoms): + line_parts = lines[atom_idx].split() + self.atomlist.append(line_parts[1] + line_parts[0]) + self.types.append(line_parts[1]) + if not self.is_lcfo: + self.mulliken.append(float(line_parts[2])) + self.loewdin.append(float(line_parts[3])) + else: + self.loewdin.append(float(line_parts[2])) + + def get_structure_with_charges(self, structure_filename: PathLike) -> Structure: + """Get a Structure with Mulliken and Loewdin charges as site properties + + Args: + structure_filename (PathLike): The POSCAR file. + + Returns: + Structure Object with Mulliken and Loewdin charges as site properties. + """ + struct = Structure.from_file(structure_filename) + if not self.is_lcfo: + mulliken = self.mulliken + loewdin = self.loewdin + site_properties = {"Mulliken Charges": mulliken, "Loewdin Charges": loewdin} + return struct.copy(site_properties=site_properties) + raise ValueError( + "CHARGE.LCFO.lobster charges are not sorted site wise. Thus, the site properties cannot be added.", + ) + + @property + @deprecated(message="Use `mulliken` instead.", category=DeprecationWarning) + def Mulliken(self) -> list[float]: + return self.mulliken + + @property + @deprecated(message="Use `loewdin` instead.", category=DeprecationWarning) + def Loewdin(self) -> list[float]: + return self.loewdin + + +class Lobsterout(MSONable): + """Read the lobsterout and evaluate the spilling, save the basis, save warnings, save info. + + Attributes: + basis_functions (list[str]): Basis functions that were used in lobster run as strings. + basis_type (list[str]): Basis types that were used in lobster run as strings. + charge_spilling (list[float]): Charge spilling (first entry: result for spin 1, + second entry: result for spin 2 or not present). + dft_program (str): The DFT program used for the calculation of the wave function. + elements (list[str]): Elements that were present in LOBSTER calculation. + has_charge (bool): Whether CHARGE.lobster is present. + has_cohpcar (bool): Whether COHPCAR.lobster and ICOHPLIST.lobster are present. + has_madelung (bool): Whether SitePotentials.lobster and MadelungEnergies.lobster are present. + has_coopcar (bool): Whether COOPCAR.lobster and ICOOPLIST.lobster are present. + has_cobicar (bool): Whether COBICAR.lobster and ICOBILIST.lobster are present. + has_doscar (bool): Whether DOSCAR.lobster is present. + has_doscar_lso (bool): Whether DOSCAR.LSO.lobster is present. + has_projection (bool): Whether projectionData.lobster is present. + has_bandoverlaps (bool): Whether bandOverlaps.lobster is present. + has_density_of_energies (bool): Whether DensityOfEnergy.lobster is present. + has_fatbands (bool): Whether fatband calculation was performed. + has_grosspopulation (bool): Whether GROSSPOP.lobster is present. + has_polarization (bool): Whether POLARIZATION.lobster is present. + info_lines (str): Additional information on the run. + info_orthonormalization (str): Information on orthonormalization. + is_restart_from_projection (bool): Whether that calculation was restarted + from an existing projection file. + lobster_version (str): The LOBSTER version. + number_of_spins (int): The number of spins. + number_of_threads (int): How many threads were used. + timing (dict[str, float]): Dict with infos on timing. + total_spilling (list[float]): The total spilling for spin channel 1 (and spin channel 2). + warning_lines (str): String with all warnings. + """ + + # Valid Lobsterout attributes + _ATTRIBUTES: ClassVar[set[str]] = { + "filename", + "is_restart_from_projection", + "lobster_version", + "number_of_threads", + "dft_program", + "number_of_spins", + "charge_spilling", + "total_spilling", + "elements", + "basis_type", + "basis_functions", + "timing", + "warning_lines", + "info_orthonormalization", + "info_lines", + "has_doscar", + "has_doscar_lso", + "has_doscar_lcfo", + "has_cohpcar", + "has_cohpcar_lcfo", + "has_coopcar", + "has_coopcar_lcfo", + "has_cobicar", + "has_cobicar_lcfo", + "has_charge", + "has_madelung", + "has_mofecar", + "has_projection", + "has_bandoverlaps", + "has_fatbands", + "has_grosspopulation", + "has_polarization", + "has_density_of_energies", + } + + # TODO: add tests for skipping COBI and Madelung + # TODO: add tests for including COBI and Madelung + def __init__(self, filename: PathLike | None, **kwargs) -> None: + """ + Args: + filename (PathLike): The lobsterout file. + **kwargs: dict to initialize Lobsterout instance + """ + self.filename = filename + if kwargs: + for attr, val in kwargs.items(): + if attr in self._ATTRIBUTES: + setattr(self, attr, val) + else: + raise ValueError(f"{attr}={val} is not a valid attribute for Lobsterout") + elif filename: + lines = _get_lines(filename) + if len(lines) == 0: + raise RuntimeError("lobsterout does not contain any data") + + # Check if LOBSTER starts from a projection + self.is_restart_from_projection = "loading projection from projectionData.lobster..." in lines + + self.lobster_version = self._get_lobster_version(data=lines) + + self.number_of_threads = self._get_threads(data=lines) + self.dft_program = self._get_dft_program(data=lines) + + self.number_of_spins = self._get_number_of_spins(data=lines) + chargespilling, totalspilling = self._get_spillings(data=lines, number_of_spins=self.number_of_spins) + self.charge_spilling = chargespilling + self.total_spilling = totalspilling + + elements, basistype, basisfunctions = self._get_elements_basistype_basisfunctions(data=lines) + self.elements = elements + self.basis_type = basistype + self.basis_functions = basisfunctions + + wall_time, user_time, sys_time = self._get_timing(data=lines) + self.timing = { + "wall_time": wall_time, + "user_time": user_time, + "sys_time": sys_time, + } + + warninglines = self._get_all_warning_lines(data=lines) + self.warning_lines = warninglines + + orthowarning = self._get_warning_orthonormalization(data=lines) + self.info_orthonormalization = orthowarning + + infos = self._get_all_info_lines(data=lines) + self.info_lines = infos + + self.has_doscar = "writing DOSCAR.lobster..." in lines and "SKIPPING writing DOSCAR.lobster..." not in lines + self.has_doscar_lso = ( + "writing DOSCAR.LSO.lobster..." in lines and "SKIPPING writing DOSCAR.LSO.lobster..." not in lines + ) + + try: + version_number = float(".".join(self.lobster_version.strip("v").split(".")[:2])) + except ValueError: + version_number = 0.0 + + if version_number < 5.1: + self.has_cohpcar = ( + "writing COHPCAR.lobster and ICOHPLIST.lobster..." in lines + and "SKIPPING writing COHPCAR.lobster and ICOHPLIST.lobster..." not in lines + ) + self.has_coopcar = ( + "writing COOPCAR.lobster and ICOOPLIST.lobster..." in lines + and "SKIPPING writing COOPCAR.lobster and ICOOPLIST.lobster..." not in lines + ) + self.has_cobicar = ( + "writing COBICAR.lobster and ICOBILIST.lobster..." in lines + and "SKIPPING writing COBICAR.lobster and ICOBILIST.lobster..." not in lines + ) + else: + self.has_cohpcar = ( + "writing COHPCAR.lobster..." in lines and "SKIPPING writing COHPCAR.lobster..." not in lines + ) + self.has_coopcar = ( + "writing COOPCAR.lobster..." in lines and "SKIPPING writing COOPCAR.lobster..." not in lines + ) + self.has_cobicar = ( + "writing COBICAR.lobster..." in lines + or "Writing COBICAR.lobster, ICOBILIST.lobster and NcICOBILIST.lobster..." in lines + ) and "SKIPPING writing COBICAR.lobster..." not in lines + + self.has_cobicar_lcfo = "writing COBICAR.LCFO.lobster..." in lines + self.has_cohpcar_lcfo = "writing COHPCAR.LCFO.lobster..." in lines + self.has_coopcar_lcfo = "writing COOPCAR.LCFO.lobster..." in lines + self.has_doscar_lcfo = "writing DOSCAR.LCFO.lobster..." in lines + self.has_polarization = "writing polarization to POLARIZATION.lobster..." in lines + self.has_charge = "SKIPPING writing CHARGE.lobster..." not in lines + self.has_projection = "saving projection to projectionData.lobster..." in lines + self.has_bandoverlaps = ( + "WARNING: I dumped the band overlap matrices to the file bandOverlaps.lobster." in lines + ) + self.has_fatbands = self._has_fatband(data=lines) + self.has_grosspopulation = "writing CHARGE.lobster and GROSSPOP.lobster..." in lines + self.has_density_of_energies = "writing DensityOfEnergy.lobster..." in lines + self.has_madelung = ( + "writing SitePotentials.lobster and MadelungEnergies.lobster..." in lines + and "skipping writing SitePotentials.lobster and MadelungEnergies.lobster..." not in lines + ) + self.has_mofecar = "Writing MOFECAR.lobster and IMOFELIST.lobster..." in lines + else: + raise ValueError("must provide either filename or kwargs to initialize Lobsterout") + + def get_doc(self) -> dict[str, Any]: + """Get a dict with all information stored in lobsterout.""" + return { + # Check if LOBSTER starts from a projection + "restart_from_projection": self.is_restart_from_projection, + "lobster_version": self.lobster_version, + "threads": self.number_of_threads, + "dft_program": self.dft_program, + "charge_spilling": self.charge_spilling, + "total_spilling": self.total_spilling, + "elements": self.elements, + "basis_type": self.basis_type, + "basis_functions": self.basis_functions, + "timing": self.timing, + "warning_lines": self.warning_lines, + "info_orthonormalization": self.info_orthonormalization, + "info_lines": self.info_lines, + "has_doscar": self.has_doscar, + "has_doscar_lso": self.has_doscar_lso, + "has_doscar_lcfo": self.has_doscar_lcfo, + "has_cohpcar": self.has_cohpcar, + "has_cohpcar_lcfo": self.has_cohpcar_lcfo, + "has_coopcar": self.has_coopcar, + "has_coopcar_lcfo": self.has_coopcar_lcfo, + "has_cobicar": self.has_cobicar, + "has_cobicar_lcfo": self.has_cobicar_lcfo, + "has_charge": self.has_charge, + "has_madelung": self.has_madelung, + "has_mofecar": self.has_mofecar, + "has_projection": self.has_projection, + "has_bandoverlaps": self.has_bandoverlaps, + "has_fatbands": self.has_fatbands, + "has_grosspopulation": self.has_grosspopulation, + "has_polarization": self.has_polarization, + "has_density_of_energies": self.has_density_of_energies, + } + + def as_dict(self) -> dict[str, Any]: + """MSONable dict.""" + dct = dict(vars(self)) + dct["@module"] = type(self).__module__ + dct["@class"] = type(self).__name__ + + return dct + + @staticmethod + def _get_lobster_version(data: list[str]) -> str: + """Get LOBSTER version.""" + for line in data: + line_parts = line.split() + if len(line_parts) > 1 and line_parts[0] == "LOBSTER": + return line_parts[1] + raise RuntimeError("Version not found.") + + @staticmethod + def _has_fatband(data: list[str]) -> bool: + """Check whether calculation has hatband data.""" + for line in data: + line_parts = line.split() + if len(line_parts) > 1 and line_parts[1] == "FatBand": + return True + return False + + @staticmethod + def _get_dft_program(data: list[str]) -> str | None: + """Get the DFT program used for calculation.""" + for line in data: + line_parts = line.split() + if len(line_parts) > 4 and line_parts[3] == "program...": + return line_parts[4] + return None + + @staticmethod + def _get_number_of_spins(data: list[str]) -> Literal[1, 2]: + """Get index of spin channel.""" + return 2 if "spillings for spin channel 2" in data else 1 + + @staticmethod + def _get_threads(data: list[str]) -> int: + """Get number of CPU threads.""" + for line in data: + line_parts = line.split() + if len(line_parts) > 11 and line_parts[11] in {"threads", "thread"}: + return int(line_parts[10]) + raise ValueError("Threads not found.") + + @staticmethod + def _get_spillings( + data: list[str], + number_of_spins: Literal[1, 2], + ) -> tuple[list[float], list[float]]: + """Get charge spillings and total spillings.""" + charge_spillings = [] + total_spillings = [] + for line in data: + line_parts = line.split() + if len(line_parts) > 2 and line_parts[2] == "spilling:": + if line_parts[1] == "charge": + charge_spillings.append(float(line_parts[3].replace("%", "")) / 100.0) + elif line_parts[1] == "total": + total_spillings.append(float(line_parts[3].replace("%", "")) / 100.0) + + if len(charge_spillings) == number_of_spins and len(total_spillings) == number_of_spins: + break + + return charge_spillings, total_spillings + + @staticmethod + def _get_elements_basistype_basisfunctions( + data: list[str], + ) -> tuple[list[str], list[str], list[list[str]]]: + """Get elements, basis types and basis functions.""" + begin = False + end = False + elements: list[str] = [] + basistypes: list[str] = [] + basisfunctions: list[list[str]] = [] + for line in data: + if begin and not end: + line_parts = line.split() + if line_parts[0] not in { + "INFO:", + "WARNING:", + "setting", + "calculating", + "post-processing", + "saving", + "spillings", + "writing", + }: + elements.append(line_parts[0]) + basistypes.append(line_parts[1].replace("(", "").replace(")", "")) + # Last sign is '' + basisfunctions.append(line_parts[2:]) + else: + end = True + + if "setting up local basis functions..." in line: + begin = True + return elements, basistypes, basisfunctions + + @staticmethod + def _get_timing( + data: list[str], + ) -> tuple[dict[str, str], dict[str, str], dict[str, str]]: + """Get wall time, user time and system time.""" + begin = False + user_times, wall_times, sys_times = [], [], [] + + for line in data: + line_parts = line.split() + if "finished" in line_parts: + begin = True + if begin: + if "wall" in line_parts: + wall_times = line_parts[2:10] + if "user" in line_parts: + user_times = line_parts[:8] + if "sys" in line_parts: + sys_times = line_parts[:8] + + wall_time_dict = { + "h": wall_times[0], + "min": wall_times[2], + "s": wall_times[4], + "ms": wall_times[6], + } + user_time_dict = { + "h": user_times[0], + "min": user_times[2], + "s": user_times[4], + "ms": user_times[6], + } + sys_time_dict = { + "h": sys_times[0], + "min": sys_times[2], + "s": sys_times[4], + "ms": sys_times[6], + } + + return wall_time_dict, user_time_dict, sys_time_dict + + @staticmethod + def _get_warning_orthonormalization(data: list[str]) -> list[str]: + """Get orthonormalization warnings.""" + orthowarnings = [] + for line in data: + line_parts = line.split() + if "orthonormalized" in line_parts: + orthowarnings.append(" ".join(line_parts[1:])) + return orthowarnings + + @staticmethod + def _get_all_warning_lines(data: list[str]) -> list[str]: + """Get all WARNING lines.""" + warnings_ = [] + for line in data: + line_parts = line.split() + if len(line_parts) > 0 and line_parts[0] == "WARNING:": + warnings_.append(" ".join(line_parts[1:])) + return warnings_ + + @staticmethod + def _get_all_info_lines(data: list[str]) -> list[str]: + """Get all INFO lines.""" + infos = [] + for line in data: + line_parts = line.split() + if len(line_parts) > 0 and line_parts[0] == "INFO:": + infos.append(" ".join(line_parts[1:])) + return infos + + +class Fatband: + """Read FATBAND_x_y.lobster files. + + Attributes: + efermi (float): Fermi level read from vasprun.xml. + eigenvals (dict[Spin, NDArray]): Eigenvalues as a dictionary of NumPy arrays of shape (nbands, nkpoints). + The first index of the array refers to the band and the second to the index of the kpoint. + The kpoints are ordered according to the order of the kpoints_array attribute. + If the band structure is not spin polarized, we only store one data set under Spin.up. + is_spin_polarized (bool): Whether this was a spin-polarized calculation. + kpoints_array (list[NDArray]): List of kpoints as NumPy arrays, in frac_coords of the given + lattice by default. + label_dict (dict[str, Union[str, NDArray]]): Dictionary that links a kpoint (in frac coords or Cartesian + coordinates depending on the coords attribute) to a label. + lattice (Lattice): Lattice object of reciprocal lattice as read from vasprun.xml. + nbands (int): Number of bands used in the calculation. + p_eigenvals (dict[Spin, NDArray]): Dictionary of orbital projections as {spin: array of dict}. + The indices of the array are [band_index, kpoint_index]. + The dict is then built the following way: {"string of element": "string of orbital as read in + from FATBAND file"}. If the band structure is not spin polarized, we only store one data set under Spin.up. + structure (Structure): Structure object. + """ + + def __init__( + self, + filenames: PathLike | list[PathLike] = ".", + kpoints_file: PathLike = "KPOINTS", + vasprun_file: PathLike | None = "vasprun.xml", + structure: Structure | IStructure | None = None, + efermi: float | None = None, + ) -> None: + """ + Args: + filenames (PathLike | list[PathLike]): File names or path to a + folder from which all "FATBAND_*" files will be read. + kpoints_file (PathLike): KPOINTS file for bandstructure calculation, typically "KPOINTS". + vasprun_file (PathLike): Corresponding vasprun.xml file. Instead, the + Fermi level from the DFT run can be provided. Then, this should be set to None. + structure (Structure): Structure object. + efermi (float): Fermi level in eV. + """ + warnings.warn( + "Make sure all relevant FATBAND files were generated and read in!", + stacklevel=2, + ) + warnings.warn( + "Use Lobster 3.2.0 or newer for fatband calculations!", + stacklevel=2, + ) + + if structure is None: + raise ValueError("A structure object has to be provided") + self.structure = structure + if vasprun_file is None and efermi is None: + raise ValueError("vasprun_file or efermi have to be provided") + + self.lattice = self.structure.lattice.reciprocal_lattice + if vasprun_file is not None: + self.efermi = Vasprun( + filename=vasprun_file, + ionic_step_skip=None, + ionic_step_offset=0, + parse_dos=True, + parse_eigen=False, + parse_projected_eigen=False, + parse_potcar_file=False, + occu_tol=1e-8, + exception_on_bad_xml=True, + ).efermi + else: + self.efermi = efermi + kpoints_object = Kpoints.from_file(kpoints_file) + + # atom_type = [] + atom_names = [] + orbital_names = [] + parameters = [] + + if not isinstance(filenames, list) or filenames is None: + if filenames is None: + filenames = "." + + filenames_new = [ + os.path.join(filenames, name) + for name in os.listdir(filenames) + if fnmatch.fnmatch(name, "FATBAND_*.lobster") + ] + + filenames = cast("list[PathLike]", filenames_new) + + if len(filenames) == 0: + raise ValueError("No FATBAND files in folder or given") + + for fname in filenames: + lines = _get_lines(fname) + + atom_names.append(os.path.split(fname)[1].split("_")[1].capitalize()) + parameters = lines[0].split() + # atom_type.append(re.split(r"[0-9]+", parameters[3])[0].capitalize()) + orbital_names.append(parameters[4]) + + # Get atomtype orbital dict + atom_orbital_dict: dict[str, list[str]] = {} + for idx, atom in enumerate(atom_names): + if atom not in atom_orbital_dict: + atom_orbital_dict[atom] = [] + atom_orbital_dict[atom].append(orbital_names[idx]) + + # Test if there are the same orbitals twice or if two different + # formats were used or if all necessary orbitals are there + for items in atom_orbital_dict.values(): + if len(set(items)) != len(items): + raise ValueError("The are two FATBAND files for the same atom and orbital. The program will stop.") + split = [item.split("_")[0] for item in items] + for number in collections.Counter(split).values(): + if number not in {1, 3, 5, 7}: + raise ValueError( + "Make sure all relevant orbitals were generated and that no duplicates (2p and 2p_x) are " + "present" + ) + + kpoints_array: list = [] + eigenvals: dict = {} + p_eigenvals: dict = {} + for ifilename, filename in enumerate(filenames): + lines = _get_lines(filename) + + if ifilename == 0: + self.nbands = int(parameters[6]) + self.number_kpts = kpoints_object.num_kpts - int(lines[1].split()[2]) + 1 + + if len(lines[1:]) == self.nbands + 2: + self.is_spinpolarized = False + elif len(lines[1:]) == self.nbands * 2 + 2: + self.is_spinpolarized = True + else: + linenumbers = [] + for iline, line in enumerate(lines[1 : self.nbands * 2 + 4]): + if line.split()[0] == "#": + linenumbers.append(iline) + + if ifilename == 0: + self.is_spinpolarized = len(linenumbers) == 2 + + if ifilename == 0: + eigenvals = {} + eigenvals[Spin.up] = [[defaultdict(float) for _ in range(self.number_kpts)] for _ in range(self.nbands)] + if self.is_spinpolarized: + eigenvals[Spin.down] = [ + [defaultdict(float) for _ in range(self.number_kpts)] for _ in range(self.nbands) + ] + + p_eigenvals = {} + p_eigenvals[Spin.up] = [ + [ + { + str(elem): {str(orb): defaultdict(float) for orb in atom_orbital_dict[elem]} + for elem in atom_names + } + for _ in range(self.number_kpts) + ] + for _ in range(self.nbands) + ] + + if self.is_spinpolarized: + p_eigenvals[Spin.down] = [ + [ + { + str(elem): {str(orb): defaultdict(float) for orb in atom_orbital_dict[elem]} + for elem in atom_names + } + for _ in range(self.number_kpts) + ] + for _ in range(self.nbands) + ] + + idx_kpt = -1 + linenumber = iband = 0 + for line in lines[1:]: + if line.split()[0] == "#": + KPOINT = np.array( + [ + float(line.split()[4]), + float(line.split()[5]), + float(line.split()[6]), + ] + ) + if ifilename == 0: + kpoints_array.append(KPOINT) + + linenumber = iband = 0 + idx_kpt += 1 + if linenumber == self.nbands: + iband = 0 + if line.split()[0] != "#": + if linenumber < self.nbands: + if ifilename == 0 and self.efermi is not None: + eigenvals[Spin.up][iband][idx_kpt] = float(line.split()[1]) + self.efermi + + p_eigenvals[Spin.up][iband][idx_kpt][atom_names[ifilename]][orbital_names[ifilename]] = float( + line.split()[2] + ) + if linenumber >= self.nbands and self.is_spinpolarized: + if ifilename == 0 and self.efermi is not None: + eigenvals[Spin.down][iband][idx_kpt] = float(line.split()[1]) + self.efermi + p_eigenvals[Spin.down][iband][idx_kpt][atom_names[ifilename]][orbital_names[ifilename]] = float( + line.split()[2] + ) + + linenumber += 1 + iband += 1 + + self.kpoints_array = kpoints_array + self.eigenvals = eigenvals + self.p_eigenvals = p_eigenvals + + label_dict = {} + if kpoints_object.labels is not None: + for idx, label in enumerate(kpoints_object.labels[-self.number_kpts :], start=0): + if label is not None: + label_dict[label] = kpoints_array[idx] + + self.label_dict = label_dict + + def get_bandstructure(self) -> LobsterBandStructureSymmLine: + """Get a LobsterBandStructureSymmLine object which can be plotted with a normal BSPlotter.""" + return LobsterBandStructureSymmLine( + kpoints=self.kpoints_array, + eigenvals=self.eigenvals, + lattice=self.lattice, + efermi=self.efermi, # type: ignore[arg-type] + labels_dict=self.label_dict, + structure=self.structure, # type: ignore[arg-type] + projections=self.p_eigenvals, + ) + + +class Bandoverlaps(MSONable): + """Read bandOverlaps.lobster files, which are not created during every LOBSTER run. + + Attributes: + band_overlaps_dict (dict[Spin, Dict[str, Dict[str, Union[float, NDArray]]]]): A dictionary + containing the band overlap data of the form: {spin: {"kpoint as string": {"maxDeviation": + float that describes the max deviation, "matrix": 2D array of the size number of bands + times number of bands including the overlap matrices with}}}. + max_deviation (list[float]): The maximal deviation for each problematic kpoint. + """ + + def __init__( + self, + filename: PathLike = "bandOverlaps.lobster", + band_overlaps_dict: dict[Spin, dict] | None = None, + max_deviation: list[float] | None = None, + ) -> None: + """ + Args: + filename (PathLike): The "bandOverlaps.lobster" file. + band_overlaps_dict: The band overlap data of the form: + { + spin: { + "k_points" : list of k-point array, + "max_deviations": list of max deviations associated with each k-point, + "matrices": list of the overlap matrices associated with each k-point, + } + }. + max_deviation (list[float]): The maximal deviations for each problematic k-point. + """ + self._filename = filename + self.band_overlaps_dict = {} if band_overlaps_dict is None else band_overlaps_dict + self.max_deviation = [] if max_deviation is None else max_deviation + + if not self.band_overlaps_dict: + lines = _get_lines(filename) + + spin_numbers = [0, 1] if lines[0].split()[-1] == "0" else [1, 2] + + self._filename = filename + self._read(lines, spin_numbers) + + def _read(self, lines: list[str], spin_numbers: list[int]) -> None: + """Read all lines of the file. + + Args: + lines (list[str]): Lines of the file. + spin_numbers (list[int]): Spin numbers depending on LOBSTER version. + """ + spin: Spin = Spin.up + kpoint_array: list = [] + overlaps: list = [] + # This has to be done like this because there can be different numbers + # of problematic k-points per spin + for line in lines: + if f"Overlap Matrix (abs) of the orthonormalized projected bands for spin {spin_numbers[0]}" in line: + spin = Spin.up + + elif f"Overlap Matrix (abs) of the orthonormalized projected bands for spin {spin_numbers[1]}" in line: + spin = Spin.down + + elif "k-point" in line: + kpoint = line.split(" ") + kpoint_array = [] + for kpointel in kpoint: + if kpointel not in {"at", "k-point", ""}: + kpoint_array.append(float(kpointel)) + + elif "maxDeviation" in line: + if spin not in self.band_overlaps_dict: + self.band_overlaps_dict[spin] = {} + if "k_points" not in self.band_overlaps_dict[spin]: + self.band_overlaps_dict[spin]["k_points"] = [] + if "max_deviations" not in self.band_overlaps_dict[spin]: + self.band_overlaps_dict[spin]["max_deviations"] = [] + if "matrices" not in self.band_overlaps_dict[spin]: + self.band_overlaps_dict[spin]["matrices"] = [] + + maxdev = line.split(" ")[2] + self.band_overlaps_dict[spin]["max_deviations"].append(float(maxdev)) + self.band_overlaps_dict[spin]["k_points"].append(kpoint_array) + self.max_deviation.append(float(maxdev)) + overlaps = [] + + else: + _lines = [float(el) for el in line.split(" ") if el != ""] + + overlaps.append(_lines) + if len(overlaps) == len(_lines): + self.band_overlaps_dict[spin]["matrices"].append(np.array(overlaps)) + + def has_good_quality_maxDeviation(self, limit_maxDeviation: float = 0.1) -> bool: + """Check if the maxDeviation from the ideal bandoverlap is smaller + or equal to a limit. + + Args: + limit_maxDeviation (float): Upper Limit of the maxDeviation. + + Returns: + bool: Whether the ideal bandoverlap is smaller or equal to the limit. + """ + return all(deviation <= limit_maxDeviation for deviation in self.max_deviation) + + def has_good_quality_check_occupied_bands( + self, + number_occ_bands_spin_up: int, + number_occ_bands_spin_down: int | None = None, + spin_polarized: bool = False, + limit_deviation: float = 0.1, + ) -> bool: + """Check if the deviation from the ideal bandoverlap of all occupied bands + is smaller or equal to limit_deviation. + + Args: + number_occ_bands_spin_up (int): Number of occupied bands of spin up. + number_occ_bands_spin_down (int): Number of occupied bands of spin down. + spin_polarized (bool): Whether this is a spin polarized calculation. + limit_deviation (float): Upper limit of the maxDeviation. + + Returns: + bool: True if the quality of the projection is good. + """ + if spin_polarized and number_occ_bands_spin_down is None: + raise ValueError("number_occ_bands_spin_down has to be specified") + + for spin in (Spin.up, Spin.down) if spin_polarized else (Spin.up,): + if spin is Spin.up: + num_occ_bands = number_occ_bands_spin_up + else: + if number_occ_bands_spin_down is None: + raise ValueError("number_occ_bands_spin_down has to be specified") + num_occ_bands = number_occ_bands_spin_down + + for overlap_matrix in self.band_overlaps_dict[spin]["matrices"]: + sub_array = np.asarray(overlap_matrix)[:num_occ_bands, :num_occ_bands] + + if not np.allclose(sub_array, np.identity(num_occ_bands), atol=limit_deviation, rtol=0): + return False + + return True + + @property + @deprecated(message="Use `band_overlaps_dict` instead.", category=DeprecationWarning) + def bandoverlapsdict(self) -> dict: + return self.band_overlaps_dict + + +class Grosspop(MSONable): + """Read GROSSPOP.lobster/ GROSSPOP.LCFO.lobster files. + + Attributes: + list_dict_grosspop (list[dict[str, str| dict[str, str]]]): List of dictionaries + including all information about the grosspopulations. Each dictionary contains the following keys: + - 'element': The element symbol of the atom. + - 'Mulliken GP': A dictionary of Mulliken gross populations, where the keys are the orbital labels and the + values are the corresponding gross populations as strings. + - 'Loewdin GP': A dictionary of Loewdin gross populations, where the keys are the orbital labels and the + values are the corresponding gross populations as strings. + The 0th entry of the list refers to the first atom in GROSSPOP.lobster and so on. + """ + + def __init__( + self, + filename: PathLike = "GROSSPOP.lobster", + is_lcfo: bool = False, + list_dict_grosspop: list[dict] | None = None, + ) -> None: + """ + Args: + filename (PathLike): The "GROSSPOP.lobster" file. + is_lcfo (bool): Whether the GROSSPOP file is in LCFO format. + list_dict_grosspop (list[dict]): All information about the gross populations. + """ + self._filename = filename + self.is_lcfo = is_lcfo + self.list_dict_grosspop = [] if list_dict_grosspop is None else list_dict_grosspop + if not self.list_dict_grosspop: + lines = _get_lines(filename) + + # Read file to list of dict + small_dict: dict[str, Any] = {} + for line in lines[3:]: + cleanlines = [idx for idx in line.split(" ") if idx != ""] + if len(cleanlines) == 5 and cleanlines[0].isdigit() and not self.is_lcfo: + small_dict = { + "Mulliken GP": {}, + "Loewdin GP": {}, + "element": cleanlines[1], + } + small_dict["Mulliken GP"][cleanlines[2]] = float(cleanlines[3]) + small_dict["Loewdin GP"][cleanlines[2]] = float(cleanlines[4]) + elif len(cleanlines) == 4 and cleanlines[0].isdigit() and self.is_lcfo: + small_dict = {"Loewdin GP": {}, "mol": cleanlines[1]} + small_dict["Loewdin GP"][cleanlines[2]] = float(cleanlines[3]) + elif len(cleanlines) == 5 and cleanlines[0].isdigit() and self.is_lcfo: + small_dict = {"Loewdin GP": {}, "mol": cleanlines[1]} + small_dict["Loewdin GP"][cleanlines[2]] = { + Spin.up: float(cleanlines[3]), + Spin.down: float(cleanlines[4]), + } + elif len(cleanlines) == 5 and not cleanlines[0].isdigit(): + small_dict["Mulliken GP"][cleanlines[0]] = { + Spin.up: float(cleanlines[1]), + Spin.down: float(cleanlines[2]), + } + small_dict["Loewdin GP"][cleanlines[0]] = { + Spin.up: float(cleanlines[3]), + Spin.down: float(cleanlines[4]), + } + if "total" in cleanlines[0]: + self.list_dict_grosspop.append(small_dict) + elif len(cleanlines) == 7 and cleanlines[0].isdigit(): + small_dict = { + "Mulliken GP": {}, + "Loewdin GP": {}, + "element": cleanlines[1], + } + small_dict["Mulliken GP"][cleanlines[2]] = { + Spin.up: float(cleanlines[3]), + Spin.down: float(cleanlines[4]), + } + small_dict["Loewdin GP"][cleanlines[2]] = { + Spin.up: float(cleanlines[5]), + Spin.down: float(cleanlines[6]), + } + + elif len(cleanlines) > 0 and "spin" not in line and self.is_lcfo: + if len(cleanlines) == 2: + small_dict["Loewdin GP"][cleanlines[0]] = float(cleanlines[1]) + else: + small_dict["Loewdin GP"][cleanlines[0]] = { + Spin.up: float(cleanlines[1]), + Spin.down: float(cleanlines[2]), + } + if "total" in cleanlines[0]: + self.list_dict_grosspop.append(small_dict) + elif len(cleanlines) > 0 and "spin" not in line: + small_dict["Mulliken GP"][cleanlines[0]] = float(cleanlines[1]) + small_dict["Loewdin GP"][cleanlines[0]] = float(cleanlines[2]) + if "total" in cleanlines[0]: + self.list_dict_grosspop.append(small_dict) + + def get_structure_with_total_grosspop(self, structure_filename: PathLike) -> Structure: + """Get a Structure with Mulliken and Loewdin total grosspopulations as site properties. + + Args: + structure_filename (PathLike): The POSCAR file. + + Returns: + Structure Object with Mulliken and Loewdin total grosspopulations as site properties. + """ + struct = Structure.from_file(structure_filename) + if not self.is_lcfo: + mulliken_gps: list[dict] = [] + loewdin_gps: list[dict] = [] + for grosspop in self.list_dict_grosspop: + mulliken_gps.append(grosspop["Mulliken GP"]["total"]) + loewdin_gps.append(grosspop["Loewdin GP"]["total"]) + + site_properties = { + "Total Mulliken GP": mulliken_gps, + "Total Loewdin GP": loewdin_gps, + } + return struct.copy(site_properties=site_properties) + raise ValueError( + "The GROSSPOP.LCFO.lobster data is not site wise. Thus, the site properties cannot be added.", + ) + + +class Wavefunction: + """Read wave function files from LOBSTER and create an VolumetricData object. + + Attributes: + grid (tuple[int, int, int]): Grid for the wave function [Nx+1, Ny+1, Nz+1]. + points (list[Tuple[float, float, float]]): Points. + real (list[float]): Real parts of wave function. + imaginary (list[float]): Imaginary parts of wave function. + distance (list[float]): Distances to the first point in wave function file. + """ + + def __init__(self, filename: PathLike, structure: Structure) -> None: + """ + Args: + filename (PathLike): The wavecar file from LOBSTER. + structure (Structure): The Structure object. + """ + self.filename = filename + self.structure = structure + self.grid, self.points, self.real, self.imaginary, self.distance = Wavefunction._parse_file(filename) + + @staticmethod + def _parse_file( + filename: PathLike, + ) -> tuple[tuple[int, int, int], list[tuple[float, float, float]], list[float], list[float], list[float]]: + """Parse wave function file. + + Args: + filename (PathLike): The file to parse. + + Returns: + grid (tuple[int, int, int]): Grid for the wave function [Nx+1, Ny+1, Nz+1]. + points (list[Tuple[float, float, float]]): Points. + real (list[float]): Real parts of wave function. + imaginary (list[float]): Imaginary parts of wave function. + distance (list[float]): Distances to the first point in wave function file. + """ + lines = _get_lines(filename) + + points = [] + distances = [] + reals = [] + imaginaries = [] + line_parts = lines[0].split() + grid: tuple[int, int, int] = (int(line_parts[7]), int(line_parts[8]), int(line_parts[9])) + + for line in lines[1:]: + line_parts = line.split() + if len(line_parts) >= 6: + points.append((float(line_parts[0]), float(line_parts[1]), float(line_parts[2]))) + distances.append(float(line_parts[3])) + reals.append(float(line_parts[4])) + imaginaries.append(float(line_parts[5])) + + if len(reals) != grid[0] * grid[1] * grid[2] or len(imaginaries) != grid[0] * grid[1] * grid[2]: + raise ValueError("Something went wrong while reading the file") + + return grid, points, reals, imaginaries, distances + + def set_volumetric_data(self, grid: tuple[int, int, int], structure: Structure) -> None: + """Create the VolumetricData instances. + + Args: + grid (tuple[int, int, int]): Grid on which wavefunction was calculated, e.g. (1, 2, 2). + structure (Structure): The Structure object. + """ + Nx = grid[0] - 1 + Ny = grid[1] - 1 + Nz = grid[2] - 1 + a = structure.lattice.matrix[0] + b = structure.lattice.matrix[1] + c = structure.lattice.matrix[2] + new_x = [] + new_y = [] + new_z = [] + new_real = [] + new_imaginary = [] + new_density = [] + + for runner, (x, y, z) in enumerate(itertools.product(range(Nx + 1), range(Ny + 1), range(Nz + 1))): + x_here = x / float(Nx) * a[0] + y / float(Ny) * b[0] + z / float(Nz) * c[0] + y_here = x / float(Nx) * a[1] + y / float(Ny) * b[1] + z / float(Nz) * c[1] + z_here = x / float(Nx) * a[2] + y / float(Ny) * b[2] + z / float(Nz) * c[2] + + if x != Nx and y != Ny and z != Nz: + if ( + not np.isclose(self.points[runner][0], x_here, 1e-3) + and not np.isclose(self.points[runner][1], y_here, 1e-3) + and not np.isclose(self.points[runner][2], z_here, 1e-3) + ): + raise ValueError( + "The provided wavefunction from Lobster does not contain all relevant" + " points. " + "Please use a line similar to: printLCAORealSpaceWavefunction kpoint 1 " + "coordinates 0.0 0.0 0.0 coordinates 1.0 1.0 1.0 box bandlist 1 " + ) + + new_x.append(x_here) + new_y.append(y_here) + new_z.append(z_here) + + new_real.append(self.real[runner]) + new_imaginary.append(self.imaginary[runner]) + new_density.append(self.real[runner] ** 2 + self.imaginary[runner] ** 2) + + self.final_real = np.reshape(new_real, [Nx, Ny, Nz]) + self.final_imaginary = np.reshape(new_imaginary, [Nx, Ny, Nz]) + self.final_density = np.reshape(new_density, [Nx, Ny, Nz]) + + self.volumetricdata_real = VolumetricData(structure, {"total": self.final_real}) + self.volumetricdata_imaginary = VolumetricData(structure, {"total": self.final_imaginary}) + self.volumetricdata_density = VolumetricData(structure, {"total": self.final_density}) + + def get_volumetricdata_real(self) -> VolumetricData: + """Get a VolumetricData object including the real part of the wave function. + + Returns: + VolumetricData + """ + if not hasattr(self, "volumetricdata_real"): + self.set_volumetric_data(self.grid, self.structure) + return self.volumetricdata_real + + def get_volumetricdata_imaginary(self) -> VolumetricData: + """Get a VolumetricData object including the imaginary part of the wave function. + + Returns: + VolumetricData + """ + if not hasattr(self, "volumetricdata_imaginary"): + self.set_volumetric_data(self.grid, self.structure) + return self.volumetricdata_imaginary + + def get_volumetricdata_density(self) -> VolumetricData: + """Get a VolumetricData object including the density part of the wave function. + + Returns: + VolumetricData + """ + if not hasattr(self, "volumetricdata_density"): + self.set_volumetric_data(self.grid, self.structure) + return self.volumetricdata_density + + def write_file( + self, + filename: PathLike = "WAVECAR.vasp", + part: Literal["real", "imaginary", "density"] = "real", + ) -> None: + """Save the wave function in a file that can be read by VESTA. + + This will only work if the wavefunction from lobster is constructed with: + "printLCAORealSpaceWavefunction kpoint 1 coordinates 0.0 0.0 0.0 + coordinates 1.0 1.0 1.0 box bandlist 1 2 3 4 5 6 " + or similar (the whole unit cell has to be covered!). + + Args: + filename (PathLike): The output file, e.g. "WAVECAR.vasp". + part ("real" | "imaginary" | "density"]): Part of the wavefunction to save. + """ + if not ( + hasattr(self, "volumetricdata_real") + and hasattr(self, "volumetricdata_imaginary") + and hasattr(self, "volumetricdata_density") + ): + self.set_volumetric_data(self.grid, self.structure) + + if part == "real": + self.volumetricdata_real.write_file(filename) + elif part == "imaginary": + self.volumetricdata_imaginary.write_file(filename) + elif part == "density": + self.volumetricdata_density.write_file(filename) + else: + raise ValueError('part can be only "real" or "imaginary" or "density"') + + +# Madelung and site potential classes +class MadelungEnergies(MSONable): + """Read MadelungEnergies.lobster files generated by LOBSTER. + + Attributes: + madelungenergies_mulliken (float): The Madelung energy based on the Mulliken approach. + madelungenergies_loewdin (float): The Madelung energy based on the Loewdin approach. + ewald_splitting (float): The Ewald splitting parameter to compute SitePotentials. + """ + + def __init__( + self, + filename: PathLike = "MadelungEnergies.lobster", + ewald_splitting: float | None = None, + madelungenergies_mulliken: float | None = None, + madelungenergies_loewdin: float | None = None, + ) -> None: + """ + Args: + filename (PathLike): The "MadelungEnergies.lobster" file. + ewald_splitting (float): The Ewald splitting parameter to compute SitePotentials. + madelungenergies_mulliken (float): The Madelung energy based on the Mulliken approach. + madelungenergies_loewdin (float): The Madelung energy based on the Loewdin approach. + """ + self._filename = filename + self.ewald_splitting = None if ewald_splitting is None else ewald_splitting + self.madelungenergies_loewdin = None if madelungenergies_loewdin is None else madelungenergies_loewdin + self.madelungenergies_mulliken = None if madelungenergies_mulliken is None else madelungenergies_mulliken + + if self.ewald_splitting is None: + lines = _get_lines(filename)[5] + if len(lines) == 0: + raise RuntimeError("MadelungEnergies file contains no data.") + + line_parts = lines.split() + self._filename = filename + self.ewald_splitting = float(line_parts[0]) + self.madelungenergies_mulliken = float(line_parts[1]) + self.madelungenergies_loewdin = float(line_parts[2]) + + @property + @deprecated(message="Use `madelungenergies_loewdin` instead.", category=DeprecationWarning) + def madelungenergies_Loewdin(self) -> float | None: + return self.madelungenergies_loewdin + + @property + @deprecated(message="Use `madelungenergies_mulliken` instead.", category=DeprecationWarning) + def madelungenergies_Mulliken(self) -> float | None: + return self.madelungenergies_mulliken + + +class SitePotential(MSONable): + """Read SitePotentials.lobster files generated by LOBSTER. + + Attributes: + atomlist (list[str]): Atoms in SitePotentials.lobster. + types (list[str]): Types of atoms in SitePotentials.lobster. + num_atoms (int): Number of atoms in SitePotentials.lobster. + sitepotentials_mulliken (list[float]): Mulliken potentials of sites in SitePotentials.lobster. + sitepotentials_loewdin (list[float]): Loewdin potentials of sites in SitePotentials.lobster. + madelungenergies_mulliken (float): The Madelung energy based on the Mulliken approach. + madelungenergies_loewdin (float): The Madelung energy based on the Loewdin approach. + ewald_splitting (float): The Ewald Splitting parameter to compute SitePotentials. + """ + + def __init__( + self, + filename: PathLike = "SitePotentials.lobster", + ewald_splitting: float | None = None, + num_atoms: int | None = None, + atomlist: list[str] | None = None, + types: list[str] | None = None, + sitepotentials_loewdin: list[float] | None = None, + sitepotentials_mulliken: list[float] | None = None, + madelungenergies_mulliken: float | None = None, + madelungenergies_loewdin: float | None = None, + ) -> None: + """ + Args: + filename (PathLike): The SitePotentials file, typically "SitePotentials.lobster". + ewald_splitting (float): Ewald splitting parameter used for computing Madelung energies. + num_atoms (int): Number of atoms in the structure. + atomlist (list[str]): Atoms in the structure. + types (list[str]): Unique atom types in the structure. + sitepotentials_loewdin (list[float]): Loewdin site potentials. + sitepotentials_mulliken (list[float]): Mulliken site potentials. + madelungenergies_mulliken (float): Madelung energy based on the Mulliken approach. + madelungenergies_loewdin (float): Madelung energy based on the Loewdin approach. + """ + self._filename = filename + self.ewald_splitting: list | float = ewald_splitting or [] + self.num_atoms: int | None = num_atoms + self.types: list[str] = types or [] + self.atomlist: list[str] = atomlist or [] + self.sitepotentials_loewdin: list[float] = sitepotentials_loewdin or [] + self.sitepotentials_mulliken: list[float] = sitepotentials_mulliken or [] + self.madelungenergies_loewdin: list | float = madelungenergies_loewdin or [] + self.madelungenergies_mulliken: list | float = madelungenergies_mulliken or [] + + if self.num_atoms is None: + lines = _get_lines(filename) + if len(lines) == 0: + raise RuntimeError("SitePotentials file contains no data.") + + self._filename = filename + self.ewald_splitting = float(lines[0].split()[9]) + + lines = lines[5:] + self.num_atoms = len(lines) - 2 + for atom in range(self.num_atoms): + line_parts = lines[atom].split() + self.atomlist.append(line_parts[1] + line_parts[0]) + self.types.append(line_parts[1]) + self.sitepotentials_mulliken.append(float(line_parts[2])) + self.sitepotentials_loewdin.append(float(line_parts[3])) + + self.madelungenergies_mulliken = float(lines[self.num_atoms + 1].split()[3]) + self.madelungenergies_loewdin = float(lines[self.num_atoms + 1].split()[4]) + + def get_structure_with_site_potentials(self, structure_filename: PathLike) -> Structure: + """Get a Structure with Mulliken and Loewdin charges as site properties. + + Args: + structure_filename (PathLike): The POSCAR file. + + Returns: + Structure Object with Mulliken and Loewdin charges as site properties. + """ + struct = Structure.from_file(structure_filename) + mulliken = self.sitepotentials_mulliken + loewdin = self.sitepotentials_loewdin + site_properties = { + "Mulliken Site Potentials (eV)": mulliken, + "Loewdin Site Potentials (eV)": loewdin, + } + return struct.copy(site_properties=site_properties) + + @property + @deprecated(message="Use `sitepotentials_mulliken` instead.", category=DeprecationWarning) + def sitepotentials_Mulliken(self) -> list[float]: + return self.sitepotentials_mulliken + + @property + @deprecated(message="Use `sitepotentials_loewdin` instead.", category=DeprecationWarning) + def sitepotentials_Loewdin(self) -> list[float]: + return self.sitepotentials_loewdin + + @property + @deprecated(message="Use `madelungenergies_mulliken` instead.", category=DeprecationWarning) + def madelungenergies_Mulliken(self): + return self.madelungenergies_mulliken + + @property + @deprecated(message="Use `madelungenergies_loewdin` instead.", category=DeprecationWarning) + def madelungenergies_Loewdin(self): + return self.madelungenergies_loewdin + + +def get_orb_from_str(orbs: list[str]) -> tuple[str, list[tuple[int, Orbital]]]: + """Get Orbitals from string representations. + + Args: + orbs (list[str]): Orbitals, e.g. ["2p_x", "3s"]. + + Returns: + tuple[str, list[tuple[int, Orbital]]]: Orbital label, orbitals. + """ + # TODO: also use for plotting of DOS + orb_labs = ( + "s", + "p_y", + "p_z", + "p_x", + "d_xy", + "d_yz", + "d_z^2", + "d_xz", + "d_x^2-y^2", + "f_y(3x^2-y^2)", + "f_xyz", + "f_yz^2", + "f_z^3", + "f_xz^2", + "f_z(x^2-y^2)", + "f_x(x^2-3y^2)", + ) + orbitals = [(int(orb[0]), Orbital(orb_labs.index(orb[1:]))) for orb in orbs] + + orb_label = "" + for iorb, orbital in enumerate(orbitals): + if iorb == 0: + orb_label += f"{orbital[0]}{orbital[1].name}" + else: + orb_label += f"-{orbital[0]}{orbital[1].name}" + + return orb_label, orbitals + + +class LobsterMatrices: + """Read Matrices file generated by LOBSTER (e.g. hamiltonMatrices.lobster). + + Attributes: + If filename == "hamiltonMatrices.lobster": + onsite_energies (list[NDArray]): Real parts of onsite energies from the + matrices each k-point. + average_onsite_energies (dict): Average onsite elements energies for + all k-points with keys as basis used in the LOBSTER computation + (uses only real part of matrix). + hamilton_matrices (dict[Spin, NDArray]): The complex Hamilton matrix at each + k-point with k-point and spin as keys. + + If filename == "coefficientMatrices.lobster": + onsite_coefficients (list[NDArray]): Real parts of onsite coefficients + from the matrices each k-point. + average_onsite_coefficient (dict): Average onsite elements coefficients + for all k-points with keys as basis used in the LOBSTER computation + (uses only real part of matrix). + coefficient_matrices (dict[Spin, NDArray]): The coefficients matrix + at each k-point with k-point and spin as keys. + + If filename == "transferMatrices.lobster": + onsite_transfer (list[NDArray]): Real parts of onsite transfer + coefficients from the matrices at each k-point. + average_onsite_transfer (dict): Average onsite elements transfer + coefficients for all k-points with keys as basis used in the + LOBSTER computation (uses only real part of matrix). + transfer_matrices (dict[Spin, NDArray]): The coefficients matrix at + each k-point with k-point and spin as keys. + + If filename == "overlapMatrices.lobster": + onsite_overlaps (list[NDArray]): Real parts of onsite overlaps + from the matrices each k-point. + average_onsite_overlaps (dict): Average onsite elements overlaps + for all k-points with keys as basis used in the LOBSTER + computation (uses only real part of matrix). + overlap_matrices (dict[NDArray]): The overlap matrix at + each k-point with k-point as keys. + """ + + def __init__( + self, + e_fermi: float | None = None, + filename: PathLike = "hamiltonMatrices.lobster", + ) -> None: + """ + Args: + e_fermi (float): Fermi level in eV for the structure only. + Relevant if input file contains Hamilton matrices data. + filename (PathLike): The hamiltonMatrices file, typically "hamiltonMatrices.lobster". + """ + + self._filename = str(filename) + with zopen(self._filename, mode="rt", encoding="utf-8") as file: + lines: list[str] = cast("list[str]", file.readlines()) + if len(lines) == 0: + raise RuntimeError("Please check provided input file, it seems to be empty") + + pattern_coeff_hamil_trans = r"(\d+)\s+kpoint\s+(\d+)" # regex pattern to extract spin and k-point number + pattern_overlap = r"kpoint\s+(\d+)" # regex pattern to extract k-point number + + if "hamilton" in self._filename: + if e_fermi is None: + raise ValueError("Please provide the fermi energy in eV ") + ( + self.onsite_energies, + self.average_onsite_energies, + self.hamilton_matrices, + ) = self._parse_matrix(file_data=lines, pattern=pattern_coeff_hamil_trans, e_fermi=e_fermi) + + elif "coefficient" in self._filename: + ( + self.onsite_coefficients, + self.average_onsite_coefficient, + self.coefficient_matrices, + ) = self._parse_matrix(file_data=lines, pattern=pattern_coeff_hamil_trans, e_fermi=0) + + elif "transfer" in self._filename: + ( + self.onsite_transfer, + self.average_onsite_transfer, + self.transfer_matrices, + ) = self._parse_matrix(file_data=lines, pattern=pattern_coeff_hamil_trans, e_fermi=0) + + elif "overlap" in self._filename: + ( + self.onsite_overlaps, + self.average_onsite_overlaps, + self.overlap_matrices, + ) = self._parse_matrix(file_data=lines, pattern=pattern_overlap, e_fermi=0) + + @staticmethod + def _parse_matrix( + file_data: list[str], + pattern: str, + e_fermi: float, + ) -> tuple[list[np.ndarray], dict[Any, Any], dict[Any, Any]]: + complex_matrices: dict = {} + matrix_diagonal_values = [] + start_inxs_real = [] + end_inxs_real = [] + start_inxs_imag = [] + end_inxs_imag = [] + # Get indices of real and imaginary part of matrix for each k point + for idx, line in enumerate(file_data): + line = line.strip() + if "Real parts" in line: + start_inxs_real.append(idx + 1) + if idx == 1: # ignore the first occurrence as files start with real matrices + pass + else: + end_inxs_imag.append(idx - 1) + + matches = re.search(pattern, file_data[idx - 1]) + if matches and len(matches.groups()) == 2: + complex_matrices[matches[2]] = {} + + if "Imag parts" in line: + end_inxs_real.append(idx - 1) + start_inxs_imag.append(idx + 1) + + # Explicitly add the last line as files end with imaginary matrix + if idx == len(file_data) - 1: + end_inxs_imag.append(len(file_data)) + + # Extract matrix data and store diagonal elements + matrix_real = [] + matrix_imag = [] + for start_inx_real, end_inx_real, start_inx_imag, end_inx_imag in zip( + start_inxs_real, end_inxs_real, start_inxs_imag, end_inxs_imag, strict=True + ): + # Matrix with text headers + matrix_real = file_data[start_inx_real:end_inx_real] + matrix_imag = file_data[start_inx_imag:end_inx_imag] + + # Extract only numerical data and convert to NumPy arrays + matrix_array_real = np.array([line.split()[1:] for line in matrix_real[1:]], dtype=float) + matrix_array_imag = np.array([line.split()[1:] for line in matrix_imag[1:]], dtype=float) + + # Combine real and imaginary parts to create a complex matrix + comp_matrix = matrix_array_real + 1j * matrix_array_imag + + matches = re.search(pattern, file_data[start_inx_real - 2]) + if matches and len(matches.groups()) == 2: + spin = Spin.up if matches[1] == "1" else Spin.down + k_point = matches[2] + complex_matrices[k_point] |= {spin: comp_matrix} + elif matches and len(matches.groups()) == 1: + k_point = matches[1] + complex_matrices |= {k_point: comp_matrix} + matrix_diagonal_values.append(comp_matrix.real.diagonal() - e_fermi) + + # Extract elements basis functions as list + elements_basis_functions = [ + line.split()[:1][0] for line in matrix_real if line.split()[:1][0] != "basisfunction" + ] + + # Get average row-wise + average_matrix_diagonal_values = np.array(matrix_diagonal_values, dtype=float).mean(axis=0) + + # Get a dict with basis functions as keys and average values as values + average_average_matrix_diag_dict = dict( + zip(elements_basis_functions, average_matrix_diagonal_values, strict=True) + ) + + return ( + matrix_diagonal_values, + average_average_matrix_diag_dict, + complex_matrices, + ) + + +class Polarization(MSONable): + """Read POLARIZATION.lobster file generated by LOBSTER. + + Attributes: + rel_mulliken_pol_vector (dict[str, float]): Relative Mulliken polarization vector. + rel_loewdin_pol_vector (dict[str, float]): Relative Mulliken polarization vector. + """ + + def __init__( + self, + filename: PathLike = "POLARIZATION.lobster", + rel_mulliken_pol_vector: dict[str, float | str] | None = None, + rel_loewdin_pol_vector: dict[str, float | str] | None = None, + ) -> None: + """ + Args: + filename (PathLike): The "POLARIZATION.lobster" file. + rel_mulliken_pol_vector (dict[str, Union[float, str]]): Relative Mulliken polarization vector. + rel_loewdin_pol_vector (dict[str, Union[float, str]]): Relative Loewdin polarization vector. + """ + self._filename = filename + self.rel_mulliken_pol_vector = {} if rel_mulliken_pol_vector is None else rel_mulliken_pol_vector + self.rel_loewdin_pol_vector = {} if rel_loewdin_pol_vector is None else rel_loewdin_pol_vector + + if not self.rel_loewdin_pol_vector and not self.rel_mulliken_pol_vector: + lines = _get_lines(filename) + if len(lines) == 0: + raise RuntimeError("Polarization file contains no data.") + + for line in lines[4:]: + cleanlines = [idx for idx in line.split(" ") if idx != ""] + if cleanlines and len(cleanlines) == 3: + self.rel_mulliken_pol_vector[cleanlines[0]] = float(cleanlines[1]) + self.rel_loewdin_pol_vector[cleanlines[0]] = float(cleanlines[2]) + if cleanlines and len(cleanlines) == 4: + self.rel_mulliken_pol_vector[cleanlines[0].replace(":", "")] = cleanlines[1].replace("\u03bc", "u") + self.rel_loewdin_pol_vector[cleanlines[2].replace(":", "")] = cleanlines[3].replace("\u03bc", "u") + + +class Bwdf(MSONable): + """Read BWDF.lobster/BWDFCOHP.lobster file generated by LOBSTER. + + Attributes: + centers (NDArray): Bond length centers for the distribution. + bwdf (dict[Spin, NDArray]): Bond weighted distribution function. + bin_width (float): Bin width used for computing the distribution by LOBSTER. + """ + + def __init__( + self, + filename: PathLike = "BWDF.lobster", + centers: NDArray | None = None, + bwdf: dict[Spin, NDArray] | None = None, + bin_width: float | None = None, + ) -> None: + """ + Args: + filename (PathLike): The "BWDF.lobster" file. Can also read BWDFCOHP.lobster. + centers (NDArray): Bond length centers for the distribution. + bwdf (dict[Spin, NDArray]): Bond weighted distribution function. + bin_width (float): Bin width used for computing the distribution by LOBSTER. + """ + self._filename = filename + self.centers = np.array([]) if centers is None else centers + self.bwdf = {} if bwdf is None else bwdf + self.bin_width = 0.0 if bin_width is None else bin_width + + if not self.bwdf: + lines = _get_lines(filename) + if len(lines) == 0: + raise RuntimeError("BWDF file contains no data.") + + self.bwdf[Spin.up] = np.array([]) + self.bwdf[Spin.down] = np.array([]) + for line in lines[1:]: + clean_line = line.strip().split() + self.centers = np.append(self.centers, float(clean_line[0])) + if len(clean_line) == 3: + self.bwdf[Spin.up] = np.append(self.bwdf[Spin.up], float(clean_line[1])) + self.bwdf[Spin.down] = np.append(self.bwdf[Spin.down], float(clean_line[2])) + else: + self.bwdf[Spin.up] = np.append(self.bwdf[Spin.up], float(clean_line[1])) + + if len(self.bwdf[Spin.down]) == 0: # remove down spin key if not spin polarized calculation + del self.bwdf[Spin.down] + + self.bin_width = np.diff(self.centers)[0] diff --git a/src/pymatgen/io/lobster/sets.py b/src/pymatgen/io/lobster/sets.py new file mode 100644 index 00000000000..830e584f333 --- /dev/null +++ b/src/pymatgen/io/lobster/sets.py @@ -0,0 +1,161 @@ +""" +This module defines the VaspInputSet abstract base class and a concrete implementation for the parameters developed +and tested by the core team of pymatgen, including the Materials Virtual Lab, Materials Project and the MIT high +throughput project. The basic concept behind an input set is to specify a scheme to generate a consistent set of VASP +inputs from a structure without further user intervention. This ensures comparability across runs. + +Read the following carefully before implementing new input sets: + +1. 99% of what needs to be done can be done by specifying user_incar_settings to override some of the defaults of + various input sets. Unless there is an extremely good reason to add a new set, **do not** add one. e.g. if you want + to turn the Hubbard U off, just set "LDAU": False as a user_incar_setting. +2. All derivative input sets should inherit appropriate configurations (e.g., from MPRelaxSet), and more often than + not, VaspInputSet should be the superclass. Superclass delegation should be used where possible. In particular, + you are not supposed to implement your own as_dict or from_dict for derivative sets unless you know what you are + doing. Improper overriding the as_dict and from_dict protocols is the major cause of implementation headaches. If + you need an example, look at how the MPStaticSet is initialized. + +The above are recommendations. The following are **UNBREAKABLE** rules: + +1. All input sets must take in a structure, list of structures or None as the first argument. If None, the input set + should perform a stateless initialization and before any output can be written, a structure must be set. +2. user_incar_settings, user_kpoints_settings and user__settings are ABSOLUTE. Any new sets you implement + must obey this. If a user wants to override your settings, you assume he knows what he is doing. Do not + magically override user supplied settings. You can issue a warning if you think the user is wrong. +3. All input sets must save all supplied args and kwargs as instance variables. e.g. self.arg = arg and + self.kwargs = kwargs in the __init__. This ensures the as_dict and from_dict work correctly. +""" + +from __future__ import annotations + +import os +import warnings +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from pymatgen.io.lobster import Lobsterin +from pymatgen.io.vasp.sets import BadInputSetWarning, MPRelaxSet, VaspInputSet + +if TYPE_CHECKING: + from collections.abc import Sequence + from typing import Literal + + UserPotcarFunctional = ( + Literal[ + "PBE", + "PBE_52", + "PBE_54", + "PBE_64", + "LDA", + "LDA_52", + "LDA_54", + "PW91", + "LDA_US", + "PW91_US", + ] + | None + ) + +MODULE_DIR = os.path.dirname(__file__) + + +@dataclass +class LobsterSet(VaspInputSet): + """Input set to prepare VASP runs that can be digested by Lobster (See cohp.de). + + Args: + structure (Structure): input structure. + isym (int): ISYM entry for INCAR, only isym=-1 and isym=0 are allowed + ismear (int): ISMEAR entry for INCAR, only ismear=-5 and ismear=0 are allowed + reciprocal_density (int): density of k-mesh by reciprocal volume + user_supplied_basis (dict): dict including basis functions for all elements in + structure, e.g. {"Fe": "3d 3p 4s", "O": "2s 2p"}; if not supplied, a + standard basis is used + address_basis_file (str): address to a file similar to + "BASIS_PBE_54_standard.yaml" in pymatgen.io.lobster.lobster_basis + user_potcar_settings (dict): dict including potcar settings for all elements in + structure, e.g. {"Fe": "Fe_pv", "O": "O"}; if not supplied, a standard basis + is used. + **kwargs: Other kwargs supported by VaspInputSet. + """ + + isym: int = 0 + ismear: int = -5 + reciprocal_density: int | None = None + address_basis_file: str | None = None + user_supplied_basis: dict | None = None + + # Latest POTCARs are preferred + # Choose PBE_54 unless the user specifies a different potcar_functional + user_potcar_functional: UserPotcarFunctional = "PBE_54" + + CONFIG = MPRelaxSet.CONFIG + _valid_potcars: Sequence[str] | None = ("PBE_52", "PBE_54", "PBE_64") + + def __post_init__(self) -> None: + super().__post_init__() + warnings.warn( + "Make sure that all parameters are okay! This is a brand new implementation.", + stacklevel=2, + ) + + if self.user_potcar_functional in ["PBE_52", "PBE_64"]: + warnings.warn( + f"Using {self.user_potcar_functional} POTCARs with basis functions generated for PBE_54 POTCARs. " + "Basis functions for elements with obsoleted, updated or newly added POTCARs in " + f"{self.user_potcar_functional} will not be available and may cause errors or inaccuracies.", + BadInputSetWarning, + stacklevel=2, + ) + if self.isym not in {-1, 0}: + raise ValueError("Lobster cannot digest WAVEFUNCTIONS with symmetry. isym must be -1 or 0") + if self.ismear not in {-5, 0}: + raise ValueError("Lobster usually works with ismear=-5 or ismear=0") + + self._config_dict["POTCAR"]["W"] = "W_sv" + + @property + def kpoints_updates(self) -> dict[str, int]: + """Updates to the kpoints configuration for this calculation type.""" + # Test if this is okay + return {"reciprocal_density": self.reciprocal_density or 310} + + @property + def incar_updates(self) -> dict[str, Any]: + """Updates to the INCAR config for this calculation type.""" + + potcar_symbols = self.potcar_symbols + + # Predefined basis! Check if the basis is okay! (charge spilling and bandoverlaps!) + if self.user_supplied_basis is None and self.address_basis_file is None: + basis = Lobsterin.get_basis(structure=self.structure, potcar_symbols=potcar_symbols) # type: ignore[arg-type] + elif self.address_basis_file is not None: + basis = Lobsterin.get_basis( + structure=self.structure, # type: ignore[arg-type] + potcar_symbols=potcar_symbols, + address_basis_file=self.address_basis_file, + ) + elif self.user_supplied_basis is not None: + # Test if all elements from structure are in user_supplied_basis + for atom_type in self.structure.symbol_set: # type: ignore[union-attr] + if atom_type not in self.user_supplied_basis: + raise ValueError(f"There are no basis functions for the atom type {atom_type}") + basis = [f"{key} {value}" for key, value in self.user_supplied_basis.items()] + else: + basis = None + + lobsterin = Lobsterin(settingsdict={"basisfunctions": basis}) + nbands = lobsterin._get_nbands(structure=self.structure) # type: ignore[arg-type] + + return { + "EDIFF": 1e-6, + "NSW": 0, + "LWAVE": True, + "ISYM": self.isym, + "NBANDS": nbands, + "IBRION": -1, + "ISMEAR": self.ismear, + "LORBIT": 11, + "ICHARG": 0, + "ALGO": "Normal", + } diff --git a/tests/electronic_structure/test_cohp.py b/tests/electronic_structure/test_cohp.py new file mode 100644 index 00000000000..dd397320e20 --- /dev/null +++ b/tests/electronic_structure/test_cohp.py @@ -0,0 +1,1527 @@ +from __future__ import annotations + +import orjson +import pytest +from numpy.testing import assert_allclose +from pytest import approx + +from pymatgen.electronic_structure.cohp import ( + Cohp, + CompleteCohp, + IcohpCollection, + IcohpValue, + get_integrated_cohp_in_energy_range, +) +from pymatgen.electronic_structure.core import Orbital, Spin +from pymatgen.util.testing import TEST_FILES_DIR, MatSciTest + +TEST_DIR = f"{TEST_FILES_DIR}/electronic_structure/cohp" + + +class TestCohp: + def setup_method(self): + with open(f"{TEST_DIR}/cohp.json", "rb") as file: + self.cohp = Cohp.from_dict(orjson.loads(file.read())) + self.cohp_only = Cohp(self.cohp.efermi, self.cohp.energies, self.cohp.cohp) + with open(f"{TEST_DIR}/coop.json", "rb") as file: + self.coop = Cohp.from_dict(orjson.loads(file.read())) + with open(f"{TEST_DIR}/cobi.json", "rb") as file: + self.cobi = Cohp.from_dict(orjson.loads(file.read())) + + def test_as_from_dict(self): + with open(f"{TEST_DIR}/cohp.json", "rb") as file: + cohp_dict = orjson.loads(file.read()) + assert self.cohp.as_dict() == cohp_dict + + with open(f"{TEST_DIR}/cobi.json", "rb") as file: + cobi_dict = orjson.loads(file.read()) + assert self.cobi.as_dict() == cobi_dict + + def test_attributes(self): + assert len(self.cohp.energies) == 301 + assert self.cohp.efermi == approx(9.75576) + assert self.coop.efermi == approx(5.90043) + assert not self.cohp.are_coops + assert self.coop.are_coops + assert not self.coop.are_cobis + assert not self.cobi.are_coops + assert self.cobi.are_cobis + + def test_get_icohp(self): + assert self.cohp.get_icohp() == self.cohp.get_cohp(integrated=True) + assert self.cohp_only.get_icohp() is None + + def test_get_interpolated_value(self): + # icohp_ef are the ICHOP(Ef) values taken from + # the ICOHPLIST.lobster file. + icohp_ef_dict = {Spin.up: -0.10218, Spin.down: -0.19701} + icoop_ef_dict = {Spin.up: 0.24714} + icohp_ef = self.cohp.get_interpolated_value(self.cohp.efermi, integrated=True) + icoop_ef = self.coop.get_interpolated_value(self.coop.efermi, integrated=True) + assert icohp_ef_dict == approx(icohp_ef) + assert icoop_ef_dict == approx(icoop_ef) + with pytest.raises(ValueError, match="ICOHP is empty"): + self.cohp_only.get_interpolated_value(5.0, integrated=True) + + def test_str(self): + header = "#Energy COOPUp ICOOPUp \n" + + with open(f"{TEST_DIR}/cohp.str", encoding="utf-8") as file: + str_cohp = file.read() + assert str(self.cohp) == str_cohp + assert str(self.coop).strip().startswith(header) + + with open(f"{TEST_DIR}/coop.str", encoding="utf-8") as file: + str_coop = file.read() + assert str(self.coop) == str_coop + assert str(self.coop).strip().startswith(header) + + def test_antibnd_states_below_efermi(self): + assert self.cohp.has_antibnd_states_below_efermi(spin=None) == { + Spin.up: True, + Spin.down: True, + } + assert self.cohp.has_antibnd_states_below_efermi(spin=None, limit=0.5) == { + Spin.up: False, + Spin.down: False, + } + assert self.cohp.has_antibnd_states_below_efermi(spin=Spin.up, limit=0.5) == {Spin.up: False} + + +class TestIcohpValue: + def setup_method(self): + # without spin polarization + label = "1" + atom1 = "K1" + atom2 = "F2" + length = "2.3" + translation = [-1, 0, 0] + num = 1 + icohp = {Spin.up: -2.0} + are_coops = False + self.icohpvalue = IcohpValue( + label=label, + atom1=atom1, + atom2=atom2, + length=length, + translation=translation, + num=num, + icohp=icohp, + are_coops=are_coops, + ) + + label_sp = "1" + atom1_sp = "K1" + atom2_sp = "F2" + length_sp = "2.3" + translation_sp = [-1, 0, 0] + num_sp = 1 + icohp_sp = {Spin.up: -1.1, Spin.down: -1.0} + are_coops_sp = False + self.icohpvalue_sp = IcohpValue( + label=label_sp, + atom1=atom1_sp, + atom2=atom2_sp, + length=length_sp, + translation=translation_sp, + num=num_sp, + icohp=icohp_sp, + are_coops=are_coops_sp, + ) + + def test_attributes(self): + # without spin polarization + assert self.icohpvalue_sp.num_bonds == 1 + assert self.icohpvalue_sp.are_coops is False + assert self.icohpvalue_sp.is_spin_polarized + assert self.icohpvalue.icohp == approx({Spin.up: -2.0}) + + # with spin polarization + assert self.icohpvalue_sp.num_bonds == 1 + assert self.icohpvalue_sp.are_coops is False + assert self.icohpvalue_sp.is_spin_polarized + assert self.icohpvalue_sp.icohp == approx({Spin.up: -1.1, Spin.down: -1.0}) + + def test_icohpvalue(self): + # without spin polarization + assert self.icohpvalue.icohpvalue(spin=Spin.up) == approx(-2.0) + + # with spin polarization + assert self.icohpvalue_sp.icohpvalue(spin=Spin.up) == approx(-1.1) + assert self.icohpvalue_sp.icohpvalue(spin=Spin.down) == approx(-1.0) + + def test_summed_icohp(self): + # without spin polarization + assert self.icohpvalue.summed_icohp == approx(-2.0) + + # with spin polarization + assert self.icohpvalue_sp.summed_icohp == approx(-2.1) + + def test_str(self): + # without spin polarization + assert str(self.icohpvalue) == "ICOHP 1 between K1 and F2 ([-1, 0, 0]): -2.0 eV (Spin up)" + + # with spin polarization + expected = "ICOHP 1 between K1 and F2 ([-1, 0, 0]): -1.1 eV (Spin up) and -1.0 eV (Spin down)" + assert str(self.icohpvalue_sp) == expected + + +class TestCombinedIcohp: + def setup_method(self): + # without spin polarization: + are_coops = are_cobis = is_spin_polarized = False + list_atom2 = ["K2", "K2", "K2", "K2", "K2", "K2"] + list_icohp = [ + {Spin.up: -0.40075}, + {Spin.up: -0.40074}, + {Spin.up: -0.40079}, + {Spin.up: -0.40079}, + {Spin.up: -0.40074}, + {Spin.up: -0.40075}, + ] + list_icoop = [ + {Spin.up: 0.02342}, + {Spin.up: 0.02342}, + {Spin.up: 0.02343}, + {Spin.up: 0.02343}, + {Spin.up: 0.02342}, + {Spin.up: 0.02342}, + ] + list_labels = ["1", "2", "3", "4", "5", "6"] + list_length = [2.71199, 2.71199, 2.71199, 2.71199, 2.71199, 2.71199] + list_num = [1, 1, 1, 1, 1, 1] + list_atom1 = ["F1", "F1", "F1", "F1", "F1", "F1"] + list_translation = [ + [0, -1, -1], + [-1, 0, -1], + [0, 0, -1], + [-1, -1, 0], + [0, -1, 0], + [-1, 0, 0], + ] + self.icohpcollection_KF = IcohpCollection( + is_spin_polarized=is_spin_polarized, + are_coops=are_coops, + are_cobis=are_cobis, + list_labels=list_labels, + list_atom1=list_atom1, + list_atom2=list_atom2, + list_length=list_length, + list_translation=list_translation, + list_num=list_num, + list_icohp=list_icohp, + ) + + self.icoopcollection_KF = IcohpCollection( + is_spin_polarized=is_spin_polarized, + are_coops=True, + list_labels=list_labels, + list_atom1=list_atom1, + list_atom2=list_atom2, + list_length=list_length, + list_translation=list_translation, + list_num=list_num, + list_icohp=list_icoop, + ) + self.icohpcollection_orbitalwise = IcohpCollection.from_dict( + { + "@module": "pymatgen.electronic_structure.cohp", + "@class": "IcohpCollection", + "@version": None, + "list_labels": ["1", "2"], + "list_atom1": ["O5", "O5"], + "list_atom2": ["Ta2", "Ta2"], + "list_length": [1.99474, 1.99474], + "list_translation": [[0, 0, -1], [0, 0, 0]], + "list_num": [1, 1], + "list_icohp": [ + {"1": 0.29324, "-1": 0.29324}, + {"1": 0.29324, "-1": 0.29324}, + ], + "is_spin_polarized": True, + "list_orb_icohp": [ + { + "2s-6s": { + "icohp": {"1": 0.0247, "-1": 0.0247}, + "orbitals": [[2, 0], [6, 0]], + }, + "2s-5py": { + "icohp": {"1": 8e-05, "-1": 8e-05}, + "orbitals": [[2, 0], [5, 1]], + }, + }, + { + "2s-6s": { + "icohp": {"1": 0.0247, "-1": 0.0247}, + "orbitals": [[2, 0], [6, 0]], + }, + "2s-5py": { + "icohp": {"1": 0.5, "-1": 0}, + "orbitals": [[2, 0], [5, 1]], + }, + }, + ], + "are_coops": False, + "are_cobis": True, + } + ) + # with spin polarization: + list_atom2_sp = ["Fe7", "Fe9"] + list_labels_sp = ["1", "2"] + list_translation_sp = [[0, 0, 0], [0, 0, 0]] + list_length_sp = [2.83189, 2.45249] + list_atom1_sp = ["Fe8", "Fe8"] + is_spin_polarized_sp = True + are_coops_sp = False + list_num_sp = [2, 1] + list_icohp_sp = [ + {Spin.up: -0.10218, Spin.down: -0.19701}, + {Spin.up: -0.28485, Spin.down: -0.58279}, + ] + list_icoop_sp = [ + {Spin.up: -0.11389, Spin.down: -0.20828}, + {Spin.up: -0.04087, Spin.down: -0.05756}, + ] + + self.icohpcollection_Fe = IcohpCollection( + is_spin_polarized=is_spin_polarized_sp, + are_coops=are_coops_sp, + are_cobis=False, + list_labels=list_labels_sp, + list_atom1=list_atom1_sp, + list_atom2=list_atom2_sp, + list_length=list_length_sp, + list_translation=list_translation_sp, + list_num=list_num_sp, + list_icohp=list_icohp_sp, + ) + self.icoopcollection_Fe = IcohpCollection( + is_spin_polarized=is_spin_polarized_sp, + are_coops=True, + list_labels=list_labels_sp, + list_atom1=list_atom1_sp, + list_atom2=list_atom2_sp, + list_length=list_length_sp, + list_translation=list_translation_sp, + list_num=list_num_sp, + list_icohp=list_icoop_sp, + ) + + def test_get_icohp_by_label(self): + # without spin polarization + + # ICOHPs + assert self.icohpcollection_KF.get_icohp_by_label("1") == approx(-0.40075) + assert self.icohpcollection_KF.get_icohp_by_label("2") == approx(-0.40074) + assert self.icohpcollection_KF.get_icohp_by_label("3") == approx(-0.40079) + assert self.icohpcollection_KF.get_icohp_by_label("4") == approx(-0.40079) + assert self.icohpcollection_KF.get_icohp_by_label("5") == approx(-0.40074) + assert self.icohpcollection_KF.get_icohp_by_label("6") == approx(-0.40075) + + # with spin polarization + # summed spin + # ICOHPs + assert self.icohpcollection_Fe.get_icohp_by_label("1") == approx(-0.10218 - 0.19701) + assert self.icohpcollection_Fe.get_icohp_by_label("2") == approx(-0.28485 - 0.58279) + + # Spin up + # ICOHPs + assert self.icohpcollection_Fe.get_icohp_by_label("1", summed_spin_channels=False) == approx(-0.10218) + assert self.icohpcollection_Fe.get_icohp_by_label("2", summed_spin_channels=False) == approx(-0.28485) + + # Spin down + # ICOHPs + assert self.icohpcollection_Fe.get_icohp_by_label("1", summed_spin_channels=False, spin=Spin.down) == approx( + -0.19701 + ) + assert self.icohpcollection_Fe.get_icohp_by_label("2", summed_spin_channels=False, spin=Spin.down) == approx( + -0.58279 + ) + + # orbitalwise + assert self.icohpcollection_orbitalwise.get_icohp_by_label("1", orbitals="2s-6s") == approx(0.0494) + assert self.icohpcollection_orbitalwise.get_icohp_by_label( + "1", orbitals="2s-6s", spin=Spin.up, summed_spin_channels=False + ) == approx(0.0247) + assert self.icohpcollection_orbitalwise.get_icohp_by_label( + "1", orbitals="2s-6s", spin=Spin.down, summed_spin_channels=False + ) == approx(0.0247) + assert self.icohpcollection_orbitalwise.get_icohp_by_label( + "2", orbitals="2s-5py", spin=Spin.up, summed_spin_channels=False + ) == approx(0.5) + + def test_get_summed_icohp_by_label_list(self): + # without spin polarization + assert self.icohpcollection_KF.get_summed_icohp_by_label_list( + ["1", "2", "3", "4", "5", "6"], divisor=6.0 + ) == approx(-0.40076) + + # with spin polarization + sum1 = (-0.10218 - 0.19701 - 0.28485 - 0.58279) / 2.0 + sum2 = (-0.10218 - 0.28485) / 2.0 + sum3 = (-0.19701 - 0.58279) / 2.0 + assert self.icohpcollection_Fe.get_summed_icohp_by_label_list(["1", "2"], divisor=2.0) == approx(sum1) + assert self.icohpcollection_Fe.get_summed_icohp_by_label_list( + ["1", "2"], summed_spin_channels=False, divisor=2.0 + ) == approx(sum2) + assert self.icohpcollection_Fe.get_summed_icohp_by_label_list( + ["1", "2"], summed_spin_channels=False, spin=Spin.down, divisor=2.0 + ) == approx(sum3) + + def test_get_icohp_dict_by_bondlengths(self): + # without spin polarization + icohpvalue = {} + icohpvalue["1"] = { + "@module": "pymatgen.electronic_structure.cohp", + "num": 1, + "length": 2.71199, + "icohp": {Spin.up: -0.40075}, + "are_coops": False, + "are_cobis": False, + "label": "1", + "atom2": "K2", + "@class": "IcohpValue", + "atom1": "F1", + "translation": [0, -1, -1], + "orbitals": None, + } + icohpvalue["2"] = { + "@module": "pymatgen.electronic_structure.cohp", + "num": 1, + "length": 2.71199, + "icohp": {Spin.up: -0.40074}, + "are_coops": False, + "are_cobis": False, + "label": "2", + "atom2": "K2", + "@class": "IcohpValue", + "atom1": "F1", + "translation": [-1, 0, -1], + "orbitals": None, + } + icohpvalue["3"] = { + "@module": "pymatgen.electronic_structure.cohp", + "num": 1, + "length": 2.71199, + "icohp": {Spin.up: -0.40079}, + "are_coops": False, + "are_cobis": False, + "label": "3", + "atom2": "K2", + "@class": "IcohpValue", + "atom1": "F1", + "translation": [0, 0, -1], + "orbitals": None, + } + icohpvalue["4"] = { + "@module": "pymatgen.electronic_structure.cohp", + "num": 1, + "length": 2.71199, + "icohp": {Spin.up: -0.40079}, + "are_coops": False, + "are_cobis": False, + "label": "4", + "atom2": "K2", + "@class": "IcohpValue", + "atom1": "F1", + "translation": [-1, -1, 0], + "orbitals": None, + } + icohpvalue["5"] = { + "@module": "pymatgen.electronic_structure.cohp", + "num": 1, + "length": 2.71199, + "icohp": {Spin.up: -0.40074}, + "are_coops": False, + "are_cobis": False, + "label": "5", + "atom2": "K2", + "@class": "IcohpValue", + "atom1": "F1", + "translation": [0, -1, 0], + "orbitals": None, + } + icohpvalue["6"] = { + "@module": "pymatgen.electronic_structure.cohp", + "num": 1, + "length": 2.71199, + "icohp": {Spin.up: -0.40075}, + "are_coops": False, + "are_cobis": False, + "label": "6", + "atom2": "K2", + "@class": "IcohpValue", + "atom1": "F1", + "translation": [-1, 0, 0], + "orbitals": None, + } + + dict_KF = self.icohpcollection_KF.get_icohp_dict_by_bondlengths(minbondlength=0.0, maxbondlength=8.0) + for key, value in sorted(dict_KF.items()): + v = value.as_dict() + if "@version" in v: + v.pop("@version") + assert v == icohpvalue[key] + + assert self.icohpcollection_KF.get_icohp_dict_by_bondlengths(minbondlength=0.0, maxbondlength=1.0) == {} + + # with spin polarization + icohpvalue_spin = {} + icohpvalue_spin["1"] = { + "num": 2, + "atom2": "Fe7", + "translation": [0, 0, 0], + "@module": "pymatgen.electronic_structure.cohp", + "are_coops": False, + "are_cobis": False, + "atom1": "Fe8", + "label": "1", + "length": 2.83189, + "@class": "IcohpValue", + "icohp": {Spin.up: -0.10218, Spin.down: -0.19701}, + "orbitals": None, + } + icohpvalue_spin["2"] = { + "num": 1, + "atom2": "Fe9", + "translation": [0, 0, 0], + "@module": "pymatgen.electronic_structure.cohp", + "are_coops": False, + "are_cobis": False, + "atom1": "Fe8", + "label": "2", + "length": 2.45249, + "@class": "IcohpValue", + "icohp": {Spin.up: -0.28485, Spin.down: -0.58279}, + "orbitals": None, + } + + dict_Fe = self.icohpcollection_Fe.get_icohp_dict_by_bondlengths(minbondlength=0.0, maxbondlength=8.0) + for key, value in sorted(dict_Fe.items()): + v = value.as_dict() + if "@version" in v: + v.pop("@version") + assert v == icohpvalue_spin[key] + + dict_Fe2 = self.icohpcollection_Fe.get_icohp_dict_by_bondlengths(minbondlength=2.5, maxbondlength=2.9) + assert len(dict_Fe2) == 1 + for key, value in sorted(dict_Fe2.items()): + v = value.as_dict() + if "@version" in v: + v.pop("@version") + assert v == icohpvalue_spin[key] + + def test_get_icohp_dict_of_site(self): + # without spin polarization + icohpvalue = {} + icohpvalue["1"] = { + "translation": [0, -1, -1], + "are_coops": False, + "are_cobis": False, + "@module": "pymatgen.electronic_structure.cohp", + "length": 2.71199, + "atom2": "K2", + "@class": "IcohpValue", + "atom1": "F1", + "num": 1, + "label": "1", + "icohp": {Spin.up: -0.40075}, + "orbitals": None, + } + icohpvalue["2"] = { + "translation": [-1, 0, -1], + "are_coops": False, + "are_cobis": False, + "@module": "pymatgen.electronic_structure.cohp", + "length": 2.71199, + "atom2": "K2", + "@class": "IcohpValue", + "atom1": "F1", + "num": 1, + "label": "2", + "icohp": {Spin.up: -0.40074}, + "orbitals": None, + } + icohpvalue["3"] = { + "translation": [0, 0, -1], + "are_coops": False, + "are_cobis": False, + "@module": "pymatgen.electronic_structure.cohp", + "length": 2.71199, + "atom2": "K2", + "@class": "IcohpValue", + "atom1": "F1", + "num": 1, + "label": "3", + "icohp": {Spin.up: -0.40079}, + "orbitals": None, + } + icohpvalue["4"] = { + "translation": [-1, -1, 0], + "are_coops": False, + "are_cobis": False, + "@module": "pymatgen.electronic_structure.cohp", + "length": 2.71199, + "atom2": "K2", + "@class": "IcohpValue", + "atom1": "F1", + "num": 1, + "label": "4", + "icohp": {Spin.up: -0.40079}, + "orbitals": None, + } + icohpvalue["5"] = { + "translation": [0, -1, 0], + "are_coops": False, + "are_cobis": False, + "@module": "pymatgen.electronic_structure.cohp", + "length": 2.71199, + "atom2": "K2", + "@class": "IcohpValue", + "atom1": "F1", + "num": 1, + "label": "5", + "icohp": {Spin.up: -0.40074}, + "orbitals": None, + } + icohpvalue["6"] = { + "translation": [-1, 0, 0], + "are_coops": False, + "are_cobis": False, + "@module": "pymatgen.electronic_structure.cohp", + "length": 2.71199, + "atom2": "K2", + "@class": "IcohpValue", + "atom1": "F1", + "num": 1, + "label": "6", + "icohp": {Spin.up: -0.40075}, + "orbitals": None, + } + + dict_KF = self.icohpcollection_KF.get_icohp_dict_of_site(site=0) + + for key, value in sorted(dict_KF.items()): + v = value.as_dict() + if "@version" in v: + v.pop("@version") + assert v == icohpvalue[key] + + # compare number of results dependent on minsummedicohp, maxsummedicohp,minbondlength, maxbondlength, and + # only_bonds_to + dict_KF_2 = self.icohpcollection_KF.get_icohp_dict_of_site( + site=0, + minsummedicohp=None, + maxsummedicohp=-0.0, + minbondlength=0.0, + maxbondlength=8.0, + ) + dict_KF_3 = self.icohpcollection_KF.get_icohp_dict_of_site( + site=0, + minsummedicohp=None, + maxsummedicohp=-0.5, + minbondlength=0.0, + maxbondlength=8.0, + ) + dict_KF_4 = self.icohpcollection_KF.get_icohp_dict_of_site( + site=0, + minsummedicohp=0.0, + maxsummedicohp=None, + minbondlength=0.0, + maxbondlength=8.0, + ) + dict_KF_5 = self.icohpcollection_KF.get_icohp_dict_of_site( + site=0, + minsummedicohp=None, + maxsummedicohp=None, + minbondlength=0.0, + maxbondlength=2.0, + ) + dict_KF_6 = self.icohpcollection_KF.get_icohp_dict_of_site( + site=0, + minsummedicohp=None, + maxsummedicohp=None, + minbondlength=3.0, + maxbondlength=8.0, + ) + dict_KF_7 = self.icohpcollection_KF.get_icohp_dict_of_site(site=0, only_bonds_to=["K"]) + dict_KF_8 = self.icohpcollection_KF.get_icohp_dict_of_site(site=1, only_bonds_to=["K"]) + dict_KF_9 = self.icohpcollection_KF.get_icohp_dict_of_site(site=1, only_bonds_to=["F"]) + + assert len(dict_KF_2) == 6 + assert len(dict_KF_3) == 0 + assert len(dict_KF_4) == 0 + assert len(dict_KF_5) == 0 + assert len(dict_KF_6) == 0 + assert len(dict_KF_7) == 6 + assert len(dict_KF_8) == 0 + assert len(dict_KF_9) == 6 + + # spin polarization + + dict_Fe = self.icohpcollection_Fe.get_icohp_dict_of_site(site=0) + assert len(dict_Fe) == 0 + + # Fe8 + dict_Fe2 = self.icohpcollection_Fe.get_icohp_dict_of_site(site=7) + assert len(dict_Fe2) == 2 + # Test the values + + icohplist_Fe = {} + icohplist_Fe["1"] = { + "are_coops": False, + "are_cobis": False, + "translation": [0, 0, 0], + "icohp": {Spin.down: -0.19701, Spin.up: -0.10218}, + "length": 2.83189, + "@module": "pymatgen.electronic_structure.cohp", + "atom1": "Fe8", + "atom2": "Fe7", + "label": "1", + "orbitals": None, + "@class": "IcohpValue", + "num": 2, + } + icohplist_Fe["2"] = { + "are_coops": False, + "are_cobis": False, + "translation": [0, 0, 0], + "icohp": {Spin.down: -0.58279, Spin.up: -0.28485}, + "length": 2.45249, + "@module": "pymatgen.electronic_structure.cohp", + "atom1": "Fe8", + "atom2": "Fe9", + "label": "2", + "orbitals": None, + "@class": "IcohpValue", + "num": 1, + } + + for key, value in sorted(dict_Fe2.items()): + v = value.as_dict() + if "@version" in v: + v.pop("@version") + assert v == icohplist_Fe[key] + + # Fe9 + dict_Fe3 = self.icohpcollection_Fe.get_icohp_dict_of_site(site=8) + assert len(dict_Fe3) == 1 + + # compare number of results dependent on minsummedicohp, maxsummedicohp,minbondlength, maxbondlength + # Fe8 + dict_Fe4 = self.icohpcollection_Fe.get_icohp_dict_of_site( + site=7, + minsummedicohp=-0.3, + maxsummedicohp=None, + minbondlength=0.0, + maxbondlength=8.0, + ) + assert len(dict_Fe4) == 1 + values = list(dict_Fe4.values()) + v = values[0].as_dict() + if "@version" in v: + v.pop("@version") + assert v == icohplist_Fe["1"] + + dict_Fe5 = self.icohpcollection_Fe.get_icohp_dict_of_site( + site=7, + minsummedicohp=None, + maxsummedicohp=-0.3, + minbondlength=0.0, + maxbondlength=8.0, + ) + assert len(dict_Fe5) == 1 + values = list(dict_Fe5.values()) + v = values[0].as_dict() + if "@version" in v: + v.pop("@version") + assert v == icohplist_Fe["2"] + + dict_Fe6 = self.icohpcollection_Fe.get_icohp_dict_of_site( + site=7, + minsummedicohp=None, + maxsummedicohp=None, + minbondlength=0.0, + maxbondlength=2.5, + ) + + assert len(dict_Fe6) == 1 + values = list(dict_Fe6.values()) + v = values[0].as_dict() + if "@version" in v: + v.pop("@version") + assert v == icohplist_Fe["2"] + + dict_Fe7 = self.icohpcollection_Fe.get_icohp_dict_of_site( + site=7, + minsummedicohp=None, + maxsummedicohp=None, + minbondlength=2.5, + maxbondlength=8.0, + ) + assert len(dict_Fe7) == 1 + values = list(dict_Fe7.values()) + v = values[0].as_dict() + if "@version" in v: + v.pop("@version") + assert v == icohplist_Fe["1"] + + def test_extremum_icohpvalue(self): + # without spin polarization + # ICOHPs + assert self.icohpcollection_KF.extremum_icohpvalue() == approx(-0.40079) + # ICOOPs + assert self.icoopcollection_KF.extremum_icohpvalue() == approx(0.02343) + # with spin polarization + # summed spin + # ICOHPs + assert self.icohpcollection_Fe.extremum_icohpvalue() == approx(-0.86764) + assert self.icoopcollection_Fe.extremum_icohpvalue() == approx(-0.09842999999999999) + # ICOOPs + # spin up + # ICOHPs + assert self.icohpcollection_Fe.extremum_icohpvalue(summed_spin_channels=False) == approx(-0.28485) + # ICOOPs + assert self.icoopcollection_Fe.extremum_icohpvalue(summed_spin_channels=False) == approx(-0.04087) + # spin down + # ICOHPs + assert self.icohpcollection_Fe.extremum_icohpvalue(summed_spin_channels=False, spin=Spin.down) == approx( + -0.58279 + ) + # ICOOPs + assert self.icoopcollection_Fe.extremum_icohpvalue(summed_spin_channels=False, spin=Spin.down) == approx( + -0.05756 + ) + + +class TestCompleteCohp(MatSciTest): + def setup_method(self): + filepath = f"{TEST_DIR}/complete_cohp_lobster.json" + with open(filepath, "rb") as file: + self.cohp_lobster_dict = CompleteCohp.from_dict(orjson.loads(file.read())) + filepath = f"{TEST_DIR}/complete_coop_lobster.json" + with open(filepath, "rb") as file: + self.coop_lobster_dict = CompleteCohp.from_dict(orjson.loads(file.read())) + filepath = f"{TEST_DIR}/complete_cohp_lmto.json" + with open(filepath, "rb") as file: + self.cohp_lmto_dict = CompleteCohp.from_dict(orjson.loads(file.read())) + filepath = f"{TEST_DIR}/complete_cohp_orbitalwise.json" + with open(filepath, "rb") as file: + self.cohp_orb_dict = CompleteCohp.from_dict(orjson.loads(file.read())) + # Lobster 3.0 + filepath = f"{TEST_DIR}/complete_cohp_forb.json" + with open(filepath, "rb") as file: + self.cohp_lobster_forb_dict = CompleteCohp.from_dict(orjson.loads(file.read())) + + # Lobster 2.0 + filepath = f"{TEST_DIR}/COPL.BiSe" + structure = f"{TEST_DIR}/CTRL.BiSe" + self.cohp_lmto = CompleteCohp.from_file("lmto", filename=filepath, structure_file=structure) + filepath = f"{TEST_DIR}/COHPCAR.lobster.gz" + structure = f"{TEST_DIR}/POSCAR" + self.cohp_lobster = CompleteCohp.from_file("lobster", filename=filepath, structure_file=structure) + # with open(f"{TEST_DIR}/complete_cohp_lobster.json", "w", encoding="utf-8") as file: + # json.dump(self.cohp_lobster.as_dict(), file) + filepath = f"{TEST_DIR}/COOPCAR.lobster.BiSe.gz" + structure = f"{TEST_DIR}/POSCAR.BiSe" + self.coop_lobster = CompleteCohp.from_file( + "lobster", filename=filepath, structure_file=structure, are_coops=True + ) + filepath = f"{TEST_DIR}/COHPCAR.lobster.orbitalwise.gz" + structure = f"{TEST_DIR}/POSCAR.orbitalwise" + self.cohp_orb = CompleteCohp.from_file("lobster", filename=filepath, structure_file=structure) + # with open(f"{TEST_DIR}/complete_cohp_orbitalwise.json", "w", encoding="utf-8") as file: + # json.dump(self.cohp_orb.as_dict(), file) + filepath = f"{TEST_DIR}/COHPCAR.lobster.notot.orbitalwise.gz" + self.cohp_notot = CompleteCohp.from_file("lobster", filename=filepath, structure_file=structure) + # Lobster 3.0 + filepath = f"{TEST_DIR}/COHPCAR.lobster.Na2UO4.gz" + structure = f"{TEST_DIR}/POSCAR.Na2UO4" + self.cohp_lobster_forb = CompleteCohp.from_file("lobster", filename=filepath, structure_file=structure) + + # spinpolarized case: + filepath = f"{TEST_DIR}/environments/COHPCAR.lobster.mp-190_2.gz" + structure = f"{TEST_DIR}/environments/CONTCAR.mp-190.gz" + self.cohp_lobster_spin_polarized = CompleteCohp.from_file( + "lobster", filename=filepath, structure_file=structure + ) + # COBI + filepath = f"{TEST_DIR}/COBICAR.lobster.gz" + structure = f"{TEST_DIR}/POSCAR.COBI" + + self.cobi = CompleteCohp.from_file("lobster", filename=filepath, structure_file=structure, are_cobis=True) + + # COBI multi-center + filepath = f"{TEST_DIR}/COBICAR.lobster.GeTe.multi.orbitalwise.full" + structure = f"{TEST_DIR}/POSCAR.GeTe" + self.cobi_multi = CompleteCohp.from_file( + "lobster", + filename=filepath, + structure_file=structure, + are_multi_center_cobis=True, + ) + + # COBI multi-center + filepath = f"{TEST_DIR}/COBICAR.lobster.B2H6.spin" + structure = f"{TEST_DIR}/POSCAR.B2H6" + self.cobi_multi_B2H6 = CompleteCohp.from_file( + "lobster", + filename=filepath, + structure_file=structure, + are_multi_center_cobis=True, + ) + + # COBI multi-center + filepath = f"{TEST_DIR}/COBICAR.lobster.B2H6.spin.average.2" + structure = f"{TEST_DIR}/POSCAR.B2H6" + self.cobi_multi_B2H6_average2 = CompleteCohp.from_file( + "lobster", filename=filepath, structure_file=structure, are_cobis=True + ) + + def test_attributes(self): + assert not self.cohp_lobster.are_coops + assert not self.cohp_lobster.are_cobis + assert not self.cohp_lobster_dict.are_coops + assert not self.cohp_lmto.are_coops + assert not self.cohp_lmto_dict.are_coops + assert self.coop_lobster.are_coops + assert self.coop_lobster_dict.are_coops + assert not self.cohp_lobster_forb.are_coops + assert not self.cohp_lobster_forb_dict.are_coops + + assert len(self.cohp_lobster.energies) == 301 + assert len(self.cohp_lmto.energies) == 801 + assert len(self.coop_lobster.energies) == 241 + assert len(self.cohp_lobster_forb.energies) == 7 + + assert self.cohp_lobster.efermi == approx(9.75576) + assert self.cohp_lmto.efermi == approx(-2.3433) + assert self.coop_lobster.efermi == approx(5.90043) + assert self.cohp_lobster_forb.efermi == approx(4.12875) + + assert self.cobi.are_cobis + assert not self.cobi.are_coops + + assert self.cohp_lobster_forb.cohp[Spin.up][0] == approx(0.00000) + assert self.cohp_lobster_forb.icohp[Spin.up][0] == approx(-0.09040) + + def test_average_multi_center_cobi(self): + # tests if the averages for a mult-center cobi are computed in the same way as in Lobster + for cohp1, cohp2 in zip( + self.cobi_multi_B2H6.get_cohp_by_label("average").cohp[Spin.up], + self.cobi_multi_B2H6_average2.get_cohp_by_label("average").cohp[Spin.up], + strict=True, + ): + assert cohp1 == approx(cohp2, abs=1e-4) + + for cohp1, cohp2 in zip( + self.cobi_multi_B2H6.get_cohp_by_label("average").cohp[Spin.down], + self.cobi_multi_B2H6_average2.get_cohp_by_label("average").cohp[Spin.down], + strict=True, + ): + assert cohp1 == approx(cohp2, abs=1e-4) + + for icohp1, icohp2 in zip( + self.cobi_multi_B2H6.get_cohp_by_label("average").icohp[Spin.up], + self.cobi_multi_B2H6_average2.get_cohp_by_label("average").icohp[Spin.up], + strict=True, + ): + assert icohp1 == approx(icohp2, abs=1e-4) + + for icohp1, icohp2 in zip( + self.cobi_multi_B2H6.get_cohp_by_label("average").icohp[Spin.down], + self.cobi_multi_B2H6_average2.get_cohp_by_label("average").icohp[Spin.down], + strict=True, + ): + assert icohp1 == approx(icohp2, abs=1e-4) + + def test_dict(self): + # The JSON files are dict representations of the COHPs from the LMTO + # and LOBSTER calculations and should thus be the same. + + def is_equal(a, b): + a_dict = a.as_dict() + b_dict = b.as_dict() + del a_dict["structure"] + del b_dict["structure"] + return a_dict == b_dict and a.structure == b.structure + + assert is_equal(self.cohp_lobster, self.cohp_lobster_dict) + assert is_equal(self.cohp_orb, self.cohp_orb_dict) + # Lobster 3.0, including f orbitals + + assert is_equal(self.cohp_lobster_forb, self.cohp_lobster_forb_dict) + + # Testing the LMTO dicts will be more involved. Since the average + # is calculated and not read, there may be differences in rounding + # with a very small number of matrix elements, which would cause the + # test to fail + cohp_lmto_dict = self.cohp_lmto.as_dict() + for key in ["COHP", "ICOHP"]: + assert_allclose( + cohp_lmto_dict[key]["average"]["1"], + self.cohp_lmto_dict.as_dict()[key]["average"]["1"], + 5, + ) + # check if the same dicts are generated + cobi_new = CompleteCohp.from_dict(self.cobi_multi.as_dict()) + assert is_equal(self.cobi_multi, cobi_new) + # for key in cohp_lmto_dict: + # if key not in ["COHP", "ICOHP"]: + # assert cohp_lmto_dict[key] == self.cohp_lmto_dict.as_dict()[key] + # else: + # for bond in cohp_lmto_dict[key]: + # if bond != "average": + # assert cohp_lmto_dict[key][bond] == self.cohp_lmto_dict.as_dict()[key][bond] + + def test_icohp_values(self): + # icohp_ef are the ICHOP(Ef) values taken from + # the ICOHPLIST.lobster file. + icohp_ef_dict = { + "1": {Spin.up: -0.10218, Spin.down: -0.19701}, + "2": {Spin.up: -0.28485, Spin.down: -0.58279}, + } + all_cohps_lobster = self.cohp_lobster.all_cohps + for bond, val in icohp_ef_dict.items(): + icohp_ef = all_cohps_lobster[bond].get_interpolated_value(self.cohp_lobster.efermi, integrated=True) + assert val == icohp_ef + + icoop_ef_dict = { + "1": {Spin.up: 0.14245}, + "2": {Spin.up: -0.04118}, + "3": {Spin.up: 0.14245}, + "4": {Spin.up: -0.04118}, + "5": {Spin.up: -0.03516}, + "6": {Spin.up: 0.10745}, + "7": {Spin.up: -0.03516}, + "8": {Spin.up: 0.10745}, + "9": {Spin.up: -0.12395}, + "10": {Spin.up: 0.24714}, + "11": {Spin.up: -0.12395}, + } + all_coops_lobster = self.coop_lobster.all_cohps + for bond, val in icoop_ef_dict.items(): + icoop_ef = all_coops_lobster[bond].get_interpolated_value(self.coop_lobster.efermi, integrated=True) + assert val == icoop_ef + + def test_get_cohp_by_label(self): + assert self.cohp_orb.get_cohp_by_label("1").energies[0] == approx(-11.7225) + assert self.cohp_orb.get_cohp_by_label("1").energies[5] == approx(-11.47187) + assert not self.cohp_orb.get_cohp_by_label("1").are_coops + assert self.cohp_orb.get_cohp_by_label("1").cohp[Spin.up][0] == approx(0.0) + assert self.cohp_orb.get_cohp_by_label("1").cohp[Spin.up][300] == approx(0.03392) + assert self.cohp_orb.get_cohp_by_label("average").cohp[Spin.up][230] == approx(-0.08792) + assert self.cohp_orb.get_cohp_by_label("average").energies[230] == approx(-0.19368000000000007) + assert not self.cohp_orb.get_cohp_by_label("average").are_coops + # test methods from super class that could be overwritten + assert self.cohp_orb.get_icohp()[Spin.up][3] == approx(0.0) + assert self.cohp_orb.get_cohp()[Spin.up][3] == approx(0.0) + + def test_get_cohp_by_label_summed_spin(self): + # files without spin polarization + assert self.cohp_orb.get_cohp_by_label("1", summed_spin_channels=True).energies[0] == approx(-11.7225) + assert self.cohp_orb.get_cohp_by_label("1", summed_spin_channels=True).energies[5] == approx(-11.47187) + assert not self.cohp_orb.get_cohp_by_label("1", summed_spin_channels=True).are_coops + assert self.cohp_orb.get_cohp_by_label("1", summed_spin_channels=True).cohp[Spin.up][0] == approx(0.0) + assert self.cohp_orb.get_cohp_by_label("1", summed_spin_channels=True).cohp[Spin.up][300] == approx(0.03392) + assert self.cohp_orb.get_cohp_by_label("average", summed_spin_channels=True).cohp[Spin.up][230] == approx( + -0.08792 + ) + assert self.cohp_orb.get_cohp_by_label("average", summed_spin_channels=True).energies[230] == approx( + -0.19368000000000007 + ) + assert not self.cohp_orb.get_cohp_by_label("average", summed_spin_channels=True).are_coops + + # file with spin polarization + assert self.cohp_lobster_spin_polarized.get_cohp_by_label("1", summed_spin_channels=False).cohp[Spin.up][ + 300 + ] * 2 == approx( + self.cohp_lobster_spin_polarized.get_cohp_by_label("1", summed_spin_channels=True).cohp[Spin.up][300] + ) + assert self.cohp_lobster_spin_polarized.get_cohp_by_label("1", summed_spin_channels=False).cohp[Spin.down][ + 300 + ] * 2 == approx( + self.cohp_lobster_spin_polarized.get_cohp_by_label("1", summed_spin_channels=True).cohp[Spin.up][300] + ) + assert self.cohp_lobster_spin_polarized.get_cohp_by_label("1", summed_spin_channels=True).energies[0] == approx( + -15.03759 + 1.96204 + ) + assert self.cohp_lobster_spin_polarized.get_cohp_by_label("1", summed_spin_channels=True).energies[5] == approx( + -14.78697 + 1.96204 + ) + assert not self.cohp_lobster_spin_polarized.get_cohp_by_label("1", summed_spin_channels=True).are_coops + + def test_get_summed_cohp_by_label_list(self): + assert self.cohp_orb.get_summed_cohp_by_label_list(["1"]).energies[0] == approx(-11.7225) + assert self.cohp_orb.get_summed_cohp_by_label_list(["1", "1"]).energies[0] == approx(-11.7225) + assert self.cohp_orb.get_summed_cohp_by_label_list(["1"]).energies[5] == approx(-11.47187) + assert not self.cohp_orb.get_summed_cohp_by_label_list(["1"]).are_coops + assert self.cohp_orb.get_summed_cohp_by_label_list(["1"]).cohp[Spin.up][0] == approx(0.0) + assert self.cohp_orb.get_summed_cohp_by_label_list(["1", "1"]).cohp[Spin.up][0] == approx(0.0) + assert self.cohp_orb.get_summed_cohp_by_label_list(["1", "1"]).cohp[Spin.up][300] == approx(0.03392 * 2.0) + assert self.cohp_orb.get_summed_cohp_by_label_list(["1", "1"], divisor=2).cohp[Spin.up][300] == approx(0.03392) + + def test_get_summed_cohp_by_label_list_summed_spin(self): + # files without spin polarization + assert self.cohp_orb.get_summed_cohp_by_label_list(["1"], summed_spin_channels=True).energies[0] == approx( + -11.7225 + ) + assert self.cohp_orb.get_summed_cohp_by_label_list(["1", "1"], summed_spin_channels=True).energies[0] == approx( + -11.7225 + ) + assert self.cohp_orb.get_summed_cohp_by_label_list(["1"], summed_spin_channels=True).energies[5] == approx( + -11.47187 + ) + assert not self.cohp_orb.get_summed_cohp_by_label_list(["1"], summed_spin_channels=True).are_coops + assert self.cohp_orb.get_summed_cohp_by_label_list(["1"], summed_spin_channels=True).cohp[Spin.up][0] == approx( + 0.0 + ) + assert self.cohp_orb.get_summed_cohp_by_label_list(["1", "1"], summed_spin_channels=True).cohp[Spin.up][ + 0 + ] == approx(0.0) + assert self.cohp_orb.get_summed_cohp_by_label_list(["1", "1"], summed_spin_channels=True).cohp[Spin.up][ + 300 + ] == approx(0.03392 * 2.0) + assert self.cohp_orb.get_summed_cohp_by_label_list(["1", "1"], summed_spin_channels=True, divisor=2).cohp[ + Spin.up + ][300] == approx(0.03392) + + # file with spin polarization + assert self.cohp_lobster_spin_polarized.get_summed_cohp_by_label_list(["1"], summed_spin_channels=False).cohp[ + Spin.up + ][300] * 2 == approx( + self.cohp_lobster_spin_polarized.get_summed_cohp_by_label_list(["1"], summed_spin_channels=True).cohp[ + Spin.up + ][300] + ) + assert self.cohp_lobster_spin_polarized.get_summed_cohp_by_label_list(["1"], summed_spin_channels=False).cohp[ + Spin.down + ][300] * 2 == approx( + self.cohp_lobster_spin_polarized.get_summed_cohp_by_label_list(["1"], summed_spin_channels=True).cohp[ + Spin.up + ][300] + ) + assert self.cohp_lobster_spin_polarized.get_summed_cohp_by_label_list( + ["1", "1"], summed_spin_channels=True + ).energies[0] == approx(-15.03759 + 1.96204) + assert self.cohp_lobster_spin_polarized.get_summed_cohp_by_label_list( + ["1"], summed_spin_channels=True + ).energies[5] == approx(-14.78697 + 1.96204) + assert not self.cohp_lobster_spin_polarized.get_summed_cohp_by_label_list( + ["1"], summed_spin_channels=True + ).are_coops + + def test_get_summed_cohp_by_label_and_orbital_list(self): + ref = self.cohp_orb.orb_res_cohp["1"]["4s-4px"] + ref2 = self.cohp_orb.orb_res_cohp["1"]["4px-4pz"] + cohp_label = self.cohp_orb.get_summed_cohp_by_label_and_orbital_list(["1"], ["4s-4px"]) + cohp_label2 = self.cohp_orb.get_summed_cohp_by_label_and_orbital_list(["1", "1"], ["4s-4px", "4s-4px"]) + cohp_label2x = self.cohp_orb.get_summed_cohp_by_label_and_orbital_list( + ["1", "1"], ["4s-4px", "4s-4px"], divisor=2 + ) + cohp_label3 = self.cohp_orb.get_summed_cohp_by_label_and_orbital_list(["1", "1"], ["4px-4pz", "4s-4px"]) + + assert_allclose(cohp_label.cohp[Spin.up], ref["COHP"][Spin.up]) + assert_allclose(cohp_label2.cohp[Spin.up], ref["COHP"][Spin.up] * 2.0) + assert_allclose(cohp_label3.cohp[Spin.up], ref["COHP"][Spin.up] + ref2["COHP"][Spin.up]) + assert_allclose(cohp_label.icohp[Spin.up], ref["ICOHP"][Spin.up]) + assert_allclose(cohp_label2.icohp[Spin.up], ref["ICOHP"][Spin.up] * 2.0) + assert_allclose(cohp_label2x.icohp[Spin.up], ref["ICOHP"][Spin.up]) + assert_allclose(cohp_label3.icohp[Spin.up], ref["ICOHP"][Spin.up] + ref2["ICOHP"][Spin.up]) + expected_msg = "label_list and orbital_list don't have the same length" + with pytest.raises(ValueError, match=expected_msg): + self.cohp_orb.get_summed_cohp_by_label_and_orbital_list(["1"], ["4px-4pz", "4s-4px"]) + with pytest.raises(ValueError, match=expected_msg): + self.cohp_orb.get_summed_cohp_by_label_and_orbital_list(["1", "2"], ["4s-4px"]) + + def test_get_summed_cohp_by_label_and_orbital_list_summed_spin_channels(self): + ref = self.cohp_orb.orb_res_cohp["1"]["4s-4px"] + ref2 = self.cohp_orb.orb_res_cohp["1"]["4px-4pz"] + cohp_label = self.cohp_orb.get_summed_cohp_by_label_and_orbital_list( + ["1"], ["4s-4px"], summed_spin_channels=True + ) + cohp_label2 = self.cohp_orb.get_summed_cohp_by_label_and_orbital_list( + ["1", "1"], ["4s-4px", "4s-4px"], summed_spin_channels=True + ) + cohp_label2x = self.cohp_orb.get_summed_cohp_by_label_and_orbital_list( + ["1", "1"], ["4s-4px", "4s-4px"], divisor=2, summed_spin_channels=True + ) + cohp_label3 = self.cohp_orb.get_summed_cohp_by_label_and_orbital_list( + ["1", "1"], ["4px-4pz", "4s-4px"], summed_spin_channels=True + ) + + assert_allclose(cohp_label.cohp[Spin.up], ref["COHP"][Spin.up]) + assert_allclose(cohp_label2.cohp[Spin.up], ref["COHP"][Spin.up] * 2.0) + assert_allclose(cohp_label3.cohp[Spin.up], ref["COHP"][Spin.up] + ref2["COHP"][Spin.up]) + assert_allclose(cohp_label.icohp[Spin.up], ref["ICOHP"][Spin.up]) + assert_allclose(cohp_label2.icohp[Spin.up], ref["ICOHP"][Spin.up] * 2.0) + assert_allclose(cohp_label2x.icohp[Spin.up], ref["ICOHP"][Spin.up]) + assert_allclose(cohp_label3.icohp[Spin.up], ref["ICOHP"][Spin.up] + ref2["ICOHP"][Spin.up]) + expected_msg = "label_list and orbital_list don't have the same length" + with pytest.raises(ValueError, match=expected_msg): + self.cohp_orb.get_summed_cohp_by_label_and_orbital_list( + ["1"], ["4px-4pz", "4s-4px"], summed_spin_channels=True + ) + with pytest.raises(ValueError, match=expected_msg): + self.cohp_orb.get_summed_cohp_by_label_and_orbital_list(["1", "2"], ["4s-4px"], summed_spin_channels=True) + + # files with spin polarization + assert self.cohp_lobster_spin_polarized.get_summed_cohp_by_label_and_orbital_list( + ["1"], ["6s-6s"], summed_spin_channels=False + ).cohp[Spin.up][300] * 2 == approx( + self.cohp_lobster_spin_polarized.get_summed_cohp_by_label_and_orbital_list( + ["1"], ["6s-6s"], summed_spin_channels=True + ).cohp[Spin.up][300] + ) + assert self.cohp_lobster_spin_polarized.get_summed_cohp_by_label_and_orbital_list( + ["1"], ["6s-6s"], summed_spin_channels=False + ).cohp[Spin.down][300] * 2 == approx( + self.cohp_lobster_spin_polarized.get_summed_cohp_by_label_and_orbital_list( + ["1"], ["6s-6s"], summed_spin_channels=True + ).cohp[Spin.up][300] + ) + assert self.cohp_lobster_spin_polarized.get_summed_cohp_by_label_and_orbital_list( + ["1"], ["6s-6s"], summed_spin_channels=True + ).energies[0] == approx(-15.03759 + 1.96204) + assert self.cohp_lobster_spin_polarized.get_summed_cohp_by_label_and_orbital_list( + ["1"], ["6s-6s"], summed_spin_channels=True + ).energies[5] == approx(-14.78697 + 1.96204) + assert not self.cohp_lobster_spin_polarized.get_summed_cohp_by_label_and_orbital_list( + ["1"], ["6s-6s"], summed_spin_channels=True + ).are_coops + + def test_orbital_resolved_cohp(self): + # When read from a COHPCAR file, total COHPs are calculated from + # the orbital-resolved COHPs if the total is missing. This may be + # case for LOBSTER version 2.2.0 and earlier due to a bug with the + # cohpgenerator keyword. The calculated total should be approximately + # the total COHP calculated by LOBSTER. Due to numerical errors in + # the LOBSTER calculation, the precision is not very high though. + + assert_allclose( + self.cohp_orb.all_cohps["1"].cohp[Spin.up], + self.cohp_notot.all_cohps["1"].cohp[Spin.up], + atol=1e-3, + ) + assert_allclose( + self.cohp_orb.all_cohps["1"].icohp[Spin.up], + self.cohp_notot.all_cohps["1"].icohp[Spin.up], + atol=1e-3, + ) + + # Tests different methods for getting orbital-resolved COHPs + ref = self.cohp_orb.orb_res_cohp["1"]["4s-4px"] + cohp_label = self.cohp_orb.get_orbital_resolved_cohp("1", "4s-4px") + assert cohp_label.cohp == ref["COHP"] + assert cohp_label.icohp == ref["ICOHP"] + orbitals = [[Orbital.s, Orbital.px], ["s", "px"], [0, 3]] + cohps = [self.cohp_orb.get_orbital_resolved_cohp("1", [[4, orb[0]], [4, orb[1]]]) for orb in orbitals] + for cohp in cohps: + assert cohp.as_dict() == cohp_label.as_dict() + + def test_orbital_resolved_cohp_summed_spin_channels(self): + ref = self.cohp_orb.orb_res_cohp["1"]["4s-4px"] + cohp_label = self.cohp_orb.get_orbital_resolved_cohp("1", "4s-4px", summed_spin_channels=True) + assert cohp_label.cohp == ref["COHP"] + assert cohp_label.icohp == ref["ICOHP"] + orbitals = [[Orbital.s, Orbital.px], ["s", "px"], [0, 3]] + cohps = [ + self.cohp_orb.get_orbital_resolved_cohp("1", [[4, orb[0]], [4, orb[1]]], summed_spin_channels=True) + for orb in orbitals + ] + + for cohp in cohps: + assert cohp.as_dict() == cohp_label.as_dict() + + # spin polarization + assert self.cohp_lobster_spin_polarized.get_orbital_resolved_cohp( + "1", "6s-6s", summed_spin_channels=False + ).cohp[Spin.up][300] * 2 == approx( + self.cohp_lobster_spin_polarized.get_orbital_resolved_cohp("1", "6s-6s", summed_spin_channels=True).cohp[ + Spin.up + ][300] + ) + assert self.cohp_lobster_spin_polarized.get_orbital_resolved_cohp( + "1", "6s-6s", summed_spin_channels=False + ).cohp[Spin.down][300] * 2 == approx( + self.cohp_lobster_spin_polarized.get_orbital_resolved_cohp("1", "6s-6s", summed_spin_channels=True).cohp[ + Spin.up + ][300] + ) + assert self.cohp_lobster_spin_polarized.get_orbital_resolved_cohp( + "1", "6s-6s", summed_spin_channels=True + ).energies[0] == approx(-15.03759 + 1.96204) + assert self.cohp_lobster_spin_polarized.get_orbital_resolved_cohp( + "1", "6s-6s", summed_spin_channels=True + ).energies[5] == approx(-14.78697 + 1.96204) + assert not self.cohp_lobster_spin_polarized.get_orbital_resolved_cohp( + "1", "6s-6s", summed_spin_channels=True + ).are_coops + + +class TestMethod: + def setup_method(self): + filepath = f"{TEST_DIR}/COHPCAR.lobster.gz" + structure = f"{TEST_DIR}/POSCAR" + self.cohp_lobster = CompleteCohp.from_file("lobster", filename=filepath, structure_file=structure) + + filepath = f"{TEST_DIR}/COHPCAR.lobster.orbitalwise.gz" + structure = f"{TEST_DIR}/POSCAR.orbitalwise" + self.cohp_orb = CompleteCohp.from_file("lobster", filename=filepath, structure_file=structure) + + filepath = f"{TEST_DIR}/environments/COHPCAR.lobster.mp-190_2.gz" + structure = f"{TEST_DIR}/environments/CONTCAR.mp-190.gz" + self.cohp_lobster_spin_polarized = CompleteCohp.from_file( + "lobster", filename=filepath, structure_file=structure + ) + + def test_get_integrated_cohp_in_energy_range_full(self): + # integration up to Fermi level + + cohp = self.cohp_lobster + result = get_integrated_cohp_in_energy_range( + cohp, + label="1", + orbital=None, + energy_range=None, + relative_E_Fermi=True, + summed_spin_channels=True, + ) + assert result == approx(-0.10218 - 0.19701) + + result = get_integrated_cohp_in_energy_range( + cohp, + label="1", + orbital=None, + energy_range=None, + relative_E_Fermi=True, + summed_spin_channels=False, + ) + assert result[Spin.up] == approx(-0.10218) + assert result[Spin.down] == approx(-0.19701) + + # One without spin polarization + + result = get_integrated_cohp_in_energy_range( + self.cohp_orb, + label="1", + orbital=None, + energy_range=None, + relative_E_Fermi=False, + summed_spin_channels=False, + ) + assert result[Spin.up] == approx(-4.36062) + + result = get_integrated_cohp_in_energy_range( + self.cohp_orb, + label="1", + orbital=None, + energy_range=None, + relative_E_Fermi=True, + summed_spin_channels=False, + ) + + assert result[Spin.up] == approx(-4.36062) + + result = get_integrated_cohp_in_energy_range( + self.cohp_orb, + label="1", + orbital=None, + energy_range=None, + relative_E_Fermi=True, + summed_spin_channels=True, + ) + + assert result == approx(-4.36062) + # something else for orbital resolved version + # self.cohp_lobster_spin_polarized + + result = get_integrated_cohp_in_energy_range( + self.cohp_lobster_spin_polarized, + label="1", + orbital="6s-6s", + energy_range=None, + relative_E_Fermi=True, + summed_spin_channels=False, + ) + + assert result[Spin.up] == approx(-0.00006) + assert result[Spin.down] == approx(-0.00006) + + result = get_integrated_cohp_in_energy_range( + self.cohp_lobster_spin_polarized, + label="1", + orbital="6s-6s", + energy_range=None, + relative_E_Fermi=True, + summed_spin_channels=True, + ) + + assert result == approx(-0.00006 * 2) + + def test_get_integrated_cohp_in_energy_range_onefloat(self): + # only one float is given for energy range + cohp = self.cohp_lobster + fermi = cohp.efermi + result = get_integrated_cohp_in_energy_range( + cohp, + label="1", + orbital=None, + energy_range=-0.60201, + relative_E_Fermi=True, + summed_spin_channels=True, + ) + + assert result == approx(-0.10218 - 0.19701 + 0.14894 + 0.21889) + + result = get_integrated_cohp_in_energy_range( + cohp, + label="1", + orbital=None, + energy_range=-0.60201, + relative_E_Fermi=True, + summed_spin_channels=False, + ) + + assert result[Spin.up] == approx(-0.10218 + 0.14894) + assert result[Spin.down] == approx(-0.19701 + 0.21889) + # only one float is given for energy range (relative to E-fermi) + + result = get_integrated_cohp_in_energy_range( + cohp, + label="1", + orbital=None, + energy_range=-0.60201 + fermi, + relative_E_Fermi=False, + summed_spin_channels=True, + ) + assert result == approx(-0.10218 - 0.19701 + 0.14894 + 0.21889) + + result = get_integrated_cohp_in_energy_range( + cohp, + label="1", + orbital=None, + energy_range=-0.60201 + fermi, + relative_E_Fermi=False, + summed_spin_channels=False, + ) + + assert result[Spin.up] == approx(-0.10218 + 0.14894) + assert result[Spin.down] == approx(-0.19701 + 0.21889) + + # without spin + fermi = self.cohp_orb.efermi + result = get_integrated_cohp_in_energy_range( + self.cohp_orb, + label="1", + orbital=None, + energy_range=-14.0350 + fermi, + relative_E_Fermi=False, + summed_spin_channels=False, + ) + + assert result[Spin.up] == approx(-4.36062) + + result = get_integrated_cohp_in_energy_range( + self.cohp_orb, + label="1", + orbital=None, + energy_range=-14.03509, + relative_E_Fermi=True, + summed_spin_channels=False, + ) + assert result[Spin.up] == approx(-4.36062) + + result = get_integrated_cohp_in_energy_range( + self.cohp_orb, + label="1", + orbital=None, + energy_range=-14.03509, + relative_E_Fermi=True, + summed_spin_channels=True, + ) + + assert result == approx(-4.36062) + + def test_get_integrated_cohp_in_energy_range_whole_range(self): + cohp = self.cohp_lobster + fermi = cohp.efermi + result = get_integrated_cohp_in_energy_range( + cohp, + label="1", + orbital=None, + energy_range=[-0.60201, 0], + relative_E_Fermi=True, + summed_spin_channels=True, + ) + assert result == approx(-0.10218 - 0.19701 + 0.14894 + 0.21889) + + result = get_integrated_cohp_in_energy_range( + cohp, + label="1", + orbital=None, + energy_range=[-0.60201, 0], + relative_E_Fermi=True, + summed_spin_channels=False, + ) + + assert result[Spin.up] == approx(-0.10218 + 0.14894) + assert result[Spin.down] == approx(-0.19701 + 0.21889) + # whole energy range + + result = get_integrated_cohp_in_energy_range( + cohp, + label="1", + orbital=None, + energy_range=[-0.60201 + fermi, 0 + fermi], + relative_E_Fermi=False, + summed_spin_channels=True, + ) + assert result == approx(-0.10218 - 0.19701 + 0.14894 + 0.21889) + + result = get_integrated_cohp_in_energy_range( + cohp, + label="1", + orbital=None, + energy_range=[-0.60201 + fermi, 0 + fermi], + relative_E_Fermi=False, + summed_spin_channels=False, + ) + + assert result[Spin.up] == approx(-0.10218 + 0.14894) + assert result[Spin.down] == approx(-0.19701 + 0.21889) + + # without spin + fermi = self.cohp_orb.efermi + result = get_integrated_cohp_in_energy_range( + self.cohp_orb, + label="1", + orbital=None, + energy_range=[-14.0350 + fermi, fermi], + relative_E_Fermi=False, + summed_spin_channels=False, + ) + + assert result[Spin.up] == approx(-4.36062) + + result = get_integrated_cohp_in_energy_range( + self.cohp_orb, + label="1", + orbital=None, + energy_range=[-14.0350, 0], + relative_E_Fermi=True, + summed_spin_channels=False, + ) + + assert result[Spin.up] == approx(-4.36062) + + result = get_integrated_cohp_in_energy_range( + self.cohp_orb, + label="1", + orbital=None, + energy_range=[-14.0350, 0], + relative_E_Fermi=True, + summed_spin_channels=True, + ) + + assert result == approx(-4.36062) diff --git a/tests/io/lobster/__init__.py b/tests/io/lobster/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/io/lobster/test_inputs.py b/tests/io/lobster/test_inputs.py new file mode 100644 index 00000000000..0c3a8e2f2f2 --- /dev/null +++ b/tests/io/lobster/test_inputs.py @@ -0,0 +1,639 @@ +from __future__ import annotations + +import numpy as np +import pytest +from pytest import approx + +from pymatgen.core.structure import Structure +from pymatgen.io.lobster import Lobsterin +from pymatgen.io.lobster.inputs import get_all_possible_basis_combinations +from pymatgen.io.vasp.inputs import Incar, Kpoints, Potcar +from pymatgen.util.testing import FAKE_POTCAR_DIR, TEST_FILES_DIR, VASP_IN_DIR, VASP_OUT_DIR, MatSciTest + +TEST_DIR = f"{TEST_FILES_DIR}/electronic_structure/cohp" + +__author__ = "Janine George, Marco Esters" +__copyright__ = "Copyright 2017, The Materials Project" +__version__ = "0.2" +__email__ = "janine.george@uclouvain.be, esters@uoregon.edu" +__date__ = "Dec 10, 2017" + + +class TestLobsterin(MatSciTest): + def setup_method(self): + self.Lobsterin = Lobsterin.from_file(f"{TEST_DIR}/lobsterin.1") + self.Lobsterin2 = Lobsterin.from_file(f"{TEST_DIR}/lobsterin.2") + self.Lobsterin3 = Lobsterin.from_file(f"{TEST_DIR}/lobsterin.3") + self.Lobsterin4 = Lobsterin.from_file(f"{TEST_DIR}/lobsterin.4.gz") + + def test_from_file(self): + # Test reading from file + assert self.Lobsterin["cohpstartenergy"] == approx(-15.0) + assert self.Lobsterin["cohpendenergy"] == approx(5.0) + assert self.Lobsterin["basisset"] == "pbeVaspFit2015" + assert self.Lobsterin["gaussiansmearingwidth"] == approx(0.1) + assert self.Lobsterin["basisfunctions"][0] == "Fe 3d 4p 4s" + assert self.Lobsterin["basisfunctions"][1] == "Co 3d 4p 4s" + assert self.Lobsterin["skipdos"] + assert self.Lobsterin["skipcohp"] + assert self.Lobsterin["skipcoop"] + assert self.Lobsterin["skippopulationanalysis"] + assert self.Lobsterin["skipgrosspopulation"] + + # Test if comments are correctly removed + assert self.Lobsterin == self.Lobsterin2 + + def test_duplicates_from_file(self): + with open(f"{TEST_DIR}/lobsterin.1", encoding="utf-8") as file: + original_file = file.readlines() + + # String and float keywords does not allow duplicates + float_dup_file = original_file.copy() + float_dup_file.append("cohpstartenergy -15.0") + + float_tmp_file = self.tmp_path / "tmp_lobster_in_float" + float_tmp_file.write_text("\n".join(float_dup_file)) + + with pytest.raises(ValueError, match="Same keyword cohpstartenergy twice!"): + _ = Lobsterin.from_file(float_tmp_file) + + # Boolean and list keywords allow duplicates + bool_dup_file = original_file.copy() + bool_dup_file.append("skipdos") + + bool_tmp_file = self.tmp_path / "tmp_lobster_in_bool" + bool_tmp_file.write_text("\n".join(bool_dup_file)) + + _ = Lobsterin.from_file(bool_tmp_file) # no error should be raised + + def test_magic_methods(self): + """Test __getitem__, __setitem__ and __contains__, + should be case independent. + """ + # Test __setitem__ + assert self.Lobsterin["COHPSTARTENERGY"] == approx(-15.0) + + with pytest.raises(KeyError, match="Key hello is currently not available"): + self.Lobsterin["HELLO"] = True + + # Test __getitem__ + self.Lobsterin["skipCOHP"] = False + assert self.Lobsterin["skipcohp"] is False + assert self.Lobsterin.get("skipCOHP") is False + + with pytest.raises(KeyError, match="key='World' is not available"): + _ = self.Lobsterin["World"] + + # Test __contains__ + assert "COHPSTARTENERGY" in self.Lobsterin + assert "cohpstartenergy" in self.Lobsterin + + assert "helloworld" not in self.Lobsterin + + def test_initialize_from_dict(self): + # initialize from dict + lobsterin = Lobsterin( + { + "cohpstartenergy": -15.0, + "cohpendenergy": 5.0, + "basisset": "pbeVaspFit2015", + "gaussiansmearingwidth": 0.1, + "basisfunctions": ["Fe 3d 4p 4s", "Co 3d 4p 4s"], + "skipdos": True, + "skipcohp": True, + "skipcoop": True, + "skippopulationanalysis": True, + "skipgrosspopulation": True, + } + ) + assert lobsterin["cohpstartenergy"] == approx(-15.0) + assert lobsterin["cohpendenergy"] == approx(5.0) + assert lobsterin["basisset"] == "pbeVaspFit2015" + assert lobsterin["gaussiansmearingwidth"] == approx(0.1) + assert lobsterin["basisfunctions"][0] == "Fe 3d 4p 4s" + assert lobsterin["basisfunctions"][1] == "Co 3d 4p 4s" + assert {*lobsterin} >= { + "skipdos", + "skipcohp", + "skipcoop", + "skippopulationanalysis", + "skipgrosspopulation", + } + with pytest.raises(KeyError, match="There are duplicates for the keywords!"): + lobsterin2 = Lobsterin({"cohpstartenergy": -15.0, "cohpstartEnergy": -20.0}) + lobsterin2 = Lobsterin({"cohpstartenergy": -15.0}) + # can only calculate nbands if basis functions are provided + with pytest.raises( + ValueError, + match="No basis functions are provided. The program cannot calculate nbands.", + ): + lobsterin2._get_nbands(structure=Structure.from_file(f"{VASP_IN_DIR}/POSCAR_Fe3O4")) + + def test_standard_settings(self): + # test standard settings + for option in [ + "standard", + "standard_from_projection", + "standard_with_fatband", + "onlyprojection", + "onlydos", + "onlycohp", + "onlycoop", + "onlycobi", + "onlycohpcoop", + "onlycohpcoopcobi", + ]: + lobsterin1 = Lobsterin.standard_calculations_from_vasp_files( + f"{VASP_IN_DIR}/POSCAR_Fe3O4", + f"{VASP_IN_DIR}/INCAR.lobster", + f"{VASP_IN_DIR}/POTCAR_Fe3O4.gz", + option=option, + ) + assert lobsterin1["cohpstartenergy"] == approx(-35.0) + assert lobsterin1["cohpendenergy"] == approx(5.0) + assert lobsterin1["basisset"] == "pbeVaspFit2015" + assert lobsterin1["gaussiansmearingwidth"] == approx(0.1) + assert lobsterin1["basisfunctions"][0] == "Fe 3d 4p 4s " + assert lobsterin1["basisfunctions"][1] == "O 2p 2s " + + if option in [ + "standard", + "standard_with_fatband", + "onlyprojection", + "onlycohp", + "onlycoop", + "onlycohpcoop", + ]: + assert lobsterin1["saveProjectiontoFile"] + if option in [ + "standard", + "standard_with_fatband", + "onlycohp", + "onlycoop", + "onlycohpcoop", + ]: + assert lobsterin1["cohpGenerator"] == "from 0.1 to 6.0 orbitalwise" + if option == "standard": + assert "skipdos" not in lobsterin1 + assert "skipcohp" not in lobsterin1 + assert "skipcoop" not in lobsterin1 + if option == "standard_with_fatband": + assert lobsterin1["createFatband"] == ["Fe 3d 4p 4s ", "O 2p 2s "] + assert "skipdos" not in lobsterin1 + assert "skipcohp" not in lobsterin1 + assert "skipcoop" not in lobsterin1 + if option == "standard_from_projection": + assert lobsterin1["loadProjectionFromFile"] + if option in [ + "onlyprojection", + "onlycohp", + "onlycoop", + "onlycobi", + "onlycohpcoop", + "onlycohpcoopcobi", + ]: + assert lobsterin1["skipdos"] + assert lobsterin1["skipPopulationAnalysis"] + assert lobsterin1["skipGrossPopulation"] + assert lobsterin1["skipMadelungEnergy"] + + if option == "onlydos": + assert lobsterin1["skipPopulationAnalysis"] + assert lobsterin1["skipGrossPopulation"] + assert lobsterin1["skipcohp"] + assert lobsterin1["skipcoop"] + assert lobsterin1["skipcobi"] + assert lobsterin1["skipMadelungEnergy"] + if option == "onlycohp": + assert lobsterin1["skipcoop"] + assert lobsterin1["skipcobi"] + if option == "onlycoop": + assert lobsterin1["skipcohp"] + assert lobsterin1["skipcobi"] + if option == "onlyprojection": + assert lobsterin1["skipdos"] + if option == "onlymadelung": + assert lobsterin1["skipPopulationAnalysis"] + assert lobsterin1["skipGrossPopulation"] + assert lobsterin1["skipcohp"] + assert lobsterin1["skipcoop"] + assert lobsterin1["skipcobi"] + assert lobsterin1["skipdos"] + # test basis functions by dict + lobsterin_new = Lobsterin.standard_calculations_from_vasp_files( + f"{VASP_IN_DIR}/POSCAR_Fe3O4", + f"{VASP_IN_DIR}/INCAR.lobster", + dict_for_basis={"Fe": "3d 4p 4s", "O": "2s 2p"}, + option="standard", + ) + assert lobsterin_new["basisfunctions"] == ["Fe 3d 4p 4s", "O 2s 2p"] + + # test gaussian smearing + lobsterin_new = Lobsterin.standard_calculations_from_vasp_files( + f"{VASP_IN_DIR}/POSCAR_Fe3O4", + f"{VASP_IN_DIR}/INCAR.lobster2", + dict_for_basis={"Fe": "3d 4p 4s", "O": "2s 2p"}, + option="standard", + ) + assert "gaussiansmearingwidth" not in lobsterin_new + + # fatband and ISMEAR=-5 does not work together + with pytest.raises( + ValueError, + match="ISMEAR has to be 0 for a fatband calculation with Lobster", + ): + lobsterin_new = Lobsterin.standard_calculations_from_vasp_files( + f"{VASP_IN_DIR}/POSCAR_Fe3O4", + f"{VASP_IN_DIR}/INCAR.lobster2", + dict_for_basis={"Fe": "3d 4p 4s", "O": "2s 2p"}, + option="standard_with_fatband", + ) + + def test_standard_with_energy_range_from_vasprun(self): + # test standard_with_energy_range_from_vasprun + lobsterin_comp = Lobsterin.standard_calculations_from_vasp_files( + f"{VASP_IN_DIR}/POSCAR_C2", + f"{VASP_IN_DIR}/INCAR_C2", + f"{VASP_IN_DIR}/POTCAR_C2.gz", + f"{VASP_OUT_DIR}/vasprun.C2.xml.gz", + option="standard_with_energy_range_from_vasprun", + ) + assert lobsterin_comp["COHPstartEnergy"] == approx(-28.3679) + assert lobsterin_comp["COHPendEnergy"] == approx(32.8968) + assert lobsterin_comp["COHPSteps"] == 301 + + def test_diff(self): + # test diff + assert self.Lobsterin.diff(self.Lobsterin2)["Different"] == {} + assert self.Lobsterin.diff(self.Lobsterin2)["Same"]["cohpstartenergy"] == approx(-15.0) + + # test diff in both directions + for entry in self.Lobsterin.diff(self.Lobsterin3)["Same"]: + assert entry in self.Lobsterin3.diff(self.Lobsterin)["Same"] + for entry in self.Lobsterin3.diff(self.Lobsterin)["Same"]: + assert entry in self.Lobsterin.diff(self.Lobsterin3)["Same"] + for entry in self.Lobsterin.diff(self.Lobsterin3)["Different"]: + assert entry in self.Lobsterin3.diff(self.Lobsterin)["Different"] + for entry in self.Lobsterin3.diff(self.Lobsterin)["Different"]: + assert entry in self.Lobsterin.diff(self.Lobsterin3)["Different"] + + assert ( + self.Lobsterin.diff(self.Lobsterin3)["Different"]["skipcohp"]["lobsterin1"] + == self.Lobsterin3.diff(self.Lobsterin)["Different"]["skipcohp"]["lobsterin2"] + ) + + def test_diff_case_insensitivity(self): + """Test case-insensitivity of diff method.""" + with open(f"{TEST_DIR}/lobsterin.1", encoding="utf-8") as file: + lobsterin_content = file.read() + + lobsterin_content.replace("COHPstartEnergy -15.0", "cohpSTARTEnergy -15.0") + lobsterin_content.replace("skipcohp", "skipCOHP") + + tmp_file_path = self.tmp_path / "tmp_lobster_in" + tmp_file_path.write_text(lobsterin_content) + + lobsterin_diff_case = Lobsterin.from_file(tmp_file_path) + assert self.Lobsterin.diff(lobsterin_diff_case)["Different"] == {} + + def test_dict_functionality(self): + for key in ("COHPstartEnergy", "COHPstartEnergy", "COhPstartenergy"): + start_energy = self.Lobsterin.get(key) + assert start_energy == approx(-15.0), f"{start_energy=}, {key=}" + + lobsterin_copy = self.Lobsterin.copy() + lobsterin_copy.update({"cohpstarteNergy": -10.00}) + assert lobsterin_copy["cohpstartenergy"] == approx(-10.0) + lobsterin_copy.pop("cohpstarteNergy") + assert "cohpstartenergy" not in lobsterin_copy + lobsterin_copy.pop("cohpendenergY") + lobsterin_copy["cohpsteps"] = 100 + assert lobsterin_copy["cohpsteps"] == 100 + len_before = len(lobsterin_copy.items()) + assert len_before == 9, f"{len_before=}" + + lobsterin_copy.popitem() + len_after = len(lobsterin_copy.items()) + assert len_after == len_before - 1 + + # Test case sensitivity of |= operator + self.Lobsterin |= {"skipCOHP": True} # Camel case + assert self.Lobsterin["skipcohp"] is True + + self.Lobsterin |= {"skipcohp": False} # lower case + assert self.Lobsterin["skipcohp"] is False + + def test_read_write_lobsterin(self): + outfile_path = self.tmp_path / "lobsterin_test" + lobsterin1 = Lobsterin.from_file(f"{TEST_DIR}/lobsterin.1") + lobsterin1.write_lobsterin(outfile_path) + lobsterin2 = Lobsterin.from_file(outfile_path) + assert lobsterin1.diff(lobsterin2)["Different"] == {} + + def test_get_basis(self): + # get basis functions + lobsterin1 = Lobsterin({}) + potcar = Potcar.from_file(f"{VASP_IN_DIR}/POTCAR_Fe3O4.gz") + potcar_names = [name["symbol"] for name in potcar.spec] + + assert lobsterin1.get_basis( + Structure.from_file(f"{TEST_FILES_DIR}/cif/Fe3O4.cif"), + potcar_symbols=potcar_names, + ) == ["Fe 3d 4p 4s ", "O 2p 2s "] + potcar = Potcar.from_file(f"{TEST_DIR}/POTCAR.GaAs") + potcar_names = [name["symbol"] for name in potcar.spec] + assert lobsterin1.get_basis( + Structure.from_file(f"{TEST_DIR}/POSCAR.GaAs"), + potcar_symbols=potcar_names, + ) == ["Ga 3d 4p 4s ", "As 4p 4s "] + + def test_get_all_possible_basis_functions(self): + potcar = Potcar.from_file(f"{VASP_IN_DIR}/POTCAR_Fe3O4.gz") + potcar_names = [name["symbol"] for name in potcar.spec] + result = Lobsterin.get_all_possible_basis_functions( + Structure.from_file(f"{TEST_FILES_DIR}/cif/Fe3O4.cif"), + potcar_symbols=potcar_names, + ) + assert result[0] == {"Fe": "3d 4s", "O": "2p 2s"} + assert result[1] == {"Fe": "3d 4s 4p", "O": "2p 2s"} + + potcar2 = Potcar.from_file(f"{FAKE_POTCAR_DIR}/POT_GGA_PAW_PBE_54/POTCAR.Fe.gz") + Potcar_names2 = [name["symbol"] for name in potcar2.spec] + result2 = Lobsterin.get_all_possible_basis_functions( + Structure.from_file(f"{TEST_FILES_DIR}/cif/Fe.cif"), + potcar_symbols=Potcar_names2, + ) + assert result2[0] == {"Fe": "3d 4s"} + + def test_get_potcar_symbols(self): + lobsterin1 = Lobsterin({}) + assert lobsterin1._get_potcar_symbols(f"{VASP_IN_DIR}/POTCAR_Fe3O4.gz") == [ + "Fe", + "O", + ] + assert lobsterin1._get_potcar_symbols(f"{TEST_DIR}/POTCAR.GaAs") == [ + "Ga_d", + "As", + ] + + def test_write_lobsterin(self): + # write lobsterin, read it and compare it + outfile_path = self.tmp_path / "lobsterin_test" + lobsterin1 = Lobsterin.standard_calculations_from_vasp_files( + f"{VASP_IN_DIR}/POSCAR_Fe3O4", + f"{VASP_IN_DIR}/INCAR.lobster", + f"{VASP_IN_DIR}/POTCAR_Fe3O4.gz", + option="standard", + ) + lobsterin1.write_lobsterin(outfile_path) + lobsterin2 = Lobsterin.from_file(outfile_path) + assert lobsterin1.diff(lobsterin2)["Different"] == {} + + def test_write_incar(self): + # write INCAR and compare + outfile_path = self.tmp_path / "INCAR_test" + lobsterin1 = Lobsterin.standard_calculations_from_vasp_files( + f"{VASP_IN_DIR}/POSCAR_Fe3O4", + f"{VASP_IN_DIR}/INCAR.lobster", + f"{VASP_IN_DIR}/POTCAR_Fe3O4.gz", + option="standard", + ) + lobsterin1.write_INCAR( + f"{VASP_IN_DIR}/INCAR.lobster3", + outfile_path, + f"{VASP_IN_DIR}/POSCAR_Fe3O4", + isym=-1, + ) + + incar1 = Incar.from_file(f"{VASP_IN_DIR}/INCAR.lobster3") + incar2 = Incar.from_file(outfile_path) + + assert incar1.diff(incar2)["Different"] == { + "ISYM": {"INCAR1": 2, "INCAR2": -1}, + "NBANDS": {"INCAR1": None, "INCAR2": 86}, + "NSW": {"INCAR1": 500, "INCAR2": 0}, + "LWAVE": {"INCAR1": False, "INCAR2": True}, + } + + def test_write_kpoints(self): + # line mode + outfile_path = self.tmp_path / "KPOINTS_test" + outfile_path2 = self.tmp_path / "POSCAR_test" + lobsterin1 = Lobsterin({}) + # test writing primitive cell + lobsterin1.write_POSCAR_with_standard_primitive( + POSCAR_input=f"{VASP_IN_DIR}/POSCAR_Fe3O4", POSCAR_output=outfile_path2 + ) + + lobsterin1.write_KPOINTS( + POSCAR_input=outfile_path2, + KPOINTS_output=outfile_path, + kpoints_line_density=58, + isym=-1, + ) + kpoint = Kpoints.from_file(outfile_path) + assert kpoint.num_kpts == 562 + assert kpoint.kpts[-1][0] == approx(-0.5) + assert kpoint.kpts[-1][1] == approx(0.5) + assert kpoint.kpts[-1][2] == approx(0.5) + assert kpoint.labels[-1] == "T" + kpoint2 = Kpoints.from_file(f"{VASP_IN_DIR}/KPOINTS_band.lobster") + + labels = [] + number = 0 + for label in kpoint.labels: + if label is not None: + if number != 0: + if label != labels[number - 1]: + labels.append(label) + number += 1 + else: + labels.append(label) + number += 1 + + labels2 = [] + number2 = 0 + for label in kpoint2.labels: + if label is not None: + if number2 != 0: + if label != labels2[number2 - 1]: + labels2.append(label) + number2 += 1 + else: + labels2.append(label) + number2 += 1 + assert labels == labels2 + + # without line mode + lobsterin1.write_KPOINTS( + POSCAR_input=outfile_path2, + KPOINTS_output=outfile_path, + line_mode=False, + isym=-1, + ) + kpoint = Kpoints.from_file(outfile_path) + kpoint2 = Kpoints.from_file(f"{VASP_OUT_DIR}/IBZKPT.lobster") + + for num_kpt, list_kpoint in enumerate(kpoint.kpts): + assert list_kpoint[0] == approx(kpoint2.kpts[num_kpt][0]) + assert list_kpoint[1] == approx(kpoint2.kpts[num_kpt][1]) + assert list_kpoint[2] == approx(kpoint2.kpts[num_kpt][2]) + + assert kpoint.num_kpts == 108 + + # without line mode, use grid instead of reciprocal density + lobsterin1.write_KPOINTS( + POSCAR_input=outfile_path2, + KPOINTS_output=outfile_path, + line_mode=False, + from_grid=True, + input_grid=[6, 6, 3], + isym=-1, + ) + kpoint = Kpoints.from_file(outfile_path) + kpoint2 = Kpoints.from_file(f"{VASP_OUT_DIR}/IBZKPT.lobster") + + for num_kpt, list_kpoint in enumerate(kpoint.kpts): + assert list_kpoint[0] == approx(kpoint2.kpts[num_kpt][0]) + assert list_kpoint[1] == approx(kpoint2.kpts[num_kpt][1]) + assert list_kpoint[2] == approx(kpoint2.kpts[num_kpt][2]) + + assert kpoint.num_kpts == 108 + + # + # #without line mode, using a certain grid, isym=0 instead of -1 + lobsterin1.write_KPOINTS( + POSCAR_input=f"{TEST_DIR}/POSCAR.Li", + KPOINTS_output=outfile_path, + line_mode=False, + from_grid=True, + input_grid=[3, 3, 3], + isym=0, + ) + + kpoint1 = Kpoints.from_file(outfile_path) + kpoint2 = Kpoints.from_file(f"{TEST_DIR}/IBZKPT_3_3_3_Li") + for ikpoint, kpoint in enumerate(kpoint1.kpts): + assert self.is_kpoint_in_list( + kpoint, + kpoint2.kpts, + kpoint1.kpts_weights[ikpoint], + kpoint2.kpts_weights, + ) + for ikpoint, kpoint in enumerate(kpoint2.kpts): + assert self.is_kpoint_in_list( + kpoint, + kpoint1.kpts, + kpoint2.kpts_weights[ikpoint], + kpoint1.kpts_weights, + ) + + lobsterin1.write_KPOINTS( + POSCAR_input=f"{TEST_DIR}/POSCAR.Li", + KPOINTS_output=outfile_path, + line_mode=False, + from_grid=True, + input_grid=[2, 2, 2], + isym=0, + ) + + kpoint1 = Kpoints.from_file(outfile_path) + kpoint2 = Kpoints.from_file(f"{TEST_DIR}/IBZKPT_2_2_2_Li") + for ikpoint, kpoint in enumerate(kpoint1.kpts): + assert self.is_kpoint_in_list( + kpoint, + kpoint2.kpts, + kpoint1.kpts_weights[ikpoint], + kpoint2.kpts_weights, + ) + for ikpoint, kpoint in enumerate(kpoint2.kpts): + assert self.is_kpoint_in_list( + kpoint, + kpoint1.kpts, + kpoint2.kpts_weights[ikpoint], + kpoint1.kpts_weights, + ) + + def is_kpoint_in_list(self, kpoint, kpointlist, weight, weightlist) -> bool: + found = 0 + for ikpoint2, kpoint2 in enumerate(kpointlist): + if ( + np.isclose(kpoint[0], kpoint2[0]) + and np.isclose(kpoint[1], kpoint2[1]) + and np.isclose(kpoint[2], kpoint2[2]) + ): + if weight == weightlist[ikpoint2]: + found += 1 + elif ( + np.isclose(-kpoint[0], kpoint2[0]) + and np.isclose(-kpoint[1], kpoint2[1]) + and np.isclose(-kpoint[2], kpoint2[2]) + ) and weight == weightlist[ikpoint2]: + found += 1 + return found == 1 + + def test_as_from_dict(self): + # tests as dict and from dict methods + new_lobsterin = Lobsterin.from_dict(self.Lobsterin.as_dict()) + assert new_lobsterin == self.Lobsterin + new_lobsterin.to_json() + + +class TestUtils(MatSciTest): + def test_get_all_possible_basis_combinations(self): + # this basis is just for testing (not correct) + min_basis = ["Li 1s 2s ", "Na 1s 2s", "Si 1s 2s"] + max_basis = ["Li 1s 2p 2s ", "Na 1s 2p 2s", "Si 1s 2s"] + combinations_basis = get_all_possible_basis_combinations(min_basis, max_basis) + assert combinations_basis == [ + ["Li 1s 2s", "Na 1s 2s", "Si 1s 2s"], + ["Li 1s 2s", "Na 1s 2s 2p", "Si 1s 2s"], + ["Li 1s 2s 2p", "Na 1s 2s", "Si 1s 2s"], + ["Li 1s 2s 2p", "Na 1s 2s 2p", "Si 1s 2s"], + ] + + min_basis = ["Li 1s 2s"] + max_basis = ["Li 1s 2s 2p 3s"] + combinations_basis = get_all_possible_basis_combinations(min_basis, max_basis) + assert combinations_basis == [ + ["Li 1s 2s"], + ["Li 1s 2s 2p"], + ["Li 1s 2s 3s"], + ["Li 1s 2s 2p 3s"], + ] + + min_basis = ["Li 1s 2s", "Na 1s 2s"] + max_basis = ["Li 1s 2s 2p 3s", "Na 1s 2s 2p 3s"] + combinations_basis = get_all_possible_basis_combinations(min_basis, max_basis) + assert combinations_basis == [ + ["Li 1s 2s", "Na 1s 2s"], + ["Li 1s 2s", "Na 1s 2s 2p"], + ["Li 1s 2s", "Na 1s 2s 3s"], + ["Li 1s 2s", "Na 1s 2s 2p 3s"], + ["Li 1s 2s 2p", "Na 1s 2s"], + ["Li 1s 2s 2p", "Na 1s 2s 2p"], + ["Li 1s 2s 2p", "Na 1s 2s 3s"], + ["Li 1s 2s 2p", "Na 1s 2s 2p 3s"], + ["Li 1s 2s 3s", "Na 1s 2s"], + ["Li 1s 2s 3s", "Na 1s 2s 2p"], + ["Li 1s 2s 3s", "Na 1s 2s 3s"], + ["Li 1s 2s 3s", "Na 1s 2s 2p 3s"], + ["Li 1s 2s 2p 3s", "Na 1s 2s"], + ["Li 1s 2s 2p 3s", "Na 1s 2s 2p"], + ["Li 1s 2s 2p 3s", "Na 1s 2s 3s"], + ["Li 1s 2s 2p 3s", "Na 1s 2s 2p 3s"], + ] + + min_basis = ["Si 1s 2s 2p", "Na 1s 2s"] + max_basis = ["Si 1s 2s 2p 3s", "Na 1s 2s 2p 3s"] + combinations_basis = get_all_possible_basis_combinations(min_basis, max_basis) + assert combinations_basis == [ + ["Si 1s 2s 2p", "Na 1s 2s"], + ["Si 1s 2s 2p", "Na 1s 2s 2p"], + ["Si 1s 2s 2p", "Na 1s 2s 3s"], + ["Si 1s 2s 2p", "Na 1s 2s 2p 3s"], + ["Si 1s 2s 2p 3s", "Na 1s 2s"], + ["Si 1s 2s 2p 3s", "Na 1s 2s 2p"], + ["Si 1s 2s 2p 3s", "Na 1s 2s 3s"], + ["Si 1s 2s 2p 3s", "Na 1s 2s 2p 3s"], + ] diff --git a/tests/io/lobster/test_outputs.py b/tests/io/lobster/test_outputs.py new file mode 100644 index 00000000000..51c351335f4 --- /dev/null +++ b/tests/io/lobster/test_outputs.py @@ -0,0 +1,2550 @@ +from __future__ import annotations + +import copy +import gzip +import math +import os + +import numpy as np +import orjson +import pytest +from numpy.testing import assert_allclose, assert_array_equal +from pytest import approx + +from pymatgen.core.structure import Structure +from pymatgen.electronic_structure.cohp import IcohpCollection +from pymatgen.electronic_structure.core import Orbital, Spin +from pymatgen.io.lobster import ( + Bandoverlaps, + Bwdf, + Charge, + Cohpcar, + Doscar, + Fatband, + Grosspop, + Icohplist, + LobsterMatrices, + Lobsterout, + MadelungEnergies, + NciCobiList, + Polarization, + SitePotential, + Wavefunction, +) +from pymatgen.io.lobster.outputs import _get_lines +from pymatgen.io.vasp import Vasprun +from pymatgen.util.testing import TEST_FILES_DIR, VASP_IN_DIR, VASP_OUT_DIR, MatSciTest + +TEST_DIR = f"{TEST_FILES_DIR}/electronic_structure/cohp" + +__author__ = "Janine George, Marco Esters" +__copyright__ = "Copyright 2017, The Materials Project" +__version__ = "0.2" +__email__ = "janine.george@uclouvain.be, esters@uoregon.edu" +__date__ = "Dec 10, 2017" + + +class TestBwdf(MatSciTest): + def setup_method(self): + self.bwdf_coop = Bwdf(filename=f"{TEST_DIR}/BWDF.lobster.AlN.gz") + self.bwdf_cohp = Bwdf(filename=f"{TEST_DIR}/BWDFCOHP.lobster.NaCl.gz") + + def test_attributes(self): + assert self.bwdf_coop.bin_width == approx(0.02005000, abs=1e-4) + assert self.bwdf_cohp.bin_width == approx(0.02005000, abs=1e-4) + assert len(self.bwdf_coop.centers) == 201 + assert len(self.bwdf_cohp.centers) == 143 + assert self.bwdf_coop.bwdf[Spin.down][-2] == approx(0.00082, abs=1e-4) + assert self.bwdf_coop.bwdf[Spin.up][0] == approx(0.81161, abs=1e-4) + assert self.bwdf_cohp.bwdf[Spin.up][103] == approx(-0.01392, abs=1e-4) + + +class TestCohpcar(MatSciTest): + def setup_method(self): + self.cohp_bise = Cohpcar(filename=f"{TEST_DIR}/COHPCAR.lobster.BiSe.gz") + self.coop_bise = Cohpcar( + filename=f"{TEST_DIR}/COOPCAR.lobster.BiSe.gz", + are_coops=True, + ) + + # Make sure Cohpcar also works with terminating line ending char + gz_path = f"{TEST_DIR}/COOPCAR.lobster.gz" + with gzip.open(gz_path, "rt", encoding="utf-8") as f: + content = f.read() + "\n" + + # Test default filename (None should be redirected to "COHPCAR.lobster") + with open("COHPCAR.lobster", "w", encoding="utf-8") as f: + f.write(content) + + self.cohp_fe = Cohpcar(filename=None) + self.coop_fe = Cohpcar( + filename=f"{TEST_DIR}/COOPCAR.lobster.gz", + are_coops=True, + ) + self.orb = Cohpcar(filename=f"{TEST_DIR}/COHPCAR.lobster.orbitalwise.gz") + self.orb_notot = Cohpcar(filename=f"{TEST_DIR}/COHPCAR.lobster.notot.orbitalwise.gz") + + # Lobster 3.1 (Test data is from prerelease of Lobster 3.1) + self.cohp_KF = Cohpcar(filename=f"{TEST_DIR}/COHPCAR.lobster.KF.gz") + self.coop_KF = Cohpcar( + filename=f"{TEST_DIR}/COHPCAR.lobster.KF.gz", + are_coops=True, + ) + + # example with f electrons + self.cohp_Na2UO4 = Cohpcar(filename=f"{TEST_DIR}/COHPCAR.lobster.Na2UO4.gz") + self.coop_Na2UO4 = Cohpcar( + filename=f"{TEST_DIR}/COOPCAR.lobster.Na2UO4.gz", + are_coops=True, + ) + self.cobi = Cohpcar( + filename=f"{TEST_DIR}/COBICAR.lobster.gz", + are_cobis=True, + ) + # 3 center + self.cobi2 = Cohpcar( + filename=f"{TEST_DIR}/COBICAR.lobster.GeTe", + are_cobis=False, + are_multi_center_cobis=True, + ) + # 4 center + self.cobi3 = Cohpcar( + filename=f"{TEST_DIR}/COBICAR.lobster.GeTe_4center", + are_cobis=False, + are_multi_center_cobis=True, + ) + # partially orbital-resolved + self.cobi4 = Cohpcar( + filename=f"{TEST_DIR}/COBICAR.lobster.GeTe.multi.orbitalwise", + are_cobis=False, + are_multi_center_cobis=True, + ) + # fully orbital-resolved + self.cobi5 = Cohpcar( + filename=f"{TEST_DIR}/COBICAR.lobster.GeTe.multi.orbitalwise.full", + are_cobis=False, + are_multi_center_cobis=True, + ) + # spin polarized + # fully orbital-resolved + self.cobi6 = Cohpcar( + filename=f"{TEST_DIR}/COBICAR.lobster.B2H6.spin", + are_cobis=False, + are_multi_center_cobis=True, + ) + + # COHPCAR.LCFO.lobster from v5.1.1 + self.cohp_nacl_lcfo = Cohpcar(filename=f"{TEST_DIR}/COHPCAR.LCFO.lobster.NaCl.gz", is_lcfo=True) + + def test_attributes(self): + assert not self.cohp_bise.are_coops + assert self.coop_bise.are_coops + assert not self.cohp_bise.is_spin_polarized + assert not self.coop_bise.is_spin_polarized + assert not self.cohp_fe.are_coops + assert self.coop_fe.are_coops + assert self.cohp_fe.is_spin_polarized + assert self.coop_fe.is_spin_polarized + assert len(self.cohp_bise.energies) == 241 + assert len(self.coop_bise.energies) == 241 + assert len(self.cohp_fe.energies) == 301 + assert len(self.coop_fe.energies) == 301 + assert len(self.cohp_bise.cohp_data) == 12 + assert len(self.coop_bise.cohp_data) == 12 + assert len(self.cohp_fe.cohp_data) == 3 + assert len(self.coop_fe.cohp_data) == 3 + + # Lobster 3.1 + assert not self.cohp_KF.are_coops + assert self.coop_KF.are_coops + assert not self.cohp_KF.is_spin_polarized + assert not self.coop_KF.is_spin_polarized + assert len(self.cohp_KF.energies) == 6 + assert len(self.coop_KF.energies) == 6 + assert len(self.cohp_KF.cohp_data) == 7 + assert len(self.coop_KF.cohp_data) == 7 + + # Lobster 4.1.0 + assert not self.cohp_KF.are_cobis + assert not self.coop_KF.are_cobis + assert not self.cobi.are_coops + assert self.cobi.are_cobis + assert not self.cobi.is_spin_polarized + + # test multi-center cobis + assert not self.cobi2.are_cobis + assert not self.cobi2.are_coops + assert self.cobi2.are_multi_center_cobis + + # test COHPCAR.LCFO.lobster v 5.1.1 + assert len(self.cohp_nacl_lcfo.orb_res_cohp) == 16 + assert self.cohp_nacl_lcfo.is_lcfo + assert not self.cohp_nacl_lcfo.is_spin_polarized + assert not self.cohp_nacl_lcfo.are_coops + assert not self.cohp_nacl_lcfo.are_cobis + assert len(self.cohp_nacl_lcfo.energies) == 11 + + def test_energies(self): + efermi_bise = 5.90043 + elim_bise = (-0.124679, 11.9255) + efermi_fe = 9.75576 + elim_fe = (-0.277681, 14.7725) + efermi_KF = -2.87475 + elim_KF = (-11.25000 + efermi_KF, 7.5000 + efermi_KF) + + assert self.cohp_bise.efermi == approx(efermi_bise) + assert self.coop_bise.efermi == approx(efermi_bise) + assert self.cohp_fe.efermi == approx(efermi_fe) + assert self.coop_fe.efermi == approx(efermi_fe) + # Lobster 3.1 + assert self.cohp_KF.efermi == approx(efermi_KF) + assert self.coop_KF.efermi == approx(efermi_KF) + + assert self.cohp_bise.energies[0] + self.cohp_bise.efermi == approx(elim_bise[0], abs=1e-4) + assert self.cohp_bise.energies[-1] + self.cohp_bise.efermi == approx(elim_bise[1], abs=1e-4) + assert self.coop_bise.energies[0] + self.coop_bise.efermi == approx(elim_bise[0], abs=1e-4) + assert self.coop_bise.energies[-1] + self.coop_bise.efermi == approx(elim_bise[1], abs=1e-4) + + assert self.cohp_fe.energies[0] + self.cohp_fe.efermi == approx(elim_fe[0], abs=1e-4) + assert self.cohp_fe.energies[-1] + self.cohp_fe.efermi == approx(elim_fe[1], abs=1e-4) + assert self.coop_fe.energies[0] + self.coop_fe.efermi == approx(elim_fe[0], abs=1e-4) + assert self.coop_fe.energies[-1] + self.coop_fe.efermi == approx(elim_fe[1], abs=1e-4) + + # Lobster 3.1 + assert self.cohp_KF.energies[0] + self.cohp_KF.efermi == approx(elim_KF[0], abs=1e-4) + assert self.cohp_KF.energies[-1] + self.cohp_KF.efermi == approx(elim_KF[1], abs=1e-4) + assert self.coop_KF.energies[0] + self.coop_KF.efermi == approx(elim_KF[0], abs=1e-4) + assert self.coop_KF.energies[-1] + self.coop_KF.efermi == approx(elim_KF[1], abs=1e-4) + + def test_cohp_data(self): + lengths_sites_bise = { + "1": (2.882308829886294, (0, 6)), + "2": (3.1014396233274444, (0, 9)), + "3": (2.8823088298862083, (1, 7)), + "4": (3.1014396233275434, (1, 8)), + "5": (3.0500070394403904, (2, 9)), + "6": (2.9167594580335807, (2, 10)), + "7": (3.05000703944039, (3, 8)), + "8": (2.9167594580335803, (3, 11)), + "9": (3.3752173204052101, (4, 11)), + "10": (3.0729354518345948, (4, 5)), + "11": (3.3752173204052101, (5, 10)), + } + lengths_sites_fe = { + "1": (2.8318907764979082, (7, 6)), + "2": (2.4524893531900283, (7, 8)), + } + # Lobster 3.1 + lengths_sites_KF = { + "1": (2.7119923200622269, (0, 1)), + "2": (2.7119923200622269, (0, 1)), + "3": (2.7119923576010501, (0, 1)), + "4": (2.7119923576010501, (0, 1)), + "5": (2.7119923200622269, (0, 1)), + "6": (2.7119923200622269, (0, 1)), + } + + for data in [self.cohp_bise.cohp_data, self.coop_bise.cohp_data]: + for bond, val in data.items(): + if bond != "average": + assert val["length"] == lengths_sites_bise[bond][0] + assert val["sites"] == lengths_sites_bise[bond][1] + assert len(val["COHP"][Spin.up]) == 241 + assert len(val["ICOHP"][Spin.up]) == 241 + for data in [self.cohp_fe.cohp_data, self.coop_fe.cohp_data]: + for bond, val in data.items(): + if bond != "average": + assert val["length"] == lengths_sites_fe[bond][0] + assert val["sites"] == lengths_sites_fe[bond][1] + assert len(val["COHP"][Spin.up]) == 301 + assert len(val["ICOHP"][Spin.up]) == 301 + + # Lobster 3.1 + for data in [self.cohp_KF.cohp_data, self.coop_KF.cohp_data]: + for bond, val in data.items(): + if bond != "average": + assert val["length"] == lengths_sites_KF[bond][0] + assert val["sites"] == lengths_sites_KF[bond][1] + assert len(val["COHP"][Spin.up]) == 6 + assert len(val["ICOHP"][Spin.up]) == 6 + + for data in [self.cobi2.cohp_data]: + for bond, val in data.items(): + if bond != "average": + if int(bond) >= 13: + assert len(val["COHP"][Spin.up]) == 11 + assert len(val["cells"]) == 3 + else: + assert len(val["COHP"][Spin.up]) == 11 + assert len(val["cells"]) == 2 + + for data in [self.cobi3.cohp_data, self.cobi4.cohp_data]: + for bond, val in data.items(): + if bond != "average": + if int(bond) >= 13: + assert len(val["cells"]) == 4 + else: + assert len(val["cells"]) == 2 + for data in [self.cobi5.cohp_data]: + for bond, val in data.items(): + if bond != "average": + if int(bond) >= 25: + assert len(val["cells"]) == 4 + else: + assert len(val["cells"]) == 2 + for data in [self.cobi6.cohp_data]: + for bond, val in data.items(): + if bond != "average": + if int(bond) >= 21: + assert len(val["cells"]) == 3 + assert len(val["COHP"][Spin.up]) == 12 + assert len(val["COHP"][Spin.down]) == 12 + for cohp1, cohp2 in zip(val["COHP"][Spin.up], val["COHP"][Spin.down], strict=False): + assert cohp1 == approx(cohp2, abs=1e-4) + else: + assert len(val["cells"]) == 2 + assert len(val["COHP"][Spin.up]) == 12 + assert len(val["COHP"][Spin.down]) == 12 + for cohp1, cohp2 in zip(val["COHP"][Spin.up], val["COHP"][Spin.down], strict=False): + assert cohp1 == approx(cohp2, abs=1e-3) + + def test_orbital_resolved_cohp(self): + orbitals = [(Orbital(jj), Orbital(ii)) for ii in range(4) for jj in range(4)] + assert self.cohp_bise.orb_res_cohp is None + assert self.coop_bise.orb_res_cohp is None + assert self.cohp_fe.orb_res_cohp is None + assert self.coop_fe.orb_res_cohp is None + assert self.orb_notot.cohp_data["1"]["COHP"] is None + assert self.orb_notot.cohp_data["1"]["ICOHP"] is None + for orbs in self.orb.orb_res_cohp["1"]: + orb_set = self.orb.orb_res_cohp["1"][orbs]["orbitals"] + assert orb_set[0][0] == 4 + assert orb_set[1][0] == 4 + assert (orb_set[0][1], orb_set[1][1]) in orbitals + + # test d and f orbitals + ref_list1 = [*[5] * 28, *[6] * 36, *[7] * 4] + ref_list2 = [ + *["f0"] * 4, + *["f1"] * 4, + *["f2"] * 4, + *["f3"] * 4, + *["f_1"] * 4, + *["f_2"] * 4, + *["f_3"] * 4, + *["dx2"] * 4, + *["dxy"] * 4, + *["dxz"] * 4, + *["dyz"] * 4, + *["dz2"] * 4, + *["px"] * 4, + *["py"] * 4, + *["pz"] * 4, + *["s"] * 8, + ] + for iorb, orbs in enumerate(sorted(self.cohp_Na2UO4.orb_res_cohp["49"])): + orb_set = self.cohp_Na2UO4.orb_res_cohp["49"][orbs]["orbitals"] + assert orb_set[0][0] == ref_list1[iorb] + assert str(orb_set[0][1]) == ref_list2[iorb] + + # The sum of the orbital-resolved COHPs should be approximately + # the total COHP. Due to small deviations in the LOBSTER calculation, + # the precision is not very high though. + cohp = self.orb.cohp_data["1"]["COHP"][Spin.up] + icohp = self.orb.cohp_data["1"]["ICOHP"][Spin.up] + tot = np.sum( + [self.orb.orb_res_cohp["1"][orbs]["COHP"][Spin.up] for orbs in self.orb.orb_res_cohp["1"]], + axis=0, + ) + assert_allclose(tot, cohp, atol=1e-3) + tot = np.sum( + [self.orb.orb_res_cohp["1"][orbs]["ICOHP"][Spin.up] for orbs in self.orb.orb_res_cohp["1"]], + axis=0, + ) + assert_allclose(tot, icohp, atol=1e-3) + + # Lobster 3.1 + cohp_KF = self.cohp_KF.cohp_data["1"]["COHP"][Spin.up] + icohp_KF = self.cohp_KF.cohp_data["1"]["ICOHP"][Spin.up] + tot_KF = np.sum( + [self.cohp_KF.orb_res_cohp["1"][orbs]["COHP"][Spin.up] for orbs in self.cohp_KF.orb_res_cohp["1"]], + axis=0, + ) + assert_allclose(tot_KF, cohp_KF, atol=1e-3) + tot_KF = np.sum( + [self.cohp_KF.orb_res_cohp["1"][orbs]["ICOHP"][Spin.up] for orbs in self.cohp_KF.orb_res_cohp["1"]], + axis=0, + ) + assert_allclose(tot_KF, icohp_KF, atol=1e-3) + + # d and f orbitals + cohp_Na2UO4 = self.cohp_Na2UO4.cohp_data["49"]["COHP"][Spin.up] + icohp_Na2UO4 = self.cohp_Na2UO4.cohp_data["49"]["ICOHP"][Spin.up] + tot_Na2UO4 = np.sum( + [ + self.cohp_Na2UO4.orb_res_cohp["49"][orbs]["COHP"][Spin.up] + for orbs in self.cohp_Na2UO4.orb_res_cohp["49"] + ], + axis=0, + ) + assert_allclose(tot_Na2UO4, cohp_Na2UO4, atol=1e-3) + tot_Na2UO4 = np.sum( + [ + self.cohp_Na2UO4.orb_res_cohp["49"][orbs]["ICOHP"][Spin.up] + for orbs in self.cohp_Na2UO4.orb_res_cohp["49"] + ], + axis=0, + ) + + assert_allclose(tot_Na2UO4, icohp_Na2UO4, atol=1e-3) + + assert "5s-4s-5s-4s" in self.cobi4.orb_res_cohp["13"] + assert "5px-4px-5px-4px" in self.cobi4.orb_res_cohp["13"] + assert len(self.cobi4.orb_res_cohp["13"]["5px-4px-5px-4px"]["COHP"][Spin.up]) == 11 + + assert "5s-4s-5s-4s" in self.cobi5.orb_res_cohp["25"] + assert "5px-4px-5px-4px" in self.cobi5.orb_res_cohp["25"] + assert len(self.cobi5.orb_res_cohp["25"]["5px-4px-5px-4px"]["COHP"][Spin.up]) == 11 + + assert len(self.cobi6.orb_res_cohp["21"]["2py-1s-2s"]["COHP"][Spin.up]) == 12 + assert len(self.cobi6.orb_res_cohp["21"]["2py-1s-2s"]["COHP"][Spin.down]) == 12 + + +class TestDoscar: + def setup_method(self): + # first for spin polarized version + doscar = f"{VASP_OUT_DIR}/DOSCAR.lobster.spin" + poscar = f"{VASP_IN_DIR}/POSCAR.lobster.spin_DOS" + + # not spin polarized + doscar2 = f"{VASP_OUT_DIR}/DOSCAR.lobster.nonspin" + poscar2 = f"{VASP_IN_DIR}/POSCAR.lobster.nonspin_DOS" + + # DOSCAR.LCFO.lobster + doscar3 = f"{VASP_OUT_DIR}/DOSCAR.LCFO.lobster.AlN" + poscar3 = f"{VASP_IN_DIR}/POSCAR.AlN" + + self.DOSCAR_spin_pol = Doscar(doscar=doscar, structure_file=poscar) + self.DOSCAR_nonspin_pol = Doscar(doscar=doscar2, structure_file=poscar2) + + self.DOSCAR_spin_pol = Doscar(doscar=doscar, structure_file=poscar) + self.DOSCAR_nonspin_pol = Doscar(doscar=doscar2, structure_file=poscar2) + + self.DOSCAR_lcfo = Doscar(doscar=doscar3, structure_file=poscar3, is_lcfo=True) + + with open(f"{TEST_FILES_DIR}/electronic_structure/dos/structure_KF.json", "rb") as file: + data = orjson.loads(file.read()) + + self.structure = Structure.from_dict(data) + + # test structure argument + self.DOSCAR_spin_pol2 = Doscar(doscar=doscar, structure_file=None, structure=Structure.from_file(poscar)) + + def test_complete_dos(self): + # first for spin polarized version + energies_spin = [-11.25000, -7.50000, -3.75000, 0.00000, 3.75000, 7.50000] + tdos_up = [0.00000, 0.79999, 0.00000, 0.79999, 0.00000, 0.02577] + tdos_down = [0.00000, 0.79999, 0.00000, 0.79999, 0.00000, 0.02586] + fermi = 0.0 + + pdos_f_2s_up = [0.00000, 0.00159, 0.00000, 0.00011, 0.00000, 0.00069] + pdos_f_2s_down = [0.00000, 0.00159, 0.00000, 0.00011, 0.00000, 0.00069] + pdos_f_2py_up = [0.00000, 0.00160, 0.00000, 0.25801, 0.00000, 0.00029] + pdos_f_2py_down = [0.00000, 0.00161, 0.00000, 0.25819, 0.00000, 0.00029] + pdos_f_2pz_up = [0.00000, 0.00161, 0.00000, 0.25823, 0.00000, 0.00029] + pdos_f_2pz_down = [0.00000, 0.00160, 0.00000, 0.25795, 0.00000, 0.00029] + pdos_f_2px_up = [0.00000, 0.00160, 0.00000, 0.25805, 0.00000, 0.00029] + pdos_f_2px_down = [0.00000, 0.00161, 0.00000, 0.25814, 0.00000, 0.00029] + + assert_allclose(energies_spin, self.DOSCAR_spin_pol.completedos.energies) + assert_allclose(tdos_up, self.DOSCAR_spin_pol.completedos.densities[Spin.up]) + assert_allclose(tdos_down, self.DOSCAR_spin_pol.completedos.densities[Spin.down]) + assert fermi == approx(self.DOSCAR_spin_pol.completedos.efermi) + + assert_allclose( + self.DOSCAR_spin_pol.completedos.structure.frac_coords, + self.structure.frac_coords, + ) + assert_allclose( + self.DOSCAR_spin_pol2.completedos.structure.frac_coords, + self.structure.frac_coords, + ) + assert_allclose(self.DOSCAR_spin_pol.completedos.pdos[self.structure[0]]["2s"][Spin.up], pdos_f_2s_up) + assert_allclose(self.DOSCAR_spin_pol.completedos.pdos[self.structure[0]]["2s"][Spin.down], pdos_f_2s_down) + assert_allclose(self.DOSCAR_spin_pol.completedos.pdos[self.structure[0]]["2p_y"][Spin.up], pdos_f_2py_up) + assert_allclose(self.DOSCAR_spin_pol.completedos.pdos[self.structure[0]]["2p_y"][Spin.down], pdos_f_2py_down) + assert_allclose(self.DOSCAR_spin_pol.completedos.pdos[self.structure[0]]["2p_z"][Spin.up], pdos_f_2pz_up) + assert_allclose(self.DOSCAR_spin_pol.completedos.pdos[self.structure[0]]["2p_z"][Spin.down], pdos_f_2pz_down) + assert_allclose(self.DOSCAR_spin_pol.completedos.pdos[self.structure[0]]["2p_x"][Spin.up], pdos_f_2px_up) + assert_allclose(self.DOSCAR_spin_pol.completedos.pdos[self.structure[0]]["2p_x"][Spin.down], pdos_f_2px_down) + + energies_nonspin = [-11.25000, -7.50000, -3.75000, 0.00000, 3.75000, 7.50000] + tdos_nonspin = [0.00000, 1.60000, 0.00000, 1.60000, 0.00000, 0.02418] + pdos_f_2s = [0.00000, 0.00320, 0.00000, 0.00017, 0.00000, 0.00060] + pdos_f_2py = [0.00000, 0.00322, 0.00000, 0.51635, 0.00000, 0.00037] + pdos_f_2pz = [0.00000, 0.00322, 0.00000, 0.51636, 0.00000, 0.00037] + pdos_f_2px = [0.00000, 0.00322, 0.00000, 0.51634, 0.00000, 0.00037] + + assert_allclose(energies_nonspin, self.DOSCAR_nonspin_pol.completedos.energies) + + assert_allclose(tdos_nonspin, self.DOSCAR_nonspin_pol.completedos.densities[Spin.up]) + + assert fermi == approx(self.DOSCAR_nonspin_pol.completedos.efermi) + + assert self.DOSCAR_nonspin_pol.completedos.structure == self.structure + + assert_allclose(self.DOSCAR_nonspin_pol.completedos.pdos[self.structure[0]]["2s"][Spin.up], pdos_f_2s) + assert_allclose(self.DOSCAR_nonspin_pol.completedos.pdos[self.structure[0]]["2p_y"][Spin.up], pdos_f_2py) + assert_allclose(self.DOSCAR_nonspin_pol.completedos.pdos[self.structure[0]]["2p_z"][Spin.up], pdos_f_2pz) + assert_allclose(self.DOSCAR_nonspin_pol.completedos.pdos[self.structure[0]]["2p_x"][Spin.up], pdos_f_2px) + + def test_pdos(self): + # first for spin polarized version + + pdos_f_2s_up = [0.00000, 0.00159, 0.00000, 0.00011, 0.00000, 0.00069] + pdos_f_2s_down = [0.00000, 0.00159, 0.00000, 0.00011, 0.00000, 0.00069] + pdos_f_2py_up = [0.00000, 0.00160, 0.00000, 0.25801, 0.00000, 0.00029] + pdos_f_2py_down = [0.00000, 0.00161, 0.00000, 0.25819, 0.00000, 0.00029] + pdos_f_2pz_up = [0.00000, 0.00161, 0.00000, 0.25823, 0.00000, 0.00029] + pdos_f_2pz_down = [0.00000, 0.00160, 0.00000, 0.25795, 0.00000, 0.00029] + pdos_f_2px_up = [0.00000, 0.00160, 0.00000, 0.25805, 0.00000, 0.00029] + pdos_f_2px_down = [0.00000, 0.00161, 0.00000, 0.25814, 0.00000, 0.00029] + + assert_allclose(self.DOSCAR_spin_pol.pdos[0]["2s"][Spin.up], pdos_f_2s_up) + assert_allclose(self.DOSCAR_spin_pol.pdos[0]["2s"][Spin.down], pdos_f_2s_down) + assert_allclose(self.DOSCAR_spin_pol.pdos[0]["2p_y"][Spin.up], pdos_f_2py_up) + assert_allclose(self.DOSCAR_spin_pol.pdos[0]["2p_y"][Spin.down], pdos_f_2py_down) + assert_allclose(self.DOSCAR_spin_pol.pdos[0]["2p_z"][Spin.up], pdos_f_2pz_up) + assert_allclose(self.DOSCAR_spin_pol.pdos[0]["2p_z"][Spin.down], pdos_f_2pz_down) + assert_allclose(self.DOSCAR_spin_pol.pdos[0]["2p_x"][Spin.up], pdos_f_2px_up) + assert_allclose(self.DOSCAR_spin_pol.pdos[0]["2p_x"][Spin.down], pdos_f_2px_down) + + # non spin + pdos_f_2s = [0.00000, 0.00320, 0.00000, 0.00017, 0.00000, 0.00060] + pdos_f_2py = [0.00000, 0.00322, 0.00000, 0.51635, 0.00000, 0.00037] + pdos_f_2pz = [0.00000, 0.00322, 0.00000, 0.51636, 0.00000, 0.00037] + pdos_f_2px = [0.00000, 0.00322, 0.00000, 0.51634, 0.00000, 0.00037] + + assert_allclose(self.DOSCAR_nonspin_pol.pdos[0]["2s"][Spin.up], pdos_f_2s) + assert_allclose(self.DOSCAR_nonspin_pol.pdos[0]["2p_y"][Spin.up], pdos_f_2py) + assert_allclose(self.DOSCAR_nonspin_pol.pdos[0]["2p_z"][Spin.up], pdos_f_2pz) + assert_allclose(self.DOSCAR_nonspin_pol.pdos[0]["2p_x"][Spin.up], pdos_f_2px) + + # test with DOSCAR.LCFO.lobster file + pdos_1a1_AlN = [ + 0.0, + 0.22594, + 0.01335, + 0.0, + 0.0, + 0.0, + 0.00228, + 0.02836, + 0.03053, + 0.01612, + 0.02379, + ] + pdos_3py_Al = [ + 0.0, + 0.02794, + 0.00069, + 0.0, + 0.0, + 0.0, + 0.00216, + 0.0682, + 0.06966, + 0.04402, + 0.16579, + ] + pdos_2s_N = [ + 0.0, + 0.25324, + 0.0157, + 0.0, + 0.0, + 0.0, + 0.0006, + 0.01747, + 0.02247, + 0.01589, + 0.03565, + ] + + assert self.DOSCAR_lcfo._is_lcfo + assert_allclose(self.DOSCAR_lcfo.pdos[0]["1a1"][Spin.down], pdos_1a1_AlN) + assert_allclose(self.DOSCAR_lcfo.pdos[1]["3p_y"][Spin.down], pdos_3py_Al) + assert_allclose(self.DOSCAR_lcfo.pdos[2]["2s"][Spin.down], pdos_2s_N) + + def test_tdos(self): + # first for spin polarized version + energies_spin = [-11.25000, -7.50000, -3.75000, 0.00000, 3.75000, 7.50000] + tdos_up = [0.00000, 0.79999, 0.00000, 0.79999, 0.00000, 0.02577] + tdos_down = [0.00000, 0.79999, 0.00000, 0.79999, 0.00000, 0.02586] + fermi = 0.0 + + assert_allclose(energies_spin, self.DOSCAR_spin_pol.tdos.energies) + assert_allclose(tdos_up, self.DOSCAR_spin_pol.tdos.densities[Spin.up]) + assert_allclose(tdos_down, self.DOSCAR_spin_pol.tdos.densities[Spin.down]) + assert fermi == approx(self.DOSCAR_spin_pol.tdos.efermi) + + energies_nonspin = [-11.25000, -7.50000, -3.75000, 0.00000, 3.75000, 7.50000] + tdos_nonspin = [0.00000, 1.60000, 0.00000, 1.60000, 0.00000, 0.02418] + fermi = 0.0 + + assert_allclose(energies_nonspin, self.DOSCAR_nonspin_pol.tdos.energies) + assert_allclose(tdos_nonspin, self.DOSCAR_nonspin_pol.tdos.densities[Spin.up]) + assert fermi == approx(self.DOSCAR_nonspin_pol.tdos.efermi) + + def test_energies(self): + # first for spin polarized version + energies_spin = [-11.25000, -7.50000, -3.75000, 0.00000, 3.75000, 7.50000] + + assert_allclose(energies_spin, self.DOSCAR_spin_pol.energies) + + energies_nonspin = [-11.25000, -7.50000, -3.75000, 0.00000, 3.75000, 7.50000] + assert_allclose(energies_nonspin, self.DOSCAR_nonspin_pol.energies) + + def test_tdensities(self): + # first for spin polarized version + tdos_up = [0.00000, 0.79999, 0.00000, 0.79999, 0.00000, 0.02577] + tdos_down = [0.00000, 0.79999, 0.00000, 0.79999, 0.00000, 0.02586] + + assert_allclose(tdos_up, self.DOSCAR_spin_pol.tdensities[Spin.up]) + assert_allclose(tdos_down, self.DOSCAR_spin_pol.tdensities[Spin.down]) + + tdos_nonspin = [0.00000, 1.60000, 0.00000, 1.60000, 0.00000, 0.02418] + assert_allclose(tdos_nonspin, self.DOSCAR_nonspin_pol.tdensities[Spin.up]) + + # test with DOSCAR.LCFO.lobster file + tdos_up = [ + 0.0, + 1.75477, + 0.11803, + 0.0, + 0.0, + 0.0, + 0.04156, + 0.82291, + 0.74449, + 0.42481, + 1.04535, + ] + + assert_allclose(tdos_up, self.DOSCAR_lcfo.tdensities[Spin.up]) + + def test_itdensities(self): + itdos_up = [1.99997, 4.99992, 4.99992, 7.99987, 7.99987, 8.09650] + itdos_down = [1.99997, 4.99992, 4.99992, 7.99987, 7.99987, 8.09685] + assert_allclose(itdos_up, self.DOSCAR_spin_pol.itdensities[Spin.up]) + assert_allclose(itdos_down, self.DOSCAR_spin_pol.itdensities[Spin.down]) + + itdos_nonspin = [4.00000, 10.00000, 10.00000, 16.00000, 16.00000, 16.09067] + assert_allclose(itdos_nonspin, self.DOSCAR_nonspin_pol.itdensities[Spin.up]) + + def test_is_spin_polarized(self): + # first for spin polarized version + assert self.DOSCAR_spin_pol.is_spin_polarized + + assert not self.DOSCAR_nonspin_pol.is_spin_polarized + + +class TestCharge(MatSciTest): + def setup_method(self): + self.charge2 = Charge(filename=f"{TEST_DIR}/CHARGE.lobster.MnO") + # gzipped file + self.charge = Charge(filename=f"{TEST_DIR}/CHARGE.lobster.MnO2.gz") + self.charge_lcfo = Charge(filename=f"{TEST_DIR}/CHARGE.LCFO.lobster.ALN.gz", is_lcfo=True) + + def test_attributes(self): + assert self.charge2.mulliken == approx([-1.30, 1.30]) + assert self.charge2.loewdin == approx([-1.25, 1.25]) + assert self.charge2.atomlist == ["O1", "Mn2"] + assert self.charge2.types == ["O", "Mn"] + assert self.charge2.num_atoms == 2 + + # test with CHARG.LCFO.lobster file + assert self.charge_lcfo.is_lcfo + assert self.charge_lcfo.num_atoms == 3 + assert self.charge_lcfo.types == ["AlN", "Al", "N"] + assert self.charge_lcfo.atomlist == ["AlN1", "Al2", "N3"] + assert_allclose(self.charge_lcfo.loewdin, [0.0, 1.02, -1.02]) + assert not self.charge_lcfo.mulliken + + def test_get_structure_with_charges(self): + structure_dict2 = { + "lattice": { + "c": 3.198244, + "volume": 23.132361565928807, + "b": 3.1982447183003364, + "gamma": 60.00000011873414, + "beta": 60.00000401737447, + "alpha": 60.00000742944491, + "matrix": [ + [2.769761, 0.0, 1.599122], + [0.923254, 2.611356, 1.599122], + [0.0, 0.0, 3.198244], + ], + "a": 3.1982443884113985, + }, + "@class": "Structure", + "sites": [ + { + "xyz": [1.846502883732, 1.305680611356, 3.198248797366], + "properties": {"Loewdin Charges": -1.25, "Mulliken Charges": -1.3}, + "abc": [0.499998, 0.500001, 0.500002], + "species": [{"occu": 1, "element": "O"}], + "label": "O", + }, + { + "xyz": [0.0, 0.0, 0.0], + "properties": {"Loewdin Charges": 1.25, "Mulliken Charges": 1.3}, + "abc": [0.0, 0.0, 0.0], + "species": [{"occu": 1, "element": "Mn"}], + "label": "Mn", + }, + ], + "charge": None, + "@module": "pymatgen.core.structure", + } + s2 = Structure.from_dict(structure_dict2) + assert s2 == self.charge2.get_structure_with_charges(f"{VASP_IN_DIR}/POSCAR_MnO") + + def test_exception(self): + structure_file = f"{VASP_IN_DIR}/POSCAR.AlN" + with pytest.raises(ValueError, match="CHARGE.LCFO.lobster charges are not sorted site wise"): + self.charge_lcfo.get_structure_with_charges(structure_filename=structure_file) + + def test_msonable(self): + dict_data = self.charge2.as_dict() + charge_from_dict = Charge.from_dict(dict_data) + all_attributes = vars(self.charge2) + for attr_name, attr_value in all_attributes.items(): + assert getattr(charge_from_dict, attr_name) == attr_value + + +class TestLobsterout(MatSciTest): + def setup_method(self): + self.lobsterout_normal = Lobsterout(filename=f"{TEST_DIR}/lobsterout.normal") + # make sure .gz files are also read correctly + self.lobsterout_normal = Lobsterout(filename=f"{TEST_DIR}/lobsterout.normal2.gz") + self.lobsterout_fatband_grosspop_densityofenergies = Lobsterout( + filename=f"{TEST_DIR}/lobsterout.fatband_grosspop_densityofenergy" + ) + self.lobsterout_saveprojection = Lobsterout(filename=f"{TEST_DIR}/lobsterout.saveprojection") + self.lobsterout_skipping_all = Lobsterout(filename=f"{TEST_DIR}/lobsterout.skipping_all") + self.lobsterout_twospins = Lobsterout(filename=f"{TEST_DIR}/lobsterout.twospins") + self.lobsterout_GaAs = Lobsterout(filename=f"{TEST_DIR}/lobsterout.GaAs") + self.lobsterout_from_projection = Lobsterout(filename=f"{TEST_DIR}/lobsterout_from_projection") + self.lobsterout_onethread = Lobsterout(filename=f"{TEST_DIR}/lobsterout.onethread") + self.lobsterout_cobi_madelung = Lobsterout(filename=f"{TEST_DIR}/lobsterout_cobi_madelung") + self.lobsterout_doscar_lso = Lobsterout(filename=f"{TEST_DIR}/lobsterout_doscar_lso") + + # TODO: implement skipping madelung/cobi + self.lobsterout_skipping_cobi_madelung = Lobsterout(filename=f"{TEST_DIR}/lobsterout.skip_cobi_madelung") + + # Lobster v5.1.1 + self.lobsterout_v511 = Lobsterout(filename=f"{TEST_DIR}/lobsterout_v511.gz") + + def test_attributes(self): + assert self.lobsterout_normal.basis_functions == [ + [ + "3s", + "4s", + "3p_y", + "3p_z", + "3p_x", + "3d_xy", + "3d_yz", + "3d_z^2", + "3d_xz", + "3d_x^2-y^2", + ] + ] + assert self.lobsterout_normal.basis_type == ["pbeVaspFit2015"] + assert_allclose(self.lobsterout_normal.charge_spilling, [0.0268]) + assert self.lobsterout_normal.dft_program == "VASP" + assert self.lobsterout_normal.elements == ["Ti"] + assert self.lobsterout_normal.has_charge + assert self.lobsterout_normal.has_cohpcar + assert self.lobsterout_normal.has_coopcar + assert self.lobsterout_normal.has_doscar + assert not self.lobsterout_normal.has_projection + assert self.lobsterout_normal.has_bandoverlaps + assert not self.lobsterout_normal.has_density_of_energies + assert not self.lobsterout_normal.has_fatbands + assert not self.lobsterout_normal.has_grosspopulation + assert self.lobsterout_normal.info_lines == [ + "There are more PAW bands than local basis functions available.", + "To prevent trouble in orthonormalization and Hamiltonian reconstruction", + "the PAW bands from 21 and upwards will be ignored.", + ] + assert self.lobsterout_normal.info_orthonormalization == [ + "3 of 147 k-points could not be orthonormalized with an accuracy of 1.0E-5." + ] + assert not self.lobsterout_normal.is_restart_from_projection + assert self.lobsterout_normal.lobster_version == "v3.1.0" + assert self.lobsterout_normal.number_of_spins == 1 + assert self.lobsterout_normal.number_of_threads == 8 + assert self.lobsterout_normal.timing == { + "wall_time": {"h": "0", "min": "0", "s": "2", "ms": "702"}, + "user_time": {"h": "0", "min": "0", "s": "20", "ms": "330"}, + "sys_time": {"h": "0", "min": "0", "s": "0", "ms": "310"}, + } + assert self.lobsterout_normal.total_spilling[0] == approx([0.044000000000000004][0]) + assert self.lobsterout_normal.warning_lines == [ + "3 of 147 k-points could not be orthonormalized with an accuracy of 1.0E-5.", + "Generally, this is not a critical error. But to help you analyze it,", + "I dumped the band overlap matrices to the file bandOverlaps.lobster.", + "Please check how much they deviate from the identity matrix and decide to", + "use your results only, if you are sure that this is ok.", + ] + + assert self.lobsterout_fatband_grosspop_densityofenergies.basis_functions == [ + [ + "3s", + "4s", + "3p_y", + "3p_z", + "3p_x", + "3d_xy", + "3d_yz", + "3d_z^2", + "3d_xz", + "3d_x^2-y^2", + ] + ] + assert self.lobsterout_fatband_grosspop_densityofenergies.basis_type == ["pbeVaspFit2015"] + assert_allclose(self.lobsterout_fatband_grosspop_densityofenergies.charge_spilling, [0.0268]) + assert self.lobsterout_fatband_grosspop_densityofenergies.dft_program == "VASP" + assert self.lobsterout_fatband_grosspop_densityofenergies.elements == ["Ti"] + assert self.lobsterout_fatband_grosspop_densityofenergies.has_charge + assert not self.lobsterout_fatband_grosspop_densityofenergies.has_cohpcar + assert not self.lobsterout_fatband_grosspop_densityofenergies.has_coopcar + assert not self.lobsterout_fatband_grosspop_densityofenergies.has_doscar + assert not self.lobsterout_fatband_grosspop_densityofenergies.has_projection + assert self.lobsterout_fatband_grosspop_densityofenergies.has_bandoverlaps + assert self.lobsterout_fatband_grosspop_densityofenergies.has_density_of_energies + assert self.lobsterout_fatband_grosspop_densityofenergies.has_fatbands + assert self.lobsterout_fatband_grosspop_densityofenergies.has_grosspopulation + assert self.lobsterout_fatband_grosspop_densityofenergies.info_lines == [ + "There are more PAW bands than local basis functions available.", + "To prevent trouble in orthonormalization and Hamiltonian reconstruction", + "the PAW bands from 21 and upwards will be ignored.", + ] + assert self.lobsterout_fatband_grosspop_densityofenergies.info_orthonormalization == [ + "3 of 147 k-points could not be orthonormalized with an accuracy of 1.0E-5." + ] + assert not self.lobsterout_fatband_grosspop_densityofenergies.is_restart_from_projection + assert self.lobsterout_fatband_grosspop_densityofenergies.lobster_version == "v3.1.0" + assert self.lobsterout_fatband_grosspop_densityofenergies.number_of_spins == 1 + assert self.lobsterout_fatband_grosspop_densityofenergies.number_of_threads == 8 + assert self.lobsterout_fatband_grosspop_densityofenergies.timing == { + "wall_time": {"h": "0", "min": "0", "s": "4", "ms": "136"}, + "user_time": {"h": "0", "min": "0", "s": "18", "ms": "280"}, + "sys_time": {"h": "0", "min": "0", "s": "0", "ms": "290"}, + } + assert self.lobsterout_fatband_grosspop_densityofenergies.total_spilling[0] == approx([0.044000000000000004][0]) + assert self.lobsterout_fatband_grosspop_densityofenergies.warning_lines == [ + "3 of 147 k-points could not be orthonormalized with an accuracy of 1.0E-5.", + "Generally, this is not a critical error. But to help you analyze it,", + "I dumped the band overlap matrices to the file bandOverlaps.lobster.", + "Please check how much they deviate from the identity matrix and decide to", + "use your results only, if you are sure that this is ok.", + ] + + assert self.lobsterout_saveprojection.basis_functions == [ + [ + "3s", + "4s", + "3p_y", + "3p_z", + "3p_x", + "3d_xy", + "3d_yz", + "3d_z^2", + "3d_xz", + "3d_x^2-y^2", + ] + ] + assert self.lobsterout_saveprojection.basis_type == ["pbeVaspFit2015"] + assert_allclose(self.lobsterout_saveprojection.charge_spilling, [0.0268]) + assert self.lobsterout_saveprojection.dft_program == "VASP" + assert self.lobsterout_saveprojection.elements == ["Ti"] + assert self.lobsterout_saveprojection.has_charge + assert not self.lobsterout_saveprojection.has_cohpcar + assert not self.lobsterout_saveprojection.has_coopcar + assert not self.lobsterout_saveprojection.has_doscar + assert self.lobsterout_saveprojection.has_projection + assert self.lobsterout_saveprojection.has_bandoverlaps + assert self.lobsterout_saveprojection.has_density_of_energies + assert not self.lobsterout_saveprojection.has_fatbands + assert not self.lobsterout_saveprojection.has_grosspopulation + assert self.lobsterout_saveprojection.info_lines == [ + "There are more PAW bands than local basis functions available.", + "To prevent trouble in orthonormalization and Hamiltonian reconstruction", + "the PAW bands from 21 and upwards will be ignored.", + ] + assert self.lobsterout_saveprojection.info_orthonormalization == [ + "3 of 147 k-points could not be orthonormalized with an accuracy of 1.0E-5." + ] + assert not self.lobsterout_saveprojection.is_restart_from_projection + assert self.lobsterout_saveprojection.lobster_version == "v3.1.0" + assert self.lobsterout_saveprojection.number_of_spins == 1 + assert self.lobsterout_saveprojection.number_of_threads == 8 + assert self.lobsterout_saveprojection.timing == { + "wall_time": {"h": "0", "min": "0", "s": "2", "ms": "574"}, + "user_time": {"h": "0", "min": "0", "s": "18", "ms": "250"}, + "sys_time": {"h": "0", "min": "0", "s": "0", "ms": "320"}, + } + assert self.lobsterout_saveprojection.total_spilling[0] == approx([0.044000000000000004][0]) + assert self.lobsterout_saveprojection.warning_lines == [ + "3 of 147 k-points could not be orthonormalized with an accuracy of 1.0E-5.", + "Generally, this is not a critical error. But to help you analyze it,", + "I dumped the band overlap matrices to the file bandOverlaps.lobster.", + "Please check how much they deviate from the identity matrix and decide to", + "use your results only, if you are sure that this is ok.", + ] + + assert self.lobsterout_skipping_all.basis_functions == [ + [ + "3s", + "4s", + "3p_y", + "3p_z", + "3p_x", + "3d_xy", + "3d_yz", + "3d_z^2", + "3d_xz", + "3d_x^2-y^2", + ] + ] + assert self.lobsterout_skipping_all.basis_type == ["pbeVaspFit2015"] + assert_allclose(self.lobsterout_skipping_all.charge_spilling, [0.0268]) + assert self.lobsterout_skipping_all.dft_program == "VASP" + assert self.lobsterout_skipping_all.elements == ["Ti"] + assert not self.lobsterout_skipping_all.has_charge + assert not self.lobsterout_skipping_all.has_cohpcar + assert not self.lobsterout_skipping_all.has_coopcar + assert not self.lobsterout_skipping_all.has_doscar + assert not self.lobsterout_skipping_all.has_projection + assert self.lobsterout_skipping_all.has_bandoverlaps + assert not self.lobsterout_skipping_all.has_density_of_energies + assert not self.lobsterout_skipping_all.has_fatbands + assert not self.lobsterout_skipping_all.has_grosspopulation + assert not self.lobsterout_skipping_all.has_cobicar + assert not self.lobsterout_skipping_all.has_madelung + assert self.lobsterout_skipping_all.info_lines == [ + "There are more PAW bands than local basis functions available.", + "To prevent trouble in orthonormalization and Hamiltonian reconstruction", + "the PAW bands from 21 and upwards will be ignored.", + ] + assert self.lobsterout_skipping_all.info_orthonormalization == [ + "3 of 147 k-points could not be orthonormalized with an accuracy of 1.0E-5." + ] + assert not self.lobsterout_skipping_all.is_restart_from_projection + assert self.lobsterout_skipping_all.lobster_version == "v3.1.0" + assert self.lobsterout_skipping_all.number_of_spins == 1 + assert self.lobsterout_skipping_all.number_of_threads == 8 + assert self.lobsterout_skipping_all.timing == { + "wall_time": {"h": "0", "min": "0", "s": "2", "ms": "117"}, + "user_time": {"h": "0", "min": "0", "s": "16", "ms": "79"}, + "sys_time": {"h": "0", "min": "0", "s": "0", "ms": "320"}, + } + assert self.lobsterout_skipping_all.total_spilling[0] == approx([0.044000000000000004][0]) + assert self.lobsterout_skipping_all.warning_lines == [ + "3 of 147 k-points could not be orthonormalized with an accuracy of 1.0E-5.", + "Generally, this is not a critical error. But to help you analyze it,", + "I dumped the band overlap matrices to the file bandOverlaps.lobster.", + "Please check how much they deviate from the identity matrix and decide to", + "use your results only, if you are sure that this is ok.", + ] + + assert self.lobsterout_twospins.basis_functions == [ + [ + "4s", + "4p_y", + "4p_z", + "4p_x", + "3d_xy", + "3d_yz", + "3d_z^2", + "3d_xz", + "3d_x^2-y^2", + ] + ] + assert self.lobsterout_twospins.basis_type == ["pbeVaspFit2015"] + assert self.lobsterout_twospins.charge_spilling[0] == approx(0.36619999999999997) + assert self.lobsterout_twospins.charge_spilling[1] == approx(0.36619999999999997) + assert self.lobsterout_twospins.dft_program == "VASP" + assert self.lobsterout_twospins.elements == ["Ti"] + assert self.lobsterout_twospins.has_charge + assert self.lobsterout_twospins.has_cohpcar + assert self.lobsterout_twospins.has_coopcar + assert self.lobsterout_twospins.has_doscar + assert not self.lobsterout_twospins.has_projection + assert self.lobsterout_twospins.has_bandoverlaps + assert not self.lobsterout_twospins.has_density_of_energies + assert not self.lobsterout_twospins.has_fatbands + assert not self.lobsterout_twospins.has_grosspopulation + assert self.lobsterout_twospins.info_lines == [ + "There are more PAW bands than local basis functions available.", + "To prevent trouble in orthonormalization and Hamiltonian reconstruction", + "the PAW bands from 19 and upwards will be ignored.", + ] + assert self.lobsterout_twospins.info_orthonormalization == [ + "60 of 294 k-points could not be orthonormalized with an accuracy of 1.0E-5." + ] + assert not self.lobsterout_twospins.is_restart_from_projection + assert self.lobsterout_twospins.lobster_version == "v3.1.0" + assert self.lobsterout_twospins.number_of_spins == 2 + assert self.lobsterout_twospins.number_of_threads == 8 + assert self.lobsterout_twospins.timing == { + "wall_time": {"h": "0", "min": "0", "s": "3", "ms": "71"}, + "user_time": {"h": "0", "min": "0", "s": "22", "ms": "660"}, + "sys_time": {"h": "0", "min": "0", "s": "0", "ms": "310"}, + } + assert self.lobsterout_twospins.total_spilling[0] == approx([0.2567][0]) + assert self.lobsterout_twospins.total_spilling[1] == approx([0.2567][0]) + assert self.lobsterout_twospins.warning_lines == [ + "60 of 294 k-points could not be orthonormalized with an accuracy of 1.0E-5.", + "Generally, this is not a critical error. But to help you analyze it,", + "I dumped the band overlap matrices to the file bandOverlaps.lobster.", + "Please check how much they deviate from the identity matrix and decide to", + "use your results only, if you are sure that this is ok.", + ] + + assert self.lobsterout_from_projection.basis_functions == [] + assert self.lobsterout_from_projection.basis_type == [] + assert self.lobsterout_from_projection.charge_spilling[0] == approx(0.0177) + assert self.lobsterout_from_projection.dft_program is None + assert self.lobsterout_from_projection.elements == [] + assert self.lobsterout_from_projection.has_charge + assert self.lobsterout_from_projection.has_cohpcar + assert self.lobsterout_from_projection.has_coopcar + assert self.lobsterout_from_projection.has_doscar + assert not self.lobsterout_from_projection.has_projection + assert not self.lobsterout_from_projection.has_bandoverlaps + assert not self.lobsterout_from_projection.has_density_of_energies + assert not self.lobsterout_from_projection.has_fatbands + assert not self.lobsterout_from_projection.has_grosspopulation + assert self.lobsterout_from_projection.info_lines == [] + assert self.lobsterout_from_projection.info_orthonormalization == [] + assert self.lobsterout_from_projection.is_restart_from_projection + assert self.lobsterout_from_projection.lobster_version == "v3.1.0" + assert self.lobsterout_from_projection.number_of_spins == 1 + assert self.lobsterout_from_projection.number_of_threads == 8 + assert self.lobsterout_from_projection.timing == { + "wall_time": {"h": "0", "min": "2", "s": "1", "ms": "890"}, + "user_time": {"h": "0", "min": "15", "s": "10", "ms": "530"}, + "sys_time": {"h": "0", "min": "0", "s": "0", "ms": "400"}, + } + assert self.lobsterout_from_projection.total_spilling[0] == approx([0.1543][0]) + assert self.lobsterout_from_projection.warning_lines == [] + + assert self.lobsterout_GaAs.basis_functions == [ + ["4s", "4p_y", "4p_z", "4p_x"], + [ + "4s", + "4p_y", + "4p_z", + "4p_x", + "3d_xy", + "3d_yz", + "3d_z^2", + "3d_xz", + "3d_x^2-y^2", + ], + ] + assert self.lobsterout_GaAs.basis_type == ["Bunge", "Bunge"] + assert self.lobsterout_GaAs.charge_spilling[0] == approx(0.0089) + assert self.lobsterout_GaAs.dft_program == "VASP" + assert self.lobsterout_GaAs.elements == ["As", "Ga"] + assert self.lobsterout_GaAs.has_charge + assert self.lobsterout_GaAs.has_cohpcar + assert self.lobsterout_GaAs.has_coopcar + assert self.lobsterout_GaAs.has_doscar + assert not self.lobsterout_GaAs.has_projection + assert not self.lobsterout_GaAs.has_bandoverlaps + assert not self.lobsterout_GaAs.has_density_of_energies + assert not self.lobsterout_GaAs.has_fatbands + assert not self.lobsterout_GaAs.has_grosspopulation + assert self.lobsterout_GaAs.info_lines == [ + "There are more PAW bands than local basis functions available.", + "To prevent trouble in orthonormalization and Hamiltonian reconstruction", + "the PAW bands from 14 and upwards will be ignored.", + ] + assert self.lobsterout_GaAs.info_orthonormalization == [] + assert not self.lobsterout_GaAs.is_restart_from_projection + assert self.lobsterout_GaAs.lobster_version == "v3.1.0" + assert self.lobsterout_GaAs.number_of_spins == 1 + assert self.lobsterout_GaAs.number_of_threads == 8 + assert self.lobsterout_GaAs.timing == { + "wall_time": {"h": "0", "min": "0", "s": "2", "ms": "726"}, + "user_time": {"h": "0", "min": "0", "s": "12", "ms": "370"}, + "sys_time": {"h": "0", "min": "0", "s": "0", "ms": "180"}, + } + assert self.lobsterout_GaAs.total_spilling[0] == approx(0.0859) + + assert self.lobsterout_onethread.number_of_threads == 1 + # Test lobsterout of lobster-4.1.0 + assert self.lobsterout_cobi_madelung.has_cobicar + assert self.lobsterout_cobi_madelung.has_cohpcar + assert self.lobsterout_cobi_madelung.has_madelung + assert not self.lobsterout_cobi_madelung.has_doscar_lso + + assert self.lobsterout_doscar_lso.has_doscar_lso + + assert self.lobsterout_skipping_cobi_madelung.has_cobicar is False + assert self.lobsterout_skipping_cobi_madelung.has_madelung is False + + def test_get_doc(self): + ref_data = { + "restart_from_projection": False, + "lobster_version": "v3.1.0", + "threads": 8, + "dft_program": "VASP", + "charge_spilling": [0.0268], + "total_spilling": [0.044000000000000004], + "elements": ["Ti"], + "basis_type": ["pbeVaspFit2015"], + "basis_functions": [ + [ + "3s", + "4s", + "3p_y", + "3p_z", + "3p_x", + "3d_xy", + "3d_yz", + "3d_z^2", + "3d_xz", + "3d_x^2-y^2", + ] + ], + "timing": { + "wall_time": {"h": "0", "min": "0", "s": "2", "ms": "702"}, + "user_time": {"h": "0", "min": "0", "s": "20", "ms": "330"}, + "sys_time": {"h": "0", "min": "0", "s": "0", "ms": "310"}, + }, + "warning_lines": [ + "3 of 147 k-points could not be orthonormalized with an accuracy of 1.0E-5.", + "Generally, this is not a critical error. But to help you analyze it,", + "I dumped the band overlap matrices to the file bandOverlaps.lobster.", + "Please check how much they deviate from the identity matrix and decide to", + "use your results only, if you are sure that this is ok.", + ], + "info_orthonormalization": ["3 of 147 k-points could not be orthonormalized with an accuracy of 1.0E-5."], + "info_lines": [ + "There are more PAW bands than local basis functions available.", + "To prevent trouble in orthonormalization and Hamiltonian reconstruction", + "the PAW bands from 21 and upwards will be ignored.", + ], + "has_doscar": True, + "has_doscar_lso": False, + "has_doscar_lcfo": False, + "has_cohpcar": True, + "has_cohpcar_lcfo": False, + "has_coopcar": True, + "has_coopcar_lcfo": False, + "has_cobicar_lcfo": False, + "has_charge": True, + "has_projection": False, + "has_bandoverlaps": True, + "has_fatbands": False, + "has_grosspopulation": False, + "has_polarization": False, + "has_density_of_energies": False, + "has_mofecar": False, + } + for key, item in self.lobsterout_normal.get_doc().items(): + if key not in ["has_cobicar", "has_madelung"]: + if isinstance(item, str): + assert ref_data[key], item + elif isinstance(item, int): + assert ref_data[key] == item + elif key in ("charge_spilling", "total_spilling"): + assert item[0] == approx(ref_data[key][0]) + elif isinstance(item, list | dict): + assert item == ref_data[key] + ref_data_v511 = { + "restart_from_projection": False, + "lobster_version": "v5.1.1", + "threads": 8, + "dft_program": "VASP", + "charge_spilling": [0.0111, 0.0111], + "total_spilling": [], + "elements": ["N", "Al"], + "basis_type": ["pbevaspfit2015", "pbevaspfit2015"], + "basis_functions": [ + ["2s", "2p_y", "2p_z", "2p_x"], + ["3s", "3p_y", "3p_z", "3p_x"], + ], + "timing": { + "wall_time": {"h": "0", "min": "4", "s": "8", "ms": "368"}, + "user_time": {"h": "0", "min": "22", "s": "34", "ms": "960"}, + "sys_time": {"h": "0", "min": "0", "s": "4", "ms": "100"}, + }, + "warning_lines": [ + "2 of 1354 k-points could not be orthonormalized with an accuracy of 1.0E-5.", + "Generally, this is not a critical error. But to help you analyze it,", + "I dumped the band overlap matrices to the file bandOverlaps.lobster.", + "Please check how much they deviate from the identity matrix and decide to", + "use your results only, if you are sure that this is ok.", + ], + "info_orthonormalization": ["2 of 1354 k-points could not be orthonormalized with an accuracy of 1.0E-5."], + "info_lines": [ + "No errors detected in LCFO definition...", + "Some atoms were missing in the LCFO basis you defined.", + "In order to avoid problems with matrix handling later,", + "I added each remaining atom as a new fragment.", + "This is NOT an error.", + "You did not specify a value for the point density", + "using the gridDensityForPrinting keyword.", + "Therefore, I will use the default value of 0.05.", + "You did not specify a value for the point density", + "using the gridBufferForPrinting keyword.", + "Therefore, I will use the default value of 3.04 Angstroms.", + ], + "has_doscar": True, + "has_doscar_lso": False, + "has_doscar_lcfo": True, + "has_cohpcar": True, + "has_cohpcar_lcfo": True, + "has_coopcar": True, + "has_coopcar_lcfo": False, + "has_cobicar": True, + "has_cobicar_lcfo": True, + "has_charge": True, + "has_madelung": True, + "has_mofecar": True, + "has_projection": True, + "has_bandoverlaps": True, + "has_fatbands": False, + "has_grosspopulation": True, + "has_polarization": True, + "has_density_of_energies": False, + } + + for key, item in self.lobsterout_v511.get_doc().items(): + if isinstance(item, str): + assert ref_data_v511[key], item + elif isinstance(item, int): + assert ref_data_v511[key] == item + elif key == "charge_spilling": + assert item[0] == approx(ref_data_v511[key][0]) + elif isinstance(item, list | dict): + assert item == ref_data_v511[key] + + def test_msonable(self): + dict_data = self.lobsterout_normal.as_dict() + lobsterout_from_dict = Lobsterout.from_dict(dict_data) + assert dict_data == lobsterout_from_dict.as_dict() + # test initialization with empty attributes (ensure file is not read again) + dict_data_empty = dict.fromkeys(self.lobsterout_doscar_lso._ATTRIBUTES, None) + lobsterout_empty_init_dict = Lobsterout.from_dict(dict_data_empty).as_dict() + for attribute in lobsterout_empty_init_dict: + if "@" not in attribute: + assert lobsterout_empty_init_dict[attribute] is None + + with pytest.raises(ValueError, match="invalid=val is not a valid attribute for Lobsterout"): + Lobsterout(filename=None, invalid="val") + + +class TestFatband(MatSciTest): + def setup_method(self): + self.structure = Vasprun( + filename=f"{TEST_DIR}/Fatband_SiO2/Test_p_x/vasprun.xml", + ionic_step_skip=None, + ionic_step_offset=0, + parse_dos=True, + parse_eigen=False, + parse_projected_eigen=False, + parse_potcar_file=False, + occu_tol=1e-8, + exception_on_bad_xml=True, + ).final_structure + self.fatband_SiO2_p_x = Fatband( + filenames=f"{TEST_DIR}/Fatband_SiO2/Test_p_x", + kpoints_file=f"{TEST_DIR}/Fatband_SiO2/Test_p_x/KPOINTS", + structure=self.structure, + vasprun_file=f"{TEST_DIR}/Fatband_SiO2/Test_p_x/vasprun.xml", + ) + self.vasprun_SiO2_p_x = Vasprun(filename=f"{TEST_DIR}/Fatband_SiO2/Test_p_x/vasprun.xml") + self.bs_symmline = self.vasprun_SiO2_p_x.get_band_structure(line_mode=True, force_hybrid_mode=True) + self.fatband_SiO2_p = Fatband( + filenames=f"{TEST_DIR}/Fatband_SiO2/Test_p", + kpoints_file=f"{TEST_DIR}/Fatband_SiO2/Test_p/KPOINTS", + vasprun_file=f"{TEST_DIR}/Fatband_SiO2/Test_p/vasprun.xml", + structure=self.structure, + ) + self.fatband_SiO2_p2 = Fatband( + filenames=f"{TEST_DIR}/Fatband_SiO2/Test_p", + kpoints_file=f"{TEST_DIR}/Fatband_SiO2/Test_p/KPOINTS", + structure=self.structure, + vasprun_file=None, + efermi=1.0647039, + ) + self.vasprun_SiO2_p = Vasprun(filename=f"{TEST_DIR}/Fatband_SiO2/Test_p/vasprun.xml") + self.bs_symmline2 = self.vasprun_SiO2_p.get_band_structure(line_mode=True, force_hybrid_mode=True) + self.fatband_SiO2_spin = Fatband( + filenames=f"{TEST_DIR}/Fatband_SiO2/Test_Spin", + kpoints_file=f"{TEST_DIR}/Fatband_SiO2/Test_Spin/KPOINTS", + vasprun_file=f"{TEST_DIR}/Fatband_SiO2/Test_Spin/vasprun.xml", + structure=self.structure, + ) + + self.vasprun_SiO2_spin = Vasprun(filename=f"{TEST_DIR}/Fatband_SiO2/Test_Spin/vasprun.xml") + self.bs_symmline_spin = self.vasprun_SiO2_p.get_band_structure(line_mode=True, force_hybrid_mode=True) + + def test_attributes(self): + assert_allclose(list(self.fatband_SiO2_p_x.label_dict["M"]), [0.5, 0.0, 0.0]) + assert self.fatband_SiO2_p_x.efermi == self.vasprun_SiO2_p_x.efermi + lattice1 = self.bs_symmline.lattice_rec.as_dict() + lattice2 = self.fatband_SiO2_p_x.lattice.as_dict() + for idx in range(3): + assert lattice1["matrix"][idx] == approx(lattice2["matrix"][idx]) + assert self.fatband_SiO2_p_x.eigenvals[Spin.up][1][1] - self.fatband_SiO2_p_x.efermi == approx(-18.245) + assert self.fatband_SiO2_p_x.is_spinpolarized is False + assert_allclose(self.fatband_SiO2_p_x.kpoints_array[3], [0.03409091, 0, 0]) + assert self.fatband_SiO2_p_x.nbands == 36 + assert self.fatband_SiO2_p_x.p_eigenvals[Spin.up][2][1]["Si1"]["3p_x"] == approx(0.002) + assert_allclose(self.fatband_SiO2_p_x.structure[0].frac_coords, [0.0, 0.47634315, 0.666667]) + assert self.fatband_SiO2_p_x.structure[0].species_string == "Si" + assert_allclose(self.fatband_SiO2_p_x.structure[0].coords, [-1.19607309, 2.0716597, 3.67462144]) + + assert_allclose(list(self.fatband_SiO2_p.label_dict["M"]), [0.5, 0.0, 0.0]) + assert self.fatband_SiO2_p.efermi == self.vasprun_SiO2_p.efermi + lattice1 = self.bs_symmline2.lattice_rec.as_dict() + lattice2 = self.fatband_SiO2_p.lattice.as_dict() + for idx in range(3): + assert lattice1["matrix"][idx] == approx(lattice2["matrix"][idx]) + assert self.fatband_SiO2_p.eigenvals[Spin.up][1][1] - self.fatband_SiO2_p.efermi == approx(-18.245) + assert self.fatband_SiO2_p.is_spinpolarized is False + assert_allclose(self.fatband_SiO2_p.kpoints_array[3], [0.03409091, 0, 0]) + assert self.fatband_SiO2_p.nbands == 36 + assert self.fatband_SiO2_p.p_eigenvals[Spin.up][2][1]["Si1"]["3p"] == approx(0.042) + assert_allclose(self.fatband_SiO2_p.structure[0].frac_coords, [0.0, 0.47634315, 0.666667]) + assert self.fatband_SiO2_p.structure[0].species_string == "Si" + assert_allclose(self.fatband_SiO2_p.structure[0].coords, [-1.19607309, 2.0716597, 3.67462144]) + assert self.fatband_SiO2_p.efermi == approx(1.0647039) + + assert list(self.fatband_SiO2_spin.label_dict["M"]) == approx([0.5, 0.0, 0.0]) + assert self.fatband_SiO2_spin.efermi == self.vasprun_SiO2_spin.efermi + lattice1 = self.bs_symmline_spin.lattice_rec.as_dict() + lattice2 = self.fatband_SiO2_spin.lattice.as_dict() + for idx in range(3): + assert lattice1["matrix"][idx] == approx(lattice2["matrix"][idx]) + assert self.fatband_SiO2_spin.eigenvals[Spin.up][1][1] - self.fatband_SiO2_spin.efermi == approx(-18.245) + assert self.fatband_SiO2_spin.eigenvals[Spin.down][1][1] - self.fatband_SiO2_spin.efermi == approx(-18.245) + assert self.fatband_SiO2_spin.is_spinpolarized + assert_allclose(self.fatband_SiO2_spin.kpoints_array[3], [0.03409091, 0, 0]) + assert self.fatband_SiO2_spin.nbands == 36 + + assert self.fatband_SiO2_spin.p_eigenvals[Spin.up][2][1]["Si1"]["3p"] == approx(0.042) + assert_allclose(self.fatband_SiO2_spin.structure[0].frac_coords, [0.0, 0.47634315, 0.666667]) + assert self.fatband_SiO2_spin.structure[0].species_string == "Si" + assert_allclose(self.fatband_SiO2_spin.structure[0].coords, [-1.19607309, 2.0716597, 3.67462144]) + + def test_raises(self): + with pytest.raises(ValueError, match="vasprun_file or efermi have to be provided"): + Fatband( + filenames=f"{TEST_DIR}/Fatband_SiO2/Test_Spin", + kpoints_file=f"{TEST_DIR}/Fatband_SiO2/Test_Spin/KPOINTS", + vasprun_file=None, + structure=self.structure, + ) + with pytest.raises( + ValueError, + match="The are two FATBAND files for the same atom and orbital. The program will stop", + ): + self.fatband_SiO2_p_x = Fatband( + filenames=[ + f"{TEST_DIR}/Fatband_SiO2/Test_p_x/FATBAND_si1_3p_x.lobster", + f"{TEST_DIR}/Fatband_SiO2/Test_p_x/FATBAND_si1_3p_x.lobster", + ], + kpoints_file=f"{TEST_DIR}/Fatband_SiO2/Test_p_x/KPOINTS", + vasprun_file=f"{TEST_DIR}/Fatband_SiO2/Test_p_x/vasprun.xml", + structure=self.structure, + ) + + with pytest.raises(ValueError, match="A structure object has to be provided"): + self.fatband_SiO2_p_x = Fatband( + filenames=[ + f"{TEST_DIR}/Fatband_SiO2/Test_p_x/FATBAND_si1_3p_x.lobster", + f"{TEST_DIR}/Fatband_SiO2/Test_p_x/FATBAND_si1_3p_x.lobster", + ], + kpoints_file=f"{TEST_DIR}/Fatband_SiO2/Test_p_x/KPOINTS", + vasprun_file=f"{TEST_DIR}/Fatband_SiO2/Test_p_x/vasprun.xml", + structure=None, + ) + + with pytest.raises( + ValueError, + match=r"Make sure all relevant orbitals were generated and that no duplicates \(2p and 2p_x\) are present", + ): + self.fatband_SiO2_p_x = Fatband( + filenames=[ + f"{TEST_DIR}/Fatband_SiO2/Test_p_x/FATBAND_si1_3p_x.lobster", + f"{TEST_DIR}/Fatband_SiO2/Test_p/FATBAND_si1_3p.lobster", + ], + kpoints_file=f"{TEST_DIR}/Fatband_SiO2/Test_p_x/KPOINTS", + vasprun_file=f"{TEST_DIR}/Fatband_SiO2/Test_p_x/vasprun.xml", + structure=self.structure, + ) + + with pytest.raises(ValueError, match="No FATBAND files in folder or given"): + self.fatband_SiO2_p_x = Fatband( + filenames=".", + kpoints_file=f"{TEST_DIR}/Fatband_SiO2/Test_p_x/KPOINTS", + vasprun_file=f"{TEST_DIR}/Fatband_SiO2/Test_p_x/vasprun.xml", + structure=self.structure, + ) + + def test_get_bandstructure(self): + bs_p = self.fatband_SiO2_p.get_bandstructure() + atom1 = bs_p.structure[0] + atom2 = self.bs_symmline2.structure[0] + assert atom1.frac_coords[0] == approx(atom2.frac_coords[0]) + assert atom1.frac_coords[1] == approx(atom2.frac_coords[1]) + assert atom1.frac_coords[2] == approx(atom2.frac_coords[2]) + assert atom1.coords[0] == approx(atom2.coords[0]) + assert atom1.coords[1] == approx(atom2.coords[1]) + assert atom1.coords[2] == approx(atom2.coords[2]) + assert atom1.species_string == atom2.species_string + assert bs_p.efermi == self.bs_symmline2.efermi + branch1 = bs_p.branches[0] + branch2 = self.bs_symmline2.branches[0] + assert branch2["name"] == branch1["name"] + assert branch2["start_index"] == branch1["start_index"] + assert branch2["end_index"] == branch1["end_index"] + + assert bs_p.distance[30] == approx(self.bs_symmline2.distance[30]) + lattice1 = bs_p.lattice_rec.as_dict() + lattice2 = self.bs_symmline2.lattice_rec.as_dict() + assert lattice1["matrix"][0] == approx(lattice2["matrix"][0]) + assert lattice1["matrix"][1] == approx(lattice2["matrix"][1]) + assert lattice1["matrix"][2] == approx(lattice2["matrix"][2]) + + assert bs_p.kpoints[8].frac_coords[0] == approx(self.bs_symmline2.kpoints[8].frac_coords[0]) + assert bs_p.kpoints[8].frac_coords[1] == approx(self.bs_symmline2.kpoints[8].frac_coords[1]) + assert bs_p.kpoints[8].frac_coords[2] == approx(self.bs_symmline2.kpoints[8].frac_coords[2]) + assert bs_p.kpoints[8].cart_coords[0] == approx(self.bs_symmline2.kpoints[8].cart_coords[0]) + assert bs_p.kpoints[8].cart_coords[1] == approx(self.bs_symmline2.kpoints[8].cart_coords[1]) + assert bs_p.kpoints[8].cart_coords[2] == approx(self.bs_symmline2.kpoints[8].cart_coords[2]) + assert bs_p.kpoints[50].frac_coords[0] == approx(self.bs_symmline2.kpoints[50].frac_coords[0]) + assert bs_p.kpoints[50].frac_coords[1] == approx(self.bs_symmline2.kpoints[50].frac_coords[1]) + assert bs_p.kpoints[50].frac_coords[2] == approx(self.bs_symmline2.kpoints[50].frac_coords[2]) + assert bs_p.kpoints[50].cart_coords[0] == approx(self.bs_symmline2.kpoints[50].cart_coords[0]) + assert bs_p.kpoints[50].cart_coords[1] == approx(self.bs_symmline2.kpoints[50].cart_coords[1]) + assert bs_p.kpoints[50].cart_coords[2] == approx(self.bs_symmline2.kpoints[50].cart_coords[2]) + assert bs_p.get_band_gap()["energy"] == approx(self.bs_symmline2.get_band_gap()["energy"], abs=1e-2) + assert bs_p.get_projection_on_elements()[Spin.up][0][0]["Si"] == approx(3 * (0.001 + 0.064)) + assert bs_p.get_projections_on_elements_and_orbitals({"Si": ["3p"]})[Spin.up][0][0]["Si"]["3p"] == approx(0.003) + assert bs_p.get_projections_on_elements_and_orbitals({"O": ["2p"]})[Spin.up][0][0]["O"]["2p"] == approx( + 0.002 * 3 + 0.003 * 3 + ) + dict_here = bs_p.get_projections_on_elements_and_orbitals({"Si": ["3s", "3p"], "O": ["2s", "2p"]})[Spin.up][0][ + 0 + ] + assert dict_here["Si"]["3s"] == approx(0.192) + assert dict_here["Si"]["3p"] == approx(0.003) + assert dict_here["O"]["2s"] == approx(0.792) + assert dict_here["O"]["2p"] == approx(0.015) + + bs_spin = self.fatband_SiO2_spin.get_bandstructure() + assert bs_spin.get_projection_on_elements()[Spin.up][0][0]["Si"] == approx(3 * (0.001 + 0.064)) + assert bs_spin.get_projections_on_elements_and_orbitals({"Si": ["3p"]})[Spin.up][0][0]["Si"]["3p"] == approx( + 0.003 + ) + assert bs_spin.get_projections_on_elements_and_orbitals({"O": ["2p"]})[Spin.up][0][0]["O"]["2p"] == approx( + 0.002 * 3 + 0.003 * 3 + ) + dict_here = bs_spin.get_projections_on_elements_and_orbitals({"Si": ["3s", "3p"], "O": ["2s", "2p"]})[Spin.up][ + 0 + ][0] + assert dict_here["Si"]["3s"] == approx(0.192) + assert dict_here["Si"]["3p"] == approx(0.003) + assert dict_here["O"]["2s"] == approx(0.792) + assert dict_here["O"]["2p"] == approx(0.015) + + assert bs_spin.get_projection_on_elements()[Spin.up][0][0]["Si"] == approx(3 * (0.001 + 0.064)) + assert bs_spin.get_projections_on_elements_and_orbitals({"Si": ["3p"]})[Spin.down][0][0]["Si"]["3p"] == approx( + 0.003 + ) + assert bs_spin.get_projections_on_elements_and_orbitals({"O": ["2p"]})[Spin.down][0][0]["O"]["2p"] == approx( + 0.002 * 3 + 0.003 * 3 + ) + dict_here = bs_spin.get_projections_on_elements_and_orbitals({"Si": ["3s", "3p"], "O": ["2s", "2p"]})[ + Spin.down + ][0][0] + assert dict_here["Si"]["3s"] == approx(0.192) + assert dict_here["Si"]["3p"] == approx(0.003) + assert dict_here["O"]["2s"] == approx(0.792) + assert dict_here["O"]["2p"] == approx(0.015) + bs_p_x = self.fatband_SiO2_p_x.get_bandstructure() + assert bs_p_x.get_projection_on_elements()[Spin.up][0][0]["Si"] == approx(3 * (0.001 + 0.064), abs=1e-2) + + +class TestBandoverlaps: + def setup_method(self): + # test spin-polarized calc and non spin-polarized calc + + self.band_overlaps1 = Bandoverlaps(f"{TEST_DIR}/bandOverlaps.lobster.1") + self.band_overlaps2 = Bandoverlaps(f"{TEST_DIR}/bandOverlaps.lobster.2") + + self.band_overlaps1_new = Bandoverlaps(f"{TEST_DIR}/bandOverlaps.lobster.new.1") + self.band_overlaps2_new = Bandoverlaps(f"{TEST_DIR}/bandOverlaps.lobster.new.2") + + def test_attributes(self): + # band_overlaps_dict + bo_dict = self.band_overlaps1.band_overlaps_dict + assert bo_dict[Spin.up]["max_deviations"][0] == approx(0.000278953) + assert self.band_overlaps1_new.band_overlaps_dict[Spin.up]["max_deviations"][10] == approx(0.0640933) + assert bo_dict[Spin.up]["matrices"][0].item(-1, -1) == approx(0.0188058) + assert self.band_overlaps1_new.band_overlaps_dict[Spin.up]["matrices"][10].item(-1, -1) == approx(1.0) + assert bo_dict[Spin.up]["matrices"][0].item(0, 0) == approx(1) + assert self.band_overlaps1_new.band_overlaps_dict[Spin.up]["matrices"][10].item(0, 0) == approx(0.995849) + + assert bo_dict[Spin.down]["max_deviations"][-1] == approx(4.31567e-05) + assert self.band_overlaps1_new.band_overlaps_dict[Spin.down]["max_deviations"][9] == approx(0.064369) + assert bo_dict[Spin.down]["matrices"][-1].item(0, -1) == approx(4.0066e-07) + assert self.band_overlaps1_new.band_overlaps_dict[Spin.down]["matrices"][9].item(0, -1) == approx(1.37447e-09) + + # maxDeviation + assert self.band_overlaps1.max_deviation[0] == approx(0.000278953) + assert self.band_overlaps1_new.max_deviation[0] == approx(0.39824) + assert self.band_overlaps1.max_deviation[-1] == approx(4.31567e-05) + assert self.band_overlaps1_new.max_deviation[-1] == approx(0.324898) + + assert self.band_overlaps2.max_deviation[0] == approx(0.000473319) + assert self.band_overlaps2_new.max_deviation[0] == approx(0.403249) + assert self.band_overlaps2.max_deviation[-1] == approx(1.48451e-05) + assert self.band_overlaps2_new.max_deviation[-1] == approx(0.45154) + + def test_has_good_quality_maxDeviation(self): + assert not self.band_overlaps1.has_good_quality_maxDeviation(limit_maxDeviation=0.1) + assert not self.band_overlaps1_new.has_good_quality_maxDeviation(limit_maxDeviation=0.1) + + assert self.band_overlaps1.has_good_quality_maxDeviation(limit_maxDeviation=100) + assert self.band_overlaps1_new.has_good_quality_maxDeviation(limit_maxDeviation=100) + assert self.band_overlaps2.has_good_quality_maxDeviation() + assert not self.band_overlaps2_new.has_good_quality_maxDeviation() + assert not self.band_overlaps2.has_good_quality_maxDeviation(limit_maxDeviation=0.0000001) + assert not self.band_overlaps2_new.has_good_quality_maxDeviation(limit_maxDeviation=0.0000001) + + def test_has_good_quality_check_occupied_bands(self): + assert not self.band_overlaps1.has_good_quality_check_occupied_bands( + number_occ_bands_spin_up=9, + number_occ_bands_spin_down=5, + limit_deviation=0.1, + spin_polarized=True, + ) + assert not self.band_overlaps1_new.has_good_quality_check_occupied_bands( + number_occ_bands_spin_up=9, + number_occ_bands_spin_down=5, + limit_deviation=0.1, + spin_polarized=True, + ) + assert self.band_overlaps1.has_good_quality_check_occupied_bands( + number_occ_bands_spin_up=3, + number_occ_bands_spin_down=0, + limit_deviation=1, + spin_polarized=True, + ) + assert self.band_overlaps1_new.has_good_quality_check_occupied_bands( + number_occ_bands_spin_up=3, + number_occ_bands_spin_down=0, + limit_deviation=1, + spin_polarized=True, + ) + assert not self.band_overlaps1.has_good_quality_check_occupied_bands( + number_occ_bands_spin_up=1, + number_occ_bands_spin_down=1, + limit_deviation=1e-6, + spin_polarized=True, + ) + assert not self.band_overlaps1_new.has_good_quality_check_occupied_bands( + number_occ_bands_spin_up=1, + number_occ_bands_spin_down=1, + limit_deviation=1e-6, + spin_polarized=True, + ) + assert not self.band_overlaps1.has_good_quality_check_occupied_bands( + number_occ_bands_spin_up=1, + number_occ_bands_spin_down=0, + limit_deviation=1e-6, + spin_polarized=True, + ) + assert not self.band_overlaps1_new.has_good_quality_check_occupied_bands( + number_occ_bands_spin_up=1, + number_occ_bands_spin_down=0, + limit_deviation=1e-6, + spin_polarized=True, + ) + assert not self.band_overlaps1.has_good_quality_check_occupied_bands( + number_occ_bands_spin_up=0, + number_occ_bands_spin_down=1, + limit_deviation=1e-6, + spin_polarized=True, + ) + assert not self.band_overlaps1_new.has_good_quality_check_occupied_bands( + number_occ_bands_spin_up=0, + number_occ_bands_spin_down=1, + limit_deviation=1e-6, + spin_polarized=True, + ) + assert not self.band_overlaps1.has_good_quality_check_occupied_bands( + number_occ_bands_spin_up=4, + number_occ_bands_spin_down=4, + limit_deviation=1e-3, + spin_polarized=True, + ) + assert not self.band_overlaps1_new.has_good_quality_check_occupied_bands( + number_occ_bands_spin_up=4, + number_occ_bands_spin_down=4, + limit_deviation=1e-3, + spin_polarized=True, + ) + assert not self.band_overlaps2.has_good_quality_check_occupied_bands( + number_occ_bands_spin_up=10, limit_deviation=1e-7 + ) + assert not self.band_overlaps2_new.has_good_quality_check_occupied_bands( + number_occ_bands_spin_up=10, limit_deviation=1e-7 + ) + assert self.band_overlaps2.has_good_quality_check_occupied_bands( + number_occ_bands_spin_up=1, limit_deviation=0.1 + ) + + assert not self.band_overlaps2.has_good_quality_check_occupied_bands( + number_occ_bands_spin_up=1, limit_deviation=1e-8 + ) + assert not self.band_overlaps2_new.has_good_quality_check_occupied_bands( + number_occ_bands_spin_up=1, limit_deviation=1e-8 + ) + assert self.band_overlaps2.has_good_quality_check_occupied_bands(number_occ_bands_spin_up=10, limit_deviation=1) + assert self.band_overlaps2_new.has_good_quality_check_occupied_bands( + number_occ_bands_spin_up=2, limit_deviation=0.1 + ) + assert self.band_overlaps2.has_good_quality_check_occupied_bands(number_occ_bands_spin_up=1, limit_deviation=1) + assert self.band_overlaps2_new.has_good_quality_check_occupied_bands( + number_occ_bands_spin_up=1, limit_deviation=2 + ) + + def test_has_good_quality_check_occupied_bands_patched(self): + """Test with patched data.""" + + limit_deviation = 0.1 + + rng = np.random.default_rng(42) # set seed for reproducibility + + band_overlaps = copy.deepcopy(self.band_overlaps1_new) + + number_occ_bands_spin_up_all = list(range(band_overlaps.band_overlaps_dict[Spin.up]["matrices"][0].shape[0])) + number_occ_bands_spin_down_all = list( + range(band_overlaps.band_overlaps_dict[Spin.down]["matrices"][0].shape[0]) + ) + + for actual_deviation in [0.05, 0.1, 0.2, 0.5, 1.0]: + for spin in (Spin.up, Spin.down): + for number_occ_bands_spin_up, number_occ_bands_spin_down in zip( + number_occ_bands_spin_up_all, number_occ_bands_spin_down_all, strict=False + ): + for i_arr, array in enumerate(band_overlaps.band_overlaps_dict[spin]["matrices"]): + number_occ_bands = number_occ_bands_spin_up if spin is Spin.up else number_occ_bands_spin_down + + shape = array.shape + assert np.all(np.array(shape) >= number_occ_bands) + assert len(shape) == 2 + assert shape[0] == shape[1] + + # Generate a noisy background array + patch_array = rng.uniform(0, 10, shape) + + # Patch the top-left sub-array (the part that would be checked) + patch_array[:number_occ_bands, :number_occ_bands] = np.identity(number_occ_bands) + rng.uniform( + 0, actual_deviation, (number_occ_bands, number_occ_bands) + ) + + band_overlaps.band_overlaps_dict[spin]["matrices"][i_arr] = patch_array + + result = band_overlaps.has_good_quality_check_occupied_bands( + number_occ_bands_spin_up=number_occ_bands_spin_up, + number_occ_bands_spin_down=number_occ_bands_spin_down, + spin_polarized=True, + limit_deviation=limit_deviation, + ) + # Assert for expected results + if ( + ( + math.isclose(actual_deviation, 0.05) + and number_occ_bands_spin_up <= 7 + and number_occ_bands_spin_down <= 7 + and spin is Spin.up + ) + or (math.isclose(actual_deviation, 0.05) and spin is Spin.down) + or math.isclose(actual_deviation, 0.1) + or ( + any(np.isclose(actual_deviation, [0.2, 0.5, 1.0])) + and number_occ_bands_spin_up == 0 + and number_occ_bands_spin_down == 0 + ) + ): + assert result + else: + assert not result + + def test_exceptions(self): + with pytest.raises(ValueError, match="number_occ_bands_spin_down has to be specified"): + self.band_overlaps1.has_good_quality_check_occupied_bands( + number_occ_bands_spin_up=4, + spin_polarized=True, + ) + with pytest.raises(ValueError, match="number_occ_bands_spin_down has to be specified"): + self.band_overlaps1_new.has_good_quality_check_occupied_bands( + number_occ_bands_spin_up=4, + spin_polarized=True, + ) + + def test_msonable(self): + dict_data = self.band_overlaps2_new.as_dict() + bandoverlaps_from_dict = Bandoverlaps.from_dict(dict_data) + all_attributes = vars(self.band_overlaps2_new) + for attr_name, attr_value in all_attributes.items(): + assert getattr(bandoverlaps_from_dict, attr_name) == attr_value + + def test_keys(self): + bo_dict = self.band_overlaps1.band_overlaps_dict + bo_dict_new = self.band_overlaps1_new.band_overlaps_dict + bo_dict_2 = self.band_overlaps2.band_overlaps_dict + assert len(bo_dict[Spin.up]["k_points"]) == 408 + assert len(bo_dict_2[Spin.up]["max_deviations"]) == 2 + assert len(bo_dict_new[Spin.down]["matrices"]) == 73 + + +class TestGrosspop: + def setup_method(self): + self.grosspop1 = Grosspop(f"{TEST_DIR}/GROSSPOP.lobster") + self.grosspop_511_sp = Grosspop(f"{TEST_DIR}/GROSSPOP_511_sp.lobster.AlN.gz") + self.grosspop_511_nsp = Grosspop(f"{TEST_DIR}/GROSSPOP_511_nsp.lobster.NaCl.gz") + self.grosspop_511_lcfo = Grosspop(f"{TEST_DIR}/GROSSPOP.LCFO.lobster.AlN.gz", is_lcfo=True) + + def test_attributes(self): + gross_pop_list = self.grosspop1.list_dict_grosspop + gross_pop_list_511_sp = self.grosspop_511_sp.list_dict_grosspop + gross_pop_list_511_nsp = self.grosspop_511_nsp.list_dict_grosspop + gross_pop_list_lcfo = self.grosspop_511_lcfo.list_dict_grosspop + + assert gross_pop_list[0]["Mulliken GP"]["3s"] == approx(0.52) + assert gross_pop_list[0]["Mulliken GP"]["3p_y"] == approx(0.38) + assert gross_pop_list[0]["Mulliken GP"]["3p_z"] == approx(0.37) + assert gross_pop_list[0]["Mulliken GP"]["3p_x"] == approx(0.37) + assert gross_pop_list[0]["Mulliken GP"]["total"] == approx(1.64) + assert gross_pop_list[0]["element"] == "Si" + assert gross_pop_list[0]["Loewdin GP"]["3s"] == approx(0.61) + assert gross_pop_list[0]["Loewdin GP"]["3p_y"] == approx(0.52) + assert gross_pop_list[0]["Loewdin GP"]["3p_z"] == approx(0.52) + assert gross_pop_list[0]["Loewdin GP"]["3p_x"] == approx(0.52) + assert gross_pop_list[0]["Loewdin GP"]["total"] == approx(2.16) + assert gross_pop_list[5]["Mulliken GP"]["2s"] == approx(1.80) + assert gross_pop_list[5]["Loewdin GP"]["2s"] == approx(1.60) + assert gross_pop_list[5]["element"] == "O" + assert gross_pop_list[8]["Mulliken GP"]["2s"] == approx(1.80) + assert gross_pop_list[8]["Loewdin GP"]["2s"] == approx(1.60) + assert gross_pop_list[8]["element"] == "O" + + # v5.1 spin polarized + assert gross_pop_list_511_sp[1]["Mulliken GP"]["3p_y"][Spin.up] == approx(0.19) + assert gross_pop_list_511_sp[3]["Loewdin GP"]["2s"][Spin.down] == approx(0.7) + + # v5.1 non spin polarized + assert gross_pop_list_511_nsp[0]["Mulliken GP"]["3s"] == approx(0.22) + assert gross_pop_list_511_nsp[-1]["Loewdin GP"]["total"] == approx(7.67) + + # v.5.1.1 LCFO + assert self.grosspop_511_lcfo.is_lcfo + assert gross_pop_list_lcfo[0]["mol"] == "AlN" + assert gross_pop_list_lcfo[0]["Loewdin GP"]["4a1"][Spin.up] == approx(0.07) + + def test_structure_with_grosspop(self): + struct_dict = { + "@module": "pymatgen.core.structure", + "@class": "Structure", + "charge": None, + "lattice": { + "matrix": [ + [5.021897888834907, 4.53806e-11, 0.0], + [-2.5109484443388332, 4.349090983701526, 0.0], + [0.0, 0.0, 5.511929408565514], + ], + "a": 5.021897888834907, + "b": 5.0218974974248045, + "c": 5.511929408565514, + "alpha": 90.0, + "beta": 90.0, + "gamma": 119.99999598960493, + "volume": 120.38434608659402, + }, + "sites": [ + { + "species": [{"element": "Si", "occu": 1}], + "abc": [-3e-16, 0.4763431475490085, 0.6666669999999968], + "xyz": [-1.1960730853096477, 2.0716596881533986, 3.674621443020128], + "label": "Si", + "properties": {"Total Mulliken GP": 1.64, "Total Loewdin GP": 2.16}, + }, + { + "species": [{"element": "Si", "occu": 1}], + "abc": [0.5236568524509936, 0.5236568524509926, 0.0], + "xyz": [1.3148758827683875, 2.277431295571896, 0.0], + "label": "Si", + "properties": {"Total Mulliken GP": 1.64, "Total Loewdin GP": 2.16}, + }, + { + "species": [{"element": "Si", "occu": 1}], + "abc": [0.4763431475490066, -1.2e-15, 0.3333330000000032], + "xyz": [ + 2.392146647037334, + 2.1611518932482004e-11, + 1.8373079655453863, + ], + "label": "Si", + "properties": {"Total Mulliken GP": 1.64, "Total Loewdin GP": 2.16}, + }, + { + "species": [{"element": "O", "occu": 1}], + "abc": [0.1589037798059321, 0.7440031622164922, 0.4613477252144715], + "xyz": [-1.0701550264153763, 3.235737444648381, 2.5429160941844473], + "label": "O", + "properties": {"Total Mulliken GP": 7.18, "Total Loewdin GP": 6.92}, + }, + { + "species": [{"element": "O", "occu": 1}], + "abc": [0.2559968377835071, 0.4149006175894398, 0.7946807252144676], + "xyz": [0.2437959189219816, 1.8044405351020447, 4.380224059729795], + "label": "O", + "properties": {"Total Mulliken GP": 7.18, "Total Loewdin GP": 6.92}, + }, + { + "species": [{"element": "O", "occu": 1}], + "abc": [0.5850993824105679, 0.8410962201940679, 0.1280147252144683], + "xyz": [0.8263601076506712, 3.6580039876980064, 0.7056081286390611], + "label": "O", + "properties": {"Total Mulliken GP": 7.18, "Total Loewdin GP": 6.92}, + }, + { + "species": [{"element": "O", "occu": 1}], + "abc": [0.7440031622164928, 0.1589037798059326, 0.5386522747855285], + "xyz": [3.337308710918233, 0.6910869960638374, 2.969013314381067], + "label": "O", + "properties": {"Total Mulliken GP": 7.18, "Total Loewdin GP": 6.92}, + }, + { + "species": [{"element": "O", "occu": 1}], + "abc": [0.4149006175894392, 0.2559968377835, 0.2053192747855324], + "xyz": [1.4407936739605638, 1.1133535390791505, 1.13170534883572], + "label": "O", + "properties": {"Total Mulliken GP": 7.18, "Total Loewdin GP": 6.92}, + }, + { + "species": [{"element": "O", "occu": 1}], + "abc": [0.841096220194068, 0.5850993824105675, 0.8719852747855317], + "xyz": [2.754744948452184, 2.5446504486493, 4.806321279926453], + "label": "O", + "properties": {"Total Mulliken GP": 7.18, "Total Loewdin GP": 6.92}, + }, + ], + } + + new_structure = self.grosspop1.get_structure_with_total_grosspop(f"{TEST_DIR}/POSCAR.SiO2") + assert_allclose(new_structure.frac_coords, Structure.from_dict(struct_dict).frac_coords) + + def test_exception(self): + structure_file = f"{VASP_IN_DIR}/POSCAR.AlN" + with pytest.raises(ValueError, match="The GROSSPOP.LCFO.lobster data is not site wise"): + self.grosspop_511_lcfo.get_structure_with_total_grosspop(structure_filename=structure_file) + + def test_msonable(self): + dict_data = self.grosspop1.as_dict() + grosspop_from_dict = Grosspop.from_dict(dict_data) + all_attributes = vars(self.grosspop1) + for attr_name, attr_value in all_attributes.items(): + assert getattr(grosspop_from_dict, attr_name) == attr_value + + +class TestIcohplist(MatSciTest): + def setup_method(self): + self.icohp_bise = Icohplist(filename=f"{TEST_DIR}/ICOHPLIST.lobster.BiSe") + self.icoop_bise = Icohplist( + filename=f"{TEST_DIR}/ICOOPLIST.lobster.BiSe", + are_coops=True, + ) + self.icohp_fe = Icohplist(filename=f"{TEST_DIR}/ICOHPLIST.lobster") + # allow gzipped files + self.icohp_gzipped = Icohplist(filename=f"{TEST_DIR}/ICOHPLIST.lobster.gz") + self.icoop_fe = Icohplist( + filename=f"{TEST_DIR}/ICOHPLIST.lobster", + are_coops=True, + ) + # ICOHPLIST.lobster from Lobster >v5 + self.icohp_aln_511_sp = Icohplist(filename=f"{TEST_DIR}/ICOHPLIST_511_sp.lobster.AlN.gz") + self.icohp_nacl_511_nsp = Icohplist(filename=f"{TEST_DIR}/ICOHPLIST_511_nsp.lobster.NaCl.gz") + + # ICOHPLIST.LCFO.lobster from Lobster v5.1.1 + self.icohp_lcfo = Icohplist(filename=f"{TEST_DIR}/ICOHPLIST.LCFO.lobster.AlN.gz", is_lcfo=True) + self.icohp_lcfo_non_orbitalwise = Icohplist( + filename=f"{TEST_DIR}/ICOHPLIST_non_orbitalwise.LCFO.lobster.AlN.gz", + is_lcfo=True, + ) + + # ICOBIs and orbitalwise ICOBILIST.lobster + self.icobi_orbitalwise = Icohplist( + filename=f"{TEST_DIR}/ICOBILIST.lobster", + are_cobis=True, + ) + + self.icobi = Icohplist( + filename=f"{TEST_DIR}/ICOBILIST.lobster.withoutorbitals", + are_cobis=True, + ) + self.icobi_orbitalwise_spinpolarized = Icohplist( + filename=f"{TEST_DIR}/ICOBILIST.lobster.spinpolarized", + are_cobis=True, + ) + # make sure the correct line is read to check if this is a orbitalwise ICOBILIST + self.icobi_orbitalwise_add = Icohplist( + filename=f"{TEST_DIR}/ICOBILIST.lobster.additional_case", + are_cobis=True, + ) + self.icobi_orbitalwise_spinpolarized_add = Icohplist( + filename=f"{TEST_DIR}/ICOBILIST.lobster.spinpolarized.additional_case", + are_cobis=True, + ) + + def test_attributes(self): + assert not self.icohp_bise.are_coops + assert self.icoop_bise.are_coops + assert not self.icohp_bise.is_spin_polarized + assert not self.icoop_bise.is_spin_polarized + assert len(self.icohp_bise.icohplist) == 11 + assert len(self.icoop_bise.icohplist) == 11 + assert not self.icohp_fe.are_coops + assert self.icoop_fe.are_coops + assert self.icohp_fe.is_spin_polarized + assert self.icoop_fe.is_spin_polarized + assert len(self.icohp_fe.icohplist) == 2 + assert len(self.icoop_fe.icohplist) == 2 + # test are_cobis + assert not self.icohp_fe.are_coops + assert not self.icohp_fe.are_cobis + assert self.icoop_fe.are_coops + assert not self.icoop_fe.are_cobis + assert self.icobi.are_cobis + assert not self.icobi.are_coops + + # orbitalwise + assert self.icobi_orbitalwise.orbitalwise + assert not self.icobi.orbitalwise + + assert self.icobi_orbitalwise_spinpolarized.orbitalwise + + assert self.icobi_orbitalwise_add.orbitalwise + assert self.icobi_orbitalwise_spinpolarized_add.orbitalwise + + # >v5 ICOHPLIST + assert self.icohp_aln_511_sp.is_spin_polarized + assert len(self.icohp_aln_511_sp.icohplist) == 64 + assert not self.icohp_nacl_511_nsp.is_spin_polarized + assert len(self.icohp_nacl_511_nsp.icohplist) == 152 + + # v5.1.1 LCFO + assert self.icohp_lcfo.is_lcfo + assert self.icohp_lcfo.is_spin_polarized + assert len(self.icohp_lcfo.icohplist) == 28 + assert not self.icohp_lcfo_non_orbitalwise.orbitalwise + assert len(self.icohp_lcfo_non_orbitalwise.icohplist) == 28 + + def test_values(self): + icohplist_bise = { + "1": { + "length": 2.88231, + "number_of_bonds": 3, + "icohp": {Spin.up: -2.18042}, + "translation": (0, 0, 0), + "orbitals": None, + }, + "2": { + "length": 3.10144, + "number_of_bonds": 3, + "icohp": {Spin.up: -1.14347}, + "translation": (0, 0, 0), + "orbitals": None, + }, + "3": { + "length": 2.88231, + "number_of_bonds": 3, + "icohp": {Spin.up: -2.18042}, + "translation": (0, 0, 0), + "orbitals": None, + }, + "4": { + "length": 3.10144, + "number_of_bonds": 3, + "icohp": {Spin.up: -1.14348}, + "translation": (0, 0, 0), + "orbitals": None, + }, + "5": { + "length": 3.05001, + "number_of_bonds": 3, + "icohp": {Spin.up: -1.30006}, + "translation": (0, 0, 0), + "orbitals": None, + }, + "6": { + "length": 2.91676, + "number_of_bonds": 3, + "icohp": {Spin.up: -1.96843}, + "translation": (0, 0, 0), + "orbitals": None, + }, + "7": { + "length": 3.05001, + "number_of_bonds": 3, + "icohp": {Spin.up: -1.30006}, + "translation": (0, 0, 0), + "orbitals": None, + }, + "8": { + "length": 2.91676, + "number_of_bonds": 3, + "icohp": {Spin.up: -1.96843}, + "translation": (0, 0, 0), + "orbitals": None, + }, + "9": { + "length": 3.37522, + "number_of_bonds": 3, + "icohp": {Spin.up: -0.47531}, + "translation": (0, 0, 0), + "orbitals": None, + }, + "10": { + "length": 3.07294, + "number_of_bonds": 3, + "icohp": {Spin.up: -2.38796}, + "translation": (0, 0, 0), + "orbitals": None, + }, + "11": { + "length": 3.37522, + "number_of_bonds": 3, + "icohp": {Spin.up: -0.47531}, + "translation": (0, 0, 0), + "orbitals": None, + }, + } + icooplist_bise = { + "1": { + "length": 2.88231, + "number_of_bonds": 3, + "icohp": {Spin.up: 0.14245}, + "translation": (0, 0, 0), + "orbitals": None, + }, + "2": { + "length": 3.10144, + "number_of_bonds": 3, + "icohp": {Spin.up: -0.04118}, + "translation": (0, 0, 0), + "orbitals": None, + }, + "3": { + "length": 2.88231, + "number_of_bonds": 3, + "icohp": {Spin.up: 0.14245}, + "translation": (0, 0, 0), + "orbitals": None, + }, + "4": { + "length": 3.10144, + "number_of_bonds": 3, + "icohp": {Spin.up: -0.04118}, + "translation": (0, 0, 0), + "orbitals": None, + }, + "5": { + "length": 3.05001, + "number_of_bonds": 3, + "icohp": {Spin.up: -0.03516}, + "translation": (0, 0, 0), + "orbitals": None, + }, + "6": { + "length": 2.91676, + "number_of_bonds": 3, + "icohp": {Spin.up: 0.10745}, + "translation": (0, 0, 0), + "orbitals": None, + }, + "7": { + "length": 3.05001, + "number_of_bonds": 3, + "icohp": {Spin.up: -0.03516}, + "translation": (0, 0, 0), + "orbitals": None, + }, + "8": { + "length": 2.91676, + "number_of_bonds": 3, + "icohp": {Spin.up: 0.10745}, + "translation": (0, 0, 0), + "orbitals": None, + }, + "9": { + "length": 3.37522, + "number_of_bonds": 3, + "icohp": {Spin.up: -0.12395}, + "translation": (0, 0, 0), + "orbitals": None, + }, + "10": { + "length": 3.07294, + "number_of_bonds": 3, + "icohp": {Spin.up: 0.24714}, + "translation": (0, 0, 0), + "orbitals": None, + }, + "11": { + "length": 3.37522, + "number_of_bonds": 3, + "icohp": {Spin.up: -0.12395}, + "translation": (0, 0, 0), + "orbitals": None, + }, + } + icooplist_fe = { + "1": { + "length": 2.83189, + "number_of_bonds": 2, + "icohp": {Spin.up: -0.10218, Spin.down: -0.19701}, + "translation": (0, 0, 0), + "orbitals": None, + }, + "2": { + "length": 2.45249, + "number_of_bonds": 1, + "icohp": {Spin.up: -0.28485, Spin.down: -0.58279}, + "translation": (0, 0, 0), + "orbitals": None, + }, + } + + assert icohplist_bise == self.icohp_bise.icohplist + assert self.icohp_bise.icohpcollection.extremum_icohpvalue() == approx(-2.38796) + assert icooplist_fe == self.icoop_fe.icohplist + assert self.icoop_fe.icohpcollection.extremum_icohpvalue() == approx(-0.29919) + assert icooplist_bise == self.icoop_bise.icohplist + assert self.icoop_bise.icohpcollection.extremum_icohpvalue() == approx(0.24714) + assert self.icobi.icohplist["1"]["icohp"][Spin.up] == approx(0.58649) + assert self.icobi_orbitalwise.icohplist["2"]["icohp"][Spin.up] == approx(0.58649) + assert self.icobi_orbitalwise.icohplist["1"]["icohp"][Spin.up] == approx(0.58649) + assert self.icobi_orbitalwise_spinpolarized.icohplist["1"]["icohp"][Spin.up] == approx(0.58649 / 2, abs=1e-3) + assert self.icobi_orbitalwise_spinpolarized.icohplist["1"]["icohp"][Spin.down] == approx(0.58649 / 2, abs=1e-3) + assert self.icobi_orbitalwise_spinpolarized.icohplist["2"]["icohp"][Spin.down] == approx(0.58649 / 2, abs=1e-3) + assert self.icobi.icohpcollection.extremum_icohpvalue() == approx(0.58649) + assert self.icobi_orbitalwise_spinpolarized.icohplist["2"]["orbitals"]["2s-6s"]["icohp"][Spin.up] == approx( + 0.0247 + ) + + # >v5 ICOHPLIST + assert self.icohp_aln_511_sp.icohplist["2"]["icohp"][Spin.up] == approx(-0.21482) + assert self.icohp_aln_511_sp.icohplist["2"]["icohp"][Spin.down] == approx(-0.21493) + assert self.icohp_nacl_511_nsp.icohplist["13"]["icohp"][Spin.up] == approx(-0.03021) + assert self.icohp_nacl_511_nsp.icohplist["10"]["orbitals"]["3s-2py"]["icohp"][Spin.up] == approx(-0.00113) + + # v5.1.1 LCFO + assert self.icohp_lcfo.icohplist["15"]["orbitals"]["3a1-3p"]["icohp"][Spin.up] == approx(-0.00586) + assert self.icohp_lcfo_non_orbitalwise.icohplist["16"]["icohp"][Spin.up] == approx(-0.2983) + assert self.icohp_lcfo_non_orbitalwise.icohplist["16"]["icohp"][Spin.down] == approx(-0.29842) + + def test_msonable(self): + for icohplist_obj in [self.icobi_orbitalwise_spinpolarized, self.icohp_nacl_511_nsp, self.icohp_lcfo]: + dict_data = icohplist_obj.as_dict() + icohplist_from_dict = Icohplist.from_dict(dict_data) + all_attributes = vars(icohplist_obj) + for attr_name, attr_value in all_attributes.items(): + if isinstance(attr_value, IcohpCollection): + assert getattr(icohplist_from_dict, attr_name).as_dict() == attr_value.as_dict() + else: + assert getattr(icohplist_from_dict, attr_name) == attr_value + + def test_missing_trailing_newline(self): + fname = f"{self.tmp_path}/icohplist" + with open(fname, mode="w", encoding="utf-8") as f: + f.write( + "1 Co1 O1 1.00000 0 0 0 -0.50000 -1.00000\n" + "2 Co2 O2 1.10000 0 0 0 -0.60000 -1.10000" + ) + + ip = Icohplist(filename=fname) + assert len(ip.icohplist) == 2 + assert ip.icohplist["1"]["icohp"][Spin.up] == approx(-0.5) + + +class TestNciCobiList: + def setup_method(self): + self.ncicobi = NciCobiList(filename=f"{TEST_DIR}/NcICOBILIST.lobster") + self.ncicobi_gz = NciCobiList(filename=f"{TEST_DIR}/NcICOBILIST.lobster.gz") + self.ncicobi_no_spin = NciCobiList(filename=f"{TEST_DIR}/NcICOBILIST.lobster.nospin") + self.ncicobi_no_spin_wo = NciCobiList(filename=f"{TEST_DIR}/NcICOBILIST.lobster.nospin.withoutorbitals") + self.ncicobi_wo = NciCobiList(filename=f"{TEST_DIR}/NcICOBILIST.lobster.withoutorbitals") + + def test_ncicobilist(self): + assert self.ncicobi.is_spin_polarized + assert not self.ncicobi_no_spin.is_spin_polarized + assert self.ncicobi_wo.is_spin_polarized + assert not self.ncicobi_no_spin_wo.is_spin_polarized + assert self.ncicobi.orbital_wise + assert self.ncicobi_no_spin.orbital_wise + assert not self.ncicobi_wo.orbital_wise + assert not self.ncicobi_no_spin_wo.orbital_wise + assert len(self.ncicobi.ncicobi_list) == 2 + assert self.ncicobi.ncicobi_list["2"]["number_of_atoms"] == 3 + assert self.ncicobi.ncicobi_list["2"]["ncicobi"][Spin.up] == approx(0.00009) + assert self.ncicobi.ncicobi_list["2"]["ncicobi"][Spin.down] == approx(0.00009) + assert self.ncicobi.ncicobi_list["2"]["interaction_type"] == "[X22[0,0,0]->Xs42[0,0,0]->X31[0,0,0]]" + assert ( + self.ncicobi.ncicobi_list["2"]["ncicobi"][Spin.up] == self.ncicobi_wo.ncicobi_list["2"]["ncicobi"][Spin.up] + ) + assert ( + self.ncicobi.ncicobi_list["2"]["ncicobi"][Spin.up] == self.ncicobi_gz.ncicobi_list["2"]["ncicobi"][Spin.up] + ) + assert ( + self.ncicobi.ncicobi_list["2"]["interaction_type"] == self.ncicobi_gz.ncicobi_list["2"]["interaction_type"] + ) + assert sum(self.ncicobi.ncicobi_list["2"]["ncicobi"].values()) == approx( + self.ncicobi_no_spin.ncicobi_list["2"]["ncicobi"][Spin.up] + ) + + +class TestWavefunction(MatSciTest): + def test_parse_file(self): + grid, points, real, imaginary, distance = Wavefunction._parse_file( + f"{TEST_DIR}/LCAOWaveFunctionAfterLSO1PlotOfSpin1Kpoint1band1.gz" + ) + assert_array_equal([41, 41, 41], grid) + assert points[4][0] == approx(0.0000) + assert points[4][1] == approx(0.0000) + assert points[4][2] == approx(0.4000) + assert real[8] == approx(1.38863e-01) + assert imaginary[8] == approx(2.89645e-01) + assert len(imaginary) == 41 * 41 * 41 + assert len(real) == 41 * 41 * 41 + assert len(points) == 41 * 41 * 41 + assert distance[0] == approx(0.0000) + + def test_set_volumetric_data(self): + wave1 = Wavefunction( + filename=f"{TEST_DIR}/LCAOWaveFunctionAfterLSO1PlotOfSpin1Kpoint1band1.gz", + structure=Structure.from_file(f"{TEST_DIR}/POSCAR_O.gz"), + ) + + wave1.set_volumetric_data(grid=wave1.grid, structure=wave1.structure) + assert wave1.volumetricdata_real.data["total"][0, 0, 0] == approx(-3.0966) + assert wave1.volumetricdata_imaginary.data["total"][0, 0, 0] == approx(-6.45895e00) + + def test_get_volumetricdata_real(self): + wave1 = Wavefunction( + filename=f"{TEST_DIR}/LCAOWaveFunctionAfterLSO1PlotOfSpin1Kpoint1band1.gz", + structure=Structure.from_file(f"{TEST_DIR}/POSCAR_O.gz"), + ) + volumetricdata_real = wave1.get_volumetricdata_real() + assert volumetricdata_real.data["total"][0, 0, 0] == approx(-3.0966) + + def test_get_volumetricdata_imaginary(self): + wave1 = Wavefunction( + filename=f"{TEST_DIR}/LCAOWaveFunctionAfterLSO1PlotOfSpin1Kpoint1band1.gz", + structure=Structure.from_file(f"{TEST_DIR}/POSCAR_O.gz"), + ) + volumetricdata_imaginary = wave1.get_volumetricdata_imaginary() + assert volumetricdata_imaginary.data["total"][0, 0, 0] == approx(-6.45895e00) + + def test_get_volumetricdata_density(self): + wave1 = Wavefunction( + filename=f"{TEST_DIR}/LCAOWaveFunctionAfterLSO1PlotOfSpin1Kpoint1band1.gz", + structure=Structure.from_file(f"{TEST_DIR}/POSCAR_O.gz"), + ) + volumetricdata_density = wave1.get_volumetricdata_density() + assert volumetricdata_density.data["total"][0, 0, 0] == approx((-3.0966 * -3.0966) + (-6.45895 * -6.45895)) + + def test_write_file(self): + wave1 = Wavefunction( + filename=f"{TEST_DIR}/LCAOWaveFunctionAfterLSO1PlotOfSpin1Kpoint1band1.gz", + structure=Structure.from_file(f"{TEST_DIR}/POSCAR_O.gz"), + ) + real_wavecar_path = f"{self.tmp_path}/real-wavecar.vasp" + wave1.write_file(filename=real_wavecar_path, part="real") + assert os.path.isfile(real_wavecar_path) + + imag_wavecar_path = f"{self.tmp_path}/imaginary-wavecar.vasp" + wave1.write_file(filename=imag_wavecar_path, part="imaginary") + assert os.path.isfile(imag_wavecar_path) + + density_wavecar_path = f"{self.tmp_path}/density-wavecar.vasp" + wave1.write_file(filename=density_wavecar_path, part="density") + assert os.path.isfile(density_wavecar_path) + + +class TestSitePotentials(MatSciTest): + def setup_method(self) -> None: + self.sitepotential = SitePotential(filename=f"{TEST_DIR}/SitePotentials.lobster.perovskite") + + def test_attributes(self): + assert self.sitepotential.sitepotentials_loewdin == [ + -8.77, + -17.08, + 9.57, + 9.57, + 8.45, + ] + assert self.sitepotential.sitepotentials_mulliken == [ + -11.38, + -19.62, + 11.18, + 11.18, + 10.09, + ] + assert self.sitepotential.madelungenergies_loewdin == approx(-28.64) + assert self.sitepotential.madelungenergies_mulliken == approx(-40.02) + assert self.sitepotential.atomlist == ["La1", "Ta2", "N3", "N4", "O5"] + assert self.sitepotential.types == ["La", "Ta", "N", "N", "O"] + assert self.sitepotential.num_atoms == 5 + assert self.sitepotential.ewald_splitting == approx(3.14) + + def test_get_structure(self): + structure = self.sitepotential.get_structure_with_site_potentials(f"{TEST_DIR}/POSCAR.perovskite") + assert structure.site_properties["Loewdin Site Potentials (eV)"] == [ + -8.77, + -17.08, + 9.57, + 9.57, + 8.45, + ] + assert structure.site_properties["Mulliken Site Potentials (eV)"] == [ + -11.38, + -19.62, + 11.18, + 11.18, + 10.09, + ] + + def test_msonable(self): + dict_data = self.sitepotential.as_dict() + sitepotential_from_dict = SitePotential.from_dict(dict_data) + all_attributes = vars(self.sitepotential) + for attr_name, attr_value in all_attributes.items(): + assert getattr(sitepotential_from_dict, attr_name) == attr_value + + +class TestMadelungEnergies(MatSciTest): + def setup_method(self) -> None: + self.madelungenergies = MadelungEnergies(filename=f"{TEST_DIR}/MadelungEnergies.lobster.perovskite") + + def test_attributes(self): + assert self.madelungenergies.madelungenergies_loewdin == approx(-28.64) + assert self.madelungenergies.madelungenergies_mulliken == approx(-40.02) + assert self.madelungenergies.ewald_splitting == approx(3.14) + + def test_msonable(self): + dict_data = self.madelungenergies.as_dict() + madelung_from_dict = MadelungEnergies.from_dict(dict_data) + all_attributes = vars(self.madelungenergies) + for attr_name, attr_value in all_attributes.items(): + assert getattr(madelung_from_dict, attr_name) == attr_value + + +class TestLobsterMatrices(MatSciTest): + def setup_method(self) -> None: + self.hamilton_matrices = LobsterMatrices( + filename=f"{TEST_DIR}/Na_hamiltonMatrices.lobster.gz", e_fermi=-2.79650354 + ) + self.transfer_matrices = LobsterMatrices(filename=f"{TEST_DIR}/C_transferMatrices.lobster.gz") + self.overlap_matrices = LobsterMatrices(filename=f"{TEST_DIR}/Si_overlapMatrices.lobster.gz") + self.coeff_matrices = LobsterMatrices(filename=f"{TEST_DIR}/Si_coefficientMatricesLSO1.lobster.gz") + + def test_attributes(self): + # hamilton matrices + assert self.hamilton_matrices.average_onsite_energies == approx( + { + "Na1_3s": 0.58855353, + "Na1_2p_y": -25.72719646, + "Na1_2p_z": -25.72719646, + "Na1_2p_x": -25.72719646, + } + ) + ref_onsite_energies = [ + [-0.22519646, -25.76989646, -25.76989646, -25.76989646], + [1.40230354, -25.68449646, -25.68449646, -25.68449646], + ] + assert_allclose(self.hamilton_matrices.onsite_energies, ref_onsite_energies) + + ref_imag_mat_spin_up = np.zeros((4, 4)) + + assert_allclose( + self.hamilton_matrices.hamilton_matrices["1"][Spin.up].imag, + ref_imag_mat_spin_up, + ) + + ref_real_mat_spin_up = [ + [-3.0217, 0.0, 0.0, 0.0], + [0.0, -28.5664, 0.0, 0.0], + [0.0, 0.0, -28.5664, 0.0], + [0.0, 0.0, 0.0, -28.5664], + ] + assert_allclose( + self.hamilton_matrices.hamilton_matrices["1"][Spin.up].real, + ref_real_mat_spin_up, + ) + + # overlap matrices + assert self.overlap_matrices.average_onsite_overlaps == approx( + { + "Si1_3s": 1.00000009, + "Si1_3p_y": 0.99999995, + "Si1_3p_z": 0.99999995, + "Si1_3p_x": 0.99999995, + } + ) + ref_onsite_ovelaps = [[1.00000009, 0.99999995, 0.99999995, 0.99999995]] + + assert_allclose(self.overlap_matrices.onsite_overlaps, ref_onsite_ovelaps) + + ref_imag_mat = np.zeros((4, 4)) + + assert_allclose(self.overlap_matrices.overlap_matrices["1"].imag, ref_imag_mat) + + ref_real_mat = [ + [1.00000009, 0.0, 0.0, 0.0], + [0.0, 0.99999995, 0.0, 0.0], + [0.0, 0.0, 0.99999995, 0.0], + [0.0, 0.0, 0.0, 0.99999995], + ] + + assert_allclose(self.overlap_matrices.overlap_matrices["1"].real, ref_real_mat) + + assert len(self.overlap_matrices.overlap_matrices) == 1 + # transfer matrices + ref_onsite_transfer = [ + [-0.70523233, -0.07099237, -0.65987499, -0.07090411], + [-0.03735031, -0.66865552, 0.69253776, 0.80648063], + ] + assert_allclose(self.transfer_matrices.onsite_transfer, ref_onsite_transfer) + + ref_imag_mat_spin_down = [ + [-0.99920553, 0.0, 0.0, 0.0], + [0.0, 0.71219607, -0.06090336, -0.08690835], + [0.0, -0.04539545, -0.69302453, 0.08323944], + [0.0, -0.12220894, -0.09749622, -0.53739499], + ] + + assert_allclose( + self.transfer_matrices.transfer_matrices["1"][Spin.down].imag, + ref_imag_mat_spin_down, + ) + + ref_real_mat_spin_down = [ + [-0.03735031, 0.0, 0.0, 0.0], + [0.0, -0.66865552, 0.06086057, 0.13042529], + [-0.0, 0.04262018, 0.69253776, -0.12491928], + [0.0, 0.11473763, 0.09742773, 0.80648063], + ] + + assert_allclose( + self.transfer_matrices.transfer_matrices["1"][Spin.down].real, + ref_real_mat_spin_down, + ) + + # coefficient matrices + assert list(self.coeff_matrices.coefficient_matrices["1"]) == [ + Spin.up, + Spin.down, + ] + assert self.coeff_matrices.average_onsite_coefficient == approx( + { + "Si1_3s": 0.6232626450000001, + "Si1_3p_y": -0.029367565000000012, + "Si1_3p_z": -0.50003867, + "Si1_3p_x": 0.13529422, + } + ) + + ref_imag_mat_spin_up = [ + [-0.59697342, 0.0, 0.0, 0.0], + [0.0, 0.50603774, 0.50538255, -0.26664607], + [0.0, -0.45269894, 0.56996771, 0.23223275], + [0.0, 0.47836456, 0.00476861, 0.50184424], + ] + + assert_allclose( + self.coeff_matrices.coefficient_matrices["1"][Spin.up].imag, + ref_imag_mat_spin_up, + ) + + ref_real_mat_spin_up = [ + [0.80226096, 0.0, 0.0, 0.0], + [0.0, -0.33931137, -0.42979933, -0.34286226], + [0.0, 0.30354633, -0.48472536, 0.29861248], + [0.0, -0.32075579, -0.00405544, 0.64528776], + ] + + assert_allclose( + self.coeff_matrices.coefficient_matrices["1"][Spin.up].real, + ref_real_mat_spin_up, + ) + + def test_raises(self): + with pytest.raises(ValueError, match="Please provide the fermi energy in eV"): + self.hamilton_matrices = LobsterMatrices(filename=f"{TEST_DIR}/Na_hamiltonMatrices.lobster.gz") + + with pytest.raises( + RuntimeError, + match="Please check provided input file, it seems to be empty", + ): + self.hamilton_matrices = LobsterMatrices(filename=f"{TEST_DIR}/hamiltonMatrices.lobster") + + +class TestPolarization(MatSciTest): + def setup_method(self) -> None: + self.polarization = Polarization(filename=f"{TEST_DIR}/POLARIZATION.lobster.AlN.gz") + + def test_attributes(self): + assert self.polarization.rel_loewdin_pol_vector == { + "x": -0.0, + "y": -0.01, + "z": 45.62, + "abs": 45.62, + "unit": "uC/cm2", + } + assert self.polarization.rel_mulliken_pol_vector == { + "x": -0.0, + "y": -0.02, + "z": 56.14, + "abs": 56.14, + "unit": "uC/cm2", + } + + +def test_get_lines(): + """Ensure `_get_lines` is not trailing end char sensitive.""" + with open("without-end-char", mode="wb") as f: + f.write(b"first line\nsecond line") + + with open("with-end-char", mode="wb") as f: + f.write(b"first line\nsecond line\n") + + without_end_char = _get_lines("without-end-char") + with_end_char = _get_lines("with-end-char") + + assert len(with_end_char) == len(without_end_char) == 2 diff --git a/tests/io/lobster/test_sets.py b/tests/io/lobster/test_sets.py new file mode 100644 index 00000000000..3d7948c094e --- /dev/null +++ b/tests/io/lobster/test_sets.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import re + +import pytest +from pytest import approx + +from pymatgen.core import SETTINGS, Structure +from pymatgen.io.lobster.sets import LobsterSet +from pymatgen.io.vasp.sets import MODULE_DIR, BadInputSetWarning +from pymatgen.util.testing import FAKE_POTCAR_DIR, TEST_FILES_DIR, VASP_IN_DIR, MatSciTest + +TEST_DIR = f"{TEST_FILES_DIR}/io/vasp" + +pytest.MonkeyPatch().setitem(SETTINGS, "PMG_VASP_PSP_DIR", str(FAKE_POTCAR_DIR)) + +NO_PSP_DIR = SETTINGS.get("PMG_VASP_PSP_DIR") is None +skip_if_no_psp_dir = pytest.mark.skipif(NO_PSP_DIR, reason="PMG_VASP_PSP_DIR is not set") + + +class TestLobsterSet(MatSciTest): + def setup_method(self): + self.set = LobsterSet + file_path = f"{VASP_IN_DIR}/POSCAR" + file_path2 = f"{VASP_IN_DIR}/POSCAR.lobster.spin_DOS" + self.struct = Structure.from_file(file_path) + self.struct2 = Structure.from_file(file_path2) + + # test for different parameters! + self.lobsterset1 = self.set(self.struct, isym=-1, ismear=-5) + self.lobsterset2 = self.set(self.struct, isym=0, ismear=0) + # only allow isym=-1 and isym=0 + with pytest.raises( + ValueError, + match=re.escape("Lobster cannot digest WAVEFUNCTIONS with symmetry. isym must be -1 or 0"), + ): + self.lobsterset_new = self.set(self.struct, isym=2, ismear=0) + with pytest.raises(ValueError, match="Lobster usually works with ismear=-5 or ismear=0"): + self.lobsterset_new = self.set(self.struct, isym=-1, ismear=2) + # test if one can still hand over grid density of kpoints + self.lobsterset3 = self.set(self.struct, isym=0, ismear=0, user_kpoints_settings={"grid_density": 6000}) + # check if users can overwrite settings in this class with the help of user_incar_settings + self.lobsterset4 = self.set(self.struct, user_incar_settings={"ALGO": "Fast"}) + # use basis functions supplied by user + self.lobsterset5 = self.set( + self.struct, + user_supplied_basis={"Fe": "3d 3p 4s", "P": "3p 3s", "O": "2p 2s"}, + ) + with pytest.raises(ValueError, match="There are no basis functions for the atom type O"): + self.lobsterset6 = self.set(self.struct, user_supplied_basis={"Fe": "3d 3p 4s", "P": "3p 3s"}).incar + self.lobsterset7 = self.set( + self.struct, + address_basis_file=f"{MODULE_DIR}/../lobster/lobster_basis/BASIS_PBE_54_standard.yaml", + ) + with pytest.warns(BadInputSetWarning, match="Overriding the POTCAR"): + self.lobsterset6 = self.set(self.struct) + + # test W_sw + self.lobsterset8 = self.set(Structure.from_file(f"{TEST_FILES_DIR}/electronic_structure/cohp/POSCAR.W")) + + # test if potcar selection is consistent with PBE_54 + self.lobsterset9 = self.set(self.struct2) + + def test_incar(self): + incar1 = self.lobsterset1.incar + assert "NBANDS" in incar1 + assert incar1["NBANDS"] == 116 + assert incar1["NSW"] == 0 + assert incar1["ISMEAR"] == -5 + assert incar1["ISYM"] == -1 + assert incar1["ALGO"] == "Normal" + assert incar1["EDIFF"] == approx(1e-6) + incar2 = self.lobsterset2.incar + assert incar2["ISYM"] == 0 + assert incar2["ISMEAR"] == 0 + incar4 = self.lobsterset4.incar + assert incar4["ALGO"] == "Fast" + + def test_kpoints(self): + kpoints1 = self.lobsterset1.kpoints + assert kpoints1.comment.split()[5] == "6138" + kpoints2 = self.lobsterset2.kpoints + assert kpoints2.comment.split()[5] == "6138" + kpoints3 = self.lobsterset3.kpoints + assert kpoints3.comment.split()[5] == "6000" + + @skip_if_no_psp_dir + def test_potcar(self): + # PBE_54 is preferred at the moment + functional, symbol = "PBE_54", "K_sv" + assert self.lobsterset1.user_potcar_functional == functional + # test if potcars selected are consistent with PBE_54 + assert self.lobsterset2.potcar.symbols == ["Fe_pv", "P", "O"] + # test if error raised contains correct potcar symbol for K element as PBE_54 set + with pytest.raises( + FileNotFoundError, + match=f"You do not have the right POTCAR with {functional=} and {symbol=}", + ): + _ = self.lobsterset9.potcar.symbols + + def test_as_from_dict(self): + dict_here = self.lobsterset1.as_dict() + + lobsterset_new = self.set.from_dict(dict_here) + # test relevant parts again + incar1 = lobsterset_new.incar + assert "NBANDS" in incar1 + assert incar1["NBANDS"] == 116 + assert incar1["NSW"] == 0 + assert incar1["NSW"] == 0 + assert incar1["ISMEAR"] == -5 + assert incar1["ISYM"] == -1 + assert incar1["ALGO"] == "Normal" + kpoints1 = lobsterset_new.kpoints + assert kpoints1.comment.split()[5] == "6138" + assert lobsterset_new.user_potcar_functional == "PBE_54"