"""
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