Skip to content

neurospatial.layout.engines.regular_grid

regular_grid

Classes

RegularGridLayout

RegularGridLayout()

Bases: _GridMixin

Axis-aligned rectangular N-D grid layout.

Discretizes space into a uniform N-dimensional grid. Can infer the active portion of this grid based on provided data samples using occupancy and morphological operations. Inherits grid-based functionalities from _GridMixin.

Initialize a RegularGridLayout engine.

Source code in src/neurospatial/layout/engines/regular_grid.py
def __init__(self) -> None:
    """Initialize a RegularGridLayout engine."""
    self._build_params_used = {}
    self.bin_centers = np.empty((0, 0))
    self.dimension_ranges = None
    self.grid_edges = None
    self.grid_shape = None
    self.active_mask = None
Functions
build
build(*, bin_size: float | Sequence[float], dimension_ranges: Sequence[tuple[float, float]] | None = None, positions: NDArray[float64] | None = None, add_boundary_bins: bool = False, infer_active_bins: bool = True, dilate: bool = True, fill_holes: bool = True, close_gaps: bool = True, bin_count_threshold: int = 0, connect_diagonal_neighbors: bool = True) -> None

Build the regular N-D grid layout.

Parameters:

Name Type Description Default
bin_size Union[float, Sequence[float]]

Width of the cells in each dimension, in the units of positions. Cells are exactly this wide.

required
dimension_ranges Optional[Sequence[Tuple[float, float]]]

Explicit [(min_d0, max_d0), ..., (min_dN-1, max_dN-1)] to cover. If None, the extent of positions plus half a bin on each side. A range that is not a whole number of cells is widened equally on both sides; afterwards dimension_ranges holds the grid's extent.

None
positions (Optional[NDArray[float64]], shape(n_samples, n_dims))

Data used to infer dimension_ranges (if not provided) and/or to infer active bins (if infer_active_bins is True).

None
add_boundary_bins bool

If True, adds one bin on each side of the grid, extending the range.

False
infer_active_bins bool

If True and positions are provided, infers active bins based on occupancy and morphological operations.

True
dilate bool

If infer_active_bins is True, dilates the inferred active area.

True
fill_holes bool

If infer_active_bins is True, fills holes in the inferred active area.

True
close_gaps bool

If infer_active_bins is True, closes gaps in the inferred active area.

True
bin_count_threshold int

If infer_active_bins is True, minimum samples in a bin to be considered initially occupied.

0
connect_diagonal_neighbors bool

If True, connects diagonal neighbors in the connectivity graph.

True

Raises:

Type Description
TypeError

If bin_size is not numeric.

ValueError

If both dimension_ranges and positions are None, or dimension_ranges is None and every row of positions has a NaN; if bin_size is not positive and finite or has the wrong number of entries ([E1002]); if the full grid's bin centers would need more than 8 GiB (usually transposed positions or a bin_size in the wrong units); or if no bin is active after inference ([E1001]).

Notes

close_gaps, fill_holes and dilate are applied only to grids with two or more dimensions, in that order: closing, then hole filling, then dilation. A 1-D grid keeps exactly the bins that hold at least bin_count_threshold samples (and at least one).

Source code in src/neurospatial/layout/engines/regular_grid.py
@capture_build_params(data=("positions", "dimension_ranges"))
def build(
    self,
    *,
    bin_size: float | Sequence[float],
    dimension_ranges: Sequence[tuple[float, float]] | None = None,
    positions: NDArray[np.float64] | None = None,
    add_boundary_bins: bool = False,
    infer_active_bins: bool = True,
    dilate: bool = True,
    fill_holes: bool = True,
    close_gaps: bool = True,
    bin_count_threshold: int = 0,
    connect_diagonal_neighbors: bool = True,
) -> None:
    """Build the regular N-D grid layout.

    Parameters
    ----------
    bin_size : Union[float, Sequence[float]]
        Width of the cells in each dimension, in the units of `positions`.
        Cells are exactly this wide.
    dimension_ranges : Optional[Sequence[Tuple[float, float]]], optional
        Explicit `[(min_d0, max_d0), ..., (min_dN-1, max_dN-1)]` to cover.
        If None, the extent of `positions` plus half a bin on each side.
        A range that is not a whole number of cells is widened equally on
        both sides; afterwards `dimension_ranges` holds the grid's extent.
    positions : Optional[NDArray[np.float64]], shape (n_samples, n_dims), optional
        Data used to infer `dimension_ranges` (if not provided) and/or to
        infer active bins (if `infer_active_bins` is True).
    add_boundary_bins : bool, default=False
        If True, adds one bin on each side of the grid, extending the range.
    infer_active_bins : bool, default=True
        If True and `positions` are provided, infers active bins based
        on occupancy and morphological operations.
    dilate : bool, default=True
        If `infer_active_bins` is True, dilates the inferred active area.
    fill_holes : bool, default=True
        If `infer_active_bins` is True, fills holes in the inferred active area.
    close_gaps : bool, default=True
        If `infer_active_bins` is True, closes gaps in the inferred active area.
    bin_count_threshold : int, default=0
        If `infer_active_bins` is True, minimum samples in a bin to be
        considered initially occupied.
    connect_diagonal_neighbors : bool, default=True
        If True, connects diagonal neighbors in the connectivity graph.

    Raises
    ------
    TypeError
        If `bin_size` is not numeric.
    ValueError
        If both `dimension_ranges` and `positions` are None, or
        `dimension_ranges` is None and every row of `positions` has a
        NaN; if `bin_size` is not positive and finite or has the wrong number of
        entries ([E1002]); if the full grid's bin centers would need more
        than 8 GiB (usually transposed `positions` or a `bin_size` in the
        wrong units); or if no bin is active after inference ([E1001]).

    Notes
    -----
    `close_gaps`, `fill_holes` and `dilate` are applied only to grids
    with two or more dimensions, in that order: closing, then hole
    filling, then dilation. A 1-D grid keeps exactly the bins that hold
    at least `bin_count_threshold` samples (and at least one).

    """

    self._begin_build()
    if dimension_ranges is not None:
        self.dimension_ranges = dimension_ranges
    else:
        if positions is None:
            raise ValueError(
                "dimension_ranges must be provided if positions is None.",
            )

        # Extend the range half a bin beyond the data on each side.
        half = validate_cell_size(bin_size) / 2.0
        buffer_for_inference: float | list[float] = (
            float(half[0]) if np.ndim(bin_size) == 0 else half.tolist()
        )
        self.dimension_ranges = _infer_dimension_ranges_from_samples(
            positions=positions,
            buffer_around_data=buffer_for_inference,
        )

    (
        self.grid_edges,
        full_grid_bin_centers,
        self.grid_shape,
    ) = _create_regular_grid(
        positions=positions,
        bin_size=bin_size,
        dimension_range=self.dimension_ranges,
        add_boundary_bins=add_boundary_bins,
    )
    # The cells span whole bin sizes, so they can reach past the range.
    self.dimension_ranges = tuple(
        (float(edges[0]), float(edges[-1])) for edges in self.grid_edges
    )

    # Warn if the grid is very large.
    check_grid_size_safety(self.grid_shape)

    if infer_active_bins and positions is not None:
        self.active_mask = _infer_active_bins_from_regular_grid(
            positions=positions,
            edges=self.grid_edges,
            close_gaps=close_gaps,
            fill_holes=fill_holes,
            dilate=dilate,
            bin_count_threshold=bin_count_threshold,
            boundary_exists=add_boundary_bins,
        )
    else:
        # No positions or not inferring active bins, use all bins
        self.active_mask = np.ones(self.grid_shape, dtype=bool)

    if not np.any(self.active_mask):
        # Build comprehensive error message with diagnostics
        error_lines = ["[E1001] No active bins found after filtering."]
        error_lines.append("")

        # Add diagnostic information
        error_lines.append("Diagnostics:")

        # Show data range
        if positions is not None:
            data_clean = positions[np.all(np.isfinite(positions), axis=1)]
            if len(data_clean) > 0:
                # Convert to Python native types for cleaner display
                data_min = data_clean.min(axis=0).tolist()
                data_max = data_clean.max(axis=0).tolist()
                data_range = (
                    data_clean.max(axis=0) - data_clean.min(axis=0)
                ).tolist()
                error_lines.append(
                    f"  Data range: {list(zip(data_min, data_max, strict=True))}"
                )
                error_lines.append(f"  Data extent: {data_range}")
                error_lines.append(f"  Number of samples: {len(data_clean)}")
            else:
                # All data is NaN - inform user clearly
                error_lines.append(
                    "  Data samples: every row has a NaN or infinite value (no valid data)"
                )
                error_lines.append(
                    f"  Number of samples (including NaN): {len(positions)}"
                )

        # Show grid information
        if isinstance(bin_size, (float, int, np.number)):
            bin_size_str = f"{bin_size}"
        else:
            bin_size_str = f"{list(bin_size)}"
        error_lines.append(f"  bin_size: {bin_size_str}")
        error_lines.append(f"  Grid shape: {self.grid_shape}")
        error_lines.append(f"  Total bins in grid: {np.prod(self.grid_shape)}")

        # Show filtering parameters
        error_lines.append(f"  bin_count_threshold: {bin_count_threshold}")
        error_lines.append(
            f"  Morphological operations: dilate={dilate}, fill_holes={fill_holes}, close_gaps={close_gaps}"
        )
        error_lines.append("")

        # Explain WHY this happened (common causes)
        error_lines.append("Common causes:")
        error_lines.append("  1. bin_size is too large relative to your data range")
        error_lines.append(
            "  2. bin_count_threshold is too high (no bins have enough samples)"
        )
        error_lines.append(
            "  3. Data is too sparse and morphological operations are disabled"
        )
        error_lines.append("")

        # Explain HOW to fix (specific suggestions)
        error_lines.append("Suggestions to fix:")
        error_lines.append("  1. Reduce bin_size to create more bins")
        error_lines.append(
            "  2. Reduce bin_count_threshold (try 0 for initial testing)"
        )
        error_lines.append(
            "  3. Enable morphological operations (dilate=True, fill_holes=True, close_gaps=True)"
        )
        error_lines.append(
            "  4. Check that positions cover the expected spatial range"
        )

        raise ValueError("\n".join(error_lines))

    self._assign_cells(
        full_grid_bin_centers, connect_diagonal=connect_diagonal_neighbors
    )

Functions