Source code for astronomix.initial_condition_generation.turbulent_ic_generator

"""
Generate Gaussian random fields with a prescribed power-law spectrum.

Builds a real-valued 3D turbulent field by sampling complex Fourier
coefficients with a target amplitude scaling ``A0 * k^slope`` inside a
wavenumber band, enforcing Hermitian symmetry so the inverse transform is
real, and transforming back to real space. The implementation deliberately
avoids explicit meshgrid / index arrays (relying on broadcasting and roll /
flip tricks instead) to keep the memory footprint low at large resolution.
"""

# jax
import jax
import jax.numpy as jnp


def _cosine_taper(k, k0, k1):
    """Cosine roll-off factor in [0, 1]: 1 for ``k <= k0``, smoothly down to 0
    at ``k >= k1``.

    Args:
        k: The wavenumber magnitude (array) to taper.
        k0: The wavenumber at which the taper starts (factor still 1).
        k1: The wavenumber at which the taper reaches 0.

    Returns:
        The taper factor, same shape as ``k``.
    """
    # Guard against a degenerate band (k1 == k0): fall back to a hard cutoff.
    small = 1e-12
    if k1 <= k0 + small:
        return jnp.where(k <= k0, 1.0, 0.0)
    fraction = (k - k0) / (k1 - k0)
    fraction = jnp.clip(fraction, 0.0, 1.0)
    return jnp.where(k <= k0, 1.0, 0.5 * (1.0 + jnp.cos(jnp.pi * fraction)))


[docs] def create_turb_field( Ndim, A0, slope, kmin, kmax, key, sharding=None, kroll_frac=0.85, zero_mean=True, ): """ Generate a real Gaussian random field with a target amplitude scaling. This version is optimized for memory by avoiding explicit meshgrid / index arrays and using broadcasting and efficient array manipulations instead. Args: Ndim: The number of grid points along each of the three axes. A0: The amplitude prefactor of the power-law spectrum. slope: The spectral slope (``amplitude ~ A0 * k^slope``). kmin: The lower edge of the wavenumber band carrying power. kmax: The upper edge of the wavenumber band carrying power. key: The PRNG key used to sample the Fourier coefficients. sharding: An optional sharding applied to the large k-space arrays. kroll_frac: The fraction of ``kmax`` at which the cosine roll-off begins. zero_mean: Whether to remove the DC component so the field has zero mean. Returns: The real-valued turbulent field of shape ``(Ndim, Ndim, Ndim)``. """ # Integer wavenumbers laid out in FFT order [-N/2 .. N/2-1]. wavenumbers_1d = jnp.fft.fftfreq(Ndim, d=1.0) * Ndim # --- Memory Optimization 1: Broadcasting instead of meshgrid --- # Compute the k-space magnitude without materialising full kx, ky, kz # arrays; JAX broadcasts the 1D arrays to 3D during the operation. wavenumber_magnitude_squared = ( wavenumbers_1d.reshape(Ndim, 1, 1) ** 2 + wavenumbers_1d.reshape(1, Ndim, 1) ** 2 + wavenumbers_1d.reshape(1, 1, Ndim) ** 2 ) # Shard the first large array created; subsequent element-wise operations # inherit the sharding. if sharding is not None: wavenumber_magnitude_squared = jax.device_put( wavenumber_magnitude_squared, sharding ) wavenumber_magnitude = jnp.sqrt(wavenumber_magnitude_squared) # Optional roll-off: start tapering at kroll_frac * kmax and finish at kmax. k_roll_start = kroll_frac * kmax taper = _cosine_taper(wavenumber_magnitude, k_roll_start, kmax) # Restrict power to the requested k-band. band_mask = (wavenumber_magnitude >= kmin) & (wavenumber_magnitude <= kmax) # Set the target amplitude, avoiding division by zero at k = 0. wavenumber_magnitude_safe = jnp.where( wavenumber_magnitude == 0.0, 1.0, wavenumber_magnitude ) amplitude = A0 * (wavenumber_magnitude_safe**slope) if zero_mean: amplitude = amplitude.at[0, 0, 0].set(0.0) # Apply the band restriction and the taper. amplitude = amplitude * band_mask * taper # Sample complex Fourier coefficients with variance ~ amplitude^2. sigma = amplitude / jnp.sqrt(2.0) subkeys = jax.random.split(key, 2) real_part = jax.random.normal(subkeys[0], shape=wavenumber_magnitude.shape) * sigma imag_part = jax.random.normal(subkeys[1], shape=wavenumber_magnitude.shape) * sigma fourier_coeffs = real_part + 1j * imag_part # --- Memory Optimization 2: Enforce Hermitian symmetry without indices --- # The map F[k] -> conj(F[-k]) is, under the fftfreq convention, equivalent # to flipping all axes and rolling by one element. This is much cheaper than # a gather with explicit index arrays. fourier_conj_flipped = jnp.roll( jnp.flip(jnp.conj(fourier_coeffs), axis=(0, 1, 2)), shift=1, axis=(0, 1, 2), ) fourier_symmetric = 0.5 * (fourier_coeffs + fourier_conj_flipped) # --- Memory Optimization 3: Find self-conjugate modes without indices --- # Self-conjugate modes satisfy k_i = -k_i (mod N), occurring at indices 0 # and N/2 (for even N). Build the 3D mask by broadcasting a 1D mask. index_1d = jnp.arange(Ndim) self_conj_1d = index_1d == ((-index_1d) % Ndim) self_conj_mask = ( self_conj_1d.reshape(Ndim, 1, 1) & self_conj_1d.reshape(1, Ndim, 1) & self_conj_1d.reshape(1, 1, Ndim) ) # The self-conjugate modes must be purely real, so drop their imaginary part. fourier_symmetric = jnp.where( self_conj_mask, jnp.real(fourier_symmetric).astype(fourier_symmetric.dtype), fourier_symmetric, ) # Explicitly pin the DC component to a real zero when a zero mean is wanted. if zero_mean: fourier_symmetric = fourier_symmetric.at[0, 0, 0].set(0.0 + 0.0j) # Inverse transform back to real space; the imaginary part is zero by # construction up to round-off, so take the real part. real_space_field = jnp.real(jnp.fft.ifftn(fourier_symmetric)) return real_space_field