Environment architecture changes¶
The Environment internals were reorganized so that each kind of data has one
owner. Rate maps, smoothing and decoding give the same numbers as before;
env.occupancy changes only for intervals that end at a dropout or outside the
environment. This page lists what you may need to change in your own code,
with the old call as a comment above the new one. The examples use positions and
times from your own recording, and env built from them:
import numpy as np
from neurospatial import Environment
rng = np.random.default_rng(0)
positions = rng.uniform(0, 100, size=(2000, 2)) # x, y in cm
times = np.arange(len(positions)) / 30.0 # 30 Hz tracking, in seconds
env = Environment.from_samples(positions, bin_size=4.0, units="cm")
Saving environments: use files, not pickles from older versions¶
Pickle files of an Environment written by an earlier version of neurospatial
cannot be loaded. An environment state from another version raises a
TypeError that says to use files instead.
Pickle still works for sending an environment to worker
processes of the same version (for example with multiprocessing), but it is
not meant for storing data.
To keep an environment between sessions, save it with to_file (or write it to
an NWB file) and load it back with from_file:
# Before: pickle.dump(env, open("arena.pkl", "wb"))
env.to_file("arena")
env = Environment.from_file("arena")
If you only have an old pickle, load it with the neurospatial version that
wrote it, call env.to_file(...) there, and then load that file with the
current version.
Removed import paths¶
These modules no longer exist. Use the public API instead.
| Removed | Use instead |
|---|---|
neurospatial.environment._coordinates |
neurospatial.environment.CartesianSystem, EgocentricPolarSystem, or env.coordinate_system |
neurospatial.environment._metadata |
env.name, env.units, env.frame |
neurospatial.environment._ownership |
nothing: the environment owns its geometry |
neurospatial.environment._from_bins |
Environment.from_bins |
neurospatial.environment.transforms |
Environment.subset, Environment.rebin |
neurospatial.layout._binning, _locators, _polygon, ... |
neurospatial.binning (Binning, RectilinearLocator, PolygonCells, ...) |
neurospatial.layout._capabilities (RectilinearGridProvider) |
layout.binning().rectilinear |
neurospatial.environment.trajectory.BinSequenceWithRuns |
neurospatial.environment.BinSequenceWithRuns |
neurospatial.environment.fields, .queries, .metrics, .trajectory, .regions, .visualization |
the matching Environment method, such as env.smooth, env.neighbors, env.boundary_bins, env.occupancy, env.region_mask or env.plot_field |
neurospatial.environment.serialization |
env.to_file, Environment.from_file, env.to_dict, Environment.from_dict, env.to_nwb (or neurospatial.io) |
neurospatial.environment.factories, .polar, .cartesian |
the Environment factories, such as Environment.from_samples and Environment.from_polar_egocentric |
neurospatial.environment._HAS_SHAPELY |
nothing: shapely is a required dependency and always available |
The modules that implement Environment methods now have private names
(neurospatial.environment._fields and so on) and may change without notice.
Call the methods; they return the same results.
# Before: from neurospatial.environment.trajectory import BinSequenceWithRuns
from neurospatial.environment import BinSequenceWithRuns
runs = env.bin_sequence_with_runs(times, positions)
assert isinstance(runs, BinSequenceWithRuns)
neurospatial.arena still provides the public arena classes (Arena,
OpenArena, MaskArena, TrackArena, UnknownArena, EgocentricSurround).
neurospatial.ops.binning is now neurospatial.ops.mapping¶
neurospatial.binning holds the Binning records that describe an
environment's bins. The point-to-bin utilities had a confusingly similar
name, so neurospatial.ops.binning is renamed neurospatial.ops.mapping.
The functions are unchanged:
# Before: from neurospatial.ops.binning import regions_to_mask
from neurospatial.ops.mapping import regions_to_mask, resample_field
from neurospatial.ops import TieBreakStrategy # a Literal type alias
Environments are immutable values¶
An Environment cannot be changed after it is built, so it now behaves like a
plain value.
Equality. Two environments are equal only when they are the same object.
env == "session1" used to compare the name; it now returns False without an
error, so if env == "session1": silently takes the other branch. Search your
code for == " next to an environment and compare the name instead:
env.name == "session1". env.with_metadata(name="other") returns a new
environment, which does not equal the original. Environments can be put in sets
and used as dictionary keys.
Copying. Environment.copy() is removed because there is nothing to copy.
copy.copy(env) and copy.deepcopy(env) return the same object.
import copy
session = env.with_metadata(name="session1")
# Before: session == "session1" (now always False)
assert session != "session1"
assert session.name == "session1"
# Before: env2 = env.copy()
env2 = env # nothing can change it, so share it
assert copy.deepcopy(env) is env
rate_maps = {session: "rate maps for session 1"} # usable as a dict key
No "fitted" state. An environment always has its bins and connectivity, so
EnvironmentNotFittedError (error code E1004), check_fitted and the
neurospatial.environment.decorators module are removed. Remove except
EnvironmentNotFittedError blocks. Calling Environment() directly still
raises the E1006 error that points to the factories. Passing an object that is
not an Environment to an analysis function raises a TypeError that shows
the corrected call.
Linearization errors. to_linear and linear_to_nd raise TypeError
(not AttributeError) on an environment that is not a linearized track:
# Before: except AttributeError:
try:
linear_position = env.to_linear(positions)
except TypeError:
linear_position = None # env is an open field, not a track
One analysis cache, shared by relabelled copies¶
An environment computes some analyses once and keeps them: the KD-tree behind
nearest_bin, the search structures behind bin_at on tracks, meshes and
clipped environments, diffusion and Gaussian smoothing kernels, the eigenbases used by
smooth, diffuse and get_mrf_basis, the gradient and divergence
operators, boundary bins and the bin and edge attribute tables. They now live
in one cache per environment.
clear_cache() takes no arguments. It releases every cached analysis. The
kdtree=, kernels= and cached_properties= options are removed, together
with neurospatial.ops.mapping.clear_kdtree_cache. Clearing only frees
memory: the next analysis computes what it needs again and gives the same
result.
# Before: env.clear_cache(kdtree=True, kernels=False, cached_properties=False)
# Before: env.clear_cache(kernels=True)
# Before: clear_kdtree_cache(env)
env.clear_cache()
Relabelled copies share the cache. env.with_metadata(...) and
env.with_regions(...) return environments with the same bins, so they now
share their parent's cache instead of starting empty. Renaming an environment
or replacing its regions keeps the kernels and eigenbases already computed:
from neurospatial import compute_spatial_rate
spike_times = np.sort(rng.uniform(times[0], times[-1], 300))
rates = compute_spatial_rate(env, spike_times, times, positions)
renamed = env.with_metadata(name="session 2")
rates = compute_spatial_rate(renamed, spike_times, times, positions) # reuses the cache
Calling clear_cache() on one of them clears it for all of them. To get an
environment with its own empty cache, derive one (subset, clip, rebin,
apply_transform, combine) or load one from a file, dict, NWB or pickle.
Threads. Threads can share one environment: each cached analysis is
computed once even when several threads ask for it at the same time.
smooth, diffuse and get_mrf_basis calls on one environment and its
relabelled copies run one at a time, because each holds a lock on the shared
cache for the whole call. To smooth in parallel (for example one neuron per
worker), use worker processes; each unpickles its own environment with an
empty cache.
Polar environments report their own layout type¶
Environment.from_polar_egocentric(...) environments now report
env.layout_type == "Polar" (it was "MaskedGrid"), and print(env) shows
Polar. The bins are sectors in distance and egocentric angle, so the old
name suggested a Cartesian grid. To test whether an environment is polar, use
the coordinate kind rather than the layout name:
polar_env = Environment.from_polar_egocentric(
distance_range=(0.0, 50.0),
angle_range=(-np.pi, np.pi),
distance_bin_size=5.0,
angle_bin_size=np.pi / 8,
circular_angle=True,
)
# Before: polar_env.layout_type == "MaskedGrid"
assert polar_env.coordinate_kind == "polar"
assert env.coordinate_kind == "cartesian"
A polar environment saved with an earlier version loads with
layout_type == "Polar" too.
Whether the angle wraps around at ±π is read only from the environment's
EgocentricSurround arena. Files from before arenas were saved
(Environment-v2 files and NWB environment schema 2.0) do not have one, so a
polar environment stored in them can no longer be read. Load it with the
version that wrote it and save it again, or rebuild it with
Environment.from_polar_egocentric and the same ranges, bin sizes and
circular_angle, as above.
Custom layout engines supply binning()¶
Each layout engine now hands its geometry to the environment through one
method, binning(), which returns a Binning: the bins, neighbor pairs with
their distances (their order becomes env.edge_pairs), point lookup, the
faces between cells (or the reason they are missing) and any grid, track,
shape and outline records. All built-in engines implement it, and the
LayoutEngine protocol lists it.
If you wrote your own layout engine:
- Implement
binning()from the engine's built state. Returning only built-in records is required, because the environment keeps a copy of the record and never calls back into the engine. A subclass of a built-in engine inherits a workingbinning(); such subclasses used to be refused and now work normally. - The environment keeps only what
binning()returns. It derives the connectivity graph,grid_edges,grid_shape,active_maskanddimension_rangesfrom theBinning, so an engine needs no other attribute and attributes added to an engine's own graph are not kept. SetBinning(..., dimension_ranges=...)when the cells do not show the range the bins cover. Lookup and faces come only frombinning(): the built-in engines no longer havepoint_to_bin_index,finite_volume_geometryoris_grid_compatible(see below), and a custom engine does not need them. A layout whose methods were replaced on the instance is no longer refused. - A built-in engine computes its neighbor pairs as arrays in
build()and no longer builds a graph. Itsconnectivityis derived frombinning()on first access, with the same node and edge attributes asenv.connectivity. It can no longer be assigned, and editing it changes neither the engine's bins nor an environment built from it. To publish other neighbor pairs, return them from your own engine'sbinning(). - An engine may also have a
layout_typelabel, and build arguments recorded withneurospatial.layout.base.capture_build_paramsbecomeenv.layout_parameters. Both are optional. The label becomesenv.layout_typeand the binning'skind(see "One layout label" below); without one, the environment uses your binning'skind. Name the arguments that are input data, such as positions, masks, polygons or graphs, once in the decorator, as in@capture_build_params(data=("positions",)). They are not recorded, and any other argument that is not a scalar or a flat sequence of scalars raises aTypeErrorwhen the engine is built, instead of being dropped silently. - Each bin center must be found in its own bin by the binning's locator; the environment checks this for every engine when it is built and refuses a binning that fails it.
- An external object that has no
binning()method still needs an installed snapshot adapter (see the interoperability guide). neurospatial.layout._capabilities.RectilinearGridProvideris removed. Read the grid record from the engine's binning:
from neurospatial.layout import RegularGridLayout, create_layout
class CoarseGrid(RegularGridLayout):
"""A regular grid whose subclass adds no geometry of its own."""
engine = CoarseGrid()
engine.build(bin_size=10.0, dimension_ranges=[(0.0, 100.0), (0.0, 100.0)])
grid_env = Environment.from_layout(engine, units="cm")
layout = create_layout(
"RegularGrid", bin_size=4.0, dimension_ranges=[(0.0, 100.0), (0.0, 100.0)]
)
# Before: RectilinearGridProvider(layout)
grid = layout.binning().rectilinear
An engine does not need to subclass anything. This one places bins at recorded points and finds the nearest one for each position:
import numpy as np
from neurospatial.binning import (
BinConnections,
Binning,
MissingFaces,
NearestBinLocator,
SpatialBins,
)
class RecordedPoints:
"""Bins at recorded reward-port positions, joined in a ring."""
def build(self, *, points):
points = np.asarray(points, dtype=float)
ring = np.arange(len(points))
pairs = np.column_stack([ring, np.roll(ring, -1)])
lengths = np.linalg.norm(points[pairs[:, 1]] - points[pairs[:, 0]], axis=1)
self._binning = Binning(
"RecordedPoints",
SpatialBins.from_arrays(points, np.ones(len(points)), 2),
BinConnections.from_arrays(pairs, lengths),
NearestBinLocator(points, max_distance=5.0),
MissingFaces("Recorded points have no cells."),
dimension_ranges=((0.0, 100.0), (0.0, 100.0)),
)
def binning(self):
return self._binning
ports = RecordedPoints()
ports.build(points=[[10.0, 10.0], [90.0, 10.0], [90.0, 90.0], [10.0, 90.0]])
ports_env = Environment.from_layout(ports, units="cm")
assert ports_env.bin_at([[11.0, 9.0], [50.0, 50.0]]).tolist() == [0, -1]
The connectivity graph is rebuilt from the bins¶
env.connectivity was already read-only. It is now rebuilt from the bins
whenever an environment is built, derived (subset, clip, rebin,
apply_transform, combine) or loaded, so every environment carries the
same standard attributes: pos, source_grid_flat_index and
original_grid_nd_index on nodes; distance, vector, edge_id and, in
2-D, angle_2d on edges; pos_1D and source_edge_id on tracks. The
values are numbers and tuples, read without a copy. A saved file keeps its
graph for other tools, but loading does not read it back, so attributes
added to a saved graph are dropped. Keep per-bin labels in your own arrays
indexed by bin ID, or annotate an editable copy:
# Before: env.connectivity.nodes[0]["label"] = "start" (refused)
graph = env.to_networkx()
graph.nodes[0]["label"] = "start"
labels = np.full(env.n_bins, "", dtype=object) # or an array indexed by bin
labels[0] = "start"
Neighbor pairs live in BinConnections¶
A Binning keeps its bins (SpatialBins: centers, measures and
measure_dim) and their neighbor pairs (BinConnections: pairs, navigation
distances and is_open) in separate records, passed in that order. A wall
closes the pairs across it instead of deleting them; connections.pairs and
env.edge_pairs are the open pairs, as before. The clip mapping is read from
the clipped locator, so Binning no longer takes clip_mapping=:
from neurospatial.binning import (
BinConnections,
Binning,
MissingFaces,
NearestBinLocator,
SpatialBins,
)
sites = np.array([[0.0, 0.0], [2.0, 0.0]])
# Before: bins = SpatialBins.from_arrays(sites, [1.0, 1.0], 2, [[0, 1]], [2.0])
# Before: Binning("sites", bins, NearestBinLocator(sites), MissingFaces("none"))
bins = SpatialBins.from_arrays(sites, [1.0, 1.0], 2)
connections = BinConnections.from_arrays([[0, 1]], [2.0])
sites_env = Environment.from_bins(
Binning("sites", bins, connections, NearestBinLocator(sites), MissingFaces("none"))
)
# Before: sites_env.binning.bins.pairs
assert sites_env.binning.connections.pairs.tolist() == [[0, 1]]
Track graph attributes must be saveable¶
A track environment (from_graph, linear_track, maze) keeps the graph you
built it from, and saves it with the environment. An attribute that cannot be
saved at all, such as a function or a lock, is now refused when the
environment is built, with a TypeError that names the node or edge and the
attribute. Before, it failed later, when the environment was pickled for a
worker process or written to a file.
Attributes that pickle can store but JSON cannot, such as sets, dates or NaN,
still build and still pickle. to_file, to_dict and NWB export refuse them
with a ValueError that names the attribute. Store such values as numbers,
strings or lists, or keep them outside the graph:
import networkx as nx
track = nx.Graph()
track.add_node(0, pos=(0.0, 0.0))
track.add_node(1, pos=(100.0, 0.0))
# Before: track.add_edge(0, 1, distance=100.0, reward=lambda t: t > 5)
track.add_edge(0, 1, distance=100.0, reward_after_s=5.0)
linear = Environment.from_graph(track, [(0, 1)], edge_spacing=0.0, bin_size=4.0)
Derived environments keep their labels¶
subset, clip and rebin describe the same arena in the same coordinates,
so they keep the parent's name, units, frame, arena and named regions.
apply_transform keeps the name and units, moves the regions and arena with
the bins, and sets the frame only from the frame= you pass, because the new
coordinates are no longer in the parent's frame.
| Call | Before | Now |
|---|---|---|
env.subset(...) |
name "", regions dropped |
name and regions kept |
env.clip(polygon) |
regions dropped | regions kept |
env.rebin(...) |
name "<name>_rebinned" |
name kept |
env.apply_transform(T) |
name "<name>_transformed", frame "<frame>_transformed" (or "transformed") |
name kept, frame None |
env.apply_transform(T, frame="session2") |
not available | frame "session2" |
Regions are annotations and may extend beyond the retained bins. To drop them,
call child.with_regions(Regions()). To rename a derived environment, pass
name= to subset, clip, rebin or apply_transform, or call
with_metadata(name=...):
from neurospatial import Regions
from neurospatial.ops.transforms import translate
west = env.bin_centers[:, 0] < 50.0
# Before: env.subset(bins=west) dropped the regions and the name
west_env = env.subset(mask=west, name="west half").with_regions(Regions())
edges = np.linspace(0.0, 100.0, 21)
square = Environment.from_grid_mask(np.ones((20, 20), dtype=bool), (edges, edges))
# Before: square.rebin(2) was named "<name>_rebinned"
coarse_env = square.rebin(2, name="coarse")
shifted_env = env.apply_transform(translate(-50.0, -50.0), frame="centered")
layout_parameters keeps choices, not data¶
env.layout_parameters used to keep every argument given to the layout
engine, including the full position array passed to from_samples. Saved
files therefore grew with the length of the recording. It now keeps only the
scalar choices that describe how the bins were built, such as bin_size,
connect_diagonal_neighbors, pixel_size or circular_angle. Lists of
numbers come back as tuples.
| Value you used to read | Read it from |
|---|---|
layout_parameters["graph_definition"] |
env.binning.linearization.graph |
layout_parameters["edge_order"] |
env.binning.linearization.edge_order |
layout_parameters["active_mask"], ["grid_edges"] |
env.active_mask, env.grid_edges |
layout_parameters["dimension_ranges"] |
env.dimension_ranges |
layout_parameters["polygon"], ["boundary_polygon"] |
env.arena (for example env.arena.polygonal_domain()) |
layout_parameters["positions"] |
keep your own position array |
The parameters never affected any analysis, and loading a saved environment does not need them: the bins, graph and arena are stored directly.
# Before: env.layout_parameters["dimension_ranges"]
ranges = env.dimension_ranges
bin_size = env.layout_parameters["bin_size"] # scalar choices are still there
The recorded choices describe the bins that were built. An option the bins
did not use is recorded at the value that does nothing: with
infer_active_bins=False every bin is active, so open_field records
fill_holes=False (it used to record its default fill_holes=True), and a
1-D grid records connect_diagonal_neighbors=False. An
option that cannot act at all is refused, as edge_spacing is on a straight
linear_track, which has one edge and no gap between edges:
# Before: Environment.linear_track(endpoints=[(0, 0), (100, 0)], bin_size=2.0, edge_spacing=5.0)
track = Environment.linear_track(endpoints=[(0, 0), (100, 0)], bin_size=2.0)
assert Environment.open_field(
positions, bin_size=4.0, infer_active_bins=False
).layout_parameters["fill_holes"] is False
One layout label¶
A layout used to have several names: LayoutType.REGULAR_GRID was
"RegularGrid", but RegularGridLayout().layout_type was the category
"grid", saved files also stored a layout_category, and
Environment.from_samples(..., layout="regular-grid") reported
env.layout_type == "regular-grid". Derived environments invented more:
"subset", "PolygonCells" after clip, "combined", and
"RegularGrid_transformed" after each apply_transform.
Now LayoutType is the one vocabulary. An engine's layout_type, the
binning's kind and env.layout_type are the same LayoutType value. The
user spellings that create_layout and layout= accept ("regular-grid",
"regular_grid", and the short names "grid", "polygon" and "mesh")
are reported in their canonical form. LayoutType.POLAR and
LayoutType.COMBINED label environments from from_polar_egocentric and
combine; create_layout refuses them and names the factory to use.
Derived environments keep their parent's label, and env.derivations lists
what was applied, oldest first:
| Call | layout_type before |
layout_type now |
derivations |
|---|---|---|---|
grid.subset(...) |
"MaskedGrid" |
"RegularGrid" |
("subset",) |
hexagons.subset(...) |
"subset" |
"Hexagonal" |
("subset",) |
grid.clip(polygon) |
"PolygonCells" |
"RegularGrid" |
("clip",) |
grid.apply_transform(T) |
"RegularGrid_transformed" |
"RegularGrid" |
("apply_transform",) |
polygon_grid.rebin(2) |
"RegularGrid" |
"ShapelyPolygon" |
("rebin",) |
Environment.combine({...}) |
"combined" |
"Combined" |
("combine",) |
combine starts a new history, because the result has no single parent.
env.layout_parameters keeps the parent's build choices through subset,
clip and apply_transform instead of being replaced by
{"source": "subset", "original_n_bins": ...} or gaining
"transformed": True. rebin builds new bins and records their bin_size
and connect_diagonal_neighbors, whether you passed a factor or a
bin_size. combine records no build choices.
west = env.subset(mask=env.bin_centers[:, 0] < 50.0)
# Before: west.layout_type == "MaskedGrid" and
# west.layout_parameters == {"source": "subset", ...}
assert west.layout_type == env.layout_type
assert west.derivations == ("subset",)
assert west.layout_parameters == env.layout_parameters
Code that compared engine.layout_type with a category such as "grid" or
"mask" should compare with a LayoutType value, or better, ask the bins
what it needs (for example env.binning.rectilinear is not None for an
axis-aligned grid). Files saved by earlier versions load with the canonical
label: suffixes are dropped, "subset" and "PolygonCells" take the label
of the bins they were cut from (a grid subset or clip reads as
"MaskedGrid", as such a subset was labelled), polar bins read as
"Polar", and the "source" and "transformed" markers become
env.derivations. Input data that earlier versions kept in the layout
parameters (positions, masks, polygons) is dropped when such a file is read;
the bins themselves are stored in the file.
Tracks need track_width to look up positions¶
A track's centerline and bin size do not say how far from the centerline a
position is still on the track. Without a width, every position, however far
off the track (a reflection, a tracking jump to the other side of the room),
used to be put in its nearest bin, and its time and spikes were counted there.
A track built without track_width now refuses to assign 2-D positions to
bins: bin_at, contains, occupancy, the bin sequences,
transitions(times=..., positions=...), points= queries, point regions and
the analyses built on them raise NotImplementedError. With a width,
positions farther than half the width from every centerline segment get bin
-1 and add no occupancy, like positions outside an open-field arena.
Pass the measured full width of the corridor, in the units of your positions,
to linear_track, maze or from_graph. The error message shows how to give
an existing track a width (see the next section).
import numpy as np
from neurospatial import Environment
# Before: Environment.linear_track(endpoints=[(0, 0), (200, 0)], bin_size=2.0)
track = Environment.linear_track(
endpoints=[(0, 0), (200, 0)], bin_size=2.0, track_width=10.0, units="cm"
)
positions = np.array([[100.0, 2.0], [100.0, 500.0]]) # on the track; 5 m away
assert track.bin_at(positions)[1] == -1 # before: the bin at x = 100
linear_to_nd and graph queries on bin indices (bins=) do not need the
width. to_linear does not either, but it projects every position onto the
centerline, however far off the track it is, so it is not a way around the
refusal: keep only the samples bin_at puts on a track with a width. Simulated mazes from neurospatial.simulation.mazes pass
their corridor width to their track. The Sungod, honeycomb and small hex
mazes, whose corridors differ in width, pass the widest one: a sample within
half of it from the centerline counts as on the track, so a narrow corridor
can admit a few samples just outside its walls.
Tracks saved by earlier versions¶
A track saved by an earlier version has no width, and neither does a track
you no longer have the build script for. Read it as before and give it its
measured full width with env.with_track_width(width). It keeps the bins,
the linear coordinates, the name, units, frame and regions, so rate maps and
decoders keep their bin order. It also works on a subset or a transformed
track; give the width in the transformed coordinates.
import numpy as np
from neurospatial import Environment
# A track saved without a width, as earlier versions wrote it
saved = Environment.linear_track(
endpoints=[(0, 0), (200, 0)], bin_size=2.0, units="cm"
).to_dict()
track = Environment.from_dict(saved) # or from_file(path), from_nwb(nwbfile)
# Before: track.bin_at(positions) put every position in its nearest bin
track = track.with_track_width(10.0) # the corridor is 10 cm wide
positions = np.array([[101.0, 2.0], [101.0, 500.0]]) # on the track; 5 m away
assert track.bin_at(positions).tolist() == [50, -1]
# Save it again so the width is stored with it
saved = track.to_dict()
dimension_ranges is the extent of the bins' cells¶
env.dimension_ranges is the extent of the cells the bins cover, for every
layout. A parent reports its cells' extent and env.subset(...) the extent of
the cells it keeps, with no margin, so a subset never reports a larger range
than its parent. Plot limits follow dimension_ranges, so plots fit the cells
instead of cutting them off or leaving extra space around them.
| Layout | Before | Now |
|---|---|---|
| Hexagonal | the range the tiling was asked to cover (data or dimension_ranges=), which cut off the outer hexagons |
the hexagons' extent, corners included |
| Triangular mesh | the bounds of the triangle centroids | the mesh boundary's bounds |
Track (linear_track, maze, from_graph) |
the bounds of the bin centers, half a bin short at each end | the centerline the bins cover, end to end (the width is not included) |
Rectangular grid (including from_polygon, masks and images), polar |
the whole grid, inactive border cells included | the active cells (unchanged when every border row and column has an active cell) |
| Grid subsets | the whole grid the subset keeps (grid_edges) |
the cells the subset keeps |
Subsets of hexagonal, mesh and clipped polygon cells (env.clip(...)) |
kept cells' extent plus 5% on each side | kept cells' extent |
| Track subsets | kept bin centers' extent | the centerline the kept bins cover |
For a 200 cm linear track built with bin_size=2.0, dimension_ranges[0]
was (1.0, 199.0) and is now (0.0, 200.0). A hexagonal environment
requested over (0, 30) with hexagon_width=4.0 now reports
(-2.0, 36.0), the hexagons it built.
A mesh subset bounds only the triangles it keeps, so a subset of one corner of
an arena no longer reports the extent of the whole arena. The same rule applies
when an arena= passed to from_layout, from_grid_mask or from_pixel_mask
removes bins. Environment.from_bins bounds triangular-mesh bins by their
triangles' vertices rather than every stored mesh point. A grid subset still
keeps the whole lattice in env.grid_edges and env.grid_shape, which is
what napari draws, but its dimension_ranges is the cells it keeps.
Code that reads dimension_ranges as a number changes with it on hexagonal,
mesh, track and grid-subset environments: the automatic scale of
neurospatial.ops.spatial_basis, the "bandwidth spans the environment"
warning, the uniform field placement of simulate_session, axis limits,
env.info(), and the extent that animation overlays and an uncalibrated
video are drawn over. To keep the old numbers, pass them yourself, for
example the bin-center bounds:
track = Environment.linear_track(endpoints=[(0, 0), (200, 0)], bin_size=2.0)
assert track.dimension_ranges[0] == (0.0, 200.0)
# Before: track.dimension_ranges[0] == (1.0, 199.0), the bin-center bounds
centers_low, centers_high = track.bin_centers.min(axis=0), track.bin_centers.max(axis=0)
If you want space around a subset's cells in a figure, widen the axes limits after plotting:
import matplotlib.pyplot as plt
hex_env = Environment.from_samples(positions, bin_size=8.0, layout="Hexagonal")
child = hex_env.subset(mask=hex_env.bin_centers[:, 0] < 50.0)
fig, ax = plt.subplots()
child.plot(ax=ax)
low, high = child.dimension_ranges[0]
ax.set_xlim(low - 1.0, high + 1.0)
Factory arguments¶
Every Environment factory takes its data positionally and every option by
keyword, following the argument order used across neurospatial. An option
that the layout would ignore raises an error when you change it from its
default, instead of being ignored.
An old call that passes an option by position raises a TypeError that names
the keyword to use and shows the corrected call, so the error tells you how to
update each line:
try:
Environment.from_samples(positions, 2.0)
except TypeError as error:
assert "pass bin_size=2.0 by keyword" in str(error)
from_samples and open_field¶
bin_size is keyword-only:
# Before: env = Environment.from_samples(positions, 2.0)
env = Environment.from_samples(positions, bin_size=2.0)
Both factories list all their options. dimension_ranges= is a named option;
other names that used to be forwarded to the layout engine now raise
TypeError. The options that clean up a regular grid's active-bin mask or set
its diagonal connections (dilate, fill_holes, close_gaps,
add_boundary_bins, connect_diagonal_neighbors) mean nothing on a hexagonal
layout, so setting any of them to a value other than its default with
layout="Hexagonal" raises ValueError (it used to be ignored). Remove
those values from the call; leaving them out gives the same hexagons as
before. The defaults themselves are accepted, and so is fill_holes=False in
open_field (whose default is True), because a hexagonal layout fills no
holes. A parameter set that sweeps layout over ["RegularGrid", "Hexagonal"]
can therefore spell the options out:
# Before: Environment.from_samples(positions, 4.0, layout="Hexagonal", fill_holes=True)
hex_env = Environment.from_samples(positions, bin_size=4.0, layout="Hexagonal")
grid_options = {"dilate": False, "fill_holes": False, "connect_diagonal_neighbors": True}
for layout in ["RegularGrid", "Hexagonal"]:
Environment.from_samples(positions, bin_size=4.0, layout=layout, **grid_options)
In the same way, bin_count_threshold, dilate, fill_holes and close_gaps
decide which bins the samples make active. With infer_active_bins=False
every bin in the extent is active, so changing any of them from its default
raises ValueError instead of being ignored. A layout= other than "RegularGrid" or
"Hexagonal" raises ValueError (it was NotImplementedError); build such a
layout with create_layout and pass it to from_layout.
open_field fills holes by default. To keep unvisited interior bins out, pass
fill_holes=False (this used to raise a TypeError):
Track factories¶
from_graph keeps the graph and edge order positional; bin_size and
edge_spacing are keywords. edge_spacing defaults to 0.0, the same as in
linear_track and maze, so a track whose edges join end to end needs no
spacing argument:
import networkx as nx
graph = nx.Graph()
graph.add_node(0, pos=(0.0, 0.0))
graph.add_node(1, pos=(100.0, 0.0))
graph.add_node(2, pos=(100.0, 50.0))
graph.add_edge(0, 1, distance=100.0)
graph.add_edge(1, 2, distance=50.0)
edge_order = [(0, 1), (1, 2)]
# Before: env = Environment.from_graph(graph, edge_order, 0.0, 2.0)
track = Environment.from_graph(graph, edge_order, bin_size=2.0)
# A gap between arms that do not touch is still a choice you make
track = Environment.from_graph(graph, edge_order, bin_size=2.0, edge_spacing=10.0)
maze names its maze type by keyword:
nodes = {
"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),
}
# Before: env = Environment.maze("plus", node_positions=nodes, bin_size=2.0)
plus_maze = Environment.maze(kind="plus", node_positions=nodes, bin_size=2.0)
from_graph, linear_track and maze now accept regions= like the other
factories.
Polygon, grid-mask and pixel-mask factories¶
The bin size of a polygon and the pixel size of an image mask are keywords; the polygon, the mask and the grid edges stay positional:
from shapely.geometry import box
arena_polygon = box(0.0, 0.0, 100.0, 100.0)
image_mask = np.ones((40, 60), dtype=bool)
active_mask = np.ones((10, 10), dtype=bool)
grid_edges = (np.linspace(0.0, 100.0, 11), np.linspace(0.0, 100.0, 11))
# Before: Environment.from_polygon(arena_polygon, 2.0)
polygon_env = Environment.from_polygon(arena_polygon, bin_size=2.0)
# Before: Environment.from_pixel_mask(image_mask, 0.5)
image_env = Environment.from_pixel_mask(image_mask, pixel_size=0.5)
mask_env = Environment.from_grid_mask(active_mask, grid_edges) # unchanged
All three accept regions=, so every Cartesian factory takes the same name=,
units=, frame= and regions= keywords.
Egocentric polar factory¶
from_polar_egocentric has no data arguments, so every argument is a keyword.
Swapping the distance and angle bin sizes used to build a valid but wrong
environment without an error:
# Before: Environment.from_polar_egocentric((0, 50), (-np.pi, np.pi), 5.0, np.pi / 8)
polar_env = Environment.from_polar_egocentric(
distance_range=(0, 50),
angle_range=(-np.pi, np.pi),
distance_bin_size=5.0,
angle_bin_size=np.pi / 8,
)
from_layout takes a built layout engine¶
from_layout used to take a layout name and a dictionary of build parameters,
which repeated what from_samples, from_polygon, from_grid_mask,
from_pixel_mask and from_graph already do with named, checked arguments.
It now takes a layout engine you have built, so its job is the layouts that
have no dedicated factory: a triangular mesh, a grid or hexagonal tiling over
explicit ranges with no position samples, or an engine you wrote (see
custom layout engines).
from shapely.geometry import box
from neurospatial.layout import create_layout
arena = box(0.0, 0.0, 50.0, 50.0)
# Before:
# env = Environment.from_layout(
# "TriangularMesh", {"boundary_polygon": arena, "point_spacing": 2.5}
# )
mesh = create_layout("TriangularMesh", boundary_polygon=arena, point_spacing=2.5)
mesh_env = Environment.from_layout(mesh, units="cm")
The environment copies the bins from the engine's binning(), so changing the
engine afterwards does not change the environment. env.layout_parameters
comes from the engine's record of its build(...) arguments, including the
defaults it resolved, for example connect_diagonal_neighbors=True for a
masked grid. Input data is never recorded: a hexagonal tiling built from
ranges records hexagon_width, infer_active_bins and bin_count_threshold
but no positions, and a triangular mesh records only point_spacing, with
or without holes in its boundary.
NWB: loading versus building¶
Environment.from_nwb used to do two different things depending on its
arguments. It now only loads an environment that was saved in the file, and
names the record with name=, as env.to_nwb(nwbfile, name) does. Building an
environment from the file's Position data is
neurospatial.io.nwb.environment_from_position, which takes the same binning
options as Environment.from_samples, with bin_size as a keyword:
from neurospatial.io.nwb import environment_from_position
# Before: env = Environment.from_nwb(nwbfile, scratch_name="linear_track")
env = Environment.from_nwb(nwbfile, name="linear_track")
# Before: env = Environment.from_nwb(nwbfile, bin_size=5.0, units="cm")
# Before: env = environment_from_position(nwbfile, 5.0)
env = environment_from_position(nwbfile, bin_size=5.0, units="cm")
The old calls tell you what to use instead.
Environment.from_nwb(nwbfile, bin_size=5.0) raises a TypeError that shows
the environment_from_position(nwbfile, bin_size=5.0) call, and
Environment.from_nwb(nwbfile) on a file with no saved environment raises a
KeyError that lists the environments the file does store and, when the file
has Position data, names it and points to environment_from_position.
Which bins a region covers¶
Every region query uses one rule. A point region, such as a reward port,
covers the bin that contains the point. A polygon region, such as a reward
zone or a maze arm, covers the bins whose centers lie inside it. A center
exactly on its edge is covered when nudging it a hair north-east would move it
inside, so polygons that share an edge, such as quadrants, split the centers on
it and no bin is in two of them. bins_in_region, region_mask, region_membership,
distance_to with a region, subset(region_names=...),
subset(polygon=...) and regions_to_mask all follow it, so the bins you
count time in are the bins you measure distance to.
| Call | Before | Now |
|---|---|---|
env.region_membership(), point region |
no bins | the bin containing the point |
regions_to_mask(env, "reward"), point region |
no bins and a warning | the bin containing the point |
env.distance_to("reward"), point region |
warning, then ValueError |
distance to the bin containing the point |
env.subset(region_names=["reward"]), point region |
ValueError |
keeps the bin containing the point |
env.bins_in_region("zone"), centers on the polygon's edge |
left out | included on the south and west edges, left out on the north and east edges |
env.subset(polygon=...) and subset(region_names=...), centers on the edge |
left out | as bins_in_region |
include_boundary= on bins_in_region, region_mask, region_membership, subset, regions_to_mask, points_in_any_region and regions_containing_points |
chose open or closed edges | removed; one rule for every query |
A bin center lies exactly on a polygon's edge when the polygon is drawn on the
bin grid, for example box(0, 0, 50, 100) on a grid with a center at
x = 50. Analyses that use bins_in_region then see the edge bins on the
polygon's south and west sides: decision-region entry times
(decision_region_entry_time, compute_decision_analysis,
compute_vte_session), the goal bins and decision boundary of
compute_decision_analysis, trials_to_region_arrays and
distance_to_boundary(region_name=...). Two regions that meet along a column
of centers each get part of it, never both:
from shapely.geometry import box
from neurospatial import Regions
edges = np.linspace(0.0, 100.0, 11) # bin centers at 5, 15, ..., 95 cm
grid = Environment.from_grid_mask(np.ones((10, 10), dtype=bool), (edges, edges))
regions = Regions()
regions.add("reward", point=(52.0, 48.0))
regions.add("west", polygon=box(0.0, 0.0, 55.0, 100.0)) # edge on x = 55
regions.add("east", polygon=box(55.0, 0.0, 100.0, 100.0))
grid = grid.with_regions(regions)
reward_bins = grid.bins_in_region("reward") # the bin containing the point
assert np.array_equal(np.flatnonzero(grid.region_membership()[:, 0]), reward_bins)
# The column of centers at x = 55 lies on the shared edge: it goes to "east",
# the side a nudge north-east enters, so the halves split the grid 50 / 50.
west_bins, east_bins = grid.bins_in_region("west"), grid.bins_in_region("east")
assert (len(west_bins), len(east_bins)) == (50, 50)
assert np.all(grid.bin_centers[east_bins, 0] >= 55.0)
Query targets are keywords: bins=, points= or region=¶
A bin index and a coordinate look the same as numbers, so the graph and
distance queries no longer guess which one you meant. Name the target with a
keyword: bins= for bin indices, points= for coordinates and, for
distance_to, region= for a named region. A point means the bin that
contains it, so the distance to a reward port at (82, 77) is the distance to
the bin that contains the port.
positions = np.random.default_rng(0).uniform(0, 100, size=(2000, 2)) # cm
env = Environment.from_samples(positions, bin_size=4.0, units="cm")
reward_port = np.array([[82.0, 77.0]]) # cm, one row per point
reward_bin = int(env.bin_at(reward_port)[0])
start_bin = int(env.bin_at(positions[:1])[0])
# Before: env.distance_to([reward_bin])
to_reward = env.distance_to(bins=reward_bin)
assert np.array_equal(to_reward, env.distance_to(points=reward_port))
# Before: env.neighbors(reward_bin)
around_reward = env.neighbors(bins=reward_bin)
# Before: env.path_between(start_bin, reward_bin)
route = env.path_between(bins=[start_bin, reward_bin])
# Before: env.distance_between(positions[0], reward_port[0])
route_cm = env.distance_between(points=[positions[0], reward_port[0]])
# Before: env.reachable_from(reward_bin, radius=20.0, metric="geodesic")
near_reward = env.reachable_from(bins=reward_bin, radius=20.0, metric="geodesic")
# Before: env.rings(center_bin=reward_bin, hops=2)
rings = env.rings(bins=reward_bin, hops=2)
With a named region, pass region=:
from neurospatial import Regions
positions = np.random.default_rng(0).uniform(0, 100, size=(2000, 2)) # cm
env = Environment.from_samples(positions, bin_size=4.0, units="cm")
regions = Regions()
regions.add("reward", point=(82.0, 77.0))
env = env.with_regions(regions)
# Before: env.distance_to("reward")
to_reward = env.distance_to(region="reward")
assert np.array_equal(to_reward, env.distance_to(points=[[82.0, 77.0]]))
An old call raises a TypeError that says which keyword applies to the
value you passed and shows the corrected call. It makes the same guess the old
call made, integers as bins and real numbers as points, so check that the
suggestion means what you intended:
from neurospatial import Regions
positions = np.random.default_rng(0).uniform(0, 100, size=(2000, 2)) # cm
regions = Regions()
regions.add("reward", point=(82.0, 77.0))
env = Environment.from_samples(positions, bin_size=4.0, units="cm", regions=regions)
try:
env.neighbors(12)
except TypeError as error:
assert "env.neighbors(bins=12)" in str(error)
try:
env.distance_to("reward")
except TypeError as error:
assert "env.distance_to(region='reward')" in str(error)
What the old guesses did, and what happens now:
| Call | Before | Now |
|---|---|---|
env.distance_to([50.7, 12.3]) |
distance to bins 50 and 12 | use points=[[50.7, 12.3]]; floats in bins= raise TypeError |
env.neighbors([0, 1]) |
neighbors of the point (0, 1) | use bins=0, or points=[[0, 1]] |
env.distance_to(3) |
TypeError: len() of unsized object |
env.distance_to(bins=3) works |
env.distance_between(p, far_point) |
inf |
ValueError naming the point outside the environment |
polar_env.distance_between(...) |
CartesianOnlyError |
graph path length between two bins or (distance, angle) points |
env.bin_center_of(np.array([10.0, 10.0])) |
centers of bin 10, twice | TypeError pointing to env.bin_at |
distance_to_region(position_bins, env, target_bins=50.7) |
distance to bin 50 | TypeError; pass a bin index, for example int(env.bin_at(goal)[0]) |
shortest_path_length(env, start_outside, goal) |
distance from the last bin | ValueError naming the point outside |
Other refusals that used to give a number or a different error:
env.bin_center_oftakes a 1-D array of bin indices; a 2-D index array raises instead of returning an array of shape(a, b, n_dims).- A polygon region on an environment whose bins are not 2-D raises
ValueError(it wasNotImplementedError) inregion_membershipandregions_to_mask. compute_path_efficiencyandsubgoal_efficiencywithmetric="geodesic"raiseValueErrorwhen the trajectory starts outside the environment, instead of measuring from the last bin.geodesic_voronoi_labelsanddistance_to_decision_boundaryraiseValueErrorfor an empty goal list;distance_to_decision_boundaryused to returninf.
points= always has shape (n_points, n_dims): wrap one point as
[[x, y]]. Points with NaN coordinates or outside the environment raise a
ValueError that names the first such row, instead of mapping to -1. Queries
that start from one bin (neighbors, reachable_from, rings) take one bin
or one point; path_between and distance_between take two, the start and the
end.
subset selects bins with a boolean mask, so its keyword is mask=; bins=
always means integer bin indices. env.subset(bins=west) raises a TypeError
that says to pass mask=:
west = env.bin_centers[:, 0] < 50.0 # one True/False per bin
# Before: env.subset(bins=west)
west_env = env.subset(mask=west)
# From bin indices, build the mask first
north_bins = np.flatnonzero(env.bin_centers[:, 1] > 50.0)
keep = np.zeros(env.n_bins, dtype=bool)
keep[north_bins] = True
north_env = env.subset(mask=keep)
Missing data: NaN is a missing value, never zero¶
A NaN position sample is a missing sample in every per-sample query: it gets
bin -1 from bin_at and nearest_bin, False from contains and NaN from
interpolate, and adds no occupancy time and no transition. nearest_bin and
interpolate used to raise for it, and transitions(times=..., positions=...)
raised for any sample outside the environment. A NaN bin of a field is a bin
with no value: smooth and diffuse with mode="average" average over the
observed bins and fill holes from them, where smooth used to refuse the field
and diffuse turned every bin NaN. A bin stays NaN when its share of smoothing
weight on observed bins is at most 0.1% of the largest share in its connected
part, for example in a room with no visits. mode="transition" and
mode="density" refuse NaN, because a missing bin has no defensible mass:
fill it explicitly, or use mode="average" if the field is a rate. Fields
without NaN give the same values as before.
import numpy as np
from neurospatial import Environment
rng = np.random.default_rng(0)
positions = rng.uniform(0, 100, size=(2000, 2)) # cm
env = Environment.from_samples(positions, bin_size=4.0, units="cm")
rate = np.where(env.bin_centers[:, 0] < 70.0, 5.0, np.nan) # Hz, east unvisited
# Before: env.smooth(rate, 8.0) raised "Field contains NaN values"
smoothed = env.smooth(rate, bandwidth=8.0, mode="average")
assert np.allclose(smoothed[np.isfinite(smoothed)], 5.0)
# Before: env.nearest_bin([[np.nan, 10.0]]) raised a SciPy error
assert env.nearest_bin(np.array([[np.nan, 10.0]]))[0] == -1
env.occupancy follows the rules compute_spatial_rate uses for spike counts
and occupancy, so dividing your own spike counts by env.occupancy uses the
same denominator as the rate maps. An interval counts only when both of its
tracked samples are inside the environment. A dropout (a NaN row) is a
missing sample, treated exactly like a deleted row: the gap between the
tracked samples around it is bridged when it is no longer than max_gap,
with its time in the bin of the sample before it, and excluded when it is
longer. Do not fill dropouts during preprocessing.
import numpy as np
from neurospatial import Environment
env = Environment.from_samples(np.linspace(0, 100, 101)[:, None], bin_size=10.0)
times = np.array([0.0, 0.1, 0.2, 0.3])
positions = np.array([[15.0], [16.0], [np.nan], [45.0]]) # cm; one dropout
deleted = env.occupancy(times[[0, 1, 3]], positions[[0, 1, 3]])
assert np.allclose(env.occupancy(times, positions), deleted) # 0.3 s in all
# A max_gap shorter than the 0.2 s gap makes it a tracking loss
assert np.isclose(env.occupancy(times, positions, max_gap=0.15).sum(), 0.1)
occupancy, bin_sequence, bin_sequence_with_runs and trajectory
transitions now warn, like compute_spatial_rate, when max_gap and the
other interval gates exclude every interval, or when the median sampling
interval is longer than max_gap. Timestamps in milliseconds trigger it: each
33 ms frame looks like a 33 s recording gap. Convert times to seconds.
Cartesian-only methods on polar environments¶
An egocentric polar environment stores (distance, angle) bins around the
animal. Methods that read positions as Cartesian (x, y), such as subset,
rebin, interpolate and apply_transform, still refuse it, but the refusal
now happens when you call the method, not when you look it up:
hasattr(polar_env, "subset")isTrueanddir(polar_env)lists the method. Calling it raisesCartesianOnlyError.CartesianOnlyErroris aTypeError, and it is importable asneurospatial.CartesianOnlyError. It is no longer anAttributeErroror aNotImplementedError.- Every Cartesian-only refusal raises this one error: the methods, the
Cartesian modes of shared methods such as
distance_to(metric="euclidean"), analysis functions such asspatial_autocorrelation, andEnvironment.combine. - The message says what was refused, why it needs Cartesian positions, and what
to use on a polar environment. When a polar environment has an equivalent,
the message ends with the code, also in
error.example.
Polar environments look up (distance, angle) rows¶
bin_at, contains, occupancy, bin_sequence, bin_sequence_with_runs
and transitions(times=..., positions=...) work on an egocentric polar
environment. Each row is (distance, angle), the angle in radians, and a row
gets the bin that contains it, or -1 (False) outside the distance and angle
ranges. The points= queries (distance_to, neighbors, ...) and point
regions use the same lookup. An angle is a direction, so an angle a whole turn
outside the angle range (for example 3.5 on (-π, π)) finds the same bin as
the angle inside it, and π on a full circle is the bin at -π; before, such
rows were outside. occupancy(time_allocation="linear") still refuses a polar
environment, because a straight path between two samples is not straight in
(distance, angle).
import numpy as np
from neurospatial import Environment
polar_env = Environment.from_polar_egocentric(
distance_range=(0.0, 40.0), # cm from the animal
angle_range=(-np.pi, np.pi),
distance_bin_size=10.0,
angle_bin_size=np.pi / 4,
units="cm",
)
points = np.array([[15.0, 0.3], [15.0, 0.3 + 2 * np.pi], [55.0, 0.0]])
# Before: polar_env.bin_at(points) raised CartesianOnlyError, which suggested
# graph operations or building an allocentric Cartesian environment instead
bins = polar_env.bin_at(points)
assert bins[0] == bins[1] >= 0 # one direction, one bin
assert bins[2] == -1 # beyond the 40 cm surround
nearest_bin on a polar environment now measures the physical distance
between places, in the environment's length units: it compares
(distance * cos(angle), distance * sin(angle)) positions in the animal's
plane, not the raw (distance, angle) columns. It can therefore return a
different bin than before, and return_dist=True distances and
max_distance are lengths. It is still the nearest bin center, not the bin
that contains the row; use bin_at for that.
import numpy as np
from neurospatial import Environment
polar_env = Environment.from_polar_egocentric(
distance_range=(0.0, 40.0),
angle_range=(-np.pi, np.pi),
distance_bin_size=10.0,
angle_bin_size=np.pi / 4,
units="cm",
)
row = np.array([[10.2, 0.0]]) # 10.2 cm straight ahead
# Before: nearest_bin compared cm and radians as one unit
nearest, distance_cm = polar_env.nearest_bin(row, return_dist=True)
containing = polar_env.bin_at(row) # the 10-20 cm ring, ahead
assert polar_env.bin_centers[containing[0], 0] == 15.0
The analysis functions that read positions as the animal's allocentric
positions keep refusing a polar environment, now with their own name in the
message: compute_spatial_rate, compute_spatial_rates,
place_cell_significance, compute_directional_place_fields, the view-rate functions,
compute_decision_analysis, compute_vte_session, the geodesic modes of
compute_step_lengths, mean_square_displacement and decoding_error,
the distance_to_reward and distance_to_boundary event regressors,
simulate_trajectory_ou, and the boundary, object-vector, spatial-view and
geodesic place-cell models. For egocentric rate maps use
neurospatial.encoding.compute_egocentric_rate.
A polar environment now answers component_keys, bin_sources and
get_bridge_attributes() like any environment that was not combined: an empty
tuple, None and an empty table.
ops functions that renamed an Environment method¶
Three functions in neurospatial.ops only called an Environment method
under another name. They are removed; call the method. The arguments and
results are unchanged.
import numpy as np
from neurospatial import Environment
from neurospatial.ops import translate
rng = np.random.default_rng(0)
positions = rng.uniform(0, 100, size=(2000, 2)) # cm
env = Environment.from_samples(positions, bin_size=4.0, units="cm")
# Before: map_points_to_bins(env, positions[:5], tie_break="lowest_index")
bins = env.nearest_bin(positions[:5], tie_break="lowest_index")
# Before: compute_differential_operator(env)
D = env.get_differential_operator() # (n_bins, n_edges)
# Before: apply_transform_to_environment(env, translate(10.0, 0.0), frame="rig")
shifted = env.apply_transform(translate(10.0, 0.0), frame="rig")
assert np.allclose(shifted.bin_centers, env.bin_centers + [10.0, 0.0])
map_points_to_bins refused polar environments; env.nearest_bin accepts
them and compares the stored distance and angle columns directly.
validate_environment is removed¶
neurospatial.layout.validation.validate_environment(env) re-checked what
every factory already checks when it builds an environment, so it always
passed. Remove the call. Its strict=True mode warned when units or frame
were missing; declare them when you build the environment instead.
# Before: validate_environment(env) # warned: no frame
labelled = env.with_metadata(frame="room")
assert (labelled.units, labelled.frame) == ("cm", "room")
Layout engines no longer look up points or build faces themselves¶
The built-in layout engines had point_to_bin_index, finite_volume_geometry
and is_grid_compatible. An environment never called them; it uses the
engine's binning(). They are removed. Ask the environment, or the engine's
binning:
from neurospatial.layout import create_layout
engine = create_layout(
"RegularGrid", bin_size=4.0, dimension_ranges=[(0.0, 100.0), (0.0, 100.0)]
)
binning = engine.binning()
# Before: engine.point_to_bin_index(positions[:5])
bins = binning.locator.locate(positions[:5])
# Before: engine.finite_volume_geometry()
faces = binning.build_faces() # face areas faces.A, cell measures faces.volumes
# Before: engine.is_grid_compatible
is_grid = binning.rectilinear is not None
assert is_grid and bins.min() >= 0
For an environment, env.bin_at(positions) locates points and
env.binning.rectilinear is not None tells you whether the bins form a
rectangular grid.
An environment's public attributes cannot be set¶
Assigning any public attribute of an Environment, even one the class does
not define, raises an AttributeError; it used to attach the value silently.
Keep your own session labels beside the environment:
# Before: env.session_id = "s1"
sessions = {env.name: {"session_id": "s1"}}
labelled = env.with_metadata(name="s1") # or set the environment's own name
Simulated plus-maze arms are numbered in linear order¶
simulation.mazes.make_plus_maze now builds its linear track with
Environment.maze(kind="plus"), the same builder as every other plus maze.
Its arms are numbered in the order they are laid out along the linear
position, clockwise from north. East and south swap numbers in each bin's
source_edge_id and in the edge_id of the track graph:
| Arm | Before | Now |
|---|---|---|
| north | 0 | 0 |
| south | 1 | 2 |
| east | 2 | 1 |
| west | 3 | 3 |
Bin positions, linear positions (pos_1D) and every analysis are unchanged.
Select an arm by where its bins lie, or by a region, rather than by a
hard-coded number:
from neurospatial.simulation.mazes import make_plus_maze
track = make_plus_maze(include_track=True).env_track
arm = np.array([track.connectivity.nodes[b]["source_edge_id"] for b in range(track.n_bins)])
# Before: east = arm == 2
east = track.bin_centers[:, 0] > 0
assert set(arm[east]) == {1}
Grid analyses on layouts without square lags raise NotImplementedError¶
The autocorrelogram behind grid scores needs a 2-D grid of rectangular
cells, one width along each axis. spatial_autocorrelation used to raise
ValueError for tracks and meshes, and accepted hexagonal environments with
wrong results. It now raises NotImplementedError for all of them, naming
spatial_autocorrelation_radial, which works on every layout. Grids with
many inactive cells are accepted: inactive cells are left out of each lag
like unvisited bins.
Polar environments raise CartesianOnlyError (a TypeError), here and for
wall-based measures such as border_score:
from neurospatial.encoding.grid import (
spatial_autocorrelation,
spatial_autocorrelation_radial,
)
track = Environment.linear_track(endpoints=[(0, 0), (100, 0)], bin_size=2.0, units="cm")
rate = np.random.default_rng(1).random(track.n_bins)
# Before: except ValueError:
try:
autocorr = spatial_autocorrelation(track, rate)
except NotImplementedError:
distances, correlations = spatial_autocorrelation_radial(track, rate)
Rate-map grid_score(), grid_scores() and batch_grid_scores() raise the
same NotImplementedError on these environments. summary_table() reports a
NaN grid score, explains it in attrs["unavailable_metrics"]["grid_score"],
and labels a spatially tuned cell "undetermined" rather than "place",
since it could be a grid cell.
Grid orientation is in radians, in the environment's frame¶
grid_properties(...).orientation used to be in degrees, in [0, 60), and
measured in the autocorrelogram's image layout, which reflected the field
lattice about 45°. It is now the lattice angle in the environment's frame:
radians from +x (East) counterclockwise toward +y (North), in [0, π/3),
because a hexagonal lattice repeats every 60°. orientation_std is in
radians too. peak_coords columns are now x then y offsets; earlier
versions returned them y first, so swap the columns of any saved values.
GridCellModel(grid_orientation=...) takes the same angle, so a simulated
grid cell is reported with the orientation it was given:
from neurospatial.encoding.grid import (
autocorrelation_pixel_size,
grid_properties,
spatial_autocorrelation,
)
from neurospatial.simulation import GridCellModel
open_field = np.random.default_rng(0).uniform(0, 100, size=(5000, 2)) # x, y in cm
arena = Environment.from_samples(open_field, bin_size=2.0, units="cm")
cell = GridCellModel(arena, grid_spacing=30.0, grid_orientation=np.radians(20.0))
autocorr = spatial_autocorrelation(arena, cell.firing_rate(np.zeros(len(arena.bin_centers)), arena.bin_centers))
properties = grid_properties(autocorr, pixel_size=autocorrelation_pixel_size(arena))
# Before: the lattice sat at 50° (grid_orientation set the plane waves), and
# properties.orientation reported it reflected about 45°, in degrees
assert abs(np.degrees(properties.orientation) - 20.0) < 2.0
To compare with orientations computed by an earlier version, convert them:
old_orientation_deg = 10.0 # from an earlier version
orientation = np.radians((90.0 - old_orientation_deg) % 60.0) # 20° in radians
The conversion is approximate. Earlier versions also averaged in a spare peak that could lie on the second ring of fields, so old values were off by up to about 5°. Recompute orientations from your rate maps when you can.
GridCellModel(grid_orientation=θ) used to set the angle of its plane waves,
which put the field lattice π/6 further counterclockwise. To reproduce a
simulation made with an earlier version, add π/6:
from neurospatial.simulation import GridCellModel
earlier_grid_orientation = 0.0
# Before: GridCellModel(env, grid_spacing=30.0, grid_orientation=earlier_grid_orientation)
cell = GridCellModel(env, grid_spacing=30.0, grid_orientation=earlier_grid_orientation + np.pi / 6)
graph_turn_sequence takes the bins only¶
start_bin and end_bin were never used: the order of the samples already
sets the direction of travel. Drop them from the call. The labels are now the
animal's own left and right in the y-up frame (heading North and then West
is "left"); earlier versions swapped them, so recompute saved sequences:
from neurospatial.behavior import graph_turn_sequence
position_bins = env.bin_at(positions)
# Before: graph_turn_sequence(env, position_bins, start_bin, end_bin)
turns = graph_turn_sequence(env, position_bins)
Smoothing several fields at once¶
env.smooth now takes a batch of fields with bins first, shape
(n_bins, n_fields), as env.diffuse does. Population rate arrays are
stored (n_units, n_bins), so transpose them on the way in and out. Passing
the rate result itself raises TypeError with this call:
rates = np.random.default_rng(1).random((3, env.n_bins)) # (n_units, n_bins), Hz
# Before: np.stack([env.smooth(unit_rates, 5.0) for unit_rates in rates])
smoothed = env.smooth(rates.T, bandwidth=5.0, mode="average").T
Transforms that change lengths name their units¶
env.apply_transform(transform) used to keep the environment's units, even
when the transform was a pixel-to-centimeter calibration. A transform that
scales or shears lengths now needs units= when the environment has units.
Translations, rotations and reflections keep the units without it.
from neurospatial.ops import scale_2d
pixels = env.with_metadata(units="px")
# Before: pixels.apply_transform(scale_2d(0.1)).with_metadata(units="cm")
room = pixels.apply_transform(scale_2d(0.1), units="cm")
assert room.units == "cm"
Smoothing needs mode=¶
env.smooth, env.diffuse and env.compute_kernel take a required mode=.
They had different defaults (smooth averaged, the other two made densities),
so the same call gave different numbers depending on the method. Choose the
mode by what the field holds: "average" for a rate map or other value per
bin, "transition" for counts, occupancy or probability mass (keeps the
total), and "density" to turn counts into a density. Leaving it out raises a
TypeError that lists the three.
rate = np.random.default_rng(3).random(env.n_bins) # Hz
# Before: env.smooth(rate, bandwidth=5.0) (averaged by default)
smoothed = env.smooth(rate, bandwidth=5.0, mode="average")
# Before: env.diffuse(counts, bandwidth=5.0) (density by default)
counts = np.random.default_rng(4).poisson(2.0, env.n_bins).astype(float)
density = env.diffuse(counts, bandwidth=5.0, mode="density")
Method options are keywords¶
Environment methods take their data positionally and every option by
keyword, like the factories. A bandwidth is an option, so name it; this also
keeps a smoothing width from being read as some other number:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
rate = np.random.default_rng(2).random(env.n_bins) # Hz
# Before: env.smooth(rate, 5.0)
smoothed = env.smooth(rate, bandwidth=5.0, mode="average")
# Before: env.diffuse(counts, 5.0); env.compute_kernel(5.0)
kernel = env.compute_kernel(bandwidth=5.0, mode="density")
# Before: env.plot_field(rate, ax, "magma")
fig, ax = plt.subplots()
env.plot_field(smoothed, ax, cmap="magma")
plt.close(fig)
An old positional call raises a TypeError that names the keyword and shows
the corrected call:
rate = np.random.default_rng(2).random(env.n_bins) # Hz
try:
env.smooth(rate, 5.0)
except TypeError as error:
assert "pass bandwidth=5.0 by keyword, as in env.smooth(field, bandwidth=5.0)" in str(
error
)
to_nwb(nwbfile, "arena") is now to_nwb(nwbfile, name="arena"), matching
Environment.from_nwb(nwbfile, name="arena").
Coordinates passed to bin_at, contains, nearest_bin, to_linear and
interpolate are named positions, as in the analysis functions. A call
that passes them by position is unchanged:
# Before: env.bin_at(points_nd=positions); env.nearest_bin(points=positions)
bins = env.bin_at(positions=positions)
nearest = env.nearest_bin(positions)
Draw the trajectory with an overlay¶
env.animate_fields(..., overlay_trajectory=positions) is removed; no backend
drew it. Use a position overlay, which every backend draws:
from neurospatial.animation import PositionOverlay
# Before: env.animate_fields(fields, frame_times=..., overlay_trajectory=positions)
path = PositionOverlay(positions=positions, times=times, trail_length=30)
# env.animate_fields(fields, frame_times=frame_times, overlays=[path])
Misspelled region names raise¶
A region name that is not in env.regions used to be skipped by
animate_fields(show_regions=[...]), regions_containing_points(region_names=...)
and plot_regions(region_names=...), so the region was silently missing. Each
now raises RegionNotFoundError naming the closest region; fix the name:
from neurospatial import RegionNotFoundError, Regions
from neurospatial.regions import regions_containing_points
regions = Regions()
regions.add("reward", point=(50.0, 50.0))
# Before: regions_containing_points(positions, regions, region_names=["rewrd"])
# returned a table without the column
try:
regions_containing_points(positions, regions, region_names=["rewrd"])
except RegionNotFoundError as error:
print(str(error).splitlines()[-1]) # Fix: pass region_names=['reward']
table = regions_containing_points(
positions, regions, region_names=["reward"], point_radius=5.0 # cm
)
Bin indices are np.intp¶
bin_sequence, bin_sequence_with_runs(...).bins, components and rings
return np.intp arrays (64-bit on 64-bit systems) instead of int32, like
bin_at and nearest_bin. The values are the same. Code that compares the
dtype or stores the arrays in an int32 file column should cast explicitly:
sequence = env.bin_sequence(times, positions)
assert sequence.dtype == np.intp
# Before: sequence was int32; keep that layout with an explicit cast
compact = sequence.astype(np.int32)
Infinite positions are missing samples¶
from_samples, open_field and environment_from_position treat a row with
an infinite coordinate like a row with NaN: as a missing sample that does not
shape the bins. Such a row used to raise an overflow error, so a recording
with one bad sample can now be used without cleaning it first:
glitched = positions.copy()
glitched[10] = [np.inf, 50.0] # a tracking glitch
# Before: Environment.from_samples(glitched, bin_size=4.0) raised "Requested binning overflows"
arena = Environment.from_samples(glitched, bin_size=4.0, units="cm")
animate_fields returns a view or a file¶
env.animate_fields builds an Animation scene and either opens a view of it
or writes a file. Interactive backends ("napari", "widget",
"fastplotlib") return an AnimationView, whose native attribute is the
napari viewer, notebook widget or fastplotlib animation; closing the view
releases the video files it opened. Video and HTML exports return the Path
they wrote. All backends now play the same scene on the recording clock:
- A field row is held until the next one only up to
max_gap=0.5seconds; across longer gaps the field is missing instead of frozen. Passmax_gap=Nonefor one field per lap or trial. - A file's frame rate is the sampling rate of
frame_timestimesspeed, capped atmax_playback_fps; a capped export keepsspeed, skips rows and warns. - Several fields shown side by side are a mapping from name to field, or a
list of fields named by
layer_names, in every backend. - Every backend draws every overlay type. A combination an environment cannot show (for example video in a 3-D environment) raises before drawing instead of being skipped with a warning.
Removed options: napari cache_size, chunk_size and max_chunks; widget
initial_cache_size, cache_limit, image_format and return_container;
dry_run; frame_labels outside backend="fastplotlib" (every frame is
labeled with its recording time); and large_session_napari_config. Napari
and the notebook widget read rows lazily without cache settings.
# Before: viewer = env.animate_fields(fields, frame_times=frame_times, backend="napari")
# viewer.layers (the napari viewer itself)
# Now: view = env.animate_fields(...); view.native.layers; view.close()
# Before: env.animate_fields(..., backend="html", frame_labels=labels, dry_run=True)
path = env.animate_fields(
fields, frame_times=frame_times, save_path="animation.html", dpi=40
)
assert path.name == "animation.html"
Misspelled animation options raise¶
A name that no backend accepts raises TypeError with the closest option, so
a typo cannot silently change nothing. An option that another backend uses,
such as crf= with backend="html", does nothing, and says so with one
UserWarning that names the backend that uses it. fps= raises; the frame
rate comes from frame_times and speed=.