Source code for arpes.utilities.conversion.bounds_calculations

"""Calculates momentum bounds from the angular coordinates.

Mostly these are used as common helper routines to the coordinate conversion code,
which is responsible for actually outputting the desired bounds.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

import numpy as np

from arpes.constants import K_INV_ANGSTROM
from arpes.xarray_extensions.accessor.spectrum_type import EnergyNotation

if TYPE_CHECKING:
    import xarray as xr
    from numpy.typing import NDArray

__all__ = ("full_angles_to_k",)


[docs] def full_angles_to_k( # noqa: PLR0913 kinetic_energy: NDArray[np.floating] | float, phi: float | NDArray[np.floating], psi: float | NDArray[np.floating], alpha: float | NDArray[np.floating], beta: float | NDArray[np.floating], theta: float | NDArray[np.floating], chi: float | NDArray[np.floating], inner_potential: float, ) -> ( tuple[float, float, float] | tuple[NDArray[np.floating], NDArray[np.floating], NDArray[np.floating]] ): """Converts from the full set of standard PyARPES angles to momentum. More details on angle to momentum conversion can be found at `the momentum conversion notes <https://arpes.readthedocs.io/momentum-conversion>`. Args: kinetic_energy (float | xr.DataArray): kinetic energy phi (float): angle along analyzer psi (float): analyzer deflector angle alpha (float): analyzer rotation angle beta (float): scan angle perpendicular to theta theta (float): goniometer azimuthal angle chi (float): sample azimuthal angle inner_potential (float): material inner potential in eV Returns: [(float, float, float)]: [(kx, ky, kz)] """ chi = -chi # use the full direct momentum conversion sin_alpha, cos_alpha = np.sin(alpha), np.cos(alpha) sin_beta, cos_beta = np.sin(beta), np.cos(beta) sin_theta, cos_theta = np.sin(theta), np.cos(theta) sin_chi, cos_chi = np.sin(chi), np.cos(chi) sin_phi, cos_phi = np.sin(phi), np.cos(phi) sin_psi, cos_psi = np.sin(psi), np.cos(psi) vx = cos_alpha * cos_psi * sin_phi - sin_alpha * sin_psi vy = sin_alpha * cos_psi * sin_phi + cos_alpha * sin_psi vz = cos_phi * cos_psi # perform theta rotation vrtheta_x = cos_theta * vx - sin_theta * vz vrtheta_y = vy vrtheta_z = sin_theta * vx + cos_theta * vz # perform beta rotation vrbeta_x = vrtheta_x vrbeta_y = cos_beta * vrtheta_y - sin_beta * vrtheta_z vrbeta_z = sin_beta * vrtheta_y + cos_beta * vrtheta_z # Perform chi rotation vrchi_x = cos_chi * vrbeta_x - sin_chi * vrbeta_y vrchi_y = sin_chi * vrbeta_x + cos_chi * vrbeta_y vrchi_z = vrbeta_z v_par_sq = vrchi_x**2 + vrchi_y**2 """ velocity -> momentum in each of parallel and perpendicular directions in the perpendicular case, we need the value of the cos^2(zeta) for the polar declination angle zeta in the sample (emission) frame. The total in plane velocity v_parallel is proportional to sin(zeta), so by the trig identity: 1 = cos^2(zeta) + sin^2(zeta) we may substitute cos^2(zeta) for 1 - sin^2(zeta) which is 1 - (vrchi_x **2 + vrchi_y ** 2) above. """ k_par = K_INV_ANGSTROM * np.sqrt(kinetic_energy) k_perp = K_INV_ANGSTROM * np.sqrt(kinetic_energy * (1 - v_par_sq) + inner_potential) return k_par * vrchi_x, k_par * vrchi_y, k_perp * vrchi_z
def euler_to_kx( kinetic_energy: NDArray[np.floating], phi: NDArray[np.floating] | float, beta: NDArray[np.floating] | float, theta: float = 0, *, slit_is_vertical: bool = False, ) -> NDArray[np.floating]: """Calculates kx from the phi/beta Euler angles given the experimental geometry.""" factor = K_INV_ANGSTROM * np.sqrt(kinetic_energy) return factor * (np.sin(beta) * np.cos(phi) if slit_is_vertical else np.sin(phi + theta)) def euler_to_ky( kinetic_energy: NDArray[np.floating], phi: NDArray[np.floating] | float, beta: NDArray[np.floating] | float, theta: float = 0, *, slit_is_vertical: bool = False, ) -> NDArray[np.floating]: """Calculates ky from the phi/beta Euler angles given the experimental geometry.""" return ( K_INV_ANGSTROM * np.sqrt(kinetic_energy) * ( np.cos(theta) * np.sin(phi) + np.cos(beta) * np.cos(phi) * np.sin(theta) if slit_is_vertical else (np.cos(phi + theta) * np.sin(beta)) ) ) def euler_to_kz( # noqa: PLR0913 kinetic_energy: NDArray[np.floating], phi: NDArray[np.floating] | float, beta: NDArray[np.floating] | float, theta: float = 0, inner_potential: float = 10, *, slit_is_vertical: bool = False, ) -> NDArray[np.floating]: """Calculates kz from the phi/beta Euler angles given the experimental geometry.""" beta_term = ( -np.sin(theta) * np.sin(phi) + np.cos(theta) * np.cos(beta) * np.cos(phi) if slit_is_vertical else np.cos(phi + theta) * np.cos(beta) ) return K_INV_ANGSTROM * np.sqrt(kinetic_energy * beta_term**2 + inner_potential) def spherical_to_kx( kinetic_energy: float, theta: float, phi: float, ) -> float: """Calculates kx from the sample spherical (emission, not measurement) coordinates.""" return K_INV_ANGSTROM * np.sqrt(kinetic_energy) * np.sin(theta) * np.cos(phi) def spherical_to_ky( kinetic_energy: float, theta: float, phi: float, ) -> float: """Calculates ky from the sample spherical (emission, not measurement) coordinates.""" return K_INV_ANGSTROM * np.sqrt(kinetic_energy) * np.sin(theta) * np.sin(phi) def spherical_to_kz( kinetic_energy: float, theta: float, inner_potential: float, ) -> float: r"""Calculates the out of plane momentum from sample spherical (not measurement) coordinates. K_INV_ANGSTROM encodes that k_z = \frac{\sqrt{2 * m * E_kin * \cos^2\theta + V_0}}{\hbar} Args: kinetic_energy: kinetic energy (E_kin) theta: angle theta phi: angle phi inner_potential(float): inner potential (V_0) Returns: The out of plane momentum, kz. """ return K_INV_ANGSTROM * np.sqrt(kinetic_energy * np.cos(theta) ** 2 + inner_potential) def calculate_kp_kz_bounds(arr: xr.DataArray) -> tuple[tuple[float, float], tuple[float, float]]: """Calculates kp and kz bounds for angle-hv Fermi surfaces.""" phi_offset = arr.S.phi_offset phi_min = arr.coords["phi"].min().item() - phi_offset phi_max = arr.coords["phi"].max().item() - phi_offset binding_energy_min, binding_energy_max = ( arr.coords["eV"].min().item(), arr.coords["eV"].max().item(), ) hv_min, hv_max = arr.coords["hv"].min().item(), arr.coords["hv"].max().item() wf = arr.S.analyzer_work_function # <= **FIX ME!!** kx_min = min( spherical_to_kx(hv_max - binding_energy_max - wf, phi_min, 0.0), spherical_to_kx(hv_min - binding_energy_max - wf, phi_min, 0.0), ) kx_max = max( spherical_to_kx(hv_max - binding_energy_max - wf, phi_max, 0.0), spherical_to_kx(hv_min - binding_energy_max - wf, phi_max, 0.0), ) angle_max = max(abs(phi_min), abs(phi_max)) assert isinstance(angle_max, float) inner_V = arr.S.inner_potential kz_min = spherical_to_kz(hv_min + binding_energy_min - wf, angle_max, inner_V) kz_max = spherical_to_kz(hv_max + binding_energy_max - wf, 0.0, inner_V) return ( (round(kx_min, 2), round(kx_max, 2)), # kp (round(kz_min, 2), round(kz_max, 2)), # kz ) def calculate_kp_bounds(arr: xr.DataArray) -> tuple[float, float]: """Calculates kp bounds for a single ARPES cut. Args: arr (xr.DataArray): ARPES 'cut'-type (the number of the anglar axis is 1 ("phi")) data Returns (tuple[float, float]): Minimum and maximum value of K region from the ARPES data """ phi_coords = arr.coords["phi"].values - arr.S.phi_offset beta = float(arr.coords["beta"]) - arr.S.beta_offset phi_low, phi_high = np.min(phi_coords), np.max(phi_coords) phi_mid = (phi_high + phi_low) / 2 sampled_phi_values = np.array([phi_low, phi_mid, phi_high]) max_kinetic_energy = ( max( arr.coords["eV"].max().item(), arr.S.hv - arr.S.analyzer_work_function, ) if arr.S.energy_notation == "Binding" else arr.coords["eV"].max().item() ) kps = K_INV_ANGSTROM * np.sqrt(max_kinetic_energy) * np.sin(sampled_phi_values) * np.cos(beta) return round(np.min(kps), 2), round(np.max(kps), 2) def calculate_kx_ky_bounds( arr: xr.DataArray, ) -> tuple[tuple[float, float], tuple[float, float]]: """Calculates the kx and ky range for a dataset with a fixed photon energy. This is used to infer the gridding that should be used for a k-space conversion. Based on Jonathan Denlinger's old codes Args: arr: Dataset that includes a key indicating the photon energy of the scan Returns: ((kx_low, kx_high,), (ky_low, ky_high,)) """ phi_coords, beta_coords = ( arr.coords["phi"] - arr.S.phi_offset, arr.coords["beta"] - arr.S.beta_offset, ) # Sample hopefully representatively along the edges phi_low: float phi_high: float phi_low, phi_high = phi_coords.min().item(), phi_coords.max().item() phi_mid: float = (phi_high + phi_low) / 2 sampled_phi_values: NDArray[np.floating] = np.array( [ phi_high, phi_high, phi_mid, phi_low, phi_low, phi_low, phi_mid, phi_high, phi_high, ], ) beta_low, beta_high = beta_coords.min().item(), beta_coords.max().item() beta_mid = (beta_high + beta_low) / 2 sampled_beta_values = np.array( [ beta_mid, beta_high, beta_high, beta_high, beta_mid, beta_low, beta_low, beta_low, beta_mid, ], ) max_kinetic_energy = ( max( arr.coords["eV"].max().item(), arr.S.hv - arr.S.analyzer_work_function, ) if arr.S.energy_notation is EnergyNotation.BINDING else arr.coords["eV"].max().item() ) # note that the type of the kinetic_energy is float in below. kxs: NDArray[np.floating] = ( K_INV_ANGSTROM * np.sqrt(max_kinetic_energy) * np.sin(sampled_phi_values) ) kys: NDArray[np.floating] = ( K_INV_ANGSTROM * np.sqrt(max_kinetic_energy) * np.cos(sampled_phi_values) * np.sin(sampled_beta_values) ) return ( (round(np.min(kxs), 2).astype(float), round(np.max(kxs), 2).astype(float)), (round(np.min(kys), 2).astype(float), round(np.max(kys), 2).astype(float)), )