Source code for astronomix.data_classes.simulation_helper_data

"""
Geometric helper data for the simulation.

Bundles the precomputed per-cell geometry (cell centers, radii, volumes, cell
boundaries) used throughout the solver and the snapshot diagnostics, together
with the machinery to build only the fields the active subsystems actually need
(see :class:`HelperDataRequirements`), optionally on the host, and to pad /
shard / unpad them for the time integrator.
"""

# general
from contextlib import nullcontext
import math

# typing
from types import NoneType
from typing import NamedTuple, Union

# jax
import jax
from jax import NamedSharding
from jax.sharding import PartitionSpec
import jax.numpy as jnp

# astronomix constants
from astronomix.option_classes.simulation_config import (
    CARTESIAN,
    CYLINDRICAL,
    FINITE_VOLUME,
    SPHERICAL,
)

# astronomix containers
from astronomix.option_classes.simulation_config import (
    SimulationConfig,
    StaticFloatVector,
    StaticIntVector,
)

# astronomix functions
from astronomix._geometry.geometry import _center_of_volume, _r_hat_alpha


[docs] class HelperData(NamedTuple): """Helper data used throughout the simulation.""" #: The geometric centers of the cells. #: For Cartesian ``dimensionality > 1`` this is the full meshgrid of #: cell coordinates with shape ``(Nx, Ny[, Nz], dim)``. Subsystems #: that only need a single axis should prefer #: :attr:`cell_centers_x` / :attr:`cell_centers_y` / :attr:`cell_centers_z` #: so the full meshgrid does not need to be materialised. geometric_centers: jnp.ndarray = None #: The volumetric centers of the cells. #: Same as the geometric centers for Cartesian geometry. volumetric_centers: jnp.ndarray = None #: 1D arrays of cell centers along each Cartesian axis. #: Shape ``(N_axis + 2 * ngc,)``. These broadcast against the state #: array so consumers that only need one axis (e.g. the frame #: tracker) avoid materialising the full meshgrid. cell_centers_x: jnp.ndarray = None cell_centers_y: jnp.ndarray = None cell_centers_z: jnp.ndarray = None #: cell center to box center distances #: only for config.dimensionality > 1 r: jnp.ndarray = None #: A helper variable, defined as #: \hat{r}^\alpha = V_j / (2 * \alpha * \pi * \Delta r) #: with V_j the volume of cell j, \alpha the geometry factor #: and \Delta r the cell width. r_hat_alpha: jnp.ndarray = None #: The cell volumes. cell_volumes: jnp.ndarray = None #: Coordinates of the inner cell boundaries. inner_cell_boundaries: jnp.ndarray = None #: Coordinates of the outer cell boundaries. outer_cell_boundaries: jnp.ndarray = None
[docs] class HelperDataRequirements(NamedTuple): """Per-field requirements for :class:`HelperData`. Each flag indicates whether the matching :class:`HelperData` field is consumed by any active subsystem (solver, physics module, snapshot calculator). Fields whose flag is ``False`` are left unmaterialised and stay at the ``HelperData`` default of ``None``. """ needs_geometric_centers: bool = False needs_volumetric_centers: bool = False needs_cell_centers_x: bool = False needs_cell_centers_y: bool = False needs_cell_centers_z: bool = False needs_r: bool = False needs_r_hat_alpha: bool = False needs_cell_volumes: bool = False needs_inner_cell_boundaries: bool = False needs_outer_cell_boundaries: bool = False
def _helper_data_requirements(config: SimulationConfig) -> HelperDataRequirements: """Derive which :class:`HelperData` fields the configuration needs. Args: config: The simulation configuration. Returns: A :class:`HelperDataRequirements` whose flags are ``True`` only for the helper-data fields consumed by some currently active subsystem, so the builder can leave the rest unmaterialised. """ dimensionality = config.dimensionality curvilinear = config.geometry != CARTESIAN finite_volume = config.solver_mode == FINITE_VOLUME needs_geometric_centers = False needs_volumetric_centers = False needs_r_hat_alpha = False needs_cell_volumes = False needs_inner_cell_boundaries = False needs_outer_cell_boundaries = False needs_r = False # Finite-volume geometric source / reconstruction terms in # curvilinear geometry (1D spherical / cylindrical). if finite_volume and curvilinear: needs_geometric_centers = True needs_volumetric_centers = True needs_r_hat_alpha = True # 1D curvilinear get_helper_data also produces cell_volumes # and inner/outer boundaries; downstream code reads them in # the wind / cosmic-ray paths, gated by their own flags. # Stellar wind injection. if config.wind_config.stellar_wind: needs_cell_volumes = True needs_inner_cell_boundaries = True needs_outer_cell_boundaries = True if dimensionality > 1: needs_geometric_centers = True needs_r = True # Cosmic-ray diffusive shock acceleration. if config.cosmic_ray_config.diffusive_shock_acceleration: needs_geometric_centers = True needs_cell_volumes = True # Neural-network body force / cnn corrector positions. if config.neural_net_force_config.neural_net_force: needs_geometric_centers = True # Frame tracking (3D Cartesian) only needs the z-axis array. needs_cell_centers_x = False needs_cell_centers_y = False needs_cell_centers_z = False if config.frame_tracking: needs_cell_centers_z = True # Snapshot diagnostics. if config.return_snapshots: snapshot_settings = config.snapshot_settings volume_consumers = ( snapshot_settings.return_total_mass or snapshot_settings.return_total_energy or snapshot_settings.return_internal_energy or snapshot_settings.return_kinetic_energy or snapshot_settings.return_gravitational_energy ) if dimensionality == 1 and volume_consumers: needs_cell_volumes = True if snapshot_settings.return_radial_momentum: if dimensionality == 1: needs_cell_volumes = True else: needs_geometric_centers = True return HelperDataRequirements( needs_geometric_centers=needs_geometric_centers, needs_volumetric_centers=needs_volumetric_centers, needs_cell_centers_x=needs_cell_centers_x, needs_cell_centers_y=needs_cell_centers_y, needs_cell_centers_z=needs_cell_centers_z, needs_r=needs_r, needs_r_hat_alpha=needs_r_hat_alpha, needs_cell_volumes=needs_cell_volumes, needs_inner_cell_boundaries=needs_inner_cell_boundaries, needs_outer_cell_boundaries=needs_outer_cell_boundaries, ) def _normalize_config_vectors(config: SimulationConfig) -> SimulationConfig: """Promote scalar box_size / num_cells to StaticVectors and pick grid_spacing.""" if isinstance(config.box_size, float): config = config._replace( box_size=StaticFloatVector( config.box_size, config.box_size, config.box_size ) ) if isinstance(config.num_cells, int): config = config._replace( num_cells=StaticIntVector( config.num_cells, config.num_cells, config.num_cells ) ) grid_spacing_vec = config.box_size / config.num_cells if config.dimensionality == 1: config = config._replace(grid_spacing=grid_spacing_vec.x) elif config.dimensionality == 2: config = config._replace(grid_spacing=grid_spacing_vec.x) if not math.isclose(grid_spacing_vec.x, grid_spacing_vec.y): raise ValueError( "For now, we assume the grid spacing is the same in all dimensions. " f"Got grid spacing {grid_spacing_vec}." ) elif config.dimensionality == 3: config = config._replace(grid_spacing=grid_spacing_vec.x) if not ( math.isclose(grid_spacing_vec.x, grid_spacing_vec.y) and math.isclose(grid_spacing_vec.x, grid_spacing_vec.z) ): raise ValueError( "For now, we assume the grid spacing is the same in all dimensions. " f"Got grid spacing {grid_spacing_vec}." ) return config _ALL_FIELDS_REQUIRED = HelperDataRequirements( needs_geometric_centers=True, needs_volumetric_centers=True, needs_cell_centers_x=True, needs_cell_centers_y=True, needs_cell_centers_z=True, needs_r=True, needs_r_hat_alpha=True, needs_cell_volumes=True, needs_inner_cell_boundaries=True, needs_outer_cell_boundaries=True, )
[docs] def get_helper_data( config: SimulationConfig, sharding: Union[NoneType, NamedSharding] = None, padded: bool = False, requirements: Union[NoneType, HelperDataRequirements] = None, ) -> HelperData: """Generate the helper data for the simulation. Only the fields requested by ``requirements`` are materialised; the rest stay at the :class:`HelperData` default of ``None``. When ``requirements`` is ``None`` (the default), all fields are built so that scripts using ``get_helper_data`` to construct initial conditions keep working. The time integrator passes a config-derived :class:`HelperDataRequirements` explicitly so the in-simulation helper data is minimal. With ``config.host_helper_data=True`` the arrays are built inside a CPU device context and only the requested fields are moved onto the target device (respecting ``sharding`` for 3D Cartesian ``geometric_centers``). Fields needed only as intermediates (e.g. the full ``X, Y, Z`` meshgrid when only ``r`` is requested) never materialise on the accelerator. """ config = _normalize_config_vectors(config) if requirements is None: requirements = _ALL_FIELDS_REQUIRED if padded: ngc = config.num_ghost_cells else: ngc = 0 grid_spacing = config.grid_spacing host_build = config.host_helper_data if host_build: cpu_devices = jax.devices("cpu") if not cpu_devices: raise RuntimeError( "host_helper_data=True requires a CPU device to be available." ) build_ctx = jax.default_device(cpu_devices[0]) else: build_ctx = nullcontext() with build_ctx: helper_data = _build_helper_data(config, requirements, ngc, grid_spacing) if host_build: helper_data = _move_helper_data_to_device(helper_data, sharding) else: # Match the original behaviour: apply sharding to the 3D Cartesian # geometric_centers if requested. helper_data = _apply_sharding(helper_data, sharding, config) return helper_data
def _build_helper_data( config: SimulationConfig, requirements: HelperDataRequirements, ngc: int, grid_spacing: float, ) -> HelperData: """Compute only the fields the requirements ask for. Intermediates (e.g. the X, Y, Z meshgrid used to derive ``r``) are held as locals; they are not stored in the returned struct unless explicitly requested. """ fields = {} if config.geometry == SPHERICAL or config.geometry == CYLINDRICAL: # 1D curvilinear geometry. r_axis = jnp.linspace( grid_spacing / 2 - ngc * grid_spacing, config.box_size.x + grid_spacing / 2 + ngc * grid_spacing, config.num_cells.x + 2 * ngc, endpoint=False, ) if requirements.needs_geometric_centers: fields["geometric_centers"] = r_axis if requirements.needs_cell_centers_x: fields["cell_centers_x"] = r_axis if requirements.needs_inner_cell_boundaries: fields["inner_cell_boundaries"] = r_axis - grid_spacing / 2 if requirements.needs_outer_cell_boundaries: fields["outer_cell_boundaries"] = r_axis + grid_spacing / 2 # r_hat_alpha is a building block for both volumetric_centers and # cell_volumes; compute it lazily. r_hat_alpha = None if ( requirements.needs_r_hat_alpha or requirements.needs_volumetric_centers or requirements.needs_cell_volumes ): r_hat_alpha = _r_hat_alpha(r_axis, grid_spacing, config.geometry) if requirements.needs_volumetric_centers: fields["volumetric_centers"] = _center_of_volume( r_axis, grid_spacing, config.geometry ) if requirements.needs_r_hat_alpha: fields["r_hat_alpha"] = r_hat_alpha if requirements.needs_cell_volumes: fields["cell_volumes"] = ( 2 * config.geometry * jnp.pi * grid_spacing * r_hat_alpha ) return HelperData(**fields) # Cartesian geometry. if config.dimensionality == 1: r_axis = jnp.linspace( grid_spacing / 2 - ngc * grid_spacing, config.box_size.x - grid_spacing / 2 + ngc * grid_spacing, config.num_cells.x + 2 * ngc, ) if requirements.needs_geometric_centers: fields["geometric_centers"] = r_axis if requirements.needs_volumetric_centers: fields["volumetric_centers"] = r_axis if requirements.needs_cell_centers_x: fields["cell_centers_x"] = r_axis if requirements.needs_r_hat_alpha: fields["r_hat_alpha"] = grid_spacing * jnp.ones_like(r_axis) if requirements.needs_cell_volumes: fields["cell_volumes"] = grid_spacing * jnp.ones_like(r_axis) if requirements.needs_inner_cell_boundaries: fields["inner_cell_boundaries"] = r_axis - grid_spacing / 2 if requirements.needs_outer_cell_boundaries: fields["outer_cell_boundaries"] = r_axis + grid_spacing / 2 return HelperData(**fields) # Cartesian dimensionality > 1. # Build the 1D axis arrays lazily and only the ones we need. box_sizes = (config.box_size.x, config.box_size.y, config.box_size.z) cell_counts = (config.num_cells.x, config.num_cells.y, config.num_cells.z) need_centers = requirements.needs_geometric_centers need_vol_centers = requirements.needs_volumetric_centers need_r = requirements.needs_r need_meshgrid = need_centers or need_vol_centers or need_r per_axis_requested = ( requirements.needs_cell_centers_x, requirements.needs_cell_centers_y, requirements.needs_cell_centers_z, )[: config.dimensionality] # The 1D per-axis arrays are needed either when explicitly requested # or as building blocks for the meshgrid. axis_needed = [need_meshgrid or pa for pa in per_axis_requested] axis_arrays = [] for size, ncells, needed in zip( box_sizes[: config.dimensionality], cell_counts[: config.dimensionality], axis_needed, ): if needed: axis_arrays.append( jnp.linspace( grid_spacing / 2 - ngc * grid_spacing, size + grid_spacing / 2 + ngc * grid_spacing, ncells + 2 * ngc, endpoint=False, ) ) else: axis_arrays.append(None) if requirements.needs_cell_centers_x and axis_arrays[0] is not None: fields["cell_centers_x"] = axis_arrays[0] if config.dimensionality >= 2 and requirements.needs_cell_centers_y and axis_arrays[1] is not None: fields["cell_centers_y"] = axis_arrays[1] if config.dimensionality >= 3 and requirements.needs_cell_centers_z and axis_arrays[2] is not None: fields["cell_centers_z"] = axis_arrays[2] if not need_meshgrid: return HelperData(**fields) geometric_centers = jnp.array(jnp.meshgrid(*axis_arrays, indexing="ij")) geometric_centers = jnp.moveaxis(geometric_centers, 0, -1) if need_centers: fields["geometric_centers"] = geometric_centers if need_vol_centers: # In Cartesian, volumetric centers coincide with geometric centers. fields["volumetric_centers"] = geometric_centers if need_r: if config.dimensionality == 2: box_center = jnp.array( [config.box_size.x / 2, config.box_size.y / 2] ) else: box_center = jnp.array( [config.box_size.x / 2, config.box_size.y / 2, config.box_size.z / 2] ) fields["r"] = jnp.linalg.norm( geometric_centers - box_center, axis=-1 ) return HelperData(**fields) def _apply_sharding( helper_data: HelperData, sharding: Union[NoneType, NamedSharding], config: SimulationConfig, ) -> HelperData: """Apply the user-provided sharding to the 3D Cartesian geometric_centers.""" if sharding is None: return helper_data if config.geometry != CARTESIAN or config.dimensionality != 3: return helper_data if helper_data.geometric_centers is None: return helper_data # primitive_state is (vars, X, Y, Z); geometric_centers is (X, Y, Z, vec). # Reusing the primitive-state PartitionSpec positionally would put the # vars-axis mesh assignment on the X-axis of geometric_centers and shift # the spatial assignments by one — i.e. Y would inherit XAXIS (split) # while X would inherit VARAXIS (replicated), breaking co-location with # primitive_state. Drop the leading vars entry and pad with None for # the trailing vector index so the spatial axes line up. spatial_spec = PartitionSpec(*sharding.spec[1:4], None) centers_sharding = NamedSharding(sharding.mesh, spatial_spec) centers = jax.lax.with_sharding_constraint( helper_data.geometric_centers, centers_sharding ) replaced = {"geometric_centers": centers} if helper_data.volumetric_centers is not None: # volumetric_centers points at the same array in Cartesian. replaced["volumetric_centers"] = centers return helper_data._replace(**replaced) def _move_helper_data_to_device( helper_data: HelperData, sharding: Union[NoneType, NamedSharding], ) -> HelperData: """Move only materialised fields onto the target device. With ``sharding`` provided, ``geometric_centers`` / ``volumetric_centers`` are placed with that sharding; the remaining 1D arrays are replicated via ``jax.device_put``. """ moved = {} for name, value in helper_data._asdict().items(): if value is None: continue if sharding is not None and name in ( "geometric_centers", "volumetric_centers", ): moved[name] = jax.device_put(value, sharding) else: moved[name] = jax.device_put(value) return HelperData(**moved) def _unpad_helper_data( helper_data: HelperData, config: SimulationConfig, ) -> HelperData: """Return a view of ``helper_data`` with ghost-cell slices removed. Used inside the time integrator to compute snapshot diagnostics on the physical domain without keeping a separate unpadded helper-data struct around. Slicing is a free operation under jit. """ ngc = config.num_ghost_cells if ngc == 0: return helper_data fields = {} for name, value in helper_data._asdict().items(): if value is None: continue fields[name] = _slice_ghost(value, ngc, config.dimensionality) return HelperData(**fields) def _slice_ghost(value: jnp.ndarray, ngc: int, dimensionality: int) -> jnp.ndarray: """Strip ngc ghost cells from each spatial axis. Spatial axes are the leading ``dimensionality`` axes for the Cartesian fields (e.g. shape ``(Nx+2g, Ny+2g, Nz+2g, 3)``); 1D curvilinear fields are simple ``(N+2g,)`` arrays. """ slicer = [slice(None)] * value.ndim for axis in range(min(dimensionality, value.ndim)): slicer[axis] = slice(ngc, value.shape[axis] - ngc) return value[tuple(slicer)]