"""
Code for analysing the thermodynamics of defect formation in solids, including
calculation of formation energies as functions of Fermi level and chemical
potentials, charge transition levels, defect/carrier concentrations etc.
"""
import contextlib
import importlib.util
import statistics
import warnings
from collections import defaultdict
from collections.abc import Callable, Iterable, Iterator
from copy import copy, deepcopy
from functools import lru_cache, partial, reduce
from itertools import chain, product
from operator import methodcaller
from typing import TYPE_CHECKING, Any, TypeAlias, cast
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from matplotlib import colors
from matplotlib.figure import Figure
from monty.json import MSONable
from monty.serialization import dumpfn, loadfn
from pymatgen.core.composition import Composition, Element
from pymatgen.electronic_structure.dos import Dos, FermiDos, Spin
from pymatgen.io.vasp import Vasprun
from pymatgen.util.typing import PathLike
from scipy.optimize import brentq
from scipy.spatial import HalfspaceIntersection
from tqdm import tqdm
from doped import _doped_obj_properties_methods
from doped.chemical_potentials import ChemicalPotentialGrid, get_X_rich_poor_limit, plot_chempot_heatmap
from doped.core import (
DefectEntry,
_get_dft_chempots,
_no_chempots_warning,
_orientational_degeneracy_warning,
)
from doped.generation import sort_defect_entries
from doped.utils.configurations import apply_s2_to_s1_transformation, get_transformation_from_s2_to_s1
from doped.utils.efficiency import _fast_dict_deepcopy_max_two_levels
from doped.utils.parsing import (
_compare_incar_tags,
_compare_kpoints,
_compare_potcar_symbols,
_get_bulk_supercell,
get_nelect_from_vasprun,
get_vasprun,
)
from doped.utils.plotting import (
TransitionLevel,
_format_TL_charge_label,
_rename_key_and_dicts,
doped_plot_style,
formation_energy_plot,
transition_level_diagram,
)
from doped.utils.symmetry import cluster_coords, get_all_equiv_sites, get_primitive_structure, get_sga
if TYPE_CHECKING:
with contextlib.suppress(ImportError):
from py_sc_fermi.defect_species import DefectSpecies
from py_sc_fermi.defect_system import DefectSystem
from py_sc_fermi.dos import DOS
[docs]
def bold_print(string: str) -> None:
"""
Does what it says on the tin.
Prints the input string in bold.
"""
print("\033[1m" + string + "\033[0m")
def _raise_limit_with_user_chempots_error(no_chempots=True):
problem = (
(
"the supplied chempots are not in the doped format (i.e. with `limits` in the chempots dict), "
"and instead correspond to just a single chemical potential limit"
)
if no_chempots
else "no `chempots` have been supplied"
)
raise ValueError(
f"You have specified a chemical potential limit, but {problem}, so `limit` cannot be used here!"
)
def _parse_limit(chempots: dict, limit: str | None = None):
if limit is not None:
if limit in chempots["limits"]:
return limit # direct match, just return limit name
if "limits" not in chempots or "User Chemical Potentials" in chempots["limits"]:
# user specified chempots
_raise_limit_with_user_chempots_error(no_chempots=True)
if "No User Chemical Potentials" in chempots["limits"]:
_raise_limit_with_user_chempots_error(no_chempots=False)
if "rich" in limit or "poor" in limit:
limit = get_X_rich_poor_limit(limit, chempots)
return limit
[docs]
def get_rich_poor_limit_dict(chempots: dict) -> dict:
"""
Get a dictionary of {"X-rich": limit, "X-poor": limit...} for each element
X in the chempots phase diagram.
Args:
chempots (dict): The chempots dict, in the doped format.
"""
if (
"limits" not in chempots
or "User Chemical Potentials" in chempots["limits"]
or "No User Chemical Potentials" in chempots["limits"]
):
raise ValueError(
"The supplied chempots are not in the doped format (i.e. with `limits` in the "
"chempots dict), and so the X-rich/poor limits cannot be determined!"
)
comps = {comp for key in chempots["limits"] for comp in key.split("-")}
elts = {element.symbol for comp in comps for element in Composition(comp).elements}
return {
f"{elt}-{rich_or_poor}": get_X_rich_poor_limit(elt, chempots, rich=(rich_or_poor == "rich"))
for elt in elts
for rich_or_poor in ["rich", "poor"]
}
def _get_rich_poor_limit_name_from_dict(
limit: str, limit_rich_poor_dict: dict[str, str], bracket: bool = False
) -> str:
"""
Given a chemical potential limit ``limit``, get the corresponding key in
``limit_rich_poor_dict`` (which should be a dict of ``{rich/poor limit
name: limit}`` (e.g. ``{"Mg-rich": "MgO-Mg"...}``)) where ``limit`` is a
value.
Favours 'rich' limits over 'poor' (e.g. 'O-rich' favoured over 'Mg-poor'
for MgO), and includes the actual limit name in parentheses in the output
if ``bracket`` is ``True``.
"""
if limit_rich_poor_dict and limit in limit_rich_poor_dict.values():
# sort dict to favour 'rich' before 'poor' (e.g. 'O-rich' favoured over 'Mg-poor' for MgO)
sorted_rich_poor_dict = dict(
sorted(
limit_rich_poor_dict.items(), key=lambda item: ("rich" in item[0], item[0]), reverse=True
)
)
# get first key with matching value:
x_rich_poor = list(sorted_rich_poor_dict.keys())[list(sorted_rich_poor_dict.values()).index(limit)]
return f"{x_rich_poor} ({limit})" if bracket else x_rich_poor
return limit
def _update_old_chempots_dict(chempots: dict | None = None) -> dict | None:
"""
Update a chempots dict in the old ``doped`` format (i.e. with ``facets``
rather than ``limits``) to that of the new format.
Also replaces any usages of ``"elt_refs"`` with ``"el_refs"``.
"""
if chempots is not None and any("facets" in key or "elt_refs" in key for key in chempots):
for key in list(chempots.keys()):
chempots[key.replace("elt_refs", "el_refs").replace("facets", "limits")] = chempots.pop(key)
return chempots
def _parse_chempots(
chempots: dict | None = None, el_refs: dict | None = None, update_el_refs: bool = False
) -> tuple[dict | None, dict | None]:
"""
Parse the chemical potentials input, formatting them in the ``doped``
format for use in analysis functions.
Can be either ``doped`` format or user-specified format.
Returns parsed ``chempots`` and ``el_refs``
"""
for i in [chempots, el_refs]:
if i is not None and not isinstance(i, dict):
raise ValueError(
f"Invalid chempots/el_refs format:\nchempots: {type(chempots)}\nel_refs: {type(el_refs)}\n"
"Must be a dict (e.g. from `CompetingPhasesAnalyzer.chempots`) or `None`!"
)
if chempots is None:
if el_refs is not None:
chempots = {
"limits": {"User Chemical Potentials": el_refs},
"elemental_refs": el_refs,
"limits_wrt_el_refs": {"User Chemical Potentials": dict.fromkeys(el_refs, 0)},
}
return chempots, el_refs
chempots = _fast_dict_deepcopy_max_two_levels(chempots) # deep copy to avoid overwriting original
chempots = _update_old_chempots_dict(chempots)
assert chempots is not None # typing
if "limits_wrt_el_refs" in chempots: # doped format
if not update_el_refs:
el_refs = chempots.get("elemental_refs", el_refs)
if el_refs is not None: # update el_refs in chempots dict
chempots["elemental_refs"] = el_refs
chempots["limits"] = {
limit: {
el: relative_chempot + el_refs[el]
for el, relative_chempot in chempots["limits_wrt_el_refs"][limit].items()
}
for limit in chempots["limits_wrt_el_refs"]
}
return chempots, chempots.get("elemental_refs")
if el_refs is None:
chempots = {
"limits": {"User Chemical Potentials": chempots},
"elemental_refs": dict.fromkeys(chempots, 0),
"limits_wrt_el_refs": {"User Chemical Potentials": chempots},
}
else: # relative chempots and el_refs given
relative_chempots = chempots
chempots = {"limits_wrt_el_refs": {"User Chemical Potentials": relative_chempots}}
chempots["elemental_refs"] = el_refs
chempots["limits"] = {
"User Chemical Potentials": {
el: relative_chempot + el_refs[el] for el, relative_chempot in relative_chempots.items()
}
}
return chempots, chempots.get("elemental_refs")
[docs]
def raw_energy_from_chempots(composition: str | dict | Composition, chempots: dict) -> float:
"""
Given an input composition (as a ``str``, ``dict`` or ``pymatgen``
|Composition| object) and chemical potentials dictionary, get the
corresponding raw energy of the composition (i.e. taking the energies given
in the ``'limits'`` subdicts of ``chempots``, in the ``doped`` chemical
potentials dictionary format).
Args:
composition (str | dict | |Composition|):
Composition to get the raw energy of.
chempots (dict):
Chemical potentials dictionary.
Returns:
Raw energy of the composition.
"""
if not isinstance(composition, Composition):
composition = Composition(composition)
if "limits" not in chempots:
# non-``None`` dict input -> parsed chempots are non-``None``:
chempots, _el_refs = cast("tuple[dict, dict | None]", _parse_chempots(chempots))
raw_energies_dict = dict(next(iter(chempots["limits"].values())))
if any(el.symbol not in raw_energies_dict for el in composition.elements):
warnings.warn(
f"The chemical potentials dictionary (with elements {list(raw_energies_dict.keys())} does not "
f"contain all the elements in the host composition "
f"({[el.symbol for el in composition.elements]})!"
)
# note this can also be achieved with: (implements exact same code)
# from pymatgen.core.composition import ChemicalPotential
# raw_energy = ChemicalPotential(raw_energies_dict).get_energy(composition)
return sum(raw_energies_dict.get(el.symbol, 0) * stoich for el, stoich in composition.items())
[docs]
def group_defects_by_type_and_distance(
defect_entries: list[DefectEntry],
dist_tol: float = 1.5,
symprec: float = 0.1,
method: str = "single",
) -> dict[str, dict[int, set[DefectEntry]]]:
"""
Given an input list of |DefectEntry| objects, returns a dictionary of
format ``{simple defect name: {cluster index: {DefectEntry, ...}}``, with
defect types as keys, and values being sub-dictionaries of defect entries
clustered according to the given ``dist_tol`` distance tolerance (between
symmetry-equivalent sites in the bulk supercell) and clustering ``method``.
``simple defect name`` is the nominal defect type (e.g. ``Te_i`` for
``Te_i_Td_Cd2.83_+2``) and ``{DefectEntry, ...}`` is a set of |DefectEntry|
objects which have been grouped in the same cluster.
This is used to group together different defect entries (different charge
states, and/or ground and metastable states (different spin or geometries))
which correspond to the same defect type (e.g. interstitials at a given
site) and occupy similar sites in the host lattice, which is then used in
plotting, transition level analysis and defect concentration calculations;
e.g. in the frozen defect approximation, the total concentration of a given
defect type group is calculated at the annealing temperature, and then the
equilibrium relative population of the constituent entries is recalculated
at the quenched temperature.
Note: The ``get_min_dist_between_equiv_sites`` function in
``doped.utils.symmetry`` can be useful for analysing the inter-defect
distances and resulting clustering behaviour.
Args:
defect_entries (list[|DefectEntry|]):
A list of |DefectEntry| objects to group together based on type
and distance between symmetry-equivalent sites.
dist_tol (float):
Distance threshold (in Å) for clustering equivalent defect sites.
(Default: 1.5)
symprec (float):
Symmetry precision for finding equivalent sites in the bulk
supercell, for site clustering. Default is 0.1 (matching ``doped``
default for point symmetry determination for relaxed defect
supercells).
method (str):
Clustering algorithm to use with the ``scipy`` ``linkage()``
function. Default is ``"single"`` (equivalent to the Nearest Point
algorithm; recommended choice when dealing with small numbers of
defects (i.e. when subdivided by defect type), but can be prone to
daisy-chaining effects).
Recommended choices include ``"centroid"`` (which clusters sites
based on the distance between cluster centroids, vs ``dist_tol``)
or ``"single"`` (clusters based on the minimum distance between any
pair of sites, vs ``dist_tol``). See ``scipy`` ``linkage()`` for
other choices.
Returns:
dict:
Dictionary of
``{simple defect name: {cluster index: {DefectEntry, ...}}}``.
"""
# Note: This algorithm works well for the vast majority of cases, however because it involves
# clustering, the results can be a little sensitive to which / how many defects are parsed together
# (due to daisy-chaining effects). The user can always adjust `dist_tol` as desired.
defect_type_cluster_dict: dict[str, dict[int, set[DefectEntry]]] = {
entry.defect.name: defaultdict(set) for entry in defect_entries
}
for defect_name in list(defect_type_cluster_dict.keys()):
defect_type_cluster_dict[defect_name] = group_defects_by_distance(
[entry for entry in defect_entries if entry.defect.name == defect_name],
dist_tol=dist_tol,
symprec=symprec,
method=method,
)
return defect_type_cluster_dict
[docs]
def group_defects_by_distance(
entry_list: list[DefectEntry],
dist_tol: float = 1.5,
symprec: float = 0.1,
method: str = "average",
) -> dict[int, set[DefectEntry]]:
r"""
Given an input list of |DefectEntry| objects, returns a dictionary of
defect entries clustered according to the given ``dist_tol`` distance
tolerance (between symmetry-equivalent sites in the bulk structure) and
clustering ``method``; format: ``{cluster index: {DefectEntry, ...}}``.
This is used to group together different defect entries (different charge
states, and/or ground and metastable states (different spin or geometries))
which occupy similar sites in the host lattice, which is then used in
plotting, transition level analysis and defect concentration calculations;
e.g. in the frozen defect approximation, the total concentration of a given
defect type group is calculated at the annealing temperature, and then the
equilibrium relative population of the constituent entries is recalculated
at the quenched temperature. When ``site_competition = True`` (default) in
defect concentration calculations, the grouping returned by this function
is used to determine which defects occupy the same sites (and hence compete
for site occupancy). Note that while large ``dist_tol``\s are often
preferable for plotting (to condense the defect formation energy lines),
this is often not ideal for determining site competition in concentration
analyses as it can lead to unrealistically-large clusters.
Note: The ``get_min_dist_between_equiv_sites`` function in
``doped.utils.symmetry`` can be useful for analysing the inter-defect
distances and resulting clustering behaviour.
Args:
entry_list ([|DefectEntry|, ...]):
A list of |DefectEntry| objects to group together.
dist_tol (float):
Distance threshold (in Å) for clustering equivalent defect sites.
(Default: 1.5)
symprec (float):
Symmetry precision for finding equivalent sites in the bulk
structure, for site clustering. Default is 0.1 (matching ``doped``
default for point symmetry determination for relaxed defect
supercells).
method (str):
Clustering algorithm to use with ``linkage()``. Default is
``"average"``, which is typically better than the ``scipy`` default
of ``"single`` for defect site clustering, as it avoids
unintentional daisy-chaining effects. Another reasonable choice is
``"complete"``, which ensures that no two sites in a given cluster
are more than ``tol`` apart. See the docstrings and source code of
:func:`~doped.utils.symmetry.cluster_coords` for more details.
``"centroid"``/``"median"``/``"ward"`` should not be used for
as they assume a flat Euclidean space, which is violated with PBC
distances.
Returns:
dict: Dictionary of ``{cluster index: {DefectEntry, ...}}``.
"""
clustered = _group_defects_by_distance( # cached on a hashable (tuple) ``entry_list``
tuple(entry_list), dist_tol=dist_tol, symprec=symprec, method=method
) # return a fresh copy so callers can safely mutate the returned dict without corrupting the cache:
return {cluster_idx: set(entries) for cluster_idx, entries in clustered.items()}
@lru_cache(maxsize=1000)
def _group_defects_by_distance(
entry_list: tuple[DefectEntry, ...],
dist_tol: float = 1.5,
symprec: float = 0.1,
method: str = "average",
) -> dict[int, set[DefectEntry]]:
"""
Cached implementation of :func:`group_defects_by_distance`.
Takes a (hashable) ``tuple`` of |DefectEntry| objects so that results can
be cached w/``functools.lru_cache``. See :func:`group_defects_by_distance`
for details. The returned dict should not be mutated directly (it is the
cached object); use the public wrapper, which returns a copy.
"""
reference_entry = entry_list[0]
bulk_structure = reference_entry.defect.structure
bulk_sga = get_sga(bulk_structure, symprec=symprec)
symm_bulk_struct = bulk_sga.get_symmetrized_structure()
equiv_fcoords_entries_dict: dict[tuple[tuple[float, float, float], ...], list[DefectEntry]] = (
defaultdict(list)
) # {(equiv defect site frac coords): entry list}}
# Clustering Workflow:
# 1. Generate ``equiv_fcoords_entries_dict``: {(equiv defect sites): entry list}, initially joining
# together any entries which are within min(0.05, dist_tol) Å of each other (i.e. (near-)exact
# matches).
# 2. Cluster equivalent defect sites using ``cluster_coords`` with ``dist_tol``.
# 3. Take unique clusters (of equivalent sites) and start with the largest (i.e. accounting for the
# fact that the defect entries corresponding to some clusters may be subsets of others,
# if e.g. they have a lower symmetry / higher multiplicity etc., and so we favour larger rather
# than smaller clusters, or equivalently less rather than more clusters), iterate through and add
# a defect entry cluster of all entries corresponding to that equiv site cluster which are not
# already members of an earlier defect entry cluster.
for entry in entry_list:
# using primitive structure rather than bulk supercell here should mostly avoid
# periodicity-breaking issues
entry_bulk_structure = entry.defect.structure
entry_bulk_site = entry.defect.site
entry_equiv_sites = getattr(entry.defect, "equivalent_sites", None)
if entry_bulk_structure != bulk_structure:
# bulk structures differ, so need to try transforming the ``entry_bulk_site`` (and equiv sites)
# to match the ``bulk_structure`` used for all defect entries here
trans = get_transformation_from_s2_to_s1(bulk_structure, entry_bulk_structure)
if trans is not None:
supercell_matrix, trans_vector, mapping = trans
extended = entry_bulk_structure.copy()
# extend bulk structure to include defect site:
extended.append(entry_bulk_site.species, entry_bulk_site.frac_coords)
if entry_equiv_sites:
extended.extend(entry_equiv_sites)
reoriented_extended = apply_s2_to_s1_transformation(
bulk_structure,
extended,
supercell_matrix,
trans_vector,
mapping,
new_lattice="struct1", # ``bulk_structure`` lattice
)
entry_bulk_site = reoriented_extended[len(bulk_structure)] # get reoriented defect site
entry_bulk_structure = bulk_structure
if entry_equiv_sites:
entry_equiv_sites = reoriented_extended[len(bulk_structure) + 1 :]
else:
warnings.warn(
f"The bulk primitive structure for defect entry '{entry.name}' differs from that of "
f"the reference entry ({reference_entry.name}; first in ``entry_list``), and "
f"automatic reorientation failed. This may lead to incorrect site clustering, which "
f"could affect defect concentration calculations (mostly only in extremely high "
f"concentration regimes)."
)
# get min distances to each equiv_fcoords_tuple for previously checked defect entries:
min_dist_list = [ # min dist for all equiv site tuples, in case multiple less than dist_tol
entry_bulk_site.lattice.get_all_distances(
entry_bulk_site.frac_coords, list(equiv_fcoords_tuple)
).min()
for equiv_fcoords_tuple in equiv_fcoords_entries_dict
]
if min_dist_list and min(min_dist_list) < min(
0.05, dist_tol
): # near-exact match, add to corresponding entry list
idxmin = np.argmin(min_dist_list) # entry list
equiv_fcoords_entries_dict[list(equiv_fcoords_entries_dict.keys())[idxmin]].append(entry)
else: # no exact match found, add new entry
if entry_equiv_sites:
equiv_fcoords_tuple = tuple( # tuple because lists aren't hashable (can't be dict keys)
tuple(site.frac_coords) for site in entry_equiv_sites
)
else:
try:
equiv_fcoords_tuple = (
tuple( # tuple because lists aren't hashable (can't be dict keys)
tuple(site.frac_coords)
for site in symm_bulk_struct.find_equivalent_sites(entry_bulk_site)
)
)
except ValueError: # likely interstitials, need to add equiv sites to tuple
equiv_fcoords_tuple = (
tuple( # tuple because lists aren't hashable (can't be dict keys)
tuple(frac_coords)
for frac_coords in cast( # just_frac_coords=True -> list[np.ndarray]
"list[np.ndarray]",
get_all_equiv_sites(
entry_bulk_site.frac_coords,
symm_bulk_struct,
symprec=symprec,
just_frac_coords=True,
return_symprec_and_dist_tol_factor=False,
),
)
)
)
equiv_fcoords_entries_dict[equiv_fcoords_tuple].append(entry)
all_frac_coords = list(chain.from_iterable(equiv_fcoords_entries_dict.keys()))
# cn is an array of cluster numbers, of length ``len(all_frac_coords)``, so we take the set of
# cluster numbers ``n`` to get unique cluster numbers, sort by cluster size to favour larger
# clusters (see workflow comment above), using ``np.where(cn == n)[0]`` to get the indices of ``cn`` /
# ``all_frac_coords`` which are in cluster ``n``, and then also by index for deterministic behaviour:
# centroid usually best method to avoid large daisy-chaining effects with many defects:
cn = cluster_coords(all_frac_coords, bulk_structure, dist_tol=dist_tol, method=method)
unique_clusters = sorted(
set(cn), key=lambda n: (len(np.where(cn == n)[0]), min(np.where(cn == n)[0])), reverse=True
)
clustered_defect_entries: dict[int, set[DefectEntry]] = {} # {cluster index: {DefectEntry, ...}}
def map_coords_to_keys(equiv_fcoords_entries_dict):
coord_to_key_map = {}
for key in equiv_fcoords_entries_dict:
for coord in key:
coord_to_key_map[coord] = key
return coord_to_key_map
# Pre-compute the map for quick look-up
coord_to_key_map = map_coords_to_keys(equiv_fcoords_entries_dict)
for n in unique_clusters:
defect_entries = chain.from_iterable( # get the corresponding defect entries:
equiv_fcoords_entries_dict.get( # get corresponding key and thus entries
coord_to_key_map[all_frac_coords[i]], []
)
for i in np.where(cn == n)[0]
)
if new_entries_to_cluster := { # reduce to unique entries which are not in any previous cluster
entry
for entry in defect_entries
if all(entry not in entry_set for entry_set in clustered_defect_entries.values())
}:
clustered_defect_entries[n] = new_entries_to_cluster
return clustered_defect_entries
[docs]
def group_defects_by_name(entry_list: list[DefectEntry]) -> dict[str, set[DefectEntry]]:
"""
Given an input list of |DefectEntry| objects, returns a dictionary of
``{defect name without charge: [DefectEntry]}``, where the values are lists
of |DefectEntry| objects with the same defect name (excluding charge
state).
The ``DefectEntry.name`` attributes are used to get the defect names. These
should be in the format:
"{defect_name}_{optional_site_info}_{charge_state}". If the
``DefectEntry.name`` attribute is not defined or does not end with the
charge state, then the entry will be renamed with the doped default name
for the `unrelaxed` defect (i.e. using the point symmetry of the defect
site in the bulk cell).
For example, ``v_Cd_C3v_+1``, ``v_Cd_Td_+1`` and ``v_Cd_C3v_+2`` will be
grouped as
``{"v_Cd_C3v": [v_Cd_C3v_+1, v_Cd_C3v_+2], "v_Cd_Td": [v_Cd_Td_+1]}``.
Args:
entry_list ([|DefectEntry|]):
A list of |DefectEntry| objects to group together by defect name
(without charge).
Returns:
dict: Dictionary of ``{defect name without charge: [DefectEntry]}``.
"""
from doped.analysis import check_and_set_defect_entry_name # avoid circular import
grouped_entries: dict[str, set[DefectEntry]] = {} # dict for groups of entries with the same prefix
for _i, entry in enumerate(entry_list):
# check defect entry name and (re)define if necessary
check_and_set_defect_entry_name(entry, entry.name)
entry_name_wout_charge = entry.name.rsplit("_", 1)[0]
# If the prefix is not yet in the dictionary, initialize it with empty lists
if entry_name_wout_charge not in grouped_entries:
grouped_entries[entry_name_wout_charge] = set()
# Append the current entry to the appropriate group
grouped_entries[entry_name_wout_charge].add(entry)
return grouped_entries
[docs]
def name_defect_cluster(entry_list: list[DefectEntry]) -> str:
"""
Given a list of |DefectEntry| objects (assumed to be a 'defect
cluster/group'), determines and returns a representative name for the
cluster.
The representative name is chosen by identifying the most common defect
name (excluding the charge state) among the lowest energy entries (within
a 25 meV noise threshold) for each charge state. If this does not yield a
unique choice, the shortest name is preferred, followed by lexicographical
order (sorting by name), then by frequency among the `exact` minimum energy
entries (without noise threshold), and finally by whether the entry is the
lowest-energy state at the lowest absolute charge state present in the
cluster.
Args:
entry_list (list[|DefectEntry|]):
List of |DefectEntry| objects constituting the defect cluster.
Returns:
str: Representative defect name (without charge) for the cluster.
"""
sorted_defect_entries = sorted(
entry_list, key=lambda x: (-x.charge_state, x.get_ediff(), x.name)
) # sort by charge (most positive first), then energy, then name
# take first of each charge state to get min energies:
min_energy_by_charge = {
defect_entry.charge_state: defect_entry.get_ediff() for defect_entry in sorted_defect_entries[::-1]
} # reversed order, last (first) entry overwrites energy
min_energy_defects_by_charge = {
charge: [
defect_entry.name.rsplit("_", 1)[0]
for defect_entry in sorted_defect_entries
if defect_entry.get_ediff() - min_energy < 0.025 # 25 meV noise threshold
and defect_entry.charge_state == charge
]
for charge, min_energy in min_energy_by_charge.items()
}
min_energy_defects_by_charge_no_noise = {
charge: [
defect_entry.name.rsplit("_", 1)[0]
for defect_entry in sorted_defect_entries
if defect_entry.get_ediff() == min_energy # no noise threshold
and defect_entry.charge_state == charge
]
for charge, min_energy in min_energy_by_charge.items()
}
min_energy_min_abs_charge_entry = next(
iter(min_energy_defects_by_charge_no_noise[min(min_energy_defects_by_charge_no_noise, key=abs)])
)
possible_defect_names = [
defect_entry.name.rsplit("_", 1)[0] for defect_entry in sorted_defect_entries
] # names without charge
return max(
possible_defect_names,
key=lambda x: (
sum(1 for charge, name_list in min_energy_defects_by_charge.items() if x in name_list),
-len(x),
x,
sum(
1 for charge, name_list in min_energy_defects_by_charge_no_noise.items() if x in name_list
),
x == min_energy_min_abs_charge_entry,
),
)
[docs]
class DefectThermodynamics(MSONable):
"""
Class for analysing the calculated thermodynamics of defects in solids.
Similar to a ``pymatgen`` ``PhaseDiagram`` object, allowing the analysis of
formation energies as functions of Fermi level and chemical potentials
(i.e. defect formation energy / transition level diagrams), charge
transition levels, defect/carrier concentrations as functions of
temperature, chemical potential etc.
This class can be used to return:
a) defect formation energy diagrams (plots),
b) stability of charge states for a given defect,
c) tables of all formation energies / symmetries & degeneracies,
d) tables of equilibrium and constrained defect/carrier concentrations,
e) charge transition levels, including metastable states,
f) doping analysis,
g) ...
Note that |DefectThermodynamics| objects can be used to initialise the
``FermiSolver`` class in this module, which implements a number of
convenience methods for thermodynamic analyses; such as scanning over
temperatures, chemical potentials, effective dopant concentrations etc,
minimising or maximising a target property (e.g. defect/carrier
concentration), and also allowing greater control over constraints and
approximations in defect concentration calculations; such as specifying
(known) fixed concentrations of a defect/dopant, specifying defects to
exclude from high-temperature concentration fixing in the frozen defect
approximation (e.g. highly-mobile defects), and/or fixing defect charge
states upon quenching.
"""
# attributes set in ``_parse_transition_levels`` (called from ``__init__``); annotated here so their
# types can be determined when accessed externally:
transition_level_map: dict[str, dict[float, list[int]]]
all_entries: dict[str, list[DefectEntry]]
stable_entries: dict[str, list[DefectEntry]]
defect_charge_map: dict[str, list[int]]
def __init__(
self,
defect_entries: dict[str, DefectEntry] | list[DefectEntry],
chempots: dict | None = None,
el_refs: dict | None = None,
vbm: float | None = None,
band_gap: float | None = None,
dist_tol: float = 1.5,
check_compatibility: bool = True,
bulk_dos: FermiDos | None = None,
skip_dos_check: bool = False,
):
r"""
Create a |DefectThermodynamics| object, which can be used to analyse
the calculated thermodynamics of defects in solids (formation energies,
transition levels, concentrations etc.).
Usually initialised using
``DefectsParser.get_defect_thermodynamics()``, but can also be
initialised with a list or dict of |DefectEntry| objects (e.g. from
``DefectsParser.defect_dict``).
Note that the ``DefectEntry.name`` attributes are used to label the
defects in plots.
Args:
defect_entries (dict[str, |DefectEntry|] or list[|DefectEntry|]):
A dict or list of |DefectEntry| objects. If a
``DefectEntry.name`` attribute is not defined or does not end
with the charge state (as ``..._{charge state}``), then the
entry will be renamed with the ``doped`` default name.
chempots (dict):
Dictionary of chemical potentials to use for calculating the
defect formation energies. This can have the form of
``{"limits": [{'limit': [chempot_dict]}]}`` (the format
generated by ``doped``\'s chemical potential parsing functions
(see tutorials)) which allows easy analysis over a range of
chemical potentials -- where limit(s) (chemical potential
limit(s)) to analyse/plot can later be chosen using the
``limits`` argument.
Alternatively this can be a dictionary of chemical potentials
for a single limit, in the format:
``{element symbol: chemical potential}``.
If manually specifying chemical potentials this way, you can
set the ``el_refs`` option with the DFT reference energies of
the elemental phases in order to show the formal (relative)
chemical potentials above the formation energy plot, in which
case it is the formal chemical potentials (i.e. relative to
the elemental references) that should be given here, otherwise
the absolute (DFT) chemical potentials should be given.
If ``None`` (default), sets all chemical potentials to zero.
Chemical potentials can also be supplied later in each analysis
function, or set using ``DefectThermodynamics.chempots = ...``
(with the same input options).
el_refs (dict):
Dictionary of elemental reference energies for the chemical
potentials in the format:
``{element symbol: reference energy}`` (to determine the formal
chemical potentials, when ``chempots`` has been manually
specified as ``{element symbol: chemical potential}``).
Unnecessary if ``chempots`` is provided in format generated by
``doped`` (see tutorials).
If ``None`` (default), sets all elemental reference energies to
zero. Reference energies can also be supplied later in each
analysis function, or set using
``DefectThermodynamics.el_refs = ...`` (with the same input
options).
vbm (float):
VBM eigenvalue to use as Fermi level reference point for
analysis. If ``None`` (default), will use ``"vbm"`` from the
``calculation_metadata`` dict attributes of the parsed
|DefectEntry| objects, which by default is taken from the
bulk supercell VBM (unless ``bulk_band_gap_vr`` is set during
defect parsing). Note that ``vbm`` should only affect the
reference for the Fermi level values output by ``doped`` (as
this VBM eigenvalue is used as the zero reference), thus
affecting the position of the band edges in the defect
formation energy plots and doping window / dopability limit
functions, and the reference of the reported Fermi levels.
band_gap (float):
Band gap of the host, to use for analysis. If ``None``
(default), will use "band_gap" from the
``calculation_metadata`` dict attributes of the |DefectEntry|
objects in ``defect_entries``.
dist_tol (float):
Distance threshold (in Å) to use for clustering (equivalent)
defect sites (for plotting, transition level analysis and
defect concentration calculations; see ``plot()`` and
``get_fermi_level_and_concentrations()`` for more information).
See the ``dist_tol`` attribute, ``group_defects_by_distance()``
and ``group_defects_by_type_and_distance()`` functions for more
information on clustering strategies --
``self.clustered_defect_entries`` and/or
``self.clustered_defect_entries_by_type`` can also be
manually set with these functions for greater control.
(Default: 1.5)
check_compatibility (bool):
Whether to check the compatibility of the bulk entry for each
defect entry (i.e. that all reference bulk energies are the
same). Default is ``True``.
bulk_dos (FermiDos or |Vasprun| or PathLike):
``pymatgen`` ``FermiDos`` for the bulk electronic density of
states (DOS), for calculating Fermi level positions and
defect/carrier concentrations. Alternatively, can be a
``pymatgen`` |Vasprun| object or path to the
``vasprun.xml(.gz)`` output of a bulk DOS calculation in VASP.
Can also provide later in ``get_equilibrium_fermi_level()``,
``get_fermi_level_and_concentrations`` etc., or set using
``DefectThermodynamics.bulk_dos = ...`` (with the same input
options).
Usually this is a static calculation with the `primitive` cell
of the bulk material, with relatively dense `k`-point sampling
(especially for materials with disperse band edges) to ensure
an accurately-converged DOS and thus Fermi level. Using large
``NEDOS`` (>3000) and ``ISMEAR = -5`` (tetrahedron smearing)
are recommended for best convergence (wrt `k`-point sampling)
in VASP. Consistent functional settings should be used for the
bulk DOS and defect supercell calculations. See the
:ref:`Tips:Density of States (DOS) Calculations` tips section.
(Default: None)
skip_dos_check (bool):
Whether to skip the warning about the DOS VBM differing from
the defect entries VBM by >0.05 eV. Should only be used when
the reason for this difference is known/acceptable. Default is
``False`` (don't skip check).
Key Attributes:
defect_entries (dict[str, |DefectEntry|]):
Dict of |DefectEntry| objects included in the
|DefectThermodynamics| set, with their names as keys.
chempots (dict):
Dictionary of chemical potentials to use for calculating the
defect formation energies (and hence concentrations etc.), in
the ``doped`` format.
el_refs (dict):
Dictionary of elemental reference energies for the chemical
potentials.
vbm (float):
VBM energy to use as Fermi level reference point for analysis.
band_gap (float):
Band gap of the host, to use for analysis.
dist_tol (float):
Distance threshold (in Å) to use for clustering (equivalent)
defect sites (for plotting, transition level analysis and
defect concentration calculations).
transition_levels (dict):
Dictionary of charge transition levels for each defect entry.
(e.g. ``{defect_name: {charge: transition_level}}``).
check_compatibility (bool):
Whether to check the compatibility of the bulk entry for each
defect entry (i.e. that all reference bulk energies are the
same).
bulk_formula (str):
The reduced formula of the bulk structure (e.g. "CdTe").
bulk_dos (FermiDos):
``pymatgen`` ``FermiDos`` for the bulk electronic density of
states (DOS), used for calculating Fermi level positions and
defect/carrier concentrations.
skip_dos_check (bool):
Whether to skip the warning about the DOS VBM differing from
the defect entries VBM by >0.05 eV. Should only be used when
the reason for this difference is known/acceptable.
clustered_defect_entries (dict[int, set[|DefectEntry|]]):
Dictionary of defect entries clustered according to the
``dist_tol`` distance tolerance (between symmetry-equivalent
sites). This is used to identify defects which occupy similar
sites, which is then used in defect concentration calculations
for determining site competition.
clustered_defect_entries_by_type (dict[str, dict[int, set[|DefectEntry|]]]):
Dictionary of defect entries clustered according to the
``dist_tol`` distance tolerance (between symmetry-equivalent
sites), grouped by defect type (i.e. simple defect name, e.g.
``Te_i`` for ``Te_i_Td_Cd2.83_+2``). This is used to group
together different defect entries (different charge states,
ground and metastable states (e.g. different spin or
geometries)) which correspond to the same defect type (e.g.
``Te_i``) and occupy similar sites, which is then used in
plotting and transition level analysis.
"""
if not defect_entries:
raise ValueError(
"No defects found in `defect_entries`. Please check the supplied dictionary is in the "
"correct format (i.e. {'defect_name': defect_entry}), or as a list: [defect_entry]."
)
if isinstance(defect_entries, list):
defect_entries = {entry.name: entry for entry in defect_entries}
# get and check VBM/bandgap values first; used in defect entry setting
def _raise_VBM_band_gap_value_error(vals, type="VBM"):
raise ValueError(
f"{type} values for defects in `defect_dict` do not match within 0.05 eV of each other, "
f"and so are incompatible for thermodynamic analysis with DefectThermodynamics. The "
f"{type} values in the dictionary are: {vals}. You should recheck the correct/same bulk "
f"files were used when parsing. If this is acceptable, you can instead manually specify "
f"{type} in DefectThermodynamics initialisation."
)
self.vbm = vbm
self.band_gap = band_gap
if self.vbm is None or self.band_gap is None:
vbm_vals: list[float] = [
defect_entry.calculation_metadata["vbm"]
for defect_entry in defect_entries.values()
if defect_entry.calculation_metadata.get("vbm") is not None
]
band_gap_vals: list[float] = [
val
for defect_entry in defect_entries.values()
for val in (
defect_entry.calculation_metadata.get("band_gap"),
defect_entry.calculation_metadata.get("gap"),
)
if val is not None
]
# get the max difference in VBM & band_gap vals:
if vbm_vals and self.vbm is None:
if max(vbm_vals) - min(vbm_vals) > 0.05:
_raise_VBM_band_gap_value_error(vbm_vals, type="VBM")
self.vbm = next(iter(vbm_vals))
if band_gap_vals and self.band_gap is None:
if max(band_gap_vals) - min(band_gap_vals) > 0.05:
_raise_VBM_band_gap_value_error(band_gap_vals, type="band_gap")
self.band_gap = next(iter(band_gap_vals))
for i, name in [(self.vbm, "VBM eigenvalue"), (self.band_gap, "band gap value")]:
if i is None:
raise ValueError(
f"No {name} was supplied or able to be parsed from the defect entries "
f"(calculation_metadata attributes). Please specify the {name} in the function input."
)
# all required to be set before parsing defect entries:
self.check_compatibility = check_compatibility
self._chempots, self._el_refs = _parse_chempots(chempots, el_refs, update_el_refs=True)
self._dist_tol = dist_tol
# defect_entries setter runs ``self._sort_parse_and_check_entries()``, to order entries for
# deterministic behaviour; particularly important for plotting and tabulation
self.defect_entries = defect_entries
self.skip_dos_check = skip_dos_check
self.bulk_dos = bulk_dos # use setter method, needs to be after setting VBM
bulk_entry = next(iter(self.defect_entries.values())).bulk_entry
if bulk_entry is not None:
self.bulk_formula = bulk_entry.structure.composition.get_reduced_formula_and_factor(
iupac_ordering=True
)[0]
else:
self.bulk_formula = None
def _sort_parse_and_check_entries(self) -> None:
"""
Sort the defect entries, parse the transition levels, and check the
compatibility of the bulk entries (if ``self.check_compatibility`` is
``True``).
"""
defect_entries_dict: dict[str, DefectEntry] = {}
for entry in self.defect_entries.values():
# rename defect entry names in dict if necessary ("_a", "_b"...)
(
entry_name,
[
defect_entries_dict,
],
) = _rename_key_and_dicts(
entry.name,
[
defect_entries_dict,
],
)
defect_entries_dict[entry_name] = entry
sorted_defect_entries_dict = sort_defect_entries(defect_entries_dict)
self._defect_entries = sorted_defect_entries_dict
self._parse_transition_levels() # cluster defects and determine transition levels
if self.check_compatibility:
self._check_bulk_compatibility()
self._check_bulk_chempots_compatibility(self._chempots)
[docs]
def as_dict(self):
"""
Returns:
JSON-serializable dict representation of |DefectThermodynamics|.
"""
return {
"@module": type(self).__module__,
"@class": type(self).__name__,
"defect_entries": self.defect_entries,
"chempots": self.chempots,
"el_refs": self.el_refs,
"vbm": self.vbm,
"band_gap": self.band_gap,
"dist_tol": self.dist_tol,
"check_compatibility": self.check_compatibility,
"bulk_formula": self.bulk_formula,
"bulk_dos": self.bulk_dos,
"skip_dos_check": self.skip_dos_check,
}
[docs]
@classmethod
def from_dict(cls, d):
"""
Reconstitute a |DefectThermodynamics| object from a dict representation
created using ``as_dict()``.
Args:
d (dict): dict representation of |DefectThermodynamics|.
Returns:
|DefectThermodynamics| object
"""
with warnings.catch_warnings():
warnings.filterwarnings(
"ignore", "Use of properties is"
) # `message` only needs to match start of message
def _get_defect_entry(entry_dict):
if isinstance(entry_dict, DefectEntry):
return entry_dict
return DefectEntry.from_dict(entry_dict)
if isinstance(d.get("defect_entries"), list):
d["defect_entries"] = [
_get_defect_entry(entry_dict) for entry_dict in d.get("defect_entries")
]
else:
d["defect_entries"] = {
name: _get_defect_entry(entry_dict)
for name, entry_dict in d.get("defect_entries").items()
}
return cls(
defect_entries=d.get("defect_entries"),
chempots=d.get("chempots"),
el_refs=d.get("el_refs"),
vbm=d.get("vbm"),
band_gap=d.get("band_gap"),
dist_tol=d.get("dist_tol", 1.5),
check_compatibility=d.get("check_compatibility", True),
bulk_dos=(
FermiDos.from_dict(d["bulk_dos"])
if isinstance(d.get("bulk_dos"), dict)
else d.get("bulk_dos")
),
skip_dos_check=d.get("skip_dos_check", False),
)
[docs]
def to_json(self, filename: PathLike | None = None):
"""
Save the |DefectThermodynamics| object as a json file, which can be
reloaded with the ``DefectThermodynamics.from_json()`` class method.
Note that file extensions with ".gz" will be automatically compressed
(recommended to save space)!
Args:
filename (PathLike):
Filename to save json file as. If ``None``, the filename will
be set as ``{Chemical Formula}_defect_thermodynamics.json.gz``
where ``{Chemical Formula}`` is the chemical formula of the
host material.
"""
if filename is None:
if self.bulk_formula is not None:
filename = f"{self.bulk_formula}_defect_thermodynamics.json.gz"
else:
filename = "defect_thermodynamics.json.gz"
dumpfn(self, filename)
[docs]
@classmethod
def from_json(cls, filename: PathLike):
"""
Load a |DefectThermodynamics| object from a json(.gz) file.
Note that ``.json.gz`` files can be loaded directly.
Args:
filename (PathLike):
Filename of json file to load |DefectThermodynamics|
object from.
Returns:
|DefectThermodynamics| object
"""
return loadfn(filename)
def _get_chempots(
self, chempots: dict | None = None, el_refs: dict | None = None
) -> tuple[dict | None, dict | None]:
"""
Parse chemical potentials, either using input values (after formatting
them in the ``doped`` format) or using the class attributes if set.
"""
if isinstance(chempots, dict) and "elemental_refs" in chempots and el_refs is None:
# doped chempot dict input, use its elemental refs
chempots, el_refs = _parse_chempots(chempots, chempots["elemental_refs"])
else: # use stored or provided el_refs
chempots, el_refs = _parse_chempots(
chempots or self.chempots, el_refs or self.el_refs, update_el_refs=True
)
if self.check_compatibility:
self._check_bulk_chempots_compatibility(chempots)
return chempots, el_refs
def _parse_transition_levels(self, symprec: float = 0.1):
r"""
Parses the charge transition levels for defect entries in the
|DefectThermodynamics| object, and stores information about the stable
charge states, transition levels etc.
Defect entries of the same type (e.g. ``Te_i``, ``v_Cd``) are grouped
together (for plotting and transition level analysis) based on the
minimum distance between (equivalent) defect sites, controlled by
``dist_tol`` (1.5 Å by default), to distinguish between different
inequivalent sites. Each defect group is then named according to the
most common name (without charge) for defects in that cluster, with
shorter defect names (and lower energies) preferred if this is not a
unique choice.
Code for parsing the transition levels was originally templated from
the pyCDT (pymatgen<=2022.7.25) thermodynamics code (deleted in later
versions).
This function uses ``scipy``\'s ``HalfspaceIntersection`` to construct
the polygons corresponding to defect stability as a function of the
Fermi-level. The Halfspace Intersection constructs N-dimensional
hyperplanes, in this case N=2, based on the equation of defect
formation energy with considering chemical potentials:
E_form = E_0^{Corrected} + Q_{defect}*(E_{VBM} + E_{Fermi}).
Extra hyperplanes are constructed to bound this space so that the
algorithm can actually find enclosed region. This code was modeled
after the Halfspace Intersection code for the Pourbaix Diagram.
Note: The ``get_min_dist_between_equiv_sites`` function in
``doped.utils.symmetry`` can be useful for analysing the inter-defect
distances and resulting clustering behaviour.
Args:
symprec (float):
Symmetry precision for finding equivalent sites in the bulk
supercell, for site clustering. Default is 0.1 (matching
``doped`` default for point symmetry determination for relaxed
defect supercells).
"""
# determine defect charge transition levels:
with warnings.catch_warnings(): # ignore formation energies chempots warning when just parsing TLs
warnings.filterwarnings("ignore", message="No chemical potentials")
midgap_formation_energies = [ # without chemical potentials
entry.formation_energy(
fermi_level=0.5 * (self.band_gap if self.band_gap is not None else 0),
vbm=entry.calculation_metadata.get("vbm", self.vbm),
)
for entry in self.defect_entries.values()
]
# note that in general, we have chosen to favour ``entry.calculation_metadata.get("vbm")`` over
# ``self.vbm`` for all calculations of formation energies / transition levels / concentrations.
# These values should be the same, and we have warnings if they differ by too much,
# but in general the |DefectEntry| value should be the most reliable, as this is tied to its
# raw supercell energy difference from the chosen bulk cell for that |DefectEntry|.
# but we do use ``self.band_gap`` preferably as this only affects the plot ranges (rather than
# formation energies)
# set range to {min E_form - 30, max E_form +30} eV for y (formation energy), and
# {VBM - 1, CBM + 1} eV for x (fermi level)
min_y_lim = min(midgap_formation_energies) - 30
max_y_lim = max(midgap_formation_energies) + 30
limits = [[-1, (self.band_gap or 0) + 1], [min_y_lim, max_y_lim]]
stable_entries: dict[str, list[DefectEntry]] = {}
defect_charge_map: dict[str, list[int]] = {}
transition_level_map: dict[str, dict[float, list[int]]] = {}
all_entries: dict[str, list[DefectEntry]] = {} # same format as stable_entries, but incl unstable
try:
self.clustered_defect_entries: dict[int, set[DefectEntry]] | dict[str, set[DefectEntry]] = (
group_defects_by_distance(
list(self.defect_entries.values()), dist_tol=self.dist_tol, symprec=symprec
)
) # {cluster index: {DefectEntry, ...}}; dict[int, set[DefectEntry]]
# inner key is a cluster index (int) here, but a defect name (str) in the fallback below:
self.clustered_defect_entries_by_type: dict[str, Any] = group_defects_by_type_and_distance(
list(self.defect_entries.values()), dist_tol=self.dist_tol, symprec=symprec
) # {simple defect name: {cluster index: {DefectEntry, ...}}};
# dict[str, dict[int, set[DefectEntry]]]
except Exception as e:
self.clustered_defect_entries = group_defects_by_name(
list(self.defect_entries.values())
) # {defect name without charge: [DefectEntry,...]}; dict[str, set[DefectEntry]]
self.clustered_defect_entries_by_type = {
entry.defect.name: defaultdict(set) for entry in self.defect_entries.values()
} # {simple defect name: {defect name without charge: {DefectEntry, ...}}};
# dict[str, dict[str, set[DefectEntry]]]
for defect_name_wout_charge, defect_entry_set in self.clustered_defect_entries.items():
self.clustered_defect_entries_by_type[next(iter(defect_entry_set)).defect.name][
defect_name_wout_charge
] = defect_entry_set
warnings.warn(
f"Grouping (inequivalent) defects by distance failed with error: {e!r}"
f"\nGrouping by defect names (`DefectEntry.name`) instead."
) # possibly different bulks (though this should be caught/warned about earlier), or not
# parsed with recent doped versions etc
grouped_entries_list: list[list[DefectEntry]] = list(
chain(*map(methodcaller("values"), self.clustered_defect_entries_by_type.values()))
) # [[DefectEntry, ...], ...]
for grouped_defect_entries in grouped_entries_list:
sorted_defect_entries = sorted(
grouped_defect_entries, key=lambda x: (-x.charge_state, x.get_ediff(), x.name)
) # sort by charge (most positive first, following left-to-right order in formation energy
# diagrams), and then energy (for those of the same charge) for ordering in output plots/dfs
# prepping coefficient matrix for half-space intersection
# [-Q, 1, -1*(E_form+Q*VBM)] -> -Q*E_fermi+E+-1*(E_form+Q*VBM) <= 0 where E_fermi and E are
# the variables in the hyperplanes
hyperplanes = np.array(
[
[
-1.0 * entry.charge_state,
1,
-1.0
* (
entry.get_ediff()
+ entry.charge_state
* float(entry.calculation_metadata.get("vbm", self.vbm or 0))
),
]
for entry in sorted_defect_entries
]
)
border_hyperplanes = [
[-1, 0, limits[0][0]],
[1, 0, -1 * limits[0][1]],
[0, -1, limits[1][0]],
[0, 1, -1 * limits[1][1]],
]
hs_hyperplanes = np.vstack([hyperplanes, border_hyperplanes])
interior_point = [(self.band_gap or 0) / 2, min(midgap_formation_energies) - 1.0]
hs_ints = HalfspaceIntersection(hs_hyperplanes, np.array(interior_point))
# Group the intersections and corresponding facets
ints_and_facets_zip = zip(hs_ints.intersections, hs_ints.dual_facets, strict=False)
# Only include the facets corresponding to entries, not the boundaries
total_entries = len(sorted_defect_entries)
ints_and_facets_filter = filter(
lambda int_and_facet: all(np.array(int_and_facet[1]) < total_entries),
ints_and_facets_zip,
)
# sort based on transition level
ints_and_facets_list = sorted(
ints_and_facets_filter, key=lambda int_and_facet: int_and_facet[0][0]
)
defect_name_wout_charge = name_defect_cluster(sorted_defect_entries)
defect_name_wout_charge, output_dicts = _rename_key_and_dicts(
defect_name_wout_charge,
[transition_level_map, all_entries, stable_entries, defect_charge_map],
)
transition_level_map, all_entries, stable_entries, defect_charge_map = output_dicts
if len(ints_and_facets_list) > 0: # unpack into lists
_, facets = zip(*ints_and_facets_list, strict=False)
transition_level_map[defect_name_wout_charge] = { # map of transition level: charge states
intersection[0]: sorted(
[sorted_defect_entries[i].charge_state for i in facet], reverse=True
)
for intersection, facet in ints_and_facets_list
}
stable_entries[defect_name_wout_charge] = sorted(
{sorted_defect_entries[i] for dual in facets for i in dual},
key=lambda x: (-x.charge_state, x.get_ediff(), len(x.name)),
)
defect_charge_map[defect_name_wout_charge] = sorted(
[entry.charge_state for entry in sorted_defect_entries], reverse=True
)
elif len(sorted_defect_entries) == 1:
transition_level_map[defect_name_wout_charge] = {}
stable_entries[defect_name_wout_charge] = [sorted_defect_entries[0]]
defect_charge_map[defect_name_wout_charge] = [sorted_defect_entries[0].charge_state]
else: # if ints_and_facets is empty, then there is likely only one defect...
# confirm formation energies dominant for one defect over other identical defects
name_set = [entry.name for entry in sorted_defect_entries]
with warnings.catch_warnings(): # ignore chempots warning when just parsing TLs
warnings.filterwarnings("ignore", message="No chemical potentials")
vb_list = [
entry.formation_energy(
fermi_level=limits[0][0], vbm=entry.calculation_metadata.get("vbm", self.vbm)
)
for entry in sorted_defect_entries
]
cb_list = [
entry.formation_energy(
fermi_level=limits[0][1], vbm=entry.calculation_metadata.get("vbm", self.vbm)
)
for entry in sorted_defect_entries
]
vbm_def_index = vb_list.index(min(vb_list))
name_stable_below_vbm = name_set[vbm_def_index]
cbm_def_index = cb_list.index(min(cb_list))
name_stable_above_cbm = name_set[cbm_def_index]
if name_stable_below_vbm != name_stable_above_cbm:
raise ValueError(
f"HalfSpace identified only one stable charge out of list: {name_set}\n"
f"But {name_stable_below_vbm} is stable below vbm and "
f"{name_stable_above_cbm} is stable above cbm.\nList of VBM formation "
f"energies: {vb_list}\nList of CBM formation energies: {cb_list}"
)
transition_level_map[defect_name_wout_charge] = {}
stable_entries[defect_name_wout_charge] = [sorted_defect_entries[vbm_def_index]]
defect_charge_map[defect_name_wout_charge] = sorted(
[entry.charge_state for entry in sorted_defect_entries], reverse=True
)
all_entries[defect_name_wout_charge] = sorted_defect_entries
self.transition_level_map = transition_level_map
self.stable_entries = stable_entries
self.all_entries = all_entries
self.defect_charge_map = defect_charge_map
# sort dictionaries deterministically:
self.transition_level_map = dict(
sorted(self.transition_level_map.items(), key=lambda item: self._map_sort_func(item[0]))
)
self.stable_entries = dict(
sorted(self.stable_entries.items(), key=lambda item: self._map_sort_func(item[0]))
)
self.all_entries = dict(
sorted(self.all_entries.items(), key=lambda item: self._map_sort_func(item[0]))
)
self.defect_charge_map = dict(
sorted(self.defect_charge_map.items(), key=lambda item: self._map_sort_func(item[0]))
)
self.transition_levels = {
defect_name: list(defect_tls.keys())
for defect_name, defect_tls in transition_level_map.items()
}
self.stable_charges = {
defect_name: [entry.charge_state for entry in entries]
for defect_name, entries in stable_entries.items()
}
def _map_sort_func(self, name_wout_charge):
"""
Convenience sorting function for dictionaries in and outputs from
|DefectThermodynamics|.
"""
for i in range(name_wout_charge.count("_") + 1): # number of underscores in name
split_name = name_wout_charge.rsplit("_", i)[0]
if indices := [ # find first occurrence of name_wout_charge in defect_entries
i for i, name in enumerate(self._defect_entries.keys()) if name.startswith(split_name)
]:
return min(indices), split_name
return 100, split_name # if name not in defect_entries, put at end
def _check_bulk_compatibility(self):
"""
Helper function to quickly check if all entries have compatible bulk
calculation settings, by checking that the bulk energy per atom is the
same for all defect entries.
By proxy checks that same bulk/defect calculation settings were used in
all cases, from each bulk/defect combination already being checked when
parsing. This is to catch any cases where defects may have been parsed
separately and combined (rather than altogether with |DefectsParser|,
which ensures the same bulk in each case), and where a different bulk
reference calculation was (mistakenly) used.
In some cases, mismatches here are ok -- e.g. if SOC is known to be
important for just one defect state, so one uses a SOC bulk reference
for this state but not for the others, but this is expected to be a
rare use case.
"""
bulk_energies_per_atom = [
entry.bulk_entry.energy / entry.bulk_entry.composition.num_atoms
for entry in self.defect_entries.values()
]
if max(bulk_energies_per_atom) - min(bulk_energies_per_atom) > 0.001: # 1 meV/atom tolerance
warnings.warn(
f"Note that not all defects in `defect_entries` have the same reference bulk energy per "
f"atom, with energies differing by >1 meV/atom. This may be intentional (e.g. if "
f"combining SOC and non-SOC defect/bulk calculations), but otherwise can lead to "
f"inaccuracies in predicted formation energies if mixing of inequivalent bulk references "
f"is unintendend! The bulk energies per atom of defect entries in `defect_entries` are:\n"
f"{list(zip(self.defect_entries.keys(), bulk_energies_per_atom, strict=False))}\n"
f"You can suppress this warning by setting "
f"``DefectThermodynamics.check_compatibility = False``."
)
def _check_bulk_defects_compatibility(self):
"""
Helper function to quickly check if all entries have compatible
defect/bulk calculation settings.
Currently not used, as the bulk/defect compatibility is checked when
parsing, and the compatibility across bulk calculations is checked with
``_check_bulk_compatibility()``.
"""
# check each defect entry against its own bulk, and also check each bulk against each other
reference_defect_entry = next(iter(self.defect_entries.values()))
reference_run_metadata = reference_defect_entry.calculation_metadata["run_metadata"]
for defect_entry in self.defect_entries.values():
with warnings.catch_warnings(record=True) as captured_warnings:
run_metadata = defect_entry.calculation_metadata["run_metadata"]
# compare defect and bulk:
_compare_incar_tags(run_metadata["defect_incar"], run_metadata["bulk_incar"])
_compare_potcar_symbols(
run_metadata["defect_potcar_symbols"], run_metadata["bulk_potcar_symbols"]
)
_compare_kpoints(
run_metadata["defect_actual_kpoints"],
run_metadata["bulk_actual_kpoints"],
run_metadata["defect_kpoints"],
run_metadata["bulk_kpoints"],
)
# compare bulk and reference bulk:
_compare_incar_tags(
run_metadata["bulk_incar"],
reference_run_metadata["bulk_incar"],
defect_name=f"other bulk (for {reference_defect_entry.name})",
)
_compare_potcar_symbols(
run_metadata["bulk_potcar_symbols"],
reference_run_metadata["bulk_potcar_symbols"],
defect_name=f"other bulk (for {reference_defect_entry.name})",
)
_compare_kpoints(
run_metadata["defect_actual_kpoints"],
reference_run_metadata["bulk_actual_kpoints"],
run_metadata["defect_kpoints"],
reference_run_metadata["bulk_kpoints"],
defect_name=f"other bulk (for {reference_defect_entry.name})",
)
if captured_warnings:
concatenated_warnings = "\n".join(str(warning.message) for warning in captured_warnings)
warnings.warn(
f"Incompatible defect/bulk calculation settings detected for defect "
f"{defect_entry.name}: \n{concatenated_warnings}"
)
def _get_bulk_comp_and_energy_per_atom_mode_values(self) -> tuple[Composition, float]:
"""
Helper function to get the mode values of the bulk composition and
energy per atom for the defect entries in this |DefectThermodynamics|
object.
Returns:
tuple:
The mode values of the bulk composition and energy per atom for
the defect entries in this |DefectThermodynamics| object, as a
tuple of (bulk_comp, bulk_energy_per_atom).
"""
bulk_comps: list[Composition] = []
bulk_energies_per_atom: list[float] = []
for entry in self._defect_entries.values():
bulk = entry.bulk_entry
comp = bulk.composition
bulk_comps.append(comp)
bulk_energies_per_atom.append(bulk.energy / comp.num_atoms)
return statistics.mode(bulk_comps), statistics.mode(bulk_energies_per_atom)
def _check_bulk_chempots_compatibility(self, chempots: dict | None = None):
r"""
Helper function to quickly check if the supplied chemical potentials
dictionary matches the bulk supercell used for the defect calculations,
by comparing the raw energies per atom (from the bulk supercell
calculation, and that of the chemical potentials supplied).
In some cases, mismatches here are ok -- e.g. if SOC is used for a
defect state but not for the chemical potentials (for a well-founded
reason, in most cases the same settings should be used;
https://doi.org/10.1038/s41578-025-00879-y).
Args:
chempots (dict, optional):
Dictionary of chemical potentials to check compatibility with
the bulk supercell calculations (``DefectEntry.bulk_entry``\s),
in the ``doped`` format.
If ``None`` (default), will use ``self.chempots`` (= 0 for all
chemical potentials by default). This can have the form of
``{"limits": [{'limit': [chempot_dict]}]}`` (the format
generated by ``doped``\'s chemical potential parsing functions
(see tutorials)), or alternatively a dictionary of chemical
potentials for a single limit (``limit``), in the format:
``{element symbol: chemical potential}``.
If manually specifying chemical potentials this way, you can
set the ``el_refs`` option with the DFT reference energies of
the elemental phases, in which case it is the formal chemical
potentials (i.e. relative to the elemental references) that
should be given here, otherwise the absolute (DFT) chemical
potentials should be given.
"""
if chempots is None and self.chempots is None:
return
if not hasattr(self, "_bulk_comp_mode") or not hasattr(self, "_bulk_energy_per_atom_mode"):
self._bulk_comp_mode, self._bulk_energy_per_atom_mode = (
self._get_bulk_comp_and_energy_per_atom_mode_values()
)
bulk_chempot_energy_per_atom = (
raw_energy_from_chempots(self._bulk_comp_mode, chempots or self.chempots)
/ self._bulk_comp_mode.num_atoms
)
if abs(self._bulk_energy_per_atom_mode - bulk_chempot_energy_per_atom) > 0.025:
warnings.warn( # 0.05 eV intrinsic defect formation energy error tolerance, taking per-atom
# chempot error and multiplying by 2 to account for how this would affect antisite
# formation energies (extreme case)
f"Note that the raw (DFT) energy of the bulk supercell calculation "
f"({self._bulk_energy_per_atom_mode:.2f} eV/atom) differs from that expected from the "
f"supplied chemical potentials ({bulk_chempot_energy_per_atom:.2f} eV/atom) by >0.025 eV. "
f"In some rare cases this might be expected (if intentionally using different defect / "
f"chemical potential settings for well-founded reasons), but otherwise will give "
f"inaccuracies of similar magnitude in the predicted formation energies!"
f"\nYou can suppress this warning by setting "
f"``DefectThermodynamics.check_compatibility = False``."
)
[docs]
def add_entries(
self,
defect_entries: dict[str, DefectEntry] | list[DefectEntry],
check_compatibility: bool = True,
):
"""
Add additional defect entries to the |DefectThermodynamics| object.
Args:
defect_entries ({str: |DefectEntry|} or [|DefectEntry|]):
A dict or list of |DefectEntry| objects, to add to the
``DefectThermodynamics.defect_entries`` dict. If a
``DefectEntry.name`` attribute is not defined or does not end
with the charge state (as ``..._{charge state}``), then the
entry will be renamed with the ``doped`` default name.
check_compatibility (bool):
Whether to check the compatibility of the bulk entry for each
defect entry (i.e. that all reference bulk energies are the
same). Default is ``True``.
"""
self.check_compatibility = check_compatibility
if not defect_entries:
raise ValueError(
"No defects found in `defect_entries`. Please check the supplied dictionary is in the "
"correct format (i.e. {'defect_name': defect_entry}), or as a list: [defect_entry]."
)
if isinstance(defect_entries, list): # append 'pre_formatting' so we don't overwrite any existing
defect_entries = {f"{entry.name}_pre_formatting": entry for entry in defect_entries}
self.defect_entries = {**self._defect_entries, **defect_entries} # add new entries and format
@property
def defect_entries(self):
"""
Get the dict of parsed |DefectEntry| objects in the
|DefectThermodynamics| analysis object.
"""
return self._defect_entries
@defect_entries.setter
def defect_entries(self, input_defect_entries):
r"""
Set the dict of parsed |DefectEntry|\s to include in the
|DefectThermodynamics| object, and reparse the thermodynamic
information (transition levels etc).
"""
self._defect_entries = input_defect_entries
if self.check_compatibility:
# in ``_check_bulk_chempots_compatibility`` we compare the bulk energy per atom using the mode
# (most common) value of bulk composition across defect entries. ``statistics.mode`` is costly
# when run many times for many entries, so we cache the result, and only update when
# ``defect_entries`` is updated (and ``self.check_compatibility`` is ``True``, such that
# ``_check_bulk_chempots_compatibility()`` will be called):
self._bulk_comp_mode, self._bulk_energy_per_atom_mode = (
self._get_bulk_comp_and_energy_per_atom_mode_values()
)
self._sort_parse_and_check_entries()
@property
def chempots(self):
r"""
Get the chemical potentials dictionary used for calculating the defect
formation energies.
``chempots`` is a dictionary of chemical potentials to use for
calculating the defect formation energies, in the form of:
``{"limits": [{'limit': [chempot_dict]}]}`` (the format generated by
``doped``\'s chemical potential parsing functions (see tutorials))
which allows easy analysis over a range of chemical potentials -- where
limit(s) (chemical potential limit(s)) to analyse/plot can later be
chosen using the ``limits`` argument.
"""
return self._chempots
@chempots.setter
def chempots(self, input_chempots):
r"""
Set the chemical potentials dictionary (``chempots``), and reparse to
have the required ``doped`` format.
``chempots`` is a dictionary of chemical potentials to use for
calculating the defect formation energies, in the form of:
``{"limits": [{'limit': [chempot_dict]}]}`` (the format generated by
``doped``\'s chemical potential parsing functions (see tutorials))
which allows easy analysis over a range of chemical potentials -- where
limit(s) (chemical potential limit(s)) to analyse/plot can later be
chosen using the ``limits`` argument.
Alternatively this can be a dictionary of chemical potentials for a
single limit, in the format: ``{element symbol: chemical potential}``.
If manually specifying chemical potentials this way, you can set the
``el_refs`` option with the DFT reference energies of the elemental
phases in order to show the formal (relative) chemical potentials above
the formation energy plot, in which case it is the formal chemical
potentials (i.e. relative to the elemental references) that should be
given here, otherwise the absolute (DFT) chemical potentials should be
given.
If ``None`` (default), sets all formal chemical potentials (i.e.
relative to the elemental reference energies in ``el_refs``) to zero.
Chemical potentials can also be supplied later in each analysis
function. (Default: None)
"""
if isinstance(input_chempots, dict) and "elemental_refs" in input_chempots:
# doped chempot dict input, use its el_refs
self._chempots, self._el_refs = _parse_chempots(
input_chempots, input_chempots.get("elemental_refs")
)
else:
self._chempots, self._el_refs = _parse_chempots(
input_chempots, self._el_refs, update_el_refs=False
)
if self.check_compatibility:
self._check_bulk_chempots_compatibility(self._chempots)
@property
def el_refs(self):
"""
Get the elemental reference energies for the chemical potentials.
This is in the form of a dictionary of elemental reference energies for
the chemical potentials, as: ``{element symbol: reference energy}``.
"""
return self._el_refs
@el_refs.setter
def el_refs(self, input_el_refs):
"""
Set the elemental reference energies for the chemical potentials
(``el_refs``), and reparse to have the required ``doped`` format.
This is in the form of a dictionary of elemental reference energies for
the chemical potentials, in the format:
``{element symbol: reference energy}``, and is used to determine the
formal chemical potentials, when ``chempots`` has been manually
specified as ``{element symbol: chemical potential}``. Unnecessary if
``chempots`` is provided in format generated by ``doped`` (see
tutorials).
"""
self._chempots, self._el_refs = _parse_chempots(self._chempots, input_el_refs, update_el_refs=True)
@property
def bulk_dos(self):
"""
Get the ``pymatgen`` ``FermiDos`` for the bulk electronic density of
states (DOS), for calculating Fermi level positions and defect/carrier
concentrations, if set.
Otherwise, returns ``None``.
"""
return self._bulk_dos
@bulk_dos.setter
def bulk_dos(self, input_bulk_dos: FermiDos | Vasprun | PathLike):
r"""
Set the ``pymatgen`` ``FermiDos`` for the bulk electronic density of
states (DOS), for calculating Fermi level positions and defect/carrier
concentrations.
Should be a ``pymatgen`` ``FermiDos`` for the bulk electronic DOS, a
``pymatgen`` |Vasprun| object or path to the ``vasprun.xml(.gz)``
output of a bulk DOS calculation in VASP. Can also be provided later
when using ``get_equilibrium_fermi_level()``,
``get_fermi_level_and_concentrations`` etc.
Usually this is a static calculation with the `primitive` cell of the
bulk material, with relatively dense `k`-point sampling (especially for
materials with disperse band edges) to ensure an accurately-converged
DOS and thus Fermi level. Using large ``NEDOS`` (>3000) and
``ISMEAR = -5`` (tetrahedron smearing) are recommended for best
convergence (wrt `k`-point sampling) in VASP. Consistent functional
settings should be used for the bulk DOS and defect supercell
calculations. See the
:ref:`Tips:Density of States (DOS) Calculations` section.
"""
self._bulk_dos = self._parse_fermi_dos(input_bulk_dos, skip_dos_check=self.skip_dos_check)
@property
def defect_names(self):
"""
List of names of defects in the |DefectThermodynamics| set.
"""
return list(self.defect_charge_map.keys())
@property
def all_stable_entries(self):
"""
List all stable entries (defect + charge) in the |DefectThermodynamics|
set.
"""
return list(chain.from_iterable(self.stable_entries.values()))
@property
def all_unstable_entries(self):
"""
List all unstable entries (defect + charge) in the
|DefectThermodynamics| set.
"""
return [e for e in self.defect_entries.values() if e not in self.all_stable_entries]
@property
def unstable_entries(self):
"""
Dictionary of unstable entries in the |DefectThermodynamics| set, as
``{defect name without charge: [list of DefectEntry objects]}``.
"""
return {
k: [entry for entry in v if entry not in self.stable_entries[k]]
for k, v in self.all_entries.items()
}
@property
def dist_tol(self):
r"""
Distance threshold (in Å) to use for clustering (equivalent) defect
sites (for plotting, transition level analysis and defect concentration
calculations; see ``plot()`` and
``get_fermi_level_and_concentrations()`` for more information). See
``group_defects_by_distance()`` and
``group_defects_by_type_and_distance()`` for more information on
clustering strategies -- ``self.clustered_defect_entries`` and/or
``self.clustered_defect_entries_by_type`` can also be manually set with
these functions for greater control.
With default settings, |DefectEntry|\s `of the same type` and with
distances between equivalent defect sites less than ``dist_tol`` (1.5 Å
by default) are `mostly` grouped together. If a |DefectEntry|\'s site
has a distance less than ``dist_tol`` to multiple sets of equivalent
sites, then it should be matched to the one with the lowest minimum
distance. Each defect group is then named according to the most common
name (without charge) for defects in that cluster, with shorter defect
names (and lower energies) preferred if this is not a unique choice.
This is used to group together different defect entries (different
charge states, and/or ground and metastable states (different spin or
geometries)) which correspond to the same defect type (e.g.
interstitials at a given site), which is then used in plotting,
transition level analysis and defect concentration calculations; e.g.
in the frozen defect approximation, the total concentration of a given
defect type group is calculated at the annealing temperature, and then
the equilibrium relative population of the constituent entries is
recalculated at the quenched temperature.
``dist_tol`` is also used to cluster defects of `all types` when
determining site competition in defect concentration calculations.
"""
return self._dist_tol
@dist_tol.setter
def dist_tol(self, input_dist_tol: float):
r"""
Set the ``dist_tol`` attribute value, and re-parse defect clusters and
derived information with this new distance tolerance.
"""
self._dist_tol = input_dist_tol
self._parse_transition_levels() # re-group and parse based on new dist_tol
def _single_formation_energy_table(
self,
relative_chempots: dict,
el_refs: dict,
fermi_level: float = 0,
skip_formatting: bool = False,
) -> pd.DataFrame:
r"""
Returns a defect formation energy table for a single chemical potential
limit as a pandas ``DataFrame``.
See ``get_formation_energies()`` for the table key.
Args:
relative_chempots (dict):
Dictionary of formal (i.e. relative to elemental reference
energies) chemical potentials in the form
``{element symbol: energy}``.
el_refs (dict):
Dictionary of elemental reference energies for the chemical
potentials in the format:
``{element symbol: reference energy}``.
(Default: None)
fermi_level (float):
Value corresponding to the electron chemical potential,
referenced to the VBM eigenvalue, which is taken from the
``calculation_metadata`` dict attributes of |DefectEntry|\s
in ``self.defect_entries`` if present, otherwise ``self.vbm``
-- which corresponds to the VBM of the `bulk supercell`
calculation by default, unless ``bulk_band_gap_vr`` is set
during defect parsing). If ``None`` (default), set to the
mid-gap Fermi level (E_g/2).
skip_formatting (bool):
Whether to skip formatting the defect charge states as
strings (and keep as ``int``\s and ``float``\s instead).
(default: ``False``)
Returns:
``pandas`` ``DataFrame`` sorted by formation energy
"""
table = []
for name, defect_entry in self.defect_entries.items():
row = [
name.rsplit("_", 1)[0], # name without charge,
(
defect_entry.charge_state
if skip_formatting
else f"{'+' if defect_entry.charge_state > 0 else ''}{defect_entry.charge_state}"
),
]
row += [defect_entry.get_ediff() - sum(defect_entry.corrections.values())]
row += [defect_entry.charge_state * defect_entry.calculation_metadata.get("vbm", self.vbm)]
row += [defect_entry.charge_state * fermi_level]
row += [defect_entry._get_chempot_term(el_refs) if any(el_refs.values()) else "N/A"]
row += [defect_entry._get_chempot_term(relative_chempots)]
row += [sum(defect_entry.corrections.values())]
dft_chempots = {el: energy + el_refs[el] for el, energy in relative_chempots.items()}
formation_energy = defect_entry.formation_energy(
chempots=dft_chempots,
fermi_level=fermi_level,
vbm=defect_entry.calculation_metadata.get("vbm", self.vbm),
)
row += [formation_energy]
row += [defect_entry.calculation_metadata.get("defect_path", "N/A")]
row += [
sum(
[
val
for key, val in defect_entry.corrections_metadata.items()
if "charge_correction_error" in key
]
)
]
table.append(row)
formation_energy_df = pd.DataFrame(
table,
columns=[
"Defect",
"q",
"ΔEʳᵃʷ",
"qE_VBM",
"qE_F",
"Σμ_ref",
"Σμ_formal",
"E_corr",
"Eᶠᵒʳᵐ",
"Path",
"Δ[E_corr]",
],
)
# round all floats to 3dp:
formation_energy_df = formation_energy_df.round(3)
return formation_energy_df.set_index(["Defect", "q"])
def _prepare_doping_scan(
self,
chempots: dict | None,
limit: str | None,
el_refs: dict | None,
n_points: int,
name: str,
) -> tuple[dict, dict | None, list[tuple[str, dict | None]], dict]:
"""
Common setup for ``get_dopability_limits`` / ``get_doping_windows``.
Validates ``chempots``/``el_refs``, builds the chemical-potential scan
points (vertex limits + optional interior grid), and the rich/poor
``limit_dict`` for output labelling. ``name`` is inserted into the
error message raised when no chemical potentials are available.
"""
chempots, el_refs = self._get_chempots(
chempots, el_refs
) # returns ``self.chempots`` if ``chempots`` is ``None``
if chempots is None:
raise ValueError(
f"No chemical potentials supplied or present in "
f"DefectThermodynamics.chempots, so {name} cannot be calculated."
)
limit = _parse_limit(chempots, limit)
scan_points: list[tuple[str, dict | None]] = (
[(limit, None)] if limit is not None else _get_doping_scan_points(chempots, n_points=n_points)
)
try:
limit_dict = get_rich_poor_limit_dict(chempots)
except ValueError:
limit_dict = {}
return chempots, el_refs, scan_points, limit_dict
def _iter_scan_point_charged_entries(
self,
scan_points: list[tuple[str, dict | None]],
chempots: dict,
) -> Iterator[tuple[str, dict, str | None, "DefectEntry"]]:
"""
Yield ``(point_label, fe_chempots, fe_limit, entry)`` for each charged
stable defect entry at each chemical potential scan point.
For vertex points (``point_chempots is None``) the full ``chempots``
dict is passed along with the vertex label; for interior points a
single-limit ``{element: formal_chempot}`` dict is passed with
``fe_limit=None``.
"""
for point_label, point_chempots in scan_points:
fe_chempots = chempots if point_chempots is None else point_chempots
fe_limit = point_label if point_chempots is None else None
for entry in self.all_stable_entries:
if entry.charge_state == 0:
continue
yield point_label, fe_chempots, fe_limit, entry
[docs]
def get_dopability_limits(
self,
chempots: dict | None = None,
limit: str | None = None,
el_refs: dict | None = None,
n_points: int = 100,
) -> pd.DataFrame:
r"""
Find the dopability limits of the defect system, searching over all
stable chemical potentials (as defined by ``chempots``) and returning
the most p/n-type conditions, or for a given chemical potential limit
(if ``limit`` is set or ``chempots`` corresponds to a single chemical
potential limit; i.e. ``{element symbol: chemical potential, ...}``).
The dopability limites are defined by the (first) Fermi level positions
at which defect formation energies become negative as the Fermi level
moves towards/beyond the band edges, thus determining the maximum
possible Fermi level range upon doping for a given chemical potential.
Note that the Fermi level positions are given relative to ``self.vbm``,
which is the VBM eigenvalue of the bulk supercell calculation by
default, unless ``bulk_band_gap_vr`` is set during defect parsing.
This is computed by obtaining the formation energies for every stable
defect with non-zero charge, and then finding the highest Fermi level
position at which a donor defect (positive charge) has zero formation
energy (crosses the x-axis) -- giving the lower dopability limit, and
the lowest Fermi level position at which an acceptor defect (negative
charge) has zero formation energy -- giving the upper dopability limit,
across accessible chemical potentials.
If the extrema occur at interior chemical potential points rather than
at the vertices (i.e. chemical potential limits), then 'Limit' name
used is ``'Interior (μ_X=...)'`` where ``(μ_X=...)`` are the chemical
potentials for that interior point.
Args:
chempots (dict):
Dictionary of chemical potentials to use for calculating the
defect formation energies (and thus dopability limits). If
``None`` (default), will use ``self.chempots``. This can have
the form of ``{"limits": [{'limit': [chempot_dict]}]}`` (the
format generated by ``doped``\'s chemical potential parsing
functions (see tutorials)) and specific limits (chemical
potential limits) can then be chosen using ``limit``.
Alternatively this can be a dictionary of chemical potentials
for a single limit, in the format:
``{element symbol: chemical potential}``.
If manually specifying chemical potentials this way, you can
set the ``el_refs`` option with the DFT reference energies of
the elemental phases, in which case it is the formal chemical
potentials (i.e. relative to the elemental references) that
should be given here, otherwise the absolute (DFT) chemical
potentials should be given.
One can also set ``DefectThermodynamics.chempots = ...`` (with
the same input options) to set the default chemical potentials
for all calculations.
limit (str):
The chemical potential limit for which to calculate formation
energies (and thus dopability limits). Can be either:
- ``None``, in which case we search over all limits in
``chempots`` and return the most n/p-type conditions, unless
``chempots`` is a single chemical potential limit.
- ``"X-rich"/"X-poor"`` where ``X`` is an element in the
system, in which case the most X-rich/poor limit will be used
(e.g. "Li-rich") -- see
:func:`~doped.chemical_potentials.get_X_rich_poor_limit`.
- A key in the ``(self.)chempots["limits"]`` dictionary.
The latter two options can only be used if ``chempots`` is in
the ``doped`` format (see chemical potentials tutorial).
(Default: None)
el_refs (dict):
Dictionary of elemental reference energies for the chemical
potentials in the format:
``{element symbol: reference energy}`` (to determine the formal
chemical potentials, when ``chempots`` has been manually
specified as ``{element symbol: chemical potential}``).
Unnecessary if ``chempots`` is provided/present in format
generated by ``doped`` (see tutorials).
One can also set ``DefectThermodynamics.el_refs = ...`` (with
the same input options) to set the default elemental reference
energies for all calculations.
(Default: None)
n_points (int):
Approximate number of interior chemical potential grid points
to scan (in addition to the chemical potential (vertex) limits)
to identify the optimal p/n-type conditions, as the extrema
(which are piecewise-linear concave/convex functions of the
chemical potentials) can, in complex cases, occur at interior
points rather than vertex limits. Set to ``0`` to only scan
over vertex limits (faster but less comprehensive). Ignored
if ``limit`` is given or there is only a single chemical
potential limit. Defaults to 100.
Returns:
``pandas`` ``DataFrame`` of dopability limits, with columns:
"limit", "Compensating Defect", "Dopability Limit" for both
p/n-type where 'Dopability limit' values are the corresponding
Fermi level positions in eV, relative to the VBM (``self.vbm``).
"""
chempots, el_refs, scan_points, limit_dict = self._prepare_doping_scan(
chempots, limit, el_refs, n_points, name="dopability limits"
)
donor_intercepts: list[tuple] = []
acceptor_intercepts: list[tuple] = []
for point_label, fe_chempots, fe_limit, entry in self._iter_scan_point_charged_entries(
scan_points, chempots
):
# formation energy (FE) = q*E_F + c; where c = VBM FE, so x-intercept is -VBM_FE/q:
intercept = (
-self.get_formation_energy(
entry, chempots=fe_chempots, limit=fe_limit, el_refs=el_refs, fermi_level=0
)
/ entry.charge_state
)
(donor_intercepts if entry.charge_state > 0 else acceptor_intercepts).append(
(point_label, entry.name, intercept)
)
# get the most p/n-type limit, by getting the limit with the minimum/maximum max/min-intercept,
# where max/min-intercept is the max/min intercept for that limit (i.e. the compensating intercept)
donor_row = _get_limiting_intercept_row(donor_intercepts, "max", "idxmin", fallback=-np.inf)
acceptor_row = _get_limiting_intercept_row(acceptor_intercepts, "min", "idxmax", fallback=np.inf)
if donor_row["intercept"] > acceptor_row["intercept"]:
warnings.warn(
"Donor and acceptor doping limits intersect at negative defect formation energies "
"(unphysical)!"
)
return _format_limiting_doping_result(
[donor_row, acceptor_row], limit_dict, "Dopability Limit (eV from VBM/CBM)"
)
[docs]
def get_doping_windows(
self,
chempots: dict | None = None,
limit: str | None = None,
el_refs: dict | None = None,
n_points: int = 100,
) -> pd.DataFrame:
r"""
Find the doping windows of the defect system, searching over all stable
chemical potentials (as defined by ``chempots``) and returning the most
p/n-type conditions, or for a given chemical potential limit (if
``limit`` is set or ``chempots`` corresponds to a single chemical
potential limit; i.e. ``{element symbol: chemical potential, ...}``).
Doping window is defined by the formation energy of the lowest energy
compensating defect species at the corresponding band edge (i.e. VBM
for hole doping and CBM for electron doping), as these set the upper
limit to the formation energy of dopants which could push the Fermi
level close to the band edge without being negated by defect charge
compensation.
Note that the band edge positions are taken from ``self.vbm`` and
``self.band_gap``, which are parsed from the `bulk supercell
calculation` by default, unless ``bulk_band_gap_vr`` is set during
defect parsing.
If a dopant has a higher formation energy than the doping window at the
band edge, then its charge will be compensated by formation of the
corresponding limiting defect species (rather than free carrier
populations).
If the extrema occur at interior chemical potential points rather than
at the vertices (i.e. chemical potential limits), then 'Limit' name
used is ``'Interior (μ_X=...)'`` where ``(μ_X=...)`` are the chemical
potentials for that interior point.
Args:
chempots (dict):
Dictionary of chemical potentials to use for calculating the
defect formation energies (and thus doping windows). If
``None`` (default), will use ``self.chempots``. This can have
the form of ``{"limits": [{'limit': [chempot_dict]}]}`` (the
format generated by ``doped``\'s chemical potential parsing
functions (see tutorials)) and specific limits (chemical
potential limits) can then be chosen using ``limit``.
Alternatively this can be a dictionary of chemical potentials
for a single limit, in the format:
``{element symbol: chemical potential}``.
If manually specifying chemical potentials this way, you can
set the ``el_refs`` option with the DFT reference energies of
the elemental phases, in which case it is the formal chemical
potentials (i.e. relative to the elemental references) that
should be given here, otherwise the absolute (DFT) chemical
potentials should be given.
One can also set ``DefectThermodynamics.chempots = ...`` (with
the same input options) to set the default chemical potentials
for all calculations.
limit (str):
The chemical potential limit for which to calculate formation
energies (and thus doping windows). Can be either:
- ``None``, in which case we search over all limits in
``chempots`` and return the most n/p-type conditions, unless
``chempots`` is a single chemical potential limit.
- ``"X-rich"/"X-poor"`` where ``X`` is an element in the
system, in which case the most X-rich/poor limit will be used
(e.g. "Li-rich") -- see
:func:`~doped.chemical_potentials.get_X_rich_poor_limit`.
- A key in the ``(self.)chempots["limits"]`` dictionary.
The latter two options can only be used if ``chempots`` is in
the ``doped`` format (see chemical potentials tutorial).
(Default: None)
el_refs (dict):
Dictionary of elemental reference energies for the chemical
potentials in the format:
``{element symbol: reference energy}`` (to determine the formal
chemical potentials, when ``chempots`` has been manually
specified as ``{element symbol: chemical potential}``).
Unnecessary if ``chempots`` is provided/present in format
generated by ``doped`` (see tutorials).
One can also set ``DefectThermodynamics.el_refs = ...`` (with
the same input options) to set the default elemental reference
energies for all calculations.
(Default: None)
n_points (int):
Approximate number of interior chemical potential grid points
to scan (in addition to the chemical potential (vertex) limits)
to identify the optimal p/n-type conditions, as the extrema
(which are piecewise-linear concave/convex functions of the
chemical potentials) can, in complex cases, occur at interior
points rather than vertex limits. Set to ``0`` to only scan
over vertex limits (faster but less comprehensive). Ignored
if ``limit`` is given or there is only a single chemical
potential limit. Defaults to 100.
Returns:
``pandas`` ``DataFrame`` of doping windows, with columns:
"limit", "Compensating Defect", "Doping Window" for both p/n-type
where 'Doping Window' values are the corresponding doping windows
in eV.
"""
chempots, el_refs, scan_points, limit_dict = self._prepare_doping_scan(
chempots, limit, el_refs, n_points, name="doping windows"
)
vbm_donor_intercepts: list[tuple] = []
cbm_acceptor_intercepts: list[tuple] = []
for point_label, fe_chempots, fe_limit, entry in self._iter_scan_point_charged_entries(
scan_points, chempots
):
intercepts = vbm_donor_intercepts if entry.charge_state > 0 else cbm_acceptor_intercepts
intercepts.append(
(
point_label,
entry.name,
self.get_formation_energy(
entry,
chempots=fe_chempots,
limit=fe_limit,
el_refs=el_refs,
fermi_level=0 if entry.charge_state > 0 else self.band_gap,
),
)
)
# get the most p/n-type limit, by getting the limit with the maximum min-intercept, where
# min-intercept is the min intercept for that limit (i.e. the compensating intercept)
rows = [
_get_limiting_intercept_row(intercepts, "min", "idxmax", fallback=fallback)
for intercepts, fallback in [
(vbm_donor_intercepts, np.inf),
(cbm_acceptor_intercepts, -np.inf),
]
]
return _format_limiting_doping_result(rows, limit_dict, "Doping Window (eV at VBM/CBM)")
[docs]
def prune_to_stable_entries(
self,
unstable_entries: bool | str = "not shallow",
shallow_charge_stability_tolerance: float | None = None,
charge_stability_tolerance: float = 0,
**kwargs,
) -> "DefectThermodynamics":
"""
This function takes the defect entries in
``DefectThermodynamics.defect_entries``, prunes them to only those
which pass a given stability criterion, and regenerates a new
|DefectThermodynamics| object with these defect entries.
This function can be used to prune out defect entries which are
detected to be shallow (perturbed host, 'fake') states according to
eigenvalue analysis (see the
:ref:`Tips:Eigenvalue / Electronic Structure Analysis` section
for more info), and/or entries which are only stable for certain
Fermi levels outside or within a small window of the band edges.
Doesn't modify the original |DefectThermodynamics| (``self``) object!
This function is used internally in ``doped`` with the
``unstable_entries`` argument in ``DefectThermodynamics.plot()``,
but can also be used to prune out shallow/unstable defect entries for
other purposes (e.g. if one wants to exclude these entries in
concentration calculations -- though usually these states are
irrelevant in such calculations due to their low/near-negligible
stabilities; particularly when reasonable supercell sizes, DFT
functionals and structure searching are used).
Note that this can affect the defect clustering (and thus naming in
plotting/concentration analyses) slightly, by removing some defects and
potentially breaking apart some clusters as a result.
Args:
unstable_entries (bool, str):
Controls the pruning of unstable/shallow defect states; allowed
values are ``True``, ``False`` or ``"not shallow"``. If
``"not shallow"`` (default), defect entries which are predicted
to be shallow (perturbed host) states according to eigenvalue
analysis and only stable for Fermi levels within a small window
to a band edge (``shallow_stability_tol``) are omitted.
If ``False``, `all` defects which are not stable for any Fermi
level in the band gap (``charge_stability_tol``) are `also`
omitted. ``shallow_stability_tol`` and ``charge_stability_tol``
can be tuned with the ``shallow_charge_stability_tolerance``
and ``charge_stability_tolerance`` keyword arguments
respectively. If ``True``, defect entries are not pruned based
on stability/shallow classification.
shallow_charge_stability_tolerance (float):
Tolerance for the Fermi level stability window for defects
which have been classified as shallow states. If ``None``
(default), will be set to the smaller of 0.05 eV or 10% of the
band gap.
charge_stability_tolerance (float):
Tolerance for the Fermi level stability window for `all` defect
charge states, if ``unstable_entries=False``. Default is 0 eV,
meaning all charge states which are stable for any Fermi level
in the band gap will be included, but can be set to a positive
value (meaning only defect charge states which are stable at
Fermi levels in the band gap `further` than this energy window
from a band edge) or negative value (which is a less strict
pruning, only excluding charge states which become stable at
Fermi levels outside the band gap `further` than the absolute
value of this energy window from a band edge).
**kwargs:
Additional keyword arguments to pass to the
|DefectThermodynamics| initialisation (via
``DefectThermodynamics.from_dict()``).
Returns:
New |DefectThermodynamics| object with pruned defect entries.
"""
if unstable_entries is True: # all
return self
if unstable_entries not in [False, "not shallow"]: # check unstable_entries input options
raise ValueError(
f"`unstable_entries` option must be either True, False or 'not shallow' -- not "
f"{unstable_entries}. See docs/docstrings for more info."
)
# determine tolerances:
shallow_tol = (
shallow_charge_stability_tolerance
if shallow_charge_stability_tolerance is not None
else min(0.05, self.band_gap * 0.1 if self.band_gap is not None else 0.05)
)
stability_tol = None if unstable_entries == "not shallow" else charge_stability_tolerance
pruned_defect_entries = {}
for name, defect_entry in self.defect_entries.items():
fermi_stability_window = self._get_in_gap_fermi_level_stability_window(defect_entry)
if stability_tol is not None and fermi_stability_window < stability_tol:
continue # skip
if defect_entry.is_shallow and fermi_stability_window < shallow_tol:
continue # skip
pruned_defect_entries[name] = defect_entry
defect_thermo_dict = self.as_dict()
defect_thermo_dict.update(
{
"defect_entries": pruned_defect_entries,
"check_compatibility": False,
"skip_dos_check": True,
}
)
defect_thermo_dict.update(kwargs)
return DefectThermodynamics.from_dict(defect_thermo_dict)
# TODO: Default to plot both the most (most-electronegative-)anion-rich and the
# (most-electropositive-)cation-rich chempot limits? (Rather than all limits)
# TODO: Should have similar colours for similar defect types, an option to just show amalgamated
# lowest energy charge states for each _defect type_) -- equivalent to setting the dist_tol to
# infinity (but should be easier to just do here by taking the short defect name). NaP is an example
# for this -- should have a test built for however we want to handle cases like this. See Ke's example
# case too with different interstitial sites.
# Related: Currently updating `dist_tol` to change the number of defects being plotted,
# can also change the colours of the different defect lines (e.g. for CdTe_wout_meta increasing
# `dist_tol` to 2 to merge all Te interstitials, results in the colours of other defect lines (
# e.g. Cd_Te) changing at the same time -- ideally this wouldn't happen!
[docs]
def plot(
self,
chempots: dict | None = None,
limit: str | None = None,
el_refs: dict | None = None,
all_entries: bool | str = False,
unstable_entries: bool | str = "not shallow",
defect_subset: list[str] | str | None = None,
chempot_table: bool | None = None,
style_file: PathLike | None = None,
xlim: tuple | None = None,
ylim: tuple | None = None,
fermi_level: float | None = None,
include_site_info: bool | None = None,
colormap: str | colors.Colormap | None = None,
linestyles: str | list[str] = "-",
auto_labels: bool = False,
filename: PathLike | None = None,
**kwargs,
) -> Figure | list[Figure]:
r"""
Produce a defect formation energy vs Fermi level plot (a.k.a. a defect
formation energy / transition level diagram), returning the
``matplotlib`` ``Figure`` object to allow further plot customisation.
Note that the band edge positions are taken from ``self.vbm`` and
``self.band_gap``, which are parsed from the `bulk supercell
calculation` by default, unless ``bulk_band_gap_vr`` is set during
defect parsing.
Note that different defect entries (different charge states, and/or
ground and metastable states (different spin or geometries); e.g.
interstitials at a given site) are grouped together in distinct defect
types according to ``self.dist_tol``, which is also used in transition
level analysis and defect concentrations. This can be adjusted as shown
in the :doc:`plotting customisation tutorial <plotting_customisation_tutorial>`.
See the ``dist_tol`` attribute, ``group_defects_by_distance()`` and
``group_defects_by_type_and_distance()`` functions for more information
on clustering strategies.
Args:
chempots (dict):
Dictionary of chemical potentials to use for calculating the
defect formation energies. If ``None`` (default), will use
``self.chempots``. This can have the form of
``{"limits": [{'limit': [chempot_dict]}]}`` (the format
generated by ``doped``\'s chemical potential parsing functions
(see tutorials)) and specific limits (chemical potential
limits) can then be chosen using ``limit``.
Alternatively this can be a dictionary of chemical potentials
for a single limit, in the format:
``{element symbol: chemical potential}``.
If manually specifying chemical potentials this way, you can
set the ``el_refs`` option with the DFT reference energies of
the elemental phases in order to show the formal (relative)
chemical potentials above the formation energy plot, in which
case it is the formal chemical potentials (i.e. relative to the
elemental references) that should be given here, otherwise the
absolute (DFT) chemical potentials should be given.
One can also set ``DefectThermodynamics.chempots = ...`` (with
the same input options) to set the default chemical potentials
for all calculations.
limit (str):
The chemical potential limit for which to plot formation
energies. Can be either:
- ``None``, in which case plots are generated for all limits in
``chempots``.
- ``"X-rich"/"X-poor"`` where ``X`` is an element in the
system, in which case the most X-rich/poor limit will be used
(e.g. "Li-rich") -- see
:func:`~doped.chemical_potentials.get_X_rich_poor_limit`.
- A key in the ``(self.)chempots["limits"]`` dictionary.
The latter two options can only be used if ``chempots`` is in
the ``doped`` format (see chemical potentials tutorial).
(Default: None)
el_refs (dict):
Dictionary of elemental reference energies for the chemical
potentials in the format:
``{element symbol: reference energy}`` (to determine the formal
chemical potentials, when ``chempots`` has been manually
specified as ``{element symbol: chemical potential}``).
Unnecessary if ``chempots`` is provided/present in format
generated by ``doped`` (see tutorials).
One can also set ``DefectThermodynamics.el_refs = ...`` (with
the same input options) to set the default elemental reference
energies for all calculations.
(Default: None)
all_entries (bool, str):
Whether to plot the formation energy lines of `all` defect
entries, rather than the default of showing only the
equilibrium states at each Fermi level position (traditional).
If instead set to ``"faded"``, will plot the equilibrium states
in bold, and all unstable states in faded grey
(default: ``False``)
unstable_entries (bool, str):
Controls the plotting of unstable/shallow defect states;
allowed values are ``True``, ``False`` or ``"not shallow"``.
If ``"not shallow"`` (default), defect entries which are
predicted to be shallow (perturbed host) states according to
eigenvalue analysis and only stable for Fermi levels within a
small window to a band edge (``shallow_stability_tol``) are
omitted from plotting. If ``False``, `all` defects which are
not stable for any Fermi level in the band gap are `also`
omitted from plotting.
``shallow_stability_tol`` is set to the smaller of 0.05 eV or
10% of the band gap by default, but can be set by a
``shallow_charge_stability_tolerance = X`` keyword argument. If
``unstable_entries=False``, the Fermi window stability
tolerance for all defects (default = 0; meaning any in-gap
stability) can be set by a ``charge_stability_tolerance = X``
keyword argument (positive or negative).
If ``True``, defect entries are not pruned based on stability /
shallow classification.
See ``prune_to_stable_entries`` for more info.
defect_subset (list[str], str):
If provided, only defects whose name contains at least one of
the given substrings are plotted (e.g. ``["v_", "Te_Cd"]``
would keep all vacancies plus ``Te_Cd``). A bare string is
treated as a single-element list. (Default: ``None`` -- all
defects)
chempot_table (bool | None):
Whether to include a table of the chemical potentials above the
formation energy plot. If ``None`` (default), shown if multiple
plots are generated (i.e. multiple chemical potential limits)
else not shown.
style_file (PathLike):
Path to a ``mplstyle`` file to use for the plot. If ``None``
(default), uses the default doped style (from
``doped/utils/doped.mplstyle``).
xlim:
Tuple (min,max) giving the range of the x-axis (Fermi level).
May want to set manually when including transition level
labels, to avoid crossing the axes.
Default is to plot from -0.3 to +0.3 eV above the band gap.
ylim:
Tuple (min,max) giving the range for the y-axis (formation
energy). May want to set manually when including transition
level labels, to avoid crossing the axes. Default is from 0 to
just above the maximum formation energy value in the band gap.
fermi_level (float):
If set, plots a dashed vertical line at this Fermi level value,
typically used to indicate the equilibrium Fermi level position
if known/calculated (e.g. with
``get_fermi_level_and_concentrations``). (Default: None)
include_site_info (bool, None):
Whether to include site info in defect names in the plot legend
(e.g. ``$Cd_{i_{C3v}}$`` rather than ``$Cd_{i}$``). If ``None``
(default), site info is omitted unless needed to disambiguate
non-grouped defects with the same name (i.e. inequivalent sites
for the same defect type). If ``False``, site info is never
included. If ``True``, site info is shown for all defect names.
In all cases, if duplicate defect names remain, "-a", "-b",
"-c" etc. are appended to the names to differentiate them.
colormap (str, matplotlib.colors.Colormap):
Colormap to use for the formation energy lines, either as a
string (which can be a colormap name from
https://matplotlib.org/stable/users/explain/colors/colormaps or
from https://www.fabiocrameri.ch/colourmaps -- append 'S' if
using a sequential colormap from the latter) or a ``Colormap``
/ ``ListedColormap`` object.
If ``None`` (default), uses ``tab10`` with ``alpha=0.75`` (if
10 or fewer lines to plot), ``tab20`` (if 20 or fewer lines) or
``batlow`` (if more than 20 lines; citation:
https://zenodo.org/records/8409685).
linestyles (list):
Linestyles to use for the formation energy lines, either as a
single linestyle (``str``) or list of linestyles
(``list[str]``) in the order of appearance of lines in the plot
legend. Default is ``"-"``; i.e. solid lines for all entries.
auto_labels (bool):
Whether to automatically label the transition levels with their
charge states. If there are many transition levels, this can be
quite ugly. (default: ``False``)
filename (PathLike): Filename to save the plot to.
(Default: None (not saved))
**kwargs:
Additional keyword arguments for advanced customisation, such
as ``shallow_charge_stability_tolerance`` or
``charge_stability_tolerance`` for controlling stability window
tolerances with the ``unstable_entries`` parameter (see
argument description for more info).
Returns:
``matplotlib`` ``Figure`` object, or list of ``Figure`` objects if
multiple limits chosen.
"""
if all_entries not in [False, True, "faded"]: # check input options
raise ValueError(
f"`all_entries` option must be either False, True, or 'faded', not {all_entries}"
)
chempots, el_refs = self._get_chempots(
chempots, el_refs
) # returns self.chempots/self.el_refs if chempots is None
if chempots is None:
chempots = {
"limits": {"No User Chemical Potentials": None}
} # empty chempots dict to allow plotting, user will be warned
limit = _parse_limit(chempots, limit)
limits = [limit] if limit is not None else list(chempots["limits"].keys())
if (
chempots
and limit is None
and el_refs is None
and "limits" not in chempots
and any(np.isclose(chempot, 0, atol=0.1) for chempot in chempots.values())
):
# if any chempot is close to zero, this is likely a formal chemical potential and so inaccurate
# here (trying to make this as idiotproof as possible to reduce unnecessary user queries...)
warnings.warn(
"At least one of your manually-specified chemical potentials is close to zero, "
"which is likely a _formal_ chemical potential (i.e. relative to the elemental "
"reference energies), but you have not specified the elemental reference "
"energies with `el_refs`. This will give large errors in the absolute values "
"of formation energies, but the transition level positions will be unaffected."
)
thermo_to_plot = self.prune_to_stable_entries( # unstable_entries pruning
unstable_entries=unstable_entries, **kwargs
) # Note that this will need to be updated if we add other kwarg options to this function
with doped_plot_style(style_file):
figs = []
for limit in limits:
dft_chempots = chempots["limits"][limit]
plot_title = limit if len(limits) > 1 else None
plot_filename = (
f"{filename.rsplit('.', 1)[0]}_{limit}.{filename.rsplit('.', 1)[1]}"
if filename
else None
)
with warnings.catch_warnings(): # avoid double warning about no chempots supplied
warnings.filterwarnings("ignore", "No chemical potentials")
fig = formation_energy_plot(
thermo_to_plot,
dft_chempots=dft_chempots,
el_refs=el_refs,
chempot_table=chempot_table if chempot_table is not None else len(limits) > 1,
all_entries=all_entries,
defect_subset=defect_subset,
xlim=xlim,
ylim=ylim,
fermi_level=fermi_level,
include_site_info=include_site_info,
title=plot_title,
colormap=colormap,
linestyles=linestyles,
auto_labels=auto_labels,
filename=plot_filename,
)
figs.append(fig)
return figs[0] if len(figs) == 1 else figs
[docs]
def plot_transition_levels(
self,
all: bool | str = "faded",
defect_subset: list[str] | str | None = None,
unstable_entries: bool | str = "not shallow",
include_site_info: bool | None = None,
ylim: tuple | None = None,
show_charge_labels: bool = True,
show_band_labels: bool | None = None,
label_fontsize: float | None = None,
column_width: float = 0.4,
figsize: tuple | None = None,
style_file: PathLike | None = None,
filename: PathLike | None = None,
**kwargs,
) -> Figure:
r"""
Produce a vertical defect energy level / charge-transition level
diagram, with one column per defect and short horizontal lines marking
each charge transition level within the host band gap, labelled with
the corresponding charge state transition.
Note that the band edge positions are taken from ``self.vbm`` and
``self.band_gap``, which are parsed from the `bulk supercell`
calculation by default, unless ``bulk_band_gap_vr`` is set during
defect parsing. The VBM lies at 0 eV (blue shaded region), and the CBM
lies at ``self.band_gap`` (orange shaded region).
Metastable charge states are denoted with a ``*`` in the charge
transition labels.
This function uses an intelligent dynamic label placement algorithm to
try and label each of the charge transition levels without having
overlapping labels/lines. However, in some edge cases (e.g. with many
bunched transition levels, metastable charge states etc.) this can
still give imperfect results, in which case we advise saving to PDF or
another vector graphic file type, and manually editing (for
publication-ready figures).
Args:
all (bool, str):
Controls inclusion of single-electron transition levels
involving metastable defect charge states (denoted with ``*``
in the labels). Mostly equivalent to ``all`` in |get_TLs|.
Allowed values:
- ``"faded"`` (default): show all single-electron TLs, with
metastable-containing TLs drawn as faded grey lines `without`
labels (keeps the plot uncluttered).
- ``"faded_labels"``: same as ``"faded"`` but `with` labels
drawn for the faded metastable TLs too.
- ``True``: show all single-electron TLs at full opacity, all
labelled.
- ``False``: show only the thermodynamic ground-state
transition levels (i.e. those visible on the standard defect
formation energy diagram).
defect_subset (list[str], str):
If provided, only defects whose name contains at least one of
the given substrings are plotted (e.g. ``["v_", "Te_Cd"]``
would keep all vacancies plus ``Te_Cd``). A bare string is
treated as a single-element list. (Default: ``None`` -- all
defects)
unstable_entries (bool, str):
Controls the inclusion of unstable/shallow defect states, as in
:meth:`~doped.thermodynamics.DefectThermodynamics.plot()`;
allowed values are ``True``, ``False`` or ``"not shallow"``. If
``"not shallow"`` (default), defect entries which are predicted
to be shallow (perturbed host) states according to eigenvalue
analysis and only stable for Fermi levels within a small window
to a band edge (``shallow_stability_tol``) are omitted. If
``False``, `all` defects which are not stable for any Fermi
level in the band gap are `also` omitted. If ``True``, defect
entries are not pruned based on stability / shallow
classification. See ``prune_to_stable_entries`` for more info.
include_site_info (bool, None):
Whether to include site info in defect names in the column
headers (e.g. ``$V_{Cd_{Td}}$`` rather than ``$V_{Cd}$``). If
``None`` (default), site info is omitted unless needed to
disambiguate non-grouped defects with the same name (i.e.
inequivalent sites for the same defect type). If ``False``,
site info is never included. If ``True``, site info is shown
for all defect names. In all cases, if duplicate defect names
remain, "-a", "-b", "-c" etc. are appended to the names to
differentiate them.
ylim (tuple):
Energy axis limits in eV (relative to ``self.vbm``). Defaults
to ``(-0.05 * band_gap, 1.05 * band_gap)``.
show_charge_labels (bool):
Whether to label each transition level with its charge states
(e.g. ``"(+1/0)"``). Defaults to ``True``.
show_band_labels (bool):
Whether to draw the "VBM" and "CBM" labels in the blue/orange
band-edge shaded zones. If ``None`` (default), they are shown
only if they would not overlap any transition level label
(right side of plot preferred, falling back to the left); if
both sides would clash they are hidden. ``True`` forces them on
the right; ``False`` hides them.
label_fontsize (float):
Font size for the charge transition labels. Defaults to ~90%
of the current ``matplotlib`` ``font.size`` rcParam. Can be a
useful parameter to tune for busy plots.
column_width (float):
Width (in axes units) of the horizontal line segments inside
each defect column, where the column spacing is equal to ``1``.
Defaults to ``0.4``. Can be a useful parameter to tune for busy
plots.
figsize (tuple):
``(width, height)`` of the figure in inches. Defaults to a size
that scales with the number of defects.
style_file (PathLike):
Path to a ``mplstyle`` file to use for the plot. If ``None``
(default), uses the default doped style (from
``doped/utils/doped.mplstyle``).
filename (PathLike):
If set, save the figure to this path. (Default: None)
**kwargs:
Additional keyword arguments controlling the stability window
tolerances when pruning unstable/shallow entries (e.g.
``shallow_charge_stability_tolerance`` or
``charge_stability_tolerance``); see
``prune_to_stable_entries`` for more info.
Returns:
``matplotlib`` ``Figure`` object.
"""
with doped_plot_style(style_file):
return transition_level_diagram(
self.prune_to_stable_entries(unstable_entries=unstable_entries, **kwargs),
all=all,
defect_subset=defect_subset,
include_site_info=include_site_info,
ylim=ylim,
show_charge_labels=show_charge_labels,
show_band_labels=show_band_labels,
label_fontsize=label_fontsize,
column_width=column_width,
figsize=figsize,
filename=filename,
)
def _get_single_electron_tls(self) -> dict[str, list[TransitionLevel]]:
"""
Compute all single-electron charge transition levels for each defect.
Returns a ``{defect_name: [TransitionLevel, ...]}`` dict where each
entry corresponds to a transition between consecutive charge states
(i.e. single-electron charge transfer TL) ``q_pos`` and
``q_neg = q_pos - 1`` (so ``q_pos`` is always the more positive charge
state), ``TL_eV`` is the (full-precision) transition level position in
eV relative to the VBM (``self.vbm``), and ``pos_meta``/``neg_meta``
indicate whether the upper/lower charge state is metastable (i.e. not
in ``self.all_stable_entries``). The ``faded`` rendering flag is left
at its ``False`` default.
Shared by |get_TLs| and transition level plotting routines
(:func:`~doped.utils.plotting._get_transition_level_data`).
"""
stable_entries = self.all_stable_entries
single_electron_tls: dict[str, list[TransitionLevel]] = {}
for defect_name, grouped_defect_entries in self.all_entries.items():
sorted_entries = sorted(grouped_defect_entries, key=lambda x: x.charge_state)
tls: list[TransitionLevel] = []
for pos, neg in product(sorted_entries, repeat=2):
if pos.charge_state - neg.charge_state != 1:
continue
mean_VBM = float( # take mean VBM, should be the same, but allow for small differences
np.mean([x.calculation_metadata.get("vbm", self.vbm) for x in [pos, neg]])
)
TL = neg.get_ediff() - pos.get_ediff() - mean_VBM
pos_meta = not any(pos == y for y in stable_entries)
neg_meta = not any(neg == y for y in stable_entries)
tls.append(
TransitionLevel(float(TL), (pos.charge_state, neg.charge_state), pos_meta, neg_meta)
)
single_electron_tls[defect_name] = tls
return single_electron_tls
[docs]
def get_transition_levels(
self,
all: bool = False,
format_charges: bool = True,
unstable_entries: bool | str = "not shallow",
**kwargs,
) -> pd.DataFrame | None:
"""
Return a ``DataFrame`` of the charge transition levels for the defects
in the |DefectThermodynamics| object (stored in the
``transition_level_map`` attribute).
Note that the transition level (and Fermi level) positions are given
relative to ``self.vbm``, which is the VBM eigenvalue of the bulk
supercell calculation by default, unless ``bulk_band_gap_vr`` is set
during defect parsing.
By default, only returns the thermodynamic ground-state transition
levels (i.e. those visible on the defect formation energy diagram), not
including metastable defect states -- which can be important for
recombination, migration, degeneracy/concentrations etc.;
https://doi.org/10.1038/s41578-025-00879-y,
https://doi.org/10.1039/D2FD00043A, https://doi.org/10.1039/D3CS00432E.
e.g. negative-U defects will show the 2-electron transition level
(N+1/N-1) rather than (N+1/N) and (N/N-1).
If instead all single-electron transition levels are desired, set
``all = True``.
Returns a ``DataFrame`` with columns:
- "Defect": Defect name.
- "Charges":
Defect charge states which make up the transition level
(as a string if ``format_charges=True``, otherwise as a list of
integers).
- "eV from VBM":
Transition level position in eV from the VBM (``self.vbm``).
- "In Band Gap?":
Whether the transition level is within the host band gap.
- "N(Metastable)":
Number of metastable states involved in the transition level
(0, 1 or 2). Only included if ``all = True``.
Args:
all (bool):
Whether to print all single-electron transition levels (i.e.
including metastable defect states), or just the thermodynamic
ground-state transition levels (default).
format_charges (bool):
Whether to format the transition level charge states as strings
(e.g. ``"ε(+1/+2)"``) or keep in list format (e.g. ``[1,2]``).
Default is ``True``.
unstable_entries (bool, str):
Controls the inclusion of unstable/shallow defect states, as in
``DefectThermodynamics.plot()``; allowed values are ``True``,
``False`` or ``"not shallow"``. If ``"not shallow"`` (default),
defect entries which are predicted to be shallow (perturbed
host) states according to eigenvalue analysis and only stable
for Fermi levels within a small window to a band edge
(``shallow_stability_tol``) are omitted. If ``False``, `all`
defects which are not stable for any Fermi level in the band
gap are `also` omitted. If ``True``, defect entries are not
pruned based on stability / shallow classification. See
``prune_to_stable_entries`` for more info.
**kwargs:
Additional keyword arguments to control the stability window
tolerances when pruning unstable/shallow entries (e.g.
``shallow_charge_stability_tolerance`` or
``charge_stability_tolerance``); see
``prune_to_stable_entries`` for more info.
"""
tl_thermo = self.prune_to_stable_entries(unstable_entries=unstable_entries, **kwargs)
# create a dataframe from the transition level map, with defect name, transition level charges and
# TL position in eV from the VBM:
transition_level_map_list = []
for defect_name, transition_level_dict in tl_thermo.transition_level_map.items():
if not transition_level_dict:
transition_level_map_list.append( # add defects with no TL to dataframe as "None"
{
"Defect": defect_name,
"Charges": "None",
"eV from VBM": np.inf,
"In Band Gap?": False,
"-q_pos": np.inf, # for sorting
"-q_neg": np.inf, # for sorting
}
)
if all:
transition_level_map_list[-1]["N(Metastable)"] = 0
if not all:
transition_level_map_list.extend(
{
"Defect": defect_name,
"Charges": (
_format_TL_charge_label(transition_level_charges, prefix="ε")
if format_charges
else transition_level_charges
),
"eV from VBM": round(TL, 3),
"In Band Gap?": (TL > 0) and tl_thermo.band_gap and (tl_thermo.band_gap > TL),
"-q_pos": -transition_level_charges[0], # for sorting
"-q_neg": -transition_level_charges[1], # for sorting
}
for TL, transition_level_charges in transition_level_dict.items()
)
# now get metastable TLs
if all:
for defect_name_wout_charge, tls in tl_thermo._get_single_electron_tls().items():
for TL, transition_level_charges, pos_meta, neg_meta, _faded in tls:
transition_level_map_list.append(
{
"Defect": defect_name_wout_charge,
"Charges": (
_format_TL_charge_label(
transition_level_charges,
pos_meta=pos_meta,
neg_meta=neg_meta,
prefix="ε",
)
if format_charges
else transition_level_charges
),
"eV from VBM": round(TL, 3),
"In Band Gap?": (TL > 0) and tl_thermo.band_gap and (tl_thermo.band_gap > TL),
"N(Metastable)": [pos_meta, neg_meta].count(True),
"-q_pos": -transition_level_charges[0], # for sorting
"-q_neg": -transition_level_charges[1], # for sorting
}
)
if not transition_level_map_list:
warnings.warn("No transition levels found for chosen parameters!")
return None
tl_df = pd.DataFrame(transition_level_map_list)
if "N(Metastable)" not in tl_df.columns: # add, because we use it for sorting in case all=True
tl_df["N(Metastable)"] = 0
# sort df by Defect appearance order in defect_entries, Defect, then by TL position:
tl_df["Defect Appearance Order"] = tl_df["Defect"].map(tl_thermo._map_sort_func)
tl_df = tl_df.sort_values(
by=["Defect Appearance Order", "Defect", "-q_pos", "-q_neg", "N(Metastable)", "eV from VBM"]
)
tl_df = tl_df.drop(columns=["Defect Appearance Order", "-q_pos", "-q_neg"])
if not all:
tl_df = tl_df.drop(columns="N(Metastable)")
return tl_df.set_index(["Defect", "Charges"])
[docs]
def print_transition_levels(
self, all: bool = False, unstable_entries: bool | str = "not shallow", **kwargs
):
"""
Iteratively prints the charge transition levels for the defects in the
|DefectThermodynamics| object (stored in the ``transition_level_map``
attribute).
By default, only returns the thermodynamic ground-state transition
levels (i.e. those visible on the defect formation energy diagram), not
including metastable defect states -- which can be important for
recombination, migration, degeneracy/concentrations etc., see e.g.
https://doi.org/10.1038/s41578-025-00879-y,
https://doi.org/10.1039/D2FD00043A, https://doi.org/10.1039/D3CS00432E.
e.g. negative-U defects will show the 2-electron transition level
(N+1/N-1) rather than (N+1/N) and (N/N-1).
If instead all single-electron transition levels are desired, set
``all = True``.
Args:
all (bool):
Whether to print all single-electron transition levels (i.e.
including metastable defect states), or just the thermodynamic
ground-state transition levels (default).
unstable_entries (bool, str):
Controls the inclusion of unstable/shallow defect states, as in
``DefectThermodynamics.plot()``; allowed values are ``True``,
``False`` or ``"not shallow"``. If ``"not shallow"`` (default),
defect entries which are predicted to be shallow (perturbed
host) states according to eigenvalue analysis and only stable
for Fermi levels within a small window to a band edge
(``shallow_stability_tol``) are omitted. If ``False``, `all`
defects which are not stable for any Fermi level in the band
gap are `also` omitted. If ``True``, defect entries are not
pruned based on stability / shallow classification. See
``prune_to_stable_entries`` for more info.
**kwargs:
Additional keyword arguments to control the stability window
tolerances when pruning unstable/shallow entries (e.g.
``shallow_charge_stability_tolerance`` or
``charge_stability_tolerance``); see
``prune_to_stable_entries`` for more info.
"""
tl_thermo = self.prune_to_stable_entries(unstable_entries=unstable_entries, **kwargs)
if not all:
for defect_name, tl_info in tl_thermo.transition_level_map.items():
bold_print(f"Defect: {defect_name}")
for tl_efermi, chargeset in tl_info.items():
print(
f"Transition level ε({max(chargeset):{'+' if max(chargeset) else ''}}/"
f"{min(chargeset):{'+' if min(chargeset) else ''}}) at {tl_efermi:.3f} eV above "
f"the VBM"
)
print("") # add space
else:
# any pruning already applied above, so don't re-prune (unstable_entries=True):
all_df = tl_thermo.get_transition_levels(all=True, unstable_entries=True)
if all_df is None:
return
for defect_name, tl_df in all_df.groupby("Defect", sort=False):
bold_print(f"Defect: {defect_name}")
for index, row in tl_df.iterrows():
tl_label = tuple(index)[1]
if tl_label != "None": # TL to print
print(f"Transition level {tl_label} at {row['eV from VBM']:.3f} eV above the VBM")
print("") # add space
[docs]
def get_symmetries_and_degeneracies(
self,
skip_formatting: bool = False,
symprec: float | None = None,
bulk_symprec: float | None = None,
**kwargs,
) -> pd.DataFrame:
r"""
Generates a table of the bulk-site & relaxed defect point group
symmetries, spin/orientational/total degeneracies and (bulk-)site
multiplicities for each defect in the |DefectThermodynamics| object.
Table Key:
- 'Defect': Defect name (without charge)
- 'q': Defect charge state.
- 'Site_Symm': Point group symmetry of the defect site in the bulk cell.
- 'Defect_Symm': Point group symmetry of the relaxed defect.
- 'g_Orient': Orientational degeneracy of the defect.
- 'g_Spin': Spin degeneracy of the defect.
- 'g_Total': Total degeneracy of the defect.
- 'Mult': Multiplicity of the defect site in the bulk cell, per primitive unit cell.
For interstitials, the bulk site symmetry corresponds to the point
symmetry of the interstitial site with `no relaxation of the host
structure`, while for vacancies/substitutions it is simply the symmetry
of their corresponding bulk site. This corresponds to the point
symmetry of ``DefectEntry.defect``, or
``calculation_metadata["bulk_site"]/["unrelaxed_defect_structure"]``.
Point group symmetries are taken from the calculation_metadata
("relaxed point symmetry" and "bulk site symmetry") if present (should
be, if parsed with doped and defect supercell doesn't break host
periodicity), otherwise are attempted to be recalculated.
Note: ``doped`` tries to use the ``defect_entry.defect_supercell`` to
determine the `relaxed` site symmetry. However, it should be noted that
this is not guaranteed to work in all cases; namely for non-diagonal
supercell expansions, or sometimes for non-scalar supercell expansion
matrices (e.g. a 2x1x2 expansion)(particularly with high-symmetry
materials) which can mess up the periodicity of the cell. doped tries
to automatically check if this is the case, and will warn you if so.
This can also be checked by using this function on your doped
`generated` defects:
.. code-block:: python
from doped.generation import get_defect_name_from_entry
for defect_name, defect_entry in defect_gen.items():
print(defect_name,
get_defect_name_from_entry(defect_entry, relaxed=False),
get_defect_name_from_entry(defect_entry), "\n")
And if the point symmetries match in each case, then doped should be
able to correctly determine the final relaxed defect symmetry (and
orientational degeneracy) -- otherwise periodicity-breaking prevents
this.
If periodicity-breaking prevents auto-symmetry determination, you can
manually determine the relaxed defect and bulk-site point symmetries,
and/or orientational degeneracy, from visualising the structures (e.g.
using VESTA)(can use |get_orientational_degeneracy| to obtain the
corresponding orientational degeneracy factor for given defect/bulk
site point symmetries) and setting the corresponding values in the
``'relaxed point symmetry'`` / ``'bulk site symmetry'`` entries in
``DefectEntry.calculation_metadata`` and/or
``DefectEntry.degeneracy_factors['orientational degeneracy']``
attributes.
Note that the bulk-site point symmetry corresponds to that of
``DefectEntry.defect``, or equivalently
``calculation_metadata["bulk_site"]/["unrelaxed_defect_structure"]``,
which for vacancies/substitutions is the symmetry of the corresponding
bulk site, while for interstitials it is the point symmetry of the
`final relaxed` interstitial site when placed in the (unrelaxed) bulk
structure. The degeneracy factor is used in the calculation of
defect/carrier concentrations and Fermi level behaviour (e.g.
https://doi.org/10.1038/s41578-025-00879-y,
https://doi.org/10.1039/D2FD00043A, https://doi.org/10.1039/D3CS00432E).
Args:
skip_formatting (bool):
Whether to skip formatting the defect charge states as
strings (and keep as ``int``\s and ``float``\s instead).
(default: ``False``)
symprec (float):
Symmetry precision to use for determining symmetry operations
and thus point symmetries with ``spglib``, for the `relaxed`
defect supercell. Default in ``doped`` is ``0.1`` which matches
that used by the ``Materials Project`` and is larger than the
``pymatgen`` default of ``0.01`` to account for residual
structural noise in relaxed defect supercells. If set, then
site symmetries & degeneracies will be re-parsed/computed even
if already present in the |DefectEntry| object
``calculation_metadata``.
You may want to adjust for your system (e.g. if there are very
slight octahedral distortions etc.). If
``fixed_symprec_and_dist_tol_factor`` is ``False`` (default),
this value will be automatically adjusted (up to 10x, down to
0.1x) until the identified equivalent sites from ``spglib``
have consistent point group symmetries. Setting ``verbose`` to
``True`` will print information on the trialled ``symprec``
(and ``dist_tol_factor`` values).
(Default: None)
bulk_symprec (float):
Symmetry precision to use for determining symmetry operations
and thus point symmetries with ``spglib``, for the `unrelaxed`
(bulk site) point symmetry. Default in ``doped`` is ``0.01``
which matches the ``pymatgen`` default. You may want to adjust
for your system (e.g. if there are very slight octahedral
distortions etc.). If set, then site symmetries & degeneracies
will be re-parsed/computed even if already present in the
|DefectEntry| object ``calculation_metadata``.
If ``fixed_symprec_and_dist_tol_factor`` is ``False``
(default), this value will be automatically adjusted (up to
10x, down to 0.1x) until the identified equivalent sites from
``spglib`` have consistent point group symmetries. Setting
``verbose`` to ``True`` will print information on the trialled
``symprec`` (and ``dist_tol_factor`` values).
(Default: None)
**kwargs:
Additional keyword arguments to pass to
``get_all_equiv_sites`` /
``get_equiv_frac_coords_in_primitive``, such as
``dist_tol_factor``, ``fixed_symprec_and_dist_tol_factor``, and
``verbose``, and/or |Defect| initialization (such as
``oxi_state``, ``multiplicity``, ``dist_tol_factor``) in the
``defect_and_info_from_structures`` function.
Returns:
``pandas`` ``DataFrame``
"""
table_list = []
for name, defect_entry in self.defect_entries.items():
defect_entry._parse_and_set_symmetries_and_degeneracies(
symprec=symprec, bulk_symprec=bulk_symprec, **kwargs
)
try:
multiplicity_per_unit_cell = defect_entry.defect.multiplicity * (
len(get_primitive_structure(defect_entry.defect.structure)) # spglib primitive
/ len(defect_entry.defect.structure)
) # ensure multiplicity corresponds to unit cell (which it should by default anyway,
# now that parsed |Defect|s are defined in the primitive unit cell)
except Exception:
multiplicity_per_unit_cell = "N/A"
total_degeneracy = (
reduce(lambda x, y: x * y, defect_entry.degeneracy_factors.values())
if defect_entry.degeneracy_factors
else "N/A"
)
table_list.append(
{
"Defect": name.rsplit("_", 1)[0], # name without charge
"q": defect_entry.charge_state,
"Site_Symm": defect_entry.calculation_metadata.get("bulk site symmetry", "N/A"),
"Defect_Symm": defect_entry.calculation_metadata.get("relaxed point symmetry", "N/A"),
"g_Orient": defect_entry.degeneracy_factors.get("orientational degeneracy", "N/A"),
"g_Spin": defect_entry.degeneracy_factors.get("spin degeneracy", "N/A"),
"g_Total": total_degeneracy,
"Mult": multiplicity_per_unit_cell,
}
)
if any(
defect_entry.calculation_metadata.get("periodicity_breaking_supercell", False)
for defect_entry in self.defect_entries.values()
):
warnings.warn(_orientational_degeneracy_warning)
symmetry_df = pd.DataFrame(table_list)
if not skip_formatting:
symmetry_df["q"] = symmetry_df["q"].apply(lambda x: f"{'+' if x > 0 else ''}{x}")
return symmetry_df.set_index(["Defect", "q"])
def _get_and_set_fermi_level(self, fermi_level: float | None = None) -> float:
"""
Handle the input Fermi level choice.
If Fermi level not set, defaults to mid-gap Fermi level (E_g/2) and
prints an info message to the user.
"""
if fermi_level is None:
fermi_level = 0.5 * (self.band_gap or 0)
print(
f"Fermi level was not set, so using mid-gap Fermi level (E_g/2 = {fermi_level:.2f} eV "
f"relative to the VBM)."
)
return fermi_level
def _sanitise_chempots_for_concentrations(self, chempots, el_refs, limit):
limit = _check_chempots_and_limit_settings(chempots, limit) # only warn once
if chempots is None:
all_comps = [
entry.sc_entry.composition if entry.sc_entry else entry.bulk_entry.composition
for entry in self.defect_entries.values()
]
empty_el_dict = dict.fromkeys({el.symbol for comp in all_comps for el in comp}, 0)
chempots = {
"limits": {"No User Chemical Potentials": empty_el_dict},
"limits_wrt_el_refs": {"No User Chemical Potentials": empty_el_dict},
"elemental_refs": el_refs or empty_el_dict,
}
limit = "No User Chemical Potentials"
return chempots, limit
[docs]
def get_equilibrium_concentrations(
self,
temperature: float = 300,
chempots: dict | None = None,
limit: str | None = None,
el_refs: dict | None = None,
fermi_level: float | None = None,
per_charge: bool = True,
per_site: bool = False,
skip_formatting: bool = False,
site_competition: bool | str = True,
lean: bool = False,
) -> pd.DataFrame:
r"""
Compute the `equilibrium` concentrations (in cm^-3) for all
|DefectEntry|\s in the |DefectThermodynamics| object, at a given
chemical potential limit, Fermi level and temperature, assuming the
dilute limit approximation.
Note that these are the `equilibrium` defect concentrations!
``DefectThermodynamics.get_fermi_level_and_concentrations()`` can
instead be used to calculate the Fermi level and defect concentrations
for a material grown/annealed at higher temperatures and then cooled
(quenched) to room/operating temperature (where defect concentrations
are assumed to remain fixed) -- this is known as the frozen defect
approach and is typically the most valid approximation (see its
docstring for more information).
The degeneracy/multiplicity factor "g" is an important parameter in the
defect concentration equation, affecting the final concentration by up
to 2 orders of magnitude. This factor is taken from the product of the
``defect_entry.defect.multiplicity`` and
``defect_entry.degeneracy_factors`` attributes. Discussion in:
https://doi.org/10.1038/s41578-025-00879-y,
https://doi.org/10.1039/D2FD00043A, https://doi.org/10.1039/D3CS00432E.
Note that the ``FermiSolver`` class implements a number of convenience
methods for thermodynamic analyses; such as scanning over temperatures,
chemical potentials, effective dopant concentrations etc., minimising
or maximising a target property (e.g. defect/carrier concentration),
and also allowing greater control over constraints and approximations
in defect concentration calculations (i.e. fixed/variable defect(s) and
charge states), which may be useful. See the
:doc:`FermiSolver tutorial <fermisolver_tutorial>`.
Args:
temperature (float):
Temperature in Kelvin at which to calculate the equilibrium concentrations.
Default is 300 K.
chempots (dict):
Dictionary of chemical potentials to use for calculating the
defect formation energies (and thus concentrations). If
``None`` (default), will use ``self.chempots`` (= 0 for all
chemical potentials by default).. This can have the form of
``{"limits": [{'limit': [chempot_dict]}]}`` (the format
generated by ``doped``\'s chemical potential parsing functions
(see tutorials)) and specific limits (chemical potential
limits) can then be chosen using ``limit``.
Alternatively this can be a dictionary of chemical potentials
for a single limit, in the format:
``{element symbol: chemical potential}``.
If manually specifying chemical potentials this way, you can
set the ``el_refs`` option with the DFT reference energies of
the elemental phases, in which case it is the formal chemical
potentials (i.e. relative to the elemental references) that
should be given here, otherwise the absolute (DFT) chemical
potentials should be given.
One can also set ``DefectThermodynamics.chempots = ...`` (with
the same input options) to set the default chemical potentials
for all calculations.
limit (str):
The chemical potential limit for which to calculate formation
energies (and thus concentrations). Can be either:
- ``None``, if ``chempots`` corresponds to a single chemical
potential limit -- otherwise will use the first chemical
potential limit in the ``chempots`` dict.
- ``"X-rich"/"X-poor"`` where ``X`` is an element in the
system, in which case the most X-rich/poor limit will be used
(e.g. "Li-rich") -- see
:func:`~doped.chemical_potentials.get_X_rich_poor_limit`.
- A key in the ``(self.)chempots["limits"]`` dictionary.
The latter two options can only be used if ``chempots`` is in
the ``doped`` format (see chemical potentials tutorial).
(Default: None)
el_refs (dict):
Dictionary of elemental reference energies for the chemical
potentials in the format:
``{element symbol: reference energy}`` (to determine the formal
chemical potentials, when ``chempots`` has been manually
specified as ``{element symbol: chemical potential}``).
Unnecessary if ``chempots`` is provided/present in format
generated by ``doped`` (see tutorials).
One can also set ``DefectThermodynamics.el_refs = ...`` (with
the same input options) to set the default elemental reference
energies for all calculations.
(Default: None)
fermi_level (float):
Value corresponding to the electron chemical potential,
referenced to the VBM (using ``self.vbm``, which is the VBM of
the `bulk supercell` calculation by default). If ``None``
(default), set to the mid-gap Fermi level (E_g/2).
per_charge (bool):
Whether to break down the defect concentrations into individual
defect charge states (e.g. ``v_Cd_0``, ``v_Cd_-1``, ``v_Cd_-2``
instead of ``v_Cd``). Default is ``True``.
per_site (bool):
Whether to also return the concentrations as per-site
concentrations in percent (i.e. concentration divided by
``DefectEntry.bulk_site_concentration``). (default: ``False``)
skip_formatting (bool):
Whether to skip formatting the defect charge states and
concentrations as strings (and keep as ``int``\s and
``float``\s instead). (default: ``False``)
site_competition (bool | str):
If ``True`` (default), uses the updated Fermi-Dirac-like
formula for defect concentrations, which accounts for defect
site competition at high concentrations (see Kasamatsu et al.
(10.1016/j.ssi.2010.11.022) appendix for derivation -- updated
here to additionally account for configurational degeneracies
``g`` (see https://doi.org/10.1039/D3CS00432E)), which gives
the following defect concentration equation:
``N_X = N*[g*exp(-E/kT) / (1 + sum(g_i*exp(-E_i/kT)))]``
(https://doi.org/10.1038/s41578-025-00879-y,
https://doi.org/10.1021/jacs.5c07104) where ``i`` runs over
all defects which occupy the same site.
If ``False``, uses the standard dilute limit approximation.
Alternatively ``site_competition`` can be set to a string
(``"verbose"``), which will give the same behaviour as ``True``
as well as including the lattice site indices (used to
determine which defects will compete for the same sites) in the
output ``DataFrame``.
lean (bool):
Whether to return a leaner ``DataFrame`` with `only` the defect
name, charge state, and concentration in cm^-3 (assumes
``skip_formatting=True`` and ``per_site=False``). Mostly
intended for internal ``doped`` usage, to reduce compute times
when calculating defect concentrations repeatedly.
(default: ``False``)
Returns:
``pandas`` ``DataFrame`` of defect concentrations (and formation
energies) for each |DefectEntry| in the |DefectThermodynamics|
object.
"""
fermi_level = self._get_and_set_fermi_level(fermi_level)
chempots, el_refs = self._get_chempots(
chempots, el_refs
) # returns self.chempots/self.el_refs if chempots is None
skip_formatting = skip_formatting or lean
per_site = per_site and not lean
energy_concentration_list: list[dict[str, Any]] = []
chempots, limit = self._sanitise_chempots_for_concentrations(
chempots, el_refs, limit
) # warns about chempots/limit choices if necessary
# build reverse lookup for cluster numbers (avoids repeated linear scan in conc loop below):
_entry_to_cluster = {entry: k for k, v in self.clustered_defect_entries.items() for entry in v}
# Note: DataFrame initialisation from the list of dicts here actually ends up contributing a
# non-negligible compute cost (~10%), which could be made faster by using a dict of lists/arrays
# which is possible, but would make the code much less readable (e.g. for implementing site
# competition rescaling). Could be revisited if needed
for defect_name_wout_charge, defect_entry_list in self.all_entries.items():
for defect_entry in defect_entry_list:
formation_energy = defect_entry.formation_energy(
chempots=chempots,
limit=limit,
el_refs=el_refs,
fermi_level=fermi_level,
vbm=defect_entry.calculation_metadata.get("vbm", self.vbm),
)
raw_concentration = defect_entry.equilibrium_concentration(
chempots=chempots,
limit=limit,
el_refs=el_refs,
fermi_level=fermi_level,
vbm=defect_entry.calculation_metadata.get("vbm", self.vbm),
temperature=temperature,
per_site=False, # only concentration in cm^-3 here
formation_energy=formation_energy, # reduce compute times
site_competition=None if site_competition else False,
) # use standard dilute limit here, rescaling with site competition below if ``True``
per_site_concentration = (
raw_concentration / defect_entry.bulk_site_concentration
if (site_competition or per_site)
else None
) # only calculate if needed
charge = (
defect_entry.charge_state
if skip_formatting
else f"{'+' if defect_entry.charge_state > 0 else ''}{defect_entry.charge_state}"
)
energy_concentration_list.append(
{
"Defect": defect_name_wout_charge,
"Charge": charge,
"Concentration (cm^-3)": raw_concentration,
"Formation Energy (eV)": round(formation_energy, 3),
"Concentration (per site)": per_site_concentration,
"Lattice Site Index": _entry_to_cluster[defect_entry], # cluster index
}
)
if site_competition:
with np.errstate(divide="ignore", invalid="ignore"):
for cluster_number in self.clustered_defect_entries:
matching_concentration_dicts = [
concentration_dict
for concentration_dict in energy_concentration_list
if concentration_dict["Lattice Site Index"] == cluster_number
]
summed_per_site_concentration = sum(
float(concentration_dict["Concentration (per site)"])
for concentration_dict in matching_concentration_dicts
)
for concentration_dict in matching_concentration_dicts:
for conc_key in ["Concentration (per site)", "Concentration (cm^-3)"]:
concentration_dict[conc_key] = float(concentration_dict[conc_key]) / (
1 + summed_per_site_concentration
)
for concentration_dict in energy_concentration_list:
if not per_site:
concentration_dict.pop("Concentration (per site)")
if lean: # pop formation energy (& per-site conc, site idx) for pd.DataFrame init speed
concentration_dict.pop("Formation Energy (eV)")
if lean or not isinstance(site_competition, str):
concentration_dict.pop("Lattice Site Index")
conc_df = pd.DataFrame(energy_concentration_list)
# Note that in concentration / FermiSolver functions, we avoid altering the output ordering and
# try to just use the DefectThermodynamics entry ordering (which is already controlled) as is
conc_columns = [col for col in conc_df.columns if "Concentration" in col]
if per_charge:
if lean:
return conc_df # Defect/Charge not set as index w/lean=True & per_charge=False, for speed
conc_col = next(iter(conc_columns)) # either conc col can be used, this is cm^-3 as it's 1st
conc_df["Charge State Population"] = conc_df[conc_col] / conc_df.groupby("Defect")[
conc_col
].transform("sum")
conc_df["Charge State Population"] = conc_df["Charge State Population"].apply(
lambda x: f"{x:.2%}"
)
conc_df = conc_df.set_index(["Defect", "Charge"])
else: # group by defect and sum concentrations:
conc_df = _group_defect_charge_state_concentrations(conc_df)
for conc_col in conc_columns:
conc_df[conc_col] = conc_df[conc_col].apply(
lambda x, conc_col=conc_col: _format_concentration(
x,
per_site=("per site" in conc_col),
skip_formatting=skip_formatting,
)
)
return conc_df
def _parse_fermi_dos(
self, bulk_dos: PathLike | Vasprun | FermiDos | None = None, skip_dos_check: bool = False
) -> FermiDos | None:
if bulk_dos is None:
return None
fdos = None
if isinstance(bulk_dos, FermiDos):
fdos = bulk_dos
# most similar settings to Vasprun.eigenvalue_band_properties:
fdos_vbm = fdos.get_cbm_vbm(tol=1e-4, abs_tol=True)[1] # tol 1e-4 is lowest possible, as VASP
fdos_band_gap = fdos.get_gap(tol=1e-4, abs_tol=True) # rounds the DOS outputs to 4 dp
if isinstance(bulk_dos, PathLike):
bulk_dos = get_vasprun(bulk_dos, parse_dos=True) # converted to fdos in next block
if isinstance(bulk_dos, Vasprun): # either supplied Vasprun or parsed from string there
fdos_band_gap, _cbm, fdos_vbm, _ = bulk_dos.eigenvalue_band_properties
fdos = get_fermi_dos(bulk_dos)
if (
fdos
and not skip_dos_check
and (abs(fdos_vbm - self.vbm) > 0.05 or abs(fdos_band_gap - self.band_gap) > 0.05)
):
mismatching = "band gap" if abs(fdos_band_gap - self.band_gap) > 0.05 else "VBM eigenvalue"
warnings.warn(
f"The {mismatching} of the bulk DOS calculation (VBM = {fdos_vbm:.2f} eV, band gap = "
f"{fdos_band_gap:.2f} eV) differs by >0.05 eV from `DefectThermodynamics.vbm/gap` "
f"(VBM = {self.vbm:.2f} eV, band gap = {self.band_gap:.2f} eV; which are taken from the "
f"bulk supercell calculation by default, unless `bulk_band_gap_vr` is set during defect "
f"parsing). This can cause inaccuracies in thermodynamics & concentration analyses; see "
f"https://doped.readthedocs.io/en/latest/Tips.html#density-of-states-dos-calculations "
f"for advice.\n"
f"Note that the Fermi level will be always referenced to `DefectThermodynamics.vbm`!"
)
return fdos
[docs]
def get_equilibrium_fermi_level(
self,
bulk_dos: FermiDos | Vasprun | PathLike | None = None,
temperature: float = 300,
chempots: dict | None = None,
limit: str | None = None,
el_refs: dict | None = None,
return_concs: bool = False,
skip_dos_check: bool = False,
effective_dopant_concentration: float | None = None,
site_competition: bool = True,
) -> float | tuple[float, float, float]:
r"""
Calculate the self-consistent Fermi level, at a given chemical
potential limit and temperature, assuming `equilibrium` defect
concentrations (i.e. under annealing) and the dilute limit
approximation, by self-consistently solving for the Fermi level which
yields charge neutrality.
Note that the returned Fermi level is given relative to ``self.vbm``,
which is the VBM eigenvalue of the bulk supercell calculation by
default, unless ``bulk_band_gap_vr`` is set during defect parsing.
Note that this assumes `equilibrium` defect concentrations!
``DefectThermodynamics.get_fermi_level_and_concentrations()`` can
instead be used to calculate the Fermi level and defect concentrations
for a material grown/annealed at higher temperatures and then cooled
(quenched) to room/operating temperature (where defect concentrations
are assumed to remain fixed) -- this is known as the frozen defect
approach and is typically the most valid approximation (see its
docstring for more information).
Note that the bulk DOS calculation should be well-converged with
respect to `k`-points for accurate Fermi level predictions!
The degeneracy/multiplicity factor "g" is an important parameter in the
defect concentration equation, affecting the final concentration by up
to 2 orders of magnitude. This factor is taken from the product of the
``defect_entry.defect.multiplicity`` and
``defect_entry.degeneracy_factors`` attributes. Discussion in:
https://doi.org/10.1038/s41578-025-00879-y,
https://doi.org/10.1039/D2FD00043A, https://doi.org/10.1039/D3CS00432E.
Note that the ``FermiSolver`` class implements a number of convenience
methods for thermodynamic analyses; such as scanning over temperatures,
chemical potentials, effective dopant concentrations etc, minimising or
maximising a target property (e.g. defect/carrier concentration), and
also allowing greater control over constraints and approximations in
defect concentration calculations (i.e. fixed/variable defect(s) and
charge states), which may be useful. See the
:doc:`FermiSolver tutorial <fermisolver_tutorial>`.
Args:
bulk_dos (FermiDos or |Vasprun| or PathLike):
``pymatgen`` ``FermiDos`` for the bulk electronic density of
states (DOS), for calculating carrier concentrations.
Alternatively, can be a ``pymatgen`` |Vasprun| object or path
to the ``vasprun.xml(.gz)`` output of a bulk DOS calculation in
VASP -- however this will be much slower when looping over many
conditions as it will re-parse the DOS each time! (So
preferably set ``DefectThermodynamics.bulk_docs = ...`` or upon
initialisation: ``DefectThermodynamics(..., bulk_dos=...)``.
Usually this is a static calculation with the `primitive` cell
of the bulk material, with relatively dense `k`-point sampling
(especially for materials with disperse band edges) to ensure
an accurately-converged DOS and thus Fermi level. Using large
``NEDOS`` (>3000) and ``ISMEAR = -5`` (tetrahedron smearing)
are recommended for best convergence (wrt `k`-point sampling)
in VASP. Consistent functional settings should be used for the
bulk DOS and defect supercell calculations. See the
:ref:`Tips:Density of States (DOS) Calculations` section.
``bulk_dos`` can also be left as ``None`` (default), if it has
previously been provided and parsed, and thus is set as the
``self.bulk_dos`` attribute. If provided, will overwrite the
``self.bulk_dos`` attribute!
temperature (float):
Temperature in Kelvin at which to calculate the equilibrium
Fermi level. Default is 300 K.
chempots (dict):
Dictionary of chemical potentials to use for calculating the
defect formation energies (and thus concentrations and Fermi
level). If ``None`` (default), will use ``self.chempots``
(= 0 for all chemical potentials by default). This can have the
form of ``{"limits": [{'limit': [chempot_dict]}]}`` (the format
generated by ``doped``\'s chemical potential parsing functions
(see tutorials)) and specific limits (chemical potential
limits) can then be chosen using ``limit``.
Alternatively this can be a dictionary of chemical potentials
for a single limit, in the format:
``{element symbol: chemical potential}``.
If manually specifying chemical potentials this way, you can
set the ``el_refs`` option with the DFT reference energies of
the elemental phases, in which case it is the formal chemical
potentials (i.e. relative to the elemental references) that
should be given here, otherwise the absolute (DFT) chemical
potentials should be given.
One can also set ``DefectThermodynamics.chempots = ...`` (with
the same input options) to set the default chemical potentials
for all calculations.
limit (str):
The chemical potential limit for which to determine the
equilibrium Fermi level. Can be either:
- ``None``, if ``chempots`` corresponds to a single chemical
potential limit -- otherwise will use the first chemical
potential limit in the ``chempots`` dict.
- ``"X-rich"/"X-poor"`` where ``X`` is an element in the
system, in which case the most X-rich/poor limit will be used
(e.g. "Li-rich") -- see
:func:`~doped.chemical_potentials.get_X_rich_poor_limit`.
- A key in the ``(self.)chempots["limits"]`` dictionary.
The latter two options can only be used if ``chempots`` is in
the ``doped`` format (see chemical potentials tutorial).
(Default: None)
el_refs (dict):
Dictionary of elemental reference energies for the chemical
potentials in the format:
``{element symbol: reference energy}`` (to determine the formal
chemical potentials, when ``chempots`` has been manually
specified as ``{element symbol: chemical potential}``).
Unnecessary if ``chempots`` is provided/present in format
generated by ``doped`` (see tutorials).
One can also set ``DefectThermodynamics.el_refs = ...`` (with
the same input options) to set the default elemental reference
energies for all calculations.
(Default: None)
return_concs (bool):
Whether to return the corresponding electron and hole
concentrations (in cm^-3) as well as the Fermi level.
Default is ``False``.
skip_dos_check (bool):
Whether to skip the warning about the DOS VBM differing from
``self.vbm`` by >0.05 eV. Should only be used when the reason
for this difference is known/acceptable. (default: ``False``)
effective_dopant_concentration (float):
Fixed concentration (in cm^-3) of an arbitrary dopant/impurity
in the material to include in the charge neutrality condition,
in order to analyse the Fermi level / doping response under
hypothetical doping conditions. If a negative value is given,
the dopant is assumed to be an acceptor dopant (i.e. negative
defect charge state), while a positive value corresponds to
donor doping. For dopants of charge ``q``, the input value
should be ``q * 'Dopant Concentration'``.
(Default: None; no extrinsic dopant)
site_competition (bool):
If ``True`` (default), uses the updated Fermi-Dirac-like
formula for defect concentrations, which accounts for defect
site competition at high concentrations (see Kasamatsu et al.
(10.1016/j.ssi.2010.11.022) appendix for derivation -- updated
here to additionally account for configurational degeneracies
``g`` (see https://doi.org/10.1039/D3CS00432E)), which gives
the following defect concentration equation:
``N_X = N*[g*exp(-E/kT) / (1 + sum(g_i*exp(-E_i/kT)))]``
(https://doi.org/10.1038/s41578-025-00879-y,
https://doi.org/10.1021/jacs.5c07104) where ``i`` runs over
all defects which occupy the same site.
If ``False``, uses the standard dilute limit approximation.
Returns:
Self consistent Fermi level (in eV from the VBM (``self.vbm``)),
and the corresponding electron and hole concentrations (in cm^-3)
if ``return_concs=True``.
"""
if bulk_dos is not None:
self._bulk_dos = self._parse_fermi_dos(bulk_dos, skip_dos_check=skip_dos_check)
if self.bulk_dos is None: # none provided, and none previously set
raise ValueError(
"No bulk DOS calculation (`bulk_dos`) provided or previously parsed to "
"`DefectThermodynamics.bulk_dos`, which is required for calculating carrier "
"concentrations and solving for Fermi level position."
)
chempots, el_refs = self._get_chempots(
chempots, el_refs
) # returns self.chempots/self.el_refs if chempots is None
# handle chempot warning(s) once here, as otherwise brentq calls this function many times:
chempots, limit = self._sanitise_chempots_for_concentrations(
chempots, el_refs, limit
) # warns about chempots/limit choices if necessary
def _get_total_q(fermi_level):
conc_df = self.get_equilibrium_concentrations(
chempots=chempots,
limit=limit,
el_refs=el_refs,
temperature=temperature,
fermi_level=fermi_level,
site_competition=site_competition,
lean=True,
)
# add effective dopant concentration if supplied:
conc_df = _add_effective_dopant_concentration(conc_df, effective_dopant_concentration)
# Defect/Charge not set as index w/lean=True & per_charge=False, for speed
qd_tot = (conc_df["Charge"] * conc_df["Concentration (cm^-3)"]).sum()
qd_tot += self.bulk_dos.get_doping(fermi_level=fermi_level + self.vbm, temperature=temperature)
return qd_tot
assert isinstance(self.band_gap, float) # typing
assert isinstance(self.vbm, float) # typing
eq_fermi_level: float = brentq(_get_total_q, -4.0, self.band_gap + 4.0)
if return_concs:
e_conc, h_conc = self.bulk_dos.get_e_h_concs(eq_fermi_level + self.vbm, temperature)
return eq_fermi_level, e_conc, h_conc
return eq_fermi_level
[docs]
def get_fermi_level_and_concentrations(
self,
bulk_dos: FermiDos | Vasprun | PathLike | None = None,
annealing_temperature: float = 1000,
quenched_temperature: float = 300,
chempots: dict | None = None,
limit: str | None = None,
el_refs: dict | None = None,
delta_VBM: float | Callable = 0.0,
delta_CBM: float | Callable = 0.0,
delta_gap: float | Callable = 0.0,
per_charge: bool = True,
per_site: bool = False,
skip_formatting: bool = False,
return_annealing_values: bool = False,
effective_dopant_concentration: float | None = None,
site_competition: bool | str = True,
**kwargs,
) -> (
tuple[float, float, float, pd.DataFrame]
| tuple[float, float, float, pd.DataFrame, float, float, float, pd.DataFrame]
):
r"""
Calculate the self-consistent Fermi level and corresponding
carrier/defect calculations, for a given chemical potential limit,
annealing temperature and quenched/operating temperature, using the
frozen defect and dilute limit approximations under the constraint of
charge neutrality.
This function works by calculating the self-consistent Fermi level and
total concentration of each defect type at the annealing temperature,
then fixing the total concentrations to these values and re-calculating
the self-consistent (constrained equilibrium) Fermi level and relative
charge state concentrations under this constraint at the quenched /
operating temperature.
According to the 'frozen defect' approximation, we typically expect
defect concentrations to reach equilibrium during annealing/crystal
growth (at elevated temperatures), but `not` upon quenching (i.e. at
room/operating temperature) where we expect kinetic inhibition of
defect annihilation and hence non-equilibrium defect concentrations /
Fermi level. Typically, this is approximated by computing the
equilibrium Fermi level and defect concentrations at the annealing
temperature, and then assuming the total concentration of each defect
is fixed to this value, but that the relative populations of defect
charge states (and the Fermi level) can re-equilibrate at the lower
(room) temperature. See https://doi.org/10.1038/s41578-025-00879-y
(brief discussion), https://doi.org/10.1016/j.cpc.2019.06.017 (detailed)
and ``doped`` tutorials for more information. In certain
cases (such as Li-ion battery materials or extremely slow charge
capture/emission), these approximations may have to be adjusted such
that some defects/charge states are considered fixed and some are
allowed to re-equilibrate (e.g. highly mobile Li vacancies /
interstitials). The ``FermiSolver`` class can be used in these cases
for more fine-grained control over constraints and approximations in
defect concentration calculations, as demonstrated in the
:doc:`FermiSolver tutorial <fermisolver_tutorial>`.
Note that different defect entries (different charge states, and/or
ground and metastable states (different spin or geometries); e.g.
interstitials at a given site) are grouped together in distinct defect
types according to ``self.dist_tol`` , which is also used in plotting
and transition level analysis. In the frozen defect approximation here,
the total concentration of a given defect type group is calculated at
the annealing temperature, and then the equilibrium relative population
of the constituent entries is recalculated at the quenched temperature.
Note that the bulk DOS calculation should be well-converged with
respect to `k`-points for accurate Fermi level predictions!
The degeneracy/multiplicity factor "g" is an important parameter in the
defect concentration equation, affecting the final concentration by up
to 2 orders of magnitude. This factor is taken from the product of the
``defect_entry.defect.multiplicity`` and
``defect_entry.degeneracy_factors`` attributes. Discussion in:
https://doi.org/10.1038/s41578-025-00879-y,
https://doi.org/10.1039/D2FD00043A, https://doi.org/10.1039/D3CS00432E.
Note that, in addition to finer-grained control over constraints and
approximations as mentioned above, the ``FermiSolver`` class implements
a number of convenience methods for thermodynamic analyses; such as
scanning over temperatures, chemical potentials, effective dopant
concentrations etc., minimising or maximising a target property (e.g.
defect/carrier concentration), which may be useful -- see tutorial.
Args:
bulk_dos (FermiDos or |Vasprun| or PathLike):
``pymatgen`` ``FermiDos`` for the bulk electronic density of
states (DOS), for calculating carrier concentrations.
Alternatively, can be a ``pymatgen`` |Vasprun| object or path
to the ``vasprun.xml(.gz)`` output of a bulk DOS calculation in
VASP -- however this will be much slower when looping over many
conditions as it will re-parse the DOS each time! (So
preferably set ``DefectThermodynamics.bulk_docs = ...`` or upon
initialisation: ``DefectThermodynamics(..., bulk_dos=...)``.
Usually this is a static calculation with the `primitive` cell
of the bulk material, with relatively dense `k`-point sampling
(especially for materials with disperse band edges) to ensure
an accurately-converged DOS and thus Fermi level. Using large
``NEDOS`` (>3000) and ``ISMEAR = -5`` (tetrahedron smearing)
are recommended for best convergence (wrt `k`-point sampling)
in VASP. Consistent functional settings should be used for the
bulk DOS and defect supercell calculations. See the
:ref:`Tips:Density of States (DOS) Calculations` section.
``bulk_dos`` can also be left as ``None`` (default), if it has
previously been provided and parsed, and thus is set as the
``self.bulk_dos`` attribute. If provided, will overwrite the
``self.bulk_dos`` attribute!
annealing_temperature (float):
Temperature in Kelvin at which to calculate the high
temperature (fixed) total defect concentrations, which should
correspond to the highest temperature during annealing /
synthesis of the material (at which we assume equilibrium
defect concentrations) within the frozen defect approach.
Default is 1000 K.
quenched_temperature (float):
Temperature in Kelvin at which to calculate the self-consistent
(constrained equilibrium) Fermi level and carrier
concentrations, given the fixed total concentrations, which
should correspond to operating temperature of the material
(typically room temperature). Default is 300 K.
chempots (dict):
Dictionary of chemical potentials to use for calculating the
defect formation energies (and thus concentrations and Fermi
level). If ``None`` (default), will use ``self.chempots``. This
can have the form of
``{"limits": [{'limit': [chempot_dict]}]}`` (the format
generated by ``doped``\'s chemical potential parsing functions
(see tutorials)) and specific limits (chemical potential
limits) can then be chosen using ``limit``.
Alternatively this can be a dictionary of chemical potentials
for a single limit, in the format:
``{element symbol: chemical potential}``.
If manually specifying chemical potentials this way, you can
set the ``el_refs`` option with the DFT reference energies of
the elemental phases, in which case it is the formal chemical
potentials (i.e. relative to the elemental references) that
should be given here, otherwise the absolute (DFT) chemical
potentials should be given.
One can also set ``DefectThermodynamics.chempots = ...`` (with
the same input options) to set the default chemical potentials
for all calculations.
limit (str):
The chemical potential limit for which to calculate formation
energies (and thus concentrations and Fermi level). Can be:
- ``None``, if ``chempots`` corresponds to a single chemical
potential limit -- otherwise will use the first chemical
potential limit in the ``chempots`` dict.
- ``"X-rich"/"X-poor"`` where ``X`` is an element in the
system, in which case the most X-rich/poor limit will be used
(e.g. "Li-rich") -- see
:func:`~doped.chemical_potentials.get_X_rich_poor_limit`.
- A key in the ``(self.)chempots["limits"]`` dictionary.
The latter two options can only be used if ``chempots`` is in
the ``doped`` format (see chemical potentials tutorial).
(Default: None)
el_refs (dict):
Dictionary of elemental reference energies for the chemical
potentials in the format:
``{element symbol: reference energy}`` (to determine the formal
chemical potentials, when ``chempots`` has been manually
specified as ``{element symbol: chemical potential}``).
Unnecessary if ``chempots`` is provided/present in format
generated by ``doped`` (see tutorials).
One can also set ``DefectThermodynamics.el_refs = ...`` (with
the same input options) to set the default elemental reference
energies for all calculations.
(Default: None)
delta_VBM (float | Callable):
Rigid shift (in eV; positive = upshift) to apply to the VBM
position `at the annealing temperature`, e.g. to model
temperature-dependent band edges. If set, applies a scissor
correction to ``fermi_dos`` (rigidly shifting the valence band)
while defect levels remain fixed. Can be a value or a function
of annealing temperature, e.g. ``lambda T: -2e-4*T``. Default
is ``0.0`` (no VBM shift).
delta_CBM (float | Callable):
Rigid shift (in eV; positive = upshift) to apply to the CBM
position `at the annealing temperature`, e.g. to model
temperature-dependent band edges. If set, applies a scissor
correction to ``fermi_dos`` (rigidly shifting the conduction
band) while defect levels remain fixed. Can be a value or a
function of annealing temperature, e.g. ``lambda T: -2e-4*T``.
Default is ``0.0`` (no CBM shift).
delta_gap (float | Callable):
(Deprecated, to be removed in v4.1; use ``delta_VBM``/
``delta_CBM`` instead.) Change in band gap (in eV) of the
host material at the annealing temperature, assuming symmetric
renormalisation about the VBM and CBM (i.e. assuming equal
up/downshifts of the band-edges around their original
eigenvalues -> ``delta_VBM = -delta_gap/2``,
``delta_CBM = +delta_gap/2``. Default is ``0.0`` (no shift).
``delta_VBM`` and ``delta_CBM`` take priority over
``delta_gap``.
per_charge (bool):
Whether to break down the defect concentrations into individual
defect charge states (e.g. ``v_Cd_0``, ``v_Cd_-1``, ``v_Cd_-2``
instead of ``v_Cd``). Default is ``True``.
per_site (bool):
Whether to also return the concentrations as per-site
concentrations in percent (i.e. concentration divided by
``DefectEntry.bulk_site_concentration``). (default: ``False``)
skip_formatting (bool):
Whether to skip formatting the defect charge states and
concentrations as strings (and keep as ``int``\s and
``float``\s instead). (default: ``False``)
return_annealing_values (bool):
If True, also returns the Fermi level, electron and hole
concentrations and defect concentrations at the annealing
temperature. The Fermi level is given with respect to
``DefectThermodynamics.vbm`` (i.e. the quenched VBM, same
reference as the returned (quenched) Fermi level). Default is
``False``.
effective_dopant_concentration (float):
Fixed concentration (in cm^-3) of an arbitrary dopant/impurity
in the material to include in the charge neutrality condition,
in order to analyse the Fermi level / doping response under
hypothetical doping conditions. If a negative value is given,
the dopant is assumed to be an acceptor dopant (i.e. negative
defect charge state), while a positive value corresponds to
donor doping. For dopants of charge ``q``, the input value
should be ``q * 'Dopant Concentration'``.
(Default: None; no extrinsic dopant)
site_competition (bool | str):
If ``True`` (default), uses the updated Fermi-Dirac-like
formula for defect concentrations, which accounts for defect
site competition at high concentrations (see Kasamatsu et al.
(10.1016/j.ssi.2010.11.022) appendix for derivation -- updated
here to additionally account for configurational degeneracies
``g`` (see https://doi.org/10.1039/D3CS00432E)), which gives
the following defect concentration equation:
``N_X = N*[g*exp(-E/kT) / (1 + sum(g_i*exp(-E_i/kT)))]``
(https://doi.org/10.1038/s41578-025-00879-y,
https://doi.org/10.1021/jacs.5c07104) where ``i`` runs over
all defects which occupy the same site.
If ``False``, uses the standard dilute limit approximation.
Alternatively ``site_competition`` can be set to a string
(``"verbose"``), which will give the same behaviour as ``True``
as well as including the lattice site indices (used to
determine which defects will compete for the same sites) in the
output ``DataFrame``.
**kwargs:
Additional keyword arguments to pass to ``scissor_dos`` (if
``delta_VBM``, ``delta_CBM`` or ``delta_gap`` are not 0) or
``_parse_fermi_dos`` (``skip_dos_check``; to skip the warning
about the DOS VBM differing from ``self.vbm`` by >0.05 eV;
default is ``False``).
Returns:
Predicted quenched Fermi level (in eV from the VBM (``self.vbm``)),
the corresponding electron and hole concentrations (in cm^-3) and a
dataframe of the quenched defect concentrations (in cm^-3);
``(fermi_level, e_conc, h_conc, conc_df)``.
If ``return_annealing_values=True``, `also` returns the annealing
Fermi level, electron and hole concentrations and a dataframe of
the annealing defect concentrations (in cm^-3);
``(...conc_df, A_fermi_level, A_e_conc, A_h_conc, A_conc_df)``
where ``A_...`` is the property at the annealing temperature.
"""
if kwargs and any(i not in ["verbose", "tol", "skip_dos_check"] for i in kwargs):
raise ValueError(f"Invalid keyword arguments: {', '.join(kwargs.keys())}")
if bulk_dos is not None:
self._bulk_dos = self._parse_fermi_dos(
bulk_dos, skip_dos_check=kwargs.get("skip_dos_check", False)
)
if self.bulk_dos is None: # none provided, and none previously set
raise ValueError(
"No bulk DOS calculation (`bulk_dos`) provided or previously parsed to "
"`DefectThermodynamics.bulk_dos`, which is required for calculating carrier "
"concentrations and solving for Fermi level position."
)
orig_fermi_dos = deepcopy(self.bulk_dos) # can get modified during annealing loops
chempots, el_refs = self._get_chempots(
chempots, el_refs
) # returns self.chempots/self.el_refs if chempots is None
# handle chempot warning(s) once here, as otherwise brentq calls this function many times:
chempots, limit = self._sanitise_chempots_for_concentrations(
chempots, el_refs, limit
) # warns about chempots/limit choices if necessary
annealing_dos = (
self.bulk_dos
if all(i == 0 for i in [delta_VBM, delta_CBM, delta_gap])
else scissor_dos(
dos=self.bulk_dos,
delta_gap=delta_gap if not callable(delta_gap) else delta_gap(annealing_temperature),
delta_VBM=delta_VBM if not callable(delta_VBM) else delta_VBM(annealing_temperature),
delta_CBM=delta_CBM if not callable(delta_CBM) else delta_CBM(annealing_temperature),
verbose=kwargs.get("verbose", False),
tol=kwargs.get("tol", 1e-8),
)
)
annealing_fermi_level = self.get_equilibrium_fermi_level(
annealing_dos,
chempots=chempots,
limit=limit,
el_refs=el_refs,
temperature=annealing_temperature,
return_concs=False,
skip_dos_check=True, # already warned if necessary
effective_dopant_concentration=effective_dopant_concentration,
site_competition=bool(site_competition),
)
assert not isinstance(annealing_fermi_level, tuple) # float w/ return_concs=False, for typing
self._bulk_dos = orig_fermi_dos # reset to original DOS for quenched calculations
annealing_defect_concentrations = self.get_equilibrium_concentrations(
chempots=chempots,
limit=limit,
el_refs=el_refs,
fermi_level=annealing_fermi_level,
temperature=annealing_temperature,
per_charge=False, # give total concentrations for each defect
site_competition=site_competition,
lean=True,
)
annealing_defect_concentrations = _add_effective_dopant_concentration(
annealing_defect_concentrations, effective_dopant_concentration
) # add effective dopant concentration if supplied
total_concentrations = dict( # {Defect: Total Concentration (cm^-3)}
zip(
annealing_defect_concentrations.index, # index is Defect name, when per_charge=False
annealing_defect_concentrations["Concentration (cm^-3)"],
strict=False,
)
)
get_constrained_concentrations = partial(
self._get_constrained_concentrations,
total_concentrations=total_concentrations,
temperature=quenched_temperature,
chempots=chempots,
limit=limit,
el_refs=el_refs,
per_charge=per_charge,
per_site=per_site,
skip_formatting=skip_formatting,
effective_dopant_concentration=effective_dopant_concentration,
site_competition=site_competition,
lean=False,
)
def _get_constrained_total_q(fermi_level):
conc_df = get_constrained_concentrations(
fermi_level, per_charge=True, per_site=False, skip_formatting=True, lean=True
)
# Defect/Charge not set as index w/lean=True (default), for speed
qd_tot = (conc_df["Charge"] * conc_df["Concentration (cm^-3)"]).sum()
return qd_tot + orig_fermi_dos.get_doping( # use orig fermi dos for quenched temperature
fermi_level=fermi_level + self.vbm,
temperature=quenched_temperature,
)
assert isinstance(self.band_gap, float) # typing
assert isinstance(self.vbm, float) # typing
eq_fermi_level: float = brentq(_get_constrained_total_q, -10.0, self.band_gap + 10.0)
e_conc, h_conc = orig_fermi_dos.get_e_h_concs(eq_fermi_level + self.vbm, quenched_temperature)
conc_df = get_constrained_concentrations(eq_fermi_level) # not lean for output
if not return_annealing_values:
return (eq_fermi_level, e_conc, h_conc, conc_df)
annealing_e_conc, annealing_h_conc = annealing_dos.get_e_h_concs(
annealing_fermi_level + self.vbm, annealing_temperature
)
annealing_defect_concentrations = get_constrained_concentrations(
annealing_fermi_level, temperature=annealing_temperature
)
return (
eq_fermi_level,
e_conc,
h_conc,
conc_df,
annealing_fermi_level,
annealing_e_conc,
annealing_h_conc,
annealing_defect_concentrations,
)
[docs]
def get_quenched_fermi_level_and_concentrations(self, *args, **kwargs):
r"""
Deprecated alias for :meth:`get_fermi_level_and_concentrations`.
.. deprecated:: 4.0
Use :meth:`get_fermi_level_and_concentrations` instead; this name will be removed in v4.1.
"""
warnings.warn(
"`DefectThermodynamics.get_quenched_fermi_level_and_concentrations` is deprecated "
"and will be removed in the next minor release (v4.1); use "
"`DefectThermodynamics.get_fermi_level_and_concentrations` instead.",
DeprecationWarning,
stacklevel=2,
)
return self.get_fermi_level_and_concentrations(*args, **kwargs)
def _get_constrained_concentrations(
self,
fermi_level: float,
total_concentrations: dict[str, float],
temperature: float = 300,
chempots: dict | None = None,
limit: str | None = None,
el_refs: dict | None = None,
per_charge: bool = True,
per_site: bool = False,
skip_formatting: bool = True,
effective_dopant_concentration: float | None = None,
site_competition: bool | str = True,
lean: bool = True,
) -> pd.DataFrame:
"""
Convenience method to calculate defect populations under constrained
equilibrium, where total defect concentrations are fixed (according to
``total_concentrations``, which should be provided in the format:
``{defect name: concentration in cm^-3}``) and their relative charge
state populations are re-calculated at the quenched temperature
(``temperature``).
See ``DefectThermodynamics.get_fermi_level_and_concentrations()`` for
details.
"""
conc_df = self.get_equilibrium_concentrations(
chempots=chempots,
limit=limit,
el_refs=el_refs,
temperature=temperature,
fermi_level=fermi_level,
per_charge=per_charge,
per_site=per_site,
skip_formatting=True,
site_competition=site_competition,
lean=lean,
)
defects = conc_df["Defect"] if lean else conc_df.index.get_level_values("Defect")
unconstrained_total_concentrations = conc_df.groupby("Defect")["Concentration (cm^-3)"].transform(
"sum"
)
# set total concentration to match annealing concentrations but with same relative concentrations
conc_columns = [col for col in conc_df.columns if "Concentration" in col]
conc_df["Total Concentration (cm^-3)"] = defects.map(total_concentrations)
for conc_col in conc_columns: # doesn't include Total Concentration
conc_df[conc_col] *= (
conc_df["Total Concentration (cm^-3)"] / unconstrained_total_concentrations
)
if not per_charge:
conc_df = _group_defect_charge_state_concentrations(conc_df)
# drop "Total Concentration" as it's a duplicate of "Concentration" ``per_charge=False``:
conc_df = conc_df.drop(columns=["Total Concentration (cm^-3)"])
conc_df = _add_effective_dopant_concentration(conc_df, effective_dopant_concentration)
if not skip_formatting:
conc_columns = [col for col in conc_df.columns if "Concentration" in col]
for conc_col in conc_columns: # Now includes Total Concentration (if present)
conc_df[conc_col] = conc_df[conc_col].apply(
lambda x, conc_col=conc_col: _format_concentration(
x,
per_site=("per site" in conc_col),
skip_formatting=skip_formatting,
)
)
if per_charge: # format charge states
conc_df.index = conc_df.index.set_levels(
conc_df.index.levels[1].map(
lambda q: f"{'+' if q > 0 else ''}{int(q) if np.isclose(q, int(q)) else q}"
),
level=1,
)
return conc_df
def _get_in_gap_fermi_level_stability_window(self, defect_entry: str | DefectEntry) -> float:
"""
Convenience method to calculate the maximum difference between a Fermi
level at which ``defect_entry`` is the ground-state charge state, and
the band edges.
i.e. taken from the minimum of (CBM - lowest TL) and (highest TL - VBM)
where TL is any transition level involving ``defect_entry``.
Args:
defect_entry (str or |DefectEntry|):
Either a string of the defect entry name (in
``DefectThermodynamics.defect_entries``), or a
|DefectEntry| object.
Returns:
float:
Maximum difference between Fermi levels at which
``defect_entry`` is the ground-state charge state, and the band
edges.
"""
if not isinstance(defect_entry, DefectEntry):
defect_entry = self.defect_entries[defect_entry]
assert isinstance(defect_entry, DefectEntry)
stable_entries_name_dict = {
name: [ent.name for ent in entry_list] for name, entry_list in self.stable_entries.items()
}
if not any(defect_entry.name in name_list for name_list in stable_entries_name_dict.values()):
return -np.inf
grouped_defect_name_wout_charge = next(
name
for name in stable_entries_name_dict
if defect_entry.name in stable_entries_name_dict[name]
)
# get highest and lowest TL (defining stability window):
lowest = np.inf
highest = -np.inf
for tl, charge_list in self.transition_level_map[grouped_defect_name_wout_charge].items():
if charge_list[-1] == defect_entry.charge_state:
lowest = tl
if charge_list[0] == defect_entry.charge_state:
highest = tl
if lowest == np.inf and highest == -np.inf:
# no TLs and already checked stable -> only stable charge state
return np.inf
assert self.band_gap is not None
stability_windows = np.array([self.band_gap - lowest, highest])
return min(stability_windows[np.isfinite(stability_windows)])
[docs]
def plot_chempot_heatmap(
self,
dependent_element: str | Element | None = None,
fixed_elements: dict[str, float] | None = None,
bordering_phases: bool = True,
xlim: tuple[float, float] | None = None,
ylim: tuple[float, float] | None = None,
cbar_range: tuple[float, float] | None = None,
colormap: str | colors.Colormap | None = None,
padding: float | None = None,
title: str | bool = False,
label_positions: bool | dict[str, tuple[float, float]] | list[tuple[float, float]] = True,
filename: PathLike | None = None,
style_file: PathLike | None = None,
**kwargs,
) -> plt.Figure:
"""
Plot a heatmap of the chemical potentials (in
``DefectThermodynamics.chempots``) for a multinary system, showing
bordering secondary phases.
In this plot, the ``dependent_element`` chemical potential is plotted
as a heatmap over the stability region of the host composition, as a
function of two other elemental chemical potentials on the x and y
axes.
3-D data is required to plot a 2-D heatmap, and so this function can be
applied as-is for ternary systems, but for higher-dimensional systems
a set of chemical potential constraints must be provided (as
``fixed_elements``) to project the chemical stability region to 3-D;
see the competing phases tutorial section
:ref:`chemical_potentials_tutorial:Analysing and visualising the chemical potential limits`.
Note that due to an issue with ``matplotlib`` ``Stroke`` path effects,
sometimes there can be odd holes in the whitespace around the chemical
formula labels (see:
https://github.com/matplotlib/matplotlib/issues/25669).
This is only the case for ``png`` output, so saving to e.g. ``svg``
or ``pdf`` instead will avoid this issue.
If using the default colour map (``batlow``) in publications, please
consider citing: https://zenodo.org/records/8409685
Tip: https://github.com/frssp/cplapy can be used to generate 3D plots
of chemical stability regions, to show the bordering competing phases
in quaternary systems.
Args:
dependent_element (str or Element):
The element for which the chemical potential is plotted as a
heatmap. If None (default), the last element in the bulk
composition formula is used (which corresponds to the most
electronegative element present).
fixed_elements (dict):
A dictionary of chemical potentials to fix (in the format:
``{element: value}``; e.g. ``{"Li": -2}``) if the chemical
system is >3-D. Constraining chemical potentials is required for
multinary systems, in order to reduce the dimensionality to 3-D
for plotting a 2-D heatmap. For a system with N elements, N-3
fixed chemical potentials should be specified. If ``None``
(default), the chemical potentials of the first N-3 elements in
the bulk composition are fixed to their mean values in the
stability region (i.e. the centroid of the stability region).
bordering_phases (bool):
Whether to plot the competing/secondary phases which border the
host composition in the chemical potential diagram.
Default is ``True``.
xlim (tuple):
The x-axis limits for the plot. If None (default), the limits
are set to the minimum and maximum values of the x-axis data,
with padding equal to ``padding`` (default = 10% of the range).
ylim (tuple):
The y-axis limits for the plot. If None (default), the limits
are set to the minimum and maximum values of the y-axis data,
with padding equal to ``padding`` (default = 10% of the range).
cbar_range (tuple):
The range for the colourbar. If None (default), the range is
set to the minimum and maximum values of the data.
colormap (str, matplotlib.colors.Colormap):
Colormap to use for the heatmap, either as a string (which can
be a colormap name from https://www.fabiocrameri.ch/colourmaps
/ https://matplotlib.org/stable/users/explain/colors/colormaps)
or a ``Colormap`` / ``ListedColormap`` object. If ``None``
(default), uses ``batlow`` from
https://www.fabiocrameri.ch/colourmaps.
Append "S" to the colormap name if using a sequential colormap
from https://www.fabiocrameri.ch/colourmaps.
padding (float):
The padding to add to the x and y axis limits. If ``None``
(default), the padding is set to 10% of the range.
title (str or bool):
The title for the plot. If ``False`` (default), no title is
added. If ``True``, the title is set to the bulk composition
formula, or if ``str``, the title is set to the provided
string.
label_positions (bool, dict or list):
The positions for the chemical formula line labels. If ``True``
(default), the labels are placed using a custom ``doped``
algorithm which attempts to find the best possible positions
(minimising overlap). If ``False``, no labels are added.
Alternatively a dictionary can be provided, where the keys are
the chemical formulae and the values are tuples of
``(x_coord, y-offset)`` at which to place the line labels
(where y-offset is the offset from the line at ``x=x_coord``).
A list of tuples can also be provided, where the order is
assumed to match the competing phase lines.
filename (PathLike):
The filename to save the plot to. If ``None`` (default), the
plot is not saved.
style_file (PathLike):
Path to a mplstyle file to use for the plot. If ``None``
(default), uses the default ``doped`` style (from
``doped/utils/doped.mplstyle``).
**kwargs:
Additional keyword arguments to pass to
``ChemicalPotentialDiagram.get_grid()``, such as ``n_points``
(default = 1000) and ``cartesian`` (default = ``False``).
Returns:
plt.Figure: The ``matplotlib`` ``Figure`` object.
"""
if not self.chempots:
raise ValueError("No chemical potentials in DefectThermodynamics.chempots to plot!")
return plot_chempot_heatmap(
self.chempots,
composition=self.bulk_formula,
dependent_element=dependent_element,
fixed_elements=fixed_elements,
bordering_phases=bordering_phases,
xlim=xlim,
ylim=ylim,
cbar_range=cbar_range,
colormap=colormap,
padding=padding,
title=title,
label_positions=label_positions,
filename=filename,
style_file=style_file,
**kwargs,
)
def __repr__(self):
"""
Returns a string representation of the |DefectThermodynamics| object.
"""
formula = _get_bulk_supercell(
next(iter(self.defect_entries.values()))
).composition.get_reduced_formula_and_factor(iupac_ordering=True)[0]
properties, methods = _doped_obj_properties_methods(self)
return (
f"doped DefectThermodynamics for bulk composition {formula} with {len(self.defect_entries)} "
f"defect entries (in self.defect_entries). Available attributes:\n"
f"{properties}\n\nAvailable methods:\n{methods}"
)
def __getattr__(self, attr):
"""
Redirects an unknown attribute/method call to the ``defect_entries``
dictionary attribute, if the attribute doesn't exist in
|DefectThermodynamics|.
"""
try:
super().__getattribute__(attr)
except AttributeError as exc:
if attr == "_defect_entries":
raise exc
return getattr(self._defect_entries, attr)
def __getitem__(self, key):
"""
Makes |DefectThermodynamics| object subscriptable, so that it can be
indexed like a dictionary, using the ``defect_entries`` dictionary
attribute.
"""
return self.defect_entries[key]
def __setitem__(self, key, value):
"""
Set the value of a specific key (defect name) in the ``defect_entries``
dictionary, using ``add_entries`` (to check compatibility).
"""
self.add_entries(
[
value,
]
)
def __delitem__(self, key):
"""
Deletes the specified defect entry from the ``defect_entries``
dictionary.
"""
del self.defect_entries[key]
def __contains__(self, key):
"""
Returns ``True`` if the ``defect_entries`` dictionary contains the
specified defect name.
"""
return key in self.defect_entries
def __len__(self):
"""
Returns the number of entries in the ``defect_entries`` dictionary.
"""
return len(self.defect_entries)
def __iter__(self):
"""
Returns an iterator over the ``defect_entries`` dictionary.
"""
return iter(self.defect_entries)
def _check_chempots_and_limit_settings(chempots: dict | None = None, limit: str | None = None):
"""
Convenience function to check the input values of ``chempots`` and
``limit`` for ``doped`` thermodynamic analysis functions, and warn users
(once) if no chemical potentials or limits are specified.
Returns the default chemical potential ``limit`` to be used, if
``chempots`` is ``None``.
"""
if chempots is None:
_no_chempots_warning()
else:
limit = _parse_limit(chempots, limit)
if limit is None:
limit = next(iter(chempots["limits"].keys()))
if len(chempots["limits"]) > 1: # more than 1 limit, so warn
warnings.warn(
f"No chemical potential limit specified! Using {limit} for computing the formation "
f"energy"
)
return limit
def _add_effective_dopant_concentration(
conc_df: pd.DataFrame,
effective_dopant_concentration: float | None = None,
):
"""
Add the effective dopant concentration to the concentration ``DataFrame``.
Args:
conc_df (pd.DataFrame):
``DataFrame`` of defect concentrations.
effective_dopant_concentration (float):
The effective dopant concentration to add to the ``DataFrame``.
For dopants of charge ``q``, the input value should be
``q * 'Dopant Concentration'``.
(Default: None; no extrinsic dopant)
Returns:
pd.DataFrame:
``DataFrame`` of defect concentrations with the effective
dopant concentration
"""
if effective_dopant_concentration is None:
return conc_df
# if ``lean`` was ``True``, then Defect/Charge not set as indices:
lean = all(i in conc_df.columns for i in ["Defect", "Charge"])
eff_dopant_df = pd.DataFrame(
{
"Defect": "Dopant",
"Charge": int(np.sign(effective_dopant_concentration)),
"Formation Energy (eV)": np.nan,
"Concentration (cm^-3)": np.abs(effective_dopant_concentration),
"Charge State Population": "100.0%",
},
index=[0],
)
if not lean:
eff_dopant_df = eff_dopant_df.set_index(conc_df.index.names)
for col in conc_df.columns:
if col not in eff_dopant_df.columns:
# if string, set to "N/A", otherwise e.g. concentration per site, if per_site=True
eff_dopant_df[col] = "N/A" if isinstance(conc_df[col].iloc[0], str) else np.nan
columns_to_drop = [col for col in eff_dopant_df.columns if col not in conc_df.columns]
eff_dopant_df = eff_dopant_df.drop(columns=columns_to_drop)
eff_dopant_df = eff_dopant_df[conc_df.columns] # ensure it matches the original order
return pd.concat([conc_df, eff_dopant_df], ignore_index=lean)
def _group_defect_charge_state_concentrations(
conc_df: pd.DataFrame,
):
summed_df = conc_df.groupby("Defect").sum(numeric_only=True) # auto-reordered by groupby sum
defects = (
conc_df["Defect"] if "Defect" in conc_df.columns else conc_df.index.get_level_values("Defect")
)
summed_df = summed_df.loc[defects.unique()] # retain ordering
if "Lattice Site Index" in conc_df.columns: # don't sum lattice site indices
summed_df["Lattice Site Index"] = conc_df.groupby("Defect")["Lattice Site Index"].first()
return summed_df.drop( # Defect set as index now, from groupby()
columns=[
i
for i in [
"Charge",
"Formation Energy (eV)",
"Charge State Population",
]
if i in summed_df.columns
]
)
def _format_concentration(raw_concentration: float, per_site: bool = False, skip_formatting: bool = False):
"""
Format concentration values for ``DataFrame`` outputs.
"""
if skip_formatting:
return raw_concentration
if per_site:
return _format_per_site_concentration(raw_concentration)
return f"{raw_concentration:.3e}"
def _format_per_site_concentration(raw_concentration: float):
"""
Format per-site concentrations for ``DataFrame`` outputs.
"""
if isinstance(raw_concentration, str):
return raw_concentration
if np.isnan(raw_concentration):
return "N/A"
if raw_concentration > 1e-5:
return f"{raw_concentration:.3%}"
return f"{raw_concentration * 100:.3e} %"
def _format_limiting_doping_result(
rows: list["pd.Series"],
limit_dict: dict,
value_col_name: str,
) -> pd.DataFrame:
"""
Format the (p-type, n-type) limiting rows for the ``DataFrame`` returned by
``get_dopability_limits`` / ``get_doping_windows``.
"""
return pd.DataFrame(
[
[
_get_rich_poor_limit_name_from_dict(row["limit"], limit_dict, bracket=True),
row["name"],
round(row["intercept"], 3),
]
for row in rows
],
columns=["Limit", "Compensating Defect", value_col_name],
index=["p-type", "n-type"],
)
def _get_limiting_intercept_row(
intercepts: list[tuple], inner_agg: str, outer_agg: str, fallback: float = np.inf, rounding_dp: int = 4
) -> pd.Series:
"""
Select the limiting row from a list of ``(limit, name, intercept)`` tuples,
for use in ``get_dopability_limits``/``get_doping_windows``.
For each chemical potential point, we first pick the intercept that
constrains that point most (``inner_agg``, e.g. ``"max"`` for donor
dopability intercepts), then across points we pick the one giving the most
favourable such constraint (``outer_agg``, e.g. ``"idxmin"``). Intercepts
are rounded to ``rounding_dp`` d.p. for group/tie-break comparisons, so
float precision (e.g. 6-decimal rounding of grid-point coordinates) doesn't
cause interior points to be picked over vertex limits when they give
effectively the same intercept (``idxmax``/``idxmin`` return the first
index on ties, and vertex rows are always listed first).
"""
intercept_df = pd.DataFrame(
intercepts or [("N/A", "N/A", fallback)], columns=["limit", "name", "intercept"]
)
intercept_df["_r"] = intercept_df["intercept"].round(rounding_dp)
mask = intercept_df.groupby("limit")["_r"].transform(cast("Any", inner_agg)) == intercept_df["_r"]
return intercept_df.iloc[getattr(intercept_df[mask]["_r"], outer_agg)()]
def _get_doping_scan_points(
chempots: dict,
n_points: int = 100,
) -> list[tuple[str, dict | None]]:
r"""
Build a list of chemical potential points to scan for doping analyses
(``get_dopability_limits``/``get_doping_windows``).
All vertex chemical potential limits are always included (with
``chempot_dict=None``, meaning the caller should pass the full ``chempots``
dict and select the limit by label). Additional `interior` chemical
potential grid points (i.e. non-vertex; convex combinations of the
vertices) are included if ``n_points > 0`` and there are at least two
vertex limits. Interior grid points are returned as single-limit chempots
dicts in ``{element: formal_chempot, ...}`` (i.e. wrt the ``el_refs``)
form.
Args:
chempots (dict):
Chemical potentials dictionary in the ``doped`` format (i.e.
``{"limits": {limit_name: {element: chempot, ...}, ...}, ...}``).
n_points (int):
Target number of grid points to generate. If ``<= 0``, only vertex
limits are returned. Defaults to 100.
Returns:
list:
List of ``(label, chempot_dict)`` tuples, with
``chempot_dict=None`` for vertex points and a
``{element: formal_chempot, ...}`` dict for interior points.
"""
limits = list(chempots["limits"].keys())
points: list[tuple[str, dict | None]] = [(lim, None) for lim in limits] # ``None`` w/vertex limits
if n_points <= 0 or len(limits) < 2:
return points
limits_wrt_el_refs = chempots.get("limits_wrt_el_refs", chempots["limits"])
n_elements = len(next(iter(limits_wrt_el_refs.values())))
interior_chempot_dicts: list[dict] = []
try:
if n_elements == 2:
start = limits_wrt_el_refs[limits[0]]
end = limits_wrt_el_refs[limits[-1]]
all_points = get_interpolated_chempots(start, end, n_points)
interior_chempot_dicts = all_points[1:-1] # drop endpoints (== vertex limits)
elif n_elements >= 3:
grid_df = ChemicalPotentialGrid(chempots).get_grid(
n_points=n_points,
cartesian=False,
decimal_places=6,
include_vertices=False,
)
interior_chempot_dicts = [
{k.split("_")[1].split()[0]: v for k, v in chempot_series.to_dict().items()}
for _idx, chempot_series in grid_df.iterrows()
]
except (ValueError, IndexError):
# e.g. <2D chempot space, or degenerate polytope (too few vertices for ConvexHull)
interior_chempot_dicts = []
for chempot_dict in interior_chempot_dicts:
label = "Interior (" + ", ".join(f"μ_{el}={v:.2f}" for el, v in chempot_dict.items()) + ")"
points.append((label, chempot_dict))
return points
[docs]
def get_fermi_dos(dos_vr: PathLike | Vasprun):
"""
Create a ``FermiDos`` object from the provided ``dos_vr``, which can be
either a path to a ``vasprun.xml(.gz)`` file, or a ``pymatgen`` |Vasprun|
object (parsed with ``parse_dos = True``).
Args:
dos_vr (PathLike | |Vasprun|):
Path to a ``vasprun.xml(.gz)`` file, or a |Vasprun| object.
Returns:
FermiDos: The ``FermiDos`` object.
"""
if not isinstance(dos_vr, Vasprun):
dos_vr = get_vasprun(dos_vr, parse_dos=True)
return FermiDos(dos_vr.complete_dos, nelecs=get_nelect_from_vasprun(dos_vr))
[docs]
def scissor_dos(
dos: Dos | FermiDos = None,
delta_VBM: float = 0.0,
delta_CBM: float = 0.0,
delta_gap: float = 0.0,
tol: float = 1e-8,
verbose: bool = True,
):
"""
Given an input ``Dos``/``FermiDos`` object, rigidly shifts the valence and
conduction bands of the DOS object by ``delta_VBM`` and ``delta_CBM``
respectively (positive = upshift, negative = downshift).
Note this assumes a non-spin-polarised (i.e. non-magnetic) density of
states!
.. deprecated:: 4.0
The ``delta_gap`` parameter is deprecated and will be removed in
v4.1; use ``delta_VBM``/``delta_CBM`` instead.
Args:
dos (Dos/FermiDos):
The input DOS object to scissor.
delta_VBM (float):
Rigid shift to apply to the valence band / VBM in eV;
positive = upshift. Default: 0.
delta_CBM (float):
Rigid shift to apply to the conduction band / CBM in eV;
positive = upshift. Default: 0.
delta_gap (float):
(Deprecated, to be removed in v4.1.) The amount by which to
increase/decrease the band gap (in eV), applied symmetrically
around the original gap. Equivalent to
``delta_VBM=-delta_gap/2`` and ``delta_CBM=+delta_gap/2``.
``delta_VBM`` and ``delta_CBM`` take priority over ``delta_gap``.
tol (float):
The tolerance to use for determining the VBM and CBM (used in
``Dos.get_gap(tol=tol)``). Default: 1e-8.
verbose (bool):
Whether to print information about the original and new band gaps.
Returns:
FermiDos: The scissored DOS object.
"""
if delta_gap:
warnings.warn( # TODO: remove ``delta_gap`` (and this branch) in v4.1.
"The ``delta_gap`` parameter is deprecated and will be removed in v4.1. Use "
"``delta_VBM`` and ``delta_CBM`` instead (positive = upshift); the symmetric equivalent is "
"``delta_VBM=-delta_gap/2``, ``delta_CBM=+delta_gap/2``. ``delta_VBM`` and ``delta_CBM`` take "
"priority over ``delta_gap`` here.",
DeprecationWarning,
stacklevel=2,
)
delta_VBM = delta_VBM or -delta_gap / 2
delta_CBM = delta_CBM or +delta_gap / 2
delta_gap = delta_CBM - delta_VBM # change in band gap (keep this ``delta_gap`` definition/usage)
dos = deepcopy(dos) # don't overwrite object
dos_gap = dos.get_gap(tol=tol)
dos_densities = dos.densities[Spin.up]
# internally, shift just the CBM region by delta_gap, then rigidly shift all energies by delta_VBM
# so the final VBM is at orig_VBM + delta_VBM and final CBM is at orig_CBM + delta_CBM
cbm_index = np.where((dos_densities > tol) & (dos.energies - dos.efermi > dos_gap / 2))[0][0]
cbm_energy = dos.energies[cbm_index]
# get closest index with energy near cbm_energy + delta_gap:
new_cbm_index = np.argmin(np.abs(dos.energies - (cbm_energy + delta_gap)))
new_cbm_energy = dos.energies[new_cbm_index]
vbm_energy = dos.energies[
np.where((dos_densities > tol) & (dos.energies - dos.efermi < dos_gap / 2))[0][-1]
]
if not (
np.isclose(cbm_energy - vbm_energy, dos_gap, atol=1e-1)
and np.isclose(new_cbm_energy - cbm_energy, delta_gap, atol=1e-2)
):
warnings.warn(
"The new band gap does not appear to be equal to the original band gap plus the scissor "
"shift, suggesting an error in `scissor_dos`, beware!\n"
f"Got original gap (from manual indexing): {cbm_energy - vbm_energy}, {dos_gap} "
f"from dos.get_gap(tol=tol) and new gap: {dos_gap + delta_gap}"
)
# Determine the number of values in energies/densities to remove/add to avoid duplication
values_to_remove_or_add = int(new_cbm_index - cbm_index)
scissored_dos_dict = dos.as_dict()
# Shift the CBM and remove/add values:
if values_to_remove_or_add < 0: # remove values
scissored_dos_dict["energies"] = np.concatenate(
(dos.energies[: cbm_index + values_to_remove_or_add], dos.energies[cbm_index:] + delta_gap)
)
scissored_dos_dict["densities"][Spin.up] = np.concatenate(
(
dos_densities[: cbm_index + values_to_remove_or_add],
dos_densities[cbm_index:],
)
)
elif values_to_remove_or_add > 0: # add more zero DOS values:
scissored_dos_dict["energies"] = np.concatenate(
(
dos.energies[:cbm_index],
dos.energies[cbm_index:new_cbm_index],
dos.energies[cbm_index:] + delta_gap,
)
)
scissored_dos_dict["densities"][Spin.up] = np.concatenate(
(
dos_densities[:cbm_index],
np.zeros(values_to_remove_or_add),
dos_densities[cbm_index:],
)
)
# Assuming non-spin-polarised bulk here:
scissored_dos_dict["densities"][Spin.down] = scissored_dos_dict["densities"][Spin.up]
# now apply the rigid shift of all energies by delta_VBM:
# ensure 'energies' is array (should be if function used correctly, not if band gap change is zero...):
scissored_dos_dict["energies"] = np.array(scissored_dos_dict["energies"]) + delta_VBM
scissored_dos_dict["efermi"] += delta_VBM
if verbose:
print(f"Orig gap: {dos_gap:.4f}, new gap:{dos_gap + delta_gap:.4f}")
scissored_dos_dict["structure"] = dos.structure.as_dict()
if isinstance(dos, FermiDos):
return FermiDos.from_dict(scissored_dos_dict)
return Dos.from_dict(scissored_dos_dict)
[docs]
class FermiSolver(MSONable):
def __init__(
self,
defect_thermodynamics: DefectThermodynamics,
bulk_dos: FermiDos | Vasprun | PathLike | None = None,
chempots: dict | None = None,
el_refs: dict | None = None,
backend: str = "doped",
skip_dos_check: bool = False,
):
r"""
Class to calculate the Fermi level, defect and carrier concentrations
under various conditions, using the input |DefectThermodynamics|
object.
This class implements a number of convenience methods for thermodynamic
analyses; such as scanning over temperatures, chemical potentials,
effective dopant concentrations etc, minimising or maximising a target
property (e.g. defect/carrier concentration), and also allowing greater
control over constraints and approximations in defect concentration
calculations; such as specifying (known) fixed concentrations of a
defect/dopant, specifying defects to exclude from high-temperature
concentration fixing in the frozen defect approximation (e.g. highly
mobile defects), and/or fixing defect charge states upon quenching.
This constructor initializes a ``FermiSolver`` object, setting up the
necessary attributes, which includes loading the bulk density of states
(DOS) data from either the input |DefectThermodynamics| or
``bulk_dos``.
If using the ``py-sc-fermi`` backend (currently required for the
``fixed_defects``, ``free_defects`` and ``fix_charge_states`` options
in the scanning functions), please cite the code paper:
Squires et al., JOSS 2023; https://doi.org/10.21105/joss.04962.
Args:
defect_thermodynamics (|DefectThermodynamics|):
A |DefectThermodynamics| object, providing access to defect
formation energies and other related thermodynamic properties.
bulk_dos (FermiDos or |Vasprun| or PathLike):
Either a path to the ``vasprun.xml(.gz)`` output of a bulk DOS
calculation in VASP, a ``pymatgen`` |Vasprun| object or a
``pymatgen`` ``FermiDos`` for the bulk electronic DOS, for
calculating carrier concentrations.
If not provided, uses ``DefectThermodynamics.bulk_dos`` if
present.
Usually this is a static calculation with the `primitive` cell
of the bulk material, with relatively dense `k`-point sampling
(especially for materials with disperse band edges) to ensure
an accurately-converged DOS and thus Fermi level. Using large
``NEDOS`` (>3000) and ``ISMEAR = -5`` (tetrahedron smearing)
are recommended for best convergence (wrt `k`-point sampling)
in VASP. Consistent functional settings should be used for the
bulk DOS and defect supercell calculations. See the
:ref:`Tips:Density of States (DOS) Calculations` section.
chempots (dict | None):
Dictionary of chemical potentials to use for calculating the
defect formation energies (and thus concentrations and Fermi
level), under different chemical environments.
If ``None`` (default), will use ``DefectThermodynamics.chempots``
(= 0 for all chemical potentials by default, if not set).
This can have the form of
``{"limits": [{'limit': [chempot_dict]}]}`` (the format
generated by ``doped``\'s chemical potential parsing functions
(see tutorials)) and specific limits (chemical potential
limits) can then be chosen using ``limit``.
Alternatively this can be a dictionary of chemical potentials
for a single limit, in the format:
``{element symbol: chemical potential}``.
If manually specifying chemical potentials this way, you can
set the ``el_refs`` option with the DFT reference energies of
the elemental phases, in which case it is the formal chemical
potentials (i.e. relative to the elemental references) that
should be given here, otherwise the absolute (DFT) chemical
potentials should be given.
Chemical potentials can also be supplied later in each analysis
function, or set using ``DefectThermodynamics.chempots = ...``
or ``FermiSolver.defect_thermodynamics.chempots = ...`` (with
the same input options) to set the default chemical potentials
for all calculations.
If provided here, then ``defect_thermodynamics.chempots`` is
set to this input.
el_refs (dict | None):
Dictionary of elemental reference energies for the chemical
potentials in the format:
``{element symbol: reference energy}`` (to determine the formal
chemical potentials, when ``chempots`` has been manually
specified as ``{element symbol: chemical potential}``).
Unnecessary if ``chempots`` is provided/present in
|DefectThermodynamics| in the format generated by ``doped``
(see tutorials), or if ``DefectThermodynamics.el_refs`` has
been set.
If provided here, then ``defect_thermodynamics.el_refs`` is set
to this input.
Elemental reference energies can also be supplied later in each
analysis function, or set using
``FermiSolver.defect_thermodynamics.el_refs = ...`` or
``DefectThermodynamics.el_refs = ...`` (with the same input
options). Default is ``None``.
backend (str | None):
The code backend to use for the thermodynamic calculations,
which can be either ``"doped"`` or ``"py-sc-fermi"``.
``"py-sc-fermi"`` allows the use of ``fixed_defects`` and
``free_defects`` for advanced constrained defect equilibria
(e.g. mobile defects, see advanced thermodynamics tutorial),
while ``"doped"`` is usually much quicker.
Default is ``doped``, but will attempt to switch to
``py-sc-fermi`` if required (and installed).
skip_dos_check (bool):
Whether to skip the warning about the DOS VBM differing from
``DefectThermodynamics.vbm`` by >0.05 eV. Should only be used
when the reason for this difference is known/acceptable.
Default is ``False``.
Key attributes:
defect_thermodynamics (|DefectThermodynamics|):
The |DefectThermodynamics| object used for the thermodynamic
calculations.
backend (str):
The code backend used for the thermodynamic calculations
(``"doped"`` or ``"py-sc-fermi"``).
volume (float):
Volume of the unit cell in the bulk DOS calculation (stored in
``self.defect_thermodynamics.bulk_dos``).
skip_dos_check (bool):
Whether to skip the warning about the DOS VBM differing from
the defect entries VBM by >0.05 eV. Should only be used when
the reason for this difference is known/acceptable.
py_sc_fermi_dos (DOS):
A ``py-sc-fermi`` ``DOS`` object, generated from the input
``FermiDos`` object, for use with the ``py-sc-fermi`` backend.
"""
# shallow copy so attribute writes here (e.g. ``_bulk_dos``, ``_chempots``) don't mutate
# the user's original ``DefectThermodynamics`` object; shared attributes
# (``defect_entries``, ``chempots``, etc.) are still the same refs in both:
self.defect_thermodynamics = copy(defect_thermodynamics)
self.skip_dos_check = skip_dos_check
if bulk_dos is not None:
self.defect_thermodynamics._bulk_dos = self.defect_thermodynamics._parse_fermi_dos(
bulk_dos, skip_dos_check=self.skip_dos_check
)
if self.defect_thermodynamics.bulk_dos is None:
raise ValueError(
"No bulk DOS calculation (`bulk_dos`) provided or previously parsed to "
"`DefectThermodynamics.bulk_dos`, which is required for calculating carrier "
"concentrations and solving for Fermi level position."
)
self.volume: float = self.defect_thermodynamics.bulk_dos.volume
if "fermi" in backend.lower():
if bool(importlib.util.find_spec("py_sc_fermi")):
self.backend = "py-sc-fermi"
else: # py-sc-fermi explicitly chosen but not installed
raise ImportError(
"py-sc-fermi is not installed, so only the doped FermiSolver backend is available."
)
elif backend.lower() == "doped":
self.backend = "doped"
else:
raise ValueError(f"Unrecognised `backend`: {backend}")
self.py_sc_fermi_dos = None
self._DefectSystem = self._DefectSpecies = self._DefectChargeState = self._DOS = None
if self.backend == "py-sc-fermi":
self._activate_py_sc_fermi_backend()
# Parse chemical potentials, either using input values (after formatting them in the doped format)
# or using the class attributes if set; written onto our shallow copy, leaving the user's
# original ``DefectThermodynamics`` chempots/el_refs untouched:
self.defect_thermodynamics._chempots, self.defect_thermodynamics._el_refs = _parse_chempots(
chempots or self.defect_thermodynamics.chempots, el_refs or self.defect_thermodynamics.el_refs
)
if self.defect_thermodynamics.chempots is None:
raise ValueError(
"You must supply a chemical potentials dictionary or have them present in the "
"DefectThermodynamics object."
)
def _activate_py_sc_fermi_backend(self, error_message: str | None = None):
orig_showwarning = warnings.showwarning
try:
from py_sc_fermi.defect_charge_state import DefectChargeState
from py_sc_fermi.defect_species import DefectSpecies
from py_sc_fermi.defect_system import DefectSystem
from py_sc_fermi.dos import DOS
except ImportError as exc: # py-sc-fermi activation attempted but not installed
finishing_message = (
", but py-sc-fermi is not installed, so only the doped FermiSolver backend is available!"
)
message = (
error_message + finishing_message
if error_message
else "The py-sc-fermi backend was attempted to be activated" + finishing_message
)
raise ImportError(message) from exc
finally: # avoid py-sc-fermi warning suppression; fixed in >=2.1.0, can be removed in future
warnings.showwarning = orig_showwarning
self._DefectSystem = DefectSystem
self._DefectSpecies = DefectSpecies
self._DefectChargeState = DefectChargeState
self._DOS = DOS
if isinstance(self.defect_thermodynamics.bulk_dos, FermiDos):
# the energy array passed to ``py-sc-fermi`` ``DOS`` is ``fdos.energies - thermo.vbm``,
# so in that shifted frame the CBM sits at ``dos_cbm - thermo.vbm``, not at
# ``thermo.band_gap`` (those differ whenever the DOS and bulk supercell calculations
# yield slightly different VBM eigenvalues, which is common in practice):
fdos = self.defect_thermodynamics.bulk_dos
dos_cbm = fdos.get_cbm_vbm(tol=1e-4, abs_tol=True)[0]
self.py_sc_fermi_dos = _get_py_sc_fermi_dos_from_fermi_dos(
fdos,
vbm=self.defect_thermodynamics.vbm,
bandgap=dos_cbm - self.defect_thermodynamics.vbm,
)
ms = ( # multiplicity scaling
next(iter(self.defect_thermodynamics.defect_entries.values())).defect.structure.volume
/ self.volume
)
if not np.isclose(ms, round(ms), atol=3e-2): # check multiplicity scaling is almost an integer
warnings.warn(
f"The detected volume ratio ({ms:.3f}) between the defect supercells and the DOS "
f"calculation cell is non-integer, indicating that they correspond to (slightly) "
f"different unit cell volumes, or that the DOS calculation was not performed in the "
f"primitive unit cell. The former can cause quantitative errors of a similar relative "
f"magnitude in the predicted defect/carrier concentrations!"
)
def _get_fermi_level_and_carriers(
self,
single_chempot_dict: dict[str, float],
el_refs: dict[str, float] | None = None,
temperature: float = 300,
effective_dopant_concentration: float | None = None,
site_competition: bool = True,
) -> tuple[float, float, float]:
r"""
Calculate the equilibrium Fermi level and carrier concentrations under
a given chemical potential regime and temperature.
Args:
single_chempot_dict (dict[str, float]):
Dictionary of chemical potentials to use for calculating the
equilibrium Fermi level position. Here, this should be a
dictionary of chemical potentials for a single limit
(``limit``), in the format:
``{element symbol: chemical potential}``.
If ``el_refs`` is provided or set in
``self.defect_thermodynamics.el_refs`` then it is the formal
chemical potentials (i.e. relative to the elemental reference
energies) that should be given here, otherwise the absolute
(DFT) chemical potentials should be given.
el_refs (dict[str, float]):
Dictionary of elemental reference energies for the chemical
potentials in the format:
``{element symbol: reference energy}``. Unnecessary if
``self.defect_thermodynamics.el_refs`` is set (i.e. if
``chempots`` was provided to ``self.defect_thermodynamics`` in
the format generated by ``doped``). (Default: None)
temperature (float):
The temperature at which to solve for the Fermi level and
carrier concentrations, in Kelvin. Defaults to 300 K.
effective_dopant_concentration (float | None):
The fixed concentration (in cm^-3) of an arbitrary dopant or
impurity in the material. This value is included in the charge
neutrality condition to analyse the Fermi level and doping
response under hypothetical doping conditions.
A positive value corresponds to donor doping, while a negative
value corresponds to acceptor doping. For dopants of charge
``q``, the input should be ``q * 'Dopant Concentration'``.
Defaults to ``None``, corresponding to no additional extrinsic
dopant.
site_competition (bool):
If ``True`` (default), uses the updated Fermi-Dirac-like
formula for defect concentrations, which accounts for defect
site competition at high concentrations (see Kasamatsu et al.
(10.1016/j.ssi.2010.11.022) appendix for derivation -- updated
here to additionally account for configurational degeneracies
``g`` (see https://doi.org/10.1039/D3CS00432E)), which gives
the following defect concentration equation:
``N_X = N*[g*exp(-E/kT) / (1 + sum(g_i*exp(-E_i/kT)))]``
(https://doi.org/10.1038/s41578-025-00879-y,
https://doi.org/10.1021/jacs.5c07104) where ``i`` runs over
all defects which occupy the same site. If ``False``, uses the
standard dilute limit approximation.
Returns:
tuple[float, float, float]: A tuple containing:
- The Fermi level (float) in eV.
- The electron concentration (float) in cm^-3.
- The hole concentration (float) in cm^-3.
"""
fermi_level, electrons, holes = cast( # return_concs=True -> (fermi_level, electrons, holes)
"tuple[float, float, float]",
self.defect_thermodynamics.get_equilibrium_fermi_level(
chempots=single_chempot_dict,
el_refs=el_refs,
temperature=temperature,
return_concs=True,
effective_dopant_concentration=effective_dopant_concentration,
site_competition=site_competition,
),
)
return fermi_level, electrons, holes
def _get_and_check_thermo_chempots(
self, chempots: dict | None = None, el_refs: dict | None = None
) -> tuple[dict, dict]:
"""
Convenience method to get the ``chempots`` and ``el_refs`` from
``self.defect_thermodynamics.chempots`` and check that ``chempots`` is
not ``None``.
"""
chempots, el_refs = self.defect_thermodynamics._get_chempots(
chempots, el_refs
) # returns self.defect_thermodynamics.chempots if chempots is None
if chempots is None:
raise ValueError( # this function is only called if no user chempots were supplied
"No chemical potentials supplied or present in self.defect_thermodynamics.chempots!"
)
assert isinstance(el_refs, dict) # both returned as dictionaries now
return chempots, el_refs
def _get_single_chempot_dict(
self, limit: str | None = None, chempots: dict | None = None, el_refs: dict | None = None
) -> tuple[dict[str, float], Any]:
"""
Get the chemical potentials for a single limit (``limit``) from the
``chempots`` (or ``self.defect_thermodynamics.chempots``) dictionary,
`with respect to the elemental references` (i.e. from
``"limits_wrt_el_refs"``).
Returns a `single` chemical potential dictionary for the specified
limit.
"""
chempots, el_refs = self._get_and_check_thermo_chempots(chempots, el_refs)
limit = _parse_limit(chempots, limit)
if (
limit not in chempots["limits_wrt_el_refs"]
and "User Chemical Potentials" not in chempots["limits_wrt_el_refs"]
):
raise ValueError(
f"Limit '{limit}' not found in the chemical potentials dictionary! You must specify "
f"an appropriate limit or provide an appropriate chempots dictionary."
)
return chempots["limits_wrt_el_refs"][limit or "User Chemical Potentials"], el_refs
def _py_sc_fermi_concs_to_df(
self,
conc_dict: dict,
defect_system: "DefectSystem",
) -> pd.DataFrame:
"""
Convert a ``py-sc-fermi`` ``defect_system.concentration_dict()`` output
into a ``concentrations`` ``DataFrame`` in the standard ``doped``
format.
The ``per_charge`` setting is auto-detected from the structure of
``conc_dict``: ``defect_system.concentration_dict(decomposed=True)``
gives dict values (``{charge: concentration}``) per defect ->
per-charge output (one row per (defect, charge), with a
``Total Concentration (cm^-3)`` column), while
``decomposed=False`` gives scalar values per defect -> per-defect
output (one row per defect with the total concentration in
``Concentration (cm^-3)``).
"""
special_keys = {"Fermi Energy", "n0", "p0", "Dopant"}
# auto-detect per_charge from the structure of conc_dict (decomposed=True -> dict values):
per_charge = any(isinstance(v, dict) for k, v in conc_dict.items() if k not in special_keys)
data = []
for defect_name_wout_charge, conc_or_charge_conc_dict in conc_dict.items():
if defect_name_wout_charge in special_keys:
continue
per_defect_data = {
"Defect": defect_name_wout_charge,
"Charge": None,
"Concentration (cm^-3)": conc_or_charge_conc_dict,
"Formation Energy (eV)": None,
"Charge State Population": None,
"Total Concentration (cm^-3)": conc_or_charge_conc_dict,
}
if per_charge:
total = sum(conc_or_charge_conc_dict.values()) # ``{charge: concentration}``
for charge, conc in conc_or_charge_conc_dict.items():
row = per_defect_data.copy()
row["Charge"] = charge
row["Concentration (cm^-3)"] = conc
row["Formation Energy (eV)"] = round(
defect_system.defect_species_by_name(defect_name_wout_charge)
.charge_states[charge]
.get_formation_energy(conc_dict["Fermi Energy"]),
3,
)
row["Charge State Population"] = f"{conc / total:.2%}"
row["Total Concentration (cm^-3)"] = total
data.append(row)
else:
for col in (
"Charge",
"Formation Energy (eV)",
"Charge State Population",
"Total Concentration (cm^-3)",
):
per_defect_data.pop(col)
data.append(per_defect_data)
conc_df = pd.DataFrame(data)
return conc_df.set_index(["Defect", "Charge"] if per_charge else "Defect", drop=True)
@staticmethod
def _append_chempots_and_dopant(
concentrations: pd.DataFrame,
single_chempot_dict: dict[str, float],
effective_dopant_concentration: float | None,
append_chempots: bool,
) -> pd.DataFrame:
"""
Append ``μ_X (eV)`` columns (one per element, if ``append_chempots``)
and a ``Dopant (cm^-3)`` column (if an effective dopant was specified,
also removing any ``Dopant`` rows if present) to a concentrations
``DataFrame`` in place, and return it.
"""
if append_chempots:
for key, value in single_chempot_dict.items():
concentrations[f"μ_{key} (eV)"] = value
if effective_dopant_concentration is not None:
# drop Dopant row, included as column instead
with warnings.catch_warnings(): # dropping on a non-lexsorted multi-index, fine as small df
warnings.simplefilter("ignore", pd.errors.PerformanceWarning)
concentrations = concentrations.drop("Dopant", errors="ignore")
concentrations["Dopant (cm^-3)"] = effective_dopant_concentration
return concentrations
def _equilibrium_solve(
self,
single_chempot_dict: dict[str, float],
el_refs: dict[str, float] | None = None,
temperature: float = 300,
effective_dopant_concentration: float | None = None,
per_charge: bool = True,
per_site: bool = False,
append_chempots: bool = True,
fixed_defects: dict[str, float] | None = None,
site_competition: bool | str = True,
) -> pd.DataFrame:
r"""
Calculate the Fermi level and defect/carrier concentrations under
`thermodynamic equilibrium`, given a set of chemical potentials and a
temperature.
Typically not intended for direct usage, as the same functionality is
provided by ``DefectThermodynamics.get_equilibrium_concentrations`` /
``DefectThermodynamics.get_equilibrium_fermi_level()``.
Rather this is used internally to provide a unified interface for both
backends, within the ``scan_...`` functions.
Args:
single_chempot_dict (dict[str, float]):
Dictionary of chemical potentials to use for calculating the
equilibrium Fermi level position and defect/carrier
concentrations. Here, this should be a dictionary of chemical
potentials for a single limit (``limit``), in the format:
``{element symbol: chemical potential}``.
If ``el_refs`` is provided or set in
``self.defect_thermodynamics.el_refs`` then it is the `formal`
chemical potentials (i.e. relative to the elemental reference
energies) that should be given here, otherwise the absolute
(DFT) chemical potentials should be given.
el_refs (dict[str, float]):
Dictionary of elemental reference energies for the chemical
potentials in the format:
``{element symbol: reference energy}``. Unnecessary if
``self.defect_thermodynamics.el_refs`` is set (i.e. if
``chempots`` was provided to ``self.defect_thermodynamics`` in
the format generated by ``doped``). (Default: None)
temperature (float):
The temperature at which to solve for defect concentrations,
in Kelvin. Defaults to 300 K.
effective_dopant_concentration (float | None):
The fixed concentration (in cm^-3) of an arbitrary dopant or
impurity in the material. This value is included in the charge
neutrality condition to analyse the Fermi level and doping
response under hypothetical doping conditions.
A positive value corresponds to donor doping, while a negative
value corresponds to acceptor doping. For dopants of charge
``q``, the input should be ``q * 'Dopant Concentration'``.
Defaults to ``None``, corresponding to no additional extrinsic
dopant.
per_charge (bool):
Whether to break down the defect concentrations into individual
defect charge states (e.g. ``v_Cd_0``, ``v_Cd_-1``,
``v_Cd_-2`` instead of ``v_Cd``). Default is ``True``.
per_site (bool):
Whether to also return the concentrations as per-site
concentrations in percent (i.e. concentration divided by
``DefectEntry.bulk_site_concentration``). Not supported for the
``py-sc-fermi`` backend. Default is ``False``.
append_chempots (bool):
Whether to append the chemical potentials (and effective dopant
concentration, if provided) to the output ``DataFrame``.
Default is ``True``.
fixed_defects (dict[str, float] | None):
A dictionary of defect concentrations to fix regardless of
chemical potentials / temperature / Fermi level, in the format:
``{defect_name: concentration}``, where ``defect_name`` is the
name of a defect entry without (e.g. ``"v_O"``) or with (e.g.
``"v_O_+2"``) the charge state; which will then fix either the
total concentration of that defect or only the concentration
for the specified charge state.
Concentrations should be given in cm^-3. This can be used to
simulate the effect of a fixed impurity concentration.
Defaults to ``None``.
site_competition (bool | str):
If ``True`` (default), uses the updated Fermi-Dirac-like
formula for defect concentrations, which accounts for defect
site competition at high concentrations (see Kasamatsu et al.
(10.1016/j.ssi.2010.11.022) appendix for derivation -- updated
here to additionally account for configurational degeneracies
``g`` (see https://doi.org/10.1039/D3CS00432E)), which gives
the following defect concentration equation:
``N_X = N*[g*exp(-E/kT) / (1 + sum(g_i*exp(-E_i/kT)))]``
(https://doi.org/10.1038/s41578-025-00879-y,
https://doi.org/10.1021/jacs.5c07104) where ``i`` runs over
all defects which occupy the same site.
If ``False``, uses the standard dilute limit approximation.
Alternatively ``site_competition`` can be set to a string
(``"verbose"``), which will give the same behaviour as ``True``
as well as including the lattice site indices (used to
determine which defects will compete for the same sites) in the
output ``DataFrame``.
Returns:
pd.DataFrame:
A ``DataFrame`` containing the calculated defect and carrier
concentrations, along with the self-consistent Fermi level.
The columns include:
- "Defect":
The defect type.
- "Concentration (cm^-3)":
The concentration of the defect in cm^-3.
- "Temperature (K)":
The temperature at which the calculation was performed,
in Kelvin.
- "Fermi Level (eV wrt VBM)":
The self-consistent Fermi level in eV, relative to the
VBM of the bulk DOS.
- "Electrons (cm^-3)":
The electron concentration in cm^-3.
- "Holes (cm^-3)":
The hole concentration in cm^-3.
- "μ_X (eV)":
Chemical potentials in eV, if ``append_chempots`` is
``True``.
- "Dopant (cm^-3)":
The effective arbitrary dopant concentration in cm^-3,
if set.
"""
py_sc_fermi_required = fixed_defects is not None
if py_sc_fermi_required and self._DOS is None:
self._activate_py_sc_fermi_backend(
error_message="The `fixed_defects` option is currently only supported for the py-sc-fermi "
"backend"
)
if self.backend == "py-sc-fermi" and per_site:
raise ValueError("The `per_site` option is not supported for the py-sc-fermi backend.")
# kwargs shared by both backend leaf calls (``_get_fermi_level_and_carriers`` and
# ``get_equilibrium_concentrations`` w/``doped``, ``_generate_defect_system`` w/``py-sc-fermi``):
common_kwargs: dict[str, Any] = {
"single_chempot_dict": single_chempot_dict,
"el_refs": el_refs,
"temperature": temperature,
"effective_dopant_concentration": effective_dopant_concentration,
}
if self.backend == "doped" and not py_sc_fermi_required:
fermi_level, electrons, holes = self._get_fermi_level_and_carriers(
**common_kwargs, site_competition=bool(site_competition)
)
common_kwargs["chempots"] = common_kwargs.pop("single_chempot_dict")
common_kwargs.pop("effective_dopant_concentration")
concentrations = self.defect_thermodynamics.get_equilibrium_concentrations(
fermi_level=fermi_level,
per_charge=per_charge,
per_site=per_site,
skip_formatting=True, # keep concentration values as floats
site_competition=site_competition,
**common_kwargs,
)
else: # py-sc-fermi backend:
defect_system = self._generate_defect_system(**common_kwargs, fixed_defects=fixed_defects)
conc_dict = defect_system.concentration_dict(decomposed=per_charge)
fermi_level, electrons, holes = conc_dict["Fermi Energy"], conc_dict["n0"], conc_dict["p0"]
concentrations = self._py_sc_fermi_concs_to_df(conc_dict, defect_system)
if per_charge:
concentrations.pop("Total Concentration (cm^-3)")
# order in both cases is Defect, Concentration, Temperature, Fermi Level, e, h, Chempots:
concentrations["Temperature (K)"] = temperature
concentrations["Fermi Level (eV wrt VBM)"] = fermi_level
concentrations["Electrons (cm^-3)"] = electrons
concentrations["Holes (cm^-3)"] = holes
excluded_columns = ["Defect", "Charge", "Charge State Population"]
for column in concentrations.columns.difference(excluded_columns):
concentrations[column] = concentrations[column].astype(float) # format as floats
return self._append_chempots_and_dopant(
concentrations, single_chempot_dict, effective_dopant_concentration, append_chempots
)
@staticmethod
def _add_annealing_columns(
concentrations: pd.DataFrame,
annealing_fermi_level: float,
annealing_e_conc: float,
annealing_h_conc: float,
annealing_conc_df: pd.DataFrame,
) -> pd.DataFrame:
"""
Inject the ``return_annealing_values`` columns (Fermi level / electron.
/ hole concentrations and per-defect/charge ``Concentration @
T_Anneal``, plus ``Total Concentration @ T_Anneal`` when the index is
per-charge) into ``concentrations`` in-place, sourcing the per-row
concentrations from ``annealing_conc_df`` (which must be indexed
compatibly with ``concentrations``).
``per_charge`` setting is auto-detected from ``concentrations.index``
(a ``MultiIndex`` with a ``Charge`` level means per-charge).
"""
per_charge = (
isinstance(concentrations.index, pd.MultiIndex) and "Charge" in concentrations.index.names
)
concentrations["Fermi Level @ T_Anneal"] = annealing_fermi_level
concentrations["Electrons @ T_Anneal"] = annealing_e_conc
concentrations["Holes @ T_Anneal"] = annealing_h_conc
concentrations["Concentration @ T_Anneal (cm^-3)"] = annealing_conc_df["Concentration (cm^-3)"]
if per_charge:
concentrations["Total Concentration @ T_Anneal (cm^-3)"] = annealing_conc_df[
"Total Concentration (cm^-3)"
]
return concentrations
def _pseudo_equilibrium_solve(
self,
single_chempot_dict: dict[str, float],
el_refs: dict[str, float] | None = None,
annealing_temperature: float = 1000,
quenched_temperature: float = 300,
effective_dopant_concentration: float | None = None,
delta_VBM: float | Callable = 0.0,
delta_CBM: float | Callable = 0.0,
delta_gap: float | Callable = 0.0,
per_charge: bool = True,
per_site: bool = False,
return_annealing_values: bool = False,
fixed_defects: dict[str, float] | None = None,
free_defects: list[str] | None = None,
append_chempots: bool = True,
fix_charge_states: bool = False,
site_competition: bool | str = True,
**kwargs,
) -> pd.DataFrame:
r"""
Calculate the self-consistent Fermi level and corresponding
carrier/defect calculations under pseudo-equilibrium conditions given a
set of elemental chemical potentials, an annealing temperature, and a
quenched temperature.
Typically not intended for direct usage, as the same functionality is
provided by
``DefectThermodynamics.get_fermi_level_and_concentrations()``. Rather
this is used internally to provide a unified interface for both
backends, within the ``scan_...`` functions.
'Pseudo-equilibrium' here refers to the use of frozen defect and dilute
limit approximations under the constraint of charge neutrality (i.e.
constrained equilibrium). According to the 'frozen defect'
approximation, we typically expect defect concentrations to reach
equilibrium during annealing/crystal growth (at elevated temperatures),
but `not` upon quenching (i.e. at room/operating temperature) where we
expect kinetic inhibition of defect annihilation and hence non-
equilibrium defect concentrations / Fermi level. Typically, this is
approximated by computing the equilibrium Fermi level and defect
concentrations at the annealing temperature, and then assuming the
total concentration of each defect is fixed to this value, but that the
relative populations of defect charge states (and the Fermi level) can
re-equilibrate at the lower (room) temperature. Discussion in
https://doi.org/10.1038/s41578-025-00879-y (brief),
https://doi.org/10.1016/j.cpc.2019.06.017 (detailed) and ``doped``
tutorials. In certain cases (such as Li-ion battery materials or
extremely slow charge capture/emission), these approximations may
have to be adjusted such that some defects / charge states are
considered fixed and some are allowed to re-equilibrate (e.g.
highly mobile Li vacancies/interstitials). Modelling these specific
cases can be achieved using the ``free_defects`` and/or
``fix_charge_states`` options, as demonstrated in the
:doc:`FermiSolver tutorial <fermisolver_tutorial>`.
This function works by calculating the self-consistent Fermi level and
total concentration of each defect at the annealing temperature, then
fixing the total concentrations to these values and re-calculating the
self-consistent (constrained equilibrium) Fermi level and relative
charge state concentrations under this constraint at the quenched /
operating temperature. If using the ``"py-sc-fermi"`` backend, then you
can optionally fix the concentrations of individual defect
`charge states` (rather than fixing total defect concentrations) using
``fix_charge_states=True``, and/or optionally specify ``free_defects``
to exclude from high-temperature concentration fixing (e.g. highly
mobile defects).
Note that the bulk DOS calculation should be well-converged with
respect to k-points for accurate Fermi level predictions!
The degeneracy/multiplicity factor "g" is an important parameter in the
defect concentration equation (and thus Fermi level calculation),
affecting the final concentration by up to 2 orders of magnitude. This
factor is taken from the product of the
``defect_entry.defect.multiplicity`` and
``defect_entry.degeneracy_factors`` attributes which are automatically
determined during ``doped`` defect calculation parsing. Discussion in:
https://doi.org/10.1038/s41578-025-00879-y,
https://doi.org/10.1039/D2FD00043A, https://doi.org/10.1039/D3CS00432E.
Args:
single_chempot_dict (dict[str, float]):
Dictionary of chemical potentials to use for calculating the
pseudo-equilibrium Fermi level position and defect/carrier
concentrations. Here, this should be a dictionary of chemical
potentials for a single limit (``limit``), in the format:
``{element symbol: chemical potential}``.
If ``el_refs`` is provided or set in
``self.defect_thermodynamics.el_refs`` then it is the `formal`
chemical potentials (i.e. relative to the elemental reference
energies) that should be given here, otherwise the absolute
(DFT) chemical potentials should be given.
el_refs (dict[str, float]):
Dictionary of elemental reference energies for the chemical
potentials in the format:
``{element symbol: reference energy}``. Unnecessary if
``self.defect_thermodynamics.el_refs`` is set (i.e. if
``chempots`` was provided to ``self.defect_thermodynamics`` in
the format generated by ``doped``). (Default: None)
annealing_temperature (float):
Temperature in Kelvin at which to calculate the high
temperature (fixed) total defect concentrations, which should
correspond to the highest temperature during annealing /
synthesis of the material (at which we assume equilibrium
defect concentrations) within the frozen defect approach.
Defaults to 1000 K.
quenched_temperature (float):
Temperature in Kelvin at which to calculate the self-consistent
(constrained equilibrium) Fermi level and carrier
concentrations, given the fixed total concentrations, which
should correspond to operating temperature of the material
(typically room temperature). Defaults to 300 K.
effective_dopant_concentration (float | None):
The fixed concentration (in cm^-3) of an arbitrary dopant or
impurity in the material. This value is included in the charge
neutrality condition to analyse the Fermi level and doping
response under hypothetical doping conditions.
A positive value corresponds to donor doping, while a negative
value corresponds to acceptor doping. For dopants of charge
``q``, the input should be ``q * 'Dopant Concentration'``.
Defaults to ``None``, corresponding to no additional extrinsic
dopant.
delta_VBM (float | Callable):
Rigid shift (in eV; positive = upshift) to apply to the VBM
position `at the annealing temperature`, e.g. to model
temperature-dependent band edges. If set, applies a scissor
correction to ``fermi_dos`` (rigidly shifting the valence band)
while defect levels remain fixed. Can be a value or a function
of annealing temperature, e.g. ``lambda T: -2e-4*T``. Default
is ``0.0`` (no VBM shift).
delta_CBM (float | Callable):
Rigid shift (in eV; positive = upshift) to apply to the CBM
position `at the annealing temperature`, e.g. to model
temperature-dependent band edges. If set, applies a scissor
correction to ``fermi_dos`` (rigidly shifting the conduction
band) while defect levels remain fixed. Can be a value or a
function of annealing temperature, e.g. ``lambda T: -2e-4*T``.
Default is ``0.0`` (no CBM shift).
delta_gap (float | Callable):
(Deprecated, to be removed in v4.1; use ``delta_VBM``/
``delta_CBM`` instead.) Change in band gap (in eV) of the
host material at the annealing temperature, assuming symmetric
renormalisation about the VBM and CBM (i.e. assuming equal
up/downshifts of the band-edges around their original
eigenvalues -> ``delta_VBM = -delta_gap/2``,
``delta_CBM = +delta_gap/2``. Default is ``0.0`` (no shift).
``delta_VBM`` and ``delta_CBM`` take priority over
``delta_gap``.
per_charge (bool):
Whether to break down the defect concentrations into individual
defect charge states (e.g. ``v_Cd_0``, ``v_Cd_-1``, ``v_Cd_-2``
instead of ``v_Cd``). Default is ``True``.
per_site (bool):
Whether to also return the concentrations as per-site
concentrations in percent (i.e. concentration divided by
``DefectEntry.bulk_site_concentration``). Not supported for the
``py-sc-fermi`` backend. Default is ``False``.
return_annealing_values (bool):
If True, also returns the Fermi level, electron and hole
concentrations and defect concentrations at the annealing
temperature. The Fermi level is given with respect to
``DefectThermodynamics.vbm`` (i.e. the quenched VBM, same
reference as the returned (quenched) Fermi level). Default is
``False``.
fixed_defects (dict[str, float] | None):
A dictionary of defect concentrations to fix at the quenched
temperature, in the format: ``{defect_name: concentration}``,
where ``defect_name`` is the name of a defect entry without
(e.g. ``"v_O"``) or with (e.g. ``"v_O_+2"``) the charge state;
which will then fix either the total concentration of that
defect or only the concentration for the specified charge
state. Concentrations should be given in cm^-3. This can be
used to fix the concentrations of specific defects regardless
of the chemical potentials, or anneal-quench procedure (e.g. to
simulate the effect of a fixed impurity concentration).
Defaults to ``None``.
free_defects (list[str] | None):
A list of defects (without charge states) to be excluded from
high-temperature concentration fixing. Useful for highly mobile
defects that are not expected to be "frozen-in" upon quenching.
Any defects whose names begin with a string in this list will
be excluded from high-temperature concentration fixing (e.g.
``"v_"`` will match all vacancy defects with
``doped``\-formatted names).
Defaults to ``None``.
append_chempots (bool):
Whether to append the chemical potentials (and effective dopant
concentration, if provided) to the output ``DataFrame``.
Default is ``True``.
fix_charge_states (bool):
Whether to fix the concentrations of individual defect charge
states (``True``) or allow charge states to vary while keeping
total defect concentrations fixed (``False``) upon quenching.
Not expected to be physically sensible in most cases.
Defaults to ``False``.
site_competition (bool | str):
If ``True`` (default), uses the updated Fermi-Dirac-like
formula for defect concentrations, which accounts for defect
site competition at high concentrations (see Kasamatsu et al.
(10.1016/j.ssi.2010.11.022) appendix for derivation -- updated
here to additionally account for configurational degeneracies
``g`` (see https://doi.org/10.1039/D3CS00432E)), which gives
the following defect concentration equation:
``N_X = N*[g*exp(-E/kT) / (1 + sum(g_i*exp(-E_i/kT)))]``
(https://doi.org/10.1038/s41578-025-00879-y,
https://doi.org/10.1021/jacs.5c07104) where ``i`` runs over
all defects which occupy the same site.
Note that this option is only supported for the ``doped``
backend. If ``False`` (or using the ``py-sc-fermi`` backend),
uses the standard dilute limit approximation.
Alternatively ``site_competition`` can be set to a string
(``"verbose"``), which will give the same behaviour as ``True``
as well as including the lattice site indices (used to
determine which defects will compete for the same sites) in the
output ``DataFrame``.
**kwargs:
Additional keyword arguments to pass to ``scissor_dos`` (if
``delta_VBM``, ``delta_CBM`` or ``delta_gap`` are not 0).
Returns:
pd.DataFrame:
A ``DataFrame`` containing the defect and carrier
concentrations under pseudo-equilibrium conditions, along with
the self-consistent Fermi level.
The columns include:
- "Defect":
The defect type.
- "Concentration (cm^-3)":
The concentration of the defect in cm^-3.
- "Annealing Temperature (K)":
The annealing temperature in Kelvin.
- "Quenched Temperature (K)":
The quenched temperature in Kelvin.
- "Fermi Level (eV wrt VBM)":
The self-consistent Fermi level in eV, relative to the
VBM of the bulk DOS.
- "Electrons (cm^-3)":
The electron concentration in cm^-3.
- "Holes (cm^-3)":
The hole concentration in cm^-3.
- "μ_X (eV)":
Chemical potentials in eV, if ``append_chempots``
is ``True``.
- "Dopant (cm^-3)":
The effective arbitrary dopant concentration in cm^-3,
if set.
Additional columns may include concentrations for specific
defects and other relevant data.
"""
# TODO: Allow matching of substring (e.g. "O_i" matching "O_i_C2" and "O_i_Ci") for fixed_defects
# and update docstrings ("...where the _total_ concentration of all defect entries whose names
# begin with ``defect_name`` will...") & tests accordingly, see _get_min_max_target_values
# TODO: Should allow just specifying an (extrinsic) element for fixed_defects, to allow the user
# to specify the known concentration of a dopant / over/under-stoichiometry of a given element
# (but with unknown relative populations of different possible defects) -- realistically the most
# commonly desired option (but would require a bit of refactoring from the current `py-sc-fermi`
# implementation; see code in _get_min_max_target_values()). The DefectThermodynamics JSONs in
# the repo for extrinsic-doped Selenium would be a good test case for this.
# TODO: In future the ``fixed_defects``, ``free_defects`` and ``fix_charge_states`` options may
# be added to the ``doped`` backend (in theory very simple to add, and `doped` quicker)
py_sc_fermi_required = fix_charge_states or free_defects or fixed_defects is not None
if py_sc_fermi_required and self._DOS is None:
self._activate_py_sc_fermi_backend(
error_message="The `fix_charge_states`, `free_defects` and `fixed_defects` options are "
"currently only supported for the py-sc-fermi backend"
)
if self.backend == "py-sc-fermi" and per_site:
raise ValueError("The `per_site` option is not supported for the py-sc-fermi backend.")
common_kwargs: dict[str, Any] = { # kwargs shared by both backend leaf calls
"single_chempot_dict": single_chempot_dict,
"el_refs": el_refs,
"annealing_temperature": annealing_temperature,
"quenched_temperature": quenched_temperature,
"effective_dopant_concentration": effective_dopant_concentration,
"delta_VBM": delta_VBM,
"delta_CBM": delta_CBM,
"delta_gap": delta_gap,
"return_annealing_values": True,
**kwargs,
}
if self.backend == "doped" and not py_sc_fermi_required:
common_kwargs["chempots"] = common_kwargs.pop("single_chempot_dict")
(
fermi_level,
electrons,
holes,
concentrations,
annealing_fermi_level,
annealing_e_conc,
annealing_h_conc,
annealing_conc_df,
) = cast( # full (annealing) output tuple as return_annealing_values=True
"tuple[float, float, float, pd.DataFrame, float, float, float, pd.DataFrame]",
self.defect_thermodynamics.get_fermi_level_and_concentrations(
skip_formatting=True, # keep concentration values as floats
site_competition=site_competition,
per_charge=per_charge,
per_site=per_site,
**common_kwargs,
),
)
else: # py-sc-fermi
(
defect_system,
annealing_fermi_level,
annealing_e_conc,
annealing_h_conc,
annealing_total_concs,
annealing_per_charge_concs,
) = self._generate_annealed_defect_system(
fixed_defects=fixed_defects,
free_defects=free_defects,
fix_charge_states=fix_charge_states,
**common_kwargs,
)
conc_dict = defect_system.concentration_dict(decomposed=per_charge)
fermi_level, electrons, holes = conc_dict["Fermi Energy"], conc_dict["n0"], conc_dict["p0"]
concentrations = self._py_sc_fermi_concs_to_df(conc_dict, defect_system)
if return_annealing_values: # build ``annealing_conc_df`` matching ``doped`` structure
index = concentrations.index
annealing_columns = ["Concentration (cm^-3)", "Total Concentration (cm^-3)"]
if per_charge:
annealing_data = [
(annealing_per_charge_concs[d][c], annealing_total_concs[d]) for d, c in index
]
else:
annealing_data = [annealing_total_concs[d] for d in index]
annealing_columns.remove("Total Concentration (cm^-3)")
annealing_conc_df = pd.DataFrame(
annealing_data,
columns=annealing_columns,
index=index,
)
# order in both cases is Defect, Concentration, Temperature(s), Fermi Level, e, h, Chempots:
concentrations["Annealing Temperature (K)"] = annealing_temperature
concentrations["Quenched Temperature (K)"] = quenched_temperature
concentrations["Fermi Level (eV wrt VBM)"] = fermi_level
concentrations["Electrons (cm^-3)"] = electrons
concentrations["Holes (cm^-3)"] = holes
if return_annealing_values:
concentrations = self._add_annealing_columns(
concentrations,
annealing_fermi_level,
annealing_e_conc,
annealing_h_conc,
annealing_conc_df,
)
return self._append_chempots_and_dopant(
concentrations, single_chempot_dict, effective_dopant_concentration, append_chempots
)
def _check_temperature_settings(
self,
annealing_temperature: float | range | list[float] | np.ndarray | None = None,
temperature: float | range | list[float] | np.ndarray = 300,
quenched_temperature: float | range | list[float] | np.ndarray = 300,
range=False,
):
"""
Helper function to check the user input temperature settings, and warn
or raise errors as appropriate.
"""
message = (
"Both ``annealing_temperature`` and ``temperature`` were set, but they are "
"mutually-exclusive options, with ``annealing_temperature`` employing the 'frozen defect "
"approximation' (typically desired) and ``temperature`` assuming total equilibrium "
"(see docstrings/tutorials)!"
)
if range:
message = message.replace("`` ", "_range`` ")
if annealing_temperature is not None and temperature != 300: # both set by user
raise ValueError(message)
if annealing_temperature is None and quenched_temperature != 300: # quenched set but no annealing
raise ValueError(
"Quenched temperature was set but no annealing temperature was given! Required for the "
"'frozen defect approximation', see docstrings/tutorials."
)
def _solve(
self,
single_chempot_dict: dict[str, float],
el_refs: dict[str, float] | None = None,
annealing_temperature: float | None = None,
quenched_temperature: float = 300,
temperature: float = 300,
effective_dopant_concentration: float | None = None,
delta_VBM: float | Callable = 0.0,
delta_CBM: float | Callable = 0.0,
delta_gap: float | Callable = 0.0,
per_charge: bool = True,
per_site: bool = False,
return_annealing_values: bool = False,
fixed_defects: dict[str, float] | None = None,
free_defects: list[str] | None = None,
append_chempots: bool = True,
fix_charge_states: bool = False,
site_competition: bool | str = True,
**user_kwargs,
) -> pd.DataFrame:
PseudoSolveFunc: TypeAlias = Callable[..., pd.DataFrame] # 'type aliases' for function signatures
EquilibriumSolveFunc: TypeAlias = Callable[..., pd.DataFrame]
solve_func: PseudoSolveFunc | EquilibriumSolveFunc = (
self._pseudo_equilibrium_solve
if annealing_temperature is not None
else self._equilibrium_solve
)
kwargs = {
"single_chempot_dict": single_chempot_dict,
"el_refs": el_refs,
"effective_dopant_concentration": effective_dopant_concentration,
"per_charge": per_charge,
"per_site": per_site,
"fixed_defects": fixed_defects,
"append_chempots": append_chempots,
"site_competition": site_competition,
**user_kwargs,
}
if annealing_temperature is not None: # _pseudo_equilibrium_solve
kwargs.update(
{
"annealing_temperature": annealing_temperature,
"quenched_temperature": quenched_temperature,
"delta_VBM": delta_VBM,
"delta_CBM": delta_CBM,
"delta_gap": delta_gap,
"return_annealing_values": return_annealing_values,
"free_defects": free_defects,
"fix_charge_states": fix_charge_states,
}
)
else: # _equilibrium_solve
kwargs["temperature"] = temperature
_ = kwargs.pop("verbose", None) # not relevant for equilibrium solve
return solve_func(**kwargs)
# TODO: Should have a general ``scan()`` function, which takes in all variables, determines which
# are ranges/iterables to scan over (i.e. multi-dimension), and then calls the appropriate
# ``scan_...`` function(s) to scan over
# each N-dimensions, concatenates and returns the result
# TODO: Related, can implement joblib Parallel / multiprocessing processing with smart batch size
# choices to make this much faster as well, if wanted. Smartest way would be to refactor all the
# internal pd.concat loops over self._solve to use a separate calc function, which internally
# determines parallelisation speedup and batch size based on grid size, and acts accordingly
[docs]
def scan_temperature(
self,
annealing_temperature_range: float | range | list[float] | np.ndarray | None = None,
quenched_temperature_range: float | range | list[float] | np.ndarray = 300,
temperature_range: float | range | list[float] | np.ndarray = 300,
chempots: dict[str, float] | None = None,
limit: str | None = None,
el_refs: dict[str, float] | None = None,
effective_dopant_concentration: float | None = None,
delta_VBM: float | Callable = 0.0,
delta_CBM: float | Callable = 0.0,
delta_gap: float | Callable = 0.0,
per_charge: bool = True,
per_site: bool = False,
return_annealing_values: bool = False,
fixed_defects: dict[str, float] | None = None,
free_defects: list[str] | None = None,
fix_charge_states: bool = False,
site_competition: bool | str = True,
**kwargs,
) -> pd.DataFrame:
r"""
Scan over a range of temperatures and solve for the defect
concentrations, carrier concentrations, and Fermi level at each
temperature / annealing & quenched temperature pair.
If ``annealing_temperature_range`` (and ``quenched_temperature_range``;
just 300 K by default) are specified, then the frozen defect
approximation is employed, whereby total defect concentrations are
calculated at the elevated annealing temperature, then fixed at these
values (unless ``free_defects`` or ``fix_charge_states`` are specified)
and the Fermi level and relative charge state populations are
recalculated at the quenched temperature. Otherwise, if only
``temperature_range`` is specified, then the Fermi level and
defect/carrier concentrations are calculated assuming thermodynamic
equilibrium at each temperature.
See ``_(pseudo_)equilibrium_solve`` docstrings for more details.
Args:
annealing_temperature_range (float | list[float] | None):
Temperature range in Kelvin at which to calculate the high
temperature (fixed) total defect concentrations, which should
correspond to the highest temperature during annealing /
synthesis of the material (at which we assume equilibrium
defect concentrations) within the frozen defect approach.
Default is ``None`` (uses ``temperature_range`` under
thermodynamic equilibrium).
quenched_temperature_range (float | list[float]):
Temperature, or range of temperatures, in Kelvin at which to
calculate the self-consistent (constrained equilibrium) Fermi
level and carrier concentrations, given the fixed total
concentrations, which should correspond to operating
temperature of the material (typically room temperature).
Default is just 300 K.
temperature_range (float | list[float]):
Temperature range to solve over, under thermodynamic
equilibrium (if ``annealing_temperature_range`` is not
specified). Defaults to just 300 K.
chempots (dict):
Dictionary of chemical potentials to use for calculating the
defect formation energies (and thus concentrations and Fermi
level). If ``None`` (default), will use
``self.defect_thermodynamics.chempots`` (= 0 for all
chemical potentials by default).
This can have the form of
``{"limits": [{'limit': [chempot_dict]}]}`` (the format
generated by ``doped``\'s chemical potential parsing functions
(see tutorials)) and specific limits (chemical potential
limits) can then be chosen using ``limit``.
Alternatively this can be a dictionary of chemical potentials
for a single limit, in the format:
``{element symbol: chemical potential}``.
If manually specifying chemical potentials this way, you can
set the ``el_refs`` option with the DFT reference energies of
the elemental phases, in which case it is the formal chemical
potentials (i.e. relative to the elemental references) that
should be given here, otherwise the absolute (DFT) chemical
potentials should be given.
One can also set ``DefectThermodynamics.chempots = ...`` or
``FermiSolver.defect_thermodynamics.chempots = ...`` (with the
same input options) to set the default chemical potentials for
all calculations.
limit (str):
The chemical potential limit for which to calculate the Fermi
level positions and defect/carrier concentrations. Can be:
- ``None``, if ``chempots`` corresponds to a single chemical
potential limit -- otherwise will use the first chemical
potential limit in the ``chempots`` dict.
- ``"X-rich"/"X-poor"`` where ``X`` is an element in the
system, in which case the most X-rich/poor limit will be used
(e.g. "Li-rich") -- see
:func:`~doped.chemical_potentials.get_X_rich_poor_limit`.
- A key in ``(self.defect_thermodynamics.)chempots["limits"]``.
The latter two options can only be used if ``chempots`` is in
the ``doped`` format (see chemical potentials tutorial).
(Default: None)
el_refs (dict):
Dictionary of elemental reference energies for the chemical
potentials in the format:
``{element symbol: reference energy}`` (to determine the formal
chemical potentials, when ``chempots`` has been manually
specified as ``{element symbol: chemical potential}``).
Unnecessary if ``chempots`` is provided/present in format
generated by ``doped`` (see tutorials).
One can also set ``DefectThermodynamics.el_refs = ...`` or
``FermiSolver.defect_thermodynamics.el_refs = ...`` (with the
same input options) to set the default elemental reference
energies for all calculations.
(Default: None)
effective_dopant_concentration (float | None):
The fixed concentration (in cm^-3) of an arbitrary dopant or
impurity in the material. This value is included in the charge
neutrality condition to analyse the Fermi level and doping
response under hypothetical doping conditions.
A positive value corresponds to donor doping, while a negative
value corresponds to acceptor doping. For dopants of charge
``q``, the input should be ``q * 'Dopant Concentration'``.
Defaults to ``None``, corresponding to no additional extrinsic
dopant.
delta_VBM (float | Callable):
Rigid shift (in eV; positive = upshift) to apply to the VBM
position `at the annealing temperature`, e.g. to model
temperature-dependent band edges. If set, applies a scissor
correction to ``bulk_dos`` (rigidly shifting the valence band)
while defect levels remain fixed. Can be a value or a function
of annealing temperature, e.g. ``lambda T: -2e-4*T``. Default
is ``0.0`` (no VBM shift).
delta_CBM (float | Callable):
Rigid shift (in eV; positive = upshift) to apply to the CBM
position `at the annealing temperature`, e.g. to model
temperature-dependent band edges. If set, applies a scissor
correction to ``bulk_dos`` (rigidly shifting the conduction
band) while defect levels remain fixed. Can be a value or a
function of annealing temperature, e.g. ``lambda T: -2e-4*T``.
Default is ``0.0`` (no CBM shift).
delta_gap (float | Callable):
(Deprecated, to be removed in v4.1; use ``delta_VBM``/
``delta_CBM`` instead.) Change in band gap (in eV) of the
host material at the annealing temperature, assuming symmetric
renormalisation about the VBM and CBM (i.e. assuming equal
up/downshifts of the band-edges around their original
eigenvalues -> ``delta_VBM = -delta_gap/2``,
``delta_CBM = +delta_gap/2``. Default is ``0.0`` (no shift).
``delta_VBM`` and ``delta_CBM`` take priority over
``delta_gap``.
per_charge (bool):
Whether to break down the defect concentrations into individual
defect charge states (e.g. ``v_Cd_0``, ``v_Cd_-1``,
``v_Cd_-2`` instead of ``v_Cd``). Default is ``True``.
per_site (bool):
Whether to also return the concentrations as per-site
concentrations in percent (i.e. concentration divided by
``DefectEntry.bulk_site_concentration``). Not supported for the
``py-sc-fermi`` backend. Default is ``False``.
return_annealing_values (bool):
If True, also returns the Fermi level, electron and hole
concentrations and defect concentrations at the annealing
temperature. The Fermi level is given with respect to
``DefectThermodynamics.vbm`` (i.e. the quenched VBM, same
reference as the returned (quenched) Fermi level). Default is
``False``.
fixed_defects (dict[str, float] | None):
A dictionary of defect concentrations to fix regardless of
chemical potentials / temperature / Fermi level, in the format:
``{defect_name: concentration}``, where ``defect_name`` is the
name of a defect entry without (e.g. ``"v_O"``) or with (e.g.
``"v_O_+2"``) the charge state; which will then fix either the
total concentration of that defect or only the concentration
for the specified charge state. Concentrations should be given
in cm^-3. This can be used to fix the concentrations of
specific defects regardless of the chemical potentials, or
anneal-quench procedure (e.g. to simulate the effect of a fixed
impurity concentration). Defaults to ``None``.
free_defects (list[str] | None):
A list of defects (without charge states) to be excluded from
high-temperature concentration fixing. Useful for highly mobile
defects that are not expected to be "frozen-in" upon quenching.
Any defects whose names begin with a string in this list will
be excluded from high-temperature concentration fixing (e.g.
``"v_"`` will match all vacancy defects with
``doped``\-formatted names).
Defaults to ``None``.
fix_charge_states (bool):
Whether to fix the concentrations of individual defect charge
states (``True``) or allow charge states to vary while keeping
total defect concentrations fixed (``False``) upon quenching.
Not expected to be physically sensible in most cases.
Defaults to ``False``.
site_competition (bool | str):
If ``True`` (default), uses the updated Fermi-Dirac-like
formula for defect concentrations, which accounts for defect
site competition at high concentrations (see Kasamatsu et al.
(10.1016/j.ssi.2010.11.022) appendix for derivation -- updated
here to additionally account for configurational degeneracies
``g`` (see https://doi.org/10.1039/D3CS00432E)), which gives
the following defect concentration equation:
``N_X = N*[g*exp(-E/kT) / (1 + sum(g_i*exp(-E_i/kT)))]``
(https://doi.org/10.1038/s41578-025-00879-y,
https://doi.org/10.1021/jacs.5c07104) where ``i`` runs over
all defects which occupy the same site.
Note that this option is only supported for the ``doped``
backend. If ``False`` (or using the ``py-sc-fermi`` backend),
uses the standard dilute limit approximation.
Alternatively ``site_competition`` can be set to a string
(``"verbose"``), which will give the same behaviour as ``True``
as well as including the lattice site indices (used to
determine which defects will compete for the same sites) in the
output ``DataFrame``.
**kwargs:
Additional keyword arguments to pass to ``scissor_dos`` (if
``delta_VBM``, ``delta_CBM`` or ``delta_gap`` are not 0).
Returns:
pd.DataFrame:
DataFrame containing defect and carrier concentrations.
"""
self._check_temperature_settings(
annealing_temperature_range, temperature_range, quenched_temperature_range, range=True
)
# Ensure temperature ranges are lists:
temperature_list = _ensure_list(temperature_range)
annealing_temperature_list = _ensure_list(annealing_temperature_range) # returns None if None
quenched_temperature_list = _ensure_list(quenched_temperature_range)
single_chempot_dict, el_refs = self._get_single_chempot_dict(limit, chempots, el_refs)
temp_args = product(
*(
i if isinstance(i, Iterable) else [i]
for i in [annealing_temperature_list, quenched_temperature_list, temperature_list]
)
)
return pd.concat(
[
self._solve(
single_chempot_dict=single_chempot_dict,
el_refs=el_refs,
annealing_temperature=annealing_temperature,
quenched_temperature=quenched_temperature,
temperature=temperature,
effective_dopant_concentration=effective_dopant_concentration,
delta_VBM=delta_VBM,
delta_CBM=delta_CBM,
delta_gap=delta_gap,
per_charge=per_charge,
per_site=per_site,
return_annealing_values=return_annealing_values,
fixed_defects=fixed_defects,
free_defects=free_defects,
fix_charge_states=fix_charge_states,
site_competition=site_competition,
**kwargs,
)
for annealing_temperature, quenched_temperature, temperature in tqdm(list(temp_args))
]
)
[docs]
def scan_dopant_concentration(
self,
effective_dopant_concentration_range: float | range | list | np.ndarray,
annealing_temperature: float | None = None,
quenched_temperature: float = 300,
temperature: float = 300,
chempots: dict[str, float] | None = None,
limit: str | None = None,
el_refs: dict[str, float] | None = None,
delta_VBM: float | Callable = 0.0,
delta_CBM: float | Callable = 0.0,
delta_gap: float | Callable = 0.0,
per_charge: bool = True,
per_site: bool = False,
return_annealing_values: bool = False,
fixed_defects: dict[str, float] | None = None,
free_defects: list[str] | None = None,
fix_charge_states: bool = False,
site_competition: bool | str = True,
**kwargs,
) -> pd.DataFrame:
r"""
Calculate the defect concentrations under a range of effective
(hypothetical) dopant concentrations.
If ``annealing_temperature`` (and ``quenched_temperature``; 300 K by
default) are specified, then the frozen defect approximation is
employed, whereby total defect concentrations are calculated at the
elevated annealing temperature, then fixed at these values (unless
``free_defects`` or ``fix_charge_states`` are specified) and the Fermi
level and relative charge state populations are recalculated at the
quenched temperature. Otherwise, if only ``temperature`` is specified,
then the Fermi level and defect/carrier concentrations are calculated
assuming thermodynamic equilibrium at that temperature.
See ``_(pseudo_)equilibrium_solve`` docstrings for more details.
Args:
effective_dopant_concentration_range (float | list[float]):
The range of effective dopant concentrations to solve over.
This can be a single value or a list of values representing
different concentrations. These are taken as fixed
concentrations (in cm^-3) of an arbitrary dopant or impurity
in the material, included in the charge neutrality condition
to analyse the Fermi level and doping response under
hypothetical doping conditions.
Positive values correspond to donor doping, while negative
values correspond to acceptor doping. For dopants of charge
``q``, the input should be ``q * 'Dopant Concentration'``.
annealing_temperature (float | None):
Temperature in Kelvin at which to calculate the high
temperature (fixed) total defect concentrations, which should
correspond to the highest temperature during annealing /
synthesis of the material (at which we assume equilibrium
defect concentrations) within the frozen defect approach.
Default is ``None`` (uses ``temperature`` under thermodynamic
equilibrium).
quenched_temperature (float):
Temperature in Kelvin at which to calculate the self-consistent
(constrained equilibrium) Fermi level and carrier
concentrations, given the fixed total concentrations, which
should correspond to operating temperature of the material
(typically room temperature). Defaults to 300 K.
temperature (float):
The temperature at which to solve for defect concentrations
and Fermi level, under thermodynamic equilibrium (if
``annealing_temperature`` is not specified).
Defaults to 300 K.
chempots (dict):
Dictionary of chemical potentials to use for calculating the
defect formation energies (and thus concentrations and Fermi
level). If ``None`` (default), will use
``self.defect_thermodynamics.chempots`` (= 0 for all
chemical potentials by default).
This can have the form of
``{"limits": [{'limit': [chempot_dict]}]}`` (the format
generated by ``doped``\'s chemical potential parsing functions
(see tutorials)) and specific limits (chemical potential
limits) can then be chosen using ``limit``.
Alternatively this can be a dictionary of chemical potentials
for a single limit, in the format:
``{element symbol: chemical potential}``.
If manually specifying chemical potentials this way, you can
set the ``el_refs`` option with the DFT reference energies of
the elemental phases, in which case it is the formal chemical
potentials (i.e. relative to the elemental references) that
should be given here, otherwise the absolute (DFT) chemical
potentials should be given.
One can also set ``DefectThermodynamics.chempots = ...`` or
``FermiSolver.defect_thermodynamics.chempots = ...`` (with the
same input options) to set the default chemical potentials for
all calculations.
limit (str):
The chemical potential limit for which to calculate the Fermi
level positions and defect/carrier concentrations. Can be:
- ``None``, if ``chempots`` corresponds to a single chemical
potential limit -- otherwise will use the first chemical
potential limit in the ``chempots`` dict.
- ``"X-rich"/"X-poor"`` where ``X`` is an element in the
system, in which case the most X-rich/poor limit will be used
(e.g. "Li-rich") -- see
:func:`~doped.chemical_potentials.get_X_rich_poor_limit`.
- A key in ``(self.defect_thermodynamics.)chempots["limits"]``.
The latter two options can only be used if ``chempots`` is in
the ``doped`` format (see chemical potentials tutorial).
(Default: None)
el_refs (dict):
Dictionary of elemental reference energies for the chemical
potentials in the format:
``{element symbol: reference energy}`` (to determine the formal
chemical potentials, when ``chempots`` has been manually
specified as ``{element symbol: chemical potential}``).
Unnecessary if ``chempots`` is provided/present in format
generated by ``doped`` (see tutorials).
One can also set ``DefectThermodynamics.el_refs = ...`` or
``FermiSolver.defect_thermodynamics.el_refs = ...`` (with the
same input options) to set the default elemental reference
energies for all calculations.
(Default: None)
delta_VBM (float | Callable):
Rigid shift (in eV; positive = upshift) to apply to the VBM
position `at the annealing temperature`, e.g. to model
temperature-dependent band edges. If set, applies a scissor
correction to ``bulk_dos`` (rigidly shifting the valence band)
while defect levels remain fixed. Can be a value or a function
of annealing temperature, e.g. ``lambda T: -2e-4*T``. Default
is ``0.0`` (no VBM shift).
delta_CBM (float | Callable):
Rigid shift (in eV; positive = upshift) to apply to the CBM
position `at the annealing temperature`, e.g. to model
temperature-dependent band edges. If set, applies a scissor
correction to ``bulk_dos`` (rigidly shifting the conduction
band) while defect levels remain fixed. Can be a value or a
function of annealing temperature, e.g. ``lambda T: -2e-4*T``.
Default is ``0.0`` (no CBM shift).
delta_gap (float | Callable):
(Deprecated, to be removed in v4.1; use ``delta_VBM``/
``delta_CBM`` instead.) Change in band gap (in eV) of the
host material at the annealing temperature, assuming symmetric
renormalisation about the VBM and CBM (i.e. assuming equal
up/downshifts of the band-edges around their original
eigenvalues -> ``delta_VBM = -delta_gap/2``,
``delta_CBM = +delta_gap/2``. Default is ``0.0`` (no shift).
``delta_VBM`` and ``delta_CBM`` take priority over
``delta_gap``.
per_charge (bool):
Whether to break down the defect concentrations into individual
defect charge states (e.g. ``v_Cd_0``, ``v_Cd_-1``, ``v_Cd_-2``
instead of ``v_Cd``). Default is ``True``.
per_site (bool):
Whether to also return the concentrations as per-site
concentrations in percent (i.e. concentration divided by
``DefectEntry.bulk_site_concentration``). Not supported for the
``py-sc-fermi`` backend. Default is ``False``.
return_annealing_values (bool):
If True, also returns the Fermi level, electron and hole
concentrations and defect concentrations at the annealing
temperature. The Fermi level is given with respect to
``DefectThermodynamics.vbm`` (i.e. the quenched VBM, same
reference as the returned (quenched) Fermi level). Default is
``False``.
fixed_defects (dict[str, float] | None):
A dictionary of defect concentrations to fix regardless of
chemical potentials / temperature / Fermi level, in the format:
``{defect_name: concentration}``, where ``defect_name`` is the
name of a defect entry without (e.g. ``"v_O"``) or with (e.g.
``"v_O_+2"``) the charge state; which will then fix either the
total concentration of that defect or only the concentration
for the specified charge state. Concentrations should be given
in cm^-3. This can be used to fix the concentrations of
specific defects regardless of the chemical potentials, or
anneal-quench procedure (e.g. to simulate the effect of a fixed
impurity concentration). Defaults to ``None``.
free_defects (list[str] | None):
A list of defects (without charge states) to be excluded from
high-temperature concentration fixing. Useful for highly mobile
defects that are not expected to be "frozen-in" upon quenching.
Any defects whose names begin with a string in this list will
be excluded from high-temperature concentration fixing (e.g.
``"v_"`` will match all vacancy defects with
``doped``\-formatted names).
Defaults to ``None``.
fix_charge_states (bool):
Whether to fix the concentrations of individual defect charge
states (``True``) or allow charge states to vary while keeping
total defect concentrations fixed (``False``) upon quenching.
Not expected to be physically sensible in most cases.
Defaults to ``False``.
site_competition (bool | str):
If ``True`` (default), uses the updated Fermi-Dirac-like
formula for defect concentrations, which accounts for defect
site competition at high concentrations (see Kasamatsu et al.
(10.1016/j.ssi.2010.11.022) appendix for derivation -- updated
here to additionally account for configurational degeneracies
``g`` (see https://doi.org/10.1039/D3CS00432E)), which gives
the following defect concentration equation:
``N_X = N*[g*exp(-E/kT) / (1 + sum(g_i*exp(-E_i/kT)))]``
(https://doi.org/10.1038/s41578-025-00879-y,
https://doi.org/10.1021/jacs.5c07104) where ``i`` runs over
all defects which occupy the same site.
Note that this option is only supported for the ``doped``
backend. If ``False`` (or using the ``py-sc-fermi`` backend),
uses the standard dilute limit approximation.
Alternatively ``site_competition`` can be set to a string
(``"verbose"``), which will give the same behaviour as ``True``
as well as including the lattice site indices (used to
determine which defects will compete for the same sites) in the
output ``DataFrame``.
**kwargs:
Additional keyword arguments to pass to ``scissor_dos`` (if
``delta_VBM``, ``delta_CBM`` or ``delta_gap`` are not 0).
Returns:
pd.DataFrame:
A ``DataFrame`` containing the defect and carrier
concentrations for each effective dopant concentration. Each
row represents the concentrations for a different dopant
concentration.
"""
self._check_temperature_settings(annealing_temperature, temperature, quenched_temperature)
effective_dopant_concentration_list = _ensure_list(effective_dopant_concentration_range)
single_chempot_dict, el_refs = self._get_single_chempot_dict(limit, chempots, el_refs)
return pd.concat(
[
self._solve(
single_chempot_dict=single_chempot_dict,
el_refs=el_refs,
annealing_temperature=annealing_temperature,
quenched_temperature=quenched_temperature,
temperature=temperature,
effective_dopant_concentration=effective_dopant_concentration,
delta_VBM=delta_VBM,
delta_CBM=delta_CBM,
delta_gap=delta_gap,
per_charge=per_charge,
per_site=per_site,
return_annealing_values=return_annealing_values,
fixed_defects=fixed_defects,
free_defects=free_defects,
fix_charge_states=fix_charge_states,
site_competition=site_competition,
**kwargs,
)
for effective_dopant_concentration in tqdm(effective_dopant_concentration_list)
]
)
[docs]
def interpolate_chempots(
self,
n_points: int = 10,
chempots: list[dict] | dict | None = None,
limits: list[str] | None = None,
el_refs: dict[str, float] | None = None,
annealing_temperature: float | None = None,
quenched_temperature: float = 300,
temperature: float = 300,
effective_dopant_concentration: float | None = None,
delta_VBM: float | Callable = 0.0,
delta_CBM: float | Callable = 0.0,
delta_gap: float | Callable = 0.0,
per_charge: bool = True,
per_site: bool = False,
return_annealing_values: bool = False,
fixed_defects: dict[str, float] | None = None,
free_defects: list[str] | None = None,
fix_charge_states: bool = False,
site_competition: bool | str = True,
**kwargs,
) -> pd.DataFrame:
r"""
Interpolate between `two` sets of chemical potentials and solve for the
defect concentrations and Fermi level at each interpolated point.
Chemical potentials can be interpolated between two sets of chemical
potential dictionaries/values, or between two specified limits. If you
want to scan over a >=2D grid of chemical potentials, you should use
``scan_chemical_potential_grid`` instead.
If ``annealing_temperature`` (and ``quenched_temperature``; 300 K by
default) are specified, then the frozen defect approximation is
employed, whereby total defect concentrations are calculated at the
elevated annealing temperature, then fixed at these values (unless
``free_defects`` or ``fix_charge_states`` are specified) and the Fermi
level and relative charge state populations are recalculated at the
quenched temperature. Otherwise, if only ``temperature`` is specified,
then the Fermi level and defect/carrier concentrations are calculated
assuming thermodynamic equilibrium at that temperature.
See ``_(pseudo_)equilibrium_solve`` docstrings for more details.
Args:
n_points (int):
The number of points to generate between chemical potential
end points. Defaults to 10.
chempots (list[dict] | None):
The chemical potentials to interpolate between. This can be
either a list containing two dictionaries, each representing
a set of chemical potentials for a single limit (in the format:
``{element symbol: chemical potential}``) to interpolate
between, or can be a single chemical potentials dictionary in
the ``doped`` format (i.e.
``{"limits": [{'limit': [chempot_dict]}], ...}``) -- in which
case ``limits`` must be specified to pick the end-points to
interpolate between.
If ``None`` (default), will use
``self.defect_thermodynamics.chempots``. Note that you can also
set ``FermiSolver.defect_thermodynamics.chempots = ...``
or ``DefectThermodynamics.chempots = ...`` (with the same input
options) to set the default chemical potentials for all
calculations.
If manually specifying chemical potentials with a list of two
dictionaries, you can also set the ``el_refs`` option with the
DFT reference energies of the elemental phases if desired, in
which case it is the formal chemical potentials (i.e. relative
to the elemental references) that should be given here,
otherwise the absolute (DFT) chemical potentials should be
given.
limits (list[str] | None):
The chemical potential limits to interpolate between, as a list
containing two strings. Each string should be in the format
``"X-rich"/"X-poor"`` (see
:func:`~doped.chemical_potentials.get_X_rich_poor_limit`),
where X is an element in the system, or a key in
``(self.defect_thermodynamics.)chempots["limits"]``.
If not provided, ``chempots`` must be specified as a list of
two single chemical potential dictionaries for single limits,
or must be a binary system with only 2 limits in ``chempots``.
el_refs (dict):
Dictionary of elemental reference energies for the chemical
potentials in the format:
``{element symbol: reference energy}`` (to determine the formal
chemical potentials, when ``chempots`` has been manually
specified as ``[{element symbol: chemical potential}, ...]``).
Unnecessary if ``chempots`` is provided/present in format
generated by ``doped`` (i.e.
``{"limits": [{'limit': [chempot_dict]}], ...}``).
One can also set ``DefectThermodynamics.el_refs = ...`` or
``FermiSolver.defect_thermodynamics.el_refs = ...`` (with the
same input options) to set the default elemental reference
energies for all calculations.
(Default: None)
annealing_temperature (float | None):
Temperature in Kelvin at which to calculate the high
temperature (fixed) total defect concentrations, which should
correspond to the highest temperature during annealing /
synthesis of the material (at which we assume equilibrium
defect concentrations) within the frozen defect approach.
Default is ``None`` (uses ``temperature`` under thermodynamic
equilibrium).
quenched_temperature (float):
Temperature in Kelvin at which to calculate the self-consistent
(constrained equilibrium) Fermi level and carrier
concentrations, given the fixed total concentrations, which
should correspond to operating temperature of the material
(typically room temperature). Defaults to 300 K.
temperature (float):
The temperature at which to solve for defect concentrations
and Fermi level, under thermodynamic equilibrium (if
``annealing_temperature`` is not specified).
Defaults to 300 K.
effective_dopant_concentration (float | None):
The fixed concentration (in cm^-3) of an arbitrary dopant or
impurity in the material. This value is included in the charge
neutrality condition to analyse the Fermi level and doping
response under hypothetical doping conditions.
A positive value corresponds to donor doping, while a negative
value corresponds to acceptor doping. For dopants of charge
``q``, the input should be ``q * 'Dopant Concentration'``.
Defaults to ``None``, corresponding to no additional extrinsic
dopant.
delta_VBM (float | Callable):
Rigid shift (in eV; positive = upshift) to apply to the VBM
position `at the annealing temperature`, e.g. to model
temperature-dependent band edges. If set, applies a scissor
correction to ``bulk_dos`` (rigidly shifting the valence band)
while defect levels remain fixed. Can be a value or a function
of annealing temperature, e.g. ``lambda T: -2e-4*T``. Default
is ``0.0`` (no VBM shift).
delta_CBM (float | Callable):
Rigid shift (in eV; positive = upshift) to apply to the CBM
position `at the annealing temperature`, e.g. to model
temperature-dependent band edges. If set, applies a scissor
correction to ``bulk_dos`` (rigidly shifting the conduction
band) while defect levels remain fixed. Can be a value or a
function of annealing temperature, e.g. ``lambda T: -2e-4*T``.
Default is ``0.0`` (no CBM shift).
delta_gap (float | Callable):
(Deprecated, to be removed in v4.1; use ``delta_VBM``/
``delta_CBM`` instead.) Change in band gap (in eV) of the
host material at the annealing temperature, assuming symmetric
renormalisation about the VBM and CBM (i.e. assuming equal
up/downshifts of the band-edges around their original
eigenvalues -> ``delta_VBM = -delta_gap/2``,
``delta_CBM = +delta_gap/2``. Default is ``0.0`` (no shift).
``delta_VBM`` and ``delta_CBM`` take priority over
``delta_gap``.
per_charge (bool):
Whether to break down the defect concentrations into individual
defect charge states (e.g. ``v_Cd_0``, ``v_Cd_-1``,
``v_Cd_-2`` instead of ``v_Cd``). Default is ``True``.
per_site (bool):
Whether to also return the concentrations as per-site
concentrations in percent (i.e. concentration divided by
``DefectEntry.bulk_site_concentration``). Not supported for the
``py-sc-fermi`` backend. Default is ``False``.
return_annealing_values (bool):
If True, also returns the Fermi level, electron and hole
concentrations and defect concentrations at the annealing
temperature. The Fermi level is given with respect to
``DefectThermodynamics.vbm`` (i.e. the quenched VBM, same
reference as the returned (quenched) Fermi level). Default is
``False``.
fixed_defects (dict[str, float] | None):
A dictionary of defect concentrations to fix regardless of
chemical potentials / temperature / Fermi level, in the format:
``{defect_name: concentration}``, where ``defect_name`` is the
name of a defect entry without (e.g. ``"v_O"``) or with (e.g.
``"v_O_+2"``) the charge state; which will then fix either the
total concentration of that defect or only the concentration
for the specified charge state. Concentrations should be given
in cm^-3. This can be used to fix the concentrations of
specific defects regardless of the chemical potentials, or
anneal-quench procedure (e.g. to simulate the effect of a fixed
impurity concentration). Defaults to ``None``.
free_defects (list[str] | None):
A list of defects (without charge states) to be excluded from
high-temperature concentration fixing. Useful for highly mobile
defects that are not expected to be "frozen-in" upon quenching.
Any defects whose names begin with a string in this list will
be excluded from high-temperature concentration fixing (e.g.
``"v_"`` will match all vacancy defects with
``doped``\-formatted names). Defaults to ``None``.
fix_charge_states (bool):
Whether to fix the concentrations of individual defect charge
states (``True``) or allow charge states to vary while keeping
total defect concentrations fixed (``False``) upon quenching.
Not expected to be physically sensible in most cases.
Defaults to ``False``.
site_competition (bool | str):
If ``True`` (default), uses the updated Fermi-Dirac-like
formula for defect concentrations, which accounts for defect
site competition at high concentrations (see Kasamatsu et al.
(10.1016/j.ssi.2010.11.022) appendix for derivation -- updated
here to additionally account for configurational degeneracies
``g`` (see https://doi.org/10.1039/D3CS00432E)), which gives
the following defect concentration equation:
``N_X = N*[g*exp(-E/kT) / (1 + sum(g_i*exp(-E_i/kT)))]``
(https://doi.org/10.1038/s41578-025-00879-y,
https://doi.org/10.1021/jacs.5c07104) where ``i`` runs over
all defects which occupy the same site.
Note that this option is only supported for the ``doped``
backend. If ``False`` (or using the ``py-sc-fermi`` backend),
uses the standard dilute limit approximation.
Alternatively ``site_competition`` can be set to a string
(``"verbose"``), which will give the same behaviour as ``True``
as well as including the lattice site indices (used to
determine which defects will compete for the same sites) in the
output ``DataFrame``.
**kwargs:
Additional keyword arguments to pass to ``scissor_dos`` (if
``delta_VBM``, ``delta_CBM`` or ``delta_gap`` are not 0).
Returns:
pd.DataFrame:
A ``DataFrame`` containing the defect and carrier
concentrations for each interpolated set of chemical
potentials. Each row represents the concentrations for a
different interpolated point.
"""
self._check_temperature_settings(annealing_temperature, temperature, quenched_temperature)
if isinstance(chempots, list): # should be two single chempot dictionaries
if len(chempots) != 2:
raise ValueError(
f"If `chempots` is a list, it must contain two dictionaries representing the starting "
f"and ending chemical potentials. The provided list has {len(chempots)} entries!"
)
single_chempot_dict_1, single_chempot_dict_2 = chempots
else: # should be a dictionary in the ``doped`` format or ``None``:
chempots, el_refs = self._get_and_check_thermo_chempots(chempots, el_refs)
if limits is None or len(limits) != 2:
if len(chempots["limits"]) == 2:
limits = list(chempots["limits"].keys())
else:
raise ValueError(
f"If `chempots` is not provided as a list, then `limits` must be a list "
f"containing two strings representing the chemical potential limits to "
f"interpolate between. The provided `limits` is: {limits}."
)
single_chempot_dict_1, el_refs = self._get_single_chempot_dict(limits[0], chempots, el_refs)
single_chempot_dict_2, el_refs = self._get_single_chempot_dict(limits[1], chempots, el_refs)
interpolated_chempots = get_interpolated_chempots(
single_chempot_dict_1, single_chempot_dict_2, n_points
)
return self.scan_chempots(
interpolated_chempots,
el_refs=el_refs,
annealing_temperature=annealing_temperature,
quenched_temperature=quenched_temperature,
temperature=temperature,
effective_dopant_concentration=effective_dopant_concentration,
delta_VBM=delta_VBM,
delta_CBM=delta_CBM,
delta_gap=delta_gap,
per_charge=per_charge,
per_site=per_site,
return_annealing_values=return_annealing_values,
fixed_defects=fixed_defects,
free_defects=free_defects,
fix_charge_states=fix_charge_states,
site_competition=site_competition,
**kwargs,
)
[docs]
def scan_chempots(
self,
chempots: list[dict[str, float]] | dict[str, dict] | None = None,
limits: list[str] | None = None,
el_refs: dict[str, float] | None = None,
annealing_temperature: float | None = None,
quenched_temperature: float = 300,
temperature: float = 300,
effective_dopant_concentration: float | None = None,
delta_VBM: float | Callable = 0.0,
delta_CBM: float | Callable = 0.0,
delta_gap: float | Callable = 0.0,
per_charge: bool = True,
per_site: bool = False,
return_annealing_values: bool = False,
fixed_defects: dict[str, float] | None = None,
free_defects: list[str] | None = None,
fix_charge_states: bool = False,
site_competition: bool | str = True,
**kwargs,
) -> pd.DataFrame:
r"""
Scan over a list of chemical potentials and solve for the defect
concentrations and Fermi level at each set of chemical potentials.
Note that this function only solves for the Fermi level and
defect/carrier concentrations `at the given chemical potentials` (and
not at any points between them), whereas
``scan_chemical_potential_grid``, ``interpolate_chempots`` and
``optimise`` scan over the grid/points `between` given chemical
potentials (typically chemical potential limits), which may be desired.
If ``annealing_temperature`` (and ``quenched_temperature``; 300 K by
default) are specified, then the frozen defect approximation is
employed, whereby total defect concentrations are calculated at the
elevated annealing temperature, then fixed at these values (unless
``free_defects`` or ``fix_charge_states`` are specified) and the Fermi
level and relative charge state populations are recalculated at the
quenched temperature. Otherwise, if only ``temperature`` is specified,
then the Fermi level and defect/carrier concentrations are calculated
assuming thermodynamic equilibrium at that temperature.
See ``_(pseudo_)equilibrium_solve`` docstrings for more details.
Args:
chempots (list[dict] | dict | None):
The chemical potentials to scan over. This can be either a list
containing dictionaries of a set of chemical potentials for a
`single` limit (in the format:
``{element symbol: chemical potential}``), or can be a single
chemical potentials dictionary in the ``doped`` format (i.e.
``{"limits": [{'limit': [chempot_dict]}], ...}``) -- in which
case ``limits`` can be specified to pick the chemical potential
limits to scan over (otherwise scans over all limits in the
``chempots`` dictionary).
By default, will use ``self.defect_thermodynamics.chempots``
(if ``None``). Note that you can also set
``FermiSolver.defect_thermodynamics.chempots = ...``
or ``DefectThermodynamics.chempots = ...`` (with the same input
options) to set the default chemical potentials for all
calculations.
If manually specifying chemical potentials with a list of
dictionaries, you can also set the ``el_refs`` option with the
DFT reference energies of the elemental phases if desired, in
which case it is the formal chemical potentials (i.e. relative
to the elemental references) that should be given here,
otherwise the absolute (DFT) chemical potentials should be
given.
limits (list[str] | None):
The chemical potential limits to scan over, as a list of
strings, if ``chempots`` was provided / is present in the
``doped`` format. Each string should be in the format
``"X-rich"/"X-poor"`` (see
:func:`~doped.chemical_potentials.get_X_rich_poor_limit`),
where X is an element in the system, or a key in
``(self.defect_thermodynamics.)chempots["limits"]``.
If ``None`` (default) and ``chempots`` is in the ``doped``
format (rather than a list of single chemical potential
limits), scans over all limits in the ``chempots`` dictionary.
el_refs (dict):
Dictionary of elemental reference energies for the chemical
potentials in the format:
``{element symbol: reference energy}`` (to determine the formal
chemical potentials, when ``chempots`` has been manually
specified as ``[{element symbol: chemical potential}, ...]``).
Unnecessary if ``chempots`` is provided/present in format
generated by ``doped`` (i.e.
``{"limits": [{'limit': [chempot_dict]}], ...}``).
One can also set ``DefectThermodynamics.el_refs = ...`` or
``FermiSolver.defect_thermodynamics.el_refs = ...`` (with the
same input options) to set the default elemental reference
energies for all calculations.
(Default: None)
annealing_temperature (float | None):
Temperature in Kelvin at which to calculate the high
temperature (fixed) total defect concentrations, which should
correspond to the highest temperature during annealing /
synthesis of the material (at which we assume equilibrium
defect concentrations) within the frozen defect approach.
Default is ``None`` (uses ``temperature`` under thermodynamic
equilibrium).
quenched_temperature (float):
Temperature in Kelvin at which to calculate the self-consistent
(constrained equilibrium) Fermi level and carrier
concentrations, given the fixed total concentrations, which
should correspond to operating temperature of the material
(typically room temperature). Defaults to 300 K.
temperature (float):
The temperature at which to solve for defect concentrations
and Fermi level, under thermodynamic equilibrium (if
``annealing_temperature`` is not specified).
Defaults to 300 K.
effective_dopant_concentration (float | None):
The fixed concentration (in cm^-3) of an arbitrary dopant or
impurity in the material. This value is included in the charge
neutrality condition to analyse the Fermi level and doping
response under hypothetical doping conditions.
A positive value corresponds to donor doping, while a negative
value corresponds to acceptor doping. For dopants of charge
``q``, the input should be ``q * 'Dopant Concentration'``.
Defaults to ``None``, corresponding to no additional extrinsic
dopant.
delta_VBM (float | Callable):
Rigid shift (in eV; positive = upshift) to apply to the VBM
position `at the annealing temperature`, e.g. to model
temperature-dependent band edges. If set, applies a scissor
correction to ``bulk_dos`` (rigidly shifting the valence band)
while defect levels remain fixed. Can be a value or a function
of annealing temperature, e.g. ``lambda T: -2e-4*T``. Default
is ``0.0`` (no VBM shift).
delta_CBM (float | Callable):
Rigid shift (in eV; positive = upshift) to apply to the CBM
position `at the annealing temperature`, e.g. to model
temperature-dependent band edges. If set, applies a scissor
correction to ``bulk_dos`` (rigidly shifting the conduction
band) while defect levels remain fixed. Can be a value or a
function of annealing temperature, e.g. ``lambda T: -2e-4*T``.
Default is ``0.0`` (no CBM shift).
delta_gap (float | Callable):
(Deprecated, to be removed in v4.1; use ``delta_VBM``/
``delta_CBM`` instead.) Change in band gap (in eV) of the
host material at the annealing temperature, assuming symmetric
renormalisation about the VBM and CBM (i.e. assuming equal
up/downshifts of the band-edges around their original
eigenvalues -> ``delta_VBM = -delta_gap/2``,
``delta_CBM = +delta_gap/2``. Default is ``0.0`` (no shift).
``delta_VBM`` and ``delta_CBM`` take priority over
``delta_gap``.
per_charge (bool):
Whether to break down the defect concentrations into individual
defect charge states (e.g. ``v_Cd_0``, ``v_Cd_-1``,
``v_Cd_-2`` instead of ``v_Cd``). Default is ``True``.
per_site (bool):
Whether to also return the concentrations as per-site
concentrations in percent (i.e. concentration divided by
``DefectEntry.bulk_site_concentration``). Not supported for the
``py-sc-fermi`` backend. Default is ``False``.
return_annealing_values (bool):
If True, also returns the Fermi level, electron and hole
concentrations and defect concentrations at the annealing
temperature. The Fermi level is given with respect to
``DefectThermodynamics.vbm`` (i.e. the quenched VBM, same
reference as the returned (quenched) Fermi level). Default is
``False``.
fixed_defects (dict[str, float] | None):
A dictionary of defect concentrations to fix regardless of
chemical potentials / temperature / Fermi level, in the format:
``{defect_name: concentration}``, where ``defect_name`` is the
name of a defect entry without (e.g. ``"v_O"``) or with (e.g.
``"v_O_+2"``) the charge state; which will then fix either the
total concentration of that defect or only the concentration
for the specified charge state. Concentrations should be given
in cm^-3. This can be used to simulate the effect of a fixed
impurity concentration. Defaults to ``None``.
free_defects (list[str] | None):
A list of defects (without charge states) to be excluded from
high-temperature concentration fixing. Useful for highly mobile
defects that are not expected to be "frozen-in" upon quenching.
Any defects whose names begin with a string in this list will
be excluded from high-temperature concentration fixing (e.g.
``"v_"`` will match all vacancy defects with
``doped``\-formatted names). Defaults to ``None``.
fix_charge_states (bool):
Whether to fix the concentrations of individual defect charge
states (``True``) or allow charge states to vary while keeping
total defect concentrations fixed (``False``) upon quenching.
Not expected to be physically sensible in most cases.
Defaults to ``False``.
site_competition (bool | str):
If ``True`` (default), uses the updated Fermi-Dirac-like
formula for defect concentrations, which accounts for defect
site competition at high concentrations (see Kasamatsu et al.
(10.1016/j.ssi.2010.11.022) appendix for derivation -- updated
here to additionally account for configurational degeneracies
``g`` (see https://doi.org/10.1039/D3CS00432E)), which gives
the following defect concentration equation:
``N_X = N*[g*exp(-E/kT) / (1 + sum(g_i*exp(-E_i/kT)))]``
(https://doi.org/10.1038/s41578-025-00879-y,
https://doi.org/10.1021/jacs.5c07104) where ``i`` runs over
all defects which occupy the same site.
Note that this option is only supported for the ``doped``
backend. If ``False`` (or using the ``py-sc-fermi`` backend),
uses the standard dilute limit approximation.
Alternatively ``site_competition`` can be set to a string
(``"verbose"``), which will give the same behaviour as ``True``
as well as including the lattice site indices (used to
determine which defects will compete for the same sites) in the
output ``DataFrame``.
**kwargs:
Additional keyword arguments to pass to ``scissor_dos`` (if
``delta_VBM``, ``delta_CBM`` or ``delta_gap`` are not 0).
Returns:
pd.DataFrame:
A ``DataFrame`` containing defect and carrier concentrations
for each set of chemical potentials. Each row corresponds to a
different set of chemical potentials.
"""
chempots = chempots if chempots is not None else self.defect_thermodynamics.chempots
self._check_temperature_settings(annealing_temperature, temperature, quenched_temperature)
if isinstance(chempots, dict): # should be a dictionary in the ``doped`` format or ``None``:
chempots, el_refs = self._get_and_check_thermo_chempots(chempots, el_refs)
chempots, el_refs = self._parse_and_check_grid_like_chempots(chempots)
if limits is None:
limits = list(chempots["limits_wrt_el_refs"].keys())
elif not isinstance(limits, list):
raise ValueError(
"`limits` must be either a list of limits (as strings) or `None` for `scan_chempots`!"
)
chempots = [self._get_single_chempot_dict(limit, chempots, el_refs)[0] for limit in limits]
return pd.concat(
[
self._solve(
single_chempot_dict=single_chempot_dict,
el_refs=el_refs,
annealing_temperature=annealing_temperature,
quenched_temperature=quenched_temperature,
temperature=temperature,
effective_dopant_concentration=effective_dopant_concentration,
delta_VBM=delta_VBM,
delta_CBM=delta_CBM,
delta_gap=delta_gap,
per_charge=per_charge,
per_site=per_site,
return_annealing_values=return_annealing_values,
fixed_defects=fixed_defects,
free_defects=free_defects,
fix_charge_states=fix_charge_states,
site_competition=site_competition,
**kwargs,
)
for single_chempot_dict in tqdm(chempots)
]
)
[docs]
def scan_chemical_potential_grid(
self,
chempots: dict | None = None,
n_points: int = 100,
annealing_temperature: float | None = None,
quenched_temperature: float = 300,
temperature: float = 300,
effective_dopant_concentration: float | None = None,
delta_VBM: float | Callable = 0.0,
delta_CBM: float | Callable = 0.0,
delta_gap: float | Callable = 0.0,
per_charge: bool = True,
per_site: bool = False,
return_annealing_values: bool = False,
fixed_defects: dict[str, float] | None = None,
free_defects: list[str] | None = None,
fixed_elements: dict[str, float] | None = None,
fix_charge_states: bool = False,
site_competition: bool | str = True,
cartesian: bool = False,
**kwargs,
) -> pd.DataFrame:
r"""
Scan over a grid of chemical potentials and solve for the defect
concentrations and Fermi level at each point.
This method generates a |ChemicalPotentialGrid| object from a
``doped``-formatted chemical potential dictionary, and then calls
``_solve`` for each point in the grid.
If ``annealing_temperature`` (and ``quenched_temperature``; 300 K by
default) are specified, then the frozen defect approximation is
employed, whereby total defect concentrations are calculated at the
elevated annealing temperature, then fixed at these values (unless
``free_defects`` or ``fix_charge_states`` are specified) and the Fermi
level and relative charge state populations are recalculated at the
quenched temperature. Otherwise, if only ``temperature`` is specified,
then the Fermi level and defect/carrier concentrations are calculated
assuming thermodynamic equilibrium at that temperature.
See ``_(pseudo_)equilibrium_solve`` docstrings for more details.
Args:
chempots (dict | None):
Dictionary of chemical potentials to scan over, in the
``doped`` format (i.e.
``{"limits": [{'limit': [chempot_dict]}], ...}``) -- the format
generated by ``doped``\'s chemical potential parsing functions
(see tutorials).
By default, will use ``self.defect_thermodynamics.chempots``
(if ``None``). Note that you can also set
``FermiSolver.defect_thermodynamics.chempots = ...``
or ``DefectThermodynamics.chempots = ...`` (with the same input
options) to set the default chemical potentials for all
calculations, and you can set
``FermiSolver.defect_thermodynamics.el_refs = ...`` or
``DefectThermodynamics.el_refs = ...`` if you want to update
the elemental reference energies for any reason.
n_points (int):
`Minimum` number of grid points to generate. The output grid
will contain at least this many points (regularly spaced in
barycentric or Cartesian space depending ``cartesian``), in
addition to the chemical potential limit vertices themselves,
then with any duplicate / overlapping points dropped. Note that
large values (>= 1e5) with multinary systems can explode,
crashing system memory.
Default is 100.
annealing_temperature (float | None):
Temperature in Kelvin at which to calculate the high
temperature (fixed) total defect concentrations, which should
correspond to the highest temperature during annealing /
synthesis of the material (at which we assume equilibrium
defect concentrations) within the frozen defect approach.
Default is ``None`` (uses ``temperature`` under thermodynamic
equilibrium).
quenched_temperature (float):
Temperature in Kelvin at which to calculate the self-consistent
(constrained equilibrium) Fermi level and carrier
concentrations, given the fixed total concentrations, which
should correspond to operating temperature of the material
(typically room temperature). Defaults to 300 K.
temperature (float):
The temperature at which to solve for defect concentrations
and Fermi level, under thermodynamic equilibrium (if
``annealing_temperature`` is not specified).
Defaults to 300 K.
effective_dopant_concentration (float | None):
The fixed concentration (in cm^-3) of an arbitrary dopant or
impurity in the material. This value is included in the charge
neutrality condition to analyse the Fermi level and doping
response under hypothetical doping conditions.
A positive value corresponds to donor doping, while a negative
value corresponds to acceptor doping. For dopants of charge
``q``, the input should be ``q * 'Dopant Concentration'``.
Defaults to ``None``, corresponding to no additional extrinsic
dopant.
delta_VBM (float | Callable):
Rigid shift (in eV; positive = upshift) to apply to the VBM
position `at the annealing temperature`, e.g. to model
temperature-dependent band edges. If set, applies a scissor
correction to ``bulk_dos`` (rigidly shifting the valence band)
while defect levels remain fixed. Can be a value or a function
of annealing temperature, e.g. ``lambda T: -2e-4*T``. Default
is ``0.0`` (no VBM shift).
delta_CBM (float | Callable):
Rigid shift (in eV; positive = upshift) to apply to the CBM
position `at the annealing temperature`, e.g. to model
temperature-dependent band edges. If set, applies a scissor
correction to ``bulk_dos`` (rigidly shifting the conduction
band) while defect levels remain fixed. Can be a value or a
function of annealing temperature, e.g. ``lambda T: -2e-4*T``.
Default is ``0.0`` (no CBM shift).
delta_gap (float | Callable):
(Deprecated, to be removed in v4.1; use ``delta_VBM``/
``delta_CBM`` instead.) Change in band gap (in eV) of the
host material at the annealing temperature, assuming symmetric
renormalisation about the VBM and CBM (i.e. assuming equal
up/downshifts of the band-edges around their original
eigenvalues -> ``delta_VBM = -delta_gap/2``,
``delta_CBM = +delta_gap/2``. Default is ``0.0`` (no shift).
``delta_VBM`` and ``delta_CBM`` take priority over
``delta_gap``.
per_charge (bool):
Whether to break down the defect concentrations into individual
defect charge states (e.g. ``v_Cd_0``, ``v_Cd_-1``,
``v_Cd_-2`` instead of ``v_Cd``). Default is ``True``.
per_site (bool):
Whether to also return the concentrations as per-site
concentrations in percent (i.e. concentration divided by
``DefectEntry.bulk_site_concentration``). Not supported for the
``py-sc-fermi`` backend. Default is ``False``.
return_annealing_values (bool):
If True, also returns the Fermi level, electron and hole
concentrations and defect concentrations at the annealing
temperature. The Fermi level is given with respect to
``DefectThermodynamics.vbm`` (i.e. the quenched VBM, same
reference as the returned (quenched) Fermi level). Default is
``False``.
fixed_defects (dict[str, float] | None):
A dictionary of defect concentrations to fix regardless of
chemical potentials / temperature / Fermi level, in the format:
``{defect_name: concentration}``, where ``defect_name`` is the
name of a defect entry without (e.g. ``"v_O"``) or with (e.g.
``"v_O_+2"``) the charge state; which will then fix either the
total concentration of that defect or only the concentration
for the specified charge state. Concentrations should be given
in cm^-3. This can be used to simulate the effect of a fixed
impurity concentration. Defaults to ``None``.
free_defects (list[str] | None):
A list of defects (without charge states) to be excluded from
high-temperature concentration fixing. Useful for highly mobile
defects that are not expected to be "frozen-in" upon quenching.
Any defects whose names begin with a string in this list will
be excluded from high-temperature concentration fixing (e.g.
``"v_"`` will match all vacancy defects with
``doped``\-formatted names). Defaults to ``None``.
fixed_elements (dict):
A dictionary of chemical potentials to fix (in the format:
``{column_name: value}``; e.g. ``{"Li": -2}``). See
:meth:`~doped.chemical_potentials.ChemicalPotentialGrid.get_constrained_grid`.
Only possible with >=4D chemical spaces.
fix_charge_states (bool):
Whether to fix the concentrations of individual defect charge
states (``True``) or allow charge states to vary while keeping
total defect concentrations fixed (``False``) upon quenching.
Not expected to be physically sensible in most cases.
Defaults to ``False``.
site_competition (bool | str):
If ``True`` (default), uses the updated Fermi-Dirac-like
formula for defect concentrations, which accounts for defect
site competition at high concentrations (see Kasamatsu et al.
(10.1016/j.ssi.2010.11.022) appendix for derivation -- updated
here to additionally account for configurational degeneracies
``g`` (see https://doi.org/10.1039/D3CS00432E)), which gives
the following defect concentration equation:
``N_X = N*[g*exp(-E/kT) / (1 + sum(g_i*exp(-E_i/kT)))]``
(https://doi.org/10.1038/s41578-025-00879-y,
https://doi.org/10.1021/jacs.5c07104) where ``i`` runs over
all defects which occupy the same site.
Note that this option is only supported for the ``doped``
backend. If ``False`` (or using the ``py-sc-fermi`` backend),
uses the standard dilute limit approximation.
Alternatively ``site_competition`` can be set to a string
(``"verbose"``), which will give the same behaviour as ``True``
as well as including the lattice site indices (used to
determine which defects will compete for the same sites) in the
output ``DataFrame``.
cartesian (bool):
Whether to generate the search grid (over chemical potentials)
in Cartesian (energy) coordinates. If ``False`` (default), the
grid is generated in barycentric coordinates, which is far more
efficient, but means that the grid is evenly spaced in
barycentric ('relative') coordinates, and not necessarily in
Cartesian coordinates.
**kwargs:
Additional keyword arguments to pass to ``scissor_dos`` (if
``delta_VBM``, ``delta_CBM`` or ``delta_gap`` are not 0).
Returns:
pd.DataFrame:
A ``DataFrame`` containing the Fermi level solutions at the
grid points, based on the provided chemical potentials and
conditions.
"""
self._check_temperature_settings(annealing_temperature, temperature, quenched_temperature)
chempots, el_refs = self._parse_and_check_grid_like_chempots(chempots)
grid = ChemicalPotentialGrid(chempots).get_grid(
n_points=n_points, cartesian=cartesian, fixed_elements=fixed_elements, decimal_places=6
)
chempot_dict_list = [
{k.split("_")[1].split()[0]: v for k, v in chempot_series.to_dict().items()}
for _idx, chempot_series in grid.iterrows()
]
return self.scan_chempots(
chempots=chempot_dict_list,
el_refs=el_refs,
annealing_temperature=annealing_temperature,
quenched_temperature=quenched_temperature,
temperature=temperature,
effective_dopant_concentration=effective_dopant_concentration,
delta_VBM=delta_VBM,
delta_CBM=delta_CBM,
delta_gap=delta_gap,
per_charge=per_charge,
return_annealing_values=return_annealing_values,
per_site=per_site,
fixed_defects=fixed_defects,
free_defects=free_defects,
fix_charge_states=fix_charge_states,
site_competition=site_competition,
**kwargs,
)
def _parse_and_check_grid_like_chempots(self, chempots: dict | None = None) -> tuple[dict, dict]:
r"""
Parse a dictionary of chemical potentials for the chemical potential
scanning functions, checking that it is in the correct format.
Args:
chempots (dict | None):
Dictionary of chemical potentials to scan over, in the
``doped`` format (i.e.
``{"limits": [{'limit': [chempot_dict]}], ...}``) -- the format
generated by ``doped``\'s chemical potential parsing functions
(see tutorials).
By default, will use ``self.defect_thermodynamics.chempots``
(if ``None``).
Returns:
dict:
The chemical potentials in the correct format, along with the
elemental reference energies.
"""
chempots, el_refs = self._get_and_check_thermo_chempots(chempots)
if len(chempots["limits"]) == 1:
raise ValueError(
"Only one chemical potential limit is present in "
"`chempots`/`self.defect_thermodynamics.chempots`, which makes no sense for a chemical "
"potential grid scan (with `scan_chemical_potential_grid`/`optimise`/`scan_chempots`)!"
)
return chempots, el_refs
[docs]
def optimise(
self,
target: str,
min_or_max: str = "max",
chempots: dict | None = None,
annealing_temperature: float | None = None,
quenched_temperature: float = 300,
temperature: float = 300,
tolerance: float = 0.01,
n_points: int = 30,
effective_dopant_concentration: float | None = None,
delta_VBM: float | Callable = 0.0,
delta_CBM: float | Callable = 0.0,
delta_gap: float | Callable = 0.0,
per_charge: bool = True,
per_site: bool = False,
return_annealing_values: bool = False,
fixed_defects: dict[str, float] | None = None,
free_defects: list[str] | None = None,
fixed_elements: dict[str, float] | None = None,
fix_charge_states: bool = False,
site_competition: bool | str = True,
cartesian: bool = False,
**kwargs,
) -> pd.DataFrame:
r"""
Search for the chemical potentials that minimise or maximise a target
variable, such as electron concentration, within a specified tolerance.
See ``target`` argument description below for valid choices. This
function iterates over a grid of chemical potentials and "zooms in" on
the chemical potential that either minimises or maximises the target
variable. The process continues until the `relative` change in the
target variable is less than the specified tolerance.
If ``annealing_temperature`` (and ``quenched_temperature``; 300 K by
default) are specified, then the frozen defect approximation is
employed, whereby total defect concentrations are calculated at the
elevated annealing temperature, then fixed at these values (unless
``free_defects`` or ``fix_charge_states`` are specified) and the Fermi
level and relative charge state populations are recalculated at the
quenched temperature. Otherwise, if only ``temperature`` is specified,
then the Fermi level and defect/carrier concentrations are calculated
assuming thermodynamic equilibrium at that temperature.
See ``_(pseudo_)equilibrium_solve`` docstrings for more details.
Args:
target (str):
The target variable to minimise or maximise, e.g., "Electrons
(cm^-3)", "Te_i", "Fermi Level (eV wrt VBM)" etc. Valid
``target`` values are column names (or substrings), such as
"Electrons (cm^-3)", "Holes (cm^-3)",
"Fermi Level (eV wrt VBM)", "μ_X (eV)", etc., or defect names
(without charge states), such as "v_O", "Te_i", etc.
If a full defect name is given (e.g. ``Te_i_Td_Te2.83``) then
the concentration of that defect will be used as the target
variable. If a defect name substring is given instead (e.g.
``Te_i``), then the target variable will be the summed
concentration of all defects with that substring in their name
(e.g. ``Te_i_Td_Te2.83``, ``Te_i_C3v`` etc).
min_or_max (str):
Specify whether to "minimise" ("min") or "maximise" ("max";
default) the target variable.
chempots (dict | None):
Dictionary of chemical potentials to scan over, in the
``doped`` format (i.e.
``{"limits": [{'limit': [chempot_dict]}], ...}``) -- the format
generated by ``doped``\'s chemical potential parsing functions
(see tutorials).
By default, will use ``self.defect_thermodynamics.chempots``
(if ``None``). Note that you can also set
``FermiSolver.defect_thermodynamics.chempots = ...``
or ``DefectThermodynamics.chempots = ...`` (with the same input
options) to set the default chemical potentials for all
calculations, and you can set
``FermiSolver.defect_thermodynamics.el_refs = ...`` or
``DefectThermodynamics.el_refs = ...`` if you want to update
the elemental reference energies for any reason.
annealing_temperature (float | None):
Temperature in Kelvin at which to calculate the high
temperature (fixed) total defect concentrations, which should
correspond to the highest temperature during annealing /
synthesis of the material (at which we assume equilibrium
defect concentrations) within the frozen defect approach.
Default is ``None`` (uses ``temperature`` under thermodynamic
equilibrium).
quenched_temperature (float):
Temperature in Kelvin at which to calculate the self-consistent
(constrained equilibrium) Fermi level and carrier
concentrations, given the fixed total concentrations, which
should correspond to operating temperature of the material
(typically room temperature). Defaults to 300 K.
temperature (float):
The temperature at which to solve for defect concentrations
and Fermi level, under thermodynamic equilibrium (if
``annealing_temperature`` is not specified).
Defaults to 300 K.
tolerance (float):
The convergence criterion for the target variable. The search
stops when the relative change in the target value is less than
this value. Defaults to ``0.01``.
n_points (int):
`Minimum` number of grid points to generate for each iteration
of the search; see ``scan_chemical_potential_grid`` docstring.
Large values are typically not required as the search will
iterate to convergence. Defaults to 30.
effective_dopant_concentration (float | None):
The fixed concentration (in cm^-3) of an arbitrary dopant or
impurity in the material. This value is included in the charge
neutrality condition to analyse the Fermi level and doping
response under hypothetical doping conditions.
A positive value corresponds to donor doping, while a negative
value corresponds to acceptor doping. For dopants of charge
``q``, the input should be ``q * 'Dopant Concentration'``.
Defaults to ``None``, corresponding to no additional extrinsic
dopant.
delta_VBM (float | Callable):
Rigid shift (in eV; positive = upshift) to apply to the VBM
position `at the annealing temperature`, e.g. to model
temperature-dependent band edges. If set, applies a scissor
correction to ``bulk_dos`` (rigidly shifting the valence band)
while defect levels remain fixed. Can be a value or a function
of annealing temperature, e.g. ``lambda T: -2e-4*T``. Default
is ``0.0`` (no VBM shift).
delta_CBM (float | Callable):
Rigid shift (in eV; positive = upshift) to apply to the CBM
position `at the annealing temperature`, e.g. to model
temperature-dependent band edges. If set, applies a scissor
correction to ``bulk_dos`` (rigidly shifting the conduction
band) while defect levels remain fixed. Can be a value or a
function of annealing temperature, e.g. ``lambda T: -2e-4*T``.
Default is ``0.0`` (no CBM shift).
delta_gap (float | Callable):
(Deprecated, to be removed in v4.1; use ``delta_VBM``/
``delta_CBM`` instead.) Change in band gap (in eV) of the
host material at the annealing temperature, assuming symmetric
renormalisation about the VBM and CBM (i.e. assuming equal
up/downshifts of the band-edges around their original
eigenvalues -> ``delta_VBM = -delta_gap/2``,
``delta_CBM = +delta_gap/2``. Default is ``0.0`` (no shift).
``delta_VBM`` and ``delta_CBM`` take priority over
``delta_gap``.
per_charge (bool):
Whether to break down the defect concentrations into individual
defect charge states (e.g. ``v_Cd_0``, ``v_Cd_-1``,
``v_Cd_-2`` instead of ``v_Cd``). Default is ``True``.
per_site (bool):
Whether to also return the concentrations as per-site
concentrations in percent (i.e. concentration divided by
``DefectEntry.bulk_site_concentration``). Not supported for the
``py-sc-fermi`` backend. Default is ``False``.
return_annealing_values (bool):
If True, also returns the Fermi level, electron and hole
concentrations and defect concentrations at the annealing
temperature. The Fermi level is given with respect to
``DefectThermodynamics.vbm`` (i.e. the quenched VBM, same
reference as the returned (quenched) Fermi level). Default is
``False``.
fixed_defects (dict[str, float] | None):
A dictionary of defect concentrations to fix regardless of
chemical potentials / temperature / Fermi level, in the format:
``{defect_name: concentration}``, where ``defect_name`` is the
name of a defect entry without (e.g. ``"v_O"``) or with (e.g.
``"v_O_+2"``) the charge state; which will then fix either the
total concentration of that defect or only the concentration
for the specified charge state. Concentrations should be given
in cm^-3. This can be used to simulate the effect of a fixed
impurity concentration. Defaults to ``None``.
free_defects (list[str] | None):
A list of defects (without charge states) to be excluded from
high-temperature concentration fixing. Useful for highly mobile
defects that are not expected to be "frozen-in" upon quenching.
Any defects whose names begin with a string in this list will
be excluded from high-temperature concentration fixing (e.g.
``"v_"`` will match all vacancy defects with
``doped``\-formatted names). Defaults to ``None``.
fixed_elements (dict):
A dictionary of chemical potentials to fix (in the format:
``{column_name: value}``; e.g. ``{"Li": -2}``). See
:meth:`~doped.chemical_potentials.ChemicalPotentialGrid.get_constrained_grid`.
Only possible with >=4D chemical spaces.
fix_charge_states (bool):
Whether to fix the concentrations of individual defect charge
states (``True``) or allow charge states to vary while keeping
total defect concentrations fixed (``False``) upon quenching.
Not expected to be physically sensible in most cases.
Defaults to ``False``.
site_competition (bool | str):
If ``True`` (default), uses the updated Fermi-Dirac-like
formula for defect concentrations, which accounts for defect
site competition at high concentrations (see Kasamatsu et al.
(10.1016/j.ssi.2010.11.022) appendix for derivation -- updated
here to additionally account for configurational degeneracies
``g`` (see https://doi.org/10.1039/D3CS00432E)), which gives
the following defect concentration equation:
``N_X = N*[g*exp(-E/kT) / (1 + sum(g_i*exp(-E_i/kT)))]``
(https://doi.org/10.1038/s41578-025-00879-y,
https://doi.org/10.1021/jacs.5c07104) where ``i`` runs over
all defects which occupy the same site.
Note that this option is only supported for the ``doped``
backend. If ``False`` (or using the ``py-sc-fermi`` backend),
uses the standard dilute limit approximation.
Alternatively ``site_competition`` can be set to a string
(``"verbose"``), which will give the same behaviour as ``True``
as well as including the lattice site indices (used to
determine which defects will compete for the same sites) in the
output ``DataFrame``.
cartesian (bool):
Whether to generate the search grid (over chemical potentials)
in Cartesian (energy) coordinates. If ``False`` (default), the
grid is generated in barycentric coordinates, which is far more
efficient, but means that the grid is evenly spaced in
barycentric ('relative') coordinates, and not necessarily in
Cartesian coordinates. Cartesian grid spacing is always used
for 1D chemical potential spaces.
**kwargs:
Additional keyword arguments to pass to ``scissor_dos`` (if
``delta_VBM``, ``delta_CBM`` or ``delta_gap`` are not 0).
Returns:
pd.DataFrame:
A ``DataFrame`` containing the results of the minimisation or
maximisation process, including the optimal chemical potentials
and the corresponding values of the target variable.
Raises:
ValueError:
If neither ``chempots`` nor ``self.chempots`` is provided, or
if ``min_or_max`` is not ``"minimise"/"min"`` or
``"maximise"/"max"``.
"""
self._check_temperature_settings(annealing_temperature, temperature, quenched_temperature)
# Determine the dimensionality of the chemical potential space, and call appropriate method
chempots, el_refs = self._parse_and_check_grid_like_chempots(chempots)
# TODO: Add option of just specifying an element, to min/max its summed defect concentrations
# TODO: Test setting target to a defect species (with charge)
# TODO: Low-priority; support fixed_elements for 3D chempot spaces?
if len(el_refs) == 2:
optimise_func = self._optimise_line
else:
optimise_func = self._optimise_grid
kwargs.update({"cartesian": cartesian, "fixed_elements": fixed_elements})
return optimise_func(
target=target,
min_or_max=min_or_max,
chempots=chempots,
annealing_temperature=annealing_temperature,
quenched_temperature=quenched_temperature,
temperature=temperature,
tolerance=tolerance,
n_points=n_points,
effective_dopant_concentration=effective_dopant_concentration,
delta_VBM=delta_VBM,
delta_CBM=delta_CBM,
delta_gap=delta_gap,
per_charge=per_charge,
per_site=per_site,
return_annealing_values=return_annealing_values,
fixed_defects=fixed_defects,
free_defects=free_defects,
fix_charge_states=fix_charge_states,
site_competition=site_competition,
**kwargs,
)
def _optimise_line(
self,
target: str,
min_or_max: str = "max",
chempots: dict | None = None,
annealing_temperature: float | None = None,
quenched_temperature: float = 300,
temperature: float = 300,
tolerance: float = 0.01,
n_points: int = 30,
effective_dopant_concentration: float | None = None,
delta_VBM: float | Callable = 0.0,
delta_CBM: float | Callable = 0.0,
delta_gap: float | Callable = 0.0,
per_charge: bool = True,
per_site: bool = False,
return_annealing_values: bool = False,
fixed_defects: dict[str, float] | None = None,
free_defects: list[str] | None = None,
fix_charge_states: bool = False,
site_competition: bool | str = True,
**kwargs,
) -> pd.DataFrame:
r"""
``optimise`` function for 1D chemical potential spaces (i.e. binary
systems).
See the main ``optimise`` docstring for more details.
"""
chempots, el_refs = self._parse_and_check_grid_like_chempots(chempots)
# Assuming 1D space, focus on one label and get the Rich/Poor limits:
unformatted_chempots_labels = list(el_refs.keys())
rich = self._get_single_chempot_dict(f"{unformatted_chempots_labels[0]}-rich")
poor = self._get_single_chempot_dict(f"{unformatted_chempots_labels[0]}-poor")
chempots_dict_list = get_interpolated_chempots(rich[0], poor[0], n_points)
delta_gap_verbose = kwargs.pop("verbose", False) # don't interfere with other verbose option here
previous_value = None
while True: # Calculate results based on the given temperature conditions
target_df, current_value, target_chempot, converged = self._scan_chempots_and_compare(
target=target,
min_or_max=min_or_max,
previous_value=previous_value,
tolerance=tolerance,
chempots=chempots_dict_list,
el_refs=el_refs,
annealing_temperature=annealing_temperature,
quenched_temperature=quenched_temperature,
temperature=temperature,
effective_dopant_concentration=effective_dopant_concentration,
delta_VBM=delta_VBM,
delta_CBM=delta_CBM,
delta_gap=delta_gap,
per_charge=per_charge,
per_site=per_site,
return_annealing_values=return_annealing_values,
fixed_defects=fixed_defects,
free_defects=free_defects,
fix_charge_states=fix_charge_states,
verbose=previous_value is None, # first iteration, print info on target cols/rows
site_competition=site_competition,
delta_gap_verbose=delta_gap_verbose,
**kwargs,
)
if converged:
break
previous_value = current_value # otherwise update
# get midpoints of chempots_dict_list and target_chempot, and use these:
midpoint_chempots = [
{
k.split("_")[1].split()[0]: (chempot_dict[k.split("_")[1].split()[0]] + v) / 2
for k, v in target_chempot.iloc[0].items()
}
for chempot_dict in [chempots_dict_list[0], chempots_dict_list[-1]]
]
# Note that this is a 'safe' option for zooming in the search grid. If it was a linear
# function, then we could just take the closest vertices around ``target_chempot`` and use
# these, but we know that chempots & temperature & other constraints -> defect concentrations
# can be highly non-linear (e.g. CdTe concentrations in SK thesis, 10.1016/j.joule.2024.05.004,
# 10.1002/smll.202102429, 10.1021/acsenergylett.4c02722), so best to use this safe (but slower)
# approach to ensure we don't miss the true minimum/maximum. Same in both min_max functions.
chempots_dict_list = get_interpolated_chempots(
chempot_start=midpoint_chempots[0],
chempot_end=midpoint_chempots[1],
n_points=n_points,
)
return target_df
def _scan_chempots_and_compare(
self,
target: str,
min_or_max: str,
previous_value: float | None = None,
tolerance: float = 0.01,
chempots: list[dict[str, float]] | dict[str, dict] | None = None,
el_refs: dict[str, float] | None = None,
annealing_temperature: float | None = None,
quenched_temperature: float = 300,
temperature: float = 300,
effective_dopant_concentration: float | None = None,
delta_VBM: float | Callable = 0.0,
delta_CBM: float | Callable = 0.0,
delta_gap: float | Callable = 0.0,
per_charge: bool = True,
per_site: bool = False,
return_annealing_values: bool = False,
fixed_defects: dict[str, float] | None = None,
free_defects: list[str] | None = None,
fix_charge_states: bool = False,
verbose: bool = False,
site_competition: bool | str = True,
**kwargs,
):
"""
Convenience method for use in the ``_optimise_...`` methods, which
scans over a set of chemical potentials and compares the target value
to a previous value, returning the new target dataframe, value and
corresponding chemical potentials.
Unique Args:
previous_value (float):
The previous value of the target variable.
verbose (bool):
Whether to print information on identified target
rows/columns.
**kwargs:
All other arguments are the same as for the ``optimise``
method, see its docstring for more details.
Returns:
target_df (pd.DataFrame):
A ``DataFrame`` containing the current results of the
optimisation, including the optimal chemical potentials and
corresponding values of the target variable.
current_value (float):
The current (updated) value of the target variable.
target_chempot (pd.DataFrame):
The chemical potentials corresponding to the current value.
converged (bool):
Whether the search has converged to within ``tolerance``.
"""
if "delta_gap_verbose" in kwargs:
kwargs["verbose"] = kwargs.pop("delta_gap_verbose")
results_df = self.scan_chempots(
chempots=chempots,
el_refs=el_refs,
annealing_temperature=annealing_temperature,
quenched_temperature=quenched_temperature,
temperature=temperature,
effective_dopant_concentration=effective_dopant_concentration,
delta_VBM=delta_VBM,
delta_CBM=delta_CBM,
delta_gap=delta_gap,
per_charge=per_charge,
per_site=per_site,
return_annealing_values=return_annealing_values,
fixed_defects=fixed_defects,
free_defects=free_defects,
fix_charge_states=fix_charge_states,
site_competition=site_competition,
**kwargs,
)
target_df, current_value, target_chempot = _get_min_max_target_values(
results_df, target, min_or_max, verbose=verbose
)
converged = ( # Check if the change in the target value is less than the tolerance
previous_value is not None
and (
current_value == previous_value
or abs((current_value - previous_value) / (previous_value or current_value)) < tolerance
)
) # divide by (previous_value or current_value) to avoid division by zero
return target_df, current_value, target_chempot, converged
def _optimise_grid(
self,
target: str,
min_or_max: str = "max",
chempots: dict | None = None,
annealing_temperature: float | None = None,
quenched_temperature: float = 300,
temperature: float = 300,
tolerance: float = 0.01,
n_points: int = 30,
effective_dopant_concentration: float | None = None,
delta_VBM: float | Callable = 0.0,
delta_CBM: float | Callable = 0.0,
delta_gap: float | Callable = 0.0,
per_charge: bool = True,
per_site: bool = False,
return_annealing_values: bool = False,
fixed_defects: dict[str, float] | None = None,
free_defects: list[str] | None = None,
fix_charge_states: bool = False,
site_competition: bool | str = True,
cartesian: bool = False,
fixed_elements: dict[str, float] | None = None,
**kwargs,
) -> pd.DataFrame:
r"""
``optimise`` function for >=2D chemical potential spaces (i.e.
elementary/non-binary systems).
See the main ``optimise`` docstring for more details.
"""
chempots, el_refs = self._parse_and_check_grid_like_chempots(chempots)
chempots_grid = ChemicalPotentialGrid(chempots)
delta_gap_verbose = kwargs.pop("verbose", False) # don't interfere with other verbose option here
previous_value = None
while True:
chempots_dict_list = [
{k.split("_")[1].split()[0]: v for k, v in chempot_series.to_dict().items()}
for _idx, chempot_series in chempots_grid.get_grid(
n_points=n_points,
cartesian=cartesian,
fixed_elements=fixed_elements,
decimal_places=6,
).iterrows()
]
target_df, current_value, target_chempot, converged = self._scan_chempots_and_compare(
target=target,
min_or_max=min_or_max,
previous_value=previous_value,
tolerance=tolerance,
chempots=chempots_dict_list,
el_refs=el_refs,
annealing_temperature=annealing_temperature,
quenched_temperature=quenched_temperature,
temperature=temperature,
effective_dopant_concentration=effective_dopant_concentration,
delta_VBM=delta_VBM,
delta_CBM=delta_CBM,
delta_gap=delta_gap,
per_charge=per_charge,
per_site=per_site,
return_annealing_values=return_annealing_values,
fixed_defects=fixed_defects,
free_defects=free_defects,
fix_charge_states=fix_charge_states,
verbose=previous_value is None, # first iteration, print info on target cols/rows
site_competition=site_competition,
delta_gap_verbose=delta_gap_verbose,
**kwargs,
)
if converged:
break
previous_value = current_value # otherwise update
new_vertices_df = (
chempots_grid.vertices + target_chempot.iloc[0]
) / 2 # 1 row - target_chempot
# Note that this is a 'safe' option for zooming in the search grid. If it was a linear
# function, then we could just take the closest vertices around ``target_chempot`` and use
# these, but we know that chempots & temperature & other constraints -> defect concentrations
# can be highly non-linear (e.g. CdTe concentrations in SK thesis, 10.1016/j.joule.2024.05.004,
# 10.1002/smll.202102429, 10.1021/acsenergylett.4c02722), so best to use this safe (but slower)
# approach to ensure we don't miss the true minimum/maximum. Same in both min_max functions.
# Generate a new grid around target_chempot which doesn't go outside the starting grid bounds:
chempots_grid = ChemicalPotentialGrid(new_vertices_df.to_dict("index"))
return target_df
def _generate_dopant_for_py_sc_fermi(self, effective_dopant_concentration: float) -> "DefectSpecies":
"""
Generate a dopant defect charge state object, for ``py-sc-fermi``
functions.
This method creates a defect charge state object representing an
arbitrary dopant or impurity in the material, used to include in the
charge neutrality condition and analyse the Fermi level/doping response
under hypothetical doping conditions.
Args:
effective_dopant_concentration (float):
The fixed concentration (in cm^-3) of an arbitrary dopant or
impurity in the material. This value is included in the charge
neutrality condition to analyse the Fermi level and doping
response under hypothetical doping conditions.
A positive value corresponds to donor doping, while a negative
value corresponds to acceptor doping. For dopants of charge
``q``, the input should be ``q * 'Dopant Concentration'``.
Defaults to ``None``, corresponding to no additional extrinsic
dopant.
Returns:
DefectSpecies:
An instance of the ``DefectSpecies`` class, representing the
generated dopant with the specified charge state and
concentration.
Raises:
ValueError:
If ``effective_dopant_concentration`` is zero or if there is an
issue with generating the dopant.
"""
assert self._DefectChargeState
assert self._DefectSpecies
dopant = self._DefectChargeState(
charge=np.sign(effective_dopant_concentration),
fixed_concentration=abs(effective_dopant_concentration) * self.volume / 1e24, # cm^-3 -> /cell
degeneracy=1,
)
return self._DefectSpecies(
nsites=1, charge_states={np.sign(effective_dopant_concentration): dopant}, name="Dopant"
)
def _generate_defect_system(
self,
single_chempot_dict: dict[str, float],
el_refs: dict[str, float] | None = None,
temperature: float = 300,
effective_dopant_concentration: float | None = None,
fixed_defects: dict[str, float] | None = None,
) -> "DefectSystem":
"""
Generates a ``py-sc-fermi`` ``DefectSystem`` object from
``self.defect_thermodynamics`` and a set of chemical potentials.
This method constructs a ``DefectSystem`` object, which encompasses all
relevant defect species and their properties under the given
conditions, including temperature, chemical potentials, and an optional
dopant concentration.
Args:
single_chempot_dict (dict[str, float]):
Dictionary of chemical potentials to use for calculating the
equilibrium Fermi level position and defect/carrier
concentrations. Here, this should be a dictionary of chemical
potentials for a single limit (``limit``), in the format:
``{element symbol: chemical potential}``.
If ``el_refs`` is provided or set in
``self.defect_thermodynamics.el_refs`` then it is the `formal`
chemical potentials (i.e. relative to the elemental reference
energies) that should be given here, otherwise the absolute
(DFT) chemical potentials should be given.
el_refs (dict[str, float]):
Dictionary of elemental reference energies for the chemical
potentials in the format:
``{element symbol: reference energy}``. Unnecessary if
``self.defect_thermodynamics.el_refs`` is set (i.e. if
``chempots`` was provided to ``self.defect_thermodynamics`` in
the format generated by ``doped``).
(Default: None)
temperature (float):
The temperature in Kelvin at which to perform the calculations.
Defaults to 300 K.
effective_dopant_concentration (float | None):
The fixed concentration (in cm^-3) of an arbitrary dopant or
impurity in the material. This value is included in the charge
neutrality condition to analyse the Fermi level and doping
response under hypothetical doping conditions.
A positive value corresponds to donor doping, while a negative
value corresponds to acceptor doping. For dopants of charge
``q``, the input should be ``q * 'Dopant Concentration'``.
Defaults to ``None``, corresponding to no additional extrinsic
dopant.
fixed_defects (dict[str, float] | None):
A dictionary of defect concentrations to fix regardless of
chemical potentials / temperature / Fermi level, in the format:
``{defect_name: concentration}``, where ``defect_name`` is the
name of a defect entry without (e.g. ``"v_O"``) or with (e.g.
``"v_O_+2"``) the charge state; which will then fix either the
total concentration of that defect or only the concentration
for the specified charge state. Concentrations should be given
in cm^-3. This can be used to simulate the effect of a fixed
impurity concentration. Defaults to ``None``.
Returns:
DefectSystem:
An initialized ``DefectSystem`` object, containing the defect
species with their charge states, formation energies, and
degeneracies, as well as the density of states (DOS), volume,
and temperature of the system.
"""
assert self._DefectSpecies
assert self._DefectSystem
single_chempot_dict, el_refs = self.defect_thermodynamics._get_chempots(
single_chempot_dict, el_refs
) # returns self.defect_thermodynamics.chempots/self.defect_thermodynamics.el_refs if None
dft_chempots = _get_dft_chempots(single_chempot_dict, el_refs)
defect_species = [] # dicts of: {"charge_states": {...}, "nsites": X, "name": label}
for label, entry_list in self.defect_thermodynamics.all_entries.items():
# here we use site concentration (multiplicity/volume) rather than raw multiplicity throughout,
# so that the logic is supercell-size-invariant and correctly handles cases where different
# charge states of the same defect are computed in different-sized supercells.
# py-sc-fermi doesn't allow degeneracies < 1, so we set the reference site concentration to
# be either the minimum site concentration of entries in the group, or the minimum
# site-concentration-degeneracy product (if lower), to ensure degeneracy >= 1:
def _site_conc(entry):
return entry.defect.multiplicity / entry.defect.structure.volume
min_degeneracy_site_conc = min(
(np.prod(list(entry.degeneracy_factors.values())) if entry.degeneracy_factors else 1)
* _site_conc(entry)
for entry in entry_list
)
reference_site_conc = min(*(_site_conc(e) for e in entry_list), min_degeneracy_site_conc)
defect_species_dict = {
"charge_states": {},
"nsites": reference_site_conc * self.volume, # sites per DOS cell
"name": label,
}
for entry in entry_list:
formation_energy = self.defect_thermodynamics.get_formation_energy(
entry,
chempots=dft_chempots,
fermi_level=self.py_sc_fermi_dos.vbm - self.defect_thermodynamics.vbm,
)
degeneracy_factor = (
np.prod(list(entry.degeneracy_factors.values())) if entry.degeneracy_factors else 1
)
# py-sc-fermi assumes the same multiplicity (nsites) for all defect species / charge
# states of a given grouped defect, but this is not necessarily the case for
# interstitials (e.g. Te_i_Td_Te2.83_a), so we account for this in the degeneracy
# factors here (using site concentrations so this is also correct when different charge
# states use different supercell sizes):
degeneracy_factor *= _site_conc(entry) / reference_site_conc
defect_species_dict["charge_states"][entry.charge_state] = {
"charge": entry.charge_state,
"energy": formation_energy,
"degeneracy": degeneracy_factor, # has to be >=1 in py-sc-fermi
}
defect_species.append(defect_species_dict)
all_defect_species = [self._DefectSpecies.from_dict(subdict) for subdict in defect_species]
if effective_dopant_concentration is not None:
all_defect_species.append(
self._generate_dopant_for_py_sc_fermi(effective_dopant_concentration)
)
defect_system = self._DefectSystem(
defect_species=all_defect_species,
dos=self.py_sc_fermi_dos,
volume=self.volume,
temperature=temperature,
convergence_tolerance=1e-20,
)
self._fix_defect_concentrations(defect_system, fixed_defects)
return defect_system
def _fix_defect_concentrations(
self,
defect_system: "DefectSystem",
fixed_defects: dict[str, float] | None = None,
total_conc_dict: dict[str, float] | None = None,
) -> None:
"""
Utility method to fix the concentrations of defects specified by
``fix_defects`` in the ``py-sc-fermi`` ``defect_system``.
This method applies the concentration constraints to the
``defect_system.defect_species`` in place.
Args:
defect_system (DefectSystem):
``py-sc-fermi`` ``DefectSystem`` for which to fix the
concentrations of defects (in ``defect_system.defect_species``)
according to the ``fixed_defects`` input.
fixed_defects (dict[str, float] | None):
A dictionary of defect concentrations to fix regardless of
chemical potentials / temperature / Fermi level, in the format:
``{defect_name: concentration}``, where ``defect_name`` is the
name of a defect entry without (e.g. ``"v_O"``) or with (e.g.
``"v_O_+2"``) the charge state; which will then fix either the
total concentration of that defect or only the concentration
for the specified charge state. Concentrations should be given
in cm^-3. This can be used to simulate the effect of a fixed
impurity concentration. Defaults to ``None``.
total_conc_dict (dict):
Dictionary of total concentrations of defects which, if
provided, will be compared to input concentration constraints
(``fixed_defects``) and a warning will be thrown if
``fixed_defects[defect_name_with_charge]`` is larger than
``total_conc_dict[defect_name_without_charge]``.
Default is ``None``.
"""
if fixed_defects is None:
return
conc_factor = self.volume / 1e24 # cell density per cm^3; self.volume = defect_system.volume
for name, fixed_conc in fixed_defects.items():
if name.split("_")[-1].strip("+-").isdigit():
defect_name_wout_charge, q_str = name.rsplit("_", 1)
defect_system.defect_species_by_name(defect_name_wout_charge).charge_states[
int(q_str)
].fix_concentration(fixed_conc * conc_factor)
if total_conc_dict and fixed_conc > total_conc_dict[defect_name_wout_charge] * 1.001:
# fixed concentration for a defect species is higher than total conc (w/ noise tol):
warnings.warn(
f"Fixed concentration of {name} ({fixed_conc}) is higher than the total "
f"concentration of ({total_conc_dict[defect_name_wout_charge]}) at the annealing "
f"temperature. Adjusting the total concentration of {defect_name_wout_charge} to "
f"{fixed_conc}. Check that this is the behaviour you expect."
)
defect_system.defect_species_by_name(defect_name_wout_charge).fix_concentration(
fixed_conc * conc_factor
)
else:
defect_system.defect_species_by_name(name).fix_concentration(fixed_conc * conc_factor)
def _generate_annealed_defect_system(
self,
annealing_temperature: float,
single_chempot_dict: dict[str, float],
el_refs: dict[str, float] | None = None,
quenched_temperature: float = 300,
effective_dopant_concentration: float | None = None,
delta_VBM: float | Callable = 0.0,
delta_CBM: float | Callable = 0.0,
delta_gap: float | Callable = 0.0,
fixed_defects: dict[str, float] | None = None,
free_defects: list[str] | None = None,
fix_charge_states: bool = False,
return_annealing_values: bool = False,
**kwargs,
) -> "DefectSystem | tuple[DefectSystem, float, float, float, dict, dict]":
r"""
Generate a ``py-sc-fermi`` ``DefectSystem`` object that has defect
concentrations fixed to the values determined at a high temperature
(``annealing_temperature``), and then set to a lower temperature
(``quenched_temperature``).
This method creates a defect system where defect concentrations are
initially calculated at an annealing temperature and then "frozen" as
the system is cooled to a lower quenched temperature. It can optionally
fix the concentrations of individual defect charge states or allow
charge states to vary while keeping total defect concentrations fixed
(default).
See ``_pseudo_equilibrium_solve`` docstring for more details.
Args:
annealing_temperature (float):
The higher temperature (in Kelvin) at which the system is
annealed to set initial defect concentrations.
single_chempot_dict (dict[str, float]):
Dictionary of chemical potentials to use for calculating the
equilibrium Fermi level position and defect/carrier
concentrations. Here, this should be a dictionary of chemical
potentials for a single limit (``limit``), in the format:
``{element symbol: chemical potential}``.
If ``el_refs`` is provided or set in
``self.defect_thermodynamics.el_refs`` then it is the `formal`
chemical potentials (i.e. relative to the elemental reference
energies) that should be given here, otherwise the absolute
(DFT) chemical potentials should be given.
el_refs (dict[str, float]):
Dictionary of elemental reference energies for the chemical
potentials in the format:
``{element symbol: reference energy}``. Unnecessary if
``self.defect_thermodynamics.el_refs`` is set (i.e. if
``chempots`` was provided to ``self.defect_thermodynamics`` in
the format generated by ``doped``).
(Default: None)
quenched_temperature (float):
The lower temperature (in Kelvin) to which the system is
quenched. Defaults to 300 K.
effective_dopant_concentration (float | None):
The fixed concentration (in cm^-3) of an arbitrary dopant or
impurity in the material. This value is included in the charge
neutrality condition to analyse the Fermi level and doping
response under hypothetical doping conditions.
A positive value corresponds to donor doping, while a negative
value corresponds to acceptor doping. For dopants of charge
``q``, the input should be ``q * 'Dopant Concentration'``.
Defaults to ``None``, corresponding to no additional extrinsic
dopant.
delta_VBM (float | Callable):
Rigid shift (in eV; positive = upshift) to apply to the VBM
position `at the annealing temperature`, e.g. to model
temperature-dependent band edges. If set, applies a scissor
correction to ``bulk_dos`` (rigidly shifting the valence band)
while defect levels remain fixed. Can be a value or a function
of annealing temperature, e.g. ``lambda T: -2e-4*T``. Default
is ``0.0`` (no VBM shift).
delta_CBM (float | Callable):
Rigid shift (in eV; positive = upshift) to apply to the CBM
position `at the annealing temperature`, e.g. to model
temperature-dependent band edges. If set, applies a scissor
correction to ``bulk_dos`` (rigidly shifting the conduction
band) while defect levels remain fixed. Can be a value or a
function of annealing temperature, e.g. ``lambda T: -2e-4*T``.
Default is ``0.0`` (no CBM shift).
delta_gap (float | Callable):
(Deprecated, to be removed in v4.1; use ``delta_VBM``/
``delta_CBM`` instead.) Change in band gap (in eV) of the
host material at the annealing temperature, assuming symmetric
renormalisation about the VBM and CBM (i.e. assuming equal
up/downshifts of the band-edges around their original
eigenvalues -> ``delta_VBM = -delta_gap/2``,
``delta_CBM = +delta_gap/2``. Default is ``0.0`` (no shift).
``delta_VBM`` and ``delta_CBM`` take priority over
``delta_gap``.
fixed_defects (dict[str, float] | None):
A dictionary of defect concentrations to fix at the quenched
temperature regardless of chemical potentials / temperature /
Fermi level, in the format: ``{defect_name: concentration}``,
where ``defect_name`` is the name of a defect entry without
(e.g. ``"v_O"``) or with (e.g. ``"v_O_+2"``) the charge state;
which will then fix either the total concentration of that
defect or only the concentration for the specified charge
state. Concentrations should be given in cm^-3. This can be
used to simulate the effect of a fixed impurity concentration.
Defaults to ``None``.
free_defects (list[str] | None):
A list of defects (without charge states) to be excluded from
high-temperature concentration fixing. Useful for highly mobile
defects that are not expected to be "frozen-in" upon quenching.
Any defects whose names begin with a string in this list will
be excluded from high-temperature concentration fixing (e.g.
``"v_"`` will match all vacancy defects with
``doped``\-formatted names). Defaults to ``None``.
fix_charge_states (bool):
Whether to fix the concentrations of individual defect charge
states (``True``) or allow charge states to vary while keeping
total defect concentrations fixed (``False``).
Not expected to be physically sensible in most cases.
Defaults to ``False``.
return_annealing_values (bool):
If ``True``, also return the Fermi level, electron/hole
concentrations and per-defect (and per-charge) concentrations
at the annealing temperature. Default is ``False``.
**kwargs:
Additional keyword arguments to pass to ``scissor_dos`` (if
``delta_VBM``, ``delta_CBM`` or ``delta_gap`` are not 0).
Returns:
DefectSystem:
A low-temperature defect system (``quenched_temperature``)
with defect concentrations fixed to high-temperature
(``annealing_temperature``) values.
If ``return_annealing_values=True``, instead returns the tuple::
(defect_system, annealing_fermi_level, annealing_e_conc,
annealing_h_conc, total_conc_dict,
annealing_per_charge_conc_dict)
where the annealing values are taken from the high-temperature
``DefectSystem`` solution. ``total_conc_dict`` is a
``{defect_name: total_concentration}`` dict and
``annealing_per_charge_conc_dict`` is a
``{defect_name: {charge: concentration}}`` dict.
"""
free_defects = free_defects or []
orig_py_sc_fermi_dos = self.py_sc_fermi_dos
if not all(i == 0 for i in [delta_VBM, delta_CBM, delta_gap]):
assert self.defect_thermodynamics.vbm is not None # typing
assert self.defect_thermodynamics.band_gap is not None # typing
# locate the CBM from the DOS itself so the bandgap matches the shifted-energy frame
# (``fdos.energies - thermo.vbm``); using ``thermo.band_gap`` would mislocate the CBM
# whenever the DOS VBM and the supercell VBM differ -- see py-sc-fermi DOS parsing in FS init
dos_cbm = self.defect_thermodynamics.bulk_dos.get_cbm_vbm(tol=1e-4, abs_tol=True)[0]
delta_gap = delta_gap if not callable(delta_gap) else delta_gap(annealing_temperature)
delta_VBM = (
delta_VBM if not callable(delta_VBM) else delta_VBM(annealing_temperature)
) or -delta_gap / 2
delta_CBM = (
delta_CBM if not callable(delta_CBM) else delta_CBM(annealing_temperature)
) or delta_gap / 2
self.py_sc_fermi_dos = _get_py_sc_fermi_dos_from_fermi_dos(
scissor_dos(
dos=self.defect_thermodynamics.bulk_dos,
delta_VBM=delta_VBM,
delta_CBM=delta_CBM,
delta_gap=delta_gap,
verbose=kwargs.get("verbose", False),
tol=kwargs.get("tol", 1e-8),
),
vbm=self.defect_thermodynamics.vbm + delta_VBM,
bandgap=dos_cbm - (self.defect_thermodynamics.vbm + delta_VBM) + delta_CBM,
)
else:
delta_VBM = 0 # for tracking annealing VBM position
defect_system = self._generate_defect_system(
single_chempot_dict=single_chempot_dict, # chempots handled in _generate_defect_system()
el_refs=el_refs,
temperature=annealing_temperature,
effective_dopant_concentration=effective_dopant_concentration,
) # generated with band-edge-shifted DOS
# TODO: Allow fixed_defects to be input as a negative value, to fix the concentration under all
# conditions (i.e. annealing (here) and quenched, rather than just quenched)
# self._fix_defect_concentrations(defect_system, fixed_defects, fixed_concs) # for fixed_defects
# TODO: Handle case (here and in doped backend, when supported) where ``v_C`` is input to fix
# ``v_C`` and not ``v_Cd`` (require '$' in input?)
decomposed_conc_dict = defect_system.concentration_dict(decomposed=True) # per-charge anneal vals
# capture annealing Fermi level + electron/hole/per-defect concentrations before
# ``initial_conc_dict`` is mutated below or overwritten by the quenched solve:
annealing_e_conc = decomposed_conc_dict["n0"]
annealing_h_conc = decomposed_conc_dict["p0"]
# VBM is renormalised to 0 in py-sc-fermi, so we add the VBM shift to ensure it is relative to the
# original (quenched) VBM:
annealing_fermi_level = decomposed_conc_dict["Fermi Energy"] + delta_VBM
special_keys = ("Fermi Energy", "n0", "p0", "Dopant")
annealing_per_charge_conc_dict = {
k: dict(v) for k, v in decomposed_conc_dict.items() if k not in special_keys
}
total_conc_dict: dict[str, float] = {
k: sum(v.values()) for k, v in annealing_per_charge_conc_dict.items()
}
# Exclude the free_defects, carrier concentrations and Fermi level from fixing
all_free_defects = [*special_keys, *free_defects]
if not all(i == 0 for i in [delta_VBM, delta_CBM, delta_gap]):
# regenerate defect system with band edges back to quenched temperature values
self.py_sc_fermi_dos = orig_py_sc_fermi_dos # back to original DOS
defect_system = self._generate_defect_system(
single_chempot_dict=single_chempot_dict, # chempots handled in _generate_defect_system()
el_refs=el_refs,
temperature=annealing_temperature,
effective_dopant_concentration=effective_dopant_concentration,
) # no band-edge shifting
# Apply the fixed annealing concentrations
conc_factor = self.volume / 1e24 # cell density per cm^3; self.volume = defect_system.volume
for defect_species in defect_system.defect_species:
if fix_charge_states: # fix by charge state
for q, defect_charge_state in defect_species.charge_states.items():
species_keys = [f"{defect_species.name}_{int(q)}", f"{defect_species.name}_{int(q):+}"]
if not any(name.startswith(i) for name in species_keys for i in all_free_defects):
defect_charge_state.fix_concentration(
annealing_per_charge_conc_dict[defect_species.name][q] * conc_factor
)
elif not any(defect_species.name.startswith(i) for i in all_free_defects): # fix total conc
defect_species.fix_concentration(total_conc_dict[defect_species.name] * conc_factor)
# Fix concentrations if ``fixed_defects`` supplied:
# ``total_conc_dict`` supplied to warn if ``|fixed_defect_species| > |fixed_total_conc|``
self._fix_defect_concentrations(defect_system, fixed_defects, total_conc_dict)
defect_system.temperature = quenched_temperature
if return_annealing_values:
return (
defect_system,
annealing_fermi_level,
annealing_e_conc,
annealing_h_conc,
total_conc_dict,
annealing_per_charge_conc_dict,
)
return defect_system
[docs]
def get_interpolated_chempots(
chempot_start: dict,
chempot_end: dict,
n_points: int,
) -> list:
"""
Generate a list of interpolated chemical potentials between two points.
Here, these should be dictionaries of chemical potentials for `single`
limits, in the format: ``{element symbol: chemical potential}``.
Args:
chempot_start (dict):
A dictionary representing the starting chemical potentials.
chempot_end (dict):
A dictionary representing the ending chemical potentials.
n_points (int):
The number of interpolated points to generate, `including`
the start and end points.
Returns:
list:
A list of dictionaries, where each dictionary contains a
`single` set of interpolated chemical potentials. The length of
the list corresponds to `n_points`, and each dictionary
corresponds to an interpolated state between the starting and
ending chemical potentials.
"""
return [
{
key: chempot_start[key] + (chempot_end[key] - chempot_start[key]) * i / (n_points - 1)
for key in chempot_start
}
for i in range(n_points)
]
def _get_py_sc_fermi_dos_from_fermi_dos(
fermi_dos: FermiDos,
vbm: float | None = None,
nelect: int | None = None,
bandgap: float | None = None,
) -> "DOS":
"""
Given an input ``pymatgen`` ``FermiDos`` object, return a corresponding
``py-sc-fermi`` ``DOS`` object (which can then be used with the ``py-sc-
fermi`` ``FermiSolver`` backend).
Args:
fermi_dos (FermiDos):
``pymatgen`` ``FermiDos`` object to convert to ``py-sc-fermi``
``DOS``.
vbm (float):
The valence band maximum (VBM) eigenvalue in eV. If not provided,
the VBM will be taken from the ``FermiDos`` object. When this
function is used internally in ``doped``, the
``DefectThermodynamics.vbm`` attribute is used.
nelect (int):
The total number of electrons in the system. If not provided, the
number of electrons will be taken from the ``FermiDos`` object
(which usually takes this value from the ``vasprun.xml(.gz)`` when
parsing).
bandgap (float):
Band gap of the system in eV. If not provided, the band gap will be
taken from the ``FermiDos`` object. When this function is used
internally in ``doped``, the ``DefectThermodynamics.band_gap``
attribute is used.
Returns:
DOS: A ``py-sc-fermi`` ``DOS`` object.
"""
try:
from py_sc_fermi.dos import DOS
except ImportError as exc:
raise ImportError("py-sc-fermi must be installed to use this function!") from exc
densities = fermi_dos.densities
if vbm is None: # tol 1e-4 is lowest possible, as VASP rounds to 4 dp:
vbm = fermi_dos.get_cbm_vbm(tol=1e-4, abs_tol=True)[1]
edos = fermi_dos.energies - vbm
if len(densities) == 2:
dos = np.array([densities[Spin.up], densities[Spin.down]])
spin_pol = True
else:
dos = np.array(densities[Spin.up])
spin_pol = False
if nelect is None:
# this requires the input dos to be a FermiDos. NELECT could be calculated alternatively
# by integrating the tdos of a ``pymatgen`` ``Dos`` object, but this isn't expected to be
# a common use case and using parsed NELECT from vasprun.xml(.gz) is more reliable
nelect = fermi_dos.nelecs
if bandgap is None:
bandgap = fermi_dos.get_gap(tol=1e-4, abs_tol=True)
py_sc_fermi_dos = DOS(dos=dos, edos=edos, nelect=nelect, bandgap=bandgap, spin_polarised=spin_pol)
py_sc_fermi_dos.vbm = vbm # store absolute VBM eigenvalue for downstream use (e.g. when scissoring)
return py_sc_fermi_dos
def _get_min_max_target_values(
results_df: pd.DataFrame, target: str, min_or_max: str, verbose: bool = False
) -> tuple:
"""
Convenience function to get the minimum or maximum value(s) of a ``target``
column or row in a ``results_df`` DataFrame, and the corresponding chemical
potentials.
Mainly intended for internal ``doped`` usage in the ``FermiSolver``
``optimise`` method.
Args:
results_df (pd.DataFrame):
``DataFrame`` of defect concentrations, as output by the
``FermiSolver`` ``scan_chempots`` /
``scan_chemical_potential_grid`` methods (which corresponds to the
``_(pseudo_)equilibrium_solve`` ``DataFrame`` outputs, appended
together for multiple chemical potentials).
target (str):
The target variable to minimise or maximise, e.g., "Electrons
(cm^-3)", "Te_i", "Fermi Level (eV wrt VBM)" etc. Valid ``target``
values are column names (or substrings), such as
"Electrons (cm^-3)", "Holes (cm^-3)", "Fermi Level (eV wrt VBM)",
"μ_X (eV)", etc., or defect names (without charge states), such as
"v_O", "Te_i", etc.
If a full defect name is given (e.g. ``Te_i_Td_Te2.83``) then the
concentration of that defect will be used as the target variable.
If a defect name substring is given instead (e.g. ``Te_i``), then
the target variable will be the summed concentration of all defects
with that substring in their name (e.g. ``Te_i_Td_Te2.83``,
``Te_i_C3v`` etc).
min_or_max (str):
Whether to find the minimum or maximum value(s) of the target.
Should be either "min" or "max".
verbose (bool):
Whether to print information on identified target rows/columns.
Returns:
tuple:
A tuple containing the results ``DataFrame`` at the chemical
potentials which minimise/maximise the target property
(``target_df``), the minimised/maximised value of the target
property, and the corresponding chemical potentials -- in the given
chemical potential range.
"""
def min_or_max_func(x):
return x.min() if "min" in min_or_max else x.max()
chempots_labels = [col for col in results_df.columns if col.startswith("μ_")]
# handle MultiIndex (per_charge=True gives ("Defect", "Charge") tuples) -- extract defect names:
defect_names = (
results_df.index.get_level_values("Defect")
if isinstance(results_df.index, pd.MultiIndex)
else results_df.index
)
# determine target; can be column, defect name, element (TODO), starting string of column name,
# starting string of defect name, column name subset or defect name subset, w/that preferential order:
target_names = (
[col for col in results_df.columns if col == target]
or [name for name in defect_names if name == target]
or [col for col in results_df.columns if col.lower().startswith(target.lower())]
or [name for name in defect_names if name.lower().startswith(target.lower())]
or [col for col in results_df.columns if target in col]
or [name for name in defect_names if target in name]
)
target_names = sorted(set(target_names), key=target_names.index) # preserve order
if not target_names:
raise ValueError(
f"Target '{target}' not found in results DataFrame! Must be a column or defect "
f"name/substring! See docstring for more info."
)
column = next(iter(target_names)) in results_df.columns
if verbose:
print(
f"Searching for chemical potentials which {min_or_max}imise the target "
f"{'column' if column else 'defect(s)'}: {target_names}..."
)
if column:
target_name = next(iter(target_names))
if len(target_names) > 1: # can only match one column
warnings.warn(
f"Multiple columns with the name '{target}' found in the results DataFrame! "
f"Choosing the first match '{target_name}' as the target."
)
current_value = min_or_max_func(results_df[target_name])
target_df = results_df[results_df[target_name] == current_value]
target_chempot = target_df[chempots_labels]
else:
# filter df for the chosen defect(s), handling both simple and MultiIndex (per_charge=True):
filtered_df = results_df[defect_names.isin(target_names)]
# TODO: When adding element option, will need to subtract for vacancies...
# group by chemical potentials, to sum values at the same chempots (e.g. for different defects /
# charge states); only sum the concentration column to avoid issues with non-numeric columns (e.g.
# "Charge State Population" strings):
summed_conc = filtered_df.groupby(chempots_labels)["Concentration (cm^-3)"].sum()
current_value = min_or_max_func(summed_conc) # find the extremum
# get chempots which min/maximise the target:
target_chempot = summed_conc[summed_conc == current_value].index.to_frame()
# get all DataFrame rows which have the chempots matching the extremum row:
target_df = results_df[results_df[chempots_labels].eq(target_chempot.iloc[0]).all(axis=1)]
target_chempot = target_chempot.drop_duplicates(ignore_index=True)
return target_df, current_value, target_chempot
def _ensure_list(
var: float | range | list | np.ndarray | None = None,
) -> list[float | int] | np.ndarray | None:
if isinstance(var, range):
return list(var)
return [var] if isinstance(var, int | float) else var