Source code for fields.buffers.base

"""
Buffer base classes and buffer resolution support.

This module defines the core :py:class:`BufferBase` class, which all buffer types must subclass,
and the metaclass :class:`_BufferMeta`, which manages registration into the
default buffer registry and enforces interface correctness.

The buffer system abstracts different data storage backends (NumPy, unyt, HDF5, etc.)
behind a common interface so that field operations can delegate storage concerns. Novel buffer
classes can be implemented with relative ease vis-a-vis subclasses of :py:class:`BufferBase`.
"""
from abc import ABC, ABCMeta, abstractmethod
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, Type, Union

import numpy as np
import unyt
from numpy.typing import ArrayLike

from pymetric.fields.mixins._generic import NumpyArithmeticMixin

from .registry import __DEFAULT_BUFFER_REGISTRY__, BufferRegistry


# ========================================= #
# Buffer Meta Class                         #
# ========================================= #
class _BufferMeta(ABCMeta):
    """
    Metaclass for all Pisces buffer classes.

    This metaclass automatically registers concrete buffer classes with the
    global `__DEFAULT_BUFFER_REGISTRY__` if they are not abstract and define
    the `__can_resolve__` attribute.

    Expected class attributes:
    ---------------------------
    - __is_abc__ : bool
        Whether the class is abstract. If True, registration is skipped.

    - __can_resolve__ : List[Type] or None
        A list of array-like types (e.g. `np.ndarray`, `unyt_array`) that this buffer can wrap.
        Must be defined on all concrete buffer classes.
    """

    def __new__(mcls, name, bases, namespace, **kwargs):
        # Create the generic class object with a call to super().
        cls = super().__new__(mcls, name, bases, namespace, **kwargs)

        # Extract the class flags and use them to determine the triaging
        # behavior.
        is_abstract = getattr(cls, "__is_abc__", False)

        if is_abstract:
            return cls

        # Validate the __can_resolve__ attribute. This requires managing
        # the various possibly typing conventions.
        can_resolve = getattr(cls, "__can_resolve__", None)
        if can_resolve is None:
            raise TypeError(
                f"Concrete buffer subclass '{name}' must define "
                f"`__can_resolve__` (a type or iterable of types)."
            )

        # Accept single type or iterable of types
        if isinstance(can_resolve, type):
            can_resolve = (can_resolve,)

        if not isinstance(can_resolve, Iterable):
            raise TypeError(
                f"'{name}.__can_resolve__' must be a type or an iterable of types, "
                f"got object of type {type(can_resolve).__name__}."
            )

        # Ensure every entry is itself a 'type'
        bad_entries = [t for t in can_resolve if not isinstance(t, type)]
        if bad_entries:
            bad_str = ", ".join(repr(t) for t in bad_entries)
            raise TypeError(
                f"All entries in '{name}.__can_resolve__' must be types; "
                f"found invalid entries: {bad_str}"
            )

        # Store back the normalised tuple so the rest of the code can rely on it
        cls.__can_resolve__ = tuple(can_resolve)

        __DEFAULT_BUFFER_REGISTRY__.register(cls)
        return cls


# ========================================= #
# Abstract Base Class (BufferBase)          #
# ========================================= #
[docs] class BufferBase(NumpyArithmeticMixin, ABC, metaclass=_BufferMeta): """ Abstract base class for Pisces Geometry-compatible field buffers. This interface abstracts data storage details so that field operations can be performed uniformly regardless of whether the underlying data is NumPy, unyt, HDF5, etc. """ # === Class Attributes === # # These attributes configure behavior and registration rules for buffer subclasses. # All **concrete** buffer classes MUST define these attributes explicitly. # # Abstract classes may omit them by setting __is_abc__ = True. __is_abc__: bool = True """ Marks the class as abstract (not to be registered). Set to `False` on all concrete subclasses. """ __can_resolve__: List[Type] = NotImplemented """ A list of data types (e.g., [np.ndarray, unyt_array]) that this buffer can wrap via the `coerce()` method. This is required for automatic buffer resolution. Must be defined on concrete subclasses. """ __core_array_types__: Optional[Tuple[Type, ...]] = None """ Type(s) that the buffer expects to wrap in its constructor (`__init__`). Used by `__validate_array_object__()` to ensure the input array object is valid. Typically set to `np.ndarray`, `unyt_array`, or `h5py.Dataset`. If `None`, no validation is performed. """ __representation_types__: Optional[Tuple[Type, ...]] = None """ The type(s) that can be represented with this buffer class. Currently only used when performing tests. Should still be implemented in all subclasses. """ __resolution_priority__: int = 0 """ Optional integer priority used during buffer resolution. Lower numbers are prioritized first when resolving unknown array-like inputs. Used by buffer registries that support resolution ordering. """ __array_priority__ = 2.0 """ The priority of the buffer class in numpy operations. """ __array_function_dispatch__: Optional[Dict[Callable, Callable]] = { # Internally defined methods. np.copy: lambda self, *args, **kwargs: self.copy(*args, **kwargs), np.transpose: lambda self, *args, **kwargs: self.transpose(*args, **kwargs), np.reshape: lambda self, *args, **kwargs: self.reshape(*args, **kwargs), np.ravel: lambda self, *args, **kwargs: self.flatten(*args, **kwargs), np.squeeze: lambda self, *args, **kwargs: self.squeeze(*args, **kwargs), np.expand_dims: lambda self, *args, **kwargs: self.expand_dim(*args, **kwargs), np.broadcast_to: lambda self, *args, **kwargs: self.broadcast_to( *args, **kwargs ), # Internally defined properties. np.shape: lambda self, *_, **__: self.shape, np.ndim: lambda self, *_, **__: self.ndim, np.size: lambda self, *_, **__: self.size, # Simple redirect transformations. np.moveaxis: lambda self, s, d, *args, **kwargs: self._apply_numpy_transform_on_repr( np.moveaxis, [s, d], {}, *args, **kwargs ), np.swapaxes: lambda self, ax1, ax2, *args, **kwargs: self._apply_numpy_transform_on_repr( np.swapaxes, [ax1, ax2], {}, *args, **kwargs ), np.tile: lambda self, reps, *args, **kwargs: self._apply_numpy_transform_on_repr( np.tile, [reps], {}, *args, **kwargs ), } """ `__array_function_dispatch__` is a dictionary which can optionally map NumPy callables to internal implementations to allow overriding of default behavior. By default, when a NumPy function (non ufunc) is called on a Buffer, the buffer is stripped and the operation occurs on the underlying representation. If a callable is specified here, then `__array_function__()` will catch the redirect and triage accordingly. """ # === Initialization === # # The initialization procedure should be meta stable # in the sense that it always behaves the same way: __init__ # requires a pre-coerced type and simply checks for type compliance. # Other methods can be used for more adaptive behavior.
[docs] def __init__(self, array_object: ArrayLike): """ Initialize a buffer from a validated array-like object. This constructor assumes that the input is already fully compatible with the expected core array type for this buffer class (e.g., :py:class:`numpy.ndarray`, :py:class:`unyt.unyt_array`, etc.). No validation or coercion of the data is performed beyond checking its type. .. warning:: This method does **not** attempt to coerce or sanitize the input array. If you pass an incompatible or incorrect array-like object, a ``TypeError`` will be raised. For flexible or user-facing buffer construction, use :meth:`from_array` or :meth:`coerce` instead. Parameters ---------- array_object : ArrayLike A pre-validated, backend-specific array object that will be wrapped by this buffer. Must be an instance of the class’s ``__core_array_types__``, if that attribute is defined. Raises ------ TypeError If the array does not match the expected core type(s). See Also -------- BufferBase.from_array : Preferred interface for safe buffer construction. BufferBase.coerce : Coerces arbitrary array-like objects into valid buffers. """ self.__array_object__: ArrayLike = array_object self.__validate_array_object__()
def __validate_array_object__(self): """ Validate that the wrapped array object is of the expected core type. This method checks that the buffer's internal array (``__array_object__``) matches the type or types declared in ``__core_array_types__``. If this condition fails, a `TypeError` is raised. This method may be extended in subclasses to include stricter or domain-specific validation logic. Raises ------ TypeError If the internal array object does not match any type in ``__core_array_types__``. """ core_types = self.__class__.__core_array_types__ # No validation requested → simply return. if core_types is None: return # Accept a single type or an iterable of types. if not isinstance(core_types, tuple): core_types = (core_types,) if not isinstance(self.__array_object__, core_types): expected_names = ", ".join(t.__name__ for t in core_types) raise TypeError( f"{self.__class__.__name__} expects array of type {expected_names}, " f"but got {type(self.__array_object__).__name__}. " "Use '.from_array()' or '.coerce()' if conversion is possible." )
[docs] @classmethod @abstractmethod def from_array( cls, obj: Any, *args, dtype: Optional[Any] = None, **kwargs ) -> "BufferBase": """ Attempt to construct a new buffer instance from an array-like object. This method is the canonical entry point for converting arbitrary array-like inputs into a buffer of this type. It behaves similarly to a cast operation, and will coerce the input as needed to match the expected backend format (e.g., :class:`~numpy.ndarray`, class:`~unyt.unyt_array`, etc.). The method should be overridden in subclasses to handle type conversion, unit attachment, memory layout, or any other backend-specific behavior. Parameters ---------- obj : array-like Input data to be wrapped. This can be any object that is compatible with the backend's array casting rules—such as lists, tuples, NumPy arrays, unyt arrays, or backend-native types (e.g., HDF5 datasets). The input will be coerced into a backend-compatible array before being wrapped in a buffer instance. If coercion fails, a `TypeError` will be raised. dtype : data-type, optional Desired data type of the resulting array. If not specified, the type is inferred from `obj`. *args, **kwargs : Additional arguments to customize the construction. These may include: - `units` for unit-aware buffers - `order`, `copy`, or `device` for backend-specific configuration - Any arguments accepted by the backend constructor Returns ------- BufferBase A new buffer instance wrapping the coerced array. Raises ------ TypeError If the input cannot be coerced into a valid array for this backend. """ pass
# === Resolution Logic === #
[docs] @classmethod def can_handle(cls, obj: Any) -> bool: """ Return ``True`` if *obj* can be wrapped by this buffer class. The test is simply ``isinstance(obj, t)`` for at least one *t* in ``__can_resolve__``. Subclasses should set ``__can_resolve__`` to either: - a single type (e.g. ``np.ndarray``), or - an iterable/tuple of types (e.g. ``(np.ndarray, np.ma.MaskedArray)``). If a subclass leaves ``__can_resolve__`` as ``NotImplemented`` the method always returns ``False`` so the registry can skip it. """ can = cls.__can_resolve__ if can is NotImplemented: return False # Accept both a single type and an iterable of types. if not isinstance(can, (tuple, list)): can = (can,) return isinstance(obj, tuple(can))
[docs] @classmethod def can_handle_list(cls) -> List[str]: """ Return a list of type names that this buffer can wrap. This method provides a human-readable list of supported types defined in ``__can_resolve__``. It is typically used for debugging, diagnostics, or generating documentation for supported backends. Returns ------- list of str The names of the supported types (e.g., ``['ndarray', 'unyt_array']``). Raises ------ TypeError If ``__can_resolve__`` is not defined or not iterable. """ if cls.__can_resolve__ is NotImplemented: return [] return [t.__name__ for t in cls.__can_resolve__]
# === Required Constructors === #
[docs] @classmethod @abstractmethod def zeros(cls, shape, *args, **kwargs) -> "BufferBase": """ Create a new buffer filled with zeros. This method constructs a new backend-specific array of the given shape, filled with zeros, and wraps it in a buffer instance. Parameters ---------- shape : tuple of int The desired shape of the buffer, including both grid and element dimensions. *args : Positional arguments passed through to the array constructor (backend-specific). **kwargs : Additional keyword arguments passed to the array constructor. May include: - ``dtype``: Data type of the array (e.g., ``float32``, ``int64``) - ``units``: Units of the array (if supported) Returns ------- BufferBase A buffer instance wrapping a zero-initialized array. """ pass
[docs] @classmethod @abstractmethod def empty(cls, shape, *args, **kwargs) -> "BufferBase": """ Create a new buffer with a window into unaltered memory. This method constructs a new backend-specific array of the given shape, and wraps it in a buffer instance. Parameters ---------- shape : tuple of int The desired shape of the buffer, including both grid and element dimensions. *args : Positional arguments passed through to the array constructor (backend-specific). **kwargs : Additional keyword arguments passed to the array constructor. May include: - ``dtype``: Data type of the array (e.g., ``float32``, ``int64``) - ``units``: Units of the array (if supported) Returns ------- BufferBase A buffer instance wrapping an uninitialized array. """ pass
[docs] @classmethod @abstractmethod def ones(cls, shape, *args, **kwargs) -> "BufferBase": """ Create a new buffer filled with ones. Constructs a backend-compatible array filled with ones and wraps it in a buffer instance. Parameters ---------- shape : tuple of int The desired shape of the buffer, including both grid and element dimensions. *args : Positional arguments forwarded to the array constructor. **kwargs : Additional keyword arguments passed to the array constructor. May include: - ``dtype``: Data type of the array (e.g., ``float32``, ``int64``) - ``units``: Units of the array (if supported) Returns ------- BufferBase A buffer instance wrapping a one-filled array. """ pass
[docs] @classmethod @abstractmethod def full(cls, shape, *args, fill_value=0.0, **kwargs) -> "BufferBase": """ Create a new buffer filled with a constant value. This method builds a backend-specific array of the given shape and fills it with the provided `fill_value`. The resulting array is wrapped and returned as a buffer instance. Parameters ---------- shape : tuple of int The desired shape of the buffer (grid + element dimensions). *args : Additional positional arguments passed to the backend constructor. fill_value : float, default 0.0 The constant value to use for every element in the array. **kwargs : Additional keyword arguments passed to the array constructor. May include: - ``dtype``: Data type of the array (e.g., ``float32``, ``int64``) - ``units``: Units of the array (if supported) Returns ------- BufferBase A buffer instance wrapping a constant-filled array. """ pass
[docs] @classmethod def zeros_like(cls, other: "BufferBase", *args, **kwargs) -> "BufferBase": """ Create a new buffer filled with zeros and matching the shape of another buffer. This method delegates to the class's `zeros` constructor, using the shape of the provided buffer instance. Parameters ---------- other : BufferBase The buffer whose shape will be used. *args : Additional positional arguments forwarded to `zeros`. **kwargs : Additional keyword arguments forwarded to `zeros`. Common options include: - `dtype` : data type of the buffer - `units` : physical units (for unit-aware buffers) Returns ------- BufferBase A buffer filled with zeros and the same shape as `other`. """ return cls.zeros(other.shape, *args, **kwargs)
[docs] @classmethod def ones_like(cls, other: "BufferBase", *args, **kwargs) -> "BufferBase": """ Create a new buffer filled with ones and matching the shape of another buffer. This method delegates to the class's `ones` constructor, using the shape of the provided buffer instance. Parameters ---------- other : BufferBase The buffer whose shape will be used. *args : Additional positional arguments forwarded to `ones`. **kwargs : Additional keyword arguments forwarded to `ones`. Common options include: - `dtype` : data type of the buffer - `units` : physical units (for unit-aware buffers) Returns ------- BufferBase A buffer filled with ones and the same shape as `other`. """ return cls.ones(other.shape, *args, **kwargs)
[docs] @classmethod def full_like( cls, other: "BufferBase", fill_value: Any = 0.0, *args, **kwargs ) -> "BufferBase": """ Create a new buffer filled with a constant value and matching the shape of another buffer. This method delegates to the class's `full` constructor, using the shape of the provided buffer instance. Parameters ---------- other : BufferBase The buffer whose shape will be used. fill_value : scalar or quantity, default 0.0 The constant value to fill the buffer with. *args : Additional positional arguments forwarded to `full`. **kwargs : Additional keyword arguments forwarded to `full`. Common options include: - `dtype` : data type of the buffer - `units` : physical units (for unit-aware buffers) Returns ------- BufferBase A buffer filled with the specified value and the same shape as `other`. """ return cls.full(other.shape, *args, fill_value=fill_value, **kwargs)
[docs] @classmethod def empty_like(cls, other: "BufferBase", *args, **kwargs) -> "BufferBase": """ Create a new buffer allocation matching the shape of another buffer. This method delegates to the class's `empty` constructor, using the shape of the provided buffer instance. Parameters ---------- other : BufferBase The buffer whose shape will be used. *args : Additional positional arguments forwarded to `empty`. **kwargs : Additional keyword arguments forwarded to `empty`. Common options include: - `dtype` : data type of the buffer - `units` : physical units (for unit-aware buffers) Returns ------- BufferBase An unallocated buffer like `other`. """ return cls.empty(other.shape, *args, **kwargs)
# === NumPy-Like Interface === # def __getitem__(self, idx): return self.__array_object__[idx] def __setitem__(self, idx, value): self.__array_object__[idx] = value def __array__(self, dtype=None): return np.asarray(self.__array_object__, dtype=dtype) def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): """ Forward semantics for numpy operations on arrays. The heuristic of buffer numpy interaction is that we perform the operations between the `RepresentationType`s of each of the input buffers. Our returned value is determined by the `RepresentationType`s of each of the input buffers. If `out` is specified, then an attempt is made to place the result into the relevant buffer. """ # Convert all of the inputs into their corresponding representation type. This # will break any lazy-loading behavior in the inputs and convert everything to # numpy compatible types. core_inputs = [ x.as_repr() if isinstance(x, self.__class__) else x for x in inputs ] # Handle `out`: We fetch the out kwarg, check if it is a buffer type, and then # attempt to place the result into the buffer by specifying out=self.__array_object__. out = kwargs.get("out", None) if out is not None: # Normalize to a tuple for uniform processing is_tuple = isinstance(out, tuple) out_tuple = out if is_tuple else (out,) # Unwrap buffers unwrapped_out = tuple( o.as_core() if isinstance(o, self.__class__) else o for o in out_tuple ) kwargs["out"] = unwrapped_out if is_tuple else unwrapped_out[0] # Apply the ufunc result = getattr(ufunc, method)(*core_inputs, **kwargs) # Pass result through based on the typing. if isinstance(result, tuple): return out_tuple elif result is not None: return out_tuple[0] else: return None else: # out was not specified, we simply return the unwrapped behavior. return getattr(ufunc, method)(*core_inputs, **kwargs) def __array_function__(self, func, types, args, kwargs): """ Override NumPy high-level functions for BufferBase. The heuristic for this behavior is to simply delegate operations to the buffer representation unless there is a specific override in place. """ # Check for custom forwarding implementations via # the __array_functions_dispatch__. if all(issubclass(t, self.__class__) for t in types): # Fetch the dispatch and check for the override of # this function. redirect_func = getattr(self, "__array_function_dispatch__", {}).get( func, None ) if redirect_func is not None: # We have a redirection, we now delegate to that. return redirect_func(*args, **kwargs) # No valid dispatch found. We now strip the args down and # pass through without and further alterations. unwrapped_args = tuple( a.as_repr() if isinstance(a, self.__class__) else a for a in args ) unwrapped_kwargs = { _k: _v.as_core() if isinstance(_v, self.__class__) else _v for _k, _v in kwargs.items() } return func(*unwrapped_args, **unwrapped_kwargs) def __repr__(self): return f"{self.__class__.__name__}(shape={self.shape}, dtype={self.dtype})" def __str__(self): return self.__array_object__.__str__() def __len__(self) -> int: """ Return the length of the buffer along its first axis. This is equivalent to `len(buffer.as_core())`, and will raise an error if the buffer has zero dimensions. Returns ------- int The size of the first dimension. Raises ------ TypeError If the buffer is scalar (zero-dimensional). """ return len(self.__array_object__) def __iter__(self): """ Return an iterator over the outermost dimension of the buffer. This allows iteration like `for row in buffer`, where each row is returned as a slice of the buffer. Slices are returned as NumPy arrays or `unyt_array`, depending on the underlying backend. Returns ------- Iterator[Any] An iterator over the first dimension of the wrapped array. """ return iter(self.__array_object__) def __eq__(self, other: Any) -> bool: """ Check for equality with another buffer or array-like object. This uses NumPy-style broadcasting and comparison. If `other` is not a buffer, it will be coerced to an array for comparison. This performs an *element-wise* comparison and returns a boolean scalar only if the entire contents are equal. Parameters ---------- other : Any Another buffer or array-like object. Returns ------- bool True if the contents are equal (element-wise). False otherwise. """ return self.as_core() == other # === Public Properties === # @property def shape(self) -> Tuple[int, ...]: """Shape of the underlying array.""" return self.__array_object__.shape @property def units(self): """ Physical units attached to the buffer data. Returns ------- unyt.unit_registry.Unit The physical units associated with this buffer’s array values. """ return None @units.setter def units(self, units): raise ValueError(f"Class {self.__class__.__name__} does not support units.") @property def size(self) -> int: """Total number of elements.""" return self.__array_object__.size @property def ndim(self) -> int: """Number of dimensions.""" return self.__array_object__.ndim @property def has_units(self) -> bool: """ Whether the buffer carries unit metadata. This returns `True` if the buffer has an attached physical unit (i.e., `self.units` is not `None`), and `False` otherwise. Returns ------- bool `True` if the buffer has units, `False` if it is unitless. """ return self.units is not None @property def dtype(self) -> Any: """Data type of the array.""" return self.__array_object__.dtype @property def c(self): """ Shorthand for `as_core()`. This returns the raw backend-specific array (e.g., `np.ndarray`, `unyt_array`, or HDF5 dataset), without applying any conversions or wrapping. Useful for advanced users who want direct access. Equivalent to: `self.as_core()` Returns ------- ArrayLike The backend-native data structure stored in this buffer. """ return self.__array_object__ @property def d(self): """ Shorthand for `as_array()`. This returns the buffer data as a plain `numpy.ndarray`, stripping any units or backend context. Equivalent to: `self.as_array()` Returns ------- numpy.ndarray The numerical contents of the buffer as a standard array. """ return self.as_array() @property def v(self): """ Shorthand for `as_unyt_array()`. This returns the buffer data as a `unyt_array`, preserving any attached physical units. Equivalent to: `self.as_unyt_array()` Returns ------- unyt.unyt_array Unit-tagged array of the buffer's data. """ return self.as_unyt_array()
[docs] def as_array(self) -> np.ndarray: """ Return the buffer as a NumPy array. Returns ------- numpy.ndarray """ return self.__array__(dtype=self.dtype)
[docs] def as_unyt_array(self) -> unyt.unyt_array: """ Convert the buffer contents into a `unyt_array` with attached units. This method returns the contents of the buffer as a `unyt.unyt_array`, using the physical units defined by the buffer (via the `units` property). This is particularly useful when working with buffers that store physical quantities and need to interoperate with unit-aware calculations. If the buffer has no defined units (`self.units` is `None`), the array is returned as a dimensionless `unyt_array`. Returns ------- unyt.unyt_array A unit-aware array representing the contents of this buffer. See Also -------- BufferBase.units : The unit system attached to the buffer. BufferBase.as_array : Returns the underlying array as a NumPy array. """ if self.units is None: units = "" else: units = self.units return unyt.unyt_array(self.as_array(), units)
[docs] def as_core(self) -> ArrayLike: """ Return the raw backend array object stored in this buffer. This method provides direct access to the internal array-like object (e.g., :py:class:`numpy.ndarray`, :py:class:`unyt.unyt_array`, or :py:class:`h5py.Dataset`) without any conversion or wrapping. It is useful for advanced users who need to access backend-specific methods or metadata not exposed through the generic buffer interface. Unlike :meth:`as_array`, this method returns the native format of the underlying backend, preserving units or lazy behavior if applicable. Returns ------- ArrayLike The unmodified internal array object stored in the buffer. """ return self.__array_object__
[docs] def as_repr(self) -> ArrayLike: """ Return a NumPy-compatible array for use in NumPy operations. This method is used internally to provide a consistent interface for applying NumPy ufuncs and broadcasting logic across different buffer backends. It returns an array-like object that is suitable for NumPy operations such as `np.sin()`, `np.add()`, or reductions like `np.sum()`. By default, this returns the result of :meth:`as_array`, which typically coerces the internal buffer into a standard `numpy.ndarray`. Subclasses may override this method to return more specialized representations (e.g., a `unyt_array` that preserves units, or a lazily sliced `h5py.Dataset`). Returns ------- ArrayLike A NumPy-operable array object, such as `numpy.ndarray`, `unyt_array`, or a backend-compatible equivalent. See Also -------- BufferBase.__array_ufunc__ : How NumPy dispatches operations on buffers. BufferBase.as_array : Returns the NumPy array representation used here by default. """ return self.as_array()
# ------------------------------ # # Standard Numpy Transformations # # ------------------------------ # def _apply_numpy_transform_on_repr( self, func: Callable, fargs, fkwargs, *args, **kwargs ) -> "BufferBase": """ Apply a NumPy-compatible transformation to the buffer's representation. This method is used to wrap high-level NumPy functions or methods (e.g., `np.reshape`, `np.copy`, `np.squeeze`) that operate on the buffer's `as_repr()` array, which may be a NumPy array or a unit-tagged `unyt_array`. Parameters ---------- func : Callable The NumPy-compatible function or method to apply. fargs : tuple Positional arguments for the function (not for `from_array`). fkwargs : dict Keyword arguments for the function (not for `from_array`). *args : Positional arguments passed to `.from_array()` after transformation. **kwargs : Keyword arguments passed to `.from_array()` after transformation. Returns ------- BufferBase A new buffer wrapping the transformed representation. """ repr_view = self.as_repr() return self.__class__.from_array( func(repr_view, *fargs, **fkwargs), *args, **kwargs ) def _apply_numpy_transform_on_core( self, func: Callable, fargs, fkwargs, *args, **kwargs ) -> "BufferBase": """ Apply a transformation to the raw backend array (`as_core()`). This method applies the given NumPy-compatible function directly to the buffer’s raw storage (e.g., `np.ndarray`, `unyt_array`, `h5py.Dataset`), and wraps the result in a new buffer instance. This is useful when you want to preserve the underlying structure and avoid unnecessary coercion to a representation type (e.g., for memory efficiency or backend-specific manipulations). Parameters ---------- func : Callable The function or method to apply to the core array. fargs : tuple Positional arguments for the function (not for `from_array`). fkwargs : dict Keyword arguments for the function (not for `from_array`). *args : Positional arguments passed to `.from_array()` after transformation. **kwargs : Keyword arguments passed to `.from_array()` after transformation. Returns ------- BufferBase A new buffer wrapping the transformed core array. """ core = self.as_core() return self.__class__.from_array(func(core, *fargs, **fkwargs), *args, **kwargs)
[docs] def copy(self, *args, **kwargs) -> "BufferBase": """ Return a deep copy of this buffer. This creates a new buffer instance containing a copy of the underlying array data. Any units or backend metadata are preserved, and the copy is fully detached from the original. Parameters ---------- *args : Additional positional arguments forwarded to :meth:`from_array`. **kwargs : Additional keyword arguments forwarded to :meth:`from_array`. Returns ------- BufferBase A deep copy of the current buffer. """ return self._apply_numpy_transform_on_repr(np.copy, [], {}, *args, **kwargs)
[docs] def astype(self, dtype: Any, *args, **kwargs) -> "BufferBase": """ Return a copy of this buffer with a different data type. This performs a type conversion using the underlying array and returns a new buffer of the same class with the updated `dtype`. Parameters ---------- dtype : data-type The target data type for the returned array. *args : Additional positional arguments forwarded to `from_array`. **kwargs : Additional keyword arguments forwarded to `astype()` and `from_array`. Returns ------- BufferBase A new buffer instance with the specified data type. """ return self.__class__.from_array(self.as_repr().astype(dtype), *args, **kwargs)
[docs] def reshape(self, shape, *args, **kwargs) -> "BufferBase": """ Return a reshaped copy of this buffer. This reshapes the buffer into a new shape and returns a new buffer instance. The reshaping is done using the NumPy-compatible view returned by `as_repr()`. Parameters ---------- shape : tuple of int Target shape for the new buffer. *args : Additional positional arguments forwarded to `from_array`. **kwargs : Additional keyword arguments forwarded to `from_array`. Returns ------- BufferBase A new buffer with reshaped data. """ return self._apply_numpy_transform_on_repr( np.reshape, [shape], {}, *args, **kwargs )
[docs] def transpose(self, axes=None, *args, **kwargs) -> "BufferBase": """ Return this buffer with axes transposed. See :func:`numpy.transpose`. Parameters ---------- axes : tuple or list of ints, optional If specified, it must be a tuple or list which contains a permutation of [0, 1, ..., N-1] where N is the number of axes of `self`. Negative indices can also be used to specify axes. The i-th axis of the returned array will correspond to the axis numbered ``axes[i]`` of the input. If not specified, defaults to ``range(self.ndim)[::-1]``, which reverses the order of the axes. *args : Additional positional arguments forwarded to `from_array`. **kwargs : Additional keyword arguments forwarded to `from_array`. Returns ------- BufferBase A new transposed buffer. """ return self._apply_numpy_transform_on_repr( np.transpose, [axes], {}, *args, **kwargs )
[docs] def flatten(self, *args, order="C", **kwargs) -> "BufferBase": """ Return a flattened 1D view of this buffer. This flattens the buffer using the specified memory layout and returns a new buffer instance. Parameters ---------- order : {'C','F', 'A', 'K'}, optional The elements of `a` are read using this index order. 'C' means to index the elements in row-major, C-style order, with the last axis index changing fastest, back to the first axis index changing slowest. 'F' means to index the elements in column-major, Fortran-style order, with the first index changing fastest, and the last index changing slowest. Note that the 'C' and 'F' options take no account of the memory layout of the underlying array, and only refer to the order of axis indexing. 'A' means to read the elements in Fortran-like index order if `a` is Fortran *contiguous* in memory, C-like order otherwise. 'K' means to read the elements in the order they occur in memory, except for reversing the data when strides are negative. By default, 'C' index order is used. *args : Additional positional arguments forwarded to `from_array`. **kwargs : Additional keyword arguments forwarded to `from_array`. Returns ------- BufferBase A contiguous 1-D array of the same subtype as `self`, with shape ``(self.size,)``. Note that matrices are special cased for backward compatibility, if `self` is a matrix, then y is a 1-D ndarray. """ return self.__class__.from_array( self.as_repr().flatten(order=order), *args, **kwargs )
[docs] def squeeze(self, *args, axis=None, **kwargs) -> "BufferBase": """ Remove axes of length one from `self`. Parameters ---------- axis : None or int or tuple of ints, optional Selects a subset of the entries of length one in the shape. If an axis is selected with shape entry greater than one, an error is raised. *args : Additional positional arguments forwarded to `from_array`. **kwargs : Additional keyword arguments forwarded to `from_array`. Returns ------- BufferBase The input buffer, but with all or a subset of the dimensions of length 1 removed. This is always `self` itself or a view into `self`. Note that if all axes are squeezed, the result is a 0d array and not a scalar. """ return self._apply_numpy_transform_on_repr( np.squeeze, [axis], {}, *args, **kwargs )
[docs] def expand_dims(self, axis: int, *args, **kwargs) -> "BufferBase": """ Expand the shape of an array. Insert a new axis that will appear at the `axis` position in the expanded array shape. Parameters ---------- axis : int or tuple of ints Position in the expanded axes where the new axis (or axes) is placed. *args : Additional positional arguments forwarded to `from_array`. **kwargs : Additional keyword arguments forwarded to `from_array`. Returns ------- BufferBase A buffer with the expanded shape. """ return self._apply_numpy_transform_on_repr( np.expand_dims, [axis], {}, *args, **kwargs )
[docs] def broadcast_to(self, shape: Any, *args, **kwargs) -> "BufferBase": """ Broadcast an array to a new shape. Parameters ---------- shape : tuple or int The shape of the desired output array. A single integer ``i`` is interpreted as ``(i,)``. Returns ------- broadcast : BufferBase A readonly view on the original array with the given shape. It is typically not contiguous. Furthermore, more than one element of a broadcasted array may refer to a single memory location. Raises ------ ValueError If the array is not compatible with the new shape according to NumPy's broadcasting rules. """ return self._apply_numpy_transform_on_repr( np.broadcast_to, [shape], {}, *args, **kwargs )
# ------------------------------ # # Unit Handling # # ------------------------------ # # These method supplement those above to help with # unit handling. # === Inplace unit manipulation === #
[docs] @abstractmethod def convert_to_units( self, units: Union[str, unyt.Unit], equivalence=None, **kwargs ): """ Convert this buffer's data to the specified physical units (in-place). This operation replaces the buffer's internal data with a unit-converted equivalent. It modifies the object directly. Not all buffer classes support in-place unit assignment. Subclasses that do not should override this method to raise an appropriate error. Parameters ---------- units : str or unyt.Unit Target units to convert the data to. equivalence : str, optional Unit equivalence to apply during conversion (e.g., "mass_energy"). **kwargs : Additional keyword arguments forwarded to the equivalence logic. Raises ------ UnitConversionError If the conversion is not dimensionally consistent. NotImplementedError If the subclass does not support in-place unit modification. Notes ----- If the buffer's units are `None`, this method assigns the new units directly without modifying data. Otherwise, it performs a physical conversion. """ raise ValueError( f"Cannot set units for buffer of class {self.__class__.__name__}." )
[docs] def convert_to_base(self, unit_system=None, equivalence=None, **kwargs): """ Convert this buffer in-place to base units for the given unit system. The base units are those defined by `unyt` for the specified unit system. This is equivalent to calling `convert_to_units` with `.get_base_equivalent()`. Parameters ---------- unit_system : str, optional Unit system to use for base units (e.g., "mks", "cgs"). If not provided, defaults to MKS. equivalence : str, optional Equivalence scheme to use during the conversion (if applicable). **kwargs : Additional keyword arguments forwarded to the equivalence. Raises ------ UnitConversionError If the conversion is not dimensionally valid. NotImplementedError If the buffer does not support unit conversion. """ self.convert_to_units( self.units.get_base_equivalent(unit_system), equivalence=equivalence, **kwargs, )
# === Casting Unit Manipulation === #
[docs] def in_units( self, units, *args, equivalence=None, buffer_class=None, buffer_registry=None, as_array: bool = False, equiv_kw: Optional[dict] = None, **kwargs, ): """ Return a new copy of this buffer cast to the specified physical units. This method is non-destructive and returns either a new buffer or a raw unit-tagged `unyt_array`. It is the preferred way to convert units for downstream usage or manipulation. Parameters ---------- units : str or unyt.Unit Target physical units to cast to. equivalence : str, optional Name of a supported `unyt` equivalence scheme (e.g., "mass_energy"). buffer_class : type, optional If provided, explicitly wrap result in this buffer class. buffer_registry : BufferRegistry, optional If resolving from array, use this registry. as_array : bool, default False If True, return a `unyt_array` instead of re-wrapping as a buffer. equiv_kw : dict, optional Keyword arguments for the equivalence function. *args, **kwargs : Passed to the buffer constructor if wrapping is performed. Returns ------- unyt_array or BufferBase Either a raw unit-tagged array or a new buffer with the requested units. Raises ------ UnitConversionError If the units are incompatible. """ # Cast to unyt array. uarr = self.as_unyt_array() # Cast to the correct units. equiv_kw = equiv_kw or {} converted = uarr.to(units, equivalence=equivalence, **equiv_kw) # Figure out the returning system. if as_array: return converted else: return buffer_from_array( converted, *args, buffer_class=buffer_class, buffer_registry=buffer_registry, **kwargs, )
[docs] def to( self, units, *args, equivalence=None, buffer_class=None, buffer_registry=None, as_array: bool = False, **kwargs, ): """ Return a new buffer (or array) with values cast to the specified units. This is a shorthand for `.in_units(...)`, and fully equivalent in functionality. Parameters ---------- units : str or unyt.Unit Desired output units. equivalence : str, optional Optional equivalence name for converting between dimensionally different types. buffer_class : type, optional Explicit buffer type for re-wrapping. buffer_registry : BufferRegistry, optional Optional registry to use for resolution. as_array : bool, default False If True, return raw `unyt_array` instead of a buffer. *args, **kwargs : Forwarded to `.in_units`. Returns ------- BufferBase or unyt_array Buffer (or array) in the new units. See Also -------- in_units : Underlying method. """ return self.in_units( units, *args, equivalence=equivalence, buffer_class=buffer_class, buffer_registry=buffer_registry, as_array=as_array, **kwargs, )
[docs] def to_value( self, units, equivalence=None, **kwargs, ): """ Return a NumPy array of values converted to the specified physical units. This is equivalent to calling `.in_units(..., as_array=True).value`. It strips unit information and returns a plain NumPy array for interoperability. Parameters ---------- units : str or unyt.Unit Target units for conversion. equivalence : str, optional Equivalence name (e.g., "mass_energy"). **kwargs : Additional arguments passed to `unyt.to`. Returns ------- numpy.ndarray Data in the specified units, stripped of unit tags. """ return self.as_unyt_array().to_value(units, equivalence=equivalence, **kwargs)
# === Registry Integration === #
[docs] @classmethod def resolve( cls, array_like: Any, *args, buffer_registry: Optional["BufferRegistry"] = None, **kwargs, ) -> "BufferBase": """ Resolve and instantiate a buffer subclass for an arbitrary array-like input. This method delegates to :func:`buffer_from_array`, which attempts to find a compatible buffer backend and coerce the input into it. The registry dispatch system is used unless explicitly overridden. Parameters ---------- array_like : Any An array-like object to be wrapped as a buffer. Supported types vary depending on the registered buffer classes (e.g., `np.ndarray`, `unyt_array`, `h5py.Dataset`). buffer_registry : _BufferRegistry, optional A custom buffer registry to use for dispatch. If None (default), the global `__DEFAULT_BUFFER_REGISTRY__` will be used. *args, **kwargs : dict Additional arguments passed to the :func:`buffer_from_array` method of the resolved buffer class. These may include unit annotations, dtype specifications, HDF5 parameters, etc. Returns ------- BufferBase An instance of the appropriate buffer subclass, wrapping the adapted array data. Raises ------ TypeError If no compatible buffer class is found in the registry for the given object type. See Also -------- buffer_from_array : General-purpose resolution utility. BufferBase.from_array : Class-based coercion. BufferBase.coerce : Direct array conversion method. """ return buffer_from_array( array_like, *args, buffer_registry=buffer_registry, **kwargs )
[docs] def buffer_from_array( obj: Any, *args, buffer_class: Optional[Type["BufferBase"]] = None, buffer_registry: Optional["BufferRegistry"] = None, **kwargs, ) -> "BufferBase": """ Construct a buffer from a raw array-like object. This function performs the **buffer resolution** process (see :ref:`buffers`) to determine a suitable buffer to wrap the provided object. It is the recommended high-level interface for constructing buffers when the underlying storage format is not known in advance (e.g., NumPy, unyt, HDF5). Parameters ---------- obj : array-like Input data to wrap (e.g., :py:class:`list`, :py:class:`~numpy.ndarray`, :py:class:`~unyt.array.unyt_array`, :py:class:`~h5py.Dataset`, etc.). By default, the type of `obj` will be used in conjunction with `registry` to determine which buffer class is used to wrap the array. If `buffer_class` is explicitly provided, then an attempt will be made to wrap the array with that class instead (regardless of the registry). buffer_registry : ~fields.buffers.registry.BufferRegistry, optional A custom buffer registry to use for automatic resolution. If None (default), uses the global ``__DEFAULT_BUFFER_REGISTRY__``. buffer_class : :py:class:`~fields.buffers.base.BufferBase`, optional An explicit buffer class to use instead of registry resolution. If specified, the function bypasses registry lookup and directly calls :meth:`~BufferBase.from_array`. *args, **kwargs : Additional arguments forwarded to the :meth:`~BufferBase.from_array` method. Returns ------- ~fields.buffers.base.BufferBase A fully constructed buffer instance wrapping the input data. Raises ------ TypeError If no compatible buffer type is found in the registry (when ``buffer_class`` is not specified), or if the input is not valid for the explicitly provided ``buffer_class``. Examples -------- By default, the correct buffer class is resolved vis-a-vis the registry. As such, if you simply support an array-like input, a valid buffer will be constructed: - A :py:class:`list`, :py:class:`tuple`, etc. will be interpreted as an array: >>> from pymetric.fields.buffers.core import ArrayBuffer, UnytArrayBuffer >>> buffer_from_array([1, 2, 3]) ArrayBuffer(shape=(3,), dtype=int64) - A :py:class:`~unyt.array.unyt_array` will be interpreted as an :py:class:`~fields.buffers.core.UnytArrayBuffer`: >>> from unyt import unyt_array >>> from pymetric.fields.buffers.core import ArrayBuffer, UnytArrayBuffer >>> buffer_from_array(unyt_array([1, 2, 3],units='keV')) UnytArrayBuffer(shape=(3,), dtype=int64) You can also **enforce** a particular buffer class by specifying the ``buffer_class``: >>> from pymetric.fields.buffers.core import ArrayBuffer, UnytArrayBuffer >>> u = buffer_from_array([1, 2, 3],buffer_class=UnytArrayBuffer) >>> >>> # Let's look at the type and the units >>> print(type(u), u.units) <class 'pymetric.fields.buffers.core.UnytArrayBuffer'> dimensionless >>> >>> # The units can be specified in kwargs: >>> u = buffer_from_array([1, 2, 3],buffer_class=UnytArrayBuffer, units='keV') >>> print(type(u), u.units) <class 'pymetric.fields.buffers.core.UnytArrayBuffer'> keV Notes ----- - If `buffer_class` is provided, the registry is ignored. - If `buffer_class` is not provided, resolution proceeds via the registry, honoring the ``__resolution_priority__`` values of registered buffer classes. - This method is especially useful in backend-agnostic workflows, field initialization logic, or serialization pipelines. See Also -------- :py:meth:`~fields.buffers.base.BufferBase.from_array`: Class-specific buffer creation method. ~fields.buffers.registry.BufferRegistry.resolve : Resolve from a specific buffer registry. """ if buffer_class is not None: return buffer_class.from_array(obj, *args, **kwargs) if buffer_registry is None: from pymetric.fields.buffers.registry import __DEFAULT_BUFFER_REGISTRY__ buffer_registry = __DEFAULT_BUFFER_REGISTRY__ return buffer_registry.resolve(obj, **kwargs)