Environments¶
Choose spatial bins from the data you have: tracked positions, an image mask,
a grid mask or a track graph. A polygon is optional. Environment keeps the
bins, their connectivity and the continuous domain together so that position
queries and subsequent analyses use the same coordinates.
Start with tracked positions¶
from_samples() is the usual starting point for a recording. Supply positions
with shape (n_samples, n_dims) in consistent physical units:
import numpy as np
from neurospatial import Environment
rng = np.random.default_rng(0)
positions = rng.uniform(0.0, 20.0, size=(200, 2))
env = Environment.from_samples(
positions,
bin_size=2.0,
units="cm",
name="Tracked session",
)
assert env.arena.domain_known
assert env.arena.outline is None # inferred from positions, not a wall
By default, bins containing observations are active. bin_count_threshold
controls how many samples a bin needs; dilate, fill_holes and close_gaps
can change the estimated support. Set infer_active_bins=False when the full
grid is the intended analysis region. layout="Hexagonal" selects hexagonal
bins instead of the default rectangular grid.
Inspect env.grid_edges and env.bin_sizes for the resulting discretization.
Use from_grid_mask() when exact physical cell edges are already known.
The inferred domain describes the active cells. An unvisited patch does not establish a physical wall. Inspect the support before interpreting missing occupancy as inaccessible space.
Presets for common apparatus¶
Three presets cover the usual apparatus. Each returns an ordinary
Environment, so every analysis below works the same way on it.
open_field() bins tracked positions like from_samples() but fills interior
holes. A patch in the middle of the arena that the animal happened not to
visit stays in the analysis, as an unvisited bin rather than a gap:
import numpy as np
from neurospatial import Environment
rng = np.random.default_rng(0)
positions = rng.uniform(0.0, 100.0, size=(5000, 2)) # 100 x 100 cm box
# The animal never crossed an 8 cm patch in the center
positions = positions[np.linalg.norm(positions - 50.0, axis=1) > 8.0]
arena = Environment.open_field(positions, bin_size=5.0, units="cm")
visited = Environment.from_samples(positions, bin_size=5.0, units="cm")
center = np.array([[50.0, 50.0]])
assert arena.contains(center).all() # the patch is part of the arena
assert not visited.contains(center).any() # from_samples keeps only visited bins
linear_track() and maze() build tracks from their geometry, not from
positions, and linearize them so place fields can be computed along the track.
linear_track() takes the track's endpoints (or a list of corner positions for
a bent track). maze() takes a plus, T or W maze from the positions of its
center, junctions and arm ends. track_width is the full corridor width;
positions farther from the centerline than half of it are outside the track:
import numpy as np
from neurospatial import Environment
track = Environment.linear_track(
endpoints=[(0.0, 0.0), (180.0, 0.0)],
bin_size=2.0,
track_width=8.0,
units="cm",
)
assert track.is_linearized_track
np.testing.assert_allclose(track.to_linear(np.array([[90.0, 2.0]])), [90.0])
assert track.bin_at(np.array([[90.0, 20.0]]))[0] == -1 # 20 cm off the track
plus_maze = Environment.maze(
kind="plus",
node_positions={
"center": (0.0, 0.0),
"north": (0.0, 50.0),
"south": (0.0, -50.0),
"east": (50.0, 0.0),
"west": (-50.0, 0.0),
},
bin_size=2.0,
track_width=8.0,
units="cm",
)
assert plus_maze.is_linearized_track
For any other track, build a NetworkX graph of the centerline and pass it to
from_graph().
Start with a mask¶
A grid in physical coordinates¶
from_grid_mask() accepts an N-dimensional boolean mask and explicit edges for
each coordinate axis. For a Cartesian 2-D grid, active_mask[i, j] selects the
cell between x_edges[i:i+2] and y_edges[j:j+2].
import numpy as np
from neurospatial import Environment
active_mask = np.array([[True, True], [True, False], [False, True]])
x_edges = np.arange(0.0, 8.0, 2.0)
y_edges = np.arange(0.0, 12.0, 4.0)
env = Environment.from_grid_mask(
active_mask,
grid_edges=(x_edges, y_edges),
name="Analysis mask",
units="cm",
)
assert env.n_bins == active_mask.sum()
np.testing.assert_array_equal(env.bin_sizes, 8.0)
This defines a continuous analysis domain from the active cells. It does not assert that inactive cells are bounded by measured physical walls.
An image or segmented floor plan¶
from_pixel_mask() accepts image indexing, image_mask[row, column]. Positions
remain Cartesian (x, y): columns run along x and rows along y. A scalar
pixel_size gives square pixels; (width, height) sets x and y calibration.
Use the same origin, scale and coordinate directions for your tracking data.
import numpy as np
from neurospatial import Environment
image_mask = np.array([[True, False, True], [False, True, False]])
env = Environment.from_pixel_mask(image_mask, pixel_size=(2.0, 3.0), units="cm")
assert env.n_bins == 3
np.testing.assert_array_equal(env.bin_sizes, 6.0)
assert env.contains(np.array([[1.0, 1.5], [5.0, 1.5]])).all()
The mask itself supplies the domain; you do not need to trace an additional
polygon. Its physical interpretation depends on how the mask was obtained.
The default MaskArena records computational support without declaring its
outline a physical boundary; pass arena=MaskArena(..., outline="wall") (or
"drop") when the mask traces the apparatus.
Immutable values and labels¶
Set name, units and frame at construction. These labels and the published
geometry are read-only. Units describe the values already supplied; declaring
units="cm" does not calibrate pixels or rescale coordinates.
To rename an analysis or correct a declaration, keep the returned environment:
Omitted labels are preserved; explicit None clears units or frame.
Replacements share the immutable geometry and have separate analysis caches.
Use a factory, subset, clip or a supported transform for geometry changes.
Build named selections with Regions and publish them with with_regions;
the region guide shows that workflow.
Bring explicit bins¶
Use from_bins() when another tool already defines the bin centers, measures,
neighbor pairs and lookup rule. SpatialBins holds the bins and their measures,
BinConnections their neighbor pairs and navigation distances. Supply the
measures and distances explicitly; nearest-center lookup does not establish cell
boundaries or faces.
import numpy as np
from neurospatial import Environment
from neurospatial.binning import (
BinConnections,
Binning,
MissingFaces,
NearestBinLocator,
SpatialBins,
)
centers = np.array([[0.0, 0.0], [2.0, 0.0], [2.0, 2.0]])
bins = SpatialBins.from_arrays(
centers=centers,
sizes=np.array([1.0, 1.0, 1.0]), # supplied areas, in cm squared
measure_dim=2,
)
connections = BinConnections.from_arrays(
pairs=np.array([[1, 2], [1, 0]]),
pair_distances=np.array([3.0, 4.0]), # supplied navigation path lengths
)
env = Environment.from_bins(
Binning(
kind="sample_sites",
bins=bins,
connections=connections,
locator=NearestBinLocator(centers, max_distance=0.25),
faces=MissingFaces("Only centers and navigation distances were measured."),
),
units="cm",
frame="lab",
)
np.testing.assert_array_equal(env.bin_at([[0.0, 0.0], [10.0, 0.0]]), [0, -1])
np.testing.assert_array_equal(env.edge_pairs, [[1, 2], [1, 0]])
graph = env.to_networkx()
graph.remove_node(0) # editable export; the environment keeps its own graph
assert env.n_bins == env.connectivity.number_of_nodes() == 3
max_distance is how far, in the centers' length units, a position may be from
a bin center and still belong to that bin; the position 10 cm away above gets
-1. Choose it from what a bin represents, such as the radius a recording site
covers. Without max_distance the bins have no limit, so position lookups
raise unless from_bins(..., arena=...) supplies the domain that bounds them.
Pass max_distance=math.inf only when every position, however far away,
should get its nearest bin.
The environment owns its published snapshot. edge_pairs determines the order
and orientation of pair-aligned values, including flux when faces are available;
NetworkX edge iteration can have a different order. MissingFaces permits lookup
and graph navigation while numerical operations requiring cell faces raise.
Supply KnownFaces and consistent geometry when those operations are required.
For supplied path lengths whose paths are unknown, translations, rotations and reflections preserve the lengths; uniform scaling scales them by the same factor. Unequal axis scaling and shear raise because scalar path lengths do not determine how those paths stretch. See snapshot adapters to convert an external provider into these built-in records.
env.binning returns an independent snapshot for inspecting or preparing geometry.
Changing its array shapes, writable flags or values cannot change the environment.
Reading it keeps native face recipes lazy; from_bins() builds them and checks
them against the bins, so a Cartesian snapshot rebuilds directly:
import numpy as np
from neurospatial import Environment
original = Environment.from_grid_mask(
np.ones((2, 2), dtype=bool), grid_edges=(np.arange(3.0),) * 2
)
rebuilt = Environment.from_bins(original.binning, arena=original.arena)
np.testing.assert_array_equal(rebuilt.bin_centers, original.bin_centers)
MissingFaces already states that numerical geometry is unavailable and needs
no conversion. Polar snapshots retain radius/angle coordinates; use environment
storage to restore those, because from_bins() constructs Cartesian geometry.
For nearest-center assignment, use env.nearest_bin(positions). It reuses the
environment's query tree and supports tie-breaking and distance limits.
This is different from bin_at(), which finds the cell that contains each
position (on a polar environment, each (distance, angle) row).
On an egocentric polar environment, nearest_bin measures physical distance:
it places the bin centers and the (distance, angle) rows in the animal's
plane, (distance * cos(angle), distance * sin(angle)), before comparing
them. Its distances and max_distance are in the environment's length units.
It is still not a containment test: use bin_at() for the bin that holds a
row.
For a custom numerical analysis, use env.coordinate_system to interpret the
columns and env.get_diffusion_geometry() to inspect conductances, cell measures
and connected diffusion components. These are independent read-only exports.
The environment keeps its expensive geometry and eigenbasis caches private.
env.compute_gaussian_kernel(bandwidth=...) returns unnormalized Gaussian weights
that ignore walls; env.diffuse(field, bandwidth=..., mode=...) respects the numerical boundary
geometry. After diffusion, env.diffusion_accuracy(bandwidth=...) reports its relative
noise floor. A spatial model can request a Markov random field smoothness basis
with env.get_mrf_basis(occupancy, rank=...).
import numpy as np
from neurospatial import Environment
from neurospatial.environment import CartesianSystem
env: Environment[CartesianSystem] = Environment.from_grid_mask(
np.ones((3, 3), dtype=bool), grid_edges=(np.arange(4.0),) * 2
)
geometry = env.get_diffusion_geometry()
assert geometry.W.shape == (9, 9)
assert geometry.n_components == 1
weights = env.compute_gaussian_kernel(bandwidth=1.0)
assert not weights.flags.writeable
smoothed = env.diffuse(np.arange(env.n_bins, dtype=float), bandwidth=1.0, mode="average")
assert env.diffusion_accuracy(bandwidth=1.0) <= 1e-6
basis = env.get_mrf_basis(np.ones(env.n_bins), rank=4)
assert basis.B.shape == (env.n_bins, 4)
Geometry and Gaussian exports are new read-only objects over the cached,
immutable arrays, so repeated reads do not copy them. Use .copy() for
editable arrays or sparse matrices. MRF basis arrays are already independent
and editable. All of these come from the environment's one analysis cache.
with_metadata and with_regions share it, because their bins are the same;
derived environments and restored files start with an empty one (see
Performance and caching).
Add known geometry when available¶
env.arena describes continuous support; env.bin_at(positions) and
env.contains(positions) also require an active analysis bin. A subset keeps
its parent's arena, so a dropped bin can lie inside the arena while being
outside the subset. env.arena.contains(positions) asks about the continuous
domain alone when that domain is known.
A measured polygon can be supplied through from_polygon(), or through the
optional arena= argument of the sample and mask factories. Explicit polygon
selection keeps whole bins whose centers lie strictly inside the polygon;
lookup also rejects points outside its boundary or inside its holes. A curved or
diagonal wall leaves slivers inside the arena whose grid cells were not kept
(their centers lie outside, or exactly on the wall); a position in a sliver
belongs to the neighboring bin, less than one cell away, so an animal running along the wall stays in
occupancy and spike counts. This selection does not change cell areas. Use
clip() below when exact intersected cell areas are needed.
import numpy as np
from shapely.geometry import Point
from neurospatial import Environment
env = Environment.from_polygon(Point(0.0, 0.0).buffer(10.0), bin_size=2.0)
assert env.arena.outline == "wall"
assert env.bin_at(np.array([[9.9, 2.0]]))[0] == -1
Walls, transparent walls and drop edges¶
Every physical boundary has a kind, which says what it blocks:
| kind | blocks movement | blocks gaze | example |
|---|---|---|---|
"wall" |
yes | yes | an opaque box or maze wall |
"transparent_wall" |
yes | no | a clear plexiglass divider |
"drop" |
yes | no | the edge of an elevated track or platform |
None (outline only) |
unknown | no | a domain inferred from positions or a mask |
from_polygon declares the polygon's outline an opaque wall. For an elevated
track or platform pass arena=OpenArena(polygon, outline="drop"). Named walls
take their kinds from wall_kinds: one kind for all, or a mapping from wall
name to kind. A named wall lying on the outline gives that stretch its own
kind, so a box with opaque outer walls and one clear side declares the clear
side as a named transparent wall built from the outline's vertices. An animal
that climbs a wall or leans over a drop is not modeled.
Analyses choose the property they need. Border scores and boundary-vector maps measure every boundary the animal cannot cross, so an elevated platform scores like the same platform with walls; boundary-vector cells (Lever et al., 2009) and most border cells (van Wijngaarden et al., 2020) keep firing along a drop edge. Gaze, line of sight and view fields stop only at opaque walls: a gaze that leaves an elevated track meets no wall, so its ray-cast viewed location is NaN.
import numpy as np
from shapely.geometry import LineString, box
from neurospatial import Environment
from neurospatial.arena import OpenArena
from neurospatial.ops.visibility import compute_viewed_location
platform = box(0, 0, 100, 100)
arena = OpenArena(
platform,
outline="drop",
walls={
"glass": LineString([(30, 20), (30, 80)]),
"board": LineString([(70, 20), (70, 80)]),
},
wall_kinds={"glass": "transparent_wall"},
)
env = Environment.from_polygon(platform, bin_size=5.0, units="cm", arena=arena)
viewed = compute_viewed_location(
np.array([[10.0, 50.0], [10.0, 50.0]]),
np.array([0.0, np.pi]), # east, then west
method="ray_cast",
env=env,
)
np.testing.assert_allclose(viewed[0], [70.0, 50.0]) # through the glass
assert np.isnan(viewed[1]).all() # past the drop edge
OpenArena separates a known polygon from explicitly supplied internal walls.
Walls block navigation and diffusion connections between the bins on either
side. A wall that covers part of a cell edge, such as a barrier ending partway
along it, leaves the open part of that edge connected. A measured wall rarely
lies exactly on bin edges, so the factories that build bins (from_samples,
open_field, from_polygon, from_grid_mask, from_pixel_mask and
from_layout) fit the two together with wall_fit:
wall_fit="snap"(the default) keeps every bin and moves the wall onto the bin edges between the bins it divides, with a warning that says how far it moved, at most about half a bin. Paths, occupancy, smoothing and wall distances all use the moved wall, and every analysis works as on any grid.wall_fit="split"keeps the measured wall and cuts each bin it crosses into one bin per side. The split bins are exact polygons, so smoothing and other finite-volume operations refuse the bins next to a wall's end or along a diagonal wall. Use it for binned rates (method="binned") or for wall-distance measures such as boundary-vector maps, where the exact wall matters.
Name each wall by passing a mapping, walls={"divider": line}, and errors,
warnings and saved files refer to it by that name (arena.wall_names). Walls
given as a sequence are named "wall_0", "wall_1", and so on.
A wall closes the neighbor pairs on either side of it rather than deleting
them. For a wall on bin edges (including a snapped one), the bins, their order
and the list of pairs (env.binning.connections) are the same as without the
wall, and only the pairs' is_open flags differ; wall_fit="split" makes new
bins, and the pieces on either side of the wall are closed pairs.
Paths, distances, smoothing and gradients use the open pairs, env.edge_pairs.
env.get_blocked_edges() lists each closed pair and the wall that closes it, a
quick check that a wall separates the bins you meant.
from_bins, rebin and saved environments keep the bins they are given, so a
wall through one of their bins raises an error naming the wall and the bin.
Declare walls in the original coordinates before clipping or transforming so
their source alignment is retained. A newly declared wall after those operations
can raise if coordinate precision prevents proving its clearance from a cell;
recenter coordinates or declare the wall before deriving the environment.
import numpy as np
from shapely.geometry import LineString, box
from neurospatial import Environment
from neurospatial.arena import OpenArena
arena = OpenArena(box(0, 0, 4, 4), walls={"divider": LineString([(2, 0), (2, 4)])})
env = Environment.from_grid_mask(
np.ones((4, 4), dtype=bool),
grid_edges=(np.arange(5.0), np.arange(5.0)),
arena=arena,
)
left = env.bin_centers[:, 0] < 2
assert all(left[u] == left[v] for u, v in env.connectivity.edges)
# A barrier at x = 2.4 runs through bins: by default it moves to x = 2.
import warnings
barrier = OpenArena(box(0, 0, 4, 4), walls={"barrier": LineString([(2.4, 0), (2.4, 4)])})
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
snapped = Environment.from_grid_mask(
np.ones((4, 4), dtype=bool),
grid_edges=(np.arange(5.0), np.arange(5.0)),
arena=barrier,
)
assert "moved the walls onto bin edges by up to 0.4" in str(caught[0].message)
assert snapped.arena.wall_names == ("barrier",)
# The wall closes the pairs across it; the bins and their pair order stay.
blocked = env.get_blocked_edges()
assert set(blocked["wall"]) == {"divider"}
assert all(left[u] != left[v] for u, v in zip(blocked["source"], blocked["target"]))
split = Environment.from_grid_mask(
np.ones((4, 4), dtype=bool),
grid_edges=(np.arange(5.0), np.arange(5.0)),
arena=barrier,
wall_fit="split",
)
assert (snapped.n_bins, split.n_bins) == (16, 20)
For a linearized track, from_graph(..., track_width=...) supplies a measured
full width in the graph's coordinate units. Containment uses distance to the
actual centerline segments, with round end caps: a position farther than half
the width from every segment gets bin -1. A track built without a width has
no footprint, so bin_at, contains, occupancy, the bin sequences,
points= queries and point regions refuse with NotImplementedError.
env.with_track_width(width) gives such a track, including one saved by an
earlier version, its measured width and keeps its bins; to_linear, linear_to_nd and the graph
queries on bin indices still work. to_linear projects every position onto
the centerline, however far off the track, so check bin_at(positions) >= 0
on a track with a width before trusting linear positions.
Egocentric polar environments similarly record their radial and angular
analysis support without inferring physical walls.
Optional: clip cells to a polygon¶
env.subset(...) selects whole bins. env.clip(polygon) intersects exact
planar cells with a polygon and the known arena domain. Polygons can contain
holes or disconnected regions. A source cell split into disconnected pieces
produces multiple output bins.
import numpy as np
from shapely.geometry import MultiPolygon, box
from neurospatial import Environment
parent = Environment.from_grid_mask(
np.ones((1, 1), dtype=bool), grid_edges=(np.array([0.0, 1.0]),) * 2
)
child = parent.clip(MultiPolygon([box(0, 0, 0.2, 1), box(0.8, 0, 1, 1)]))
np.testing.assert_allclose(child.bin_sizes, [0.2, 0.2])
assert child.clip_mapping is not None
np.testing.assert_array_equal(child.clip_mapping.parent_bin_indices, [0, 0])
assert child.arena.polygonal_domain().equals(parent.arena.polygonal_domain())
The mapping identifies each child's bin in the environment passed to the most
recent clip() call. Subsets select its rows and transforms preserve them;
a subsequent clip creates a new immediate-parent mapping. The array is
read-only and survives saving and loading.
Clipping preserves the name, units, frame, arena and named regions. It does
not transfer counts, occupancy, rates or fitted decoders. Reassign raw positions
and events to the new bins and fit again. A parent mapping alone cannot say
how activity was distributed between split pieces. Unequal piece areas do not
bias decoding: the default decoding prior is uniform over space (mass
proportional to env.bin_sizes), and the MAP bin and credible region rank
bins by density, probability per unit area.
Containment and plotting retain the exact cell boundaries and holes. Numerical
operations such as smoothing, diffusion and spatial derivatives additionally
require certified finite-volume geometry: convex cells and a single open,
orthogonal interface between neighboring centers. Unsupported geometry raises
NotImplementedError with the reason. An arbitrary valid crop can therefore
be queried and plotted even when these numerical operations are unavailable.
A rectangular crop of a regular grid can retain them:
import numpy as np
from shapely.geometry import box
from neurospatial import Environment
parent = Environment.from_grid_mask(
np.ones((2, 2), dtype=bool), grid_edges=(np.arange(3.0),) * 2
)
child = parent.clip(box(0.25, 0, 2, 2))
smoothed = child.smooth(np.ones(child.n_bins), bandwidth=0.5, mode="average")
np.testing.assert_allclose(smoothed, 1.0)
Clipping or transforming geometry refuses features that cannot be resolved at the stored coordinate precision. Recenter or rescale coordinates before making extremely thin cells at a large coordinate origin.
Use bin_at() for membership. Nearest-center interpolation remains an
approximation, especially for concave or clipped cells.
Occupancy and position queries¶
env.occupancy(times, positions) measures time in seconds, rather than counting
samples. Its default assigns each valid interval to its starting bin. Use
time_allocation="linear" to split a straight segment over supported cells.
On grids of any dimension and on polygon cells, this handles entry, exit and
re-entry, excludes holes, and does not renormalize the retained duration: a
segment half inside the environment adds half its duration, or half a count
with return_seconds=False, whichever way the animal travels. Gaps, speed and
epoch filters still apply. A proper crossing of an explicit physical wall raises instead of
interpolating through it; endpoint contact and motion along a wall are allowed
when a bin owns those points.
Spatial encoding uses start allocation by default. Choose
time_allocation="linear" explicitly to use the same retained time support
for spike counts and dwell time. This option requires exact planar cells and
also works with environments built directly from samples or masks. Retained
time windows include entry and exclude exit, subject to spatial bin ownership.
Events at an internal shared cell boundary use its locator owner. The final
tracking timestamp remains excluded.
At an exact corner crossing, that owner can have zero dwell time. Linear rate
estimation raises if a spike lands in such a bin: a positive count divided by
zero exposure has no finite firing rate. Inspect the boundary observation and
choose an appropriate spatial or tracking resolution before fitting again.
If absolute timestamps cannot represent a retained time span, subtract the same
origin from tracking, spike times and any time windows before analysis; this raises
explicitly rather than publishing zero-width observed intervals.
In this simulated straight traversal, only four seconds and four spikes lie in the retained cell. The hole and outside portions contribute to neither counts nor dwell time:
import numpy as np
from shapely.geometry import Polygon, box
from neurospatial import Environment, compute_spatial_rates
from neurospatial.decoding import BayesianDecoder
parent = Environment.from_grid_mask(
np.ones((1, 1), dtype=bool),
grid_edges=(np.array([0.0, 6.0]), np.array([0.0, 2.0])),
)
region = Polygon(
box(0, 0, 6, 2).exterior.coords,
holes=[box(2, 0.5, 4, 1.5).exterior.coords],
)
env = parent.clip(region)
times = np.array([0.0, 8.0])
positions = np.array([[-1.0, 1.0], [7.0, 1.0]])
spikes = [np.arange(0.5, 8.0, 1.0)]
rates = compute_spatial_rates(
env, spikes, times, positions,
method="binned", bandwidth=0, time_allocation="linear",
max_gap=None, # This synthetic segment is known to be continuous.
)
np.testing.assert_allclose(rates.occupancy, [4.0])
np.testing.assert_allclose(rates.firing_rates, [[1.0]])
decoder = BayesianDecoder.from_rates(rates, dt=1.0)
# Prediction can use its own continuous time grid without tracking positions.
decoded = decoder.predict(spikes, np.arange(0.0, 8.25, 0.25))
np.testing.assert_allclose(decoded.posterior.sum(axis=1), 1.0)
For an explicit mask whose cell edges differ from the analysis grid, linear subdivision currently requires 2-D polygon support. Higher-dimensional masks must match the grid support for linear allocation; otherwise the method raises with guidance to use start allocation or a matching grid.
Other common queries are env.neighbors(bins=bin_index),
env.path_between(bins=[start_bin, end_bin]) and
env.distance_between(points=[point_a, point_b]). Each query takes its target
by keyword: bins= for bin indices, points= for coordinates (one row per
point, mapped to the bin that contains it) and, for distance_to, region=
for a named region. A bin index and a coordinate look alike as numbers, so the
keyword says which one you mean. See the API reference for
their coordinate and distance conventions.
Choose physical walls or analysis boundaries¶
Physical border scores use the declared outline rings (including holes) and
named walls, of every kind: walls, transparent walls and drop edges. The
outline must be declared: internal walls alone do not say where the outer
boundary is, so an arena declared with OpenArena(..., outline=None) refuses
physical scoring even when it has internal walls. A crop or a missing position sample does not create a
wall. If only tracking-derived support or an analysis mask is available, request
boundary_source="analysis" to score against the outline of the retained bins
instead.
Physical mode is the default. It requires measured wall geometry and a
retained bin at every piece of measured wall; it refuses, saying where, when
bins stop short of a wall. Supplying OpenArena establishes wall locations; it
does not add bins. Unvisited bins along a wall are missing, not evidence of
zero firing, and they are not evidence that a field reaches the wall either:
as in Solstad et al. (2008), coverage is the fraction of the whole wall, and
unvisited wall counts as not covered, so missing data can lower a border score
but never raise it. observed_wall_fraction(env, firing_rate) reports how
much of the walls a rate map observes. A valid zero rate near the wall still contributes to a negative
score for a field away from the wall.
import numpy as np
from shapely.geometry import box
from neurospatial import Environment
from neurospatial.arena import OpenArena
from neurospatial.encoding import border_score
env = Environment.from_grid_mask(
np.ones((10, 10), dtype=bool), grid_edges=(np.arange(0.0, 101.0, 10.0),) * 2,
arena=OpenArena(box(0, 0, 100, 100)), units="cm",
)
field = (env.bin_centers[:, 0] < 10).astype(float) # one column on the west wall
score = border_score(env, field, metric="euclidean")
# The field covers the whole west wall; its centers are 5 cm from it, a tenth
# of the 50 cm radius of the largest circle that fits in the box.
np.testing.assert_allclose(score, (1 - 0.1) / (1 + 0.1))
# The full grid's outline is the box, so the analysis outline agrees here.
approximation = border_score(env, field, boundary_source="analysis")
np.testing.assert_allclose(approximation, score)
This is the border score of Solstad et al. (2008). Coverage is the largest
fraction of one wall that one field covers. Each outline ring is split into
walls at its corners, in physical units: a polygon's vertices are corners
unless they sample a curve (a rectangle has four walls, an octagon eight, a box
with rounded corners four). A ring without corners, such as a circular arena,
offers each field any half of it as its wall, following Høydal et al. (2018).
Each named wall is one more wall; a named wall that lies on the outline
replaces that piece of it, so walls can be declared by hand. Distance is the firing-rate-weighted mean
distance from field bin centers to the nearest wall, divided by the radius of
the largest circle that fits in the arena without crossing a wall. Geodesic
mode includes center-to-wall lengths and paths through retained graph bins. An
unavailable route makes the score NaN; crops can restrict represented routes.
Euclidean mode avoids that graph restriction. The usual 0.5 cutoff was chosen
for rectangular boxes, where a map that fires everywhere scores 0.5;
untuned_border_score(env) gives that baseline for other arena shapes.
For a rotated rectangular arena, construct bins in coordinates aligned with the measured rectangle, then transform the bins and arena together. Transform the recorded positions into the same frame before computing rates. A change of frame then preserves the represented wall coverage:
import numpy as np
from shapely.geometry import box
from neurospatial import Environment
from neurospatial.encoding import border_score
from neurospatial.ops.transforms import AffineND
env = Environment.from_polygon(box(0, 0, 100, 100), bin_size=10.0, units="cm")
field = (env.bin_centers[:, 0] < 10).astype(float)
score = border_score(env, field, metric="euclidean")
angle = np.pi / 4
transform = AffineND(np.array([
[np.cos(angle), -np.sin(angle), 20.0],
[np.sin(angle), np.cos(angle), 10.0],
[0.0, 0.0, 1.0],
]))
rotated = env.apply_transform(transform)
np.testing.assert_allclose(border_score(rotated, field, metric="euclidean"), score)
recorded_positions = np.array([[5.0, 5.0], [5.0, 15.0]])
rotated_positions = transform(recorded_positions)
assert np.all(rotated.bin_at(rotated_positions) >= 0)
Rebuilding an axis-aligned grid from a rotated polygon can leave slanted wall
segments outside the selected bins. For other arena shapes, use exact cells
that cover the measured walls and collect observations in their wall-adjacent
cells. clip() only cuts existing cells; it cannot restore missing bins.
The same choice is available on result.border_score(), population
border_scores() and label_cell_types(), and summary_table(). Tables retain
other metrics when wall geometry is unavailable: border scores are NaN, with a
reason in table.attrs["unavailable_metrics"] and the chosen source in
table.attrs["border_boundary_source"]. Cell-type labels are then
"undetermined", because any cell may be a border cell. On a 1-D track grid
and border cells do not apply, so a tuned cell is "place". No analysis
approximation is selected
implicitly. Physical border coverage currently requires exact planar cells and
polygonal walls; a TrackArena declares no physical boundary, so declare a
walled or elevated track as an OpenArena of its floor polygon.
Independently transformed combined components also refuse physical contact
until a shared-frame contact certificate is available; combine before applying
one shared transform, or explicitly choose the analysis approximation.
A combined environment also needs a declared outline for every component: a
field at the edge of a component without one would otherwise be scored against
walls elsewhere, so physical scoring refuses and names the component.
Compute gaze and visibility against walls¶
Ray casting and cue visibility use the same boundary_source choice. Physical
mode reads the declared outline and named walls, and only opaque walls
("wall") stop a gaze: transparent walls and drop edges do not. It does not
stop when a ray crosses an analysis crop or an unvisited bin. Analysis mode explicitly uses
analysis-cell boundaries, constrained by the known domain and internal walls.
import numpy as np
from shapely.geometry import box, LineString
from neurospatial import Environment
from neurospatial.arena import OpenArena
from neurospatial.ops.visibility import compute_viewed_location, visible_cues
env = Environment.from_grid_mask(
np.ones((10, 10), dtype=bool), grid_edges=(np.arange(11.0),) * 2,
arena=OpenArena(box(0, 0, 10, 10), walls=(LineString([(5, 0), (5, 10)]),)),
)
crop = env.subset(mask=env.bin_centers[:, 0] < 4)
hit = compute_viewed_location(
np.array([[2.5, 4.5]]), np.array([0.0]), env=crop,
method="ray_cast", max_distance=30,
)
np.testing.assert_allclose(hit, [[5.0, 4.5]]) # real wall, beyond the analysis crop
approximate = compute_viewed_location(
np.array([[2.5, 4.5]]), np.array([0.0]), env=crop,
method="ray_cast", max_distance=30, boundary_source="analysis",
)
np.testing.assert_allclose(approximate, [[4.0, 4.5]])
visible, _, _ = visible_cues(
crop, np.array([2.5, 4.5]), 0.0, np.array([[5.0, 4.5], [7.5, 4.5]]),
)
np.testing.assert_array_equal(visible, [True, False]) # on the wall; behind it
compute_viewed_location() returns the first wall contact. No hit within
max_distance, a nonfinite observation, or an observer outside a declared
outline yields NaN. An observer tracked exactly on a wall sees the next wall
when it faces into the room; facing out through the wall, or exactly along it,
gives a zero-distance contact at the observer. On an internal wall, the
observer sees the side it faces. A cue at the first contact is visible; a cue
behind it is occluded. A lost tracking frame is not an occluded view:
visible_cues() and compute_view_field() refuse a NaN or infinite position
or heading, and compute_viewshed_trajectory() keeps such frames missing,
with a visibility_map of NaN. Stored affine frames
use locally bounded projection errors for boundary contact. Physical mode only
uses supplied wall evidence; inferred domains do not add unasserted outer walls.
compute_viewshed() uses the same test for bins as for cues. A bin is
visible when the direction to its center lies inside the field of view and the
straight line to its center crosses no wall. The test is exact for each bin, so
the result does not depend on bin size or distance. A bin whose center is
hidden counts as hidden even if a corner of it is in view. Missing analysis
bins do not occlude physical sight lines. The result's visibility_map is 1
for visible bins and 0 for hidden ones. Averaging it over observed frames gives
the fraction of time each bin was in view.
The source choice is forwarded by the single/population view encoders, shuffle
tests and SpatialViewCellModel. With the default gaze_model="ray_cast",
encoding assigns a wall hit to the bin just inside the observer's side.
from_polygon() keeps whole cells whose centers lie strictly inside, so its
cells stop short of a curved or slanted wall; a hit in the sliver between them
and the wall belongs to the bin that owns positions there, as an animal's
position in that sliver does. A hit outside the retained analysis support (in
bins a subset removed, or beyond the cells) makes physical view encoding
unavailable and raises with guidance when it contributes to the selected
recording time. The final sample starts no exposure interval, and hits during
excluded epochs, recording windows, long gaps or zero-duration intervals cannot
change the map. Supplying wall geometry cannot add missing bins, and assigning
a distant wall to a nearby bin would change the measured location. Build cells
that reach the wall, or explicitly request the analysis-boundary
approximation. To give exact cell areas at a curved or slanted wall, clip a
grid that covers the arena to the measured polygon and attach its walls:
Environment.from_bins(grid.clip(polygon).binning, arena=OpenArena(polygon)).
The geometry-only compute_viewed_location() still returns the known physical
hit even when its location has no analysis bin.
View encoding requires a retained bin for each observed finite hit; it does not
require gaze observations at every wall. Unviewed bins remain missing, and rays
with no hit or invalid observations remain missing. Spike counts and exposure
share that assignment, and results record the source in analysis_settings.
Encoder/model searches cover the known geometry rather than imposing a fixed
100-unit range. In the view encoders, fixed-distance gaze stops at the same
walls: its viewed point is view_distance ahead or the first wall, whichever
is nearer, so a gaze never lands behind a wall.
Physical encoding of independently transformed combined components requires a
shared certified owner frame; unsupported cases refuse with guidance. A
TrackArena declares no physical boundary; declare a track's floor polygon as
an OpenArena instead. For a
position-derived environment with supported planar cells, explicitly select
boundary_source="analysis" when that approximation is the intended model.
Change bin size¶
env.rebin(bin_size=...) builds a new rectangular lattice over the bins the
environment has, within its arena: rebinning a subset keeps the selection
rather than bringing back the space it left out. It keeps the original grid
frame, lattice anchor, units, frame label and regions. The argument is a physical length along each retained grid axis;
a tuple gives different lengths along different axes. It can refine or coarsen.
import numpy as np
from neurospatial import Environment
env = Environment.from_grid_mask(
np.ones((6, 6), dtype=bool),
grid_edges=(np.arange(7.0),) * 2, units="cm", frame="lab",
)
crop = env.subset(mask=env.bin_centers[:, 0] < 2)
coarse = crop.rebin(bin_size=2.0)
assert coarse.n_bins == 3 # the cropped column, at the new size
np.testing.assert_allclose(coarse.bin_sizes, 4.0)
assert coarse.rebin(bin_size=1.0).n_bins == crop.n_bins
Whole-bin selection keeps new cells that cover the environment's bins and whose
centers lie inside the arena; it does not promise exact clipped areas. Original bins in a new perimeter cell whose center lies outside
the arena take the neighboring new bin, like any position in a sliver. When
original bins are still left without a new bin (beyond the new grid, or in a
cell a subset removed),
rebin warns with their count and share of the area, because positions there
map to -1 and drop out of occupancy and spike totals. Physical walls must
still follow complete new cell faces. New bins on both sides of a barrier in
the outline narrower than a bin, such as the wall between two maze arms, meet
only outside the arena, so they are not neighbors: path lengths and smoothing
go around the barrier, as in every environment with a known outline. Use
clip() separately, or a finer grid, where needed. Re-binning
creates new bin IDs and does not copy rates, counts or fitted models.
New rectangular environments store an owned GridBinningSpec independently of
provenance. It survives cropping, clipping, transforms and JSON/NWB storage.
Hexagons, tracks, meshes, combined environments and older records without a
supported specification refuse new-size re-binning. Explicit-bin providers can
supply a GridBinningSpec describing future rectangular construction.
The existing env.rebin(factor=2) remains available for uniform regular grids,
including its truncation warning. Supply exactly one of factor and bin_size.
Carry values to a subset or a coarser grid¶
Two common questions need values moved between environments: does the map
estimated on part of the arena match the full map there, and what is the
occupancy at a coarser resolution? Each derived environment numbers its bins
from 0, so bin j of a subset is not bin j of the arena.
child.bin_mapping_from(parent) pairs the bins from their geometry. It reports
only pairs it can prove: a bin that is the same cell as a parent bin (a subset,
the cells a clip did not cut), or a fine cell that lies wholly inside one cell of
a coarser grid on the same lattice (rebin). The simulated session below has a
random trajectory over a 40 x 40 cm box and two cells with random spikes.
import numpy as np
from neurospatial import Environment
from neurospatial.encoding import compare_spatial_rates, compute_spatial_rates
times = np.arange(18_000) / 30.0 # 10 minutes at 30 Hz
positions = np.random.default_rng(0).uniform(0.0, 40.0, size=(len(times), 2)) # cm
spike_rng = np.random.default_rng(1)
spike_times = [np.sort(spike_rng.choice(times, size=n, replace=False)) for n in (400, 900)]
env = Environment.from_grid_mask(
np.ones((10, 10), dtype=bool), grid_edges=(np.arange(11) * 4.0,) * 2, units="cm"
)
def binned_rates(where):
return compute_spatial_rates(
where, spike_times, times, positions,
method="binned", bandwidth=0.0, unit_ids=[1, 2],
)
# Compare the map estimated on the left half with the full map there.
left = env.subset(mask=env.bin_centers[:, 0] < 20)
to_left = left.bin_mapping_from(env)
print(to_left)
full_map, left_map = binned_rates(env), binned_rates(left)
on_left = compare_spatial_rates(
full_map, left_map, bin_mapping=to_left.pairs, topology="may_differ"
)
assert on_left.status.tolist() == ["scored", "scored"]
# Coarsen counts and occupancy to 8 cm bins.
coarse = env.rebin(2)
to_coarse = coarse.bin_mapping_from(env)
occupancy = to_coarse.sum(full_map.occupancy) # seconds per 8 cm bin
counts = np.where(
full_map.occupancy > 0, full_map.firing_rates * full_map.occupancy, 0.0
) # spikes per 4 cm bin; unvisited bins have no spikes
rates = to_coarse.rates(counts, full_map.occupancy) # summed spikes / summed time
np.testing.assert_allclose(occupancy, coarse.occupancy(times, positions))
np.testing.assert_allclose(rates, binned_rates(coarse).firing_rates)
The printed mapping counts each kind of bin: all 50 bins of the left half pair
one-to-one with arena bins, and the other 50 arena bins are unmapped. Its
pairs are rows of (bin in env, bin in left), the orientation
compare_spatial_rates takes. After rebin(2) every 8 cm bin holds four 4 cm
bins, so summed occupancy and pooled rates equal estimating on the coarse grid
from the same trajectory. Rates are pooled from counts and occupancy, never
averaged: 0 Hz for 100 s and 100 Hz for 1 s pool to 100 spikes / 101 s =
0.99 Hz, not 50 Hz. A smoothed map's value in a bin borrows spikes from its
neighbors, so it is not a count that adds up; estimate smoothed maps again on
the new environment. to_coarse.category(labels, missing="mixed") carries
labels such as region names, keeping a label only where all the fine bins in a
coarse bin agree.
The mapping never guesses. Missing values stay missing: a coarse bin with any
NaN fine bin is NaN. A clip's cut cells are listed in split_parent_bins and
their pieces in uncovered_child_bins, which get NaN, because a count cannot
be divided between pieces without knowing where in the cell each observation
fell. The same holds at the edge of an arena with holes: a fine bin next to an
empty bin owns the boundary they share, and when the coarse grid gives that
boundary to a different coarse bin, the fine bin is split and both coarse bins
are uncovered, since a binned count cannot say how many samples lay exactly on
the boundary. Estimate on the coarse environment when you need those bins. A
finer grid, a rotated or offset lattice, or an independently built
environment raises ValueError; re-estimate on the new environment from the
original observations, or use neurospatial.ops.map_probabilities and
ops.resample_field, which assign bins by nearest center and are
approximations. apply_transform needs no mapping, because transforms keep bin
IDs. When a truncating rebin drops fine bins, occupancy summed from the fine
grid equals the coarse grid's own occupancy only if positions in the dropped
bins are excluded from both: an interval that ends in a dropped bin counts on
the fine grid but not on the coarse one.
Name cues, monitors and objects¶
Landmarks are the things the animal sees or approaches: a cue card, a monitor, an object, a reward port. Give each one a name and its geometry in the environment's coordinates: a point, a line (a monitor or a card along a wall) or a polygon (an object's footprint). Landmarks are not bins, so they may lie outside the arena, as a cue on the room wall does, and they neither block movement nor block the view; declare a wall for that. The name is the landmark's identity: keep it the same in every session, so a cue rotated or an object moved between sessions is still the same landmark.
import numpy as np
from neurospatial import Environment
from neurospatial.landmarks import Landmark, Landmarks
edges = np.arange(0.0, 101.0, 5.0)
arena = Environment.from_grid_mask(
np.ones((20, 20), dtype=bool), grid_edges=(edges, edges), units="cm"
)
arena = arena.with_landmarks(
Landmarks(
[
Landmark("cue_card", "point", [50.0, 108.0], {"role": "cue"}),
Landmark("monitor", "line", [(10.0, 100.0), (90.0, 100.0)]),
Landmark("object", "polygon", [(20, 20), (30, 20), (30, 30), (20, 30)]),
]
)
)
# Each landmark's nearest point to the animal, shape (n_time, n_landmarks, 2):
# a line or polygon is measured from its closest edge, not its center.
positions = np.array([[50.0, 90.0], [15.0, 40.0]])
nearest = arena.landmarks.nearest_points(positions, ["monitor", "object"])
assert nearest[0, 0].tolist() == [50.0, 100.0]
# Whether projected gaze lands on the monitor, within 2 cm:
gaze = np.array([[60.0, 99.5], [60.0, 80.0]])
assert arena.landmarks.near(gaze, "monitor", tolerance=2.0).tolist() == [True, False]
Encoding functions that take object_positions accept landmark names
instead of coordinates, and record the names in the result; see
object vectors.
subset, clip and rebin keep the landmarks, apply_transform moves them
with the bins, and combine prefixes their names with the component key, as
it does for regions. A polar environment, centered on the animal, holds none.
Check the environment against the apparatus¶
env.plot() draws everything the environment holds: the bins and the
connections between them, the declared walls (red, named), the landmarks
(orange, named) and the regions (in the legend). Compare it with a video frame
or a sketch of the rig before analyzing: a wall on the wrong side or a cue in
the wrong corner shows up here, but not in a rate map. It draws a
hundred-thousand-bin grid in about a second. show_walls=False,
show_landmarks=False and show_regions=False hide each layer.
env.plot(backend="fastplotlib") draws the same picture on the GPU, where you
can pan, zoom and rotate a 3-D environment, in Jupyter or from a remote GPU
server; see the fastplotlib backend.
import numpy as np
from shapely.geometry import LineString, box
from neurospatial import Environment
from neurospatial.arena import OpenArena
from neurospatial.landmarks import Landmark, Landmarks
edges = np.arange(0.0, 101.0, 5.0)
divided = Environment.from_grid_mask(
np.ones((20, 20), dtype=bool),
grid_edges=(edges, edges),
units="cm",
arena=OpenArena(box(0, 0, 100, 100), walls={"divider": LineString([(50, 0), (50, 70)])}),
)
divided = divided.with_landmarks(
Landmarks(
[
Landmark("cue_card", "point", [50.0, 108.0]),
Landmark("monitor", "line", [(10.0, 100.0), (90.0, 100.0)]),
]
)
)
ax = divided.plot()
Save the environment with the analysis¶
env.to_file(path) writes JSON metadata and NPZ arrays;
env.to_dict() supplies an in-memory equivalent, and
neurospatial.io.nwb.write_environment stores the same record in an NWB file.
They preserve the arena, named walls, regions, landmarks, exact cells,
coordinate metadata and supported numerical geometry without running a layout
builder on load.
Environments saved by earlier versions of neurospatial do not load: recreate them from their source inputs and save them again.
See also¶
- Layout engines: choose discretization strategies.
- Regions: define named analysis regions.
- API reference: full parameters and methods.