Source code for lobsterpy.coxx.describe

# Copyright (c) lobsterpy development team
# Distributed under the terms of a BSD 3-Clause "New" or "Revised" License

"""This module defines classes to describe the COOPs/COHPs or COBIs automatically."""

from __future__ import annotations

import warnings
from pathlib import Path

from monty.json import MSONable
from pymatgen.analysis.chemenv.coordination_environments.coordination_geometries import (
    AllCoordinationGeometries,
)

from lobsterpy.plotting import InteractiveCohpPlotter, PlainCohpPlotter

_ALL_CE = AllCoordinationGeometries()

_SUFFIX_RULES = [
    ("plane", "planar"),
    ("prism", "prismatic"),
    ("antiprism", "antiprismatic"),
    ("pyramid", "pyramidal"),
    ("bipyramid", "bipyramidal"),
    ("cupola", "cupolar"),
    ("hedron", "hedral"),
]

_EXCEPTIONS = {
    "cube": "cubic",
    "see-saw": "see-saw like",
    "plane normalized": "plane normalized",  # Prevent "plane" -> "planar" transformation
}


def _to_adjective(name: str) -> str:
    """
    Convert coordination geometry name to adjective form.

    :param name: coordination geometry name (e.g., "Cube")
    """
    name = name.lower()

    # Check exceptions first (includes special cases and no-change entries)
    for noun, adj in _EXCEPTIONS.items():
        if noun in name:
            return name.replace(noun, adj)

    # Apply suffix-based transformation rules
    for noun, adj in _SUFFIX_RULES:
        if noun in name:
            return name.replace(noun, adj)

    return name


_CE_TO_TEXT: dict[str, str] = {
    cg.ce_symbol: f"{_to_adjective(cg.name)} (CN={cg.coordination_number})"
    for cg in _ALL_CE.get_implemented_geometries()
}


[docs] class Description(MSONable): """ Base class that will write generate a text description for all relevant bonds. It analyses all relevant coordination environments in the system based on electronic structure theory. """ def __init__(self, analysis_object): """ Generate a text description for all relevant bonds. :param analysis_object: Analysis object from lobsterpy.analysis """ self.analysis_object = analysis_object self.set_description()
[docs] def as_dict(self): """ Get a dictionary representation of the Description object. Returns: A dictionary containing the condensed bonding analysis and text description. """ return { "analysis_object": self.analysis_object.as_dict(), "@module": self.__class__.__module__, "@class": self.__class__.__name__, }
[docs] def set_description(self): """ Set the descriptions of the structures using the cation names, starting with numbers at 1. Uses the cation names from the lobster files. Returns: None """ self.condensed_bonding_analysis = self.analysis_object.condensed_bonding_analysis # set type of population analyzed type_pop = self.analysis_object._get_pop_type() # set units for populations units = " eV" if type_pop == "COHP" else "" if self.analysis_object.which_bonds == "cation-anion": relevant_cations = ", ".join( [ str(site.specie) + str(isite + 1) for isite, site in enumerate(self.analysis_object.structure) if isite in self.analysis_object.seq_ineq_ions ] ) self.text = [] self.text.append( "The compound " + str(self.condensed_bonding_analysis["formula"]) + " has " + str(self.condensed_bonding_analysis["number_of_considered_ions"]) + " symmetry-independent cation(s) with relevant cation-anion interactions: " + str(relevant_cations) + "." ) for key, item in self.condensed_bonding_analysis["sites"].items(): # It has 3 Ta-N (mean ICOHP: -4.78 eV, antibonding interactions below EFermi), bond_info = [] orb_info = [] for type, properties in item["bonds"].items(): if not properties["has_antibdg_states_below_Efermi"]: bond_info.append( str(properties["number_of_bonds"]) + " " + item["ion"] + "-" + str(type) + f" (mean I{type_pop}: " "" + properties[f"I{type_pop}_mean"] + f"{units}, 0.0 percent antibonding interaction below EFermi)" ) if self.analysis_object.orbital_resolved: text_orbital = self._generate_orbital_resolved_analysis_text( orbital_resolved_data=properties, type_pop=type_pop, atom_name=str(type), ion=item["ion"], ) orb_info.extend(text_orbital) else: bond_info.append( str(properties["number_of_bonds"]) + " " + item["ion"] + "-" + str(type) + f" (mean I{type_pop}: " "" + properties[f"I{type_pop}_mean"] + f"{units}, " + str(round(properties["antibonding"]["perc"] * 100, 3)) + " percent antibonding interaction below EFermi)" ) if self.analysis_object.orbital_resolved: text_orbital = self._generate_orbital_resolved_analysis_text( orbital_resolved_data=properties, type_pop=type_pop, atom_name=str(type), ion=item["ion"], ) orb_info.extend(text_orbital) bonds = ",".join(bond_info[0:-1]) + ", and " + bond_info[-1] if len(bond_info) > 1 else bond_info[0] if len(orb_info) > 1: orb_bonds = "".join(orb_info).replace(".In", ". In") else: orb_bonds = orb_info[0] if orb_info else "" if item["env"] == "O:6": self.text.append( str(item["ion"]) + str(key + 1) + " has an " + str(self._coordination_environment_to_text(item["env"])) + " coordination environment. It has " + str(bonds) + " bonds." ) if orb_bonds: self.text.append(orb_bonds) else: self.text.append( str(item["ion"]) + str(key + 1) + " has a " + str(self._coordination_environment_to_text(item["env"])) + " coordination environment. It has " + str(bonds) + " bonds." ) if orb_bonds: self.text.append(orb_bonds) elif self.analysis_object.which_bonds == "all": relevant_ions = ", ".join( [ str(site.specie) + str(isite + 1) for isite, site in enumerate(self.analysis_object.structure) if isite in self.analysis_object.seq_ineq_ions ] ) self.text = [] self.text.append( "The compound " + str(self.condensed_bonding_analysis["formula"]) + " has " + str(self.condensed_bonding_analysis["number_of_considered_ions"]) + " symmetry-independent atoms(s) with relevant bonds: " + str(relevant_ions) + "." ) for key, item in self.condensed_bonding_analysis["sites"].items(): # It has 3 Ta-N (mean ICOHP: -4.78 eV, antibonding interactions below EFermi), bond_info = [] orb_info = [] for type, properties in item["bonds"].items(): if not properties["has_antibdg_states_below_Efermi"]: bond_info.append( str(properties["number_of_bonds"]) + " " + item["ion"] + "-" + str(type) + f" (mean I{type_pop}: " "" + properties[f"I{type_pop}_mean"] + f"{units}, 0.0 percent antibonding interaction below EFermi)" ) if self.analysis_object.orbital_resolved: text_orbital = self._generate_orbital_resolved_analysis_text( orbital_resolved_data=properties, type_pop=type_pop, atom_name=str(type), ion=item["ion"], ) orb_info.extend(text_orbital) else: bond_info.append( str(properties["number_of_bonds"]) + " " + item["ion"] + "-" + str(type) + f" (mean I{type_pop}: " "" + properties[f"I{type_pop}_mean"] + f"{units}, " + str(round(properties["antibonding"]["perc"] * 100, 3)) + " percent antibonding interaction below EFermi)" ) if self.analysis_object.orbital_resolved: text_orbital = self._generate_orbital_resolved_analysis_text( orbital_resolved_data=properties, type_pop=type_pop, atom_name=str(type), ion=item["ion"], ) orb_info.extend(text_orbital) bonds = ",".join(bond_info[0:-1]) + ", and " + bond_info[-1] if len(bond_info) > 1 else bond_info[0] if len(orb_info) > 1: orb_bonds = "".join(orb_info).replace(".In", ". In") else: orb_bonds = orb_info[0] if orb_info else "" if item["env"] == "O:6": self.text.append( str(item["ion"]) + str(key + 1) + " has an " + str(self._coordination_environment_to_text(item["env"])) + " coordination environment. It has " + str(bonds) + " bonds." ) if orb_bonds: self.text.append(orb_bonds) else: self.text.append( str(item["ion"]) + str(key + 1) + " has a " + str(self._coordination_environment_to_text(item["env"])) + " coordination environment. It has " + str(bonds) + " bonds." ) if orb_bonds: self.text.append(orb_bonds) if ( "madelung_energy" in self.analysis_object.condensed_bonding_analysis and self.analysis_object.condensed_bonding_analysis["madelung_energy"] is not None ): self.text.append( "The Madelung energy of this crystal structure per unit cell is: " + str(self.analysis_object.condensed_bonding_analysis["madelung_energy"]) + " eV." )
def _generate_orbital_resolved_analysis_text( self, orbital_resolved_data: dict, ion: str, atom_name: str, type_pop: str, ): """ Generate text from orbital-resolved analysis data of the most relevant COHP, COOP, or COBI. :param orbital_resolved_data: dict of orbital data from condensed bonding analysis object :param ion: name of ion at the site :param atom_name: name of atomic speice to which ion is bonded :param type_pop: population type analysed could be "COHP" or "COOP" or "COBI" Returns: A python list with text describing the orbital which contributes the most to the bonding and antibonding in the bond at site """ orb_info = [] if orbital_resolved_data["orbital_data"]["orbital_summary_stats"]: orb_names = [] orb_contri = [] # get atom-pair list with ion placed first atom_pair = self.analysis_object._sort_name([ion, atom_name], nameion=ion) if "max_bonding_contribution" in orbital_resolved_data["orbital_data"]["orbital_summary_stats"]: for orb, data in orbital_resolved_data["orbital_data"]["orbital_summary_stats"][ "max_bonding_contribution" ].items(): atom_pair_with_orb_name = self.analysis_object._sort_orbital_atom_pair( atom_pair=atom_pair, complete_cohp=self.analysis_object.chemenv.completecohp, label=orbital_resolved_data["orbital_data"]["relevant_bonds"][0], orb_pair=orb, ) orb_names.append("-".join(atom_pair_with_orb_name)) orb_contri.append( str( round( data * 100, 3, ) ) ) orb_names_anti = [] orb_antibonding = [] if "max_antibonding_contribution" in orbital_resolved_data["orbital_data"]["orbital_summary_stats"]: for orb, data in orbital_resolved_data["orbital_data"]["orbital_summary_stats"][ "max_antibonding_contribution" ].items(): atom_pair_with_orb_name = self.analysis_object._sort_orbital_atom_pair( atom_pair=atom_pair, complete_cohp=self.analysis_object.chemenv.completecohp, label=orbital_resolved_data["orbital_data"]["relevant_bonds"][0], orb_pair=orb, ) orb_names_anti.append("-".join(atom_pair_with_orb_name)) orb_antibonding.append( str( round( data * 100, 3, ) ) ) if len(orb_contri) > 1: orb_name_contri = "" for inx, name in enumerate(orb_names): if len(orb_contri) == 2 and inx + 1 != len(orb_contri): orb_name_contri += f"{name} " elif 2 < len(orb_contri) != inx + 1: orb_name_contri += f"{name}, " else: orb_name_contri += f"and {name}" orb_name_contri += " orbitals, contributing " for inx, contribution in enumerate(orb_contri): if len(orb_contri) == 2 and inx + 1 != len(orb_contri): orb_name_contri += f"{contribution} " elif 2 < len(orb_contri) != inx + 1: orb_name_contri += f"{contribution}, " else: orb_name_contri += f"and {contribution} percent, respectively" num_bonds = len(orbital_resolved_data["orbital_data"]["relevant_bonds"]) bonds = "bonds" if num_bonds > 1 else "bond" orb_info.append( f"In the {num_bonds} " + "-".join(atom_pair) + f" {bonds}, relative to the summed I{type_pop}s, " + "the maximum bonding contribution is from " + orb_name_contri ) elif not orb_contri: num_bonds = len(orbital_resolved_data["orbital_data"]["relevant_bonds"]) bonds = "bonds" if num_bonds > 1 else "bond" orb_info.append( f"In the {num_bonds} " + "-".join(atom_pair) + f" {bonds}, relative to the summed I{type_pop}s, " + f"no orbital has a bonding contribution greater than " f"{self.analysis_object.orbital_cutoff * 100} percent" ) else: num_bonds = len(orbital_resolved_data["orbital_data"]["relevant_bonds"]) bonds = "bonds" if num_bonds > 1 else "bond" orb_info.append( f"In the {num_bonds} " + "-".join(atom_pair) + f" {bonds}, relative to the summed I{type_pop}s, " + "the maximum bonding contribution is from the " + f"{orb_names[0]}" + f" orbital, contributing {orb_contri[0]} percent" ) if len(orb_antibonding) > 1: orb_anti = "" for inx, name in enumerate(orb_names_anti): if len(orb_names_anti) == 2 and inx + 1 != len(orb_names_anti): orb_anti += f"{name} " elif 2 < len(orb_antibonding) != inx + 1: orb_anti += f"{name}, " else: orb_anti += f"and {name}" orb_anti += " orbitals, contributing " for inx, contribution in enumerate(orb_antibonding): if len(orb_names_anti) == 2 and inx + 1 != len(orb_names_anti): orb_anti += f"{contribution} " elif 2 < len(orb_antibonding) != inx + 1: orb_anti += f"{contribution}, " else: orb_anti += f"and {contribution} percent, respectively." orb_info.append(f", whereas the maximum antibonding contribution is from {orb_anti}") elif not orb_antibonding: orb_info.append(", whereas no significant antibonding contribution is found in this bond.") else: orb_info.append( f", whereas the maximum antibonding contribution is from the " f"{orb_names_anti[0]} orbital, contributing {orb_antibonding[0]} percent." ) else: # get atom-pair list with ion placed first atom_pair = self.analysis_object._sort_name([ion, atom_name], nameion=ion) percentage_cutoff = round(self.analysis_object.orbital_cutoff * 100, 2) orb_info.append( f"No individual orbital interactions detected above {percentage_cutoff} percent" f" with summed I{type_pop} as reference for the " + "-".join(atom_pair) + " bond." ) return orb_info
[docs] def plot_cohps( self, xlim: list[float] | None = None, ylim: list[float] | None = [-4, 2], integrated: bool = False, title: str = "", save: bool = False, filename: str | None = None, sigma: float | None = None, hide: bool = False, ): """ Automatically generate plots of the most relevant COHPs, COOPs, or COBIs. :param save: will save the plot to a file :param filename: name of the file to save the plot. :param ylim: energy scale that is shown in plot (eV) :param xlim: energy range for COHPs in eV :param integrated: if True, integrated COHPs will be shown :param sigma: Standard deviation of Gaussian broadening applied to population data. If None, no broadening will be added. :param title: sets the title of figure generated :param hide: if True, the plot will not be shown. Returns: A matplotlib object. """ seq_cohps = self.analysis_object.seq_cohps if self.analysis_object.which_bonds == "cation-anion": seq_ineq_cations = self.analysis_object.seq_ineq_ions elif self.analysis_object.which_bonds == "all": seq_ineq_cations = self.analysis_object.seq_ineq_ions seq_labels = self.analysis_object.seq_labels_cohps structure = self.analysis_object.structure if len(seq_ineq_cations) >= 20: warnings.warn( "We will switch of displaying all plots " "as there are more than 20 inequivalent ions. " "We will instead save them in files called " "'automatic-analysis-*.png'.", stacklevel=2, ) hide = True save = True if filename is None: filename = "./automatic_analysis.png" for iplot, (ication, labels, cohps) in enumerate(zip(seq_ineq_cations, seq_labels, seq_cohps)): namecation = str(structure[ication].specie) cp = PlainCohpPlotter( are_coops=self.analysis_object.are_coops, are_cobis=self.analysis_object.are_cobis, ) for label, cohp in zip(labels, cohps): if label is not None: cp.add_cohp(namecation + str(ication + 1) + ": " + label, cohp) plot = cp.get_plot(integrated=integrated, sigma=sigma) plot.ylim(ylim) if xlim is not None: plot.xlim(xlim) plot.title(title) if save: if len(seq_ineq_cations) > 1: if isinstance(filename, str): filename = Path(filename) # type: ignore filename_new = ( filename.parent / f"{filename.stem}-{iplot}{filename.suffix}" # type: ignore ) else: filename_new = filename plot.savefig(filename_new) if hide: plot.close() if not hide: plot.show()
[docs] def plot_interactive_cohps( self, ylim: list[float] | None = None, xlim: list[float] | None = None, save_as_html: bool = False, filename: str | None = None, integrated: bool = False, title: str = "", sigma: float | None = None, label_resolved: bool = False, orbital_resolved: bool = False, hide: bool = False, ): """ Automatically generate interactive plots of the most relevant COHPs, COBIs or COOPs. :param save_as_html: will save the plot to a html file :param filename: name of the file to save the plot. :param ylim: energy scale that is shown in plot (eV) :param xlim: energy range for COHPs in eV :param integrated: if True, integrated COHPs will be shown :param sigma: Standard deviation of Gaussian broadening applied to population data. If None, no broadening will be added. :param label_resolved: if true, relevant cohp curves will be further resolved based on band labels :param orbital_resolved: if true, relevant orbital interactions in cohp curves will be added to figure :param title: Title of the interactive plot :param hide: if True, the plot will not be shown. Returns: A plotly.graph_objects.Figure object. """ cba_cohp_plot_data = {} # Initialize dict to store plot data set_cohps = self.analysis_object.seq_cohps set_labels_cohps = self.analysis_object.seq_labels_cohps set_inequivalent_cations = self.analysis_object.seq_ineq_ions structure = self.analysis_object.structure for _iplot, (ication, labels, cohps) in enumerate(zip(set_inequivalent_cations, set_labels_cohps, set_cohps)): label_str = f"{structure[ication].specie!s}{ication + 1!s}: " for label, cohp in zip(labels, cohps): if label is not None: cba_cohp_plot_data[label_str + label] = cohp ip = InteractiveCohpPlotter( are_coops=self.analysis_object.are_coops, are_cobis=self.analysis_object.are_cobis, ) if label_resolved or orbital_resolved: ip.add_all_relevant_cohps( analyse=self.analysis_object, label_resolved=label_resolved, orbital_resolved=orbital_resolved, ) else: ip.add_cohps_from_plot_data(plot_data_dict=cba_cohp_plot_data) plot = ip.get_plot(integrated=integrated, xlim=xlim, ylim=ylim, sigma=sigma) plot.update_layout(title_text=title) if save_as_html: plot.write_html(filename, include_mathjax="cdn") if not hide: return plot.show() return plot
@staticmethod def _coordination_environment_to_text(ce: str): """ Convert a coordination environment string into a text description of the environment. :param ce: output from ChemEnv package (e.g., "O:6") Returns: A text description of coordination environment """ if ce in _CE_TO_TEXT: return _CE_TO_TEXT[ce] if ce.isdigit(): return f"{ce}-fold" return ce
[docs] def write_description(self): """Print the description of the COHPs or COBIs or COOPs to the screen.""" for textpart in self.text: print(textpart)
[docs] @staticmethod def get_calc_quality_description(quality_dict): """ Generate a text description of the LOBSTER calculation quality. :param quality_dict: python dictionary from lobsterpy.analysis.get_lobster_calc_quality_summary """ warnings.warn( "This method is being deprecated and will be " "removed on 30-03-2026. Please use `lobsterpy.quality.LobsterCalcQuality.describe()` instead.", DeprecationWarning, stacklevel=2, ) text_des = [] for key, val in quality_dict.items(): if key == "minimal_basis": if val: text_des.append("The LOBSTER calculation used minimal basis.") if not val: text_des.append( "Consider rerunning the calculation with the minimum basis as well. Choosing a " "larger basis set is only recommended if you see a significant improvement of " "the charge spilling." ) elif key == "charge_spilling": text_des.append( "The absolute and total charge spilling for the calculation is {} and {} %, respectively.".format( quality_dict[key]["abs_charge_spilling"], quality_dict[key]["abs_total_spilling"], ) ) elif key == "band_overlaps_analysis": if quality_dict[key]["file_exists"]: if quality_dict[key]["has_good_quality_maxDeviation"]: text_des.append( "The bandOverlaps.lobster file is generated during the LOBSTER run. This " "indicates that the projected wave function is not completely orthonormalized; " "however, the maximal deviation values observed compared to the identity matrix " "is below the threshold of 0.1." ) else: text_des.append( "The bandOverlaps.lobster file is generated during the LOBSTER run. This " "indicates that the projected wave function is not completely orthonormalized. " "The maximal deviation value from the identity matrix is {}, and there are " "{} percent k-points above the deviation threshold of 0.1. Please check the " "results of other quality checks like dos comparisons, charges, " "charge spillings before using the results for further " "analysis.".format( quality_dict[key]["max_deviation"], quality_dict[key]["percent_kpoints_abv_limit"], ) ) else: text_des.append( "The projected wave function is completely orthonormalized as no " "bandOverlaps.lobster file is generated during the LOBSTER run." ) elif key == "charge_comparisons": if val: for charge in ["mulliken", "loewdin"]: if val[f"bva_{charge}_agree"]: text_des.append( f"The atomic charge signs from {charge.capitalize()} population analysis " f"agree with the bond valence analysis." ) if not val[f"bva_{charge}_agree"]: text_des.append( f"The atomic charge signs from {charge.capitalize()} population analysis " f"do not agree with the bond valence analysis." ) else: text_des.append( "Oxidation states from BVA analyzer cannot be determined. " "Thus BVA charge comparison is not conducted." ) elif key == "dos_comparisons": comp_types = [] tani_index = [] for orb in val: if orb.split("_")[-1] in ["s", "p", "d", "f", "summed"]: comp_types.append(orb.split("_")[-1]) tani_index.append(str(val[orb])) text_des.append( "The Tanimoto index from DOS comparisons in the energy range between {}, {} eV " "for {} orbitals are: {}.".format( val["e_range"][0], val["e_range"][1], ", ".join(comp_types), ", ".join(tani_index), ) ) return text_des
[docs] @staticmethod def write_calc_quality_description(calc_quality_text): """Print the calculation quality description to the screen.""" warnings.warn( "This method is being deprecated and will be " "removed on 30-03-2026. Please use `lobsterpy.quality.LobsterCalcQuality.print_description()` instead.", DeprecationWarning, stacklevel=2, ) print(" ".join(calc_quality_text))