Source code for arpes.analysis.gap

"""Utilities for gap fitting in ARPES, contains tools to normalize by Fermi-Dirac occupation."""

from __future__ import annotations

from logging import DEBUG, INFO
from typing import TYPE_CHECKING

import numpy as np
import xarray as xr

from arpes.constants import K_BOLTZMANN_EV_KELVIN
from arpes.debug import setup_logger
from arpes.fits.fit_models import AffineBroadenedFD
from arpes.provenance import update_provenance
from arpes.utilities import normalize_to_spectrum

if TYPE_CHECKING:
    from lmfit.model import ModelResult

    from arpes._typing.base import DataType

__all__ = ("determine_broadened_fermi_distribution", "normalize_by_fermi_dirac", "symmetrize")


LOGLEVELS = (DEBUG, INFO)
LOGLEVEL = LOGLEVELS[1]
logger = setup_logger(__name__, LOGLEVEL)


[docs] def determine_broadened_fermi_distribution( data: xr.DataArray, *, fixed_temperature: bool = True, ) -> ModelResult: """Determine the parameters for broadening by temperature and instrumental resolution. As a general rule, we first try to estimate the instrumental broadening and linewidth broadening according to calibrations provided for the beamline + instrument, as a starting point. We also calculate the thermal broadening to expect, and fit an edge location. Then we use a Gaussian convolved Fermi-Dirac distribution against an affine density of states near the Fermi level, with a constant offset background above the Fermi level as a simple but effective model when away from lineshapes. These parameters can be used to bootstrap a fit to actual data or used directly in ``normalize_by_fermi_dirac``. Args: data (xr.DataArray): The data we want to estimate from. fixed_temperature (bool): Whether we should force the temperature to the recorded value. Return: The estimated fit result for the Fermi distribution. """ params = {} if fixed_temperature: params["width"] = { "value": data.S.temp * K_BOLTZMANN_EV_KELVIN, "vary": False, } sum_dims = list(data.dims) sum_dims.remove("eV") return AffineBroadenedFD().fit( data=data.sum(sum_dims), x=data.coords["eV"], params=params, )
@update_provenance("Normalize By Fermi Dirac") def normalize_by_fermi_dirac( # noqa: PLR0913 data: DataType, reference_data: DataType | None = None, broadening: float = 0, temperature_axis: str = "", temp_offset: float = 0, *, plot: bool = False, **kwargs: bool, ) -> xr.DataArray: """Normalizes data by Fermi level. Data normalization according to a Fermi level reference on separate data or using the same source spectrum. To do this, a linear density of states is multiplied against a resolution broadened Fermi-Dirac distribution (`arpes.fits.fit_models.AffineBroadenedFD`). We then set the density of states to 1 and evaluate this model to obtain a reference that the desired spectrum is normalized by. Args: data: Data to be normalized. reference_data: A reference spectrum, typically a metal reference. If not provided the integrated data is used. Beware: this is inappropriate if your data is gapped. plot: A debug flag, allowing you to view the normalization spectrum and relevant curve-fits. broadening: Detector broadening. temperature_axis: Temperature coordinate, used to adjust the quality of the reference for temperature dependent data. temp_offset: Temperature calibration in the case of low temperature data. Useful if the temperature at the sample is known to be hotter than the value recorded off of a diode. **kwargs: pass to determine_broadened_fermi_distribution (Thus, fixed_temperature) Returns: Data after normalization by the Fermi occupation factor. """ reference_data = data if reference_data is None else reference_data broadening_fit = determine_broadened_fermi_distribution(reference_data, **kwargs) broadening = broadening_fit.params["conv_width"].value if broadening is None else broadening if plot: msg = f"Gaussian broadening is: {broadening_fit.params['conv_width'].value * 1000} meV" msg += " (Gaussian sigma)" logger.info(msg) msg = f"Fermi edge location is: {broadening_fit.params['center'].value * 1000} meV" msg += " (fit chemical potential)" logger.info(msg) msg = f"Fermi width is: {broadening_fit.params['width'].value * 1000} meV" msg += " (fit fermi width)" logger.info(msg) broadening_fit.plot() offset = broadening_fit.params["offset"].value without_offset = broadening_fit.eval(offset=0) cut_index = -np.argmax(without_offset[::-1] > 0.1 * offset) cut_energy = reference_data.coords["eV"].values[cut_index] if (not temperature_axis) and "temp" in data.dims: temperature_axis = "temp" transpose_order: list[str] = [str(dim) for dim in data.dims] transpose_order.remove("eV") if temperature_axis: transpose_order.remove(temperature_axis) transpose_order = [*transpose_order, temperature_axis] transpose_order = [*transpose_order, "eV"] without_background = (data - data.sel(eV=slice(cut_energy, None)).mean("eV")).transpose( *transpose_order, ) # <== NEED TO CHECK (What it the type of without_background ?) without_background_arr = normalize_to_spectrum(without_background) assert isinstance(without_background_arr, xr.DataArray) if temperature_axis: divided = without_background_arr.G.map_axes( temperature_axis, lambda x, coord: x / broadening_fit.eval( x=x.coords["eV"].values, lin_slope=0, const_bkg=1, offset=0, conv_width=broadening, width=(coord[temperature_axis] + temp_offset) * K_BOLTZMANN_EV_KELVIN, ), ) else: divided = without_background_arr / broadening_fit.eval( x=data.coords["eV"].values, conv_width=broadening, lin_slope=0, const_bkg=1, offset=0, ) divided.coords["eV"].values = ( divided.coords["eV"].values - broadening_fit.params["center"].value ) return divided def _shift_energy_interpolate( data: xr.DataArray, shift: xr.DataArray | None = None, ) -> xr.DataArray: data = data if isinstance(data, xr.DataArray) else normalize_to_spectrum(data) data_arr = data.transpose("eV", ...) new_data = data_arr.copy(deep=True) new_axis = new_data.coords["eV"] new_values = new_data.values * 0 if shift is None: closest_to_zero = data_arr.coords["eV"].sel(eV=0, method="nearest") shift = -closest_to_zero assert isinstance(shift, xr.DataArray) stride = data_arr.G.stride("eV", generic_dim_names=False) if np.abs(shift) >= stride: n_strides = int(shift / stride) new_axis = new_axis + n_strides * stride shift = shift - stride * n_strides new_axis = new_axis + shift assert shift is not None weight = float(shift / stride) new_values = new_values + data_arr.values * (1 - weight) if shift > 0: new_values[1:] += data_arr.values[:-1] * weight if shift < 0: new_values[:-1] += data_arr.values[1:] * weight new_data.coords["eV"] = new_axis new_data.values = new_values return new_data
[docs] @update_provenance("Symmetrize") def symmetrize( data: xr.DataArray, *, subpixel: bool = False, ) -> xr.DataArray: """Symmetrizes data across the chemical potential. This provides a crude tool by which gap analysis can be performed. In this implementation, subpixel accuracy is achieved by interpolating data. Args: data: Input array. subpixel: Enable subpixel correction Returns: The symmetrized data. """ data = data if isinstance(data, xr.DataArray) else normalize_to_spectrum(data) data = data.transpose("eV", ...) if subpixel: data = _shift_energy_interpolate(data) assert isinstance(data, xr.DataArray) above = data.sel(eV=slice(0, None)) below = data.sel(eV=slice(None, 0)).copy(deep=True) length_eV_coords = len(above.coords["eV"]) zeros = below.values * 0 zeros[-length_eV_coords:] = above.values[::-1] below.values += zeros if subpixel: full_data = below.copy(deep=True) new_above = full_data.copy(deep=True)[::-1] new_above.coords["eV"] = new_above.coords["eV"] * -1 full_data = xr.concat([full_data, new_above[1:]], dim="eV") result = full_data else: result = below return result