"""
Defines the AbstractGrid class and child classes.
"""

from collections.abc import Sequence

__all__ = [
    "AbstractGrid",
    "CartesianGrid",
    "NonUniformCartesianGrid",
]

import contextlib
import warnings
from abc import ABC, abstractmethod
from collections import namedtuple
from collections.abc import Iterable
from functools import cached_property
from typing import ClassVar

import astropy.units as u
import numpy as np
import pandas as pd
import scipy.interpolate as interp
import xarray as xr
from scipy.spatial import distance

from plasmapy.utils.decorators.helpers import modify_docstring


def _detect_is_uniform_grid(pts0, pts1, pts2, tol: float = 1e-6):
    r"""
    Determine whether a grid is uniform (uniformly spaced) by computing the
    variance of the grid gradients.
    """
    variance = np.zeros([3])
    dx = np.gradient(pts0, axis=0)
    variance[0] = np.std(dx) / np.mean(dx)
    dy = np.gradient(pts1, axis=1)
    variance[1] = np.std(dy) / np.mean(dy)
    dz = np.gradient(pts2, axis=2)
    variance[2] = np.std(dz) / np.mean(dz)

    return np.allclose(variance, 0.0, atol=tol)


class AbstractGrid(ABC):
    r"""
    Abstract grid represents a 3D grid of positions. The grid is stored
    as an `~numpy.ndarray`, while the units associated with each
    dimension are stored separately.

    There are two preferred methods to creating a grid object:

    1. Initializing the grid by providing three 3D
       `~astropy.units.Quantity` arrays of positions along each axis
       (``xpoints``, ``ypoints``, ``zpoints``)

       .. code-block:: python

          AbstractGrid(xpoints, ypoints, zpoints)

    2. A new grid can also be created using a syntax similar to
       `numpy.linspace` by providing two three-element
       `~astropy.units.Quantity` arrays of start and stop values and
       setting the ``num`` keyword to the number of points along each axis.

       .. code-block:: python

          AbstractGrid(
              start=[x0, y0, z0],
              stop=[x1, y1, z1],
              num=[Nx, Ny, Nz],
              **kwargs,
          )

       In this case, any additional keyword arguments ``**kwargs`` provided
       will be passed directly to `~numpy.linspace`.

    """

    def __init__(self, *seeds: Sequence[u.Quantity], num: int = 100, **kwargs) -> None:
        # Initialize some variables
        self._interpolator = None
        self._is_uniform = None

        # If three inputs are given, assume it's a user-provided grid
        if len(seeds) == 3:
            self._load_grid(seeds[0], seeds[1], seeds[2])

        # If two inputs are given, assume they are start and stop arrays
        # to create a new grid
        # kwargs are passed to np.linspace in _make_grid()
        elif len(seeds) == 2:
            self._make_grid(seeds[0], seeds[1], num=num, **kwargs)

        else:
            raise TypeError(
                f"{self.__class__.__name__} takes 2 or 3 "
                f"positional arguments but {len(seeds)} were given"
            )

    def _validate(self) -> bool:
        r"""
        Checks to make sure that the grid parameters are
        consistent with the coordinate system and units selected.
        """
        return True

    # A named tuple describing a key recognized by PlasmaPy to correspond to
    # a particular physical quantity
    RecognizedQuantity = namedtuple(
        "RecognizedQuantities", ["key", "description", "unit"]
    )

    # These standard keys are used to refer to certain
    # physical quantities. This dictionary also provides the expected unit.
    _recognized_quantities_list: ClassVar[list[RecognizedQuantity]] = [
        RecognizedQuantity("x", "x spatial position", u.m),
        RecognizedQuantity("y", "y spatial position", u.m),
        RecognizedQuantity("z", "z spatial position", u.m),
        RecognizedQuantity("rho", "Mass density", u.kg / u.m**3),
        RecognizedQuantity("E_x", "Electric field (x component)", u.V / u.m),
        RecognizedQuantity("E_y", "Electric field (y component)", u.V / u.m),
        RecognizedQuantity("E_z", "Electric field (z component)", u.V / u.m),
        RecognizedQuantity("B_x", "Magnetic field (x component)", u.T),
        RecognizedQuantity("B_y", "Magnetic field (y component)", u.T),
        RecognizedQuantity("B_z", "Magnetic field (z component)", u.T),
        RecognizedQuantity("phi", "Electric Scalar Potential", u.V),
        RecognizedQuantity("n_e", "Electron Number Density", 1 / u.m**3),
        RecognizedQuantity("dEdx", "Stopping Power", u.J * u.m**2 / u.kg),
    ]

    # Create a dict of recognized quantities for fast access by key
    _recognized_quantities: ClassVar[list[RecognizedQuantity]] = {}
    for _rq in _recognized_quantities_list:
        _recognized_quantities[_rq.key] = _rq

    @classmethod
    def recognized_quantities(cls):
        r"""
        A dictionary of standard key names representing particular physical
        quantities. Using these keys allows these
        quantities to be recognized automatically by other PlasmaPy functions.
        Each entry contains a tuple containing a description and the unit
        associated with the quantity.
        """
        return cls._recognized_quantities

    def require_quantities(
        self,
        req_quantities: Iterable[str],
        replace_with_zeros: bool = False,
        warn_on_replace_with_zeros: bool = True,
    ):
        r"""
        Check to make sure that a list of required quantities are present.
        Optionally, can create missing quantities and fill them with
        an array of zeros.

        Parameters
        ----------
        req_quantities : ``iterable`` of `str`
            A list of quantity keys that are required.

        replace_with_zeros : `bool`, optional
            If true, missing quantities will be replaced with an array
            of zeros. If false, an exception will be raised instead.
            The default is False.

        warn_on_replace_with_zeros : `bool`, default: `True`
            If `True`, warn if a required quantity is replaced with an
            array of zeros. If `False`, no warning is shown.

        Raises
        ------
        KeyError
            If ``replace_with_zeros`` is `False` and a required quantity
            is missing.

        KeyError
            If ``replace_with_zeros`` is `True` but the
            `~astropy.units.Quantity` is not in the list of recognized
            quantities. In this case the units for the quantity are unknown,
            so an array of zeros cannot be constructed.
        """
        for rq in req_quantities:
            # Error check that grid contains E and B variables required
            if rq not in self.quantities:
                # If missing, warn user and then replace with an array of zeros
                if not replace_with_zeros:
                    raise KeyError(
                        f"{rq} is not specified for the provided "
                        "grid but is required."
                    )
                elif rq not in self.recognized_quantities():
                    raise KeyError(
                        f"{rq} is not a recognized key, and "
                        "so cannot be automatically assumed "
                        "to be zero."
                    )

                if warn_on_replace_with_zeros:
                    warnings.warn(
                        f"{rq} is not specified for the provided grid."
                        "This quantity will be assumed to be zero.",
                        RuntimeWarning,
                    )

                unit = self.recognized_quantities()[rq].unit
                arg = {rq: np.zeros(self.shape) * unit}
                self.add_quantities(**arg)

    # *************************************************************************
    # Fundamental properties of the grid
    # *************************************************************************

    def __repr__(self) -> str:
        line_sep = "-----------------------------\n"
        shape = list(self.shape)
        coords = list(self.ds.coords.keys())
        ax_units = self.units
        ax_dtypes = [self.ds[i].dtype for i in coords]

        coord_lbls = [f"{i}: {j}" for i, j in zip(coords, shape, strict=False)]

        s = f"*** Grid Summary ***\n{type(self)}\n"

        s += f"Dimensions: ({', '.join(coord_lbls)})\n"

        if self.is_uniform:
            s += (
                "Uniformly Spaced: (dax0, dax1, dax2) = "
                f"({self.dax0:.3f}, {self.dax1:.3f}, {self.dax2:.3f})\n"
            )
        else:
            s += "Non-Uniform Spacing\n"

        s += line_sep + "Coordinates:\n"
        for i in range(len(self.shape)):
            s += f"\t-> {coords[i]} ({ax_units[i]}) {ax_dtypes[i]} ({shape[i]},)\n"

        keys = self.quantities
        rkeys = [k for k in keys if k in list(self.recognized_quantities().keys())]
        nrkeys = [k for k in keys if k not in list(self.recognized_quantities().keys())]

        s += line_sep + "Recognized Quantities:\n"
        if not rkeys:
            s += "-None-\n"
        else:
            for key in rkeys:
                unit = self.ds[key].attrs["unit"]
                dtype = self.ds[key].dtype
                shape = self.ds[key].shape
                s += f"\t-> {key} ({unit}) {dtype} {shape} \n"

        s += line_sep + "Unrecognized Quantities:\n"
        if not nrkeys:
            s += "-None-\n"
        else:
            for key in nrkeys:
                unit = self.ds[key].attrs["unit"]
                dtype = self.ds[key].dtype
                shape = self.ds[key].shape
                s += f"\t-> {key} ({unit}) {dtype} {shape} \n"

        return s

    def __getitem__(self, key):
        """
        Given a key, return the corresponding array as a `~astropy.units.Quantity`.

        Returning with ``copy=False`` means that the array returned is a direct
        reference to the underlying DataArray, so changes made will be reflected
        in the underlying DataArray.
        """
        return u.Quantity(self.ds[key].data, self.ds[key].attrs["unit"], copy=False)

    @property
    def is_uniform(self) -> bool:
        """
        A boolean value reflecting whether or not the grid points are
        uniformly spaced.
        """

        if self._is_uniform is None:
            raise ValueError(
                "The `is_uniform` attribute is not accessible "
                "before a grid has been loaded."
            )

        return self._is_uniform

    @property
    def shape(self):
        r"""Shape of the grid."""
        if self.is_uniform:
            return (self.ax0.size, self.ax1.size, self.ax2.size)

        return self.ds.coords["ax0"].shape

    @property
    def grids(self):
        r"""
        Three grids of vertex positions (in each coordinate), each having
        shape (N0, N1, N2).
        """
        if not self.is_uniform:
            return (
                self.ds["ax0"].data * self.unit0,
                self.ds["ax1"].data * self.unit1,
                self.ds["ax2"].data * self.unit2,
            )

        pts0, pts1, pts2 = np.meshgrid(self.ax0, self.ax1, self.ax2, indexing="ij")
        return pts0, pts1, pts2

    @property
    def grid(self):
        r"""
        A single grid of vertex positions of shape (N0, N1, N2, 3).

        Only defined for grids for which the ``unit`` property is defined.
        """
        pts0, pts1, pts2 = self.grids
        if self.is_uniform:
            n0, n1, n2 = pts0.shape
            grid = np.zeros([n0, n1, n2, 3]) * self.unit
        else:
            n = pts0.size
            grid = np.zeros([n, 3]) * self.unit

        grid[..., 0] = pts0
        grid[..., 1] = pts1
        grid[..., 2] = pts2
        return grid

    @property
    def pts0(self):
        r"""Array of positions in dimension 1."""
        return self.grids[0]

    @property
    def pts1(self):
        r"""Array of positions in dimension 2."""
        return self.grids[1]

    @property
    def pts2(self):
        r"""Array of positions in dimension 3."""
        return self.grids[2]

    @property
    def units(self) -> list:
        r"""A `list` of the units of each dimension."""
        return self.ds.attrs["axis_units"]

    @property
    def unit0(self):
        r"""Unit of dimension 1."""
        return self.units[0]

    @property
    def unit1(self):
        r"""Unit of dimension 2."""
        return self.units[1]

    @property
    def unit2(self):
        r"""Unit of dimension 3."""
        return self.units[2]

    @property
    def unit(self):
        r"""
        The unit for the entire grid. Only valid if all dimensions of the
        grid have the same units.

        Raises
        ------
        `ValueError`
            If all grid dimensions do not have identical units.
        """
        if self.units[0] == self.units[1] == self.units[2]:
            return self.units[0]
        else:
            raise ValueError(
                f"Array dimensions do not all have the same units: {self.units}"
            )

    # *************************************************************************
    # 1D axes and step sizes (valid only for uniform grids)
    # *************************************************************************
    @property
    def si_scale_factors(self) -> list[float]:
        """
        3-element list containing unitless scale factors for converting
        the corresponding axis from its stored units to SI.
        """
        return self._si_factors

    def _get_ax(self, *, axis: int, si: bool = False):
        """
        Helper function for retrieving axis values.

        Parameters
        ----------
        axis: int
            Axis index for axis of interest (e.g. ``0`` for :attr:`ax0`).
        si: bool
            (Default: `False`) If `True` then convert the axis values to
            their SI equivalent.

        Returns
        -------
        ~numpy.ndarray or `~astropy.units.Quantity`
            If ``si==True`` then return a unitless `~numpy.ndarray`.
            If ``si==False`` then return a `~astropy.units.Quantity`
            array with the original units.

        Raises
        ------
        ValueError
            If the grid is not uniform.
        """
        ax_name = f"ax{axis}"

        if not self.is_uniform:
            raise ValueError(
                "The axis properties are only valid on uniformly spaced grids."
            )

        vals = self.ds.coords[ax_name].to_numpy()
        return vals * self.si_scale_factors[axis] if si else vals * self.units[axis]

    def _get_dax(self, *, axis: int, si: bool = False):
        """
        Helper function for calculating grid spacing.

        See Also
        --------
        plasmapy.plasma.grids.AbstractGrid._get_ax
        """
        ax = self._get_ax(axis=axis, si=si)
        return np.mean(np.gradient(ax))

    @property
    def _ax0_si(self):
        """
        The :attr:`ax0` axis without units, but scaled such that its values
        correspond to SI values.

        Only valid for uniform grids.
        """
        return self._get_ax(axis=0, si=True)

    @property
    def ax0(self):
        r"""
        First axis of the grid.

        Only valid for uniform grids.
        """
        return self._get_ax(axis=0)

    @property
    def _ax1_si(self):
        """
        The :attr:`ax1` axis without units, but scaled such that its values
        correspond to SI values.

        Only valid for uniform grids.
        """
        return self._get_ax(axis=1, si=True)

    @property
    def ax1(self):
        r"""
        Second axis of the grid.

        Only valid for uniform grids.
        """
        return self._get_ax(axis=1)

    @property
    def _ax2_si(self):
        """
        The :attr:`ax2` axis without units, but scaled such that its values
        correspond to SI values.

        Only valid for uniform grids.
        """
        return self._get_ax(axis=2, si=True)

    @property
    def ax2(self):
        r"""
        Third axis of the grid.

        Only valid for uniform grids.
        """
        return self._get_ax(axis=2)

    @property
    def _dax0_si(self):
        """
        Grid step size along axis :attr:`ax0` without units and scaled such
        that its values correspond to SI values.

        Only valid for uniform grids.
        """
        return self._get_dax(axis=0, si=True)

    @property
    def dax0(self):
        r"""
        Grid step size along axis :attr:`ax0`.

        Only valid for uniform grids.
        """
        return self._get_dax(axis=0)

    @property
    def _dax1_si(self):
        """
        Grid step size along axis :attr:`ax1` without units and scaled such
        that its values correspond to SI values.

        Only valid for uniform grids.
        """
        return self._get_dax(axis=1, si=True)

    @property
    def dax1(self):
        r"""
        Grid step size along axis :attr:`ax1`.

        Only valid for uniform grids.
        """
        return self._get_dax(axis=1)

    @property
    def _dax2_si(self):
        """
        Grid step size along axis :attr:`ax2` without units and scaled such
        that its values correspond to SI values.

        Only valid for uniform grids.
        """
        return self._get_dax(axis=2, si=True)

    @property
    def dax2(self):
        r"""
        Grid step size along axis :attr:`ax2`.

        Only valid for uniform grids.
        """
        return self._get_dax(axis=2)

    # *************************************************************************
    # Loading and creating grids
    # *************************************************************************

    def _load_grid(
        self,
        pts0: u.Quantity,
        pts1: u.Quantity,
        pts2: u.Quantity,
    ):
        r"""
        Initialize the grid object from a user-supplied grid.

        Parameters
        ----------
        grid{0,1,2} : `~astropy.units.Quantity` array, shape (n0, n1, n2)
            Grids of coordinate positions.

        **kwargs : `~astropy.units.Quantity` array, shape (n0, n1, n2)
            Quantities defined on the grid.
        """

        # Validate input
        if pts0.shape != pts1.shape or pts0.shape != pts2.shape:
            raise ValueError(
                "Provided arrays of grid points are of unequal "
                f"shape: pts0 = {pts0.shape}, "
                f"pts1 = {pts1.shape}, "
                f"pts2 = {pts2.shape}."
            )

        self._is_uniform = _detect_is_uniform_grid(pts0, pts1, pts2)

        # Create dataset
        self.ds = xr.Dataset()

        self.ds.attrs["axis_units"] = [pts0.unit, pts1.unit, pts2.unit]

        # Store the conversion factors for each axis to SI
        self._si_factors = [
            pts0.unit.si.scale,
            pts1.unit.si.scale,
            pts2.unit.si.scale,
        ]

        if self.is_uniform:
            self.ds.coords["ax0"] = pts0[:, 0, 0]
            self.ds.coords["ax1"] = pts1[0, :, 0]
            self.ds.coords["ax2"] = pts2[0, 0, :]

        else:
            mdx = pd.MultiIndex.from_arrays(
                [pts0.flatten(), pts1.flatten(), pts2.flatten()],
                names=["ax0", "ax1", "ax2"],
            )
            self.ds.coords["ax"] = mdx

        # Check to make sure that the object created satisfies any
        # requirements: eg. units correspond to the coordinate system
        self._validate()

    def add_quantities(self, **kwargs: u.Quantity) -> None:
        r"""
        Adds a quantity to the dataset as a new DataArray.

        Parameters
        ----------
        **kwargs : key, array pairs
            The key will be used as the dataset key, while the array holds the
            quantity.
        """

        for key, quantity in kwargs.items():
            # Check key against a list of "known" keys with pre-defined
            # meanings (eg. E_x, n_e) and raise a warning if a "non-standard"
            # key is being used so the user is aware.
            if key in self.recognized_quantities():
                try:
                    quantity.to(self.recognized_quantities()[key].unit)
                except u.UnitConversionError as ex:
                    raise ValueError(
                        f"Units provided for {key} ({quantity.unit}) "
                        "are not compatible with the correct units "
                        f"for that recognized key ({self.recognized_quantities()[key]})."
                    ) from ex

            else:
                warnings.warn(
                    f"Warning: {key} is not recognized quantity key", stacklevel=2
                )

            if self.is_uniform:
                dims = ["ax0", "ax1", "ax2"]
                coords = {
                    "ax0": self.ds.coords["ax0"],
                    "ax1": self.ds.coords["ax1"],
                    "ax2": self.ds.coords["ax2"],
                }
            # If grid is non-uniform, flatten quantity
            else:
                quantity = quantity.flatten()  # noqa: PLW2901
                dims = ["ax"]
                coords = {"ax": self.ds.coords["ax"]}

            if quantity.shape != self.shape:
                raise ValueError(
                    f"Shape of quantity '{key}' {quantity.shape} "
                    f"does not match the grid shape {self.shape}."
                )

            data = xr.DataArray(
                quantity, dims=dims, coords=coords, attrs={"unit": quantity.unit}
            )
            self.ds[key] = data

    @property
    def quantities(self):
        r"""
        A list of the keys corresponding to the quantities currently
        defined on the grid.
        """
        return list(self.ds.data_vars)

    def _make_grid(  # noqa: C901, PLR0912
        self,
        start: float | u.Quantity,
        stop: float | u.Quantity,
        num: int = 100,
        units=None,
        **kwargs,
    ):
        r"""
        Creates a grid based on ``start``, ``stop``, and ``num`` values
        in a manner that mirrors the interface of the `numpy.linspace`
        function.

        Parameters
        ----------
        start : number (`~astropy.units.Quantity`) or an array, list, or
            tuple of three of the same.
            Starting values for each dimension. If one value is given,
            the same value will be used for all three dimensions.

        stop : number (`~astropy.units..Quantity`) or an array, list, or
            tuple of three of the same.
            End values for each dimension. If one value is given,
            the same value will be used for all three dimensions.

        num : `int` or `list` or `tuple` of three `int` objects, optional
            The number of points in each dimension. If a single integer is
            given, the same number of points will be used in each dimension.
            The default is 100.

        **kwargs : Additional arguments
            Any additional arguments will be passed directly to
            `numpy.linspace`.
        """

        # Store variables in dict for validation
        var = {"stop": stop, "start": start, "num": num}

        # Ensure that start and stop end up as a list of three u.Quantity objs
        # and num a list of three integers
        # TODO: python3.10: simplify using structural pattern matching
        for k in ("start", "stop"):
            # Convert tuple to list
            if isinstance(var[k], tuple):
                var[k] = list(var[k])

            if isinstance(var[k], list):
                if len(var[k]) == 1:
                    var[k] = var[k] * 3

                # Make sure it's a list of quantities
                if not all(isinstance(v, u.Quantity) for v in var[k]):
                    raise TypeError(
                        f"The argument `{k}` must be an "
                        "`astropy.units.Quantity` or a list of same, "
                        f"but a {type(var[k])} was given."
                    )
            elif isinstance(var[k], u.Quantity):
                # Extend to 3 elements if only one is given
                # Case of >1 but != 3 is handled later
                var[k] = [var[k]] * 3 if var[k].size == 1 else list(var[k])
            else:
                raise TypeError(
                    f"The argument `{k}` must be an "
                    "`astropy.units.Quantity` or a list of same, "
                    f"but a {type(var[k])} was given."
                )

        # Convert tuple to list
        if isinstance(var["num"], tuple):
            var["num"] = list(var["num"])

        if isinstance(var["num"], list):
            if len(var["num"]) == 1:
                var["num"] = var["num"] * 3
        elif isinstance(var["num"], int):
            var["num"] = [var["num"]] * 3
        else:
            raise TypeError(
                f"The argument `num` must be an int or list of "
                f"same, but a {type(var[k])} was given."
            )

        # Check to make sure all lists now contain three values
        # (throws exception if user supplies a list of two, say)
        for k in var:
            if len(var[k]) != 3:
                raise TypeError(
                    f"{k} must be either a single value or a "
                    "list of three values, but "
                    f"({len(var[k])} values were given)."
                )

        # Take variables back out of dict
        start = var["start"]
        stop = var["stop"]
        num = var["num"]

        # Extract units from input arrays (if they are there), then
        # remove the units from those arrays
        units = []
        for i in range(3):
            # Determine unit for dimension
            unit = start[i].unit
            units.append(unit)

            # Attempt to convert stop unit to start unit
            try:
                stop[i] = stop[i].to(unit)

            except u.UnitConversionError as ex:
                raise ValueError(
                    f"Units of {stop[i]} and {unit} are not compatible"
                ) from ex

            # strip units
            stop[i] = stop[i].value
            start[i] = start[i].value

        # Create coordinate mesh
        pts0, pts1, pts2 = self._make_mesh(start, stop, num, **kwargs)

        # Load into the dataset using the _load_grid function
        self._load_grid(
            pts0 * units[0],
            pts1 * units[1],
            pts2 * units[2],
        )

    def _make_mesh(self, start, stop, num: int, **kwargs):
        r"""
        Creates mesh as part of _make_grid(). Separated into its own function
        so it can be re-implemented to make non-uniformly spaced meshes.
        """
        # Construct the axis arrays
        ax0 = np.linspace(start[0], stop[0], num=num[0], **kwargs)
        ax1 = np.linspace(start[1], stop[1], num=num[1], **kwargs)
        ax2 = np.linspace(start[2], stop[2], num=num[2], **kwargs)

        # Construct the coordinate arrays
        pts0, pts1, pts2 = np.meshgrid(ax0, ax1, ax2, indexing="ij")

        return pts0, pts1, pts2

    # *************************************************************************
    # Methods
    # *************************************************************************

    def on_grid(self, pos):
        r"""
        Given a list of positions, determines which are in the region
        bounded by the grid points.

        For non-uniform grids, "on grid" is defined as being bounded by
        grid points in all axes.

        Parameters
        ----------
        pos : `~numpy.ndarray` or `~astropy.units.Quantity` array, shape (n,3)
            An array of positions in space, where the second dimension
            corresponds to the three dimensions of the grid.

        """

        if hasattr(pos, "unit"):
            pos = pos.si.value

        # Find the bounds
        if self.is_uniform:
            ax0_min, ax0_max = np.min(self.ax0.si.value), np.max(self.ax0.si.value)
            ax1_min, ax1_max = np.min(self.ax1.si.value), np.max(self.ax1.si.value)
            ax2_min, ax2_max = np.min(self.ax2.si.value), np.max(self.ax2.si.value)

        else:
            pts0, pts1, pts2 = self.grids
            ax0_min, ax0_max = np.min(self.pts0).si.value, np.max(self.pts0).si.value
            ax1_min, ax1_max = np.min(self.pts1).si.value, np.max(self.pts1).si.value
            ax2_min, ax2_max = np.min(self.pts2).si.value, np.max(self.pts2).si.value

        # Check each point elementwise against the bounds
        on_grid = (
            np.greater(ax0_min, pos[:, 0]).astype(np.int8)
            + np.less(ax0_max, pos[:, 0]).astype(np.int8)
            + np.greater(ax1_min, pos[:, 1]).astype(np.int8)
            + np.less(ax1_max, pos[:, 1]).astype(np.int8)
            + np.greater(ax2_min, pos[:, 2]).astype(np.int8)
            + np.less(ax2_max, pos[:, 2]).astype(np.int8)
        )

        return np.where(on_grid == 0, True, False)  # noqa: FBT003

    @abstractmethod
    def vector_intersects(self, p1, p2):
        r"""
        `True` if the vector from ``p1`` to ``p2`` intersects the grid,
        and `False` otherwise.

        The definition of 'intersects' is determined by the implementation
        for each subclass.
        """
        ...

    # *************************************************************************
    # Interpolators
    # *************************************************************************

    # This property holds the list of quantity keys currently being interpolated
    # It's used in the following cached properties
    _interp_args: ClassVar = []

    @cached_property
    def _interp_quantities(self):
        r"""Create a dimensionless array of quantities to be interpolated."""
        nargs = len(self._interp_args)
        # Load the arrays to be interpolated from and their units
        if self.is_uniform:
            nx, ny, nz = self.shape
            _interp_quantities = np.zeros([nx, ny, nz, nargs])
        else:
            npoints = self.shape[0]
            _interp_quantities = np.zeros([npoints, nargs])

        for j, arg in enumerate(self._interp_args):
            _interp_quantities[..., j] = self.ds[arg].to_numpy()

        return _interp_quantities

    @cached_property
    def _interp_units(self):
        r"""
        Create a list of the units corresponding to the last dimension
        in the `_interp_quantities` array.
        """
        return [self.ds[arg].attrs["unit"] for arg in self._interp_args]

    @abstractmethod
    def nearest_neighbor_interpolator(
        self, pos: np.ndarray | u.Quantity, *args, persistent: bool = False
    ):
        r"""
        Interpolate values on the grid using a nearest-neighbor scheme with
        no higher-order weighting.

        Parameters
        ----------
        pos : `~numpy.ndarray` or `~astropy.units.Quantity` array, shape (n,3)
            An array of positions in space, where the second dimension
            corresponds to the three dimensions of the grid. If an
            `~numpy.ndarray` is provided, units will be assumed to match
            those of the grid.

        *args : `str`
            Strings that correspond to DataArrays in the dataset

        persistent : `bool`
            If `True`, the interpolator will assume the grid and its
            contents have not changed since the last interpolation. This
            substantially speeds up the interpolation when many
            interpolations are performed on the same grid in a loop.
            ``persistent`` overrides to `False` if the arguments list
            has changed since the last call.
        """
        ...

    def _persistent_interpolator_setup(self, pos, args, persistent):
        r"""
        Setup common to all persistent interpolators.

        Parameters
        ----------
        pos : `~numpy.ndarray` or `~astropy.units.Quantity` array, shape (n,3)
            An array of positions in space, where the second dimension
            corresponds to the three dimensions of the grid. If a
            `~numpy.ndarray` is provided, units will be assumed to match
            those of the grid.

        args : `str`
            Strings that correspond to DataArrays in the dataset

        persistent : `bool`
            If `True`, the interpolator will assume the grid and its
            contents have not changed since the last interpolation. This
            substantially speeds up the interpolation when many
            interpolations are performed on the same grid in a loop.
            ``persistent`` overrides to `False` if the arguments list
            has changed since the last call.

        Returns
        -------
        pos: `~numpy.ndarray`
            Position array with dimensions fixed, units converted to
            standard and stripped.

        args : `str`
            Strings that correspond to DataArrays in the dataset

        persistent : `bool`
            If `True`, the interpolator will assume the grid and its
            contents have not changed since the last interpolation. This
            substantially speeds up the interpolation when many
            interpolations are performed on the same grid in a loop.
            ``persistent`` overrides to `False` if the arguments list
            has changed since the last call.

        Raises
        ------
        KeyError
            A KeyError is raised if one of the args does not correspond
            to a DataArray in the DataSet.
        """

        # Condition pos
        if isinstance(pos, u.Quantity):
            pos = pos.to(u.m).value
        elif self.unit != u.m:
            pos *= self.unit.si.scale
        # If a single point was given, add empty dimension
        if pos.ndim == 1:
            pos = np.reshape(pos, [1, 3])

        # -- Validate args --
        # must be np.ndarray or u.Quantity arrays of same shape as grid
        for arg in args:
            if arg not in self.quantities:
                raise KeyError(
                    "Quantity arguments must correspond to "
                    "DataArrays in the DataSet. "
                    f"{arg} was not found. "
                    f"Existing keys are: {self.quantities}"
                )

        # If persistent, double check the arguments list hasn't changed
        # If they have, run as non-persistent this time
        if persistent and args != self._interp_args:
            persistent = False

        # Update _interp_args variable
        self._interp_args = args

        # If not persistent, clear the cached properties so they are re-created
        # when called below
        if not persistent:
            with contextlib.suppress(AttributeError):
                del self._interp_quantities
            with contextlib.suppress(AttributeError):
                del self._interp_units

        return pos, args, persistent


def _fast_nearest_neighbor_interpolate(pos, ax):
    """
    Find the indices in the axis `ax` that are closest to the
    values in the array `pos`.

    Assumes the axis `ax` is sorted in ascending order.
    """
    # Find the index where each position would be inserted into the axis.
    # This is equivalent to a nearest neighbor interpolation but always
    # rounding to the next highest axis point.
    indices = np.searchsorted(ax, pos)

    # searchsorted can return an out-of-bound index on the top end, so replace
    # that point with the closest in bound index
    indices = np.where(indices == ax.size, ax.size - 1, indices)

    # For any points that are closer to the point below than the point above,
    # correct the index
    return np.where(
        np.abs(ax[indices] - pos) > np.abs(ax[indices - 1] - pos), indices - 1, indices
    )


class CartesianGrid(AbstractGrid):
    r"""A uniformly spaced Cartesian grid."""

    def _validate(self):
        # Check that all units are lengths
        for i in range(3):
            try:
                self.units[i].to(u.m)
            except u.UnitConversionError as ex:
                raise ValueError(
                    "Units of grid are not valid for a Cartesian "
                    f"grid: {self.units}."
                ) from ex

    @property
    def grid_resolution(self):
        r"""
        A scalar estimate of the grid resolution, calculated as the
        the minima of [dax0, dax1, dax2].

        """
        return min(self.dax0, self.dax1, self.dax2)

    def vector_intersects(self, p1, p2):
        r"""
        `True` if the vector from ``p1`` to ``p2`` intersects the grid,
        and `False` otherwise.

        This is a standard ray-box intersection algorithm.
        """
        p1, p2 = p1.si.value, p2.si.value
        # Calculate the minimum and maximum of each
        Ax, Bx = np.min(self.pts0.si.value), np.max(self.pts0.si.value)
        Ay, By = np.min(self.pts1.si.value), np.max(self.pts1.si.value)
        Az, Bz = np.min(self.pts2.si.value), np.max(self.pts2.si.value)
        A = np.array([Ax, Ay, Az])
        B = np.array([Bx, By, Bz])

        # Calculate the equation of the line from p1 to p2 such that
        # r = p1 + t*D
        D = np.abs(p2 - p1)

        # Calculate the intersection points. These operations are just vectorized
        # for convenience. Ignore div-by-zero: outputting infty's here is fine.
        with np.errstate(divide="ignore"):
            Tmin = (A - p1) / D
            Tmax = (B - p1) / D

        Tmin = np.max(Tmin)
        Tmax = np.min(Tmax)

        return Tmin < Tmax

    @modify_docstring(prepend=AbstractGrid.nearest_neighbor_interpolator.__doc__)
    def nearest_neighbor_interpolator(
        self, pos: np.ndarray | u.Quantity, *args, persistent: bool = False
    ):
        r""" """  # noqa: D419

        # Shared setup
        pos, args, persistent = self._persistent_interpolator_setup(
            pos, args, persistent
        )

        ax0, ax1, ax2 = self._ax0_si, self._ax1_si, self._ax2_si

        # Find particles that are off the grid
        mask_particle_off = (
            (pos[:, 0] < ax0.min())
            | (pos[:, 0] > ax0.max())
            | (pos[:, 1] < ax1.min())
            | (pos[:, 1] > ax1.max())
            | (pos[:, 2] < ax2.min())
            | (pos[:, 2] > ax2.max())
        )

        # Interpolate nearest index to each point along each axis
        i0 = _fast_nearest_neighbor_interpolate(pos[:, 0], ax0)
        i1 = _fast_nearest_neighbor_interpolate(pos[:, 1], ax1)
        i2 = _fast_nearest_neighbor_interpolate(pos[:, 2], ax2)

        vals = self._interp_quantities[i0, i1, i2, :]

        # Replace values of off-grid particles with NaN
        vals[mask_particle_off, :] = np.nan

        # Split output array into arrays with units
        # Apply units to output arrays
        output = [
            vals[..., index] * self._interp_units[index] for index, _ in enumerate(args)
        ]
        return output[0] if len(output) == 1 else tuple(output)

    def volume_averaged_interpolator(
        self, pos: np.ndarray | u.Quantity, *args, persistent: bool = False
    ):
        r"""
        Interpolate values on the grid using a volume-averaged scheme with
        no higher-order weighting.

        Parameters
        ----------
        pos : `~numpy.ndarray` or `~astropy.units.Quantity` array, shape (n,3)
            An array of positions in space, where the second dimension
            corresponds to the three dimensions of the grid. If a
            `~numpy.ndarray` is provided, units will be assumed to match
            those of the grid.

        *args : `str`
            Strings that correspond to DataArrays in the dataset

        persistent : `bool`
            If `True`, the interpolator will assume the grid and its
            contents have not changed since the last interpolation. This
            substantially speeds up the interpolation when many
            interpolations are performed on the same grid in a loop.
            ``persistent`` overrides to `False` if the arguments list
            has changed since the last call.

        Notes
        -----
        This interpolator approximates the value of a quantity at a given
        interpolation point using a weighted sum of the values at the eight grid
        vertices that surround the point. The weighting factors are calculated by
        defining a volume :math:`dx × dy × dz`
        (where :math:`dx`, :math:`dy`, and :math:`dz` are the grid
        spacings in each direction) around each grid vertex and around the
        interpolation point. The contribution of each grid vertex is then
        weighted by the fraction of the volume surrounding the interpolation
        point that overlaps the volume surrounding that vertex. This effectively
        introduces a linear interpolation between grid vertices.

        This implementation of this algorithm assumes that the grid is uniformly
        spaced and Cartesian.
        """
        # Shared setup
        pos, args, persistent = self._persistent_interpolator_setup(
            pos, args, persistent
        )

        nparticles = pos.shape[0]
        nargs = len(args)

        # Load grid attributes (so this isn't repeated)
        ax0, ax1, ax2 = self._ax0_si, self._ax1_si, self._ax2_si
        dx, dy, dz = self._dax0_si, self._dax1_si, self._dax2_si
        n0, n1, n2 = self.shape

        # find cell nearest to each position
        nearest_neighbor_index = np.zeros((nparticles, 3), dtype=np.int32)
        nearest_neighbor_index[..., 0] = _fast_nearest_neighbor_interpolate(
            pos[:, 0], ax0
        )
        nearest_neighbor_index[..., 1] = _fast_nearest_neighbor_interpolate(
            pos[:, 1], ax1
        )
        nearest_neighbor_index[..., 2] = _fast_nearest_neighbor_interpolate(
            pos[:, 2], ax2
        )

        # Create a mask for positions that are off the grid. The values at
        # these points will be set to zero later.
        mask_particle_off = (
            (pos[:, 0] < ax0.min())
            | (pos[:, 0] > ax0.max())
            | (pos[:, 1] < ax1.min())
            | (pos[:, 1] > ax1.max())
            | (pos[:, 2] < ax2.min())
            | (pos[:, 2] > ax2.max())
        )

        # Get the physical positions for the nearest neighbor cell
        # for the each particle
        xpos = ax0[nearest_neighbor_index[:, 0]]
        ypos = ax1[nearest_neighbor_index[:, 1]]
        zpos = ax2[nearest_neighbor_index[:, 2]]
        nearest_neighbor_pos = np.array([xpos, ypos, zpos]).swapaxes(0, 1)

        # Determine the indices for the grid cells bounding the particle
        # - The imaginary cell centered on the particle will overlap with
        #   1 to 8 surrounding cells
        # - typically this is 8 but will be 4, 2, or 1 when the particle is
        #   near the boundary of the grid
        bounding_cell_indices = np.empty((nparticles, 8, 3), dtype=np.int32)
        lower_indices = np.where(
            pos >= nearest_neighbor_pos,
            nearest_neighbor_index,
            nearest_neighbor_index - 1,
        )

        # populate x indices
        bounding_cell_indices[:, 0:4, 0] = np.tile(
            lower_indices[:, 0], (4, 1)
        ).swapaxes(0, 1)
        bounding_cell_indices[:, 4:, 0] = bounding_cell_indices[:, 0:4, 0] + 1

        # populate y indices
        bounding_cell_indices[:, [0, 1, 4, 5], 1] = np.tile(
            lower_indices[:, 1], (4, 1)
        ).swapaxes(0, 1)
        bounding_cell_indices[:, [2, 3, 6, 7], 1] = (
            bounding_cell_indices[:, [0, 1, 4, 5], 1] + 1
        )

        # populate z indices
        bounding_cell_indices[:, 0::2, 2] = np.tile(
            lower_indices[:, 2], (4, 1)
        ).swapaxes(0, 1)
        bounding_cell_indices[:, 1::2, 2] = bounding_cell_indices[:, 0::2, 2] + 1

        # Create a mask for cells whose locations would be off the grid,
        # which occurs when the interpolation point is near the edge of the
        # grid. These values will be weighted as zero later.
        mask_cell_off = (
            (bounding_cell_indices < 0).any(axis=2)
            | (bounding_cell_indices[:, :, 0] >= n0)
            | (bounding_cell_indices[:, :, 1] >= n1)
            | (bounding_cell_indices[:, :, 2] >= n2)
        )

        # Zero any out of bounds indices so IndexError is not raised
        # during indexing.  This means an incorrect value will be retrieved
        # but will not be used because of the zero weighting and the
        # off the grid mask
        bounding_cell_indices[mask_cell_off, :] = 0

        # Calculate the volume of the overlap between the point volume
        # and the volume of each of the surrounding vertices
        lx = dx - np.abs(pos[:, None, 0] - ax0[bounding_cell_indices[..., 0]])
        ly = dy - np.abs(pos[:, None, 1] - ax1[bounding_cell_indices[..., 1]])
        lz = dz - np.abs(pos[:, None, 2] - ax2[bounding_cell_indices[..., 2]])
        bounding_cell_weights = lx * ly * lz

        # Set the weight for any off-grid vertices (cell or particle) to zero
        bounding_cell_weights[mask_cell_off] = 0.0
        bounding_cell_weights[mask_particle_off, ...] = 0.0
        norms = np.sum(bounding_cell_weights, axis=1)
        mask_norm_zero = norms == 0.0
        bounding_cell_weights[~mask_norm_zero] = (
            bounding_cell_weights[~mask_norm_zero, ...] / norms[~mask_norm_zero, None]
        )

        # Get the values of each of the interpolated quantities at each
        # of the bounding vertices
        vals = self._interp_quantities[
            bounding_cell_indices[..., 0],
            bounding_cell_indices[..., 1],
            bounding_cell_indices[..., 2],
            :,
        ]
        # Construct a weighted average of the interpolated quantities
        weighted_ave = np.sum(bounding_cell_weights[..., None] * vals, axis=1)
        weighted_ave[mask_particle_off, :] = np.nan

        # Split output array into arrays with units
        # Apply units to output arrays
        output = [
            weighted_ave[..., arg] * self._interp_units[arg] for arg in range(nargs)
        ]

        return output[0] if len(output) == 1 else tuple(output)


class NonUniformCartesianGrid(AbstractGrid):
    r"""
    A Cartesian grid in which the ``_make_mesh`` method produces a
    non-uniformly spaced grid.
    """

    def _validate(self):
        """Check that all units are lengths."""
        for i in range(3):
            try:
                self.units[i].to(u.m)
            except u.UnitConversionError as ex:
                raise ValueError(
                    "Units of grid are not valid for a Cartesian "
                    f"grid: {self.units}."
                ) from ex

    @property
    def grid_resolution(self):
        r"""
        A scalar estimate of the grid resolution, calculated as the
        closest spacing between any two points.
        """
        distances = distance.cdist(self.grid, self.grid)
        np.fill_diagonal(distances, np.inf)
        return np.min(distances) * self.unit

    def vector_intersects(self, p1, p2):
        r"""
        `True` if the vector from ``p1`` to ``p2`` intersects the grid,
        and `False` otherwise. The grid is defined by a cube extending in
        each dimension from the minimum value of the grid to the maximum value
        of the grid in that dimension.

        This is a standard ray-box intersection algorithm.
        """
        p1, p2 = p1.si.value, p2.si.value
        # Calculate the minimum and maximum of each
        Ax, Bx = np.min(self.pts0.si.value), np.max(self.pts0.si.value)
        Ay, By = np.min(self.pts1.si.value), np.max(self.pts1.si.value)
        Az, Bz = np.min(self.pts2.si.value), np.max(self.pts2.si.value)
        A = np.array([Ax, Ay, Az])
        B = np.array([Bx, By, Bz])

        # Calculate the equation of the line from p1 to p2 such that
        # r = p1 + t*D
        D = np.abs(p2 - p1)

        # Calculate the intersection points. These operations are just vectorized
        # for convenience. Ignore div-by-zero: outputting infty's here is fine.
        with np.errstate(divide="ignore"):
            Tmin = (A - p1) / D
            Tmax = (B - p1) / D

        Tmin = np.max(Tmin)
        Tmax = np.min(Tmax)

        return Tmin < Tmax

    def _make_mesh(self, start, stop, num: int, **kwargs):
        r"""
        Creates mesh as part of ``_make_grid()``. Separated into its own
        function so it can be re-implemented to make non-uniform grids.
        """
        # Construct the axis arrays
        ax0 = np.sort(
            np.random.uniform(low=start[0], high=stop[0], size=num[0])  # noqa: NPY002
        )

        ax1 = np.sort(
            np.random.uniform(low=start[1], high=stop[1], size=num[1])  # noqa: NPY002
        )

        ax2 = np.sort(
            np.random.uniform(low=start[2], high=stop[2], size=num[2])  # noqa: NPY002
        )

        # Construct the coordinate arrays
        arr0, arr1, arr2 = np.meshgrid(ax0, ax1, ax2, indexing="ij")

        return arr0, arr1, arr2

    @cached_property
    def _nearest_neighbor_interpolator(self):
        """
        Creates a nearest neighbor interpolator object for this grid, which can
        then be called repeatedly.

        """

        return interp.NearestNDInterpolator(
            self.grid.to(u.m).value, self._interp_quantities
        )

    @modify_docstring(prepend=AbstractGrid.nearest_neighbor_interpolator.__doc__)
    def nearest_neighbor_interpolator(
        self, pos: np.ndarray | u.Quantity, *args, persistent: bool = False
    ):
        r""" """  # noqa: D419
        # Shared setup
        pos, args, persistent = self._persistent_interpolator_setup(
            pos, args, persistent
        )

        # Clear additional property that is not handled in the
        # _persistent_interpolator_setup function because it is unique
        # to this non_uniform grid.
        if not persistent:
            with contextlib.suppress(AttributeError):
                del self._nearest_neighbor_interpolator

        pts0 = self.pts0.to(u.m).value
        pts1 = self.pts1.to(u.m).value
        pts2 = self.pts2.to(u.m).value

        mask_particle_off = (
            (pos[:, 0] < pts0.min())
            | (pos[:, 0] > pts0.max())
            | (pos[:, 1] < pts1.min())
            | (pos[:, 1] > pts1.max())
            | (pos[:, 2] < pts2.min())
            | (pos[:, 2] > pts2.max())
        )

        vals = self._nearest_neighbor_interpolator(pos)
        vals[mask_particle_off] = np.nan

        output = [vals[:, arg] * self._interp_units[arg] for arg in range(len(args))]

        return output[0] if len(output) == 1 else tuple(output)
