Source code for tbkit.optics

r"""
Linear and nonlinear optical response of 2D Bloch models (:class:`~tbkit.kspace.KSpace`)
over a uniform Brillouin-zone mesh.

* :func:`optical_conductivity` -- the Kubo-Greenwood conductivity
  :math:`\sigma_{ab}(\omega)`, interband and intraband (Drude), with a
  broadening :math:`\eta`.
* :func:`joint_dos` -- the joint density of states of vertical transitions.
* :func:`berry_curvature_dipole` -- the Berry curvature dipole of the
  nonlinear Hall effect (Sodemann and Fu 2015).
* :func:`shift_current` -- the shift-current conductivity
  :math:`\sigma^{abc}(0;\omega,-\omega)` (Sipe and Shkrebtii 2000).

Every function works in units :math:`\hbar = e = 1`, energies (and
:math:`\hbar\omega`) in the units of the hoppings, lengths in those of
*prim_vec*, and counts each orbital once: a spinless model gives the
response *per spin*.

The velocities are the exact :math:`\partial H/\partial k_a` of
*KSpace._bloch_derivatives* along the Cartesian axes :math:`x, y` (for a 2D
lattice embedded in 3D space, the axes of *KSpace.k_basis*). With
``positions=True`` (the default) the bond vectors include the orbital
positions, :math:`\mathbf{d} = \mathbf{R} + \boldsymbol\tau_j -
\boldsymbol\tau_i`, so that the velocity is the physical :math:`i[H,
\mathbf{r}]` (see *KSpace.hall_conductivity*).

Examples
--------
Example usage::

    from tbkit.optics import optical_conductivity
    omega = np.linspace(0.05, 3., 60)
    sigma = optical_conductivity(graphene, omega, e_fermi=0., nk=300)
    # Re sigma ~ pi/4 e^2/h per spin for hbar omega << t
"""
from __future__ import annotations

from typing import Any, Iterator

import numpy as np
from numpy.typing import ArrayLike, NDArray

import tbkit.error_handling as error_handling
import tbkit.occupation as occupation
from tbkit.kspace import KSpace


_AXES = {'x': 0, 'y': 1}
_DEGENERATE = 1e-9


def _mesh(ks: KSpace, nk: int | tuple[int, int], positions: bool) -> NDArray[np.float64]:
    '''
    Private function. Validate the model (a static, Hermitian, 2D KSpace)
    and return the k-points of a uniform Brillouin-zone mesh.
    '''
    error_handling.kspace(ks, KSpace)
    ks._check_static()
    error_handling.dim_exact(ks.dim, 2)
    error_handling.hermitian_model(ks.is_hermitian())
    error_handling.boolean(positions, 'positions')
    return ks.mesh_grid(nk)[1]


[docs] def cell_area(ks: KSpace) -> float: r''' Get the area :math:`A_c = |\mathbf{a}_1\times\mathbf{a}_2|` of the unit cell of a 2D model (in the units of *prim_vec*), which turns a Brillouin-zone average into an integral: :math:`\int\frac{d^2k}{(2\pi)^2}g = \frac{1}{N_kA_c}\sum_{\mathbf{k}}g`. Parameters ---------- ks : **KSpace** instance, 2D Returns ------- area : Positive real ''' error_handling.kspace(ks, KSpace) error_handling.dim_exact(ks.dim, 2) a1, a2 = (np.zeros(3) for _ in range(2)) a1[:ks.space_dim] = ks.lat.prim_vec[0] a2[:ks.space_dim] = ks.lat.prim_vec[1] return float(np.linalg.norm(np.cross(a1, a2)))
def _bands( ks: KSpace, kpts: NDArray[np.float64], positions: bool, second: bool = False, ) -> Iterator[tuple]: r''' Private function. Over chunks of the k-points *kpts*: the band energies (nk, n), the velocity matrices :math:`v^a_{nm} = \langle n|\partial_aH|m\rangle` in the eigenbasis (2, nk, n, n) and, with *second*, the matrices :math:`w^{ab}_{nm} = \langle n|\partial_a\partial_bH|m\rangle` (2, 2, nk, n, n). ''' directions = [ks.k_basis[:, 0], ks.k_basis[:, 1]] chunk = max(1, 200000 // ks.norb ** 2) if second: # d^2 H / dk_a dk_b is the first derivative of the Bloch sum of the # hoppings t * i(d.u_a): the same _bloch_sum, with rescaled amplitudes. hops = ks._hop d = np.array([h[2] for h in hops], dtype='f8').reshape(len(hops), ks.space_dim) if positions: tau = ks.orbital_positions() d = d + tau[[h[1] for h in hops]] - tau[[h[0] for h in hops]] scaled = [[(h[0], h[1], h[2], h[3] * 1j * (dd @ u)) for h, dd in zip(hops, d)] for u in directions] for c0 in range(0, len(kpts), chunk): kc = kpts[c0:c0 + chunk] ham, dham = ks._bloch_derivatives(kc, directions, positions) en, vec = np.linalg.eigh(ham) vh = vec.conj().transpose(0, 2, 1) vel = np.array([vh @ dh @ vec for dh in dham]) if not second: yield en, vel continue k_cart = kc @ ks.k_basis.T w = np.array([ks._bloch_sum(h, k_cart, directions, positions)[1] for h in scaled]) yield en, vel, vh[None, None] @ w @ vec[None, None] def _as_output(values: NDArray, like: ArrayLike) -> Any: ''' Private function. A scalar for a scalar input, else shaped like it. ''' if np.ndim(like) == 0: return values[0] return values.reshape(np.shape(like) + values.shape[1:])
[docs] def optical_conductivity( ks: KSpace, omega: float | ArrayLike, e_fermi: float = 0., temperature: float = 0., eta: float = 0.05, nk: int | tuple[int, int] = 100, component: str = 'xx', positions: bool = True, ) -> complex | NDArray[np.complex128]: r''' Get the optical conductivity :math:`\sigma_{ab}(\omega)` of a 2D model from the Kubo-Greenwood formula over a uniform Brillouin-zone mesh, .. math:: \sigma_{ab}(\omega) = \frac{i e^2}{\hbar}\int\frac{d^2k}{(2\pi)^2} \sum_{n,m}\frac{f_n - f_m}{E_m - E_n}\, \frac{v^a_{nm}\,v^b_{mn}}{\hbar\omega + E_n - E_m + i\eta}\, , with :math:`v^a_{nm} = \langle n|\partial H/\partial k_a|m\rangle`, :math:`f_n` the Fermi-Dirac occupations at *e_fermi* and *temperature*, and :math:`\eta` a phenomenological broadening (the inverse lifetime). Pairs of degenerate states, :math:`n = m` included, enter through the limit :math:`(f_n-f_m)/(E_m-E_n)\to-\partial f/\partial E`: the intraband (Drude) term :math:`\frac{i}{\hbar\omega+i\eta}\sum_n (-f'_n)v^a_{nn}v^b_{nn}`. It samples the Fermi surface only through the thermal window, so it needs a *temperature* larger than the level spacing of the mesh; at ``temperature=0`` only the interband part is computed. It is the Ohm's-law tensor, :math:`j_a = \sigma_{ab}E_b`. In a gap its static Hall part is the TKNN conductance: :math:`\sigma_{yx}(0) = -\sigma_{xy}(0)` equals *KSpace.hall_conductivity* (which returns :math:`\sigma^{\mathrm{Ohm}}_{yx}`, see its docstring). The absorptive part obeys the f-sum rule :math:`\int_0^\infty\mathrm{Re}\,\sigma_{aa}\,d\omega = \frac{\pi e^2}{2\hbar^2}\int\frac{d^2k}{(2\pi)^2}\sum_n f_n\langle n|\partial_a^2H|n\rangle`. For graphene at neutrality, :math:`\mathrm{Re}\,\sigma_{xx} \to \frac{e^2}{16\hbar}` per spin and valley for :math:`\eta \ll \hbar\omega \ll t`: the universal :math:`\sigma_0 = e^2/4\hbar` once spin and valleys are counted, :math:`\pi/4` in units of :math:`e^2/h` for a spinless model. Parameters ---------- ks : **KSpace** instance, 2D, Hermitian omega : Real number or array of real numbers Photon energies :math:`\hbar\omega`. e_fermi : Real number, default 0 Fermi energy. temperature : Positive real or zero Default value 0 (:math:`k_B = 1`). eta : Positive real, default 0.05 Broadening, larger than the spacing of the transition energies on the mesh. nk : Positive integer, or tuple of 2, default 100 k-mesh. component : 'xx', 'xy', 'yx' or 'yy', default 'xx' positions : Boolean, default True Orbital positions in the bond vectors (see *KSpace.hall_conductivity*). Returns ------- sigma Complex number (or ndarray shaped like *omega*), in units of :math:`e^2/h`. ''' kpts = _mesh(ks, nk, positions) error_handling.frequencies(omega, 'omega') error_handling.real_number(e_fermi, 'e_fermi') error_handling.positive_real_zero(temperature, 'temperature') error_handling.positive_real(eta, 'eta') a, b = error_handling.tensor_component(component, 2) w_arr = np.atleast_1d(np.asarray(omega, dtype='f8')).ravel() total = np.zeros(len(w_arr), 'c16') for en, vel in _bands(ks, kpts, positions): f = occupation.fermi_dirac(en, e_fermi, temperature) minus_df = f * (1. - f) / temperature if temperature > 0 else np.zeros_like(f) de = en[:, None, :] - en[:, :, None] # E_m - E_n degenerate = np.abs(de) < _DEGENERATE ratio = np.where(degenerate, minus_df[:, :, None], (f[:, :, None] - f[:, None, :]) / np.where(degenerate, 1., de)) weight = ratio * vel[a] * vel[b].transpose(0, 2, 1) keep = weight != 0 wk, dk = weight[keep], np.where(degenerate, 0., de)[keep] step = max(1, 2000000 // len(w_arr)) for c0 in range(0, len(wk), step): total += (wk[None, c0:c0 + step] / (w_arr[:, None] - dk[None, c0:c0 + step] + 1j * eta)).sum(axis=1) sigma = 1j * 2 * np.pi * total / (len(kpts) * cell_area(ks)) return _as_output(sigma, omega)
[docs] def joint_dos( ks: KSpace, omega: float | ArrayLike, e_fermi: float = 0., temperature: float = 0., eta: float = 0.05, nk: int | tuple[int, int] = 100, ) -> float | NDArray[np.float64]: r''' Get the joint density of states of the vertical (interband) transitions, per unit cell, .. math:: J(\omega) = \frac{1}{N_k}\sum_{\mathbf{k}}\sum_{n\neq m} f_n\,(1 - f_m)\,\delta(\hbar\omega - E_m + E_n)\, , with the delta function a Gaussian of standard deviation :math:`\eta`. Its integral over :math:`\omega` is the number of (occupied, empty) pairs per k-point. For graphene at neutrality, :math:`J = A_c\,\hbar\omega/(4\pi\hbar^2 v_F^2)` near the Dirac points (both valleys, per spin). Parameters ---------- ks : **KSpace** instance, 2D, Hermitian omega : Real number or array of real numbers Photon energies. e_fermi : Real number, default 0 temperature : Positive real or zero, default 0 eta : Positive real, default 0.05 Gaussian width. nk : Positive integer, or tuple of 2, default 100 Returns ------- jdos Real number (or ndarray shaped like *omega*), in states per unit cell and per unit energy. ''' kpts = _mesh(ks, nk, True) error_handling.frequencies(omega, 'omega') error_handling.real_number(e_fermi, 'e_fermi') error_handling.positive_real_zero(temperature, 'temperature') error_handling.positive_real(eta, 'eta') w_arr = np.atleast_1d(np.asarray(omega, dtype='f8')).ravel() total = np.zeros(len(w_arr)) for en, _ in _bands(ks, kpts, False): f = occupation.fermi_dirac(en, e_fermi, temperature) weight = f[:, :, None] * (1. - f[:, None, :]) weight[:, np.arange(ks.norb), np.arange(ks.norb)] = 0. keep = weight > 0 wk = weight[keep] dk = (en[:, None, :] - en[:, :, None])[keep] total += _gaussian_sum(w_arr, dk, wk, eta) return _as_output(total / len(kpts), omega)
def _gaussian_sum( x: NDArray[np.float64], centers: NDArray[np.float64], weights: NDArray[np.float64], eta: float, ) -> NDArray[np.float64]: r''' Private function. :math:`\sum_i w_i\,g(x - c_i)` with the normalized Gaussian :math:`g` of standard deviation *eta*, in chunks of the centers. ''' out = np.zeros(len(x)) step = max(1, 2000000 // len(x)) for c0 in range(0, len(centers), step): u = (x[:, None] - centers[None, c0:c0 + step]) / eta out += (weights[None, c0:c0 + step] * np.exp(-0.5 * u ** 2)).sum(axis=1) return out / (np.sqrt(2 * np.pi) * eta)
[docs] def berry_curvature_dipole( ks: KSpace, e_fermi: float | ArrayLike, temperature: float, nk: int | tuple[int, int] = 100, positions: bool = True, ) -> NDArray[np.float64]: r''' Get the Berry curvature dipole of a 2D model (Sodemann and Fu, Phys. Rev. Lett. 115, 216806 (2015)), .. math:: D_a = \int\frac{d^2k}{(2\pi)^2}\sum_n f_n\,\partial_a\Omega_n = \int\frac{d^2k}{(2\pi)^2}\sum_n \left(-\frac{\partial f}{\partial E}\right)_{E_n} v^a_{nn}\,\Omega_n\, , the first moment of the Berry curvature :math:`\Omega_n` (the convention of *KSpace.berry_curvature* and *hall_conductivity*, :math:`\Omega_n = -2\,\mathrm{Im}\sum_{m\neq n}v^x_{nm}v^y_{mn}/(E_n-E_m)^2`) over the occupied states. It is a Fermi-surface property: the second form, used here, samples it through the thermal window :math:`-\partial f/\partial E`, so *temperature* must be positive and larger than the level spacing of the mesh near :math:`E_F`. A finite dipole needs broken inversion symmetry and a single mirror line at most. In a DC field it drives the nonlinear Hall current :math:`j_a = \chi_{abc}E_bE_c`, :math:`\chi_{abc} = -\varepsilon_{adc}\frac{e^3\tau}{2\hbar^2(1+i\omega\tau)}D_{bd}`, with :math:`D_{bd} = D_b` along :math:`d = z` in 2D. For the tilted massive Dirac cone :math:`H = t k_x + v(k_x\sigma_x + k_y\sigma_y) + m\sigma_z` doped into the conduction band (:math:`\mu > |m|`), the first order in the tilt is :math:`D_x = -\frac{3\,t\,m\,(\mu^2 - m^2)}{8\pi\mu^4}`, :math:`D_y = 0`, from :math:`\Omega_c = -mv^2/2\varepsilon^3` (Sodemann and Fu's model, in these conventions). Parameters ---------- ks : **KSpace** instance, 2D, Hermitian e_fermi : Real number, or array of real numbers Fermi energies. temperature : Positive real Temperature (:math:`k_B = 1`). nk : Positive integer, or tuple of 2, default 100 positions : Boolean, default True See *KSpace.hall_conductivity*. Returns ------- dipole Real ndarray of shape (2,): :math:`(D_x, D_y)` (or ``e_fermi.shape + (2,)``), in units of length (of *prim_vec*). ''' kpts = _mesh(ks, nk, positions) error_handling.fermi_energies(e_fermi) error_handling.positive_real(temperature, 'temperature') mus = np.atleast_1d(np.asarray(e_fermi, dtype='f8')).ravel() total = np.zeros((len(mus), 2)) for en, vel in _bands(ks, kpts, positions): de = en[:, :, None] - en[:, None, :] degenerate = np.abs(de) < _DEGENERATE inv2 = np.where(degenerate, 0., 1. / np.where(degenerate, 1., de) ** 2) omega_n = -2 * np.sum(np.imag(vel[0] * vel[1].transpose(0, 2, 1)) * inv2, axis=2) vdiag = np.real(np.diagonal(vel, axis1=2, axis2=3)) # (2, nk, n) for e, mu in enumerate(mus): f = occupation.fermi_dirac(en, float(mu), temperature) window = f * (1. - f) / temperature total[e] += np.einsum('kn,akn->a', window * omega_n, vdiag) return _as_output(total / (len(kpts) * cell_area(ks)), e_fermi)
[docs] def generalized_derivative( en: NDArray[np.float64], vel: NDArray[np.complex128], w: NDArray[np.complex128], ) -> tuple[NDArray[np.complex128], NDArray[np.complex128]]: r''' Get the interband position matrix elements and their generalized derivatives from the band energies and the first and second derivatives of :math:`H` in the eigenbasis (a sum over states, Sipe and Shkrebtii 2000; Cook et al. 2017; :math:`\hbar = 1`, :math:`\omega_{nm} = E_n - E_m`, :math:`\Delta^a_{nm} = v^a_{nn} - v^a_{mm}`): .. math:: r^b_{nm} = \frac{v^b_{nm}}{i\omega_{nm}}\, ,\qquad r^b_{nm;a} = \frac{i}{\omega_{nm}}\left[ \frac{v^b_{nm}\Delta^a_{nm} + v^a_{nm}\Delta^b_{nm}}{\omega_{nm}} - w^{ba}_{nm} + \sum_{p\neq n,m}\left( \frac{v^b_{np}v^a_{pm}}{\omega_{pm}} - \frac{v^a_{np}v^b_{pm}}{\omega_{np}} \right)\right]\, , :math:`r^b_{nm;a} = \partial_ar^b_{nm} - i(A^a_{nn} - A^a_{mm})r^b_{nm}`, gauge covariant. Degenerate pairs (:math:`|\omega_{nm}| < 10^{-9}`) are set to zero, and dropped from the sum over :math:`p`. Parameters ---------- en : Real ndarray, shape (nk, n) Band energies. vel : Complex ndarray, shape (2, nk, n, n) :math:`v^a_{nm}`. w : Complex ndarray, shape (2, 2, nk, n, n) :math:`w^{ab}_{nm} = \langle n|\partial_a\partial_bH|m\rangle`. Returns ------- r : Complex ndarray, shape (2, nk, n, n): r[b] is :math:`r^b_{nm}` r_der Complex ndarray, shape (2, 2, nk, n, n): r_der[b, a] is :math:`r^b_{nm;a}`. ''' en, vel, w = np.asarray(en), np.asarray(vel), np.asarray(w) error_handling.band_derivatives(en, vel, w) om = en[:, :, None] - en[:, None, :] degenerate = np.abs(om) < _DEGENERATE inv = np.where(degenerate, 0., 1. / np.where(degenerate, 1., om)) diag = np.diagonal(vel, axis1=2, axis2=3) # (2, nk, n) delta = diag[:, :, :, None] - diag[:, :, None, :] r = -1j * vel * inv[None] r_der = np.zeros((2, 2) + om.shape, 'c16') for b in range(2): for a in range(2): # the full sums over p include p = n, m: those terms add # Delta^b v^a / omega, removed again below full = vel[b] @ (vel[a] * inv) - (vel[a] * inv) @ vel[b] bracket = ((vel[b] * delta[a] + vel[a] * delta[b]) * inv - w[b, a] + full - vel[a] * delta[b] * inv) r_der[b, a] = 1j * inv * bracket return r, r_der
[docs] def shift_current( ks: KSpace, omega: float | ArrayLike, e_fermi: float = 0., temperature: float = 0., eta: float = 0.05, nk: int | tuple[int, int] = 100, component: str = 'xxx', positions: bool = True, ) -> float | NDArray[np.float64]: r''' Get the shift-current conductivity of a 2D model under linearly polarized light (Sipe and Shkrebtii, Phys. Rev. B 61, 5337 (2000)), :math:`j^a = 2\sigma^{abc}(0;\omega,-\omega)E^b(\omega)E^c(-\omega)`, .. math:: \sigma^{abc}(\omega) = -\frac{\pi e^3}{2\hbar^2}\int\frac{d^2k}{(2\pi)^2} \sum_{n,m}f_{nm}\,\mathrm{Im}\left[r^b_{mn}r^c_{nm;a} + r^c_{mn}r^b_{nm;a}\right]\delta(\omega_{mn} - \omega)\, , :math:`f_{nm} = f_n - f_m`, with the position matrix elements :math:`r` and their generalized derivatives from :func:`generalized_derivative` (a sum over states, with the exact second derivatives of :math:`H`). The delta function is a Gaussian of standard deviation :math:`\eta`. For :math:`b = c`, :math:`\mathrm{Im}[r^b_{mn}r^b_{nm;a}] = |r^b_{nm}|^2R^{a,b}_{nm}` with :math:`R^{a,b}_{nm}` the shift vector, the real-space displacement of the electron in the transition. It vanishes with inversion symmetry. Unlike the Hall conductance of full bands, it depends on where the orbitals sit in the cell (Ibanez-Azpiroz, Tsirkin and Souza, Phys. Rev. B 97, 245143 (2018)): ``positions=False``, which puts them all at the cell origin, describes a different crystal. Parameters ---------- ks : **KSpace** instance, 2D, Hermitian, without overlap omega : Real number or array of real numbers Photon energies :math:`\hbar\omega`. e_fermi : Real number, default 0 temperature : Positive real or zero, default 0 eta : Positive real, default 0.05 Gaussian width. nk : Positive integer, or tuple of 2, default 100 component Three letters among 'x', 'y' (current direction, then the two field directions). Default value 'xxx'. positions : Boolean, default True See *KSpace.hall_conductivity*. Returns ------- sigma Real number (or ndarray shaped like *omega*), in units of :math:`e^3/\hbar\times` length / energy (:math:`\hbar = e = 1`), counting each orbital once. ''' kpts = _mesh(ks, nk, positions) error_handling.no_overlap(bool(ks._overlap_hop)) error_handling.frequencies(omega, 'omega') error_handling.real_number(e_fermi, 'e_fermi') error_handling.positive_real_zero(temperature, 'temperature') error_handling.positive_real(eta, 'eta') a, b, c = error_handling.tensor_component(component, 3) w_arr = np.atleast_1d(np.asarray(omega, dtype='f8')).ravel() total = np.zeros(len(w_arr)) for en, vel, w in _bands(ks, kpts, positions, second=True): r, r_der = generalized_derivative(en, vel, w) f = occupation.fermi_dirac(en, e_fermi, temperature) fnm = f[:, :, None] - f[:, None, :] integrand = np.imag(r[b].transpose(0, 2, 1) * r_der[c, a] + r[c].transpose(0, 2, 1) * r_der[b, a]) * fnm keep = integrand != 0 wmn = (en[:, None, :] - en[:, :, None])[keep] total += _gaussian_sum(w_arr, wmn, integrand[keep], eta) sigma = -np.pi / 2 * total / (len(kpts) * cell_area(ks)) return _as_output(sigma, omega)