Source code for dgamore.greens_function

# SPDX-FileCopyrightText: 2025-2026 Julian Peil <julian.peil@tuwien.ac.at>
# SPDX-License-Identifier: MIT
#
# DGAmore — Multi-Orbital Ladder Dynamical Vertex Approximation (LDGA) &
#           Eliashberg Equation Solver for Strongly Correlated Electron Systems
r"""
Single-particle Green's function. :class:`GreensFunction` builds the momentum-dependent interacting Green's
function :math:`G_{ab}(k, \nu) = [(\imath\nu + \mu)\delta_{ab} - \varepsilon_{ab}(k) - \Sigma_{ab}(k, \nu)]^{-1}`
from a :class:`SelfEnergy`, the band dispersion :math:`\varepsilon(k)` and the chemical potential :math:`\mu`,
and derives the filling, occupation, kinetic and (Galitskii-Migdal) potential energies. The module-level helpers
adjust :math:`\mu` to a target filling via a Newton root search. Moment-corrected asymptotic sums are used so the
finite Matsubara box does not bias the energies/filling.
"""

import numpy as np
from scipy import optimize as opt

from dgamore.matsubara_frequencies import MFHelper
from dgamore.self_energy import SelfEnergy
from dgamore.two_point import TwoPoint


def _fermi_dirac_density(h: np.ndarray, beta: float) -> np.ndarray:
    r"""
    Returns the (possibly k-resolved) single-particle density matrix :math:`\rho = f(\beta h)` of a static
    effective Hamiltonian :math:`h = \varepsilon + \Sigma_\infty - \mu` (shape ``[..., o, o]``), evaluated in the
    eigenbasis with a numerically stable Fermi function (the two branches avoid overflow of ``exp`` for large
    positive/negative eigenvalues). This is the orbital density-matrix block shared by the local filling
    (:func:`get_total_fill`) and the k-resolved occupation (:meth:`GreensFunction.get_fill_nonlocal`).

    :param h: The static effective Hamiltonian :math:`\varepsilon + \Sigma_\infty - \mu`, shape ``[..., o, o]``.
    :param beta: Inverse temperature :math:`\beta`.
    :return: The density matrix :math:`\rho`, same shape as ``h``.
    """
    eigenvals, eigenvecs = np.linalg.eig(beta * h)

    rho_diag = np.empty_like(eigenvals)
    mask = eigenvals > 0
    rho_diag[mask] = np.exp(-eigenvals[mask]) / (1 + np.exp(-eigenvals[mask]))
    rho_diag[~mask] = 1 / (1 + np.exp(eigenvals[~mask]))

    # rho = V diag(rho_diag) V^-1. Scaling the columns of V by rho_diag is identical to the diagonal matmul (each
    # entry is a single product, no accumulation), but avoids materializing the dense [..., o, o] diagonal and one matmul.
    return (eigenvecs * rho_diag[..., None, :]) @ np.linalg.inv(eigenvecs)


[docs] def get_total_fill(mu: float, ek: np.ndarray, sigma_mat: np.ndarray, beta: float, smom0: np.ndarray) -> float: r""" Returns the total filling calculated from the self-energy, :math:`\mu`, kinetic Hamiltonian and more. Helper method for the root finding of :math:`\mu` via a Newton method. A local model Green's function built from the self-energy moment is subtracted to accelerate the Matsubara sum convergence. This is the cheap, purely local (k-summed) scalar variant used inside the :math:`\mu` root search; :meth:`GreensFunction.get_fill_nonlocal` is the k-resolved counterpart that additionally returns the occupation matrices. Both share the Fermi-Dirac density matrix via :func:`_fermi_dirac_density`. :param mu: Chemical potential :math:`\mu`. :param ek: Band dispersion :math:`\varepsilon(k)`, shape ``[kx, ky, kz, o1, o2]``. :param sigma_mat: Self-energy array, shape ``[k, o1, o2, v]``. :param beta: Inverse temperature :math:`\beta`. :param smom0: Zeroth moment :math:`\Sigma_\infty` of the self-energy, shape ``[o1, o2]``. :return: The total filling (electron number) :math:`n`. """ n_bands = sigma_mat.shape[-2] eye_bands = np.eye(n_bands, n_bands) iv = 1j * MFHelper.vn(sigma_mat.shape[-1] // 2, beta) iv_bands = iv[None, None, :] * eye_bands[..., None] mu_bands = mu * eye_bands hloc = np.mean(ek, axis=(0, 1, 2)) mat = iv_bands + mu_bands[..., None] - hloc[..., None] - smom0[..., None] g_model_mat = GreensFunction._invert_last_orbital_block(mat) ek = ek.reshape(np.prod(ek.shape[:3]), n_bands, n_bands) # sigma will always enter with shape (k,o1,o2,v) mat = iv_bands[None, ...] + mu_bands[None, ..., None] - ek[..., None] - sigma_mat g_full_mat = GreensFunction._invert_last_orbital_block(mat) g_loc_mat = np.mean(g_full_mat, axis=0) rho_loc = _fermi_dirac_density(hloc.real + smom0 - mu_bands, beta) occ = rho_loc + np.sum(g_loc_mat.real - g_model_mat.real, axis=-1) / beta return 2.0 * np.trace(occ).real
[docs] def root_fun( mu: float, target_filling: float, ek: np.ndarray, sigma_mat: np.ndarray, beta: float, smom0: np.ndarray ) -> float: r""" Residual function used to find a new chemical potential :math:`\mu` via Newton's method: the difference between the current filling and the target filling. :param mu: Chemical potential :math:`\mu`. :param target_filling: Desired total filling. :param ek: Band dispersion :math:`\varepsilon(k)`. :param sigma_mat: Self-energy array, shape ``[k, o1, o2, v]``. :param beta: Inverse temperature :math:`\beta`. :param smom0: Zeroth moment :math:`\Sigma_\infty` of the self-energy. :return: The signed filling residual ``filling(mu) - target_filling``. """ return get_total_fill(mu, ek, sigma_mat, beta, smom0) - target_filling
[docs] def update_mu( mu0: float, target_filling: float, ek: np.ndarray, sigma_mat: np.ndarray, beta: float, smom0: np.ndarray, logger=None, ) -> float: r""" Updates the chemical potential to match the target filling by using Newton's method to find the optimal :math:`\mu`. On failure to converge the starting value is returned unchanged. :param mu0: Initial guess for the chemical potential. :param target_filling: Desired total filling. :param ek: Band dispersion :math:`\varepsilon(k)`. :param sigma_mat: Self-energy array, shape ``[k, o1, o2, v]``. :param beta: Inverse temperature :math:`\beta`. :param smom0: Zeroth moment :math:`\Sigma_\infty` of the self-energy. :param logger: Optional logger; if given, a failed root search is logged at debug level. :return: The updated (real) chemical potential, or ``mu0`` if the root search did not converge. :raises ValueError: If the converged chemical potential has a non-negligible imaginary part. """ mu = mu0 try: mu = opt.newton(root_fun, mu, args=(target_filling, ek, sigma_mat, beta, smom0), tol=1e-6) except RuntimeError: if logger is not None: logger.debug("Root finding for chemical potential failed.") return mu0 if np.abs(mu.imag) < 1e-8: mu = mu.real else: raise ValueError("Chemical Potential must be real.") return mu
[docs] class GreensFunction(TwoPoint): r""" The single-particle Green's function :math:`G_{ab}(k, \nu) = [(\imath\nu + \mu)\delta_{ab} - \varepsilon_{ab}(k) - \Sigma_{ab}(k, \nu)]^{-1}`. Built from a :class:`SelfEnergy`, the band dispersion :math:`\varepsilon(k)` and the chemical potential :math:`\mu`; on top of the two-point orbital bookkeeping inherited from :class:`LocalTwoPoint` it adds the Dyson construction (local and momentum-resolved) and the derived quantities — filling, occupation matrices, kinetic and (Galitskii-Migdal) potential energy — all using moment-corrected asymptotic Matsubara sums so the finite frequency box does not bias the result. """ def __init__( self, mat: np.ndarray, sigma: SelfEnergy = None, ek: np.ndarray = None, full_niv_range: bool = True, calc_filling: bool = True, has_compressed_q_dimension: bool = False, nk: tuple = (1, 1, 1), beta: float = None, mu: float = None, ): r""" Initializes the Green's function; if a self-energy and dispersion are given (and ``calc_filling`` is True), also computes the local Green's function and the filling/occupation. :param mat: Underlying Green's function array (two orbital axes and one fermionic frequency axis, optionally preceded by momentum axes). Overwritten by the local Green's function when ``calc_filling`` is True. :param sigma: The :class:`SelfEnergy` used to construct the Green's function (optional). :param ek: Band dispersion :math:`\varepsilon(k)` (optional). :param full_niv_range: Whether the object spans the full (signed) fermionic range or only :math:`\nu \geq 0`. :param calc_filling: If True (and ``sigma``/``ek`` are given), compute the local Green's function and the filling/occupation, exposed via the :attr:`n`, :attr:`occ` and :attr:`occ_k` properties. :param has_compressed_q_dimension: Whether the momentum is stored as a single compressed axis ``[q, ...]`` (True) or as three separate axes ``[kx, ky, kz, ...]`` (False). :param nk: Number of momenta per spatial direction ``(nkx, nky, nkz)``. :param beta: Inverse temperature :math:`\beta`. :param mu: Chemical potential :math:`\mu`. """ TwoPoint.__init__(self, mat, nk, full_niv_range, has_compressed_q_dimension) self._sigma = sigma self._ek = ek self._beta = beta self._mu = mu self._n = None self._occ = None self._occ_k = None if sigma is not None and ek is not None and calc_filling: self.mat = self._get_gloc_mat() self._n, self._occ, self._occ_k = self.get_fill_nonlocal() @property def ek(self) -> np.ndarray: r""" The band dispersion stored on this object. :return: The band dispersion :math:`\varepsilon(k)` as a numpy array. """ return self._ek @property def n(self) -> float: r""" The total filling computed for this Green's function. :return: The total filling :math:`n`, or None if the filling has not been computed. """ return self._n @property def occ(self) -> np.ndarray: """ The k-averaged occupation matrix. :return: The k-averaged occupation (shape ``[o1, o2]``), or None if it has not been computed. """ return self._occ @property def occ_k(self) -> np.ndarray: """ The k-resolved occupation matrix. :return: The k-resolved occupation (shape ``[kx, ky, kz, o1, o2]``), or None if it has not been computed. """ return self._occ_k
[docs] @staticmethod def get_g_full(siw: SelfEnergy, mu: float, ek: np.ndarray, beta: float): r""" Builds the full momentum-dependent Green's function :math:`G(k, \nu) = [(\imath\nu + \mu) - \varepsilon(k) - \Sigma(k, \nu)]^{-1}`. :param siw: The :class:`SelfEnergy` :math:`\Sigma`. :param mu: Chemical potential :math:`\mu`. :param ek: Band dispersion :math:`\varepsilon(k)`. :param beta: Inverse temperature :math:`\beta`. :return: The momentum-dependent :class:`GreensFunction` (filling not recomputed). """ eye_bands = np.eye(siw.n_bands, siw.n_bands) iv = 1j * MFHelper.vn(siw.niv, beta) iv_bands = iv[None, None, :] * eye_bands[..., None] mu_bands = mu * eye_bands[:, :, None] mat = ( iv_bands[None, None, None, ...] + mu_bands[None, None, None, ...] - ek[..., None] - siw.decompress_q_dimension().mat ) mat = GreensFunction._invert_last_orbital_block(mat) return GreensFunction(mat, siw, ek, siw.full_niv_range, False, False, nk=ek.shape[:3], beta=beta, mu=mu)
[docs] @staticmethod def create_g_loc(siw: SelfEnergy, ek: np.ndarray, beta: float, mu: float, calc_filling: bool = True) -> "GreensFunction": r""" Builds a local (k-summed) Green's function from a self-energy and band dispersion. :param siw: The :class:`SelfEnergy` :math:`\Sigma`. :param ek: Band dispersion :math:`\varepsilon(k)`. :param beta: Inverse temperature :math:`\beta`. :param mu: Chemical potential :math:`\mu`. :param calc_filling: If True, compute the filling/occupation (exposed via the ``n``/``occ``/``occ_k`` properties). :return: The local :class:`GreensFunction`. """ return GreensFunction( np.empty_like(siw.mat), siw, ek, siw.full_niv_range, calc_filling, nk=siw.nq, beta=beta, mu=mu )
@staticmethod def _invert_last_orbital_block(mat: np.ndarray) -> np.ndarray: r""" Inverts the trailing orbital block of a ``[..., o1, o2, v]`` array, i.e. computes the per-frequency (and per-momentum) matrix inverse over the two orbital axes. The fermionic axis is moved in front of the orbital pair so ``numpy.linalg.inv`` batches over ``[..., v]`` and is moved back afterwards. Both moves are views, so the only allocation is the inverse itself (the Dyson step is therefore memory-neutral). :param mat: Array with layout ``[..., o1, o2, v]`` (local ``[o1, o2, v]`` or momentum-resolved). :return: The inverted array in the same ``[..., o1, o2, v]`` layout. """ return np.moveaxis(np.linalg.inv(np.moveaxis(mat, -1, -3)), -3, -1)
[docs] def get_g_wv(self, wn: np.ndarray, niv_cut: int) -> np.ndarray: r""" Returns the frequency-shifted Green's function :math:`G_{ab}^{\nu - \omega}` on a fermionic window of half width ``niv_cut``, for the bosonic frequencies in ``wn``. :param wn: Array of bosonic Matsubara indices :math:`\omega`. :param niv_cut: Half width of the fermionic window :math:`\nu`. :return: Array of shape ``[o1, o2, w, v]``. """ niv_cut_range = np.arange(-niv_cut, niv_cut) return self.mat[..., self.niv + niv_cut_range[None, :] - wn[:, None]]
[docs] def get_fill_nonlocal(self) -> tuple[float, np.ndarray, np.ndarray]: r""" Computes the filling and occupation from the momentum-resolved Green's function, using the analytic density-matrix of the model (moment) Green's function plus the box correction to accelerate convergence. :return: A tuple of (i) the total filling :math:`n`, (ii) the k-averaged occupation (shape ``[o1, o2]``), and (iii) the k-resolved occupation (shape ``[kx, ky, kz, o1, o2]``). """ mat = self._get_gfull_mat() g_model = self._get_g_model_k_mat() smom0 = self._sigma.smom[0][None, None, None, ...] mu_bands: np.ndarray = self._mu * np.eye(self.n_bands)[None, None, None, ...] rho_k = _fermi_dirac_density(self._ek.real + smom0 - mu_bands, self._beta) occ_k = rho_k + np.sum(mat.real - g_model.real, axis=-1) / self._beta occ_k.real[np.abs(occ_k) < 1e-12] = 0.0 occ_mean = np.mean(occ_k, axis=(0, 1, 2)) occ_mean.real[np.abs(occ_mean) < 1e-12] = 0.0 n_el = 2.0 * np.trace(occ_mean).real self._n, self._occ, self._occ_k = n_el, occ_mean, occ_k return n_el, occ_mean, occ_k
[docs] def get_ekin(self) -> float: r""" Returns the kinetic energy from the band dispersion and the k-resolved occupation, :math:`E_{kin} = \sum_{\sigma \vec{k} ab} \varepsilon(\vec{k})_{ab}\, n(\vec{k})_{ba}`. :return: The kinetic energy per site. """ return 2 * np.sum(self._ek * self._occ_k.swapaxes(-1, -2)).real / self.nq_tot
[docs] def get_epot(self, niv_asympt: int = 50000) -> float: r""" Computes the moment-corrected Galitskii-Migdal potential energy, .. math:: E_{pot} = \sum_k \mathrm{Tr}[\Sigma_\infty \rho_k] + \frac{1}{\beta} \sum_{k,\nu} \mathrm{Tr}[(\Sigma - \Sigma_\infty) G] + \frac{1}{\beta} \big[\textstyle\sum_{\mathrm{big}} - \sum_{\mathrm{box}}\big]\, \mathrm{Tr}[(\Sigma_{\mathrm{mod}} - \Sigma_\infty) G_{\mathrm{mod}}], i.e. the exact Hartree term, the in-box correlation part, and the analytic :math:`1/\nu^2` tail. Here :math:`\Sigma_{\mathrm{mod}} - \Sigma_\infty = -\Sigma_1/(\imath\nu)` and :math:`G_{\mathrm{mod}} = [\imath\nu + \mu - \varepsilon_k - \Sigma_\infty]^{-1}`. The model subtraction cancels the :math:`1/\nu^2` tail of the correlation sum (remainder :math:`\sim 1/\nu^4`), while the large sum supplies the part beyond the stored box. :param niv_asympt: Number of positive fermionic frequencies used for the asymptotic ("big") tail sum. :return: The potential energy per site. """ smom0, smom1 = self._sigma.smom # Sigma_inf, first tail coeff; both [o1, o2] # 1) Hartree: physical (tail-corrected) occupation, convergence factor exact. e_hartree = np.sum(smom0[None, None, None] * self._occ_k.swapaxes(-1, -2)).real # 2) In-box correlation part: Tr[(Sigma - Sigma_inf) G], Sigma_inf already counted above. Contract the orbital # trace directly with einsum (g transposed in orbitals) instead of materializing the transposed copy -- this # avoids the GreensFunction deepcopy in ``transpose_orbitals`` (which duplicates ``_ek``/``_sigma``) and the # full [k, o, o, v] product temporary. dsigma = self._sigma.decompress_q_dimension().mat - smom0[..., None] g = self.decompress_q_dimension().mat e_corr = np.einsum("...abv,...bav->...", dsigma, g).sum().real / self._beta # 3) Analytic 1/v^2 model tail: replace the truncated box value by the large-box one. e_tail = self._model_epot(smom0, smom1, niv_asympt, self._beta) - self._model_epot( smom0, smom1, self.niv, self._beta ) return (e_hartree + e_corr + e_tail) / self.nq_tot
def _model_epot(self, smom0, smom1, niv, beta): r""" Evaluates the analytic :math:`1/\nu^2` model potential-energy tail :math:`\frac{1}{\beta}\sum_{k,\nu} \mathrm{Tr}[(-\Sigma_1/\imath\nu) G_{\mathrm{mod}}]` over a frequency box of half width ``niv`` (used as the difference of a large and a small box in :meth:`get_epot`). :param smom0: Zeroth self-energy moment :math:`\Sigma_\infty`, shape ``[o1, o2]``. :param smom1: First self-energy tail coefficient :math:`\Sigma_1`, shape ``[o1, o2]``. :param niv: Number of positive fermionic frequencies in the box. :param beta: Inverse temperature :math:`\beta`. :return: The model tail contribution to the potential energy (real scalar, not yet divided by ``nk_tot``). """ h = (self._ek + smom0[None, None, None]).reshape(self.nq_tot, self.n_bands, self.n_bands) lam, u = np.linalg.eig(h) # once per k u_inv = np.linalg.inv(u) smom1_rot = u_inv @ smom1 @ u # rotate tail coeff into eigenbasis iv = 1j * MFHelper.vn(niv, beta) g_diag = 1.0 / (iv[None, :] + self._mu - lam[:, :, None]) # [k, band, v] # Tr[(-smom1/iv) G_mod] = -sum_i (smom1_rot)_ii * g_diag_i / iv integrand = -np.einsum("kii,kiv->kv", smom1_rot, g_diag) / iv[None, :] return integrand.sum().real / beta def _get_gfull_mat(self) -> np.ndarray: r""" Builds the full momentum-dependent Green's function array :math:`[(\imath\nu + \mu) - \varepsilon(k) - \Sigma(k, \nu)]^{-1}`. :return: The Green's function array, shape ``[kx, ky, kz, o1, o2, v]``. """ iv_bands, mu_bands = self._get_g_params_local() iv_bands = iv_bands[None, None, None, ...] mu_bands = mu_bands[None, None, None, ...] sigma_mat = self._sigma.mat if len(self._sigma.mat.shape) == 3: # (o1,o1,v) sigma_mat = sigma_mat[None, None, None, ...] mat = iv_bands + mu_bands - self._ek[..., None] - sigma_mat return self._invert_last_orbital_block(mat) def _get_gloc_mat(self) -> np.ndarray: """ Builds the local (k-averaged) Green's function array. :return: The local Green's function array, shape ``[o1, o2, v]``. """ return np.mean(self._get_gfull_mat(), axis=(0, 1, 2)) def _get_g_model_k_mat(self) -> np.ndarray: """ Builds the k-resolved model Green's function from the zeroth self-energy moment and the band dispersion. Subtracting it accelerates the Matsubara sum convergence when computing the k-resolved occupation. :return: The k-resolved model Green's function array, shape ``[kx, ky, kz, o1, o2, v]``. """ iv_bands, mu_bands = self._get_g_params_local() smom0 = self._sigma.smom[0][None, None, None, ...] mat = iv_bands[None, None, None] + mu_bands[None, None, None] - self._ek[..., None] - smom0[..., None] return self._invert_last_orbital_block(mat) def _get_g_params_local(self): r""" Projects the fermionic frequencies :math:`\imath\nu` and the chemical potential :math:`\mu` onto the diagonal of the orbital/band space. :return: The tuple ``(iv_bands, mu_bands)`` of diagonal frequency and chemical-potential arrays. """ eye_bands = np.eye(self.n_bands, self.n_bands) iv = 1j * MFHelper.vn(self.niv, self._beta) iv_bands = iv[None, None, :] * eye_bands[..., None] mu_bands = self._mu * eye_bands[:, :, None] return iv_bands, mu_bands