Source code for grids.mixins.core

"""
Core mixins for the PyMetric grid infrastructure.

This module contains mixin classes that are included (by default) in the core :py:class:`~grids.base.GridBase`
class and are therefore structural elements of the base class. These are primarily a method
for ensuring that code structure is coherent and readable on the developer end. Mixins manage

- Coordinate system introspection and axis normalization
- Unit system access and serialization
- Ghost zone and halo management
- Chunking behavior and iteration
- HDF5 input/output operations
- Index validation and vectorized access utilities

Each mixin expects the parent class to implement specific attributes (e.g., ``__cs__``, ``__dd__``,
``__ghost_dd__``, etc.) that are required for it to function correctly. These mixins follow a
minimal and declarative style, avoiding unnecessary side effects or object instantiation.

Each mixin is typed with ``Generic[...]`` bounds on their expected protocols.
"""
from pathlib import Path
from typing import (
    TYPE_CHECKING,
    Any,
    Generic,
    List,
    Literal,
    Optional,
    Sequence,
    Tuple,
    TypeVar,
    Union,
)

import h5py
import numpy as np

from pymetric.grids.utils._typing import DomainDimensions
from pymetric.utilities.arrays import normalize_index

if TYPE_CHECKING:
    from matplotlib.pyplot import Axes

    from pymetric.coordinates.base import _CoordinateSystemBase
    from pymetric.grids.mixins._typing import (
        _SupportsGridChunking,
        _SupportsGridCore,
        _SupportsGridIO,
    )
    from pymetric.grids.utils._typing import (
        AxesInput,
        ChunkSizeInput,
        GhostZonesInput,
        HaloOffsetInput,
        IndexInput,
    )

# =================================== #
# Type Annotations                    #
# =================================== #
# These type annotations are used for compatibility
# with static type checkers like mypy.
_SupGridCore = TypeVar("_SupGridCore", bound="_SupportsGridCore")
_SupGridIO = TypeVar("_SupGridIO", bound="_SupportsGridIO")
_SupGridChunking = TypeVar("_SupGridChunking", bound="_SupportsGridChunking")


# =================================== #
# Mixin Classes                       #
# =================================== #
# These classes form core mixins for the base
# grid types and are used primarily for development
# organization so that the base class does not become
# over cluttered.
[docs] class GridUtilsMixin(Generic[_SupGridCore]): """ Grid mixin class for supporting various utilities, validators, etc. for use in grid subclasses. """ # ================================ # # Input Coercion Methods # # ================================ # # These methods provide support for coercing various # common inputs like chunks, axes, halo offsets, etc. def _standardize_chunk_size( self: _SupGridCore, chunk_size: Optional["ChunkSizeInput"] ) -> DomainDimensions: """ Normalize and validate a chunk size specification. Parameters ---------- chunk_size : int or sequence of int Chunk size per axis. If an int, it is broadcasted to all axes. Returns ------- DomainDimensions A tuple-like object (typically a NumPy array) of length ndim, specifying the chunk size along each axis. Raises ------ ValueError If chunk sizes do not evenly divide the domain shape. TypeError If input format is invalid. """ ndim = self.ndim dd = np.asarray(self.__dd__) # active grid dimensions (excluding ghost zones) # Convert chunk_size to ndarray of shape (ndim,) if isinstance(chunk_size, int): chunks = np.full(ndim, chunk_size, dtype=int) elif isinstance(chunk_size, Sequence): chunks = np.asarray(chunk_size, dtype=int) if chunks.shape != (ndim,): raise ValueError( f"chunk_size must be scalar or sequence of length {ndim}, got shape {chunks.shape}" ) else: raise TypeError( f"chunk_size must be int or sequence of ints, got {type(chunk_size)}" ) # Check for divisibility if np.any(dd % chunks != 0): raise ValueError( f"chunk_size {chunks.tolist()} does not evenly divide grid shape {dd.tolist()}." ) return DomainDimensions(chunks) @staticmethod def _standardize_halo_offset( halo_offsets: Optional["HaloOffsetInput"], num_axes: int ) -> np.ndarray: """ Normalize user input into a halo offset array of shape (2, naxes). This utility is used internally by methods like `get_slice_from_chunks` to convert various halo specification formats into a consistent `(2, naxes)` array of integer offsets. Parameters ---------- halo_offsets : int, list[int], or np.ndarray, optional - int: symmetric halo on all sides of all axes. - 1D array (length = num_axes): symmetric halo per axis. - 2D array of shape (2, num_axes): explicit (left, right) per axis. - 2D array of shape (num_axes, 2): same, auto-transposed. - None: no halo. num_axes : int Number of axes the halo should apply to. Returns ------- np.ndarray A (2, naxes) array of integer offsets, with [0] = left, [1] = right. Raises ------ ValueError If the input shape is invalid or contains non-integers. """ if halo_offsets is None: return np.zeros((2, num_axes), dtype=int) halo = np.asarray(halo_offsets) if halo.ndim == 0: # Scalar → symmetric halo for all sides, all axes return np.full((2, num_axes), int(halo), dtype=int) if halo.ndim == 1: if halo.shape[0] != num_axes: raise ValueError( f"1D halo must have length {num_axes}, got {halo.shape[0]}." ) return np.tile(halo, (2, 1)).astype(int) if halo.ndim == 2: if halo.shape == (2, num_axes): return halo.astype(int) elif halo.shape == (num_axes, 2): return halo.T.astype(int) else: raise ValueError( f"2D halo must have shape (2, {num_axes}) or ({num_axes}, 2), got {halo.shape}." ) raise ValueError( f"Invalid halo specification: expected scalar, 1D (len={num_axes}), " f"or 2D shape (2, {num_axes}) or ({num_axes}, 2). Got shape {halo.shape}." ) @staticmethod def _standardize_ghost_zones( ghost_zones: Union["GhostZonesInput", None], num_axes: int ) -> np.ndarray: """ Normalize input into a ghost zone array of shape (2, num_axes). This method standardizes user input for ghost zone sizes along each axis. Valid inputs include: - A single integer (symmetric ghost zone on all sides of all axes) - A sequence or array of length `num_axes` (symmetric per axis) - A 2D array of shape (2, num_axes) (explicit left/right per axis) Parameters ---------- ghost_zones : int, list[int], np.ndarray or None The ghost zone specification to standardize. num_axes : int Number of grid axes the ghost zone specification should apply to. Returns ------- np.ndarray A (2, num_axes) array where row 0 is the number of ghost cells on the lower (left) side of each axis, and row 1 is for the upper (right) side. Raises ------ ValueError If the input shape is invalid or contains the wrong number of elements. """ if ghost_zones is None: return np.zeros((2, num_axes), dtype=int) gz = np.asarray(ghost_zones, dtype=int) if gz.ndim == 0: # Scalar: same for all sides and axes return np.full((2, num_axes), int(gz), dtype=int) if gz.ndim == 1: if gz.shape[0] != num_axes: raise ValueError( f"1D ghost_zones must have length {num_axes}, got {gz.shape[0]}." ) return np.tile(gz, (2, 1)).astype(int) if gz.ndim == 2 and gz.shape == (2, num_axes): return gz.astype(int) raise ValueError( f"Invalid ghost_zones specification: expected scalar, " f"1D array of length {num_axes}, or shape (2, {num_axes}). Got shape {gz.shape}." )
[docs] def standardize_axes( self: _SupGridCore, axes: Optional["AxesInput"] = None ) -> List[str]: """ Standardize the axes provided. This will ensure that all axes are valid axes and that the axes are ordered canonically. Parameters ---------- axes: list of str The axes specification to standardize. Returns ------- list of str The standardized axes. """ # Fill axes if it is None. if axes is None: axes = self.axes # Reorder the axes to match the canonical # ordering. axes = self.__cs__.order_axes_canonical(axes) return list(axes)
def _standardize_index( self: _SupGridCore, index: "IndexInput", axes: Optional["AxesInput"] = None, origin: Literal["active", "global"] = "active", ) -> Tuple[int, ...]: """ Normalize and validate a grid index for a subset of axes. Parameters ---------- index : int, tuple[int, ...], or array-like Index to validate. May be flat, tuple-form, or array-like. axes : sequence of str or str, optional Subset of axes being accessed. If None, all axes are assumed. origin : {"active", "global"}, default="active" Whether the index is relative to the active domain or the global domain. Returns ------- tuple of int A tuple-form index in global coordinates. Raises ------ IndexError If the index is out of bounds. TypeError If the index is malformed. """ # Manage the axes and the number of axes. These # determine the number of expected indices. axes = self.standardize_axes(axes) axis_indices = self.coordinate_system.convert_axes_to_indices(axes) num_axes = len(axis_indices) # Perform the type coercion on the index # to ensure that it is the correct type casting. if isinstance(index, int): index = (index,) elif isinstance(index, Sequence): index = tuple(index) else: raise TypeError( f"Index must be int, tuple[int,...], or array-like, got {type(index)}" ) # Extract the shape depending on the origin parameter # and then start performing checks. if origin == "active": shape = self.__dd__[axis_indices] elif origin == "global": shape = self.__ghost_dd__[axis_indices] else: raise ValueError(f"Unknown origin {origin!r}") # Check the length of the array to ensure that # it matches. if len(index) != num_axes: raise IndexError( f"Expected {num_axes}D index for axes {axes}, got {len(index)}" ) # Now normalize the axes and then check that they are all reasonable. for _i, _v in enumerate(index): _shape_ = int(shape[_i]) _new_index_ = normalize_index(_v, _shape_) if not (0 <= _new_index_ < _shape_): raise IndexError( f"Index {_v} (normalized={_new_index_}) out of bounds for axis '{self.axes[axis_indices[_i]]}' (size {_shape_})." ) # Convert to global space if origin == "active": index = tuple( index[i] + self.__ghost_zones__[0, axis_indices[i]] for i in range(num_axes) ) return index
[docs] def check_field_shape( self: _SupGridCore, array_shape: Sequence[int], axes: Optional["AxesInput"] = None, field_shape: Optional[Sequence[int]] = None, ) -> None: """ Validate that a field array shape is compatible with the grid over the specified axes. Parameters ---------- array_shape : Sequence[int] The shape of the input array to check. axes : str or list of str, optional The axes that the field is defined over. If None, all axes are used. field_shape : Sequence[int], optional Expected trailing dimensions (e.g., for vector or tensor fields). If None, no trailing dims are checked. Raises ------ ValueError If the array's shape does not match the expected grid shape or combined (grid + field) shape. """ # Standardize axis names and compute expected grid shape axes = self.standardize_axes(axes) num_axes, mask = self._count_and_mask_axes(axes) grid_shape = tuple(self.__ghost_dd__[mask]) # Check the leading dimensions match grid shape actual_grid_shape = tuple(array_shape[:num_axes]) if actual_grid_shape != grid_shape: raise ValueError( f"Incompatible grid shape for field over axes {axes}.\n" f" Expected grid shape: {grid_shape}\n" f" Found leading shape : {actual_grid_shape}" ) # Check trailing field dimensions if provided if field_shape is not None: expected_shape = grid_shape + tuple(field_shape) if tuple(array_shape) != expected_shape: raise ValueError( f"Incompatible full field shape.\n" f" Expected: {expected_shape}\n" f" Found : {tuple(array_shape)}" )
# ================================ # # Minimal Utilities # # ================================ # # These are little methods that get used a lot in # various other methods. def _count_and_mask_axes( self: _SupGridCore, axes: Optional["AxesInput"] = None ) -> Tuple[int, np.ndarray]: # Start by standardizing the axes that we have before passing # them down to create the mask. axes = self.standardize_axes(axes) mask = self.__cs__.build_axes_mask(axes) return len(axes), mask def _adjust_slices_for_origin( self: _SupGridCore, slices: Union[slice, Sequence[slice]], axes: Optional["AxesInput"] = None, origin: Literal["active", "global"] = "active", ) -> List[slice]: """ Adjust a list of slices from active-space to global-space index coordinates. Parameters ---------- slices : slice or sequence of slice The slices to adjust. Each one corresponds to an axis in `axes`. origin : {"active", "global"}, default="active" The origin of the slices. If "active", ghost zone offsets will be applied. If "global", the slices are returned unchanged. axes : str or list of str, optional The axes the slices apply to. If None, all axes are assumed. Returns ------- list of slice Slices adjusted for ghost zone offsets, with `None` boundaries resolved to valid global index-space extents. """ # Normalize inputs axes = self.standardize_axes(axes) axes_indices = self.__cs__.convert_axes_to_indices(axes) slices = [slices] if isinstance(slices, slice) else list(slices) if len(slices) != len(axes): raise ValueError( f"Expected {len(axes)} slices for axes {axes}, got {len(slices)}" ) adjusted = [] for slc, ax in zip(slices, axes_indices): gz_lo, gz_hi = self.__ghost_zones__[:, ax] gdd = self.__ghost_dd__[ax] if origin == "active": # Resolve `start` if slc.start is None: start = gz_lo else: start = gz_lo + slc.start # Resolve `stop` if slc.stop is None: stop = gdd - gz_hi else: stop = gz_lo + slc.stop elif origin == "global": # Pass through `None` or direct values start = slc.start stop = slc.stop else: raise ValueError(f"Invalid origin: {origin!r}") adjusted.append(slice(start, stop, slc.step)) return adjusted @staticmethod def _centers_to_edges( coordinates: Sequence[np.ndarray], bbox: np.ndarray, ) -> List[np.ndarray]: """ Compute coordinate edge arrays from center coordinates and a given bounding box. For each axis, computes (n+1) edge values from (n) center values using midpoint averaging, and applies the provided bounding box to fill the outer edge values. Parameters ---------- coordinates : Sequence[np.ndarray] Coordinate arrays representing cell centers (shape (n,)). bbox : np.ndarray A (2, ndim) array specifying [lower, upper] bounds for each axis. Used to fill in the boundary edges. Returns ------- list of np.ndarray One edge array per axis with shape (n + 1,). """ edge_arrays = [] for i, coords in enumerate(coordinates): # Compute interior edge midpoints centers = 0.5 * (coords[1:] + coords[:-1]) # Add bounding box values as exterior edges lower_edge = bbox[0, i] upper_edge = bbox[1, i] edges = np.concatenate([[lower_edge], centers, [upper_edge]]) edge_arrays.append(edges) return edge_arrays @staticmethod def _edges_to_centers( coordinates: Sequence[np.ndarray], ) -> List[np.ndarray]: """ Compute coordinate center arrays from edge coordinates. For each axis, computes n center values from n+1 edge values. Parameters ---------- coordinates : Sequence[np.ndarray] Coordinate arrays representing edge positions (shape (n + 1,)). Returns ------- list of np.ndarray One array per axis with shape (n,) containing center positions. """ return [0.5 * (coords[1:] + coords[:-1]) for coords in coordinates] # =============================== # # Coordinates # # =============================== # # These methods handle the coordinate generation # procedures. The only method in the base class is the # `compute_coords_from_slices` abstract method. Everything # else utilizes that.
[docs] def compute_coords_from_slices( self: _SupGridCore, slices: Union[slice, Sequence[slice]], /, axes: Optional["AxesInput"] = None, origin: Literal["active", "global"] = "active", __validate__: bool = True, ): """ Compute coordinate arrays over a region of the grid specified by slice objects. Parameters ---------- slices : slice or list of slice Slice(s) specifying the region to extract along the selected axes. axes : str or list of str, optional Axes to apply the slices to. If None, all axes are used. origin : {"active", "global"}, default="active" Whether the slices are relative to the active domain (excluding ghost zones) or the full ghost-augmented domain. __validate__ : bool, default=True Enable or disable all validation procedures. This should only be done in tight loop scenarios where performance is critical and typing is well controlled. Returns ------- tuple of np.ndarray Physical coordinate arrays corresponding to the sliced region, one per axis. """ # Standardize axes and their indices if __validate__: axes = self.standardize_axes(axes) axes_indices = self.__cs__.convert_axes_to_indices(axes) # Normalize slice input if __validate__: slices = [slices] if isinstance(slices, slice) else list(slices) if len(slices) != len(axes): raise ValueError( f"Expected {len(axes)} slice(s) for axes {axes}, but got {len(slices)}." ) # Adjust slice indices if origin is 'active' slices = self._adjust_slices_for_origin(slices, axes=axes, origin=origin) # Dispatch to low-level coordinate computation return self._compute_coordinates_on_slices( np.asarray(axes_indices, dtype=int), slices )
[docs] def compute_mesh_from_slices( self: _SupGridCore, slices: Union[slice, Sequence[slice]], /, axes: Optional["AxesInput"] = None, origin: Literal["active", "global"] = "active", __validate__: bool = True, **kwargs, ): """ Compute coordinate arrays over a region of the grid specified by slice objects. Parameters ---------- slices : slice or list of slice Slice(s) specifying the region to extract along the selected axes. axes : str or list of str, optional Axes to apply the slices to. If None, all axes are used. origin : {"active", "global"}, default="active" Whether the slices are relative to the active domain (excluding ghost zones) or the full ghost-augmented domain. __validate__ : bool, default=True Enable or disable all validation procedures. This should only be done in tight loop scenarios where performance is critical and typing is well controlled. kwargs: Additional keyword arguments to pass to :func:`numpy.meshgrid`. Returns ------- tuple of np.ndarray Physical coordinate arrays corresponding to the sliced region, one per axis. """ coords = self.compute_coords_from_slices( slices, axes=axes, origin=origin, __validate__=__validate__ ) return np.meshgrid(*coords, **kwargs)
[docs] def compute_coords_from_index( self: _SupGridCore, index: "IndexInput", /, axes: Optional["AxesInput"] = None, origin: Literal["active", "global"] = "active", __validate__: bool = True, ) -> Tuple[float, ...]: """ Compute the physical coordinates at a specific grid index. Parameters ---------- index : int or sequence of int The grid index to evaluate. If `axes` is provided, must match its length. axes : str or list of str, optional The axes to extract coordinates along. If None, all axes are used. origin : {"active", "global"}, default="active" Whether the index is specified relative to the active domain or the global grid. __validate__ : bool, default=True If True, checks index bounds and normalizes it to global coordinates. Returns ------- tuple of float The physical coordinate values at the given index, one per selected axis. """ # Standardize and validate axes if __validate__: axes = self.standardize_axes(axes) axes_indices = np.asarray(self.__cs__.convert_axes_to_indices(axes), dtype=int) # Standardize and validate index if requested if __validate__: index = self._standardize_index(index, axes, origin) # Turn index into a slice of width 1 for each axis slices = [slice(i, i + 1) for i in index] # Compute coordinate arrays for the 1-point slice and extract scalars coords = self._compute_coordinates_on_slices( axis_indices=axes_indices, slices=slices ) return tuple(float(c[0]) for c in coords)
[docs] def compute_domain_coords( self: _SupGridCore, /, axes: Optional["AxesInput"] = None, origin: Literal["active", "global"] = "active", __validate__: bool = True, ): """ Compute the 1D coordinate arrays for the full extent of the grid domain along the selected axes. This is a convenience wrapper around `compute_coords_from_slices(...)` that returns coordinates spanning the entire active or ghost-augmented domain. Parameters ---------- axes : str or list of str, optional The axes for which to compute coordinates. If None, all axes are used. origin : {"active", "global"}, default="active" Whether to generate coordinates for the active domain or the global (ghost-augmented) domain. __validate__ : bool, default=True Whether to validate axis input and index bounds. Can be disabled in tight loops. Returns ------- tuple of np.ndarray A tuple of 1D arrays — one per axis — representing physical coordinates along each axis. """ axes = self.standardize_axes(axes) slices = [slice(None)] * len(axes) return self.compute_coords_from_slices( slices, axes=axes, origin=origin, __validate__=__validate__ )
[docs] def compute_domain_mesh( self: _SupGridCore, /, axes: Optional["AxesInput"] = None, origin: Literal["active", "global"] = "active", __validate__: bool = True, **kwargs, ): """ Compute a meshgrid of coordinate arrays spanning the full domain of the grid. This constructs a broadcasted meshgrid over the selected axes, using either the active domain or the full ghost-augmented domain, as controlled by `origin`. Parameters ---------- axes : str or list of str, optional The axes to include in the meshgrid. If None, all axes are used. origin : {"active", "global"}, default="active" Whether to base the meshgrid on the active domain or full global domain. __validate__ : bool, default=True Whether to validate inputs. Can be disabled for performance. **kwargs : Additional keyword arguments passed to `np.meshgrid`, such as `indexing`. Returns ------- tuple of np.ndarray A tuple of N coordinate arrays, each of shape matching the domain, suitable for use in vectorized field evaluation. """ coords = self.compute_domain_coords( axes=axes, origin=origin, __validate__=__validate__ ) return np.meshgrid(*coords, indexing="ij", **kwargs)
[docs] def compute_domain_edges( self: _SupGridCore, /, axes: Optional["AxesInput"] = None, origin: Literal["active", "global"] = "active", __validate__: bool = True, ): """ Return coordinate edge arrays along each axis. If the grid is vertex-centered, returns coordinate arrays directly. If the grid is cell-centered, computes edges from centers and bounding box. Parameters ---------- axes : str or list of str, optional Axes to include. Defaults to all. origin : {"active", "global"}, default="active" Whether to include ghost zones. __validate__ : bool, default=True Whether to validate axis inputs. Returns ------- list of np.ndarray Coordinate edge arrays per axis. """ axes = self.standardize_axes(axes) axes_indices = self.__cs__.convert_axes_to_indices(axes) coords = self.compute_domain_coords( axes=axes, origin=origin, __validate__=__validate__ ) if origin == "active": bbox = self.bbox[:, axes_indices] else: bbox = self.gbbox[:, axes_indices] if self.centering == "vertex": return coords elif self.centering == "cell": return self._centers_to_edges(coords, bbox) else: raise NotImplementedError( f"Centering mode '{self.centering}' not supported." )
[docs] def compute_domain_centers( self: _SupGridCore, /, axes: Optional["AxesInput"] = None, origin: Literal["active", "global"] = "active", __validate__: bool = True, ): """ Return coordinate center arrays along each axis. If the grid is vertex-centered, computes centers from edges. If the grid is cell-centered, returns coordinate arrays directly. Parameters ---------- axes : str or list of str, optional Axes to include. Defaults to all. origin : {"active", "global"}, default="active" Whether to include ghost zones. __validate__ : bool, default=True Whether to validate axis inputs. Returns ------- list of np.ndarray Coordinate center arrays per axis. """ coords = self.compute_domain_coords( axes=axes, origin=origin, __validate__=__validate__ ) if self.centering == "vertex": return self._edges_to_centers(coords) elif self.centering == "cell": return coords else: raise NotImplementedError( f"Centering mode '{self.centering}' not supported." )
# =============================== # # Slicing # # =============================== # # These methods each handle various slicing procedures for # the grid. These methods do not include methods which # center chunking semantics as that is in its own mixin class.
[docs] def compute_domain_slice( self: _SupGridCore, axes: Optional["AxesInput"] = None, include_ghosts: bool = False, halo_offsets: Optional["HaloOffsetInput"] = None, oob_behavior: str = "raise", ) -> Tuple[slice, ...]: """ Compute a slice representing the entire grid domain with some set of axes, halo offsets, and ghost zone behavior. .. hint:: This method is useful when you need to extract the entire domain but want to exclude ghost zones or want to include some number of additional cells around the boundary. Parameters ---------- axes : str or list of str, optional The names of the axes to include in the slice. If None, all axes are used. This determines the number of :py:class:`slice` objects returned in the output tuple — one slice per selected axis. .. note:: Regardless of the ordering of ``axes``, the axes are reordered to match canonical ordering as defined by the coordinate system. include_ghosts : bool, optional If ``True``, then the slice will include the ghost zones around the boundary of the domain. Otherwise (default), the ghost zones are excluded from the resulting slice. halo_offsets : int, sequence[int], or np.ndarray, optional Extra padding (in ghost-cell units) to apply on top of the active or ghost-augmented domain. This allows you to extend the region further outward beyond ghost zones — for example, to reserve additional halo space for multi-pass stencils. Supported formats: - **int**: same padding applied to both sides of all axes - **sequence of length N**: symmetric padding per axis (left = right) - **array of shape (2, N)**: explicit left/right padding per axis .. note:: This is applied *after* ghost zones (if `include_ghosts=True`). Total padding = ghost zones + halo. oob_behavior : {"raise", "clip"}, default="raise" Determines what to do if the computed slice (after applying ghost zones and halo padding) would extend beyond the allocated grid buffer (`__ghost_dd__`). Options: - **"raise"** : Raise an `IndexError` if any part of the slice is out of bounds. - **"clip"** : Clamp the slice to stay within the valid grid extent. Use "clip" if you're performing relaxed operations (e.g., visualization or padding-aware reads), and "raise" for strict enforcement during stencil computations or buffer validation. Returns ------- tuple of slice One slice per selected axis, expressed **in global index space**. """ # Standardize / verify the inputs. num_axes, axes_mask = self._count_and_mask_axes(axes) axes_indices = np.asarray(self.__cs__.convert_axes_to_indices(axes), dtype=int) halo_offsets = self._standardize_halo_offset(halo_offsets, num_axes) # Modify the halo offsets so that they can # be used in a vectorized context later. halo_offsets[0, :] *= -1 # Determine base start/stop slices if include_ghosts: start = np.zeros(num_axes, dtype=int) stop = self.__ghost_dd__[axes_indices] else: start = self.__ghost_zones__[0, axes_indices].copy() stop = start + self.__dd__[axes_indices] # Apply halo padding (already flipped left side above) start += halo_offsets[0, :] stop += halo_offsets[1, :] # Bounds checking full_extent = self.__ghost_dd__[axes_indices] if oob_behavior == "raise": oob_left = start < 0 oob_right = stop > full_extent if np.any(oob_left | oob_right): for i, (s, e, maxlen) in enumerate(zip(start, stop, full_extent)): if s < 0 or e > maxlen: ax = self.axes[axes_indices[i]] raise IndexError( f"Domain slice out of bounds on axis '{ax}' (axis {axes_indices[i]}):\n" f" Computed slice : slice({s}, {e})\n" f" Valid domain extent: [0, {maxlen})" ) elif oob_behavior == "clip": start = np.maximum(start, 0) stop = np.minimum(stop, full_extent) else: raise ValueError( f"Invalid oob_behavior: {oob_behavior!r}. Choose 'raise' or 'clip'." ) # Return Python slice objects return tuple(slice(s, e) for s, e in zip(start, stop))
[docs] def determine_domain_shape( self: _SupGridCore, axes: Optional["AxesInput"] = None, include_ghosts: bool = False, halo_offsets: Optional["HaloOffsetInput"] = None, oob_behavior: Literal["clip", "raise"] = "raise", ) -> Tuple[int, ...]: """ Compute the shape of the domain along specified axes, including ghost zones and optional halo padding. Parameters ---------- axes : str or list of str, optional The names of the axes to include in the slice. If None, all axes are used. This determines the number of :py:class:`slice` objects returned in the output tuple — one slice per selected axis. .. note:: Regardless of the ordering of ``axes``, the axes are reordered to match canonical ordering as defined by the coordinate system. include_ghosts : bool, optional If ``True``, then the slice will include the ghost zones around the boundary of the domain. Otherwise (default), the ghost zones are excluded from the resulting slice. halo_offsets : int, sequence[int], or np.ndarray, optional Extra padding (in ghost-cell units) to apply on top of the active or ghost-augmented domain. This allows you to extend the region further outward beyond ghost zones — for example, to reserve additional halo space for multi-pass stencils. Supported formats: - **int**: same padding applied to both sides of all axes - **sequence of length N**: symmetric padding per axis (left = right) - **array of shape (2, N)**: explicit left/right padding per axis .. note:: This is applied *after* ghost zones (if `include_ghosts=True`). Total padding = ghost zones + halo. oob_behavior : {"raise", "clip"}, default="raise" Determines what to do if the computed slice (after applying ghost zones and halo padding) would extend beyond the allocated grid buffer (`__ghost_dd__`). Options: - **"raise"** : Raise an `IndexError` if any part of the slice is out of bounds. - **"clip"** : Clamp the slice to stay within the valid grid extent. Use "clip" if you're performing relaxed operations (e.g., visualization or padding-aware reads), and "raise" for strict enforcement during stencil computations or buffer validation. Returns ------- tuple of int The full shape of the region over the selected axes, including ghosts and halo. """ # Standardize axes and get dimensionality axes = self.standardize_axes(axes) num_axes = len(axes) axes_indices = np.asarray(self.__cs__.convert_axes_to_indices(axes), dtype=int) # Standardize halo specification halo_offsets = self._standardize_halo_offset(halo_offsets, num_axes) total_halo = np.sum(halo_offsets, axis=0) # Base domain shape max_shape = self.__ghost_dd__[axes_indices] base_shape = ( self.__ghost_dd__[axes_indices] if include_ghosts else self.__dd__[axes_indices] ) shape = base_shape + total_halo # Handle out-of-bounds behavior overrun = shape > max_shape if np.any(overrun): if oob_behavior == "raise": raise ValueError( f"Halo padding exceeds allocated domain on axes: " f"{[self.axes[ai] for ai, flag in zip(axes_indices, overrun) if flag]}" ) elif oob_behavior == "clip": shape = np.minimum(shape, max_shape) else: raise ValueError( f"Invalid oob_behavior: {oob_behavior!r}. Must be 'raise' or 'clip'." ) return tuple(int(s) for s in shape)
# =============================== # # Interpolation on Domains # # =============================== #
[docs] def construct_domain_interpolator( self: _SupGridCore, field: np.ndarray, field_axes: Sequence[str], method: str = "linear", bounds_error: bool = False, fill_value: Optional[float] = np.nan, **kwargs, ): """ Construct an interpolator object over the entire grid domain on a particular subset of axes. This method utilizes SciPy's :class:`~scipy.interpolate.RegularGridInterpolator` to leverage its C-level interpolation methods. Parameters ---------- field : np.ndarray The data to interpolate, shaped according to `field_axes`. field_axes : Sequence[str] Logical axes spanned by the field (e.g., ["x", "y"]). method : {"linear", "nearest"}, default="linear" Interpolation method. bounds_error : bool, default=False Whether to raise an error when queried points fall outside the domain. fill_value : float or None, default=np.nan Value used to fill in for out-of-bound points if `bounds_error=False`. **kwargs Additional keyword arguments passed to the interpolator constructor. Returns ------- interpolator : RegularGridInterpolator A callable that takes physical coordinates and returns interpolated values. """ from scipy.interpolate import RegularGridInterpolator # Standardize the field axes and then obtain the relevant coordinates. field_axes = self.standardize_axes(field_axes) coords = self.compute_domain_coords(axes=field_axes, origin="global") # Create the interpolator. self.check_field_shape(field.shape, axes=field_axes) interpolator = RegularGridInterpolator( points=coords, values=field, method=method, bounds_error=bounds_error, fill_value=fill_value, **kwargs, ) return interpolator
# =============================== # # Casting # # =============================== #
[docs] def broadcast_shape_to_axes( self: _SupGridCore, shape: Union[int, Sequence[int]], axes_in: "AxesInput", axes_out: "AxesInput", ) -> Tuple[int, ...]: """ Expand an input shape aligned to `axes_in` so it is broadcastable against `axes_out`. This inserts singleton dimensions (1s) in the positions corresponding to any axes present in `axes_out` but not in `axes_in`, while preserving trailing non-grid dimensions. Parameters ---------- shape : int or sequence of int Shape aligned with `axes_in`, possibly followed by extra non-grid dimensions (e.g., for vector or tensor fields). If a scalar, it is treated as `(shape,)`. axes_in : str or list of str The axes corresponding to the leading dimensions of `shape`. axes_out : str or list of str The target axes into which the shape should be broadcastable. Returns ------- tuple of int A shape tuple broadcastable over the axes in `axes_out`, preserving any trailing non-grid dimensions. Raises ------ ValueError If `axes_in` is not a subset of `axes_out`, or if the shape does not match the number of input axes. """ # Standardize axes axes_in = self.standardize_axes(axes_in) axes_out = self.standardize_axes(axes_out) # Convert shape to tuple if isinstance(shape, int): shape = (shape,) shape = tuple(shape) n_in, _ = len(axes_in), len(axes_out) if not set(axes_in).issubset(set(axes_out)): raise ValueError( f"axes_in {axes_in} must be a subset of axes_out {axes_out}" ) if len(shape) < n_in: raise ValueError( f"Shape {shape} does not have enough dimensions to match axes_in {axes_in}" ) trailing = shape[n_in:] # preserve any non-grid dims (e.g. for vector fields) shape_map = dict(zip(axes_in, shape[:n_in])) broadcast_shape = tuple(shape_map.get(ax, 1) for ax in axes_out) + trailing return broadcast_shape
[docs] def broadcast_shapes_to_axes( self, shapes: Sequence[Union[int, Sequence[int]]], axes_in: Sequence["AxesInput"], axes_out: "AxesInput", ) -> Sequence[Tuple[int, ...]]: """ Broadcast multiple input shapes aligned to `axes_in` so they are compatible with `axes_out`. This is a pluralized version of :meth:`broadcast_shape_to_axes` that applies the same logic to a list of shapes and axis specifications. Parameters ---------- shapes : sequence of shape-like A list of shapes, each of which corresponds to an input array. Each shape must be aligned with the axes provided in `axes_in`, possibly followed by additional non-grid dimensions (e.g., for vector or tensor components). axes_in : sequence of AxesInput A list of axis specifications. Each entry describes the grid axes that the corresponding shape aligns to. axes_out : AxesInput The target axes into which all shapes should be broadcast-compatible. Axes not present in an input are inserted as singleton dimensions (1). Returns ------- sequence of tuple[int, ...] The broadcasted shapes, each of which is compatible with the `axes_out` layout. Raises ------ ValueError If the number of input shapes and axis specs do not match, or if any axes_in are not a subset of axes_out. """ return tuple( self.broadcast_shape_to_axes(s, ai, axes_out) for s, ai in zip(shapes, axes_in) )
[docs] def broadcast_array_to_axes( self: _SupGridCore, array: np.ndarray, axes_in: "AxesInput", axes_out: "AxesInput", **kwargs, ) -> np.ndarray: """ Reshape and broadcast an array aligned with `axes_in` so it is compatible with `axes_out`. This utility is used to make field-like arrays broadcastable over a grid whose axes are specified by `axes_out`. It inserts singleton dimensions (1s) for any axes present in `axes_out` but not in `axes_in`, while preserving any trailing (non-grid) dimensions such as vector or tensor components. Parameters ---------- array : np.ndarray Input array. Its leading dimensions must correspond to the axes in `axes_in`. Any remaining dimensions (e.g., component indices) are preserved. axes_in : str or list of str Axes that the leading dimensions of the array are aligned to. axes_out : str or list of str Target axes to broadcast against. The returned array will have dimensions aligned with these axes. **kwargs: Any kwargs to pass to the array's `.reshape` method. Returns ------- np.ndarray A view or broadcasted copy of the input array, shaped to match `axes_out` and ready for operations over the target grid. Raises ------ ValueError If `axes_in` is not a subset of `axes_out`, or if the array shape is incompatible with the specified input axes. """ # Determine the broadcast shape. new_shape = self.broadcast_shape_to_axes(array.shape, axes_in, axes_out) return array.reshape(new_shape, **kwargs)
[docs] def broadcast_arrays_to_axes( self, arrays: Sequence[np.ndarray], axes_in: Sequence["AxesInput"], axes_out: "AxesInput", ) -> Sequence[np.ndarray]: """ Broadcast multiple arrays aligned to `axes_in` so they are compatible with `axes_out`. This pluralized version of :meth:`broadcast_array_to_axes` takes a sequence of arrays and corresponding axis specifications and returns arrays reshaped to be broadcastable over a common target axis layout. Parameters ---------- arrays : sequence of np.ndarray A list of arrays to reshape and broadcast. Each array's leading dimensions must align with the corresponding entry in `axes_in`. Trailing dimensions are preserved. axes_in : sequence of AxesInput A list of axis specifications for the input arrays. axes_out : AxesInput The target axes into which all arrays will be broadcast-compatible. Returns ------- sequence of np.ndarray The broadcast-compatible arrays, each reshaped according to the `axes_out` layout. Raises ------ ValueError If the input shapes and axes are incompatible or misaligned. """ return tuple( self.broadcast_array_to_axes(a, ai, axes_out) for a, ai in zip(arrays, axes_in) )
[docs] def tile_array_to_axes( self: _SupGridCore, array: np.ndarray, axes_in: "AxesInput", axes_out: "AxesInput", /, *, include_ghosts: bool = False, halo_offsets: Optional["HaloOffsetInput"] = None, oob_behavior: str = "raise", **kwargs, ) -> np.ndarray: """ Tile a lower-dimensional array across the full domain of the grid defined by `axes_out`. This function reshapes the input array to match the leading axes in `axes_out`, inserts singleton dimensions for any missing axes, and tiles (repeats) the data over the selected region of the grid, which may include ghost zones and halos. Parameters ---------- array : np.ndarray The input array to tile. Its leading dimensions must match the axes in `axes_in`. axes_in : str or list of str Axes corresponding to the array's grid-aligned dimensions. axes_out : str or list of str Target axes over which to tile. This defines the full spatial shape. include_ghosts : bool, optional If ``True``, then the slice will include the ghost zones around the boundary of the domain. Otherwise (default), the ghost zones are excluded from the resulting slice. halo_offsets : int, sequence[int], or np.ndarray, optional Extra padding (in ghost-cell units) to apply on top of the active or ghost-augmented domain. This allows you to extend the region further outward beyond ghost zones — for example, to reserve additional halo space for multi-pass stencils. Supported formats: - **int**: same padding applied to both sides of all axes - **sequence of length N**: symmetric padding per axis (left = right) - **array of shape (2, N)**: explicit left/right padding per axis .. note:: This is applied *after* ghost zones (if `include_ghosts=True`). Total padding = ghost zones + halo. oob_behavior : {"raise", "clip"}, default="raise" Determines what to do if the computed slice (after applying ghost zones and halo padding) would extend beyond the allocated grid buffer (`__ghost_dd__`). Options: - **"raise"** : Raise an `IndexError` if any part of the slice is out of bounds. - **"clip"** : Clamp the slice to stay within the valid grid extent. Use "clip" if you're performing relaxed operations (e.g., visualization or padding-aware reads), and "raise" for strict enforcement during stencil computations or buffer validation. **kwargs : Additional keyword arguments passed to `np.broadcast_to`. Returns ------- np.ndarray A tiled array broadcasted over the full region selected by `axes_out`. The trailing dimensions (not in `axes_out`) are preserved. Raises ------ ValueError If the array is not compatible with the grid or specified axes. """ # Standardize the input shape and axes shape_in = array.shape axes_in = self.standardize_axes(axes_in) axes_out = self.standardize_axes(axes_out) # Get the shape of the region we're tiling over region_slice = self.compute_domain_slice( axes=axes_out, include_ghosts=include_ghosts, halo_offsets=halo_offsets, oob_behavior=oob_behavior, ) region_shape = tuple(s.stop - s.start for s in region_slice) # Determine the full target shape (grid + trailing dims) trailing_shape = shape_in[len(axes_in) :] target_shape = region_shape + trailing_shape # Broadcast the array to the target shape broadcast_shape = self.broadcast_shape_to_axes(shape_in, axes_in, axes_out) reshaped = array.reshape(broadcast_shape + trailing_shape) return np.broadcast_to(reshaped, target_shape, **kwargs)
[docs] def empty( self: _SupGridCore, /, element_shape: Union[int, Sequence[int]] = (), axes: Optional["AxesInput"] = None, *, include_ghosts: bool = False, halo_offsets: Optional["HaloOffsetInput"] = None, oob_behavior: Literal["clip", "raise"] = "raise", **kwargs, ) -> np.ndarray: """ Return an uninitialized array shaped to match a domain-aligned region of the grid. This is useful for quickly allocating grid-aligned buffers (e.g., field data) without initializing values. Parameters ---------- element_shape : int or sequence of int, default=() Additional trailing shape to append to the grid dimensions. For scalar fields, use `()`. For vector fields, e.g. `(3,)`. axes : str or list of str, optional The names of the axes to include in the slice. If None, all axes are used. This determines the number of :py:class:`slice` objects returned in the output tuple — one slice per selected axis. .. note:: Regardless of the ordering of ``axes``, the axes are reordered to match canonical ordering as defined by the coordinate system. include_ghosts : bool, optional If ``True``, then the slice will include the ghost zones around the boundary of the domain. Otherwise (default), the ghost zones are excluded from the resulting slice. halo_offsets : int, sequence[int], or np.ndarray, optional Extra padding (in ghost-cell units) to apply on top of the active or ghost-augmented domain. This allows you to extend the region further outward beyond ghost zones — for example, to reserve additional halo space for multi-pass stencils. Supported formats: - **int**: same padding applied to both sides of all axes - **sequence of length N**: symmetric padding per axis (left = right) - **array of shape (2, N)**: explicit left/right padding per axis .. note:: This is applied *after* ghost zones (if `include_ghosts=True`). Total padding = ghost zones + halo. oob_behavior : {"raise", "clip"}, default="raise" Determines what to do if the computed slice (after applying ghost zones and halo padding) would extend beyond the allocated grid buffer (`__ghost_dd__`). Options: - **"raise"** : Raise an `IndexError` if any part of the slice is out of bounds. - **"clip"** : Clamp the slice to stay within the valid grid extent. Use "clip" if you're performing relaxed operations (e.g., visualization or padding-aware reads), and "raise" for strict enforcement during stencil computations or buffer validation. **kwargs : Additional keyword arguments passed to `np.empty`. Returns ------- np.ndarray An uninitialized array of shape `(domain_shape, *element_shape)`. """ # Determine the domain shape we want. domain_shape = self.determine_domain_shape( axes=axes, include_ghosts=include_ghosts, halo_offsets=halo_offsets, oob_behavior=oob_behavior, ) array_shape = domain_shape + tuple(element_shape) return np.empty(array_shape, **kwargs)
[docs] def zeros( self: _SupGridCore, /, element_shape: Union[int, Sequence[int]] = (), axes: Optional["AxesInput"] = None, *, include_ghosts: bool = False, halo_offsets: Optional["HaloOffsetInput"] = None, oob_behavior: Literal["clip", "raise"] = "raise", **kwargs, ) -> np.ndarray: """ Return an array of zeros shaped to match a domain-aligned region of the grid. This is useful for creating initialized scalar/vector/tensor buffers over the grid. Parameters ---------- element_shape : int or sequence of int, default=() Additional trailing shape to append to the grid dimensions. axes : str or list of str, optional The names of the axes to include in the slice. If None, all axes are used. This determines the number of :py:class:`slice` objects returned in the output tuple — one slice per selected axis. .. note:: Regardless of the ordering of ``axes``, the axes are reordered to match canonical ordering as defined by the coordinate system. include_ghosts : bool, optional If ``True``, then the slice will include the ghost zones around the boundary of the domain. Otherwise (default), the ghost zones are excluded from the resulting slice. halo_offsets : int, sequence[int], or np.ndarray, optional Extra padding (in ghost-cell units) to apply on top of the active or ghost-augmented domain. This allows you to extend the region further outward beyond ghost zones — for example, to reserve additional halo space for multi-pass stencils. Supported formats: - **int**: same padding applied to both sides of all axes - **sequence of length N**: symmetric padding per axis (left = right) - **array of shape (2, N)**: explicit left/right padding per axis .. note:: This is applied *after* ghost zones (if `include_ghosts=True`). Total padding = ghost zones + halo. oob_behavior : {"raise", "clip"}, default="raise" Determines what to do if the computed slice (after applying ghost zones and halo padding) would extend beyond the allocated grid buffer (`__ghost_dd__`). Options: - **"raise"** : Raise an `IndexError` if any part of the slice is out of bounds. - **"clip"** : Clamp the slice to stay within the valid grid extent. Use "clip" if you're performing relaxed operations (e.g., visualization or padding-aware reads), and "raise" for strict enforcement during stencil computations or buffer validation. **kwargs : Additional keyword arguments passed to `np.zeros`. Returns ------- np.ndarray A zero-initialized array of shape `(domain_shape, *element_shape)`. """ # Determine the domain shape we want. domain_shape = self.determine_domain_shape( axes=axes, include_ghosts=include_ghosts, halo_offsets=halo_offsets, oob_behavior=oob_behavior, ) array_shape = domain_shape + tuple(element_shape) return np.zeros(array_shape, **kwargs)
[docs] def ones( self: _SupGridCore, /, element_shape: Union[int, Sequence[int]] = (), axes: Optional["AxesInput"] = None, *, include_ghosts: bool = False, halo_offsets: Optional["HaloOffsetInput"] = None, oob_behavior: Literal["clip", "raise"] = "raise", **kwargs, ) -> np.ndarray: """ Return an array of ones shaped to match a domain-aligned region of the grid. This is useful for initializing buffers to a uniform value over the grid. Parameters ---------- element_shape : int or sequence of int, default=() Additional trailing shape to append to the grid dimensions. axes : str or list of str, optional The names of the axes to include in the slice. If None, all axes are used. This determines the number of :py:class:`slice` objects returned in the output tuple — one slice per selected axis. .. note:: Regardless of the ordering of ``axes``, the axes are reordered to match canonical ordering as defined by the coordinate system. include_ghosts : bool, optional If ``True``, then the slice will include the ghost zones around the boundary of the domain. Otherwise (default), the ghost zones are excluded from the resulting slice. halo_offsets : int, sequence[int], or np.ndarray, optional Extra padding (in ghost-cell units) to apply on top of the active or ghost-augmented domain. This allows you to extend the region further outward beyond ghost zones — for example, to reserve additional halo space for multi-pass stencils. Supported formats: - **int**: same padding applied to both sides of all axes - **sequence of length N**: symmetric padding per axis (left = right) - **array of shape (2, N)**: explicit left/right padding per axis .. note:: This is applied *after* ghost zones (if `include_ghosts=True`). Total padding = ghost zones + halo. oob_behavior : {"raise", "clip"}, default="raise" Determines what to do if the computed slice (after applying ghost zones and halo padding) would extend beyond the allocated grid buffer (`__ghost_dd__`). Options: - **"raise"** : Raise an `IndexError` if any part of the slice is out of bounds. - **"clip"** : Clamp the slice to stay within the valid grid extent. Use "clip" if you're performing relaxed operations (e.g., visualization or padding-aware reads), and "raise" for strict enforcement during stencil computations or buffer validation. **kwargs : Additional keyword arguments passed to `np.ones`. Returns ------- np.ndarray A one-initialized array of shape `(domain_shape, *element_shape)`. """ # Determine the domain shape we want. domain_shape = self.determine_domain_shape( axes=axes, include_ghosts=include_ghosts, halo_offsets=halo_offsets, oob_behavior=oob_behavior, ) array_shape = domain_shape + tuple(element_shape) return np.ones(array_shape, **kwargs)
[docs] def full( self: _SupGridCore, fill_value: Any, /, element_shape: Union[int, Sequence[int]] = (), axes: Optional["AxesInput"] = None, *, include_ghosts: bool = False, halo_offsets: Optional["HaloOffsetInput"] = None, oob_behavior: Literal["clip", "raise"] = "raise", **kwargs, ) -> np.ndarray: """ Return a constant-valued array shaped to match a domain-aligned region of the grid. Parameters ---------- fill_value : Any The value to fill the array with. element_shape : int or sequence of int, default=() Additional trailing shape to append to the grid dimensions. axes : str or list of str, optional The names of the axes to include in the slice. If None, all axes are used. This determines the number of :py:class:`slice` objects returned in the output tuple — one slice per selected axis. .. note:: Regardless of the ordering of ``axes``, the axes are reordered to match canonical ordering as defined by the coordinate system. include_ghosts : bool, optional If ``True``, then the slice will include the ghost zones around the boundary of the domain. Otherwise (default), the ghost zones are excluded from the resulting slice. halo_offsets : int, sequence[int], or np.ndarray, optional Extra padding (in ghost-cell units) to apply on top of the active or ghost-augmented domain. This allows you to extend the region further outward beyond ghost zones — for example, to reserve additional halo space for multi-pass stencils. Supported formats: - **int**: same padding applied to both sides of all axes - **sequence of length N**: symmetric padding per axis (left = right) - **array of shape (2, N)**: explicit left/right padding per axis .. note:: This is applied *after* ghost zones (if `include_ghosts=True`). Total padding = ghost zones + halo. oob_behavior : {"raise", "clip"}, default="raise" Determines what to do if the computed slice (after applying ghost zones and halo padding) would extend beyond the allocated grid buffer (`__ghost_dd__`). Options: - **"raise"** : Raise an `IndexError` if any part of the slice is out of bounds. - **"clip"** : Clamp the slice to stay within the valid grid extent. Use "clip" if you're performing relaxed operations (e.g., visualization or padding-aware reads), and "raise" for strict enforcement during stencil computations or buffer validation. **kwargs : Additional keyword arguments passed to `np.full`. Returns ------- np.ndarray A constant-valued array of shape `(domain_shape, *element_shape)`. """ # Determine the domain shape we want. domain_shape = self.determine_domain_shape( axes=axes, include_ghosts=include_ghosts, halo_offsets=halo_offsets, oob_behavior=oob_behavior, ) array_shape = domain_shape + tuple(element_shape) return np.full(array_shape, fill_value=fill_value, **kwargs)
# =============================== # # Summary Methods # # =============================== #
[docs] def summary(self: _SupGridCore) -> None: """ Print a summary of the grid structure, including domain shape, chunking, coordinate system, and ghost zone information. """ print(f"\nGrid Summary — {self.__class__.__name__}") print("-" * 60) # Coordinate system and axes cs = self.coordinate_system print(f"\tCoordinate System : {cs.__class__.__name__}") print(f"\tCoordinate Axes : {', '.join(self.axes)}") # Bounding boxes print("\nBounding Box (active):") print(f"\tLower : {self.bbox[0]}") print(f"\tUpper : {self.bbox[1]}") print("Bounding Box (global):") print(f"\tLower : {self.gbbox[0]}") print(f"\tUpper : {self.gbbox[1]}") # Grid shapes print( "\nShape: ", np.array(self.shape), " (active). ", np.array(self.gdd), " (global).", ) # Chunking info if self.chunking: print("\nChunking Enabled : TRUE") print("\tChunk Size : ", np.array(self.chunk_size)) print("\tNumber of Chunks : ", np.array(self.cdd)) else: print("\nChunking Enabled : FALSE") # Ghost zones print("\nGhost Zones:") for i, ax in enumerate(self.axes): left, right = self.ghost_zones[:, i] print(f" - {ax:<5s} : left={left}, right={right}") print("-" * 60)
[docs] def show(self: _SupGridCore) -> None: """ Alias for `summary()` to allow interactive/quick display. """ return self.summary()
[docs] class GridIOMixin(Generic[_SupGridIO]): """ Mixin class to support basic IO methods for grids. """ @staticmethod def _build_hdf5_stub( filename: Union[str, Path], group_name: Optional[str] = None, overwrite: bool = False, ) -> None: """ Ensure the HDF5 file exists and is writable. Parameters ---------- filename : str or Path Path to the HDF5 file. group_name : str or None Group to store data in. If None, uses the root group. overwrite : bool Whether to overwrite the file if it exists (only applies if group_name is None). """ import h5py filename = Path(filename) if filename.exists(): if group_name is None: if not overwrite: raise OSError( f"HDF5 file '{filename}' exists. Pass `overwrite=True` to overwrite." ) filename.unlink() # if group_name is not None, group will be handled later # Create empty file with h5py.File(filename, "w"): pass @staticmethod def _parse_hdf5_group( f: "h5py.File", group_name: Optional[str] = None, overwrite: bool = False ) -> "h5py.Group": """ Create or retrieve the HDF5 group for writing data. Parameters ---------- f : h5py.File An open HDF5 file handle. group_name : str or None Name of the group to access. If None, use root. overwrite : bool Whether to delete the group if it already exists. Returns ------- h5py.Group The group to write to. """ if group_name is None: return f if group_name in f: if overwrite: del f[group_name] else: raise OSError( f"Group '{group_name}' already exists in '{f.filename}'. Use overwrite=True to replace it." ) return f.require_group(group_name) def _serialize_unit_system(self: _SupGridIO, header: "h5py.Group") -> None: """ Store the current unit system in an HDF5 group as string attributes. Parameters ---------- header : h5py.Group The group to write unit attributes into. """ unit_keys = [ "length", "mass", "time", "angle", "temperature", "current_mks", "luminous_intensity", ] for key in unit_keys: unit = self.unit_system.get(key, None) if unit is not None: header.attrs[f"unit__{key}"] = str(unit) @staticmethod def _deserialize_unit_system(group: "h5py.Group") -> dict: """ Load unit system attributes from an HDF5 group and return as a dictionary. Parameters ---------- group : h5py.Group The group containing `unit__*` attributes. Returns ------- dict[str, str] A mapping from physical dimensions to unit strings. """ return { key.replace("unit__", ""): group.attrs[key] for key in group.attrs if key.startswith("unit__") } def _save_coordinate_system( self: _SupGridIO, filename: Union[str, Path], group_name: Optional[str] = None, overwrite: bool = False, ): """ Save the coordinate system to an HDF5 file under `group_name/coord_systm`. Parameters ---------- filename : str or Path HDF5 file path. group_name : str or None Group path in the file. overwrite : bool Whether to overwrite the coordinate system group. """ subgroup = "coord_systm" if group_name is None else f"{group_name}/coord_systm" self.coordinate_system.to_hdf5(filename, subgroup, overwrite=overwrite) @classmethod def _load_coordinate_system( cls, filename: Union[str, Path], group_name: Optional[str] = None ) -> "_CoordinateSystemBase": """ Load a coordinate system from an HDF5 file. Parameters ---------- filename : str or Path HDF5 file to load from. group_name : str or None Group prefix to read from. Returns ------- CoordinateSystemBase Loaded coordinate system object. """ # noinspection PyProtectedMember from pymetric.coordinates.base import _CoordinateSystemBase subgroup = "coord_systm" if group_name is None else f"{group_name}/coord_systm" return _CoordinateSystemBase.from_hdf5(filename, subgroup)
[docs] class GridPlotMixin(Generic[_SupGridChunking]): """ Mixin class for plotting 2D projections of structured coordinate grids. Provides utility methods to visualize: - Grid lines aligned with coordinate axes - Chunk boundaries (if chunking is enabled) Useful for debugging domain layout, visualizing ghost zones, and showing subdomain tiling in structured finite difference computations. """
[docs] def plot_grid_lines( self: _SupGridChunking, grid_axes: Optional[Sequence[str]] = None, ax: Optional["Axes"] = None, include_ghosts: bool = False, **kwargs, ): """ Plot coordinate grid lines along the specified 2D slice of the domain. Parameters ---------- grid_axes : list of str, optional The two coordinate axes to display in the plot. If not specified, the first two axes of the coordinate system are selected. ax : ~matplotlib.axes.Axes, optional Matplotlib axis object to draw into. If None, a new figure is created. include_ghosts : bool, default=False Whether to include ghost zones in the plotting extent and coordinates. **kwargs Additional keyword arguments passed to :func:`matplotlib.pyplot.hlines` and :func:`matplotlib.pyplot.vlines` (e.g., linewidth, linestyle, etc). Returns ------- matplotlib.axes.Axes The matplotlib axis object used for plotting. """ # Ensure matplotlib is actually imported but we don't # want to waste time importing it from the top of the module. import matplotlib.pyplot as plt # Validate the axes and ensure everything is set up. if self.ndim < 2: raise ValueError("Cannot show grid lines for coordinate system in 1D.") grid_axes = ( self.standardize_axes(grid_axes) if grid_axes is not None else self.axes[:2] ) grid_axes_indices = self.__cs__.convert_axes_to_indices(grid_axes) if len(grid_axes) != 2: raise ValueError(f"Exactly two axes must be provided. Got {grid_axes}.") # Extract the X and Y coordinates from the grid. This requires resolving the # desired origin as well. origin: Literal["global", "active"] = "global" if include_ghosts else "active" x_coords, y_coords = self.compute_domain_coords(grid_axes, origin=origin) # Determine the boundaries for the x and y coordinates. This will depend # specifically on the relevant bouding box. # Get axis indices and bounding box bbox = ( self.gbbox[:, grid_axes_indices] if include_ghosts else self.bbox[:, grid_axes_indices] ) # -- PLOTTING SEQUENCE -- # if ax is None: fig, ax = plt.subplots(1, 1) # Draw the vertical lines at each of the x positions. # Draw the horizontal lines at each of the y positions. kwargs["color"] = kwargs.get("color", "k") ax.vlines(x_coords, bbox[0, 1], bbox[1, 1], **kwargs) ax.hlines(y_coords, bbox[0, 0], bbox[1, 0], **kwargs) return ax
[docs] def plot_chunk_lines( self: _SupGridChunking, grid_axes: Optional[Sequence[str]] = None, ax: Optional["Axes"] = None, include_ghosts: bool = False, **kwargs, ): """ Plot chunk boundaries along the specified 2D projection of the domain. Parameters ---------- grid_axes : list of str, optional The two coordinate axes to display in the plot. If not specified, the first two axes of the coordinate system are selected. ax : ~matplotlib.axes.Axes, optional Matplotlib axis object to draw into. If None, a new figure is created. include_ghosts : bool, default=False Whether to include ghost zones in the plotting extent and chunk structure. **kwargs Additional keyword arguments passed to :func:`matplotlib.pyplot.hlines` and :func:`matplotlib.pyplot.vlines` (e.g., linewidth, linestyle, etc). Returns ------- matplotlib.axes.Axes The matplotlib axis object used for plotting. Raises ------ ValueError If the coordinate system is 1D or if an invalid number of axes is specified. RuntimeError If chunking is not supported by this grid. """ # Ensure matplotlib is actually imported but we don't # want to waste time importing it from the top of the module. import matplotlib.pyplot as plt # Validate the axes and ensure everything is set up. self._ensure_supports_chunking() if self.ndim < 2: raise ValueError("Cannot show chunk lines for coordinate system in 1D.") grid_axes = ( self.standardize_axes(grid_axes) if grid_axes is not None else self.axes[:2] ) grid_axes_indices = self.__cs__.convert_axes_to_indices(grid_axes) if len(grid_axes) != 2: raise ValueError(f"Exactly two axes must be provided. Got {grid_axes}.") # Extract the chunk coordinates. cx, cy = [], [] for ccx, ccy in self.iter_chunk_coords( axes=grid_axes, include_ghosts=include_ghosts ): cx.append(ccx[0]) cy.append(ccy[0]) # Determine the boundaries for the x and y coordinates. This will depend # specifically on the relevant bouding box. # Get axis indices and bounding box bbox = ( self.gbbox[:, grid_axes_indices] if include_ghosts else self.bbox[:, grid_axes_indices] ) cx.append(bbox[1, 0]) cy.append(bbox[1, 1]) # -- PLOTTING SEQUENCE -- # if ax is None: fig, ax = plt.subplots(1, 1) # Draw the vertical lines at each of the x positions. # Draw the horizontal lines at each of the y positions. kwargs["color"] = kwargs.get("color", "k") ax.vlines(cx, bbox[0, 1], bbox[1, 1], **kwargs) ax.hlines(cy, bbox[0, 0], bbox[1, 0], **kwargs) return ax
[docs] def plot_ghost_zone_shading( self: _SupGridChunking, grid_axes: Optional[Sequence[str]] = None, ax: Optional["Axes"] = None, facecolor: str = "gray", alpha: float = 0.3, ): """ Shade the ghost zones in a 2D grid projection. Parameters ---------- grid_axes : list of str, optional Axes to visualize (must be 2D). Defaults to the first two axes. ax : matplotlib.axes.Axes, optional Axis to plot into. A new one is created if None. facecolor : str Color to fill the ghost region. alpha : float Transparency level for shading. Returns ------- matplotlib.axes.Axes Axis object containing the shaded plot. """ import matplotlib.pyplot as plt from matplotlib.patches import Rectangle if self.ndim < 2: raise ValueError("Cannot shade ghost zones for coordinate system in 1D.") grid_axes = ( self.standardize_axes(grid_axes) if grid_axes is not None else self.axes[:2] ) if len(grid_axes) != 2: raise ValueError("Shading requires exactly two axes.") grid_axes_indices = self.__cs__.convert_axes_to_indices(grid_axes) bbox = self.bbox[:, grid_axes_indices] gbbox = self.gbbox[:, grid_axes_indices] if ax is None: fig, ax = plt.subplots(1, 1) # Add ghost rectangles along each axis ax.add_patch( Rectangle( (float(gbbox[0, 0]), float(gbbox[0, 1])), float(bbox[0, 0] - gbbox[0, 0]), float(gbbox[1, 1] - gbbox[0, 1]), facecolor=facecolor, alpha=alpha, ) ) ax.add_patch( Rectangle( (float(bbox[1, 0]), float(gbbox[0, 1])), float(gbbox[1, 0] - bbox[1, 0]), float(gbbox[1, 1] - gbbox[0, 1]), facecolor=facecolor, alpha=alpha, ) ) ax.add_patch( Rectangle( (float(gbbox[0, 0]), float(gbbox[0, 1])), float(gbbox[1, 0] - gbbox[0, 0]), float(bbox[0, 1] - gbbox[0, 1]), facecolor=facecolor, alpha=alpha, ) ) ax.add_patch( Rectangle( (float(gbbox[0, 0]), float(bbox[1, 1])), float(gbbox[1, 0] - gbbox[0, 0]), float(gbbox[1, 1] - bbox[1, 1]), facecolor=facecolor, alpha=alpha, ) ) return ax