Source code for astronomix.analysis_helpers.energy_spectrum

"""
Shell-averaged energy, cross and helicity spectra of 3D vector fields.

Provides the FFT-based spectral diagnostics used by the snapshot machinery:
generic vector-field energy and cross spectra, plus the MHD physics wrappers
(density-weighted kinetic energy, magnetic energy, cross helicity, magnetic
helicity). All spectra share a single shell-binning of the wavenumber grid so
their bins line up.
"""

# general
import math

# jax
import jax.numpy as jnp


# ==========================================================================
#  Binning mode constants
# ==========================================================================

LOG_BINNING = 0       # Logarithmic bins — constant dk/k, smoothest at high k
INTEGER_BINNING = 1   # Integer mode shells — dk = 2*pi/L, good statistics per bin
PHYSICAL_BINNING = 2  # Physical wavenumber — dk = 1, finest resolution (default)


# ==========================================================================
#  Shared helpers
# ==========================================================================

def _wavenumber_bins(Nx, Ny, Nz, binning=PHYSICAL_BINNING):
    """
    Compute shell-binning indices for spectral accumulation.

    Args:
        Nx, Ny, Nz: Grid dimensions.
        binning: One of LOG_BINNING, INTEGER_BINNING, PHYSICAL_BINNING.

    Returns:
        bin_indices: Flat int32 shell-bin indices, shape (Nx*Ny*Nz,).
        number_of_bins: Number of shells.
        wavenumber_centers: Physical wavenumber bin centers, shape
            (number_of_bins,).
    """
    # Integer mode numbers along each axis (fftfreq with d = 1/N yields integers).
    freq_x = jnp.fft.fftfreq(Nx, d=1.0 / Nx)
    freq_y = jnp.fft.fftfreq(Ny, d=1.0 / Ny)
    freq_z = jnp.fft.fftfreq(Nz, d=1.0 / Nz)
    freq_grid_x, freq_grid_y, freq_grid_z = jnp.meshgrid(
        freq_x, freq_y, freq_z, indexing="ij"
    )

    # Magnitude of the integer mode vector at each grid point, and the largest
    # magnitude any mode can reach (the corner of the half-grid).
    mode_magnitude = jnp.sqrt(freq_grid_x**2 + freq_grid_y**2 + freq_grid_z**2)
    max_mode = math.sqrt((Nx // 2) ** 2 + (Ny // 2) ** 2 + (Nz // 2) ** 2)

    if binning == LOG_BINNING:
        k_min = 1.0
        k_max = max_mode
        number_of_bins = max(Nx, Ny, Nz) // 4
        log_edges = jnp.logspace(
            jnp.log10(k_min * 0.5), jnp.log10(k_max + 0.5), number_of_bins + 1
        )
        bin_indices = jnp.clip(
            jnp.digitize(mode_magnitude.ravel(), log_edges) - 1, 0, number_of_bins - 1
        )
        # Bin centers are the geometric mean of the edges, scaled to physical
        # wavenumber.
        wavenumber_centers = 2.0 * jnp.pi * jnp.sqrt(log_edges[:-1] * log_edges[1:])
        return bin_indices, number_of_bins, wavenumber_centers

    elif binning == INTEGER_BINNING:
        bin_indices = mode_magnitude.astype(jnp.int32).ravel()
        number_of_bins = int(max_mode) + 2
        wavenumber_centers = (jnp.arange(number_of_bins) + 0.5) * 2.0 * jnp.pi
        return bin_indices, number_of_bins, wavenumber_centers

    else:  # PHYSICAL_BINNING
        physical_wavenumber = 2.0 * jnp.pi * mode_magnitude
        bin_indices = physical_wavenumber.astype(jnp.int32).ravel()
        number_of_bins = int(2.0 * math.pi * max_mode) + 2
        wavenumber_centers = jnp.arange(number_of_bins) + 0.5
        return bin_indices, number_of_bins, wavenumber_centers


# ==========================================================================
#  Generic spectrum functions
# ==========================================================================

[docs] def vector_field_energy_spectrum(fx, fy, fz, energy_coeff=1.0, binning=PHYSICAL_BINNING): """ Energy spectrum E(k) of a vector field (fx, fy, fz). Satisfies: sum(E(k)) == mean(c * |f|^2) over the domain (shell-summed convention). Args: fx, fy, fz: Field components, each shaped (Nx, Ny, Nz). energy_coeff: Scalar multiplier c (default 1.0). binning: LOG_BINNING, INTEGER_BINNING, or PHYSICAL_BINNING. Returns: wavenumber_centers: Physical wavenumber bin centers. Ek: Energy spectrum per bin. Based on: https://qiauil.github.io/blog/2026/tke_spectrum/ """ Nx, Ny, Nz = fx.shape N_total = float(Nx * Ny * Nz) fx_hat = jnp.fft.fftn(fx) fy_hat = jnp.fft.fftn(fy) fz_hat = jnp.fft.fftn(fz) energy_fft = energy_coeff * ( jnp.abs(fx_hat) ** 2 + jnp.abs(fy_hat) ** 2 + jnp.abs(fz_hat) ** 2 ) / N_total**2 bin_indices, number_of_bins, wavenumber_centers = _wavenumber_bins( Nx, Ny, Nz, binning ) Ek = jnp.zeros(number_of_bins).at[bin_indices].add(energy_fft.ravel()) return wavenumber_centers, Ek
[docs] def vector_field_cross_spectrum(f1x, f1y, f1z, f2x, f2y, f2z, coeff=1.0, binning=PHYSICAL_BINNING): """ Cross spectrum C(k) between two vector fields f1 and f2. Satisfies: sum(C(k)) == mean(c * f1 . f2) over the domain. The spectrum is the shell-summed co-spectrum (real part of the conjugate dot product in Fourier space). Unlike energy spectra, cross spectra are not positive-definite. Args: f1x, f1y, f1z: First field components, each (Nx, Ny, Nz). f2x, f2y, f2z: Second field components, each (Nx, Ny, Nz). coeff: Scalar multiplier (default 1.0). binning: LOG_BINNING, INTEGER_BINNING, or PHYSICAL_BINNING. Returns: wavenumber_centers: Physical wavenumber bin centers. Ck: Cross spectrum per bin. """ Nx, Ny, Nz = f1x.shape N_total = float(Nx * Ny * Nz) f1x_hat = jnp.fft.fftn(f1x) f1y_hat = jnp.fft.fftn(f1y) f1z_hat = jnp.fft.fftn(f1z) f2x_hat = jnp.fft.fftn(f2x) f2y_hat = jnp.fft.fftn(f2y) f2z_hat = jnp.fft.fftn(f2z) cross_fft = coeff * jnp.real( jnp.conj(f1x_hat) * f2x_hat + jnp.conj(f1y_hat) * f2y_hat + jnp.conj(f1z_hat) * f2z_hat ) / N_total**2 bin_indices, number_of_bins, wavenumber_centers = _wavenumber_bins( Nx, Ny, Nz, binning ) Ck = jnp.zeros(number_of_bins).at[bin_indices].add(cross_fft.ravel()) return wavenumber_centers, Ck
# ========================================================================== # MHD physics wrappers # ==========================================================================
[docs] def get_kinetic_energy_spectrum(vx, vy, vz, rho, binning=PHYSICAL_BINNING): """ Kinetic energy spectrum with density weighting. Uses w = sqrt(rho) * u so that sum(E_k) == mean(0.5 * rho * |u|^2). Args: vx, vy, vz: Velocity components, each (Nx, Ny, Nz). rho: Density field, (Nx, Ny, Nz). binning: LOG_BINNING, INTEGER_BINNING, or PHYSICAL_BINNING. Returns: wavenumber_centers, Ek: Wavenumber bin centers and kinetic energy spectrum. """ rho_sqrt = jnp.sqrt(rho) return vector_field_energy_spectrum( rho_sqrt * vx, rho_sqrt * vy, rho_sqrt * vz, energy_coeff=0.5, binning=binning, )
[docs] def get_magnetic_energy_spectrum(Bx, By, Bz, mu0=1.0, binning=PHYSICAL_BINNING): """ Magnetic energy spectrum. sum(E_m) == mean(|B|^2 / (2 * mu0)). Args: Bx, By, Bz: Magnetic field components, each (Nx, Ny, Nz). mu0: Magnetic permeability (default 1.0 for Alfvénic units). binning: LOG_BINNING, INTEGER_BINNING, or PHYSICAL_BINNING. Returns: wavenumber_centers, Em: Wavenumber bin centers and magnetic energy spectrum. """ return vector_field_energy_spectrum( Bx, By, Bz, energy_coeff=1.0 / (2.0 * mu0), binning=binning, )
[docs] def get_cross_helicity_spectrum(vx, vy, vz, Bx, By, Bz, binning=PHYSICAL_BINNING): """ Cross-helicity spectrum H_c(k). Cross helicity: H_c = integral(u . B) dV. Spectrum satisfies: sum(H_c(k)) == mean(u . B). Args: vx, vy, vz: Velocity components, each (Nx, Ny, Nz). Bx, By, Bz: Magnetic field components, each (Nx, Ny, Nz). binning: LOG_BINNING, INTEGER_BINNING, or PHYSICAL_BINNING. Returns: wavenumber_centers, Hc: Wavenumber bin centers and cross-helicity spectrum. """ return vector_field_cross_spectrum( vx, vy, vz, Bx, By, Bz, coeff=1.0, binning=binning, )
[docs] def get_magnetic_helicity_spectrum(Bx, By, Bz, binning=PHYSICAL_BINNING): """ Magnetic helicity spectrum H_m(k). Magnetic helicity: H_m = integral(A . B) dV, where B = curl(A). In a periodic domain with Coulomb gauge, the vector potential is recovered in Fourier space as: A_hat(k) = +i k x B_hat(k) / |k|^2. Spectrum satisfies: sum(H_m(k)) == mean(A . B). Args: Bx, By, Bz: Magnetic field components, each (Nx, Ny, Nz). binning: LOG_BINNING, INTEGER_BINNING, or PHYSICAL_BINNING. Returns: wavenumber_centers, Hm: Wavenumber bin centers and magnetic helicity spectrum. """ Nx, Ny, Nz = Bx.shape N_total = float(Nx * Ny * Nz) Bx_hat = jnp.fft.fftn(Bx) By_hat = jnp.fft.fftn(By) Bz_hat = jnp.fft.fftn(Bz) # Physical wavevector components on the FFT grid. freq_x = jnp.fft.fftfreq(Nx, d=1.0 / Nx) freq_y = jnp.fft.fftfreq(Ny, d=1.0 / Ny) freq_z = jnp.fft.fftfreq(Nz, d=1.0 / Nz) freq_grid_x, freq_grid_y, freq_grid_z = jnp.meshgrid( freq_x, freq_y, freq_z, indexing="ij" ) kx = 2.0 * jnp.pi * freq_grid_x ky = 2.0 * jnp.pi * freq_grid_y kz = 2.0 * jnp.pi * freq_grid_z k_sq = kx**2 + ky**2 + kz**2 # Guard the k=0 mode against division by zero; its contribution is zeroed # out below, so the placeholder value is irrelevant. k_sq_safe = jnp.where(k_sq == 0, 1.0, k_sq) # Vector potential in Coulomb gauge: A_hat = +i (k x B_hat) / |k|^2, built # from the cross product k x B_hat component-wise. curl_x = ky * Bz_hat - kz * By_hat curl_y = kz * Bx_hat - kx * Bz_hat curl_z = kx * By_hat - ky * Bx_hat Ax_hat = +1j * curl_x / k_sq_safe Ay_hat = +1j * curl_y / k_sq_safe Az_hat = +1j * curl_z / k_sq_safe # Drop the k=0 mode: it carries no helicity and would otherwise pick up the # placeholder denominator above. Ax_hat = jnp.where(k_sq == 0, 0.0, Ax_hat) Ay_hat = jnp.where(k_sq == 0, 0.0, Ay_hat) Az_hat = jnp.where(k_sq == 0, 0.0, Az_hat) # Helicity density in Fourier space: Re(A_hat* . B_hat). helicity_fft = jnp.real( jnp.conj(Ax_hat) * Bx_hat + jnp.conj(Ay_hat) * By_hat + jnp.conj(Az_hat) * Bz_hat ) / N_total**2 # Accumulate the helicity density into wavenumber shells. bin_indices, number_of_bins, wavenumber_centers = _wavenumber_bins( Nx, Ny, Nz, binning ) Hm = jnp.zeros(number_of_bins).at[bin_indices].add(helicity_fft.ravel()) return wavenumber_centers, Hm