Source code for astronomix.analysis_helpers.jacobians

"""
Helpers for computing Jacobians of the simulator.

For a base state that depends only on ``y``, the streamwise (``x``) direction is
translation-invariant, so different ``x``-Fourier modes decouple in the
linearised problem. These helpers assemble the dense Jacobian of a single
``+kx`` Fourier block — either of the spatial RHS or of the time-integrated
simulator — column-by-column in small batches, avoiding the cost of forming the
full grid-sized Jacobian.
"""

# jax
import jax
import jax.numpy as jnp

# astronomix functions
from astronomix._fluid_equations._equations import conserved_state_from_primitive
from astronomix.analysis_helpers._exposed_rhs import _exposed_rhs
from astronomix.option_classes.simulation_config import finalize_config
from astronomix.time_stepping import time_integration


[docs] def single_xmode_rhs_jacobian2D( primitive_state_unperturbed, config, params, registered_variables, helper_data, wavelength, assembly_batch_size=4, ): """ Return the dense +kx Fourier-block Jacobian of the simulator RHS. The full semi-discrete RHS is dU/dt = R(U), where U is the conserved state on the full 2D grid. The full Jacobian dR/dU has size (nvar * Nx * Ny) x (nvar * Nx * Ny), which is usually too large to form explicitly. If the unperturbed state U0 depends only on y, the x-direction is translation-invariant and different streamwise Fourier modes decouple in the linearized problem. We restrict the perturbation to one Fourier mode, dU(x, y) = qhat(y) exp(i kx x), with qhat(y) in C^(nvar * Ny). The returned matrix represents L_k qhat = P_k J[ qhat(y) exp(i kx x) ], where J = dR/dU evaluated at U0 and P_k projects back onto the +kx Fourier mode. A complex Fourier amplitude is just a compact representation of sine and cosine perturbations: Re[qhat exp(i kx x)] = Re(qhat) cos(kx x) - Im(qhat) sin(kx x). Since the simulator RHS is real-valued, the complex tangent action is built from two real JVPs: J Re[qhat exp(i kx x)] + i J Im[qhat exp(i kx x)]. Unlike a direct jacfwd over the real-packed vector [Re(qhat), Im(qhat)], this function assembles the complex Jacobian column-by-column in small batches. This avoids materializing all tangent directions at once. Args: primitive_state_unperturbed: Primitive base state. It is converted to conserved variables before linearization. config: Simulation config. The x-boundary should be periodic. params: Simulation parameters. registered_variables: Variable registry. helper_data: Grid/helper data containing cell centers. wavelength: Physical wavelength of the x-Fourier perturbation. Must be commensurate with the x-domain length. assembly_batch_size: Number of Jacobian columns to compute per JVP batch. Lower this if GPU memory is still tight. Returns: J_complex: Complex array of shape (nvar * Ny, nvar * Ny), representing the raw +kx Fourier-block tangent operator of the implemented simulator RHS. """ config = finalize_config(config, primitive_state_unperturbed.shape) conserved_base_state = conserved_state_from_primitive( primitive_state_unperturbed, params.gamma, config, registered_variables, ) real_dtype = conserved_base_state.dtype complex_dtype = jnp.complex128 if real_dtype == jnp.float64 else jnp.complex64 nvar, Nx, Ny = conserved_base_state.shape n = nvar * Ny Lx = float(config.box_size.x) m_float = Lx / float(wavelength) m = int(round(m_float)) if abs(m_float - m) > 1e-12: raise ValueError( f"wavelength={wavelength} is not commensurate with Lx={Lx}. " f"Got Lx / wavelength = {m_float}, not an integer." ) kx = 2.0 * jnp.pi * m / Lx X = helper_data.geometric_centers[:, :, 0] phase = jnp.exp(1j * kx * X).astype(complex_dtype) phase_conj = jnp.conj(phase) def rhs(q): return _exposed_rhs(q, params, config, registered_variables) def rhs_jvp(dq_real): _, Jdq = jax.jvp(rhs, (conserved_base_state,), (dq_real,)) return Jdq def apply_one_real_column(qhat_flat_real): """ Apply L_k to one real basis vector qhat. Because L_k is complex-linear, applying it to all real basis vectors gives all columns of the complex matrix L_k = A + iB. """ qhat = qhat_flat_real.reshape(nvar, Ny).astype(complex_dtype) dq_complex = qhat[:, None, :] * phase[None, :, :] Jdq_complex = ( rhs_jvp(jnp.real(dq_complex).astype(real_dtype)) + 1j * rhs_jvp(jnp.imag(dq_complex).astype(real_dtype)) ) Jhat = jnp.mean( Jdq_complex * phase_conj[None, :, :], axis=1, ) return Jhat.reshape(-1) apply_batch = jax.jit(jax.vmap(apply_one_real_column)) column_blocks = [] for start in range(0, n, assembly_batch_size): stop = min(start + assembly_batch_size, n) cols = jnp.arange(start, stop) basis_batch = jax.nn.one_hot(cols, n, dtype=real_dtype) # Shape: (batch, n). Each row is one output column. out_batch = apply_batch(basis_batch) # Store as matrix columns. column_blocks.append(jnp.swapaxes(out_batch, 0, 1)) J_complex = jnp.concatenate(column_blocks, axis=1) return J_complex
[docs] def single_xmode_jacobian2Dt( primitive_state_unperturbed, config, params, registered_variables, helper_data, wavelength, assembly_batch_size=4, ): r""" Return the dense +kx Fourier-block Jacobian of the time-integrated simulator. This is the time-integration analogue of :func:`single_xmode_rhs_jacobian2D`: instead of linearising the instantaneous RHS ``R(U)`` it linearises the full time integration over ``[t_start, t_end]``. Because the time map amplifies growing modes, this can bring out the eigenmodes with large ``Re(\lambda)`` more cleanly than the RHS Jacobian. The complex ``+kx`` block is assembled column-by-column from real JVPs exactly as in the RHS variant. Args: primitive_state_unperturbed: Primitive base state to linearise about. config: Simulation config. The x-boundary should be periodic. params: Simulation parameters. registered_variables: Variable registry. helper_data: Grid/helper data containing cell centers. wavelength: Physical wavelength of the x-Fourier perturbation. Must be commensurate with the x-domain length. assembly_batch_size: Number of Jacobian columns to compute per JVP batch. Lower this if GPU memory is tight. Returns: J_complex: Complex array of shape (nvar * Ny, nvar * Ny), the raw +kx Fourier-block tangent operator of the time-integrated simulator. """ config = finalize_config(config, primitive_state_unperturbed.shape) real_dtype = primitive_state_unperturbed.dtype complex_dtype = jnp.complex128 if real_dtype == jnp.float64 else jnp.complex64 nvar, Nx, Ny = primitive_state_unperturbed.shape n = nvar * Ny Lx = float(config.box_size.x) m_float = Lx / float(wavelength) m = int(round(m_float)) if abs(m_float - m) > 1e-12: raise ValueError( f"wavelength={wavelength} is not commensurate with Lx={Lx}. " f"Got Lx / wavelength = {m_float}, not an integer." ) kx = 2.0 * jnp.pi * m / Lx X = helper_data.geometric_centers[:, :, 0] phase = jnp.exp(1j * kx * X).astype(complex_dtype) phase_conj = jnp.conj(phase) def rhs(q): return time_integration(q, config, params, registered_variables) def rhs_jvp(dq_real): _, Jdq = jax.jvp(rhs, (primitive_state_unperturbed,), (dq_real,)) return Jdq def apply_one_real_column(qhat_flat_real): """ Apply L_k to one real basis vector qhat. Because L_k is complex-linear, applying it to all real basis vectors gives all columns of the complex matrix L_k = A + iB. """ qhat = qhat_flat_real.reshape(nvar, Ny).astype(complex_dtype) dq_complex = qhat[:, None, :] * phase[None, :, :] Jdq_complex = ( rhs_jvp(jnp.real(dq_complex).astype(real_dtype)) + 1j * rhs_jvp(jnp.imag(dq_complex).astype(real_dtype)) ) Jhat = jnp.mean( Jdq_complex * phase_conj[None, :, :], axis=1, ) return Jhat.reshape(-1) apply_batch = jax.jit(jax.vmap(apply_one_real_column)) column_blocks = [] for start in range(0, n, assembly_batch_size): stop = min(start + assembly_batch_size, n) cols = jnp.arange(start, stop) basis_batch = jax.nn.one_hot(cols, n, dtype=real_dtype) # Shape: (batch, n). Each row is one output column. out_batch = apply_batch(basis_batch) # Store as matrix columns. column_blocks.append(jnp.swapaxes(out_batch, 0, 1)) J_complex = jnp.concatenate(column_blocks, axis=1) return J_complex