Source code for dgamore.hamiltonian

# 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"""
Lattice model setup. :class:`Hamiltonian` assembles the (multi-orbital) Hubbard model: the real-space hopping
:math:`t_{ab}(R)`, the local interaction :math:`U_{abcd}` and the non-local interaction :math:`V_{abcd}(R)`. It
offers convenience builders (single-band, orbital-diagonal, Kanamori, 2D :math:`t`-:math:`t'`-:math:`t''`),
readers/writers for wannier90 / wien2k ``hr``/``hk`` and ``umatrix`` files, and Fourier transforms to obtain the
band dispersion :math:`\varepsilon_{ab}(k)` and momentum-dependent interaction :math:`V_{abcd}(q)`.
:class:`HoppingElement` and :class:`InteractionElement` are small validated containers for single hopping/
interaction entries.
"""

import itertools as it
import logging

import numpy as np
import pandas as pd

import dgamore.brillouin_zone as bz
from dgamore.interaction import LocalInteraction, Interaction
from dgamore.n_point_base import SpinChannel


[docs] class HoppingElement: """ Data class to store a hopping element of the Hamiltonian. The hopping element is defined by the relative lattice vector ``r_lat``, the orbitals ``orbs`` and the value of the hopping ``value``. A hopping element represents a single line in a w2k file. Added this to make the code more readable and to avoid passing around lists of values. """ def __init__(self, r_lat: list, orbs: list, value: float = 0.0): """ Validates and stores a single hopping element. :param r_lat: The relative lattice vector as a list of 3 integers. :param orbs: The two (1-based) orbital indices ``[o1, o2]`` of the hopping. :param value: The hopping amplitude. :raises ValueError: If ``r_lat`` is not 3 integers, ``orbs`` is not 2 positive integers, or ``value`` is not a number. """ if not (isinstance(r_lat, list) and len(r_lat) == 3 and all(isinstance(x, int) for x in r_lat)): raise ValueError("'r_lat' must be a list with exactly 3 integer elements.") if not ( isinstance(orbs, list) and len(orbs) == 2 and all(isinstance(x, int) for x in orbs) and all(orb > 0 for orb in orbs) ): raise ValueError("'orbs' must be a list with exactly 2 integer elements that are greater than 0.") if not isinstance(value, (int, float)): raise ValueError("'value' must be a valid number.") self.r_lat = tuple(r_lat) self.orbs = np.array(orbs, dtype=int) self.value = float(value)
[docs] class InteractionElement: """ Data class to store an interaction element of the Hamiltonian. The interaction element is defined by the relative lattice vector ``r_lat``, the orbitals ``orbs`` and the value of the interaction ``value``. An interaction element represents a single entry in the interaction matrix. Added this to make the code more readable and to avoid passing around lists of values. """ def __init__(self, r_lat: list[int], orbs: list[int], value: float): """ Validates and stores a single interaction element. :param r_lat: The relative lattice vector as a list of 3 integers. :param orbs: The four (1-based) orbital indices ``[o1, o2, o3, o4]`` of the interaction. :param value: The interaction value. :raises ValueError: If ``r_lat`` is not 3 integers, ``orbs`` is not 4 positive integers, or ``value`` is not a number. """ if not (isinstance(r_lat, list) and len(r_lat) == 3 and all(isinstance(x, int) for x in r_lat)): raise ValueError("'r_lat' must be a list with exactly 3 integer elements.") if not ( isinstance(orbs, list) and len(orbs) == 4 and all(isinstance(x, int) for x in orbs) and all(orb > 0 for orb in orbs) ): raise ValueError("'orbs' must be a list with exactly 4 integer elements that are greater than zero.") if not isinstance(value, (int, float)): raise ValueError("'value' must be a real number.") self.r_lat = tuple(r_lat) self.orbs = np.array(orbs, dtype=int) self.value = float(value)
[docs] class Hamiltonian: """ Class to handle the Hamiltonian of the Hubbard model. Contains the hopping terms (``er``) and the local (``local_interaction``) and nonlocal interaction (``nonlocal_interaction``) terms. Has a few helper methods to simplify adding terms to the Hamiltonian. """ def __init__(self): """ Initializes an empty Hamiltonian; hopping and interaction terms are added afterwards via the builder/reader methods. """ self._er = None self._ek = None self._er_r_grid = None self._er_orbs = None self._er_r_weights = None self._ur_local = None self._ur_nonlocal = None self._ur_r_grid = None self._ur_orbs = None self._ur_r_weights = None self._local_interaction = None self._nonlocal_interaction = None
[docs] def single_band_interaction(self, u: float) -> "Hamiltonian": r""" Sets the local interaction for a single-band model from a single Hubbard :math:`U`. :param u: The Hubbard interaction :math:`U`. :return: ``self`` (for chaining). """ return self.interaction_orbital_diagonal(u, 1)
[docs] def interaction_orbital_diagonal(self, u: float, n_bands: int = 1) -> "Hamiltonian": r""" Sets a purely orbital-diagonal local interaction for a multi-band model: the interaction tensor is zero everywhere except the ``[0,0,0,0]`` element, which is set to ``u``. :param u: The Hubbard interaction :math:`U` on the first orbital. :param n_bands: Number of orbitals/bands. :return: ``self`` (for chaining). """ interaction_elements = [] for i, j, k, l in it.product(range(n_bands), repeat=4): if i == j == k == l == 0: interaction_elements.append(InteractionElement([0, 0, 0], [i + 1, j + 1, k + 1, l + 1], u)) else: interaction_elements.append(InteractionElement([0, 0, 0], [i + 1, j + 1, k + 1, l + 1], 0)) return self._add_interaction_term(interaction_elements)
[docs] def kanamori_interaction_d(self, n_bands: int, udd: float, jdd: float, vdd: float = None) -> "Hamiltonian": r""" Adds the Kanamori interaction terms ONLY for d orbitals to the Hamiltonian. The interaction terms are defined by the Hubbard ``udd`` (U), the exchange interaction ``jdd`` (J) and the inter-orbital density-density interaction ``vdd`` (V or sometimes U'). ``vdd`` is an optional parameter, if left empty, it is set to V=U-2J. :param n_bands: Number of d orbitals/bands. :param udd: The intra-orbital Hubbard interaction :math:`U_{dd}`. :param jdd: The Hund's exchange :math:`J_{dd}`. :param vdd: The inter-orbital interaction :math:`V_{dd}`; defaults to :math:`U_{dd} - 2 J_{dd}` if None. :return: ``self`` (for chaining). """ return self.kanamori_interaction_dp(nd_bands=n_bands, udd=udd, jdd=jdd, vdd=vdd)
[docs] def kanamori_interaction_p(self, n_bands: int, upp: float, jpp: float, vpp: float = None) -> "Hamiltonian": r""" Adds the Kanamori interaction terms ONLY for p orbitals to the Hamiltonian. The interaction terms are defined by the Hubbard ``upp`` (U), the exchange interaction ``jpp`` (J) and the inter-orbital density-density interaction ``vpp`` (V or sometimes U'). ``vpp`` is an optional parameter, if left empty, it is set to V=U-2J. :param n_bands: Number of p orbitals/bands. :param upp: The intra-orbital Hubbard interaction :math:`U_{pp}`. :param jpp: The Hund's exchange :math:`J_{pp}`. :param vpp: The inter-orbital interaction :math:`V_{pp}`; defaults to :math:`U_{pp} - 2 J_{pp}` if None. :return: ``self`` (for chaining). """ return self.kanamori_interaction_dp(np_bands=n_bands, upp=upp, jpp=jpp, vpp=vpp)
[docs] def kanamori_interaction_dp( self, nd_bands: int = 0, np_bands: int = 0, udd: float = 0.0, upp: float = 0.0, udp: float = 0.0, jdd: float = 0.0, jpp: float = 0.0, jdp: float = 0.0, vdd: float = None, vpp: float = None, ) -> "Hamiltonian": r""" Adds the full Kanamori interaction terms for d and p orbitals to the Hamiltonian. The interaction terms are defined by the local interaction Hubbard U, the exchange interaction J and the inter-orbital density-density interaction V or sometimes U'. vdd (vpp) (vdp) are optional parameters, if left empty, they are set to V=U-2J. :param nd_bands: Number of d orbitals (placed first in the orbital ordering). :param np_bands: Number of p orbitals (placed after the d orbitals). :param udd: Intra-orbital Hubbard :math:`U_{dd}`. :param upp: Intra-orbital Hubbard :math:`U_{pp}`. :param udp: The d-p inter-orbital interaction (used as :math:`V_{dp}`). :param jdd: Hund's exchange :math:`J_{dd}`. :param jpp: Hund's exchange :math:`J_{pp}`. :param jdp: The d-p exchange :math:`J_{dp}`. :param vdd: Inter-orbital :math:`V_{dd}`; defaults to :math:`U_{dd} - 2 J_{dd}` if None. :param vpp: Inter-orbital :math:`V_{pp}`; defaults to :math:`U_{pp} - 2 J_{pp}` if None. :return: ``self`` (for chaining). """ # Default V=U-2J if vdd is None: vdd = udd - 2 * jdd if vpp is None: vpp = upp - 2 * jpp def orb_type(i: int) -> str: """ Classifies an orbital as a d- or p-orbital by its index. :param i: 0-based orbital index. :return: ``"d"`` if the orbital is among the first ``nd_bands`` (d orbitals), else ``"p"``. """ return "d" if i < nd_bands else "p" # d bands come first def get_params(o1: int, o2: int) -> tuple[float, float, float]: """ Returns the ``(U, J, V)`` Kanamori parameters for an orbital pair according to their d/p types. :param o1: 0-based index of the first orbital. :param o2: 0-based index of the second orbital. :return: The ``(U, J, V)`` tuple for that orbital combination. """ t1, t2 = orb_type(o1), orb_type(o2) if t1 == "d" and t2 == "d": return udd, jdd, vdd elif t1 == "p" and t2 == "p": return upp, jpp, vpp else: return 0, jdp, udp # udp is used as "Vdp", since there is no Udp possible that fits U_llll r_loc = [0, 0, 0] n_tot = nd_bands + np_bands interaction_elements = [] for a, b, c, d in it.product(range(n_tot), repeat=4): bands = [a + 1, b + 1, c + 1, d + 1] # choose parameters based on (a,b) pair (equivalently (c,d)) u, j, v = get_params(a, b) if a == b == c == d: # U_{llll} interaction_elements.append(InteractionElement(r_loc, bands, u)) elif (a == d and b == c) or (a == b and c == d): # U_{lmml}, U_{llmm} interaction_elements.append(InteractionElement(r_loc, bands, j)) elif a == c and b == d: # U_{lmlm} interaction_elements.append(InteractionElement(r_loc, bands, v)) return self._add_interaction_term(interaction_elements)
[docs] def kinetic_one_band_2d_t_tp_tpp(self, t: float, tp: float, tpp: float) -> "Hamiltonian": r""" Adds the kinetic terms for a one-band model in 2D with nearest (t), next-nearest (tp) and next-next-nearest (tpp) neighbor hopping. :param t: Nearest-neighbor hopping :math:`t`. :param tp: Next-nearest-neighbor hopping :math:`t'`. :param tpp: Next-next-nearest-neighbor hopping :math:`t''`. :return: ``self`` (for chaining). """ orbs = [1, 1] hopping_elements = [ HoppingElement(r_lat=[1, 0, 0], orbs=orbs, value=-t), HoppingElement(r_lat=[0, 1, 0], orbs=orbs, value=-t), HoppingElement(r_lat=[-1, 0, 0], orbs=orbs, value=-t), HoppingElement(r_lat=[0, -1, 0], orbs=orbs, value=-t), HoppingElement(r_lat=[1, 1, 0], orbs=orbs, value=-tp), HoppingElement(r_lat=[1, -1, 0], orbs=orbs, value=-tp), HoppingElement(r_lat=[-1, 1, 0], orbs=orbs, value=-tp), HoppingElement(r_lat=[-1, -1, 0], orbs=orbs, value=-tp), HoppingElement(r_lat=[2, 0, 0], orbs=orbs, value=-tpp), HoppingElement(r_lat=[0, 2, 0], orbs=orbs, value=-tpp), HoppingElement(r_lat=[-2, 0, 0], orbs=orbs, value=-tpp), HoppingElement(r_lat=[0, -2, 0], orbs=orbs, value=-tpp), ] return self._add_kinetic_term(hopping_elements)
[docs] def read_hr_w2k(self, filename: str = "./wannier_hr.dat") -> "Hamiltonian": """ Reads the ``wannier_hr.dat`` file from a wien2k hr file and sets the real-space kinetic Hamiltonian. This is then typically Fourier-transformed to momentum space to obtain the k-dependent band dispersion. :param filename: Path to the ``wannier_hr.dat`` file. :return: ``self`` (for chaining). """ er_file = pd.read_csv(filename, skiprows=1, names=np.arange(15), sep=r"\s+", dtype=float, engine="python") n_bands = er_file.values[0][0].astype(int) nr = er_file.values[1][0].astype(int) tmp = np.reshape(er_file.values, (np.size(er_file.values), 1)) tmp = tmp[~np.isnan(tmp)] self._er_r_weights = tmp[2 : 2 + nr].astype(int) self._er_r_weights = np.reshape(self._er_r_weights, (np.size(self._er_r_weights), 1)) ns = 7 n_tmp = np.size(tmp[2 + nr :]) // ns tmp = np.reshape(tmp[2 + nr :], (n_tmp, ns)) self._er_r_grid = np.reshape(tmp[:, 0:3], (nr, n_bands, n_bands, 3)) self._er_orbs = np.reshape(tmp[:, 3:5], (nr, n_bands, n_bands, 2)) self._er = np.reshape(tmp[:, 5] + 1j * tmp[:, 6], (nr, n_bands, n_bands)) return self
[docs] def read_hk_w2k(self, fname: str, spin_sym: bool = True): r""" Reads a Hamiltonian :math:`H_{bb'}(k)` from a text file. Expects a text file with white-space separated values in the syntax as generated by wannier90: the first line is a header with three integers, optionally followed by '#'-prefixed comment: <no of k-points> <no of wannier functions> <no of bands (ignored)> For each k-point, there is a header line with the x, y, z coordinates of the k-point, followed by <nbands> rows as lines of 2*<nbands> values each, which are the real and imaginary part of each column. :param fname: Path to the ``hk`` text file. :param spin_sym: If True, assume spin symmetry (no spin-orbit); if False, the file carries a doubled spin structure that is folded back to ``nbands``. :return: A pair ``(ham, kpoints)`` where ``ham`` is a new :class:`Hamiltonian` with ``ek`` set to the complex array :math:`H(k)` (k-point first, then band indices) and ``kpoints`` is the real array of k-point ``(x, y, z)`` components (transposed). :raises RuntimeError: If a spin-orbit Hamiltonian has an odd band count. :raises ValueError: If the file contains fewer k-points than declared in the header. """ logger = logging.getLogger() hk_file = open(fname, "r", encoding="utf-8") spin_orbit = not spin_sym def nextline(): """ Reads the next file line, stripping any ``#`` comment, and splits it into whitespace-separated tokens. :return: The list of tokens on the line (empty if the line is blank/comment-only). """ line = hk_file.readline() return line[: line.find("#")].split() # parse header header = nextline() if header[0] == "VERSION": logger.warning("Version 2 headers are obsolete (specify in input file!)") nkpoints, natoms = map(int, nextline()) lines = np.array([nextline() for _ in range(natoms)], np.int_) nbands = np.sum(lines[:, :2]) del lines, natoms elif len(header) != 3: logger.warning("Version 1 headers are obsolete (specify in input file!)") header = list(map(int, header)) nkpoints = header[0] nbands = header[1] * (header[2] + header[3]) else: nkpoints, nbands, _ = map(int, header) del header # nspins is the spin dimension for H(k); G(iw), Sigma(iw) etc. will always # be spin-dependent if spin_orbit: if nbands % 2: raise RuntimeError("Spin-structure of Hamiltonian!") nbands //= 2 nspins = 2 else: nspins = 1 # GS: inside read_hamiltonian nspins is therefore equal to 1 if spin_orbit=0 # GS: outside nspins is however always set to 2. Right? # GS: this also means that we have to set nspins internally in read_ImHyb too hk_file.flush() # parse data hk = np.fromfile(hk_file, sep=" ") hk = hk.reshape(-1, 3 + 2 * nbands**2 * nspins**2) kpoints_file = hk.shape[0] if kpoints_file > nkpoints: logger.warning("truncating Hk points") elif kpoints_file < nkpoints: raise ValueError(f"problem! {kpoints_file} < {nkpoints}") kpoints = hk[:nkpoints, :3] hk = hk[:nkpoints, 3:].reshape(nkpoints, nspins, nbands, nspins, nbands, 2) hk = hk[..., 0] + 1j * hk[..., 1] if not np.allclose(hk, hk.transpose(0, 3, 4, 1, 2).conj()): logger.warning("Hermiticity violation detected in Hk file") # go from wannier90/convert_Hamiltonian structure to our Green's function # convention hk = hk.transpose(0, 2, 1, 4, 3) hk_file.close() hk = np.squeeze(hk) ham = Hamiltonian() ham._ek = hk return ham, kpoints.T
[docs] def write_hr_w2k(self, filename: str) -> None: """ Writes the real-space kinetic Hamiltonian to a file in the wien2k ``hr`` format. :param filename: Output file path. :return: None. """ n_columns = 15 n_r = self._er.shape[0] n_bands = self._er.shape[-1] file = open(filename, "w", encoding="utf-8") file.write("# Written using the wannier module of the dga code\n") file.write(f"{n_bands} \n") file.write(f"{n_r} \n") for i in range(0, len(self._er_r_weights), n_columns): line = " ".join(map(str, self._er_r_weights[i : i + n_columns, 0])) file.write(" " + line + "\n") hr = self._er.reshape(n_r * n_bands**2, 1) r_grid = self._er_r_grid.reshape(n_r * n_bands**2, 3).astype(int) orbs = self._er_orbs.reshape(n_r * n_bands**2, 2).astype(int) for i in range(0, n_r * n_bands**2): line = "{: 5d}{: 5d}{: 5d}{: 5d}{: 5d}{: 12.6f}{: 12.6f}".format( *r_grid[i, :], *orbs[i, :], hr[i, 0].real, hr[i, 0].imag ) file.write(line + "\n")
[docs] def write_hk_w2k(self, filename: str, k_grid: bz.KGrid, ek: np.ndarray = None) -> None: """ Writes the k-space kinetic Hamiltonian to a file in the wien2k ``hk`` format. :param filename: Output file path. :param k_grid: The :class:`KGrid` defining the k-points to write. :param ek: Optional band dispersion to write; computed from the Hamiltonian on ``k_grid`` if None. :return: None. """ if ek is None: ek = self.get_ek(k_grid) f = open(filename, "w", encoding="utf-8") n_bands = ek.shape[-1] ek = ek.reshape(k_grid.nk_tot, n_bands, n_bands) kmesh = k_grid.kmesh_list print(k_grid.nk_tot, n_bands, n_bands, file=f) for ik in range(k_grid.nk_tot): print(kmesh[0, ik], kmesh[1, ik], kmesh[2, ik], file=f) ek_slice = np.copy(ek[ik, ...]) np.savetxt( f, ek_slice.view(float), fmt="%.12f", delimiter=" ", newline="\n", header="", footer="", comments="#" ) f.close()
[docs] def read_umatrix(self, filename: str) -> "Hamiltonian": """ Reads a file and creates the interaction matrix from it. The file should contain the number of bands in the first line and the number of r values in the second line. From the third line onwards it should contain the interaction matrix entries. It looks very similar to the format of a wannier_hr.dat file. The format is: r_lat_x r_lat_y r_lat_z orb1 orb2 orb3 orb4 realvalue imagvalue, where r_lat is the relative lattice vector and orb1-4 are the orbital indices. The interaction is assumed to be purely real. The ordering of the entries themselves does not matter. Note: The file must not contain any comments or empty lines. :param filename: Path to the umatrix file. :return: ``self`` (for chaining). """ umatrix_file = pd.read_csv(filename, skiprows=0, names=np.arange(15), sep=r"\s+", dtype=float, engine="python") values = umatrix_file.values nr = values[1][0].astype(int) values = values[~np.isnan(values)] n_cols = 9 n_rows = np.size(values[2 + nr :]) // n_cols values = np.reshape(values[2 + nr :], (n_rows, n_cols)) interaction_elements = [] for i in range(len(values)): interaction_elements.append( InteractionElement( r_lat=values[i, 0:3].astype(int).tolist(), orbs=values[i, 3:7].astype(int).tolist(), value=values[i, 7].astype(float), ) ) return self._add_interaction_term(interaction_elements)
[docs] def get_ek(self, k_grid: bz.KGrid = None) -> np.ndarray: r""" Returns the band dispersion :math:`\varepsilon_{ab}(k)`, computing and caching it from the real-space hopping on ``k_grid`` if not already set. :param k_grid: The :class:`KGrid` to evaluate on (ignored if the dispersion is already cached/set). :return: The band dispersion array of shape ``[kx, ky, kz, o1, o2]``. """ if self._ek is not None: return self._ek self._ek = self._convham_2_orbs(k_grid.kmesh.reshape(3, -1)) n_bands = self._ek.shape[-1] self._ek = self._ek.reshape(*k_grid.nk, n_bands, n_bands) return self._ek
[docs] def set_ek(self, ek: np.ndarray) -> "Hamiltonian": """ Sets the band dispersion directly (bypassing the Fourier transform). :param ek: The band dispersion array. :return: ``self`` (for chaining). """ self._ek = ek return self
[docs] def get_local_u(self) -> LocalInteraction: """ Returns the local interaction. Since the local interaction is momentum-independent, its momentum-space and real-space representations coincide. :return: The local interaction as a :class:`LocalInteraction`. """ return LocalInteraction(self._ur_local)
[docs] def get_vq(self, q_grid: bz.KGrid) -> "Interaction": r""" Returns the momentum-dependent non-local interaction :math:`V_{abcd}(q)` by Fourier-transforming the real-space interaction onto ``q_grid``. :param q_grid: The :class:`KGrid` defining the momentum grid. :return: The non-local interaction as an :class:`Interaction`. """ uq = self._convham_4_orbs(q_grid.kmesh.reshape(3, -1)) n_bands = uq.shape[-1] return Interaction(uq.reshape(q_grid.nk + (n_bands,) * 4), SpinChannel.NONE, q_grid.nk)
def _add_kinetic_term(self, hopping_elements: list) -> "Hamiltonian": """ Builds the real-space kinetic term (grid, orbitals, weights and ``er`` matrix) from a list of hopping elements. :param hopping_elements: List of :class:`HoppingElement` (or dicts convertible to one). :return: ``self`` (for chaining). :raises ValueError: If any hopping element is local (``r_lat == [0, 0, 0]``). """ hopping_elements = self._parse_elements(hopping_elements, HoppingElement) if any(np.allclose(el.r_lat, [0, 0, 0]) for el in hopping_elements): raise ValueError("Local hopping is not allowed!") r_to_index, n_rp, n_orbs = self._prepare_lattice_indices_and_orbs(hopping_elements) self._er_r_grid = self._create_er_grid(r_to_index, n_orbs) self._er_orbs = self._create_er_orbs(n_rp, n_orbs) self._er_r_weights = np.ones(n_rp)[:, None] self._er = np.zeros((n_rp, n_orbs, n_orbs)) for he in hopping_elements: self._insert_er_element(self._er, r_to_index, he.r_lat, *he.orbs, he.value) return self def _add_interaction_term(self, interaction_elements: list) -> "Hamiltonian": """ Builds the local and non-local interaction terms from a list of interaction elements (the local part is stored separately, so the ``[0,0,0]`` lattice vector is removed from the non-local grid). :param interaction_elements: List of :class:`InteractionElement` (or dicts convertible to one). :return: ``self`` (for chaining). """ interaction_elements = self._parse_elements(interaction_elements, InteractionElement) r_to_index, n_rp, n_orbs = self._prepare_lattice_indices_and_orbs(interaction_elements) # we need local interactions in a separate object, hence we do not care about the [0,0,0] r_lat in here n_rp -= 1 r_to_index.pop((0, 0, 0)) self._ur_r_grid = self._create_ur_grid(r_to_index, n_orbs) self._ur_orbs = self._create_ur_orbs(n_rp, n_orbs) self._ur_r_weights = np.ones(n_rp)[:, None] self._ur_local = np.zeros((n_orbs, n_orbs, n_orbs, n_orbs)) self._ur_nonlocal = np.zeros((n_rp, n_orbs, n_orbs, n_orbs, n_orbs)) for ie in interaction_elements: if np.allclose(ie.r_lat, [0, 0, 0]): self._insert_ur_element(self._ur_local, None, None, *ie.orbs, ie.value) else: self._insert_ur_element(self._ur_nonlocal, r_to_index, ie.r_lat, *ie.orbs, ie.value) return self def _create_er_grid(self, r_to_index: dict[list, int], n_orbs: int) -> np.ndarray: """ Builds the real-space lattice-vector grid for the kinetic Hamiltonian. :param r_to_index: Mapping from lattice-vector tuple to its row index. :param n_orbs: Number of orbitals. :return: The grid array of shape ``[n_rp, o1, o2, 3]``. """ n_rp = len(r_to_index) r_grid = np.zeros((n_rp, n_orbs, n_orbs, 3)) for r_vec in r_to_index.keys(): r_grid[r_to_index[r_vec], :, :, :] = r_vec return r_grid def _create_ur_grid(self, r_to_index: dict[list, int], n_orbs: int) -> np.ndarray: """ Builds the real-space lattice-vector grid for the interaction Hamiltonian. :param r_to_index: Mapping from lattice-vector tuple to its row index. :param n_orbs: Number of orbitals. :return: The grid array of shape ``[n_rp, o1, o2, o3, o4, 3]``. """ n_rp = len(r_to_index) r_grid = np.zeros((n_rp, n_orbs, n_orbs, n_orbs, n_orbs, 3)) for r_vec in r_to_index.keys(): r_grid[r_to_index[r_vec], :, :, :, :, :] = r_vec return r_grid def _create_er_orbs(self, n_rp: int, n_orbs: int) -> np.ndarray: """ Builds the (1-based) orbital-index array for the kinetic Hamiltonian. :param n_rp: Number of real-space lattice vectors. :param n_orbs: Number of orbitals. :return: The orbital-index array of shape ``[n_rp, o1, o2, 2]``. """ orbs = np.zeros((n_rp, n_orbs, n_orbs, 2)) for r, io1, io2 in it.product(range(n_rp), range(n_orbs), range(n_orbs)): orbs[r, io1, io2, :] = np.array([io1 + 1, io2 + 1]) return orbs def _create_ur_orbs(self, n_rp: int, n_orbs: int) -> np.ndarray: """ Builds the (1-based) orbital-index array for the interaction Hamiltonian. :param n_rp: Number of real-space lattice vectors. :param n_orbs: Number of orbitals. :return: The orbital-index array of shape ``[n_rp, o1, o2, o3, o4, 4]``. """ orbs = np.zeros((n_rp, n_orbs, n_orbs, n_orbs, n_orbs, 4)) for r, io1, io2, io3, io4 in it.product( range(n_rp), range(n_orbs), range(n_orbs), range(n_orbs), range(n_orbs) ): orbs[r, io1, io2, io3, io4, :] = np.array([io1 + 1, io2 + 1, io3 + 1, io4 + 1]) return orbs def _insert_er_element( self, er_mat: np.ndarray, r_to_index: dict[list, int], r_vec: list, orb1: int, orb2: int, hr_elem: float ) -> None: """ Writes a single hopping value into the kinetic Hamiltonian matrix at the matching lattice/orbital position. :param er_mat: The kinetic Hamiltonian array to write into. :param r_to_index: Mapping from lattice-vector tuple to its row index. :param r_vec: The lattice vector of this element. :param orb1: First (1-based) orbital index. :param orb2: Second (1-based) orbital index. :param hr_elem: The hopping value. :return: None. """ index = r_to_index[r_vec] er_mat[index, orb1 - 1, orb2 - 1] = hr_elem def _insert_ur_element( self, ur_mat: np.ndarray, r_to_index: dict[list, int] | None, r_vec: list | None, orb1: int, orb2: int, orb3: int, orb4: int, value: float, ) -> None: """ Writes a single interaction value into the interaction Hamiltonian. If ``r_to_index``/``r_vec`` are None the value is treated as local (no lattice axis); otherwise it is placed at the matching lattice position. :param ur_mat: The interaction Hamiltonian array to write into (local or non-local). :param r_to_index: Mapping from lattice-vector tuple to its row index, or None for the local part. :param r_vec: The lattice vector of this element, or None for the local part. :param orb1: First (1-based) orbital index. :param orb2: Second (1-based) orbital index. :param orb3: Third (1-based) orbital index. :param orb4: Fourth (1-based) orbital index. :param value: The interaction value. :return: None. """ if r_to_index is None or r_vec is None: ur_mat[orb1 - 1, orb2 - 1, orb3 - 1, orb4 - 1] = value return index = r_to_index[r_vec] ur_mat[index, orb1 - 1, orb2 - 1, orb3 - 1, orb4 - 1] = value def _convham_2_orbs(self, k_mesh: np.ndarray) -> np.ndarray: r""" Fourier-transforms the real-space hopping to the band dispersion :math:`\varepsilon_{ab}(k)`, looping over lattice vectors to keep the memory footprint low. :param k_mesh: The k-points as an array of shape ``[3, nk]``. :return: The band dispersion of shape ``[nk, o1, o2]``. """ n_rp = self._er.shape[0] n_orbs = self._er.shape[1] nk = k_mesh.shape[1] result = np.zeros((n_orbs, n_orbs, nk), dtype=np.complex128) for r in range(n_rp): phase = np.exp(1j * np.tensordot(self._er_r_grid[r], k_mesh, axes=([2], [0]))) phase /= float(self._er_r_weights[r, 0]) result += phase * self._er[r, ..., None] return np.transpose(result, axes=(2, 0, 1)) def _convham_4_orbs(self, k_mesh: np.ndarray) -> np.ndarray: r""" Fourier-transforms the real-space non-local interaction to :math:`V_{abcd}(q)`, looping over lattice vectors to keep the memory footprint low. :param k_mesh: The q-points as an array of shape ``[3, nk]``. :return: The momentum-dependent interaction of shape ``[nk, o1, o2, o3, o4]``. """ n_rp = self._ur_nonlocal.shape[0] n_orbs = self._ur_nonlocal.shape[1] nk = k_mesh.shape[1] result = np.zeros((n_orbs, n_orbs, n_orbs, n_orbs, nk), dtype=np.complex128) for r in range(n_rp): phase = np.exp(1j * np.tensordot(self._ur_r_grid[r], k_mesh, axes=([4], [0]))) phase /= float(self._ur_r_weights[r, 0]) result += phase * self._ur_nonlocal[r, ..., None] return np.transpose(result, axes=(4, 0, 1, 2, 3)) def _parse_elements(self, elements: list, element_type: type) -> list: """ Normalizes a list of elements to instances of ``element_type``, constructing them from dicts where needed. :param elements: List of elements, each either an ``element_type`` instance or a dict of its kwargs. :param element_type: The target class (:class:`HoppingElement` or :class:`InteractionElement`). :return: A list of ``element_type`` instances. """ if not all(isinstance(item, element_type) for item in elements): return [element_type(**element) for element in elements] return elements def _prepare_lattice_indices_and_orbs(self, elements: list) -> tuple: """ Derives the lattice-vector indexing and dimensions shared by the Hamiltonian matrices from a list of elements. :param elements: List of hopping or interaction elements. :return: The tuple ``(r_to_index, n_rp, n_orbs)`` of the lattice-vector-to-index map, the number of lattice vectors, and the number of orbitals. """ unique_lat_r = set([el.r_lat for el in elements]) r_to_index = {tup: index for index, tup in enumerate(unique_lat_r)} n_rp = len(r_to_index) n_orbs = int(max(np.array([el.orbs for el in elements]).flatten())) return r_to_index, n_rp, n_orbs def _check_interaction_swapping_symmetry(self, uq_local: np.ndarray, uq_nonlocal: np.ndarray): r""" Checks the interaction swapping symmetry: :math:`U_{lmm'l'} = U_{mll'm'}` for the local part and :math:`V^{q}_{lmm'l'} = V^{-q}_{mll'm'}` for the non-local part. NOTE: The check for the non-local :math:`V^q` needs to be revised because a straight inversion of the first axis is not correct. :param uq_local: The local interaction tensor :math:`U_{abcd}`. :param uq_nonlocal: The non-local interaction tensor :math:`V_{abcd}(q)`. :return: None. :raises AssertionError: If either swapping symmetry is violated. """ assert np.allclose( uq_local, np.einsum("abcd->badc", uq_local) ), "Swapping symmetry of the interaction is not satisfied!" assert np.allclose( uq_nonlocal, np.einsum("qabcd->qbadc", np.flip(uq_nonlocal, axis=0)) ), "Swapping symmetry of the interaction is not satisfied!"