Core Concepts¶
Understanding these core concepts will help you use neurospatial effectively.
Spatial Discretization¶
neurospatial converts continuous spatial coordinates into discrete bins (also called nodes or spatial bins). This discretization is essential for:
- Computing spatial statistics (occupancy, firing rates, etc.)
- Representing spatial relationships in a graph structure
- Performing efficient spatial queries
- Reducing computational complexity
Example: Continuous to Discrete¶
import numpy as np
from neurospatial import Environment
# Continuous coordinates (cm)
continuous_position = np.array([[10.5, 12.3]])
# Create discretized environment
env = Environment.from_samples(
continuous_position,
bin_size=2.0, # 2 cm bins
units="cm",
)
# Map to discrete bin index
bin_index = env.bin_at(continuous_position)[0]
print(f"Continuous position {continuous_position[0]} → Bin {bin_index}")
# Get bin center (representative coordinate)
bin_center = env.bin_centers[bin_index]
print(f"Bin {bin_index} center: {bin_center}")
Bins and Active Bins¶
Bins¶
A bin is a discrete spatial unit with:
- A unique integer index
- A center coordinate in N-dimensional space
- Optional size/extent information
- Connectivity to neighboring bins
Active vs Inactive Bins¶
Active bins are bins considered part of the environment. Inactive bins are excluded (e.g., walls, obstacles, or unvisited regions).
import numpy as np
from neurospatial import Environment
# Tracked positions (cm) from a 100 x 100 cm open field
rng = np.random.default_rng(0)
positions = rng.uniform(0.0, 100.0, size=(2000, 2))
# By default, only bins that contain at least one sample are active
env = Environment.from_samples(positions, bin_size=2.0, units="cm")
print(f"Visited bins: {env.n_bins}")
# Require several samples before a bin counts as visited
env_strict = Environment.from_samples(
positions, bin_size=2.0, units="cm", bin_count_threshold=5
)
print(f"Bins with at least 5 samples: {env_strict.n_bins}")
# Keep every bin in the sampled extent, visited or not
env_all = Environment.from_samples(
positions, bin_size=2.0, units="cm", infer_active_bins=False
)
print(f"All bins in the extent: {env_all.n_bins}")
For an open arena, Environment.open_field(positions, bin_size=2.0) also
fills interior holes, so a patch the animal happened not to visit stays part
of the arena.
Why Active/Inactive Matters¶
In neuroscience, you often want to exclude:
- Areas the animal didn't visit
- Physical barriers (walls, obstacles)
- Regions outside the experimental arena
This prevents:
- Dividing by zero in firing rate calculations
- Including irrelevant bins in statistics
- Wasting computation on empty space
Connectivity Graphs¶
Every environment includes a connectivity graph (NetworkX Graph) that defines spatial relationships between bins.
Graph Structure¶
- Nodes: Represent bins (indexed 0, 1, 2, ...)
- Edges: Connect neighboring bins
- Attributes: Store spatial metadata
# `env` is the 100 x 100 cm open field from the example above
# Access the connectivity graph (a NetworkX Graph)
G = env.connectivity
print(f"Number of nodes (bins): {G.number_of_nodes()}")
print(f"Number of edges (neighbor links): {G.number_of_edges()}")
# Neighboring bins of a given bin
print(f"Neighbors of bin 0: {env.neighbors(bins=0)}")
Nodes and edges also carry metadata — bin positions, inter-bin distances, and displacement vectors — that power shortest paths and geodesic distances. Those mandatory attributes are documented in Architecture & Internals, which you only need when reading the graph directly or writing a custom layout.
Why Graphs?¶
Graphs provide:
- Flexible topology: Handle complex shapes, holes, and disconnected regions
- Efficient queries: NetworkX algorithms for paths, distances, neighborhoods
- Manifold distances: Geodesic distances that respect environment boundaries
- Extensibility: Add custom node/edge attributes for your analysis
Layout Engines¶
Layout engines define how continuous space is discretized into bins. They implement the LayoutEngine protocol.
Available Layout Engines¶
| Engine | Description | Use Case |
|---|---|---|
RegularGridLayout |
Rectangular/cuboid grids | Standard spatial binning |
HexagonalLayout |
Hexagonal tessellation | Uniform neighbor distances |
GraphLayout |
1D linearized tracks | Maze/track experiments |
MaskedGridLayout |
Grid with active mask | Arbitrary active regions |
ImageMaskLayout |
Binary image masking | Image-based boundaries |
ShapelyPolygonLayout |
Polygon-bounded grid | Circular/custom arenas |
TriangularMeshLayout |
Triangular tessellation | Alternative to grids |
Automatic Selection¶
Factory methods automatically choose the appropriate engine:
import numpy as np
from shapely.geometry import Point
from neurospatial import Environment
rng = np.random.default_rng(0)
positions = rng.uniform(0.0, 100.0, size=(2000, 2)) # cm
# Regular grid (automatic)
env_grid = Environment.from_samples(positions, bin_size=2.0)
# Hexagonal (explicit)
env_hex = Environment.from_samples(positions, bin_size=2.0, layout="Hexagonal")
# Polygon-bounded (automatic): a circular arena of radius 40 cm
arena_outline = Point(50.0, 50.0).buffer(40.0)
env_poly = Environment.from_polygon(arena_outline, bin_size=2.0)
# 1D linearized track (automatic)
env_1d = Environment.linear_track(
endpoints=[(0.0, 0.0), (100.0, 0.0)],
bin_size=2.0,
track_width=10.0, # full corridor width; needed to assign positions to bins
)
print(env_grid.layout_type, env_hex.layout_type, env_poly.layout_type, env_1d.layout_type)
Layout engines are wired into Environment through a protocol (structural
typing), so you can add a custom discretization strategy without subclassing.
That extension point, and the raw graph metadata each engine must produce, are
covered in Architecture & Internals.
1D vs N-D Environments¶
N-D Environments (Grids)¶
Most environments are N-dimensional (typically 2D or 3D):
- Use grid-based layouts
- Spatial queries in original coordinate space
- Multiple neighbors per bin (4-8 in 2D, 6-26 in 3D)
# 2D environment, from the tracked positions above
env_2d = Environment.from_samples(positions, bin_size=2.0)
print(f"Dimensionality: {env_2d.n_dims}D")
print(f"Is 1D: {env_2d.is_linearized_track}") # False
# Spatial queries use 2D coordinates
point_2d = np.array([[10.0, 15.0]])
bin_idx = env_2d.bin_at(point_2d)[0]
1D Environments (Linearized Tracks)¶
1D linearized environments represent tracks/mazes as a single dimension:
- Use
GraphLayoutengine - Linearize complex 2D/3D geometries into 1D
- Essential for track-based experiments (T-maze, plus maze, etc.)
Environment.linear_track() and Environment.maze() (plus, T and W mazes)
build the common tracks from their endpoints and junctions; see
presets. For any other track, pass a
graph of the track's centerline to Environment.from_graph():
import networkx as nx
import numpy as np
from neurospatial import Environment
# Define track structure
graph = nx.Graph()
graph.add_node(0, pos=(0.0, 0.0))
graph.add_node(1, pos=(10.0, 0.0))
graph.add_node(2, pos=(20.0, 0.0))
graph.add_edge(0, 1, edge_id=0, distance=10.0)
graph.add_edge(1, 2, edge_id=1, distance=10.0)
# Create 1D environment
env_1d = Environment.from_graph(
graph=graph,
edge_order=[(0, 1), (1, 2)],
edge_spacing=0.0,
bin_size=2.0,
track_width=10.0, # full corridor width; needed to assign positions to bins
)
print(f"Is 1D: {env_1d.is_linearized_track}") # True
# Convert 2D position to 1D linear coordinate
position_2d = np.array([[15.0, 0.0]])
position_1d = env_1d.to_linear(position_2d)
print(f"2D position {position_2d[0]} → 1D position {position_1d[0]:.2f}")
# Convert back
position_2d_reconstructed = env_1d.linear_to_nd(position_1d)
When to use 1D:
- Track-based experiments (mazes, linear tracks)
- Analyzing sequences along specific paths
- Decoding position in 1D space
- Reducing dimensionality for analysis
Published values and coordinate labels¶
An environment's geometry, name, units, frame and named regions are read-only. Set labels when constructing it:
Units describe the coordinates supplied; setting units="cm" does not convert
pixels into centimeters. Calibrate coordinates before construction. To correct
a declaration or rename an analysis, keep the returned environment:
This shares the published geometry and gives the new environment its own
analysis caches. It does not change env or rescale coordinates. Use factories,
subset, clip or a supported transform to create different geometry.
Regions of Interest (ROIs)¶
Regions are named spatial areas within an environment:
- Immutable
Regionobjects (point or polygon) - Built with a mutable
Regionscollection and snapshotted by the environment - JSON serialization support
from shapely.geometry import Point
from neurospatial import Regions
regions = Regions(env.regions.values())
regions.add("StartLocation", point=(0.0, 0.0))
regions.add("RewardZone", polygon=Point(10.0, 10.0).buffer(5.0))
env = env.with_regions(regions)
# Access regions
print(list(env.regions)) # ['StartLocation', 'RewardZone']
print(env.regions["RewardZone"].kind) # 'polygon'
# Region operations
area = env.regions["RewardZone"].area # coordinate units squared
center = env.regions["RewardZone"].center # centroid; may lie outside a U shape
# Edit the builder and publish a replacement; env keeps its original snapshot.
regions.update_region("StartLocation", point=(5.0, 5.0))
updated = env.with_regions(regions)
with_regions replaces the complete collection. Start with
Regions(env.regions.values()) to retain existing names, or Regions() for a
new collection. Regions are analysis selections and do not define physical
walls. See the region guide for editing and buffering.
Construct a complete environment¶
Factory methods return environments with validated geometry and connectivity, ready for spatial queries. There is no public fit step or empty environment to initialize later.
import numpy as np
from neurospatial import Environment
positions = np.array([[0., 0.], [0., 10.], [10., 0.], [10., 10.]])
env = Environment.from_samples(positions, bin_size=2.0)
bin_idx = env.bin_at(positions[:1])
Calling the bare constructor raises E1006 immediately:
Factory Methods vs Constructor¶
Always use factory methods, not the bare constructor:
| Factory Method | Purpose |
|---|---|
Environment.from_samples() |
Bin tracked positions |
Environment.open_field() |
Open arena from tracked positions (fills interior holes) |
Environment.linear_track() |
Linear or piecewise-linear track from its endpoints |
Environment.maze() |
Plus, T or W maze from its junction and arm positions |
Environment.from_graph() |
Any other track, from a graph of its centerline |
Environment.from_polygon() |
Grid bounded by polygon |
Environment.from_grid_mask() |
Pre-defined N-D boolean mask |
Environment.from_pixel_mask() |
Binary image mask |
Environment.from_polar_egocentric() |
Egocentric (distance, angle) bins around the animal |
Environment.from_bins() |
Explicit bins you computed yourself |
Environment.from_layout() |
A built layout engine (triangular mesh, your own engine) |
Environment.combine() |
Join separate environments, such as two linked arenas |
Factory methods ensure:
- Complete, validated geometry and connectivity
- Correct layout engine selection
- Validation of parameters
- Sensible defaults
Summary¶
Key takeaways:
- Discretization: Continuous space → discrete bins
- Active bins: Only include relevant spatial regions
- Connectivity graphs: Define spatial relationships with mandatory metadata
- Layout engines: Protocol-based, swappable discretization strategies
- 1D vs N-D: Different query methods for linearized vs grid environments
- Regions: Named, immutable ROIs within environments
- Factory methods: Always use these, not bare constructor
See Also¶
- Architecture & Internals: Mandatory graph metadata and the layout-engine protocol — for reading the graph directly or writing a custom layout engine.
Next Steps¶
- User Guide: Detailed feature guides
- API Reference: Complete API documentation
- Examples: Real-world use cases
- Glossary: Spatial-coding vocabulary reference