Source code for dgamore.n_point_base

# 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
"""
Foundational mixins for every physical quantity in the code. :class:`IHaveMat` owns the underlying numpy array
(stored in the global precision :data:`DTYPE`) and the memory-management/arithmetic helpers; :class:`IHaveChannel`
adds the spin channel and frequency notation; :class:`IAmNonLocal` adds a single momentum dimension with the
compressed/decompressed and irreducible/full-BZ bookkeeping. Concrete classes (Green's function, self-energy,
vertices, interaction, gap function) compose these mixins.
"""

import gc
from abc import ABC
from copy import deepcopy
from enum import Enum

import numpy as np
import scipy as sp

from dgamore.brillouin_zone import KGrid

# Global storage precision for all N-point quantities. Switch this single constant to np.complex128 for
# double precision (at the cost of doubling the memory footprint). All objects deriving from IHaveMat store
# their underlying matrix in this dtype; it is also exposed as the class attribute IHaveMat.DTYPE.
DTYPE = np.complex64


[docs] class SpinChannel(Enum): """ Enum for the different spin combinations. :cvar DENS: Density (charge) channel. :cvar MAGN: Magnetic (spin) channel. :cvar SING: Singlet pairing channel. :cvar TRIP: Triplet pairing channel. :cvar UU: Up-up spin combination. :cvar UD: Up-down spin combination. :cvar UD_BAR: Crossed (transverse) up-down spin combination. :cvar NONE: No / unspecified channel (e.g. a bare, not-yet-projected quantity). """ DENS = "dens" MAGN = "magn" SING = "sing" TRIP = "trip" UU = "uu" UD = "ud" UD_BAR = "ud_bar" NONE = "none"
[docs] class FrequencyNotation(Enum): """ Enum for the different frequency notations. Is interchangeable with the channel reducibility. :cvar PH: Particle-hole notation. :cvar PH_BAR: Transverse (crossed) particle-hole notation. :cvar PP: Particle-particle notation. """ PH = "ph" PH_BAR = "ph_bar" PP = "pp"
[docs] class IHaveMat(ABC): """ Abstract interface for classes that have a mat attribute. Adds a couple of convenience methods for matrix operations. Also adds a way to easily delete the underlying matrix to free memory. """ _libc = None _malloc_trim_available = None # Upper bound (in bytes) on the transient temporaries that filter_small_values may materialize per chunk. The # mask is evaluated in slices so the full-size |real|/|imag|/boolean temporaries are never built all at once. _FILTER_CHUNK_BYTES = 256 * 1024**2 # Storage precision for the underlying matrix. Defaults to the module-level ``DTYPE`` (complex64). # Override the module constant (or this attribute) to switch the whole code base to double precision. DTYPE = DTYPE def __init__(self, mat: np.ndarray): """ Stores the array and records its original shape. :param mat: The underlying numpy array; it is coerced to the global storage precision :data:`DTYPE`. """ self.mat = mat self._original_shape = self.mat.shape @property def mat(self) -> np.ndarray: """ Returns the underlying matrix, i.e. the numpy array. :return: The underlying numpy array (or None if it has been freed). """ return self._mat @mat.setter def mat(self, value: np.ndarray) -> None: """ Sets the underlying matrix, i.e. the numpy array, coerced to the global storage precision ``DTYPE`` (complex64 by default to save memory). To switch the whole code base to higher precision, change the module-level ``DTYPE`` constant (or ``IHaveMat.DTYPE``) to e.g. np.complex128. Aliasing contract: ``astype(..., copy=False)`` means an array that is already of dtype ``DTYPE`` is stored **by reference, not copied**. Constructing an object from (or assigning) a view or an array that is still referenced elsewhere therefore aliases it -- a subsequent in-place mutation (e.g. ``obj[...] =``, ``filter_small_values``, a ``copy=False`` transformation) would affect the source. ``.copy()`` the input first if the source must stay independent. :param value: The new numpy array (or None to free the storage). """ if value is None: self._mat = None return self._mat = value.astype(DTYPE, copy=False) @property def current_shape(self) -> tuple: """ Keeps track of the current shape of the underlying numpy array. :return: The current shape of ``mat``. """ return self._mat.shape @property def original_shape(self) -> tuple: """ Keeps track of the previous shape of the underlying numpy array for any reshaping process. E.g., it is needed when reshaping it to compound indices where the original shape would have been lost otherwise. :return: The stored original (pre-reshape) shape. """ return self._original_shape @original_shape.setter def original_shape(self, value) -> None: """ Sets the original shape of the matrix. Keeps track of the previous shape of the underlying numpy array for any reshaping process. E.g., it is needed when reshaping it to compound indices where the original shape would have been lost otherwise. :param value: The shape tuple to store as the original shape. """ self._original_shape = value @property def memory_usage_in_gb(self) -> float: """ Returns the memory usage of the numpy array in GigaBytes (GB). :return: Memory footprint of ``mat`` in GB. """ return self.mat.nbytes / (1024**3) def __mul__(self, other) -> "IHaveMat": """ Multiplies the object by a scalar number, returning a new (deep-copied) object. :param other: A scalar (int, float or complex) to multiply with. :return: A new object holding ``self * other``. :raises ValueError: If ``other`` is not a number. """ if not isinstance(other, (int, float, complex)): raise ValueError("Multiplication only supported with numbers.") copy = deepcopy(self) copy.mat *= other return copy def __rmul__(self, other) -> "IHaveMat": """ Reflected scalar multiplication ``other * self``; see :meth:`__mul__`. :param other: A scalar to multiply with. :return: A new object holding ``self * other``. """ return self.__mul__(other) def __neg__(self) -> "IHaveMat": """ Negates the matrix (``-self``); see :meth:`__mul__`. :return: A new object holding ``-self``. """ return self.__mul__(-1.0) def __truediv__(self, other) -> "IHaveMat": """ Divides the object by a scalar number; see :meth:`__mul__`. :param other: A scalar (int, float or complex) to divide by. :return: A new object holding ``self / other``. :raises ValueError: If ``other`` is not a number. """ if not isinstance(other, (int, float, complex)): raise ValueError("Division only supported with numbers.") return self.__mul__(1.0 / other) def __getitem__(self, item): """ Indexing shortcut: ``obj[item]`` is equivalent to ``obj.mat[item]``. :param item: Any numpy-compatible index. :return: The indexed view/value of ``mat``. """ return self.mat[item] def __setitem__(self, key, value): """ Assignment shortcut: ``obj[key] = value`` is equivalent to ``obj.mat[key] = value`` (in-place). :param key: Any numpy-compatible index. :param value: The value to assign. """ self.mat[key] = value def __del__(self): """ Deletes the underlying numpy array to free memory. However, the memory might not be immediately freed by Python if you call this method directly, e.g. by del obj, because the object might still be part of a reference cycle. In this case, the memory will be freed when the reference cycle is collected by the garbage collector, which can be forced by calling gc.collect() after del obj. However, it is generally recommended to use the free() method instead of del obj to explicitly free memory. The destructor does **not** trim the heap (it calls ``free()`` with the default ``trim=False``): ``malloc_trim`` walks the whole heap, so trimming on every object destruction in the tight per-q/per-w loops is wasted work for no memory benefit (the buffer is released regardless). Return freed pages to the OS explicitly with ``free(trim=True)`` at coarse, heavy boundaries, or via the ``with obj:`` context manager. """ self.free() def __enter__(self): """ Context manager entry; see :meth:`__exit__`. :return: ``self``. """ return self def __exit__(self, exc_type, exc, tb): """ Context manager exit: frees the underlying array (and trims heap memory) when leaving a ``with`` block. :param exc_type: Exception type if one was raised inside the block, else None. :param exc: Exception instance if one was raised, else None. :param tb: Traceback if an exception was raised, else None. """ self.free(True)
[docs] def free(self, trim: bool = False): """ Explicitly releases the underlying numpy array. If True and running on Linux, attempts to return freed heap memory back to the OS using malloc_trim. ``trim`` defaults to **False** on purpose: ``malloc_trim`` walks the entire heap, so it should be requested only at coarse, heavy boundaries (e.g. after a large object is released at the end of a self-consistency iteration), never on every release in a hot loop. :param trim: If True, additionally attempt to return freed heap memory to the OS (Linux only). """ if self._mat is not None: self._mat = None gc.collect() if trim: self._malloc_trim()
[docs] def update_original_shape(self): """ Updates the original shape of the numpy array to the current array. This is often needed when the matrix is reshaped. """ self.original_shape = self.current_shape
def _clone_without_mat(self): """ Returns a deep copy of the object that carries all metadata but **not** the underlying array (``mat`` is ``None`` on the clone). This avoids the wasteful duplication of ``mat`` in transformation methods that deep-copy ``self`` only to overwrite ``copy.mat`` with a freshly computed array immediately afterwards. The caller is expected to assign a new array to ``clone.mat`` before using it. :return: A deep copy of ``self`` whose ``mat`` is ``None``. """ mat = self._mat self._mat = None try: clone = deepcopy(self) finally: self._mat = mat return clone
[docs] def times(self, contraction: str, *args) -> np.ndarray: """ Multiplies the matrices of multiple objects with the contraction specified and returns the result as a numpy array. :param contraction: An einsum contraction string applied to ``self.mat`` and the operands in ``args``. :param args: Further operands, each either an :class:`IHaveMat` or a numpy array. :return: The contracted numpy array. :raises ValueError: If any operand is neither an :class:`IHaveMat` nor a numpy array. """ if not all(isinstance(obj, (IHaveMat, np.ndarray)) for obj in args): raise ValueError("Args has atleast one object with the wrong type. Allowed are [IHaveMat] or [np.ndarray].") return np.einsum( contraction, self.mat, *[obj.mat if isinstance(obj, IHaveMat) else obj for obj in args], optimize=True )
[docs] def filter_small_values(self, threshold: float = 1e-12): """ Sets all values in the underlying matrix to zero which are smaller than the given threshold in absolute value. This can be used to save memory and speed up calculations by setting very small values to zero. The mask is evaluated in chunks so the full-size ``|real|``/``|imag|`` and boolean temporaries (~0.75x the array) are never materialized all at once -- on a very large array that transient spike would otherwise dominate the footprint right when ``mat`` is at its largest. Chunking is also faster (cache-resident temporaries, fewer page faults). A small array is filtered in a single pass to avoid the per-chunk overhead. :param threshold: Values whose real and imaginary parts are both below this magnitude are zeroed (in place). :return: ``self`` (for chaining). """ mat = self.mat # Per-element transient cost of the mask: the two float halves (== itemsize total) plus two boolean arrays. temp_bytes_per_elem = mat.dtype.itemsize + 2 budget_bytes = self._FILTER_CHUNK_BYTES # cap the per-chunk temporaries (class attribute; patchable in tests) def _zero_below(view: np.ndarray) -> None: """Zeroes the entries of ``view`` whose real and imag parts are both below ``threshold`` (in place).""" view[(np.abs(view.real) < threshold) & (np.abs(view.imag) < threshold)] = 0.0 if mat.flags["C_CONTIGUOUS"]: # A flat view is free for a C-contiguous array; chunk it along the single flat axis. flat = mat.reshape(-1) n = flat.shape[0] step = max(1, budget_bytes // temp_bytes_per_elem) if step >= n: _zero_below(flat) else: for i in range(0, n, step): _zero_below(flat[i : i + step]) else: # Non-contiguous: reshape(-1) would copy the whole array, so chunk axis 0 (slices stay views). n = mat.shape[0] rest_elems = max(1, mat.size // n) step = max(1, budget_bytes // (rest_elems * temp_bytes_per_elem)) if step >= n: _zero_below(mat) else: for i in range(0, n, step): _zero_below(mat[i : i + step]) return self
@classmethod def _malloc_trim(cls): """ Returns unused heap memory to the OS using glibc malloc_trim. Only available on Linux systems; a no-op elsewhere. The availability is detected once and cached on the class. """ import os if cls._malloc_trim_available is False: return if cls._malloc_trim_available is None: if os.name != "posix" or not os.path.exists("/proc"): cls._malloc_trim_available = False return try: import ctypes cls._libc = ctypes.CDLL("libc.so.6") cls._malloc_trim_available = True except Exception: cls._malloc_trim_available = False return try: cls._libc.malloc_trim(0) except Exception: pass
[docs] class IHaveChannel(ABC): """ Abstract interface for classes that have a channel attribute. Adds a property for the spin channel and the frequency notation. """ def __init__( self, channel: SpinChannel = SpinChannel.NONE, frequency_notation: FrequencyNotation = FrequencyNotation.PH ): """ Stores the spin channel and frequency notation of the object. :param channel: Spin channel of the object (see :class:`SpinChannel`). :param frequency_notation: Frequency notation of the object (see :class:`FrequencyNotation`). """ self._channel = channel self._frequency_notation = frequency_notation @property def channel(self) -> SpinChannel: """ Returns the spin channel of the object. For a set of available channels, see the enum :class:`SpinChannel`. :return: The current spin channel. """ return self._channel @channel.setter def channel(self, value: SpinChannel) -> None: """ Sets the spin channel of the object. For a set of available channels, see the enum :class:`SpinChannel`. :param value: The spin channel to set. :raises ValueError: If ``value`` is not a :class:`SpinChannel`. """ if not isinstance(value, SpinChannel): raise ValueError("Channel must be of type SpinChannel.") self._channel = value
[docs] def set_channel(self, channel: SpinChannel): """ Sets the spin channel of the object (chainable). For a set of available channels, see the enum :class:`SpinChannel`. :param channel: The spin channel to set. :return: ``self`` (for chaining). """ self.channel = channel return self
@property def frequency_notation(self) -> FrequencyNotation: """ Returns the frequency notation (not the channel reducibility) of the object. For a set of available frequency notations, see the enum :class:`FrequencyNotation`. :return: The current frequency notation. """ return self._frequency_notation @frequency_notation.setter def frequency_notation(self, value: FrequencyNotation) -> None: """ Sets the frequency notation of the object. For a set of available frequency notations, see the enum :class:`FrequencyNotation`. :param value: The frequency notation to set. :raises ValueError: If ``value`` is not a :class:`FrequencyNotation`. """ if not isinstance(value, FrequencyNotation): raise ValueError("Frequency notation must be of type FrequencyNotation.") self._frequency_notation = value
[docs] def set_frequency_notation(self, value: FrequencyNotation): """ Sets the frequency notation of the object (chainable). For a set of available frequency notations, see the enum :class:`FrequencyNotation`. :param value: The frequency notation to set. :return: ``self`` (for chaining). """ self.frequency_notation = value return self
[docs] class IAmNonLocal(IHaveMat, ABC): """ Abstract interface for objects that are momentum dependent. Since we focus on ladder objects, we do not need more than one momentum dimension for one- and two-particle quantities. """ def __init__(self, mat: np.ndarray, nq: tuple[int, int, int], has_compressed_q_dimension: bool = False): """ Stores the array, the momentum-grid size and the momentum-layout flag. :param mat: The underlying numpy array with one (compressed) or three (decompressed) leading momentum axes. :param nq: Number of momenta per spatial direction ``(nqx, nqy, nqz)``. :param has_compressed_q_dimension: Whether the momentum is stored as a single compressed axis ``[q, ...]`` (True) or as three separate axes ``[qx, qy, qz, ...]`` (False). """ IHaveMat.__init__(self, mat) self._nq = nq self._has_compressed_q_dimension = has_compressed_q_dimension @property def nq(self) -> tuple[int, int, int]: """ Returns the number of momenta in the object. This should always be equal to the k- or q-point grid of the lattice. :return: The number of momenta per spatial direction ``(nqx, nqy, nqz)``. """ return self._nq @property def nq_tot(self) -> int: """ Returns the total number of momenta in the object. This might be lower than np.prod(self.nq) if the object is currently saved in the irreducible Brillouin zone. :return: The total number of stored momenta. """ return ( int(self.nq[0] * self.nq[1] * self.nq[2]) if not self.has_compressed_q_dimension else self.original_shape[0] ) @property def has_compressed_q_dimension(self) -> bool: """ Returns whether the underlying matrix has a compressed momentum dimension [q,...] or not [qx,qy,qz,...]. :return: True if the momentum is stored as a single compressed axis. """ return self._has_compressed_q_dimension @property def n_bands(self) -> int: """ Returns the number of bands in the nonlocal two- or four-point object. If the object has a compressed momentum dimension, the array has dimension [q, o1, o2, ... ], otherwise it has dimension [qx, qy, qz, o1, o2, ... ]. :return: The number of bands (orbitals). """ return self.original_shape[1] if self.has_compressed_q_dimension else self.original_shape[3]
[docs] def shift_k_by_q(self, q: tuple | list[int] = (0, 0, 0)): r""" Shifts all momenta by the given values and returns a copy of the object with a decompressed momentum dimension. :param q: The momentum shift :math:`\vec{q}` as integer grid offsets ``(qx, qy, qz)``. :return: A copy shifted by :math:`\vec{q}`, in the same momentum-compression state as ``self``. """ copy = self._clone_without_mat() copy.mat = self.mat # shared reference; np.roll below allocates a fresh array, leaving self.mat untouched compress = False if copy.has_compressed_q_dimension: compress = True copy.decompress_q_dimension() copy.mat = np.roll(copy.mat, [-i for i in q], axis=(0, 1, 2)) if compress: copy.compress_q_dimension() return copy
[docs] def shift_k_by_pi(self): r""" Shifts all momenta by :math:`\pi` and returns the object with a decompressed momentum dimension. :return: A copy shifted by :math:`\pi` along every momentum axis, in the same compression state as ``self``. """ copy = self._clone_without_mat() copy.mat = self.mat # shared reference; np.roll below allocates a fresh array, leaving self.mat untouched compress = False if copy.has_compressed_q_dimension: compress = True copy.decompress_q_dimension() shifts = np.array(copy.current_shape[:3]) // 2 copy.mat = np.roll(copy.mat, shift=shifts, axis=(0, 1, 2)) if compress: copy.compress_q_dimension() return copy
[docs] def compress_q_dimension(self): """ Converts the object from [qx,qy,qz,...] to [q,...], where len(q) = qx*qy*qz. :return: ``self`` with a compressed momentum dimension (a no-op if already compressed). """ if self.has_compressed_q_dimension: return self self.mat = self.mat.reshape((self.nq_tot, *self.original_shape[3:])) self._has_compressed_q_dimension = True self.update_original_shape() return self
[docs] def decompress_q_dimension(self): """ Converts the object from [q,...] to [qx,qy,qz,...], where len(q) = qx*qy*qz. :return: ``self`` with a decompressed momentum dimension (a no-op if already decompressed). """ if not self.has_compressed_q_dimension: return self self.mat = self.mat.reshape((*self.nq, *self.current_shape[1:])) self._has_compressed_q_dimension = False self.update_original_shape() return self
[docs] def reduce_q(self, q_list: np.ndarray): r""" Reduces the object to the given list of momenta and returns a copy with a compressed momentum dimension. Acts like a filter. Makes it possible to use e.g. only the :math:`\vec{q}=0` component of a non-local object or filter the irreducible Brillouin zone from an object in the full Brillouin zone. :param q_list: Array of momentum grid indices to keep, shape ``[3, n_q]`` (one column per momentum). :return: A copy reduced to ``q_list``, with a compressed momentum dimension. """ copy = self._clone_without_mat() copy.mat = self.mat # shared reference; the boolean-mask indexing below copies, leaving self.mat untouched if copy.has_compressed_q_dimension: copy.decompress_q_dimension() # Build the keep-mask via flat indices instead of a (n_q, 3, nqx, nqy, nqz) broadcast comparison. ``q_list`` # has shape [n_q, 3] (one momentum per row). Out-of-range momenta are silently dropped (matching the previous # behaviour of yielding no match for them). shape3 = copy.current_shape[:3] q_arr = np.asarray(q_list) if q_arr.ndim != 2 or q_arr.shape[1] != 3: raise ValueError("q_list must have shape [n_q, 3].") coords = (q_arr[:, 0], q_arr[:, 1], q_arr[:, 2]) in_range = np.all([(c >= 0) & (c < s) for c, s in zip(coords, shape3)], axis=0) flat_idx = np.ravel_multi_index(tuple(c[in_range] for c in coords), shape3) mask = np.zeros(int(np.prod(shape3)), dtype=bool) mask[flat_idx] = True copy.mat = copy.mat.reshape((mask.size, *copy.current_shape[3:]))[mask] copy.update_original_shape() copy._has_compressed_q_dimension = True return copy
[docs] def find_q(self, q: tuple[int, int, int] = (0, 0, 0)): r""" Finds the matrix element for a single momentum :math:`\vec{q}` and returns a compressed copy. :param q: The momentum grid index ``(qx, qy, qz)`` to select. :return: A compressed copy containing only the requested momentum (``nq = (1, 1, 1)``). :raises ValueError: If no matrix element is found for the given momentum. """ q_arr = np.atleast_2d(np.array(q, dtype=int)) result = self.reduce_q(q_arr) # reduce_q already returns an independent copy; no extra deepcopy needed result._nq = (1, 1, 1) if getattr(result, "mat", None) is None or result.mat.size == 0 or result.current_shape[0] == 0: raise ValueError("No matrix element found for the given momentum.") return result
[docs] def filter_q_index(self, index: int = 0): r""" Filters the object to the given index of the momentum dimension and returns a copy. Acts like a filter. Makes it possible to use e.g. only the n-th q-component of a non-local object. :param index: The position along the compressed momentum axis to keep. :return: A compressed copy containing only that single momentum (``nq = (1, 1, 1)``). """ if not self.has_compressed_q_dimension: self.compress_q_dimension() # Copy only the single requested q-slice instead of deep-copying the whole multi-q array; ``.copy()`` so the # returned object does not keep the full parent array alive via a view. copy = self._clone_without_mat() copy.mat = self.mat[index][None, ...].copy() copy.update_original_shape() copy._nq = (1, 1, 1) return copy
[docs] def q_mean(self): r""" Averages over the momentum dimension and returns a copy of the object with nq = (1,1,1). :return: A copy holding the momentum average (``nq = (1, 1, 1)``). """ copy = self._clone_without_mat() if self.has_compressed_q_dimension: copy.mat = np.mean(self.mat, axis=0)[None, ...] else: copy.mat = np.mean(self.mat, axis=(0, 1, 2))[None, None, None, ...] copy.update_original_shape() copy._nq = (1, 1, 1) return copy
[docs] def interpolate_q_grid(self, nq_new: tuple[int, int, int], copy: bool = True): r""" Re-samples the object onto a new Gamma-centered momentum grid (:math:`\Gamma` at index 0) and returns a copy if specified. Up- and downsampling are both supported, per axis and in any combination. Returns in the same momentum compression state as the input. For each axis the method chooses automatically: * if the target is a sub-lattice of the source (``n_new`` divides ``n_old``), the exact :math:`\Sigma(\vec{q})` at the retained points is obtained by striding -- no band-limiting; * otherwise (upsampling or incommensurate downsampling) a band-limited Fourier interpolation (FFT -> pad/truncate spectrum -> iFFT, with symmetric Nyquist handling) is used. Requires a full-BZ object. A compressed dimension that is not the full BZ (e.g. IBZ-reduced) is rejected. :param nq_new: Target number of momenta per spatial direction ``(nqx, nqy, nqz)``. :param copy: If True, operate on and return a deep copy; if False, mutate and return ``self`` in place. :return: The re-gridded object in the same momentum-compression state as the input. :raises ValueError: If a target size is not a positive integer, or the object is compressed but not full-BZ. """ copy = deepcopy(self) if copy else self nq_new = tuple(int(n) for n in nq_new) if any(n < 1 for n in nq_new): raise ValueError("Target grid sizes must be positive integers.") if nq_new == copy.nq: return copy compress = False if copy.has_compressed_q_dimension: if copy.current_shape[0] != int(np.prod(copy.nq)): raise ValueError("Re-gridding requires a full-BZ object.") compress = True copy.decompress_q_dimension() for axis, n_new in enumerate(nq_new): n_old = copy.current_shape[axis] if n_new == n_old: continue if n_new < n_old and n_old % n_new == 0: copy.mat = np.take(copy.mat, np.arange(0, n_old, n_old // n_new), axis=axis) else: copy.mat = sp.signal.resample(copy.mat, n_new, axis=axis) copy._nq = nq_new copy.update_original_shape() return copy.compress_q_dimension() if compress else copy
[docs] def map_to_full_bz(self, k_grid: "KGrid", nq: tuple = None): """ Maps to full BZ using k_grid's inverse map and precomputed orbital rotation tensors. :param k_grid: The momentum grid carrying the irreducible-to-full-BZ map and per-k orbital rotations. :param nq: Optional override for the number of momenta; if None the object's own ``nq`` is used. :return: ``self`` expanded to the full BZ (four orbital dimensions transformed). """ return self._map_to_full_bz(k_grid, 4, nq)
def _map_to_full_bz(self, k_grid: "KGrid", num_orbital_dimensions: int, nq: tuple = None): r""" Maps the object from the irreducible to the full Brillouin zone. First expands the compressed IBZ momentum dimension to the full BZ by copying each IBZ value to all its symmetry-equivalent FBZ images via ``k_grid.irrk_inv``. Then applies the per-k orbital transformation stored on ``k_grid`` by ``specify_auto_symmetries(hk)``. The orbital transformation follows the ket/bra convention of the operator ordering :math:`G_{abcd} := \langle T[c_a c^\dagger_b c_c c^\dagger_d]\rangle` -- annihilation indices (positions 1, 3) transform with :math:`U`, creation indices (positions 2, 4) with :math:`U^\dagger` -- combined with a per-k antisymmetry sign :math:`\sigma_k` and an optional complex conjugation ``conj_k``: 2-index : :math:`M_{ab}(k) = \sigma_k \, U_{aa'} [M_{a'b'}(k_{rep})]^{[*conj_k]} U^\dagger_{b'b}` 4-index : :math:`M_{abcd}(k) = \sigma_k^2 \, U_{aa'} [M_{a'b'c'd'}(k_{rep})]^{[*conj_k]} U^\dagger_{b'b} U_{cc'} U^\dagger_{d'd}` If ``k_grid`` is not yet in auto mode (``specify_auto_symmetries`` has not been called), only the momentum expansion is performed and orbital indices are left unchanged. :param k_grid: The momentum grid carrying the irreducible-to-full-BZ map and per-k orbital rotations. :param num_orbital_dimensions: Number of orbital axes to transform; must be 2 or 4. :param nq: Optional override for the number of momenta; if None the object's own ``nq`` is used. :return: ``self`` expanded to the full BZ. :raises ValueError: If the object does not have a compressed momentum dimension. """ if not self.has_compressed_q_dimension: raise ValueError("Mapping to full BZ only possible for compressed momentum dimension.") assert num_orbital_dimensions in (2, 4), "Number of orbital dimensions must be 2 or 4." if nq is not None: self._nq = nq # Expand IBZ -> FBZ via the standard irrk_inv map (no orbital action yet). flat_inv = k_grid.irrk_inv.ravel() out_shape = (np.prod(self.nq), *self.current_shape[1:]) expanded = np.empty(out_shape, dtype=self.mat.dtype) np.take(self.mat, flat_inv, axis=0, out=expanded) self.mat = expanded # Apply per-k orbital transformation if auto-mode data is present. if getattr(k_grid, "is_auto", False): from dgamore import symmetry_reduction self.mat = symmetry_reduction.apply_auto_orbital_transform( self.mat, us=k_grid._auto_us.reshape(np.prod(k_grid.nk), *k_grid._auto_us.shape[3:]), sigmas=k_grid._auto_sigmas.reshape(-1), conjs=k_grid._auto_conjs.reshape(-1), num_orbital_dimensions=num_orbital_dimensions, ) self.update_original_shape() return self
[docs] def fft(self, copy: bool = True): """ Performs a discrete forward Fourier transform over the momentum dimension and returns a copy if specified. :param copy: If True, operate on and return a deep copy; if False, mutate and return ``self`` in place. :return: The Fourier-transformed object, in the same momentum-compression state as the input. """ if copy: return deepcopy(self).fft(copy=False) compress = False if self.has_compressed_q_dimension: compress = True self.decompress_q_dimension() # scipy.fft + overwrite_x transforms the complex64 array in place (no extra buffer). Do NOT switch to # numpy.fft.fftn: it computes in double precision internally and peaks at ~5x the array size. self.mat = sp.fft.fftn(self.mat, axes=(0, 1, 2), overwrite_x=True) return self.compress_q_dimension() if compress else self
[docs] def ifft(self, copy: bool = True): """ Performs a discrete inverse Fourier transform over the momentum dimension and returns a copy if specified. :param copy: If True, operate on and return a deep copy; if False, mutate and return ``self`` in place. :return: The inverse-Fourier-transformed object, in the same momentum-compression state as the input. """ if copy: return deepcopy(self).ifft(copy=False) compress = False if self.has_compressed_q_dimension: compress = True self.decompress_q_dimension() # scipy.fft + overwrite_x transforms the complex64 array in place (no extra buffer). Do NOT switch to # numpy.fft.ifftn: it computes in double precision internally and peaks at ~5x the array size. self.mat = sp.fft.ifftn(self.mat, axes=(0, 1, 2), overwrite_x=True) return self.compress_q_dimension() if compress else self
[docs] def flip_momentum_axis(self, copy: bool = True): r""" Flips the momentum axis :math:`F^{q}\to F^{-q}` of the object and returns a copy if specified. :param copy: If True, operate on and return a deep copy; if False, mutate and return ``self`` in place. :return: The momentum-flipped object, in the same momentum-compression state as the input. """ if copy: clone = self._clone_without_mat() clone.mat = self.mat # shared; flip/roll below allocate a fresh array, leaving self.mat untouched return clone.flip_momentum_axis(copy=False) compress = False if self.has_compressed_q_dimension: compress = True self.decompress_q_dimension() self.mat = np.roll(np.flip(self.mat, axis=(0, 1, 2)), shift=1, axis=(0, 1, 2)) return self.compress_q_dimension() if compress else self
def _align_q_dimensions_for_operations(self, other: "IAmNonLocal"): """ Helper method which adapts the frequency dimensions of two non-local objects to fit each other for addition or multiplication. :param other: The other non-local object to align with ``self``. :return: ``other``, compressed if necessary to match ``self``'s momentum layout. """ if not self.has_compressed_q_dimension and other.has_compressed_q_dimension: self.compress_q_dimension() if not other.has_compressed_q_dimension and self.has_compressed_q_dimension: other = other.compress_q_dimension() return other