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