"""
A module for handling lattice fluxes.
Boundary fluxes model magnetic flux threading through the holes of a torus
(periodic boundary conditions). When a particle hops across a periodic
boundary in direction :math:`\\mu`, it acquires a phase
:math:`\\exp(i \\phi_\\mu)`. In reciprocal space this is equivalent to
shifting the allowed k-points:
.. math::
f_\\mu \\;\\to\\; f_\\mu + \\frac{\\phi_\\mu}{2\\pi\\,L_\\mu},
where :math:`f_\\mu = n_\\mu / L_\\mu` is the standard fractional k-coordinate.
The flux values are always stored in **radians**.
--------------------------------
File : lattices/tools/lattice_flux.py
Author : Maksymilian Kliczkowski
Version : 1.0
Date : 2026-02-13
--------------------------------
"""
from dataclasses import dataclass
from typing import Mapping, Union, Optional, Dict, Tuple, TYPE_CHECKING
import numpy as np
# Runtime import – LatticeDirection is used in actual code paths, not just annotations.
from .lattice_tools import LatticeDirection
[docs]
@dataclass(frozen=True)
class BoundaryFlux:
"""
Collection of magnetic fluxes piercing lattice boundary loops.
The value associated with a direction is interpreted as the phase
``phi``
(in radians) acquired upon wrapping around the boundary once along that
direction. The corresponding hopping phase factor is ``exp(1j * phi)``.
The fluxes are stored as a mapping from :class:`LatticeDirection` to corresponding
complex phase values.
Options for specifying fluxes include:
- Uniform flux in all directions (single float value).
- Direction-specific fluxes (mapping from direction to float).
- Zero flux (empty mapping).
Physically, these fluxes correspond to magnetic fluxes threading
the holes of a torus formed by periodic boundary conditions.
Example:
>>> flux = BoundaryFlux({LatticeDirection.X: np.pi/2, LatticeDirection.Y: np.pi})
>>> flux.phase(LatticeDirection.X)
(6.123233995736766e-17+1j)
>>> flux.phase(LatticeDirection.Y)
(-1+0j)
>>> flux.is_trivial
False
For non-abelian gauge fields, more complex structures are needed.
"""
values : Mapping['LatticeDirection', float]
# ------------------------------------------------------------------
# Phase retrieval
# ------------------------------------------------------------------
[docs]
def phase(self, direction: 'LatticeDirection', winding: int = 1) -> complex:
"""
Return ``exp(1j * winding * phi_direction)``.
Parameters:
-----------
direction : LatticeDirection
The lattice direction for which to get the phase factor.
winding : int, optional
The winding number for the phase factor. Defaults to 1.
"""
phi = float(self.values.get(direction, 0.0))
return np.exp(1j * winding * phi)
[docs]
def phase_product(self, wx: int = 0, wy: int = 0, wz: int = 0) -> complex:
"""
Return total phase from combined winding numbers in all directions.
Returns :math:`\\exp(i (w_x \\phi_x + w_y \\phi_y + w_z \\phi_z))`.
"""
phi_total = wx * float(self.values.get(LatticeDirection.X, 0.0))
phi_total += wy * float(self.values.get(LatticeDirection.Y, 0.0))
phi_total += wz * float(self.values.get(LatticeDirection.Z, 0.0))
return np.exp(1j * phi_total)
[docs]
def get(self, direction: 'LatticeDirection') -> float:
"""Return raw flux (radians) for *direction*, defaulting to 0."""
return float(self.values.get(direction, 0.0))
# ------------------------------------------------------------------
# Topological / triviality queries
# ------------------------------------------------------------------
@property
def is_trivial(self) -> bool:
"""
``True`` when all fluxes are effectively zero (mod 2π).
A flux of exactly :math:`2\\pi n` (integer multiples) is considered
trivial because the hopping phase reduces to unity.
"""
tol = 1e-12
for phi in self.values.values():
# reduce modulo 2π into (-π, π]
reduced = (float(phi) + np.pi) % (2 * np.pi) - np.pi
if abs(reduced) > tol:
return False
return True
@property
def is_nontrivial(self) -> bool:
"""``True`` when any direction carries a non-zero flux (mod 2π)."""
return not self.is_trivial
@property
def total_flux(self) -> float:
"""Sum of all flux values (in radians)."""
return sum(float(v) for v in self.values.values())
# ------------------------------------------------------------------
# k-space shift
# ------------------------------------------------------------------
[docs]
def k_shift_fractions(self,
Lx: int = 1,
Ly: int = 1,
Lz: int = 1) -> Tuple[float, float, float]:
r"""
Return the *fractional* k-grid offset induced by the boundary fluxes.
With flux :math:`\phi_\mu` in direction :math:`\mu` of length
:math:`L_\mu`, the allowed Bloch momenta shift from
:math:`f_\mu = n_\mu / L_\mu` to
:math:`f_\mu + \phi_\mu / (2\pi L_\mu)`.
Returns
-------
(delta_fx, delta_fy, delta_fz) : tuple[float, float, float]
Fractional coordinate shifts (add to the standard grid).
"""
twopi = 2.0 * np.pi
dx = float(self.values.get(LatticeDirection.X, 0.0)) / (twopi * Lx) if Lx > 0 else 0.0
dy = float(self.values.get(LatticeDirection.Y, 0.0)) / (twopi * Ly) if Ly > 0 else 0.0
dz = float(self.values.get(LatticeDirection.Z, 0.0)) / (twopi * Lz) if Lz > 0 else 0.0
return (dx, dy, dz)
# ------------------------------------------------------------------
# Conversion helpers
# ------------------------------------------------------------------
[docs]
def as_dict(self) -> Dict[str, float]:
"""Return a plain ``{direction_name: flux}`` dictionary."""
return {d.name.lower(): float(v) for d, v in self.values.items()}
[docs]
def as_array(self) -> np.ndarray:
"""Return fluxes as ``[phi_x, phi_y, phi_z]`` array (radians)."""
return np.array([
float(self.values.get(LatticeDirection.X, 0.0)),
float(self.values.get(LatticeDirection.Y, 0.0)),
float(self.values.get(LatticeDirection.Z, 0.0)),
])
# ------------------------------------------------------------------
# Factory helpers
# ------------------------------------------------------------------
[docs]
@classmethod
def zero(cls) -> 'BoundaryFlux':
"""Create a ``BoundaryFlux`` with zero flux in all directions."""
return cls({d: 0.0 for d in LatticeDirection})
# ------------------------------------------------------------------
# Dunder helpers
# ------------------------------------------------------------------
[docs]
def __bool__(self) -> bool:
"""``True`` when the flux is non-trivial (has physical effect)."""
return self.is_nontrivial
def __repr__(self):
parts = []
for d in LatticeDirection:
phi = float(self.values.get(d, 0.0))
parts.append(f'{phi:.4f}')
return f"({','.join(parts)})"
def __str__(self):
return self.__repr__()
def _normalize_flux_dict(flux: Optional[Union[float, complex, 'BoundaryFlux', Mapping[Union[str, 'LatticeDirection'], float]]]) -> Optional[BoundaryFlux]:
"""
Normalize flux input into a :class:`BoundaryFlux` instance.
This function handles various input formats for flux specification, including:
- A single float value representing uniform flux in all directions.
- A single complex number representing a uniform complex phase in all directions.
- A mapping from direction (either as a string or a LatticeDirection) to float values.
- An existing :class:`BoundaryFlux` instance (returned as-is).
Allows to treat the value as a complex phase if a complex number is provided,
which can be useful for certain applications where the flux is naturally expressed
as a phase factor.
Parameters
----------
flux : float or complex or BoundaryFlux or Mapping or None
- If ``None``, return ``None`` (no twist).
- If a ``BoundaryFlux``, returned unchanged.
- If a float, interpreted as uniform flux (radians) in all directions.
- If a complex number, interpreted as a uniform complex phase
(``np.angle(flux)`` is used for all directions).
- If a mapping, keys can be :class:`LatticeDirection` members
or their string names (case-insensitive). Values are fluxes in radians.
Returns
-------
BoundaryFlux or None
"""
if flux is None:
return None # No flux specified, return None to indicate no twist
# Already a BoundaryFlux — pass through
if isinstance(flux, BoundaryFlux):
return flux
if isinstance(flux, (int, float)):
phi = float(flux)
return BoundaryFlux({direction: phi for direction in LatticeDirection})
if isinstance(flux, complex):
# Complex number → extract angle and apply uniformly
phase = np.angle(flux)
return BoundaryFlux({direction: phase for direction in LatticeDirection})
if isinstance(flux, Mapping):
out: Dict[LatticeDirection, float] = {}
# parse mapping
for key, value in flux.items():
if isinstance(key, LatticeDirection):
direction = key
elif isinstance(key, str):
try:
direction = LatticeDirection[key.upper()]
except KeyError as exc:
raise ValueError(f"Unknown lattice direction '{key}' for flux specification.") from exc
else:
raise TypeError(f"Unsupported flux key type: {type(key)!r}")
if isinstance(value, (int, float)):
out[direction] = float(value)
elif isinstance(value, complex):
out[direction] = np.angle(value)
else:
raise TypeError(f"Unsupported flux value type: {type(value)!r}")
return BoundaryFlux(out)
raise TypeError(f"Unsupported flux specification of type {type(flux)!r}.")
# -----------------------------------------------------------------------------------------------------------
#! END OF FILE
# ----------------------------------------------------------------------------------------------------------