Source code for arpes.analysis.self_energy

"""Contains self-energy analysis routines."""

from __future__ import annotations

from typing import TYPE_CHECKING, Literal

import lmfit as lf
import numpy as np
import xarray as xr
from lmfit.models import LinearModel, LorentzianModel
from skimage.measure import LineModelND, ransac

from arpes.constants import HBAR_PER_EV, METERS_PER_SECOND_PER_EV_ANGSTROM

if TYPE_CHECKING:
    from _typeshed import Incomplete
    from numpy.typing import NDArray

__all__ = (
    "estimate_bare_band",
    "fit_for_self_energy",
    "quasiparticle_lifetime",
    "to_self_energy",
)


def get_peak_parameter(
    data: xr.DataArray,
    parameter_name: str,
) -> xr.DataArray:
    """Extracts a parameter from a potentially prefixed peak-like component.

    Works so long as there is only a single peak defined in the model.

    Args:
        data: The input data containing the peak fitting results.
        parameter_name: The name of the parameter which should be extracted.

    Returns:
        The array of parameters corresponding to a peak in a single-peak curve fit.
    """
    first_item = data.values.ravel()[0]
    peak_like = (
        lf.models.LorentzianModel,
        lf.models.VoigtModel,
        lf.models.GaussianModel,
        lf.models.PseudoVoigtModel,
    )

    if isinstance(first_item, lf.model.ModelResult):
        if isinstance(first_item.model, lf.model.CompositeModel):
            peak_like_components = [
                c for c in first_item.model.components if isinstance(c, peak_like)
            ]
            assert len(peak_like_components) == 1

            return data.F.p(f"{peak_like_components[0].prefix}{parameter_name}")
        return data.F.p(parameter_name)

    msg = f"Unsupported dispersion {type(data)}, expected xr.DataArray[lmfit.ModelResult]"
    raise ValueError(
        msg,
    )


def local_fermi_velocity(bare_band: xr.DataArray) -> float:
    """Calculates the band velocity under assumptions of a linear bare band."""
    params = LinearModel().guess(bare_band, bare_band.coords["eV"].values)
    fitted_model = bare_band.S.modelfit("eV", model=LinearModel(), params=params)
    raw_velocity: float = fitted_model.params["slope"].value
    if "eV" in bare_band.dims:
        # the "y" values are in `bare_band` are momenta and the "x" values are energy, therefore
        # the slope is dy/dx = dk/dE
        raw_velocity = 1 / raw_velocity

    return raw_velocity * METERS_PER_SECOND_PER_EV_ANGSTROM


[docs] def estimate_bare_band( dispersion: xr.DataArray, bare_band_specification: str = "", ) -> xr.DataArray: """Estimates the bare band from a fitted dispersion. This can be done in a few ways: #. None: Equivalent to 'baseline_linear' below #. `'linear'`: A linear fit to the dispersion is used, and this also provides the fermi_velocity #. `'ransac_linear'`: A linear fit with random sample consensus (RANSAC) region will be used and this also provides the `fermi_velocity` #. `'hough'`: Hough transform based method Args: dispersion: The array of the fitted peak locations. bare_band_specification: What kind of bare band to assume. One of "linear", "ransac_linear", and "hough". Returns: An estimate of the bare band dispersion. """ try: centers = get_peak_parameter(dispersion, "center") except ValueError: centers = dispersion mom_options = [d for d in dispersion.dims if d in {"k", "kp", "kx", "ky", "kz"}] assert len(mom_options) <= 1 fit_dimension = "eV" if "eV" in dispersion.dims else mom_options[0] if not bare_band_specification: bare_band_specification = "ransac_linear" params = LinearModel().guess(centers, centers.coords["eV"].values) initial_linear_fit = centers.S.modelfit("eV", LinearModel(), params) if bare_band_specification == "linear": fitted_model = initial_linear_fit elif bare_band_specification == "ransac_linear": min_samples = len(centers.coords[fit_dimension]) // 10 residual = initial_linear_fit.residual assert residual is not None residual_threshold = np.median(np.abs(residual)) * 1 _, inliers = ransac( np.stack([centers.coords[fit_dimension], centers]).T, LineModelND, max_trials=1000, min_samples=min_samples, residual_threshold=residual_threshold, ) inlier_data = centers.where( xr.DataArray( inliers, coords={fit_dimension: centers.coords[fit_dimension]}, dims=[fit_dimension], ), drop=True, ) params = LinearModel().guess(inlier_data, inlier_data.coords[fit_dimension]) fitted_model = inlier_data.S.modelfit(fit_dimension, LinearModel(), params=params) elif bare_band_specification == "hough": msg = "Hough Transform estimate of bare band not yet supported." raise NotImplementedError(msg) else: msg = f"Unrecognized bare band type: {bare_band_specification}" raise ValueError(msg) ys = fitted_model.eval(x=centers.coords[fit_dimension]) return xr.DataArray(ys, centers.coords, centers.dims)
[docs] def quasiparticle_lifetime( self_energy: xr.DataArray, ) -> NDArray[np.floating]: """Calculates the quasiparticle mean free path in meters (meters!). The bare band is used to calculate the band/Fermi velocity and internally the procedure to calculate the quasiparticle lifetime is used. Args: self_energy: The measured or estimated self-energy. bare_band: The bare band defining the band velocity. Returns: An estimate of the quasiparticle lifetime along the band. """ imaginary_part = np.abs(np.imag(self_energy)) / 2 return HBAR_PER_EV / imaginary_part
def quasiparticle_mean_free_path( self_energy: xr.DataArray, bare_band: xr.DataArray, ) -> NDArray[np.floating]: lifetime = quasiparticle_lifetime(self_energy) return lifetime * local_fermi_velocity(bare_band)
[docs] def to_self_energy( dispersion: xr.DataArray, bare_band: xr.DataArray, fermi_velocity: float = 0, *, k_independent: bool = True, ) -> xr.Dataset: r"""Converts MDC fit results into the self energy. This largely consists of extracting out the linewidth and the difference between the dispersion and the bare band value. .. math:: lorentzian(x, amplitude, center, sigma) = (amplitude / pi) * sigma/(sigma^2 + ((x-center))**2) Once we have the curve-fitted dispersion we can calculate the self energy if we also know the bare-band dispersion. If the bare band is not known, then at least the imaginary part of the self energy is still calculable, and a guess as to the real part can be calculated under assumptions of the bare band dispersion as being free electron like with effective mass m* or being Dirac like (these are equivalent at low enough energy). Acceptabe bare band specifications are discussed in detail in `estimate_bare_band` above. To future readers of the code, please note that the half-width half-max of a Lorentzian is equal to the $\gamma$ parameter, which defines the imaginary part of the self energy. Args: dispersion (xr.DataArray | xr.Dataset): The array of the fitted peak locations. When xr.Dataset is set, ".results" is used. bare_band (xr.DataArray): the bare band. fermi_velocity (float): The fermi velocity. If not set, use local_fermi_velocity k_independent: bool Returns: The equivalent self energy from the bare band and the measured dispersion. """ if not k_independent: msg = ( "PyARPES does not currently support self energy analysis" " except in the k-independent formalism." ) raise NotImplementedError( msg, ) if isinstance(dispersion, xr.Dataset): dispersion = dispersion.results assert isinstance(dispersion, xr.DataArray) from_mdcs = "eV" in dispersion.dims # if eV is in the dimensions, then we fitted MDCs estimated_bare_band = estimate_bare_band(dispersion, bare_band_specification="ransac_linear") if not fermi_velocity: fermi_velocity = local_fermi_velocity(estimated_bare_band) assert isinstance(fermi_velocity, float) imaginary_part = get_peak_parameter(dispersion, "fwhm") / 2 centers = get_peak_parameter(dispersion, "center") if from_mdcs: imaginary_part *= fermi_velocity / METERS_PER_SECOND_PER_EV_ANGSTROM real_part = -( (centers * fermi_velocity / METERS_PER_SECOND_PER_EV_ANGSTROM) - dispersion.coords["eV"].values ) else: real_part = centers - bare_band self_energy = xr.DataArray( real_part + 1.0j * imaginary_part, coords=dispersion.coords, dims=dispersion.dims, ) return xr.Dataset({"self_energy": self_energy, "bare_band": estimated_bare_band})
[docs] def fit_for_self_energy( data: xr.DataArray, bare_band: xr.DataArray, method: Literal["mdc", "edc"] = "mdc", **kwargs: Incomplete, ) -> xr.Dataset: """Fits for the self energy of a dataset containing a single band. Args: data: The input ARPES data. method: Determine the broadcast dimension in broadcast_model, one of 'mdc' and 'edc' bare_band: Optionally, the bare band. If None is provided the bare band will be estimated. **kwargs: pass to S.modelfit (typical kwargs is param={}) Returns: The self energy resulting from curve-fitting. """ assert data.S.is_kspace model = LorentzianModel() + LinearModel() if method == "mdc": possible_mometum_dims = ("kp", "kx", "ky", "kz") mom_axis = next(str(dim) for dim in data.dims if dim in possible_mometum_dims) assert mom_axis is not None model = LorentzianModel() + LinearModel() fit_results = data.S.modelfit(coords=mom_axis, model=model, **kwargs) else: fit_results = data.S.modelfit(coords="eV", model=model, **kwargs) return to_self_energy(fit_results.modelfilt_results, bare_band=bare_band)